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.
- 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 +32 -137
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +240 -39
- package/src/lib/docker-post-mortem.js +289 -0
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +211 -0
- package/src/lib/execution-resume.js +455 -0
- package/src/lib/execution-store.js +93 -6
- package/src/lib/exit-reason.js +264 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +66 -1
- package/src/lib/isolation.js +28 -15
- 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 +233 -69
- 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/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +57 -2
- 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-170.js +321 -0
- package/test/regression-171.js +562 -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,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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
711
|
+
currentRecords[index].endTime = null;
|
|
712
|
+
currentRecords[index].staleDetectedAt = new Date().toISOString();
|
|
626
713
|
result.cleaned++;
|
|
627
714
|
}
|
|
628
715
|
}
|