start-command 0.37.8 → 0.38.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +66 -26
  5. package/src/lib/args-parser-queries.js +90 -9
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/container-removal.js +163 -0
  8. package/src/lib/detached-finalize.js +29 -2
  9. package/src/lib/docker-cleanup.js +7 -4
  10. package/src/lib/docker-command-handoff.js +34 -6
  11. package/src/lib/docker-post-mortem.js +2 -0
  12. package/src/lib/docker-runtime-args.js +7 -0
  13. package/src/lib/docker-snapshot-resume.js +222 -0
  14. package/src/lib/docker-snapshot-safety.js +124 -12
  15. package/src/lib/execution-attempt.js +21 -9
  16. package/src/lib/execution-chain.js +182 -0
  17. package/src/lib/execution-cleanup.js +193 -0
  18. package/src/lib/execution-control.js +2 -1
  19. package/src/lib/execution-env.js +120 -0
  20. package/src/lib/execution-identity.js +180 -0
  21. package/src/lib/execution-recovery.js +19 -0
  22. package/src/lib/execution-resume.js +77 -6
  23. package/src/lib/execution-store.js +63 -22
  24. package/src/lib/isolation.js +25 -3
  25. package/src/lib/launch-identity-options.js +81 -0
  26. package/src/lib/launch-identity.js +411 -0
  27. package/src/lib/log-uploader.js +15 -1
  28. package/src/lib/query-commands.js +34 -6
  29. package/src/lib/screen-isolation.js +11 -2
  30. package/src/lib/snapshot-resume-options.js +174 -0
  31. package/src/lib/spawn-helpers.js +33 -16
  32. package/src/lib/status-formatter.js +64 -35
  33. package/src/lib/status-links-notation.js +97 -0
  34. package/src/lib/store-lock.js +3 -0
  35. package/src/lib/usage.js +15 -1
  36. package/test/args-parser-attach-resume.js +1 -1
  37. package/test/args-parser-control.js +2 -2
  38. package/test/regression-224.js +807 -0
  39. package/test/regression-225.js +415 -0
  40. package/test/regression-226.js +81 -0
  41. package/test/regression-229.js +267 -0
  42. package/test/regression-230.js +234 -0
  43. package/test/regression-231.js +298 -0
  44. package/test/shared-golden.js +35 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # start-command
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e62079: Executions now carry a stable identity a supervisor can rely on. A launch with an explicit `--session-id` that already exists is refused with exit 1 (`execution <uuid> already exists; use $ --resume <uuid> -- <command> to continue it, or pass --append-attempt`) instead of replacing the record and truncating its log; `--append-attempt` keeps the record, archives the previous attempt in `attemptHistory` and appends the new attempt to the same log (#229). Every backend (direct, `--isolated-user`, screen, tmux, docker, ssh, and docker resumes of stopped containers) exports `START_EXECUTION_ID`, `START_SESSION_NAME` and `START_ATTEMPT` to the command it runs (#230). `$ --alias <id> <name>` adds a name and `$ --rename <id> <name>` sets the display name of an execution, `--continues <id>` records the execution a fresh launch continues, `--status` reports `aliases` and the continuation `chain`, and `--upload-log` uploads the logs of the whole chain, oldest first (#231).
8
+
9
+ ## 0.37.9
10
+
11
+ ### Patch Changes
12
+
13
+ - 55ed66d: A Docker snapshot resume can now be supervised safely. `--snapshot-wait <seconds>` (or `START_SNAPSHOT_LOCK_WAIT`) waits for another resume's snapshot on the host and says once which container it is waiting for. `--snapshot-disk-wait <seconds>` (or `START_SNAPSHOT_DISK_WAIT`) re-checks free disk instead of refusing at once. A busy lock and disk that is still short now exit 75 (`EX_TEMPFAIL`), so a caller can tell "retry later" from a real failure. `--remove-original-after <seconds>` removes the original only if the replacement is still running after the delay. `--snapshot-size-hint <bytes>` skips the `docker inspect --size` walk, and `--snapshot-prepare <command>` runs a cleanup command in the started original before the commit. A long `docker commit` writes its elapsed time to the log every minute. The `start-command.snapshot-image` label is documented as a stable interface, and `$ --cleanup <uuid-or-session-name>` removes an execution's containers and then their snapshot images, never forcing a removal (#224).
14
+
15
+ `$ --status` now reports `containerState removed` and `containerRemovedObservedAt` when the docker container of a finished detached execution no longer exists, instead of showing the old `containerId` as if the container still held its disk. The removal is recorded when start's own cleanup policy removes the container and when `--status` finds it gone; `containerId` is kept for history, and a docker daemon that cannot be reached is not mistaken for a removed container (#225).
16
+
17
+ `$ --status` no longer prints `[object Object]` for nested option values such as `launchOwner`, `exitEvidence`, `resolvedLimits` and `resourceLimitSpecs`: the links-notation output nests them like other objects and the text output prints them as compact JSON (#226).
18
+
3
19
  ## 0.37.8
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -29,6 +29,9 @@ $ -i docker -d --label task.tool=codex -- my-task
29
29
  $ --attach <id>
30
30
  $ --resume <id> -- <command>
31
31
  $ --resume-all
32
+ $ --session-id <uuid> --append-attempt -- <command>
33
+ $ --continues <id> -- <command>
34
+ $ --rename <id> <name>
32
35
  ```
33
36
 
34
37
  See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
@@ -40,6 +43,11 @@ checked examples.
40
43
  containers support copy-free command handoff; legacy snapshots require capacity
41
44
  preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
42
45
 
46
+ Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
47
+ `--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
48
+ exit 75 when they should be retried later; `--cleanup <id>` removes one
49
+ execution's containers and snapshot images without forcing.
50
+
43
51
  ## Development
44
52
 
45
53
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.8",
3
+ "version": "0.38.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
@@ -18,7 +18,6 @@ const {
18
18
  getTimestamp,
19
19
  createLogHeader,
20
20
  createLogFooter,
21
- writeLogFile,
22
21
  appendLogFile,
23
22
  createLogPath,
24
23
  getDefaultDockerImage,
@@ -41,6 +40,7 @@ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
41
40
  const { resolveChildExitCode } = require('../lib/exit-reason');
42
41
  const { printUsage } = require('../lib/usage');
43
42
  const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
43
+ const launchIdentity = require('../lib/launch-identity');
44
44
 
45
45
  // A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
46
46
  // start nor replace the command's exit code: drop the failed output and keep
@@ -245,6 +245,8 @@ const useCommandStream =
245
245
 
246
246
  // Generate session ID if not provided (auto-generate UUID)
247
247
  const sessionId = wrapperOptions.sessionId || generateUUID();
248
+ // How this launch uses its execution id (issue #229), set before dispatch
249
+ let launch = null;
248
250
 
249
251
  // Main execution
250
252
  (async () => {
@@ -256,6 +258,19 @@ const sessionId = wrapperOptions.sessionId || generateUUID();
256
258
  process.exit(queryExitCode === null ? 0 : queryExitCode);
257
259
  }
258
260
 
261
+ // An explicit --session-id must not overwrite a tracked execution (#229).
262
+ try {
263
+ launch = launchIdentity.prepareLaunch(
264
+ getExecutionStore(),
265
+ wrapperOptions,
266
+ sessionId,
267
+ createLogPath(wrapperOptions.isolated || 'direct', sessionId)
268
+ );
269
+ } catch (error) {
270
+ console.error(`Error: ${error.message}`);
271
+ process.exit(1);
272
+ }
273
+
259
274
  if (wrapperOptions.isolated === 'docker') {
260
275
  const {
261
276
  resolveResourceOptions,
@@ -314,8 +329,8 @@ async function runWithIsolation(
314
329
  effectiveImage = getDefaultDockerImage();
315
330
  }
316
331
 
317
- // Create log file path
318
- const logFilePath = createLogPath(environment || 'direct', sessionId);
332
+ // Create log file path (an appended attempt keeps the execution's log)
333
+ const logFilePath = launch.logPath;
319
334
 
320
335
  // Get session name (will be generated by runIsolated if not provided)
321
336
  const sessionName =
@@ -424,6 +439,7 @@ async function runWithIsolation(
424
439
  useCommandStream,
425
440
  },
426
441
  });
442
+ launchIdentity.applyLaunch(executionRecord, launch);
427
443
  }
428
444
 
429
445
  // Print start block with session ID and isolation info
@@ -448,8 +464,13 @@ async function runWithIsolation(
448
464
  currentExecutionRecord = executionRecord;
449
465
  require('../lib/launch-owner').markLaunch(executionRecord);
450
466
  try {
451
- store.save(executionRecord);
467
+ launchIdentity.claimLaunch(store, executionRecord, launch);
452
468
  } catch (err) {
469
+ if (err instanceof launchIdentity.LaunchRefusedError) {
470
+ console.error(`Error: ${err.message}`);
471
+ currentExecutionRecord = null;
472
+ process.exit(1);
473
+ }
453
474
  console.error(
454
475
  JSON.stringify({
455
476
  code: 'LAUNCH_RESERVATION_FAILED',
@@ -483,7 +504,12 @@ async function runWithIsolation(
483
504
  );
484
505
  }
485
506
 
486
- writeLogFile(logFilePath, logContent);
507
+ launchIdentity.writeLaunchLog(
508
+ logFilePath,
509
+ logContent,
510
+ launch,
511
+ executionRecord
512
+ );
487
513
 
488
514
  let result;
489
515
 
@@ -495,6 +521,7 @@ async function runWithIsolation(
495
521
  ...options,
496
522
  session: sessionName,
497
523
  sessionId,
524
+ attemptNumber: launch.attemptNumber,
498
525
  image: effectiveImage,
499
526
  endpoint: options.endpoint,
500
527
  detached: mode === 'detached',
@@ -513,7 +540,10 @@ async function runWithIsolation(
513
540
  });
514
541
  } else if (createdUser) {
515
542
  // Run directly as the created user (no isolation environment)
516
- result = await runAsIsolatedUser(cmd, createdUser);
543
+ result = await runAsIsolatedUser(
544
+ launchIdentity.isolatedUserCommand(cmd, sessionId, launch),
545
+ createdUser
546
+ );
517
547
  } else {
518
548
  // This shouldn't happen in isolation mode, but handle gracefully
519
549
  result = { success: false, message: 'No isolation configuration provided' };
@@ -549,6 +579,10 @@ async function runWithIsolation(
549
579
  if (mode !== 'detached') {
550
580
  executionRecord.complete(exitCode);
551
581
  }
582
+ if (result.containerRemoved) {
583
+ // The cleanup policy removed the container (issue #225).
584
+ require('../lib/container-removal').markContainerRemoved(executionRecord);
585
+ }
552
586
  try {
553
587
  store.save(
554
588
  mode === 'detached'
@@ -633,8 +667,8 @@ async function runDirect(cmd, sessionId) {
633
667
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
634
668
  const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
635
669
 
636
- // Setup logging
637
- const logFilePath = createLogPath('direct', sessionId);
670
+ // Setup logging (an appended attempt keeps the execution's log)
671
+ const logFilePath = launch.logPath;
638
672
 
639
673
  let logContent = '';
640
674
  const startTime = getTimestamp();
@@ -662,6 +696,8 @@ async function runDirect(cmd, sessionId) {
662
696
  runtimeVersion,
663
697
  },
664
698
  });
699
+ launchIdentity.applyLaunch(executionRecord, launch);
700
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
665
701
  // Set global reference for signal cleanup
666
702
  currentExecutionRecord = executionRecord;
667
703
  }
@@ -742,7 +778,13 @@ async function runDirect(cmd, sessionId) {
742
778
  shell,
743
779
  shellArgs,
744
780
  logFilePath,
745
- logContent,
781
+ logContent: launchIdentity.launchLogContent(
782
+ logContent,
783
+ launch,
784
+ executionRecord
785
+ ),
786
+ appendLog: launch.append,
787
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
746
788
  startTimeMs,
747
789
  executionRecord,
748
790
  store,
@@ -782,8 +824,8 @@ async function runDirectWithCommandStream(
782
824
  const isWindows = process.platform === 'win32';
783
825
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
784
826
 
785
- // Setup logging
786
- const logFilePath = createLogPath('direct', sessionId);
827
+ // Setup logging (an appended attempt keeps the execution's log)
828
+ const logFilePath = launch.logPath;
787
829
 
788
830
  let logContent = '';
789
831
  const startTime = getTimestamp();
@@ -812,6 +854,8 @@ async function runDirectWithCommandStream(
812
854
  executionMode: 'command-stream',
813
855
  },
814
856
  });
857
+ launchIdentity.applyLaunch(executionRecord, launch);
858
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
815
859
  // Set global reference for signal cleanup
816
860
  currentExecutionRecord = executionRecord;
817
861
  }
@@ -849,27 +893,23 @@ async function runDirectWithCommandStream(
849
893
  console.log('[command-stream] Using command-stream library');
850
894
  console.log('');
851
895
 
852
- // Save initial execution record (PID will be updated later if available)
853
- if (executionRecord && store) {
854
- try {
855
- store.save(executionRecord);
856
- } catch (err) {
857
- if (config.verbose) {
858
- console.error(
859
- `[Tracking] Warning: Could not save execution record: ${err.message}`
860
- );
861
- }
862
- }
863
- }
864
-
865
896
  // Execute the command using command-stream with real-time output
866
897
  // Using mirror: true to show output in real-time, capture: true to collect it
867
898
  // Using raw() to avoid auto-escaping that might interfere with complex shell commands
868
- const $cmd = $({ mirror: true, capture: true });
899
+ const $cmd = $({
900
+ mirror: true,
901
+ capture: true,
902
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
903
+ });
869
904
 
870
905
  let exitCode;
871
906
  try {
872
- writeLogFile(logFilePath, logContent);
907
+ launchIdentity.writeLaunchLog(
908
+ logFilePath,
909
+ logContent,
910
+ launch,
911
+ executionRecord
912
+ );
873
913
  // Use raw() to pass the command without auto-escaping
874
914
  // This is important for complex commands with pipes, redirects, etc.
875
915
  const result = await $cmd`${raw(cmd)}`;
@@ -7,7 +7,9 @@
7
7
  *
8
8
  * Query modes: --status, --list [--running]
9
9
  * Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
10
- * --resume [-- <command>], --resume-all, --cleanup
10
+ * --resume [-- <command>], --resume-all,
11
+ * --cleanup [<uuid-or-session-name>]
12
+ * Naming modes: --alias <id> <name>, --rename <id> <name> (issue #231)
11
13
  */
12
14
 
13
15
  /**
@@ -37,6 +39,10 @@ function createQueryOptionDefaults() {
37
39
  resumeAll: false, // Re-attach/resume every execution still marked running
38
40
  cleanup: false, // Clean up stale "executing" records
39
41
  cleanupDryRun: false, // Show what would be cleaned without cleaning
42
+ cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
43
+ alias: null, // Execution that --alias adds a name to
44
+ rename: null, // Execution that --rename renames
45
+ newName: null, // Name given by --alias/--rename
40
46
  };
41
47
  }
42
48
 
@@ -75,19 +81,78 @@ const FLAG_OPTIONS = {
75
81
  '--resume-all': (options) => {
76
82
  options.resumeAll = true;
77
83
  },
78
- '--cleanup': (options) => {
79
- options.cleanup = true;
80
- },
81
- '--cleanup-dry-run': (options) => {
82
- options.cleanup = true;
83
- options.cleanupDryRun = true;
84
- },
85
84
  };
86
85
 
86
+ /**
87
+ * `--cleanup` and `--cleanup-dry-run` take an optional execution: without one
88
+ * they clean up stale records, with one they remove that execution's Docker
89
+ * containers and snapshot images (issue #224).
90
+ */
91
+ const CLEANUP_OPTIONS = {
92
+ '--cleanup': false,
93
+ '--cleanup-dry-run': true,
94
+ };
95
+
96
+ function parseCleanupOption(args, index, options) {
97
+ const arg = args[index];
98
+ const equals = arg.indexOf('=');
99
+ const name = equals === -1 ? arg : arg.slice(0, equals);
100
+ if (!(name in CLEANUP_OPTIONS)) {
101
+ return 0;
102
+ }
103
+ options.cleanup = true;
104
+ options.cleanupDryRun = options.cleanupDryRun || CLEANUP_OPTIONS[name];
105
+ if (equals !== -1) {
106
+ const value = arg.slice(equals + 1);
107
+ if (!value) {
108
+ throw new Error(
109
+ `Option ${name} requires a UUID or session name argument`
110
+ );
111
+ }
112
+ options.cleanupTarget = value;
113
+ return 1;
114
+ }
115
+ const next = args[index + 1];
116
+ if (next !== undefined && !next.startsWith('-')) {
117
+ options.cleanupTarget = next;
118
+ return 2;
119
+ }
120
+ return 1;
121
+ }
122
+
87
123
  function hasValue(value) {
88
124
  return value !== null && value !== undefined;
89
125
  }
90
126
 
127
+ /**
128
+ * `--alias <id> <name>` and `--rename <id> <name>` take two arguments.
129
+ */
130
+ const NAMING_OPTIONS = {
131
+ '--alias': 'alias',
132
+ '--rename': 'rename',
133
+ };
134
+
135
+ function parseNamingOption(args, index, options) {
136
+ const key = NAMING_OPTIONS[args[index]];
137
+ if (!key) {
138
+ return 0;
139
+ }
140
+ const [identifier, name] = args.slice(index + 1, index + 3);
141
+ if (
142
+ identifier === undefined ||
143
+ name === undefined ||
144
+ identifier.startsWith('-') ||
145
+ name.startsWith('-')
146
+ ) {
147
+ throw new Error(
148
+ `Option ${args[index]} requires a UUID or session name and a new name`
149
+ );
150
+ }
151
+ options[key] = identifier;
152
+ options.newName = name;
153
+ return 3;
154
+ }
155
+
91
156
  /**
92
157
  * Parse a single query/control option.
93
158
  * @param {string[]} args - Arguments array
@@ -98,6 +163,16 @@ function hasValue(value) {
98
163
  function parseQueryOption(args, index, options) {
99
164
  const arg = args[index];
100
165
 
166
+ const cleanupConsumed = parseCleanupOption(args, index, options);
167
+ if (cleanupConsumed > 0) {
168
+ return cleanupConsumed;
169
+ }
170
+
171
+ const namingConsumed = parseNamingOption(args, index, options);
172
+ if (namingConsumed > 0) {
173
+ return namingConsumed;
174
+ }
175
+
101
176
  const flag = FLAG_OPTIONS[arg];
102
177
  if (flag) {
103
178
  flag(options);
@@ -179,6 +254,12 @@ function activeQueryModes(options) {
179
254
  if (options.cleanup) {
180
255
  modes.push('--cleanup');
181
256
  }
257
+ if (hasValue(options.alias)) {
258
+ modes.push('--alias');
259
+ }
260
+ if (hasValue(options.rename)) {
261
+ modes.push('--rename');
262
+ }
182
263
  return modes;
183
264
  }
184
265
 
@@ -211,7 +292,7 @@ function validateQueryOptions(options) {
211
292
 
212
293
  if (modes.length > 1) {
213
294
  throw new Error(
214
- 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup in the same invocation'
295
+ 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename in the same invocation'
215
296
  );
216
297
  }
217
298
 
@@ -37,8 +37,9 @@
37
37
  * --read-only Follow --attach output without forwarding stdin
38
38
  * --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
39
39
  * --resume-all Re-attach or reconcile every execution still marked running
40
- * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
40
+ * --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
41
41
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
42
+ * --alias <id> <name>, --rename <id> <name>, --append-attempt, --continues <id>: see args-parser-queries.js, launch-identity-options.js
42
43
  */
43
44
 
44
45
  const { getDefaultDockerImage } = require('./docker-utils');
@@ -46,6 +47,8 @@ const dockerNetworkOptions = require('./docker-network-options');
46
47
  const dockerRecoveryOptions = require('./docker-recovery-options');
47
48
  const cpuPenalty = require('./cpu-penalty');
48
49
  const dockerResourceOptions = require('./docker-resource-options');
50
+ const snapshotResumeOptions = require('./snapshot-resume-options');
51
+ const launchIdentityOptions = require('./launch-identity-options');
49
52
  const { parseSequence, isSequence } = require('./sequence-parser');
50
53
  const { buildCommandString } = require('./shell-utils');
51
54
  const {
@@ -215,6 +218,8 @@ function parseArgs(args) {
215
218
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
216
219
  useCommandStream: false, // Use command-stream library for command execution
217
220
  ...createQueryOptionDefaults(),
221
+ ...snapshotResumeOptions.createSnapshotOptionDefaults(),
222
+ ...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
218
223
  };
219
224
 
220
225
  let commandArgs = [];
@@ -566,12 +571,24 @@ function parseOption(args, index, options) {
566
571
  return 1;
567
572
  }
568
573
 
574
+ // Snapshot resume options (--snapshot-wait, --remove-original-after, ...)
575
+ const snapshotConsumed = snapshotResumeOptions.parse(args, index, options);
576
+ if (snapshotConsumed > 0) {
577
+ return snapshotConsumed;
578
+ }
579
+
569
580
  // Query/control options (--status, --list, --attach, --resume, ...)
570
581
  const queryConsumed = parseQueryOption(args, index, options);
571
582
  if (queryConsumed > 0) {
572
583
  return queryConsumed;
573
584
  }
574
585
 
586
+ // --append-attempt, --continues <id> (issues #229, #231)
587
+ const identityConsumed = launchIdentityOptions.parse(args, index, options);
588
+ if (identityConsumed > 0) {
589
+ return identityConsumed;
590
+ }
591
+
575
592
  // Not a recognized wrapper option
576
593
  return 0;
577
594
  }
@@ -820,7 +837,9 @@ function validateOptions(options) {
820
837
  throw new Error('--keep-user option is only valid with --isolated-user');
821
838
  }
822
839
 
840
+ snapshotResumeOptions.validate(options);
823
841
  validateQueryOptions(options);
842
+ launchIdentityOptions.validate(options);
824
843
 
825
844
  // Validate shell option
826
845
  if (options.shell !== null && options.shell !== undefined) {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Removed-container bookkeeping for `--status` (issue #225).
3
+ *
4
+ * A kept docker container can be removed later (by hand, by an orchestrator,
5
+ * or by start-command's own cleanup policy). `docker inspect` then exits 1
6
+ * with `No such object` / `No such container`, which used to be folded into
7
+ * the same `null` as "docker could not be queried at all", so `--status` kept
8
+ * reporting the old `containerId` as if the container still held its disk.
9
+ *
10
+ * This module tells the two failures apart and records the removal on the
11
+ * execution record as `containerState: removed` plus
12
+ * `containerRemovedObservedAt`. `containerId` is kept for history.
13
+ */
14
+
15
+ const { spawnSync } = require('child_process');
16
+
17
+ /** `containerState` of a record whose container no longer exists. */
18
+ const CONTAINER_STATE_REMOVED = 'removed';
19
+
20
+ /**
21
+ * Docker's answer for a container that does not exist:
22
+ * `Error: No such object: <name>` (`docker inspect`, Docker 29 prints it
23
+ * lower-case) and `Error response from daemon: No such container: <name>`
24
+ * (`docker container inspect`). A daemon that cannot be reached prints
25
+ * "Cannot connect" / "failed to connect", which must not match.
26
+ */
27
+ const DOCKER_NOT_FOUND_PATTERN = /no such (object|container)/i;
28
+
29
+ /**
30
+ * Environment variable the detached watcher sets for the finalizer when its
31
+ * own `docker rm -f` removed the container.
32
+ */
33
+ const CONTAINER_REMOVED_ENV = 'START_INTERNAL_CONTAINER_REMOVED';
34
+
35
+ /**
36
+ * @param {{status?: number|null, stderr?: string|Buffer, error?: Error}} result
37
+ * `spawnSync` result of a `docker inspect`
38
+ * @returns {boolean} True only when docker answered that the object is unknown
39
+ */
40
+ function isDockerNotFound(result) {
41
+ if (!result || result.error || result.status === 0) {
42
+ return false;
43
+ }
44
+ return DOCKER_NOT_FOUND_PATTERN.test(String(result.stderr || ''));
45
+ }
46
+
47
+ /**
48
+ * Run `docker inspect -f <format> <target>`.
49
+ * @returns {{stdout: string|null, notFound: boolean}} `stdout` is null when
50
+ * the inspect failed; `notFound` says the failure was "no such container"
51
+ */
52
+ function dockerInspect(dockerCommand, spawnOptions, format, target) {
53
+ const result = spawnSync(
54
+ dockerCommand,
55
+ ['inspect', '-f', format, target],
56
+ spawnOptions
57
+ );
58
+ if (result.error || result.status !== 0 || !result.stdout) {
59
+ return { stdout: null, notFound: isDockerNotFound(result) };
60
+ }
61
+ return { stdout: String(result.stdout), notFound: false };
62
+ }
63
+
64
+ /**
65
+ * Set `containerState: removed` on a record, keeping an earlier observation
66
+ * time when there is one.
67
+ * @param {Object} record - Execution record (mutated)
68
+ * @param {string} [observedAt] - When the removal was observed
69
+ * @returns {Object} The same record
70
+ */
71
+ function markContainerRemoved(record, observedAt = new Date().toISOString()) {
72
+ record.containerState = CONTAINER_STATE_REMOVED;
73
+ record.containerRemovedObservedAt =
74
+ record.containerRemovedObservedAt || observedAt;
75
+ return record;
76
+ }
77
+
78
+ /**
79
+ * Whether a finished detached docker record should be checked for removal.
80
+ * Executing records are never touched: a container that is not visible yet
81
+ * on a slow Docker-in-Docker host is not a removed one (issue #136).
82
+ */
83
+ function shouldProbeRemoval(record) {
84
+ const opts = (record && record.options) || {};
85
+ return (
86
+ record.status === 'executed' &&
87
+ opts.isolated === 'docker' &&
88
+ opts.isolationMode === 'detached' &&
89
+ Boolean(opts.sessionName || opts.containerId) &&
90
+ record.containerState !== CONTAINER_STATE_REMOVED
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Attach `containerState: removed` to a finished detached docker record whose
96
+ * container no longer exists. The container id is preferred over the session
97
+ * name, so a later container that reuses the name is not mistaken for it.
98
+ *
99
+ * @param {Object} record - Enriched execution record
100
+ * @param {{dockerCommand: string, spawnOptions: Object,
101
+ * sessionNotFound?: boolean}} probe - How to run docker, and whether the
102
+ * status probe by session name already answered "no such container"
103
+ * @returns {Object} The record, or a copy carrying the removal
104
+ */
105
+ function attachContainerRemoval(record, probe) {
106
+ if (!shouldProbeRemoval(record)) {
107
+ return record;
108
+ }
109
+ const containerId = record.options.containerId;
110
+ const notFound = containerId
111
+ ? dockerInspect(
112
+ probe.dockerCommand,
113
+ probe.spawnOptions,
114
+ '{{.Id}}',
115
+ containerId
116
+ ).notFound
117
+ : probe.sessionNotFound === true;
118
+ if (!notFound) {
119
+ return record;
120
+ }
121
+ const copy = Object.assign(
122
+ Object.create(Object.getPrototypeOf(record)),
123
+ record
124
+ );
125
+ return markContainerRemoved(copy);
126
+ }
127
+
128
+ /**
129
+ * Persist a removal `--status` observed, best effort: a read-only query must
130
+ * never fail because the store could not be written.
131
+ * @param {Object} store - ExecutionStore
132
+ * @param {Object} stored - Record as read from the store
133
+ * @param {Object} enriched - Record as reported
134
+ */
135
+ function persistContainerRemoval(store, stored, enriched) {
136
+ if (
137
+ !store ||
138
+ enriched.containerState !== CONTAINER_STATE_REMOVED ||
139
+ stored.containerState === CONTAINER_STATE_REMOVED ||
140
+ typeof store.markContainerRemoved !== 'function'
141
+ ) {
142
+ return;
143
+ }
144
+ try {
145
+ store.markContainerRemoved(
146
+ stored.uuid,
147
+ enriched.containerRemovedObservedAt
148
+ );
149
+ } catch {
150
+ // The removal is still reported; it is simply observed again next time.
151
+ }
152
+ }
153
+
154
+ module.exports = {
155
+ CONTAINER_REMOVED_ENV,
156
+ CONTAINER_STATE_REMOVED,
157
+ DOCKER_NOT_FOUND_PATTERN,
158
+ attachContainerRemoval,
159
+ dockerInspect,
160
+ isDockerNotFound,
161
+ markContainerRemoved,
162
+ persistContainerRemoval,
163
+ };