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.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -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
+ };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `--resume-all`: re-attach or reconcile every execution still marked running.
3
+ *
4
+ * When the supervisor host restarts, the detached docker completion watcher
5
+ * (`docker logs -f ... ; docker inspect ... ; footer`) dies with it. The
6
+ * container keeps running but nothing streams its output into the session log
7
+ * any more, and nothing will ever write the exit footer — the record stays
8
+ * `executing` forever. `--resume-all` repairs that state:
9
+ *
10
+ * - reattached: a live docker container gets a fresh completion watcher.
11
+ * - running: a live screen/tmux session needs nothing (its logging is
12
+ * in-session), it is only reported.
13
+ * - reconciled: the session is gone, so the record is finalized from the same
14
+ * evidence `--status` uses (docker exit code, log footer).
15
+ * - unknown: the backend cannot be probed locally (ssh), left untouched.
16
+ *
17
+ * Commands are never silently restarted here: resuming actual work is an
18
+ * explicit, per-session decision made with `--resume`.
19
+ */
20
+
21
+ const {
22
+ getDockerContainerCleanupPolicy,
23
+ startDetachedDockerCompletionWatcher,
24
+ } = require('./docker-cleanup');
25
+ const { escapeForLinksNotation } = require('./output-blocks');
26
+ const { runCommand } = require('./execution-control');
27
+ const { SessionState, probeSession } = require('./session-probe');
28
+
29
+ /**
30
+ * Outcomes reported for each execution that was still marked running.
31
+ */
32
+ const ResumeAllAction = {
33
+ REATTACHED: 'reattached',
34
+ RUNNING: 'running',
35
+ RECONCILED: 'reconciled',
36
+ UNKNOWN: 'unknown',
37
+ };
38
+
39
+ function formatResumeAllAsLinksNotation(summary) {
40
+ const lines = ['executionResumeAll', ` count ${summary.count}`];
41
+ for (const entry of summary.executions) {
42
+ lines.push(' execution');
43
+ lines.push(` uuid ${escapeForLinksNotation(entry.uuid)}`);
44
+ lines.push(` backend ${escapeForLinksNotation(entry.backend)}`);
45
+ lines.push(` sessionName ${escapeForLinksNotation(entry.sessionName)}`);
46
+ lines.push(` state ${escapeForLinksNotation(entry.state)}`);
47
+ lines.push(` action ${escapeForLinksNotation(entry.action)}`);
48
+ if (entry.exitCode !== null && entry.exitCode !== undefined) {
49
+ lines.push(` exitCode ${entry.exitCode}`);
50
+ }
51
+ lines.push(` message ${escapeForLinksNotation(entry.message)}`);
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+
56
+ function formatResumeAllAsText(summary) {
57
+ if (summary.count === 0) {
58
+ return 'No executions are currently marked as running.';
59
+ }
60
+ const lines = [`Executions still marked running: ${summary.count}`, ''];
61
+ for (const entry of summary.executions) {
62
+ lines.push(`${entry.action.toUpperCase()} ${entry.uuid}`);
63
+ lines.push(` Backend: ${entry.backend}`);
64
+ lines.push(` Session Name: ${entry.sessionName}`);
65
+ lines.push(` State: ${entry.state}`);
66
+ lines.push(` ${entry.message}`);
67
+ lines.push('');
68
+ }
69
+ return lines.join('\n').trimEnd();
70
+ }
71
+
72
+ function formatResumeAll(summary, outputFormat) {
73
+ if (outputFormat === 'json') {
74
+ return JSON.stringify(summary, null, 2);
75
+ }
76
+ if (outputFormat === 'text') {
77
+ return formatResumeAllAsText(summary);
78
+ }
79
+ return formatResumeAllAsLinksNotation(summary);
80
+ }
81
+
82
+ function describeRecord(record, probe, action, message, exitCode = null) {
83
+ return {
84
+ uuid: record.uuid,
85
+ backend:
86
+ probe.backend || (record.options && record.options.isolated) || null,
87
+ sessionName:
88
+ probe.sessionName ||
89
+ (record.options && record.options.sessionName) ||
90
+ null,
91
+ state: probe.state,
92
+ action,
93
+ exitCode,
94
+ message,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Re-attach or reconcile every execution still marked running.
100
+ * @param {?object} store - Execution store
101
+ * @param {object} deps - {outputFormat, probe, runner, startWatcher, reconcile}
102
+ * @returns {{success: boolean, output?: string, error?: string}}
103
+ */
104
+ function resumeAllExecutions(store, deps = {}) {
105
+ if (!store) {
106
+ return { success: false, error: 'Execution tracking is disabled.' };
107
+ }
108
+
109
+ const runner = deps.runner || runCommand;
110
+ const probeFn = deps.probe || ((record) => probeSession(record, runner));
111
+ const startWatcher =
112
+ deps.startWatcher || startDetachedDockerCompletionWatcher;
113
+ const reconcile =
114
+ deps.reconcile || require('./status-formatter').enrichDetachedStatus;
115
+
116
+ let records;
117
+ try {
118
+ records = store.getExecuting();
119
+ } catch (err) {
120
+ return { success: false, error: err.message };
121
+ }
122
+
123
+ const executions = [];
124
+
125
+ for (const record of records) {
126
+ const probe = probeFn(record);
127
+ const sessionName = probe.sessionName;
128
+
129
+ if (probe.alive && probe.backend === 'docker') {
130
+ startWatcher(
131
+ sessionName,
132
+ getDockerContainerCleanupPolicy(record.options || {}),
133
+ record.logPath || null,
134
+ record.uuid || null
135
+ );
136
+ executions.push(
137
+ describeRecord(
138
+ record,
139
+ probe,
140
+ ResumeAllAction.REATTACHED,
141
+ `Re-attached completion watcher to running container: ${sessionName}`
142
+ )
143
+ );
144
+ continue;
145
+ }
146
+
147
+ if (probe.alive) {
148
+ executions.push(
149
+ describeRecord(
150
+ record,
151
+ probe,
152
+ ResumeAllAction.RUNNING,
153
+ `Session is still running: ${sessionName}`
154
+ )
155
+ );
156
+ continue;
157
+ }
158
+
159
+ if (probe.state === SessionState.UNKNOWN) {
160
+ executions.push(
161
+ describeRecord(
162
+ record,
163
+ probe,
164
+ ResumeAllAction.UNKNOWN,
165
+ `Session liveness cannot be probed locally: ${sessionName || record.uuid}`
166
+ )
167
+ );
168
+ continue;
169
+ }
170
+
171
+ const reconciled = reconcile(record);
172
+ if (reconciled && reconciled.status === 'executed') {
173
+ store.save(reconciled);
174
+ executions.push(
175
+ describeRecord(
176
+ record,
177
+ probe,
178
+ ResumeAllAction.RECONCILED,
179
+ `Session ended while unsupervised; record finalized. Use \`$ --resume ${record.uuid}\` to continue it.`,
180
+ reconciled.exitCode
181
+ )
182
+ );
183
+ continue;
184
+ }
185
+
186
+ executions.push(
187
+ describeRecord(
188
+ record,
189
+ probe,
190
+ ResumeAllAction.UNKNOWN,
191
+ `Session is not running but no terminal result could be resolved: ${sessionName || record.uuid}`
192
+ )
193
+ );
194
+ }
195
+
196
+ return {
197
+ success: true,
198
+ output: formatResumeAll(
199
+ { count: executions.length, executions },
200
+ deps.outputFormat
201
+ ),
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ ResumeAllAction,
207
+ formatResumeAll,
208
+ formatResumeAllAsLinksNotation,
209
+ formatResumeAllAsText,
210
+ resumeAllExecutions,
211
+ };