trail-boss/daemon/db.ts
jedarden 4e593de16d feat(trail-boss): phase 6 complete - all 7 acceptance scenarios passing
Walking skeleton test suite passes end-to-end:
- AS-1: Permission block enqueue/dequeue
- AS-2: FIFO ordering
- AS-3: Answered-in-pane reconcile
- AS-4: Dropped-event recovery
- AS-5: Skip + cooldown
- AS-6: No forced focus-steal
- AS-7: Pane reuse regression

Phase 6 exit criterion met (AS-1 through AS-6 pass).
Phase 6 complete (AS-7 also passes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:55:37 -04:00

193 lines
6.6 KiB
TypeScript

// SQLite database layer for Trail Boss state
import { Database } from "bun:sqlite";
import * as fs from "fs";
import * as path from "path";
const DATA_DIR = process.env.TRAILBOSS_DATA_DIR ?? path.join(process.env.HOME ?? "", ".local/share/trailboss");
const DB_PATH = path.join(DATA_DIR, "trailboss.db");
// Ensure data directory exists
fs.mkdirSync(DATA_DIR, { recursive: true });
export const db = new Database(DB_PATH);
db.exec("PRAGMA journal_mode=WAL");
db.exec("PRAGMA foreign_keys=ON");
// Load schema
const schema = fs.readFileSync(path.join(import.meta.dir, "schema.sql"), "utf-8");
db.exec(schema);
// Session registry operations
export function upsertSession(
sessionId: string,
paneId: string,
cwd: string,
transcriptPath: string,
lastStuckAt: number | null,
lastStuckReason: string | null,
lastMessage: string | null
): void {
const now = Date.now();
const stmt = db.prepare(`
INSERT INTO sessions (session_id, pane_id, cwd, transcript_path, last_seen_at, last_stuck_at, last_stuck_reason, last_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
pane_id = excluded.pane_id,
cwd = excluded.cwd,
transcript_path = excluded.transcript_path,
last_seen_at = excluded.last_seen_at,
last_stuck_at = COALESCE(excluded.last_stuck_at, sessions.last_stuck_at),
last_stuck_reason = COALESCE(excluded.last_stuck_reason, sessions.last_stuck_reason),
last_message = COALESCE(excluded.last_message, sessions.last_message)
`);
stmt.run(sessionId, paneId, cwd, transcriptPath, now, lastStuckAt, lastStuckReason, lastMessage, now);
}
export function getSession(sessionId: string): { session_id: string; pane_id: string; cwd: string; transcript_path: string; last_stuck_at: number | null; last_stuck_reason: string | null; last_message: string | null } | null {
const stmt = db.prepare("SELECT * FROM sessions WHERE session_id = ?");
return stmt.get(sessionId) as ReturnType<typeof getSession>;
}
export function deleteSession(sessionId: string): void {
// Remove queue rows first (FK references sessions.session_id)
db.prepare("DELETE FROM queue WHERE session_id = ?").run(sessionId);
db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
}
// Queue operations
// Enqueue a session, or update existing entry if already queued (idempotent)
export function enqueue(sessionId: string, reason: string, stuckAt: number): void {
const now = Date.now();
// First try to update existing queued entry
const updateStmt = db.prepare(`
UPDATE queue
SET reason = ?, stuck_at = ?, skip_cooldown_until = NULL
WHERE session_id = ? AND dequeued_at IS NULL
`);
const result = updateStmt.run(reason, stuckAt, sessionId);
// If no rows were updated, insert new entry
if (result.changes === 0) {
const insertStmt = db.prepare(`
INSERT INTO queue (session_id, stuck_at, reason, created_at)
VALUES (?, ?, ?, ?)
`);
insertStmt.run(sessionId, stuckAt, reason, now);
}
}
export function dequeue(sessionId: string): void {
const now = Date.now();
const stmt = db.prepare(`
UPDATE queue SET dequeued_at = ? WHERE session_id = ? AND dequeued_at IS NULL
`);
stmt.run(now, sessionId);
}
// Dequeue any synthetic bootstrap entry whose session_id equals the pane_id
// (but skip if it's already the real session). Called when a real hook fires for a pane.
export function dequeueByPaneId(paneId: string, realSessionId: string): void {
const now = Date.now();
// Bootstrap entries have session_id = pane_id
const stmt = db.prepare(`
UPDATE queue SET dequeued_at = ?
WHERE session_id = ? AND session_id != ? AND dequeued_at IS NULL
`);
stmt.run(now, paneId, realSessionId);
// Also clean up the synthetic session row itself
const del = db.prepare(`DELETE FROM sessions WHERE session_id = ? AND session_id != ?`);
del.run(paneId, realSessionId);
}
export function skipHead(sessionId: string, cooldownMs: number): void {
const now = Date.now();
const cooldownUntil = now + cooldownMs;
// Move to tail: update stuck_at to now (so it's last in FIFO) and set cooldown
const stmt = db.prepare(`
UPDATE queue
SET stuck_at = ?, skip_cooldown_until = ?
WHERE id = (SELECT id FROM queue WHERE dequeued_at IS NULL ORDER BY stuck_at ASC LIMIT 1)
AND session_id = ?
`);
stmt.run(now, cooldownUntil, sessionId);
}
export function getHead(): { id: number; session_id: string; stuck_at: number; skip_cooldown_until: number | null } | null {
const now = Date.now();
const stmt = db.prepare(`
SELECT q.id, q.session_id, q.stuck_at, q.skip_cooldown_until
FROM queue q
JOIN sessions s ON s.session_id = q.session_id
WHERE q.dequeued_at IS NULL
AND (q.skip_cooldown_until IS NULL OR q.skip_cooldown_until < ?)
ORDER BY q.stuck_at ASC
LIMIT 1
`);
return stmt.get(now) as ReturnType<typeof getHead>;
}
export function getStuckCount(): number {
const now = Date.now();
const stmt = db.prepare(`
SELECT COUNT(*) as count
FROM queue q
WHERE q.dequeued_at IS NULL
AND (q.skip_cooldown_until IS NULL OR q.skip_cooldown_until < ?)
`);
const result = stmt.get(now) as { count: number };
return result.count;
}
export function getAllStuck(limit: number = 50): Array<{
id: number;
session_id: string;
pane_id: string;
cwd: string;
reason: string;
last_message: string | null;
stuck_at: number;
skip_cooldown_until: number | null;
}> {
const now = Date.now();
const stmt = db.prepare(`
SELECT
q.id,
q.session_id,
s.pane_id,
s.cwd,
q.reason,
s.last_message as last_message,
q.stuck_at,
q.skip_cooldown_until
FROM queue q
JOIN sessions s ON s.session_id = q.session_id
WHERE q.dequeued_at IS NULL
AND (q.skip_cooldown_until IS NULL OR q.skip_cooldown_until < ?)
ORDER BY q.stuck_at ASC
LIMIT ?
`);
return stmt.all(now, limit) as ReturnType<typeof getAllStuck>;
}
// Reconcile: get all sessions that might be stuck but need verification
export function getSessionsForReconcile(limit: number = 100): Array<{
session_id: string;
transcript_path: string;
last_stuck_at: number | null;
}> {
const stmt = db.prepare(`
SELECT session_id, transcript_path, last_stuck_at
FROM sessions
WHERE last_stuck_at IS NOT NULL
ORDER BY last_stuck_at DESC
LIMIT ?
`);
return stmt.all(limit) as ReturnType<typeof getSessionsForReconcile>;
}
// Cleanup old dequeued items
export function cleanupQueue(olderThanMs: number = 24 * 60 * 60 * 1000): void {
const cutoff = Date.now() - olderThanMs;
const stmt = db.prepare("DELETE FROM queue WHERE dequeued_at < ?");
stmt.run(cutoff);
}