/** * SMI-5615: Per-surface serialized JSONL writer with daily/size rollover and * retention sweep. * * Write-serialization invariant (F2, from * docs/internal/implementation/production-error-logging.md — the plan's * original P-5 draft omitted this; it was the Opus `concurrency-auditor` * plan-audit's most likely real-world race): * * A redacted error record (stack ≤20 frames) can exceed `PIPE_BUF` * (4096 bytes), so concurrent, independent `appendFile`/`appendFileSync` * calls from parallel async tool invocations are NOT safe here — two * large concurrent writes can interleave/tear into one invalid JSONL line. * * Fix: one long-lived `fs.createWriteStream(path, { flags: 'a' })` per * surface, created lazily on first write and cached in a module-scoped * map. ALL writes for a given surface go through THIS single stream. * * That alone (Node serializing writes queued on one stream) would already * prevent tearing between two writes to the SAME stream object. This * module goes one step further for simplicity and an even stronger * guarantee: every write is wrapped in an async mutex (`runExclusive`) keyed * by surface, so at most one write is ever in flight to a surface's * underlying file descriptor at a time — including the write that decides * whether a daily/size rollover is needed. That decision-and-possible-swap * (open a new stream, `end()` the old one) happens INSIDE the same * exclusive section as the write that triggered it, so a rotation can * never race a write: no write can land on a stream that has already been * told to `end()`, and no two writers can simultaneously decide "today's * file doesn't exist yet" and both create a stream. * * Daily rollover: `skillsmith--.jsonl`, keyed off the * calendar date at write time (mocked by `vi.setSystemTime` in tests — no * separate clock seam needed). * * Size cap: ~10MB per file. When the currently-open file for TODAY would * exceed the cap, roll to a `.1`/`.2`/... suffixed continuation file for the * same day (`skillsmith--.jsonl.`), through the same * serialization point as everything else. * * Retention: on module init, asynchronously (NOT awaited — never blocks * import) sweep `~/.skillsmith/logs/` and delete files whose mtime is older * than 14 days. `pruneExpiredLogs` is also exported directly so tests can * await a deterministic run against a temp directory instead of racing the * fire-and-forget module-init sweep. */ import type { Surface } from './types.js'; /** * Serializes `line` for `surface`: acquires that surface's exclusive queue, * resolves (opening/rotating as needed) the current stream, and writes. * Resolves once the OS-level write has completed; rejects (never throws * synchronously) if the directory/file couldn't be created or the write * failed — callers (see `logger.ts`) must handle rejection to honor the * "logger never throws" invariant. */ export declare function writeLogLine(surface: Surface, line: string): Promise; /** * Deletes files under the log directory whose mtime is older than * `RETENTION_DAYS`. Best-effort: a missing directory or a per-file stat/ * unlink failure (permissions, a concurrent process already deleted it, * etc.) is swallowed rather than thrown — this must never be able to crash * module init or take down a caller that awaits it in a test. * * Exported (not just fired at module init) so tests can await a * deterministic run against a temp directory rather than racing the * fire-and-forget call below. */ export declare function pruneExpiredLogs(): Promise; /** * Closes any open streams and clears all in-memory state. Test-only — used * by `rotation.test.ts`/`logger.test.ts` between cases so state from one * temp-dir scenario never leaks into the next. * * SMI-5837: drains every surface's `queueTails` entry FIRST, before touching * `states`/streams. `writeLogLine` is fire-and-forget from `logger.ts`'s * perspective (F2's "logger never blocks" design) — a caller's test can * observe ITS OWN write landing (via `waitForLogFile`) while an EARLIER * write to the same surface is still queued behind it under I/O contention * (`runExclusive` is FIFO per surface, but the queue only guarantees order, * not that every prior write has drained by the time a later one resolves, * if a test only awaits its own file appearing rather than the whole * queue). Previously, clearing `queueTails` here (without awaiting it) * detached any still-in-flight write from the mutex without cancelling it — * it kept running as an orphaned promise, and because `resolveStream` reads * `SKILLSMITH_LOG_DIR`/`states` LIVE (by design, so a test can repoint the * log dir without `vi.resetModules()`), that orphaned write would land * wherever those pointed by the time it finally completed: a LATER test's * fresh temp directory. The stray record (missing that later test's own * `correlationId`, `msg`, etc.) could then be the FIRST line a test reads * back, failing an assertion that has nothing to do with the actual write * under test. Reproduced under combined CPU+disk I/O contention (~1/15 * runs); never reproduced in isolation, matching the original flake report. * Awaiting every tracked queue tail first guarantees no write can still be * in flight once this function returns, closing that window. Every * `runExclusive` task converts both success AND failure into a resolved * (never rejected) tail promise (see `runExclusive` above), so this await * is safe even for the crash-proofing tests' deliberately-failing writes. */ export declare function __resetLoggingStateForTests(): Promise; //# sourceMappingURL=rotation.d.ts.map