c8ctl-plugin-nano 1.54.0 → 1.55.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.
@@ -0,0 +1,162 @@
1
+ // Bounded per-worker log writer for the supervisor daemon (jwulf/c8ctl-plugin-nano#183).
2
+ //
3
+ // A supervised `nano work` child is long-lived and chatty: it streams
4
+ // stdout/stderr for the entire life of the fleet. The daemon used to hand the
5
+ // child a *raw append fd* as its stdio, so the OS wrote straight to
6
+ // `logs/supervisor/worker-<id>.log` and the file only ever grew — multi-GB logs
7
+ // could fill the disk with no cap and no rotation.
8
+ //
9
+ // This module lets the daemon OWN the bytes instead. The child is spawned with
10
+ // piped stdout/stderr and every chunk is fed through {@link createLogRing},
11
+ // which appends to the primary log file and, when it reaches the cap, ROTATES:
12
+ // the primary is renamed to `<log>.1` (replacing any previous `.1`) and a fresh
13
+ // primary is started. That keeps the newest output always retained (a ring/tail,
14
+ // not a hard stop) while bounding on-disk usage to ~2x the cap (the live primary
15
+ // plus one rotated file). `nano supervisor logs` tails the primary, so it keeps
16
+ // showing the most recent output across a rotation.
17
+ //
18
+ // The cap is operator-configurable via `NANO_SUPERVISOR_LOG_MAX_BYTES`
19
+ // (see {@link resolveLogMaxBytes}); `0`/negative opts out (unbounded), matching
20
+ // the existing `NANO_SUPERVISOR_*` env conventions.
21
+
22
+ import {
23
+ openSync as fsOpenSync,
24
+ writeSync as fsWriteSync,
25
+ closeSync as fsCloseSync,
26
+ fstatSync as fsFstatSync,
27
+ renameSync as fsRenameSync,
28
+ } from 'node:fs';
29
+
30
+ /** Default per-worker log cap: 10 MB. */
31
+ export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
32
+
33
+ /** Suffix of the single rotated-out file kept alongside the primary log. */
34
+ export const ROTATED_SUFFIX = '.1';
35
+
36
+ /**
37
+ * Resolve the per-worker log byte cap from an operator-supplied value with a
38
+ * sane fallback. Accepts a number or a numeric string (env vars arrive as
39
+ * strings). An unset/blank/non-numeric value falls back to `fallback`
40
+ * (default 10 MB). A value `<= 0` means "unbounded / off" and is returned as `0`
41
+ * so callers can opt out of the ring entirely and keep the legacy direct-fd
42
+ * write.
43
+ *
44
+ * @param {unknown} raw the operator value (e.g. `process.env.NANO_SUPERVISOR_LOG_MAX_BYTES`)
45
+ * @param {number} [fallback] the default when `raw` is absent/invalid
46
+ * @returns {number} a non-negative integer byte cap (`0` == unbounded/off)
47
+ */
48
+ export function resolveLogMaxBytes(raw, fallback = DEFAULT_LOG_MAX_BYTES) {
49
+ const base = Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : DEFAULT_LOG_MAX_BYTES;
50
+ if (raw === undefined || raw === null || raw === '') return base;
51
+ const s = typeof raw === 'number' ? raw : String(raw).trim();
52
+ if (s === '') return base; // whitespace-only == unset
53
+ const n = typeof s === 'number' ? s : Number(s);
54
+ if (!Number.isFinite(n)) return base;
55
+ if (n <= 0) return 0; // explicit opt-out: unbounded
56
+ return Math.floor(n);
57
+ }
58
+
59
+ /**
60
+ * @typedef {object} LogRing
61
+ * @property {(chunk: Buffer|string) => void} write append a chunk, rotating at the cap
62
+ * @property {() => void} close close the underlying fd (idempotent)
63
+ * @property {() => number} size current byte size of the live primary file
64
+ * @property {() => number} rotations how many times the log has rotated
65
+ */
66
+
67
+ /**
68
+ * Create a bounded, rotating writer for one worker's log file.
69
+ *
70
+ * Semantics:
71
+ * - Opens `logFile` in append mode (preserving any existing content — its bytes
72
+ * count toward the cap, so a reopen of a nearly-full file rotates promptly).
73
+ * - On each {@link LogRing.write}, if writing the chunk would push the primary
74
+ * file over `maxBytes` (and the file is non-empty), it ROTATES first: close
75
+ * the primary, rename it to `<logFile><ROTATED_SUFFIX>` (atomically replacing
76
+ * any prior rotated file), then open a fresh empty primary. The chunk is then
77
+ * written to the fresh primary. A single chunk larger than the cap is still
78
+ * written whole (never split mid-line); it just triggers a rotation on the
79
+ * following write.
80
+ * - `maxBytes <= 0` disables rotation entirely (unbounded append). Callers
81
+ * normally take the legacy direct-fd path instead of constructing a ring in
82
+ * that case; this is a defensive no-op for symmetry.
83
+ *
84
+ * All IO is synchronous so that, by the time a `write` returns, the bytes are on
85
+ * disk — that is what makes the rotation boundary deterministically testable
86
+ * without wall-clock flushing, and it means the daemon never loses already
87
+ * received bytes if the worker dies.
88
+ *
89
+ * @param {string} logFile absolute path to the primary log file
90
+ * @param {number} maxBytes byte cap (`<= 0` == unbounded); see {@link resolveLogMaxBytes}
91
+ * @param {object} [io] injectable fs surface for deterministic tests
92
+ * @param {typeof fsOpenSync} [io.openSync]
93
+ * @param {typeof fsWriteSync} [io.writeSync]
94
+ * @param {typeof fsCloseSync} [io.closeSync]
95
+ * @param {typeof fsFstatSync} [io.fstatSync]
96
+ * @param {typeof fsRenameSync} [io.renameSync]
97
+ * @returns {LogRing}
98
+ */
99
+ export function createLogRing(logFile, maxBytes, io = {}) {
100
+ const openSync = io.openSync || fsOpenSync;
101
+ const writeSync = io.writeSync || fsWriteSync;
102
+ const closeSync = io.closeSync || fsCloseSync;
103
+ const fstatSync = io.fstatSync || fsFstatSync;
104
+ const renameSync = io.renameSync || fsRenameSync;
105
+
106
+ const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.floor(maxBytes) : 0;
107
+ const rotatedFile = `${logFile}${ROTATED_SUFFIX}`;
108
+
109
+ let fd = openSync(logFile, 'a');
110
+ // Seed `size` from the existing file so appending to a nearly-full log rotates
111
+ // at the right point rather than starting the count from zero.
112
+ let size = 0;
113
+ try { size = fstatSync(fd).size; } catch { size = 0; }
114
+ let rotations = 0;
115
+ let closed = false;
116
+
117
+ const rotate = () => {
118
+ try { closeSync(fd); } catch { /* fd may already be gone */ }
119
+ try {
120
+ // Replace any previous rotated file with the just-filled primary. `rename`
121
+ // is atomic, so a concurrent tail reopening the primary never sees a gap.
122
+ renameSync(logFile, rotatedFile);
123
+ fd = openSync(logFile, 'a');
124
+ size = 0;
125
+ rotations += 1;
126
+ } catch {
127
+ // Rotation failed (permissions / transient FS issue). `renameSync` or the
128
+ // subsequent `openSync` threw AFTER we closed the primary fd, so leaving it
129
+ // as-is would strand the ring on a closed fd and silently stop draining.
130
+ // Instead, best-effort reopen the primary in append mode and keep writing:
131
+ // we favor continued log capture over strict bounding, briefly exceeding the
132
+ // cap until rotation can succeed again.
133
+ try {
134
+ fd = openSync(logFile, 'a');
135
+ try { size = fstatSync(fd).size; } catch { size = 0; }
136
+ } catch { /* can't reopen either; the write() try/catch swallows the fallout */ }
137
+ }
138
+ };
139
+
140
+ return {
141
+ write(chunk) {
142
+ if (closed) return;
143
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
144
+ if (buf.length === 0) return;
145
+ // Rotate BEFORE writing when the primary already holds bytes and this
146
+ // chunk would tip it over the cap — this bounds each file at <= cap + the
147
+ // final chunk (pipe chunks are small), and never splits a chunk mid-line.
148
+ if (cap > 0 && size > 0 && size + buf.length > cap) rotate();
149
+ try {
150
+ writeSync(fd, buf);
151
+ size += buf.length;
152
+ } catch { /* a transient write failure must not crash the daemon */ }
153
+ },
154
+ close() {
155
+ if (closed) return;
156
+ closed = true;
157
+ try { closeSync(fd); } catch { /* best effort */ }
158
+ },
159
+ size() { return size; },
160
+ rotations() { return rotations; },
161
+ };
162
+ }