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
|
@@ -8,13 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { execSync, spawnSync } = require('child_process');
|
|
11
|
-
const fs = require('fs');
|
|
12
11
|
const {
|
|
13
12
|
escapeForLinksNotation,
|
|
14
13
|
formatAsNestedLinksNotation,
|
|
15
14
|
} = require('./output-blocks');
|
|
16
15
|
const { collectProcessIds } = require('./execution-control');
|
|
17
16
|
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
17
|
+
const {
|
|
18
|
+
describeExitCode,
|
|
19
|
+
resolveExitReason,
|
|
20
|
+
resolveMemoryExhaustion,
|
|
21
|
+
} = require('./exit-reason');
|
|
22
|
+
const {
|
|
23
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
24
|
+
readLogTail,
|
|
25
|
+
} = require('./isolation-log-utils');
|
|
26
|
+
const { normalizeDockerTimestamp } = require('./docker-post-mortem');
|
|
27
|
+
const { END_TIME_SOURCE } = require('./detached-finalize');
|
|
18
28
|
|
|
19
29
|
/**
|
|
20
30
|
* Inspect the live state of a detached docker container by name.
|
|
@@ -40,7 +50,8 @@ function inspectDockerState(sessionName) {
|
|
|
40
50
|
[
|
|
41
51
|
'inspect',
|
|
42
52
|
'-f',
|
|
43
|
-
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}'
|
|
53
|
+
'{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
|
|
54
|
+
'{{.State.StartedAt}} {{.State.FinishedAt}}',
|
|
44
55
|
sessionName,
|
|
45
56
|
],
|
|
46
57
|
getDockerSpawnOptions({
|
|
@@ -52,13 +63,18 @@ function inspectDockerState(sessionName) {
|
|
|
52
63
|
if (result.error || result.status !== 0 || !result.stdout) {
|
|
53
64
|
return null;
|
|
54
65
|
}
|
|
55
|
-
|
|
66
|
+
// Parsed positionally and defensively: an older docker (or a stubbed one)
|
|
67
|
+
// may answer with fewer fields than the template asks for.
|
|
68
|
+
const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
|
|
69
|
+
result.stdout.trim().split(/\s+/);
|
|
56
70
|
const exitCode = Number.parseInt(exitRaw, 10);
|
|
57
71
|
return {
|
|
58
72
|
running: runningRaw === 'true',
|
|
59
73
|
exitCode: Number.isFinite(exitCode) ? exitCode : null,
|
|
60
74
|
oomKilled:
|
|
61
75
|
oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
|
|
76
|
+
startedAt: normalizeDockerTimestamp(startedRaw),
|
|
77
|
+
finishedAt: normalizeDockerTimestamp(finishedRaw),
|
|
62
78
|
};
|
|
63
79
|
}
|
|
64
80
|
|
|
@@ -175,13 +191,6 @@ function isDetachedSessionAlive(record) {
|
|
|
175
191
|
}
|
|
176
192
|
}
|
|
177
193
|
|
|
178
|
-
/**
|
|
179
|
-
* Number of trailing bytes scanned for the terminal log footer. The footer is
|
|
180
|
-
* always appended at the very end of the log, so there is no reason to read
|
|
181
|
-
* (potentially megabytes of) command output on every `--status` call.
|
|
182
|
-
*/
|
|
183
|
-
const LOG_TAIL_BYTES = 16 * 1024;
|
|
184
|
-
|
|
185
194
|
/**
|
|
186
195
|
* The three-line terminal footer that `start` itself writes (see
|
|
187
196
|
* `createLogFooter()` and `createShellLogFooterSnippet()`):
|
|
@@ -196,44 +205,49 @@ const LOG_TAIL_BYTES = 16 * 1024;
|
|
|
196
205
|
* `start` appends itself (issue #150).
|
|
197
206
|
*/
|
|
198
207
|
const LOG_FOOTER_PATTERN =
|
|
199
|
-
/^={10,}[ \t]*\r?\n^Finished:[^\r\n]
|
|
208
|
+
/^={10,}[ \t]*\r?\n^Finished:[ \t]*([^\r\n]*)\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
|
|
200
209
|
|
|
201
210
|
/**
|
|
202
|
-
*
|
|
211
|
+
* Parse the `Finished:` line of the footer into an ISO timestamp.
|
|
203
212
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
213
|
+
* `start` writes it as `YYYY-MM-DD HH:MM:SS[.mmm]` in UTC (both from JS and
|
|
214
|
+
* from the shell snippet). Anything else — a truncated log, a test fixture, a
|
|
215
|
+
* future format — yields null so the caller falls back instead of inventing a
|
|
216
|
+
* timestamp out of an unparseable string.
|
|
207
217
|
*
|
|
208
|
-
* @param {string}
|
|
209
|
-
* @
|
|
210
|
-
* @returns {string|null} Tail content, or null when the file cannot be read
|
|
218
|
+
* @param {string|null|undefined} value - Raw text after `Finished:`
|
|
219
|
+
* @returns {string|null} ISO timestamp, or null
|
|
211
220
|
*/
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
fd = fs.openSync(logPath, 'r');
|
|
216
|
-
const size = fs.fstatSync(fd).size;
|
|
217
|
-
const length = Math.min(size, bytes);
|
|
218
|
-
const buffer = Buffer.alloc(length);
|
|
219
|
-
fs.readSync(fd, buffer, 0, length, size - length);
|
|
220
|
-
const tail = buffer.toString('utf8');
|
|
221
|
-
if (size <= bytes) {
|
|
222
|
-
return tail;
|
|
223
|
-
}
|
|
224
|
-
const firstNewline = tail.indexOf('\n');
|
|
225
|
-
return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
|
|
226
|
-
} catch {
|
|
221
|
+
function parseFooterTimestamp(value) {
|
|
222
|
+
if (typeof value !== 'string') {
|
|
227
223
|
return null;
|
|
228
|
-
} finally {
|
|
229
|
-
if (fd !== undefined) {
|
|
230
|
-
try {
|
|
231
|
-
fs.closeSync(fd);
|
|
232
|
-
} catch {
|
|
233
|
-
// ignore
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
224
|
}
|
|
225
|
+
const trimmed = value.trim();
|
|
226
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/.test(trimmed)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const parsed = Date.parse(`${trimmed.replace(' ', 'T').replace(/Z$/, '')}Z`);
|
|
230
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read the terminal footer written at the very end of a log.
|
|
235
|
+
* @param {string|null} tail - Tail of the execution log
|
|
236
|
+
* @returns {{exitCode: number|null, finishedAt: string|null}} Footer facts
|
|
237
|
+
*/
|
|
238
|
+
function parseFooterFromTail(tail) {
|
|
239
|
+
if (tail === null || tail === undefined) {
|
|
240
|
+
return { exitCode: null, finishedAt: null };
|
|
241
|
+
}
|
|
242
|
+
const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
|
|
243
|
+
if (matches.length === 0) {
|
|
244
|
+
return { exitCode: null, finishedAt: null };
|
|
245
|
+
}
|
|
246
|
+
const last = matches[matches.length - 1];
|
|
247
|
+
return {
|
|
248
|
+
exitCode: parseInt(last[2], 10),
|
|
249
|
+
finishedAt: parseFooterTimestamp(last[1]),
|
|
250
|
+
};
|
|
237
251
|
}
|
|
238
252
|
|
|
239
253
|
/**
|
|
@@ -244,31 +258,66 @@ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
|
|
|
244
258
|
* @param {string} logPath - Path to the log file
|
|
245
259
|
* @returns {number|null}
|
|
246
260
|
*/
|
|
247
|
-
function
|
|
248
|
-
if (
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
const tail = readLogTail(logPath);
|
|
252
|
-
if (tail === null) {
|
|
261
|
+
function parseExitCodeFromTail(tail) {
|
|
262
|
+
if (tail === null || tail === undefined) {
|
|
253
263
|
return null;
|
|
254
264
|
}
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
return parseFooterFromTail(tail).exitCode;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readExitCodeFromLog(logPath) {
|
|
269
|
+
if (!logPath) {
|
|
257
270
|
return null;
|
|
258
271
|
}
|
|
259
|
-
return
|
|
272
|
+
return parseExitCodeFromTail(readLogTail(logPath));
|
|
260
273
|
}
|
|
261
274
|
|
|
262
275
|
/**
|
|
263
|
-
*
|
|
276
|
+
* Resolve the live status of a detached execution.
|
|
264
277
|
* If a record shows "executing" but the detached session has actually ended,
|
|
265
278
|
* returns an updated copy with status "executed". If it shows "executed" but
|
|
266
279
|
* the session is still running, returns a copy with status "executing".
|
|
267
280
|
* @param {Object} record - Execution record
|
|
281
|
+
* @param {string|null} logTail - Tail of the execution log (already read once)
|
|
268
282
|
* @returns {Object} Possibly updated execution record
|
|
269
283
|
*/
|
|
270
|
-
|
|
271
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Decide the moment a finished execution ended, and say where that moment came
|
|
286
|
+
* from (issue #170.2).
|
|
287
|
+
*
|
|
288
|
+
* Precedence: the container's own `State.FinishedAt`, then the `Finished:` line
|
|
289
|
+
* of the footer `start` wrote itself, and only then the time the end was
|
|
290
|
+
* observed — which is explicitly marked as such, so a consumer can tell a real
|
|
291
|
+
* finish time from a query-time observation.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} enriched - Record copy being enriched (mutated)
|
|
294
|
+
* @param {Object|null} dockerState - Inspected docker state, when available
|
|
295
|
+
* @param {string|null} footerFinishedAt - Footer timestamp, when parseable
|
|
296
|
+
* @returns {void}
|
|
297
|
+
*/
|
|
298
|
+
function applyEndTime(enriched, dockerState, footerFinishedAt) {
|
|
299
|
+
if (enriched.endTime) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const dockerFinishedAt = dockerState ? dockerState.finishedAt : null;
|
|
303
|
+
if (dockerFinishedAt) {
|
|
304
|
+
enriched.endTime = new Date(dockerFinishedAt).toISOString();
|
|
305
|
+
enriched.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (footerFinishedAt) {
|
|
309
|
+
enriched.endTime = footerFinishedAt;
|
|
310
|
+
enriched.endTimeSource = END_TIME_SOURCE.LOG_FOOTER;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
enriched.endTime = new Date().toISOString();
|
|
314
|
+
enriched.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
|
|
315
|
+
enriched.observedAt = enriched.endTime;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveDetachedStatus(record, logTail) {
|
|
319
|
+
const footer = parseFooterFromTail(logTail);
|
|
320
|
+
const footerExit = footer.exitCode;
|
|
272
321
|
const dockerState = isDetachedDockerRecord(record)
|
|
273
322
|
? readDockerState(record)
|
|
274
323
|
: null;
|
|
@@ -305,9 +354,7 @@ function enrichDetachedStatus(record) {
|
|
|
305
354
|
const enriched = cloneRecord();
|
|
306
355
|
enriched.status = 'executed';
|
|
307
356
|
enriched.exitCode = footerExit;
|
|
308
|
-
|
|
309
|
-
enriched.endTime = new Date().toISOString();
|
|
310
|
-
}
|
|
357
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
311
358
|
return enriched;
|
|
312
359
|
}
|
|
313
360
|
if (oomKilled === true) {
|
|
@@ -319,9 +366,7 @@ function enrichDetachedStatus(record) {
|
|
|
319
366
|
const enriched = cloneRecord();
|
|
320
367
|
enriched.status = 'executed';
|
|
321
368
|
enriched.exitCode = 137;
|
|
322
|
-
|
|
323
|
-
enriched.endTime = new Date().toISOString();
|
|
324
|
-
}
|
|
369
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
325
370
|
return enriched;
|
|
326
371
|
}
|
|
327
372
|
}
|
|
@@ -361,14 +406,102 @@ function enrichDetachedStatus(record) {
|
|
|
361
406
|
footerExit ??
|
|
362
407
|
(oomKilled === true ? 137 : -1);
|
|
363
408
|
}
|
|
364
|
-
|
|
365
|
-
enriched.endTime = new Date().toISOString();
|
|
366
|
-
}
|
|
409
|
+
applyEndTime(enriched, dockerState, footer.finishedAt);
|
|
367
410
|
}
|
|
368
411
|
|
|
369
412
|
return enriched;
|
|
370
413
|
}
|
|
371
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Attach an `exitReason` hint to a finished record.
|
|
417
|
+
*
|
|
418
|
+
* The hint explains an otherwise opaque exit code (`139` with `oomKilled
|
|
419
|
+
* false` is the motivating case from issue #162) and is derived from the same
|
|
420
|
+
* log tail the footer scan already reads. It is purely additive: `status`,
|
|
421
|
+
* `exitCode` and `oomKilled` are never changed by it.
|
|
422
|
+
*
|
|
423
|
+
* @param {Object} record - Execution record
|
|
424
|
+
* @param {string|null} logTail - Tail of the execution log
|
|
425
|
+
* @returns {Object} Record, or a copy carrying `exitReason`
|
|
426
|
+
*/
|
|
427
|
+
function attachExitReason(record, logTail) {
|
|
428
|
+
if (!record || record.status !== 'executed') {
|
|
429
|
+
return record;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const exitReason = resolveExitReason({
|
|
433
|
+
exitCode: record.exitCode,
|
|
434
|
+
logTail,
|
|
435
|
+
oomKilled: record.oomKilled,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (!exitReason) {
|
|
439
|
+
return record;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
443
|
+
Object.assign(enriched, record);
|
|
444
|
+
enriched.exitReason = exitReason;
|
|
445
|
+
return enriched;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Attach the memory-exhaustion observation to a finished record (issue #165).
|
|
450
|
+
*
|
|
451
|
+
* A runtime that aborts on its own heap limit (`FATAL ERROR: Reached heap limit
|
|
452
|
+
* ...`) never trips a container signal, so `oomKilled false` next to `exitCode
|
|
453
|
+
* 139` is a false negative for "out of memory" every single time. The evidence
|
|
454
|
+
* is in the log tail this scan already holds, so it is surfaced explicitly
|
|
455
|
+
* instead of leaving every consumer to re-derive it.
|
|
456
|
+
*
|
|
457
|
+
* Like `exitReason`, this is purely additive: `status`, `exitCode` and
|
|
458
|
+
* `oomKilled` are never changed by it.
|
|
459
|
+
*
|
|
460
|
+
* @param {Object} record - Execution record
|
|
461
|
+
* @param {string|null} logTail - Tail of the execution log
|
|
462
|
+
* @returns {Object} Record, or a copy carrying the memory fields
|
|
463
|
+
*/
|
|
464
|
+
function attachMemoryExhaustion(record, logTail) {
|
|
465
|
+
if (!record || record.status !== 'executed') {
|
|
466
|
+
return record;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const memory = resolveMemoryExhaustion({
|
|
470
|
+
exitCode: record.exitCode,
|
|
471
|
+
logTail,
|
|
472
|
+
oomKilled: record.oomKilled,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (!memory) {
|
|
476
|
+
return record;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const enriched = Object.create(Object.getPrototypeOf(record));
|
|
480
|
+
Object.assign(enriched, record);
|
|
481
|
+
enriched.memoryExhausted = memory.memoryExhausted;
|
|
482
|
+
enriched.memoryExhaustedReason = memory.memoryExhaustedReason;
|
|
483
|
+
return enriched;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Enrich execution record with live session status and exit diagnostics.
|
|
488
|
+
*
|
|
489
|
+
* The tail is read once, with the wider fatal-marker window: a dying runtime
|
|
490
|
+
* prints a native stack trace *after* its `FATAL ERROR` line, which can push
|
|
491
|
+
* the marker out of the narrower footer window (issue #165).
|
|
492
|
+
*
|
|
493
|
+
* @param {Object} record - Execution record
|
|
494
|
+
* @returns {Object} Possibly updated execution record
|
|
495
|
+
*/
|
|
496
|
+
function enrichDetachedStatus(record) {
|
|
497
|
+
const logTail =
|
|
498
|
+
record && record.logPath
|
|
499
|
+
? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
|
|
500
|
+
: null;
|
|
501
|
+
const resolved = resolveDetachedStatus(record, logTail);
|
|
502
|
+
return attachMemoryExhaustion(attachExitReason(resolved, logTail), logTail);
|
|
503
|
+
}
|
|
504
|
+
|
|
372
505
|
/**
|
|
373
506
|
* Wrap a record so its serialized form includes a `currentTime` field when
|
|
374
507
|
* the status is "executing". This reflects the moment `--status` was invoked,
|
|
@@ -509,10 +642,19 @@ function formatRecordAsText(record) {
|
|
|
509
642
|
`UUID: ${obj.uuid}`,
|
|
510
643
|
`Status: ${obj.status}`,
|
|
511
644
|
`Command: ${obj.command}`,
|
|
512
|
-
`Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
|
|
645
|
+
`Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
|
|
513
646
|
...(obj.oomKilled !== undefined
|
|
514
647
|
? [`OOM Killed: ${obj.oomKilled}`]
|
|
515
648
|
: []),
|
|
649
|
+
...(obj.exitReason !== undefined
|
|
650
|
+
? [`Exit Reason: ${obj.exitReason}`]
|
|
651
|
+
: []),
|
|
652
|
+
...(obj.memoryExhausted !== undefined
|
|
653
|
+
? [`Memory Exhausted: ${obj.memoryExhausted}`]
|
|
654
|
+
: []),
|
|
655
|
+
...(obj.memoryExhaustedReason !== undefined
|
|
656
|
+
? [`Memory Evidence: ${obj.memoryExhaustedReason}`]
|
|
657
|
+
: []),
|
|
516
658
|
`PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
|
|
517
659
|
`Working Directory: ${obj.workingDirectory}`,
|
|
518
660
|
`Shell: ${obj.shell}`,
|
|
@@ -522,10 +664,19 @@ function formatRecordAsText(record) {
|
|
|
522
664
|
if (obj.currentTime) {
|
|
523
665
|
lines.push(`Current Time: ${obj.currentTime}`);
|
|
524
666
|
}
|
|
525
|
-
lines.push(
|
|
526
|
-
|
|
527
|
-
`
|
|
528
|
-
|
|
667
|
+
lines.push(`End Time: ${obj.endTime || 'N/A'}`);
|
|
668
|
+
if (obj.endTimeSource) {
|
|
669
|
+
// Where `End Time` came from: a real finish time, or merely when `start`
|
|
670
|
+
// noticed the execution was over (issue #170.2).
|
|
671
|
+
lines.push(`End Time Source: ${obj.endTimeSource}`);
|
|
672
|
+
}
|
|
673
|
+
if (obj.containerStartedAt) {
|
|
674
|
+
lines.push(`Container Started: ${obj.containerStartedAt}`);
|
|
675
|
+
}
|
|
676
|
+
if (obj.staleDetectedAt) {
|
|
677
|
+
lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
|
|
678
|
+
}
|
|
679
|
+
lines.push(`Log Path: ${obj.logPath}`);
|
|
529
680
|
|
|
530
681
|
// Format options as nested list instead of JSON
|
|
531
682
|
const optionEntries = Object.entries(obj.options || {}).filter(
|
|
@@ -677,17 +828,25 @@ function queryStatus(store, identifier, outputFormat) {
|
|
|
677
828
|
* Handle execution list query and output the result
|
|
678
829
|
* @param {Object} store - ExecutionStore instance
|
|
679
830
|
* @param {string|null} outputFormat - Output format (links-notation, json, text)
|
|
831
|
+
* @param {Object} [options] - List options
|
|
832
|
+
* @param {boolean} [options.running] - Only list executions that are still running
|
|
680
833
|
* @returns {{success: boolean, output?: string, error?: string}}
|
|
681
834
|
*/
|
|
682
|
-
function listExecutions(store, outputFormat) {
|
|
835
|
+
function listExecutions(store, outputFormat, options = {}) {
|
|
683
836
|
if (!store) {
|
|
684
837
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
685
838
|
}
|
|
686
839
|
|
|
687
840
|
try {
|
|
688
|
-
const
|
|
841
|
+
const enriched = sortRecordsByStartTimeDesc(
|
|
689
842
|
store.getAll().map(prepareRecordForList)
|
|
690
843
|
);
|
|
844
|
+
// --running reports the reconciled status, not the stored one: a record
|
|
845
|
+
// whose session already died is listed as executed even if the store still
|
|
846
|
+
// says "executing" (issue #162).
|
|
847
|
+
const records = options.running
|
|
848
|
+
? enriched.filter((record) => record.status === 'executing')
|
|
849
|
+
: enriched;
|
|
691
850
|
return {
|
|
692
851
|
success: true,
|
|
693
852
|
output: formatRecordList(records, outputFormat || 'links-notation'),
|
|
@@ -708,8 +867,13 @@ module.exports = {
|
|
|
708
867
|
listExecutions,
|
|
709
868
|
isDetachedSessionAlive,
|
|
710
869
|
enrichDetachedStatus,
|
|
870
|
+
parseFooterFromTail,
|
|
871
|
+
parseFooterTimestamp,
|
|
711
872
|
readExitCodeFromLog,
|
|
712
873
|
readLogTail,
|
|
874
|
+
attachExitReason,
|
|
875
|
+
attachMemoryExhaustion,
|
|
876
|
+
resolveDetachedStatus,
|
|
713
877
|
attachCurrentTime,
|
|
714
878
|
attachProcessIds,
|
|
715
879
|
};
|
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 [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
|
|
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
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
6
|
--isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
@@ -28,9 +28,15 @@ Options:
|
|
|
28
28
|
--use-command-stream Use command-stream library for execution (experimental)
|
|
29
29
|
--status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
|
|
30
30
|
--list List all tracked executions (--output-format: links-notation|json|text)
|
|
31
|
+
--running With --list, only report executions that are still running
|
|
31
32
|
--upload-log <id> Upload the stored log for an execution UUID or session name
|
|
32
33
|
--stop <id> Ask a detached isolated execution to stop gracefully
|
|
33
34
|
--terminate <id> Terminate a detached isolated execution immediately
|
|
35
|
+
--attach <id> Attach to a running detached isolated execution
|
|
36
|
+
--read-only With --attach, follow output without sending input
|
|
37
|
+
--resume <id> Restart a stopped detached execution in the same environment
|
|
38
|
+
(append -- <command> to run a different command there)
|
|
39
|
+
--resume-all Re-attach or reconcile every execution still marked running
|
|
34
40
|
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
35
41
|
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
36
42
|
--version, -v Show version information
|
|
@@ -54,6 +60,12 @@ Examples:
|
|
|
54
60
|
$ --upload-log my-screen-session # Upload stored execution log
|
|
55
61
|
$ --stop my-screen-session # Ask detached execution to stop gracefully
|
|
56
62
|
$ --terminate my-screen-session # Terminate detached execution immediately
|
|
63
|
+
$ --list --running # Only executions that are still running
|
|
64
|
+
$ --attach my-docker-session # Re-enter a running detached session
|
|
65
|
+
$ --attach my-docker-session --read-only # Follow its output without stdin
|
|
66
|
+
$ --resume my-docker-session # Restart the stored command in the same container
|
|
67
|
+
$ --resume my-docker-session -- bash # Run a different command in the same container
|
|
68
|
+
$ --resume-all # Re-attach every still-running execution
|
|
57
69
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
58
70
|
console.log('');
|
|
59
71
|
console.log('Piping with $:');
|
package/src/lib/user-manager.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { execSync, spawnSync } = require('child_process');
|
|
11
|
+
const { randomInt } = require('crypto');
|
|
11
12
|
|
|
12
13
|
// Debug mode from environment
|
|
13
14
|
const DEBUG =
|
|
@@ -73,6 +74,19 @@ function groupExists(groupname) {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Generate `length` cryptographically random base36 characters.
|
|
79
|
+
* @param {number} length
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function randomBase36(length) {
|
|
83
|
+
let value = '';
|
|
84
|
+
while (value.length < length) {
|
|
85
|
+
value += randomInt(36).toString(36);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
/**
|
|
77
91
|
* Generate a unique username for isolation
|
|
78
92
|
* @param {string} [prefix='start'] - Prefix for the username
|
|
@@ -80,7 +94,10 @@ function groupExists(groupname) {
|
|
|
80
94
|
*/
|
|
81
95
|
function generateIsolatedUsername(prefix = 'start') {
|
|
82
96
|
const timestamp = Date.now().toString(36);
|
|
83
|
-
|
|
97
|
+
// A CSPRNG, not Math.random: this suffix is the only unguessable part of the
|
|
98
|
+
// name of a user account that other processes may race to create
|
|
99
|
+
// (CodeQL js/insecure-randomness).
|
|
100
|
+
const random = randomBase36(4);
|
|
84
101
|
// Keep username short (max 32 chars on most systems)
|
|
85
102
|
// and valid (only alphanumeric, hyphen, underscore)
|
|
86
103
|
return `${prefix}-${timestamp}${random}`.substring(0, 31);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the --attach/--resume/--resume-all/--running options (issue #162)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { test, describe } = require('node:test');
|
|
6
|
+
const assert = require('node:assert');
|
|
7
|
+
const { parseArgs, validateOptions } = require('../src/lib/args-parser');
|
|
8
|
+
|
|
9
|
+
function parse(args) {
|
|
10
|
+
const result = parseArgs(args);
|
|
11
|
+
validateOptions(result.wrapperOptions);
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('--attach', () => {
|
|
16
|
+
test('accepts a session identifier as a separate argument', () => {
|
|
17
|
+
const { wrapperOptions } = parse(['--attach', 'my-session']);
|
|
18
|
+
assert.strictEqual(wrapperOptions.attach, 'my-session');
|
|
19
|
+
assert.strictEqual(wrapperOptions.readOnly, false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('accepts the --attach=<id> form', () => {
|
|
23
|
+
const { wrapperOptions } = parse([
|
|
24
|
+
'--attach=dd1acfbe-3c01-4ffa-8c78-f825457f5813',
|
|
25
|
+
]);
|
|
26
|
+
assert.strictEqual(
|
|
27
|
+
wrapperOptions.attach,
|
|
28
|
+
'dd1acfbe-3c01-4ffa-8c78-f825457f5813'
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('supports --read-only', () => {
|
|
33
|
+
const { wrapperOptions } = parse(['--attach', 'my-session', '--read-only']);
|
|
34
|
+
assert.strictEqual(wrapperOptions.attach, 'my-session');
|
|
35
|
+
assert.strictEqual(wrapperOptions.readOnly, true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects --read-only without --attach', () => {
|
|
39
|
+
assert.throws(() => {
|
|
40
|
+
parse(['--read-only', '--status', 'my-session']);
|
|
41
|
+
}, /--read-only option is only valid with --attach/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('requires an identifier argument', () => {
|
|
45
|
+
assert.throws(() => {
|
|
46
|
+
parseArgs(['--attach']);
|
|
47
|
+
}, /Option --attach requires a UUID or session name argument/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('rejects an empty --attach= value', () => {
|
|
51
|
+
assert.throws(() => {
|
|
52
|
+
parseArgs(['--attach=']);
|
|
53
|
+
}, /Option --attach requires a UUID or session name argument/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('--resume', () => {
|
|
58
|
+
test('accepts a session identifier', () => {
|
|
59
|
+
const { wrapperOptions, command } = parse(['--resume', 'my-session']);
|
|
60
|
+
assert.strictEqual(wrapperOptions.resume, 'my-session');
|
|
61
|
+
assert.strictEqual(command, '');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('accepts a replacement command after the -- separator', () => {
|
|
65
|
+
const { wrapperOptions, command, rawCommand } = parse([
|
|
66
|
+
'--resume',
|
|
67
|
+
'my-session',
|
|
68
|
+
'--',
|
|
69
|
+
'echo',
|
|
70
|
+
'hello world',
|
|
71
|
+
]);
|
|
72
|
+
assert.strictEqual(wrapperOptions.resume, 'my-session');
|
|
73
|
+
// Quoted argv elements stay quoted so the inner shell keeps the
|
|
74
|
+
// boundaries the user typed (issue #164).
|
|
75
|
+
assert.strictEqual(command, "echo 'hello world'");
|
|
76
|
+
assert.deepStrictEqual(rawCommand, ['echo', 'hello world']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('accepts the --resume=<id> form with a replacement command', () => {
|
|
80
|
+
const { wrapperOptions, rawCommand } = parse([
|
|
81
|
+
'--resume=my-session',
|
|
82
|
+
'--',
|
|
83
|
+
'npm',
|
|
84
|
+
'test',
|
|
85
|
+
]);
|
|
86
|
+
assert.strictEqual(wrapperOptions.resume, 'my-session');
|
|
87
|
+
assert.deepStrictEqual(rawCommand, ['npm', 'test']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('supports --output-format', () => {
|
|
91
|
+
const { wrapperOptions } = parse([
|
|
92
|
+
'--resume',
|
|
93
|
+
'my-session',
|
|
94
|
+
'--output-format',
|
|
95
|
+
'json',
|
|
96
|
+
]);
|
|
97
|
+
assert.strictEqual(wrapperOptions.outputFormat, 'json');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('--resume-all', () => {
|
|
102
|
+
test('is a boolean flag', () => {
|
|
103
|
+
const { wrapperOptions } = parse(['--resume-all']);
|
|
104
|
+
assert.strictEqual(wrapperOptions.resumeAll, true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('supports --output-format', () => {
|
|
108
|
+
const { wrapperOptions } = parse([
|
|
109
|
+
'--resume-all',
|
|
110
|
+
'--output-format',
|
|
111
|
+
'json',
|
|
112
|
+
]);
|
|
113
|
+
assert.strictEqual(wrapperOptions.resumeAll, true);
|
|
114
|
+
assert.strictEqual(wrapperOptions.outputFormat, 'json');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('--list --running', () => {
|
|
119
|
+
test('sets the running filter', () => {
|
|
120
|
+
const { wrapperOptions } = parse(['--list', '--running']);
|
|
121
|
+
assert.strictEqual(wrapperOptions.list, true);
|
|
122
|
+
assert.strictEqual(wrapperOptions.running, true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('rejects --running without --list', () => {
|
|
126
|
+
assert.throws(() => {
|
|
127
|
+
parse(['--running']);
|
|
128
|
+
}, /--running option is only valid with --list/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('query mode exclusivity', () => {
|
|
133
|
+
const combinations = [
|
|
134
|
+
['--attach', 'a', '--resume', 'b'],
|
|
135
|
+
['--attach', 'a', '--status', 'b'],
|
|
136
|
+
['--resume', 'a', '--terminate', 'b'],
|
|
137
|
+
['--resume-all', '--list'],
|
|
138
|
+
['--resume-all', '--cleanup'],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
for (const args of combinations) {
|
|
142
|
+
test(`rejects ${args.join(' ')}`, () => {
|
|
143
|
+
assert.throws(() => {
|
|
144
|
+
parse(args);
|
|
145
|
+
}, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('defaults', () => {
|
|
151
|
+
test('are all inactive without query options', () => {
|
|
152
|
+
const { wrapperOptions } = parse(['echo', 'hi']);
|
|
153
|
+
assert.strictEqual(wrapperOptions.attach, null);
|
|
154
|
+
assert.strictEqual(wrapperOptions.readOnly, false);
|
|
155
|
+
assert.strictEqual(wrapperOptions.resume, null);
|
|
156
|
+
assert.strictEqual(wrapperOptions.resumeAll, false);
|
|
157
|
+
assert.strictEqual(wrapperOptions.running, false);
|
|
158
|
+
});
|
|
159
|
+
});
|