mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D52 — daily audit rotation (lib/audit.mjs + bin/olp-audit-rotate.mjs) (#29)
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to lib/audit.mjs
per ADR 0008 § 5 + ships an external cron tool. Rotation is
SYNCHRONOUS at v0.3.0 — synchronous design eliminates the race that
an async wrapper would create between date-change-detection and the
append.
lib/audit.mjs EXTENSIONS:
- New _maybeRotateAudit({ olpHome, logEvent }) (sync): probes live
audit.ndjson; if it holds events from past UTC date, renames it
to audit-YYYY-MM-DD.ndjson. Idempotent. If target file exists
(cron beat in-server check), logs warn + skips per § 5.3.
- appendAuditEvent extended: cheap fast-path date check via module-
cached _lastSeenUtcDate. On date change, calls _maybeRotateAudit
synchronously BEFORE appendFileSync — so old-date events land in
the rotated file and new-date events land in the fresh live file.
No event straddles the boundary.
- Why SYNCHRONOUS: an async wrapper would let the sync
appendFileSync race the not-yet-completed renameSync, landing
today's event in the about-to-be-renamed file. Sync rotation is
the only correct ordering at the append-fired-from-many-routes
scale OLP runs. (Test 26b-1 caught this during local run; the
initial async-wrapper implementation failed because the live
file at assertion time didn't exist.)
- New exports: _maybeRotateAudit (sync), getAuditRotateCount,
getAuditRotateFailCount, __resetAuditRotateState,
__setLastSeenUtcDateForTesting.
- First-event-date discovery: when probing the live file's date,
reads only the first ndjson line + parses its ts. Falls back to
file mtime if events absent (corrupt/empty edge).
bin/olp-audit-rotate.mjs (~95 lines): external cron tool per § 5.2.
Calls _maybeRotateAudit once + reports outcome. Exit codes 0
(success or no-op), 1 (bad usage), 2 (rotation failed). Installed
via package.json bin so `npx olp-audit-rotate [--olp-home=<path>]`
works. Example cron line in file header.
CONCURRENT-SAFETY (§ 5.3):
In-process sequential appends after the first date-change detection
short-circuit via the updated _lastSeenUtcDate cache → exactly 1
rename even under N sequential appends. Cross-process (cron + server)
coexistence handled by the "target already exists → skip + warn"
branch.
TESTS — Suite 26, +12 (588 → 600):
26a-1..5: _maybeRotateAudit (no live file / today already /
yesterday→rotate / idempotent re-call / cron-race target-exists
warn)
26b-1: appendAuditEvent past UTC date change triggers sync rotation
+ append lands in fresh live file
26c-1: 10 sequential appendAuditEvent across date change → exactly
1 rotation + all 10 events in new live file
26d-1..4: bin/olp-audit-rotate.mjs CLI (--help / no-live-file /
yesterday-file-rotates / unknown-flag exit 1)
26e-1: rotated files queryable via lib/audit-query.mjs
discoverAuditFiles + readAuditWindow cross-file read
package.json: bin.olp-audit-rotate + scripts.olp-audit-rotate entries
added.
DOCUMENTATION:
- AGENTS.md: lib/audit.mjs marker promoted ✅ (D45 append + D52
rotation both shipped); new bin/olp-audit-rotate.mjs entry.
- CHANGELOG.md: D52 entry under Unreleased per release_kit overlay.
NOT IN D52:
- tried_providers schema fix (D53; D45 P2 deferral)
- E2E + docs polish (D54)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 588 → 600 (+12). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger).
- ADR 0008 § 5.2 (external cron alternative).
- ADR 0008 § 5.3 (concurrent-rotation safety + cron-coexistence).
- ADR 0008 § 5.4 (renamed-file query path consumed by D49 lib/
audit-query.mjs).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: extends lib/audit.mjs (a Phase 2 internal
module) + adds new bin/ CLI + small package.json bin/scripts entries.
No provider plugin / entry surface / IR change.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+174
-2
@@ -23,15 +23,23 @@
|
||||
* message content. Hash + shape only.
|
||||
*/
|
||||
|
||||
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||
import { appendFileSync, mkdirSync, chmodSync, renameSync, existsSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||
const OLP_HOME_ENV = 'OLP_HOME';
|
||||
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
||||
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
||||
const ROTATED_FILE_PREFIX = 'audit-';
|
||||
const ROTATED_FILE_SUFFIX = '.ndjson';
|
||||
|
||||
let _dropCounter = 0;
|
||||
let _rotateCounter = 0; // observability + test assertion target
|
||||
let _rotateFailCounter = 0;
|
||||
// Module-cached "last UTC date we saw at append time" so we don't read
|
||||
// disk metadata on every append just to check for rotation.
|
||||
let _lastSeenUtcDate = _utcDateNow();
|
||||
|
||||
/**
|
||||
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
||||
@@ -44,6 +52,120 @@ function _resolveOlpHome(opts) {
|
||||
return DEFAULT_OLP_HOME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current UTC date as YYYY-MM-DD. Module-private; reused by rotation logic.
|
||||
*/
|
||||
function _utcDateNow() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first event's `ts` from the live audit file to discover the
|
||||
* date it was opened on (for stale-cache recovery when the process is
|
||||
* restarted and the in-memory `_lastSeenUtcDate` doesn't match). Returns
|
||||
* the YYYY-MM-DD string, or null if the file is missing / empty /
|
||||
* malformed.
|
||||
*/
|
||||
function _firstEventDateInLiveFile(livePath) {
|
||||
try {
|
||||
if (!existsSync(livePath)) return null;
|
||||
// Read the file + slice off the first ndjson line. For audit ndjson the
|
||||
// first line is at most a few hundred bytes; this is fine for the rare
|
||||
// "process restart with a stale live file" recovery path. (Family-scale
|
||||
// single-file audit is bounded; multi-MB scans are not a real concern
|
||||
// until Phase 4+ when Option 3 SQLite migration would kick in anyway.)
|
||||
const raw = readFileSync(livePath, 'utf-8');
|
||||
const nl = raw.indexOf('\n');
|
||||
const firstLine = nl === -1 ? raw : raw.slice(0, nl);
|
||||
if (!firstLine) return null;
|
||||
const obj = JSON.parse(firstLine);
|
||||
if (typeof obj?.ts !== 'string') return null;
|
||||
return obj.ts.slice(0, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the live audit file (if any) to its UTC-date suffix. Idempotent:
|
||||
* if a rotated file with that name already exists (e.g., cron beat us to
|
||||
* it), this skips the rename.
|
||||
*
|
||||
* Per ADR 0008 § 5.1 + § 5.3. SYNCHRONOUS so callers (appendAuditEvent +
|
||||
* external cron) can rely on it completing before the next IO operation.
|
||||
* Sync rotation eliminates the race that an async wrapper would create
|
||||
* between the date-change-detection and the append (where the append could
|
||||
* land in the old un-rotated file).
|
||||
*
|
||||
* Concurrent in-process invocations: Node's single-threaded event loop
|
||||
* serializes synchronous calls within a tick. Cross-tick concurrency
|
||||
* uses the `_lastSeenUtcDate` cache as the gate — once set, subsequent
|
||||
* appends short-circuit the rotation check.
|
||||
*
|
||||
* @param {object} args - { olpHome, logEvent, _nowFn (test injection) }
|
||||
* @returns {{ rotated: boolean, fromPath?: string, toPath?: string, dateUsed?: string }}
|
||||
*/
|
||||
export function _maybeRotateAudit(args = {}) {
|
||||
const olpHome = _resolveOlpHome(args);
|
||||
const logEvent = args.logEvent ?? ((level, ev, data) => {
|
||||
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||
});
|
||||
const nowFn = args._nowFn ?? (() => new Date());
|
||||
|
||||
const logsDir = join(olpHome, 'logs');
|
||||
const livePath = join(logsDir, LIVE_AUDIT_FILE);
|
||||
if (!existsSync(livePath)) {
|
||||
// No live file yet (first append ever); no rotation needed.
|
||||
_lastSeenUtcDate = nowFn().toISOString().slice(0, 10);
|
||||
return { rotated: false };
|
||||
}
|
||||
// Determine the "date the live file holds" — use the first event's ts.
|
||||
// Fall back to file mtime if events absent (corrupt / empty file edge).
|
||||
let fileDate = _firstEventDateInLiveFile(livePath);
|
||||
if (fileDate === null) {
|
||||
try {
|
||||
fileDate = statSync(livePath).mtime.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return { rotated: false };
|
||||
}
|
||||
}
|
||||
const today = nowFn().toISOString().slice(0, 10);
|
||||
if (fileDate === today) {
|
||||
_lastSeenUtcDate = today;
|
||||
return { rotated: false };
|
||||
}
|
||||
|
||||
// Rotate: rename live → audit-<fileDate>.ndjson.
|
||||
const rotatedName = `${ROTATED_FILE_PREFIX}${fileDate}${ROTATED_FILE_SUFFIX}`;
|
||||
const rotatedPath = join(logsDir, rotatedName);
|
||||
if (existsSync(rotatedPath)) {
|
||||
// Cron or another writer beat us; the live file holding fileDate's
|
||||
// events must be merged manually. Per ADR 0008 § 5.3 we log + skip.
|
||||
logEvent('warn', 'audit_rotate_target_exists', {
|
||||
livePath, rotatedPath,
|
||||
message: 'rotation target exists — concurrent rotator beat in-process check; manual merge required if events overlap',
|
||||
});
|
||||
_lastSeenUtcDate = today;
|
||||
return { rotated: false };
|
||||
}
|
||||
try {
|
||||
renameSync(livePath, rotatedPath);
|
||||
_rotateCounter++;
|
||||
_lastSeenUtcDate = today;
|
||||
logEvent('info', 'audit_rotated', { fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate });
|
||||
return { rotated: true, fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate };
|
||||
} catch (err) {
|
||||
_rotateFailCounter++;
|
||||
logEvent('warn', 'audit_rotate_failed', {
|
||||
livePath, rotatedPath,
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
// Don't update _lastSeenUtcDate so the next append retries.
|
||||
return { rotated: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
||||
*
|
||||
@@ -60,13 +182,28 @@ function _resolveOlpHome(opts) {
|
||||
export function appendAuditEvent(event, opts = {}) {
|
||||
const olpHome = _resolveOlpHome(opts);
|
||||
const logsDir = join(olpHome, 'logs');
|
||||
const path = join(logsDir, 'audit.ndjson');
|
||||
const path = join(logsDir, LIVE_AUDIT_FILE);
|
||||
const line = JSON.stringify(event) + '\n';
|
||||
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
||||
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||
});
|
||||
|
||||
// D52: cheap fast-path date check. If the module-cached date matches the
|
||||
// current UTC date, skip the rotation probe entirely (no disk I/O). If
|
||||
// the date has changed, synchronously rotate BEFORE the append so the
|
||||
// append lands in the (post-rotation) new live file rather than the
|
||||
// about-to-rotate-away old one.
|
||||
// Per ADR 0008 § 5.1: rotation fires "on the first append after a UTC
|
||||
// date change." Synchronous rotation ensures no append straddles the
|
||||
// boundary — old-date events land in the rotated file; new-date events
|
||||
// land in the fresh live file. The cache flip happens INSIDE
|
||||
// _maybeRotateAudit so concurrent in-process re-triggers short-circuit.
|
||||
const today = _utcDateNow();
|
||||
if (today !== _lastSeenUtcDate) {
|
||||
_maybeRotateAudit({ olpHome, logEvent });
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||
@@ -108,3 +245,38 @@ export function getAuditDropCount() {
|
||||
export function __resetAuditDropCount() {
|
||||
_dropCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-process count of successful rotations performed. Test + future
|
||||
* observability surface.
|
||||
*/
|
||||
export function getAuditRotateCount() {
|
||||
return _rotateCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-process count of failed rotation attempts (rename threw, target
|
||||
* existed, etc.). Test + future observability surface.
|
||||
*/
|
||||
export function getAuditRotateFailCount() {
|
||||
return _rotateFailCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: reset the rotation counters + the in-process "last seen UTC
|
||||
* date" cache so each test exercises a fresh code path.
|
||||
*/
|
||||
export function __resetAuditRotateState() {
|
||||
_rotateCounter = 0;
|
||||
_rotateFailCounter = 0;
|
||||
_lastSeenUtcDate = _utcDateNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: force the cached "last seen UTC date" to a specific value
|
||||
* so the next appendAuditEvent observes a date change and triggers
|
||||
* rotation deterministically.
|
||||
*/
|
||||
export function __setLastSeenUtcDateForTesting(dateStr) {
|
||||
_lastSeenUtcDate = dateStr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user