switchroom 0.10.0 → 0.11.0
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/README.md +5 -4
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +201 -24
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +4407 -2252
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-command.ts +121 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +876 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +127 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +16 -18
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format 2 — health-grouped /auth snapshot + causal auto-fallback
|
|
3
|
+
* announcement. Pure functions; the gateway handles the live-API probe
|
|
4
|
+
* (via `fetchAccountQuota({force: true})`) and the broker `listState`,
|
|
5
|
+
* then hands shaped data to these formatters.
|
|
6
|
+
*
|
|
7
|
+
* JTBD this module serves:
|
|
8
|
+
* "Which accounts are maxed, what % I've used of limits, and when
|
|
9
|
+
* does it come back?"
|
|
10
|
+
*
|
|
11
|
+
* The previous "quota exhausted" wording conflated the 5-hour and
|
|
12
|
+
* 7-day windows — but those have completely different recovery times
|
|
13
|
+
* (hours vs days), and that's the most-asked question after a switch.
|
|
14
|
+
* Every text surface here names the limit type explicitly.
|
|
15
|
+
*
|
|
16
|
+
* No HTML escaping at the boundary — callers pass already-trusted
|
|
17
|
+
* label strings (broker-vetted account labels). If that ever changes
|
|
18
|
+
* the per-line `escapeHtml` helper below is the place to gate.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { QuotaResult, QuotaUtilization } from './quota-check.js';
|
|
22
|
+
import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
|
|
23
|
+
|
|
24
|
+
// ── shared types ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Tri-state health verdict per account, derived from live quota. */
|
|
27
|
+
export type AccountHealth = 'healthy' | 'throttling' | 'blocked' | 'unknown';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Combined per-account view used by every formatter in this module.
|
|
31
|
+
* Bundles the broker's persisted state with the most recent live
|
|
32
|
+
* quota probe (or `null` on probe failure / no creds).
|
|
33
|
+
*/
|
|
34
|
+
export interface AccountSnapshot {
|
|
35
|
+
label: string;
|
|
36
|
+
/** True when this is the fleet's `auth.active`. */
|
|
37
|
+
isActive: boolean;
|
|
38
|
+
/** Live quota probe result; null when the probe failed (e.g. revoked
|
|
39
|
+
* creds, network error). Renderers degrade gracefully. */
|
|
40
|
+
quota: QuotaUtilization | null;
|
|
41
|
+
/** Reason the quota probe failed, when `quota` is null. */
|
|
42
|
+
quotaError?: string;
|
|
43
|
+
/** Mirrors the broker's `expiresAt` so the table can show token-life
|
|
44
|
+
* for accounts whose creds are about to expire. */
|
|
45
|
+
expiresAtMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── health classification ────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Threshold above which an account is "throttling" (close enough to a
|
|
52
|
+
* limit that we want the user to know). 80% on either window flips
|
|
53
|
+
* the badge — gives a 20%-buffer warning before the wall.
|
|
54
|
+
*/
|
|
55
|
+
export const THROTTLING_THRESHOLD_PCT = 80;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decide the health verdict for one account. The two "binding" facts:
|
|
59
|
+
* - 5h or 7d utilization >= 100% (or `representativeClaim` non-null
|
|
60
|
+
* plus utilization >= 99.5%) → blocked
|
|
61
|
+
* - either window above 80%, or representativeClaim set with > 50% →
|
|
62
|
+
* throttling
|
|
63
|
+
* - everything else → healthy
|
|
64
|
+
* - probe failure → unknown
|
|
65
|
+
*/
|
|
66
|
+
export function classifyHealth(snap: AccountSnapshot): AccountHealth {
|
|
67
|
+
if (!snap.quota) return 'unknown';
|
|
68
|
+
const q = snap.quota;
|
|
69
|
+
const max = Math.max(q.fiveHourUtilizationPct, q.sevenDayUtilizationPct);
|
|
70
|
+
if (max >= 99.5) return 'blocked';
|
|
71
|
+
if (max >= THROTTLING_THRESHOLD_PCT) return 'throttling';
|
|
72
|
+
return 'healthy';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Which window is the user-visible "binding" one — the one that ran
|
|
77
|
+
* out, or is closer to running out. Returned as a label for headers
|
|
78
|
+
* ("hit 5-hour limit", "hit 7-day limit"). Falls back to whichever
|
|
79
|
+
* window is currently higher.
|
|
80
|
+
*/
|
|
81
|
+
export type BindingWindow = '5h' | '7d';
|
|
82
|
+
|
|
83
|
+
export function bindingWindow(q: QuotaUtilization): BindingWindow {
|
|
84
|
+
if (q.representativeClaim === 'seven_day') return '7d';
|
|
85
|
+
if (q.representativeClaim === 'five_hour') return '5h';
|
|
86
|
+
return q.sevenDayUtilizationPct >= q.fiveHourUtilizationPct ? '7d' : '5h';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── time/format helpers ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render a future Date as a friendly relative countdown ("4h 56m",
|
|
93
|
+
* "in 2d 9h", "in 6m"). Returns "—" for null/past targets so callers
|
|
94
|
+
* can use it inline without null guards.
|
|
95
|
+
*/
|
|
96
|
+
export function formatRelative(target: Date | null, now: Date = new Date()): string {
|
|
97
|
+
if (!target) return '—';
|
|
98
|
+
const deltaMs = target.getTime() - now.getTime();
|
|
99
|
+
if (deltaMs <= 0) return 'now';
|
|
100
|
+
const totalMin = Math.round(deltaMs / 60_000);
|
|
101
|
+
if (totalMin < 60) return `${totalMin}m`;
|
|
102
|
+
const h = Math.floor(totalMin / 60);
|
|
103
|
+
const m = totalMin % 60;
|
|
104
|
+
if (h < 24) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
105
|
+
const d = Math.floor(h / 24);
|
|
106
|
+
const rh = h % 24;
|
|
107
|
+
return rh > 0 ? `${d}d ${rh}h` : `${d}d`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render a Date as a friendly absolute time in the operator's
|
|
112
|
+
* timezone ("Fri 3:50 PM", "Sun 8:00 PM", "Tue 5:00 AM"). The
|
|
113
|
+
* weekday is included because resets often span a day boundary and
|
|
114
|
+
* "5:00 AM" alone is ambiguous.
|
|
115
|
+
*
|
|
116
|
+
* `tz` is forwarded to `toLocaleString`. Defaults to UTC; callers
|
|
117
|
+
* should pass `process.env.TZ` or the agent's configured timezone.
|
|
118
|
+
*/
|
|
119
|
+
export function formatAbsolute(
|
|
120
|
+
target: Date | null,
|
|
121
|
+
tz: string = 'UTC',
|
|
122
|
+
): string {
|
|
123
|
+
if (!target) return '—';
|
|
124
|
+
return target.toLocaleString('en-US', {
|
|
125
|
+
timeZone: tz,
|
|
126
|
+
weekday: 'short',
|
|
127
|
+
hour: 'numeric',
|
|
128
|
+
minute: '2-digit',
|
|
129
|
+
hour12: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Round-trim percentage to 1 dp (more precision is noise on a UX). */
|
|
134
|
+
export function fmtPct(pct: number): string {
|
|
135
|
+
return `${Math.round(pct)}%`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── /auth snapshot — Format 2 ────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export interface SnapshotRenderOpts {
|
|
141
|
+
/** Operator-local timezone for absolute reset times. Forwarded to
|
|
142
|
+
* formatAbsolute. */
|
|
143
|
+
tz?: string;
|
|
144
|
+
now?: Date;
|
|
145
|
+
/** Refresh stamp shown in the footer; usually `Date.now()` of the
|
|
146
|
+
* most recent live probe. Omit to suppress. */
|
|
147
|
+
liveProbedAtMs?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Header line shape: emoji + group title + count.
|
|
152
|
+
*
|
|
153
|
+
* 🟢 HEALTHY (4)
|
|
154
|
+
* 🟡 ACTIVE — REFRESHING SOON (1)
|
|
155
|
+
* 🔴 BLOCKED (1)
|
|
156
|
+
* ⚪ UNKNOWN (1)
|
|
157
|
+
*/
|
|
158
|
+
function groupHeader(health: AccountHealth, count: number): string {
|
|
159
|
+
const emoji = HEALTH_EMOJI[health];
|
|
160
|
+
const title = HEALTH_TITLE[health];
|
|
161
|
+
return `${emoji} <b>${title}</b> (${count})`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const HEALTH_EMOJI: Record<AccountHealth, string> = {
|
|
165
|
+
healthy: '🟢',
|
|
166
|
+
throttling: '🟡',
|
|
167
|
+
blocked: '🔴',
|
|
168
|
+
unknown: '⚪',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const HEALTH_TITLE: Record<AccountHealth, string> = {
|
|
172
|
+
healthy: 'HEALTHY',
|
|
173
|
+
throttling: 'THROTTLING',
|
|
174
|
+
blocked: 'BLOCKED',
|
|
175
|
+
unknown: 'UNKNOWN',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One-line per-account summary inside its health group.
|
|
180
|
+
*
|
|
181
|
+
* pixsoul@gmail.com ● 8% / 20%
|
|
182
|
+
* 5h refills 11:00 AM (in 6m) · 7d resets Sun 11:00 AM
|
|
183
|
+
*
|
|
184
|
+
* Two lines actually: the label/percent line and a sub-line with the
|
|
185
|
+
* reset details. The blocked variant replaces the sub-line with the
|
|
186
|
+
* recovery countdown.
|
|
187
|
+
*/
|
|
188
|
+
function renderAccountRow(
|
|
189
|
+
snap: AccountSnapshot,
|
|
190
|
+
opts: SnapshotRenderOpts,
|
|
191
|
+
): string[] {
|
|
192
|
+
const now = opts.now ?? new Date();
|
|
193
|
+
const tz = opts.tz ?? 'UTC';
|
|
194
|
+
const lines: string[] = [];
|
|
195
|
+
const marker = snap.isActive ? '● ' : '';
|
|
196
|
+
|
|
197
|
+
if (!snap.quota) {
|
|
198
|
+
lines.push(
|
|
199
|
+
`${marker}<code>${escapeHtml(snap.label)}</code> <i>quota probe failed</i>`,
|
|
200
|
+
);
|
|
201
|
+
if (snap.quotaError) {
|
|
202
|
+
lines.push(` <i>${escapeHtml(snap.quotaError)}</i>`);
|
|
203
|
+
}
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const q = snap.quota;
|
|
208
|
+
const fiveStr = fmtPct(q.fiveHourUtilizationPct);
|
|
209
|
+
const sevenStr = fmtPct(q.sevenDayUtilizationPct);
|
|
210
|
+
lines.push(
|
|
211
|
+
`${marker}<code>${escapeHtml(snap.label)}</code> ${fiveStr} / ${sevenStr}`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const health = classifyHealth(snap);
|
|
215
|
+
if (health === 'blocked') {
|
|
216
|
+
// Surface only the recovery countdown — the binding window's reset
|
|
217
|
+
// is the only thing that matters until then.
|
|
218
|
+
const win = bindingWindow(q);
|
|
219
|
+
const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
|
|
220
|
+
const winLabel = win === '5h' ? '5-hour' : '7-day';
|
|
221
|
+
lines.push(
|
|
222
|
+
` <i>back ${formatAbsolute(reset, tz)} (in ${formatRelative(reset, now)}, ${winLabel} cap)</i>`,
|
|
223
|
+
);
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Healthy / throttling: show whichever window is closer to refresh
|
|
228
|
+
// first, then the other on the same line. Reverses the screenshot's
|
|
229
|
+
// "5h then 7d" ordering when 7d is the more pressing one — the user
|
|
230
|
+
// wants the imminent number first.
|
|
231
|
+
const fiveResetIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
|
|
232
|
+
const sevenResetIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
|
|
233
|
+
const fiveFirst = fiveResetIn <= sevenResetIn;
|
|
234
|
+
const fiveSeg = q.fiveHourResetAt
|
|
235
|
+
? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (in ${formatRelative(q.fiveHourResetAt, now)})`
|
|
236
|
+
: '5h refills —';
|
|
237
|
+
const sevenSeg = q.sevenDayResetAt
|
|
238
|
+
? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (in ${formatRelative(q.sevenDayResetAt, now)})`
|
|
239
|
+
: '7d resets —';
|
|
240
|
+
lines.push(` <i>${fiveFirst ? fiveSeg : sevenSeg} · ${fiveFirst ? sevenSeg : fiveSeg}</i>`);
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build the full Format 2 snapshot. Returns ready-to-send Telegram
|
|
246
|
+
* HTML.
|
|
247
|
+
*
|
|
248
|
+
* Structure:
|
|
249
|
+
* 🔋 Auth — fleet status
|
|
250
|
+
* <empty>
|
|
251
|
+
* <group> ...accounts grouped by health, blocked-first order...
|
|
252
|
+
* <empty>
|
|
253
|
+
* ───────────────────────────
|
|
254
|
+
* Recommendation: <one-line verdict>
|
|
255
|
+
* <i>Live · refreshed Ns ago</i>
|
|
256
|
+
*
|
|
257
|
+
* Caller appends an inline keyboard via the returned hint shape (see
|
|
258
|
+
* `buildSnapshotKeyboard` below) — keep the formatting and the
|
|
259
|
+
* keyboard in lockstep so the buttons always reflect current state.
|
|
260
|
+
*/
|
|
261
|
+
export function renderAuthSnapshotFormat2(
|
|
262
|
+
snapshots: AccountSnapshot[],
|
|
263
|
+
opts: SnapshotRenderOpts = {},
|
|
264
|
+
): string {
|
|
265
|
+
const now = opts.now ?? new Date();
|
|
266
|
+
const lines: string[] = [];
|
|
267
|
+
lines.push('🔋 <b>Auth — fleet status</b>');
|
|
268
|
+
|
|
269
|
+
// Group by health. Render BLOCKED first (it's the urgent action),
|
|
270
|
+
// then THROTTLING (potential next problem), then HEALTHY (good
|
|
271
|
+
// news), then UNKNOWN (data quality issue). The active account
|
|
272
|
+
// floats to the top of its group regardless.
|
|
273
|
+
const order: AccountHealth[] = ['blocked', 'throttling', 'healthy', 'unknown'];
|
|
274
|
+
const grouped = new Map<AccountHealth, AccountSnapshot[]>();
|
|
275
|
+
for (const s of snapshots) {
|
|
276
|
+
const h = classifyHealth(s);
|
|
277
|
+
if (!grouped.has(h)) grouped.set(h, []);
|
|
278
|
+
grouped.get(h)!.push(s);
|
|
279
|
+
}
|
|
280
|
+
// Within each group, active first.
|
|
281
|
+
for (const arr of grouped.values()) {
|
|
282
|
+
arr.sort((a, b) => Number(b.isActive) - Number(a.isActive));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const h of order) {
|
|
286
|
+
const arr = grouped.get(h);
|
|
287
|
+
if (!arr || arr.length === 0) continue;
|
|
288
|
+
lines.push('');
|
|
289
|
+
lines.push(groupHeader(h, arr.length));
|
|
290
|
+
for (const s of arr) {
|
|
291
|
+
for (const ln of renderAccountRow(s, opts)) lines.push(ln);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
lines.push('');
|
|
296
|
+
lines.push('────────────────────────────');
|
|
297
|
+
lines.push(`<i>${recommendation(snapshots, now)}</i>`);
|
|
298
|
+
if (opts.liveProbedAtMs != null) {
|
|
299
|
+
const ageSec = Math.max(0, Math.round((Date.now() - opts.liveProbedAtMs) / 1000));
|
|
300
|
+
const ageStr = ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
|
|
301
|
+
lines.push(`<i>Live · refreshed ${ageStr}</i>`);
|
|
302
|
+
} else {
|
|
303
|
+
lines.push('<i>Live</i>');
|
|
304
|
+
}
|
|
305
|
+
return lines.join('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* One-sentence verdict for the snapshot footer. Format C's
|
|
310
|
+
* "recommendation engine" in a minimal form — answers "what should I
|
|
311
|
+
* do?" without hiding the table above.
|
|
312
|
+
*
|
|
313
|
+
* Shapes:
|
|
314
|
+
* "Stay on <active> — healthy."
|
|
315
|
+
* "Active <active> is throttling. Best alternative: <healthy>."
|
|
316
|
+
* "Active <active> is BLOCKED. Switch to <healthy> now."
|
|
317
|
+
* "All accounts blocked. Earliest recovery: <label> in <eta>."
|
|
318
|
+
*/
|
|
319
|
+
export function recommendation(snapshots: AccountSnapshot[], now: Date = new Date()): string {
|
|
320
|
+
const active = snapshots.find((s) => s.isActive);
|
|
321
|
+
if (!active) return 'No active account set.';
|
|
322
|
+
const activeHealth = classifyHealth(active);
|
|
323
|
+
const others = snapshots.filter((s) => !s.isActive);
|
|
324
|
+
const healthyAlt = others.find((s) => classifyHealth(s) === 'healthy');
|
|
325
|
+
|
|
326
|
+
if (activeHealth === 'healthy') {
|
|
327
|
+
return `Recommendation: stay on ${active.label}.`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (activeHealth === 'throttling') {
|
|
331
|
+
if (healthyAlt) {
|
|
332
|
+
return `Recommendation: active ${active.label} is throttling. Switch to ${healthyAlt.label} for headroom.`;
|
|
333
|
+
}
|
|
334
|
+
return `Recommendation: active ${active.label} is throttling; no healthy alternative — wait for refill.`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (activeHealth === 'blocked') {
|
|
338
|
+
if (healthyAlt) {
|
|
339
|
+
return `Recommendation: active ${active.label} is BLOCKED — switch to ${healthyAlt.label} now.`;
|
|
340
|
+
}
|
|
341
|
+
// No healthy alternative; surface the earliest recovery time.
|
|
342
|
+
const earliestRecovery = pickEarliestRecovery(snapshots, now);
|
|
343
|
+
if (earliestRecovery) {
|
|
344
|
+
return `All accounts blocked. Earliest recovery: ${earliestRecovery.label} in ${formatRelative(earliestRecovery.at, now)}.`;
|
|
345
|
+
}
|
|
346
|
+
return `All accounts blocked. Run /auth add to attach another subscription.`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// unknown
|
|
350
|
+
return `Active ${active.label}: quota probe failed; broker last_seen unknown.`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pickEarliestRecovery(
|
|
354
|
+
snapshots: AccountSnapshot[],
|
|
355
|
+
now: Date,
|
|
356
|
+
): { label: string; at: Date } | null {
|
|
357
|
+
let best: { label: string; at: Date } | null = null;
|
|
358
|
+
for (const s of snapshots) {
|
|
359
|
+
if (!s.quota) continue;
|
|
360
|
+
const win = bindingWindow(s.quota);
|
|
361
|
+
const at = win === '5h' ? s.quota.fiveHourResetAt : s.quota.sevenDayResetAt;
|
|
362
|
+
if (!at || at.getTime() <= now.getTime()) continue;
|
|
363
|
+
if (!best || at.getTime() < best.at.getTime()) {
|
|
364
|
+
best = { label: s.label, at };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return best;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── auto-fallback announcement (causal) ──────────────────────────────
|
|
371
|
+
|
|
372
|
+
export interface FallbackAnnouncementInput {
|
|
373
|
+
/** Account that just hit a limit. */
|
|
374
|
+
oldLabel: string;
|
|
375
|
+
/** Quota snapshot for the old account *at the moment of failure*.
|
|
376
|
+
* Used to name the limit type and recovery time. */
|
|
377
|
+
oldQuota: QuotaUtilization | null;
|
|
378
|
+
/** Account we just switched to. Null when no fallback was possible. */
|
|
379
|
+
newLabel: string | null;
|
|
380
|
+
/** Quota snapshot for the new account, for headroom messaging. */
|
|
381
|
+
newQuota: QuotaUtilization | null;
|
|
382
|
+
/** Agent that triggered the fallback (for context — fleet swap
|
|
383
|
+
* affects all agents but the user wants to know which one tripped). */
|
|
384
|
+
triggerAgent: string;
|
|
385
|
+
tz?: string;
|
|
386
|
+
now?: Date;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Render the causal-shape fallback announcement.
|
|
391
|
+
*
|
|
392
|
+
* ✓ Switched fleet · 5-hour limit on ken
|
|
393
|
+
*
|
|
394
|
+
* ken.thompson@outlook → pixsoul@gmail.com
|
|
395
|
+
* Triggered by: agent carrie
|
|
396
|
+
*
|
|
397
|
+
* ken recovers Fri 3:50 PM (in 4h 56m)
|
|
398
|
+
* pixsoul now: 8% of 5h · 20% of 7d (plenty of headroom)
|
|
399
|
+
*
|
|
400
|
+
* Falls back to a different shape when no eligible target was found
|
|
401
|
+
* (`newLabel === null`) — see "all-blocked" branch.
|
|
402
|
+
*/
|
|
403
|
+
export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): string {
|
|
404
|
+
const now = input.now ?? new Date();
|
|
405
|
+
const tz = input.tz ?? 'UTC';
|
|
406
|
+
const lines: string[] = [];
|
|
407
|
+
|
|
408
|
+
const limitWord = input.oldQuota ? limitWordFor(input.oldQuota) : 'quota';
|
|
409
|
+
const headerLimit = limitWord === 'quota' ? 'quota cap' : `${limitWord} limit`;
|
|
410
|
+
|
|
411
|
+
if (!input.newLabel) {
|
|
412
|
+
// All-blocked path — no swap occurred. Tell user what's broken
|
|
413
|
+
// and when the earliest reset is.
|
|
414
|
+
lines.push(
|
|
415
|
+
`🔴 <b>All accounts blocked · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
|
|
416
|
+
);
|
|
417
|
+
lines.push('');
|
|
418
|
+
lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
|
|
419
|
+
if (input.oldQuota) {
|
|
420
|
+
const recovery = recoveryAtFor(input.oldQuota);
|
|
421
|
+
if (recovery) {
|
|
422
|
+
lines.push(
|
|
423
|
+
`${escapeHtml(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
|
|
424
|
+
`(in ${formatRelative(recovery, now)})`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
lines.push('');
|
|
429
|
+
lines.push(
|
|
430
|
+
`Run <code>/auth add <label></code> to attach another subscription, ` +
|
|
431
|
+
`or <code>/auth refresh</code> to re-probe.`,
|
|
432
|
+
);
|
|
433
|
+
return lines.join('\n');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Successful swap.
|
|
437
|
+
lines.push(
|
|
438
|
+
`✓ <b>Switched fleet · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
|
|
439
|
+
);
|
|
440
|
+
lines.push('');
|
|
441
|
+
lines.push(
|
|
442
|
+
`<code>${escapeHtml(input.oldLabel)}</code> → <code>${escapeHtml(input.newLabel)}</code>`,
|
|
443
|
+
);
|
|
444
|
+
lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
|
|
445
|
+
lines.push('');
|
|
446
|
+
|
|
447
|
+
if (input.oldQuota) {
|
|
448
|
+
const recovery = recoveryAtFor(input.oldQuota);
|
|
449
|
+
if (recovery) {
|
|
450
|
+
lines.push(
|
|
451
|
+
`<code>${escapeHtml(input.oldLabel)}</code> recovers ` +
|
|
452
|
+
`${formatAbsolute(recovery, tz)} (in ${formatRelative(recovery, now)})`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (input.newQuota) {
|
|
458
|
+
const fiveStr = fmtPct(input.newQuota.fiveHourUtilizationPct);
|
|
459
|
+
const sevenStr = fmtPct(input.newQuota.sevenDayUtilizationPct);
|
|
460
|
+
const hasHeadroom =
|
|
461
|
+
input.newQuota.fiveHourUtilizationPct < THROTTLING_THRESHOLD_PCT &&
|
|
462
|
+
input.newQuota.sevenDayUtilizationPct < THROTTLING_THRESHOLD_PCT;
|
|
463
|
+
const headroomStr = hasHeadroom ? '<i>(plenty of headroom)</i>' : '<i>(near limit — watch this)</i>';
|
|
464
|
+
lines.push(
|
|
465
|
+
`<code>${escapeHtml(input.newLabel)}</code> now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
|
|
466
|
+
);
|
|
467
|
+
} else {
|
|
468
|
+
lines.push(
|
|
469
|
+
`<i>(quota probe for new account is pending — will reflect on next /auth)</i>`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return lines.join('\n');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Pick which window to name in the headline. */
|
|
477
|
+
function limitWordFor(q: QuotaUtilization): '5-hour' | '7-day' | 'quota' {
|
|
478
|
+
// If a representative-claim is present and the named window is
|
|
479
|
+
// actually maxed, name it. Otherwise pick by which window is
|
|
480
|
+
// higher.
|
|
481
|
+
if (q.representativeClaim === 'seven_day' && q.sevenDayUtilizationPct >= 99) return '7-day';
|
|
482
|
+
if (q.representativeClaim === 'five_hour' && q.fiveHourUtilizationPct >= 99) return '5-hour';
|
|
483
|
+
if (q.sevenDayUtilizationPct >= 99) return '7-day';
|
|
484
|
+
if (q.fiveHourUtilizationPct >= 99) return '5-hour';
|
|
485
|
+
// Throttling case (called pre-emptively): prefer the higher one.
|
|
486
|
+
return q.sevenDayUtilizationPct >= q.fiveHourUtilizationPct ? '7-day' : '5-hour';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function recoveryAtFor(q: QuotaUtilization): Date | null {
|
|
490
|
+
const word = limitWordFor(q);
|
|
491
|
+
if (word === '7-day') return q.sevenDayResetAt;
|
|
492
|
+
if (word === '5-hour') return q.fiveHourResetAt;
|
|
493
|
+
// Both windows healthy (called pre-emptively under explicit trigger):
|
|
494
|
+
// earliest reset wins.
|
|
495
|
+
if (!q.fiveHourResetAt) return q.sevenDayResetAt;
|
|
496
|
+
if (!q.sevenDayResetAt) return q.fiveHourResetAt;
|
|
497
|
+
return q.fiveHourResetAt.getTime() < q.sevenDayResetAt.getTime()
|
|
498
|
+
? q.fiveHourResetAt
|
|
499
|
+
: q.sevenDayResetAt;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── inline keyboard hints ────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
export interface KeyboardButton {
|
|
505
|
+
text: string;
|
|
506
|
+
/** Either a callback_data string (tap-to-action) or a switch_inline
|
|
507
|
+
* hint. We model both as a discriminated union so the gateway can
|
|
508
|
+
* trivially translate to grammy's keyboard builder. */
|
|
509
|
+
callbackData?: string;
|
|
510
|
+
/** Convenience for buttons that paste a slash-command into the input. */
|
|
511
|
+
insertText?: string;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export type KeyboardRow = KeyboardButton[];
|
|
515
|
+
|
|
516
|
+
export interface SnapshotKeyboardOpts {
|
|
517
|
+
/** Limit how many "Switch → X" buttons we render. Beyond this, the
|
|
518
|
+
* user can drill in via /usage. Default 3. */
|
|
519
|
+
maxSwitchButtons?: number;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Build the inline keyboard for the /auth snapshot.
|
|
524
|
+
*
|
|
525
|
+
* Smart-hide rules (per JTBD — never tempt the user to switch into a
|
|
526
|
+
* blocked account):
|
|
527
|
+
* - Switch buttons render only for HEALTHY non-active accounts.
|
|
528
|
+
* - If active is healthy, switch buttons are still shown but
|
|
529
|
+
* deprioritized (the recommendation footer says "stay").
|
|
530
|
+
* - "Refresh" always present (forces fresh quota probes).
|
|
531
|
+
* - Bottom row: /usage, + Add (admin shows full menu).
|
|
532
|
+
*/
|
|
533
|
+
export function buildSnapshotKeyboard(
|
|
534
|
+
snapshots: AccountSnapshot[],
|
|
535
|
+
opts: SnapshotKeyboardOpts = {},
|
|
536
|
+
): KeyboardRow[] {
|
|
537
|
+
const max = opts.maxSwitchButtons ?? 3;
|
|
538
|
+
const rows: KeyboardRow[] = [];
|
|
539
|
+
|
|
540
|
+
// Switch buttons — healthy non-active first, then throttling
|
|
541
|
+
// non-active. Skip blocked entirely.
|
|
542
|
+
const switchTargets = snapshots
|
|
543
|
+
.filter((s) => !s.isActive)
|
|
544
|
+
.sort((a, b) => switchPriority(a) - switchPriority(b))
|
|
545
|
+
.filter((s) => classifyHealth(s) !== 'blocked' && classifyHealth(s) !== 'unknown')
|
|
546
|
+
.slice(0, max);
|
|
547
|
+
|
|
548
|
+
for (const t of switchTargets) {
|
|
549
|
+
rows.push([
|
|
550
|
+
{
|
|
551
|
+
text: `Switch fleet → ${t.label}`,
|
|
552
|
+
callbackData: `auth:use:${t.label}`,
|
|
553
|
+
},
|
|
554
|
+
]);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
rows.push([
|
|
558
|
+
{ text: '↻ Refresh', callbackData: 'auth:refresh' },
|
|
559
|
+
{ text: '/usage', insertText: '/usage' },
|
|
560
|
+
{ text: '+ Add', insertText: '/auth add ' },
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
return rows;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Lower number = higher priority for "switch to me" button. */
|
|
567
|
+
function switchPriority(s: AccountSnapshot): number {
|
|
568
|
+
const h = classifyHealth(s);
|
|
569
|
+
if (h === 'healthy') return 0;
|
|
570
|
+
if (h === 'throttling') return 1;
|
|
571
|
+
if (h === 'unknown') return 2;
|
|
572
|
+
return 3; // blocked
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── shared HTML escape ───────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
function escapeHtml(s: string): string {
|
|
578
|
+
return s
|
|
579
|
+
.replace(/&/g, '&')
|
|
580
|
+
.replace(/</g, '<')
|
|
581
|
+
.replace(/>/g, '>');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── snapshot assembly helper ─────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Given the broker's `listState` data + a parallel array of live quota
|
|
588
|
+
* results (same length, same order), return the AccountSnapshot[] the
|
|
589
|
+
* formatters need.
|
|
590
|
+
*
|
|
591
|
+
* The gateway calls this after running `Promise.all(accounts.map(a =>
|
|
592
|
+
* fetchAccountQuota(a.label, {force: true})))` — both arrays are
|
|
593
|
+
* caller-provided, this is just a zip + classify.
|
|
594
|
+
*/
|
|
595
|
+
export function buildSnapshotsFromState(
|
|
596
|
+
state: ListStateData,
|
|
597
|
+
quotas: QuotaResult[],
|
|
598
|
+
): AccountSnapshot[] {
|
|
599
|
+
const out: AccountSnapshot[] = [];
|
|
600
|
+
for (let i = 0; i < state.accounts.length; i++) {
|
|
601
|
+
const acc: AccountState = state.accounts[i]!;
|
|
602
|
+
const q = quotas[i];
|
|
603
|
+
out.push({
|
|
604
|
+
label: acc.label,
|
|
605
|
+
isActive: acc.label === state.active,
|
|
606
|
+
quota: q && q.ok ? q.data : null,
|
|
607
|
+
quotaError: q && !q.ok ? q.reason : undefined,
|
|
608
|
+
expiresAtMs: acc.expiresAt,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return out;
|
|
612
|
+
}
|