Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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);
Expand Down
24 changes: 19 additions & 5 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -383,7 +397,7 @@ module.exports = {
readSessionFile,
readFolderFromFilesystem,
refreshFolder,
populateCacheFromFilesystem,
reconcileCacheFromFilesystem,
buildProjectsFromCache,
notifyRendererProjectsChanged,
sendStatus,
Expand Down
75 changes: 75 additions & 0 deletions test/reconcile-cache.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});