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
@@ -379,6 +379,23 @@ function recoverKilledExecution(params = {}) {
379
379
  }
380
380
  }
381
381
 
382
+ if (opts.commandHandoff && record.uuid) {
383
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
384
+ try {
385
+ require('./docker-command-handoff').writeExecutionEnvHandoff(
386
+ containerName,
387
+ require('./execution-env').buildExecutionEnv({
388
+ executionId: record.uuid,
389
+ sessionName: containerName,
390
+ attempt: require('./execution-attempt').nextAttemptNumber(record),
391
+ }),
392
+ runner
393
+ );
394
+ } catch {
395
+ // Non-fatal: the command still runs, with the previous START_ATTEMPT.
396
+ }
397
+ }
398
+
382
399
  const since = now().toISOString();
383
400
  const {
384
401
  appendLifecycle,
@@ -458,6 +475,8 @@ function recoverKilledExecution(params = {}) {
458
475
  record.observedAt = undefined;
459
476
  record.staleDetectedAt = undefined;
460
477
  record.containerStartedAt = undefined;
478
+ record.containerState = undefined;
479
+ record.containerRemovedObservedAt = undefined;
461
480
  const previousData = globalThis.structuredClone(store.get(executionId));
462
481
  const previous = record.constructor.fromObject
463
482
  ? record.constructor.fromObject(previousData)
@@ -34,6 +34,7 @@ const {
34
34
  appendLifecycle,
35
35
  archiveAttempt,
36
36
  createAttempt,
37
+ nextAttemptNumber,
37
38
  patchAttempt,
38
39
  } = require('./execution-attempt');
39
40
  const { runCommand } = require('./execution-control');
@@ -46,6 +47,7 @@ const { SessionState, probeSession } = require('./session-probe');
46
47
  const { getDockerNetworks } = require('./docker-network-lifecycle');
47
48
  const {
48
49
  writeCommandHandoff,
50
+ writeExecutionEnvHandoff,
49
51
  buildCommandHandoffArgs,
50
52
  commandHandoffPath,
51
53
  } = require('./docker-command-handoff');
@@ -54,6 +56,7 @@ const {
54
56
  preflightSnapshot,
55
57
  SNAPSHOT_IMAGE_LABEL,
56
58
  } = require('./docker-snapshot-safety');
59
+ const snapshotResume = require('./docker-snapshot-resume');
57
60
  const {
58
61
  ResumeAllAction,
59
62
  resumeAllExecutions,
@@ -109,6 +112,8 @@ function buildLaunchOptions(record) {
109
112
  rootSession:
110
113
  opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
111
114
  resumeCount: (Number(opts.resumeCount) || 0) + 1,
115
+ // START_ATTEMPT of the relaunched command (issue #230).
116
+ attemptNumber: nextAttemptNumber(record),
112
117
  image: opts.image || null,
113
118
  session: opts.sessionName,
114
119
  detached: true,
@@ -288,6 +293,7 @@ function buildResumePlan(
288
293
  session: newSessionName,
289
294
  containerName: newSessionName,
290
295
  sessionId: record.uuid,
296
+ attemptNumber: nextAttemptNumber(record),
291
297
  rootSession:
292
298
  opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
293
299
  resumeCount: attempt,
@@ -441,8 +447,10 @@ function activeSessionName(plan) {
441
447
  * Resume a tracked execution by UUID or session name.
442
448
  * @param {?object} store - Execution store
443
449
  * @param {string} identifier - UUID or session name
444
- * @param {object} deps - {command, outputFormat, probe, runner, startWatcher, runIsolated}
445
- * @returns {Promise<{success: boolean, output?: string, error?: string}>}
450
+ * @param {object} deps - {command, outputFormat, probe, runner, runAsync,
451
+ * startWatcher, runIsolated, removeOriginal, removeOriginalAfter,
452
+ * snapshotOptions, notify}
453
+ * @returns {Promise<{success: boolean, output?: string, error?: string, exitCode?: number}>}
446
454
  */
447
455
  async function resumeExecution(store, identifier, deps = {}) {
448
456
  if (!store) {
@@ -605,16 +613,34 @@ async function resumeExecution(store, identifier, deps = {}) {
605
613
  }
606
614
  containerId = launchResult.containerId || null;
607
615
  } else {
616
+ const notify = deps.notify || ((line) => process.stderr.write(`${line}\n`));
617
+ const snapshotOptions = { notify, ...deps.snapshotOptions };
618
+ const progress = {
619
+ sessionName: plan.sessionName,
620
+ logPath: record.logPath,
621
+ notify,
622
+ };
608
623
  let snapshotLock;
609
624
  let snapshotCreated = false;
610
625
  let derivedCreated = false;
611
626
  try {
612
627
  if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
613
- snapshotLock = acquireSnapshotLock(deps.snapshotOptions);
628
+ snapshotLock = acquireSnapshotLock({
629
+ ...snapshotOptions,
630
+ holder: plan.sessionName,
631
+ });
632
+ if (snapshotOptions.prepare) {
633
+ snapshotResume.runSnapshotPrepare(
634
+ plan.sessionName,
635
+ snapshotOptions.prepare,
636
+ runner,
637
+ progress
638
+ );
639
+ }
614
640
  const preflight = preflightSnapshot(
615
641
  plan.sessionName,
616
642
  runner,
617
- deps.snapshotOptions
643
+ snapshotOptions
618
644
  );
619
645
  plan.message += `\n${preflight.message}`;
620
646
  if (record.logPath) {
@@ -636,10 +662,33 @@ async function resumeExecution(store, identifier, deps = {}) {
636
662
  );
637
663
  }
638
664
  }
665
+ if (plan.mode === ResumeMode.DOCKER_START && opts.commandHandoff) {
666
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
667
+ writeExecutionEnvHandoff(
668
+ plan.sessionName,
669
+ require('./execution-env').buildExecutionEnv({
670
+ executionId: record.uuid,
671
+ sessionName: plan.sessionName,
672
+ attempt: attempt.number,
673
+ }),
674
+ runner
675
+ );
676
+ }
639
677
  for (const step of plan.steps) {
640
678
  let result;
641
679
  try {
642
- result = runner(step.command, step.args);
680
+ result =
681
+ step.args[0] === 'commit'
682
+ ? await snapshotResume.commitWithProgress(step, {
683
+ ...progress,
684
+ runAsync:
685
+ deps.runAsync ||
686
+ (deps.runner
687
+ ? (command, args) =>
688
+ Promise.resolve().then(() => runner(command, args))
689
+ : snapshotResume.runCommandAsync),
690
+ })
691
+ : runner(step.command, step.args);
643
692
  } catch (error) {
644
693
  result = { success: false, error: error.message };
645
694
  }
@@ -661,7 +710,11 @@ async function resumeExecution(store, identifier, deps = {}) {
661
710
  containerId = (result.stdout || '').trim() || containerId;
662
711
  }
663
712
  }
664
- if (plan.mode === ResumeMode.DOCKER_SNAPSHOT && deps.removeOriginal) {
713
+ if (
714
+ plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
715
+ deps.removeOriginal &&
716
+ !(deps.removeOriginalAfter > 0)
717
+ ) {
665
718
  const running = runner(getDockerCommand(), [
666
719
  'inspect',
667
720
  '-f',
@@ -704,6 +757,7 @@ async function resumeExecution(store, identifier, deps = {}) {
704
757
  return {
705
758
  success: false,
706
759
  error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
760
+ ...(error.exitCode ? { exitCode: error.exitCode } : {}),
707
761
  };
708
762
  } finally {
709
763
  snapshotLock?.release();
@@ -793,6 +847,23 @@ async function resumeExecution(store, identifier, deps = {}) {
793
847
  };
794
848
  }
795
849
 
850
+ if (
851
+ plan.mode === ResumeMode.DOCKER_SNAPSHOT &&
852
+ deps.removeOriginal &&
853
+ deps.removeOriginalAfter > 0
854
+ ) {
855
+ plan.message += `\n${await snapshotResume.removeOriginalAfter(
856
+ plan,
857
+ deps.removeOriginalAfter,
858
+ runner,
859
+ {
860
+ logPath: record.logPath,
861
+ notify: deps.notify || ((line) => process.stderr.write(`${line}\n`)),
862
+ sleep: deps.sleep,
863
+ }
864
+ )}`;
865
+ }
866
+
796
867
  return {
797
868
  success: true,
798
869
  output: formatResumeResult(
@@ -77,6 +77,8 @@ class ExecutionRecord {
77
77
  this.endTime = options.endTime || null;
78
78
  this.attempt = options.attempt;
79
79
  this.attemptHistory = options.attemptHistory;
80
+ // UUID of the execution this one continues (`--continues`, issue #231).
81
+ this.continues = options.continues || undefined;
80
82
  // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
81
83
  // finish time with `new Date()` whenever the record had none, which made an
82
84
  // observation indistinguishable from a real finish time. One of
@@ -101,6 +103,14 @@ class ExecutionRecord {
101
103
  options.containerStartedAt !== undefined
102
104
  ? options.containerStartedAt
103
105
  : undefined;
106
+ // `removed` once the container is known to be gone (issue #225), with
107
+ // when that was observed; `options.containerId` is kept as history.
108
+ this.containerState =
109
+ options.containerState !== undefined ? options.containerState : undefined;
110
+ this.containerRemovedObservedAt =
111
+ options.containerRemovedObservedAt !== undefined
112
+ ? options.containerRemovedObservedAt
113
+ : undefined;
104
114
  this.oomKilled =
105
115
  options.oomKilled !== undefined ? options.oomKilled : undefined;
106
116
  // Query-time hint explaining an opaque exit code (issue #162). Derived from
@@ -164,6 +174,9 @@ class ExecutionRecord {
164
174
  if (this.attemptHistory) {
165
175
  obj.attemptHistory = this.attemptHistory;
166
176
  }
177
+ if (this.continues) {
178
+ obj.continues = this.continues;
179
+ }
167
180
  if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
168
181
  obj.endTimeSource = this.endTimeSource;
169
182
  }
@@ -179,6 +192,15 @@ class ExecutionRecord {
179
192
  ) {
180
193
  obj.containerStartedAt = this.containerStartedAt;
181
194
  }
195
+ if (this.containerState !== undefined && this.containerState !== null) {
196
+ obj.containerState = this.containerState;
197
+ }
198
+ if (
199
+ this.containerRemovedObservedAt !== undefined &&
200
+ this.containerRemovedObservedAt !== null
201
+ ) {
202
+ obj.containerRemovedObservedAt = this.containerRemovedObservedAt;
203
+ }
182
204
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
183
205
  obj.oomKilled = this.oomKilled;
184
206
  }
@@ -489,36 +511,55 @@ class ExecutionStore {
489
511
  }
490
512
  }
491
513
 
514
+ /**
515
+ * Record that a finished execution's container no longer exists
516
+ * (issue #225). Applied under the database lock to the stored record, and
517
+ * only while it is still finished, so a concurrent resume is never undone.
518
+ * @param {string} uuid - Execution UUID
519
+ * @param {string} observedAt - When the removal was observed
520
+ * @returns {ExecutionRecord|null} The updated record, or null
521
+ */
522
+ markContainerRemoved(uuid, observedAt) {
523
+ const lock = new LockManager(this.lockFilePath);
524
+ if (!lock.acquire()) {
525
+ throw new Error('Failed to acquire lock for container state');
526
+ }
527
+ try {
528
+ const records = this.readLinoRecords();
529
+ const current = records.find((record) => record.uuid === uuid);
530
+ if (
531
+ !current ||
532
+ current.status !== ExecutionStatus.EXECUTED ||
533
+ current.containerState === 'removed'
534
+ ) {
535
+ return null;
536
+ }
537
+ current.containerState = 'removed';
538
+ current.containerRemovedObservedAt = observedAt;
539
+ this.writeLinoRecords(records);
540
+ if (this.useLinks) {
541
+ this.writeLinksRecord(current);
542
+ }
543
+ return current;
544
+ } finally {
545
+ lock.release();
546
+ }
547
+ }
548
+
492
549
  /**
493
550
  * Get an execution record by UUID or session name
494
551
  * First tries exact UUID match, then the current session name, then any
495
- * session name the execution used before it was resumed
552
+ * session name the execution used before it was resumed, then the display
553
+ * name and aliases
496
554
  * @param {string} identifier - UUID or session name
497
555
  * @returns {ExecutionRecord|null}
498
556
  */
499
557
  get(identifier) {
500
- const records = this.readLinoRecords();
501
- // First try exact UUID match
502
- const byUuid = records.find((r) => r.uuid === identifier);
503
- if (byUuid) {
504
- return byUuid;
505
- }
506
- // Fall back to session name lookup (stored in options.sessionName)
507
- const bySessionName = records.find(
508
- (r) => r.options && r.options.sessionName === identifier
509
- );
510
- if (bySessionName) {
511
- return bySessionName;
512
- }
513
- // Finally, honor session names this record used before it was resumed, so
514
- // one logical session stays addressable across restarts (issue #162).
515
- const byPreviousSessionName = records.find(
516
- (r) =>
517
- r.options &&
518
- Array.isArray(r.options.sessionNameHistory) &&
519
- r.options.sessionNameHistory.includes(identifier)
558
+ // Names, display names and aliases resolve too (issues #162, #231).
559
+ return require('./execution-identity').findRecord(
560
+ this.readLinoRecords(),
561
+ identifier
520
562
  );
521
- return byPreviousSessionName || null;
522
563
  }
523
564
 
524
565
  /**
@@ -201,7 +201,8 @@ function runScreenWithLogCapture(
201
201
  sessionName,
202
202
  shellInfo,
203
203
  user = null,
204
- logPath = null
204
+ logPath = null,
205
+ env = null
205
206
  ) {
206
207
  return _runScreenWithLogCapture(
207
208
  command,
@@ -210,7 +211,8 @@ function runScreenWithLogCapture(
210
211
  user,
211
212
  wrapCommandWithUser,
212
213
  isInteractiveShellCommand,
213
- logPath
214
+ logPath,
215
+ env
214
216
  );
215
217
  }
216
218
 
@@ -241,6 +243,7 @@ function runInScreen(command, options = {}) {
241
243
  keepAlive: options.keepAlive,
242
244
  logPath: options.logPath,
243
245
  wrapCommandWithUser,
246
+ env: options.executionEnv,
244
247
  })
245
248
  );
246
249
  } else {
@@ -249,7 +252,8 @@ function runInScreen(command, options = {}) {
249
252
  sessionName,
250
253
  shellInfo,
251
254
  options.user,
252
- options.logPath
255
+ options.logPath,
256
+ options.executionEnv
253
257
  );
254
258
  }
255
259
  } catch (err) {
@@ -813,8 +817,10 @@ function runInDocker(command, options = {}) {
813
817
  !containerExistedBeforeLaunch &&
814
818
  readDockerContainerStatus(containerName) === 'created';
815
819
 
820
+ let containerRemoved = false;
816
821
  if (launchFailed) {
817
822
  if (removeDockerContainer(containerName, options.logPath)) {
823
+ containerRemoved = true;
818
824
  message += `\nContainer removed after launch failure.`;
819
825
  } else {
820
826
  message += `\nWarning: failed to remove container after launch failure.`;
@@ -823,6 +829,7 @@ function runInDocker(command, options = {}) {
823
829
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
824
830
  ) {
825
831
  if (removeDockerContainer(containerName, options.logPath)) {
832
+ containerRemoved = true;
826
833
  message += `\nContainer removed after completion.${postMortem(true)}`;
827
834
  } else {
828
835
  message += `\nWarning: failed to remove container automatically.`;
@@ -848,6 +855,7 @@ function runInDocker(command, options = {}) {
848
855
  diagnostics: diagnosticFacts,
849
856
  message,
850
857
  exitCode,
858
+ containerRemoved,
851
859
  });
852
860
  });
853
861
 
@@ -905,6 +913,20 @@ function runIsolated(backend, command, options = {}) {
905
913
  session: options.sessionStack ? options.sessionStack[0] : options.session,
906
914
  logPath: options.logPath,
907
915
  };
916
+ // START_* identity (issue #230): docker passes it with `-e`; a bare screen
917
+ // shell inherits the spawn env; everything else gets an `export` prefix.
918
+ const identity = require('./execution-env');
919
+ currentOptions.executionEnv =
920
+ identity.executionEnvFromOptions(currentOptions);
921
+ if (
922
+ backend !== 'docker' &&
923
+ !(backend === 'screen' && isInteractiveShellCommand(effectiveCommand))
924
+ ) {
925
+ effectiveCommand = identity.prefixWithExecutionEnv(
926
+ effectiveCommand,
927
+ currentOptions.executionEnv
928
+ );
929
+ }
908
930
 
909
931
  switch (backend) {
910
932
  case 'screen':
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Launch options about the identity of a new execution:
3
+ * --append-attempt run as the next attempt of the tracked execution named
4
+ * by --session-id instead of being refused (issue #229)
5
+ * --continues <id> record that this execution continues an earlier one,
6
+ * which `--status` and `--upload-log` follow (issue #231)
7
+ *
8
+ * Both only apply when a command is launched, not to the query modes.
9
+ */
10
+ const { activeQueryModes } = require('./args-parser-queries');
11
+
12
+ function createLaunchIdentityOptionDefaults() {
13
+ return {
14
+ appendAttempt: false,
15
+ continues: null,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Parse one launch identity option.
21
+ * @param {string[]} args - Arguments array
22
+ * @param {number} index - Current index
23
+ * @param {object} options - Options object to populate
24
+ * @returns {number} Number of arguments consumed (0 if not recognized)
25
+ */
26
+ function parse(args, index, options) {
27
+ const arg = args[index];
28
+ if (arg === '--append-attempt') {
29
+ options.appendAttempt = true;
30
+ return 1;
31
+ }
32
+ if (arg === '--continues') {
33
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
34
+ options.continues = args[index + 1];
35
+ return 2;
36
+ }
37
+ throw new Error(
38
+ 'Option --continues requires a UUID or session name argument'
39
+ );
40
+ }
41
+ if (arg.startsWith('--continues=')) {
42
+ const value = arg.slice('--continues='.length);
43
+ if (!value) {
44
+ throw new Error(
45
+ 'Option --continues requires a UUID or session name argument'
46
+ );
47
+ }
48
+ options.continues = value;
49
+ return 1;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ /**
55
+ * Validate the launch identity options.
56
+ * @param {object} options - Parsed wrapper options
57
+ * @throws {Error} When the combination is not supported
58
+ */
59
+ function validate(options) {
60
+ const used = [
61
+ ...(options.appendAttempt ? ['--append-attempt'] : []),
62
+ ...(options.continues !== null && options.continues !== undefined
63
+ ? ['--continues']
64
+ : []),
65
+ ];
66
+ if (used.length === 0) {
67
+ return;
68
+ }
69
+ if (activeQueryModes(options).length > 0) {
70
+ throw new Error(`${used[0]} option is only valid when launching a command`);
71
+ }
72
+ if (options.appendAttempt && !options.sessionId) {
73
+ throw new Error('--append-attempt option requires --session-id <uuid>');
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ createLaunchIdentityOptionDefaults,
79
+ parse,
80
+ validate,
81
+ };