Skip to content

Commit 424007a

Browse files
spencermarxruvnet
andcommitted
feat(cli): add JSONL-backed command history backup with replay recovery
Command history (`command_executions`) was the only DB-only table with no filesystem backing. When the database was recreated, sessions recovered from `.ocr/sessions/` markdown files but command history was permanently lost. - Add `command-log.ts` module with append, read, FIFO rotation (5k lines), and idempotent replay via uid-based dedup - Add migration 9: `uid` column + unique index on `command_executions` - Export new module from `@open-code-review/cli/db` - Integrate replay into `ocr state sync` with empty-table guard - JSONL stored in `.ocr/data/.cache/` for future reuse by other backups Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 8d82001 commit 424007a

4 files changed

Lines changed: 240 additions & 1 deletion

File tree

packages/cli/src/commands/state.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
resolveActiveSession,
2828
} from "../lib/state/index.js";
2929
import type { WorkflowType, ReviewPhase, MapPhase, RoundCompleteResult, MapCompleteResult } from "../lib/state/types.js";
30+
import { replayCommandLog } from "../lib/db/command-log.js";
31+
import { getDb, saveDatabase } from "../lib/db/index.js";
3032

3133
// ── Helpers ──
3234

@@ -286,6 +288,18 @@ const syncSubcommand = new Command("sync")
286288
try {
287289
const synced = await stateSync(ocrDir);
288290
console.log(`Synced ${synced} session${synced !== 1 ? "s" : ""} from filesystem.`);
291+
292+
// Recover command history from JSONL backup if DB was recreated
293+
const db = await getDb(ocrDir);
294+
const countResult = db.exec("SELECT COUNT(*) as c FROM command_executions");
295+
const totalCmds = (countResult[0]?.values[0]?.[0] as number) ?? 0;
296+
if (totalCmds === 0) {
297+
const recovered = replayCommandLog(db, ocrDir);
298+
if (recovered > 0) {
299+
saveDatabase(db, join(ocrDir, "data", "ocr.db"));
300+
console.log(`Recovered ${recovered} command${recovered !== 1 ? "s" : ""} from backup log.`);
301+
}
302+
}
289303
} catch (error) {
290304
console.error(
291305
chalk.red(
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
}

packages/cli/src/lib/db/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ export { runMigrations, MIGRATIONS } from "./migrations.js";
3939

4040
export { resultToRows, resultToRow } from "./result-mapper.js";
4141

42+
export {
43+
cacheDir,
44+
generateCommandUid,
45+
commandLogPath,
46+
appendCommandLog,
47+
readCommandLog,
48+
replayCommandLog,
49+
} from "./command-log.js";
50+
51+
export type {
52+
CommandLogEntry,
53+
CommandLogEvent,
54+
CommandLogWriter,
55+
} from "./command-log.js";
56+
4257
// ── Connection cache ──
4358

4459
const connections = new Map<string, Database>();
@@ -114,7 +129,7 @@ export function saveDatabase(db: Database, dbPath: string): void {
114129
if (!existsSync(dir)) {
115130
mkdirSync(dir, { recursive: true });
116131
}
117-
const tmpPath = dbPath + ".tmp";
132+
const tmpPath = `${dbPath}.${process.pid}.tmp`;
118133
writeFileSync(tmpPath, Buffer.from(data));
119134
renameSync(tmpPath, dbPath);
120135
}

packages/cli/src/lib/db/migrations.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,14 @@ const MIGRATIONS: Migration[] = [
252252
ALTER TABLE map_runs ADD COLUMN section_count INTEGER DEFAULT 0;
253253
`,
254254
},
255+
{
256+
version: 9,
257+
description: "Add uid column to command_executions for JSONL-backed recovery",
258+
sql: `
259+
ALTER TABLE command_executions ADD COLUMN uid TEXT;
260+
CREATE UNIQUE INDEX idx_command_executions_uid ON command_executions(uid);
261+
`,
262+
},
255263
];
256264

257265
/**

0 commit comments

Comments
 (0)