start-command 0.37.9 → 0.39.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 (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/bin/cli.js +62 -26
  7. package/src/lib/args-parser-queries.js +45 -1
  8. package/src/lib/args-parser.js +19 -0
  9. package/src/lib/cgroup-memory.js +95 -23
  10. package/src/lib/command-builder.js +4 -0
  11. package/src/lib/container-removal.js +4 -5
  12. package/src/lib/detached-finalize.js +1 -0
  13. package/src/lib/docker-command-handoff.js +34 -6
  14. package/src/lib/docker-init-option.js +65 -0
  15. package/src/lib/docker-inspect-batch.js +176 -0
  16. package/src/lib/docker-resource-options.js +21 -2
  17. package/src/lib/docker-runtime-args.js +19 -3
  18. package/src/lib/execution-attempt.js +19 -9
  19. package/src/lib/execution-chain.js +182 -0
  20. package/src/lib/execution-control.js +8 -6
  21. package/src/lib/execution-env.js +120 -0
  22. package/src/lib/execution-identity.js +180 -0
  23. package/src/lib/execution-recovery.js +21 -1
  24. package/src/lib/execution-resume.js +21 -1
  25. package/src/lib/execution-store.js +30 -23
  26. package/src/lib/exit-evidence.js +139 -7
  27. package/src/lib/isolation.js +21 -3
  28. package/src/lib/launch-identity-options.js +81 -0
  29. package/src/lib/launch-identity.js +411 -0
  30. package/src/lib/log-uploader.js +15 -1
  31. package/src/lib/output-blocks.js +11 -0
  32. package/src/lib/query-commands.js +18 -3
  33. package/src/lib/resume-resources.js +2 -2
  34. package/src/lib/screen-isolation.js +11 -2
  35. package/src/lib/spawn-helpers.js +33 -16
  36. package/src/lib/status-formatter.js +39 -131
  37. package/src/lib/status-probe.js +149 -0
  38. package/src/lib/store-decode-cache.js +141 -0
  39. package/src/lib/usage.js +11 -1
  40. package/test/args-parser-attach-resume.js +1 -1
  41. package/test/args-parser-control.js +2 -2
  42. package/test/cargo-lock-version.mjs +121 -0
  43. package/test/check-rust-coverage.mjs +71 -0
  44. package/test/check-web-archive.mjs +154 -0
  45. package/test/ci-workflow-invariants.js +204 -0
  46. package/test/command-builder-docker-cleanup.js +24 -0
  47. package/test/command-exit-codes.mjs +91 -0
  48. package/test/docker-runtime-options.js +2 -0
  49. package/test/docker-snapshot-safety.js +39 -0
  50. package/test/execution-resume.js +2 -0
  51. package/test/fixtures/fake-npm.sh +64 -0
  52. package/test/fixtures/lychee-report.md +39 -0
  53. package/test/javascript-first-ci.js +69 -0
  54. package/test/links-workflow.mjs +127 -0
  55. package/test/lint-gate.mjs +56 -0
  56. package/test/npm-publish.mjs +181 -0
  57. package/test/recheck-broken-links.mjs +452 -0
  58. package/test/regression-176.js +2 -0
  59. package/test/regression-187.js +2 -0
  60. package/test/regression-191.js +4 -0
  61. package/test/regression-193.js +2 -0
  62. package/test/regression-198.js +2 -0
  63. package/test/regression-207.js +8 -2
  64. package/test/regression-224.js +2 -0
  65. package/test/regression-229.js +267 -0
  66. package/test/regression-230.js +234 -0
  67. package/test/regression-231.js +298 -0
  68. package/test/regression-236.js +250 -0
  69. package/test/regression-238.js +129 -0
  70. package/test/regression-241.js +254 -0
  71. package/test/regression-242.js +241 -0
  72. package/test/regression-243.js +493 -0
  73. package/test/regression-244.js +471 -0
  74. package/test/release-push-retry.mjs +172 -0
  75. package/test/run-js-tests.mjs +15 -3
  76. package/test/setup/private-snapshot-lock.js +17 -0
  77. package/test/shared-golden.js +44 -2
  78. package/test/transient-links.mjs +139 -0
@@ -25,6 +25,7 @@ const os = require('os');
25
25
  const { execFileSync, spawnSync } = require('child_process');
26
26
  const crypto = require('crypto');
27
27
  const { atomicWrite } = require('./atomic-write');
28
+ const { decodeStoreContent } = require('./store-decode-cache');
28
29
 
29
30
  // Synchronous wrapper using Bun's native ESM support
30
31
  // This works because Bun handles ESM/CJS interop
@@ -38,6 +39,12 @@ function encodeSync(data) {
38
39
  }
39
40
  }
40
41
 
42
+ const DECODE_LOG = {
43
+ memory: () => 'Reused the decoded store: the file is unchanged',
44
+ copy: () => 'Read the decoded copy of the store: the file is unchanged',
45
+ decode: (content) => `Decoded the store (${content.length} characters)`,
46
+ };
47
+
41
48
  function decodeSync(notation) {
42
49
  try {
43
50
  const codec = require('lino-objects-codec');
@@ -52,6 +59,8 @@ const DEFAULT_APP_FOLDER = path.join(os.homedir(), '.start-command');
52
59
  const LINO_DB_FILE = 'executions.lino';
53
60
  const LINKS_DB_FILE = 'executions.links';
54
61
  const LOCK_FILE = 'executions.lock';
62
+ // Decoded records as JSON, for the next process (issue #243)
63
+ const DECODED_COPY_FILE = 'executions.decoded.js.json';
55
64
  const { LockManager } = require('./store-lock');
56
65
 
57
66
  /**
@@ -77,6 +86,8 @@ class ExecutionRecord {
77
86
  this.endTime = options.endTime || null;
78
87
  this.attempt = options.attempt;
79
88
  this.attemptHistory = options.attemptHistory;
89
+ // UUID of the execution this one continues (`--continues`, issue #231).
90
+ this.continues = options.continues || undefined;
80
91
  // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
81
92
  // finish time with `new Date()` whenever the record had none, which made an
82
93
  // observation indistinguishable from a real finish time. One of
@@ -172,6 +183,9 @@ class ExecutionRecord {
172
183
  if (this.attemptHistory) {
173
184
  obj.attemptHistory = this.attemptHistory;
174
185
  }
186
+ if (this.continues) {
187
+ obj.continues = this.continues;
188
+ }
175
189
  if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
176
190
  obj.endTimeSource = this.endTimeSource;
177
191
  }
@@ -257,6 +271,7 @@ class ExecutionStore {
257
271
  this.linoDbPath = path.join(this.appFolder, LINO_DB_FILE);
258
272
  this.linksDbPath = path.join(this.appFolder, LINKS_DB_FILE);
259
273
  this.lockFilePath = path.join(this.appFolder, LOCK_FILE);
274
+ this.decodedCopyPath = path.join(this.appFolder, DECODED_COPY_FILE);
260
275
  this.useLinks = options.useLinks !== false && isClinkInstalled();
261
276
  this.verbose = options.verbose || false;
262
277
 
@@ -297,7 +312,14 @@ class ExecutionStore {
297
312
  return [];
298
313
  }
299
314
 
300
- const data = decodeSync(content);
315
+ // Decoded once per content, not once per call (issue #243).
316
+ const { data, source } = decodeStoreContent(
317
+ this.linoDbPath,
318
+ content,
319
+ decodeSync,
320
+ { copyPath: this.decodedCopyPath }
321
+ );
322
+ this.log(DECODE_LOG[source](content));
301
323
  if (!Array.isArray(data)) {
302
324
  return [];
303
325
  }
@@ -544,33 +566,17 @@ class ExecutionStore {
544
566
  /**
545
567
  * Get an execution record by UUID or session name
546
568
  * First tries exact UUID match, then the current session name, then any
547
- * session name the execution used before it was resumed
569
+ * session name the execution used before it was resumed, then the display
570
+ * name and aliases
548
571
  * @param {string} identifier - UUID or session name
549
572
  * @returns {ExecutionRecord|null}
550
573
  */
551
574
  get(identifier) {
552
- const records = this.readLinoRecords();
553
- // First try exact UUID match
554
- const byUuid = records.find((r) => r.uuid === identifier);
555
- if (byUuid) {
556
- return byUuid;
557
- }
558
- // Fall back to session name lookup (stored in options.sessionName)
559
- const bySessionName = records.find(
560
- (r) => r.options && r.options.sessionName === identifier
561
- );
562
- if (bySessionName) {
563
- return bySessionName;
564
- }
565
- // Finally, honor session names this record used before it was resumed, so
566
- // one logical session stays addressable across restarts (issue #162).
567
- const byPreviousSessionName = records.find(
568
- (r) =>
569
- r.options &&
570
- Array.isArray(r.options.sessionNameHistory) &&
571
- r.options.sessionNameHistory.includes(identifier)
575
+ // Names, display names and aliases resolve too (issues #162, #231).
576
+ return require('./execution-identity').findRecord(
577
+ this.readLinoRecords(),
578
+ identifier
572
579
  );
573
- return byPreviousSessionName || null;
574
580
  }
575
581
 
576
582
  /**
@@ -926,4 +932,5 @@ module.exports = {
926
932
  LINO_DB_FILE,
927
933
  LINKS_DB_FILE,
928
934
  LOCK_FILE,
935
+ DECODED_COPY_FILE,
929
936
  };
@@ -5,6 +5,16 @@ const MAIN_OOM = 'Exit evidence: main-oom (recent cgroup oom_kill delta)';
5
5
  const DOCKER_OOM = 'Exit evidence: main-oom (recent Docker oom event)';
6
6
  const DAEMON_RESTART =
7
7
  'Exit evidence: docker-daemon-restart (Docker service journal)';
8
+ const DAEMON_RESTART_API =
9
+ 'Exit evidence: docker-daemon-restart (Docker API: containers finished together)';
10
+ const DAEMON_RESTART_PREFIX = 'Exit evidence: docker-daemon-restart';
11
+ // dockerd gives every container its stop timeout and kills the rest within its
12
+ // 15 s shutdown timeout, so the containers of one restart finish this close.
13
+ const DAEMON_RESTART_WINDOW_MS = 15000;
14
+ const CONTAINER_STATE_FORMAT =
15
+ '{{.Id}} {{.State.FinishedAt}} {{.State.StartedAt}} {{.State.ExitCode}} {{.State.OOMKilled}}';
16
+ // Bounds one `docker inspect` call on hosts with many stopped containers.
17
+ const MAX_PEER_CONTAINERS = 256;
8
18
 
9
19
  function recentOomDelta(sample, finishedAt) {
10
20
  const fields = String(sample || '')
@@ -39,6 +49,42 @@ function daemonRestartEvidence(journal, containerId) {
39
49
  return Boolean(exited && forced);
40
50
  }
41
51
 
52
+ /**
53
+ * Socket-only daemon-restart evidence (issue #241): another container that
54
+ * finished within the same shutdown window, was not OOM-killed, and was either
55
+ * stopped by a signal (137/143) or started again afterwards (restart policy;
56
+ * moby resets its ExitCode to 0 but keeps FinishedAt). `docker events` cannot
57
+ * be used: its buffer does not survive the restart, while State does.
58
+ * @param {string} states - `CONTAINER_STATE_FORMAT` lines, one per container
59
+ */
60
+ function daemonRestartApiEvidence(states, containerId, finishedAt) {
61
+ const finished = Date.parse(finishedAt);
62
+ return (
63
+ Number.isFinite(finished) &&
64
+ String(states || '')
65
+ .split('\n')
66
+ .some((line) => {
67
+ const [id, peerFinished, peerStarted, exit, oom] = line
68
+ .trim()
69
+ .split(/\s+/);
70
+ const end = Date.parse(peerFinished);
71
+ if (
72
+ !/^[a-f0-9]{64}$/.test(id || '') ||
73
+ id === containerId ||
74
+ oom !== 'false' ||
75
+ !Number.isFinite(end) ||
76
+ end <= 0 ||
77
+ Math.abs(end - finished) > DAEMON_RESTART_WINDOW_MS
78
+ ) {
79
+ return false;
80
+ }
81
+ return (
82
+ exit === '137' || exit === '143' || Date.parse(peerStarted) > end
83
+ );
84
+ })
85
+ );
86
+ }
87
+
42
88
  function recentDockerOom(events, finishedAt) {
43
89
  const finished = Date.parse(finishedAt) / 1000;
44
90
  return (
@@ -68,6 +114,10 @@ function collectExitEvidence(
68
114
  // A local journal cannot diagnose a remote daemon. Require a local socket.
69
115
  const local =
70
116
  !process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://');
117
+ const sampledOom =
118
+ (oomKilled === true || oomKilled === 'true') &&
119
+ (Number(exitCode) === 137 || Number(exitCode) < 0) &&
120
+ recentOomDelta(sample, finishedAt);
71
121
  if (
72
122
  Number(exitCode) === 137 &&
73
123
  containerName &&
@@ -114,18 +164,35 @@ function collectExitEvidence(
114
164
  if (events.success && recentDockerOom(events.stdout, finishedAt)) {
115
165
  return DOCKER_OOM;
116
166
  }
167
+ // Without a host journal (a mounted docker.sock, a remote daemon) the
168
+ // only record of the restart is the state of the other containers.
169
+ const peers = sampledOom
170
+ ? { success: false, stdout: '' }
171
+ : run(docker, ['ps', '-a', '-q', '--no-trunc']);
172
+ const ids = peers.stdout
173
+ .split(/\s+/)
174
+ .filter(
175
+ (peer) => /^[a-f0-9]{64}$/.test(peer) && peer !== id.stdout.trim()
176
+ )
177
+ .slice(0, MAX_PEER_CONTAINERS);
178
+ // A container removed after `docker ps` fails inspect with status 1 but
179
+ // the others are still printed, so the output is used either way.
180
+ const states = ids.length
181
+ ? run(docker, ['inspect', '-f', CONTAINER_STATE_FORMAT, ...ids])
182
+ : { success: false, stdout: '' };
183
+ if (
184
+ daemonRestartApiEvidence(states.stdout, id.stdout.trim(), finishedAt)
185
+ ) {
186
+ return DAEMON_RESTART_API;
187
+ }
117
188
  if (process.env.START_DEBUG === '1') {
118
189
  console.error(
119
- `[DEBUG] Exit evidence: container=${id.stdout.trim()} finishedAt=${finishedAt} eventsAvailable=${events.success} oomEvents=${JSON.stringify(events.stdout)} sample=${JSON.stringify(sample || '')}`
190
+ `[DEBUG] Exit evidence: container=${id.stdout.trim()} finishedAt=${finishedAt} eventsAvailable=${events.success} oomEvents=${JSON.stringify(events.stdout)} peerContainers=${ids.length} peerStates=${JSON.stringify(states.stdout)} sample=${JSON.stringify(sample || '')}`
120
191
  );
121
192
  }
122
193
  }
123
194
  }
124
- if (
125
- (oomKilled === true || oomKilled === 'true') &&
126
- (Number(exitCode) === 137 || Number(exitCode) < 0) &&
127
- recentOomDelta(sample, finishedAt)
128
- ) {
195
+ if (sampledOom) {
129
196
  return MAIN_OOM;
130
197
  }
131
198
  return 'Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)';
@@ -134,12 +201,72 @@ function collectExitEvidence(
134
201
  function fromLog(log) {
135
202
  const lines = String(log || '').split('\n');
136
203
  const last = lines.filter((line) => line.startsWith('Exit evidence:')).at(-1);
204
+ const daemonRestart = Boolean(last?.startsWith(DAEMON_RESTART_PREFIX));
137
205
  return {
138
206
  mainOom: last === MAIN_OOM || last === DOCKER_OOM,
139
- daemonRestart: last === DAEMON_RESTART,
207
+ daemonRestart,
208
+ // Which source attributed the restart: the host journal or the API.
209
+ ...(daemonRestart
210
+ ? { daemonRestartSource: last === DAEMON_RESTART_API ? 'api' : 'journal' }
211
+ : {}),
140
212
  };
141
213
  }
142
214
 
215
+ /** The stored `daemonRestartSource`, kept only when a restart was attributed. */
216
+ function sourceOf(logged) {
217
+ return logged?.daemonRestart && logged.daemonRestartSource
218
+ ? { daemonRestartSource: logged.daemonRestartSource }
219
+ : {};
220
+ }
221
+
222
+ // A watcher that shares the daemon with its task (a mounted docker.sock) dies
223
+ // in the same restart and never logs evidence, so `--status` collects it from
224
+ // the container state that survived, once per process (issue #241).
225
+ const lostWatcherEvidence = new Map();
226
+ function collectLostWatcherEvidence(record, state, runner) {
227
+ const name = record?.options?.sessionName;
228
+ if (
229
+ !name ||
230
+ record.options.exitEvidence ||
231
+ state?.running !== false ||
232
+ state.exitCode !== 137 ||
233
+ !state.finishedAt
234
+ ) {
235
+ return null;
236
+ }
237
+ const key = `${name}\0${state.finishedAt}`;
238
+ if (!lostWatcherEvidence.has(key)) {
239
+ const logged = fromLog(
240
+ collectExitEvidence(
241
+ {
242
+ containerName: name,
243
+ finishedAt: state.finishedAt,
244
+ exitCode: state.exitCode,
245
+ oomKilled: state.oomKilled,
246
+ },
247
+ runner
248
+ )
249
+ );
250
+ lostWatcherEvidence.set(key, {
251
+ mainOom: logged.mainOom,
252
+ daemonRestart: logged.daemonRestart,
253
+ ...sourceOf(logged),
254
+ });
255
+ }
256
+ return { ...lostWatcherEvidence.get(key) };
257
+ }
258
+
259
+ /** Stores the lost watcher's evidence when the log tail carries none. */
260
+ function addLostWatcherEvidence(enriched, record, state, logTail) {
261
+ const lost =
262
+ state && !/^Exit evidence:/m.test(logTail || '')
263
+ ? collectLostWatcherEvidence(record, state)
264
+ : null;
265
+ if (lost) {
266
+ enriched.options = { ...enriched.options, exitEvidence: lost };
267
+ }
268
+ }
269
+
143
270
  function buildExitEvidenceSnippet(containerName, quotedLogPath) {
144
271
  const stderr = process.env.START_DEBUG === '1' ? '2>&1' : '2>/dev/null';
145
272
  return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} ${stderr}`;
@@ -149,11 +276,16 @@ module.exports = {
149
276
  MAIN_OOM,
150
277
  DOCKER_OOM,
151
278
  DAEMON_RESTART,
279
+ DAEMON_RESTART_API,
152
280
  recentOomDelta,
281
+ daemonRestartApiEvidence,
153
282
  recentDockerOom,
154
283
  daemonRestartEvidence,
155
284
  collectExitEvidence,
156
285
  fromLog,
286
+ sourceOf,
287
+ collectLostWatcherEvidence,
288
+ addLostWatcherEvidence,
157
289
  buildExitEvidenceSnippet,
158
290
  };
159
291
  if (require.main === module) {
@@ -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) {
@@ -909,6 +913,20 @@ function runIsolated(backend, command, options = {}) {
909
913
  session: options.sessionStack ? options.sessionStack[0] : options.session,
910
914
  logPath: options.logPath,
911
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
+ }
912
930
 
913
931
  switch (backend) {
914
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
+ };