@phnx-labs/agents-cli 1.20.62 → 1.20.64
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 +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- package/package.json +1 -1
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* from a secrets bundle when one is configured (see `crabboxEnv`).
|
|
13
13
|
*/
|
|
14
14
|
import { spawn, spawnSync } from 'child_process';
|
|
15
|
-
import { readAndResolveBundleEnv } from '../secrets/bundles.js';
|
|
15
|
+
import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
|
|
16
|
+
import { readMeta, writeMeta } from '../state.js';
|
|
16
17
|
/** Locate the crabbox binary, or throw an actionable error. */
|
|
17
18
|
export function findCrabbox() {
|
|
18
19
|
const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
|
|
@@ -21,21 +22,95 @@ export function findCrabbox() {
|
|
|
21
22
|
}
|
|
22
23
|
return 'crabbox';
|
|
23
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Env keys that mark a secrets bundle as usable for `--lease` — the provider
|
|
27
|
+
* tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
|
|
28
|
+
* declared key NAMES; only the matched key's VALUE is ever injected (see
|
|
29
|
+
* `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
|
|
30
|
+
*/
|
|
31
|
+
export const LEASE_PROVIDER_TOKEN_KEYS = ['HCLOUD_TOKEN', 'AWS_ACCESS_KEY_ID', 'DIGITALOCEAN_TOKEN', 'DO_TOKEN'];
|
|
32
|
+
/** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
|
|
33
|
+
export function pickLeaseBundleFromList(bundles) {
|
|
34
|
+
for (const b of bundles) {
|
|
35
|
+
if (Object.keys(b.vars ?? {}).some((k) => LEASE_PROVIDER_TOKEN_KEYS.includes(k)))
|
|
36
|
+
return b.name;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The secrets bundle to feed crabbox, resolved in priority order:
|
|
42
|
+
* 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
|
|
43
|
+
* 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
|
|
44
|
+
* 3. auto-detect: the first keychain bundle DECLARING a provider token key
|
|
45
|
+
*
|
|
46
|
+
* Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
|
|
47
|
+
* read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
|
|
48
|
+
* (one batched keychain unlock, ~7-day broker cache) — so it only runs when
|
|
49
|
+
* neither env nor config is set, and `crabboxEnv` memoizes the result for the
|
|
50
|
+
* process (it is called several times per lease, and we don't want a scan each
|
|
51
|
+
* time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
|
|
52
|
+
* Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveLeaseBundle() {
|
|
55
|
+
const env = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
|
|
56
|
+
if (env)
|
|
57
|
+
return { name: env };
|
|
58
|
+
try {
|
|
59
|
+
const configured = readMeta().lease?.secretsBundle;
|
|
60
|
+
if (configured && bundleExists(configured))
|
|
61
|
+
return { name: configured };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* config unreadable — fall through to auto-detect */
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const bundles = listBundles();
|
|
68
|
+
const name = pickLeaseBundleFromList(bundles);
|
|
69
|
+
if (name) {
|
|
70
|
+
const b = bundles.find((x) => x.name === name);
|
|
71
|
+
const keys = LEASE_PROVIDER_TOKEN_KEYS.filter((k) => !!b && k in (b.vars ?? {}));
|
|
72
|
+
return { name, keys };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* secrets unreadable — no auto-detect */
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** Process-lifetime memo so the tier-3 `listBundles()` scan runs at most once. */
|
|
81
|
+
let leaseBundleMemo;
|
|
82
|
+
function resolveLeaseBundleMemo() {
|
|
83
|
+
if (!leaseBundleMemo)
|
|
84
|
+
leaseBundleMemo = { value: resolveLeaseBundle() };
|
|
85
|
+
return leaseBundleMemo.value;
|
|
86
|
+
}
|
|
87
|
+
/** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
|
|
88
|
+
export function setLeaseSecretsBundle(name) {
|
|
89
|
+
const meta = readMeta();
|
|
90
|
+
writeMeta({ ...meta, lease: { ...meta.lease, secretsBundle: name } });
|
|
91
|
+
leaseBundleMemo = undefined; // invalidate so the next resolve sees the new config
|
|
92
|
+
}
|
|
24
93
|
/** Build the child env for crabbox, injecting a secrets bundle when configured. */
|
|
25
94
|
export function crabboxEnv(opts) {
|
|
26
|
-
const
|
|
27
|
-
|
|
95
|
+
const resolved = opts.secretsBundle
|
|
96
|
+
? { name: opts.secretsBundle }
|
|
97
|
+
: resolveLeaseBundleMemo();
|
|
98
|
+
if (!resolved)
|
|
28
99
|
return process.env;
|
|
29
100
|
try {
|
|
30
|
-
//
|
|
31
|
-
// bundle
|
|
32
|
-
//
|
|
33
|
-
|
|
101
|
+
// Auto-detected bundle → inject ONLY the provider token key(s) (least
|
|
102
|
+
// privilege; an unrelated bundle can't leak its other secrets into crabbox).
|
|
103
|
+
// An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
|
|
104
|
+
// whole — the user chose it. Same resolver `agents secrets exec` uses.
|
|
105
|
+
const { env } = readAndResolveBundleEnv(resolved.name, {
|
|
106
|
+
caller: 'agents run --lease (crabbox)',
|
|
107
|
+
keys: resolved.keys,
|
|
108
|
+
});
|
|
34
109
|
return { ...process.env, ...env };
|
|
35
110
|
}
|
|
36
111
|
catch (e) {
|
|
37
|
-
throw new Error(`Could not load secrets bundle "${
|
|
38
|
-
`Fix the bundle (agents secrets view ${
|
|
112
|
+
throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
|
|
113
|
+
`Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
|
|
39
114
|
}
|
|
40
115
|
}
|
|
41
116
|
function normalizeBox(raw) {
|
|
@@ -46,6 +121,12 @@ function normalizeBox(raw) {
|
|
|
46
121
|
const status = String(raw.status ?? '');
|
|
47
122
|
const state = String(labels.state ?? '');
|
|
48
123
|
const publicNet = (raw.public_net ?? {});
|
|
124
|
+
const num = (v) => {
|
|
125
|
+
if (v === undefined || v === '')
|
|
126
|
+
return null;
|
|
127
|
+
const n = Number(v);
|
|
128
|
+
return Number.isFinite(n) ? n : null;
|
|
129
|
+
};
|
|
49
130
|
return {
|
|
50
131
|
name: String(raw.name ?? ''),
|
|
51
132
|
status,
|
|
@@ -56,6 +137,11 @@ function normalizeBox(raw) {
|
|
|
56
137
|
profile: labels.profile,
|
|
57
138
|
class: labels.class,
|
|
58
139
|
ready: status === 'running' && state === 'ready',
|
|
140
|
+
keep: labels.keep === 'true',
|
|
141
|
+
createdAt: num(labels.created_at),
|
|
142
|
+
expiresAt: num(labels.expires_at),
|
|
143
|
+
lastTouchedAt: num(labels.last_touched_at),
|
|
144
|
+
idleTimeoutSecs: num(labels.idle_timeout_secs ?? labels.idle_timeout),
|
|
59
145
|
};
|
|
60
146
|
}
|
|
61
147
|
/** All crabbox machines the broker knows about. */
|
|
@@ -113,6 +199,24 @@ export async function crabboxWarmup(opts = {}) {
|
|
|
113
199
|
});
|
|
114
200
|
if (r.status !== 0) {
|
|
115
201
|
const detail = (r.stderr || r.stdout || '').trim();
|
|
202
|
+
// A provider `server_limit` / `resource_limit_exceeded` 403 means the account's
|
|
203
|
+
// box quota is full. Turn the raw 403 into an actionable message that names the
|
|
204
|
+
// reap-safe orphans + the one-command fix, instead of a generic failure.
|
|
205
|
+
if (/server_limit|resource_limit_exceeded/i.test(detail)) {
|
|
206
|
+
let hint = ' Stop unused boxes (`crabbox list`) or raise your provider server limit.';
|
|
207
|
+
try {
|
|
208
|
+
const orphans = reapSafeOrphans(crabboxList(opts), Math.floor(Date.now() / 1000));
|
|
209
|
+
if (orphans.length) {
|
|
210
|
+
hint =
|
|
211
|
+
` ${orphans.length} expired, idle box(es) are holding the quota — free them with ` +
|
|
212
|
+
`\`agents lease gc\` (or \`crabbox stop ${orphans[0].slug}\`).`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* best-effort hint; fall back to the generic guidance above */
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`crabbox warmup failed: provider server limit reached.${hint}`);
|
|
219
|
+
}
|
|
116
220
|
throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
|
|
117
221
|
`Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
|
|
118
222
|
}
|
|
@@ -229,3 +333,48 @@ export function crabboxStop(slug, opts = {}) {
|
|
|
229
333
|
return false;
|
|
230
334
|
}
|
|
231
335
|
}
|
|
336
|
+
/** Never reap a box touched within this many seconds, regardless of idle-timeout. */
|
|
337
|
+
export const REAP_MIN_IDLE_SECS = 3600;
|
|
338
|
+
/**
|
|
339
|
+
* Whether a box is a genuine orphan that is safe to reap.
|
|
340
|
+
*
|
|
341
|
+
* Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
|
|
342
|
+
* past) AND the box has not been touched for a safety window of
|
|
343
|
+
* `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
|
|
344
|
+
* a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
|
|
345
|
+
* recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
|
|
346
|
+
* — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
|
|
347
|
+
* — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
|
|
348
|
+
* null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
|
|
349
|
+
*/
|
|
350
|
+
export function isReapSafe(box, nowSecs) {
|
|
351
|
+
if (box.expiresAt === null || box.lastTouchedAt === null)
|
|
352
|
+
return false;
|
|
353
|
+
if (box.expiresAt > nowSecs)
|
|
354
|
+
return false;
|
|
355
|
+
const window = Math.max((box.idleTimeoutSecs ?? 0) * 2, REAP_MIN_IDLE_SECS);
|
|
356
|
+
return nowSecs - box.lastTouchedAt >= window;
|
|
357
|
+
}
|
|
358
|
+
/** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
|
|
359
|
+
export function reapSafeOrphans(boxes, nowSecs) {
|
|
360
|
+
return boxes
|
|
361
|
+
.filter((b) => isReapSafe(b, nowSecs))
|
|
362
|
+
.sort((a, b) => (a.lastTouchedAt ?? 0) - (b.lastTouchedAt ?? 0));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
|
|
366
|
+
* considered and the slugs actually stopped. Best-effort per box — a stop failure
|
|
367
|
+
* is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
|
|
368
|
+
*/
|
|
369
|
+
export function reapOrphans(opts = {}) {
|
|
370
|
+
const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
|
|
371
|
+
const candidates = reapSafeOrphans(crabboxList(opts), nowSecs);
|
|
372
|
+
if (opts.dryRun)
|
|
373
|
+
return { candidates, reaped: [] };
|
|
374
|
+
const reaped = [];
|
|
375
|
+
for (const b of candidates) {
|
|
376
|
+
if (crabboxStop(b.slug, opts))
|
|
377
|
+
reaped.push(b.slug);
|
|
378
|
+
}
|
|
379
|
+
return { candidates, reaped };
|
|
380
|
+
}
|
|
@@ -47,6 +47,19 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
|
|
|
47
47
|
checked: boolean;
|
|
48
48
|
disabled: boolean | string;
|
|
49
49
|
}[]) => Promise<AgentId[]>): Promise<AgentId[]>;
|
|
50
|
+
/**
|
|
51
|
+
* The lease runtime to provision for a headless run of `agentName`.
|
|
52
|
+
*
|
|
53
|
+
* When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
|
|
54
|
+
* that IS the runtime to install. Otherwise fall back to the single signed-in
|
|
55
|
+
* lease runtime (preferring claude), or null when none is signed in. This is the
|
|
56
|
+
* non-interactive replacement for the runtime checkbox picker: `--lease` requires
|
|
57
|
+
* a prompt, so it is headless by contract and must never block on a TTY.
|
|
58
|
+
*
|
|
59
|
+
* Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
|
|
60
|
+
* under a non-obvious runtime are resolved separately — see RUSH-1725.
|
|
61
|
+
*/
|
|
62
|
+
export declare function inferLeaseRuntime(agentName: string, detected: DetectedRuntime[]): AgentId | null;
|
|
50
63
|
/**
|
|
51
64
|
* Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
|
|
52
65
|
* LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
|
|
@@ -79,6 +79,30 @@ export async function pickRuntimes(detected, prompt) {
|
|
|
79
79
|
const { checkbox } = await import('@inquirer/prompts');
|
|
80
80
|
return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The lease runtime to provision for a headless run of `agentName`.
|
|
84
|
+
*
|
|
85
|
+
* When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
|
|
86
|
+
* that IS the runtime to install. Otherwise fall back to the single signed-in
|
|
87
|
+
* lease runtime (preferring claude), or null when none is signed in. This is the
|
|
88
|
+
* non-interactive replacement for the runtime checkbox picker: `--lease` requires
|
|
89
|
+
* a prompt, so it is headless by contract and must never block on a TTY.
|
|
90
|
+
*
|
|
91
|
+
* Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
|
|
92
|
+
* under a non-obvious runtime are resolved separately — see RUSH-1725.
|
|
93
|
+
*/
|
|
94
|
+
export function inferLeaseRuntime(agentName, detected) {
|
|
95
|
+
const signedIn = detected.filter((d) => d.signedIn && d.credPath);
|
|
96
|
+
// The agent names a lease runtime directly: require that runtime to be signed
|
|
97
|
+
// in — never silently substitute a different one for an explicit `run <runtime>`
|
|
98
|
+
// (that would lease a billable box only to boot it "Not logged in"). Not signed
|
|
99
|
+
// in → null, so the caller exits with "sign into it locally first".
|
|
100
|
+
if (LEASE_RUNTIMES.some((c) => c.id === agentName)) {
|
|
101
|
+
return signedIn.find((d) => d.id === agentName)?.id ?? null;
|
|
102
|
+
}
|
|
103
|
+
// Custom/workflow agent: fall back to the signed-in runtime (preferring claude).
|
|
104
|
+
return signedIn.find((d) => d.id === 'claude')?.id ?? signedIn[0]?.id ?? null;
|
|
105
|
+
}
|
|
82
106
|
// A long random sentinel makes an accidental (or malicious) collision with a
|
|
83
107
|
// token's contents effectively impossible, so the quoted heredoc can never be
|
|
84
108
|
// closed early by the credential body.
|
package/dist/lib/daemon.js
CHANGED
|
@@ -329,7 +329,12 @@ export async function runDaemon() {
|
|
|
329
329
|
log('WARN', `Secrets broker host skipped: ${err.message}`);
|
|
330
330
|
}
|
|
331
331
|
const scheduler = new JobScheduler(async (config) => {
|
|
332
|
-
|
|
332
|
+
const jobLabel = config.command
|
|
333
|
+
? 'command'
|
|
334
|
+
: config.workflow
|
|
335
|
+
? `workflow: ${config.workflow}`
|
|
336
|
+
: `agent: ${config.agent}`;
|
|
337
|
+
log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
|
|
333
338
|
try {
|
|
334
339
|
const meta = await executeJobDetached(config);
|
|
335
340
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
@@ -419,6 +424,8 @@ export async function runDaemon() {
|
|
|
419
424
|
log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
|
|
420
425
|
(r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
|
|
421
426
|
}
|
|
427
|
+
if (r.warnings.length)
|
|
428
|
+
log('WARN', `sessions sync: ${r.warnings[0]}`);
|
|
422
429
|
}
|
|
423
430
|
catch (err) {
|
|
424
431
|
log('ERROR', `sessions sync failed: ${err.message}`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-wide device operations — pick online targets and run a command on each.
|
|
3
|
+
*
|
|
4
|
+
* Used by `agents fleet update` / `agents fleet run` (aliases of the same
|
|
5
|
+
* subcommands under `agents devices`). Offline devices are skipped with a
|
|
6
|
+
* reason so a single dead node never blocks the rest of the rollout. Per-device
|
|
7
|
+
* throws (misconfigured auth, etc.) become `failed` rows — they never abort
|
|
8
|
+
* the remaining devices.
|
|
9
|
+
*/
|
|
10
|
+
import type { DeviceProfile, DeviceRegistry } from './registry.js';
|
|
11
|
+
export type FleetSkipReason = 'offline' | 'no-address';
|
|
12
|
+
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
|
+
export declare const FLEET_VERSION_RE: RegExp;
|
|
14
|
+
export interface FleetTarget {
|
|
15
|
+
device: DeviceProfile;
|
|
16
|
+
/** When set, this device is not reached (skip with reason). */
|
|
17
|
+
skip?: FleetSkipReason;
|
|
18
|
+
}
|
|
19
|
+
export interface FleetRunResult {
|
|
20
|
+
name: string;
|
|
21
|
+
status: 'ok' | 'failed' | 'skipped';
|
|
22
|
+
code: number | null;
|
|
23
|
+
reason?: FleetSkipReason | string;
|
|
24
|
+
/** Truncated combined stderr/stdout for failures. */
|
|
25
|
+
detail?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Classify each registered device for a fleet operation.
|
|
29
|
+
*
|
|
30
|
+
* - Tailscale-offline → skip `offline`
|
|
31
|
+
* - No address → skip `no-address`
|
|
32
|
+
* - Everything else is a target (including this machine, reached over ssh when
|
|
33
|
+
* it has a registry address — same path as any other box).
|
|
34
|
+
*/
|
|
35
|
+
export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
36
|
+
/** Human label for a skip reason. */
|
|
37
|
+
export declare function skipLabel(reason: FleetSkipReason): string;
|
|
38
|
+
/**
|
|
39
|
+
* Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
|
|
40
|
+
* Captures stdout/stderr (not inherited) so the fleet table can summarize.
|
|
41
|
+
* Throws from buildSshInvocation are returned as a non-zero result so a single
|
|
42
|
+
* misconfigured device cannot abort the fleet loop.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?: {
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}): {
|
|
47
|
+
code: number | null;
|
|
48
|
+
stdout: string;
|
|
49
|
+
stderr: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
53
|
+
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
54
|
+
* cannot inject shell metacharacters into the remote command line.
|
|
55
|
+
*/
|
|
56
|
+
export declare function upgradeCommand(version?: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Execute a command across planned targets. Pure orchestration over
|
|
59
|
+
* {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
|
|
60
|
+
* the runner are recorded as `failed` so one bad device never aborts the rest.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-wide device operations — pick online targets and run a command on each.
|
|
3
|
+
*
|
|
4
|
+
* Used by `agents fleet update` / `agents fleet run` (aliases of the same
|
|
5
|
+
* subcommands under `agents devices`). Offline devices are skipped with a
|
|
6
|
+
* reason so a single dead node never blocks the rest of the rollout. Per-device
|
|
7
|
+
* throws (misconfigured auth, etc.) become `failed` rows — they never abort
|
|
8
|
+
* the remaining devices.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
|
|
12
|
+
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
|
+
export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
14
|
+
/**
|
|
15
|
+
* Classify each registered device for a fleet operation.
|
|
16
|
+
*
|
|
17
|
+
* - Tailscale-offline → skip `offline`
|
|
18
|
+
* - No address → skip `no-address`
|
|
19
|
+
* - Everything else is a target (including this machine, reached over ssh when
|
|
20
|
+
* it has a registry address — same path as any other box).
|
|
21
|
+
*/
|
|
22
|
+
export function planFleetTargets(reg) {
|
|
23
|
+
const names = Object.keys(reg).sort();
|
|
24
|
+
return names.map((name) => {
|
|
25
|
+
const device = reg[name];
|
|
26
|
+
if (device.tailscale && !device.tailscale.online) {
|
|
27
|
+
return { device, skip: 'offline' };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
sshTargetFor(device);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { device, skip: 'no-address' };
|
|
34
|
+
}
|
|
35
|
+
return { device };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Human label for a skip reason. */
|
|
39
|
+
export function skipLabel(reason) {
|
|
40
|
+
switch (reason) {
|
|
41
|
+
case 'offline':
|
|
42
|
+
return 'offline';
|
|
43
|
+
case 'no-address':
|
|
44
|
+
return 'no address';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
|
|
49
|
+
* Captures stdout/stderr (not inherited) so the fleet table can summarize.
|
|
50
|
+
* Throws from buildSshInvocation are returned as a non-zero result so a single
|
|
51
|
+
* misconfigured device cannot abort the fleet loop.
|
|
52
|
+
*/
|
|
53
|
+
export function runOnDevice(device, cmd, opts = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const shim = writeAskpassShim();
|
|
56
|
+
const { args, env } = buildSshInvocation(device, cmd, shim);
|
|
57
|
+
const res = spawnSync('ssh', args, {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
env: { ...process.env, ...env },
|
|
60
|
+
timeout: opts.timeoutMs ?? 600_000,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
code: res.status,
|
|
64
|
+
stdout: res.stdout?.toString() ?? '',
|
|
65
|
+
stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
code: 1,
|
|
71
|
+
stdout: '',
|
|
72
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
78
|
+
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
79
|
+
* cannot inject shell metacharacters into the remote command line.
|
|
80
|
+
*/
|
|
81
|
+
export function upgradeCommand(version) {
|
|
82
|
+
if (version !== undefined && version !== '') {
|
|
83
|
+
if (!FLEET_VERSION_RE.test(version)) {
|
|
84
|
+
throw new Error(`Invalid version '${version}'. Use a semver or dist-tag (letters, digits, . _ - only).`);
|
|
85
|
+
}
|
|
86
|
+
return ['agents', 'upgrade', version, '--yes'];
|
|
87
|
+
}
|
|
88
|
+
return ['agents', 'upgrade', '--yes'];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Execute a command across planned targets. Pure orchestration over
|
|
92
|
+
* {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
|
|
93
|
+
* the runner are recorded as `failed` so one bad device never aborts the rest.
|
|
94
|
+
*/
|
|
95
|
+
export function runFleet(targets, cmd, runner = runOnDevice) {
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const t of targets) {
|
|
98
|
+
if (t.skip) {
|
|
99
|
+
results.push({
|
|
100
|
+
name: t.device.name,
|
|
101
|
+
status: 'skipped',
|
|
102
|
+
code: null,
|
|
103
|
+
reason: t.skip,
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const res = runner(t.device, cmd);
|
|
109
|
+
const ok = res.code === 0;
|
|
110
|
+
const detail = (res.stderr || res.stdout).trim().slice(0, 200);
|
|
111
|
+
results.push({
|
|
112
|
+
name: t.device.name,
|
|
113
|
+
status: ok ? 'ok' : 'failed',
|
|
114
|
+
code: res.code,
|
|
115
|
+
detail: ok ? undefined : detail || undefined,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
results.push({
|
|
120
|
+
name: t.device.name,
|
|
121
|
+
status: 'failed',
|
|
122
|
+
code: 1,
|
|
123
|
+
detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device resource probing for `agents devices list`.
|
|
3
|
+
*
|
|
4
|
+
* One SSH round-trip per device gathers load average, memory pressure, and core
|
|
5
|
+
* count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
|
|
6
|
+
* with a bounded timeout so the list stays responsive — a slow or hung box
|
|
7
|
+
* degrades to "no stats" instead of blocking the whole table.
|
|
8
|
+
*
|
|
9
|
+
* The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
|
|
10
|
+
* the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
|
|
11
|
+
* separate copy on purpose: the CLI does not import across packages.
|
|
12
|
+
*/
|
|
13
|
+
import type { DeviceProfile } from './registry.js';
|
|
14
|
+
/** Default per-device probe budget. Short enough that the list never hangs on a
|
|
15
|
+
* wedged box, long enough for a cold relayed SSH handshake. */
|
|
16
|
+
export declare const PROBE_TIMEOUT_MS = 2500;
|
|
17
|
+
export interface DeviceStats {
|
|
18
|
+
host: string;
|
|
19
|
+
reachable: boolean;
|
|
20
|
+
loadAvg1?: number;
|
|
21
|
+
ncpu?: number;
|
|
22
|
+
/** loadAvg1 / ncpu * 100 — load normalized to core count (the "has room" number). */
|
|
23
|
+
loadPercent?: number;
|
|
24
|
+
memPercent?: number;
|
|
25
|
+
memTotalBytes?: number;
|
|
26
|
+
memFreeBytes?: number;
|
|
27
|
+
fetchedAt: number;
|
|
28
|
+
}
|
|
29
|
+
/** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
|
|
30
|
+
export declare function fmtBytes(bytes: number | undefined): string;
|
|
31
|
+
export declare function parseUptime(out: string): {
|
|
32
|
+
loadAvg1?: number;
|
|
33
|
+
};
|
|
34
|
+
interface MemStats {
|
|
35
|
+
memPercent?: number;
|
|
36
|
+
memTotalBytes?: number;
|
|
37
|
+
memFreeBytes?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function parseVmStat(out: string): MemStats;
|
|
40
|
+
export declare function parseLinuxMemInfo(out: string): MemStats;
|
|
41
|
+
export declare function parseNcpu(out: string): {
|
|
42
|
+
ncpu?: number;
|
|
43
|
+
};
|
|
44
|
+
/** Assemble a DeviceStats from the three snippet sections. */
|
|
45
|
+
export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
|
|
46
|
+
export interface FleetCapacity {
|
|
47
|
+
reachable: number;
|
|
48
|
+
cores: number;
|
|
49
|
+
memTotalBytes: number;
|
|
50
|
+
memFreeBytes: number;
|
|
51
|
+
}
|
|
52
|
+
/** Sum cores and memory across reachable devices for the summary footer. */
|
|
53
|
+
export declare function fleetCapacity(statsList: Iterable<DeviceStats>): FleetCapacity;
|
|
54
|
+
/** Headroom bucket from the worst of normalized-load and memory. */
|
|
55
|
+
export type Headroom = 'idle' | 'light' | 'busy' | 'loaded' | 'unknown';
|
|
56
|
+
export declare function headroom(stats: DeviceStats | undefined): Headroom;
|
|
57
|
+
/** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
|
|
58
|
+
* an unreachable/slow/misconfigured device resolves to `reachable: false`. */
|
|
59
|
+
export declare function probeDeviceStats(device: DeviceProfile, opts?: {
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
now?: number;
|
|
62
|
+
}): Promise<DeviceStats>;
|
|
63
|
+
/** Probe the local machine directly (no ssh round-trip) — used for the "this
|
|
64
|
+
* machine" row so it always shows real numbers even if it isn't ssh-reachable
|
|
65
|
+
* from itself. */
|
|
66
|
+
export declare function probeLocalStats(host: string, opts?: {
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
now?: number;
|
|
69
|
+
}): Promise<DeviceStats>;
|
|
70
|
+
/** Probe many devices concurrently; returns a name→stats map. Bounded by the
|
|
71
|
+
* per-probe timeout, so total wall time ≈ the slowest single probe. The device
|
|
72
|
+
* named `selfName` is probed locally instead of over ssh. */
|
|
73
|
+
export declare function probeFleetStats(devices: DeviceProfile[], opts?: {
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
selfName?: string;
|
|
76
|
+
}): Promise<Map<string, DeviceStats>>;
|
|
77
|
+
export {};
|