diff --git a/folder-index-state.js b/folder-index-state.js index 7e52debe..2d433b0b 100644 --- a/folder-index-state.js +++ b/folder-index-state.js @@ -1,5 +1,5 @@ const fs = require('fs'); -const path = require('path'); +const { enumerateSessionFiles } = require('./read-session-file'); function getFolderIndexMtimeMs(folderPath) { let indexMtimeMs = 0; @@ -10,14 +10,16 @@ function getFolderIndexMtimeMs(folderPath) { return 0; } + // Stat every transcript this folder would index — top-level sessions AND + // subagent transcripts under //subagents/ — using the same + // enumeration as refreshFolder. Session files are appended in place, which + // bumps the file mtime but often leaves the containing directory mtime + // unchanged; and a folder whose ONLY change was a subagent transcript would + // be missed entirely if we only readdir'd the top level. try { - // Session files are appended in place, which updates the file mtime but - // often leaves the containing directory mtime unchanged. - const entries = fs.readdirSync(folderPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + for (const { filePath } of enumerateSessionFiles(folderPath)) { try { - const fileMtimeMs = fs.statSync(path.join(folderPath, entry.name)).mtimeMs; + const fileMtimeMs = fs.statSync(filePath).mtimeMs; if (fileMtimeMs > indexMtimeMs) indexMtimeMs = fileMtimeMs; } catch {} } diff --git a/main.js b/main.js index 9bae4d77..5fa1d0cf 100644 --- a/main.js +++ b/main.js @@ -290,7 +290,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; const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); @@ -644,6 +644,11 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { // avoid that race, await the scan here so the response carries the // freshly-populated cache. Concurrent callers share the same Promise. await populateCacheViaWorker(); + } else { + // Cache already populated: 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); diff --git a/session-cache.js b/session-cache.js index 291179fd..ccaef66a 100644 --- a/session-cache.js +++ b/session-cache.js @@ -260,18 +260,33 @@ function refreshFolder(folder, opts = {}) { 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 transcript is newer than + * what we last indexed — a cheap, stat-only gate (getFolderIndexMtimeMs vs the + * cached cache_meta.indexMtimeMs) 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); } } @@ -526,7 +541,7 @@ module.exports = { readSessionFile, readFolderFromFilesystem, refreshFolder, - populateCacheFromFilesystem, + reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, diff --git a/test/folder-index-state.test.js b/test/folder-index-state.test.js index aa070b16..17e87350 100644 --- a/test/folder-index-state.test.js +++ b/test/folder-index-state.test.js @@ -26,3 +26,31 @@ test('folder index timestamp advances when an existing session file is appended' fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + +test('folder index timestamp advances when ONLY a subagent transcript changes', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-folder-index-sub-')); + + try { + // Top-level session, untouched after creation. + fs.writeFileSync(path.join(tmpDir, 'parent.jsonl'), '{"type":"user","message":"hi"}\n', 'utf8'); + // Subagent transcript: //subagents/agent-.jsonl + const subagentsDir = path.join(tmpDir, 'parent', 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const agentPath = path.join(subagentsDir, 'agent-1.jsonl'); + fs.writeFileSync(agentPath, '{"type":"user","message":"first"}\n', 'utf8'); + + const before = getFolderIndexMtimeMs(tmpDir); + + await new Promise(resolve => setTimeout(resolve, 1100)); + + // Append ONLY to the subagent transcript — the top-level files and the + // folder dir itself are untouched. A top-level-only stat would miss this. + fs.appendFileSync(agentPath, '{"type":"assistant","message":"second"}\n', 'utf8'); + + const after = getFolderIndexMtimeMs(tmpDir); + + assert.ok(after > before, `expected index mtime to increase from a subagent-only change (${before} -> ${after})`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/test/reconcile-cache.test.js b/test/reconcile-cache.test.js new file mode 100644 index 00000000..e319ef5e --- /dev/null +++ b/test/reconcile-cache.test.js @@ -0,0 +1,77 @@ +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 transcript: a `cwd` line (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 init() expects; records which folders actually +// got (re)indexed (i.e. had refreshFolder do work and upsert sessions). +function makeFakeDb(metaMap) { + const indexed = new Set(); + const noop = () => {}; + return { + indexed, + db: { + deleteCachedFolder: noop, + getCachedByFolder: () => [], + upsertCachedSessions: (sessions) => { for (const s of sessions) indexed.add(s.folder); }, + touchCachedModified: noop, + deleteCachedSession: noop, + replaceSessionMetrics: noop, + deleteSearchFolder: noop, + deleteSearchSession: noop, + upsertSearchEntries: noop, + setFolderMeta: (folder, projectPath, indexMtimeMs) => metaMap.set(folder, { folder, projectPath, indexMtimeMs }), + getAllFolderMeta: () => metaMap, + getAllMeta: () => new Map(), + getAllCached: () => [], + getSetting: () => ({}), + getMeta: () => null, + setName: noop, + }, + }; +} + +test('reconcileCacheFromFilesystem indexes new and stale folders but skips up-to-date ones', () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-reconcile-')); + try { + writeSession(path.join(projectsDir, 'proj-new'), '/tmp/proj-new'); // never indexed (no meta) + writeSession(path.join(projectsDir, 'proj-stale'), '/tmp/proj-stale'); // meta older than disk + writeSession(path.join(projectsDir, 'proj-current'), '/tmp/proj-current'); // meta == disk + + 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.indexed.has('proj-new'), 'new folder should be indexed'); + assert.ok(fake.indexed.has('proj-stale'), 'stale folder (older indexMtimeMs) should be re-indexed'); + assert.ok(!fake.indexed.has('proj-current'), 'up-to-date folder should be skipped'); + } finally { + fs.rmSync(projectsDir, { recursive: true, force: true }); + } +});