start-command 0.32.1 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Resume tracked detached executions (issue #162).
3
+ *
4
+ * `--resume <id>` restarts a stored execution, and `--resume <id> -- <command>`
5
+ * runs a *different* command against the same container filesystem. Both keep
6
+ * the original execution UUID so `--status`, `--list` and `--upload-log` keep
7
+ * addressing one logical session across restarts.
8
+ *
9
+ * Three strategies, chosen from the probed session state:
10
+ * - DOCKER_START: the container still exists and the stored command is
11
+ * re-run by `docker start` (its original entrypoint).
12
+ * - DOCKER_SNAPSHOT: the container still exists but a new command was given,
13
+ * so its filesystem is committed to an image and a derived
14
+ * container runs the new command. This avoids
15
+ * `docker start -ai`, which would re-run the original
16
+ * entrypoint from scratch.
17
+ * - RELAUNCH: nothing is left of the session, so the command is
18
+ * launched again through the stored isolation options.
19
+ */
20
+
21
+ const {
22
+ DOCKER_CONTAINER_CLEANUP_POLICY,
23
+ getDockerCommand,
24
+ getDockerContainerCleanupPolicy,
25
+ startDetachedDockerCompletionWatcher,
26
+ } = require('./docker-cleanup');
27
+ const { escapeForLinksNotation } = require('./output-blocks');
28
+ const { runCommand } = require('./execution-control');
29
+ const { SessionState, probeSession } = require('./session-probe');
30
+ const { getDockerNetworks } = require('./docker-network-lifecycle');
31
+ const {
32
+ ResumeAllAction,
33
+ resumeAllExecutions,
34
+ } = require('./execution-resume-all');
35
+
36
+ /**
37
+ * Strategies `buildResumePlan` can pick.
38
+ */
39
+ const ResumeMode = {
40
+ DOCKER_START: 'docker-start',
41
+ DOCKER_SNAPSHOT: 'docker-snapshot',
42
+ RELAUNCH: 'relaunch',
43
+ };
44
+
45
+ /**
46
+ * Build the docker image name used to snapshot a container before running a
47
+ * new command in it. Docker repository names must be lowercase and limited to
48
+ * `[a-z0-9._-]`, so session names are sanitized.
49
+ * @param {string} sessionName - Original session name
50
+ * @param {number} attempt - Resume counter (1-based)
51
+ * @returns {string} Image reference
52
+ */
53
+ function buildSnapshotImageName(sessionName, attempt) {
54
+ const sanitized = String(sessionName)
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9._-]+/g, '-')
57
+ .replace(/^[-.]+/, '');
58
+ return `start-command-resume/${sanitized || 'session'}:${attempt}`;
59
+ }
60
+
61
+ /**
62
+ * Build the container name for a snapshot-based resume.
63
+ * @param {string} sessionName - Original session name
64
+ * @param {number} attempt - Resume counter (1-based)
65
+ * @returns {string} Container name
66
+ */
67
+ function buildResumedSessionName(sessionName, attempt) {
68
+ return `${sessionName}-resume-${attempt}`;
69
+ }
70
+
71
+ /**
72
+ * Rebuild the isolation options stored on a record so the command can be
73
+ * launched again with the same configuration.
74
+ * @param {object} record - Execution record
75
+ * @returns {object} Options for `runIsolated`
76
+ */
77
+ function buildLaunchOptions(record) {
78
+ const opts = record.options || {};
79
+ return {
80
+ image: opts.image || null,
81
+ session: opts.sessionName,
82
+ detached: true,
83
+ endpoint: opts.endpoint || null,
84
+ user: opts.user || false,
85
+ keepAlive: opts.keepAlive || false,
86
+ autoRemoveDockerContainer: opts.autoRemoveDockerContainer || false,
87
+ alwaysCleanupContainer: opts.alwaysCleanupContainer || false,
88
+ keepContainer: opts.keepContainer || false,
89
+ keepContainerOnFail: opts.keepContainerOnFail || false,
90
+ useCommandStream: opts.useCommandStream || false,
91
+ shell: opts.shell || 'auto',
92
+ privileged: opts.privileged || false,
93
+ env: opts.env || [],
94
+ volumes: opts.volumes || [],
95
+ mounts: opts.mounts || [],
96
+ networks: opts.networks || [],
97
+ networkAliases: opts.networkAliases || [],
98
+ // Append to the same log so one logical session keeps one gap-free record.
99
+ logPath: record.logPath || null,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Build the steps that start a snapshot-derived container.
105
+ *
106
+ * `docker run` can only join one network, so a session that was launched on
107
+ * several networks is rebuilt the same way `runIsolated` builds it: create the
108
+ * container, connect the additional networks, then start it. Dropping to a
109
+ * single `docker run` when there is nothing extra to connect keeps the common
110
+ * case to one command.
111
+ *
112
+ * @param {object} params - {containerArgs, extraNetworks, newSessionName, snapshotImage}
113
+ * @returns {object[]} Resume steps
114
+ */
115
+ function buildSnapshotStartSteps({
116
+ containerArgs,
117
+ extraNetworks,
118
+ newSessionName,
119
+ snapshotImage,
120
+ }) {
121
+ if (extraNetworks.length === 0) {
122
+ return [
123
+ {
124
+ command: getDockerCommand(),
125
+ args: ['run', '-d', ...containerArgs],
126
+ description: `Run the new command in ${newSessionName}`,
127
+ },
128
+ ];
129
+ }
130
+
131
+ return [
132
+ {
133
+ command: getDockerCommand(),
134
+ args: ['create', ...containerArgs],
135
+ description: `Create ${newSessionName} from ${snapshotImage}`,
136
+ },
137
+ ...extraNetworks.map((network) => ({
138
+ command: getDockerCommand(),
139
+ args: ['network', 'connect', network, newSessionName],
140
+ description: `Connect ${newSessionName} to network ${network}`,
141
+ })),
142
+ {
143
+ command: getDockerCommand(),
144
+ args: ['start', newSessionName],
145
+ description: `Run the new command in ${newSessionName}`,
146
+ },
147
+ ];
148
+ }
149
+
150
+ /**
151
+ * Decide how a stored execution should be resumed.
152
+ * @param {object} record - Execution record
153
+ * @param {?string} newCommand - Replacement command (from `-- <command>`)
154
+ * @param {object} probe - Result of `probeSession`
155
+ * @returns {object} Resume plan, or {error}
156
+ */
157
+ function buildResumePlan(record, newCommand, probe = {}) {
158
+ const opts = (record && record.options) || {};
159
+ const backend = opts.isolated;
160
+ const sessionName = opts.sessionName;
161
+
162
+ if (!sessionName) {
163
+ return {
164
+ error: 'Execution record does not contain an isolation session name.',
165
+ };
166
+ }
167
+
168
+ if (opts.isolationMode !== 'detached') {
169
+ return { error: 'Only detached isolated executions can be resumed.' };
170
+ }
171
+
172
+ if (probe.alive) {
173
+ return {
174
+ error:
175
+ `Session "${sessionName}" is still running. ` +
176
+ `Use \`$ --attach ${record.uuid}\` to re-enter it, or \`$ --stop ${record.uuid}\` first.`,
177
+ };
178
+ }
179
+
180
+ const command = newCommand || record.command;
181
+ if (!command) {
182
+ return {
183
+ error: `Execution "${record.uuid}" has no stored command to resume.`,
184
+ };
185
+ }
186
+
187
+ const attempt = (Number(opts.resumeCount) || 0) + 1;
188
+
189
+ if (backend === 'docker' && probe.state === SessionState.STOPPED) {
190
+ if (!newCommand) {
191
+ return {
192
+ mode: ResumeMode.DOCKER_START,
193
+ backend,
194
+ sessionName,
195
+ command,
196
+ attempt,
197
+ steps: [
198
+ {
199
+ command: getDockerCommand(),
200
+ args: ['start', sessionName],
201
+ description: `Start stopped container ${sessionName}`,
202
+ },
203
+ ],
204
+ message: `Resumed detached docker container: ${sessionName}`,
205
+ };
206
+ }
207
+
208
+ // Lazily required: isolation.js pulls in this module's siblings, so a
209
+ // top-level require would create a cycle.
210
+ const { buildDockerRuntimeArgs } = require('./isolation');
211
+ const snapshotImage = buildSnapshotImageName(sessionName, attempt);
212
+ const newSessionName = buildResumedSessionName(sessionName, attempt);
213
+ return {
214
+ mode: ResumeMode.DOCKER_SNAPSHOT,
215
+ backend,
216
+ sessionName,
217
+ newSessionName,
218
+ snapshotImage,
219
+ command,
220
+ attempt,
221
+ steps: [
222
+ {
223
+ command: getDockerCommand(),
224
+ args: ['commit', sessionName, snapshotImage],
225
+ description: `Snapshot container ${sessionName} as ${snapshotImage}`,
226
+ },
227
+ ...buildSnapshotStartSteps({
228
+ containerArgs: [
229
+ '--name',
230
+ newSessionName,
231
+ ...(opts.user ? ['--user', opts.user] : []),
232
+ ...buildDockerRuntimeArgs(opts),
233
+ snapshotImage,
234
+ 'sh',
235
+ '-c',
236
+ command,
237
+ ],
238
+ extraNetworks: getDockerNetworks(opts).slice(1),
239
+ newSessionName,
240
+ snapshotImage,
241
+ }),
242
+ ],
243
+ message: `Resumed session in new container ${newSessionName} from snapshot of ${sessionName}`,
244
+ };
245
+ }
246
+
247
+ return {
248
+ mode: ResumeMode.RELAUNCH,
249
+ backend,
250
+ sessionName,
251
+ command,
252
+ attempt,
253
+ steps: [],
254
+ launchOptions: buildLaunchOptions(record),
255
+ message: `Relaunched ${backend} session: ${sessionName}`,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Format a resume result as links notation.
261
+ * @param {object} result - Resume result fields
262
+ * @returns {string} Links notation block
263
+ */
264
+ function formatResumeResultAsLinksNotation(result) {
265
+ const lines = [
266
+ 'executionResume',
267
+ ` identifier ${escapeForLinksNotation(result.identifier)}`,
268
+ ` uuid ${escapeForLinksNotation(result.uuid)}`,
269
+ ` mode ${escapeForLinksNotation(result.mode)}`,
270
+ ` backend ${escapeForLinksNotation(result.backend)}`,
271
+ ` sessionName ${escapeForLinksNotation(result.sessionName)}`,
272
+ ];
273
+ if (result.previousSessionName) {
274
+ lines.push(
275
+ ` previousSessionName ${escapeForLinksNotation(result.previousSessionName)}`
276
+ );
277
+ }
278
+ if (result.snapshotImage) {
279
+ lines.push(
280
+ ` snapshotImage ${escapeForLinksNotation(result.snapshotImage)}`
281
+ );
282
+ }
283
+ lines.push(` command ${escapeForLinksNotation(result.command)}`);
284
+ lines.push(` message ${escapeForLinksNotation(result.message)}`);
285
+ return lines.join('\n');
286
+ }
287
+
288
+ function formatResumeResult(result, outputFormat) {
289
+ if (outputFormat === 'json') {
290
+ return JSON.stringify(result, null, 2);
291
+ }
292
+ if (outputFormat === 'text') {
293
+ return [
294
+ `Resume Mode: ${result.mode}`,
295
+ `UUID: ${result.uuid}`,
296
+ `Backend: ${result.backend}`,
297
+ `Session Name: ${result.sessionName}`,
298
+ `Command: ${result.command}`,
299
+ result.message,
300
+ ].join('\n');
301
+ }
302
+ return formatResumeResultAsLinksNotation(result);
303
+ }
304
+
305
+ /**
306
+ * Apply the resume outcome to the stored record, keeping the original UUID so
307
+ * one logical session stays addressable across restarts.
308
+ * @param {object} record - Execution record
309
+ * @param {object} plan - Resume plan
310
+ * @param {?string} containerId - New container id, when one was created
311
+ * @returns {object} The updated record
312
+ */
313
+ function applyResumeToRecord(record, plan, containerId) {
314
+ const options = { ...(record.options || {}) };
315
+ options.resumeCount = plan.attempt;
316
+ options.resumedAt = new Date().toISOString();
317
+
318
+ if (plan.newSessionName) {
319
+ options.sessionNameHistory = [
320
+ ...(options.sessionNameHistory || []),
321
+ options.sessionName,
322
+ ];
323
+ options.sessionName = plan.newSessionName;
324
+ }
325
+ if (plan.snapshotImage) {
326
+ options.image = plan.snapshotImage;
327
+ }
328
+ if (containerId) {
329
+ options.containerId = containerId;
330
+ }
331
+
332
+ record.options = options;
333
+ record.command = plan.command;
334
+ record.status = 'executing';
335
+ record.exitCode = null;
336
+ record.endTime = null;
337
+ record.exitReason = undefined;
338
+ record.oomKilled = undefined;
339
+ return record;
340
+ }
341
+
342
+ function activeSessionName(plan) {
343
+ return plan.newSessionName || plan.sessionName;
344
+ }
345
+
346
+ /**
347
+ * Resume a tracked execution by UUID or session name.
348
+ * @param {?object} store - Execution store
349
+ * @param {string} identifier - UUID or session name
350
+ * @param {object} deps - {command, outputFormat, probe, runner, startWatcher, runIsolated}
351
+ * @returns {Promise<{success: boolean, output?: string, error?: string}>}
352
+ */
353
+ async function resumeExecution(store, identifier, deps = {}) {
354
+ if (!store) {
355
+ return { success: false, error: 'Execution tracking is disabled.' };
356
+ }
357
+
358
+ const record = store.get(identifier);
359
+ if (!record) {
360
+ return {
361
+ success: false,
362
+ error: `No execution found with UUID or session name: ${identifier}`,
363
+ };
364
+ }
365
+
366
+ const runner = deps.runner || runCommand;
367
+ const probeFn = deps.probe || ((r) => probeSession(r, runner));
368
+ const plan = buildResumePlan(record, deps.command || null, probeFn(record));
369
+ if (plan.error) {
370
+ return { success: false, error: plan.error };
371
+ }
372
+
373
+ let containerId = null;
374
+
375
+ if (plan.mode === ResumeMode.RELAUNCH) {
376
+ const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
377
+ const launchResult = await runIsolated(
378
+ plan.backend,
379
+ plan.command,
380
+ plan.launchOptions
381
+ );
382
+ if (!launchResult || !launchResult.success) {
383
+ return {
384
+ success: false,
385
+ error: `Failed to relaunch ${plan.backend} session "${plan.sessionName}": ${
386
+ (launchResult && launchResult.message) || 'unknown error'
387
+ }`,
388
+ };
389
+ }
390
+ containerId = launchResult.containerId || null;
391
+ } else {
392
+ for (const step of plan.steps) {
393
+ const result = runner(step.command, step.args);
394
+ if (!result.success) {
395
+ const detail =
396
+ (result.stderr || '').trim() ||
397
+ result.error ||
398
+ `exit code ${result.status}`;
399
+ return {
400
+ success: false,
401
+ error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
402
+ };
403
+ }
404
+ containerId = (result.stdout || '').trim() || containerId;
405
+ }
406
+
407
+ // The completion watcher died with the previous run (or with the
408
+ // supervisor), so a new one must follow the resumed container.
409
+ const startWatcher =
410
+ deps.startWatcher || startDetachedDockerCompletionWatcher;
411
+ startWatcher(
412
+ activeSessionName(plan),
413
+ getDockerContainerCleanupPolicy(record.options || {}),
414
+ record.logPath || null,
415
+ record.uuid || null
416
+ );
417
+ }
418
+
419
+ const previousSessionName = plan.newSessionName ? plan.sessionName : null;
420
+ const updated = applyResumeToRecord(record, plan, containerId);
421
+ store.save(updated);
422
+
423
+ return {
424
+ success: true,
425
+ output: formatResumeResult(
426
+ {
427
+ identifier,
428
+ uuid: updated.uuid,
429
+ mode: plan.mode,
430
+ backend: plan.backend,
431
+ sessionName: updated.options.sessionName,
432
+ previousSessionName,
433
+ snapshotImage: plan.snapshotImage || null,
434
+ command: plan.command,
435
+ message: plan.message,
436
+ },
437
+ deps.outputFormat
438
+ ),
439
+ };
440
+ }
441
+
442
+ module.exports = {
443
+ ResumeAllAction,
444
+ ResumeMode,
445
+ resumeAllExecutions,
446
+ applyResumeToRecord,
447
+ buildLaunchOptions,
448
+ buildResumePlan,
449
+ buildResumedSessionName,
450
+ buildSnapshotImageName,
451
+ formatResumeResult,
452
+ formatResumeResultAsLinksNotation,
453
+ resumeExecution,
454
+ DOCKER_CONTAINER_CLEANUP_POLICY,
455
+ };
@@ -15,13 +15,14 @@
15
15
  * - startTime: Timestamp when execution started
16
16
  * - endTime: Timestamp when execution completed (null while executing)
17
17
  * - oomKilled: Docker resource-exhaustion signal when available
18
+ * - memoryExhausted: memory-exhaustion observation derived from the log tail
18
19
  * - options: Execution options (isolation mode, etc.)
19
20
  */
20
21
 
21
22
  const fs = require('fs');
22
23
  const path = require('path');
23
24
  const os = require('os');
24
- const { execSync, spawnSync } = require('child_process');
25
+ const { execFileSync, spawnSync } = require('child_process');
25
26
  const crypto = require('crypto');
26
27
 
27
28
  // Synchronous wrapper using Bun's native ESM support
@@ -74,8 +75,47 @@ class ExecutionRecord {
74
75
  this.logPath = options.logPath || '';
75
76
  this.startTime = options.startTime || new Date().toISOString();
76
77
  this.endTime = options.endTime || null;
78
+ // Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
79
+ // finish time with `new Date()` whenever the record had none, which made an
80
+ // observation indistinguishable from a real finish time. One of
81
+ // 'docker-finished-at' | 'log-footer' | 'observed-at'.
82
+ this.endTimeSource =
83
+ options.endTimeSource !== undefined ? options.endTimeSource : undefined;
84
+ // When the end of the execution was *noticed*, for the cases where no real
85
+ // finish time exists. Always accompanies endTimeSource === 'observed-at'.
86
+ this.observedAt =
87
+ options.observedAt !== undefined ? options.observedAt : undefined;
88
+ // When `cleanupStale()` gave up on a record whose process is gone. This is
89
+ // a detection time, not a finish time, so it is never written to `endTime`
90
+ // (issue #170.3).
91
+ this.staleDetectedAt =
92
+ options.staleDetectedAt !== undefined
93
+ ? options.staleDetectedAt
94
+ : undefined;
95
+ // `docker inspect .State.StartedAt` of the isolation container, kept next
96
+ // to `endTime` so the container's lifetime stays reconstructible after the
97
+ // container itself is gone (issue #171).
98
+ this.containerStartedAt =
99
+ options.containerStartedAt !== undefined
100
+ ? options.containerStartedAt
101
+ : undefined;
77
102
  this.oomKilled =
78
103
  options.oomKilled !== undefined ? options.oomKilled : undefined;
104
+ // Query-time hint explaining an opaque exit code (issue #162). Derived from
105
+ // the log tail by the status formatter; never persisted as a verdict.
106
+ this.exitReason =
107
+ options.exitReason !== undefined ? options.exitReason : undefined;
108
+ // Query-time memory-exhaustion observation (issue #165). A runtime that
109
+ // aborts on its own heap limit is invisible to `oomKilled`, so the evidence
110
+ // the runtime printed into the log is surfaced separately.
111
+ this.memoryExhausted =
112
+ options.memoryExhausted !== undefined
113
+ ? options.memoryExhausted
114
+ : undefined;
115
+ this.memoryExhaustedReason =
116
+ options.memoryExhaustedReason !== undefined
117
+ ? options.memoryExhaustedReason
118
+ : undefined;
79
119
  this.workingDirectory = options.workingDirectory || process.cwd();
80
120
  this.shell = options.shell || process.env.SHELL || '/bin/sh';
81
121
  this.platform = options.platform || process.platform;
@@ -106,9 +146,36 @@ class ExecutionRecord {
106
146
  startTime: this.startTime,
107
147
  endTime: this.endTime,
108
148
  };
149
+ if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
150
+ obj.endTimeSource = this.endTimeSource;
151
+ }
152
+ if (this.observedAt !== undefined && this.observedAt !== null) {
153
+ obj.observedAt = this.observedAt;
154
+ }
155
+ if (this.staleDetectedAt !== undefined && this.staleDetectedAt !== null) {
156
+ obj.staleDetectedAt = this.staleDetectedAt;
157
+ }
158
+ if (
159
+ this.containerStartedAt !== undefined &&
160
+ this.containerStartedAt !== null
161
+ ) {
162
+ obj.containerStartedAt = this.containerStartedAt;
163
+ }
109
164
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
110
165
  obj.oomKilled = this.oomKilled;
111
166
  }
167
+ if (this.exitReason !== undefined && this.exitReason !== null) {
168
+ obj.exitReason = this.exitReason;
169
+ }
170
+ if (this.memoryExhausted !== undefined && this.memoryExhausted !== null) {
171
+ obj.memoryExhausted = this.memoryExhausted;
172
+ }
173
+ if (
174
+ this.memoryExhaustedReason !== undefined &&
175
+ this.memoryExhaustedReason !== null
176
+ ) {
177
+ obj.memoryExhaustedReason = this.memoryExhaustedReason;
178
+ }
112
179
  Object.assign(obj, {
113
180
  workingDirectory: this.workingDirectory,
114
181
  shell: this.shell,
@@ -389,7 +456,10 @@ class ExecutionStore {
389
456
  */
390
457
  execClink(query) {
391
458
  try {
392
- const result = execSync(`clink '${query}' --db "${this.linksDbPath}"`, {
459
+ // The query embeds record values verbatim; a shell string would let a
460
+ // single quote in any of them break out of the argument
461
+ // (CodeQL js/shell-command-constructed-from-input, issue #168).
462
+ const result = execFileSync('clink', [query, '--db', this.linksDbPath], {
393
463
  encoding: 'utf8',
394
464
  timeout: 10000,
395
465
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -480,7 +550,8 @@ class ExecutionStore {
480
550
 
481
551
  /**
482
552
  * Get an execution record by UUID or session name
483
- * First tries exact UUID match, then falls back to session name lookup
553
+ * First tries exact UUID match, then the current session name, then any
554
+ * session name the execution used before it was resumed
484
555
  * @param {string} identifier - UUID or session name
485
556
  * @returns {ExecutionRecord|null}
486
557
  */
@@ -495,7 +566,18 @@ class ExecutionStore {
495
566
  const bySessionName = records.find(
496
567
  (r) => r.options && r.options.sessionName === identifier
497
568
  );
498
- return bySessionName || null;
569
+ if (bySessionName) {
570
+ return bySessionName;
571
+ }
572
+ // Finally, honor session names this record used before it was resumed, so
573
+ // one logical session stays addressable across restarts (issue #162).
574
+ const byPreviousSessionName = records.find(
575
+ (r) =>
576
+ r.options &&
577
+ Array.isArray(r.options.sessionNameHistory) &&
578
+ r.options.sessionNameHistory.includes(identifier)
579
+ );
580
+ return byPreviousSessionName || null;
499
581
  }
500
582
 
501
583
  /**
@@ -619,10 +701,15 @@ class ExecutionStore {
619
701
  (r) => r.uuid === staleRecord.uuid
620
702
  );
621
703
  if (index >= 0) {
622
- // Mark as executed with exit code -1 to indicate abnormal termination
704
+ // Mark as executed with exit code -1 to indicate abnormal
705
+ // termination. The real finish time is unknowable here — the
706
+ // process vanished without writing one — so the record keeps
707
+ // `endTime: null` and states *when the loss was detected* instead
708
+ // of passing cleanup time off as a finish time (issue #170.3).
623
709
  currentRecords[index].status = ExecutionStatus.EXECUTED;
624
710
  currentRecords[index].exitCode = -1;
625
- currentRecords[index].endTime = new Date().toISOString();
711
+ currentRecords[index].endTime = null;
712
+ currentRecords[index].staleDetectedAt = new Date().toISOString();
626
713
  result.cleaned++;
627
714
  }
628
715
  }