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 }); + } +});