runspec-node 0.24.0 → 0.25.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.
@@ -22,6 +22,16 @@ const _handlers: Handler[] = [];
22
22
  let _runId: string | null = null;
23
23
 
24
24
  const RUN_SUMMARY_LOGGER = 'runspec.runsummary';
25
+ // Captured-stdout records are logged under this name (mirrors Python's
26
+ // `runspec.print`). Used only for clarity in the audit file.
27
+ const PRINT_LOGGER = 'runspec.print';
28
+
29
+ // The real `process.stdout.write`, captured before we tee it. The stdout
30
+ // handler writes through this so its own output isn't re-captured (mirrors
31
+ // Python capturing the real sys.stdout reference before the tee swap).
32
+ let _rawStdoutWrite: typeof process.stdout.write | null = null;
33
+ let _stdoutBuf = '';
34
+ let _captureInstalled = false;
25
35
  // Uncaught exceptions are emitted on this dedicated logger so the file handler
26
36
  // records them while the console handlers drop them by name — console display is
27
37
  // handled explicitly in _handleUncaught (debug-gated).
@@ -114,6 +124,10 @@ interface LogRecord {
114
124
  error?: Error;
115
125
  extra?: Record<string, unknown>;
116
126
  excStructured?: Record<string, unknown>;
127
+ // Set on records synthesised from captured stdout (print-capture). Console +
128
+ // counter handlers skip these (the real stdout write already happened, and
129
+ // printed lines aren't log "events"); only file handlers persist them.
130
+ fromPrint?: boolean;
117
131
  }
118
132
 
119
133
  interface Handler {
@@ -220,8 +234,11 @@ class StdoutHandler implements Handler {
220
234
  emit(record: LogRecord): void {
221
235
  if (record.levelNum >= 30) return; // WARNING+ belongs on stderr
222
236
  if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
237
+ if (record.fromPrint) return; // the real stdout write already emitted this
223
238
  try {
224
- process.stdout.write(formatConsole(record, this.showTracebacks) + '\n');
239
+ // Write through the *raw* stream so this output isn't re-captured by the
240
+ // print-capture tee (would double it in the audit file).
241
+ (_rawStdoutWrite ?? process.stdout.write.bind(process.stdout))(formatConsole(record, this.showTracebacks) + '\n');
225
242
  } catch {
226
243
  // never disrupt
227
244
  }
@@ -262,8 +279,9 @@ class RunSummaryCounter implements Handler {
262
279
  };
263
280
 
264
281
  emit(record: LogRecord): void {
265
- // Don't count runspec's own bookkeeping records (summary + uncaught-exception).
266
- if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
282
+ // Don't count runspec's own bookkeeping records (summary + uncaught-exception)
283
+ // or captured stdout lines only genuine logger events.
284
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER || record.fromPrint) return;
267
285
  const label = LEVEL_LABEL[record.levelNum];
268
286
  if (label && label in this.counts) {
269
287
  this.counts[label]++;
@@ -617,6 +635,61 @@ export function _handleUncaught(err: Error): void {
617
635
  }
618
636
  }
619
637
 
638
+ // ── stdout capture (print → audit log) ─────────────────────────────────────────
639
+
640
+ /** Emit one captured stdout line as a file-only record (skipped by console + counter). */
641
+ function emitPrintLine(line: string): void {
642
+ if (_handlers.length === 0) return;
643
+ const record: LogRecord = { ts: new Date(), levelNum: 20, loggerName: PRINT_LOGGER, message: redact(line), fromPrint: true };
644
+ for (const h of _handlers) {
645
+ try {
646
+ if (record.levelNum >= h.level) h.emit(record);
647
+ } catch {
648
+ // never disrupt
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Tee `process.stdout.write`: output still reaches the real stream unchanged
655
+ * (so pipes / `runspec serve` capture are untouched), and each complete line is
656
+ * also written to the audit file as a `fromPrint` record. Mirrors Python's
657
+ * `_StdoutTee`, so a runnable's `console.log` output is preserved in the
658
+ * per-invocation log — not just the run summary.
659
+ */
660
+ function installStdoutCapture(): void {
661
+ if (_captureInstalled) return;
662
+ _captureInstalled = true;
663
+ _rawStdoutWrite = process.stdout.write.bind(process.stdout);
664
+ const patched = function (chunk: unknown, encoding?: unknown, cb?: unknown): boolean {
665
+ const result = (_rawStdoutWrite as (...a: unknown[]) => boolean)(chunk, encoding, cb);
666
+ try {
667
+ const s = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? (encoding as BufferEncoding) : 'utf8') : '';
668
+ if (s) {
669
+ _stdoutBuf += s;
670
+ let idx: number;
671
+ while ((idx = _stdoutBuf.indexOf('\n')) !== -1) {
672
+ const line = _stdoutBuf.slice(0, idx);
673
+ _stdoutBuf = _stdoutBuf.slice(idx + 1);
674
+ if (line) emitPrintLine(line);
675
+ }
676
+ }
677
+ } catch {
678
+ // never disrupt
679
+ }
680
+ return result;
681
+ };
682
+ process.stdout.write = patched as typeof process.stdout.write;
683
+ // Flush a trailing partial line at exit. Registered before installExitHooks so
684
+ // it runs before the run-summary record (FIFO exit handlers).
685
+ process.on('exit', () => {
686
+ if (_stdoutBuf) {
687
+ emitPrintLine(_stdoutBuf);
688
+ _stdoutBuf = '';
689
+ }
690
+ });
691
+ }
692
+
620
693
  function installExitHooks(): void {
621
694
  if (_exitHooksInstalled) return;
622
695
  _exitHooksInstalled = true;
@@ -740,6 +813,10 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
740
813
  };
741
814
  }
742
815
 
816
+ // Tee stdout into the audit log so printed output (not just the run summary)
817
+ // is preserved. Installed before the exit hooks so its flush runs first.
818
+ installStdoutCapture();
819
+
743
820
  // Uncaught-exception handling is always installed (independent of the summary
744
821
  // toggle) so the structured exception record reaches the audit file even when
745
822
  // summary is off. The exit hook only flushes a summary when _summaryState is set.
@@ -757,8 +834,15 @@ export function _resetForTest(): void {
757
834
  _loggers.clear();
758
835
  _handlers.length = 0;
759
836
  _summaryState = null;
837
+ // Un-tee stdout so the patch doesn't leak across tests.
838
+ if (_rawStdoutWrite) {
839
+ process.stdout.write = _rawStdoutWrite;
840
+ _rawStdoutWrite = null;
841
+ }
842
+ _stdoutBuf = '';
843
+ _captureInstalled = false;
760
844
  // Note: process event listeners installed by installExitHooks() stay —
761
845
  // they no-op when _summaryState is null, which is the test-time state.
762
846
  }
763
847
 
764
- export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace };
848
+ export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace, findProjectRoot };
package/src/logs.ts ADDED
@@ -0,0 +1,507 @@
1
+ /**
2
+ * runspec logs — view / status / prune / compact per-invocation audit logs.
3
+ *
4
+ * The Node port of the Python `runspec/logs.py` engine. The per-run store
5
+ * (`[config.logging] store = "per-run"`) writes one
6
+ * `{runnable}.{utc-ts}.{run_id}.log` per invocation with no in-process rotation;
7
+ * this is the read + maintenance side. Pure functions (collectRecords,
8
+ * inventory, planPrune, planCompact) are kept separate from I/O so they can be
9
+ * unit-tested without a filesystem fixture.
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import * as os from 'os';
15
+ import * as zlib from 'zlib';
16
+ import { findConfig } from './finder';
17
+ import { findProjectRoot } from './logging_setup';
18
+
19
+ // Filename grammar (under {logs}/), matching the Node + Python write side:
20
+ // {runnable}.log single-mode active file
21
+ // {runnable}.{YYYYMMDDThhmmssZ}.{run_id}.log per-run invocation file
22
+ // {runnable}.archive.{YYYYMMDD}.log[.gz] compacted archive
23
+ const PER_RUN_RE = /\.\d{8}T\d{6}Z\.[0-9a-fA-F-]{8,}\.log$/;
24
+ const ARCHIVE_RE = /\.archive\.\d{8}\.log(?:\.gz)?$/;
25
+
26
+ const DUR_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
27
+ const SIZE_MULT: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
28
+
29
+ // ── parsing helpers ────────────────────────────────────────────────────────────
30
+
31
+ /** '30m' / '24h' / '7d' / '2w' → milliseconds. Throws on a bad value. */
32
+ export function parseDuration(s: string): number {
33
+ const m = /^\s*(\d+)\s*([smhdw])\s*$/i.exec(s);
34
+ if (!m) throw new Error(`invalid duration ${JSON.stringify(s)} — use e.g. 30m, 24h, 7d, 2w`);
35
+ return parseInt(m[1], 10) * DUR_MS[m[2].toLowerCase()];
36
+ }
37
+
38
+ /** '500KB' / '10MB' / '5GB' → bytes. Throws on a bad value. */
39
+ export function parseSize(s: string): number {
40
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?\s*$/i.exec(s);
41
+ if (!m) throw new Error(`invalid size ${JSON.stringify(s)} — use e.g. 500KB, 10MB, 5GB`);
42
+ return Math.round(parseFloat(m[1]) * SIZE_MULT[(m[2] ?? 'B').toUpperCase()]);
43
+ }
44
+
45
+ function iso(mtimeMs: number | null): string | null {
46
+ if (mtimeMs === null) return null;
47
+ return new Date(mtimeMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
48
+ }
49
+
50
+ function humanSize(n: number): string {
51
+ let size = n;
52
+ for (const unit of ['B', 'KB', 'MB', 'GB', 'TB']) {
53
+ if (size < 1024 || unit === 'TB') return unit === 'B' ? `${Math.round(size)} B` : `${size.toFixed(1)} ${unit}`;
54
+ size /= 1024;
55
+ }
56
+ return `${size.toFixed(1)} TB`;
57
+ }
58
+
59
+ // ── discovery ──────────────────────────────────────────────────────────────────
60
+
61
+ /** Existing logs directories, in priority order: the project's logs/, then ~/logs. */
62
+ export function logDirs(): string[] {
63
+ const candidates: string[] = [];
64
+ try {
65
+ const { configPath } = findConfig();
66
+ const root = findProjectRoot(path.dirname(configPath)) ?? path.dirname(configPath);
67
+ candidates.push(path.join(root, 'logs'));
68
+ } catch {
69
+ // no project context (no runspec.toml) — fall back to ~/logs only
70
+ }
71
+ candidates.push(path.join(os.homedir(), 'logs'));
72
+
73
+ const seen = new Set<string>();
74
+ const out: string[] = [];
75
+ for (const d of candidates) {
76
+ const r = path.resolve(d);
77
+ if (seen.has(r)) continue;
78
+ seen.add(r);
79
+ try {
80
+ if (fs.statSync(d).isDirectory()) out.push(d);
81
+ } catch {
82
+ // not present — skip
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ function matchesRunnable(name: string, runnable: string | null): boolean {
89
+ if (!(name.endsWith('.log') || name.endsWith('.log.gz'))) return false;
90
+ if (!runnable) return true;
91
+ return name === `${runnable}.log` || name.startsWith(`${runnable}.`);
92
+ }
93
+
94
+ export function discover(dirs: string[], runnable: string | null): string[] {
95
+ const seen = new Set<string>();
96
+ const files: string[] = [];
97
+ for (const d of dirs) {
98
+ let entries: string[];
99
+ try {
100
+ entries = fs.readdirSync(d);
101
+ } catch {
102
+ continue;
103
+ }
104
+ for (const name of entries) {
105
+ if (!matchesRunnable(name, runnable)) continue;
106
+ const full = path.join(d, name);
107
+ if (seen.has(full)) continue;
108
+ try {
109
+ if (!fs.statSync(full).isFile()) continue;
110
+ } catch {
111
+ continue;
112
+ }
113
+ seen.add(full);
114
+ files.push(full);
115
+ }
116
+ }
117
+ return files;
118
+ }
119
+
120
+ /** True only for per-run files and archives — never a single {runnable}.log. */
121
+ export function isManaged(p: string): boolean {
122
+ const n = path.basename(p);
123
+ return PER_RUN_RE.test(n) || ARCHIVE_RE.test(n);
124
+ }
125
+
126
+ function isArchive(p: string): boolean {
127
+ return ARCHIVE_RE.test(path.basename(p));
128
+ }
129
+
130
+ function runnableOf(p: string): string {
131
+ const n = path.basename(p);
132
+ for (const rx of [PER_RUN_RE, ARCHIVE_RE]) {
133
+ const m = rx.exec(n);
134
+ if (m) return n.slice(0, m.index);
135
+ }
136
+ return n.endsWith('.log.gz') ? n.slice(0, -7) : n.slice(0, -4);
137
+ }
138
+
139
+ function readLines(p: string): string[] {
140
+ let text: string;
141
+ try {
142
+ text = p.endsWith('.gz') ? zlib.gunzipSync(fs.readFileSync(p)).toString('utf-8') : fs.readFileSync(p, 'utf-8');
143
+ } catch {
144
+ return [];
145
+ }
146
+ return text.split('\n').filter((l) => l.trim() !== '');
147
+ }
148
+
149
+ // ── view ────────────────────────────────────────────────────────────────────────
150
+
151
+ export interface RecPair {
152
+ rec: Record<string, any>;
153
+ raw: string;
154
+ }
155
+
156
+ export interface CollectOpts {
157
+ since?: number | null; // milliseconds
158
+ run?: string | null;
159
+ user?: string | null;
160
+ }
161
+
162
+ /** Merge a runnable's files into one timestamp-sorted list of {rec, raw}. Pure. */
163
+ export function collectRecords(dirs: string[], runnable: string, opts: CollectOpts = {}): RecPair[] {
164
+ const raws: string[] = [];
165
+ for (const f of discover(dirs, runnable)) raws.push(...readLines(f));
166
+
167
+ const parsed: RecPair[] = [];
168
+ const userOfRun: Record<string, string> = {};
169
+ for (const raw of raws) {
170
+ let rec: Record<string, any>;
171
+ try {
172
+ rec = JSON.parse(raw);
173
+ } catch {
174
+ continue;
175
+ }
176
+ const extra = rec && typeof rec.extra === 'object' && rec.extra ? rec.extra : {};
177
+ if (extra.event === 'run_summary' && extra.run_id) userOfRun[extra.run_id] = extra.user ?? '';
178
+ parsed.push({ rec, raw });
179
+ }
180
+
181
+ const cutoff = opts.since != null ? Date.now() - opts.since : null;
182
+ const keep = (rec: Record<string, any>): boolean => {
183
+ const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
184
+ const rid = extra.run_id ?? '';
185
+ if (opts.run && rid !== opts.run) return false;
186
+ if (opts.user && (userOfRun[rid] ?? extra.user ?? '') !== opts.user) return false;
187
+ if (cutoff != null) {
188
+ const t = Date.parse(rec.ts ?? '');
189
+ if (Number.isNaN(t) || t < cutoff) return false;
190
+ }
191
+ return true;
192
+ };
193
+
194
+ return parsed.filter((p) => keep(p.rec)).sort((a, b) => String(a.rec.ts ?? '').localeCompare(String(b.rec.ts ?? '')));
195
+ }
196
+
197
+ function formatLine(rec: Record<string, any>, userOfRun: Record<string, string>): string {
198
+ const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
199
+ const rid = extra.run_id ?? '';
200
+ const user = extra.user || userOfRun[rid] || '';
201
+ const ts = rec.ts ?? '';
202
+ const level = String(rec.level ?? '').padEnd(8);
203
+ const msg = rec.message ?? '';
204
+ return `${ts} ${level} run=${(rid || '-').slice(0, 8)} user=${user || '-'} ${msg}`;
205
+ }
206
+
207
+ type Writer = (s: string) => void;
208
+
209
+ function emit(records: RecPair[], asJson: boolean, out: Writer): void {
210
+ const userOfRun: Record<string, string> = {};
211
+ for (const { rec } of records) {
212
+ const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
213
+ if (extra.event === 'run_summary' && extra.run_id) userOfRun[extra.run_id] = extra.user ?? '';
214
+ }
215
+ for (const { rec, raw } of records) {
216
+ out(asJson ? raw + '\n' : formatLine(rec, userOfRun) + '\n');
217
+ }
218
+ }
219
+
220
+ export interface ViewOpts extends CollectOpts {
221
+ dirs?: string[];
222
+ asJson?: boolean;
223
+ follow?: boolean;
224
+ out?: Writer;
225
+ pollMs?: number;
226
+ }
227
+
228
+ /** Write the merged stream to `out` (default stdout). Supports `--follow` (poll). */
229
+ export function view(runnable: string, opts: ViewOpts = {}): void {
230
+ const dirs = opts.dirs ?? logDirs();
231
+ const out = opts.out ?? ((s: string) => process.stdout.write(s));
232
+ const co: CollectOpts = { since: opts.since, run: opts.run, user: opts.user };
233
+ const records = collectRecords(dirs, runnable, co);
234
+ emit(records, !!opts.asJson, out);
235
+
236
+ if (!opts.follow) return;
237
+ const key = (r: Record<string, any>) => `${r.ts ?? ''}|${(r.extra && r.extra.run_id) ?? ''}|${r.message ?? ''}`;
238
+ const seen = new Set(records.map((p) => key(p.rec)));
239
+ const interval = setInterval(() => {
240
+ const fresh = collectRecords(dirs, runnable, co).filter((p) => !seen.has(key(p.rec)));
241
+ if (fresh.length) {
242
+ emit(fresh, !!opts.asJson, out);
243
+ for (const p of fresh) seen.add(key(p.rec));
244
+ }
245
+ }, opts.pollMs ?? 1000);
246
+ // Don't keep the event loop alive solely for the poll if the process is done.
247
+ if (typeof interval.unref === 'function') interval.unref();
248
+ }
249
+
250
+ // ── status ───────────────────────────────────────────────────────────────────────
251
+
252
+ export interface InvRow {
253
+ runnable: string;
254
+ per_run_files: number;
255
+ archives: number;
256
+ total_bytes: number;
257
+ oldest: number | null; // mtime epoch ms
258
+ newest: number | null;
259
+ }
260
+
261
+ /** Per-runnable inventory of managed files. Pure (stat only). */
262
+ export function inventory(dirs: string[], runnable: string | null = null): Record<string, InvRow> {
263
+ const per: Record<string, InvRow> = {};
264
+ for (const f of discover(dirs, runnable)) {
265
+ if (!isManaged(f)) continue;
266
+ let st: fs.Stats;
267
+ try {
268
+ st = fs.statSync(f);
269
+ } catch {
270
+ continue;
271
+ }
272
+ const name = runnableOf(f);
273
+ const row = (per[name] ??= { runnable: name, per_run_files: 0, archives: 0, total_bytes: 0, oldest: null, newest: null });
274
+ if (isArchive(f)) row.archives++;
275
+ else row.per_run_files++;
276
+ row.total_bytes += st.size;
277
+ if (row.oldest === null || st.mtimeMs < row.oldest) row.oldest = st.mtimeMs;
278
+ if (row.newest === null || st.mtimeMs > row.newest) row.newest = st.mtimeMs;
279
+ }
280
+ return per;
281
+ }
282
+
283
+ export interface StatusOpts {
284
+ dirs?: string[];
285
+ asJson?: boolean;
286
+ out?: Writer;
287
+ }
288
+
289
+ export function status(runnable: string | null = null, opts: StatusOpts = {}): void {
290
+ const dirs = opts.dirs ?? logDirs();
291
+ const out = opts.out ?? ((s: string) => process.stdout.write(s));
292
+ const rows = Object.values(inventory(dirs, runnable)).sort((a, b) => a.runnable.localeCompare(b.runnable));
293
+ const totalBytes = rows.reduce((s, r) => s + r.total_bytes, 0);
294
+ const totalFiles = rows.reduce((s, r) => s + r.per_run_files + r.archives, 0);
295
+
296
+ if (opts.asJson) {
297
+ out(
298
+ JSON.stringify({
299
+ dirs,
300
+ runnables: rows.map((r) => ({
301
+ runnable: r.runnable,
302
+ per_run_files: r.per_run_files,
303
+ archives: r.archives,
304
+ total_bytes: r.total_bytes,
305
+ oldest: iso(r.oldest),
306
+ newest: iso(r.newest),
307
+ })),
308
+ total_bytes: totalBytes,
309
+ total_files: totalFiles,
310
+ }) + '\n',
311
+ );
312
+ return;
313
+ }
314
+
315
+ if (rows.length === 0) {
316
+ out('No per-invocation logs found.\n');
317
+ return;
318
+ }
319
+ const nameW = Math.max('RUNNABLE'.length, ...rows.map((r) => r.runnable.length));
320
+ out(`${'RUNNABLE'.padEnd(nameW)} ${'RUNS'.padStart(6)} ${'ARCHIVES'.padStart(8)} ${'SIZE'.padStart(10)} NEWEST\n`);
321
+ for (const r of rows) {
322
+ out(`${r.runnable.padEnd(nameW)} ${String(r.per_run_files).padStart(6)} ${String(r.archives).padStart(8)} ${humanSize(r.total_bytes).padStart(10)} ${iso(r.newest) ?? '-'}\n`);
323
+ }
324
+ out(`\n${totalFiles} file(s), ${humanSize(totalBytes)} total across ${rows.length} runnable(s).\n`);
325
+ }
326
+
327
+ // ── prune ──────────────────────────────────────────────────────────────────────
328
+
329
+ export interface PrunePolicy {
330
+ olderThan?: number | null; // ms
331
+ maxFiles?: number | null;
332
+ maxTotalSize?: number | null; // bytes
333
+ }
334
+
335
+ /** Return the managed files to delete. Pure — touches nothing. Throws if no policy. */
336
+ export function planPrune(dirs: string[], runnable: string | null, policy: PrunePolicy, now: number = Date.now()): string[] {
337
+ const { olderThan = null, maxFiles = null, maxTotalSize = null } = policy;
338
+ if (olderThan == null && maxFiles == null && maxTotalSize == null) {
339
+ throw new Error('prune needs at least one of --older-than / --max-files / --max-total-size');
340
+ }
341
+ const files = discover(dirs, runnable).filter(isManaged);
342
+ const stat = new Map<string, fs.Stats>();
343
+ for (const f of files) {
344
+ try {
345
+ stat.set(f, fs.statSync(f));
346
+ } catch {
347
+ // skip vanished file
348
+ }
349
+ }
350
+ const live = files.filter((f) => stat.has(f));
351
+ const doomed = new Set<string>();
352
+
353
+ if (olderThan != null) {
354
+ const cut = now - olderThan;
355
+ for (const f of live) if (stat.get(f)!.mtimeMs < cut) doomed.add(f);
356
+ }
357
+
358
+ if (maxFiles != null) {
359
+ const per: Record<string, string[]> = {};
360
+ for (const f of live) (per[runnableOf(f)] ??= []).push(f);
361
+ for (const group of Object.values(per)) {
362
+ group.sort((a, b) => stat.get(b)!.mtimeMs - stat.get(a)!.mtimeMs); // newest first
363
+ for (const f of group.slice(maxFiles)) doomed.add(f);
364
+ }
365
+ }
366
+
367
+ if (maxTotalSize != null) {
368
+ const ordered = [...live].sort((a, b) => stat.get(b)!.mtimeMs - stat.get(a)!.mtimeMs);
369
+ let total = 0;
370
+ for (const f of ordered) {
371
+ total += stat.get(f)!.size;
372
+ if (total > maxTotalSize) doomed.add(f);
373
+ }
374
+ }
375
+
376
+ return [...doomed].sort((a, b) => stat.get(a)!.mtimeMs - stat.get(b)!.mtimeMs);
377
+ }
378
+
379
+ export interface PruneOpts extends PrunePolicy {
380
+ dirs?: string[];
381
+ dryRun?: boolean;
382
+ asJson?: boolean;
383
+ out?: Writer;
384
+ }
385
+
386
+ export function prune(runnable: string | null, opts: PruneOpts = {}): { count: number; freed: number } {
387
+ const dirs = opts.dirs ?? logDirs();
388
+ const out = opts.out ?? ((s: string) => process.stdout.write(s));
389
+ const targets = planPrune(dirs, runnable, opts);
390
+ let freed = 0;
391
+ const deleted: Array<{ path: string; bytes: number }> = [];
392
+ for (const f of targets) {
393
+ let size = 0;
394
+ try {
395
+ size = fs.statSync(f).size;
396
+ } catch {
397
+ // already gone
398
+ }
399
+ if (!opts.dryRun) {
400
+ try {
401
+ fs.unlinkSync(f);
402
+ } catch {
403
+ // ignore
404
+ }
405
+ }
406
+ freed += size;
407
+ deleted.push({ path: f, bytes: size });
408
+ if (!opts.asJson) out(`${opts.dryRun ? 'would delete' : 'deleted'} ${f} (${size} bytes)\n`);
409
+ }
410
+ if (opts.asJson) {
411
+ out(JSON.stringify({ dry_run: !!opts.dryRun, count: targets.length, freed_bytes: freed, deleted }) + '\n');
412
+ } else {
413
+ out(`${opts.dryRun ? 'Would free' : 'Freed'} ${freed} bytes across ${targets.length} file(s).\n`);
414
+ }
415
+ return { count: targets.length, freed };
416
+ }
417
+
418
+ // ── compact ──────────────────────────────────────────────────────────────────────
419
+
420
+ function lineTs(line: string): string {
421
+ try {
422
+ return String(JSON.parse(line).ts ?? '');
423
+ } catch {
424
+ return '';
425
+ }
426
+ }
427
+
428
+ /** Map each per-runnable archive target → the per-run files to fold in. Pure. */
429
+ export function planCompact(dirs: string[], runnable: string | null, olderThan: number, now: number = Date.now()): Map<string, string[]> {
430
+ const cut = now - olderThan;
431
+ const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
432
+ const plan = new Map<string, string[]>();
433
+ for (const f of discover(dirs, runnable)) {
434
+ if (!isManaged(f) || isArchive(f)) continue;
435
+ let st: fs.Stats;
436
+ try {
437
+ st = fs.statSync(f);
438
+ } catch {
439
+ continue;
440
+ }
441
+ if (st.mtimeMs >= cut) continue;
442
+ const archive = path.join(path.dirname(f), `${runnableOf(f)}.archive.${today}.log`);
443
+ (plan.get(archive) ?? plan.set(archive, []).get(archive)!).push(f);
444
+ }
445
+ return plan;
446
+ }
447
+
448
+ export interface CompactOpts {
449
+ dirs?: string[];
450
+ gzip?: boolean;
451
+ dryRun?: boolean;
452
+ asJson?: boolean;
453
+ out?: Writer;
454
+ }
455
+
456
+ export function compact(runnable: string | null, olderThan: number, opts: CompactOpts = {}): number {
457
+ const dirs = opts.dirs ?? logDirs();
458
+ const out = opts.out ?? ((s: string) => process.stdout.write(s));
459
+ const plan = planCompact(dirs, runnable, olderThan);
460
+ let compacted = 0;
461
+ const archives: Array<{ archive: string; count: number; sources: string[] }> = [];
462
+
463
+ for (const [archive, sources] of plan) {
464
+ const target = opts.gzip ? archive + '.gz' : archive;
465
+ if (opts.dryRun) {
466
+ archives.push({ archive: target, count: sources.length, sources });
467
+ compacted += sources.length;
468
+ if (!opts.asJson) out(`would compact ${sources.length} file(s) → ${target}\n`);
469
+ continue;
470
+ }
471
+
472
+ const lines: string[] = [];
473
+ for (const existing of [archive, archive + '.gz']) {
474
+ if (fs.existsSync(existing)) lines.push(...readLines(existing));
475
+ }
476
+ for (const s of sources) lines.push(...readLines(s));
477
+ lines.sort((a, b) => lineTs(a).localeCompare(lineTs(b)));
478
+
479
+ const body = lines.join('\n') + '\n';
480
+ if (opts.gzip) {
481
+ fs.writeFileSync(target, zlib.gzipSync(Buffer.from(body, 'utf-8')));
482
+ if (fs.existsSync(archive) && archive !== target) {
483
+ try {
484
+ fs.unlinkSync(archive);
485
+ } catch {
486
+ // ignore
487
+ }
488
+ }
489
+ } else {
490
+ fs.writeFileSync(target, body, 'utf-8');
491
+ }
492
+ for (const s of sources) {
493
+ try {
494
+ fs.unlinkSync(s);
495
+ } catch {
496
+ // ignore
497
+ }
498
+ }
499
+ archives.push({ archive: target, count: sources.length, sources });
500
+ compacted += sources.length;
501
+ if (!opts.asJson) out(`compacted ${sources.length} file(s) → ${target}\n`);
502
+ }
503
+
504
+ if (opts.asJson) out(JSON.stringify({ dry_run: !!opts.dryRun, compacted, archives }) + '\n');
505
+ else if (plan.size === 0) out('nothing to compact.\n');
506
+ return compacted;
507
+ }