start-command 0.32.0 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +130 -57
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/docker-network-integration.js +22 -14
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +52 -0
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- 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
|
+
};
|