@phnx-labs/agents-cli 1.22.25 → 1.22.27
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 +427 -0
- package/README.md +35 -3
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +32 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +146 -38
- package/dist/commands/output.js +100 -25
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +349 -37
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -3
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +17 -21
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +27 -1
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +10 -1
- package/dist/lib/devices/doctor-findings.js +47 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/exec.bench.d.ts +1 -0
- package/dist/lib/exec.bench.js +186 -0
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +191 -12
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +10 -6
- package/dist/lib/hosts/dispatch.js +7 -3
- package/dist/lib/hosts/passthrough.d.ts +45 -0
- package/dist/lib/hosts/passthrough.js +59 -8
- package/dist/lib/hosts/progress.d.ts +2 -1
- package/dist/lib/hosts/progress.js +4 -3
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +6 -2
- package/dist/lib/hosts/ready.js +33 -8
- package/dist/lib/hosts/reconcile.d.ts +1 -1
- package/dist/lib/hosts/reconcile.js +16 -7
- package/dist/lib/hosts/reconnect.d.ts +56 -21
- package/dist/lib/hosts/reconnect.js +128 -71
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +3 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +2 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/models.d.ts +30 -1
- package/dist/lib/models.js +42 -144
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/redact.js +8 -3
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +9 -4
- package/dist/lib/runner.js +182 -30
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.js +3 -2
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +38 -3
- package/dist/lib/session/db.js +273 -16
- package/dist/lib/session/discover.d.ts +22 -1
- package/dist/lib/session/discover.js +239 -51
- package/dist/lib/session/insights.d.ts +37 -0
- package/dist/lib/session/insights.js +219 -9
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +42 -3
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, spawnSync } from 'child_process';
|
|
11
|
+
import { StringDecoder } from 'string_decoder';
|
|
11
12
|
import * as fs from 'fs';
|
|
12
13
|
import * as path from 'path';
|
|
13
14
|
import { getCacheDir } from './state.js';
|
|
@@ -46,6 +47,38 @@ export const SSH_OPTS = [
|
|
|
46
47
|
'-o', 'ServerAliveInterval=15',
|
|
47
48
|
'-o', 'ServerAliveCountMax=3',
|
|
48
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* Hard ceiling on the stdout one peer may return before its capture is aborted.
|
|
52
|
+
* A cross-machine fan-out streams every peer's `agents … --json` into memory in
|
|
53
|
+
* parallel, so an unbounded buffer means one runaway peer (a corrupt or
|
|
54
|
+
* pathologically large payload) can exhaust the caller's heap and take the whole
|
|
55
|
+
* sweep down with it — RUSH-2065 observed ~170MB retained across a single
|
|
56
|
+
* `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
|
|
57
|
+
* enough that N peers in flight stay bounded. A peer that overflows is treated as
|
|
58
|
+
* unreachable rather than trusted with partial output.
|
|
59
|
+
*/
|
|
60
|
+
export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
|
|
61
|
+
/**
|
|
62
|
+
* Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
|
|
63
|
+
* a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
|
|
64
|
+
* `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
|
|
65
|
+
* kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
|
|
66
|
+
* next chunk completes them.
|
|
67
|
+
*/
|
|
68
|
+
export class RemoteUtf8Accumulator {
|
|
69
|
+
decoder = new StringDecoder('utf8');
|
|
70
|
+
value = '';
|
|
71
|
+
write(chunk) {
|
|
72
|
+
this.value += this.decoder.write(chunk);
|
|
73
|
+
}
|
|
74
|
+
end() {
|
|
75
|
+
this.value += this.decoder.end();
|
|
76
|
+
return this.value;
|
|
77
|
+
}
|
|
78
|
+
current() {
|
|
79
|
+
return this.value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
49
82
|
/**
|
|
50
83
|
* OpenSSH connection-multiplexing options. The first connection to a host opens
|
|
51
84
|
* a control socket; subsequent connections (even from a *separate* `agents`
|
|
@@ -117,7 +150,11 @@ export function sshExec(target, remoteCmd, opts = {}) {
|
|
|
117
150
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
151
|
windowsHide: true,
|
|
119
152
|
});
|
|
120
|
-
|
|
153
|
+
// Node's spawnSync with a `timeout` option kills the child via SIGTERM and sets
|
|
154
|
+
// res.signal (e.g. 'SIGTERM') — it does NOT set res.error.code = 'ETIMEDOUT'.
|
|
155
|
+
// Check both so the detection fires whether the timeout is signalled or errored.
|
|
156
|
+
const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
|
|
157
|
+
(!!opts.timeoutMs && res.signal !== null);
|
|
121
158
|
return {
|
|
122
159
|
code: typeof res.status === 'number' ? res.status : null,
|
|
123
160
|
stdout: res.stdout ?? '',
|
|
@@ -206,7 +243,9 @@ export function sshExecRaw(target, remoteCmd, opts = {}) {
|
|
|
206
243
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
207
244
|
windowsHide: true,
|
|
208
245
|
});
|
|
209
|
-
|
|
246
|
+
// Same fix as sshExec: spawnSync sets res.signal, not error.code = 'ETIMEDOUT'.
|
|
247
|
+
const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
|
|
248
|
+
(!!opts.timeoutMs && res.signal !== null);
|
|
210
249
|
return {
|
|
211
250
|
code: typeof res.status === 'number' ? res.status : null,
|
|
212
251
|
stdout: res.stdout ?? Buffer.alloc(0),
|
|
@@ -286,7 +325,7 @@ export function sshStream(target, remoteCmd, opts = {}) {
|
|
|
286
325
|
assertValidSshTarget(target);
|
|
287
326
|
const mux = opts.multiplex === false ? [] : controlOpts();
|
|
288
327
|
const tty = opts.tty ? ['-tt'] : [];
|
|
289
|
-
const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...tty, target, remoteCmd];
|
|
328
|
+
const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...(opts.extraSshArgs ?? []), ...tty, target, remoteCmd];
|
|
290
329
|
const res = spawnSync('ssh', args, { stdio: 'inherit' });
|
|
291
330
|
if (typeof res.status === 'number')
|
|
292
331
|
return res.status;
|
package/dist/lib/ssh-tunnel.d.ts
CHANGED
|
@@ -27,13 +27,14 @@ export interface StartTunnelOptions {
|
|
|
27
27
|
* of one CDP session and kills it on cleanup.
|
|
28
28
|
*/
|
|
29
29
|
detached?: boolean;
|
|
30
|
+
extraSshArgs?: string[];
|
|
30
31
|
}
|
|
31
32
|
/** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
|
|
32
33
|
*
|
|
33
34
|
* Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
|
|
34
35
|
* so the tunnel inherits the same options — crucially the keepalive, which lets
|
|
35
36
|
* a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
|
|
36
|
-
export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
|
|
37
|
+
export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number, extraSshArgs?: string[]): string[];
|
|
37
38
|
/**
|
|
38
39
|
* Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
|
|
39
40
|
*
|
|
@@ -151,7 +152,7 @@ export declare function scpRemotePath(remotePath: string): string;
|
|
|
151
152
|
* assert the real binary copy path keeps BatchMode and does not route bytes
|
|
152
153
|
* through a PowerShell decoder.
|
|
153
154
|
*/
|
|
154
|
-
export declare function buildScpArgs(target: string, remotePath: string, filePath: string): string[];
|
|
155
|
+
export declare function buildScpArgs(target: string, remotePath: string, filePath: string, extraSshArgs?: string[]): string[];
|
|
155
156
|
/**
|
|
156
157
|
* `setup --host`: push the exe, then register + start the LOGON task. Remote
|
|
157
158
|
* PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -28,7 +28,7 @@ import { sshExec, SSH_OPTS, assertValidSshTarget } from './ssh-exec.js';
|
|
|
28
28
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
29
29
|
import { encodePowerShell } from './browser/drivers/ssh.js';
|
|
30
30
|
import { getDevice } from './devices/registry.js';
|
|
31
|
-
import { sshTargetFor } from './devices/connect.js';
|
|
31
|
+
import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
|
|
32
32
|
import { hostNameFor } from './devices/ssh-config.js';
|
|
33
33
|
import { getCacheDir } from './state.js';
|
|
34
34
|
import { getCliVersion } from './version.js';
|
|
@@ -38,12 +38,13 @@ import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
|
|
|
38
38
|
* Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
|
|
39
39
|
* so the tunnel inherits the same options — crucially the keepalive, which lets
|
|
40
40
|
* a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
|
|
41
|
-
export function buildTunnelArgs(user, host, localPort, remotePort) {
|
|
41
|
+
export function buildTunnelArgs(user, host, localPort, remotePort, extraSshArgs = []) {
|
|
42
42
|
return [
|
|
43
43
|
'-L',
|
|
44
44
|
`${localPort}:127.0.0.1:${remotePort}`,
|
|
45
45
|
`${user}@${host}`,
|
|
46
46
|
'-N',
|
|
47
|
+
...extraSshArgs,
|
|
47
48
|
...SSH_OPTS,
|
|
48
49
|
];
|
|
49
50
|
}
|
|
@@ -69,7 +70,7 @@ export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
|
|
|
69
70
|
reject(err);
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
|
-
const args = buildTunnelArgs(user, host, localPort, remotePort);
|
|
73
|
+
const args = buildTunnelArgs(user, host, localPort, remotePort, opts.extraSshArgs);
|
|
73
74
|
const tunnel = spawn('ssh', args, {
|
|
74
75
|
stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
|
|
75
76
|
...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
|
|
@@ -360,16 +361,16 @@ export function scpRemotePath(remotePath) {
|
|
|
360
361
|
* assert the real binary copy path keeps BatchMode and does not route bytes
|
|
361
362
|
* through a PowerShell decoder.
|
|
362
363
|
*/
|
|
363
|
-
export function buildScpArgs(target, remotePath, filePath) {
|
|
364
|
-
return [...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
|
|
364
|
+
export function buildScpArgs(target, remotePath, filePath, extraSshArgs = []) {
|
|
365
|
+
return [...extraSshArgs, ...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
|
|
365
366
|
}
|
|
366
367
|
/**
|
|
367
368
|
* Copy a local file to the remote destination with scp. This is a binary
|
|
368
369
|
* transfer; no base64 transform runs on either side.
|
|
369
370
|
*/
|
|
370
|
-
function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
|
|
371
|
+
function copyFileOverScp(target, remotePath, filePath, extraSshArgs = [], timeoutMs = 600_000) {
|
|
371
372
|
return new Promise((resolve, reject) => {
|
|
372
|
-
const child = spawn('scp', buildScpArgs(target, remotePath, filePath), {
|
|
373
|
+
const child = spawn('scp', buildScpArgs(target, remotePath, filePath, extraSshArgs), {
|
|
373
374
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
374
375
|
});
|
|
375
376
|
let stderr = '';
|
|
@@ -397,11 +398,12 @@ function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
|
|
|
397
398
|
* scp transfer. Throws with the remote stderr on any failure.
|
|
398
399
|
*/
|
|
399
400
|
export async function setupRemoteHelper(name) {
|
|
400
|
-
const { target } = await resolveRemoteDevice(name);
|
|
401
|
+
const { device, target } = await resolveRemoteDevice(name);
|
|
402
|
+
const identityArgs = deviceIdentityArgs(device);
|
|
401
403
|
// Local build output, else the checksum-verified GitHub release asset for
|
|
402
404
|
// this CLI version. Throws naming the tag it checked when neither exists.
|
|
403
405
|
const exe = await ensureWinHelperExe();
|
|
404
|
-
const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000 });
|
|
406
|
+
const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
405
407
|
if (prep.code !== 0) {
|
|
406
408
|
throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
|
|
407
409
|
}
|
|
@@ -409,12 +411,12 @@ export async function setupRemoteHelper(name) {
|
|
|
409
411
|
if (!remotePath) {
|
|
410
412
|
throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
|
|
411
413
|
}
|
|
412
|
-
const push = await copyFileOverScp(target, remotePath, exe);
|
|
414
|
+
const push = await copyFileOverScp(target, remotePath, exe, identityArgs);
|
|
413
415
|
if (push.code !== 0) {
|
|
414
416
|
throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
|
|
415
417
|
}
|
|
416
418
|
const expectedBytes = fs.statSync(exe).size;
|
|
417
|
-
const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000 });
|
|
419
|
+
const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
418
420
|
if (verify.code !== 0) {
|
|
419
421
|
throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
|
|
420
422
|
}
|
|
@@ -423,7 +425,7 @@ export async function setupRemoteHelper(name) {
|
|
|
423
425
|
// refuses to start without a token, so a token-less (open-to-any-local-process)
|
|
424
426
|
// daemon can no longer be stood up through the CLI.
|
|
425
427
|
const token = generateToken();
|
|
426
|
-
const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000 });
|
|
428
|
+
const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
427
429
|
if (tokWrite.code !== 0) {
|
|
428
430
|
throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
|
|
429
431
|
}
|
|
@@ -434,6 +436,7 @@ export async function setupRemoteHelper(name) {
|
|
|
434
436
|
// Register + start the LOGON task, pointing it at the token file.
|
|
435
437
|
const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
|
|
436
438
|
timeoutMs: 60_000,
|
|
439
|
+
extraSshArgs: identityArgs,
|
|
437
440
|
});
|
|
438
441
|
if (reg.code !== 0) {
|
|
439
442
|
throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
|
|
@@ -460,10 +463,13 @@ export function pickFreePort() {
|
|
|
460
463
|
* the state (and leaves the tunnel running in the background).
|
|
461
464
|
*/
|
|
462
465
|
export async function startRemoteTunnel(name) {
|
|
463
|
-
const { target, user, host } = await resolveRemoteDevice(name);
|
|
466
|
+
const { device, target, user, host } = await resolveRemoteDevice(name);
|
|
464
467
|
const remotePort = REMOTE_HELPER_PORT;
|
|
465
468
|
const localPort = await pickFreePort();
|
|
466
|
-
const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
|
|
469
|
+
const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
|
|
470
|
+
detached: true,
|
|
471
|
+
extraSshArgs: deviceIdentityArgs(device),
|
|
472
|
+
});
|
|
467
473
|
const tunnelPid = tunnel.pid ?? 0;
|
|
468
474
|
// Verify the daemon answers through the tunnel before we record it. This is
|
|
469
475
|
// the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
|
|
@@ -543,8 +549,11 @@ export async function stopRemoteHelper(name) {
|
|
|
543
549
|
}
|
|
544
550
|
let taskRemoved = false;
|
|
545
551
|
try {
|
|
546
|
-
const { target } = await resolveRemoteDevice(name);
|
|
547
|
-
const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
|
|
552
|
+
const { device, target } = await resolveRemoteDevice(name);
|
|
553
|
+
const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
|
|
554
|
+
timeoutMs: 60_000,
|
|
555
|
+
extraSshArgs: deviceIdentityArgs(device),
|
|
556
|
+
});
|
|
548
557
|
taskRemoved = res.code === 0;
|
|
549
558
|
}
|
|
550
559
|
catch {
|
|
@@ -47,6 +47,7 @@ export declare const loadMonitors: ModuleLoader;
|
|
|
47
47
|
export declare const loadProjects: ModuleLoader;
|
|
48
48
|
export declare const loadRun: ModuleLoader;
|
|
49
49
|
export declare const loadResume: ModuleLoader;
|
|
50
|
+
export declare const loadReconnect: ModuleLoader;
|
|
50
51
|
export declare const loadFork: ModuleLoader;
|
|
51
52
|
export declare const loadDefaults: ModuleLoader;
|
|
52
53
|
export declare const loadSet: ModuleLoader;
|
|
@@ -75,6 +76,7 @@ export declare const loadUsage: ModuleLoader;
|
|
|
75
76
|
export declare const loadCost: ModuleLoader;
|
|
76
77
|
export declare const loadInsights: ModuleLoader;
|
|
77
78
|
export declare const loadPerf: ModuleLoader;
|
|
79
|
+
export declare const loadBench: ModuleLoader;
|
|
78
80
|
export declare const loadTrends: ModuleLoader;
|
|
79
81
|
export declare const loadOutput: ModuleLoader;
|
|
80
82
|
export declare const loadBudget: ModuleLoader;
|
|
@@ -25,6 +25,7 @@ export const loadMonitors = async () => (await import('../../commands/monitors.j
|
|
|
25
25
|
export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
|
|
26
26
|
export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
|
|
27
27
|
export const loadResume = async () => (await import('../../commands/resume.js')).registerResumeCommand;
|
|
28
|
+
export const loadReconnect = async () => (await import('../../commands/reconnect.js')).registerReconnectCommand;
|
|
28
29
|
export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
|
|
29
30
|
export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
|
|
30
31
|
export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
|
|
@@ -53,6 +54,8 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
|
|
|
53
54
|
export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
|
|
54
55
|
export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
|
|
55
56
|
export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
|
|
57
|
+
export const loadBench = async () => (await import('../../commands/bench.js')).registerBenchCommand;
|
|
58
|
+
// Thin deprecated alias of `agents insights mix` — no second mix implementation.
|
|
56
59
|
export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
|
|
57
60
|
export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
|
|
58
61
|
export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
|
|
@@ -98,6 +101,7 @@ export const loadHumans = async () => (await import('../../commands/humans.js'))
|
|
|
98
101
|
export const LAZY_COMMAND_NAMES = new Set([
|
|
99
102
|
'sessions',
|
|
100
103
|
'resume',
|
|
104
|
+
'reconnect',
|
|
101
105
|
'roster',
|
|
102
106
|
'teams',
|
|
103
107
|
'cloud',
|
|
@@ -156,6 +160,7 @@ export const COMMAND_LOADERS = {
|
|
|
156
160
|
projects: [loadProjects],
|
|
157
161
|
run: [loadRun],
|
|
158
162
|
resume: [loadResume],
|
|
163
|
+
reconnect: [loadReconnect],
|
|
159
164
|
fork: [loadFork],
|
|
160
165
|
defaults: [loadDefaults],
|
|
161
166
|
set: [loadSet],
|
|
@@ -187,6 +192,7 @@ export const COMMAND_LOADERS = {
|
|
|
187
192
|
cost: [loadCost],
|
|
188
193
|
insights: [loadInsights],
|
|
189
194
|
perf: [loadPerf],
|
|
195
|
+
bench: [loadBench],
|
|
190
196
|
trends: [loadTrends],
|
|
191
197
|
output: [loadOutput],
|
|
192
198
|
budget: [loadBudget],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { captureProcessStartTime } from '../platform/index.js';
|
|
2
2
|
import { AgentType } from './parsers.js';
|
|
3
|
+
import { NoViableDeviceError } from './scheduler.js';
|
|
3
4
|
/**
|
|
4
5
|
* Compute the Lowest Common Ancestor (LCA) of multiple file paths.
|
|
5
6
|
* Returns the deepest common directory shared by all paths.
|
|
@@ -405,6 +406,18 @@ export declare class AgentManager {
|
|
|
405
406
|
* teammate schedules identically no matter how it was fired.
|
|
406
407
|
*/
|
|
407
408
|
private maybeSchedulePlacement;
|
|
409
|
+
/** Human label of a teammate's agent for the placement fail-loud message. */
|
|
410
|
+
private placementAgentLabel;
|
|
411
|
+
/**
|
|
412
|
+
* Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
|
|
413
|
+
* confirm each distinct agent among the pending, unpinned, pooled teammates
|
|
414
|
+
* has at least one viable device. Returns the first {@link NoViableDeviceError}
|
|
415
|
+
* so the command can fail loud BEFORE launching a wave, rather than silently
|
|
416
|
+
* leaving teammates stranded pending. A poolless team (no `--devices`) or a
|
|
417
|
+
* probe that could not gather positive evidence returns null — fail-loud fires
|
|
418
|
+
* only on proof that no device can run the agent, never on a probe miss.
|
|
419
|
+
*/
|
|
420
|
+
preflightPlacement(taskName: string): Promise<NoViableDeviceError | null>;
|
|
408
421
|
/**
|
|
409
422
|
* One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
|
|
410
423
|
* The supervisor calls this each wave BEFORE listByTask() so the per-teammate
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -34,7 +34,8 @@ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
|
34
34
|
import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
|
|
35
35
|
import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
|
|
36
36
|
import { getTeam } from './registry.js';
|
|
37
|
-
import { resolvePlacement,
|
|
37
|
+
import { resolvePlacement, classifyExclusions, NoViableDeviceError } from './scheduler.js';
|
|
38
|
+
import { probePoolSignals } from './placement-probe.js';
|
|
38
39
|
import { readMaxConcurrentCaps } from '../device-config.js';
|
|
39
40
|
import chalk from 'chalk';
|
|
40
41
|
let lastMemoryWarnAt = 0;
|
|
@@ -1756,7 +1757,7 @@ export class AgentManager {
|
|
|
1756
1757
|
* (immediate add-launch) and startReady() (staged launch) so an unpinned pool
|
|
1757
1758
|
* teammate schedules identically no matter how it was fired.
|
|
1758
1759
|
*/
|
|
1759
|
-
async maybeSchedulePlacement(agent, taskName) {
|
|
1760
|
+
async maybeSchedulePlacement(agent, taskName, opts = {}) {
|
|
1760
1761
|
if (agent.hostName || agent.cloudProvider)
|
|
1761
1762
|
return;
|
|
1762
1763
|
const teamMeta = await getTeam(taskName);
|
|
@@ -1765,15 +1766,79 @@ export class AgentManager {
|
|
|
1765
1766
|
const roster = await this.listByTask(taskName);
|
|
1766
1767
|
const pool = teamMeta.devices ?? [];
|
|
1767
1768
|
const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1769
|
+
// On the start path (opts.probe), gather live signals so the pick is health-,
|
|
1770
|
+
// harness-, and load-aware (RUSH-2002); the add path stays the cap-only
|
|
1771
|
+
// roster count so `teams add` never blocks on an SSH fan-out. Cached per
|
|
1772
|
+
// (pool, agent), so a wave placing many teammates probes the pool once.
|
|
1773
|
+
const signals = opts.probe && pool.length > 0
|
|
1774
|
+
? await probePoolSignals(pool, agent.agentType, { now: Date.now() })
|
|
1775
|
+
: undefined;
|
|
1776
|
+
const placeOpts = {
|
|
1777
|
+
maxConcurrent,
|
|
1778
|
+
signals,
|
|
1779
|
+
agentLabel: this.placementAgentLabel(agent),
|
|
1780
|
+
};
|
|
1781
|
+
if (signals) {
|
|
1782
|
+
for (const e of classifyExclusions(pool, roster, placeOpts).excluded) {
|
|
1783
|
+
const why = e.reason === 'capped'
|
|
1784
|
+
? `at its agents.max-concurrent cap (${e.detail} running)`
|
|
1785
|
+
: e.reason === 'not-installed'
|
|
1786
|
+
? `does not have ${this.placementAgentLabel(agent)} installed`
|
|
1787
|
+
: e.reason;
|
|
1788
|
+
console.error(chalk.dim(`[placement] '${e.device}' excluded from auto-pick — ${why}`));
|
|
1771
1789
|
}
|
|
1772
1790
|
}
|
|
1773
|
-
const { device } = resolvePlacement(teamMeta, null, roster,
|
|
1791
|
+
const { device } = resolvePlacement(teamMeta, null, roster, placeOpts);
|
|
1774
1792
|
if (device)
|
|
1775
1793
|
await this.resolveScheduledPlacement(agent, device, taskName);
|
|
1776
1794
|
}
|
|
1795
|
+
/** Human label of a teammate's agent for the placement fail-loud message. */
|
|
1796
|
+
placementAgentLabel(agent) {
|
|
1797
|
+
return agent.version ? `${agent.agentType}@${agent.version}` : String(agent.agentType);
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
|
|
1801
|
+
* confirm each distinct agent among the pending, unpinned, pooled teammates
|
|
1802
|
+
* has at least one viable device. Returns the first {@link NoViableDeviceError}
|
|
1803
|
+
* so the command can fail loud BEFORE launching a wave, rather than silently
|
|
1804
|
+
* leaving teammates stranded pending. A poolless team (no `--devices`) or a
|
|
1805
|
+
* probe that could not gather positive evidence returns null — fail-loud fires
|
|
1806
|
+
* only on proof that no device can run the agent, never on a probe miss.
|
|
1807
|
+
*/
|
|
1808
|
+
async preflightPlacement(taskName) {
|
|
1809
|
+
await this.initialize();
|
|
1810
|
+
const teamMeta = await getTeam(taskName);
|
|
1811
|
+
const pool = teamMeta?.devices ?? [];
|
|
1812
|
+
if (!teamMeta || pool.length === 0)
|
|
1813
|
+
return null;
|
|
1814
|
+
const roster = await this.listByTask(taskName);
|
|
1815
|
+
const pending = roster.filter((a) => a.status === AgentStatus.PENDING && !a.hostName && !a.cloudProvider);
|
|
1816
|
+
if (pending.length === 0)
|
|
1817
|
+
return null;
|
|
1818
|
+
const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
|
|
1819
|
+
// One representative teammate per distinct agent type — the signal + gate is
|
|
1820
|
+
// per harness, not per teammate.
|
|
1821
|
+
const byAgent = new Map();
|
|
1822
|
+
for (const a of pending)
|
|
1823
|
+
if (!byAgent.has(a.agentType))
|
|
1824
|
+
byAgent.set(a.agentType, a);
|
|
1825
|
+
for (const [, rep] of byAgent) {
|
|
1826
|
+
const signals = await probePoolSignals(pool, rep.agentType, { now: Date.now() });
|
|
1827
|
+
try {
|
|
1828
|
+
resolvePlacement(teamMeta, null, roster, {
|
|
1829
|
+
maxConcurrent,
|
|
1830
|
+
signals,
|
|
1831
|
+
agentLabel: this.placementAgentLabel(rep),
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
catch (err) {
|
|
1835
|
+
if (err instanceof NoViableDeviceError)
|
|
1836
|
+
return err;
|
|
1837
|
+
throw err;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1777
1842
|
/**
|
|
1778
1843
|
* One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
|
|
1779
1844
|
* The supervisor calls this each wave BEFORE listByTask() so the per-teammate
|
|
@@ -1866,9 +1931,12 @@ export class AgentManager {
|
|
|
1866
1931
|
// immediate-add and staged paths agree. A null pick keeps hostName null →
|
|
1867
1932
|
// local spawn, unchanged. Cloud teammates never schedule.
|
|
1868
1933
|
try {
|
|
1869
|
-
await this.maybeSchedulePlacement(agent, taskName);
|
|
1934
|
+
await this.maybeSchedulePlacement(agent, taskName, { probe: true });
|
|
1870
1935
|
}
|
|
1871
1936
|
catch (err) {
|
|
1937
|
+
// A NoViableDeviceError means the pool cannot host this teammate right
|
|
1938
|
+
// now — leave it PENDING (never a silent local fallback) and surface the
|
|
1939
|
+
// reason; a transient device loss is retried next wave.
|
|
1872
1940
|
console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
|
|
1873
1941
|
continue;
|
|
1874
1942
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AgentType } from './agents.js';
|
|
2
|
+
import type { DevicePlacementSignal } from './scheduler.js';
|
|
3
|
+
/** Per-remote readiness probe budget — matches the health probe's short window
|
|
4
|
+
* (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
|
|
5
|
+
export declare const READY_PROBE_TIMEOUT_MS = 8000;
|
|
6
|
+
/** How long a probed pool snapshot is reused within a `teams start` wave. Short
|
|
7
|
+
* enough that a device coming online / going overloaded is seen next wave, long
|
|
8
|
+
* enough that placing a wave of teammates does not re-fan-out per teammate. */
|
|
9
|
+
export declare const SIGNAL_TTL_MS = 15000;
|
|
10
|
+
/** Clear the probe cache — for tests and after a device-registry change. */
|
|
11
|
+
export declare function clearPlacementSignalCache(): void;
|
|
12
|
+
/**
|
|
13
|
+
* Probe every device in the team pool and return a name→signal map for the pure
|
|
14
|
+
* placement pick. Devices with no data at all are omitted (the pick then neither
|
|
15
|
+
* excludes nor prefers them). Never throws — a probe failure degrades to a
|
|
16
|
+
* missing/partial signal.
|
|
17
|
+
*/
|
|
18
|
+
export declare function probePoolSignals(pool: string[], agent?: AgentType, opts?: {
|
|
19
|
+
force?: boolean;
|
|
20
|
+
now?: number;
|
|
21
|
+
}): Promise<Map<string, DevicePlacementSignal>>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live pool probing for the teams placement scheduler (RUSH-2002).
|
|
3
|
+
*
|
|
4
|
+
* Gathers one {@link DevicePlacementSignal} per pool device so the pure pick in
|
|
5
|
+
* {@link ./scheduler} can filter unreachable / overloaded / not-installed
|
|
6
|
+
* devices and rank the rest. Two concerns, both best-effort:
|
|
7
|
+
*
|
|
8
|
+
* - reachability + headroom + load ← {@link probeFleetStats} (one parallel
|
|
9
|
+
* SSH fan-out over the pool; the local box is measured directly).
|
|
10
|
+
* - requested agent installed + signed in (when known) ← a one-shot readiness
|
|
11
|
+
* probe per remote device ({@link buildReadyProbeCommand} → `agents view`),
|
|
12
|
+
* the local box via {@link checkCliAvailable}/{@link checkCliSignedIn}.
|
|
13
|
+
*
|
|
14
|
+
* The result is cached briefly per (pool, agent-or-any) so a `teams start` wave that
|
|
15
|
+
* places N teammates probes the pool ONCE, not N times — the roster-count part
|
|
16
|
+
* of the rank stays live (the pure pick recounts the roster each call), only the
|
|
17
|
+
* SSH-measured load/harness snapshot is reused within the TTL.
|
|
18
|
+
*
|
|
19
|
+
* All SSH here is via `execFile` (async, bounded) so the whole pool is probed in
|
|
20
|
+
* parallel; a slow or wedged box degrades to "no signal" instead of blocking the
|
|
21
|
+
* launch. The readiness payload is `agents view --json`, so remote and local
|
|
22
|
+
* candidates carry the same installed/sign-in verdict.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from 'child_process';
|
|
25
|
+
import { probeFleetStats, headroom } from '../devices/health.js';
|
|
26
|
+
import { loadDevicesSync } from '../devices/registry.js';
|
|
27
|
+
import { buildSshInvocation, writeAskpassShim } from '../devices/connect.js';
|
|
28
|
+
import { buildReadyProbeCommand, parseReadyProbe, viewAgentSignedIn, viewHasAgent, } from '../hosts/ready.js';
|
|
29
|
+
import { localMachineId } from '../session/origin-machine.js';
|
|
30
|
+
import { normalizeHost } from '../machine-id.js';
|
|
31
|
+
import { checkCliAvailable, checkCliSignedIn } from './agents.js';
|
|
32
|
+
/** Per-remote readiness probe budget — matches the health probe's short window
|
|
33
|
+
* (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
|
|
34
|
+
export const READY_PROBE_TIMEOUT_MS = 8_000;
|
|
35
|
+
/** How long a probed pool snapshot is reused within a `teams start` wave. Short
|
|
36
|
+
* enough that a device coming online / going overloaded is seen next wave, long
|
|
37
|
+
* enough that placing a wave of teammates does not re-fan-out per teammate. */
|
|
38
|
+
export const SIGNAL_TTL_MS = 15_000;
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
function cacheKey(pool, agent) {
|
|
41
|
+
return `${agent ?? 'any-agent'}::${[...pool].map(normalizeHost).sort().join(',')}`;
|
|
42
|
+
}
|
|
43
|
+
/** Clear the probe cache — for tests and after a device-registry change. */
|
|
44
|
+
export function clearPlacementSignalCache() {
|
|
45
|
+
cache.clear();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the requested agent is installed on a REMOTE device, via one SSH
|
|
49
|
+
* readiness probe. `undefined` when the probe could not answer (ssh/login
|
|
50
|
+
* failure) — reachability is then left to {@link probeFleetStats}; `false` when
|
|
51
|
+
* the box answered but agents-cli or the agent is absent (a genuine can't-run).
|
|
52
|
+
*/
|
|
53
|
+
function probeRemoteReadiness(device, agent) {
|
|
54
|
+
let args;
|
|
55
|
+
let env;
|
|
56
|
+
try {
|
|
57
|
+
const shim = writeAskpassShim();
|
|
58
|
+
const cmd = buildReadyProbeCommand(device.shell === 'powershell' ? 'windows' : undefined);
|
|
59
|
+
// agentOnly: a read-only probe must never force a foreground Touch ID sheet
|
|
60
|
+
// on a password-auth device (mirrors probeDeviceStats in devices/health).
|
|
61
|
+
({ args, env } = buildSshInvocation(device, [cmd], shim, {}, { agentOnly: true }));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return Promise.resolve({ installed: undefined, signedIn: undefined });
|
|
65
|
+
}
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: READY_PROBE_TIMEOUT_MS }, (err, stdout) => {
|
|
68
|
+
if (err || !stdout)
|
|
69
|
+
return resolve({ installed: undefined, signedIn: undefined });
|
|
70
|
+
const probe = parseReadyProbe(stdout);
|
|
71
|
+
if (!probe.reachable)
|
|
72
|
+
return resolve({ installed: undefined, signedIn: undefined });
|
|
73
|
+
if (!probe.version)
|
|
74
|
+
return resolve({ installed: false, signedIn: false });
|
|
75
|
+
const installed = viewHasAgent(probe.view, agent);
|
|
76
|
+
resolve({ installed, signedIn: installed ? viewAgentSignedIn(probe.view, agent) : false });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Probe every device in the team pool and return a name→signal map for the pure
|
|
82
|
+
* placement pick. Devices with no data at all are omitted (the pick then neither
|
|
83
|
+
* excludes nor prefers them). Never throws — a probe failure degrades to a
|
|
84
|
+
* missing/partial signal.
|
|
85
|
+
*/
|
|
86
|
+
export async function probePoolSignals(pool, agent, opts = {}) {
|
|
87
|
+
const now = opts.now ?? Date.now();
|
|
88
|
+
const key = cacheKey(pool, agent);
|
|
89
|
+
const cached = cache.get(key);
|
|
90
|
+
if (!opts.force && cached && now - cached.at < SIGNAL_TTL_MS)
|
|
91
|
+
return cached.signals;
|
|
92
|
+
const reg = loadDevicesSync();
|
|
93
|
+
const self = normalizeHost(localMachineId());
|
|
94
|
+
const lookup = (name) => reg[name] ?? reg[normalizeHost(name)];
|
|
95
|
+
const profiles = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
for (const name of pool) {
|
|
98
|
+
const d = lookup(name);
|
|
99
|
+
if (d && !seen.has(d.name)) {
|
|
100
|
+
profiles.push(d);
|
|
101
|
+
seen.add(d.name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const selfProfile = profiles.find((d) => normalizeHost(d.name) === self);
|
|
105
|
+
const stats = await probeFleetStats(profiles, { selfName: selfProfile?.name });
|
|
106
|
+
const installed = new Map(agent
|
|
107
|
+
? await Promise.all(profiles.map(async (d) => {
|
|
108
|
+
const isSelf = normalizeHost(d.name) === self;
|
|
109
|
+
if (isSelf) {
|
|
110
|
+
const inst = checkCliAvailable(agent)[0];
|
|
111
|
+
const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
|
|
112
|
+
return [d.name, { installed: inst, signedIn }];
|
|
113
|
+
}
|
|
114
|
+
return [d.name, await probeRemoteReadiness(d, agent)];
|
|
115
|
+
}))
|
|
116
|
+
: []);
|
|
117
|
+
const signals = new Map();
|
|
118
|
+
for (const name of pool) {
|
|
119
|
+
const d = lookup(name);
|
|
120
|
+
const s = d ? stats.get(d.name) : undefined;
|
|
121
|
+
const inst = d ? installed.get(d.name) : undefined;
|
|
122
|
+
if (!s && !inst)
|
|
123
|
+
continue; // fully unknown device — leave it out of the map
|
|
124
|
+
signals.set(name, {
|
|
125
|
+
reachable: s?.reachable,
|
|
126
|
+
headroom: s ? headroom(s) : undefined,
|
|
127
|
+
loadPercent: s?.loadPercent,
|
|
128
|
+
memPercent: s?.memPercent,
|
|
129
|
+
installed: inst?.installed,
|
|
130
|
+
signedIn: inst?.signedIn,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
cache.set(key, { at: now, signals });
|
|
134
|
+
return signals;
|
|
135
|
+
}
|