@phnx-labs/agents-cli 1.22.58 → 1.22.60
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 +260 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +32 -1
- package/dist/commands/monitors.js +187 -23
- package/dist/commands/perf.js +10 -0
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +52 -16
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +2 -2
- package/dist/commands/teams.js +77 -21
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/lib/accounting/rotate.js +12 -4
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +43 -2
- package/dist/lib/feed/events.js +4 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/installations/versions.d.ts +17 -0
- package/dist/lib/installations/versions.js +53 -2
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/self-update.d.ts +42 -0
- package/dist/lib/self-update.js +88 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +32 -6
- package/dist/lib/session/db.js +128 -12
- package/dist/lib/session/live-metadata.js +3 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/sync.d.ts +113 -6
- package/dist/lib/traces/sync.js +193 -19
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
|
@@ -55,7 +55,7 @@ import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/installa
|
|
|
55
55
|
import { getAgentsInvocation } from '../lib/daemon/daemon.js';
|
|
56
56
|
import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
|
|
57
57
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
58
|
-
import { sessionPicker, buildPreview, loadSessionPreviewDigest, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
58
|
+
import { sessionPicker, buildPreview, loadSessionPreviewDigest, transcriptOnPeerOf, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
59
59
|
import { setHelpSections } from '../lib/help.js';
|
|
60
60
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
61
61
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
@@ -2017,13 +2017,14 @@ export async function renderSessionPreview(query, scope) {
|
|
|
2017
2017
|
return;
|
|
2018
2018
|
}
|
|
2019
2019
|
const session = outcome.session;
|
|
2020
|
-
|
|
2020
|
+
const transcriptPeer = transcriptOnPeerOf(session);
|
|
2021
|
+
if (transcriptPeer) {
|
|
2021
2022
|
const args = ['sessions', 'preview', session.id, '--local'];
|
|
2022
2023
|
if (scope.json)
|
|
2023
2024
|
args.push('--json');
|
|
2024
|
-
const rendered = await runOnPeer(args,
|
|
2025
|
+
const rendered = await runOnPeer(args, transcriptPeer);
|
|
2025
2026
|
if (rendered === 'no-target') {
|
|
2026
|
-
console.error(chalk.red(`Session ${session.id} is on ${
|
|
2027
|
+
console.error(chalk.red(`Session ${session.id} is on ${transcriptPeer}, but that device is not reachable.`));
|
|
2027
2028
|
process.exitCode = 1;
|
|
2028
2029
|
}
|
|
2029
2030
|
return;
|
|
@@ -2316,9 +2317,16 @@ limitSource) {
|
|
|
2316
2317
|
return;
|
|
2317
2318
|
}
|
|
2318
2319
|
// --device / --devices values are merged into options.host for internal routing. A bare `all` / `fleet` sentinel means
|
|
2319
|
-
// "search every peer" — which is already the default
|
|
2320
|
-
//
|
|
2321
|
-
|
|
2320
|
+
// "search every peer" — which is already the default for `--active` and the
|
|
2321
|
+
// interactive listing — so it resolves to no explicit host set rather than
|
|
2322
|
+
// erroring on a device literally named "all".
|
|
2323
|
+
const rawDeviceTargets = [...(options.device ?? []), ...(options.devices ?? [])];
|
|
2324
|
+
// But the HISTORICAL `--json` listing does NOT fan out by default (it stays a
|
|
2325
|
+
// deterministic local slice for scripts), so the sentinel would otherwise be
|
|
2326
|
+
// silently dropped and the fleet never reached (PHNX-2673). Remember it so that
|
|
2327
|
+
// path can sweep every peer, exactly as the interactive listing already does.
|
|
2328
|
+
const fleetWide = rawDeviceTargets.some(t => t.toLowerCase() === 'all' || t.toLowerCase() === 'fleet');
|
|
2329
|
+
const deviceTargets = rawDeviceTargets
|
|
2322
2330
|
.filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
|
|
2323
2331
|
if (deviceTargets.length > 0) {
|
|
2324
2332
|
options.host = [...(options.host ?? []), ...deviceTargets];
|
|
@@ -2775,16 +2783,43 @@ limitSource) {
|
|
|
2775
2783
|
// each peer) would fall to FTS content search and return every transcript
|
|
2776
2784
|
// that merely MENTIONS the id, defeating exact remote resolution. A genuine
|
|
2777
2785
|
// search phrase keeps the ranked metadata+content path.
|
|
2778
|
-
|
|
2786
|
+
let filtered = searchQuery
|
|
2779
2787
|
? resolveSessionQuery(sessions, searchQuery, {
|
|
2780
2788
|
scope: { agent: options.agent, project: options.project, routine: options.routine },
|
|
2781
2789
|
}).matches
|
|
2782
2790
|
: sessions;
|
|
2791
|
+
// `--device all`/`--fleet` on a historical `--json` query fans out to every
|
|
2792
|
+
// peer and merges their rows in — the SAME SSH sweep the interactive listing
|
|
2793
|
+
// below uses, and the whole-fleet twin of the explicit `--device <host>
|
|
2794
|
+
// --json` path (`runRemoteSessionsJson`). Without this the documented
|
|
2795
|
+
// "search the whole fleet" flag returned local rows only (PHNX-2673). Guarded
|
|
2796
|
+
// to the sentinel so a bare `--json` stays a deterministic local slice for
|
|
2797
|
+
// scripts, and skipped under `--local`/the peer recursion guard. Dead peers
|
|
2798
|
+
// are skipped by the fan-out, never fatal — enrichment, not a dependency.
|
|
2799
|
+
const forceLocalJson = options.local === true || process.env[NO_FANOUT_ENV] === '1';
|
|
2800
|
+
if (fleetWide && !forceLocalJson) {
|
|
2801
|
+
const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
|
|
2802
|
+
if (!forwarded.includes('--json'))
|
|
2803
|
+
forwarded.push('--json');
|
|
2804
|
+
const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
|
|
2805
|
+
try {
|
|
2806
|
+
const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
|
|
2807
|
+
if (remoteSessions.length > 0) {
|
|
2808
|
+
filtered = mergeLocalFirst([...filtered, ...remoteSessions], machineId());
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
catch {
|
|
2812
|
+
// fan-out is an enrichment, never a hard dependency
|
|
2813
|
+
}
|
|
2814
|
+
finally {
|
|
2815
|
+
fanSpinner?.stop();
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2783
2818
|
// JSON is the canonical picker contract: enrich the durable rows once
|
|
2784
2819
|
// from the shared live cache so every consumer gets lifecycle/recovery
|
|
2785
2820
|
// metadata without performing its own join or transcript scan.
|
|
2786
2821
|
const live = await gatherActiveSessions({
|
|
2787
|
-
local:
|
|
2822
|
+
local: forceLocalJson,
|
|
2788
2823
|
hosts: options.host,
|
|
2789
2824
|
});
|
|
2790
2825
|
process.stdout.write(serializeSessionsJson(serializeSessionPickerRows(filtered, live.sessions)));
|
|
@@ -3839,11 +3874,11 @@ export async function handlePickedSession(picked) {
|
|
|
3839
3874
|
}
|
|
3840
3875
|
// Reading and resuming are on DIFFERENT machines' terms, and conflating them
|
|
3841
3876
|
// is what RUSH-2022 was. Reading follows the FILE: a synced mirror sits on
|
|
3842
|
-
// this disk, so
|
|
3843
|
-
// peer. Resuming follows the HARNESS STATE, which is on the
|
|
3844
|
-
// whatever the transcript's location — a mirror included.
|
|
3877
|
+
// this disk, so a peer-owned row with no readable local transcript has to be
|
|
3878
|
+
// read on the peer. Resuming follows the HARNESS STATE, which is on the
|
|
3879
|
+
// owning machine whatever the transcript's location — a mirror included.
|
|
3845
3880
|
if (picked.action === 'view') {
|
|
3846
|
-
const readFrom = picked.session
|
|
3881
|
+
const readFrom = transcriptOnPeerOf(picked.session);
|
|
3847
3882
|
if (readFrom) {
|
|
3848
3883
|
const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], readFrom);
|
|
3849
3884
|
if (rc === 'no-target')
|
|
@@ -4469,7 +4504,8 @@ async function renderOneSession(query, mode, scope) {
|
|
|
4469
4504
|
}
|
|
4470
4505
|
if (outcome.kind === 'resolved') {
|
|
4471
4506
|
const resolved = outcome.session;
|
|
4472
|
-
|
|
4507
|
+
const transcriptPeer = transcriptOnPeerOf(resolved);
|
|
4508
|
+
if (transcriptPeer) {
|
|
4473
4509
|
const args = ['sessions', resolved.id, '--local'];
|
|
4474
4510
|
const flag = modeFlag(mode);
|
|
4475
4511
|
if (flag)
|
|
@@ -4484,9 +4520,9 @@ async function renderOneSession(query, mode, scope) {
|
|
|
4484
4520
|
args.push('--last', String(scope.filter.last));
|
|
4485
4521
|
if (scope.redact === false)
|
|
4486
4522
|
args.push('--no-redact');
|
|
4487
|
-
const rendered = await runOnPeer(args,
|
|
4523
|
+
const rendered = await runOnPeer(args, transcriptPeer);
|
|
4488
4524
|
if (rendered === 'no-target') {
|
|
4489
|
-
console.error(chalk.red(`Session ${resolved.id} is on ${
|
|
4525
|
+
console.error(chalk.red(`Session ${resolved.id} is on ${transcriptPeer}, but that device is not reachable.`));
|
|
4490
4526
|
process.exit(1);
|
|
4491
4527
|
}
|
|
4492
4528
|
return;
|
package/dist/commands/ssh.js
CHANGED
|
@@ -130,7 +130,11 @@ function pctCell(v, width) {
|
|
|
130
130
|
* header — which defeats the scannability this column exists for. */
|
|
131
131
|
const SPEC_WIDTH_MIN = 12;
|
|
132
132
|
/** The static hardware as one compact cell — `12c 64G 1T`: cores, total RAM,
|
|
133
|
-
* total root disk via fmtBytes. `—`
|
|
133
|
+
* total root disk via fmtBytes. `—` covers two cases: no probe has ever seen
|
|
134
|
+
* the box, or the probe answered but yielded no usable core count (`parseNcpu`
|
|
135
|
+
* finding none, or the Windows `ncpu` group failing the finite-and-positive
|
|
136
|
+
* check) — the cell is keyed on `ncpu` because a spec string without it would
|
|
137
|
+
* read as a machine with zero cores.
|
|
134
138
|
*
|
|
135
139
|
* Deliberately NOT gated on `reachable`: hardware does not change while a
|
|
136
140
|
* machine is down, so an offline device keeps rendering the spec from its last
|
|
@@ -2298,6 +2302,13 @@ box; \`agents doctor\` names it.
|
|
|
2298
2302
|
|
|
2299
2303
|
A box whose probe cannot answer (no POSIX shell, e.g. Windows) is reported
|
|
2300
2304
|
\`unverified\` rather than counted as a success.
|
|
2305
|
+
|
|
2306
|
+
The upgrade OWNS its global bin links: after installing, it verifies that
|
|
2307
|
+
\`<prefix>/bin/{agents,ag,browser,computer}\` resolve to the freshly-installed
|
|
2308
|
+
copy and RESTORES any the package manager dropped — the state that once left a
|
|
2309
|
+
box upgraded in place but with every \`agents\` invocation "command not found"
|
|
2310
|
+
(PHNX-2768). A link it cannot make resolve fails the upgrade loud, so that box
|
|
2311
|
+
is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
|
|
2301
2312
|
`)
|
|
2302
2313
|
.action(async (version) => {
|
|
2303
2314
|
let cmd;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { relTime, truncate, humanDuration } from '../lib/format.js';
|
|
11
11
|
import { itemPicker } from '../lib/picker.js';
|
|
12
|
+
import { deliveryDisplayLabel, deliveryColorKey } from '../lib/teams/delivery.js';
|
|
12
13
|
const AGENT_LABEL = {
|
|
13
14
|
claude: 'Claude',
|
|
14
15
|
codex: 'Codex',
|
|
@@ -21,6 +22,7 @@ function statusColor(status) {
|
|
|
21
22
|
case 'pending': return chalk.blue;
|
|
22
23
|
case 'running': return chalk.yellow;
|
|
23
24
|
case 'completed': return chalk.green;
|
|
25
|
+
case 'stranded': return chalk.yellow;
|
|
24
26
|
case 'failed': return chalk.red;
|
|
25
27
|
case 'stopped': return chalk.gray;
|
|
26
28
|
default: return chalk.white;
|
|
@@ -61,10 +63,12 @@ function displayAgent(agent, version) {
|
|
|
61
63
|
function statusCell(t) {
|
|
62
64
|
if (t.agent_count === 0)
|
|
63
65
|
return chalk.gray('empty');
|
|
66
|
+
const done = Math.max(0, t.completed - (t.stranded ?? 0));
|
|
64
67
|
const states = [
|
|
65
68
|
[t.pending, chalk.blue, 'pending'],
|
|
66
69
|
[t.running, chalk.yellow, 'working'],
|
|
67
|
-
[
|
|
70
|
+
[done, chalk.green, 'done'],
|
|
71
|
+
[t.stranded ?? 0, chalk.yellow, 'stranded'],
|
|
68
72
|
[t.failed, chalk.red, 'failed'],
|
|
69
73
|
[t.stopped, chalk.gray, 'stopped'],
|
|
70
74
|
];
|
|
@@ -76,13 +80,16 @@ function statusCell(t) {
|
|
|
76
80
|
return nonzero.map(([n, color, label]) => color(`${n} ${label}`)).join(chalk.gray(' · '));
|
|
77
81
|
}
|
|
78
82
|
function statusSummaryParts(t) {
|
|
83
|
+
const done = Math.max(0, t.completed - (t.stranded ?? 0));
|
|
79
84
|
const parts = [];
|
|
80
85
|
if (t.pending)
|
|
81
86
|
parts.push(chalk.blue(`${t.pending} pending`));
|
|
82
87
|
if (t.running)
|
|
83
88
|
parts.push(chalk.yellow(`${t.running} working`));
|
|
84
|
-
if (
|
|
85
|
-
parts.push(chalk.green(`${
|
|
89
|
+
if (done)
|
|
90
|
+
parts.push(chalk.green(`${done} done`));
|
|
91
|
+
if (t.stranded)
|
|
92
|
+
parts.push(chalk.yellow(`${t.stranded} stranded`));
|
|
86
93
|
if (t.failed)
|
|
87
94
|
parts.push(chalk.red(`${t.failed} failed`));
|
|
88
95
|
if (t.stopped)
|
|
@@ -173,7 +180,12 @@ export function buildTeamPreview(row) {
|
|
|
173
180
|
const nameW = Math.max(6, ...row.agents.map((a) => displayHandle(a).length));
|
|
174
181
|
const agentW = Math.max(8, ...row.agents.map((a) => displayAgent(a.agent_type, a.version).length));
|
|
175
182
|
for (const a of row.agents) {
|
|
176
|
-
const
|
|
183
|
+
const delivery = a.delivery;
|
|
184
|
+
const colorKey = delivery ? deliveryColorKey(delivery, a.status) : a.status;
|
|
185
|
+
const label = delivery
|
|
186
|
+
? deliveryDisplayLabel(delivery, a.status)
|
|
187
|
+
: a.status.toUpperCase();
|
|
188
|
+
const stat = statusColor(colorKey)(label);
|
|
177
189
|
const metaParts = [];
|
|
178
190
|
if (a.task_type)
|
|
179
191
|
metaParts.push(chalk.magenta(a.task_type));
|
|
@@ -244,8 +256,8 @@ function searchHaystack(row) {
|
|
|
244
256
|
parts.push(a.status);
|
|
245
257
|
}
|
|
246
258
|
// Also include team-level status words exposed in the row ("done",
|
|
247
|
-
// "working", "pending", "failed", "stopped", "empty") so the
|
|
248
|
-
// what the user sees on screen.
|
|
259
|
+
// "working", "pending", "stranded", "failed", "stopped", "empty") so the
|
|
260
|
+
// search matches what the user sees on screen.
|
|
249
261
|
const t = row.team;
|
|
250
262
|
if (t.agent_count === 0)
|
|
251
263
|
parts.push('empty');
|
|
@@ -255,6 +267,8 @@ function searchHaystack(row) {
|
|
|
255
267
|
parts.push('working', 'running');
|
|
256
268
|
if (t.completed)
|
|
257
269
|
parts.push('done', 'completed');
|
|
270
|
+
if (t.stranded)
|
|
271
|
+
parts.push('stranded');
|
|
258
272
|
if (t.failed)
|
|
259
273
|
parts.push('failed');
|
|
260
274
|
if (t.stopped)
|
package/dist/commands/teams.d.ts
CHANGED
|
@@ -120,11 +120,11 @@ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt'
|
|
|
120
120
|
export declare function runOneWave(mgr: AgentManager, team: string, json: boolean): Promise<void>;
|
|
121
121
|
export declare function buildTeamRowsFromSnapshots(registry: TeamRegistry, agents: TeamListAgentSnapshot[],
|
|
122
122
|
/** team name -> the session that spawned it (see teamSpawners). */
|
|
123
|
-
spawners?: Map<string, TeamSpawner>): {
|
|
123
|
+
spawners?: Map<string, TeamSpawner>): Promise<{
|
|
124
124
|
rows: TeamRow[];
|
|
125
125
|
teams: TaskInfo[];
|
|
126
126
|
names: string[];
|
|
127
|
-
}
|
|
127
|
+
}>;
|
|
128
128
|
/**
|
|
129
129
|
* `teams add --remote-cwd` is a no-op trap. `--remote-cwd` comes from the shared
|
|
130
130
|
* `--device` option family (option.ts) and is meaningful for commands that *route*
|
package/dist/commands/teams.js
CHANGED
|
@@ -62,6 +62,7 @@ function statusColor(status) {
|
|
|
62
62
|
case 'running': return chalk.yellow;
|
|
63
63
|
case 'completed': return chalk.green;
|
|
64
64
|
case 'pr_open': return chalk.magenta; // RUSH-2380: process done, PR not merged
|
|
65
|
+
case 'stranded': return chalk.yellow; // PHNX-2951: done but work not committed
|
|
65
66
|
case 'failed': return chalk.red;
|
|
66
67
|
case 'stopped': return chalk.gray;
|
|
67
68
|
default: return chalk.white;
|
|
@@ -150,6 +151,7 @@ function snapshotToStatusDetail(agent) {
|
|
|
150
151
|
after: agent.after,
|
|
151
152
|
task_type: agent.task_type,
|
|
152
153
|
host: agent.host,
|
|
154
|
+
workspace_dir: agent.workspace_dir,
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
function fullName(type, version) {
|
|
@@ -784,6 +786,9 @@ function printAgentDetail(a, session) {
|
|
|
784
786
|
}
|
|
785
787
|
if (a.has_errors)
|
|
786
788
|
console.log(` ${chalk.red('! reported an error')}`);
|
|
789
|
+
if (delivery === 'stranded' && a.workspace_dir) {
|
|
790
|
+
console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${a.workspace_dir}`);
|
|
791
|
+
}
|
|
787
792
|
if (a.pr_url)
|
|
788
793
|
console.log(` ${chalk.gray('PR ')}${a.pr_url}`);
|
|
789
794
|
}
|
|
@@ -864,17 +869,34 @@ function printAgentSummary(s) {
|
|
|
864
869
|
console.log(` ${chalk.gray('>')} ${truncate(firstLine, 96)}`);
|
|
865
870
|
}
|
|
866
871
|
}
|
|
872
|
+
if (delivery === 'stranded' && s.workspace_dir) {
|
|
873
|
+
console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${s.workspace_dir}`);
|
|
874
|
+
}
|
|
867
875
|
if (s.pr_url)
|
|
868
876
|
console.log(` ${chalk.gray('PR ')} ${chalk.cyan(s.pr_url)}`);
|
|
869
877
|
}
|
|
878
|
+
function formatTeamStatusSummary(summary) {
|
|
879
|
+
const done = Math.max(0, summary.completed - summary.stranded);
|
|
880
|
+
const parts = [];
|
|
881
|
+
if (summary.pending > 0)
|
|
882
|
+
parts.push(`${summary.pending} pending`);
|
|
883
|
+
if (summary.running > 0 || parts.length === 0)
|
|
884
|
+
parts.push(`${summary.running} working`);
|
|
885
|
+
if (done > 0 || summary.stranded === 0)
|
|
886
|
+
parts.push(`${done} done`);
|
|
887
|
+
if (summary.stranded > 0)
|
|
888
|
+
parts.push(`${summary.stranded} stranded`);
|
|
889
|
+
if (summary.failed > 0 || parts.length === 0)
|
|
890
|
+
parts.push(`${summary.failed} failed`);
|
|
891
|
+
if (summary.stopped > 0 || parts.length === 0)
|
|
892
|
+
parts.push(`${summary.stopped} stopped`);
|
|
893
|
+
return `(${parts.join(', ')})`;
|
|
894
|
+
}
|
|
870
895
|
// Render a team's status in the same format the `status` subcommand uses, so
|
|
871
896
|
// the interactive picker's Enter action drops the user into a familiar view.
|
|
872
897
|
async function printTeamStatus(team, result) {
|
|
873
898
|
const { summary, agents } = result;
|
|
874
|
-
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
|
|
875
|
-
chalk.gray(summary.pending > 0
|
|
876
|
-
? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
|
|
877
|
-
: `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
|
|
899
|
+
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
|
|
878
900
|
if (agents.length === 0) {
|
|
879
901
|
console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
|
|
880
902
|
}
|
|
@@ -900,10 +922,7 @@ async function printTeamStatus(team, result) {
|
|
|
900
922
|
// verbose/legacy layout.
|
|
901
923
|
function printTeamSummary(team, result) {
|
|
902
924
|
const { summary, agents } = result;
|
|
903
|
-
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
|
|
904
|
-
chalk.gray(summary.pending > 0
|
|
905
|
-
? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
|
|
906
|
-
: `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
|
|
925
|
+
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
|
|
907
926
|
if (agents.length === 0) {
|
|
908
927
|
console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
|
|
909
928
|
}
|
|
@@ -925,12 +944,13 @@ function printTeamSummary(team, result) {
|
|
|
925
944
|
console.log(chalk.gray('Raw log: agents teams logs --team ' + team + ' --teammate <name>'));
|
|
926
945
|
}
|
|
927
946
|
// Classify a team into a single bucket for --status filtering.
|
|
928
|
-
// - empty:
|
|
929
|
-
// - waiting:
|
|
930
|
-
// - working:
|
|
931
|
-
// - failed:
|
|
932
|
-
//
|
|
933
|
-
// -
|
|
947
|
+
// - empty: no teammates (created but nobody added yet)
|
|
948
|
+
// - waiting: only staged teammates — call `teams start` to kick them off
|
|
949
|
+
// - working: at least one teammate still running
|
|
950
|
+
// - failed: at least one teammate failed or was stopped (any failure wins —
|
|
951
|
+
// even if others finished, you want to know about the failure)
|
|
952
|
+
// - stranded: everyone finished, but at least one has uncommitted work and no PR
|
|
953
|
+
// - done: everyone finished successfully, no failures, no stranded work
|
|
934
954
|
function classifyTeamStatus(t) {
|
|
935
955
|
if (t.agent_count === 0)
|
|
936
956
|
return 'empty';
|
|
@@ -943,6 +963,10 @@ function classifyTeamStatus(t) {
|
|
|
943
963
|
const accounted = t.running + t.completed + t.failed + t.stopped;
|
|
944
964
|
if (accounted < t.agent_count)
|
|
945
965
|
return 'waiting';
|
|
966
|
+
// Completed-without-delivery is not "done": the work is still in the worktree
|
|
967
|
+
// and will be lost on cleanup (PHNX-2951).
|
|
968
|
+
if ((t.stranded ?? 0) > 0)
|
|
969
|
+
return 'stranded';
|
|
946
970
|
return 'done';
|
|
947
971
|
}
|
|
948
972
|
// Merge persistent team registry with tasks derived from live agents so empty
|
|
@@ -959,6 +983,7 @@ function mergeTeams(registry, tasks) {
|
|
|
959
983
|
pending: 0,
|
|
960
984
|
running: 0,
|
|
961
985
|
completed: 0,
|
|
986
|
+
stranded: 0,
|
|
962
987
|
failed: 0,
|
|
963
988
|
stopped: 0,
|
|
964
989
|
workspace_dir: null,
|
|
@@ -969,7 +994,7 @@ function mergeTeams(registry, tasks) {
|
|
|
969
994
|
}
|
|
970
995
|
return Array.from(byName.values()).sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
|
|
971
996
|
}
|
|
972
|
-
function buildTasksFromSnapshots(agents) {
|
|
997
|
+
async function buildTasksFromSnapshots(agents) {
|
|
973
998
|
const byTeam = new Map();
|
|
974
999
|
for (const agent of agents) {
|
|
975
1000
|
const teamAgents = byTeam.get(agent.task_name) || [];
|
|
@@ -981,6 +1006,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
981
1006
|
let pending = 0;
|
|
982
1007
|
let running = 0;
|
|
983
1008
|
let completed = 0;
|
|
1009
|
+
let stranded = 0;
|
|
984
1010
|
let failed = 0;
|
|
985
1011
|
let stopped = 0;
|
|
986
1012
|
let earliestStart = null;
|
|
@@ -998,6 +1024,16 @@ function buildTasksFromSnapshots(agents) {
|
|
|
998
1024
|
failed++;
|
|
999
1025
|
else if (status === AgentStatus.STOPPED)
|
|
1000
1026
|
stopped++;
|
|
1027
|
+
// Stranded = completed with no PR and a dirty local worktree (PHNX-2951).
|
|
1028
|
+
if (status === AgentStatus.COMPLETED &&
|
|
1029
|
+
!agent.pr_url?.trim() &&
|
|
1030
|
+
!agent.host &&
|
|
1031
|
+
agent.workspace_dir) {
|
|
1032
|
+
const dirty = await hasUncommittedChanges(agent.workspace_dir);
|
|
1033
|
+
if (dirty) {
|
|
1034
|
+
stranded++;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1001
1037
|
const startedAt = parseTimestamp(agent.started_at);
|
|
1002
1038
|
if (startedAt && (!earliestStart || startedAt < earliestStart)) {
|
|
1003
1039
|
earliestStart = startedAt;
|
|
@@ -1017,6 +1053,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
1017
1053
|
pending,
|
|
1018
1054
|
running,
|
|
1019
1055
|
completed,
|
|
1056
|
+
stranded,
|
|
1020
1057
|
failed,
|
|
1021
1058
|
stopped,
|
|
1022
1059
|
workspace_dir: workspaceDir,
|
|
@@ -1026,7 +1063,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
1026
1063
|
}
|
|
1027
1064
|
return tasks.sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
|
|
1028
1065
|
}
|
|
1029
|
-
export function buildTeamRowsFromSnapshots(registry, agents,
|
|
1066
|
+
export async function buildTeamRowsFromSnapshots(registry, agents,
|
|
1030
1067
|
/** team name -> the session that spawned it (see teamSpawners). */
|
|
1031
1068
|
spawners) {
|
|
1032
1069
|
const byTeam = new Map();
|
|
@@ -1035,7 +1072,26 @@ spawners) {
|
|
|
1035
1072
|
details.push(snapshotToStatusDetail(agent));
|
|
1036
1073
|
byTeam.set(agent.task_name, details);
|
|
1037
1074
|
}
|
|
1038
|
-
const teams = mergeTeams(registry, buildTasksFromSnapshots(agents));
|
|
1075
|
+
const teams = mergeTeams(registry, await buildTasksFromSnapshots(agents));
|
|
1076
|
+
// Recompute delivery for cached snapshots so `teams list` reflects stranded
|
|
1077
|
+
// work discovered by probing the real worktree (PHNX-2951).
|
|
1078
|
+
for (const details of byTeam.values()) {
|
|
1079
|
+
for (const a of details) {
|
|
1080
|
+
const snapshot = agents.find((s) => s.agent_id === a.agent_id);
|
|
1081
|
+
if (snapshot &&
|
|
1082
|
+
normalizeTeamListStatus(a.status) === AgentStatus.COMPLETED &&
|
|
1083
|
+
!snapshot.pr_url?.trim() &&
|
|
1084
|
+
!snapshot.host &&
|
|
1085
|
+
snapshot.workspace_dir) {
|
|
1086
|
+
const dirty = await hasUncommittedChanges(snapshot.workspace_dir);
|
|
1087
|
+
a.delivery = resolveTeammateDelivery({
|
|
1088
|
+
status: a.status,
|
|
1089
|
+
prUrl: snapshot.pr_url,
|
|
1090
|
+
hasUncommittedChanges: dirty,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1039
1095
|
return {
|
|
1040
1096
|
teams,
|
|
1041
1097
|
rows: teams.map((team) => ({
|
|
@@ -1107,7 +1163,7 @@ async function loadTeamRows(_mgr) {
|
|
|
1107
1163
|
catch {
|
|
1108
1164
|
// The index is an enrichment here — a missing/locked DB just drops the column.
|
|
1109
1165
|
}
|
|
1110
|
-
return buildTeamRowsFromSnapshots(registry, agents, spawners);
|
|
1166
|
+
return await buildTeamRowsFromSnapshots(registry, agents, spawners);
|
|
1111
1167
|
}
|
|
1112
1168
|
// Picker fallback for `teams logs` when the teammate ref is omitted. Shows a
|
|
1113
1169
|
// flat list of every teammate with their team context; Enter picks one.
|
|
@@ -1271,7 +1327,7 @@ export function registerTeamsCommands(program) {
|
|
|
1271
1327
|
.alias('ls')
|
|
1272
1328
|
.description('List your teams, most recent activity first')
|
|
1273
1329
|
.option('-a, --agent <agent>', 'Filter: only teams with this agent (e.g. claude or claude@2.1.112)')
|
|
1274
|
-
.option('--status <status>', 'Filter: only teams with this status (working, done, failed, or empty)')
|
|
1330
|
+
.option('--status <status>', 'Filter: only teams with this status (working, done, stranded, failed, or empty)')
|
|
1275
1331
|
.option('--since <time>', 'Filter: teams active after this time (e.g. "2h", "7d", or ISO date)')
|
|
1276
1332
|
.option('--until <time>', 'Filter: teams active before this time (e.g. "30d", or ISO date)')
|
|
1277
1333
|
.option('-n, --limit <n>', 'Show at most this many teams (default: 20)', '20')
|
|
@@ -1298,7 +1354,7 @@ export function registerTeamsCommands(program) {
|
|
|
1298
1354
|
catch {
|
|
1299
1355
|
// The session index is an enrichment here; a missing one drops the column.
|
|
1300
1356
|
}
|
|
1301
|
-
let rows = buildTeamRowsFromSnapshots(registry, everyAgent, spawners).rows;
|
|
1357
|
+
let rows = (await buildTeamRowsFromSnapshots(registry, everyAgent, spawners)).rows;
|
|
1302
1358
|
// --- query: substring match on team name ---
|
|
1303
1359
|
if (query) {
|
|
1304
1360
|
const q = query.toLowerCase();
|
|
@@ -1318,7 +1374,7 @@ export function registerTeamsCommands(program) {
|
|
|
1318
1374
|
// --- --status: classify each team, filter ---
|
|
1319
1375
|
if (opts.status) {
|
|
1320
1376
|
const want = opts.status.toLowerCase();
|
|
1321
|
-
const validStatuses = ['working', 'done', 'failed', 'empty'];
|
|
1377
|
+
const validStatuses = ['working', 'done', 'stranded', 'failed', 'empty'];
|
|
1322
1378
|
if (!validStatuses.includes(want)) {
|
|
1323
1379
|
dieFriction('teams', 'invalid-status-filter', `Invalid --status '${opts.status}'. Use one of: ${validStatuses.join(', ')}`);
|
|
1324
1380
|
}
|
|
@@ -11,7 +11,7 @@ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalle
|
|
|
11
11
|
import { carryForwardSettings } from '../lib/settings-manifest.js';
|
|
12
12
|
import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/installations/shims.js';
|
|
13
13
|
import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
|
|
14
|
-
import {
|
|
14
|
+
import { tryAutoPullSystemRepo } from '../lib/git.js';
|
|
15
15
|
import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
|
|
16
16
|
import { setHelpSections } from '../lib/help.js';
|
|
17
17
|
import { updateSessionFilePaths } from '../lib/session/db.js';
|
|
@@ -630,10 +630,18 @@ export function registerVersionsCommands(program) {
|
|
|
630
630
|
useCmd.action(async (agentArg, versionArg, options) => {
|
|
631
631
|
try {
|
|
632
632
|
const skipPrompts = options.yes || !isInteractiveTerminal();
|
|
633
|
-
// Auto-pull ~/.agents/.system if it's a git repo
|
|
633
|
+
// Auto-pull ~/.agents/.system if it's a git repo tracking the EXPECTED
|
|
634
|
+
// system remote (silent on success). The system repo ships hooks that run
|
|
635
|
+
// as shell on tool events, so pulling from a repointed origin would be
|
|
636
|
+
// remote code execution — tryAutoPullSystemRepo refuses that (PHNX-2957).
|
|
634
637
|
const agentsDir = getAgentsDir();
|
|
635
|
-
const pullResult = await
|
|
636
|
-
if (pullResult.
|
|
638
|
+
const pullResult = await tryAutoPullSystemRepo(agentsDir);
|
|
639
|
+
if (pullResult.refused) {
|
|
640
|
+
console.error(chalk.red(`Refusing to auto-sync ~/.agents/.system: its origin (${pullResult.actualRemote}) is not the expected system repo.`));
|
|
641
|
+
console.error(chalk.gray('The system repo ships hooks that run on tool events; a fast-forward from an unexpected origin is not applied. ' +
|
|
642
|
+
'Re-point it (git -C ~/.agents/.system remote set-url origin <expected>) or set AGENTS_SYSTEM_REPO, then re-run `agents setup --force`.'));
|
|
643
|
+
}
|
|
644
|
+
else if (pullResult.pulled) {
|
|
637
645
|
console.log(chalk.gray('Synced ~/.agents/.system from remote'));
|
|
638
646
|
}
|
|
639
647
|
// Support both "claude 2.0.65" and "claude@2.0.65" formats
|
package/dist/commands/view.js
CHANGED
|
@@ -4,7 +4,7 @@ import { termLink } from '../lib/format.js';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
-
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
7
|
+
import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, credentialPresence, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
8
8
|
import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
|
|
9
9
|
import { machineId } from '../lib/machine-id.js';
|
|
10
10
|
import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
|
|
@@ -24,7 +24,7 @@ import { listNativeAccounts } from '../lib/account-registry.js';
|
|
|
24
24
|
import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
|
|
25
25
|
import { getCentralRulesFileName } from '../lib/rules/rules.js';
|
|
26
26
|
import { composeRulesFromState } from '../lib/rules/compose.js';
|
|
27
|
-
import { getConfiguredRunStrategy } from '../lib/accounting/rotate.js';
|
|
27
|
+
import { getConfiguredRunStrategy, isLaunchableSignedIn } from '../lib/accounting/rotate.js';
|
|
28
28
|
import { resolveRunDefaults } from '../lib/run-defaults.js';
|
|
29
29
|
import { resolveConfiguredModel } from '../lib/models.js';
|
|
30
30
|
import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
|
|
@@ -1293,6 +1293,11 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
|
|
|
1293
1293
|
isolated: isVersionIsolated(agentId, version),
|
|
1294
1294
|
isIsolatedDefault: getIsolatedDefault(agentId) === version,
|
|
1295
1295
|
signedIn: info.signedIn,
|
|
1296
|
+
// The strict per-version launch truth (vs the display `signedIn` above,
|
|
1297
|
+
// which inherits the active/global HOME login). The same primitive
|
|
1298
|
+
// `collectRunCandidates` uses locally, so remote `--device auto` placement
|
|
1299
|
+
// is gated on identical launchability (PHNX-3466).
|
|
1300
|
+
launchable: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
|
|
1296
1301
|
authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
|
|
1297
1302
|
email: info.email,
|
|
1298
1303
|
accountId: info.accountId,
|
|
@@ -313,7 +313,7 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
|
|
|
313
313
|
if (!deduped.has(c))
|
|
314
314
|
excluded.push(c);
|
|
315
315
|
}
|
|
316
|
-
const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
|
|
316
|
+
const { picked, usageUnverified } = preferVerified(sorted, nowMs, (from) => weightedRandomByCapacity(from, nowMs), 'representative');
|
|
317
317
|
return { picked, healthy: sorted, excluded, usageUnverified };
|
|
318
318
|
}
|
|
319
319
|
/**
|
|
@@ -376,8 +376,16 @@ export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
|
376
376
|
* routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
|
|
377
377
|
* near-exhausted-but-still-eligible candidate can still be picked occasionally.
|
|
378
378
|
*/
|
|
379
|
-
function weightedRandomByCapacity(sorted) {
|
|
380
|
-
const weights = sorted.map((c) => capacityWeight(
|
|
379
|
+
function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
|
|
380
|
+
const weights = sorted.map((c) => capacityWeight(
|
|
381
|
+
// An unverified (stale or absent) snapshot carries no trustworthy number,
|
|
382
|
+
// so it weights as UNVERIFIED_WEIGHT (the floor) instead of by its frozen
|
|
383
|
+
// usedPercent. Otherwise a day-old "45% used" competes as if it were live
|
|
384
|
+
// headroom and can win the draw over a verified-healthy account — which is
|
|
385
|
+
// exactly how balanced launched into an account already at its weekly cap
|
|
386
|
+
// on a worker whose usage refresh had stalled (PHNX-3479). A verified
|
|
387
|
+
// snapshot keeps its real remaining-headroom weight.
|
|
388
|
+
isUsageVerified(c, nowMs) ? getRoutingUsedPercent(c.usageSnapshot) : null, c.usageMinutesToLimit));
|
|
381
389
|
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
382
390
|
if (total <= 0)
|
|
383
391
|
return sorted[0];
|
|
@@ -485,7 +493,7 @@ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
|
|
|
485
493
|
const excluded = summaries.filter((s) => s.best === null);
|
|
486
494
|
if (healthy.length === 0)
|
|
487
495
|
return null;
|
|
488
|
-
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
|
|
496
|
+
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best), nowMs);
|
|
489
497
|
const picked = healthy.find((s) => s.best === pickedBest);
|
|
490
498
|
return { picked, healthy, excluded };
|
|
491
499
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { type DeviceProfile } from '../devices/registry.js';
|
|
2
2
|
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
3
|
-
import { type CachedUsageSnapshot
|
|
3
|
+
import { type CachedUsageSnapshot } from './usage.js';
|
|
4
4
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
5
5
|
export declare const USAGE_PUSH_DEADLINE_MS = 20000;
|
|
6
6
|
export declare const USAGE_PULL_DEADLINE_MS = 20000;
|
|
7
|
+
/**
|
|
8
|
+
* A worker's newest local usage row older than this triggers a pull from the
|
|
9
|
+
* primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
|
|
10
|
+
* RUSH-2392), so its only source of a fresh row is the daemon push every
|
|
11
|
+
* `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
|
|
12
|
+
* cycle is tolerated before the worker actively pulls. Bounds worker staleness
|
|
13
|
+
* to ~30m instead of the days-old caches this replaces.
|
|
14
|
+
*/
|
|
15
|
+
export declare const USAGE_SYNC_MAX_AGE_MS: number;
|
|
7
16
|
/** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
|
|
8
17
|
export interface UsageSyncPayload {
|
|
9
18
|
v: 1;
|
|
@@ -75,8 +84,9 @@ export interface UsagePullDeps {
|
|
|
75
84
|
listRoles?: () => Record<string, ConfiguredDeviceRole>;
|
|
76
85
|
isPinned?: (name: string) => boolean;
|
|
77
86
|
exportRows?: () => Record<string, CachedUsageSnapshot>;
|
|
78
|
-
readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
|
|
79
87
|
ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
|
|
88
|
+
/** Injectable clock for the staleness gate; defaults to `Date.now`. */
|
|
89
|
+
now?: () => number;
|
|
80
90
|
/** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
|
|
81
91
|
pull?: (device: DeviceProfile) => {
|
|
82
92
|
ok: boolean;
|