start-command 0.37.8 → 0.38.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 +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/bin/cli.js +66 -26
- package/src/lib/args-parser-queries.js +90 -9
- package/src/lib/args-parser.js +20 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-runtime-args.js +7 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +21 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +19 -0
- package/src/lib/execution-resume.js +77 -6
- package/src/lib/execution-store.js +63 -22
- package/src/lib/isolation.js +25 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +34 -6
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +64 -35
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +15 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +35 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the guarded Docker snapshot resume (issue #224):
|
|
3
|
+
* --snapshot-wait <seconds> wait for the host snapshot lock
|
|
4
|
+
* --snapshot-disk-wait <seconds> wait for free disk instead of refusing
|
|
5
|
+
* --remove-original-after <seconds> remove the original only if the
|
|
6
|
+
* replacement still runs after the delay
|
|
7
|
+
* --snapshot-size-hint <bytes> skip the `docker inspect --size` walk
|
|
8
|
+
* --snapshot-prepare <command> `docker exec` a command in the original
|
|
9
|
+
* before it is committed
|
|
10
|
+
*
|
|
11
|
+
* They only apply to `--resume`. The two waits also read
|
|
12
|
+
* START_SNAPSHOT_LOCK_WAIT and START_SNAPSHOT_DISK_WAIT when the flags are
|
|
13
|
+
* absent, so a supervisor can set them once for every resume it runs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FIELDS = {
|
|
17
|
+
'--snapshot-wait': 'snapshotWait',
|
|
18
|
+
'--snapshot-disk-wait': 'snapshotDiskWait',
|
|
19
|
+
'--remove-original-after': 'removeOriginalAfter',
|
|
20
|
+
'--snapshot-size-hint': 'snapshotSizeHint',
|
|
21
|
+
'--snapshot-prepare': 'snapshotPrepare',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ENV_DEFAULTS = {
|
|
25
|
+
snapshotWait: ['START_SNAPSHOT_LOCK_WAIT', '--snapshot-wait'],
|
|
26
|
+
snapshotDiskWait: ['START_SNAPSHOT_DISK_WAIT', '--snapshot-disk-wait'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const UNITS = {
|
|
30
|
+
'': 1,
|
|
31
|
+
b: 1,
|
|
32
|
+
k: 1024,
|
|
33
|
+
m: 1024 ** 2,
|
|
34
|
+
g: 1024 ** 3,
|
|
35
|
+
t: 1024 ** 4,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function createSnapshotOptionDefaults() {
|
|
39
|
+
return {
|
|
40
|
+
snapshotWait: null,
|
|
41
|
+
snapshotDiskWait: null,
|
|
42
|
+
removeOriginalAfter: null,
|
|
43
|
+
snapshotSizeHint: null,
|
|
44
|
+
snapshotPrepare: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a non-negative number of seconds; fractions are allowed.
|
|
50
|
+
* @param {string} value - Raw value
|
|
51
|
+
* @param {string} name - Option or variable name for the error message
|
|
52
|
+
* @returns {number} Seconds
|
|
53
|
+
*/
|
|
54
|
+
function parseSeconds(value, name) {
|
|
55
|
+
const text = String(value).trim();
|
|
56
|
+
const seconds = Number(text);
|
|
57
|
+
if (!/^\d+(?:\.\d+)?$/.test(text) || !Number.isFinite(seconds)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid ${name} value: "${value}". Expected a non-negative number of seconds.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return seconds;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a size in bytes; k, m, g and t suffixes are 1024-based.
|
|
67
|
+
* @param {string} value - Raw value
|
|
68
|
+
* @returns {number} Bytes
|
|
69
|
+
*/
|
|
70
|
+
function parseSizeHint(value) {
|
|
71
|
+
const match = /^(\d+)([bkmgt])?$/.exec(String(value).trim().toLowerCase());
|
|
72
|
+
const bytes = match ? Number(match[1]) * UNITS[match[2] || ''] : NaN;
|
|
73
|
+
if (!Number.isSafeInteger(bytes)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Invalid --snapshot-size-hint value: "${value}". Expected a number of bytes (k, m, g, t suffixes allowed).`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseValue(flag, value) {
|
|
82
|
+
if (flag === '--snapshot-size-hint') {
|
|
83
|
+
return parseSizeHint(value);
|
|
84
|
+
}
|
|
85
|
+
if (flag === '--snapshot-prepare') {
|
|
86
|
+
if (String(value).trim() === '') {
|
|
87
|
+
throw new Error('--snapshot-prepare requires a non-empty command');
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
return parseSeconds(value, flag);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse one snapshot option.
|
|
96
|
+
* @returns {number} Arguments consumed, 0 when `args[index]` is not one
|
|
97
|
+
*/
|
|
98
|
+
function parse(args, index, options) {
|
|
99
|
+
const arg = args[index];
|
|
100
|
+
const equals = arg.indexOf('=');
|
|
101
|
+
const flag = equals === -1 ? arg : arg.slice(0, equals);
|
|
102
|
+
const field = FIELDS[flag];
|
|
103
|
+
if (!field) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
if (equals !== -1) {
|
|
107
|
+
options[field] = parseValue(flag, arg.slice(equals + 1));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const value = args[index + 1];
|
|
111
|
+
// A prepare command may itself start with a dash only through `=`.
|
|
112
|
+
if (value === undefined || value.startsWith('-')) {
|
|
113
|
+
const kind = {
|
|
114
|
+
'--snapshot-size-hint': 'bytes',
|
|
115
|
+
'--snapshot-prepare': 'command',
|
|
116
|
+
}[flag];
|
|
117
|
+
throw new Error(`Option ${flag} requires a ${kind || 'seconds'} argument`);
|
|
118
|
+
}
|
|
119
|
+
options[field] = parseValue(flag, value);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasValue(value) {
|
|
124
|
+
return value !== null && value !== undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reject snapshot options outside `--resume` and fill the waits from the
|
|
129
|
+
* environment.
|
|
130
|
+
* @param {object} options - Parsed wrapper options
|
|
131
|
+
* @param {object} [env] - Environment
|
|
132
|
+
*/
|
|
133
|
+
function validate(options, env = process.env) {
|
|
134
|
+
if (!hasValue(options.resume)) {
|
|
135
|
+
for (const [flag, field] of Object.entries(FIELDS)) {
|
|
136
|
+
if (hasValue(options[field])) {
|
|
137
|
+
throw new Error(`${flag} is only valid with --resume`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const [field, [name, flag]] of Object.entries(ENV_DEFAULTS)) {
|
|
143
|
+
if (!hasValue(options[field]) && hasValue(env[name]) && env[name] !== '') {
|
|
144
|
+
options[field] = parseSeconds(env[name], `${name} (${flag})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (hasValue(options.removeOriginalAfter)) {
|
|
148
|
+
options.removeOriginal = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The `snapshotOptions` `resumeExecution` reads from the wrapper options.
|
|
154
|
+
* @param {object} options - Parsed wrapper options
|
|
155
|
+
* @returns {object} {waitSeconds, diskWaitSeconds, sizeHint, prepare}
|
|
156
|
+
*/
|
|
157
|
+
function snapshotResumeSettings(options = {}) {
|
|
158
|
+
return {
|
|
159
|
+
waitSeconds: options.snapshotWait ?? 0,
|
|
160
|
+
diskWaitSeconds: options.snapshotDiskWait ?? 0,
|
|
161
|
+
sizeHint: options.snapshotSizeHint ?? null,
|
|
162
|
+
prepare: options.snapshotPrepare ?? null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
FIELDS,
|
|
168
|
+
createSnapshotOptionDefaults,
|
|
169
|
+
parse,
|
|
170
|
+
parseSeconds,
|
|
171
|
+
parseSizeHint,
|
|
172
|
+
snapshotResumeSettings,
|
|
173
|
+
validate,
|
|
174
|
+
};
|
package/src/lib/spawn-helpers.js
CHANGED
|
@@ -18,9 +18,14 @@ function ensureParentDirectory(filePath) {
|
|
|
18
18
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function writeInitialLog(logFilePath, logContent) {
|
|
21
|
+
function writeInitialLog(logFilePath, logContent, append = false) {
|
|
22
22
|
ensureParentDirectory(logFilePath);
|
|
23
|
-
|
|
23
|
+
if (append) {
|
|
24
|
+
// `--append-attempt` (issue #229): never truncate earlier attempts.
|
|
25
|
+
fs.appendFileSync(logFilePath, logContent || '', 'utf8');
|
|
26
|
+
} else {
|
|
27
|
+
fs.writeFileSync(logFilePath, logContent || '', 'utf8');
|
|
28
|
+
}
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
function appendLog(logFilePath, content) {
|
|
@@ -40,6 +45,8 @@ function appendLog(logFilePath, content) {
|
|
|
40
45
|
* @param {Object} options.executionRecord - Execution tracking record
|
|
41
46
|
* @param {Object} options.store - Execution store
|
|
42
47
|
* @param {Object} options.config - CLI configuration
|
|
48
|
+
* @param {Object} [options.env] - Child environment (defaults to process.env)
|
|
49
|
+
* @param {boolean} [options.appendLog] - Append to an existing log (issue #229)
|
|
43
50
|
* @param {Function} options.onComplete - Callback for completion (exitCode, endTime, logContent, durationMs)
|
|
44
51
|
* @param {Function} options.onError - Callback for errors (errorMessage, endTime, durationMs)
|
|
45
52
|
*/
|
|
@@ -54,18 +61,21 @@ async function runWithBunSpawn(options) {
|
|
|
54
61
|
config,
|
|
55
62
|
onComplete,
|
|
56
63
|
onError,
|
|
64
|
+
env,
|
|
65
|
+
appendLog: appendToLog = false,
|
|
57
66
|
} = options;
|
|
58
67
|
|
|
59
68
|
let logContent = options.logContent || '';
|
|
60
69
|
|
|
61
70
|
try {
|
|
62
|
-
writeInitialLog(logFilePath, logContent);
|
|
71
|
+
writeInitialLog(logFilePath, logContent, appendToLog);
|
|
63
72
|
|
|
64
73
|
// Spawn the process using Bun's native API
|
|
65
74
|
const proc = Bun.spawn([shell, ...shellArgs], {
|
|
66
75
|
stdout: 'pipe',
|
|
67
76
|
stderr: 'pipe',
|
|
68
77
|
stdin: 'inherit',
|
|
78
|
+
...(env ? { env } : {}),
|
|
69
79
|
});
|
|
70
80
|
|
|
71
81
|
// Update execution record with PID and save initial state
|
|
@@ -186,14 +196,16 @@ async function runWithBunSpawn(options) {
|
|
|
186
196
|
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
187
197
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
188
198
|
|
|
189
|
-
|
|
190
|
-
logContent +=
|
|
191
|
-
logContent += `Finished: ${endTime}\n`;
|
|
192
|
-
logContent += `Exit Code: 1\n`;
|
|
199
|
+
const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
|
|
200
|
+
logContent += errorTail;
|
|
193
201
|
|
|
194
|
-
// Write log file
|
|
202
|
+
// Write log file; an appended attempt keeps the earlier attempts (#229).
|
|
195
203
|
try {
|
|
196
|
-
|
|
204
|
+
if (appendToLog) {
|
|
205
|
+
writeInitialLog(logFilePath, errorTail, true);
|
|
206
|
+
} else {
|
|
207
|
+
writeInitialLog(logFilePath, logContent);
|
|
208
|
+
}
|
|
197
209
|
} catch (writeErr) {
|
|
198
210
|
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
199
211
|
}
|
|
@@ -238,15 +250,18 @@ function runWithNodeSpawn(options) {
|
|
|
238
250
|
config,
|
|
239
251
|
onComplete,
|
|
240
252
|
onError,
|
|
253
|
+
env,
|
|
254
|
+
appendLog: appendToLog = false,
|
|
241
255
|
} = options;
|
|
242
256
|
|
|
243
257
|
let logContent = options.logContent || '';
|
|
244
|
-
writeInitialLog(logFilePath, logContent);
|
|
258
|
+
writeInitialLog(logFilePath, logContent, appendToLog);
|
|
245
259
|
|
|
246
260
|
// Execute the command with captured output
|
|
247
261
|
const child = spawn(shell, shellArgs, {
|
|
248
262
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
249
263
|
shell: false,
|
|
264
|
+
...(env ? { env } : {}),
|
|
250
265
|
});
|
|
251
266
|
|
|
252
267
|
// Update execution record with PID and save initial state
|
|
@@ -330,14 +345,16 @@ function runWithNodeSpawn(options) {
|
|
|
330
345
|
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
331
346
|
const errorMessage = `Error executing command: ${err.message}`;
|
|
332
347
|
|
|
333
|
-
|
|
334
|
-
logContent +=
|
|
335
|
-
logContent += `Finished: ${endTime}\n`;
|
|
336
|
-
logContent += `Exit Code: 1\n`;
|
|
348
|
+
const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
|
|
349
|
+
logContent += errorTail;
|
|
337
350
|
|
|
338
|
-
// Write log file
|
|
351
|
+
// Write log file; an appended attempt keeps the earlier attempts (#229).
|
|
339
352
|
try {
|
|
340
|
-
|
|
353
|
+
if (appendToLog) {
|
|
354
|
+
writeInitialLog(logFilePath, errorTail, true);
|
|
355
|
+
} else {
|
|
356
|
+
writeInitialLog(logFilePath, logContent);
|
|
357
|
+
}
|
|
341
358
|
} catch (writeErr) {
|
|
342
359
|
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
343
360
|
}
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* - Text: Human-readable text format
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const { execSync
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const { escapeForLinksNotation } = require('./output-blocks');
|
|
11
12
|
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} = require('./
|
|
13
|
+
appendLinksValue,
|
|
14
|
+
formatTextOptionValue,
|
|
15
|
+
} = require('./status-links-notation');
|
|
15
16
|
const { collectProcessIds } = require('./execution-control');
|
|
16
17
|
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
17
18
|
const {
|
|
@@ -27,6 +28,11 @@ const {
|
|
|
27
28
|
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
28
29
|
const { END_TIME_SOURCE } = require('./detached-finalize');
|
|
29
30
|
const { formatCgroupMemory } = require('./cgroup-memory');
|
|
31
|
+
const {
|
|
32
|
+
attachContainerRemoval,
|
|
33
|
+
dockerInspect,
|
|
34
|
+
persistContainerRemoval,
|
|
35
|
+
} = require('./container-removal');
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Inspect the live state of a detached docker container by name.
|
|
@@ -43,32 +49,31 @@ const { formatCgroupMemory } = require('./cgroup-memory');
|
|
|
43
49
|
* instead of fabricating a terminal `-1` result.
|
|
44
50
|
*
|
|
45
51
|
* @param {string} sessionName - Container name
|
|
52
|
+
* @param {Object|null} [probe] - Receives `sessionNotFound` (issue #225)
|
|
46
53
|
* @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
|
|
47
54
|
* or null when the container cannot be inspected (not found yet, removed, or docker error)
|
|
48
55
|
*/
|
|
49
|
-
function inspectDockerState(sessionName) {
|
|
50
|
-
const
|
|
56
|
+
function inspectDockerState(sessionName, probe = null) {
|
|
57
|
+
const { stdout, notFound } = dockerInspect(
|
|
51
58
|
getDockerCommand(),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'
|
|
55
|
-
|
|
56
|
-
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
57
|
-
sessionName,
|
|
58
|
-
],
|
|
59
|
-
getDockerSpawnOptions({
|
|
60
|
-
encoding: 'utf8',
|
|
61
|
-
env: process.env,
|
|
62
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
63
|
-
})
|
|
59
|
+
dockerProbeSpawnOptions(),
|
|
60
|
+
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
|
|
61
|
+
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
62
|
+
sessionName
|
|
64
63
|
);
|
|
65
|
-
if (
|
|
64
|
+
if (probe) {
|
|
65
|
+
// "No such container" is a fact, unlike a docker that cannot be reached
|
|
66
|
+
// (issue #225); it still reads as unknown liveness here (issue #136).
|
|
67
|
+
probe.sessionNotFound = notFound;
|
|
68
|
+
}
|
|
69
|
+
if (stdout === null) {
|
|
66
70
|
return null;
|
|
67
71
|
}
|
|
68
72
|
// Parsed positionally and defensively: an older docker (or a stubbed one)
|
|
69
73
|
// may answer with fewer fields than the template asks for.
|
|
70
|
-
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
|
|
71
|
-
|
|
74
|
+
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] = stdout
|
|
75
|
+
.trim()
|
|
76
|
+
.split(/\s+/);
|
|
72
77
|
const exitCode = Number.parseInt(exitRaw, 10);
|
|
73
78
|
return {
|
|
74
79
|
running: runningRaw === 'true',
|
|
@@ -89,12 +94,20 @@ function isDetachedDockerRecord(record) {
|
|
|
89
94
|
);
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
function
|
|
97
|
+
function dockerProbeSpawnOptions() {
|
|
98
|
+
return getDockerSpawnOptions({
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
env: process.env,
|
|
101
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readDockerState(record, probe = null) {
|
|
93
106
|
const opts = record.options || {};
|
|
94
107
|
if (opts.isolated !== 'docker' || !opts.sessionName) {
|
|
95
108
|
return null;
|
|
96
109
|
}
|
|
97
|
-
const state = inspectDockerState(opts.sessionName);
|
|
110
|
+
const state = inspectDockerState(opts.sessionName, probe);
|
|
98
111
|
if (
|
|
99
112
|
record.attempt &&
|
|
100
113
|
state?.startedAt &&
|
|
@@ -325,14 +338,14 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
|
325
338
|
enriched.observedAt = enriched.endTime;
|
|
326
339
|
}
|
|
327
340
|
|
|
328
|
-
function resolveDetachedStatus(record, logTail) {
|
|
341
|
+
function resolveDetachedStatus(record, logTail, probe = null) {
|
|
329
342
|
if (require('./launch-owner').hasActiveLaunch(record)) {
|
|
330
343
|
return record;
|
|
331
344
|
}
|
|
332
345
|
const footer = parseFooterFromTail(logTail);
|
|
333
346
|
const footerExit = footer.exitCode;
|
|
334
347
|
const dockerState = isDetachedDockerRecord(record)
|
|
335
|
-
? readDockerState(record)
|
|
348
|
+
? readDockerState(record, probe)
|
|
336
349
|
: null;
|
|
337
350
|
const alive = isDetachedDockerRecord(record)
|
|
338
351
|
? dockerState === null
|
|
@@ -550,7 +563,14 @@ function enrichDetachedStatus(record) {
|
|
|
550
563
|
const logTail = record
|
|
551
564
|
? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
|
|
552
565
|
: null;
|
|
553
|
-
const
|
|
566
|
+
const probe = {
|
|
567
|
+
dockerCommand: getDockerCommand(),
|
|
568
|
+
spawnOptions: dockerProbeSpawnOptions(),
|
|
569
|
+
};
|
|
570
|
+
const resolved = attachContainerRemoval(
|
|
571
|
+
resolveDetachedStatus(record, logTail, probe),
|
|
572
|
+
probe
|
|
573
|
+
);
|
|
554
574
|
const enriched = attachMemoryExhaustion(
|
|
555
575
|
attachExitReason(resolved, logTail),
|
|
556
576
|
logTail
|
|
@@ -671,22 +691,21 @@ function formatRecordAsLinksNotation(record) {
|
|
|
671
691
|
for (const [key, value] of Object.entries(obj)) {
|
|
672
692
|
if (value !== null && value !== undefined) {
|
|
673
693
|
if (key === 'options' && typeof value === 'object') {
|
|
674
|
-
//
|
|
694
|
+
// Nested option values (launchOwner, exitEvidence, resolvedLimits…)
|
|
695
|
+
// are rendered as nested Links notation, never "[object Object]"
|
|
696
|
+
// (issue #226).
|
|
675
697
|
const optionEntries = Object.entries(value).filter(
|
|
676
698
|
([, v]) => v !== null && v !== undefined
|
|
677
699
|
);
|
|
678
700
|
if (optionEntries.length > 0) {
|
|
679
701
|
lines.push(' options');
|
|
680
702
|
for (const [optKey, optValue] of optionEntries) {
|
|
681
|
-
|
|
682
|
-
lines.push(` ${optKey} ${formattedOptValue}`);
|
|
703
|
+
appendLinksValue(lines, optKey, optValue, 4);
|
|
683
704
|
}
|
|
684
705
|
}
|
|
685
706
|
} else if (typeof value === 'object') {
|
|
686
|
-
//
|
|
687
|
-
lines
|
|
688
|
-
const nested = formatAsNestedLinksNotation(value, 2, 2);
|
|
689
|
-
lines.push(nested);
|
|
707
|
+
// Other objects use the same nested layout as the Rust formatter.
|
|
708
|
+
appendLinksValue(lines, key, value, 2);
|
|
690
709
|
} else {
|
|
691
710
|
const formattedValue = escapeForLinksNotation(value);
|
|
692
711
|
lines.push(` ${key} ${formattedValue}`);
|
|
@@ -710,6 +729,7 @@ function formatRecordAsText(record) {
|
|
|
710
729
|
`UUID: ${obj.uuid}`,
|
|
711
730
|
`Status: ${obj.status}`,
|
|
712
731
|
`Command: ${obj.command}`,
|
|
732
|
+
...require('./execution-chain').identityTextLines(obj),
|
|
713
733
|
`Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
|
|
714
734
|
...(obj.oomKilled !== undefined
|
|
715
735
|
? [`OOM Killed: ${obj.oomKilled}`]
|
|
@@ -751,6 +771,11 @@ function formatRecordAsText(record) {
|
|
|
751
771
|
if (obj.staleDetectedAt) {
|
|
752
772
|
lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
|
|
753
773
|
}
|
|
774
|
+
if (obj.containerState) {
|
|
775
|
+
// The container no longer exists; `containerId` is history (issue #225).
|
|
776
|
+
lines.push(`Container State: ${obj.containerState}`);
|
|
777
|
+
lines.push(`Container Removed: ${obj.containerRemovedObservedAt || 'N/A'}`);
|
|
778
|
+
}
|
|
754
779
|
lines.push(`Log Path: ${obj.logPath}`);
|
|
755
780
|
if (obj.attempt) {
|
|
756
781
|
lines.push(
|
|
@@ -770,7 +795,7 @@ function formatRecordAsText(record) {
|
|
|
770
795
|
if (optionEntries.length > 0) {
|
|
771
796
|
lines.push(`Options:`);
|
|
772
797
|
for (const [key, value] of optionEntries) {
|
|
773
|
-
lines.push(` ${key}: ${value}`);
|
|
798
|
+
lines.push(` ${key}: ${formatTextOptionValue(value)}`);
|
|
774
799
|
}
|
|
775
800
|
}
|
|
776
801
|
|
|
@@ -897,9 +922,13 @@ function queryStatus(store, identifier, outputFormat) {
|
|
|
897
922
|
try {
|
|
898
923
|
// Enrich detached execution status with live session check
|
|
899
924
|
const enrichedRecord = enrichDetachedStatus(record);
|
|
925
|
+
persistContainerRemoval(store, record, enrichedRecord);
|
|
900
926
|
const withProcessIds = attachProcessIds(enrichedRecord);
|
|
901
927
|
// Attach currentTime so callers can see how long an executing command has been running
|
|
902
|
-
const withCurrentTime =
|
|
928
|
+
const withCurrentTime = require('./execution-chain').attachIdentity(
|
|
929
|
+
attachCurrentTime(withProcessIds),
|
|
930
|
+
store
|
|
931
|
+
);
|
|
903
932
|
return {
|
|
904
933
|
success: true,
|
|
905
934
|
output: formatRecord(withCurrentTime, outputFormat || 'links-notation'),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links-notation and text rendering of nested `--status` values.
|
|
3
|
+
*
|
|
4
|
+
* `--status` stores structured option values such as `launchOwner`,
|
|
5
|
+
* `exitEvidence`, `resolvedLimits` and `resourceLimitSpecs`. They used to be
|
|
6
|
+
* passed through `escapeForLinksNotation` (and a template string in the text
|
|
7
|
+
* format), which printed `"[object Object]"` and lost the values (issue #226).
|
|
8
|
+
* These helpers render nested objects and arrays the same way the Rust
|
|
9
|
+
* formatter's `append_links_value` does, so both implementations print the
|
|
10
|
+
* same layout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { escapeForLinksNotation } = require('./output-blocks');
|
|
14
|
+
|
|
15
|
+
function isPresent(value) {
|
|
16
|
+
return value !== null && value !== undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append an array as a `(` … `)` block.
|
|
21
|
+
* @param {string[]} lines - Output lines
|
|
22
|
+
* @param {Array} values - Array to render
|
|
23
|
+
* @param {number} indent - Indentation of the parentheses
|
|
24
|
+
*/
|
|
25
|
+
function appendLinksArray(lines, values, indent) {
|
|
26
|
+
const prefix = ' '.repeat(indent);
|
|
27
|
+
if (values.length === 0) {
|
|
28
|
+
lines.push(`${prefix}()`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
lines.push(`${prefix}(`);
|
|
32
|
+
for (const value of values) {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
appendLinksArray(lines, value, indent + 2);
|
|
35
|
+
} else if (isPresent(value) && typeof value === 'object') {
|
|
36
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
37
|
+
if (isPresent(childValue)) {
|
|
38
|
+
appendLinksValue(lines, childKey, childValue, indent + 2);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
lines.push(`${' '.repeat(indent + 2)}${escapeForLinksNotation(value)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
lines.push(`${prefix})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Append `key value`, or `key` followed by its nested children.
|
|
50
|
+
* Children of an object are indented by four more spaces, matching the
|
|
51
|
+
* top-level ` key` / ` child` layout `--status` has always used.
|
|
52
|
+
* @param {string[]} lines - Output lines
|
|
53
|
+
* @param {string} key - Field name
|
|
54
|
+
* @param {*} value - Field value
|
|
55
|
+
* @param {number} indent - Indentation of the key
|
|
56
|
+
*/
|
|
57
|
+
function appendLinksValue(lines, key, value, indent) {
|
|
58
|
+
const prefix = ' '.repeat(indent);
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
lines.push(`${prefix}${key}`);
|
|
61
|
+
appendLinksArray(lines, value, indent + 2);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (isPresent(value) && typeof value === 'object') {
|
|
65
|
+
const entries = Object.entries(value);
|
|
66
|
+
if (entries.length === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
lines.push(`${prefix}${key}`);
|
|
70
|
+
for (const [childKey, childValue] of entries) {
|
|
71
|
+
if (isPresent(childValue)) {
|
|
72
|
+
appendLinksValue(lines, childKey, childValue, indent + 4);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
lines.push(`${prefix}${key} ${escapeForLinksNotation(value)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render an option value for the text format: scalars as-is, structured
|
|
82
|
+
* values as compact JSON (the Rust formatter uses `serde_json::to_string`).
|
|
83
|
+
* @param {*} value - Option value
|
|
84
|
+
* @returns {string} Rendered value
|
|
85
|
+
*/
|
|
86
|
+
function formatTextOptionValue(value) {
|
|
87
|
+
if (isPresent(value) && typeof value === 'object') {
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
return String(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
appendLinksArray,
|
|
95
|
+
appendLinksValue,
|
|
96
|
+
formatTextOptionValue,
|
|
97
|
+
};
|
package/src/lib/store-lock.js
CHANGED
|
@@ -14,6 +14,8 @@ class LockManager {
|
|
|
14
14
|
this.identity = null;
|
|
15
15
|
this.token = null;
|
|
16
16
|
this.monitorOwner = monitorOwner;
|
|
17
|
+
// Extra fields published in the lock file, for example who holds it.
|
|
18
|
+
this.metadata = {};
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
@@ -44,6 +46,7 @@ class LockManager {
|
|
|
44
46
|
this.fs.writeFileSync(
|
|
45
47
|
temp,
|
|
46
48
|
JSON.stringify({
|
|
49
|
+
...this.metadata,
|
|
47
50
|
pid: process.pid,
|
|
48
51
|
timestamp: Date.now(),
|
|
49
52
|
hostname: os.hostname(),
|
package/src/lib/usage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Print usage information */
|
|
2
2
|
function printUsage() {
|
|
3
|
-
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all
|
|
3
|
+
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all | $ --alias <id> <name> | $ --rename <id> <name>
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
6
|
--help, -h Show this usage and exit successfully
|
|
@@ -10,6 +10,8 @@ Options:
|
|
|
10
10
|
--session, -s <name> Session name for isolation
|
|
11
11
|
--session-id <uuid> Session UUID for tracking (auto-generated if not provided)
|
|
12
12
|
--session-name <uuid> Alias for --session-id
|
|
13
|
+
--append-attempt Run as the next attempt of the existing --session-id execution
|
|
14
|
+
--continues <id> Record that this execution continues an earlier one
|
|
13
15
|
--image <image> Docker image (optional, defaults to OS-matched image)
|
|
14
16
|
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
|
|
15
17
|
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
@@ -54,9 +56,18 @@ Options:
|
|
|
54
56
|
--resume <id> Restart a stopped detached execution in the same environment
|
|
55
57
|
(append -- <command> to run a different command there)
|
|
56
58
|
--remove-original With legacy snapshot resume, remove the stopped original after launch
|
|
59
|
+
--remove-original-after <s> Remove it only if the replacement still runs after <s> seconds
|
|
60
|
+
--snapshot-wait <s> Wait up to <s> seconds for another snapshot on this host (exit 75 after)
|
|
61
|
+
--snapshot-disk-wait <s> Wait up to <s> seconds for free disk instead of refusing (exit 75 after)
|
|
62
|
+
--snapshot-size-hint <bytes> Use this writable-layer size instead of docker inspect --size
|
|
63
|
+
--snapshot-prepare <cmd> docker exec <cmd> in the started original before it is snapshotted
|
|
57
64
|
--resume-all Re-attach or reconcile every execution still marked running
|
|
58
65
|
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
59
66
|
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
67
|
+
--cleanup <id> Remove a Docker execution's containers and snapshot images (never forced)
|
|
68
|
+
--cleanup-dry-run <id> Show the containers and images --cleanup <id> would remove
|
|
69
|
+
--alias <id> <name> Add a name the execution can be addressed by
|
|
70
|
+
--rename <id> <name> Rename the execution, keeping its previous name as an alias
|
|
60
71
|
--version, -v Show version information
|
|
61
72
|
|
|
62
73
|
Examples:
|
|
@@ -84,6 +95,9 @@ Examples:
|
|
|
84
95
|
$ --resume my-docker-session # Restart the stored command in the same container
|
|
85
96
|
$ --resume my-docker-session -- bash # Run a different command in the same container
|
|
86
97
|
$ --resume-all # Re-attach every still-running execution
|
|
98
|
+
$ --session-id <uuid> --append-attempt -- npm test # Next attempt, same log
|
|
99
|
+
$ --continues <uuid> -- npm test # Fresh execution chained to <uuid>
|
|
100
|
+
$ --rename <uuid> nightly-build # Address it as nightly-build from now on
|
|
87
101
|
$ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
|
|
88
102
|
$ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
|
|
89
103
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
@@ -142,7 +142,7 @@ describe('query mode exclusivity', () => {
|
|
|
142
142
|
test(`rejects ${args.join(' ')}`, () => {
|
|
143
143
|
assert.throws(() => {
|
|
144
144
|
parse(args);
|
|
145
|
-
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --
|
|
145
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
});
|
|
@@ -54,13 +54,13 @@ describe('control options', () => {
|
|
|
54
54
|
it('should reject combining query and control modes', () => {
|
|
55
55
|
assert.throws(() => {
|
|
56
56
|
parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
|
|
57
|
-
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --
|
|
57
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
it('should reject combining upload-log with control modes', () => {
|
|
61
61
|
assert.throws(() => {
|
|
62
62
|
parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
|
|
63
|
-
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --
|
|
63
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('should reject output-format with control modes', () => {
|