From edf06c0082bfceb3251a6b24bfc205e2ecc88882 Mon Sep 17 00:00:00 2001 From: Yannick Majoros Date: Mon, 1 Jun 2026 11:40:29 +0200 Subject: [PATCH] Reconcile cache with filesystem on get-projects so sessions stop going missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session cache is only rebuilt from the filesystem on a cold start (populateCacheViaWorker runs only when the cache is completely empty). Once the cache has any rows, a project folder that changed while the app was closed — or that was created before the build which first indexed it — is never re-scanned, so its sessions (and whole worktrees) silently disappear from the sidebar. The transcripts are intact on disk; only the cache is stale. Make get-projects reconcile first: re-index folders that are new or whose newest .jsonl is newer than what we last indexed. The check is a cheap, stat-only gate (getFolderIndexMtimeMs vs cache_meta.indexMtimeMs), so it's effectively free when nothing changed and only does real work for folders that actually need it. The previously dead populateCacheFromFilesystem() (defined and exported but never called) is repurposed into this gated reconcileCacheFromFilesystem(). --- main.js | 6 ++- session-cache.js | 24 +++++++++--- test/reconcile-cache.test.js | 75 ++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 test/reconcile-cache.test.js diff --git a/main.js b/main.js index 2c587b7..71b511d 100644 --- a/main.js +++ b/main.js @@ -265,7 +265,7 @@ sessionCache.init({ setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName, }, }); -const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem, +const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache; @@ -416,6 +416,10 @@ ipcMain.handle('get-projects', (_event, showArchived) => { return []; } + // Pick up folders changed while the app was closed, or never indexed by an + // older build, so sessions/worktrees don't silently go missing. Stat-gated, + // so it's cheap when nothing has changed. + reconcileCacheFromFilesystem(); return buildProjectsFromCache(showArchived); } catch (err) { console.error('Error listing projects:', err); diff --git a/session-cache.js b/session-cache.js index f066004..139cf85 100644 --- a/session-cache.js +++ b/session-cache.js @@ -153,18 +153,32 @@ function refreshFolder(folder) { setFolderMeta(folder, projectPath, getFolderIndexMtimeMs(folderPath)); } -/** Populate entire cache from filesystem (cold start) */ -function populateCacheFromFilesystem() { +/** + * Reconcile the cache with the filesystem. + * + * Re-indexes only folders that are new or whose newest .jsonl is newer than what + * we last indexed — a cheap, stat-only gate when nothing changed. This is what + * keeps sessions from silently going missing: a project folder that changed while + * the app was closed, or that predates the build which first indexed it, is + * otherwise never picked up, because the cold-start full scan + * (populateCacheViaWorker) only runs when the cache is completely empty. + */ +function reconcileCacheFromFilesystem() { try { + const metaMap = getAllFolderMeta(); const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name !== '.git') .map(d => d.name); for (const folder of folders) { - refreshFolder(folder); + const meta = metaMap.get(folder); + const folderPath = path.join(PROJECTS_DIR, folder); + if (!meta || getFolderIndexMtimeMs(folderPath) > (meta.indexMtimeMs || 0)) { + refreshFolder(folder); + } } } catch (err) { - console.error('Error populating cache:', err); + console.error('Error reconciling cache:', err); } } @@ -383,7 +397,7 @@ module.exports = { readSessionFile, readFolderFromFilesystem, refreshFolder, - populateCacheFromFilesystem, + reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, diff --git a/test/reconcile-cache.test.js b/test/reconcile-cache.test.js new file mode 100644 index 0000000..8e33f19 --- /dev/null +++ b/test/reconcile-cache.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const sessionCache = require('../session-cache'); +const { getFolderIndexMtimeMs } = require('../folder-index-state'); + +// Minimal valid session transcript: one line carries `cwd` (for deriveProjectPath) +// and a user message (so readSessionFile yields a non-null session). +function writeSession(folderPath, cwd) { + fs.mkdirSync(folderPath, { recursive: true }); + const line = JSON.stringify({ type: 'user', cwd, message: { role: 'user', content: 'hello' } }); + fs.writeFileSync(path.join(folderPath, 'session.jsonl'), line + '\n', 'utf8'); +} + +// In-memory fake of the db layer that init() expects, recording which folders +// actually got (re)indexed (i.e. had refreshFolder do work and upsert sessions). +function makeFakeDb(metaMap) { + const indexedFolders = new Set(); + return { + indexedFolders, + db: { + deleteCachedFolder() {}, + getCachedByFolder() { return []; }, + upsertCachedSessions(sessions) { for (const s of sessions) indexedFolders.add(s.folder); }, + deleteCachedSession() {}, + deleteSearchFolder() {}, + deleteSearchSession() {}, + upsertSearchEntries() {}, + setFolderMeta(folder, projectPath, indexMtimeMs) { metaMap.set(folder, { folder, projectPath, indexMtimeMs }); }, + getAllFolderMeta() { return metaMap; }, + getAllMeta() { return new Map(); }, + getAllCached() { return []; }, + getSetting() { return {}; }, + getMeta() { return null; }, + setName() {}, + }, + }; +} + +test('reconcileCacheFromFilesystem indexes new and stale folders but skips up-to-date ones', () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-reconcile-')); + try { + // never-indexed (no meta), stale (meta older than disk), and up-to-date folders + writeSession(path.join(projectsDir, 'proj-new'), '/tmp/proj-new'); + writeSession(path.join(projectsDir, 'proj-stale'), '/tmp/proj-stale'); + writeSession(path.join(projectsDir, 'proj-current'), '/tmp/proj-current'); + + const metaMap = new Map(); + metaMap.set('proj-stale', { folder: 'proj-stale', projectPath: '/tmp/proj-stale', indexMtimeMs: 0 }); + metaMap.set('proj-current', { + folder: 'proj-current', projectPath: '/tmp/proj-current', + indexMtimeMs: getFolderIndexMtimeMs(path.join(projectsDir, 'proj-current')), + }); + + const fake = makeFakeDb(metaMap); + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: console, + db: fake.db, + }); + + sessionCache.reconcileCacheFromFilesystem(); + + assert.ok(fake.indexedFolders.has('proj-new'), 'new folder should be indexed'); + assert.ok(fake.indexedFolders.has('proj-stale'), 'stale folder (older indexMtimeMs) should be re-indexed'); + assert.ok(!fake.indexedFolders.has('proj-current'), 'up-to-date folder should be skipped'); + } finally { + fs.rmSync(projectsDir, { recursive: true, force: true }); + } +});