@gcunharodrigues/wrxn 0.13.3 → 0.14.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,252 @@
1
+ 'use strict';
2
+
3
+ // WRXN shared rolling-prune for append-only jsonl logs (kernel #34 / C1) — the first retention policy
4
+ // in the system. The dream/sync/harvest consolidation trails (.wrxn/{dream,sync,harvest}/*.jsonl) are
5
+ // append-only and otherwise grow unbounded; this one mechanism (not four) bounds every such log by AGE
6
+ // and COUNT, wired into the session-end hook.
7
+ //
8
+ // SPLIT (mirrors the codebase: pure math + IO shell, like reward's deriveSignal/gitFactsSince):
9
+ // · retain(records, opts) — PURE, DETERMINISTIC: given parsed records in append order, return the
10
+ // survivors. The clock is INJECTED (opts.now) — there is no Date.now() in the core.
11
+ // · prune(dir, opts) — IO shell: read each *.jsonl file in `dir`, and IFF every non-empty line
12
+ // parses, rewrite it with retain()'s survivors. The clock defaults to Date.now() HERE, then flows
13
+ // into the pure core.
14
+ //
15
+ // CORRUPT-SAFE: a file with ANY unparseable line is left BYTE-INTACT (never deleted or truncated) — we
16
+ // never drop a line we cannot understand. SAFE NO-OP on an empty/missing dir. FAIL-OPEN: every fault is
17
+ // swallowed so a retention fault can never break session shutdown — it never throws.
18
+ //
19
+ // Self-contained: ships into installs alongside the hooks — node stdlib ONLY (fs / path), NO kernel-lib
20
+ // or recon import (exactly like the sidecar helper it sits beside).
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ // Retention bounds — NAMED CONSTANTS, not magic numbers. These trails are operational breadcrumbs for
26
+ // memory consolidation; the DURABLE knowledge lives in the wiki pages, so old breadcrumbs decay fast.
27
+ const MAX_AGE_DAYS = 90; // one quarter of history — the primary bound; older audit entries have little value
28
+ const MAX_RECORDS = 500; // hard per-file ceiling — the safety net against pathological (runaway-append) growth
29
+ const MS_PER_DAY = 86400000;
30
+
31
+ // The install-relative log dirs the session-end hook sweeps for WITHIN-FILE record bounding. `.wrxn/events`
32
+ // (the S2/#35 metadata-grade event source) is included so a long session's per-session *.jsonl is bounded
33
+ // by age + count like the rest; whole STALE event FILES are GC'd separately by pruneFiles (below).
34
+ const LOG_DIRS = ['.wrxn/dream', '.wrxn/sync', '.wrxn/harvest', '.wrxn/events'];
35
+
36
+ // Coerce an injected clock (ms-epoch number or a Date) to ms. Anything else → NaN (no clock).
37
+ function clockMs(now) {
38
+ if (Number.isFinite(now)) return now;
39
+ if (now instanceof Date) return now.getTime();
40
+ return NaN;
41
+ }
42
+
43
+ /**
44
+ * PURE: given parsed records in append (chronological) order, return the survivors.
45
+ * AGE-DROP: a record whose `ts` parses to a time older than `now - maxAgeDays` is dropped. A record with
46
+ * NO datable `ts` is NEVER aged out (conservative — we never drop what we cannot date). The clock is
47
+ * INJECTED via `now` (ms-epoch or Date); there is no Date.now() in this core. With no clock, age is skipped.
48
+ * COUNT-TRIM oldest-first: of what remains, keep the newest `maxRecords` (append order ⇒ newest = tail).
49
+ * @param {object[]} records
50
+ * @param {{maxAgeDays?:number, maxRecords?:number, now?:number|Date}} [opts]
51
+ * @returns {object[]}
52
+ */
53
+ function retain(records, { maxAgeDays = MAX_AGE_DAYS, maxRecords = MAX_RECORDS, now } = {}) {
54
+ let kept = Array.isArray(records) ? records : [];
55
+ const clock = clockMs(now);
56
+ if (Number.isFinite(maxAgeDays) && maxAgeDays > 0 && Number.isFinite(clock)) {
57
+ const cutoff = clock - maxAgeDays * MS_PER_DAY;
58
+ kept = kept.filter((r) => {
59
+ const t = r && typeof r === 'object' ? Date.parse(r.ts) : NaN;
60
+ return Number.isNaN(t) ? true : t >= cutoff; // undatable → keep; datable → keep iff within window
61
+ });
62
+ }
63
+ if (Number.isFinite(maxRecords) && maxRecords >= 0 && kept.length > maxRecords) {
64
+ kept = kept.slice(kept.length - maxRecords); // drop from the front (oldest) — keep the newest N
65
+ }
66
+ return kept;
67
+ }
68
+
69
+ // Monotonic disambiguator for the atomic-rewrite temp name — no wall clock needed in the IO shell.
70
+ let tmpSeq = 0;
71
+
72
+ // Prune one jsonl file. Returns true iff it was rewritten. CORRUPT-SAFE: if any non-empty line fails to
73
+ // parse, return false and leave the file byte-intact (never truncate on parse failure). A re-serialized
74
+ // survivor is byte-faithful to a compact jsonl line (JSON.stringify(JSON.parse(x)) preserves key order).
75
+ function pruneFile(file, opts) {
76
+ let raw;
77
+ try {
78
+ raw = fs.readFileSync(file, 'utf8');
79
+ } catch {
80
+ return false; // unreadable (e.g. a directory at this path) → leave it
81
+ }
82
+ const records = [];
83
+ for (const line of raw.split('\n')) {
84
+ const s = line.trim();
85
+ if (!s) continue; // blank / trailing newline — not a record, not corruption
86
+ let rec;
87
+ try {
88
+ rec = JSON.parse(s);
89
+ } catch {
90
+ return false; // a corrupt line → leave the WHOLE file byte-intact
91
+ }
92
+ records.push(rec);
93
+ }
94
+ const survivors = retain(records, opts);
95
+ if (survivors.length === records.length) return false; // nothing dropped → coalesced no-op (no rewrite)
96
+ const body = survivors.length ? survivors.map((r) => JSON.stringify(r)).join('\n') + '\n' : '';
97
+ // ATOMIC REWRITE: write survivors to a sibling temp, then rename it OVER the original. A torn write
98
+ // (crash / ENOSPC) can only damage the temp — the live log is replaced in ONE atomic step, or not at
99
+ // all. On any failure the original survives byte-intact (fail-open) and the partial temp is removed.
100
+ const tmpFile = `${file}.${process.pid}.${tmpSeq++}.tmp`; // same dir ⇒ rename is atomic (one filesystem)
101
+ try {
102
+ fs.writeFileSync(tmpFile, body);
103
+ fs.renameSync(tmpFile, file);
104
+ } catch {
105
+ try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean up */ }
106
+ return false; // rewrite failed → leave the original untouched
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * IO shell: prune every `*.jsonl` log file directly under `dir`. The clock defaults to Date.now() here,
113
+ * then flows into the pure core. SAFE NO-OP on a missing/unreadable/empty dir. FAIL-OPEN: never throws —
114
+ * one bad file never aborts the sweep, and any unexpected fault is swallowed.
115
+ * @param {string} dir
116
+ * @param {{maxAgeDays?:number, maxRecords?:number, now?:number}} [opts]
117
+ * @returns {{scanned:number, rewritten:number}}
118
+ */
119
+ function prune(dir, opts) {
120
+ const out = { scanned: 0, rewritten: 0 };
121
+ try {
122
+ const o = opts && typeof opts === 'object' ? opts : {};
123
+ // Resolve the clock HERE (the shell) — defaulting to Date.now() — then inject it into the pure core.
124
+ const now = Number.isFinite(clockMs(o.now)) ? clockMs(o.now) : Date.now();
125
+ const ropts = { maxAgeDays: o.maxAgeDays, maxRecords: o.maxRecords, now };
126
+ // SYMLINK-SAFE (dir): refuse to descend into anything that is not a REAL directory. lstat does NOT
127
+ // follow the link, so a symlinked log path (e.g. .wrxn/dream → /outside) is a no-op — never swept.
128
+ let dirStat;
129
+ try {
130
+ dirStat = fs.lstatSync(dir);
131
+ } catch {
132
+ return out; // missing / unreadable dir → no-op
133
+ }
134
+ if (!dirStat.isDirectory()) return out; // a symlink (or non-dir) at the log path → do not descend
135
+ let names;
136
+ try {
137
+ names = fs.readdirSync(dir);
138
+ } catch {
139
+ return out; // unreadable dir → no-op
140
+ }
141
+ for (const name of names) {
142
+ if (!name.endsWith('.jsonl')) continue;
143
+ try {
144
+ const file = path.join(dir, name);
145
+ // SYMLINK-SAFE (entry): lstat does NOT follow the link, so we process ONLY a regular file. A
146
+ // planted *.jsonl symlink (or a sub-dir / special file) is skipped — this destructive rewrite can
147
+ // never follow a link out of the log dir to trim a file outside it.
148
+ if (!fs.lstatSync(file).isFile()) continue;
149
+ out.scanned++;
150
+ if (pruneFile(file, ropts)) out.rewritten++;
151
+ } catch {
152
+ /* one bad file never aborts the sweep */
153
+ }
154
+ }
155
+ } catch {
156
+ /* fail-open: retention must never throw into session shutdown */
157
+ }
158
+ return out;
159
+ }
160
+
161
+ // ── WHOLE-FILE GC (S2 / #35) ─────────────────────────────────────────────────────────
162
+ //
163
+ // The event source writes one file PER SESSION, so the FILES accumulate even though retain bounds the
164
+ // records WITHIN each file. pruneFiles GCs whole *.jsonl files; retainFiles is its PURE core (same split
165
+ // as retain/prune: the clock is INJECTED, no Date.now() here).
166
+
167
+ const MAX_FILES = 200; // per-dir whole-file ceiling — the safety net (mirrors MAX_RECORDS) against unbounded per-session files
168
+
169
+ /**
170
+ * PURE: given file descriptors {name, mtimeMs, size}, return the survivors (the files to KEEP).
171
+ * EMPTY-DROP: a size-0 file (e.g. one the within-file prune drained to nothing) is dropped — it carries
172
+ * no records. AGE-DROP: a file whose mtime is older than `now - maxAgeDays` is dropped; a file with NO
173
+ * datable mtime is NEVER aged out (conservative). The clock is INJECTED via `now`; with no clock, age
174
+ * is skipped. COUNT-TRIM: of what remains, keep the newest `maxFiles` by mtime.
175
+ * @param {{name:string, mtimeMs:number, size:number}[]} files
176
+ * @param {{maxAgeDays?:number, maxFiles?:number, now?:number|Date}} [opts]
177
+ * @returns {object[]}
178
+ */
179
+ function retainFiles(files, { maxAgeDays = MAX_AGE_DAYS, maxFiles = MAX_FILES, now } = {}) {
180
+ let kept = (Array.isArray(files) ? files : []).filter((f) => f && typeof f.name === 'string');
181
+ kept = kept.filter((f) => !(Number.isFinite(f.size) && f.size === 0)); // empty file → no records → drop
182
+ const clock = clockMs(now);
183
+ if (Number.isFinite(maxAgeDays) && maxAgeDays > 0 && Number.isFinite(clock)) {
184
+ const cutoff = clock - maxAgeDays * MS_PER_DAY;
185
+ kept = kept.filter((f) => {
186
+ const t = Number(f.mtimeMs);
187
+ return Number.isFinite(t) ? t >= cutoff : true; // undatable mtime → keep
188
+ });
189
+ }
190
+ if (Number.isFinite(maxFiles) && maxFiles >= 0 && kept.length > maxFiles) {
191
+ kept = [...kept].sort((a, b) => Number(b.mtimeMs) - Number(a.mtimeMs)).slice(0, maxFiles); // newest-first, keep N
192
+ }
193
+ return kept;
194
+ }
195
+
196
+ /**
197
+ * IO shell: delete whole stale `*.jsonl` files directly under `dir` (the non-survivors of retainFiles).
198
+ * The clock defaults to Date.now() here, then flows into the pure core. SYMLINK-SAFE exactly like prune:
199
+ * lstat refuses a symlinked dir (no descend) and processes ONLY regular files (a planted *.jsonl symlink
200
+ * is skipped, never deleted/followed). SAFE NO-OP on a missing/empty dir. FAIL-OPEN: never throws — one
201
+ * bad entry never aborts the sweep.
202
+ * @param {string} dir
203
+ * @param {{maxAgeDays?:number, maxFiles?:number, now?:number}} [opts]
204
+ * @returns {{scanned:number, deleted:number}}
205
+ */
206
+ function pruneFiles(dir, opts) {
207
+ const out = { scanned: 0, deleted: 0 };
208
+ try {
209
+ const o = opts && typeof opts === 'object' ? opts : {};
210
+ const now = Number.isFinite(clockMs(o.now)) ? clockMs(o.now) : Date.now();
211
+ let dirStat;
212
+ try {
213
+ dirStat = fs.lstatSync(dir);
214
+ } catch {
215
+ return out; // missing / unreadable dir → no-op
216
+ }
217
+ if (!dirStat.isDirectory()) return out; // a symlink (or non-dir) at the event-dir path → do not descend
218
+ let names;
219
+ try {
220
+ names = fs.readdirSync(dir);
221
+ } catch {
222
+ return out;
223
+ }
224
+ const descriptors = [];
225
+ for (const name of names) {
226
+ if (!name.endsWith('.jsonl')) continue; // never touch the .gitkeep sentinel or anything non-jsonl
227
+ try {
228
+ const st = fs.lstatSync(path.join(dir, name)); // lstat → never follow a symlink
229
+ if (!st.isFile()) continue; // a planted symlink / sub-dir / special file → skip
230
+ out.scanned++;
231
+ descriptors.push({ name, mtimeMs: st.mtimeMs, size: st.size });
232
+ } catch {
233
+ /* one bad entry never aborts the sweep */
234
+ }
235
+ }
236
+ const survivors = new Set(retainFiles(descriptors, { maxAgeDays: o.maxAgeDays, maxFiles: o.maxFiles, now }).map((f) => f.name));
237
+ for (const d of descriptors) {
238
+ if (survivors.has(d.name)) continue;
239
+ try {
240
+ fs.unlinkSync(path.join(dir, d.name));
241
+ out.deleted++;
242
+ } catch {
243
+ /* best-effort: a failed unlink never aborts the sweep */
244
+ }
245
+ }
246
+ } catch {
247
+ /* fail-open: whole-file GC must never throw into session shutdown */
248
+ }
249
+ return out;
250
+ }
251
+
252
+ module.exports = { prune, pruneFiles, retain, retainFiles, MAX_AGE_DAYS, MAX_RECORDS, MAX_FILES, LOG_DIRS };