/** * Session snapshot cache service. * * Stores a DB-side copy of each session's userVisible messages so that * repeated opens of historical chats skip the Goose round-trip entirely. * * Rollback: set SESSION_SNAPSHOT_CACHE_ENABLED=0 in env → all reads/writes * are no-ops; behaviour falls back to the existing Goose path unchanged. * * The table (h5_session_snapshots) is additive-only and has no FK deps on * conversation content, so it can be dropped/truncated safely at any time. */ export function createSessionSnapshotService(pool) { function isEnabled() { return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0'; } /** * Persist a snapshot for a session. * @param {string} sessionId * @param {string} userId * @param {object} session – raw Goose session object (includes message_count, updated_at, etc.) * @param {Array} messages – already-normalized userVisible Message[] */ async function save(sessionId, userId, session, messages) { if (!isEnabled() || !pool) return; try { const now = Date.now(); const sessionMeta = { name: session.name ?? '', working_dir: session.working_dir ?? '', created_at_str: session.created_at ?? '', updated_at_str: session.updated_at ?? '', user_set_name: session.user_set_name ? 1 : 0, recipe_json: session.recipe != null ? JSON.stringify(session.recipe) : null, }; await pool.query( `INSERT INTO h5_session_snapshots (agent_session_id, user_id, name, working_dir, created_at_str, updated_at_str, user_set_name, recipe_json, synced_msg_count, source_updated_at, messages_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), working_dir = VALUES(working_dir), created_at_str = VALUES(created_at_str), updated_at_str = VALUES(updated_at_str), user_set_name = VALUES(user_set_name), recipe_json = VALUES(recipe_json), synced_msg_count = VALUES(synced_msg_count), source_updated_at = VALUES(source_updated_at), messages_json = VALUES(messages_json), synced_at = VALUES(synced_at)`, [ sessionId, userId, sessionMeta.name, sessionMeta.working_dir, sessionMeta.created_at_str, sessionMeta.updated_at_str, sessionMeta.user_set_name, sessionMeta.recipe_json, session.message_count ?? 0, session.updated_at ?? '', JSON.stringify(messages), now, ], ); } catch (err) { // Non-fatal: log and continue; caller falls back to Goose. console.warn('[snapshot] save failed:', err instanceof Error ? err.message : err); } } /** * Read a snapshot. Returns null if not found or cache is disabled. * The returned object reconstructs what `loadSessionDetail` returns. */ async function get(sessionId) { if (!isEnabled() || !pool) return null; try { const [rows] = await pool.query( `SELECT agent_session_id, user_id, name, working_dir, created_at_str, updated_at_str, user_set_name, recipe_json, synced_msg_count, source_updated_at, messages_json, synced_at FROM h5_session_snapshots WHERE agent_session_id = ? LIMIT 1`, [sessionId], ); if (!rows.length) return null; const row = rows[0]; return { session: { id: sessionId, name: row.name, working_dir: row.working_dir, message_count: row.synced_msg_count, created_at: row.created_at_str || undefined, updated_at: row.updated_at_str || undefined, user_set_name: row.user_set_name === 1, recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, conversation: null, }, messages: JSON.parse(row.messages_json), meta: { synced_msg_count: row.synced_msg_count, source_updated_at: row.source_updated_at, synced_at: row.synced_at, }, }; } catch (err) { console.warn('[snapshot] get failed:', err instanceof Error ? err.message : err); return null; } } /** * Delete a snapshot (called when the session itself is deleted). */ async function remove(sessionId) { if (!pool) return; try { await pool.query( `DELETE FROM h5_session_snapshots WHERE agent_session_id = ?`, [sessionId], ); } catch (err) { console.warn('[snapshot] remove failed:', err instanceof Error ? err.message : err); } } /** * Fetch full session from Goose and write/update the snapshot. * Fire-and-forget safe: errors are swallowed. */ async function refresh(sessionId, userId, apiFetchFn) { if (!isEnabled() || !pool) return; try { const res = await apiFetchFn(`/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', }); if (!res.ok) return; const gooseSession = await res.json(); // Store raw userVisible messages — same as write-through path. const messages = (gooseSession.conversation ?? []) .filter((m) => m.metadata?.userVisible); await save(sessionId, userId, gooseSession, messages); } catch (err) { console.warn('[snapshot] refresh failed:', sessionId, err instanceof Error ? err.message : err); } } return { save, get, remove, refresh, isEnabled }; }