@phnx-labs/agents-cli 1.22.46 → 1.22.49
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 +663 -0
- package/LICENSE +102 -182
- package/README.md +96 -49
- package/dist/bootstrap.js +4 -2
- package/dist/cli/command-registry.d.ts +2 -1
- package/dist/cli/command-registry.js +6 -2
- package/dist/commands/accounts.d.ts +58 -0
- package/dist/commands/accounts.js +243 -25
- package/dist/commands/artifacts.js +7 -6
- package/dist/commands/attach.js +1 -1
- package/dist/commands/auth.js +7 -5
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +711 -62
- package/dist/commands/config.d.ts +0 -1
- package/dist/commands/config.js +26 -24
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/daemon.d.ts +21 -0
- package/dist/commands/daemon.js +147 -12
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +107 -53
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/focus.d.ts +14 -0
- package/dist/commands/focus.js +48 -5
- package/dist/commands/go.d.ts +3 -0
- package/dist/commands/go.js +16 -3
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/inspect.js +2 -2
- package/dist/commands/lease.d.ts +1 -1
- package/dist/commands/lease.js +7 -6
- package/dist/commands/mailboxes.js +4 -3
- package/dist/commands/menubar.js +11 -4
- package/dist/commands/projects.d.ts +16 -1
- package/dist/commands/projects.js +58 -64
- package/dist/commands/resume.js +1 -1
- package/dist/commands/route.js +4 -4
- package/dist/commands/routines.js +4 -3
- package/dist/commands/run-broadcast.d.ts +1 -1
- package/dist/commands/run-broadcast.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-picker.d.ts +22 -0
- package/dist/commands/sessions-picker.js +48 -9
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +5 -0
- package/dist/commands/setup-secrets.js +23 -0
- package/dist/commands/share.d.ts +20 -5
- package/dist/commands/share.js +152 -52
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +243 -18
- package/dist/commands/teams.js +34 -2
- package/dist/commands/traces.d.ts +2 -0
- package/dist/commands/traces.js +184 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +32 -62
- package/dist/commands/watchdog.js +9 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +84 -6
- package/dist/lib/accounting/usage.js +293 -75
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +25 -15
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/drivers/ssh.d.ts +16 -2
- package/dist/lib/browser/drivers/ssh.js +36 -9
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +40 -9
- package/dist/lib/browser/profiles.d.ts +104 -65
- package/dist/lib/browser/profiles.js +277 -165
- package/dist/lib/browser/registry.d.ts +39 -0
- package/dist/lib/browser/registry.js +149 -0
- package/dist/lib/browser/remote-control.d.ts +28 -2
- package/dist/lib/browser/remote-control.js +35 -3
- package/dist/lib/browser/resolve-target.d.ts +68 -0
- package/dist/lib/browser/resolve-target.js +250 -0
- package/dist/lib/browser/runtime-state.d.ts +44 -11
- package/dist/lib/browser/runtime-state.js +98 -15
- package/dist/lib/browser/service.d.ts +65 -9
- package/dist/lib/browser/service.js +196 -61
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/task-index.d.ts +70 -0
- package/dist/lib/browser/task-index.js +180 -0
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/byok-usage.js +2 -2
- package/dist/lib/claude-statusline.d.ts +29 -0
- package/dist/lib/claude-statusline.js +139 -0
- package/dist/lib/cloud/host.d.ts +1 -1
- package/dist/lib/cloud/host.js +1 -1
- package/dist/lib/cloud/rush.js +1 -1
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/computer/computer-rpc.js +2 -2
- package/dist/lib/computer/download.d.ts +15 -15
- package/dist/lib/computer/download.js +35 -98
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
- package/dist/lib/computer/ssh-tunnel.js +12 -22
- package/dist/lib/config-keys.d.ts +6 -11
- package/dist/lib/config-keys.js +18 -19
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +2 -2
- package/dist/lib/crabbox/lease.d.ts +9 -0
- package/dist/lib/crabbox/lease.js +70 -15
- package/dist/lib/crabbox/runtimes.js +1 -1
- package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
- package/dist/lib/daemon/account-state-daemon-service.js +26 -0
- package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
- package/dist/lib/daemon/browser-ipc-service.js +46 -0
- package/dist/lib/daemon/daemon.d.ts +3 -0
- package/dist/lib/daemon/daemon.js +212 -444
- package/dist/lib/daemon/device-probe-service.d.ts +26 -0
- package/dist/lib/daemon/device-probe-service.js +47 -0
- package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
- package/dist/lib/daemon/keychain-reap-service.js +32 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
- package/dist/lib/daemon/monitor-engine-service.js +29 -0
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
- package/dist/lib/daemon/secrets-broker-service.js +66 -0
- package/dist/lib/daemon/self-heal-service.d.ts +27 -0
- package/dist/lib/daemon/self-heal-service.js +48 -0
- package/dist/lib/daemon/service.d.ts +102 -0
- package/dist/lib/daemon/service.js +98 -0
- package/dist/lib/daemon/session-index-service.d.ts +22 -0
- package/dist/lib/daemon/session-index-service.js +40 -0
- package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
- package/dist/lib/daemon/state-dir-check-service.js +63 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +298 -0
- package/dist/lib/daemon/watchdog-service.d.ts +19 -0
- package/dist/lib/daemon/watchdog-service.js +40 -0
- package/dist/lib/daemon-health.d.ts +15 -0
- package/dist/lib/daemon-health.js +29 -5
- package/dist/lib/daemon-services.d.ts +8 -1
- package/dist/lib/daemon-services.js +55 -0
- package/dist/lib/daemon-ticks.d.ts +13 -2
- package/dist/lib/daemon-ticks.js +22 -20
- package/dist/lib/device-config.d.ts +25 -10
- package/dist/lib/device-config.js +90 -39
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +26 -0
- package/dist/lib/devices/stats-cache.js +59 -7
- package/dist/lib/devices/worker-pick.d.ts +48 -0
- package/dist/lib/devices/worker-pick.js +113 -0
- package/dist/lib/exec.d.ts +70 -14
- package/dist/lib/exec.js +110 -38
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/harness/adapters/cursor.js +8 -11
- package/dist/lib/helper-download.d.ts +108 -0
- package/dist/lib/helper-download.js +194 -0
- package/dist/lib/helper-versions.d.ts +52 -0
- package/dist/lib/helper-versions.js +58 -0
- package/dist/lib/hooks/cache.js +19 -0
- package/dist/lib/hooks/install.js +2 -2
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/credentials.d.ts +1 -1
- package/dist/lib/hosts/credentials.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +7 -1
- package/dist/lib/hosts/dispatch.js +24 -5
- package/dist/lib/hosts/passthrough.d.ts +8 -15
- package/dist/lib/hosts/passthrough.js +35 -16
- package/dist/lib/hosts/providers/devices.d.ts +1 -1
- package/dist/lib/hosts/providers/devices.js +1 -1
- package/dist/lib/hosts/reconnect.d.ts +179 -21
- package/dist/lib/hosts/reconnect.js +266 -45
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/installations/migrate.d.ts +4 -5
- package/dist/lib/installations/migrate.js +6 -7
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +23 -13
- package/dist/lib/installations/versions.js +25 -2
- package/dist/lib/linear-autoclose.d.ts +1 -1
- package/dist/lib/linear-autoclose.js +1 -1
- package/dist/lib/loop.d.ts +2 -2
- package/dist/lib/loop.js +1 -1
- package/dist/lib/menubar/download-menubar.d.ts +47 -0
- package/dist/lib/menubar/download-menubar.js +60 -0
- package/dist/lib/menubar/install-menubar.d.ts +44 -7
- package/dist/lib/menubar/install-menubar.js +105 -23
- package/dist/lib/menubar/notify-desktop.d.ts +3 -3
- package/dist/lib/menubar/notify-desktop.js +6 -6
- package/dist/lib/model-tiers.d.ts +1 -1
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/project-probe.d.ts +1 -1
- package/dist/lib/project-probe.js +1 -1
- package/dist/lib/project-pull.d.ts +1 -1
- package/dist/lib/project-pull.js +2 -2
- package/dist/lib/projects.d.ts +1 -1
- package/dist/lib/projects.js +1 -1
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/sandbox.js +6 -7
- package/dist/lib/secrets/download-keychain.d.ts +47 -0
- package/dist/lib/secrets/download-keychain.js +70 -0
- package/dist/lib/secrets/install-helper.d.ts +20 -1
- package/dist/lib/secrets/install-helper.js +44 -3
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +86 -2
- package/dist/lib/session/db.d.ts +11 -1
- package/dist/lib/session/db.js +142 -8
- package/dist/lib/session/discover.d.ts +23 -1
- package/dist/lib/session/discover.js +44 -10
- package/dist/lib/session/highlights.d.ts +49 -1
- package/dist/lib/session/highlights.js +95 -0
- package/dist/lib/session/host-link.d.ts +16 -2
- package/dist/lib/session/host-link.js +7 -1
- package/dist/lib/session/parse.js +33 -1
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/remote-list.d.ts +9 -0
- package/dist/lib/session/remote/remote-list.js +19 -2
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +17 -3
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.d.ts +53 -0
- package/dist/lib/session/session-cache.js +93 -1
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/tool-calls.d.ts +2 -0
- package/dist/lib/session/tool-calls.js +1 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +19 -0
- package/dist/lib/session/trajectory-html.js +770 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +284 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +384 -0
- package/dist/lib/session/types.d.ts +15 -0
- package/dist/lib/sha256-asset.d.ts +26 -0
- package/dist/lib/sha256-asset.js +41 -0
- package/dist/lib/share/backend.d.ts +95 -0
- package/dist/lib/share/backend.js +139 -0
- package/dist/lib/share/delete.d.ts +6 -2
- package/dist/lib/share/delete.js +8 -9
- package/dist/lib/share/html.d.ts +22 -0
- package/dist/lib/share/html.js +88 -0
- package/dist/lib/share/provision.d.ts +18 -4
- package/dist/lib/share/provision.js +38 -4
- package/dist/lib/share/publish.d.ts +39 -15
- package/dist/lib/share/publish.js +55 -26
- package/dist/lib/share/worker-template.js +171 -11
- package/dist/lib/sqlite.js +16 -3
- package/dist/lib/ssh-exec.d.ts +65 -0
- package/dist/lib/ssh-exec.js +120 -4
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +6 -3
- package/dist/lib/startup/dev-build.js +3 -3
- package/dist/lib/state.d.ts +2 -2
- package/dist/lib/state.js +11 -5
- package/dist/lib/subagents-registry.js +1 -1
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/terminal/types.js +1 -1
- package/dist/lib/tmux/orphan-reap.js +1 -1
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +204 -27
- package/dist/lib/traces/backend.d.ts +29 -0
- package/dist/lib/traces/backend.js +46 -0
- package/dist/lib/traces/classify.d.ts +52 -0
- package/dist/lib/traces/classify.js +72 -0
- package/dist/lib/traces/config.d.ts +10 -0
- package/dist/lib/traces/config.js +2 -0
- package/dist/lib/traces/provision.d.ts +19 -0
- package/dist/lib/traces/provision.js +30 -0
- package/dist/lib/traces/sync.d.ts +150 -0
- package/dist/lib/traces/sync.js +447 -0
- package/dist/lib/traces/worker-template.d.ts +4 -0
- package/dist/lib/traces/worker-template.js +159 -0
- package/dist/lib/types.d.ts +34 -19
- package/dist/lib/types.js +14 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +18 -4
- package/dist/lib/usage-refresh.js +68 -19
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/dist/lib/workflows.d.ts +1 -1
- package/package.json +5 -7
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
- package/dist/lib/usage-fleet.d.ts +0 -32
- package/dist/lib/usage-fleet.js +0 -125
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device resource probing for `agents devices list`.
|
|
3
3
|
*
|
|
4
|
-
* One SSH round-trip per device gathers load average, memory pressure, and core
|
|
4
|
+
* One SSH round-trip per device gathers load average, memory pressure, disk, and core
|
|
5
5
|
* count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
|
|
6
6
|
* into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
|
|
7
7
|
* the list stays responsive — a slow or hung box degrades to "no stats" instead
|
|
@@ -21,16 +21,20 @@ export const PROBE_TIMEOUT_MS = 2_500;
|
|
|
21
21
|
* routinely blows the 2.5s POSIX budget on a relayed connection. */
|
|
22
22
|
export const WIN_PROBE_TIMEOUT_MS = 6_000;
|
|
23
23
|
const SEP = '---AGSTAT---';
|
|
24
|
-
/** One-shot remote snapshot: load,
|
|
25
|
-
|
|
26
|
-
const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
|
|
24
|
+
/** One-shot remote snapshot: load, memory, core count, then root filesystem. */
|
|
25
|
+
export const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null); echo ${SEP}; df -Pk / 2>/dev/null | tail -1`;
|
|
27
26
|
/** Windows equivalent, one labeled line via CIM. PowerShell 5.1-safe: no `||`
|
|
28
27
|
* chaining, plain string concatenation. `LoadPercentage` is $null on some
|
|
29
28
|
* hosts/VMs, which concatenates to an empty field — the parser treats that as
|
|
30
29
|
* "no load signal" and headroom falls back to memory pressure alone.
|
|
31
30
|
* wrapRemoteCommand base64-encodes this for powershell-shell devices, so the
|
|
32
31
|
* quoting survives ssh intact. */
|
|
33
|
-
const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS)`;
|
|
32
|
+
export const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS + ' diskFreeKb=' + ($disk.FreeSpace / 1KB) + ' diskTotalKb=' + ($disk.Size / 1KB))`;
|
|
33
|
+
export function localProbeInvocation(platform) {
|
|
34
|
+
return platform === 'win32'
|
|
35
|
+
? { file: 'powershell', args: ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] }
|
|
36
|
+
: { file: 'sh', args: ['-c', PROBE_SNIPPET] };
|
|
37
|
+
}
|
|
34
38
|
/** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
|
|
35
39
|
export function fmtBytes(bytes) {
|
|
36
40
|
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
|
|
@@ -99,12 +103,28 @@ export function parseNcpu(out) {
|
|
|
99
103
|
const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
|
|
100
104
|
return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
|
|
101
105
|
}
|
|
102
|
-
/**
|
|
106
|
+
/** Parse the final data row from `df -k /`, deriving usage from block counts. */
|
|
107
|
+
export function parseDf(out) {
|
|
108
|
+
const columns = out.trim().split(/\s+/);
|
|
109
|
+
if (columns.length < 4)
|
|
110
|
+
return {};
|
|
111
|
+
const totalKb = Number(columns[1]);
|
|
112
|
+
const freeKb = Number(columns[3]);
|
|
113
|
+
if (!Number.isFinite(totalKb) || totalKb <= 0 || !Number.isFinite(freeKb) || freeKb < 0)
|
|
114
|
+
return {};
|
|
115
|
+
return {
|
|
116
|
+
diskTotalBytes: totalKb * 1024,
|
|
117
|
+
diskFreeBytes: freeKb * 1024,
|
|
118
|
+
diskUsedPercent: Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Assemble a DeviceStats from the four snippet sections. */
|
|
103
122
|
export function parseProbeOutput(host, stdout, fetchedAt) {
|
|
104
|
-
const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
|
|
123
|
+
const [uptimePart = '', memPart = '', ncpuPart = '', diskPart = ''] = stdout.split(SEP);
|
|
105
124
|
const { loadAvg1 } = parseUptime(uptimePart);
|
|
106
125
|
const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
|
|
107
126
|
const { ncpu } = parseNcpu(ncpuPart);
|
|
127
|
+
const disk = parseDf(diskPart);
|
|
108
128
|
const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
|
|
109
129
|
return {
|
|
110
130
|
host,
|
|
@@ -115,6 +135,8 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
|
|
|
115
135
|
memPercent: mem.memPercent,
|
|
116
136
|
memTotalBytes: mem.memTotalBytes,
|
|
117
137
|
memFreeBytes: mem.memFreeBytes,
|
|
138
|
+
...disk,
|
|
139
|
+
specsFetchedAt: fetchedAt,
|
|
118
140
|
fetchedAt,
|
|
119
141
|
};
|
|
120
142
|
}
|
|
@@ -122,13 +144,16 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
|
|
|
122
144
|
* (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
|
|
123
145
|
* numbers, mirroring how garbage POSIX output degrades. */
|
|
124
146
|
export function parseWinProbeOutput(host, stdout, fetchedAt) {
|
|
125
|
-
const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)
|
|
147
|
+
const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)(?: diskFreeKb=([0-9.]+) diskTotalKb=([0-9.]+))?/);
|
|
126
148
|
if (!m)
|
|
127
149
|
return { host, reachable: true, fetchedAt };
|
|
128
150
|
const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
|
|
129
151
|
const freeKb = parseInt(m[2], 10);
|
|
130
152
|
const totalKb = parseInt(m[3], 10);
|
|
131
153
|
const ncpu = parseInt(m[4], 10);
|
|
154
|
+
const diskFreeKb = m[5] === undefined ? undefined : Number(m[5]);
|
|
155
|
+
const diskTotalKb = m[6] === undefined ? undefined : Number(m[6]);
|
|
156
|
+
const hasDisk = diskTotalKb !== undefined && diskTotalKb > 0 && diskFreeKb !== undefined && diskFreeKb >= 0;
|
|
132
157
|
return {
|
|
133
158
|
host,
|
|
134
159
|
reachable: true,
|
|
@@ -137,6 +162,10 @@ export function parseWinProbeOutput(host, stdout, fetchedAt) {
|
|
|
137
162
|
memPercent: totalKb > 0 ? Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)) : undefined,
|
|
138
163
|
memTotalBytes: totalKb > 0 ? totalKb * 1024 : undefined,
|
|
139
164
|
memFreeBytes: totalKb > 0 ? freeKb * 1024 : undefined,
|
|
165
|
+
diskTotalBytes: hasDisk ? diskTotalKb * 1024 : undefined,
|
|
166
|
+
diskFreeBytes: hasDisk ? diskFreeKb * 1024 : undefined,
|
|
167
|
+
diskUsedPercent: hasDisk ? Math.max(0, Math.min(100, ((diskTotalKb - diskFreeKb) / diskTotalKb) * 100)) : undefined,
|
|
168
|
+
specsFetchedAt: fetchedAt,
|
|
140
169
|
fetchedAt,
|
|
141
170
|
};
|
|
142
171
|
}
|
|
@@ -208,8 +237,9 @@ export function probeDeviceStats(device, opts = {}) {
|
|
|
208
237
|
export function probeLocalStats(host, opts = {}) {
|
|
209
238
|
const fetchedAt = opts.now ?? Date.now();
|
|
210
239
|
const isWin = process.platform === 'win32';
|
|
240
|
+
const invocation = localProbeInvocation(process.platform);
|
|
211
241
|
return new Promise((resolve) => {
|
|
212
|
-
execFile(
|
|
242
|
+
execFile(invocation.file, invocation.args, { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
|
|
213
243
|
if (err || !stdout)
|
|
214
244
|
return resolve({ host, reachable: false, fetchedAt });
|
|
215
245
|
resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The reserved `--device` value meaning the box the human is at. */
|
|
2
|
+
export declare const INTERACTIVE_DEVICE_SENTINEL = "interactive";
|
|
3
|
+
/** True when a host flag value is the interactive sentinel. */
|
|
4
|
+
export declare function isDeviceInteractive(value: string | undefined | null): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* The device pinned as `interactive.host`, or null when unset.
|
|
7
|
+
*
|
|
8
|
+
* Callers MUST refuse on null rather than falling back to the local machine.
|
|
9
|
+
* The whole point of the sentinel is reaching a screen someone is watching;
|
|
10
|
+
* silently running on a headless worker instead is the exact failure it exists
|
|
11
|
+
* to prevent, and it would fail invisibly.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveInteractiveDevice(): string | null;
|
|
14
|
+
/** The actionable error for an unset pin — shared so every call site says the same thing. */
|
|
15
|
+
export declare function interactiveUnsetError(): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `interactive` device sentinel: "wherever the human is sitting".
|
|
3
|
+
*
|
|
4
|
+
* `--device auto` means "pick a box by load". This means the opposite — one
|
|
5
|
+
* specific box, the one whose screen a person is actually looking at, pinned as
|
|
6
|
+
* `interactive.host`.
|
|
7
|
+
*
|
|
8
|
+
* It exists because a skill cannot teach a host name. Guidance that says
|
|
9
|
+
* "deliver it to zion" is wrong on every other fleet and stale the moment the
|
|
10
|
+
* pin changes, so agents were left to infer the target or to skip the step. A
|
|
11
|
+
* fixed token is something a SKILL.md can state literally and have be correct
|
|
12
|
+
* everywhere.
|
|
13
|
+
*
|
|
14
|
+
* Resolution happens at the dispatch site, before `isSelfHost`, exactly like
|
|
15
|
+
* `auto` — so a pin naming this machine runs locally instead of self-SSHing, and
|
|
16
|
+
* the forwarded argv has the routing flag stripped, which is what keeps this
|
|
17
|
+
* from recursing.
|
|
18
|
+
*/
|
|
19
|
+
import { getConfigValue } from '../device-config.js';
|
|
20
|
+
import { RESERVED_DEVICE_NAMES } from './registry.js';
|
|
21
|
+
/** The reserved `--device` value meaning the box the human is at. */
|
|
22
|
+
export const INTERACTIVE_DEVICE_SENTINEL = 'interactive';
|
|
23
|
+
/** True when a host flag value is the interactive sentinel. */
|
|
24
|
+
export function isDeviceInteractive(value) {
|
|
25
|
+
return typeof value === 'string' && value.trim().toLowerCase() === INTERACTIVE_DEVICE_SENTINEL;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The device pinned as `interactive.host`, or null when unset.
|
|
29
|
+
*
|
|
30
|
+
* Callers MUST refuse on null rather than falling back to the local machine.
|
|
31
|
+
* The whole point of the sentinel is reaching a screen someone is watching;
|
|
32
|
+
* silently running on a headless worker instead is the exact failure it exists
|
|
33
|
+
* to prevent, and it would fail invisibly.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveInteractiveDevice() {
|
|
36
|
+
const pinned = getConfigValue('interactive.host').value;
|
|
37
|
+
if (typeof pinned !== 'string' || !pinned.trim())
|
|
38
|
+
return null;
|
|
39
|
+
const host = pinned.trim();
|
|
40
|
+
// Defensive only. `interactive.host` rejects every reserved sentinel at WRITE
|
|
41
|
+
// time (assertValidDeviceName), so a pin of "interactive" or "auto" cannot be
|
|
42
|
+
// stored in the first place — which is the right layer, because refusing on
|
|
43
|
+
// read could only ever say "none is set" and send the user back to the command
|
|
44
|
+
// they just ran. This catches a config written by an older version.
|
|
45
|
+
if (RESERVED_DEVICE_NAMES.has(host.toLowerCase()))
|
|
46
|
+
return null;
|
|
47
|
+
return host;
|
|
48
|
+
}
|
|
49
|
+
/** The actionable error for an unset pin — shared so every call site says the same thing. */
|
|
50
|
+
export function interactiveUnsetError() {
|
|
51
|
+
return (`--device interactive needs an interactive host pinned, and none is set.\n` +
|
|
52
|
+
` Set it on the machine you sit at: agents config set interactive.host <device>`);
|
|
53
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Meta } from '../types.js';
|
|
2
|
+
import type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
1
3
|
/** Operating-system family of a device, used to pick the remote shell. */
|
|
2
4
|
export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
|
|
3
5
|
/** Remote shell dialect derived from the platform. */
|
|
@@ -84,8 +86,36 @@ export interface DeviceProfile {
|
|
|
84
86
|
export declare function isDialableDevice(d: DeviceProfile): boolean;
|
|
85
87
|
/** Map of device name to profile. */
|
|
86
88
|
export type DeviceRegistry = Record<string, DeviceProfile>;
|
|
87
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* `--device` values that mean "resolve me", not "connect to a box with this
|
|
91
|
+
* name". A real device registered under one of these would be unreachable,
|
|
92
|
+
* and pinning `interactive.host` to one is a misconfiguration that has to fail
|
|
93
|
+
* at WRITE time — otherwise the read side can only report "none is set", which
|
|
94
|
+
* tells the user to run the command they just ran.
|
|
95
|
+
*/
|
|
96
|
+
export declare const RESERVED_DEVICE_NAMES: Set<string>;
|
|
97
|
+
/**
|
|
98
|
+
* Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
|
|
99
|
+
*
|
|
100
|
+
* SHAPE ONLY. Safe on read paths, which must keep working for a name that is
|
|
101
|
+
* already registered — including one this version would refuse to create.
|
|
102
|
+
*/
|
|
88
103
|
export declare function assertValidDeviceName(name: string): void;
|
|
104
|
+
/**
|
|
105
|
+
* Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
|
|
106
|
+
* routing sentinel.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately separate from {@link assertValidDeviceName}, and called from
|
|
109
|
+
* exactly two kinds of place: `agents devices add <name>`, and the config keys
|
|
110
|
+
* that point AT a device (`interactive.host`).
|
|
111
|
+
*
|
|
112
|
+
* Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
|
|
113
|
+
* receive tailnet node names the user never typed — `devices sync` upserts every
|
|
114
|
+
* observed node in a loop with no per-node catch, so one node named `auto` would
|
|
115
|
+
* abort the entire sync and register nothing after it. A name the fleet OBSERVES
|
|
116
|
+
* is not a name anyone CHOSE; only the second kind is policy's business.
|
|
117
|
+
*/
|
|
118
|
+
export declare function assertRegistrableDeviceName(name: string): void;
|
|
89
119
|
/** Map a Tailscale `OS` field to our platform enum. */
|
|
90
120
|
export declare function platformFromOs(os: string | undefined): DevicePlatform;
|
|
91
121
|
/** The remote shell a platform speaks. */
|
|
@@ -132,12 +162,39 @@ export declare function upsertDevice(name: string, input: DeviceInput): Promise<
|
|
|
132
162
|
export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
|
|
133
163
|
/** Remove a device. Returns false if it was not registered. */
|
|
134
164
|
export declare function removeDevice(name: string): Promise<boolean>;
|
|
135
|
-
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
165
|
+
/**
|
|
166
|
+
* The ignore-list: tailscale node names the user explicitly dismissed from
|
|
167
|
+
* auto-discovery. A dismissed node is deliberately NOT a device (it never
|
|
168
|
+
* enters the registry), so it has no per-device doc — its home is the central,
|
|
169
|
+
* TRACKED `~/.agents/agents.yaml` under a `fleet.ignored` list, which syncs
|
|
170
|
+
* fleet-wide via `agents repo push/pull`: a dismissal on one box stops the
|
|
171
|
+
* suggestion on every box (RUSH-3062). Auto-discovery (`runDeviceSync`'s
|
|
172
|
+
* pending diff) subtracts this set, so an ignored node never re-surfaces as a
|
|
173
|
+
* suggestion. Writes go through `updateMeta` — the central-config write path
|
|
174
|
+
* (withMetaLock + atomic write) — so two agents writing concurrently cannot
|
|
175
|
+
* corrupt it, and no second lock is hand-rolled here.
|
|
176
|
+
*/
|
|
177
|
+
export type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
178
|
+
/**
|
|
179
|
+
* The full ignore-list entries — who dismissed a node, when, and on which box —
|
|
180
|
+
* the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
|
|
181
|
+
* []. A malformed block is a hard error for the same reason the registry is:
|
|
182
|
+
* silently returning [] would let the next write wipe the user's dismissals.
|
|
183
|
+
*/
|
|
184
|
+
export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
|
|
185
|
+
/** Load the set of ignored node names. Same corruption contract as
|
|
186
|
+
* {@link loadIgnoredEntries}. */
|
|
138
187
|
export declare function loadIgnored(): Promise<Set<string>>;
|
|
139
188
|
/** True if `name` is on the ignore-list. */
|
|
140
189
|
export declare function isIgnored(name: string): Promise<boolean>;
|
|
190
|
+
/**
|
|
191
|
+
* Union `names` into the meta's ignore-list, stamping new entries with
|
|
192
|
+
* `ignoredAt` and THIS machine's id. Existing entries keep their original
|
|
193
|
+
* who/when, so re-adding a name is a true no-op. Returns the input unchanged
|
|
194
|
+
* when no name is new. Exported for the one-shot legacy-store migration in
|
|
195
|
+
* lib/devices/config-migration.ts.
|
|
196
|
+
*/
|
|
197
|
+
export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
|
|
141
198
|
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
142
199
|
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
143
200
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
@@ -17,8 +17,9 @@ import * as fs from 'fs/promises';
|
|
|
17
17
|
import * as fsSync from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import lockfile from 'proper-lockfile';
|
|
20
|
-
import { getDevicesRegistryPath,
|
|
20
|
+
import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
|
|
21
21
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
22
|
+
import { machineId } from '../machine-id.js';
|
|
22
23
|
/**
|
|
23
24
|
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
24
25
|
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
@@ -62,12 +63,46 @@ function registryPath() {
|
|
|
62
63
|
/** Valid logical device name: the ssh-alias charset, so it renders into an
|
|
63
64
|
* unambiguous `Host` stanza and is safe as an ssh target. */
|
|
64
65
|
const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
65
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* `--device` values that mean "resolve me", not "connect to a box with this
|
|
68
|
+
* name". A real device registered under one of these would be unreachable,
|
|
69
|
+
* and pinning `interactive.host` to one is a misconfiguration that has to fail
|
|
70
|
+
* at WRITE time — otherwise the read side can only report "none is set", which
|
|
71
|
+
* tells the user to run the command they just ran.
|
|
72
|
+
*/
|
|
73
|
+
export const RESERVED_DEVICE_NAMES = new Set(['auto', 'interactive', 'all']);
|
|
74
|
+
/**
|
|
75
|
+
* Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
|
|
76
|
+
*
|
|
77
|
+
* SHAPE ONLY. Safe on read paths, which must keep working for a name that is
|
|
78
|
+
* already registered — including one this version would refuse to create.
|
|
79
|
+
*/
|
|
66
80
|
export function assertValidDeviceName(name) {
|
|
67
81
|
if (!DEVICE_NAME_RE.test(name)) {
|
|
68
82
|
throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
|
|
69
83
|
}
|
|
70
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
|
|
87
|
+
* routing sentinel.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately separate from {@link assertValidDeviceName}, and called from
|
|
90
|
+
* exactly two kinds of place: `agents devices add <name>`, and the config keys
|
|
91
|
+
* that point AT a device (`interactive.host`).
|
|
92
|
+
*
|
|
93
|
+
* Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
|
|
94
|
+
* receive tailnet node names the user never typed — `devices sync` upserts every
|
|
95
|
+
* observed node in a loop with no per-node catch, so one node named `auto` would
|
|
96
|
+
* abort the entire sync and register nothing after it. A name the fleet OBSERVES
|
|
97
|
+
* is not a name anyone CHOSE; only the second kind is policy's business.
|
|
98
|
+
*/
|
|
99
|
+
export function assertRegistrableDeviceName(name) {
|
|
100
|
+
assertValidDeviceName(name);
|
|
101
|
+
if (RESERVED_DEVICE_NAMES.has(name.trim().toLowerCase())) {
|
|
102
|
+
throw new Error(`${JSON.stringify(name)} is a reserved --device value, not a device name. ` +
|
|
103
|
+
`Reserved: ${[...RESERVED_DEVICE_NAMES].join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
71
106
|
/** Map a Tailscale `OS` field to our platform enum. */
|
|
72
107
|
export function platformFromOs(os) {
|
|
73
108
|
switch ((os ?? '').toLowerCase()) {
|
|
@@ -246,55 +281,76 @@ export async function removeDevice(name) {
|
|
|
246
281
|
return true;
|
|
247
282
|
});
|
|
248
283
|
}
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
/**
|
|
285
|
+
* The full ignore-list entries — who dismissed a node, when, and on which box —
|
|
286
|
+
* the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
|
|
287
|
+
* []. A malformed block is a hard error for the same reason the registry is:
|
|
288
|
+
* silently returning [] would let the next write wipe the user's dismissals.
|
|
289
|
+
*/
|
|
290
|
+
export function loadIgnoredEntries(meta = readMeta()) {
|
|
291
|
+
const raw = meta.fleet?.ignored;
|
|
292
|
+
if (raw === undefined)
|
|
293
|
+
return [];
|
|
294
|
+
if (!Array.isArray(raw) ||
|
|
295
|
+
raw.some((e) => !e ||
|
|
296
|
+
typeof e.name !== 'string' ||
|
|
297
|
+
typeof e.ignoredAt !== 'string' ||
|
|
298
|
+
typeof e.ignoredOn !== 'string')) {
|
|
299
|
+
throw new Error(`Device ignore-list corrupted in agents.yaml (fleet.ignored): expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair ~/.agents/agents.yaml.`);
|
|
300
|
+
}
|
|
301
|
+
return raw;
|
|
251
302
|
}
|
|
252
|
-
/** Load the set of ignored node names.
|
|
253
|
-
*
|
|
254
|
-
* [] would let the next write wipe the user's dismissals. */
|
|
303
|
+
/** Load the set of ignored node names. Same corruption contract as
|
|
304
|
+
* {@link loadIgnoredEntries}. */
|
|
255
305
|
export async function loadIgnored() {
|
|
256
|
-
|
|
257
|
-
let raw;
|
|
258
|
-
try {
|
|
259
|
-
raw = await fs.readFile(p, 'utf-8');
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
if (err && err.code === 'ENOENT')
|
|
263
|
-
return new Set();
|
|
264
|
-
throw err;
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const parsed = JSON.parse(raw);
|
|
268
|
-
return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
|
|
269
|
-
}
|
|
270
|
-
catch (err) {
|
|
271
|
-
throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
272
|
-
}
|
|
306
|
+
return new Set(loadIgnoredEntries().map((e) => e.name));
|
|
273
307
|
}
|
|
274
308
|
/** True if `name` is on the ignore-list. */
|
|
275
309
|
export async function isIgnored(name) {
|
|
276
310
|
return (await loadIgnored()).has(name);
|
|
277
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Union `names` into the meta's ignore-list, stamping new entries with
|
|
314
|
+
* `ignoredAt` and THIS machine's id. Existing entries keep their original
|
|
315
|
+
* who/when, so re-adding a name is a true no-op. Returns the input unchanged
|
|
316
|
+
* when no name is new. Exported for the one-shot legacy-store migration in
|
|
317
|
+
* lib/devices/config-migration.ts.
|
|
318
|
+
*/
|
|
319
|
+
export function withIgnoredAdded(meta, names, ignoredAt) {
|
|
320
|
+
const entries = loadIgnoredEntries(meta); // throws on a corrupted block — never wipe it
|
|
321
|
+
const have = new Set(entries.map((e) => e.name));
|
|
322
|
+
const fresh = names.filter((n) => !have.has(n));
|
|
323
|
+
if (fresh.length === 0)
|
|
324
|
+
return meta;
|
|
325
|
+
const fleet = (meta.fleet ?? { devices: {} });
|
|
326
|
+
const ignored = [
|
|
327
|
+
...entries,
|
|
328
|
+
...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
|
|
329
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
330
|
+
const nextFleet = { ...fleet, ignored };
|
|
331
|
+
return { ...meta, fleet: nextFleet };
|
|
332
|
+
}
|
|
278
333
|
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
279
334
|
export async function addIgnored(name) {
|
|
280
335
|
assertValidDeviceName(name);
|
|
281
|
-
const
|
|
282
|
-
return
|
|
283
|
-
const set = await loadIgnored();
|
|
284
|
-
set.add(name);
|
|
285
|
-
atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
286
|
-
return set;
|
|
287
|
-
});
|
|
336
|
+
const meta = updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
|
|
337
|
+
return new Set(loadIgnoredEntries(meta).map((e) => e.name));
|
|
288
338
|
}
|
|
289
339
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
290
340
|
* not ignored. */
|
|
291
341
|
export async function removeIgnored(name) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
296
|
-
return
|
|
297
|
-
|
|
298
|
-
|
|
342
|
+
let removed = false;
|
|
343
|
+
updateMeta((m) => {
|
|
344
|
+
const fleet = m.fleet;
|
|
345
|
+
if (!fleet?.ignored)
|
|
346
|
+
return m;
|
|
347
|
+
const entries = loadIgnoredEntries(m);
|
|
348
|
+
const next = entries.filter((e) => e.name !== name);
|
|
349
|
+
if (next.length === entries.length)
|
|
350
|
+
return m;
|
|
351
|
+
removed = true;
|
|
352
|
+
const nextFleet = { ...fleet, ignored: next };
|
|
353
|
+
return { ...m, fleet: nextFleet };
|
|
299
354
|
});
|
|
355
|
+
return removed;
|
|
300
356
|
}
|
|
@@ -8,8 +8,32 @@ import type { DeviceProfile } from './registry.js';
|
|
|
8
8
|
* rewrites the cache, unreachable boxes included).
|
|
9
9
|
*/
|
|
10
10
|
export declare const STATS_STALE_MS: number;
|
|
11
|
+
/** Static hardware totals are valid for seven days. */
|
|
12
|
+
export declare const SPECS_STALE_MS: number;
|
|
11
13
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
12
14
|
export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
|
|
15
|
+
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
16
|
+
export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Carry a device's last successfully-probed hardware facts onto a row whose
|
|
19
|
+
* probe just came back unreachable (RUSH-3096).
|
|
20
|
+
*
|
|
21
|
+
* A failed probe knows nothing about the box — `probeDeviceStats` resolves
|
|
22
|
+
* `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
|
|
23
|
+
* cached one, so `agents devices list` rendered an offline device with an empty
|
|
24
|
+
* `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
|
|
25
|
+
* RAM, and root-disk capacity do not change while a machine is down, so they
|
|
26
|
+
* survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
|
|
27
|
+
* current-state readings and stay absent rather than render a stale number as
|
|
28
|
+
* live.
|
|
29
|
+
*
|
|
30
|
+
* `specsFetchedAt` keeps the moment the facts were actually observed while
|
|
31
|
+
* `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
|
|
32
|
+
* then; unreachable, as of now" instead of backdating a reading that never
|
|
33
|
+
* happened. A reachable probe is returned untouched — its own reading is the
|
|
34
|
+
* truth, including a fact the box has stopped reporting.
|
|
35
|
+
*/
|
|
36
|
+
export declare function retainHardwareFacts(probed: DeviceStats, prior: DeviceStats | undefined): DeviceStats;
|
|
13
37
|
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
14
38
|
export declare function readStatsCache(): Record<string, DeviceStats>;
|
|
15
39
|
/**
|
|
@@ -30,6 +54,8 @@ export interface FleetStatsResult {
|
|
|
30
54
|
export interface LoadFleetStatsOptions {
|
|
31
55
|
/** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
|
|
32
56
|
forceRefresh?: boolean;
|
|
57
|
+
/** Read only static hardware facts, whose cache lifetime is seven days. */
|
|
58
|
+
specsOnly?: boolean;
|
|
33
59
|
/** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
|
|
34
60
|
selfName?: string;
|
|
35
61
|
/** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
|
|
@@ -37,10 +37,55 @@ const CACHE_FILE = '.fleet-stats.json';
|
|
|
37
37
|
* rewrites the cache, unreachable boxes included).
|
|
38
38
|
*/
|
|
39
39
|
export const STATS_STALE_MS = 3 * 60_000;
|
|
40
|
+
/** Static hardware totals are valid for seven days. */
|
|
41
|
+
export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
|
|
40
42
|
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
41
43
|
export function isFreshDeviceStats(stats, now = Date.now()) {
|
|
42
44
|
return now - stats.fetchedAt <= STATS_STALE_MS;
|
|
43
45
|
}
|
|
46
|
+
/** True when cached core, RAM-total, and disk-total facts remain current. */
|
|
47
|
+
export function isFreshDeviceSpecs(stats, now = Date.now()) {
|
|
48
|
+
const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
|
|
49
|
+
return now - fetchedAt <= SPECS_STALE_MS;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Carry a device's last successfully-probed hardware facts onto a row whose
|
|
53
|
+
* probe just came back unreachable (RUSH-3096).
|
|
54
|
+
*
|
|
55
|
+
* A failed probe knows nothing about the box — `probeDeviceStats` resolves
|
|
56
|
+
* `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
|
|
57
|
+
* cached one, so `agents devices list` rendered an offline device with an empty
|
|
58
|
+
* `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
|
|
59
|
+
* RAM, and root-disk capacity do not change while a machine is down, so they
|
|
60
|
+
* survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
|
|
61
|
+
* current-state readings and stay absent rather than render a stale number as
|
|
62
|
+
* live.
|
|
63
|
+
*
|
|
64
|
+
* `specsFetchedAt` keeps the moment the facts were actually observed while
|
|
65
|
+
* `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
|
|
66
|
+
* then; unreachable, as of now" instead of backdating a reading that never
|
|
67
|
+
* happened. A reachable probe is returned untouched — its own reading is the
|
|
68
|
+
* truth, including a fact the box has stopped reporting.
|
|
69
|
+
*/
|
|
70
|
+
export function retainHardwareFacts(probed, prior) {
|
|
71
|
+
if (probed.reachable || !prior)
|
|
72
|
+
return probed;
|
|
73
|
+
const ncpu = probed.ncpu ?? prior.ncpu;
|
|
74
|
+
const memTotalBytes = probed.memTotalBytes ?? prior.memTotalBytes;
|
|
75
|
+
const diskTotalBytes = probed.diskTotalBytes ?? prior.diskTotalBytes;
|
|
76
|
+
if (ncpu === probed.ncpu &&
|
|
77
|
+
memTotalBytes === probed.memTotalBytes &&
|
|
78
|
+
diskTotalBytes === probed.diskTotalBytes) {
|
|
79
|
+
return probed;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
...probed,
|
|
83
|
+
ncpu,
|
|
84
|
+
memTotalBytes,
|
|
85
|
+
diskTotalBytes,
|
|
86
|
+
specsFetchedAt: prior.specsFetchedAt ?? prior.fetchedAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
44
89
|
function cacheFilePath() {
|
|
45
90
|
return path.join(getCacheDir(), CACHE_FILE);
|
|
46
91
|
}
|
|
@@ -88,18 +133,22 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
88
133
|
const writeCache = opts.writeCache ?? writeStatsCache;
|
|
89
134
|
const self = opts.selfName;
|
|
90
135
|
const now = opts.now ?? Date.now();
|
|
91
|
-
|
|
136
|
+
// Always read the cache, even under --refresh. It is not *served* then, but it
|
|
137
|
+
// is the only record of each box's hardware, and a failed probe must not erase
|
|
138
|
+
// it — see {@link retainHardwareFacts}.
|
|
139
|
+
const cache = readCache();
|
|
92
140
|
const stats = new Map();
|
|
93
141
|
const toProbe = [];
|
|
94
142
|
let servedFromCache = false;
|
|
95
143
|
for (const d of devices) {
|
|
96
|
-
if (d.name === self) {
|
|
144
|
+
if (d.name === self && !opts.specsOnly) {
|
|
97
145
|
// This machine is always probed locally — cheap, no ssh, always live.
|
|
98
146
|
toProbe.push(d);
|
|
99
147
|
continue;
|
|
100
148
|
}
|
|
101
|
-
const cached = cache[d.name];
|
|
102
|
-
|
|
149
|
+
const cached = opts.forceRefresh ? undefined : cache[d.name];
|
|
150
|
+
const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
|
|
151
|
+
if (cached && cacheFresh) {
|
|
103
152
|
stats.set(d.name, cached);
|
|
104
153
|
servedFromCache = true;
|
|
105
154
|
}
|
|
@@ -113,8 +162,11 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
113
162
|
const probed = await probeFleet(toProbe, { selfName: self });
|
|
114
163
|
const fresh = {};
|
|
115
164
|
for (const [name, s] of probed) {
|
|
116
|
-
|
|
117
|
-
|
|
165
|
+
// A box that just failed to answer keeps the hardware it was last seen
|
|
166
|
+
// with, so this call and the cache it writes both carry the spec.
|
|
167
|
+
const row = retainHardwareFacts(s, cache[name]);
|
|
168
|
+
stats.set(name, row);
|
|
169
|
+
fresh[name] = row;
|
|
118
170
|
}
|
|
119
171
|
if (Object.keys(fresh).length > 0)
|
|
120
172
|
writeCache(fresh);
|
|
@@ -123,7 +175,7 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
123
175
|
// list (e.g. self not registered as an ssh target) — matches the old callers'
|
|
124
176
|
// explicit local fallback.
|
|
125
177
|
if (self && !stats.has(self)) {
|
|
126
|
-
stats.set(self, await probeLocal(self));
|
|
178
|
+
stats.set(self, retainHardwareFacts(await probeLocal(self), cache[self]));
|
|
127
179
|
}
|
|
128
180
|
let oldest = null;
|
|
129
181
|
for (const s of stats.values()) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type DevicePlacementSignal } from '../teams/scheduler.js';
|
|
2
|
+
/** Why a candidate was dropped, for the fail-loud error message. */
|
|
3
|
+
export interface WorkerExclusion {
|
|
4
|
+
device: string;
|
|
5
|
+
reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
|
|
6
|
+
}
|
|
7
|
+
export interface WorkerPickPlan {
|
|
8
|
+
/** The chosen device name. Never the local box unless it is an auto-pool member. */
|
|
9
|
+
device: string;
|
|
10
|
+
/** True when the pick IS this machine (the caller should run in place). */
|
|
11
|
+
isLocal: boolean;
|
|
12
|
+
candidates: Array<{
|
|
13
|
+
device: string;
|
|
14
|
+
loadPercent?: number;
|
|
15
|
+
headroom?: string;
|
|
16
|
+
}>;
|
|
17
|
+
excluded: WorkerExclusion[];
|
|
18
|
+
}
|
|
19
|
+
export interface WorkerPickOptions {
|
|
20
|
+
/** Restrict to these platforms. Defaults to POSIX (`linux`, `macos`). */
|
|
21
|
+
platforms?: string[];
|
|
22
|
+
/** Override the candidate pool (tests). */
|
|
23
|
+
eligibleHosts?: string[];
|
|
24
|
+
localMachine?: string;
|
|
25
|
+
/** Injected probe (tests). */
|
|
26
|
+
probe?: (pool: string[]) => Promise<Map<string, DevicePlacementSignal>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Platforms a bun/vitest offload can actually run on.
|
|
30
|
+
*
|
|
31
|
+
* Windows is excluded by policy, not by accident: `AGENTS.md` states Windows is
|
|
32
|
+
* not a required PR or ordinary-release platform and `tests.yml` runs it only as
|
|
33
|
+
* a `continue-on-error` post-merge smoke. Silently shipping the tree to
|
|
34
|
+
* `win-mini` would produce a confusing failure for a platform whose result
|
|
35
|
+
* nobody gates on.
|
|
36
|
+
*/
|
|
37
|
+
export declare const POSIX_PLATFORMS: readonly ["linux", "macos"];
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the least-loaded eligible worker.
|
|
40
|
+
*
|
|
41
|
+
* Throws — never returns a degraded answer. An offload helper that quietly
|
|
42
|
+
* decided "no worker, run here" would recreate exactly the failure the offload
|
|
43
|
+
* exists to prevent: the operator believes the work went to the fleet while
|
|
44
|
+
* their laptop is pinned. Callers surface the thrown message verbatim.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveWorkerDevice(opts?: WorkerPickOptions): Promise<WorkerPickPlan>;
|
|
47
|
+
/** The one no-worker message, naming every candidate and why it was dropped. */
|
|
48
|
+
export declare function formatNoWorkerError(excluded: WorkerExclusion[], platforms: Set<string>): string;
|