// Rotated logfmt append — the GENERIC durable-log primitive, promoted out of // lifecycle/eventlog.ts (its header anticipated exactly this: "the rotate-append // primitive is path-parameterized, so the adjacent 'log rotation' phase can // promote it to storage/ and point other log producers at it"). Producers today: // • lifecycle.log (lifecycle/eventlog.ts — daemon lifecycle decisions) // • delivery.log (daemon/deliverylog.ts — per-delivery outcomes, Ф-#8a) // // Design (carried verbatim from eventlog): // • One line per event, logfmt (`key=value`, values quoted iff they contain // whitespace/quotes/`=`). Human-greppable AND machine-parseable. // • Append-only, app-managed SIZE rotation (base → .1 … .keep). This is the // "встроенная ротация" class (Фаза — Ротация логов iapeer): a log OUR code // writes rotates itself in the writer; external rotation is only for logs // written by processes we don't control. // • The target PATH is passed IN by the caller (who routes it through cfg), // never re-resolved from env here — so a sandboxed caller cfg sandboxes the // log too (no leak to the real ~/.iapeer). // • Best-effort throughout: a write/rotate failure is swallowed. Observability // must never take down the daemon or fail a wake/reap/delivery. import { appendFileSync, closeSync, mkdirSync, openSync, readSync, renameSync, rmSync, rmdirSync, statSync } from 'fs' import { dirname } from 'path' /** Default cap per log file before it rotates to .1. */ export const DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB /** Default number of rotated backups kept (.1 … .KEEP). */ export const DEFAULT_LOG_KEEP = 5 /** logfmt value: bare token, or double-quoted with `"`/`\` and control chars escaped, * when it contains whitespace, `=`, `"` or a control char. Empty string → `""`. * Control chars (esp. \n/\r) MUST be escaped, not emitted raw: a raw newline in an * agent-supplied field (topic, a multi-line error.message) would otherwise split one * event into two physical lines — breaking the one-line-per-event contract that every * reader (grep, the postmortem parser, the TUI dashboard) relies on, and letting a * crafted value FORGE a second log record. */ export function fmtValue(v: string | number): string { const s = String(v) if (s === '') return '""' // eslint-disable-next-line no-control-regex if (/[\s"=-]/.test(s)) { const esc = s .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') // any remaining control char (\x00-\x08, \x0b, \x0c, \x0e-\x1f) → \xNN // eslint-disable-next-line no-control-regex .replace(/[-]/g, c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`) return `"${esc}"` } return s } /** Render one logfmt line (ts first, then fields in insertion order; undefined * fields are skipped). No trailing newline. Pure — unit-testable. */ export function formatEventLine(nowMs: number, fields: Record): string { const parts = [`ts=${new Date(nowMs).toISOString()}`] for (const [k, v] of Object.entries(fields)) { if (v === undefined) continue parts.push(`${k}=${fmtValue(v)}`) } return parts.join(' ') } // ───────────────────────────────────────────────────────────────────────────── // logfmt PARSE — the read half, symmetric to fmtValue/formatEventLine above. // Lives NEXT TO the writer so the two halves cannot drift: a change to the escape // vocabulary lands in both directions in one file. Consumers: the fleet-API event // stream + peer detail (daemon/fleet.ts) and the TUI dashboard log panel // (tui/dashboard/model.ts re-uses this instead of a display-only tokenizer). // ───────────────────────────────────────────────────────────────────────────── export interface ParsedEventLine { /** Epoch-ms parsed from the ts field. */ tsMs: number /** The event kind (`ev` field). */ ev: string /** ALL fields incl. ts/ev, values unescaped. */ fields: Record } /** Unescape one QUOTED logfmt value body (the text between the quotes). Inverse of * fmtValue's escape table: \\ \" \n \r \t \xNN. An unknown escape passes the * escaped char through verbatim (never throws — reader is best-effort). */ function unescapeQuoted(s: string): string { let out = '' for (let i = 0; i < s.length; i++) { const c = s[i]! if (c !== '\\' || i + 1 >= s.length) { out += c continue } const e = s[i + 1]! if (e === 'n') out += '\n' else if (e === 'r') out += '\r' else if (e === 't') out += '\t' else if (e === 'x' && i + 3 < s.length) { out += String.fromCharCode(parseInt(s.slice(i + 2, i + 4), 16)) i += 2 } else out += e // \\ and \" land here, plus any future escape (verbatim) i++ } return out } /** * Parse one `ts= ev= k=v …` logfmt line as written by formatEventLine — * QUOTE-AWARE (a quoted value may contain spaces/`=`; the display-only tokenizer this * replaces split them mid-value). Returns null for anything that is not a complete * event line (corrupt tail, a partial line mid-append, an unterminated quote), so a * tail-follower can safely feed it raw chunks. Pure — unit-testable. */ export function parseEventLine(line: string): ParsedEventLine | null { if (!line.startsWith('ts=')) return null const fields: Record = {} const n = line.length let i = 0 while (i < n) { while (i < n && line[i] === ' ') i++ if (i >= n) break const eq = line.indexOf('=', i) if (eq < 0) return null // a trailing bare token — treat the line as corrupt const key = line.slice(i, eq) if (!key || key.includes(' ')) return null i = eq + 1 if (line[i] === '"') { // quoted value — scan to the CLOSING unescaped quote let j = i + 1 while (j < n) { if (line[j] === '\\') { j += 2 continue } if (line[j] === '"') break j++ } if (j >= n) return null // unterminated quote → partial/corrupt line fields[key] = unescapeQuoted(line.slice(i + 1, j)) i = j + 1 } else { let j = i while (j < n && line[j] !== ' ') j++ fields[key] = line.slice(i, j) i = j } } const ts = fields.ts const ev = fields.ev if (!ts || !ev) return null const tsMs = Date.parse(ts) if (!Number.isFinite(tsMs)) return null return { tsMs, ev, fields } } /** Does this parsed event concern the peer? Matches the bare personality and any * `-` identity across the fields the durable logs actually carry. * (Promoted from the dashboard model — the fleet peer-detail endpoint needs it too.) */ export function eventConcernsPeer(e: { fields: Record }, personality: string): boolean { const idSuffix = `-${personality}` for (const key of ['to', 'caller', 'via', 'personality', 'identity', 'from', 'peer'] as const) { const v = e.fields[key] if (!v) continue if (v === personality || v.endsWith(idSuffix)) return true } return false } /** Read the last `bytes` of a file without loading the whole log (delivery.log can be * MBs before rotation), dropping the leading partial line when starting mid-file. * Missing/unreadable file → ''. (Promoted from the dashboard data layer — the fleet * replay/peer-detail readers share it.) */ export function readLogTail(path: string, bytes = 64 * 1024): string { try { const size = statSync(path).size const fd = openSync(path, 'r') try { const start = Math.max(0, size - bytes) const buf = Buffer.alloc(size - start) readSync(fd, buf, 0, buf.length, start) const s = buf.toString('utf8') return start > 0 ? s.slice(s.indexOf('\n') + 1) : s } finally { closeSync(fd) } } catch { return '' } } /** Stale threshold for the rotation lock: a crash mid-rotate must not wedge rotation forever. */ const ROTATE_LOCK_STALE_MS = 10_000 /** Size-rotate `path` (and its .1 … .keep backups) when the next line would push * it over `maxBytes`. Drops the oldest, shifts each backup up by one, base→.1. * * CROSS-PROCESS SAFE: lifecycle.log / delivery.log are written by BOTH the daemon * and separate CLI processes; two of them rotating at once could interleave the * rename chain and clobber a freshly-rotated .1 (a whole generation of forensic * history lost). A NON-BLOCKING exclusive lock (mkdir is atomic) makes exactly ONE * process do the rename chain; a contender simply SKIPS this round and appends — * the file grows slightly past maxBytes until the next append rotates it (harmless). * No sleeping / blocking (this runs in the router event loop). A stale lock from a * crashed rotator is reclaimed after ROTATE_LOCK_STALE_MS. Best-effort throughout. */ function rotateIfNeeded(path: string, lineLen: number, maxBytes: number, keep: number): void { let size: number try { size = statSync(path).size } catch { return // no file yet → nothing to rotate } if (size + lineLen <= maxBytes) return // cheap check first — no lock unless rotation is actually due const lock = `${path}.rotating` try { mkdirSync(lock) // atomic exclusive-create — the winner rotates } catch (e) { // EEXIST → another process holds the lock and is rotating; skip this round (it does the shift). // If the lock is STALE (a crash mid-rotate), reclaim it so rotation can resume on the next append. if ((e as NodeJS.ErrnoException).code === 'EEXIST') { try { if (Date.now() - statSync(lock).mtimeMs > ROTATE_LOCK_STALE_MS) rmdirSync(lock) } catch { /* best-effort */ } } return } try { // Re-check under the lock — a competitor may have just rotated between our size check and the lock. let cur: number try { cur = statSync(path).size } catch { return } if (cur + lineLen <= maxBytes) return try { rmSync(`${path}.${keep}`, { force: true }) } catch { /* best-effort */ } for (let i = keep - 1; i >= 1; i--) { try { renameSync(`${path}.${i}`, `${path}.${i + 1}`) } catch { /* that backup may not exist yet */ } } try { renameSync(path, `${path}.1`) } catch { /* best-effort */ } } finally { try { rmdirSync(lock) } catch { /* best-effort */ } } } export interface AppendRotatedOptions { /** Stamp the line with this epoch-ms (a caller may pass its own tick clock so the * log timestamp agrees with its accounting). Default Date.now(). */ nowMs?: number /** Rotation cap per file (default DEFAULT_LOG_MAX_BYTES). */ maxBytes?: number /** Rotated backups kept (default DEFAULT_LOG_KEEP). */ keep?: number } /** * Append one logfmt event line to the rotated log at `path` (full file path, * routed through the caller's cfg). Creates the parent dir (0700) and the file * (0600) as needed. Fully best-effort — never throws. */ export function appendRotatedEvent( path: string, fields: Record, opts: AppendRotatedOptions = {}, ): void { const line = `${formatEventLine(opts.nowMs ?? Date.now(), fields)}\n` const maxBytes = opts.maxBytes ?? DEFAULT_LOG_MAX_BYTES const keep = opts.keep ?? DEFAULT_LOG_KEEP try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) rotateIfNeeded(path, line.length, maxBytes, keep) appendFileSync(path, line, { mode: 0o600 }) } catch { /* observability is best-effort — a log failure must never break the caller */ } }