@openagents-org/agent-launcher 0.2.191 → 0.2.193
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/adapters/base.js +55 -26
- package/src/adapters/codex.js +33 -2
- package/src/adapters/failure-streak.js +89 -0
- package/src/adapters/kimi.js +11 -0
- package/src/adapters/pi.js +2 -1
- package/src/config.js +107 -28
- package/src/daemon.js +30 -35
- package/src/installer.js +26 -4
- package/src/log-writer.js +351 -0
- package/src/node-pty-perms.js +111 -0
- package/src/workspace-client.js +44 -14
package/src/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ const { WorkspaceClient } = require('./workspace-client');
|
|
|
12
12
|
const { listEndpointModels } = require('./model-list');
|
|
13
13
|
const { AUTH_MODE_KEY, isCliLogin, stripForCliLogin, typeEnvFor } = require('./env');
|
|
14
14
|
const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
|
|
15
|
+
const { LogWriter } = require('./log-writer');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Mask an API key for display (same shape the workspace backend uses for
|
|
@@ -247,7 +248,7 @@ class Daemon {
|
|
|
247
248
|
this._log(`${name}: workspace credential for '${info.networkRef}' rotated (device re-paired) — restarting to pick up the new token`);
|
|
248
249
|
this.restartAgent(name).catch((e) => {
|
|
249
250
|
info._credRestartPending = false;
|
|
250
|
-
this._log(`${name}: credential-rotation restart failed: ${e.message}
|
|
251
|
+
this._log(`${name}: credential-rotation restart failed: ${e.message}`, 'warn');
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
254
|
}
|
|
@@ -583,7 +584,7 @@ class Daemon {
|
|
|
583
584
|
}
|
|
584
585
|
for (const [key, value] of Object.entries(config)) {
|
|
585
586
|
if (!allowed.has(key)) {
|
|
586
|
-
this._log(`config for '${type}': refused non-env_config key '${key}'
|
|
587
|
+
this._log(`config for '${type}': refused non-env_config key '${key}'`, 'warn');
|
|
587
588
|
continue;
|
|
588
589
|
}
|
|
589
590
|
if (value === null || value === undefined) continue;
|
|
@@ -805,11 +806,11 @@ async _runNodeCommand(n, cmd) {
|
|
|
805
806
|
this._crashGuardsInstalled = true;
|
|
806
807
|
process.on('unhandledRejection', (reason) => {
|
|
807
808
|
const msg = reason && reason.stack ? reason.stack : String(reason);
|
|
808
|
-
this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}
|
|
809
|
+
this._log(`UNHANDLED REJECTION (daemon kept alive): ${msg}`, 'error');
|
|
809
810
|
});
|
|
810
811
|
process.on('uncaughtException', (err) => {
|
|
811
812
|
const msg = err && err.stack ? err.stack : String(err);
|
|
812
|
-
this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}
|
|
813
|
+
this._log(`UNCAUGHT EXCEPTION (daemon kept alive): ${msg}`, 'error');
|
|
813
814
|
});
|
|
814
815
|
}
|
|
815
816
|
|
|
@@ -919,6 +920,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
919
920
|
this._writeStatus();
|
|
920
921
|
this._cleanupPid();
|
|
921
922
|
this._log('Daemon stopped');
|
|
923
|
+
if (this._logWriter) await this._logWriter.flush();
|
|
922
924
|
|
|
923
925
|
if (this._shutdownResolve) this._shutdownResolve();
|
|
924
926
|
}
|
|
@@ -947,7 +949,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
947
949
|
// a hung adapter.run() promise prevents the slot from ever being
|
|
948
950
|
// released, and subsequent start/restart commands see "already running".
|
|
949
951
|
if (this._adapters && this._adapters[agentName]) {
|
|
950
|
-
this._log(`WARNING: ${agentName} adapter did not exit after stop — force-releasing slot
|
|
952
|
+
this._log(`WARNING: ${agentName} adapter did not exit after stop — force-releasing slot`, 'warn');
|
|
951
953
|
try { this._adapters[agentName].stop(); } catch {}
|
|
952
954
|
delete this._adapters[agentName];
|
|
953
955
|
}
|
|
@@ -1282,7 +1284,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
1282
1284
|
const exitCode = await new Promise((resolve) => {
|
|
1283
1285
|
proc.on('exit', (code) => resolve(code));
|
|
1284
1286
|
proc.on('error', (err) => {
|
|
1285
|
-
this._log(`${name} spawn error: ${err.message}
|
|
1287
|
+
this._log(`${name} spawn error: ${err.message}`, 'error');
|
|
1286
1288
|
resolve(1);
|
|
1287
1289
|
});
|
|
1288
1290
|
});
|
|
@@ -1309,13 +1311,13 @@ async _runNodeCommand(n, cmd) {
|
|
|
1309
1311
|
this._writeStatus();
|
|
1310
1312
|
|
|
1311
1313
|
if (info.restarts >= 10) {
|
|
1312
|
-
this._log(`${name} crashed ${info.restarts} times, giving up. Fix the issue and restart manually
|
|
1314
|
+
this._log(`${name} crashed ${info.restarts} times, giving up. Fix the issue and restart manually.`, 'error');
|
|
1313
1315
|
info.state = 'stopped';
|
|
1314
1316
|
this._writeStatus();
|
|
1315
1317
|
break;
|
|
1316
1318
|
}
|
|
1317
1319
|
|
|
1318
|
-
this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})
|
|
1320
|
+
this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})`, 'warn');
|
|
1319
1321
|
await this._sleep(info._backoff * 1000);
|
|
1320
1322
|
info._backoff = Math.min(info._backoff * 2, 60);
|
|
1321
1323
|
}
|
|
@@ -1384,6 +1386,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
1384
1386
|
toolMode: agentCfg.tool_mode || 'skills',
|
|
1385
1387
|
// Live runtime/connectivity status → daemon.status.json (Agents list/TUI).
|
|
1386
1388
|
onStatus: (update) => this._applyAdapterStatus(name, info, update),
|
|
1389
|
+
logSink: (level, msg) => this._logAs(`adapter [${name}]`, level, msg),
|
|
1387
1390
|
});
|
|
1388
1391
|
} catch (e) {
|
|
1389
1392
|
// A construction failure (e.g. unknown agent type in this core) is a hard
|
|
@@ -1391,7 +1394,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
1391
1394
|
info.state = 'error';
|
|
1392
1395
|
info.errorReason = 'adapter_crashed';
|
|
1393
1396
|
info.lastError = redactDiagnostic(e.message || String(e));
|
|
1394
|
-
this._log(`${name} failed to create ${agentType} adapter: ${info.lastError}
|
|
1397
|
+
this._log(`${name} failed to create ${agentType} adapter: ${info.lastError}`, 'error');
|
|
1395
1398
|
this._writeStatus();
|
|
1396
1399
|
return;
|
|
1397
1400
|
}
|
|
@@ -1408,11 +1411,11 @@ async _runNodeCommand(n, cmd) {
|
|
|
1408
1411
|
info.errorReason = pf.reason || 'runtime_missing';
|
|
1409
1412
|
info.lastError = redactDiagnostic(pf.message || 'runtime not available');
|
|
1410
1413
|
this._writeStatus();
|
|
1411
|
-
this._log(`${name} preflight failed (${info.errorReason}): ${info.lastError}
|
|
1414
|
+
this._log(`${name} preflight failed (${info.errorReason}): ${info.lastError}`, 'warn');
|
|
1412
1415
|
return;
|
|
1413
1416
|
}
|
|
1414
1417
|
} catch (e) {
|
|
1415
|
-
this._log(`${name} preflight error (ignored, continuing): ${e.message}
|
|
1418
|
+
this._log(`${name} preflight error (ignored, continuing): ${e.message}`, 'warn');
|
|
1416
1419
|
}
|
|
1417
1420
|
|
|
1418
1421
|
// Store adapter reference for stop and duplicate detection
|
|
@@ -1440,7 +1443,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
1440
1443
|
} catch (e) {
|
|
1441
1444
|
info.errorReason = info.errorReason || 'adapter_crashed';
|
|
1442
1445
|
info.lastError = redactDiagnostic((e.message || String(e)).slice(0, 200));
|
|
1443
|
-
this._log(`${name} adapter error: ${info.lastError}
|
|
1446
|
+
this._log(`${name} adapter error: ${info.lastError}`, 'error');
|
|
1444
1447
|
}
|
|
1445
1448
|
|
|
1446
1449
|
delete this._adapters[name];
|
|
@@ -1756,7 +1759,7 @@ async _runNodeCommand(n, cmd) {
|
|
|
1756
1759
|
// The old adapter will exit on its next poll iteration since its
|
|
1757
1760
|
// entry in _stoppedAgents triggers adapter.stop() via checkStop.
|
|
1758
1761
|
if (this._adapters && this._adapters[name]) {
|
|
1759
|
-
this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate
|
|
1762
|
+
this._log(`WARNING: adapter '${name}' did not clear after 10s — force-releasing slot to avoid duplicate`, 'warn');
|
|
1760
1763
|
try { this._adapters[name].stop(); } catch {}
|
|
1761
1764
|
delete this._adapters[name];
|
|
1762
1765
|
}
|
|
@@ -1773,33 +1776,25 @@ async _runNodeCommand(n, cmd) {
|
|
|
1773
1776
|
try { fs.unlinkSync(this.config.statusFile); } catch {}
|
|
1774
1777
|
}
|
|
1775
1778
|
|
|
1776
|
-
_log(msg) {
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1779
|
+
_log(msg, level = 'info') {
|
|
1780
|
+
this._logAs('daemon', level, msg);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* One entry in daemon.log. Adapters log through here too (see the `logSink`
|
|
1785
|
+
* passed in _adapterLoop), so batching, repeat suppression and rotation cover
|
|
1786
|
+
* their lines as well — they used to bypass all of it via console.log.
|
|
1787
|
+
*/
|
|
1788
|
+
_logAs(scope, level, msg) {
|
|
1789
|
+
if (!this._logWriter) this._logWriter = new LogWriter(this.config.logFile);
|
|
1790
|
+
const written = this._logWriter.write(level, scope, msg);
|
|
1783
1791
|
// Only log to console if stdout is a TTY (not redirected to log file)
|
|
1784
1792
|
// to avoid duplicate lines when daemonized
|
|
1785
|
-
if (!this._shuttingDown && process.stdout.isTTY) {
|
|
1786
|
-
console.log(
|
|
1793
|
+
if (written && !this._shuttingDown && process.stdout.isTTY) {
|
|
1794
|
+
console.log(`${new Date().toISOString()} ${String(level).toUpperCase()} ${scope}: ${msg}`);
|
|
1787
1795
|
}
|
|
1788
1796
|
}
|
|
1789
1797
|
|
|
1790
|
-
_maybeRotateLog() {
|
|
1791
|
-
// Rotate at 10MB, keep 1 backup
|
|
1792
|
-
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1793
|
-
try {
|
|
1794
|
-
const stat = fs.statSync(this.config.logFile);
|
|
1795
|
-
if (stat.size > MAX_SIZE) {
|
|
1796
|
-
const backup = this.config.logFile + '.1';
|
|
1797
|
-
try { fs.unlinkSync(backup); } catch {}
|
|
1798
|
-
fs.renameSync(this.config.logFile, backup);
|
|
1799
|
-
}
|
|
1800
|
-
} catch {}
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
1798
|
_sleep(ms) {
|
|
1804
1799
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1805
1800
|
}
|
package/src/installer.js
CHANGED
|
@@ -23,6 +23,7 @@ const { nodeDistUrls, installRegistry } = require('./mirrors');
|
|
|
23
23
|
const { readinessReason, REASON } = require('./adapters/health-status');
|
|
24
24
|
const { checkInstallPrereqs, missingPrereqError } = require('./install-preflight');
|
|
25
25
|
const { detectShadowedNodeShims, shadowedNodeWarning } = require('./node-shims');
|
|
26
|
+
const { fixSpawnHelpers } = require('./node-pty-perms');
|
|
26
27
|
|
|
27
28
|
const STATUS_CACHE_TTL_MS = 10000;
|
|
28
29
|
const statusCache = new Map();
|
|
@@ -1130,14 +1131,16 @@ class Installer {
|
|
|
1130
1131
|
await this._bootstrapManagedUv(agentType, bootstrapEnv, null);
|
|
1131
1132
|
|
|
1132
1133
|
// Use bundled node/npm if system npm not available
|
|
1133
|
-
|
|
1134
|
-
|
|
1134
|
+
const npmPrefix = cmd.startsWith('npm install') ? getRuntimePrefix(agentType) : null;
|
|
1135
|
+
if (npmPrefix) {
|
|
1136
|
+
const prefixDir = npmPrefix;
|
|
1135
1137
|
fs.mkdirSync(prefixDir, { recursive: true });
|
|
1136
1138
|
const args = cmd.replace('npm install', 'install --save').replace(' -g ', ` --prefix "${prefixDir}" `);
|
|
1137
1139
|
cmd = this._resolveNpmCommand(args);
|
|
1138
1140
|
}
|
|
1139
1141
|
|
|
1140
1142
|
const output = await this._execShell(cmd, 300000, installEnv);
|
|
1143
|
+
if (npmPrefix) fixSpawnHelpers(npmPrefix);
|
|
1141
1144
|
|
|
1142
1145
|
// Aider-only: the curl/uv/pipx installer can exit 0 without landing a
|
|
1143
1146
|
// runnable (or genuine) binary, so verify the real CLI exists BEFORE
|
|
@@ -1419,6 +1422,10 @@ class Installer {
|
|
|
1419
1422
|
}
|
|
1420
1423
|
if (onData) onData(`\nCursor CLI resolved: ${cursor.path}\n`);
|
|
1421
1424
|
}
|
|
1425
|
+
if (rawCmd.startsWith('npm install')) {
|
|
1426
|
+
const fixed = fixSpawnHelpers(installCwd);
|
|
1427
|
+
if (fixed.length && onData) onData(`\nMade ${fixed.length} node-pty spawn-helper(s) executable.\n`);
|
|
1428
|
+
}
|
|
1422
1429
|
this._markInstalled(agentType);
|
|
1423
1430
|
if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
|
|
1424
1431
|
resolve({ success: true, command: displayCmd });
|
|
@@ -2153,6 +2160,20 @@ class Installer {
|
|
|
2153
2160
|
return false;
|
|
2154
2161
|
}
|
|
2155
2162
|
|
|
2163
|
+
/**
|
|
2164
|
+
* The Node.js the portable runtime gets — the same pin as the launcher's
|
|
2165
|
+
* bootstrap/node-version.ts, which owns upgrading an existing runtime; this
|
|
2166
|
+
* copy only ever installs onto a machine with no Node at all. Node 24 needs
|
|
2167
|
+
* macOS 13.5 (Darwin 22.6), so older Macs get the last Node 22 instead.
|
|
2168
|
+
*/
|
|
2169
|
+
static portableNodeVersion(platform = process.platform, release = require('os').release()) {
|
|
2170
|
+
if (platform === 'darwin') {
|
|
2171
|
+
const [major, minor] = String(release).split('.').map(Number);
|
|
2172
|
+
if (major < 22 || (major === 22 && minor < 6)) return 'v22.22.3';
|
|
2173
|
+
}
|
|
2174
|
+
return 'v24.21.0';
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2156
2177
|
/**
|
|
2157
2178
|
* Download and install Node.js LTS. Streams progress via onData callback.
|
|
2158
2179
|
* After install, updates PATH so npm is available for subsequent commands.
|
|
@@ -2163,14 +2184,15 @@ class Installer {
|
|
|
2163
2184
|
const { spawn: spawnProc } = require('child_process');
|
|
2164
2185
|
const https = require('https');
|
|
2165
2186
|
const os = require('os');
|
|
2166
|
-
const nodeVersion = 'v22.22.3';
|
|
2167
2187
|
const plat = Installer.platform();
|
|
2188
|
+
const nodeVersion = Installer.portableNodeVersion();
|
|
2168
2189
|
|
|
2169
2190
|
if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
|
|
2170
2191
|
|
|
2171
2192
|
if (plat === 'windows') {
|
|
2172
2193
|
// Download portable zip — no admin required
|
|
2173
|
-
|
|
2194
|
+
// No 32-bit Windows build exists past Node 22.
|
|
2195
|
+
const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
|
2174
2196
|
const zipPath = path.join(os.tmpdir(), `node-${nodeVersion}.zip`);
|
|
2175
2197
|
|
|
2176
2198
|
await this._downloadFirst(
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The daemon's writer for daemon.log — one sink shared by the daemon and every
|
|
5
|
+
* adapter running inside it.
|
|
6
|
+
*
|
|
7
|
+
* The line format is unchanged (`<ISO time> <LEVEL> <scope>: <message>`, extra
|
|
8
|
+
* message lines following as continuation lines): Launchers already in the
|
|
9
|
+
* field parse this file, and they may run a newer core than they shipped with.
|
|
10
|
+
* What changes is how lines get there:
|
|
11
|
+
*
|
|
12
|
+
* - Batched, asynchronous writes. The old writer did an appendFileSync — an
|
|
13
|
+
* open/write/close on the event loop — plus a statSync for every line. Lines
|
|
14
|
+
* now collect in memory and go out in one append per `flushMs`, or sooner
|
|
15
|
+
* once `flushBytes` are waiting. The size check runs once per batch.
|
|
16
|
+
*
|
|
17
|
+
* - Repeat suppression. A failure that repeats every few seconds used to fill
|
|
18
|
+
* the file with identical entries and rotate out everything useful. Within
|
|
19
|
+
* each `rateWindowMs`, lines with the same level, scope and message shape
|
|
20
|
+
* (digits and ids masked) pass `rateBurst` times; the rest are counted and
|
|
21
|
+
* reported in one line when the window ends. ERROR lines always pass.
|
|
22
|
+
*
|
|
23
|
+
* - Copy-truncate rotation with gzip generations. The old rename-based
|
|
24
|
+
* rotation moved the file out from under every process that inherited a
|
|
25
|
+
* handle to it — the daemon's own stdout/stderr, and with them every
|
|
26
|
+
* adapter line — so those kept writing into the renamed backup, which the
|
|
27
|
+
* next rotation deleted while still open. Copying the file aside and then
|
|
28
|
+
* truncating it keeps every holder writing to daemon.log (they all opened
|
|
29
|
+
* it in append mode). The copy is gzipped into daemon.log.1.gz, and the
|
|
30
|
+
* older generations shift up to `keep`.
|
|
31
|
+
*
|
|
32
|
+
* Writes are best-effort, as they always were: a log that cannot be written
|
|
33
|
+
* must never take the daemon down.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const zlib = require('zlib');
|
|
38
|
+
const { pipeline } = require('stream');
|
|
39
|
+
|
|
40
|
+
const LEVEL_TOKENS = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR' };
|
|
41
|
+
|
|
42
|
+
const DEFAULTS = {
|
|
43
|
+
flushMs: 250,
|
|
44
|
+
flushBytes: 64 * 1024,
|
|
45
|
+
// Backlog bound for a log that cannot keep up (a stalled disk). Past this the
|
|
46
|
+
// oldest waiting lines are dropped and the drop is reported in the log.
|
|
47
|
+
maxPendingBytes: 4 * 1024 * 1024,
|
|
48
|
+
maxBytes: 10 * 1024 * 1024,
|
|
49
|
+
keep: 5,
|
|
50
|
+
rateWindowMs: 60 * 1000,
|
|
51
|
+
rateBurst: 20,
|
|
52
|
+
// Distinct message shapes tracked per window. A shape seen after the table
|
|
53
|
+
// is full is let through rather than tracked.
|
|
54
|
+
maxRateKeys: 1024,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every writer with lines that may still be in memory; flushed on exit. */
|
|
58
|
+
const live = new Set();
|
|
59
|
+
let exitHookInstalled = false;
|
|
60
|
+
|
|
61
|
+
function installExitHook() {
|
|
62
|
+
if (exitHookInstalled) return;
|
|
63
|
+
exitHookInstalled = true;
|
|
64
|
+
process.once('exit', () => {
|
|
65
|
+
for (const w of live) w.flushSync();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeLevel(level) {
|
|
70
|
+
return Object.prototype.hasOwnProperty.call(LEVEL_TOKENS, level) ? level : 'info';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The shape of a message: its first line with ids and numbers masked, so
|
|
75
|
+
* `Poll #257 failed` and `Poll #258 failed` count as the same line.
|
|
76
|
+
*/
|
|
77
|
+
function messageShape(message) {
|
|
78
|
+
const first = String(message).split('\n', 1)[0];
|
|
79
|
+
return first
|
|
80
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<id>')
|
|
81
|
+
.replace(/\d+/g, '#')
|
|
82
|
+
.slice(0, 200);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class LogWriter {
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} file - daemon.log
|
|
88
|
+
* @param {object} [opts] - overrides for DEFAULTS
|
|
89
|
+
*/
|
|
90
|
+
constructor(file, opts = {}) {
|
|
91
|
+
this.file = file;
|
|
92
|
+
this.opts = { ...DEFAULTS, ...opts };
|
|
93
|
+
|
|
94
|
+
this._pending = [];
|
|
95
|
+
this._pendingBytes = 0;
|
|
96
|
+
this._timer = null;
|
|
97
|
+
this._writing = false;
|
|
98
|
+
this._rotating = false;
|
|
99
|
+
this._waiters = [];
|
|
100
|
+
|
|
101
|
+
this._rate = new Map();
|
|
102
|
+
this._windowStart = Date.now();
|
|
103
|
+
this._windowTimer = null;
|
|
104
|
+
|
|
105
|
+
this.stats = { written: 0, suppressed: 0, dropped: 0, writeErrors: 0, rotations: 0 };
|
|
106
|
+
this._droppedSinceNote = 0;
|
|
107
|
+
|
|
108
|
+
live.add(this);
|
|
109
|
+
installExitHook();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Queue one entry. Returns false when it was suppressed as a repeat. */
|
|
113
|
+
write(level, scope, message) {
|
|
114
|
+
const lvl = normalizeLevel(level);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (!this._admit(lvl, scope, message, now)) return false;
|
|
117
|
+
this._enqueue(lvl, scope, message, now);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolves once everything queued so far is on disk (or failed to be). */
|
|
122
|
+
flush() {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
this._rollWindow(Date.now());
|
|
125
|
+
if (!this._writing && this._pending.length === 0) return resolve();
|
|
126
|
+
this._waiters.push(resolve);
|
|
127
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
128
|
+
this._flush();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write whatever is still queued, synchronously. For process exit, where an
|
|
134
|
+
* asynchronous write would never complete.
|
|
135
|
+
*/
|
|
136
|
+
flushSync() {
|
|
137
|
+
this._rollWindow(Date.now());
|
|
138
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
139
|
+
const data = this._takePending();
|
|
140
|
+
if (!data) return;
|
|
141
|
+
try {
|
|
142
|
+
fs.appendFileSync(this.file, data, 'utf-8');
|
|
143
|
+
this.stats.written += data.length;
|
|
144
|
+
} catch {
|
|
145
|
+
this.stats.writeErrors++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Flush synchronously and stop tracking this writer. */
|
|
150
|
+
close() {
|
|
151
|
+
this.flushSync();
|
|
152
|
+
if (this._windowTimer) { clearTimeout(this._windowTimer); this._windowTimer = null; }
|
|
153
|
+
live.delete(this);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------
|
|
157
|
+
// Repeat suppression
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
_admit(level, scope, message, now) {
|
|
161
|
+
if (level === 'error') return true;
|
|
162
|
+
if (now - this._windowStart >= this.opts.rateWindowMs) this._rollWindow(now);
|
|
163
|
+
|
|
164
|
+
const key = `${level}\u0000${scope}\u0000${messageShape(message)}`;
|
|
165
|
+
let slot = this._rate.get(key);
|
|
166
|
+
if (!slot) {
|
|
167
|
+
if (this._rate.size >= this.opts.maxRateKeys) return true;
|
|
168
|
+
slot = { level, scope, sample: String(message).split('\n', 1)[0], seen: 0, suppressed: 0 };
|
|
169
|
+
this._rate.set(key, slot);
|
|
170
|
+
}
|
|
171
|
+
slot.seen++;
|
|
172
|
+
if (slot.seen <= this.opts.rateBurst) return true;
|
|
173
|
+
|
|
174
|
+
slot.suppressed++;
|
|
175
|
+
this.stats.suppressed++;
|
|
176
|
+
// Report at the end of this window even if nothing else is ever logged.
|
|
177
|
+
if (!this._windowTimer) {
|
|
178
|
+
const wait = Math.max(0, this._windowStart + this.opts.rateWindowMs - now);
|
|
179
|
+
this._windowTimer = setTimeout(() => {
|
|
180
|
+
this._windowTimer = null;
|
|
181
|
+
this._rollWindow(Date.now());
|
|
182
|
+
}, wait);
|
|
183
|
+
if (this._windowTimer.unref) this._windowTimer.unref();
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Close the current window: one summary line per suppressed shape. */
|
|
189
|
+
_rollWindow(now) {
|
|
190
|
+
const secs = Math.max(1, Math.round((now - this._windowStart) / 1000));
|
|
191
|
+
for (const slot of this._rate.values()) {
|
|
192
|
+
if (slot.suppressed > 0) {
|
|
193
|
+
this._enqueue(
|
|
194
|
+
slot.level,
|
|
195
|
+
slot.scope,
|
|
196
|
+
`Suppressed ${slot.suppressed} more like this in the last ${secs}s: ${slot.sample}`,
|
|
197
|
+
now,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this._rate.clear();
|
|
202
|
+
this._windowStart = now;
|
|
203
|
+
if (this._windowTimer) { clearTimeout(this._windowTimer); this._windowTimer = null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ------------------------------------------------------------------
|
|
207
|
+
// Batching
|
|
208
|
+
// ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
_format(level, scope, message, now) {
|
|
211
|
+
return `${new Date(now).toISOString()} ${LEVEL_TOKENS[level]} ${scope}: ${message}\n`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_enqueue(level, scope, message, now) {
|
|
215
|
+
const line = this._format(level, scope, message, now);
|
|
216
|
+
this._pending.push(line);
|
|
217
|
+
this._pendingBytes += line.length;
|
|
218
|
+
|
|
219
|
+
while (this._pendingBytes > this.opts.maxPendingBytes && this._pending.length > 1) {
|
|
220
|
+
this._pendingBytes -= this._pending.shift().length;
|
|
221
|
+
this.stats.dropped++;
|
|
222
|
+
this._droppedSinceNote++;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this._schedule(this._pendingBytes >= this.opts.flushBytes ? 0 : this.opts.flushMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_schedule(delay) {
|
|
229
|
+
// A write in progress picks up the backlog when it completes.
|
|
230
|
+
if (this._writing) return;
|
|
231
|
+
if (this._timer) {
|
|
232
|
+
if (delay > 0) return;
|
|
233
|
+
clearTimeout(this._timer);
|
|
234
|
+
}
|
|
235
|
+
this._timer = setTimeout(() => {
|
|
236
|
+
this._timer = null;
|
|
237
|
+
this._flush();
|
|
238
|
+
}, delay);
|
|
239
|
+
if (this._timer.unref) this._timer.unref();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_takePending() {
|
|
243
|
+
if (this._droppedSinceNote > 0) {
|
|
244
|
+
this._pending.unshift(this._format(
|
|
245
|
+
'warn', 'daemon',
|
|
246
|
+
`Log writer fell behind — dropped ${this._droppedSinceNote} line(s)`,
|
|
247
|
+
Date.now(),
|
|
248
|
+
));
|
|
249
|
+
this._droppedSinceNote = 0;
|
|
250
|
+
}
|
|
251
|
+
if (this._pending.length === 0) return '';
|
|
252
|
+
const data = this._pending.join('');
|
|
253
|
+
this._pending = [];
|
|
254
|
+
this._pendingBytes = 0;
|
|
255
|
+
return data;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_flush() {
|
|
259
|
+
if (this._writing) return;
|
|
260
|
+
const data = this._takePending();
|
|
261
|
+
if (!data) return this._settle();
|
|
262
|
+
|
|
263
|
+
this._writing = true;
|
|
264
|
+
fs.appendFile(this.file, data, 'utf-8', (err) => {
|
|
265
|
+
if (err) this.stats.writeErrors++;
|
|
266
|
+
else this.stats.written += data.length;
|
|
267
|
+
this._maybeRotate(() => {
|
|
268
|
+
this._writing = false;
|
|
269
|
+
if (this._pending.length > 0) this._flush();
|
|
270
|
+
else this._settle();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_settle() {
|
|
276
|
+
if (this._writing || this._pending.length > 0) return;
|
|
277
|
+
const waiters = this._waiters;
|
|
278
|
+
this._waiters = [];
|
|
279
|
+
for (const resolve of waiters) resolve();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------------
|
|
283
|
+
// Rotation
|
|
284
|
+
// ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
_gen(n) {
|
|
287
|
+
return `${this.file}.${n}.gz`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_maybeRotate(done) {
|
|
291
|
+
if (this._rotating) return done();
|
|
292
|
+
fs.stat(this.file, (err, st) => {
|
|
293
|
+
if (err || st.size < this.opts.maxBytes) return done();
|
|
294
|
+
this._rotate(done);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Copy the full log aside, truncate it in place, then compress the copy in
|
|
300
|
+
* the background. `done` runs as soon as the truncate lands, so writing
|
|
301
|
+
* continues into the emptied file while the copy is compressed.
|
|
302
|
+
*
|
|
303
|
+
* A line another process appends between the copy and the truncate is lost.
|
|
304
|
+
* That window is the duration of one file copy, once per `maxBytes` of log.
|
|
305
|
+
*/
|
|
306
|
+
_rotate(done) {
|
|
307
|
+
this._rotating = true;
|
|
308
|
+
const copy = `${this.file}.rotating`;
|
|
309
|
+
try {
|
|
310
|
+
fs.copyFileSync(this.file, copy);
|
|
311
|
+
fs.truncateSync(this.file, 0);
|
|
312
|
+
} catch {
|
|
313
|
+
try { fs.unlinkSync(copy); } catch { /* not created */ }
|
|
314
|
+
this._rotating = false;
|
|
315
|
+
return done();
|
|
316
|
+
}
|
|
317
|
+
this.stats.rotations++;
|
|
318
|
+
done();
|
|
319
|
+
|
|
320
|
+
this._shiftGenerations();
|
|
321
|
+
const target = this._gen(1);
|
|
322
|
+
const partial = `${target}.tmp`;
|
|
323
|
+
pipeline(
|
|
324
|
+
fs.createReadStream(copy),
|
|
325
|
+
zlib.createGzip(),
|
|
326
|
+
fs.createWriteStream(partial),
|
|
327
|
+
(err) => {
|
|
328
|
+
try { fs.unlinkSync(copy); } catch { /* already gone */ }
|
|
329
|
+
if (err) {
|
|
330
|
+
try { fs.unlinkSync(partial); } catch { /* not created */ }
|
|
331
|
+
} else {
|
|
332
|
+
try { fs.renameSync(partial, target); } catch { /* best effort */ }
|
|
333
|
+
}
|
|
334
|
+
this._rotating = false;
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_shiftGenerations() {
|
|
340
|
+
const { keep } = this.opts;
|
|
341
|
+
try { fs.unlinkSync(this._gen(keep)); } catch { /* none yet */ }
|
|
342
|
+
for (let n = keep - 1; n >= 1; n--) {
|
|
343
|
+
try { fs.renameSync(this._gen(n), this._gen(n + 1)); } catch { /* none yet */ }
|
|
344
|
+
}
|
|
345
|
+
// The uncompressed backup the previous writer kept. It was always deleted
|
|
346
|
+
// at the next rotation; this is that rotation.
|
|
347
|
+
try { fs.unlinkSync(`${this.file}.1`); } catch { /* none */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = { LogWriter, messageShape, DEFAULTS };
|