maxpool 1.5.4 → 1.5.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -28,6 +28,16 @@ export function getStatePath() {
28
28
  return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
29
29
  }
30
30
 
31
+ /**
32
+ * Path to the persistent event log (a sibling of the config). Default-on; holds a
33
+ * rotating record of routing / network errors / cooldowns / reloads so incidents
34
+ * are investigable after the in-memory TUI feed has scrolled away.
35
+ */
36
+ export function getLogPath() {
37
+ const cfg = getConfigPath();
38
+ return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.log') : cfg + '.log';
39
+ }
40
+
31
41
  export function createDefaultConfig() {
32
42
  return {
33
43
  proxy: {
@@ -0,0 +1,122 @@
1
+ // Persistent, rotating, low-overhead event log so incidents are investigable
2
+ // after the fact (the TUI activity feed is in-memory only and scrolls away). It
3
+ // mirrors the existing `[Maxpool] …` console stream — routing, network errors,
4
+ // cooldowns, token refreshes, reloads, upstream-throttle — to a file that
5
+ // survives the terminal. Logging must NEVER throw into the proxy path, so every
6
+ // write is fire-and-forget and all errors are swallowed.
7
+
8
+ import { appendFile, stat, rename, chmod } from 'node:fs/promises';
9
+
10
+ const MAX_BYTES = 5 * 1024 * 1024; // rotate at ~5 MB (one .1 backup kept)
11
+ const ROTATE_CHECK_MS = 2000; // rotation-owner size-check cadence
12
+ // Cap each line below the platform PIPE_BUF (512 B on macOS) so concurrent
13
+ // O_APPEND writes from coexisting processes during a reload stay atomic (never
14
+ // interleave). 24-char ISO timestamp + space + body + newline must fit.
15
+ const MAX_BODY = 480;
16
+ const MAX_QUEUED = 10_000; // bound the in-flight queue if the disk stalls
17
+
18
+ let logPath = null;
19
+ let manageRotation = false; // SINGLE owner rotates (supervisor, or the lone unsupervised worker)
20
+ let rotationTimer = null;
21
+ let writeChain = Promise.resolve();
22
+ let queued = 0;
23
+ let dropped = 0;
24
+ let origConsole = null;
25
+
26
+ export function setEventLogPath(path, { manageRotation: owns = false } = {}) {
27
+ logPath = path || null;
28
+ manageRotation = Boolean(owns);
29
+ if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
30
+ if (logPath) {
31
+ // 0600 even on a pre-existing file (appendFile's mode only applies on create).
32
+ chmod(logPath, 0o600).catch(() => {});
33
+ if (manageRotation) {
34
+ rotationTimer = setInterval(() => { rotateIfNeeded().catch(() => {}); }, ROTATE_CHECK_MS);
35
+ rotationTimer.unref?.();
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Redact obvious secrets BEFORE anything reaches disk. We only ever log summaries
42
+ * (account names, statuses, error classes) — never raw tokens/headers/bodies — but
43
+ * this is defense-in-depth so a future log line that happens to include a token
44
+ * can't leak through the persisted file.
45
+ */
46
+ export function redactSecrets(s) {
47
+ return String(s)
48
+ .replace(/sk-ant-[A-Za-z0-9._-]+/g, 'sk-ant-[redacted]')
49
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]')
50
+ .replace(/(["']?(?:access|refresh)_?token["']?\s*[:=]\s*["']?)[A-Za-z0-9._-]{8,}/gi, '$1[redacted]')
51
+ .replace(/(["']?api[_-]?key["']?\s*[:=]\s*["']?)[A-Za-z0-9._-]{8,}/gi, '$1[redacted]');
52
+ }
53
+
54
+ export function appendEventLog(msg) {
55
+ if (!logPath || msg == null) return;
56
+ if (queued >= MAX_QUEUED) { dropped++; return; } // disk stalled — drop, don't grow memory unbounded
57
+ queued++;
58
+ writeChain = writeChain.then(() => writeLine(msg)).catch(() => {}).finally(() => { queued--; });
59
+ }
60
+
61
+ // Truncate to at most maxBytes UTF-8 bytes (not chars) without splitting a
62
+ // multibyte codepoint, so the final line stays under PIPE_BUF for atomic O_APPEND.
63
+ function capBytes(s, maxBytes) {
64
+ if (Buffer.byteLength(s) <= maxBytes) return s;
65
+ let str = Buffer.from(s).subarray(0, maxBytes - 3).toString('utf8'); // room for '…' (3 B)
66
+ if (str.endsWith('�')) str = str.slice(0, -1); // drop a partial trailing codepoint
67
+ return str + '…';
68
+ }
69
+
70
+ async function writeLine(rawMsg) {
71
+ const path = logPath;
72
+ if (!path) return;
73
+ try {
74
+ // The drop sentinel is its OWN append so neither write exceeds PIPE_BUF.
75
+ if (dropped > 0) {
76
+ const n = dropped; dropped = 0;
77
+ await appendFile(path, `${new Date().toISOString()} [event-log] dropped ${n} line(s) (disk stalled)\n`, { mode: 0o600 });
78
+ }
79
+ // Redaction + formatting on the DRAINED async chain, off the request hot path.
80
+ let body = redactSecrets(String(rawMsg)).replace(/\s*\n\s*/g, ' ');
81
+ body = capBytes(body, MAX_BODY);
82
+ await appendFile(path, `${new Date().toISOString()} ${body}\n`, { mode: 0o600 });
83
+ } catch { /* logging must never throw into the proxy */ }
84
+ }
85
+
86
+ // Rotation is run by a SINGLE owner's timer (never inside writeLine), so two
87
+ // coexisting processes during a reload can't both rename and clobber each other's
88
+ // archive. Appends are O_APPEND to the path, so a rename mid-stream just means the
89
+ // next append re-creates the live file — no lost current lines.
90
+ export async function rotateIfNeeded() {
91
+ const path = logPath;
92
+ if (!path) return;
93
+ const st = await stat(path).catch(() => null);
94
+ if (st && st.size > MAX_BYTES) await rename(path, `${path}.1`).catch(() => {});
95
+ }
96
+
97
+ /**
98
+ * Tee console.log/console.error to the event log while preserving their normal
99
+ * output (stdout in plain mode; the TUI separately re-points console at its ring
100
+ * buffer, whose _addLog also calls appendEventLog, so TUI-mode lines persist too).
101
+ */
102
+ export function installConsoleMirror() {
103
+ if (origConsole) return; // idempotent
104
+ origConsole = { log: console.log.bind(console), error: console.error.bind(console) };
105
+ for (const method of ['log', 'error']) {
106
+ const orig = origConsole[method];
107
+ console[method] = (...args) => {
108
+ try { appendEventLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')); } catch { /* never */ }
109
+ orig(...args);
110
+ };
111
+ }
112
+ }
113
+
114
+ /** Test-only barrier: resolves once every queued append has settled. */
115
+ export const flushEventLog = () => writeChain;
116
+
117
+ /** Test-only: restore console + reset all module state so tests don't bleed. */
118
+ export function __resetEventLogForTest() {
119
+ if (origConsole) { console.log = origConsole.log; console.error = origConsole.error; origConsole = null; }
120
+ if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
121
+ logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0;
122
+ }
package/src/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
- import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
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
7
  import { AccountManager } from './account-manager.js';
7
8
  import { createProxyServer } from './server.js';
8
9
  import { Prober } from './prober.js';
@@ -35,6 +36,17 @@ function isInteractiveTerminal() {
35
36
  return Boolean(process.stdout.isTTY) || process.env.MAXPOOL_FORCE_TTY === '1';
36
37
  }
37
38
 
39
+ // Wire up the persistent event log (default-on; disable with `eventLog: false` in
40
+ // config). Sets the path + tees console.log/error to disk so routing, network
41
+ // errors, cooldowns and reloads survive the in-memory TUI feed for later triage.
42
+ function initEventLog(config, { manageRotation = false } = {}) {
43
+ if (config?.eventLog === false) return;
44
+ try {
45
+ setEventLogPath(getLogPath(), { manageRotation });
46
+ installConsoleMirror();
47
+ } catch { /* logging must never block startup */ }
48
+ }
49
+
38
50
  // Which reload path an in-process reload request takes. Interactive (a live TUI)
39
51
  // reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
40
52
  // seamless reload worker boots headless and would strand the user in raw logs.
@@ -127,6 +139,7 @@ async function serverCommand() {
127
139
  async function supervisorCommand() {
128
140
  const { createServer } = await import('node:net');
129
141
  const config = await loadOrCreateConfig();
142
+ initEventLog(config, { manageRotation: true }); // supervisor is the single rotation owner
130
143
  const port = config.proxy.port;
131
144
  const host = config.proxy.host || '127.0.0.1';
132
145
 
@@ -433,6 +446,8 @@ function delay(ms) {
433
446
 
434
447
  async function serverWorkerCommand() {
435
448
  const config = await loadOrCreateConfig();
449
+ // The lone unsupervised worker owns rotation; under a supervisor, the supervisor does.
450
+ initEventLog(config, { manageRotation: typeof process.send !== 'function' });
436
451
 
437
452
  // --log-to <dir>
438
453
  const logTo = argValue('--log-to');
package/src/server.js CHANGED
@@ -874,6 +874,9 @@ async function forwardRequest(
874
874
  );
875
875
  if (queued) return;
876
876
  ctx.status = 503;
877
+ // Record the user-facing failure explicitly so it's greppable in the event
878
+ // log (the in-memory TUI feed scrolls away). `err` is the last network error.
879
+ console.error(`[Maxpool] Returned connection_unavailable (503) after network errors on all routes (last: "${account.name}" — ${err.code || err.message})`);
877
880
  sendErrorResponse(res, requestInfo, 503, {
878
881
  type: 'error',
879
882
  error: {
package/src/tui.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { fetchProfile, loginOAuth } from './oauth.js';
3
+ import { appendEventLog } from './event-log.js';
3
4
 
4
5
  // ── ANSI helpers ─────────────────────────────────────────────
5
6
 
@@ -284,6 +285,9 @@ export class TUI {
284
285
 
285
286
  _addLog(msg) {
286
287
  msg = msg.replace(/^\[Maxpool\]\s*/, '');
288
+ // Persist too — in TUI mode console is re-pointed here, bypassing the console
289
+ // mirror, so this is where TUI-mode lines reach the on-disk event log.
290
+ appendEventLog(msg);
287
291
  this.log.unshift({ t: timestamp(), msg });
288
292
  if (this.log.length > 200) this.log.length = 200;
289
293
  if (this.running) this.render();