From 5007dc38ab84ebe92da2088fa91ddb295eab1c2e Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Thu, 23 Apr 2026 16:06:22 -0400 Subject: [PATCH 1/4] feat(toolpath-desktop): pre-derive recent traces into a two-tier cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a session in Quick View or the Browse "Select →" button used to run derive synchronously on the UI path, producing a noticeable pause. This adds an in-memory + on-disk cache (`src/cache.rs`, `TraceCache`) that the tray poller warms after every 30s scan for each recent claude/pi session. Both the popover's `tray_open_trace` and the main-window `derive_claude` / `derive_pi` IPC commands route through the same cache via `derive_claude_impl` / `derive_pi_impl`, so cached hits short-circuit before any derive work. - Memory tier: `HashMap`, 32-entry LRU, rejected when a warmer is already in flight for the same key. - Disk tier: `/toolpath-desktop/trace-cache/.json`, atomic writes (.tmp + rename), 200-entry cap pruned oldest-first on startup, corrupt files deleted on read. - Freshness: keyed on the source session's `last_activity`. Warmer passes overwrite stale entries; user-initiated derives backfill with an empty timestamp and get replaced on the next poll. Tests rise from 17 to 32 unit tests (new cache-tier tests, cache-hit short-circuits for both providers, prewarm provider routing). --- CLAUDE.md | 6 +- crates/toolpath-desktop/src/cache.rs | 440 ++++++++++++++++++ .../toolpath-desktop/src/commands/derive.rs | 153 +++++- crates/toolpath-desktop/src/main.rs | 11 + crates/toolpath-desktop/src/tray.rs | 169 +++++-- 5 files changed, 733 insertions(+), 46 deletions(-) create mode 100644 crates/toolpath-desktop/src/cache.rs diff --git a/CLAUDE.md b/CLAUDE.md index 41f77c9..574a40b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `toolpath-cli` ha - `toolpath-pi`: ~88 unit tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) - `toolpath-cli`: 126 unit + 24 integration tests (all commands, track sessions, merge, validate, roundtrip, render-md snapshots) -- `toolpath-desktop`: 17 unit tests (IPC command modules — source listing, derive validation, export round-trip, upload stub, keychain input checks; tray activity-window bucketing, stats-snapshot smoke, session-id/basename helpers) +- `toolpath-desktop`: 32 unit tests (IPC command modules — source listing, derive validation + cache-hit short-circuit for claude/pi, export round-trip, upload stub, keychain input checks; tray activity-window bucketing, stats-snapshot smoke, session-id/basename helpers, Quick View provider routing; trace cache memory-tier get/insert/freshness/LRU-eviction/in-flight slots plus disk-tier persistence-across-restart, freshness-across-restart, corrupt-file recovery, disk prune, disk-dir creation fallback) Validate example documents: `for f in examples/*.json; do cargo run -p toolpath-cli -- validate --input "$f"; done` @@ -142,6 +142,10 @@ Menu-bar mode: the app runs as a normal GUI app (Dock icon + app-switcher entry) Opening a trace from the popover: clicking a recent-session row invokes `tray_open_trace { provider, project, session_id }`. The Rust side calls back into the existing `derive_claude` / `derive_pi` commands, shows the main window, and emits a `trace:opened` event to the main window with the derived `{ doc, source, filename }`. `app.svelte` listens for it and dispatches `DeriveSucceeded`, which routes to the preview. Only `claude` and `pi` have derive commands today — rows for `gemini`, `codex`, `opencode` still appear in the list (so users can see activity) but are rendered disabled. +Pre-derive cache: after each 30s poll the tray kicks off background derives for every recent claude/pi session and stashes the result in `src/cache.rs`'s `TraceCache` (shared via `app.manage(Arc)`). Both the popover's `tray_open_trace` and the main-window's `derive_claude` / `derive_pi` commands route through the same cache (via `derive_claude_impl` / `derive_pi_impl` in `commands/derive.rs`), so clicking a session — whether from Quick View or the Browse view's "Select →" button — usually resolves instantly. Cache freshness keys on the source's `last_activity`; when a session gets new turns its cached entry is replaced on the next poll. Cacheable calls are limited to single-session, `include_thinking=false` derives (the shape the poller prewarms). Warm-up runs with at most 2 concurrent threads. + +Two tiers: (1) memory, a `HashMap` capped at 32 entries; (2) disk, under `/toolpath-desktop/trace-cache/.json`, so caches survive app restarts (macOS/Linux eventually clean `/tmp` themselves). Disk is capped at 200 entries, pruned oldest-first by mtime at startup. Memory misses fall through to disk and promote the hit back into memory. Corrupt files are silently deleted on read so a bad write doesn't poison the cache forever. + Streaming pattern (Claude project/session lists): Rust command spawns a thread that emits `claude:project`, `claude:session`, `claude:projects-done`, `claude:sessions-done` events. The Svelte component subscribes with `$effect(() => { listen(...) ... return unlisten; })` — Svelte tears down listeners automatically when the effect's deps change or the component unmounts. Package manager for the frontend is `bun` (installed at `~/.bun/bin/bun`). `bun install` to set up, `bun run check` for `svelte-check`, `bun run build` for a production Vite build. Never commit `node_modules/` or `dist/` — both are ignored. diff --git a/crates/toolpath-desktop/src/cache.rs b/crates/toolpath-desktop/src/cache.rs new file mode 100644 index 0000000..5b304ab --- /dev/null +++ b/crates/toolpath-desktop/src/cache.rs @@ -0,0 +1,440 @@ +//! Two-tier cache of pre-derived traces keyed by (provider, project, session). +//! +//! The tray poller kicks off a background warm-up pass after every 30-second +//! poll: for each recent session on a supported provider it derives the trace +//! ahead of time and stores the result here. When the user clicks a row in the +//! popover the IPC command pulls from the cache instead of re-running derive, +//! so the main window opens essentially instantly. +//! +//! Tier 1 (memory) — a small `HashMap` capped at +//! [`MAX_MEMORY_ENTRIES`], hot path for repeat lookups within a session. +//! +//! Tier 2 (disk) — optional. When `TraceCache::with_disk_dir(...)` is used, +//! entries are also written to `/.json` so they survive app +//! restarts. The disk directory lives under the OS temp dir, which macOS / +//! Linux periodically clean up anyway. On memory miss we fall back to disk and +//! promote the hit into memory. +//! +//! Freshness is keyed on the source session's `last_activity` timestamp — when +//! a session gets new turns its cached entry is treated as stale and re-derived +//! on the next poll. The synchronous click path is willing to serve a mildly +//! stale entry (at worst ~30s behind the next poll); correctness is guaranteed +//! by the next warm-up pass replacing it. +//! +//! The cache is shared via `app.manage(Arc)`. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound on in-memory entries. The popover shows at most 20 recent +/// sessions so this gives one poll's worth of headroom without letting memory +/// grow unbounded as the user's activity churns over days. +const MAX_MEMORY_ENTRIES: usize = 32; + +/// Default cap on on-disk entries. Larger than the memory cap because disk +/// survives across restarts and we want yesterday's sessions still warm. +pub const DEFAULT_MAX_DISK_ENTRIES: usize = 200; + +/// Identifies a single trace. The `project` field is empty for providers that +/// don't key on project (codex/opencode) — currently unused for prewarm since +/// only claude and pi derive from the desktop backend. +#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct CacheKey { + pub provider: String, + pub project: String, + pub session_id: String, +} + +/// Cached derive output plus the `last_activity` it was derived against so the +/// warmer can detect staleness without re-running derive. +/// +/// Only the derived document is cached — source-label / filename strings are +/// cheap to reconstruct at each call site and have slightly different formats +/// for the popover-open vs. main-window-"Select" paths, so they don't belong +/// in shared state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheEntry { + pub doc: Value, + /// The session's `last_activity` at derive time (RFC3339). A newer + /// timestamp in the next poll means the cached entry is stale. Empty when + /// the writer doesn't know it (e.g. a user-initiated derive from the main + /// window) — in that case the next warmer pass will replace it. + pub last_activity: String, +} + +/// Shared, thread-safe trace cache. +#[derive(Debug, Default)] +pub struct TraceCache { + entries: Mutex>, + /// Keys whose warm-up derive is in progress. Prevents the warmer from + /// racing itself when two polls land before the first derive finishes. + in_flight: Mutex>, + /// Root for tier-2 disk caching. `None` disables disk persistence. + disk_dir: Option, +} + +impl TraceCache { + /// Memory-only cache. Useful for tests that don't want disk side effects. + #[cfg(test)] + pub fn new() -> Self { + Self::default() + } + + /// Memory + disk cache. Best-effort — failing to create the directory just + /// downgrades to memory-only without returning an error (startup + /// shouldn't fail because /tmp is weird). + pub fn with_disk_dir(dir: PathBuf) -> Self { + let dir = match fs::create_dir_all(&dir) { + Ok(_) => Some(dir), + Err(_) => None, + }; + Self { + disk_dir: dir, + ..Self::default() + } + } + + /// Fetch a cached entry, if any. Checks memory first, then disk; on a disk + /// hit, promotes the entry into memory so subsequent lookups skip the + /// filesystem. Returns a clone so the caller doesn't hold the lock across + /// IPC serialization. + pub fn get(&self, key: &CacheKey) -> Option { + if let Some(hit) = self.memory_get(key) { + return Some(hit); + } + let entry = self.disk_load(key)?; + self.memory_insert(key.clone(), entry.clone()); + Some(entry) + } + + /// Store (or overwrite) an entry in both memory and disk (if enabled). + pub fn insert(&self, key: CacheKey, entry: CacheEntry) { + self.memory_insert(key.clone(), entry.clone()); + self.disk_save(&key, &entry); + } + + /// Returns true if the cache has a fresh entry for this key — i.e. the + /// stored `last_activity` matches. Used by the warmer to skip keys that + /// are already up to date. + /// + /// Consults disk when memory misses so a freshly-restarted app doesn't + /// re-derive everything already on disk. + pub fn is_fresh(&self, key: &CacheKey, last_activity: &str) -> bool { + self.get(key) + .map(|e| e.last_activity == last_activity) + .unwrap_or(false) + } + + /// Attempt to claim a derive slot for this key. Returns true if the caller + /// now owns the slot and should proceed; false if another warmer already + /// has it in flight. + pub fn try_claim(&self, key: &CacheKey) -> bool { + let Ok(mut s) = self.in_flight.lock() else { + return false; + }; + s.insert(key.clone()) + } + + /// Release a slot claimed via `try_claim`. Call this in every exit path + /// (success or failure) so the next poll can retry on error. + pub fn release(&self, key: &CacheKey) { + if let Ok(mut s) = self.in_flight.lock() { + s.remove(key); + } + } + + /// Number of in-flight warm-ups. Used to cap concurrent derive threads. + pub fn in_flight_count(&self) -> usize { + self.in_flight.lock().map(|s| s.len()).unwrap_or(0) + } + + /// Delete the oldest-mtime cache files when the on-disk count exceeds + /// `max_entries`. Intended to run once at startup. + pub fn prune_disk(&self, max_entries: usize) { + let Some(dir) = &self.disk_dir else { + return; + }; + let Ok(read) = fs::read_dir(dir) else { + return; + }; + let mut files: Vec<(PathBuf, std::time::SystemTime)> = read + .filter_map(|e| e.ok()) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + return None; + } + let mtime = entry.metadata().ok()?.modified().ok()?; + Some((path, mtime)) + }) + .collect(); + if files.len() <= max_entries { + return; + } + files.sort_by(|a, b| a.1.cmp(&b.1)); + for (path, _) in files.iter().take(files.len() - max_entries) { + let _ = fs::remove_file(path); + } + } + + // --- internal helpers ------------------------------------------------- + + fn memory_get(&self, key: &CacheKey) -> Option { + self.entries + .lock() + .ok() + .and_then(|m| m.get(key).cloned()) + } + + fn memory_insert(&self, key: CacheKey, entry: CacheEntry) { + let Ok(mut m) = self.entries.lock() else { + return; + }; + if m.len() >= MAX_MEMORY_ENTRIES + && !m.contains_key(&key) + && let Some(oldest) = m + .iter() + .min_by(|a, b| a.1.last_activity.cmp(&b.1.last_activity)) + .map(|(k, _)| k.clone()) + { + m.remove(&oldest); + } + m.insert(key, entry); + } + + fn disk_path(&self, key: &CacheKey) -> Option { + let dir = self.disk_dir.as_ref()?; + let raw = format!("{}|{}|{}", key.provider, key.project, key.session_id); + Some(dir.join(format!("{:016x}.json", fnv1a_64(&raw)))) + } + + fn disk_load(&self, key: &CacheKey) -> Option { + let path = self.disk_path(key)?; + let bytes = fs::read(&path).ok()?; + match serde_json::from_slice::(&bytes) { + Ok(entry) => Some(entry), + Err(_) => { + // Corrupt or schema-incompatible — drop it so the next derive + // replaces it instead of replaying the error forever. + let _ = fs::remove_file(&path); + None + } + } + } + + fn disk_save(&self, key: &CacheKey, entry: &CacheEntry) { + let Some(path) = self.disk_path(key) else { + return; + }; + if write_atomic(&path, entry).is_err() { + // Best-effort — disk cache is a latency optimisation, not a + // correctness requirement. Clean up any half-written temp file. + if let Some(tmp) = tmp_path(&path) { + let _ = fs::remove_file(tmp); + } + } + } +} + +/// FNV-1a 64-bit. Stable across Rust versions (unlike `DefaultHasher`) so +/// disk entries cached by one build remain valid for the next. +fn fnv1a_64(s: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +fn tmp_path(path: &Path) -> Option { + let mut name = path.file_name()?.to_os_string(); + name.push(".tmp"); + Some(path.with_file_name(name)) +} + +fn write_atomic(path: &Path, entry: &CacheEntry) -> std::io::Result<()> { + let bytes = serde_json::to_vec(entry) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let tmp = tmp_path(path).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "cache path has no file name") + })?; + { + let mut f = fs::File::create(&tmp)?; + f.write_all(&bytes)?; + f.sync_all()?; + } + fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn key(provider: &str, project: &str, id: &str) -> CacheKey { + CacheKey { + provider: provider.into(), + project: project.into(), + session_id: id.into(), + } + } + + fn entry(activity: &str) -> CacheEntry { + CacheEntry { + doc: Value::Null, + last_activity: activity.into(), + } + } + + #[test] + fn get_returns_none_when_missing() { + let cache = TraceCache::new(); + assert!(cache.get(&key("claude", "p", "s")).is_none()); + } + + #[test] + fn insert_then_get_roundtrip() { + let cache = TraceCache::new(); + let k = key("claude", "/proj", "sess-1"); + cache.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + let got = cache.get(&k).expect("entry present"); + assert_eq!(got.last_activity, "2026-04-23T10:00:00Z"); + } + + #[test] + fn is_fresh_matches_activity_timestamp() { + let cache = TraceCache::new(); + let k = key("claude", "/proj", "sess-1"); + cache.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + assert!(cache.is_fresh(&k, "2026-04-23T10:00:00Z")); + assert!(!cache.is_fresh(&k, "2026-04-23T10:05:00Z")); + assert!(!cache.is_fresh(&key("claude", "/proj", "missing"), "2026-04-23T10:00:00Z")); + } + + #[test] + fn try_claim_is_exclusive_until_release() { + let cache = TraceCache::new(); + let k = key("claude", "/proj", "sess-1"); + assert!(cache.try_claim(&k)); + assert!(!cache.try_claim(&k)); + assert_eq!(cache.in_flight_count(), 1); + cache.release(&k); + assert_eq!(cache.in_flight_count(), 0); + assert!(cache.try_claim(&k)); + } + + #[test] + fn insert_evicts_oldest_when_full() { + let cache = TraceCache::new(); + for i in 0..MAX_MEMORY_ENTRIES { + cache.insert( + key("claude", "/p", &format!("s{i:04}")), + // Lexicographic order matches numeric order here. + entry(&format!("2026-04-23T10:{i:02}:00Z")), + ); + } + let hot = key("claude", "/p", "new"); + cache.insert(hot.clone(), entry("2026-04-23T11:00:00Z")); + + // Oldest ("s0000") should be gone; newly inserted should be present. + assert!(cache.memory_get(&key("claude", "/p", "s0000")).is_none()); + assert!(cache.memory_get(&hot).is_some()); + } + + #[test] + fn insert_overwrite_does_not_evict() { + let cache = TraceCache::new(); + let k = key("claude", "/p", "sess"); + cache.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + cache.insert(k.clone(), entry("2026-04-23T10:05:00Z")); + assert_eq!(cache.get(&k).unwrap().last_activity, "2026-04-23T10:05:00Z"); + } + + #[test] + fn disk_persists_across_instances() { + let dir = TempDir::new().unwrap(); + let k = key("claude", "/proj", "sess-1"); + { + let a = TraceCache::with_disk_dir(dir.path().to_path_buf()); + a.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + } + // Fresh instance — nothing in memory, must hit disk. + let b = TraceCache::with_disk_dir(dir.path().to_path_buf()); + assert!(b.memory_get(&k).is_none()); + let hit = b.get(&k).expect("disk hit"); + assert_eq!(hit.last_activity, "2026-04-23T10:00:00Z"); + // And the hit should have been promoted into memory. + assert!(b.memory_get(&k).is_some()); + } + + #[test] + fn disk_is_fresh_matches_across_restart() { + let dir = TempDir::new().unwrap(); + let k = key("pi", "/proj", "abc"); + { + let a = TraceCache::with_disk_dir(dir.path().to_path_buf()); + a.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + } + let b = TraceCache::with_disk_dir(dir.path().to_path_buf()); + assert!(b.is_fresh(&k, "2026-04-23T10:00:00Z")); + assert!(!b.is_fresh(&k, "2026-04-23T10:05:00Z")); + } + + #[test] + fn disk_load_discards_corrupt_file() { + let dir = TempDir::new().unwrap(); + let cache = TraceCache::with_disk_dir(dir.path().to_path_buf()); + let k = key("claude", "/proj", "sess"); + let path = cache.disk_path(&k).expect("disk enabled"); + fs::write(&path, b"{ not valid json").unwrap(); + assert!(cache.disk_load(&k).is_none()); + assert!(!path.exists(), "corrupt file should be removed"); + } + + #[test] + fn prune_disk_evicts_oldest() { + let dir = TempDir::new().unwrap(); + let cache = TraceCache::with_disk_dir(dir.path().to_path_buf()); + for i in 0..5 { + cache.insert( + key("claude", "/p", &format!("s{i}")), + entry(&format!("2026-04-23T10:0{i}:00Z")), + ); + } + cache.prune_disk(2); + let remaining = fs::read_dir(dir.path()) + .unwrap() + .filter(|e| { + e.as_ref() + .unwrap() + .path() + .extension() + .and_then(|s| s.to_str()) + == Some("json") + }) + .count(); + assert_eq!(remaining, 2); + } + + #[test] + fn with_disk_dir_falls_back_to_memory_only_when_dir_unusable() { + // Pointing at a non-directory path — create a file and hand its path. + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("not-a-dir"); + fs::write(&file_path, b"oops").unwrap(); + // `create_dir_all` on an existing file returns an error; cache should + // fall back to memory-only without panicking. + let cache = TraceCache::with_disk_dir(file_path.clone()); + let k = key("claude", "/p", "s"); + cache.insert(k.clone(), entry("2026-04-23T10:00:00Z")); + assert!(cache.get(&k).is_some()); + } +} diff --git a/crates/toolpath-desktop/src/commands/derive.rs b/crates/toolpath-desktop/src/commands/derive.rs index c58793a..3bab4cc 100644 --- a/crates/toolpath-desktop/src/commands/derive.rs +++ b/crates/toolpath-desktop/src/commands/derive.rs @@ -1,5 +1,9 @@ +use std::sync::Arc; + use serde_json::Value; +use tauri::State; +use crate::cache::{CacheEntry, CacheKey, TraceCache}; use crate::commands::keychain; use crate::error::{DesktopError, DesktopResult}; @@ -12,8 +16,15 @@ fn document_to_value(doc: &toolpath::v1::Document) -> DesktopResult { Ok(serde_json::from_str(&json)?) } -#[tauri::command] -pub fn derive_claude( +/// Core claude-derive logic, cache-aware. Used by both the Tauri command +/// wrapper and the tray's popover-open / prewarm paths — having a single +/// implementation guarantees they all share the same cache behaviour. +/// +/// Cache lookup/insert only happens for single-session, no-thinking derives +/// (the shape the tray poller prewarms). Multi-session / thinking-on calls +/// fall through to a fresh derive every time. +pub fn derive_claude_impl( + cache: &TraceCache, project_path: String, session_ids: Vec, include_thinking: bool, @@ -24,6 +35,18 @@ pub fn derive_claude( )); } + let cacheable = session_ids.len() == 1 && !include_thinking; + if cacheable { + let key = CacheKey { + provider: "claude".into(), + project: project_path.clone(), + session_id: session_ids[0].clone(), + }; + if let Some(hit) = cache.get(&key) { + return Ok(hit.doc); + } + } + let manager = toolpath_claude::ClaudeConvo::new(); let config = toolpath_claude::derive::DeriveConfig { project_path: Some(project_path.clone()), @@ -58,11 +81,41 @@ pub fn derive_claude( toolpath::v1::Document::Graph(graph) }; - document_to_value(&document) + let value = document_to_value(&document)?; + + if cacheable { + // Backfill the cache so a subsequent click (e.g. popover opening the + // same session) doesn't re-derive. `last_activity` is unknown here — + // leave it empty and let the next warmer pass replace it with a + // freshness-keyed entry. + cache.insert( + CacheKey { + provider: "claude".into(), + project: project_path, + session_id: session_ids.into_iter().next().unwrap(), + }, + CacheEntry { + doc: value.clone(), + last_activity: String::new(), + }, + ); + } + + Ok(value) } #[tauri::command] -pub fn derive_pi( +pub fn derive_claude( + cache: State<'_, Arc>, + project_path: String, + session_ids: Vec, + include_thinking: bool, +) -> DesktopResult { + derive_claude_impl(cache.inner(), project_path, session_ids, include_thinking) +} + +pub fn derive_pi_impl( + cache: &TraceCache, project_path: String, session_ids: Vec, include_thinking: bool, @@ -73,6 +126,18 @@ pub fn derive_pi( )); } + let cacheable = session_ids.len() == 1 && !include_thinking; + if cacheable { + let key = CacheKey { + provider: "pi".into(), + project: project_path.clone(), + session_id: session_ids[0].clone(), + }; + if let Some(hit) = cache.get(&key) { + return Ok(hit.doc); + } + } + let manager = toolpath_pi::PiConvo::new(); let config = toolpath_pi::DeriveConfig { include_thinking, @@ -106,7 +171,33 @@ pub fn derive_pi( toolpath::v1::Document::Graph(graph) }; - document_to_value(&document) + let value = document_to_value(&document)?; + + if cacheable { + cache.insert( + CacheKey { + provider: "pi".into(), + project: project_path, + session_id: session_ids.into_iter().next().unwrap(), + }, + CacheEntry { + doc: value.clone(), + last_activity: String::new(), + }, + ); + } + + Ok(value) +} + +#[tauri::command] +pub fn derive_pi( + cache: State<'_, Arc>, + project_path: String, + session_ids: Vec, + include_thinking: bool, +) -> DesktopResult { + derive_pi_impl(cache.inner(), project_path, session_ids, include_thinking) } #[tauri::command] @@ -169,10 +260,60 @@ mod tests { #[test] fn derive_claude_rejects_empty_selection() { - let err = derive_claude("/nowhere".into(), vec![], false).unwrap_err(); + let cache = TraceCache::new(); + let err = derive_claude_impl(&cache, "/nowhere".into(), vec![], false).unwrap_err(); assert!(matches!(err, DesktopError::InvalidInput(_))); } + #[test] + fn derive_pi_rejects_empty_selection() { + let cache = TraceCache::new(); + let err = derive_pi_impl(&cache, "/nowhere".into(), vec![], false).unwrap_err(); + assert!(matches!(err, DesktopError::InvalidInput(_))); + } + + #[test] + fn derive_claude_serves_cached_doc_without_running_derive() { + // Pre-populate the cache with a fake doc. A real derive against + // /nowhere would error; a cache hit must short-circuit before that. + let cache = TraceCache::new(); + let expected = serde_json::json!({"marker": "cached"}); + cache.insert( + CacheKey { + provider: "claude".into(), + project: "/nowhere".into(), + session_id: "sess".into(), + }, + CacheEntry { + doc: expected.clone(), + last_activity: "2026-04-23T10:00:00Z".into(), + }, + ); + let got = + derive_claude_impl(&cache, "/nowhere".into(), vec!["sess".into()], false).unwrap(); + assert_eq!(got, expected); + } + + #[test] + fn derive_pi_serves_cached_doc_without_running_derive() { + let cache = TraceCache::new(); + let expected = serde_json::json!({"marker": "pi-cached"}); + cache.insert( + CacheKey { + provider: "pi".into(), + project: "/nowhere".into(), + session_id: "sess".into(), + }, + CacheEntry { + doc: expected.clone(), + last_activity: String::new(), + }, + ); + let got = + derive_pi_impl(&cache, "/nowhere".into(), vec!["sess".into()], false).unwrap(); + assert_eq!(got, expected); + } + #[test] fn derive_git_rejects_missing_branch() { let err = derive_git("/tmp".into(), String::new(), None).unwrap_err(); diff --git a/crates/toolpath-desktop/src/main.rs b/crates/toolpath-desktop/src/main.rs index 9bba9fb..5c5bf47 100644 --- a/crates/toolpath-desktop/src/main.rs +++ b/crates/toolpath-desktop/src/main.rs @@ -3,10 +3,15 @@ windows_subsystem = "windows" )] +mod cache; mod commands; mod error; mod tray; +use std::sync::Arc; + +use tauri::Manager; + use commands::{derive, export, keychain, sources, upload}; fn main() { @@ -15,6 +20,12 @@ fn main() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_positioner::init()) .setup(|app| { + let cache_dir = std::env::temp_dir() + .join("toolpath-desktop") + .join("trace-cache"); + let trace_cache = Arc::new(cache::TraceCache::with_disk_dir(cache_dir)); + trace_cache.prune_disk(cache::DEFAULT_MAX_DISK_ENTRIES); + app.manage(trace_cache); tray::install(app)?; Ok(()) }) diff --git a/crates/toolpath-desktop/src/tray.rs b/crates/toolpath-desktop/src/tray.rs index aa9b08d..7bf6279 100644 --- a/crates/toolpath-desktop/src/tray.rs +++ b/crates/toolpath-desktop/src/tray.rs @@ -22,9 +22,11 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_positioner::{Position, WindowExt}; +use crate::cache::{CacheEntry, CacheKey, TraceCache}; + /// How often the background poller re-scans providers. const POLL_INTERVAL: Duration = Duration::from_secs(30); @@ -37,6 +39,14 @@ const RECENT_WINDOW_SECS: i64 = 24 * 60 * 60; /// Maximum number of recent sessions to include in the popover payload. const MAX_RECENT_SESSIONS: usize = 20; +/// Cap on simultaneous warm-up derive threads. Keeps the prewarm pass from +/// saturating the CPU right after a poll cycle. +const MAX_PREWARM_CONCURRENCY: usize = 2; + +/// Providers whose derive is wired up on the desktop backend. The popover +/// disables rows for anything not in this set, so we don't prewarm them either. +const PREWARM_PROVIDERS: &[&str] = &["claude", "pi"]; + /// Per-provider counts, emitted in [`TrayStats`]. #[derive(Debug, Clone, Default, Serialize)] pub struct ProviderCounts { @@ -342,57 +352,38 @@ pub struct TraceOpenedPayload { /// providers with a desktop-side derive command are supported (claude, pi). /// For gemini/codex/opencode the popover should disable the row instead of /// calling this. +/// +/// Fast path: the tray poller pre-derives recent sessions into the +/// [`TraceCache`], so this almost always resolves with a cache hit and the +/// main window opens without any re-derive latency. #[tauri::command] pub fn tray_open_trace( app: AppHandle, + cache: State<'_, Arc>, provider: String, project: String, session_id: String, ) -> Result<(), String> { - let (doc, source, filename) = match provider.as_str() { - "claude" => { - let value = crate::commands::derive::derive_claude( - project.clone(), - vec![session_id.clone()], - /* include_thinking */ false, - ) - .map_err(|e| e.to_string())?; - let filename = format!( + let doc = derive_via_cache(cache.inner(), &provider, &project, &session_id) + .map_err(|e| e.to_string())?; + + let (source, filename) = match provider.as_str() { + "claude" => ( + format!("Claude: {}", basename(&project)), + format!( "claude-{}-{}.path.json", basename_slug(&project), short(&session_id) - ); - ( - value, - format!("Claude: {}", basename(&project)), - filename, - ) - } - "pi" => { - let value = crate::commands::derive::derive_pi( - project.clone(), - vec![session_id.clone()], - /* include_thinking */ false, - ) - .map_err(|e| e.to_string())?; - let filename = format!( + ), + ), + "pi" => ( + format!("pi.dev: {}", basename(&project)), + format!( "pi-{}-{}.path.json", basename_slug(&project), short(&session_id) - ); - ( - value, - format!("pi.dev: {}", basename(&project)), - filename, - ) - } - // Not wired up in the desktop backend yet. The popover disables - // rows for these, but we still reject politely if one slips through. - "gemini" | "codex" | "opencode" => { - return Err(format!( - "Opening {provider} traces from Quick View isn't wired up yet." - )); - } + ), + ), other => return Err(format!("unknown provider: {other}")), }; @@ -408,6 +399,36 @@ pub fn tray_open_trace( Ok(()) } +/// Run derive through the shared cache-aware impls. Centralises the +/// provider -> function dispatch and the "unsupported provider" error shape. +fn derive_via_cache( + cache: &TraceCache, + provider: &str, + project: &str, + session_id: &str, +) -> Result { + match provider { + "claude" => crate::commands::derive::derive_claude_impl( + cache, + project.to_string(), + vec![session_id.to_string()], + /* include_thinking */ false, + ), + "pi" => crate::commands::derive::derive_pi_impl( + cache, + project.to_string(), + vec![session_id.to_string()], + /* include_thinking */ false, + ), + "gemini" | "codex" | "opencode" => Err(crate::error::DesktopError::InvalidInput(format!( + "Opening {provider} traces from Quick View isn't wired up yet." + ))), + other => Err(crate::error::DesktopError::InvalidInput(format!( + "unknown provider: {other}" + ))), + } +} + fn basename(path: &str) -> String { if path.is_empty() { return String::new(); @@ -531,9 +552,67 @@ fn publish_stats(app: &AppHandle) { let _ = tray.set_tooltip(Some(&tooltip)); } + prewarm_traces(app, &stats); + let _ = app.emit("tray:stats", stats); } +/// Kick off background derives for recent sessions so clicking one in the +/// popover hits the cache instead of running derive on the UI path. Skips +/// entries that are already fresh or have a warm-up in flight; caps the number +/// of simultaneous derive threads at [`MAX_PREWARM_CONCURRENCY`]. +fn prewarm_traces(app: &AppHandle, stats: &TrayStats) { + let Some(cache) = app.try_state::>() else { + return; + }; + let cache: Arc = cache.inner().clone(); + + for session in &stats.recent { + if !PREWARM_PROVIDERS.contains(&session.provider) { + continue; + } + if cache.in_flight_count() >= MAX_PREWARM_CONCURRENCY { + break; + } + let key = CacheKey { + provider: session.provider.to_string(), + project: session.project.clone(), + session_id: session.session_id.clone(), + }; + if cache.is_fresh(&key, &session.last_activity) { + continue; + } + if !cache.try_claim(&key) { + continue; + } + spawn_prewarm(cache.clone(), key, session.last_activity.clone()); + } +} + +fn spawn_prewarm(cache: Arc, key: CacheKey, last_activity: String) { + thread::spawn(move || { + // `derive_via_cache` performs the derive AND inserts the result into + // the cache (with `last_activity = ""`). We then overwrite that entry + // with one carrying the real `last_activity` so future `is_fresh` + // checks can short-circuit. + let result = + derive_via_cache(&cache, &key.provider, &key.project, &key.session_id); + if let Ok(doc) = result { + cache.insert( + key.clone(), + CacheEntry { + doc, + last_activity, + }, + ); + } + // Errors are swallowed — a broken session shouldn't poison the cache + // or spam logs every 30s. The click path will surface the error if + // the user actually picks this session. + cache.release(&key); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -589,4 +668,16 @@ mod tests { assert_eq!(short("0123456789abcdef"), "01234567"); assert_eq!(short("abc"), "abc"); } + + #[test] + fn derive_via_cache_rejects_unsupported_providers() { + let cache = TraceCache::new(); + for p in ["gemini", "codex", "opencode", "bogus"] { + let err = derive_via_cache(&cache, p, "/proj", "sess").unwrap_err(); + assert!( + matches!(err, crate::error::DesktopError::InvalidInput(_)), + "expected InvalidInput for provider {p}, got {err:?}" + ); + } + } } From b911c49b1c13b4b62712dba50d1fa14afc262c04 Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Thu, 23 Apr 2026 16:11:26 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat(toolpath-desktop):=20add=20click?= =?UTF-8?q?=E2=86=92derive=E2=86=92render=20perf=20tracer=20with=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To isolate where perceived click latency actually lives — Rust derive vs. Svelte/dagre render — the store and Preview now emit perf marks at every checkpoint in the flow: dispatch → invoke-start → invoke-end → model-updated → preview-mounted → viz-rendered (or dom-painted in chat mode) The popover's `trace:opened` event path also starts its own trace so the overlay can show post-derive render time in isolation (Rust has already finished). Every completed trace logs a phase-delta summary to the devtools console. To also show the phase-bar overlay in the bottom-right of the main window, set `localStorage.perf = "1"` and reload. Scope is read-only: no behavioural change to derive or caching. --- CLAUDE.md | 2 + .../toolpath-desktop/frontend/src/app.svelte | 10 ++ .../frontend/src/lib/PerfOverlay.svelte | 164 ++++++++++++++++++ .../frontend/src/lib/perf.svelte.ts | 95 ++++++++++ .../frontend/src/lib/store.svelte.ts | 17 ++ .../frontend/src/routes/Preview.svelte | 32 ++++ 6 files changed, 320 insertions(+) create mode 100644 crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte create mode 100644 crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts diff --git a/CLAUDE.md b/CLAUDE.md index 574a40b..f974ff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,8 @@ Pre-derive cache: after each 30s poll the tray kicks off background derives for Two tiers: (1) memory, a `HashMap` capped at 32 entries; (2) disk, under `/toolpath-desktop/trace-cache/.json`, so caches survive app restarts (macOS/Linux eventually clean `/tmp` themselves). Disk is capped at 200 entries, pruned oldest-first by mtime at startup. Memory misses fall through to disk and promote the hit back into memory. Corrupt files are silently deleted on read so a bad write doesn't poison the cache forever. +Perf tracer (`frontend/src/lib/perf.svelte.ts`, `PerfOverlay.svelte`): the store, Preview, and the `trace:opened` listener call `perfStart` / `perfMark` / `perfEnd` at each checkpoint of a click → derive → render flow (dispatch, invoke-start, invoke-end, model-updated, preview-mounted, viz-rendered). Every completed trace logs a summary to the devtools console; set `localStorage.perf = "1"` and reload to also show a phase-bar overlay in the bottom-right. Use this to tell whether perceived click latency is the Rust derive vs. the Svelte/dagre render. + Streaming pattern (Claude project/session lists): Rust command spawns a thread that emits `claude:project`, `claude:session`, `claude:projects-done`, `claude:sessions-done` events. The Svelte component subscribes with `$effect(() => { listen(...) ... return unlisten; })` — Svelte tears down listeners automatically when the effect's deps change or the component unmounts. Package manager for the frontend is `bun` (installed at `~/.bun/bin/bun`). `bun install` to set up, `bun run check` for `svelte-check`, `bun run build` for a production Vite build. Never commit `node_modules/` or `dist/` — both are ignored. diff --git a/crates/toolpath-desktop/frontend/src/app.svelte b/crates/toolpath-desktop/frontend/src/app.svelte index 3e0285c..dbdf133 100644 --- a/crates/toolpath-desktop/frontend/src/app.svelte +++ b/crates/toolpath-desktop/frontend/src/app.svelte @@ -10,6 +10,8 @@ import BrowseGithub from "./routes/BrowseGithub.svelte"; import Preview from "./routes/Preview.svelte"; import Result from "./routes/Result.svelte"; + import PerfOverlay from "./lib/PerfOverlay.svelte"; + import { perfStart, perfMark } from "./lib/perf.svelte"; import type { Document, Route } from "./lib/types"; const notInTauri = @@ -68,6 +70,12 @@ $effect(() => { let unlisten: UnlistenFn | undefined; listen("trace:opened", (payload) => { + // Perf: this is the popover's post-derive delivery; Rust did the + // derive out-of-band so what's left is pure model + render time. + // Seeing this trace end at 20ms vs 600ms tells us whether perceived + // lag is the derive or the Svelte/dagre render. + perfStart("trace:opened → preview"); + perfMark("event-received"); store.dispatch({ t: "DeriveSucceeded", doc: payload.doc, @@ -161,3 +169,5 @@ {/if} + + diff --git a/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte b/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte new file mode 100644 index 0000000..076641d --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte @@ -0,0 +1,164 @@ + + +{#if enabled && trace} +
+
+ {trace.label} + + {#if trace.durationMs != null} + {fmt(trace.durationMs)}ms + {:else} + running… + {/if} + +
+
+ {#each phases as p (p.name + p.start)} +
+ {p.name} + {fmt(p.end - p.start)} +
+ {/each} +
+
+ marks + + + + + + {#each phases as p (p.name + p.start)} + + + + + + {/each} + +
markatΔ
{p.name}{fmt(p.end)}+{fmt(p.end - p.start)}
+
+
+{/if} + + diff --git a/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts b/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts new file mode 100644 index 0000000..db792ca --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts @@ -0,0 +1,95 @@ +// Lightweight performance tracer for click → derive → render flows. +// +// Each logical operation (e.g. "derive claude session") has a single running +// trace with one or more named marks. The most-recently-completed trace lives +// in the reactive `perf.latest` field so a small overlay can visualise which +// phase took how long. Every completed trace is also dumped to the devtools +// console. +// +// Enable the on-screen overlay by calling `perfSetOverlayEnabled(true)` (or +// `localStorage.setItem("perf", "1")`) and reloading. Console output is +// always on. +// +// Typical call sequence for a "click Select → preview mounted" flow: +// +// perf.start("derive claude"); +// perf.mark("dispatch"); +// perf.mark("invoke-start"); +// perf.mark("invoke-end"); +// perf.mark("model-updated"); +// perf.mark("preview-mounted"); +// perf.mark("viz-rendered"); +// perf.end(); + +export type PerfMark = { name: string; t: number }; +export type PerfTrace = { + label: string; + startedAt: number; + marks: PerfMark[]; + durationMs: number | null; +}; + +// Reactive module-level state. Svelte 5 tracks reads of these fields across +// .svelte files that import them. +export const perf = $state<{ latest: PerfTrace | null }>({ latest: null }); + +// Non-reactive scratch pad for the in-flight trace. +let current: PerfTrace | null = null; + +function now(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +export function perfStart(label: string): void { + const t = now(); + current = { label, startedAt: t, marks: [], durationMs: null }; + // Make an early-visible copy so the overlay can show the label even before + // the first mark lands. + perf.latest = { ...current }; +} + +export function perfMark(name: string): void { + if (!current) return; + const t = now() - current.startedAt; + current.marks = [...current.marks, { name, t }]; + perf.latest = { ...current, marks: current.marks }; +} + +export function perfEnd(): void { + if (!current) return; + const dur = now() - current.startedAt; + current.durationMs = dur; + perf.latest = { ...current, durationMs: dur }; + + // Summary to console. Each mark shows absolute-from-start and delta from + // the previous mark so the slow phase is easy to spot. + const lines: string[] = [`${current.label} (total ${dur.toFixed(1)}ms)`]; + let prev = 0; + for (const m of current.marks) { + const delta = m.t - prev; + lines.push( + ` ${m.name.padEnd(18)} ${m.t.toFixed(1).padStart(8)}ms (+${delta.toFixed(1)}ms)`, + ); + prev = m.t; + } + // eslint-disable-next-line no-console + console.log("%cperf", "color:#b5652b;font-weight:600", "\n" + lines.join("\n")); + current = null; +} + +export function perfOverlayEnabled(): boolean { + try { + return globalThis.localStorage?.getItem("perf") === "1"; + } catch { + return false; + } +} + +export function perfSetOverlayEnabled(on: boolean): void { + try { + if (on) globalThis.localStorage?.setItem("perf", "1"); + else globalThis.localStorage?.removeItem("perf"); + } catch { + // ignore — overlay is a nice-to-have + } +} diff --git a/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts b/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts index 6329166..c1c325b 100644 --- a/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts +++ b/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts @@ -6,12 +6,19 @@ import type { Cmd, Dispatch, Model, Msg } from "./types"; import { initialModel, update } from "./update"; import { invoke } from "./ipc"; import { dbg } from "./debug"; +import { perfStart, perfMark, perfEnd } from "./perf.svelte"; class Store { m = $state(initialModel()); dispatch: Dispatch = (msg: Msg) => { dbg("msg", msg.t, msg); + // Perf: a derive-dispatch begins a new trace; `DeriveSucceeded` lands + // the model update that triggers the preview route to mount. + if (msg.t === "ClaudeDerive") perfStart("derive claude"); + else if (msg.t === "PiDerive") perfStart("derive pi"); + if (msg.t === "ClaudeDerive" || msg.t === "PiDerive") perfMark("dispatch"); + if (msg.t === "DeriveSucceeded") perfMark("model-updated"); const [next, cmd] = update(msg, this.m); const routeChanged = next.route !== this.m.route; this.m = next; @@ -36,14 +43,24 @@ class Store { return; case "invoke": dbg("invoke", cmd.name, cmd.args ?? {}); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-start"); + } invoke(cmd.name, cmd.args).then( (r) => { dbg("invoke.ok", cmd.name, r); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-end"); + } const m = cmd.onOk?.(r); if (m) this.dispatch(m); }, (e) => { dbg("invoke.err", cmd.name, e); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-err"); + perfEnd(); + } const m = cmd.onErr?.(e); if (m) this.dispatch(m); }, diff --git a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte index 4476d4b..a14404a 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte @@ -1,12 +1,36 @@