@phnx-labs/agents-cli 1.20.69 → 1.20.70
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 +21 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/ssh.js +39 -10
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -0
- package/dist/lib/devices/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.70
|
|
4
|
+
|
|
5
|
+
- **Fix `agents setup computer` / `agents computer setup` refusing to install a
|
|
6
|
+
valid downloaded helper.** The signature check read `codesign -dv` from stdout,
|
|
7
|
+
but that command writes its details to **stderr** on success — so the Team-ID
|
|
8
|
+
check saw an empty string, found no `TeamIdentifier`, and rejected every
|
|
9
|
+
validly-signed, notarized helper with "signed by unexpected Team (none)". It now
|
|
10
|
+
reads both streams via `spawnSync`. Verified end-to-end against the real
|
|
11
|
+
published `v1.20.69` release asset (download → sha256 → extract → codesign +
|
|
12
|
+
Team `2HTP252L87` + `spctl` notarization → install). Source:
|
|
13
|
+
`apps/cli/src/lib/computer/download.ts`.
|
|
14
|
+
|
|
15
|
+
- **The bundled macOS menu-bar helper is now a true universal binary on
|
|
16
|
+
Xcode-less release hosts.** `menubar/scripts/build.sh release` used
|
|
17
|
+
`swift build --arch arm64 --arch x86_64` (needs Xcode's xcbuild) and, on a
|
|
18
|
+
Command-Line-Tools-only host, silently fell back to a **single-arch** build —
|
|
19
|
+
shipping an arm64-only `MenubarHelper.app` in the tarball that could not run on
|
|
20
|
+
Intel Macs. It now builds each slice via `--triple` and `lipo`s them into one
|
|
21
|
+
universal binary, matching the computer helper. Source:
|
|
22
|
+
`apps/cli/menubar/scripts/build.sh`.
|
|
23
|
+
|
|
3
24
|
## 1.20.69
|
|
4
25
|
|
|
5
26
|
- **Choose a safe account with `agents run <agent>@`.** A trailing `@` opens a
|
package/README.md
CHANGED
|
@@ -432,13 +432,15 @@ agents sync --host gpu-box # make the remote machine current
|
|
|
432
432
|
agents doctor --devices # readiness matrix for every registered device
|
|
433
433
|
agents doctor --devices --json # machine-readable fleet readiness
|
|
434
434
|
agents doctor --device mac-mini # same matrix, scoped to one device
|
|
435
|
-
agents fleet status # warnings rollup + health/sync/version matrix
|
|
435
|
+
agents fleet status # warnings rollup + health/sync/version/Auth matrix (cache-first)
|
|
436
|
+
agents fleet status --live # force a live resource probe (alias of --refresh)
|
|
436
437
|
agents fleet status --json --strict # scriptable fleet health gate
|
|
437
438
|
agents check --devices # CI drift gate across every registered device
|
|
438
439
|
|
|
439
440
|
# Your Tailscale fleet, auto-discovered
|
|
440
441
|
agents devices sync # ingest `tailscale status`
|
|
441
|
-
agents devices list # fleet +
|
|
442
|
+
agents devices list # fleet + headroom: load, mem, idle/busy — which box has room (cache-first)
|
|
443
|
+
agents devices list --live # force a live probe of every device (alias of --refresh)
|
|
442
444
|
agents devices list --full # add per-device cores and free/total RAM
|
|
443
445
|
agents devices list --no-stats # instant: names/addresses only, skip the probe
|
|
444
446
|
agents ssh mac-mini # hardened SSH: fails fast if offline,
|
|
@@ -453,11 +455,22 @@ agents cloud run "nightly benchmark" --host gpu-box --agent claude # task in c
|
|
|
453
455
|
agents routines add nightly -s "0 2 * * *" -a claude -p "run the sweep" --run-on gpu-box
|
|
454
456
|
```
|
|
455
457
|
|
|
456
|
-
`agents devices list`
|
|
457
|
-
|
|
458
|
-
pressure, and an idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
|
|
458
|
+
`agents devices list` shows normalized load, memory pressure, and an
|
|
459
|
+
idle/light/busy/loaded headroom badge, plus a fleet-capacity summary
|
|
459
460
|
(`164 cores · 421G free / 518G RAM`). It answers "which machine has room right now?" —
|
|
460
|
-
the utilization signal the teammate scheduler doesn't yet see.
|
|
461
|
+
the utilization signal the teammate scheduler doesn't yet see. It's **cache-first**:
|
|
462
|
+
reads serve instantly from a stats cache the daemon warms (~every 3 min), probing only
|
|
463
|
+
this machine locally plus any device missing from the cache; pass `--refresh` (or the
|
|
464
|
+
shorter `--live`) to force a full live probe of every box. Cache-served output notes its
|
|
465
|
+
age (`updated 2m ago — pass --refresh (--live) for a live probe`).
|
|
466
|
+
|
|
467
|
+
`agents fleet status` adds an **Auth column** — which agent accounts are actually
|
|
468
|
+
logged in, per device, read from the auth-health cache (no network). Four buckets keep
|
|
469
|
+
it from crying wolf: `●live` (verified), `·present` (signed in but the agent has no
|
|
470
|
+
live-probe endpoint — e.g. codex/grok — benign), `◐degraded` (soft/self-healing:
|
|
471
|
+
expired-but-refreshing, rate-limited), and `○revoked` (server rejected — re-login now).
|
|
472
|
+
Only `○` means a real re-login is needed. Run `agents fleet ping` to force a live
|
|
473
|
+
re-verification across the fleet.
|
|
461
474
|
|
|
462
475
|
**Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH and feed one host pool: devices appear in `agents hosts list` and capability routing without a second enrollment. On `--host` runs every `agents run` option is either forwarded (`--effort --env --timeout --loop …`), rejected loud (`--secrets` never crosses SSH implicitly), or consumed locally — nothing silently drops. See [docs/00-concepts.md](apps/cli/docs/00-concepts.md#devices--hosts).
|
|
463
476
|
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/doctor.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalled
|
|
|
14
14
|
import { loadManifest, isStale } from '../lib/staleness/index.js';
|
|
15
15
|
import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
|
|
16
16
|
import { checkSyncStatus, countOrphans } from '../lib/drift.js';
|
|
17
|
+
import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
|
|
17
18
|
import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
|
|
18
19
|
import { listCliStatus } from '../lib/cli-resources.js';
|
|
19
20
|
import { setHelpSections } from '../lib/help.js';
|
|
@@ -741,6 +742,10 @@ export function registerDoctorCommand(program) {
|
|
|
741
742
|
console.log(JSON.stringify({
|
|
742
743
|
clis,
|
|
743
744
|
signIn,
|
|
745
|
+
// Cached auth-health rollup for THIS host — lets `agents fleet status`
|
|
746
|
+
// show a live-verified Auth column from the same fan-out it already
|
|
747
|
+
// runs, without a separate fleet-wide `fleet ping`.
|
|
748
|
+
auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
|
|
744
749
|
sync: syncRows,
|
|
745
750
|
orphans: orphanRows,
|
|
746
751
|
hostClis: {
|
package/dist/commands/ssh.js
CHANGED
|
@@ -31,14 +31,15 @@ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassSh
|
|
|
31
31
|
import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
|
|
32
32
|
import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
|
|
33
33
|
import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
34
|
-
import { fleetCapacity, fmtBytes, headroom,
|
|
34
|
+
import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
|
|
35
35
|
import { buildFleetHealthReport, renderFleetMatrix, renderFleetWarnings, } from '../lib/devices/health-report.js';
|
|
36
|
+
import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
|
|
36
37
|
import { checkSyncStatus, countOrphans } from '../lib/drift.js';
|
|
37
38
|
import { checkAllClis } from '../lib/teams/agents.js';
|
|
38
39
|
import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
|
|
39
40
|
import { sshExecAsync } from '../lib/ssh-exec.js';
|
|
40
41
|
import { ALL_AGENT_IDS } from '../lib/agents.js';
|
|
41
|
-
import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
|
|
42
|
+
import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
|
|
42
43
|
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
43
44
|
* command is running on so it stands out from the rest of the tailnet. */
|
|
44
45
|
function deviceSummary(d, isSelf = false) {
|
|
@@ -256,19 +257,23 @@ async function probeRemoteHealth(target) {
|
|
|
256
257
|
clis: parsed.clis ?? {},
|
|
257
258
|
sync: parsed.sync ?? [],
|
|
258
259
|
orphans: parsed.orphans ?? [],
|
|
260
|
+
// The remote self-reports its own cached auth rollup (fresh via its daemon),
|
|
261
|
+
// so the Auth column is current without a prior fleet-wide `fleet ping`.
|
|
262
|
+
// Older remotes that don't emit it fall back to this host's cache below.
|
|
263
|
+
auth: parsed.auth,
|
|
259
264
|
};
|
|
260
265
|
}
|
|
261
266
|
async function runFleetStatus(opts) {
|
|
262
267
|
const reg = await loadDevices();
|
|
263
268
|
const self = machineId();
|
|
269
|
+
const forceRefresh = Boolean(opts.refresh || opts.live);
|
|
264
270
|
const planned = planFleetTargets(reg);
|
|
265
271
|
const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
|
|
272
|
+
// Cache-first: serve remote stats from the daemon-warmed cache (instant),
|
|
273
|
+
// probe this machine locally, and only ssh out for missing/forced rows.
|
|
266
274
|
const statsMap = opts.stats === false
|
|
267
275
|
? new Map()
|
|
268
|
-
: await
|
|
269
|
-
if (opts.stats !== false && !statsMap.has(self)) {
|
|
270
|
-
statsMap.set(self, await probeLocalStats(self));
|
|
271
|
-
}
|
|
276
|
+
: (await loadFleetStats(probeable, { forceRefresh, selfName: self })).stats;
|
|
272
277
|
const rows = [localHealthRow(self, statsMap.get(self))];
|
|
273
278
|
const remoteTargets = remoteFleetTargets(planned, self)
|
|
274
279
|
.map((t) => ({
|
|
@@ -300,6 +305,15 @@ async function runFleetStatus(opts) {
|
|
|
300
305
|
});
|
|
301
306
|
}
|
|
302
307
|
}
|
|
308
|
+
// Auth column: remote rows already carry the host's self-reported rollup from
|
|
309
|
+
// its `doctor --json`. Fill the rest (this machine; older remotes that don't
|
|
310
|
+
// emit it) from this host's local cache — written by `agents fleet ping` and
|
|
311
|
+
// the daemon's local refresh. A never-probed host rolls up to "—". No network.
|
|
312
|
+
const authCache = readAuthHealthCache();
|
|
313
|
+
for (const row of rows) {
|
|
314
|
+
if (!row.auth)
|
|
315
|
+
row.auth = summarizeHostAuth(authCache, row.name);
|
|
316
|
+
}
|
|
303
317
|
const report = buildFleetHealthReport(rows);
|
|
304
318
|
if (opts.json) {
|
|
305
319
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -550,6 +564,8 @@ Typical workflow:
|
|
|
550
564
|
.description('List registered devices with platform, address, reachability, and live resource headroom.')
|
|
551
565
|
.option('--json', 'output the registry as a JSON array (for scripts and hooks)')
|
|
552
566
|
.option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
|
|
567
|
+
.option('--refresh', 'force a live probe of every device, bypassing the cache')
|
|
568
|
+
.option('--live', 'alias of --refresh (shorter to type)')
|
|
553
569
|
.option('-f, --full', 'full mode: add per-device core count and free/total memory')
|
|
554
570
|
.action(async (opts) => {
|
|
555
571
|
const reg = await loadDevices();
|
|
@@ -564,18 +580,26 @@ Typical workflow:
|
|
|
564
580
|
return;
|
|
565
581
|
}
|
|
566
582
|
const self = machineId();
|
|
583
|
+
const forceRefresh = Boolean(opts.refresh || opts.live);
|
|
567
584
|
let statsMap;
|
|
585
|
+
let freshness;
|
|
568
586
|
if (opts.stats !== false) {
|
|
569
|
-
//
|
|
570
|
-
//
|
|
587
|
+
// Cache-first: serve remote devices from the daemon-warmed cache
|
|
588
|
+
// (instant), probe this machine locally, and only ssh out for missing or
|
|
589
|
+
// forced (--refresh/--live) rows — so a warm read never hangs on a box.
|
|
571
590
|
const probeable = planFleetTargets(reg)
|
|
572
591
|
.filter((t) => !t.skip)
|
|
573
592
|
.map((t) => t.device);
|
|
574
|
-
|
|
593
|
+
// Only spin when we'll actually ssh (forced, or a cold/partial cache).
|
|
594
|
+
const cache = readStatsCache();
|
|
595
|
+
const willSsh = forceRefresh || probeable.some((d) => d.name !== self && !cache[d.name]);
|
|
596
|
+
const spinner = willSsh && isInteractiveTerminal()
|
|
575
597
|
? ora(`Probing ${probeable.length} device${probeable.length === 1 ? '' : 's'}…`).start()
|
|
576
598
|
: undefined;
|
|
577
599
|
try {
|
|
578
|
-
|
|
600
|
+
const res = await loadFleetStats(probeable, { forceRefresh, selfName: self });
|
|
601
|
+
statsMap = res.stats;
|
|
602
|
+
freshness = res;
|
|
579
603
|
}
|
|
580
604
|
finally {
|
|
581
605
|
spinner?.stop();
|
|
@@ -584,6 +608,9 @@ Typical workflow:
|
|
|
584
608
|
console.log(chalk.bold(`Devices (${names.length})`));
|
|
585
609
|
for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
|
|
586
610
|
console.log(line);
|
|
611
|
+
if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
|
|
612
|
+
console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
|
|
613
|
+
}
|
|
587
614
|
});
|
|
588
615
|
devicesCmd
|
|
589
616
|
.command('status')
|
|
@@ -591,6 +618,8 @@ Typical workflow:
|
|
|
591
618
|
.option('--json', 'output machine-readable JSON')
|
|
592
619
|
.option('--strict', 'exit non-zero when any device has drift or is unreachable')
|
|
593
620
|
.option('--no-stats', 'skip the live resource probe')
|
|
621
|
+
.option('--refresh', 'force a live probe of every device, bypassing the cache')
|
|
622
|
+
.option('--live', 'alias of --refresh (shorter to type)')
|
|
594
623
|
.action(async (opts) => {
|
|
595
624
|
await runFleetStatus(opts);
|
|
596
625
|
});
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export declare function viewAction(agentArg?: string, options?: {
|
|
|
123
123
|
resources?: string | boolean;
|
|
124
124
|
detailed?: boolean;
|
|
125
125
|
refresh?: boolean;
|
|
126
|
+
live?: boolean;
|
|
126
127
|
} & ViewSectionFilter): Promise<void>;
|
|
127
128
|
/** Register the `agents view` command. */
|
|
128
129
|
export declare function registerViewCommand(program: Command): void;
|
package/dist/commands/view.js
CHANGED
|
@@ -1400,6 +1400,8 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
|
|
|
1400
1400
|
* Exported for use by deprecated aliases.
|
|
1401
1401
|
*/
|
|
1402
1402
|
export async function viewAction(agentArg, options) {
|
|
1403
|
+
// --live is a shorter-to-type alias of --refresh; both force a live probe.
|
|
1404
|
+
const forceRefresh = options?.refresh === true || options?.live === true;
|
|
1403
1405
|
// --resources / --detailed imply --json (they only shape structured output).
|
|
1404
1406
|
const explicitResources = options?.detailed === true || options?.resources !== undefined;
|
|
1405
1407
|
const json = options?.json === true || explicitResources;
|
|
@@ -1440,7 +1442,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1440
1442
|
return;
|
|
1441
1443
|
}
|
|
1442
1444
|
// No argument: show all installed versions
|
|
1443
|
-
await showInstalledVersions(undefined, { forceRefresh
|
|
1445
|
+
await showInstalledVersions(undefined, { forceRefresh });
|
|
1444
1446
|
return;
|
|
1445
1447
|
}
|
|
1446
1448
|
// Parse agent@version syntax
|
|
@@ -1501,7 +1503,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1501
1503
|
}
|
|
1502
1504
|
else {
|
|
1503
1505
|
// Just agent name: show versions for that agent
|
|
1504
|
-
await showInstalledVersions(agentId, { forceRefresh
|
|
1506
|
+
await showInstalledVersions(agentId, { forceRefresh });
|
|
1505
1507
|
}
|
|
1506
1508
|
}
|
|
1507
1509
|
/** Register the `agents view` command. */
|
|
@@ -1512,6 +1514,7 @@ export function registerViewCommand(program) {
|
|
|
1512
1514
|
.option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
|
|
1513
1515
|
.option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
|
|
1514
1516
|
.option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
|
|
1517
|
+
.option('--live', 'Alias of --refresh (shorter to type).')
|
|
1515
1518
|
.option('--prune', 'Remove older installed versions that share an account with a newer installed version. Skips the global default.')
|
|
1516
1519
|
.option('--dry-run', 'With --prune, show duplicate versions without deleting')
|
|
1517
1520
|
.option('-y, --yes', 'Skip the prune confirmation prompt.')
|
|
@@ -49,6 +49,44 @@ export interface VerdictSummary {
|
|
|
49
49
|
export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
|
|
50
50
|
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
51
51
|
export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A host's rolled-up auth state for the `fleet status` Auth column.
|
|
54
|
+
*
|
|
55
|
+
* The four display buckets are deliberately finer-grained than
|
|
56
|
+
* {@link VerdictSummary}'s live/bad/warn: they separate "present but this agent
|
|
57
|
+
* has no live probe" (`unverified`) and "soft, self-healing expiry"
|
|
58
|
+
* (`expired`/`rate_limited`) from a genuine server rejection (`revoked`). The
|
|
59
|
+
* old three-bucket rollup lumped all of those into `warn` and the column painted
|
|
60
|
+
* them one alarming yellow — so a fleet of perfectly logged-in accounts on
|
|
61
|
+
* codex/grok/etc (which can NEVER be probed live) read as half-degraded. These
|
|
62
|
+
* buckets let the renderer show `unverified` as neutral and reserve red for the
|
|
63
|
+
* only verdict that actually means "re-login now" ({@link isDeadVerdict}).
|
|
64
|
+
*/
|
|
65
|
+
export interface HostAuthSummary {
|
|
66
|
+
/** Live-verified accounts (a real 2xx). */
|
|
67
|
+
live: number;
|
|
68
|
+
/** Signed in but this agent has no live-probe endpoint — benign, neutral. */
|
|
69
|
+
present: number;
|
|
70
|
+
/** Soft/degraded: expired (self-healing) / rate_limited / error. Mild warning. */
|
|
71
|
+
degraded: number;
|
|
72
|
+
/** Server rejected the token — genuinely needs re-login. */
|
|
73
|
+
revoked: number;
|
|
74
|
+
/** Total cached rows for this host (0 → the renderer shows "—"). */
|
|
75
|
+
total: number;
|
|
76
|
+
/** Oldest `checkedAt` (epoch ms) among this host's cached rows, or null when none. */
|
|
77
|
+
oldestCheckedAt: number | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
81
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
82
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
83
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
84
|
+
* summary (total 0), which the renderer shows as "—".
|
|
85
|
+
*
|
|
86
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
87
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
88
|
+
*/
|
|
89
|
+
export declare function summarizeHostAuth(cache: Record<string, AuthHealth>, host: string): HostAuthSummary;
|
|
52
90
|
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
53
91
|
export declare function formatCheckedAge(checkedAt: number, now?: number): string;
|
|
54
92
|
/**
|
package/dist/lib/auth-health.js
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* The rest of the CLI reports "signed in" from a local heuristic — a credential
|
|
6
6
|
* file is present and its email decodes — which cannot distinguish a good token
|
|
7
7
|
* from a revoked-but-unexpired one. This module completes a real request per
|
|
8
|
-
* (agent, account) and records the verdict in a small cache that `agents view
|
|
9
|
-
* `agents fleet status
|
|
10
|
-
*
|
|
8
|
+
* (agent, account) and records the verdict in a small cache that `agents view`
|
|
9
|
+
* (per-version chip), `agents fleet status` (the per-host Auth column, via
|
|
10
|
+
* {@link summarizeHostAuth}), and the run rotation all read. The writers are
|
|
11
|
+
* the daemon (a periodic local refresh) and `agents fleet ping` (which also
|
|
12
|
+
* fans out to write remote hosts' rows into the local cache); everyone else
|
|
13
|
+
* reads.
|
|
11
14
|
*
|
|
12
15
|
* The network probes themselves live in lib/usage.ts (where the per-provider
|
|
13
16
|
* token loaders + endpoints already are); this module classifies their result,
|
|
@@ -95,6 +98,48 @@ export function summarizeVerdicts(verdicts) {
|
|
|
95
98
|
export function isDeadVerdict(verdict) {
|
|
96
99
|
return verdict === 'revoked';
|
|
97
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
103
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
104
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
105
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
106
|
+
* summary (total 0), which the renderer shows as "—".
|
|
107
|
+
*
|
|
108
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
109
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
110
|
+
*/
|
|
111
|
+
export function summarizeHostAuth(cache, host) {
|
|
112
|
+
const prefix = `${host}:`;
|
|
113
|
+
let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
|
|
114
|
+
let oldest = null;
|
|
115
|
+
for (const [key, health] of Object.entries(cache)) {
|
|
116
|
+
if (!key.startsWith(prefix))
|
|
117
|
+
continue;
|
|
118
|
+
// `unconfigured` = no credential at all — not a probed account. Writers
|
|
119
|
+
// already drop these before they reach the cache; skip here too so a stray
|
|
120
|
+
// one never counts toward total or the freshness age (belt-and-suspenders).
|
|
121
|
+
if (health.verdict === 'unconfigured')
|
|
122
|
+
continue;
|
|
123
|
+
total++;
|
|
124
|
+
switch (health.verdict) {
|
|
125
|
+
case 'live':
|
|
126
|
+
live++;
|
|
127
|
+
break;
|
|
128
|
+
case 'unverified':
|
|
129
|
+
present++;
|
|
130
|
+
break; // signed in, no probe — benign
|
|
131
|
+
case 'revoked':
|
|
132
|
+
revoked++;
|
|
133
|
+
break; // server said no — re-login
|
|
134
|
+
default:
|
|
135
|
+
degraded++;
|
|
136
|
+
break; // expired / rate_limited / error — soft
|
|
137
|
+
}
|
|
138
|
+
if (oldest === null || health.checkedAt < oldest)
|
|
139
|
+
oldest = health.checkedAt;
|
|
140
|
+
}
|
|
141
|
+
return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
|
|
142
|
+
}
|
|
98
143
|
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
99
144
|
export function formatCheckedAge(checkedAt, now = Date.now()) {
|
|
100
145
|
const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
|
|
@@ -29,6 +29,9 @@ export declare function macHelperAssetUrls(version: string): {
|
|
|
29
29
|
zip: string;
|
|
30
30
|
sha256: string;
|
|
31
31
|
};
|
|
32
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
33
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
34
|
+
export declare function parseTeamId(codesignInfo: string): string | null;
|
|
32
35
|
/**
|
|
33
36
|
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
34
37
|
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
|
|
14
14
|
* we extract it with `ditto -x -k` after the checksum passes.
|
|
15
15
|
*/
|
|
16
|
-
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import * as os from 'node:os';
|
|
19
19
|
import * as path from 'node:path';
|
|
@@ -41,6 +41,11 @@ export function macHelperAssetUrls(version) {
|
|
|
41
41
|
const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
|
|
42
42
|
return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
|
|
43
43
|
}
|
|
44
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
45
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
46
|
+
export function parseTeamId(codesignInfo) {
|
|
47
|
+
return codesignInfo.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1] ?? null;
|
|
48
|
+
}
|
|
44
49
|
/**
|
|
45
50
|
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
46
51
|
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
@@ -55,17 +60,13 @@ export function verifyMacHelper(appPath) {
|
|
|
55
60
|
throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
|
|
56
61
|
}
|
|
57
62
|
// 2. Team identity — must be our Developer ID, not some other valid signer.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!info)
|
|
66
|
-
throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
|
|
67
|
-
}
|
|
68
|
-
const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
|
|
63
|
+
// `codesign -dv` writes its details to STDERR even on success (exit 0), so we
|
|
64
|
+
// must read stderr, not stdout. spawnSync captures both streams regardless of
|
|
65
|
+
// exit code; execFileSync would return only (empty) stdout and the Team check
|
|
66
|
+
// would falsely reject every validly-signed helper.
|
|
67
|
+
const dv = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf8' });
|
|
68
|
+
const info = `${dv.stdout ?? ''}${dv.stderr ?? ''}`;
|
|
69
|
+
const team = parseTeamId(info);
|
|
69
70
|
if (team !== EXPECTED_TEAM_ID) {
|
|
70
71
|
throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
|
|
71
72
|
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -625,6 +625,42 @@ export async function runDaemon() {
|
|
|
625
625
|
};
|
|
626
626
|
const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
|
|
627
627
|
const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
|
|
628
|
+
// Fleet cache warm: keep the caches that `agents devices list`, `fleet status`,
|
|
629
|
+
// and `agents view` read cache-first actually fresh, so a default read never
|
|
630
|
+
// has to ssh out. Two cheap refreshes: (1) this host's auth-health verdicts
|
|
631
|
+
// (also feeds the `doctor --json` Auth rollup other hosts read), and (2) the
|
|
632
|
+
// fleet resource-stats cache (one bounded parallel probe of the tailnet). Both
|
|
633
|
+
// best-effort + overlap-guarded like the probes above. ~every 3 min, plus once
|
|
634
|
+
// ~60s after startup (staggered off launch).
|
|
635
|
+
let warmingFleetCache = false;
|
|
636
|
+
const runFleetCacheWarm = async () => {
|
|
637
|
+
if (warmingFleetCache)
|
|
638
|
+
return;
|
|
639
|
+
warmingFleetCache = true;
|
|
640
|
+
try {
|
|
641
|
+
const { machineId } = await import('./machine-id.js');
|
|
642
|
+
const self = machineId();
|
|
643
|
+
const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
|
|
644
|
+
const { getCliVersion } = await import('./version.js');
|
|
645
|
+
const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
|
|
646
|
+
writeFleetAuthRows(self, authRows);
|
|
647
|
+
const { loadDevices } = await import('./devices/registry.js');
|
|
648
|
+
const { planFleetTargets } = await import('./devices/fleet.js');
|
|
649
|
+
const { loadFleetStats } = await import('./devices/stats-cache.js');
|
|
650
|
+
const reg = await loadDevices();
|
|
651
|
+
const probeable = planFleetTargets(reg).filter((t) => !t.skip).map((t) => t.device);
|
|
652
|
+
const res = await loadFleetStats(probeable, { forceRefresh: true, selfName: self });
|
|
653
|
+
log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${res.stats.size} device stat(s)`);
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
log('ERROR', `fleet cache warm failed: ${err.message}`);
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
warmingFleetCache = false;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
|
|
663
|
+
const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
|
|
628
664
|
const handleReload = () => {
|
|
629
665
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
630
666
|
scheduler.reloadAll();
|
|
@@ -657,6 +693,8 @@ export async function runDaemon() {
|
|
|
657
693
|
clearTimeout(tmuxReconcileKickoff);
|
|
658
694
|
clearInterval(launchHealthInterval);
|
|
659
695
|
clearTimeout(launchHealthKickoff);
|
|
696
|
+
clearInterval(fleetCacheInterval);
|
|
697
|
+
clearTimeout(fleetCacheKickoff);
|
|
660
698
|
hostedBroker?.close();
|
|
661
699
|
removeDaemonPid();
|
|
662
700
|
removeHeartbeat();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DeviceStats } from './health.js';
|
|
2
|
+
import { type HostAuthSummary } from '../auth-health.js';
|
|
2
3
|
export interface FleetCliStatus {
|
|
3
4
|
installed: boolean;
|
|
4
5
|
path: string | null;
|
|
@@ -28,6 +29,9 @@ export interface FleetHealthRow {
|
|
|
28
29
|
clis: Record<string, FleetCliStatus>;
|
|
29
30
|
sync: FleetSyncStatus[];
|
|
30
31
|
orphans: FleetOrphanStatus[];
|
|
32
|
+
/** Cached auth-health rollup for this host (the Auth column). Undefined when
|
|
33
|
+
* the host has never been probed (`agents fleet ping`) or the cache is cold. */
|
|
34
|
+
auth?: HostAuthSummary;
|
|
31
35
|
}
|
|
32
36
|
export interface FleetWarning {
|
|
33
37
|
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
|
|
@@ -44,3 +48,10 @@ export interface FleetHealthReport {
|
|
|
44
48
|
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
|
|
45
49
|
export declare function renderFleetWarnings(report: FleetHealthReport): string[];
|
|
46
50
|
export declare function renderFleetMatrix(report: FleetHealthReport): string[];
|
|
51
|
+
/**
|
|
52
|
+
* "as of …" line so cache-served output is honest about age and points at the
|
|
53
|
+
* refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
|
|
54
|
+
* cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
|
|
55
|
+
* table carries no timestamped data at all (nothing to date).
|
|
56
|
+
*/
|
|
57
|
+
export declare function freshnessFooter(rows: FleetHealthRow[], now?: number): string | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { padToWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
2
|
+
import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
3
3
|
import { fmtBytes, headroom } from './health.js';
|
|
4
|
+
import { formatCheckedAge } from '../auth-health.js';
|
|
4
5
|
function driftRows(row) {
|
|
5
6
|
return row.sync.filter((s) => s.status !== 'fresh');
|
|
6
7
|
}
|
|
@@ -121,6 +122,47 @@ function loadLabel(stats) {
|
|
|
121
122
|
const mem = stats.memPercent === undefined ? '-' : `${Math.round(stats.memPercent)}%`;
|
|
122
123
|
return `${load}/${mem}`;
|
|
123
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Compact per-host auth cell. Four buckets, deliberately distinct so the column
|
|
127
|
+
* doesn't cry wolf on healthy accounts:
|
|
128
|
+
* `●{live}` green — live-verified
|
|
129
|
+
* `·{present}` gray — signed in but no live probe (codex/grok/…): benign
|
|
130
|
+
* `◐{degraded}` yellow — soft/self-healing (expired/limited/error)
|
|
131
|
+
* `○{revoked}` red — server rejected the token: re-login now
|
|
132
|
+
* A host with no cached auth rows shows "—"; an unreachable/skipped row shows
|
|
133
|
+
* "-" like the other probe columns.
|
|
134
|
+
*/
|
|
135
|
+
function authLabel(row) {
|
|
136
|
+
if (row.error || row.skipped)
|
|
137
|
+
return chalk.gray('-');
|
|
138
|
+
const s = row.auth;
|
|
139
|
+
if (!s || s.total === 0)
|
|
140
|
+
return chalk.gray('—');
|
|
141
|
+
const parts = [];
|
|
142
|
+
if (s.live > 0)
|
|
143
|
+
parts.push(chalk.green(`●${s.live}`));
|
|
144
|
+
if (s.present > 0)
|
|
145
|
+
parts.push(chalk.gray(`·${s.present}`));
|
|
146
|
+
if (s.degraded > 0)
|
|
147
|
+
parts.push(chalk.yellow(`◐${s.degraded}`));
|
|
148
|
+
if (s.revoked > 0)
|
|
149
|
+
parts.push(chalk.red(`○${s.revoked}`));
|
|
150
|
+
// All-zero can't happen (total > 0); but if only present/degraded exist we
|
|
151
|
+
// still lead with them — never show an empty cell for a probed host.
|
|
152
|
+
return parts.length > 0 ? parts.join(' ') : chalk.gray('—');
|
|
153
|
+
}
|
|
154
|
+
/** Oldest epoch-ms timestamp across rows for a field, or null when none present. */
|
|
155
|
+
function oldestAcross(rows, pick) {
|
|
156
|
+
let oldest = null;
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
const t = pick(row);
|
|
159
|
+
if (t == null)
|
|
160
|
+
continue;
|
|
161
|
+
if (oldest === null || t < oldest)
|
|
162
|
+
oldest = t;
|
|
163
|
+
}
|
|
164
|
+
return oldest;
|
|
165
|
+
}
|
|
124
166
|
export function renderFleetWarnings(report) {
|
|
125
167
|
if (report.warnings.length === 0)
|
|
126
168
|
return [chalk.green('Fleet warnings: none')];
|
|
@@ -134,14 +176,19 @@ export function renderFleetMatrix(report) {
|
|
|
134
176
|
return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
|
|
135
177
|
const nameW = Math.min(22, Math.max(6, ...report.devices.map((r) => r.name.length)));
|
|
136
178
|
const versionW = Math.min(14, Math.max(7, ...report.devices.map((r) => (r.version ?? '-').length)));
|
|
179
|
+
// Auth cells are variable-length (up to four space-separated buckets, e.g.
|
|
180
|
+
// `●2 ·3 ◐1 ○1`); size the column to the widest so a mixed-auth row can't
|
|
181
|
+
// overflow the fixed slot and shove every later column out of alignment.
|
|
182
|
+
const authW = Math.max(9, ...report.devices.map((r) => stringWidth(authLabel(r))));
|
|
137
183
|
const width = terminalWidth();
|
|
138
184
|
// 4 = leading " " + the per-row status glyph + its trailing space (rows prefix
|
|
139
185
|
// ` ${statusGlyph} `; the header reserves the same 4 cols so every column lines up).
|
|
140
|
-
|
|
186
|
+
// Columns: Device, OS(8), Health(9), Sync(9), CLI(9), Auth(authW), Version, Load/Mem(9), then Note.
|
|
187
|
+
const fixed = 4 + nameW + 2 + 8 + 2 + 9 + 2 + 9 + 2 + 9 + 2 + authW + 2 + versionW + 2 + 9;
|
|
141
188
|
const noteW = Math.max(12, width - fixed);
|
|
142
189
|
const lines = [
|
|
143
190
|
chalk.bold('Fleet status'),
|
|
144
|
-
chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
|
|
191
|
+
chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Auth', authW)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
|
|
145
192
|
];
|
|
146
193
|
for (const row of report.devices) {
|
|
147
194
|
const note = row.error ?? row.skipped ?? (row.orphans.length > 0 ? `${row.orphans.length} orphaned version${row.orphans.length === 1 ? '' : 's'}` : '');
|
|
@@ -150,10 +197,32 @@ export function renderFleetMatrix(report) {
|
|
|
150
197
|
`${padToWidth(headroomLabel(row), 9)} ` +
|
|
151
198
|
`${padToWidth(driftLabel(row), 9)} ` +
|
|
152
199
|
`${padToWidth(cliLabel(row), 9)} ` +
|
|
200
|
+
`${padToWidth(authLabel(row), authW)} ` +
|
|
153
201
|
`${padToWidth(truncateToWidth(row.version ?? '-', versionW), versionW)} ` +
|
|
154
202
|
`${padToWidth(loadLabel(row.stats), 9)} ` +
|
|
155
203
|
chalk.gray(truncateToWidth(note || `free ${fmtBytes(row.stats?.memFreeBytes)}`, noteW)));
|
|
156
204
|
}
|
|
157
|
-
lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped'));
|
|
205
|
+
lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped · Auth ●live ·present ◐degraded ○revoked'));
|
|
206
|
+
const foot = freshnessFooter(report.devices);
|
|
207
|
+
if (foot)
|
|
208
|
+
lines.push(chalk.gray(foot));
|
|
158
209
|
return lines;
|
|
159
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* "as of …" line so cache-served output is honest about age and points at the
|
|
213
|
+
* refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
|
|
214
|
+
* cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
|
|
215
|
+
* table carries no timestamped data at all (nothing to date).
|
|
216
|
+
*/
|
|
217
|
+
export function freshnessFooter(rows, now = Date.now()) {
|
|
218
|
+
const oldestStats = oldestAcross(rows, (r) => r.stats?.fetchedAt);
|
|
219
|
+
const oldestAuth = oldestAcross(rows, (r) => r.auth?.oldestCheckedAt);
|
|
220
|
+
const parts = [];
|
|
221
|
+
if (oldestStats != null)
|
|
222
|
+
parts.push(`stats ${formatCheckedAge(oldestStats, now)}`);
|
|
223
|
+
if (oldestAuth != null)
|
|
224
|
+
parts.push(`auth ${formatCheckedAge(oldestAuth, now)}`);
|
|
225
|
+
if (parts.length === 0)
|
|
226
|
+
return null;
|
|
227
|
+
return ` updated ${parts.join(' · ')} — pass --refresh (--live) for a live probe`;
|
|
228
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { probeFleetStats, probeLocalStats, type DeviceStats } from './health.js';
|
|
2
|
+
import type { DeviceProfile } from './registry.js';
|
|
3
|
+
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
4
|
+
export declare function readStatsCache(): Record<string, DeviceStats>;
|
|
5
|
+
/**
|
|
6
|
+
* Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
|
|
7
|
+
* devices not in `entries` are preserved, so a partial probe (gap-fill, or a
|
|
8
|
+
* single-device refresh) never drops the rest of the fleet's cached stats.
|
|
9
|
+
*/
|
|
10
|
+
export declare function writeStatsCache(entries: Record<string, DeviceStats>): void;
|
|
11
|
+
export interface FleetStatsResult {
|
|
12
|
+
/** name → stats for every requested device (cache-served + freshly probed). */
|
|
13
|
+
stats: Map<string, DeviceStats>;
|
|
14
|
+
/** Oldest `fetchedAt` among the returned rows, or null when empty. Drives the
|
|
15
|
+
* "as of …" freshness note. */
|
|
16
|
+
oldestFetchedAt: number | null;
|
|
17
|
+
/** True when at least one row was served from cache rather than probed this call. */
|
|
18
|
+
servedFromCache: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface LoadFleetStatsOptions {
|
|
21
|
+
/** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
|
|
22
|
+
forceRefresh?: boolean;
|
|
23
|
+
/** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
|
|
24
|
+
selfName?: string;
|
|
25
|
+
/** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
|
|
26
|
+
probeFleet?: typeof probeFleetStats;
|
|
27
|
+
probeLocal?: typeof probeLocalStats;
|
|
28
|
+
readCache?: typeof readStatsCache;
|
|
29
|
+
writeCache?: typeof writeStatsCache;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Load fleet stats cache-first. See the module doc for the default vs
|
|
33
|
+
* `--refresh` behaviour. Never throws — an unreachable box degrades to a
|
|
34
|
+
* `reachable: false` row exactly as the live probe does.
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadFleetStats(devices: DeviceProfile[], opts?: LoadFleetStatsOptions): Promise<FleetStatsResult>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk cache for fleet {@link DeviceStats} so `agents devices list` and
|
|
3
|
+
* `agents fleet status` render instantly from the last probe instead of
|
|
4
|
+
* live-SSHing every registered box on every invocation.
|
|
5
|
+
*
|
|
6
|
+
* The old behaviour probed the whole fleet over ssh on every call
|
|
7
|
+
* ({@link probeFleetStats}) — with a dozen devices, a few of them cold or
|
|
8
|
+
* timing out, that turned a status glance into a multi-second hang. This module
|
|
9
|
+
* makes the reads cache-first:
|
|
10
|
+
*
|
|
11
|
+
* - **Default:** serve remote devices from the cache (instant); always probe
|
|
12
|
+
* *this* machine locally (no ssh, sub-ms) so the "this machine" row is live;
|
|
13
|
+
* probe only the remote devices missing from the cache (first run / a
|
|
14
|
+
* newly-added box), then persist them.
|
|
15
|
+
* - **`--refresh` / `--live`:** skip the cache and live-probe every device,
|
|
16
|
+
* rewriting the cache.
|
|
17
|
+
*
|
|
18
|
+
* The daemon warms this cache (~every 3 min, see `lib/daemon.ts
|
|
19
|
+
* runFleetCacheWarm`), so in steady state a default read has zero
|
|
20
|
+
* remote ssh round-trips and returns immediately with data that is at most a
|
|
21
|
+
* few minutes old — surfaced to the user as an "as of …" freshness note.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { getCacheDir } from '../state.js';
|
|
26
|
+
import { probeFleetStats, probeLocalStats } from './health.js';
|
|
27
|
+
const CACHE_FILE = '.fleet-stats.json';
|
|
28
|
+
function cacheFilePath() {
|
|
29
|
+
return path.join(getCacheDir(), CACHE_FILE);
|
|
30
|
+
}
|
|
31
|
+
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
32
|
+
export function readStatsCache() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
|
|
35
|
+
if (parsed && parsed.entries && typeof parsed.entries === 'object')
|
|
36
|
+
return parsed.entries;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// missing or corrupt — treat as empty
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
|
|
45
|
+
* devices not in `entries` are preserved, so a partial probe (gap-fill, or a
|
|
46
|
+
* single-device refresh) never drops the rest of the fleet's cached stats.
|
|
47
|
+
*/
|
|
48
|
+
export function writeStatsCache(entries) {
|
|
49
|
+
try {
|
|
50
|
+
const dir = getCacheDir();
|
|
51
|
+
if (!fs.existsSync(dir))
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const merged = {
|
|
54
|
+
version: 1,
|
|
55
|
+
entries: { ...readStatsCache(), ...entries },
|
|
56
|
+
};
|
|
57
|
+
fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// best-effort; a failed write just means the next read falls back to a live probe
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Load fleet stats cache-first. See the module doc for the default vs
|
|
65
|
+
* `--refresh` behaviour. Never throws — an unreachable box degrades to a
|
|
66
|
+
* `reachable: false` row exactly as the live probe does.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadFleetStats(devices, opts = {}) {
|
|
69
|
+
const probeFleet = opts.probeFleet ?? probeFleetStats;
|
|
70
|
+
const probeLocal = opts.probeLocal ?? probeLocalStats;
|
|
71
|
+
const readCache = opts.readCache ?? readStatsCache;
|
|
72
|
+
const writeCache = opts.writeCache ?? writeStatsCache;
|
|
73
|
+
const self = opts.selfName;
|
|
74
|
+
const cache = opts.forceRefresh ? {} : readCache();
|
|
75
|
+
const stats = new Map();
|
|
76
|
+
const toProbe = [];
|
|
77
|
+
let servedFromCache = false;
|
|
78
|
+
for (const d of devices) {
|
|
79
|
+
if (d.name === self) {
|
|
80
|
+
// This machine is always probed locally — cheap, no ssh, always live.
|
|
81
|
+
toProbe.push(d);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const cached = cache[d.name];
|
|
85
|
+
if (cached) {
|
|
86
|
+
stats.set(d.name, cached);
|
|
87
|
+
servedFromCache = true;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
toProbe.push(d);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (toProbe.length > 0) {
|
|
94
|
+
const probed = await probeFleet(toProbe, { selfName: self });
|
|
95
|
+
const fresh = {};
|
|
96
|
+
for (const [name, s] of probed) {
|
|
97
|
+
stats.set(name, s);
|
|
98
|
+
fresh[name] = s;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(fresh).length > 0)
|
|
101
|
+
writeCache(fresh);
|
|
102
|
+
}
|
|
103
|
+
// Guarantee a row for this machine even when it isn't in the passed device
|
|
104
|
+
// list (e.g. self not registered as an ssh target) — matches the old callers'
|
|
105
|
+
// explicit local fallback.
|
|
106
|
+
if (self && !stats.has(self)) {
|
|
107
|
+
stats.set(self, await probeLocal(self));
|
|
108
|
+
}
|
|
109
|
+
let oldest = null;
|
|
110
|
+
for (const s of stats.values()) {
|
|
111
|
+
if (oldest === null || s.fetchedAt < oldest)
|
|
112
|
+
oldest = s.fetchedAt;
|
|
113
|
+
}
|
|
114
|
+
return { stats, oldestFetchedAt: oldest, servedFromCache };
|
|
115
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.70",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|