maxpool 1.5.26 → 1.5.28
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/package.json +1 -1
- package/src/event-log.js +15 -2
- package/src/index.js +54 -16
- package/src/server.js +70 -34
- package/src/tui.js +56 -13
package/package.json
CHANGED
package/src/event-log.js
CHANGED
|
@@ -99,6 +99,19 @@ export async function rotateIfNeeded() {
|
|
|
99
99
|
* output (stdout in plain mode; the TUI separately re-points console at its ring
|
|
100
100
|
* buffer, whose _addLog also calls appendEventLog, so TUI-mode lines persist too).
|
|
101
101
|
*/
|
|
102
|
+
let stdoutSuppressed = false;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Suppress the console mirror's STDOUT passthrough (the event-log append still
|
|
106
|
+
* runs). Used during a seamless TUI reload so a worker that doesn't currently own
|
|
107
|
+
* the terminal — the new worker before its takeover, or the old worker draining
|
|
108
|
+
* after it handed the TTY off — can't paint plain log lines over the live TUI of
|
|
109
|
+
* the worker that DOES own it. Per-process (each worker has its own module state),
|
|
110
|
+
* so it never affects the other worker. Read live inside the mirror, so lifting it
|
|
111
|
+
* takes effect immediately even for a console wrapper captured earlier.
|
|
112
|
+
*/
|
|
113
|
+
export function setConsoleStdoutSuppressed(v) { stdoutSuppressed = !!v; }
|
|
114
|
+
|
|
102
115
|
export function installConsoleMirror() {
|
|
103
116
|
if (origConsole) return; // idempotent
|
|
104
117
|
origConsole = { log: console.log.bind(console), error: console.error.bind(console) };
|
|
@@ -106,7 +119,7 @@ export function installConsoleMirror() {
|
|
|
106
119
|
const orig = origConsole[method];
|
|
107
120
|
console[method] = (...args) => {
|
|
108
121
|
try { appendEventLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')); } catch { /* never */ }
|
|
109
|
-
orig(...args);
|
|
122
|
+
if (!stdoutSuppressed) orig(...args);
|
|
110
123
|
};
|
|
111
124
|
}
|
|
112
125
|
}
|
|
@@ -118,5 +131,5 @@ export const flushEventLog = () => writeChain;
|
|
|
118
131
|
export function __resetEventLogForTest() {
|
|
119
132
|
if (origConsole) { console.log = origConsole.log; console.error = origConsole.error; origConsole = null; }
|
|
120
133
|
if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
|
|
121
|
-
logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0;
|
|
134
|
+
logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0; stdoutSuppressed = false;
|
|
122
135
|
}
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
|
|
6
|
-
import { setEventLogPath, installConsoleMirror } from './event-log.js';
|
|
6
|
+
import { setEventLogPath, installConsoleMirror, setConsoleStdoutSuppressed } from './event-log.js';
|
|
7
7
|
import { SleepGuard } from './sleep-guard.js';
|
|
8
8
|
import { AccountManager } from './account-manager.js';
|
|
9
9
|
import { createProxyServer } from './server.js';
|
|
@@ -48,12 +48,16 @@ function initEventLog(config, { manageRotation = false } = {}) {
|
|
|
48
48
|
} catch { /* logging must never block startup */ }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// Which reload path an in-process reload request takes.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// Which reload path an in-process reload request takes. ALL supervised reloads —
|
|
52
|
+
// TUI and headless alike — use the zero-downtime baton so the listening socket
|
|
53
|
+
// never closes (no ECONNREFUSED for the ~5 other sessions routing through us). A
|
|
54
|
+
// TUI reload additionally hands the terminal off worker→worker (the new worker
|
|
55
|
+
// re-renders the TUI at takeover; the old worker's terminalHandedOff guard stops
|
|
56
|
+
// it clobbering the shared TTY on exit). Only an UNsupervised process cold-restarts.
|
|
57
|
+
// The user escape hatch MAXPOOL_TUI_COLD_RESTART=1 forces the old cold path (see
|
|
58
|
+
// requestReload) if the TTY handoff ever misbehaves on their terminal.
|
|
59
|
+
function reloadStrategy({ supervised }) {
|
|
60
|
+
return supervised ? 'seamless' : 'cold-restart';
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
switch (command) {
|
|
@@ -657,20 +661,36 @@ async function serverWorkerCommand() {
|
|
|
657
661
|
|
|
658
662
|
const port = config.proxy.port;
|
|
659
663
|
const host = config.proxy.host || '127.0.0.1';
|
|
660
|
-
// A
|
|
661
|
-
//
|
|
662
|
-
|
|
664
|
+
// A reload worker constructs a TUI when on a TTY but only START()s it on baton
|
|
665
|
+
// takeover (becomePrimary) — single-owner terminal, enforced by the baton order
|
|
666
|
+
// (old worker's tui.stop() precedes MSG_RELEASED precedes the new worker's
|
|
667
|
+
// MSG_TAKEOVER→tui.start()). It boots headless-silent until then (stdout muzzled
|
|
668
|
+
// just below), so its boot logs can't paint over the old worker's live TUI.
|
|
669
|
+
const useTUI = process.stdout.isTTY && process.stdin.isTTY;
|
|
670
|
+
|
|
671
|
+
// A reload worker with a TUI must not write plain logs to the shared terminal
|
|
672
|
+
// (still owned by the old worker's TUI) while booting toward takeover; suppress
|
|
673
|
+
// stdout (event-log append still runs). Lifted in becomePrimary at takeover.
|
|
674
|
+
if (isReloadWorker && useTUI) setConsoleStdoutSuppressed(true);
|
|
663
675
|
|
|
664
676
|
let tui = null;
|
|
665
677
|
let server = null;
|
|
666
678
|
let syncTimer = null;
|
|
667
679
|
let draining = false;
|
|
668
680
|
let restartController = null;
|
|
681
|
+
// Set once this worker hands the terminal to a new worker during a seamless TUI
|
|
682
|
+
// reload; makes this (now non-owner) worker's exit-path restoreTerminal a NO-OP
|
|
683
|
+
// so it can't flip the shared TTY's raw-mode/alt-screen back and clobber the new
|
|
684
|
+
// worker's live TUI. Only the current terminal owner (or the supervisor) restores.
|
|
685
|
+
let terminalHandedOff = false;
|
|
669
686
|
|
|
670
687
|
// Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
|
|
671
688
|
// a bare process.exit, a crash) so the user's shell is never left in raw mode
|
|
672
689
|
// or the alt-screen. Idempotent and safe even when no TUI was running.
|
|
673
690
|
const restoreTerminal = () => {
|
|
691
|
+
// Handed the TTY to a new worker (seamless TUI reload) → the new worker owns
|
|
692
|
+
// it now; restoring here would clobber its live TUI. No-op.
|
|
693
|
+
if (terminalHandedOff) return;
|
|
674
694
|
try {
|
|
675
695
|
if (tui?.running) { tui.stop(); return; }
|
|
676
696
|
if (process.stdout.isTTY) process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
@@ -783,12 +803,14 @@ async function serverWorkerCommand() {
|
|
|
783
803
|
// (not supervised, no IPC), fall back to the abrupt restart.
|
|
784
804
|
const requestReload = () => {
|
|
785
805
|
if (draining) return;
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
806
|
+
// Supervised → zero-downtime seamless baton (socket never closes; TUI hands the
|
|
807
|
+
// terminal off worker→worker). Unsupervised → cold restart.
|
|
808
|
+
// Escape hatches force the cold path: MAXPOOL_TEST_FORCE_COLD_RESTART (in-suite,
|
|
809
|
+
// no pty) and the USER-facing MAXPOOL_TUI_COLD_RESTART=1 (revert to the old
|
|
810
|
+
// brief-ECONNREFUSED cold restart if the seamless TTY handoff ever misbehaves).
|
|
811
|
+
const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1'
|
|
812
|
+
|| process.env.MAXPOOL_TUI_COLD_RESTART === '1';
|
|
813
|
+
if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
|
|
792
814
|
try {
|
|
793
815
|
process.send({ type: MSG_RELOAD_REQUEST });
|
|
794
816
|
return;
|
|
@@ -954,6 +976,10 @@ async function serverWorkerCommand() {
|
|
|
954
976
|
// baton (a reload) — freeze the update check so a reload doesn't re-probe npm
|
|
955
977
|
// (only a cold supervisor start self-updates).
|
|
956
978
|
const becomePrimary = async ({ viaTakeover }) => {
|
|
979
|
+
// We're taking the terminal now (or are the cold-start primary). Lift the
|
|
980
|
+
// reload-worker boot muzzle so the TUI's first render + any plain-mode fallback
|
|
981
|
+
// logs reach stdout. (No-op for a worker that never suppressed.)
|
|
982
|
+
setConsoleStdoutSuppressed(false);
|
|
957
983
|
if (tui) {
|
|
958
984
|
if (tui.start()) {
|
|
959
985
|
console.log(`Listening on ${host}:${port} with ${accounts.length} account(s)`);
|
|
@@ -1041,6 +1067,13 @@ async function serverWorkerCommand() {
|
|
|
1041
1067
|
await flushStateWrites();
|
|
1042
1068
|
|
|
1043
1069
|
if (tui?.running) tui.stop(); // restore terminal before the new worker takes it
|
|
1070
|
+
// The terminal is now HANDED OFF: the incoming worker will take the TTY at
|
|
1071
|
+
// MSG_TAKEOVER. Arm the guard UNCONDITIONALLY (even if the TUI was already
|
|
1072
|
+
// stopped) so this worker's later exit(0) restoreTerminal can't clobber the
|
|
1073
|
+
// new worker's TUI. Also muzzle our own remaining drain-time logs (in-flight
|
|
1074
|
+
// requests completing) so they don't paint the new worker's alt-screen.
|
|
1075
|
+
terminalHandedOff = true;
|
|
1076
|
+
setConsoleStdoutSuppressed(true);
|
|
1044
1077
|
try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ }
|
|
1045
1078
|
|
|
1046
1079
|
// Drain bounded in-flight on EXISTING access tokens (no refresh needed),
|
|
@@ -1569,6 +1602,11 @@ Options:
|
|
|
1569
1602
|
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
1570
1603
|
--with-key Include proxy API key in maxpool env output
|
|
1571
1604
|
|
|
1605
|
+
Env:
|
|
1606
|
+
MAXPOOL_TUI_COLD_RESTART=1 Reload (r) via a full cold restart instead of the
|
|
1607
|
+
zero-downtime seamless handoff — a fallback if the
|
|
1608
|
+
terminal ever misbehaves after a reload.
|
|
1609
|
+
|
|
1572
1610
|
Config: ${getConfigPath()}
|
|
1573
1611
|
`);
|
|
1574
1612
|
}
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ const DEFAULT_RETRY = {
|
|
|
24
24
|
const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) || 120_000); // headers must arrive within this
|
|
25
25
|
const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 300_000); // max gap BETWEEN streamed chunks (reset per chunk)
|
|
26
26
|
const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
|
|
27
|
+
const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
|
|
27
28
|
|
|
28
29
|
const DEFAULT_QUEUE = {
|
|
29
30
|
enabled: true,
|
|
@@ -167,32 +168,40 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
167
168
|
return;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const queueConfig = { ...DEFAULT_QUEUE, ...(config.queue || {}) };
|
|
178
|
-
const canRetryBufferedBody = body.length <= retryConfig.maxRetryBufferBytes;
|
|
179
|
-
const requestInfo = describeRequest(req, body);
|
|
180
|
-
const maxQueuedBodyBytes = queueConfig.maxQueuedBodyBytes == null
|
|
181
|
-
? Infinity
|
|
182
|
-
: Math.max(0, Number(queueConfig.maxQueuedBodyBytes) || 0);
|
|
183
|
-
const canQueueBufferedBody = body.length <= maxQueuedBodyBytes;
|
|
184
|
-
if (!canQueueBufferedBody) {
|
|
185
|
-
requestInfo.queueBlockedReason = `request body ${body.length} bytes exceeds queue.maxQueuedBodyBytes ${maxQueuedBodyBytes}`;
|
|
186
|
-
}
|
|
187
|
-
requestInfo.profile = getMaxpoolProfile(req.headers);
|
|
188
|
-
requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
|
|
189
|
-
if (requestInfo.requiresAnthropicThinkingIntegrity && requestInfo.profile === 'all') {
|
|
190
|
-
console.log('[Maxpool] Anthropic thinking detected; provider fallback disabled for this session/request');
|
|
191
|
-
}
|
|
192
|
-
prepareRuntimeProviders(accountManager, req.headers);
|
|
193
|
-
|
|
171
|
+
// The request is now TRACKED (onRequestStart accepted → tui.active +
|
|
172
|
+
// restartController.activeRequests both hold it). EVERYTHING that can throw —
|
|
173
|
+
// including body buffering — must run inside the try whose finally fires
|
|
174
|
+
// onRequestEnd, or a client that dies mid-body-read throws to the OUTER catch
|
|
175
|
+
// and leaks the active entry AND the restart drain counter (hangs a reload's
|
|
176
|
+
// drain). requestInfo starts as {} so the error path never dereferences an
|
|
177
|
+
// undefined (a body-read throw lands before describeRequest runs).
|
|
194
178
|
const ctx = { account: null, status: null };
|
|
179
|
+
let requestInfo = {};
|
|
195
180
|
try {
|
|
181
|
+
// Buffer request body (needed for retry on 429)
|
|
182
|
+
const bodyChunks = [];
|
|
183
|
+
for await (const chunk of req) {
|
|
184
|
+
bodyChunks.push(chunk);
|
|
185
|
+
}
|
|
186
|
+
const body = Buffer.concat(bodyChunks);
|
|
187
|
+
const retryConfig = { ...DEFAULT_RETRY, ...(config.retry || {}) };
|
|
188
|
+
const queueConfig = { ...DEFAULT_QUEUE, ...(config.queue || {}) };
|
|
189
|
+
const canRetryBufferedBody = body.length <= retryConfig.maxRetryBufferBytes;
|
|
190
|
+
requestInfo = describeRequest(req, body);
|
|
191
|
+
const maxQueuedBodyBytes = queueConfig.maxQueuedBodyBytes == null
|
|
192
|
+
? Infinity
|
|
193
|
+
: Math.max(0, Number(queueConfig.maxQueuedBodyBytes) || 0);
|
|
194
|
+
const canQueueBufferedBody = body.length <= maxQueuedBodyBytes;
|
|
195
|
+
if (!canQueueBufferedBody) {
|
|
196
|
+
requestInfo.queueBlockedReason = `request body ${body.length} bytes exceeds queue.maxQueuedBodyBytes ${maxQueuedBodyBytes}`;
|
|
197
|
+
}
|
|
198
|
+
requestInfo.profile = getMaxpoolProfile(req.headers);
|
|
199
|
+
requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
|
|
200
|
+
if (requestInfo.requiresAnthropicThinkingIntegrity && requestInfo.profile === 'all') {
|
|
201
|
+
console.log('[Maxpool] Anthropic thinking detected; provider fallback disabled for this session/request');
|
|
202
|
+
}
|
|
203
|
+
prepareRuntimeProviders(accountManager, req.headers);
|
|
204
|
+
|
|
196
205
|
await forwardRequest(
|
|
197
206
|
req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir,
|
|
198
207
|
retryConfig, queueConfig, requestInfo, canRetryBufferedBody, canQueueBufferedBody, new Set(),
|
|
@@ -200,10 +209,14 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
200
209
|
} catch (err) {
|
|
201
210
|
ctx.status = ctx.status || 502;
|
|
202
211
|
console.error('[Maxpool] Unhandled error:', err);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
212
|
+
// Only attempt an error body if the socket is still writable — a client
|
|
213
|
+
// that aborted during body-read leaves res destroyed; writing then throws.
|
|
214
|
+
if (!res.destroyed && !res.writableEnded && !res.headersSent) {
|
|
215
|
+
sendErrorResponse(res, requestInfo, 502, {
|
|
216
|
+
type: 'error',
|
|
217
|
+
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
207
220
|
} finally {
|
|
208
221
|
hooks.onRequestEnd?.(reqId, {
|
|
209
222
|
method: req.method, path: req.url,
|
|
@@ -1822,14 +1835,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
1822
1835
|
parseSSEEvent(event, accountIndex, accountManager, requestInfo);
|
|
1823
1836
|
}
|
|
1824
1837
|
|
|
1825
|
-
// Handle backpressure —
|
|
1826
|
-
//
|
|
1838
|
+
// Handle backpressure — bail out if the client disconnects OR goes silently
|
|
1839
|
+
// half-open. A vanished peer (laptop sleep / Wi-Fi drop / lost TCP FIN) leaves
|
|
1840
|
+
// the send buffer full so this branch is entered, but sends no FIN ('close'
|
|
1841
|
+
// never fires) and never ACKs ('drain' never fires) — so a bare await here
|
|
1842
|
+
// hangs FOREVER, pinning the account lease and never firing onRequestEnd (the
|
|
1843
|
+
// phantom-"N active" leak). Bound it: if the client can't drain a small SSE
|
|
1844
|
+
// chunk within CLIENT_DRAIN_MS it's gone → break (same disposition as a
|
|
1845
|
+
// destroyed socket: the finally runs reader.cancel()+res.end(), the lease
|
|
1846
|
+
// frees with success:true, onRequestEnd fires). A legit slow client drains
|
|
1847
|
+
// each episode well within this window; the timer is per-episode, never summed.
|
|
1827
1848
|
if (!ok) {
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1849
|
+
let drainTimer, onDrain, onClose2;
|
|
1850
|
+
const settled = new Promise(resolve => {
|
|
1851
|
+
onDrain = () => resolve(true);
|
|
1852
|
+
onClose2 = () => resolve(true);
|
|
1853
|
+
res.once('drain', onDrain);
|
|
1854
|
+
res.once('close', onClose2);
|
|
1855
|
+
// Bound the wait by the drain cap, but never longer than this stream's
|
|
1856
|
+
// idle bound (keeps it injectable for tests; prod = min(300s, 60s) = 60s).
|
|
1857
|
+
drainTimer = setTimeout(() => resolve(false), Math.min(idleMs, CLIENT_DRAIN_MS));
|
|
1858
|
+
drainTimer.unref?.();
|
|
1831
1859
|
});
|
|
1832
|
-
|
|
1860
|
+
let drained;
|
|
1861
|
+
try {
|
|
1862
|
+
drained = await settled;
|
|
1863
|
+
} finally {
|
|
1864
|
+
clearTimeout(drainTimer);
|
|
1865
|
+
res.off('drain', onDrain);
|
|
1866
|
+
res.off('close', onClose2);
|
|
1867
|
+
}
|
|
1868
|
+
if (!drained || res.destroyed) break; // client gone/stalled → stop, clean up in finally
|
|
1833
1869
|
}
|
|
1834
1870
|
}
|
|
1835
1871
|
|
package/src/tui.js
CHANGED
|
@@ -18,11 +18,38 @@ const yellow = s => fg(33, s);
|
|
|
18
18
|
const red = s => fg(31, s);
|
|
19
19
|
const cyan = s => fg(36, s);
|
|
20
20
|
const gray = s => fg(90, s);
|
|
21
|
+
const dimUnderline = s => `${ESC}2;4m${s}${RESET}`;
|
|
21
22
|
|
|
22
23
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
23
24
|
const strip = s => s.replace(ANSI_RE, '');
|
|
24
25
|
const vw = s => strip(s).length;
|
|
25
26
|
|
|
27
|
+
// ── Accounts-table columns ───────────────────────────────────
|
|
28
|
+
// Fixed column widths shared by the header row (acctHeader) AND every data row, so
|
|
29
|
+
// the header labels stay aligned with the columns they name. The Account/Type/
|
|
30
|
+
// Status/Quota start offsets (4/17/26/40) are pure functions of these widths + the
|
|
31
|
+
// 4-col row prefix, independent of the quota-bar width.
|
|
32
|
+
const NAME_W = 12; // a.name.slice(0, NAME_W).padEnd(NAME_W)
|
|
33
|
+
const TYPE_W = 8; // a.type.padEnd(TYPE_W) — fits "provider"
|
|
34
|
+
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
|
+
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Aligned column header for the accounts table. Names the three columns that carry
|
|
39
|
+
* NO inline label (Account / Type / Status) plus a group label over the two quota
|
|
40
|
+
* bars (Quota). The per-bar naming (Ses/Wk for OAuth, Tok/Req for API keys) and the
|
|
41
|
+
* load fields (Now/15m/1h) stay inline per row — they're row-type-dependent and
|
|
42
|
+
* begin at a variable offset — so the header stops after the Quota group label.
|
|
43
|
+
*/
|
|
44
|
+
function acctHeader(W) {
|
|
45
|
+
const quota = W >= 88 ? 'Quota (used% · resets-in)' : 'Quota';
|
|
46
|
+
return ROW_PREFIX
|
|
47
|
+
+ 'Account'.padEnd(NAME_W) + ' '
|
|
48
|
+
+ 'Type'.padEnd(TYPE_W) + ' '
|
|
49
|
+
+ 'Status'.padEnd(STATUS_W) + ' '
|
|
50
|
+
+ quota;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
function rpad(s, w) {
|
|
27
54
|
const gap = w - vw(s);
|
|
28
55
|
return gap > 0 ? s + ' '.repeat(gap) : s;
|
|
@@ -194,7 +221,7 @@ function emptyBar(label, w = 10) {
|
|
|
194
221
|
return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
|
|
195
222
|
}
|
|
196
223
|
|
|
197
|
-
export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown };
|
|
224
|
+
export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine };
|
|
198
225
|
|
|
199
226
|
function timestamp() {
|
|
200
227
|
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
@@ -663,12 +690,14 @@ export class TUI {
|
|
|
663
690
|
const previous = this.config.accounts[idx];
|
|
664
691
|
entry.enabled = previous.enabled;
|
|
665
692
|
this.config.accounts[idx] = entry;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
693
|
+
// Update the AccountManager to the FRESH tokens BEFORE persisting. The
|
|
694
|
+
// saveConfig writer (index.js) derives the ON-DISK tokens from the
|
|
695
|
+
// AccountManager, not from config.accounts — so persisting first writes the
|
|
696
|
+
// STALE pre-re-auth token (typically the dead one we're re-authing away from)
|
|
697
|
+
// to disk. The account then works in memory but boots DEAD on the next restart
|
|
698
|
+
// (a just-re-authed token rejected as invalid_grant). A later refresh does NOT
|
|
699
|
+
// heal disk (persistTokenRefresh's generation guard skips a disk token that
|
|
700
|
+
// != _refreshedFrom), so disk must be made correct right here.
|
|
672
701
|
const amAcct = this.am.accounts.find(account =>
|
|
673
702
|
(entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
|
|
674
703
|
);
|
|
@@ -684,6 +713,17 @@ export class TUI {
|
|
|
684
713
|
amAcct.refreshDead = false;
|
|
685
714
|
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
686
715
|
}
|
|
716
|
+
try {
|
|
717
|
+
await this.saveConfig(this.config);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
// Persist failed (e.g. disk full). The browser re-auth genuinely SUCCEEDED,
|
|
720
|
+
// so KEEP the AccountManager on the fresh tokens — reverting it to the dead
|
|
721
|
+
// token would brick a working in-memory account. Roll back only the
|
|
722
|
+
// config.accounts mirror and surface the error; disk self-heals on the next
|
|
723
|
+
// saveConfig (the AM-derived writer re-writes the fresh token).
|
|
724
|
+
this.config.accounts[idx] = previous;
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
687
727
|
this._addLog(`Updated account "${name}"`);
|
|
688
728
|
return { updated: true, name };
|
|
689
729
|
} else {
|
|
@@ -906,10 +946,13 @@ export class TUI {
|
|
|
906
946
|
lines.push(yellow(' No accounts configured. Press [a] to add one.'));
|
|
907
947
|
} else {
|
|
908
948
|
lines.push('');
|
|
909
|
-
//
|
|
910
|
-
//
|
|
949
|
+
// Aligned column header (dim + underline so it reads as chrome, not data). It
|
|
950
|
+
// labels the fixed columns that have no inline label; the Ses/Wk/Tok/Req and
|
|
951
|
+
// Now/15m/1h labels stay inline per row. A short glossary below expands the
|
|
952
|
+
// abbreviations the header + inline labels can't spell out.
|
|
953
|
+
lines.push(dimUnderline(acctHeader(W)));
|
|
911
954
|
if (W >= 88) {
|
|
912
|
-
lines.push(' ' + dim('Ses
|
|
955
|
+
lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
|
|
913
956
|
}
|
|
914
957
|
const showBoth = W >= 70;
|
|
915
958
|
const bw = showBoth
|
|
@@ -979,13 +1022,13 @@ export class TUI {
|
|
|
979
1022
|
const cur = isCur ? green('►') : ' ';
|
|
980
1023
|
|
|
981
1024
|
// Name (bold if selected)
|
|
982
|
-
const rawName = a.name.slice(0,
|
|
1025
|
+
const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
|
|
983
1026
|
const name = isSel ? bold(rawName) : rawName;
|
|
984
1027
|
|
|
985
1028
|
// Type — pad to 8 so "provider" (8 chars) doesn't overflow a 7-wide column and
|
|
986
1029
|
// shift the whole provider row (incl. its quota bars) 1 char out of alignment
|
|
987
1030
|
// with the "oauth"/"apikey" rows.
|
|
988
|
-
const type = gray(a.type.padEnd(
|
|
1031
|
+
const type = gray(a.type.padEnd(TYPE_W));
|
|
989
1032
|
|
|
990
1033
|
// Status
|
|
991
1034
|
let status;
|
|
@@ -1030,7 +1073,7 @@ export class TUI {
|
|
|
1030
1073
|
default: status = a.status || 'ready';
|
|
1031
1074
|
}
|
|
1032
1075
|
// Widened from 10 to fit "throttled 59s" so the quota bars stay column-aligned.
|
|
1033
|
-
status = rpad(status,
|
|
1076
|
+
status = rpad(status, STATUS_W);
|
|
1034
1077
|
|
|
1035
1078
|
if (a.type === 'provider') {
|
|
1036
1079
|
return this._renderProviderAcct(sel, cur, name, type, status, a, bw, showBoth);
|