maxpool 1.5.4 → 1.5.6
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/config.js +14 -0
- package/src/event-log.js +122 -0
- package/src/index.js +27 -1
- package/src/server.js +3 -0
- package/src/sleep-guard.js +77 -0
- package/src/tui.js +4 -0
package/package.json
CHANGED
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: {
|
|
@@ -36,6 +46,10 @@ export function createDefaultConfig() {
|
|
|
36
46
|
apiKey: 'mp-' + randomBytes(24).toString('base64url'),
|
|
37
47
|
},
|
|
38
48
|
upstream: 'https://api.anthropic.com',
|
|
49
|
+
// (macOS) Keep the system awake while requests are in flight so long overnight
|
|
50
|
+
// streams survive Maintenance Sleep; the Mac sleeps normally when idle. Set false
|
|
51
|
+
// to disable. AC power only; the display still sleeps (screen stays dark).
|
|
52
|
+
preventSleep: true,
|
|
39
53
|
// On startup, check npm for a newer maxpool and notify. Set false to disable.
|
|
40
54
|
updateCheck: true,
|
|
41
55
|
// When true, a newer version is installed automatically (npm i -g maxpool@latest)
|
package/src/event-log.js
ADDED
|
@@ -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,9 @@
|
|
|
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';
|
|
7
|
+
import { SleepGuard } from './sleep-guard.js';
|
|
6
8
|
import { AccountManager } from './account-manager.js';
|
|
7
9
|
import { createProxyServer } from './server.js';
|
|
8
10
|
import { Prober } from './prober.js';
|
|
@@ -35,6 +37,17 @@ function isInteractiveTerminal() {
|
|
|
35
37
|
return Boolean(process.stdout.isTTY) || process.env.MAXPOOL_FORCE_TTY === '1';
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// Wire up the persistent event log (default-on; disable with `eventLog: false` in
|
|
41
|
+
// config). Sets the path + tees console.log/error to disk so routing, network
|
|
42
|
+
// errors, cooldowns and reloads survive the in-memory TUI feed for later triage.
|
|
43
|
+
function initEventLog(config, { manageRotation = false } = {}) {
|
|
44
|
+
if (config?.eventLog === false) return;
|
|
45
|
+
try {
|
|
46
|
+
setEventLogPath(getLogPath(), { manageRotation });
|
|
47
|
+
installConsoleMirror();
|
|
48
|
+
} catch { /* logging must never block startup */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
// Which reload path an in-process reload request takes. Interactive (a live TUI)
|
|
39
52
|
// reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
|
|
40
53
|
// seamless reload worker boots headless and would strand the user in raw logs.
|
|
@@ -127,6 +140,7 @@ async function serverCommand() {
|
|
|
127
140
|
async function supervisorCommand() {
|
|
128
141
|
const { createServer } = await import('node:net');
|
|
129
142
|
const config = await loadOrCreateConfig();
|
|
143
|
+
initEventLog(config, { manageRotation: true }); // supervisor is the single rotation owner
|
|
130
144
|
const port = config.proxy.port;
|
|
131
145
|
const host = config.proxy.host || '127.0.0.1';
|
|
132
146
|
|
|
@@ -433,6 +447,8 @@ function delay(ms) {
|
|
|
433
447
|
|
|
434
448
|
async function serverWorkerCommand() {
|
|
435
449
|
const config = await loadOrCreateConfig();
|
|
450
|
+
// The lone unsupervised worker owns rotation; under a supervisor, the supervisor does.
|
|
451
|
+
initEventLog(config, { manageRotation: typeof process.send !== 'function' });
|
|
436
452
|
|
|
437
453
|
// --log-to <dir>
|
|
438
454
|
const logTo = argValue('--log-to');
|
|
@@ -454,6 +470,14 @@ async function serverWorkerCommand() {
|
|
|
454
470
|
|
|
455
471
|
const threshold = config.switchThreshold || 0.90;
|
|
456
472
|
const accountManager = new AccountManager(accounts, threshold, config.scheduler || {});
|
|
473
|
+
// (macOS) Keep the system awake ONLY while there is work in flight or queued, so a
|
|
474
|
+
// long overnight streaming request survives Maintenance Sleep; the Mac sleeps
|
|
475
|
+
// normally when idle. Disable via `preventSleep: false` in config.
|
|
476
|
+
const sleepGuard = new SleepGuard({
|
|
477
|
+
enabled: config.preventSleep !== false && process.platform === 'darwin' && process.env.MAXPOOL_DISABLE_SLEEP_GUARD !== '1',
|
|
478
|
+
getWorkPending: () => accountManager.getGlobalInFlight() > 0 || accountManager.queueState.waiting.length > 0,
|
|
479
|
+
log: msg => console.log(`[Maxpool] ${msg}`),
|
|
480
|
+
});
|
|
457
481
|
accountManager.setRoutingMode(
|
|
458
482
|
config.routing?.mode,
|
|
459
483
|
config.routing?.preferredAccount,
|
|
@@ -641,6 +665,7 @@ async function serverWorkerCommand() {
|
|
|
641
665
|
quotaSaveInterval.unref?.();
|
|
642
666
|
}
|
|
643
667
|
prober.start();
|
|
668
|
+
sleepGuard.start(); // (macOS) keep the system awake while serving, so long streams survive Maintenance Sleep
|
|
644
669
|
};
|
|
645
670
|
// Stop scheduling writes and flip the lease. Returns a promise that settles
|
|
646
671
|
// once any in-flight prober cycle has finished (so no probe-driven token
|
|
@@ -651,6 +676,7 @@ async function serverWorkerCommand() {
|
|
|
651
676
|
hasLease = false;
|
|
652
677
|
if (quotaSaveInterval) { clearInterval(quotaSaveInterval); quotaSaveInterval = null; }
|
|
653
678
|
accountManager.setWriterLease(false);
|
|
679
|
+
sleepGuard.stop(); // release the wake — only the active lease-holder keeps the Mac awake
|
|
654
680
|
await prober.stop(); // awaits any in-flight probe cycle (B1)
|
|
655
681
|
};
|
|
656
682
|
|
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: {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Conditional sleep guard (macOS): while maxpool has work in flight, prevent the
|
|
2
|
+
// SYSTEM from sleeping so long-lived streaming requests survive overnight
|
|
3
|
+
// Maintenance Sleep (which powers down the network stack and kills the
|
|
4
|
+
// connection → "terminated"). When idle, it releases so the Mac sleeps normally.
|
|
5
|
+
//
|
|
6
|
+
// Uses the built-in `caffeinate -s` (prevent SYSTEM sleep only — the DISPLAY still
|
|
7
|
+
// sleeps, so the screen stays dark) with `-w <pid>` so caffeinate auto-exits if the
|
|
8
|
+
// worker dies (never a zombie keeping the Mac awake forever). `-s` is AC-power only
|
|
9
|
+
// by design. Complete no-op off macOS or when disabled (e.g. the test suite).
|
|
10
|
+
|
|
11
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const POLL_MS = 5_000; // re-check the in-flight signal (idle-sleep timers are minutes)
|
|
14
|
+
const GRACE_MS = 60_000; // stay awake this long after the last request, to bridge gaps
|
|
15
|
+
|
|
16
|
+
export class SleepGuard {
|
|
17
|
+
constructor({ getWorkPending, log = () => {}, spawn = realSpawn, enabled, pollMs = POLL_MS, graceMs = GRACE_MS } = {}) {
|
|
18
|
+
this.getWorkPending = getWorkPending; // () => boolean: true while there's active/queued work
|
|
19
|
+
this.log = log;
|
|
20
|
+
this._spawn = spawn;
|
|
21
|
+
this.enabled = enabled ?? (process.platform === 'darwin' && process.env.MAXPOOL_DISABLE_SLEEP_GUARD !== '1');
|
|
22
|
+
this.pollMs = pollMs;
|
|
23
|
+
this.graceMs = graceMs;
|
|
24
|
+
this.child = null;
|
|
25
|
+
this.pollTimer = null;
|
|
26
|
+
this.graceTimer = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start() {
|
|
30
|
+
if (!this.enabled || this.pollTimer) return;
|
|
31
|
+
this.pollTimer = setInterval(() => this._tick(), this.pollMs);
|
|
32
|
+
this.pollTimer.unref?.();
|
|
33
|
+
this._tick(); // engage immediately if there's already work
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_tick() {
|
|
37
|
+
let pending = false;
|
|
38
|
+
try { pending = Boolean(this.getWorkPending?.()); } catch { pending = false; }
|
|
39
|
+
if (pending) {
|
|
40
|
+
if (this.graceTimer) { clearTimeout(this.graceTimer); this.graceTimer = null; }
|
|
41
|
+
this._ensureAwake();
|
|
42
|
+
} else if (this.child && !this.graceTimer) {
|
|
43
|
+
// Idle: hold the wake for a grace period, then release (bridges brief gaps
|
|
44
|
+
// between back-to-back requests so we never drop it mid-conversation).
|
|
45
|
+
this.graceTimer = setTimeout(() => { this.graceTimer = null; this._allowSleep(); }, this.graceMs);
|
|
46
|
+
this.graceTimer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_ensureAwake() {
|
|
51
|
+
if (!this.enabled || this.child) return;
|
|
52
|
+
try {
|
|
53
|
+
const c = this._spawn('/usr/bin/caffeinate', ['-s', '-w', String(process.pid)], { stdio: 'ignore' });
|
|
54
|
+
this.child = c;
|
|
55
|
+
// Only clear the ref if it's still THIS child (a stale exit can't null a newer one).
|
|
56
|
+
// caffeinate missing/unspawnable → disable so we don't re-spawn every poll forever.
|
|
57
|
+
c.once('error', () => { if (this.child === c) this.child = null; this.enabled = false; });
|
|
58
|
+
c.once('exit', () => { if (this.child === c) this.child = null; });
|
|
59
|
+
// Honest: -s keeps the SYSTEM awake ON AC ONLY (a no-op on battery); the display
|
|
60
|
+
// still sleeps so the screen stays dark.
|
|
61
|
+
this.log('Keeping the system awake while busy (caffeinate -s; AC power only, display still sleeps)');
|
|
62
|
+
} catch { this.child = null; this.enabled = false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_allowSleep() {
|
|
66
|
+
if (!this.child) return;
|
|
67
|
+
try { this.child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
68
|
+
this.child = null;
|
|
69
|
+
this.log('Idle — allowing normal system sleep again');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop() {
|
|
73
|
+
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
|
74
|
+
if (this.graceTimer) { clearTimeout(this.graceTimer); this.graceTimer = null; }
|
|
75
|
+
this._allowSleep();
|
|
76
|
+
}
|
|
77
|
+
}
|
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();
|