@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.
- package/CHANGELOG.md +65 -0
- package/bin/conductor-config.js +4 -0
- package/bin/conductor-remote.js +98 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +200 -25
- package/src/fire-session-registry.js +19 -0
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +204 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +222 -179
- package/src/remote/wait.js +133 -0
- package/src/remote-file-handlers.js +703 -0
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* conductor remote
|
|
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 {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
120
|
+
consoleImpl.log(`conductor remote exec - run a command on another daemon's host
|
|
190
121
|
|
|
191
122
|
Usage:
|
|
192
|
-
conductor remote
|
|
193
|
-
conductor remote
|
|
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
|
|
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
|
|
215
|
-
conductor remote
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
347
|
-
`
|
|
348
|
-
`
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
}
|