@love-moon/conductor-cli 0.11.2 → 0.13.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.
@@ -1,36 +1,37 @@
1
- #!/usr/bin/env node
2
-
3
1
  /**
4
- * conductor remote-exec — run one command on another daemon's host.
5
- *
6
- * conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
7
- * conductor remote-exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
2
+ * `conductor remote exec` — run one command on another daemon's host.
8
3
  *
9
4
  * The command is sent as argv and spawned without a shell on the target, so
10
5
  * quoting is not re-interpreted remotely. Pass `-- bash -lc "..."` when pipes
11
6
  * or globs are actually wanted.
12
7
  */
13
8
 
14
- import fs from "node:fs";
15
- import path from "node:path";
16
9
  import process from "node:process";
17
- import { fileURLToPath } from "node:url";
18
10
 
19
- import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
20
- import { envForExplicitConfigFile } from "../src/config-env.js";
21
- import { resolveConductorConfigPath } from "../src/conductor-paths.js";
11
+ import {
12
+ EXIT,
13
+ UsageError,
14
+ callApi,
15
+ delay,
16
+ loadCliConfig,
17
+ parseTimeoutMs as parseSharedTimeoutMs,
18
+ withRetry,
19
+ } from "./client.js";
20
+
21
+ export { EXIT, UsageError };
22
22
 
23
- /**
24
- * Following ssh: the remote command's exit code is passed through verbatim
25
- * (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
26
- * local errors would make `grep` finding nothing (1) or `ls` on a missing path
27
- * (2) indistinguishable from a network error or a usage mistake.
28
- */
29
- const EXIT = { OK: 0, CLI_ERROR: 255 };
30
23
  const DEFAULT_TIMEOUT_MS = 60_000;
31
24
  const POLL_INTERVAL_MS = 1_000;
32
25
  /** How long a single request may block server-side before we switch to polling. */
33
26
  const POST_WAIT_MS = 10_000;
27
+ /**
28
+ * A 429 means the per-user in-flight cap (8) is full — nothing was started, so
29
+ * retrying is safe. Six attempts at 500ms doubling waits out ~15s, longer than
30
+ * one saturating POST can block (POST_WAIT_MS). Nothing else is retried here:
31
+ * a 5xx may have started the command already, and a second POST would run it twice.
32
+ */
33
+ const POST_RETRY = { attempts: 6, retryable: (error) => error?.status === 429 };
34
+ const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
34
35
 
35
36
  const VALUE_FLAGS = new Map([
36
37
  ["--target", "target"],
@@ -50,12 +51,6 @@ const BOOL_FLAGS = new Map([
50
51
  ["-h", "help"],
51
52
  ]);
52
53
 
53
- const isMainModule = (() => {
54
- const currentFile = fileURLToPath(import.meta.url);
55
- const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
56
- return entryFile === currentFile;
57
- })();
58
-
59
54
  /**
60
55
  * Split argv into flags and the remote command.
61
56
  *
@@ -116,81 +111,17 @@ export function parseArgs(argv) {
116
111
  return { options, command };
117
112
  }
118
113
 
119
- export class UsageError extends Error {}
120
-
121
114
  /** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
122
115
  export function parseTimeoutMs(value) {
123
- if (value === undefined || value === null || value === "") {
124
- return DEFAULT_TIMEOUT_MS;
125
- }
126
- const raw = String(value).trim().toLowerCase();
127
- const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
128
- if (!match) {
129
- throw new UsageError(`invalid --timeout value: ${value}`);
130
- }
131
- const amount = Number.parseFloat(match[1]);
132
- const unit = match[2] || "s";
133
- const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
134
- const ms = Math.round(amount * multiplier);
135
- if (!Number.isFinite(ms) || ms <= 0) {
136
- throw new UsageError(`invalid --timeout value: ${value}`);
137
- }
138
- return ms;
116
+ return parseSharedTimeoutMs(value, DEFAULT_TIMEOUT_MS);
139
117
  }
140
118
 
141
- function loadCliConfig(configFile, env = process.env) {
142
- const configPath = resolveConductorConfigPath(configFile, env);
143
- const configEnv = envForExplicitConfigFile(configFile, env);
144
- if (fs.existsSync(configPath)) {
145
- return loadConfig(configPath, { env: configEnv });
146
- }
147
-
148
- const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
149
- const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
150
- if (agentToken && backendUrl) {
151
- return new ConductorConfig({ agentToken, backendUrl });
152
- }
153
-
154
- return loadConfig(configPath, { env: configEnv });
155
- }
156
-
157
- async function callApi(config, method, pathname, body, fetchImpl) {
158
- const url = new URL(pathname, config.backendUrl);
159
- const response = await fetchImpl(url.toString(), {
160
- method,
161
- headers: {
162
- Authorization: `Bearer ${config.agentToken}`,
163
- Accept: "application/json",
164
- ...(body ? { "Content-Type": "application/json" } : {}),
165
- },
166
- ...(body ? { body: JSON.stringify(body) } : {}),
167
- });
168
-
169
- const text = await response.text();
170
- let payload = null;
171
- try {
172
- payload = text ? JSON.parse(text) : null;
173
- } catch {
174
- payload = null;
175
- }
176
-
177
- if (!response.ok) {
178
- const message = payload?.error || text.trim() || `HTTP ${response.status}`;
179
- const error = new Error(message);
180
- error.status = response.status;
181
- throw error;
182
- }
183
- return payload;
184
- }
185
-
186
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
187
-
188
119
  export function showHelp(consoleImpl = console) {
189
- consoleImpl.log(`conductor remote-exec - run a command on another daemon's host
120
+ consoleImpl.log(`conductor remote exec - run a command on another daemon's host
190
121
 
191
122
  Usage:
192
- conductor remote-exec --target <daemon> [options] <command> [args...]
193
- conductor remote-exec --target <daemon> [options] -- <command> [args...]
123
+ conductor remote exec --target <daemon> [options] <command> [args...]
124
+ conductor remote exec --target <daemon> [options] -- <command> [args...]
194
125
 
195
126
  Options:
196
127
  -t, --target <daemon> Daemon name to run on (required)
@@ -198,96 +129,46 @@ Options:
198
129
  --timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
199
130
  -e, --env KEY=VALUE Extra environment variable (repeatable)
200
131
  --json Print the raw run result as JSON
201
- --kill-on-timeout Stop the remote command when --timeout is reached
132
+ --kill-on-timeout Stop the remote command when --timeout is reached or
133
+ this CLI is interrupted (Ctrl-C, SIGTERM)
202
134
  (default: it keeps running on the target)
203
135
  --config-file <p> Conductor config file to authenticate with
204
136
  -h, --help Show this help
205
137
 
206
138
  Notes:
207
139
  The command is spawned without a shell. For pipes, globs or redirection use:
208
- conductor remote-exec -t ubuntu -- bash -lc "ls | wc -l"
140
+ conductor remote exec -t ubuntu -- bash -lc "ls | wc -l"
209
141
 
210
142
  Exit codes follow ssh: the remote command's own code is passed through, and
211
143
  255 means this CLI failed (bad usage, daemon offline, network error).
212
144
 
145
+ A command that outlives --timeout keeps running on the target; the run id is
146
+ printed and \`conductor remote wait -t <daemon> <runId>\` picks it up again.
147
+
213
148
  Examples:
214
- conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
215
- conductor remote-exec -t ubuntu -w /srv/app -- git log --oneline -5
149
+ conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
150
+ conductor remote exec -t ubuntu -w /srv/app -- git log --oneline -5
216
151
  `);
217
152
  }
218
153
 
219
- export async function runRemoteExec(argv, deps = {}) {
220
- const consoleImpl = deps.console || console;
221
- const fetchImpl = deps.fetch || globalThis.fetch;
222
- const env = deps.env || process.env;
223
- const sleep = deps.sleep || delay;
224
- const now = deps.now || (() => Date.now());
225
-
226
- let parsed;
227
- try {
228
- parsed = parseArgs(argv);
229
- } catch (error) {
230
- consoleImpl.error(`Error: ${error.message}`);
231
- return EXIT.CLI_ERROR;
232
- }
233
- const { options, command } = parsed;
234
-
235
- if (options.help) {
236
- showHelp(consoleImpl);
237
- return EXIT.OK;
238
- }
239
-
240
- const target = typeof options.target === "string" ? options.target.trim() : "";
241
- if (!target) {
242
- consoleImpl.error("Error: --target <daemon> is required");
243
- showHelp(consoleImpl);
244
- return EXIT.CLI_ERROR;
245
- }
246
- if (command.length === 0) {
247
- consoleImpl.error("Error: no command given");
248
- showHelp(consoleImpl);
249
- return EXIT.CLI_ERROR;
250
- }
251
-
252
- let timeoutMs;
253
- try {
254
- timeoutMs = parseTimeoutMs(options.timeout);
255
- } catch (error) {
256
- consoleImpl.error(`Error: ${error.message}`);
257
- return EXIT.CLI_ERROR;
258
- }
259
-
260
- let config;
261
- try {
262
- config = deps.config || loadCliConfig(options.configFile, env);
263
- } catch (error) {
264
- consoleImpl.error(`Error: ${error.message}`);
265
- return EXIT.CLI_ERROR;
266
- }
267
-
268
- const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
269
- const deadline = now() + timeoutMs;
270
-
271
- let run;
272
- try {
273
- run = await callApi(config, "POST", basePath, {
274
- command: command[0],
275
- args: command.slice(1),
276
- ...(options.workspace ? { workspace: options.workspace } : {}),
277
- ...(Object.keys(options.env).length > 0 ? { env: options.env } : {}),
278
- // Deliberately short, and independent of `--timeout`: the overall deadline
279
- // is owned by the poll loop below. Handing the daemon the full deadline
280
- // would make one HTTP request block for it, and would leave the loop
281
- // unreachable because the POST alone would consume the whole budget.
282
- timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
283
- }, fetchImpl);
284
- } catch (error) {
285
- consoleImpl.error(`Error: ${error.message}`);
286
- return EXIT.CLI_ERROR;
287
- }
288
-
154
+ /**
155
+ * Poll `run` until it leaves `running`, the deadline passes, or `signal`
156
+ * aborts. With `killOnTimeout` a run that is still going at that point is
157
+ * cancelled on the target. Shared by `exec` (after its POST) and `wait`.
158
+ */
159
+ export async function waitForRun(config, basePath, initialRun, options = {}) {
160
+ const {
161
+ deadline,
162
+ fetchImpl = globalThis.fetch,
163
+ sleep = delay,
164
+ now = () => Date.now(),
165
+ killOnTimeout = false,
166
+ signal,
167
+ } = options;
168
+
169
+ let run = initialRun;
289
170
  let pollError = null;
290
- while (run?.status === "running" && now() < deadline) {
171
+ while (run?.status === "running" && now() < deadline && !signal?.aborted) {
291
172
  if (!run.runId) {
292
173
  pollError = new Error("daemon reported a running command but returned no runId");
293
174
  break;
@@ -310,7 +191,8 @@ export async function runRemoteExec(argv, deps = {}) {
310
191
  }
311
192
  }
312
193
 
313
- if (run?.status === "running" && options.killOnTimeout && run.runId) {
194
+ let killError = null;
195
+ if (run?.status === "running" && killOnTimeout && run.runId) {
314
196
  try {
315
197
  run = await callApi(
316
198
  config,
@@ -319,13 +201,91 @@ export async function runRemoteExec(argv, deps = {}) {
319
201
  null,
320
202
  fetchImpl,
321
203
  );
322
- consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
323
204
  } catch (error) {
324
- consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${error.message}`);
205
+ // Failing to stop it must not cost the caller the output we already have.
206
+ killError = error;
325
207
  }
326
208
  }
327
209
 
328
- if (options.json) {
210
+ return { run, pollError, killError };
211
+ }
212
+
213
+ /**
214
+ * Run one command on `target` and return the final run record.
215
+ *
216
+ * This is the programmatic half of `runRemoteExec`: same two-phase POST-then-poll
217
+ * behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
218
+ * `tar` on the target.
219
+ */
220
+ export async function execRemote(config, target, command, options = {}) {
221
+ const {
222
+ args = [],
223
+ workspace,
224
+ env,
225
+ timeoutMs = DEFAULT_TIMEOUT_MS,
226
+ fetchImpl = globalThis.fetch,
227
+ sleep = delay,
228
+ now = () => Date.now(),
229
+ killOnTimeout = false,
230
+ signal,
231
+ } = options;
232
+
233
+ const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
234
+ const deadline = now() + timeoutMs;
235
+
236
+ const started = await withRetry(() => callApi(config, "POST", basePath, {
237
+ command,
238
+ args,
239
+ ...(workspace ? { workspace } : {}),
240
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
241
+ // Deliberately short, and independent of the overall deadline: that is
242
+ // owned by the poll loop below. Handing the daemon the full budget would
243
+ // make one HTTP request block for it and leave the loop unreachable.
244
+ timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
245
+ }, fetchImpl), { ...POST_RETRY, sleep });
246
+
247
+ const waited = await waitForRun(config, basePath, started, {
248
+ deadline, fetchImpl, sleep, now, killOnTimeout, signal,
249
+ });
250
+ return { ...waited, basePath };
251
+ }
252
+
253
+ /**
254
+ * `execRemote` for callers that only care whether it worked. Throws with the
255
+ * remote's own stderr, which is what the user needs to see.
256
+ */
257
+ export async function execRemoteOrThrow(config, target, command, options = {}) {
258
+ const { run } = await execRemote(config, target, command, options);
259
+ if (run?.status === "completed" && run.exitCode === 0) return run;
260
+
261
+ const detail = (run?.stderrTail || run?.stdoutTail || run?.error || "").trim();
262
+ const what = run?.status === "running"
263
+ ? `timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`
264
+ : `exited ${run?.exitCode ?? "?"}`;
265
+ throw new Error(detail ? `${what} on ${target}: ${detail}` : `${what} on ${target}`);
266
+ }
267
+
268
+ /**
269
+ * An AbortSignal that fires on Ctrl-C / SIGTERM. Installing a listener also
270
+ * stops Node from exiting on the signal, which is the point: the CLI gets to
271
+ * print the run id (and cancel the run with --kill-on-timeout) before it goes.
272
+ * Callers must `release()` so the default behaviour comes back.
273
+ */
274
+ export function interruptSignal(proc = process) {
275
+ const controller = new AbortController();
276
+ const onSignal = () => controller.abort();
277
+ for (const name of INTERRUPT_SIGNALS) proc.on(name, onSignal);
278
+ return {
279
+ signal: controller.signal,
280
+ release: () => {
281
+ for (const name of INTERRUPT_SIGNALS) proc.off(name, onSignal);
282
+ },
283
+ };
284
+ }
285
+
286
+ /** Print a finished (or abandoned) run the way `exec` and `wait` both do, and pick the exit code. */
287
+ export function reportRun(run, { consoleImpl, json, target, pollError, waitedMs, interrupted }) {
288
+ if (json) {
329
289
  consoleImpl.log(JSON.stringify(run, null, 2));
330
290
  } else {
331
291
  if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
@@ -342,10 +302,11 @@ export async function runRemoteExec(argv, deps = {}) {
342
302
  if (pollError) {
343
303
  consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
344
304
  }
305
+ const why = interrupted ? "interrupted" : `still running on ${target} after ${waitedMs}ms`;
345
306
  consoleImpl.error(
346
- `[conductor] still running on ${target} after ${timeoutMs}ms; ` +
347
- `it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
348
- `stop it with DELETE on the same path, or use --kill-on-timeout`,
307
+ `[conductor] ${why}; it keeps going on ${target} — ` +
308
+ `resume with: conductor remote wait -t ${target} ${run.runId} ` +
309
+ `(add --kill-on-timeout to stop it instead)`,
349
310
  );
350
311
  return EXIT.CLI_ERROR;
351
312
  }
@@ -358,8 +319,90 @@ export async function runRemoteExec(argv, deps = {}) {
358
319
  return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
359
320
  }
360
321
 
361
- if (isMainModule) {
362
- // `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
363
- // and exiting outright truncates them. Let the loop drain and end naturally.
364
- process.exitCode = await runRemoteExec(process.argv.slice(2));
322
+ export async function runRemoteExec(argv, deps = {}) {
323
+ const consoleImpl = deps.console || console;
324
+ const fetchImpl = deps.fetch || globalThis.fetch;
325
+ const env = deps.env || process.env;
326
+ const sleep = deps.sleep || delay;
327
+ const now = deps.now || (() => Date.now());
328
+
329
+ let parsed;
330
+ try {
331
+ parsed = parseArgs(argv);
332
+ } catch (error) {
333
+ consoleImpl.error(`Error: ${error.message}`);
334
+ return EXIT.CLI_ERROR;
335
+ }
336
+ const { options, command } = parsed;
337
+
338
+ if (options.help) {
339
+ showHelp(consoleImpl);
340
+ return EXIT.OK;
341
+ }
342
+
343
+ const target = typeof options.target === "string" ? options.target.trim() : "";
344
+ if (!target) {
345
+ consoleImpl.error("Error: --target <daemon> is required");
346
+ showHelp(consoleImpl);
347
+ return EXIT.CLI_ERROR;
348
+ }
349
+ if (command.length === 0) {
350
+ consoleImpl.error("Error: no command given");
351
+ showHelp(consoleImpl);
352
+ return EXIT.CLI_ERROR;
353
+ }
354
+
355
+ let timeoutMs;
356
+ try {
357
+ timeoutMs = parseTimeoutMs(options.timeout);
358
+ } catch (error) {
359
+ consoleImpl.error(`Error: ${error.message}`);
360
+ return EXIT.CLI_ERROR;
361
+ }
362
+
363
+ let config;
364
+ try {
365
+ config = deps.config || loadCliConfig(options.configFile, env);
366
+ } catch (error) {
367
+ consoleImpl.error(`Error: ${error.message}`);
368
+ return EXIT.CLI_ERROR;
369
+ }
370
+
371
+ const interrupt = interruptSignal(deps.process);
372
+ let run;
373
+ let pollError = null;
374
+ try {
375
+ let killError;
376
+ ({ run, pollError, killError } = await execRemote(config, target, command[0], {
377
+ args: command.slice(1),
378
+ workspace: options.workspace,
379
+ env: options.env,
380
+ timeoutMs,
381
+ fetchImpl,
382
+ sleep,
383
+ now,
384
+ killOnTimeout: options.killOnTimeout,
385
+ signal: interrupt.signal,
386
+ }));
387
+ if (killError) {
388
+ consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
389
+ } else if (options.killOnTimeout && run?.status === "cancelled") {
390
+ const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
391
+ consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
392
+ }
393
+ } catch (error) {
394
+ consoleImpl.error(`Error: ${error.message}`);
395
+ return EXIT.CLI_ERROR;
396
+ } finally {
397
+ interrupt.release();
398
+ }
399
+
400
+ return reportRun(run, {
401
+ consoleImpl,
402
+ json: options.json,
403
+ target,
404
+ pollError,
405
+ waitedMs: timeoutMs,
406
+ interrupted: interrupt.signal.aborted,
407
+ });
365
408
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `conductor remote wait` — pick up a command started by `remote exec` that
3
+ * outlived its deadline (or the process that started it).
4
+ *
5
+ * `exec` prints the run id whenever it leaves a command behind; this verb
6
+ * polls that run the same way `exec` would have and reports it identically,
7
+ * so a caller can always finish what it started.
8
+ */
9
+
10
+ import process from "node:process";
11
+
12
+ import { EXIT, callApi, delay, loadCliConfig } from "./client.js";
13
+ import {
14
+ interruptSignal,
15
+ parseArgs,
16
+ parseTimeoutMs,
17
+ reportRun,
18
+ waitForRun,
19
+ } from "./exec.js";
20
+
21
+ export function showHelp(consoleImpl = console) {
22
+ consoleImpl.log(`conductor remote wait - wait for a command left running by \`remote exec\`
23
+
24
+ Usage:
25
+ conductor remote wait --target <daemon> [options] <runId>
26
+
27
+ Options:
28
+ -t, --target <daemon> Daemon the command is running on (required)
29
+ --timeout <dur> How long to wait this time, e.g. 30s, 2m (default: 60s)
30
+ --json Print the raw run result as JSON
31
+ --kill-on-timeout Stop the remote command when --timeout is reached or
32
+ this CLI is interrupted (default: it keeps running)
33
+ --config-file <p> Conductor config file to authenticate with
34
+ -h, --help Show this help
35
+
36
+ Output and exit codes match \`conductor remote exec\`: the command's own exit
37
+ code is passed through, 255 means this CLI failed or the command is still running.
38
+
39
+ Example:
40
+ conductor remote exec -t ubuntu --timeout 30s -- make build
41
+ # ... [conductor] still running on ubuntu after 30000ms; resume with:
42
+ conductor remote wait -t ubuntu --timeout 20m 3f9c1b2e-...
43
+ `);
44
+ }
45
+
46
+ export async function runRemoteWait(argv, deps = {}) {
47
+ const consoleImpl = deps.console || console;
48
+ const fetchImpl = deps.fetch || globalThis.fetch;
49
+ const env = deps.env || process.env;
50
+ const sleep = deps.sleep || delay;
51
+ const now = deps.now || (() => Date.now());
52
+
53
+ let parsed;
54
+ try {
55
+ parsed = parseArgs(argv);
56
+ } catch (error) {
57
+ consoleImpl.error(`Error: ${error.message}`);
58
+ return EXIT.CLI_ERROR;
59
+ }
60
+ const { options, command } = parsed;
61
+
62
+ if (options.help) {
63
+ showHelp(consoleImpl);
64
+ return EXIT.OK;
65
+ }
66
+
67
+ const target = typeof options.target === "string" ? options.target.trim() : "";
68
+ if (!target) {
69
+ consoleImpl.error("Error: --target <daemon> is required");
70
+ showHelp(consoleImpl);
71
+ return EXIT.CLI_ERROR;
72
+ }
73
+ if (command.length !== 1) {
74
+ consoleImpl.error(command.length === 0 ? "Error: no runId given" : "Error: expected exactly one runId");
75
+ showHelp(consoleImpl);
76
+ return EXIT.CLI_ERROR;
77
+ }
78
+ const runId = command[0];
79
+
80
+ let timeoutMs;
81
+ try {
82
+ timeoutMs = parseTimeoutMs(options.timeout);
83
+ } catch (error) {
84
+ consoleImpl.error(`Error: ${error.message}`);
85
+ return EXIT.CLI_ERROR;
86
+ }
87
+
88
+ let config;
89
+ try {
90
+ config = deps.config || loadCliConfig(options.configFile, env);
91
+ } catch (error) {
92
+ consoleImpl.error(`Error: ${error.message}`);
93
+ return EXIT.CLI_ERROR;
94
+ }
95
+
96
+ const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
97
+ const interrupt = interruptSignal(deps.process);
98
+ let run;
99
+ let pollError = null;
100
+ try {
101
+ const deadline = now() + timeoutMs;
102
+ run = await callApi(config, "GET", `${basePath}/runs/${encodeURIComponent(runId)}`, null, fetchImpl);
103
+ let killError;
104
+ ({ run, pollError, killError } = await waitForRun(config, basePath, run, {
105
+ deadline,
106
+ fetchImpl,
107
+ sleep,
108
+ now,
109
+ killOnTimeout: options.killOnTimeout,
110
+ signal: interrupt.signal,
111
+ }));
112
+ if (killError) {
113
+ consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
114
+ } else if (options.killOnTimeout && run?.status === "cancelled") {
115
+ const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
116
+ consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
117
+ }
118
+ } catch (error) {
119
+ consoleImpl.error(`Error: ${error.message}`);
120
+ return EXIT.CLI_ERROR;
121
+ } finally {
122
+ interrupt.release();
123
+ }
124
+
125
+ return reportRun(run, {
126
+ consoleImpl,
127
+ json: options.json,
128
+ target,
129
+ pollError,
130
+ waitedMs: timeoutMs,
131
+ interrupted: interrupt.signal.aborted,
132
+ });
133
+ }