start-command 0.32.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +130 -57
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +52 -0
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
package/src/lib/args-parser.js
CHANGED
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
29
29
|
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
30
30
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
31
|
-
* --list
|
|
31
|
+
* --list [--running] List all (or only running) tracked command executions
|
|
32
32
|
* --upload-log <uuid-or-session> Upload the stored log for a tracked execution
|
|
33
33
|
* --output-format <format> Output format for status/list (links-notation, json, text)
|
|
34
34
|
* --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
|
|
35
35
|
* --terminate <uuid-or-session-name> Terminate a detached execution immediately
|
|
36
|
+
* --attach <uuid-or-session-name> Attach to a running detached session terminal
|
|
37
|
+
* --read-only Follow --attach output without forwarding stdin
|
|
38
|
+
* --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
|
|
39
|
+
* --resume-all Re-attach or reconcile every execution still marked running
|
|
36
40
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
37
41
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
38
42
|
*/
|
|
@@ -40,6 +44,13 @@
|
|
|
40
44
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
41
45
|
const dockerNetworkOptions = require('./docker-network-options');
|
|
42
46
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
47
|
+
const { buildCommandString } = require('./shell-utils');
|
|
48
|
+
const {
|
|
49
|
+
VALID_OUTPUT_FORMATS,
|
|
50
|
+
createQueryOptionDefaults,
|
|
51
|
+
parseQueryOption,
|
|
52
|
+
validateQueryOptions,
|
|
53
|
+
} = require('./args-parser-queries');
|
|
43
54
|
|
|
44
55
|
// Debug mode from environment
|
|
45
56
|
const DEBUG =
|
|
@@ -60,15 +71,6 @@ const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
|
|
|
60
71
|
*/
|
|
61
72
|
const MAX_ISOLATION_DEPTH = 7;
|
|
62
73
|
|
|
63
|
-
/**
|
|
64
|
-
* Valid output formats for --status
|
|
65
|
-
*/
|
|
66
|
-
const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
|
|
67
|
-
|
|
68
|
-
function hasValue(value) {
|
|
69
|
-
return value !== null && value !== undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
74
|
/**
|
|
73
75
|
* UUID v4 regex pattern for validation
|
|
74
76
|
*/
|
|
@@ -89,18 +91,25 @@ function isValidUUID(str) {
|
|
|
89
91
|
* @returns {string} A new UUID v4 string
|
|
90
92
|
*/
|
|
91
93
|
function generateUUID() {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const crypto = require('crypto');
|
|
94
|
+
const crypto = require('crypto');
|
|
95
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
95
96
|
return crypto.randomUUID();
|
|
96
|
-
} catch {
|
|
97
|
-
// Fallback for environments without crypto.randomUUID
|
|
98
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
99
|
-
const r = (Math.random() * 16) | 0;
|
|
100
|
-
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
101
|
-
return v.toString(16);
|
|
102
|
-
});
|
|
103
97
|
}
|
|
98
|
+
|
|
99
|
+
// Fallback for runtimes without crypto.randomUUID (Node < 14.17). Still a
|
|
100
|
+
// CSPRNG: session ids name isolation sessions and users, so a predictable
|
|
101
|
+
// generator is a security bug, not a cosmetic one (CodeQL js/insecure-randomness).
|
|
102
|
+
const bytes = crypto.randomBytes(16);
|
|
103
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
104
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
|
105
|
+
const hex = bytes.toString('hex');
|
|
106
|
+
return [
|
|
107
|
+
hex.slice(0, 8),
|
|
108
|
+
hex.slice(8, 12),
|
|
109
|
+
hex.slice(12, 16),
|
|
110
|
+
hex.slice(16, 20),
|
|
111
|
+
hex.slice(20),
|
|
112
|
+
].join('-');
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/**
|
|
@@ -164,6 +173,8 @@ function parseEndpointValue(value, options) {
|
|
|
164
173
|
* Parse command line arguments into wrapper options and command
|
|
165
174
|
* @param {string[]} args - Array of command line arguments
|
|
166
175
|
* @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
|
|
176
|
+
* `command` re-quotes each argv element so the inner shell sees the argument
|
|
177
|
+
* boundaries the user typed (issue #164); `rawCommand` keeps the raw argv.
|
|
167
178
|
*/
|
|
168
179
|
function parseArgs(args) {
|
|
169
180
|
const wrapperOptions = {
|
|
@@ -195,14 +206,7 @@ function parseArgs(args) {
|
|
|
195
206
|
keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
|
|
196
207
|
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
197
208
|
useCommandStream: false, // Use command-stream library for command execution
|
|
198
|
-
|
|
199
|
-
list: false, // List all tracked execution records
|
|
200
|
-
uploadLog: null, // UUID/session name whose stored log should be uploaded
|
|
201
|
-
outputFormat: null, // Output format for status/list (links-notation, json, text)
|
|
202
|
-
stop: null, // UUID/session name to stop gracefully
|
|
203
|
-
terminate: null, // UUID/session name to terminate immediately
|
|
204
|
-
cleanup: false, // Clean up stale "executing" records
|
|
205
|
-
cleanupDryRun: false, // Show what would be cleaned without actually cleaning
|
|
209
|
+
...createQueryOptionDefaults(),
|
|
206
210
|
};
|
|
207
211
|
|
|
208
212
|
let commandArgs = [];
|
|
@@ -241,7 +245,7 @@ function parseArgs(args) {
|
|
|
241
245
|
|
|
242
246
|
return {
|
|
243
247
|
wrapperOptions,
|
|
244
|
-
command: commandArgs
|
|
248
|
+
command: buildCommandString(commandArgs),
|
|
245
249
|
rawCommand: commandArgs,
|
|
246
250
|
};
|
|
247
251
|
}
|
|
@@ -529,125 +533,10 @@ function parseOption(args, index, options) {
|
|
|
529
533
|
return 1;
|
|
530
534
|
}
|
|
531
535
|
|
|
532
|
-
// --status
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return 2;
|
|
537
|
-
} else {
|
|
538
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// --status=<value>
|
|
543
|
-
if (arg.startsWith('--status=')) {
|
|
544
|
-
const value = arg.slice('--status='.length);
|
|
545
|
-
if (!value) {
|
|
546
|
-
throw new Error(
|
|
547
|
-
`Option --status requires a UUID or session name argument`
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
options.status = value;
|
|
551
|
-
return 1;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// --upload-log <uuid-or-session-name>
|
|
555
|
-
if (arg === '--upload-log') {
|
|
556
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
557
|
-
options.uploadLog = args[index + 1];
|
|
558
|
-
return 2;
|
|
559
|
-
} else {
|
|
560
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// --upload-log=<value>
|
|
565
|
-
if (arg.startsWith('--upload-log=')) {
|
|
566
|
-
const value = arg.slice('--upload-log='.length);
|
|
567
|
-
if (!value) {
|
|
568
|
-
throw new Error(
|
|
569
|
-
`Option --upload-log requires a UUID or session name argument`
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
options.uploadLog = value;
|
|
573
|
-
return 1;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// --stop <uuid-or-session-name>
|
|
577
|
-
if (arg === '--stop') {
|
|
578
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
579
|
-
options.stop = args[index + 1];
|
|
580
|
-
return 2;
|
|
581
|
-
} else {
|
|
582
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// --stop=<value>
|
|
587
|
-
if (arg.startsWith('--stop=')) {
|
|
588
|
-
const value = arg.slice('--stop='.length);
|
|
589
|
-
if (!value) {
|
|
590
|
-
throw new Error(`Option --stop requires a UUID or session name argument`);
|
|
591
|
-
}
|
|
592
|
-
options.stop = value;
|
|
593
|
-
return 1;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// --terminate <uuid-or-session-name>
|
|
597
|
-
if (arg === '--terminate') {
|
|
598
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
599
|
-
options.terminate = args[index + 1];
|
|
600
|
-
return 2;
|
|
601
|
-
} else {
|
|
602
|
-
throw new Error(`Option ${arg} requires a UUID or session name argument`);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// --terminate=<value>
|
|
607
|
-
if (arg.startsWith('--terminate=')) {
|
|
608
|
-
const value = arg.slice('--terminate='.length);
|
|
609
|
-
if (!value) {
|
|
610
|
-
throw new Error(
|
|
611
|
-
`Option --terminate requires a UUID or session name argument`
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
options.terminate = value;
|
|
615
|
-
return 1;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// --list
|
|
619
|
-
if (arg === '--list') {
|
|
620
|
-
options.list = true;
|
|
621
|
-
return 1;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// --output-format <format>
|
|
625
|
-
if (arg === '--output-format') {
|
|
626
|
-
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
627
|
-
options.outputFormat = args[index + 1].toLowerCase();
|
|
628
|
-
return 2;
|
|
629
|
-
} else {
|
|
630
|
-
throw new Error(`Option ${arg} requires a format argument`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// --output-format=<value>
|
|
635
|
-
if (arg.startsWith('--output-format=')) {
|
|
636
|
-
options.outputFormat = arg.split('=')[1].toLowerCase();
|
|
637
|
-
return 1;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// --cleanup
|
|
641
|
-
if (arg === '--cleanup') {
|
|
642
|
-
options.cleanup = true;
|
|
643
|
-
return 1;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// --cleanup-dry-run
|
|
647
|
-
if (arg === '--cleanup-dry-run') {
|
|
648
|
-
options.cleanup = true;
|
|
649
|
-
options.cleanupDryRun = true;
|
|
650
|
-
return 1;
|
|
536
|
+
// Query/control options (--status, --list, --attach, --resume, ...)
|
|
537
|
+
const queryConsumed = parseQueryOption(args, index, options);
|
|
538
|
+
if (queryConsumed > 0) {
|
|
539
|
+
return queryConsumed;
|
|
651
540
|
}
|
|
652
541
|
|
|
653
542
|
// Not a recognized wrapper option
|
|
@@ -889,37 +778,7 @@ function validateOptions(options) {
|
|
|
889
778
|
throw new Error('--keep-user option is only valid with --isolated-user');
|
|
890
779
|
}
|
|
891
780
|
|
|
892
|
-
|
|
893
|
-
if (hasValue(options.outputFormat)) {
|
|
894
|
-
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
|
|
895
|
-
throw new Error(
|
|
896
|
-
`Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Query/control modes are mutually exclusive
|
|
902
|
-
const queryModes = [
|
|
903
|
-
hasValue(options.status),
|
|
904
|
-
options.list,
|
|
905
|
-
hasValue(options.uploadLog),
|
|
906
|
-
hasValue(options.stop),
|
|
907
|
-
hasValue(options.terminate),
|
|
908
|
-
options.cleanup,
|
|
909
|
-
].filter(Boolean).length;
|
|
910
|
-
|
|
911
|
-
if (queryModes > 1) {
|
|
912
|
-
throw new Error(
|
|
913
|
-
'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
|
|
914
|
-
);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Output format is only valid with read-only query modes
|
|
918
|
-
if (options.outputFormat && !options.status && !options.list) {
|
|
919
|
-
throw new Error(
|
|
920
|
-
'--output-format option is only valid with --status or --list'
|
|
921
|
-
);
|
|
922
|
-
}
|
|
781
|
+
validateQueryOptions(options);
|
|
923
782
|
|
|
924
783
|
// Validate shell option
|
|
925
784
|
if (options.shell !== null && options.shell !== undefined) {
|
|
@@ -2,8 +2,11 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
2
2
|
const {
|
|
3
3
|
appendLogFile,
|
|
4
4
|
createShellLogFooterSnippet,
|
|
5
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
6
|
+
readLogTail,
|
|
5
7
|
shellQuote,
|
|
6
8
|
} = require('./isolation-log-utils');
|
|
9
|
+
const { resolveMemoryExhaustion } = require('./exit-reason');
|
|
7
10
|
|
|
8
11
|
const DOCKER_CONTAINER_CLEANUP_POLICY = {
|
|
9
12
|
DEFAULT: 'default',
|
|
@@ -56,7 +59,9 @@ function shouldCleanupDockerContainer(policy, exitCode, oomKilled = false) {
|
|
|
56
59
|
function getDockerContainerCleanupInstructions(containerName) {
|
|
57
60
|
return [
|
|
58
61
|
`Container kept for investigation: ${containerName}`,
|
|
59
|
-
`
|
|
62
|
+
`Re-enter while running: $ --attach ${containerName}`,
|
|
63
|
+
`Continue the stored command: $ --resume ${containerName}`,
|
|
64
|
+
`Run another command in the same container: $ --resume ${containerName} -- <command>`,
|
|
60
65
|
`Remove when done: docker rm -f ${containerName}`,
|
|
61
66
|
].join('\n');
|
|
62
67
|
}
|
|
@@ -142,14 +147,80 @@ function removeDockerContainer(containerName, logPath = null) {
|
|
|
142
147
|
return !result.error && result.status === 0;
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Exit codes of a process that aborted itself: SIGABRT (134) and SIGSEGV (139).
|
|
152
|
+
* These are exactly the codes a runtime produces when it dies on its own memory
|
|
153
|
+
* limit — Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts — long
|
|
154
|
+
* before the container limit is reached, so the kernel never OOM-kills anything
|
|
155
|
+
* and `State.OOMKilled` stays `false` (issue #165).
|
|
156
|
+
*/
|
|
157
|
+
const SELF_ABORT_EXIT_CODES = [134, 139];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Appended to the kept-container reason for a self-abort exit code, so the
|
|
161
|
+
* footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
|
|
162
|
+
* above it. The footer is the string downstream tooling greps.
|
|
163
|
+
*/
|
|
164
|
+
const OOM_FLAG_BLIND_NOTE =
|
|
165
|
+
'a runtime self-abort on its own memory limit is invisible to this flag - ' +
|
|
166
|
+
'check the log above for a fatal memory marker';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Shell fragment computing `$__start_command_reason` for the kept footer.
|
|
170
|
+
* @returns {string} Shell command
|
|
171
|
+
*/
|
|
172
|
+
function buildDockerKeptReasonSnippet() {
|
|
173
|
+
return (
|
|
174
|
+
'__start_command_reason="exitCode=$__start_command_exit oomKilled=$__start_command_oom"; ' +
|
|
175
|
+
`case "$__start_command_exit" in ${SELF_ABORT_EXIT_CODES.join('|')}) ` +
|
|
176
|
+
'[ "$__start_command_oom" = true ] || ' +
|
|
177
|
+
`__start_command_reason="$__start_command_reason (${OOM_FLAG_BLIND_NOTE})";; ` +
|
|
178
|
+
'esac'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Lines appended to an attached session's message when the container is kept
|
|
184
|
+
* because the command failed. A runtime that aborts on its own memory limit
|
|
185
|
+
* never trips `State.OOMKilled`, so a bare `oomKilled false` would contradict
|
|
186
|
+
* the `FATAL ERROR` the runtime just printed into this very log (issue #165).
|
|
187
|
+
* Best effort: the tail is read right after the child exits.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} params - Container name, exit code, OOM flag and log path
|
|
190
|
+
* @returns {string} Message lines to append
|
|
191
|
+
*/
|
|
192
|
+
function buildAttachedDockerKeptMessage({
|
|
193
|
+
containerName,
|
|
194
|
+
exitCode,
|
|
195
|
+
oomKilled,
|
|
196
|
+
logPath,
|
|
197
|
+
}) {
|
|
198
|
+
let message =
|
|
199
|
+
oomKilled === true
|
|
200
|
+
? `\nContainer kept because Docker reports it was OOM-killed.`
|
|
201
|
+
: `\nContainer kept because the command failed.`;
|
|
202
|
+
const memory = resolveMemoryExhaustion({
|
|
203
|
+
exitCode,
|
|
204
|
+
logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
|
|
205
|
+
oomKilled,
|
|
206
|
+
});
|
|
207
|
+
if (memory) {
|
|
208
|
+
message += `\nMemory exhaustion detected in the log: ${memory.memoryExhaustedReason}`;
|
|
209
|
+
}
|
|
210
|
+
return `${message}\nRemove when done: docker rm -f ${containerName}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
|
|
146
214
|
const quotedName = shellQuote(containerName);
|
|
147
215
|
return (
|
|
148
|
-
|
|
149
|
-
`
|
|
216
|
+
`${buildDockerKeptReasonSnippet()}; ` +
|
|
217
|
+
`printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
|
|
218
|
+
`Re-enter while running: $ --attach %s\\n` +
|
|
219
|
+
`Continue the stored command: $ --resume %s\\n` +
|
|
220
|
+
`Run another command in the same container: $ --resume %s -- <command>\\n` +
|
|
150
221
|
`Remove when done: docker rm -f %s\\n' ` +
|
|
151
|
-
`${quotedName} "$
|
|
152
|
-
`${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
|
|
222
|
+
`${quotedName} "$__start_command_reason" ` +
|
|
223
|
+
`${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
|
|
153
224
|
);
|
|
154
225
|
}
|
|
155
226
|
|
|
@@ -244,7 +315,11 @@ function spawnAttachedDocker(dockerArgs, logPath) {
|
|
|
244
315
|
}
|
|
245
316
|
|
|
246
317
|
module.exports = {
|
|
318
|
+
buildAttachedDockerKeptMessage,
|
|
247
319
|
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
320
|
+
SELF_ABORT_EXIT_CODES,
|
|
321
|
+
OOM_FLAG_BLIND_NOTE,
|
|
322
|
+
buildDockerKeptReasonSnippet,
|
|
248
323
|
getDockerCommand,
|
|
249
324
|
getDockerSpawnOptions,
|
|
250
325
|
getDockerContainerCleanupPolicy,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach to a tracked detached execution (issue #162).
|
|
3
|
+
*
|
|
4
|
+
* `--stop`/`--terminate` can already address a stored session name; `--attach`
|
|
5
|
+
* completes the set by re-entering the session's terminal instead of only
|
|
6
|
+
* tailing its log. `--attach --read-only` follows the output without
|
|
7
|
+
* forwarding stdin, which is the safe default for supervisors that must not
|
|
8
|
+
* accidentally type into a long-running agent session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const { escapeForLinksNotation } = require('./output-blocks');
|
|
13
|
+
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
14
|
+
const { runCommand } = require('./execution-control');
|
|
15
|
+
const { SessionState, probeSession } = require('./session-probe');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run a command with the parent terminal attached (stdin included unless the
|
|
19
|
+
* plan is read-only). Used for `screen -r`, `tmux attach-session`,
|
|
20
|
+
* `docker attach`, and log following.
|
|
21
|
+
* @param {string} command - Executable
|
|
22
|
+
* @param {string[]} args - Arguments
|
|
23
|
+
* @param {object} plan - Attach plan (interactive flag)
|
|
24
|
+
* @returns {{success: boolean, status: ?number, error: ?string}}
|
|
25
|
+
*/
|
|
26
|
+
function runInteractive(command, args, plan = {}) {
|
|
27
|
+
const options = {
|
|
28
|
+
stdio: plan.interactive ? 'inherit' : ['ignore', 'inherit', 'inherit'],
|
|
29
|
+
};
|
|
30
|
+
const result = spawnSync(
|
|
31
|
+
command,
|
|
32
|
+
args,
|
|
33
|
+
command === getDockerCommand() ? getDockerSpawnOptions(options) : options
|
|
34
|
+
);
|
|
35
|
+
return {
|
|
36
|
+
success: !result.error && result.status === 0,
|
|
37
|
+
status: result.status,
|
|
38
|
+
error: result.error ? result.error.message : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function logFollowPlan(record, backend, sessionName) {
|
|
43
|
+
if (!record.logPath) {
|
|
44
|
+
return {
|
|
45
|
+
error: `Execution "${record.uuid}" has no stored log to follow.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
backend,
|
|
50
|
+
sessionName,
|
|
51
|
+
command: 'tail',
|
|
52
|
+
args: ['-f', record.logPath],
|
|
53
|
+
interactive: false,
|
|
54
|
+
method: 'LOG_FOLLOW',
|
|
55
|
+
message: `Following stored log for ${backend} session: ${sessionName}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function notRunningError(record, probe) {
|
|
60
|
+
const sessionName = probe.sessionName;
|
|
61
|
+
const uuid = record.uuid;
|
|
62
|
+
if (probe.state === SessionState.MISSING) {
|
|
63
|
+
return {
|
|
64
|
+
error:
|
|
65
|
+
`Session "${sessionName}" no longer exists, so there is nothing to attach to. ` +
|
|
66
|
+
`Use \`$ --resume ${uuid}\` to start it again, or \`$ --status ${uuid}\` to inspect the recorded result.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
error:
|
|
71
|
+
`Session "${sessionName}" is not running (state: ${probe.containerStatus || probe.state}). ` +
|
|
72
|
+
`Use \`$ --resume ${uuid}\` to continue it in the same container, or \`$ --status ${uuid}\` to inspect the recorded result.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the attach plan for an execution record.
|
|
78
|
+
* @param {object} record - Execution record
|
|
79
|
+
* @param {object} options - {readOnly: boolean, probe: object}
|
|
80
|
+
* @returns {object} Plan with command/args/interactive/method/message, or {error}
|
|
81
|
+
*/
|
|
82
|
+
function buildAttachPlan(record, options = {}) {
|
|
83
|
+
const readOnly = options.readOnly === true;
|
|
84
|
+
const probe = options.probe || {};
|
|
85
|
+
const opts = (record && record.options) || {};
|
|
86
|
+
const backend = opts.isolated;
|
|
87
|
+
const sessionName = opts.sessionName;
|
|
88
|
+
|
|
89
|
+
if (!sessionName) {
|
|
90
|
+
return {
|
|
91
|
+
error: 'Execution record does not contain an isolation session name.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (opts.isolationMode !== 'detached') {
|
|
96
|
+
return {
|
|
97
|
+
error: 'Only detached isolated executions can be attached to.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ssh sessions cannot be probed or re-entered locally: the stored log is the
|
|
102
|
+
// only channel back into them.
|
|
103
|
+
if (backend === 'ssh') {
|
|
104
|
+
return logFollowPlan(record, backend, sessionName);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!probe.alive) {
|
|
108
|
+
return notRunningError(record, { ...probe, sessionName });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (backend) {
|
|
112
|
+
case 'screen':
|
|
113
|
+
return readOnly
|
|
114
|
+
? logFollowPlan(record, backend, sessionName)
|
|
115
|
+
: {
|
|
116
|
+
backend,
|
|
117
|
+
sessionName,
|
|
118
|
+
command: 'screen',
|
|
119
|
+
args: ['-r', sessionName],
|
|
120
|
+
interactive: true,
|
|
121
|
+
method: 'SCREEN_ATTACH',
|
|
122
|
+
message: `Attaching to detached screen session: ${sessionName}`,
|
|
123
|
+
};
|
|
124
|
+
case 'tmux':
|
|
125
|
+
return {
|
|
126
|
+
backend,
|
|
127
|
+
sessionName,
|
|
128
|
+
command: 'tmux',
|
|
129
|
+
args: readOnly
|
|
130
|
+
? ['attach-session', '-r', '-t', sessionName]
|
|
131
|
+
: ['attach-session', '-t', sessionName],
|
|
132
|
+
interactive: true,
|
|
133
|
+
method: readOnly ? 'TMUX_ATTACH_READONLY' : 'TMUX_ATTACH',
|
|
134
|
+
message: `Attaching to detached tmux session: ${sessionName}${readOnly ? ' (read-only)' : ''}`,
|
|
135
|
+
};
|
|
136
|
+
case 'docker':
|
|
137
|
+
return readOnly
|
|
138
|
+
? {
|
|
139
|
+
backend,
|
|
140
|
+
sessionName,
|
|
141
|
+
command: getDockerCommand(),
|
|
142
|
+
args: ['logs', '-f', sessionName],
|
|
143
|
+
interactive: false,
|
|
144
|
+
method: 'DOCKER_LOG_FOLLOW',
|
|
145
|
+
message: `Following logs of detached docker container: ${sessionName}`,
|
|
146
|
+
}
|
|
147
|
+
: {
|
|
148
|
+
backend,
|
|
149
|
+
sessionName,
|
|
150
|
+
command: getDockerCommand(),
|
|
151
|
+
args: ['attach', sessionName],
|
|
152
|
+
interactive: true,
|
|
153
|
+
method: 'DOCKER_ATTACH',
|
|
154
|
+
message: `Attaching to detached docker container: ${sessionName}`,
|
|
155
|
+
};
|
|
156
|
+
default:
|
|
157
|
+
return {
|
|
158
|
+
error: `Attaching to detached ${backend || 'unknown'} executions is not supported.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format an attach result as links notation.
|
|
165
|
+
* @param {object} result - Attach result fields
|
|
166
|
+
* @returns {string} Links notation block
|
|
167
|
+
*/
|
|
168
|
+
function formatAttachResultAsLinksNotation(result) {
|
|
169
|
+
return [
|
|
170
|
+
'executionAttach',
|
|
171
|
+
` identifier ${escapeForLinksNotation(result.identifier)}`,
|
|
172
|
+
` uuid ${escapeForLinksNotation(result.uuid)}`,
|
|
173
|
+
` backend ${escapeForLinksNotation(result.backend)}`,
|
|
174
|
+
` sessionName ${escapeForLinksNotation(result.sessionName)}`,
|
|
175
|
+
` method ${escapeForLinksNotation(result.method)}`,
|
|
176
|
+
` readOnly ${result.readOnly === true}`,
|
|
177
|
+
` command ${escapeForLinksNotation(result.command)}`,
|
|
178
|
+
` message ${escapeForLinksNotation(result.message)}`,
|
|
179
|
+
].join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Attach to a tracked execution by UUID or session name.
|
|
184
|
+
* @param {?object} store - Execution store
|
|
185
|
+
* @param {string} identifier - UUID or session name
|
|
186
|
+
* @param {object} deps - {readOnly, probe, interactiveRunner, runner}
|
|
187
|
+
* @returns {{success: boolean, output?: string, error?: string, exitCode?: number}}
|
|
188
|
+
*/
|
|
189
|
+
function attachExecution(store, identifier, deps = {}) {
|
|
190
|
+
if (!store) {
|
|
191
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const record = store.get(identifier);
|
|
195
|
+
if (!record) {
|
|
196
|
+
return {
|
|
197
|
+
success: false,
|
|
198
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const runner = deps.runner || runCommand;
|
|
203
|
+
const probeFn = deps.probe || ((r) => probeSession(r, runner));
|
|
204
|
+
const plan = buildAttachPlan(record, {
|
|
205
|
+
readOnly: deps.readOnly === true,
|
|
206
|
+
probe: probeFn(record),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (plan.error) {
|
|
210
|
+
return { success: false, error: plan.error };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const interactiveRunner = deps.interactiveRunner || runInteractive;
|
|
214
|
+
const commandLine = [plan.command, ...plan.args].join(' ');
|
|
215
|
+
const runResult = interactiveRunner(plan.command, plan.args, plan);
|
|
216
|
+
|
|
217
|
+
const output = formatAttachResultAsLinksNotation({
|
|
218
|
+
identifier,
|
|
219
|
+
uuid: record.uuid,
|
|
220
|
+
backend: plan.backend,
|
|
221
|
+
sessionName: plan.sessionName,
|
|
222
|
+
method: plan.method,
|
|
223
|
+
readOnly: deps.readOnly === true,
|
|
224
|
+
command: commandLine,
|
|
225
|
+
message: plan.message,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!runResult.success) {
|
|
229
|
+
const detail = runResult.error || `exit code ${runResult.status}`;
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Failed to attach to ${plan.backend} session "${plan.sessionName}": ${detail}`,
|
|
233
|
+
exitCode: typeof runResult.status === 'number' ? runResult.status : 1,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { success: true, output };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
attachExecution,
|
|
242
|
+
buildAttachPlan,
|
|
243
|
+
formatAttachResultAsLinksNotation,
|
|
244
|
+
runInteractive,
|
|
245
|
+
};
|