import { afterEach, describe, expect, test } from 'bun:test' import { Database } from 'bun:sqlite' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { Recovery } from '../../src/storage/Recovery.js' describe('Recovery implementation', () => { const created: string[] = [] afterEach(() => { for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }) }) function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } { const root = mkdtempSync(join(tmpdir(), 'air-recovery-')) created.push(root) const artifactRoot = join(root, 'artifacts') const dbPath = join(root, 'session.db') const db = new Database(dbPath) db.exec(` CREATE TABLE sessions (id TEXT PRIMARY KEY); CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT); CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT); CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT); INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session'); INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task'); `) db.close() return { recovery: new Recovery({ sessionId: 'session_recovery' as any, projectId: 'project_recovery' as any, artifactRoot, dbPath, projectRoot: root, }), root, artifactRoot, dbPath, } } test('checks PID liveness with keep/mark_lost actions', () => { const { recovery } = makeRecovery() const reports = recovery.checkPidLiveness([ { agent_id: 'self', pid: process.pid }, { agent_id: 'missing', pid: 99999999 }, ]) recovery.close() expect(reports).toEqual([ { agent_id: 'self', pid: process.pid, alive: true, action: 'keep' }, { agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' }, ]) }) test('scans orphan references from SQLite tables', async () => { const { recovery } = makeRecovery() const report = await recovery.scan() recovery.close() expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2) expect(report.orphanReferences.archived).toContainEqual({ table: 'tasks', id: 'missing_session', reason: 'FK-off: session_id → sessions (1 rows)', }) expect(report.orphanReferences.archived).toContainEqual({ table: 'task_attempts', id: 'missing_task', reason: 'FK-off: task_id → tasks (1 rows)', }) }) test('quarantines non-artifact temporary orphan files', async () => { const { recovery, artifactRoot } = makeRecovery() const tmpDir = join(artifactRoot, 'tmp') const orphanPath = join(tmpDir, 'scratch.tmp') await Bun.write(orphanPath, 'orphan') const report = await recovery.scan() recovery.close() expect(report.orphanArtifacts.totalFound).toBe(1) expect(report.orphanArtifacts.quarantined).toHaveLength(1) expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true) expect(existsSync(orphanPath)).toBe(false) }) })