From aefe9ef5b474d9975ab2218d06e114c6b5f7b2b5 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 10:54:50 +0200 Subject: [PATCH] feat(ms-ai-architect): add lib/log-rotate for bounded log disk use [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation lib for v1.12.0 cron rewrite — closes brief deliverable "log-rotate" that was missing from the original plan (Phase 9 scope revision). Standard logrotate idiom, zero dependencies. - rotateLog(logPath, opts) returns {rotated, dropped, kept} - Defaults: maxSizeBytes 10 MB, maxGenerations 5 (1 active + 4 rotated) - No-op when log missing or under threshold - Over-size: drop oldest, shift .N..1 down by one, move active → .1 - maxGenerations=1 keeps only the active slot (no rotated copies) - Pure stdlib fs.renameSync chain with silent try/catch on missing gens 8/8 tests pass: missing/under-size/over-size paths, chained 6 rotations capped at maxGenerations, oldest dropped, two-step content shift. Co-Authored-By: Claude Opus 4.7 --- .../scripts/kb-update/lib/log-rotate.mjs | 94 +++++++++++++ .../tests/kb-update/test-log-rotate.test.mjs | 127 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs create mode 100644 plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs diff --git a/plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs b/plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs new file mode 100644 index 0000000..9a9eb20 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/lib/log-rotate.mjs @@ -0,0 +1,94 @@ +// log-rotate.mjs — Bounded-size log rotation for the cron-runner. +// Zero dependencies. Standard Linux logrotate idiom: when logPath exceeds +// maxSizeBytes, shift generations N..1 down by one, drop the oldest, and +// move logPath → logPath.1. The next caller writes to a fresh logPath. + +import { existsSync, statSync, renameSync, unlinkSync } from 'node:fs'; + +const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB +const DEFAULT_MAX_GENERATIONS = 5; + +function silentRename(from, to) { + try { + renameSync(from, to); + return true; + } catch { + return false; + } +} + +function silentUnlink(path) { + try { + unlinkSync(path); + return true; + } catch { + return false; + } +} + +/** + * Rotate logPath if it exceeds maxSizeBytes. Keeps at most maxGenerations + * total files (1 active + maxGenerations-1 rotated copies). No-op if logPath + * is missing or under threshold. + * + * @param {string} logPath + * @param {object} [opts] + * @param {number} [opts.maxSizeBytes] — default 10 MB + * @param {number} [opts.maxGenerations] — default 5; total active+rotated + * @returns {{rotated: boolean, dropped: string|null, kept: string[]}} + */ +export function rotateLog(logPath, opts = {}) { + const result = { rotated: false, dropped: null, kept: [] }; + if (!logPath || typeof logPath !== 'string') { + throw new Error('rotateLog: logPath is required'); + } + const maxSizeBytes = opts.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES; + const maxGenerations = Math.max(1, opts.maxGenerations ?? DEFAULT_MAX_GENERATIONS); + + if (!existsSync(logPath)) return result; + + let size; + try { + size = statSync(logPath).size; + } catch { + return result; + } + if (size <= maxSizeBytes) return result; + + // The highest rotated generation we keep is maxGenerations - 1. + // (Active log = generation 0; rotated copies = generations 1..N-1.) + const lastGen = maxGenerations - 1; + + if (lastGen === 0) { + // Only the active log is kept — rotation = drop the previous active. + silentUnlink(logPath); + result.rotated = true; + result.dropped = logPath; + return result; + } + + // Drop the oldest generation if it exists. + const oldest = `${logPath}.${lastGen}`; + if (existsSync(oldest)) { + silentUnlink(oldest); + result.dropped = oldest; + } + + // Shift down: .N-1 → .N, .N-2 → .N-1, ..., .1 → .2. + for (let i = lastGen - 1; i >= 1; i--) { + const from = `${logPath}.${i}`; + const to = `${logPath}.${i + 1}`; + if (existsSync(from)) { + silentRename(from, to); + result.kept.push(to); + } + } + + // Active log → .1. + if (silentRename(logPath, `${logPath}.1`)) { + result.kept.unshift(`${logPath}.1`); + } + + result.rotated = true; + return result; +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs new file mode 100644 index 0000000..eec914d --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-log-rotate.test.mjs @@ -0,0 +1,127 @@ +// tests/kb-update/test-log-rotate.test.mjs +// Unit tests for scripts/kb-update/lib/log-rotate.mjs + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, + statSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rotateLog } from '../../scripts/kb-update/lib/log-rotate.mjs'; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'lr-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('rotateLog — missing log is a no-op', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + const result = rotateLog(log, { maxSizeBytes: 100 }); + assert.equal(result.rotated, false); + assert.equal(existsSync(log), false); + }); +}); + +test('rotateLog — under-size log is not rotated', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + writeFileSync(log, 'tiny', 'utf8'); + const before = statSync(log).size; + const result = rotateLog(log, { maxSizeBytes: 1024 }); + assert.equal(result.rotated, false); + assert.equal(statSync(log).size, before); + assert.equal(existsSync(`${log}.1`), false); + }); +}); + +test('rotateLog — over-size log moves to .1 with original content', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + const content = 'x'.repeat(2048); + writeFileSync(log, content, 'utf8'); + const result = rotateLog(log, { maxSizeBytes: 1024 }); + assert.equal(result.rotated, true); + assert.equal(existsSync(log), false); + assert.equal(existsSync(`${log}.1`), true); + assert.equal(readFileSync(`${log}.1`, 'utf8'), content); + }); +}); + +test('rotateLog — chained rotation across 6 calls keeps at most maxGenerations files', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + const max = 5; // 1 active + 4 rotated; lastGen = 4 + for (let i = 0; i < 6; i++) { + writeFileSync(log, `gen-${i}-${'x'.repeat(2048)}`, 'utf8'); + rotateLog(log, { maxSizeBytes: 1024, maxGenerations: max }); + } + // After 6 over-size rotations, keep .1..(max-1) = .1..4 from the latest + // chain. Active log was just rotated → no active log on disk. + assert.equal(existsSync(log), false); + for (let i = 1; i <= max - 1; i++) { + assert.equal(existsSync(`${log}.${i}`), true, `expected ${log}.${i}`); + } + assert.equal(existsSync(`${log}.${max}`), false); + assert.equal(existsSync(`${log}.${max + 1}`), false); + }); +}); + +test('rotateLog — existing oldest generation is dropped on next rotation', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + // Pre-seed an oldest generation so we can prove it gets dropped. + writeFileSync(`${log}.4`, 'oldest-content', 'utf8'); + writeFileSync(log, 'x'.repeat(2048), 'utf8'); + const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 5 }); + assert.equal(result.rotated, true); + // .4 dropped; new .1 created from prior active; nothing at .4 since + // there were no .3/.2 to shift up. + assert.equal(result.dropped, `${log}.4`); + assert.equal(existsSync(`${log}.4`), false); + assert.equal(existsSync(`${log}.1`), true); + }); +}); + +test('rotateLog — maxGenerations=1 keeps only the active slot (no .1)', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + writeFileSync(log, 'x'.repeat(2048), 'utf8'); + const result = rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 1 }); + assert.equal(result.rotated, true); + assert.equal(existsSync(log), false); + assert.equal(existsSync(`${log}.1`), false); + assert.equal(result.dropped, log); + }); +}); + +test('rotateLog — preserves rotated content across two-step shift', () => { + withTmp((dir) => { + const log = join(dir, 'kb.log'); + // First rotation + writeFileSync(log, `first-${'x'.repeat(2048)}`, 'utf8'); + rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 }); + assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('first-'), true); + + // Second rotation: prior .1 should shift to .2; new .1 from second active. + writeFileSync(log, `second-${'x'.repeat(2048)}`, 'utf8'); + rotateLog(log, { maxSizeBytes: 1024, maxGenerations: 3 }); + assert.equal(readFileSync(`${log}.1`, 'utf8').startsWith('second-'), true); + assert.equal(readFileSync(`${log}.2`, 'utf8').startsWith('first-'), true); + }); +}); + +test('rotateLog — empty logPath rejected', () => { + assert.throws(() => rotateLog(''), /logPath is required/); + assert.throws(() => rotateLog(undefined), /logPath is required/); +});