start-command 0.32.1 → 0.33.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 (42) hide show
  1. package/CHANGELOG.md +21 -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 +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/echo-integration.js +20 -13
  30. package/test/execution-attach.js +221 -0
  31. package/test/execution-resume.js +433 -0
  32. package/test/execution-store.js +52 -0
  33. package/test/exit-reason.js +209 -0
  34. package/test/failure-handler.js +107 -0
  35. package/test/load-command-stream.mjs +83 -0
  36. package/test/regression-162.js +320 -0
  37. package/test/regression-164.js +308 -0
  38. package/test/regression-165.js +310 -0
  39. package/test/regression-84.js +8 -3
  40. package/test/release-name.mjs +57 -0
  41. package/test/session-probe.js +150 -0
  42. package/test/user-manager.js +23 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `--resume-all`: re-attach or reconcile every execution still marked running.
3
+ *
4
+ * When the supervisor host restarts, the detached docker completion watcher
5
+ * (`docker logs -f ... ; docker inspect ... ; footer`) dies with it. The
6
+ * container keeps running but nothing streams its output into the session log
7
+ * any more, and nothing will ever write the exit footer — the record stays
8
+ * `executing` forever. `--resume-all` repairs that state:
9
+ *
10
+ * - reattached: a live docker container gets a fresh completion watcher.
11
+ * - running: a live screen/tmux session needs nothing (its logging is
12
+ * in-session), it is only reported.
13
+ * - reconciled: the session is gone, so the record is finalized from the same
14
+ * evidence `--status` uses (docker exit code, log footer).
15
+ * - unknown: the backend cannot be probed locally (ssh), left untouched.
16
+ *
17
+ * Commands are never silently restarted here: resuming actual work is an
18
+ * explicit, per-session decision made with `--resume`.
19
+ */
20
+
21
+ const {
22
+ getDockerContainerCleanupPolicy,
23
+ startDetachedDockerCompletionWatcher,
24
+ } = require('./docker-cleanup');
25
+ const { escapeForLinksNotation } = require('./output-blocks');
26
+ const { runCommand } = require('./execution-control');
27
+ const { SessionState, probeSession } = require('./session-probe');
28
+
29
+ /**
30
+ * Outcomes reported for each execution that was still marked running.
31
+ */
32
+ const ResumeAllAction = {
33
+ REATTACHED: 'reattached',
34
+ RUNNING: 'running',
35
+ RECONCILED: 'reconciled',
36
+ UNKNOWN: 'unknown',
37
+ };
38
+
39
+ function formatResumeAllAsLinksNotation(summary) {
40
+ const lines = ['executionResumeAll', ` count ${summary.count}`];
41
+ for (const entry of summary.executions) {
42
+ lines.push(' execution');
43
+ lines.push(` uuid ${escapeForLinksNotation(entry.uuid)}`);
44
+ lines.push(` backend ${escapeForLinksNotation(entry.backend)}`);
45
+ lines.push(` sessionName ${escapeForLinksNotation(entry.sessionName)}`);
46
+ lines.push(` state ${escapeForLinksNotation(entry.state)}`);
47
+ lines.push(` action ${escapeForLinksNotation(entry.action)}`);
48
+ if (entry.exitCode !== null && entry.exitCode !== undefined) {
49
+ lines.push(` exitCode ${entry.exitCode}`);
50
+ }
51
+ lines.push(` message ${escapeForLinksNotation(entry.message)}`);
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+
56
+ function formatResumeAllAsText(summary) {
57
+ if (summary.count === 0) {
58
+ return 'No executions are currently marked as running.';
59
+ }
60
+ const lines = [`Executions still marked running: ${summary.count}`, ''];
61
+ for (const entry of summary.executions) {
62
+ lines.push(`${entry.action.toUpperCase()} ${entry.uuid}`);
63
+ lines.push(` Backend: ${entry.backend}`);
64
+ lines.push(` Session Name: ${entry.sessionName}`);
65
+ lines.push(` State: ${entry.state}`);
66
+ lines.push(` ${entry.message}`);
67
+ lines.push('');
68
+ }
69
+ return lines.join('\n').trimEnd();
70
+ }
71
+
72
+ function formatResumeAll(summary, outputFormat) {
73
+ if (outputFormat === 'json') {
74
+ return JSON.stringify(summary, null, 2);
75
+ }
76
+ if (outputFormat === 'text') {
77
+ return formatResumeAllAsText(summary);
78
+ }
79
+ return formatResumeAllAsLinksNotation(summary);
80
+ }
81
+
82
+ function describeRecord(record, probe, action, message, exitCode = null) {
83
+ return {
84
+ uuid: record.uuid,
85
+ backend:
86
+ probe.backend || (record.options && record.options.isolated) || null,
87
+ sessionName:
88
+ probe.sessionName ||
89
+ (record.options && record.options.sessionName) ||
90
+ null,
91
+ state: probe.state,
92
+ action,
93
+ exitCode,
94
+ message,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Re-attach or reconcile every execution still marked running.
100
+ * @param {?object} store - Execution store
101
+ * @param {object} deps - {outputFormat, probe, runner, startWatcher, reconcile}
102
+ * @returns {{success: boolean, output?: string, error?: string}}
103
+ */
104
+ function resumeAllExecutions(store, deps = {}) {
105
+ if (!store) {
106
+ return { success: false, error: 'Execution tracking is disabled.' };
107
+ }
108
+
109
+ const runner = deps.runner || runCommand;
110
+ const probeFn = deps.probe || ((record) => probeSession(record, runner));
111
+ const startWatcher =
112
+ deps.startWatcher || startDetachedDockerCompletionWatcher;
113
+ const reconcile =
114
+ deps.reconcile || require('./status-formatter').enrichDetachedStatus;
115
+
116
+ let records;
117
+ try {
118
+ records = store.getExecuting();
119
+ } catch (err) {
120
+ return { success: false, error: err.message };
121
+ }
122
+
123
+ const executions = [];
124
+
125
+ for (const record of records) {
126
+ const probe = probeFn(record);
127
+ const sessionName = probe.sessionName;
128
+
129
+ if (probe.alive && probe.backend === 'docker') {
130
+ startWatcher(
131
+ sessionName,
132
+ getDockerContainerCleanupPolicy(record.options || {}),
133
+ record.logPath || null
134
+ );
135
+ executions.push(
136
+ describeRecord(
137
+ record,
138
+ probe,
139
+ ResumeAllAction.REATTACHED,
140
+ `Re-attached completion watcher to running container: ${sessionName}`
141
+ )
142
+ );
143
+ continue;
144
+ }
145
+
146
+ if (probe.alive) {
147
+ executions.push(
148
+ describeRecord(
149
+ record,
150
+ probe,
151
+ ResumeAllAction.RUNNING,
152
+ `Session is still running: ${sessionName}`
153
+ )
154
+ );
155
+ continue;
156
+ }
157
+
158
+ if (probe.state === SessionState.UNKNOWN) {
159
+ executions.push(
160
+ describeRecord(
161
+ record,
162
+ probe,
163
+ ResumeAllAction.UNKNOWN,
164
+ `Session liveness cannot be probed locally: ${sessionName || record.uuid}`
165
+ )
166
+ );
167
+ continue;
168
+ }
169
+
170
+ const reconciled = reconcile(record);
171
+ if (reconciled && reconciled.status === 'executed') {
172
+ store.save(reconciled);
173
+ executions.push(
174
+ describeRecord(
175
+ record,
176
+ probe,
177
+ ResumeAllAction.RECONCILED,
178
+ `Session ended while unsupervised; record finalized. Use \`$ --resume ${record.uuid}\` to continue it.`,
179
+ reconciled.exitCode
180
+ )
181
+ );
182
+ continue;
183
+ }
184
+
185
+ executions.push(
186
+ describeRecord(
187
+ record,
188
+ probe,
189
+ ResumeAllAction.UNKNOWN,
190
+ `Session is not running but no terminal result could be resolved: ${sessionName || record.uuid}`
191
+ )
192
+ );
193
+ }
194
+
195
+ return {
196
+ success: true,
197
+ output: formatResumeAll(
198
+ { count: executions.length, executions },
199
+ deps.outputFormat
200
+ ),
201
+ };
202
+ }
203
+
204
+ module.exports = {
205
+ ResumeAllAction,
206
+ formatResumeAll,
207
+ formatResumeAllAsLinksNotation,
208
+ formatResumeAllAsText,
209
+ resumeAllExecutions,
210
+ };
@@ -0,0 +1,454 @@
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
+ );
416
+ }
417
+
418
+ const previousSessionName = plan.newSessionName ? plan.sessionName : null;
419
+ const updated = applyResumeToRecord(record, plan, containerId);
420
+ store.save(updated);
421
+
422
+ return {
423
+ success: true,
424
+ output: formatResumeResult(
425
+ {
426
+ identifier,
427
+ uuid: updated.uuid,
428
+ mode: plan.mode,
429
+ backend: plan.backend,
430
+ sessionName: updated.options.sessionName,
431
+ previousSessionName,
432
+ snapshotImage: plan.snapshotImage || null,
433
+ command: plan.command,
434
+ message: plan.message,
435
+ },
436
+ deps.outputFormat
437
+ ),
438
+ };
439
+ }
440
+
441
+ module.exports = {
442
+ ResumeAllAction,
443
+ ResumeMode,
444
+ resumeAllExecutions,
445
+ applyResumeToRecord,
446
+ buildLaunchOptions,
447
+ buildResumePlan,
448
+ buildResumedSessionName,
449
+ buildSnapshotImageName,
450
+ formatResumeResult,
451
+ formatResumeResultAsLinksNotation,
452
+ resumeExecution,
453
+ DOCKER_CONTAINER_CLEANUP_POLICY,
454
+ };