|
| 1 | +/** |
| 2 | + * JSONL-backed command history backup. |
| 3 | + * |
| 4 | + * Provides a durable append log for `command_executions` so that command |
| 5 | + * history can be recovered when the SQLite database is lost or recreated. |
| 6 | + * |
| 7 | + * The JSONL file is **write-only** during normal operation and **read-only** |
| 8 | + * during recovery. All writes are best-effort — a JSONL failure never |
| 9 | + * blocks the primary DB write path. |
| 10 | + * |
| 11 | + * The `output` field is intentionally excluded (can be megabytes). |
| 12 | + * Structural metadata (what ran, when, how it exited) is the irreplaceable |
| 13 | + * audit data worth backing up. |
| 14 | + */ |
| 15 | + |
| 16 | +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 17 | +import { dirname, join } from "node:path"; |
| 18 | +import { randomUUID } from "node:crypto"; |
| 19 | +import type { Database } from "sql.js"; |
| 20 | + |
| 21 | +// ── Types ── |
| 22 | + |
| 23 | +export type CommandLogEvent = "start" | "finish" | "cancel"; |
| 24 | +export type CommandLogWriter = "dashboard" | "cli"; |
| 25 | + |
| 26 | +export type CommandLogEntry = { |
| 27 | + /** Schema version for forward compatibility. */ |
| 28 | + v: number; |
| 29 | + /** Stable unique ID (UUIDv4) — dedup key for idempotent replay. */ |
| 30 | + uid: string; |
| 31 | + /** Original DB auto-increment ID (informational, not used for dedup). */ |
| 32 | + db_id: number; |
| 33 | + command: string; |
| 34 | + args: string | null; |
| 35 | + exit_code: number | null; |
| 36 | + started_at: string; |
| 37 | + finished_at: string | null; |
| 38 | + is_detached: number; |
| 39 | + event: CommandLogEvent; |
| 40 | + writer: CommandLogWriter; |
| 41 | +}; |
| 42 | + |
| 43 | +// ── Constants ── |
| 44 | + |
| 45 | +const CACHE_DIR = ".cache"; |
| 46 | +const FILENAME = "command-history.jsonl"; |
| 47 | + |
| 48 | +/** Maximum lines before FIFO rotation triggers. */ |
| 49 | +const MAX_LINES = 5000; |
| 50 | + |
| 51 | +/** Lines to keep after rotation (80% of max — provides headroom). */ |
| 52 | +const KEEP_LINES = 4000; |
| 53 | + |
| 54 | +let approxLineCount = -1; |
| 55 | + |
| 56 | +// ── Public API ── |
| 57 | + |
| 58 | +/** Generate a stable UUID for a new command execution. */ |
| 59 | +export function generateCommandUid(): string { |
| 60 | + return randomUUID(); |
| 61 | +} |
| 62 | + |
| 63 | +/** Resolve the `.ocr/data/.cache/` directory for JSONL backup files. */ |
| 64 | +export function cacheDir(ocrDir: string): string { |
| 65 | + return join(ocrDir, "data", CACHE_DIR); |
| 66 | +} |
| 67 | + |
| 68 | +/** Resolve the JSONL file path from the `.ocr/` directory. */ |
| 69 | +export function commandLogPath(ocrDir: string): string { |
| 70 | + return join(cacheDir(ocrDir), FILENAME); |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Append a single entry to the JSONL log. |
| 75 | + * |
| 76 | + * Best-effort: catches all errors silently. The JSONL is a backup — |
| 77 | + * failures must never block the primary DB write path. |
| 78 | + * |
| 79 | + * Uses `appendFileSync` which maps to `O_APPEND` on POSIX, providing |
| 80 | + * atomic appends for writes under the pipe buffer size (~4KB). |
| 81 | + * Each JSONL line is ~300 bytes, well within that limit. |
| 82 | + */ |
| 83 | +export function appendCommandLog(ocrDir: string, entry: CommandLogEntry): void { |
| 84 | + try { |
| 85 | + const filePath = commandLogPath(ocrDir); |
| 86 | + const dir = dirname(filePath); |
| 87 | + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); |
| 88 | + const line = JSON.stringify(entry) + "\n"; |
| 89 | + appendFileSync(filePath, line, { encoding: "utf-8" }); |
| 90 | + if (approxLineCount >= 0) approxLineCount++; |
| 91 | + rotateIfNeeded(filePath); |
| 92 | + } catch { |
| 93 | + // Silent — JSONL is a backup, not the critical path |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Read all entries from the JSONL log. |
| 99 | + * |
| 100 | + * Skips malformed lines gracefully — a single corrupt line does not |
| 101 | + * prevent recovery of all other entries. |
| 102 | + */ |
| 103 | +export function readCommandLog(ocrDir: string): CommandLogEntry[] { |
| 104 | + const filePath = commandLogPath(ocrDir); |
| 105 | + if (!existsSync(filePath)) return []; |
| 106 | + |
| 107 | + const content = readFileSync(filePath, "utf-8"); |
| 108 | + const entries: CommandLogEntry[] = []; |
| 109 | + |
| 110 | + for (const line of content.split("\n")) { |
| 111 | + if (!line.trim()) continue; |
| 112 | + try { |
| 113 | + entries.push(JSON.parse(line) as CommandLogEntry); |
| 114 | + } catch { |
| 115 | + // Skip malformed lines |
| 116 | + } |
| 117 | + } |
| 118 | + return entries; |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Replay the JSONL log into an empty `command_executions` table. |
| 123 | + * |
| 124 | + * Collapses multiple events per `uid` to the latest state, skips |
| 125 | + * incomplete (start-only) records, and checks for existing rows |
| 126 | + * to ensure idempotent import. |
| 127 | + * |
| 128 | + * Returns the number of rows imported. |
| 129 | + */ |
| 130 | +export function replayCommandLog(db: Database, ocrDir: string): number { |
| 131 | + const entries = readCommandLog(ocrDir); |
| 132 | + if (entries.length === 0) return 0; |
| 133 | + |
| 134 | + // Collapse to latest event per uid |
| 135 | + const latest = new Map<string, CommandLogEntry>(); |
| 136 | + for (const entry of entries) { |
| 137 | + if (!entry.uid || !entry.command || !entry.started_at) continue; |
| 138 | + const existing = latest.get(entry.uid); |
| 139 | + // Only 'start' events never overwrite — finish/cancel always take precedence |
| 140 | + if (!existing || entry.event !== "start") { |
| 141 | + latest.set(entry.uid, entry); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + let imported = 0; |
| 146 | + for (const entry of latest.values()) { |
| 147 | + // Skip start-only records (incomplete executions from before a crash) |
| 148 | + if (entry.event === "start" && !entry.finished_at) continue; |
| 149 | + |
| 150 | + // Idempotency check — skip if uid already exists in DB |
| 151 | + const existing = db.exec( |
| 152 | + "SELECT COUNT(*) as c FROM command_executions WHERE uid = ?", |
| 153 | + [entry.uid], |
| 154 | + ); |
| 155 | + if (((existing[0]?.values[0]?.[0] as number) ?? 0) > 0) continue; |
| 156 | + |
| 157 | + db.run( |
| 158 | + `INSERT INTO command_executions |
| 159 | + (uid, command, args, exit_code, started_at, finished_at, pid, is_detached) |
| 160 | + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, |
| 161 | + [ |
| 162 | + entry.uid, |
| 163 | + entry.command, |
| 164 | + entry.args, |
| 165 | + entry.exit_code, |
| 166 | + entry.started_at, |
| 167 | + entry.finished_at, |
| 168 | + entry.is_detached, |
| 169 | + ], |
| 170 | + ); |
| 171 | + imported++; |
| 172 | + } |
| 173 | + return imported; |
| 174 | +} |
| 175 | + |
| 176 | +// ── Internal ── |
| 177 | + |
| 178 | +/** |
| 179 | + * FIFO rotation: when the JSONL exceeds MAX_LINES, atomically rewrite |
| 180 | + * keeping only the newest KEEP_LINES entries. |
| 181 | + * |
| 182 | + * Uses temp file + rename (matching the existing DB atomic write pattern). |
| 183 | + */ |
| 184 | +function rotateIfNeeded(filePath: string): void { |
| 185 | + try { |
| 186 | + if (approxLineCount >= 0 && approxLineCount <= MAX_LINES) return; |
| 187 | + |
| 188 | + const content = readFileSync(filePath, "utf-8"); |
| 189 | + const lines = content.split("\n").filter((l) => l.trim()); |
| 190 | + approxLineCount = lines.length; |
| 191 | + |
| 192 | + if (approxLineCount <= MAX_LINES) return; |
| 193 | + |
| 194 | + const kept = lines.slice(lines.length - KEEP_LINES); |
| 195 | + const tmpPath = `${filePath}.${process.pid}.tmp`; |
| 196 | + writeFileSync(tmpPath, kept.join("\n") + "\n", { encoding: "utf-8" }); |
| 197 | + renameSync(tmpPath, filePath); |
| 198 | + approxLineCount = KEEP_LINES; |
| 199 | + } catch { |
| 200 | + // Silent — rotation failure is non-critical |
| 201 | + } |
| 202 | +} |
0 commit comments