/** * Persistence interfaces for `SessionManager` / `EpisodeManager`. * * The core-facing shape is intentionally thinner than the raw repositories * in `core/storage/repos/` — just the operations the session layer needs, * with session-friendly signatures. Tests inject in-memory fakes that * implement these interfaces without touching SQLite. * * The concrete `makeStorageBackedAdapters(...)` wires the real repos. */ import type { AgentKind, EpisodeId, SessionId } from "../../agent-contract/dto.js"; import type { makeEpisodesRepo, makeSessionsRepo } from "../storage/repos/index.js"; import type { EpochMs } from "../types.js"; export interface SessionRepo { upsertIfMissing(row: { id: SessionId; agent: AgentKind; ownerAgentKind?: AgentKind; ownerProfileId?: string; ownerWorkspaceId?: string | null; startedAt: EpochMs; lastSeenAt: EpochMs; meta: Record; }): void; touchLastSeen(id: SessionId, ts: EpochMs, metaPatch?: Record): void; getById(id: SessionId): { id: SessionId; agent: AgentKind; ownerAgentKind?: AgentKind; ownerProfileId?: string; ownerWorkspaceId?: string | null; startedAt: EpochMs; lastSeenAt: EpochMs; meta: Record; } | null; listRecent(limit?: number): Array<{ id: SessionId; agent: AgentKind; ownerAgentKind?: AgentKind; ownerProfileId?: string; ownerWorkspaceId?: string | null; startedAt: EpochMs; lastSeenAt: EpochMs; meta: Record; }>; deleteOlderThan(cutoffMs: EpochMs): number; } export interface EpisodesRepo { insert(row: { id: EpisodeId; sessionId: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; ownerWorkspaceId?: string | null; startedAt: EpochMs; endedAt: EpochMs | null; traceIds: string[]; rTask: number | null; status: "open" | "closed"; meta: Record; }): void; updateTraceIds(id: EpisodeId, traceIds: string[]): void; updateMeta(id: EpisodeId, metaPatch: Record): void; deleteById(id: EpisodeId): void; close(id: EpisodeId, endedAt: EpochMs, rTask?: number, meta?: Record): void; /** * Flip a closed episode back to `open` — V7 §0.1 "revision" path. * Idempotent for already-open episodes. */ reopen(id: EpisodeId, meta?: Record): void; getById(id: EpisodeId): unknown | null; getOpenForSession(sessionId: SessionId): unknown | null; } // ─── Storage adapter factories (real repos) ───────────────────────────────── type SqliteSessions = ReturnType; type SqliteEpisodes = ReturnType; export function adaptSessionsRepo(sqlite: SqliteSessions): SessionRepo { return { upsertIfMissing(row) { const existing = sqlite.getById(row.id); if (existing) return; sqlite.upsert({ id: row.id, agent: row.agent as AgentKind, ownerAgentKind: row.ownerAgentKind, ownerProfileId: row.ownerProfileId, ownerWorkspaceId: row.ownerWorkspaceId, startedAt: row.startedAt, lastSeenAt: row.lastSeenAt, meta: row.meta, }); }, touchLastSeen(id, ts, metaPatch) { const existing = sqlite.getById(id); const nextMeta = { ...(existing?.meta ?? {}), ...(metaPatch ?? {}) }; sqlite.touch(id, ts, nextMeta); }, getById(id) { const r = sqlite.getById(id); if (!r) return null; return { id: r.id, agent: r.agent, ownerAgentKind: r.ownerAgentKind, ownerProfileId: r.ownerProfileId, ownerWorkspaceId: r.ownerWorkspaceId, startedAt: r.startedAt, lastSeenAt: r.lastSeenAt, meta: r.meta, }; }, listRecent(limit = 50) { return sqlite.listRecent(limit).map((r) => ({ id: r.id, agent: r.agent, ownerAgentKind: r.ownerAgentKind, ownerProfileId: r.ownerProfileId, ownerWorkspaceId: r.ownerWorkspaceId, startedAt: r.startedAt, lastSeenAt: r.lastSeenAt, meta: r.meta, })); }, deleteOlderThan: sqlite.deleteOlderThan, }; } export function adaptEpisodesRepo(sqlite: SqliteEpisodes): EpisodesRepo { return { insert(row) { sqlite.insert({ id: row.id, sessionId: row.sessionId, ownerAgentKind: row.ownerAgentKind, ownerProfileId: row.ownerProfileId, ownerWorkspaceId: row.ownerWorkspaceId, startedAt: row.startedAt, endedAt: row.endedAt, traceIds: row.traceIds, rTask: row.rTask, status: row.status, meta: row.meta, }); }, updateTraceIds(id, traceIds) { sqlite.appendTrace(id, traceIds); }, updateMeta(id, metaPatch) { sqlite.updateMeta(id, metaPatch); }, deleteById(id) { sqlite.deleteById(id); }, close(id, endedAt, rTask, meta) { // CRITICAL: never use `episodes.upsert` here. The repo's upsert // is `INSERT OR REPLACE`, which SQLite executes as DELETE + // INSERT — and `traces.session_id REFERENCES sessions ON DELETE // CASCADE` (and `episode_id ON DELETE CASCADE`) means every // trace for the affected episode would be silently wiped. We // hit exactly that bug when topic-end reflection started writing // traces *before* close fired. // // The whole "close" operation needs to be incremental UPDATEs: // - status / ended_at via `sqlite.close` // - meta_json patched via `sqlite.updateMeta` (no replace) sqlite.close(id, endedAt, rTask); if (meta) sqlite.updateMeta(id, meta); }, reopen(id, meta) { const cur = sqlite.getById(id); if (!cur) return; // Same hazard as `close` above — flip the status flag with a // surgical UPDATE rather than an upsert that would cascade- // delete every trace tied to this episode. sqlite.reopen(id); if (meta) sqlite.updateMeta(id, meta); }, getById: sqlite.getById, getOpenForSession: sqlite.getOpenForSession, }; }