Skip to content
Merged
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
16 changes: 9 additions & 7 deletions folder-index-state.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const fs = require('fs');
const path = require('path');
const { enumerateSessionFiles } = require('./read-session-file');

function getFolderIndexMtimeMs(folderPath) {
let indexMtimeMs = 0;
Expand All @@ -10,14 +10,16 @@ function getFolderIndexMtimeMs(folderPath) {
return 0;
}

// Stat every transcript this folder would index — top-level sessions AND
// subagent transcripts under <folder>/<id>/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 {}
}
Expand Down
7 changes: 6 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');

Expand Down Expand Up @@ -73,7 +73,7 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, touchCachedModified,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
Expand Down Expand Up @@ -290,8 +290,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 294 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 294 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


Expand Down Expand Up @@ -644,6 +644,11 @@
// 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);
Expand Down Expand Up @@ -1403,7 +1408,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1411 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1411 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
25 changes: 20 additions & 5 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -526,7 +541,7 @@ module.exports = {
readSessionFile,
readFolderFromFilesystem,
refreshFolder,
populateCacheFromFilesystem,
reconcileCacheFromFilesystem,
buildProjectsFromCache,
notifyRendererProjectsChanged,
sendStatus,
Expand Down
28 changes: 28 additions & 0 deletions test/folder-index-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: <folder>/<parentSessionId>/subagents/agent-<id>.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 });
}
});
77 changes: 77 additions & 0 deletions test/reconcile-cache.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading