forked from doctly/switchboard
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsession-cache.js
More file actions
691 lines (640 loc) · 28.9 KB
/
Copy pathsession-cache.js
File metadata and controls
691 lines (640 loc) · 28.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
const path = require('path');
const fs = require('fs');
const { Worker } = require('worker_threads');
const { getFolderIndexMtimeMs } = require('./folder-index-state');
const { deriveProjectPath } = require('./derive-project-path');
const { readSessionFile, readSessionDisplayHeader, enumerateSessionFiles, resolveJsonlPath } = require('./read-session-file');
const { encodeProjectPath, decodeProjectFolderBestEffort } = require('./encode-project-path');
/**
* Session cache module.
* Call init(ctx) once with the shared context object.
*/
let PROJECTS_DIR, activeSessions, getMainWindow, log;
let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, touchCachedModified, replaceSessionMetrics;
let deleteSearchFolder, deleteSearchSession, upsertSearchEntries;
let setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName;
let isInitialScanComplete, setInitialScanComplete;
function init(ctx) {
PROJECTS_DIR = ctx.PROJECTS_DIR;
activeSessions = ctx.activeSessions;
getMainWindow = ctx.getMainWindow;
log = ctx.log;
// DB functions
deleteCachedFolder = ctx.db.deleteCachedFolder;
getCachedByFolder = ctx.db.getCachedByFolder;
upsertCachedSessions = ctx.db.upsertCachedSessions;
touchCachedModified = ctx.db.touchCachedModified;
deleteCachedSession = ctx.db.deleteCachedSession;
replaceSessionMetrics = ctx.db.replaceSessionMetrics;
deleteSearchFolder = ctx.db.deleteSearchFolder;
deleteSearchSession = ctx.db.deleteSearchSession;
upsertSearchEntries = ctx.db.upsertSearchEntries;
setFolderMeta = ctx.db.setFolderMeta;
getFolderMeta = ctx.db.getFolderMeta;
getAllFolderMeta = ctx.db.getAllFolderMeta;
getAllMeta = ctx.db.getAllMeta;
getAllCached = ctx.db.getAllCached;
getSetting = ctx.db.getSetting;
getMeta = ctx.db.getMeta;
setName = ctx.db.setName;
isInitialScanComplete = ctx.db.isInitialScanComplete;
setInitialScanComplete = ctx.db.setInitialScanComplete;
}
// readSessionFile is imported from read-session-file.js (shared with worker)
/** Read one folder from filesystem by scanning .jsonl files directly */
function readFolderFromFilesystem(folder) {
const folderPath = path.join(PROJECTS_DIR, folder);
const projectPath = deriveProjectPath(folderPath, folder);
if (!projectPath) return { projectPath: null, sessions: [] };
const sessions = [];
for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) {
const s = readSessionFile(filePath, folder, projectPath, { parentSessionId });
if (s) sessions.push(s);
}
return { projectPath, sessions };
}
/** Refresh a single folder incrementally: only re-read changed/new .jsonl files.
*
* @param {string} folder folder name relative to PROJECTS_DIR
* @param {object} [opts]
* @param {Set<string>|null} [opts.files] if provided, ONLY scan these on-disk
* relative paths within the folder instead of walking everything. Used by the
* fs.watch flush to avoid statSync'ing thousands of files when only a handful
* of subagent transcripts were appended. When null/undefined, walk the whole
* folder (used for bootstrap and folder-level events).
*/
function refreshFolder(folder, opts = {}) {
const folderPath = path.join(PROJECTS_DIR, folder);
if (!fs.existsSync(folderPath)) {
deleteCachedFolder(folder);
return;
}
// Reuse the previously-derived projectPath when its directory still exists.
// deriveProjectPath reads session JSONL heads, and refreshFolder runs on
// every watcher flush -- deriving each time is wasted I/O on hot folders.
// A vanished directory falls through to a fresh derive so the missing-
// project remap detection keeps working.
const knownMeta = getFolderMeta ? getFolderMeta(folder) : null;
let projectPath = knownMeta && knownMeta.projectPath && fs.existsSync(knownMeta.projectPath)
? knownMeta.projectPath
: null;
if (!projectPath) projectPath = deriveProjectPath(folderPath, folder);
if (!projectPath) {
setFolderMeta(folder, null, getFolderIndexMtimeMs(folderPath));
return;
}
// Get what's currently cached for this folder.
// cachedMap: DB sessionId -> { modified, filePath } so we can do mtime comparison
// even for subagents whose DB sessionId differs from the on-disk filename.
// filePathToDbId: inverted index so the per-file lookup is O(1) -- without it,
// refreshing a folder with N cached sessions costs O(N^2) per flush (the watcher
// fires frequently while live Claude sessions append JSONL, freezing the main
// process for folders with thousands of subagents).
const cachedSessions = getCachedByFolder(folder);
const cachedMap = new Map();
const filePathToDbId = new Map();
for (const row of cachedSessions) {
const filePath = resolveJsonlPath(PROJECTS_DIR, row);
// Keep the full row so refresh can merge display-only header updates with
// unchanged fields (created, messageCount, textContent) without re-reading
// the file body.
cachedMap.set(row.sessionId, { ...row, filePath });
filePathToDbId.set(filePath, row.sessionId);
}
// Targeted refresh: walk only the files the watcher said changed, not the
// entire folder. Skips enumerateSessionFiles (which does many readdirSyncs on
// every subagent subdir) and only stats the dirty files. Falls back to full
// walk when opts.files is omitted (bootstrap / folder-level events / cold
// delete-detection).
const targeted = opts.files instanceof Set && opts.files.size > 0;
let filesToScan;
if (targeted) {
filesToScan = [];
for (const rel of opts.files) {
const filePath = path.join(folderPath, rel);
// Derive parentSessionId for subagent paths: <folder>/<parent>/subagents/agent-X.jsonl
const parts = rel.split(path.sep);
let parentSessionId = null;
if (parts.length === 3 && parts[1] === 'subagents') {
parentSessionId = parts[0];
} else if (parts.length === 2) {
// legacy <folder>/<parent>/agent-X.jsonl layout (no subagents/ subdir)
parentSessionId = parts[0];
}
filesToScan.push({ filePath, parentSessionId });
}
} else {
filesToScan = enumerateSessionFiles(folderPath);
}
const currentIds = new Set();
let changed = false;
// Collect all changes first, then batch DB writes to minimize lock duration
const sessionsToUpsert = [];
const searchEntriesToUpsert = [];
const namesToSet = [];
const sessionsToDelete = [];
// Refresh strategy:
// - NEW file (no cache row): full readSessionFile -- small at first turn,
// seeds session_cache + FTS body in one shot.
// - EXISTING file (already cached): header-only read (~256 KB / 500 lines).
// Updates display fields (summary, slug, titles, mtime) without reading
// the full body. Avoids re-reading 200+ MB live host-session JSONLs on
// every watcher flush. Side-effect: FTS body for live sessions goes
// stale until the next cold-start (acceptable trade-off).
// - Header read failing (truncated chunk, partial JSON): fall back to a
// mtime-only DB touch so the sidebar still reflects activity.
for (const { filePath, parentSessionId } of filesToScan) {
let stat;
try { stat = fs.statSync(filePath); } catch { continue; }
const fileMtime = stat.mtime.toISOString();
const cachedDbId = filePathToDbId.get(filePath) || null;
const cachedEntry = cachedDbId ? cachedMap.get(cachedDbId) : null;
if (cachedDbId !== null) currentIds.add(cachedDbId);
// Invalidation compares against the dedicated fileMtime column, not
// `modified`. Upstream split the two (v7 migration): `modified` now holds
// the last *message* timestamp for display, so it no longer tracks the
// file's mtime and can't serve as the change-detection key. Comparing
// `modified` here would miss on nearly every row and re-read every dirty
// file on every watcher flush.
if (cachedEntry && cachedEntry.fileMtime === fileMtime) {
continue; // unchanged, skip
}
if (cachedEntry) {
// EXISTING -- header-only refresh.
const h = readSessionDisplayHeader(filePath, { parentSessionId });
if (h) {
// Merge: keep cached body/messageCount/created, overlay fresh display fields.
const merged = {
...cachedEntry,
folder, projectPath,
summary: h.summary || cachedEntry.summary,
firstPrompt: h.firstPrompt || cachedEntry.firstPrompt,
// The header read only sees the first 256 KB of the transcript, so it
// cannot know the last message timestamp that upstream's `modified`
// now means. Keep bumping `modified` to the file mtime here so live
// sessions still sort to the top of the sidebar; the cold-start full
// read (readSessionFile) corrects it to the real last-message time.
// Same class of trade-off as the stale-FTS-body note above.
modified: fileMtime,
fileMtime,
slug: h.slug || cachedEntry.slug,
aiTitle: h.aiTitle || cachedEntry.aiTitle,
parentSessionId: h.parentSessionId || cachedEntry.parentSessionId,
agentId: h.agentId || cachedEntry.agentId,
subagentType: h.subagentType || cachedEntry.subagentType,
description: h.description || cachedEntry.description,
};
sessionsToUpsert.push(merged);
if (h.customTitle && h.customTitle !== cachedEntry.customTitle) {
namesToSet.push({ id: merged.sessionId, name: h.customTitle });
}
} else {
// Header read couldn't extract signal -- just bump mtime so sort order
// stays current. fileMtime has to move too, otherwise this file stays
// permanently "dirty" and gets re-read on every watcher flush.
touchCachedModified(cachedDbId, fileMtime);
cachedEntry.modified = fileMtime;
cachedEntry.fileMtime = fileMtime;
}
changed = true;
continue;
}
// NEW file -- full readSessionFile so the FTS index gets seeded.
const s = readSessionFile(filePath, folder, projectPath, { parentSessionId });
if (s) {
currentIds.add(s.sessionId);
sessionsToUpsert.push(s);
// Per-(date,model) metrics only exist on the full-read path. The header-only
// refresh branch above doesn't produce dailyMetrics, so this is the sole
// write point for an incremental refresh -- short transaction, fine to run
// outside the upsert batch.
replaceSessionMetrics(s.sessionId, s.dailyMetrics);
// Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title.
// Only customTitle (Claude /title) promotes to session_meta.name -- AI titles must NEVER
// be written there or they'd overwrite the user's UI rename on the next index pass.
const name = getMeta(s.sessionId)?.name || s.customTitle || s.aiTitle || '';
searchEntriesToUpsert.push({
id: s.sessionId, type: 'session', folder: s.folder,
title: (name ? name + ' ' : '') + s.summary, body: s.textContent,
});
if (s.customTitle) namesToSet.push({ id: s.sessionId, name: s.customTitle });
}
changed = true;
}
// Remove sessions whose .jsonl files were deleted. Skip in targeted mode --
// we only stat'd the dirty files, so cachedMap entries not in currentIds
// weren't checked and may still exist on disk. Targeted-mode deletions are
// handled by the watcher path-stat: missing files surface via statSync's
// ENOENT in the loop above and produce no upsert. A full walk picks up any
// drift on the next folder-level event.
if (!targeted) {
for (const sessionId of cachedMap.keys()) {
if (!currentIds.has(sessionId)) {
sessionsToDelete.push(sessionId);
changed = true;
}
}
} else {
// Targeted mode still needs to delete entries for files explicitly deleted
// in this flush -- detected by statSync failing on a path we tried to scan.
for (const { filePath } of filesToScan) {
const dbId = filePathToDbId.get(filePath);
if (!dbId) continue;
try { fs.statSync(filePath); } catch {
sessionsToDelete.push(dbId);
changed = true;
}
}
}
// Batch all DB writes to reduce lock contention
if (sessionsToUpsert.length > 0) {
upsertCachedSessions(sessionsToUpsert);
}
for (const entry of searchEntriesToUpsert) {
deleteSearchSession(entry.id);
}
if (searchEntriesToUpsert.length > 0) {
upsertSearchEntries(searchEntriesToUpsert);
}
for (const { id, name } of namesToSet) {
setName(id, name);
}
for (const sessionId of sessionsToDelete) {
deleteCachedSession(sessionId);
deleteSearchSession(sessionId);
}
// Update folder mtime
setFolderMeta(folder, projectPath, getFolderIndexMtimeMs(folderPath));
}
/**
* 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.
*
* Throttled: the renderer's loadProjects() fires get-projects twice per sidebar
* paint (showArchived false/true via Promise.all), which would run the sweep
* back-to-back. The second pass is idempotent but wasted work -- and skipping it
* keeps a single metaMap snapshot per paint. Changes landing inside the
* throttle window are still picked up by the live watcher.
*/
// 5s is plenty for sidebar freshness -- the live watcher picks up real-time
// changes; this guard only prevents redundant readdir sweeps triggered by
// the double loadProjects() call per sidebar paint. Raising from 1s to 5s
// cuts idle readdirSync churn by 5x with no user-visible staleness.
const RECONCILE_THROTTLE_MS = 5000;
let lastReconcileAt = 0;
function reconcileCacheFromFilesystem() {
const now = Date.now();
if (now - lastReconcileAt < RECONCILE_THROTTLE_MS) return;
lastReconcileAt = now;
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) {
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 reconciling cache:', err);
}
}
/** Build projects response from cached data */
function buildProjectsFromCache(showArchived) {
const metaMap = getAllMeta();
const cachedRows = getAllCached();
const global = getSetting('global') || {};
const hiddenProjects = new Set(global.hiddenProjects || []);
// Group by projectPath, not on-disk folder name. Multiple ~/.claude/projects/<folder>/
// directories can resolve to the same projectPath (Claude Code's folder-name encoding
// scheme has changed over time, leaving legacy stragglers around), so we merge them into
// a single sidebar group to avoid duplicate-id collisions in the morphdom render.
// Only insert a project entry once we have a session that survives the archive filter --
// otherwise folders whose sessions are all archived would appear in the sidebar as
// undismissable phantom entries.
const projectMap = new Map();
for (const row of cachedRows) {
if (!row.projectPath) continue;
if (hiddenProjects.has(row.projectPath)) continue;
const meta = metaMap.get(row.sessionId);
const s = {
sessionId: row.sessionId,
summary: row.summary,
firstPrompt: row.firstPrompt,
created: row.created,
modified: row.modified,
messageCount: row.messageCount,
projectPath: row.projectPath,
slug: row.slug || null,
aiTitle: row.aiTitle || null,
parentSessionId: row.parentSessionId || null,
agentId: row.agentId || null,
subagentType: row.subagentType || null,
description: row.description || null,
name: meta?.name || null,
starred: meta?.starred || 0,
archived: meta?.archived || 0,
};
if (!showArchived && s.archived) continue;
if (!projectMap.has(row.projectPath)) {
projectMap.set(row.projectPath, {
folder: encodeProjectPath(row.projectPath),
projectPath: row.projectPath,
sessions: [],
missing: !fs.existsSync(row.projectPath),
});
}
projectMap.get(row.projectPath).sessions.push(s);
}
// Include empty project directories (no sessions yet). Resolve folder->projectPath
// through cache_meta (populated by the indexer) instead of re-reading a JSONL off
// disk for every directory on every render. Fall back to deriveProjectPath only
// for folders the indexer hasn't seen yet, and backfill cache_meta so subsequent
// renders are pure DB reads.
//
// While the INITIAL scan is still incomplete (completeness marker absent),
// that fallback is forbidden: cache_meta is mostly/entirely empty, so
// deriveProjectPath (readdir + up to 256 KB JSONL read) would run for every
// folder under PROJECTS_DIR — twice per sidebar paint (the renderer's
// showArchived false/true Promise.all) — synchronously on the main process,
// undermining the "return whatever's cached, zero per-folder I/O" contract
// of the non-blocking first launch. Use a best-effort decode of the folder
// name instead (display-only, corrected per folder as the scan worker fills
// cache_meta and fires projects-changed) and skip the fs.existsSync missing
// probe for the same zero-I/O reason. Nothing is written to cache_meta on
// this path — a lossy guess must never shadow the real derived path.
try {
const scanComplete = isInitialScanComplete ? isInitialScanComplete() : true;
const folderMeta = getAllFolderMeta();
const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name !== '.git');
for (const d of dirs) {
let projectPath = folderMeta.get(d.name)?.projectPath;
let placeholder = false;
if (!projectPath) {
if (scanComplete) {
projectPath = deriveProjectPath(path.join(PROJECTS_DIR, d.name), d.name);
if (projectPath) setFolderMeta(d.name, projectPath, 0);
} else {
projectPath = decodeProjectFolderBestEffort(d.name);
placeholder = true;
}
}
if (!projectPath) continue;
if (hiddenProjects.has(projectPath)) continue;
if (!projectMap.has(projectPath)) {
projectMap.set(projectPath, {
// For a placeholder the on-disk name IS the ground truth — re-encoding
// the lossy decode could diverge from it (>200-char hashed names).
folder: placeholder ? d.name : encodeProjectPath(projectPath),
projectPath,
sessions: [],
missing: placeholder ? false : !fs.existsSync(projectPath),
});
}
}
} catch {}
// Inject active plain terminal sessions so they participate in sorting
for (const [sessionId, session] of activeSessions) {
if (session.exited || !session.isPlainTerminal) continue;
if (!session.projectPath) continue;
if (hiddenProjects.has(session.projectPath)) continue;
if (!projectMap.has(session.projectPath)) {
projectMap.set(session.projectPath, {
folder: encodeProjectPath(session.projectPath),
projectPath: session.projectPath,
sessions: [],
});
}
const proj = projectMap.get(session.projectPath);
if (!proj.sessions.some(s => s.sessionId === sessionId)) {
proj.sessions.push({
sessionId, summary: 'Terminal', firstPrompt: '', projectPath: session.projectPath,
name: null, starred: 0, archived: 0, messageCount: 0,
modified: new Date(session._openedAt).toISOString(),
created: new Date(session._openedAt).toISOString(),
type: 'terminal',
});
}
}
const projects = [];
for (const proj of projectMap.values()) {
proj.sessions.sort((a, b) => new Date(b.modified) - new Date(a.modified));
projects.push(proj);
}
projects.sort((a, b) => {
// Missing projects go to the bottom
if (a.missing && !b.missing) return 1;
if (!a.missing && b.missing) return -1;
// Empty projects go to the bottom
if (a.sessions.length === 0 && b.sessions.length > 0) return 1;
if (b.sessions.length === 0 && a.sessions.length > 0) return -1;
const aDate = a.sessions[0]?.modified || '';
const bDate = b.sessions[0]?.modified || '';
return new Date(bDate) - new Date(aDate);
});
return projects;
}
// Throttle projects-changed IPC: live sessions appending JSONL trigger a flush
// every ~500ms; without throttling the renderer re-runs getProjects + morphdom
// over 100+ items at that cadence, producing visible flicker. Leading-edge fire
// + trailing flush so the first change is instant but subsequent bursts coalesce.
const NOTIFY_THROTTLE_MS = 1500;
let _notifyCooldown = false;
let _notifyPending = false;
function notifyRendererProjectsChanged() {
if (_notifyCooldown) { _notifyPending = true; return; }
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('projects-changed');
}
_notifyCooldown = true;
setTimeout(() => {
_notifyCooldown = false;
if (_notifyPending) {
_notifyPending = false;
notifyRendererProjectsChanged();
}
}, NOTIFY_THROTTLE_MS);
}
function sendStatus(text, type) {
if (text) log.info(`[status] (${type || 'info'}) ${text}`);
const mw = getMainWindow();
if (mw && !mw.isDestroyed()) {
mw.webContents.send('status-update', text, type || 'info');
}
}
// First-run cold-start progress, consumed by the renderer's dismissible
// indexing banner (see public/app.js). Deliberately separate from sendStatus:
// status-update already fires unconditionally on every populateCacheViaWorker
// run (cold start AND warm-start rebuilds AND manual rebuild-cache clicks) to
// feed the small, always-on statusBarActivity text -- overloading that stream
// to also drive a prominent banner would require the renderer to distinguish
// "genuine first run" from "routine background rebuild" by string-matching a
// human-readable message, which breaks the moment the wording changes.
// sendIndexingProgress instead only ever fires when populateCacheViaWorker
// captured coldStart=true at call time, so the banner's visibility is a pure
// function of "did I receive one of these events" -- never a warm-start flash.
function sendIndexingProgress(payload) {
const mw = getMainWindow();
if (mw && !mw.isDestroyed()) {
mw.webContents.send('indexing-progress', payload);
}
}
// --- Worker-based cache population ---
// Returns a Promise that resolves when the in-flight scan finishes. Concurrent
// callers share the same Promise so the first get-projects after a migration
// can await it instead of seeing an empty list.
let populatePromise = null;
function populateCacheViaWorker() {
if (populatePromise) return populatePromise;
// Captured once, up front. Keyed on the persistent completeness marker, not
// on isCachePopulated(): the worker streams per-folder writes, so a scan
// interrupted mid-run leaves session_cache non-empty — a row-count check
// would classify the resumed scan as warm and silently drop its banner.
// Marker absent covers both a genuine first launch AND the resume after an
// interruption; it flips true only via setInitialScanComplete() on the
// worker's final successful done message below.
const coldStart = !isInitialScanComplete();
sendStatus('Scanning projects…', 'active');
let scannedFolders = 0;
let totalFolders = 0;
let sessionCount = 0;
let indexedProjects = 0;
// ~4 events/s: one IPC message per folder is wasteful on a large tree (the
// comparable notifyRendererProjectsChanged is throttled too). The FIRST
// event (lastProgressAt still 0) and every done event always pass, so the
// banner appears immediately and never misses the terminal state.
const PROGRESS_THROTTLE_MS = 250;
let lastProgressAt = 0;
const reportProgress = (done, error) => {
if (!coldStart) return;
const now = Date.now();
if (!done && lastProgressAt !== 0 && now - lastProgressAt < PROGRESS_THROTTLE_MS) return;
lastProgressAt = now;
sendIndexingProgress({
coldStart: true,
current: scannedFolders,
total: totalFolders,
sessionsSoFar: sessionCount,
done,
...(error ? { error } : {}),
});
};
populatePromise = new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
populatePromise = null;
resolve();
};
const worker = new Worker(path.join(__dirname, 'workers', 'scan-projects.js'), {
workerData: { projectsDir: PROJECTS_DIR },
});
worker.on('message', (msg) => {
// One folder finished scanning. Write it to the DB immediately instead
// of waiting for the rest of the tree, and push a sidebar update
// (notifyRendererProjectsChanged is already throttled ~1.5s) so a large
// history fills in progressively rather than sitting empty for minutes.
if (msg.type === 'folder') {
scannedFolders = msg.current;
totalFolders = msg.total;
const r = msg.result;
if (r) {
const { folder, projectPath, sessions, indexMtimeMs } = r;
deleteCachedFolder(folder);
deleteSearchFolder(folder);
if (sessions.length > 0) {
sessionCount += sessions.length;
indexedProjects++;
upsertCachedSessions(sessions);
for (const s of sessions) {
// Only JSONL custom-title (genuine user title) promotes to the DB name column.
// AI titles must not -- see refreshFolder for the rationale.
if (s.customTitle) setName(s.sessionId, s.customTitle);
// Worker called readSessionFile, so dailyMetrics is present.
replaceSessionMetrics(s.sessionId, s.dailyMetrics);
}
upsertSearchEntries(sessions.map(s => {
// Search title precedence matches the sidebar: user rename > custom-title > ai-title.
const name = getMeta(s.sessionId)?.name || s.customTitle || s.aiTitle || '';
return {
id: s.sessionId, type: 'session', folder: s.folder,
title: (name ? name + ' ' : '') + s.summary,
body: s.textContent,
};
}));
}
setFolderMeta(folder, projectPath, indexMtimeMs);
}
sendStatus(`Scanning projects (${scannedFolders}/${totalFolders})…`, 'active');
reportProgress(false);
notifyRendererProjectsChanged();
return;
}
// msg.type === 'done'
if (!msg.ok) {
console.error('Worker scan error:', msg.error);
sendStatus('Scan failed: ' + msg.error, 'error');
reportProgress(true, msg.error);
settle();
return;
}
// The scan reached its final message with every folder written: persist
// the completeness marker. This is the ONLY place it is set (besides the
// one-time migration backfill for pre-marker installs) — error/exit
// paths must not set it, so an interrupted or failed scan leaves it
// absent and the next get-projects resumes the background worker
// instead of running the synchronous reconcile sweep on a partial
// cache. Resuming is safe: each folder message above is a
// delete-then-insert (deleteCachedFolder/deleteSearchFolder before the
// upserts), so re-scanning already-written folders never duplicates rows.
if (setInitialScanComplete) setInitialScanComplete();
sendStatus(`Indexed ${sessionCount} sessions across ${indexedProjects} projects`, 'done');
// Clear status after a few seconds
setTimeout(() => sendStatus(''), 5000);
reportProgress(true);
notifyRendererProjectsChanged();
settle();
});
worker.on('error', (err) => {
console.error('Worker error:', err);
sendStatus('Worker error: ' + err.message, 'error');
reportProgress(true, err.message);
settle();
});
// If the worker exits abnormally (SIGSEGV, OOM, uncaught exception) without
// sending a message, neither the 'message' nor 'error' handler will fire.
// Resolve here so awaiters aren't stuck forever and the next call can retry.
worker.on('exit', (code) => {
if (!settled && code !== 0) {
sendStatus('Scan worker exited unexpectedly', 'error');
reportProgress(true, 'worker exited unexpectedly');
}
settle();
});
});
return populatePromise;
}
module.exports = {
init,
readSessionFile,
readFolderFromFilesystem,
refreshFolder,
reconcileCacheFromFilesystem,
buildProjectsFromCache,
notifyRendererProjectsChanged,
sendStatus,
populateCacheViaWorker,
};