@threadbase-sh/streamer 1.24.4 → 1.24.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3807,6 +3807,61 @@ var ConversationCache = class _ConversationCache {
3807
3807
  }
3808
3808
  return ghosts;
3809
3809
  }
3810
+ /**
3811
+ * Reconcile the cache against the authoritative set of conversation file
3812
+ * paths a fresh scan surfaced: drop any cached row whose `file_path` is not
3813
+ * in `livePaths` (removed from disk, or now filtered out — e.g. became an
3814
+ * agent JSONL). This is the "removed conversations" half of a ?refresh=1
3815
+ * reconcile; the additions/updates half is upsertFromScannerMeta.
3816
+ *
3817
+ * Skip semantics depend on whether the file still exists on disk:
3818
+ * - File GONE from disk → always removed, tail or not. This matches the old
3819
+ * invalidate()+rebuild behavior (a deleted conversation must disappear on
3820
+ * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
3821
+ * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
3822
+ * so their cached history stays viewable on a background prune. refresh=1
3823
+ * has the opposite contract (mobile relies on removals being reflected), so
3824
+ * do not "unify" the two — they serve different purposes.
3825
+ * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
3826
+ * the scan snapshot predates a just-created (and now live-tailed) file.
3827
+ * A tailed row here is actively maintained from real content, so it is
3828
+ * kept — dropping it would flicker the active conversation out of
3829
+ * /api/conversations. An untailed on-disk row not in the snapshot is a
3830
+ * transient scan/discovery gap; it is left alone (not removed) and the
3831
+ * next reconcile picks it up, rather than risk removing a real file the
3832
+ * scan simply hasn't surfaced yet.
3833
+ * Returns the removed IDs.
3834
+ */
3835
+ reconcileDeletions(livePaths, opts) {
3836
+ const exists = opts?.exists ?? import_fs8.existsSync;
3837
+ const rows = this.stmts.allFilePaths.all();
3838
+ const removed = [];
3839
+ const drop = this.db.transaction((ids) => {
3840
+ for (const id of ids) {
3841
+ this.stmts.deleteTailById.run(id);
3842
+ this.stmts.deleteById.run(id);
3843
+ }
3844
+ });
3845
+ for (const row of rows) {
3846
+ if (livePaths.has(row.file_path)) continue;
3847
+ if (exists(row.file_path)) continue;
3848
+ removed.push(row.id);
3849
+ }
3850
+ if (removed.length > 0) {
3851
+ drop(removed);
3852
+ if (this.fileIndexLoaded) {
3853
+ for (const id of removed) {
3854
+ for (const [fp, cid] of this.fileIndex) {
3855
+ if (cid === id) {
3856
+ this.fileIndex.delete(fp);
3857
+ break;
3858
+ }
3859
+ }
3860
+ }
3861
+ }
3862
+ }
3863
+ return removed;
3864
+ }
3810
3865
  };
3811
3866
 
3812
3867
  // src/db/repositories/cacheMetadata.repository.ts
@@ -4884,6 +4939,14 @@ var StreamerServer = class {
4884
4939
  // window so the self-healing kickstart-relaunch race doesn't spam warn.
4885
4940
  binding = false;
4886
4941
  cacheReady = false;
4942
+ // Every fire-and-forget task that runs a scan and then writes to this.cache
4943
+ // in an async continuation (startup warm-up, background count refresh, …).
4944
+ // close() awaits all of them before closing this.cache, so a scan's post-scan
4945
+ // cache writes (upsertFromScannerMeta / populateTailFromFile / pruneGhostFiles
4946
+ // / reconcileDeletions) can never hit a cache.db that was already closed
4947
+ // ("database connection is not open"), which would otherwise leave the cache
4948
+ // empty. Register via trackCacheWrite(); each entry removes itself on settle.
4949
+ inFlightCacheWrites = /* @__PURE__ */ new Set();
4887
4950
  apiKey;
4888
4951
  apiKeySource;
4889
4952
  localNoAuth;
@@ -5452,6 +5515,7 @@ var StreamerServer = class {
5452
5515
  });
5453
5516
  }
5454
5517
  });
5518
+ this.trackCacheWrite(warmUp);
5455
5519
  if (opts?.awaitReady) await warmUp;
5456
5520
  }
5457
5521
  // Bind the HTTP listener, retrying on a transient EADDRINUSE. See the call
@@ -5499,11 +5563,23 @@ var StreamerServer = class {
5499
5563
  }
5500
5564
  }
5501
5565
  }
5566
+ // Register a fire-and-forget task that writes to this.cache after a scan, so
5567
+ // close() can await it before closing cache.db. Removes itself on settle. The
5568
+ // caller keeps its own error handling; this wrapper swallows rejections so a
5569
+ // failed task never rejects close()'s Promise.all.
5570
+ trackCacheWrite(task) {
5571
+ const guarded = task.catch(() => void 0);
5572
+ this.inFlightCacheWrites.add(guarded);
5573
+ void guarded.finally(() => {
5574
+ this.inFlightCacheWrites.delete(guarded);
5575
+ });
5576
+ }
5502
5577
  async close() {
5503
5578
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
5504
5579
  this.ptyGraceTimers.clear();
5505
5580
  this.markScannerStaleDebounced.cancel();
5506
- for (const s of this.allScanners) s.close();
5581
+ await Promise.all([...this.inFlightCacheWrites]);
5582
+ await Promise.all([...this.allScanners].map((s) => s.close()));
5507
5583
  this.allScanners.clear();
5508
5584
  this.scanner = null;
5509
5585
  this.cache?.close();
@@ -5643,12 +5719,23 @@ var StreamerServer = class {
5643
5719
  const project = url.searchParams.get("project") ?? void 0;
5644
5720
  const providerFilter = url.searchParams.get("provider") ?? void 0;
5645
5721
  const bustCache = url.searchParams.get("refresh") === "1";
5646
- if (bustCache) {
5647
- this.cache?.invalidate();
5648
- this.scanner = null;
5649
- this.scannerReady = null;
5722
+ if (bustCache && this.cache) {
5723
+ const scanner2 = await this.rescanForRefresh();
5724
+ const metas2 = [...scanner2.getMetadataCache().values()];
5725
+ try {
5726
+ this.cache.upsertFromScannerMeta(metas2);
5727
+ const livePaths = new Set(
5728
+ metas2.map((m) => m.filePath).filter((p) => Boolean(p))
5729
+ );
5730
+ this.cache.reconcileDeletions(livePaths);
5731
+ } catch (err) {
5732
+ this.log.warn(
5733
+ `refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
5734
+ { event: "conversations.reconcile_failed" }
5735
+ );
5736
+ }
5650
5737
  }
5651
- if (this.cache && !bustCache) {
5738
+ if (this.cache) {
5652
5739
  const { conversations, total: total2 } = this.cache.listConversations({
5653
5740
  project,
5654
5741
  provider: providerFilter,
@@ -5713,12 +5800,6 @@ var StreamerServer = class {
5713
5800
  };
5714
5801
  });
5715
5802
  json(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
5716
- if (this.cache && bustCache) {
5717
- try {
5718
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5719
- } catch {
5720
- }
5721
- }
5722
5803
  }
5723
5804
  async handleConversationsCount(url, res) {
5724
5805
  const project = url.searchParams.get("project") ?? void 0;
@@ -5747,19 +5828,21 @@ var StreamerServer = class {
5747
5828
  // later count reflects new/removed conversations. Never awaited by the request
5748
5829
  // path — refresh=1 returns the cached total synchronously and this catches up.
5749
5830
  refreshCountInBackground() {
5750
- void (async () => {
5751
- try {
5752
- const scanner = await this.getFreshScanner();
5753
- if (this.cache) {
5754
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5831
+ this.trackCacheWrite(
5832
+ (async () => {
5833
+ try {
5834
+ const scanner = await this.getFreshScanner();
5835
+ if (this.cache) {
5836
+ this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5837
+ }
5838
+ } catch (err) {
5839
+ this.log.warn(
5840
+ `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
5841
+ { event: "count.refresh_failed" }
5842
+ );
5755
5843
  }
5756
- } catch (err) {
5757
- this.log.warn(
5758
- `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
5759
- { event: "count.refresh_failed" }
5760
- );
5761
- }
5762
- })();
5844
+ })()
5845
+ );
5763
5846
  }
5764
5847
  handleSessionsCount(res) {
5765
5848
  json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
@@ -5872,6 +5955,30 @@ var StreamerServer = class {
5872
5955
  this.scannerReady = null;
5873
5956
  return this.getScanner();
5874
5957
  }
5958
+ // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5959
+ // survive, so classify() still skips unchanged files) and re-run its scan
5960
+ // with fullRescan:true — the escape hatch that bypasses the scanner's
5961
+ // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5962
+ // the "don't trust the gate, check disk for real" signal. Unlike
5963
+ // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
5964
+ // only ever reassigned to a live scan promise (never nulled mid-scan), so the
5965
+ // getScanner() anti-infinite-loop guard is preserved.
5966
+ async rescanForRefresh() {
5967
+ if (this.scannerReady) await this.scannerReady;
5968
+ this.scannerStale = false;
5969
+ if (!this.scanner) {
5970
+ this.scanner = new import_scanner2.ConversationScanner();
5971
+ this.allScanners.add(this.scanner);
5972
+ }
5973
+ const scanner = this.scanner;
5974
+ this.scannerReady = scanner.scan({
5975
+ ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
5976
+ ...this.codexScanOpts(),
5977
+ fullRescan: true
5978
+ });
5979
+ await this.scannerReady;
5980
+ return scanner;
5981
+ }
5875
5982
  findJsonlPath(uuid) {
5876
5983
  const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
5877
5984
  if (!(0, import_fs12.existsSync)(projectsDir)) return null;