diff --git a/CHANGELOG.md b/CHANGELOG.md index a6da5a13..7dee81be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.6](https://github.com/ScriptedAlchemy/tracedecay/compare/v0.0.5...v0.0.6) - 2026-06-21 + +### Added + +- add compression context recovery hints + +### Fixed + +- fix Windows global registry test races +- fix registry repo identity matching +- fix cleanup policy docs +- fix Hermes profile storage routing +- fix Hermes profile storage routing +- fix dashboard store and savings calculations +- fix plugin contract tests +- fix storage registry and session routing + +### Other + +- remove legacy daemon names +- Clean up CI docs and legacy naming +- remove legacy Hermes provider alias +- Update Hermes and plugins for unified stores +- Update dashboard for unified stores +- Add unified storage session backends + ## [0.0.5](https://github.com/ScriptedAlchemy/tracedecay/compare/v0.0.4...v0.0.5) - 2026-06-20 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index b41118c4..0c593d6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4499,7 +4499,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracedecay" -version = "0.0.5" +version = "0.0.6" dependencies = [ "amari-holographic", "axum 0.8.9", diff --git a/Cargo.toml b/Cargo.toml index 6632ecac..c2669902 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tracedecay" -version = "0.0.5" +version = "0.0.6" edition = "2021" description = "Code intelligence tool that builds a semantic knowledge graph from Rust, Go, Java, Scala, TypeScript, Python, C, C++, Kotlin, C#, Swift, and many more codebases" license = "MIT" diff --git a/cursor-plugin/.cursor-plugin/plugin.json b/cursor-plugin/.cursor-plugin/plugin.json index a8a0ecef..84851f2f 100644 --- a/cursor-plugin/.cursor-plugin/plugin.json +++ b/cursor-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tracedecay", - "version": "0.0.5", + "version": "0.0.6", "description": "Cursor integration for the TraceDecay semantic code intelligence MCP server.", "author": { "name": "ScriptedAlchemy", diff --git a/dashboard/shell/dist/source-stamp b/dashboard/shell/dist/source-stamp deleted file mode 100644 index a84beb2b..00000000 --- a/dashboard/shell/dist/source-stamp +++ /dev/null @@ -1 +0,0 @@ -6aded4b202b4bd1b diff --git a/docs/superpowers/plans/2026-03-27-daemon-mode.md b/docs/superpowers/plans/2026-03-27-daemon-mode.md deleted file mode 100644 index 9faf247d..00000000 --- a/docs/superpowers/plans/2026-03-27-daemon-mode.md +++ /dev/null @@ -1,726 +0,0 @@ -# Daemon Mode Implementation Plan - -> **Rebrand note:** The project has since been renamed **TraceDecay** (binary/crate `tracedecay`, MCP tools `tracedecay_*`). This dated planning artifact keeps the TraceDecay-era names it was written with. - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A background daemon that watches all tracked tracedecay projects for file changes and automatically runs incremental syncs. - -**Architecture:** A new `tracedecay daemon` subcommand backed by `src/daemon.rs`. Uses `notify` for filesystem watching, tokio timers for per-project debounce, and `daemon-kit` for cross-platform daemonization (fork on Unix via daemonize2, Windows Service via windows-service), PID file management, and service installation (launchd/systemd/Windows Service). Discovers projects from the global DB, re-polls every 60s for new ones. - -**Tech Stack:** Rust, `daemon-kit` (cross-platform daemon/service), `notify` v7 (file watcher), tokio (async runtime, timers), existing `TraceDecay::sync()`, existing `GlobalDb`. - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `Cargo.toml` | Add `daemon-kit` and `notify` dependencies | -| Modify | `src/user_config.rs` | Add `daemon_debounce: String` field | -| Modify | `src/global_db.rs` | Add `list_project_paths()` method | -| Create | `src/daemon.rs` | Core daemon: watcher, debounce, sync loop (uses daemon-kit for daemonize/PID/service) | -| Modify | `src/lib.rs` | Add `pub mod daemon;` | -| Modify | `src/main.rs` | Add `Commands::Daemon` variant and handler | -| Modify | `src/doctor.rs` | Add daemon running/autostart checks | -| Modify | `tests/user_config_test.rs` | Add `daemon_debounce` to round-trip test | - ---- - -### Task 1: Add dependencies and `daemon_debounce` config field - -**Files:** -- Modify: `Cargo.toml` -- Modify: `src/user_config.rs` -- Modify: `tests/user_config_test.rs` - -- [ ] **Step 1: Add daemon-kit and notify to Cargo.toml** - -In `[dependencies]`, add: -```toml -daemon-kit = "0.1" -notify = { version = "7", default-features = false, features = ["macos_fsevent"] } -``` - -- [ ] **Step 2: Add `daemon_debounce` to UserConfig** - -In `src/user_config.rs`, add after the `installed_agents` field: -```rust -/// Debounce duration for the daemon file watcher (e.g. "15s", "1m"). -#[serde(default = "default_daemon_debounce")] -pub daemon_debounce: String, -``` - -Add the default function: -```rust -fn default_daemon_debounce() -> String { - "15s".to_string() -} -``` - -In the `Default` impl, add: -```rust -daemon_debounce: default_daemon_debounce(), -``` - -- [ ] **Step 3: Update round-trip test** - -In `tests/user_config_test.rs`, add `daemon_debounce: "30s".to_string()` to the test struct. - -- [ ] **Step 4: Build and test** - -Run: `cargo build && cargo test user_config` - -- [ ] **Step 5: Commit** -``` -feat: add notify/nix deps and daemon_debounce config field -``` - ---- - -### Task 2: Add `list_project_paths()` to GlobalDb - -**Files:** -- Modify: `src/global_db.rs` - -- [ ] **Step 1: Add the method** - -Add to `impl GlobalDb`: -```rust -/// Returns all tracked project paths. -pub async fn list_project_paths(&self) -> Vec { - let mut rows = match self - .conn - .query("SELECT path FROM projects", ()) - .await - { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - let mut paths = Vec::new(); - loop { - match rows.next().await { - Ok(Some(row)) => { - if let Ok(path) = row.get::(0) { - paths.push(path); - } - } - _ => break, - } - } - paths -} -``` - -- [ ] **Step 2: Build** - -Run: `cargo build` - -- [ ] **Step 3: Commit** -``` -feat: add GlobalDb::list_project_paths() -``` - ---- - -### Task 3: Create `src/daemon.rs` — duration parser and daemon-kit setup - -**Files:** -- Create: `src/daemon.rs` -- Modify: `src/lib.rs` - -- [ ] **Step 1: Create daemon.rs with duration parser and daemon-kit Daemon instance** - -```rust -//! Background daemon that watches all tracked tracedecay projects for file -//! changes and runs incremental syncs automatically. - -use std::path::PathBuf; -use std::time::Duration; - -use daemon_kit::{Daemon, DaemonConfig}; - -use crate::errors::{Result, TraceDecayError}; - -/// Parse a human-readable duration string like "15s" or "1m" into a Duration. -/// Returns None if the format is unrecognized. -pub fn parse_duration(s: &str) -> Option { - let s = s.trim(); - if let Some(secs) = s.strip_suffix('s') { - secs.trim().parse::().ok().map(Duration::from_secs) - } else if let Some(mins) = s.strip_suffix('m') { - mins.trim().parse::().ok().map(|m| Duration::from_secs(m * 60)) - } else { - s.parse::().ok().map(Duration::from_secs) - } -} - -/// Build the daemon-kit Daemon instance with tracedecay paths. -fn build_daemon() -> std::result::Result { - let home = dirs::home_dir().ok_or_else(|| TraceDecayError::Config { - message: "cannot determine home directory".to_string(), - })?; - let ts_dir = home.join(".tracedecay"); - let bin = crate::agents::which_tracedecay().unwrap_or_else(|| "tracedecay".to_string()); - - let config = DaemonConfig::new("tracedecay-daemon") - .pid_dir(&ts_dir) - .log_file(ts_dir.join("daemon.log")) - .executable(PathBuf::from(bin)) - .service_args(vec!["daemon".to_string(), "--foreground".to_string()]) - .description("tracedecay file watcher daemon"); - - Ok(Daemon::new(config)) -} - -/// Returns the PID of the running daemon, or None. -pub fn running_daemon_pid() -> Option { - build_daemon().ok()?.running_pid() -} - -/// Returns true if an autostart service is installed. -pub fn is_autostart_enabled() -> bool { - build_daemon().ok().is_some_and(|d| d.is_service_installed()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_duration_seconds() { - assert_eq!(parse_duration("15s"), Some(Duration::from_secs(15))); - assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30))); - assert_eq!(parse_duration(" 5s "), Some(Duration::from_secs(5))); - } - - #[test] - fn parse_duration_minutes() { - assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60))); - assert_eq!(parse_duration("2m"), Some(Duration::from_secs(120))); - } - - #[test] - fn parse_duration_bare_number() { - assert_eq!(parse_duration("10"), Some(Duration::from_secs(10))); - } - - #[test] - fn parse_duration_invalid() { - assert_eq!(parse_duration("abc"), None); - assert_eq!(parse_duration(""), None); - assert_eq!(parse_duration("1h"), None); - } -} -``` - -- [ ] **Step 2: Add `pub mod daemon;` to lib.rs** - -In `src/lib.rs`, add: -```rust -pub mod daemon; -``` - -- [ ] **Step 3: Build and test** - -Run: `cargo build && cargo test daemon` - -- [ ] **Step 4: Commit** -``` -feat: daemon duration parser and daemon-kit setup -``` - ---- - -### Task 4: Daemon core event loop - -**Files:** -- Modify: `src/daemon.rs` - -- [ ] **Step 1: Add the main daemon run function** - -Append to `src/daemon.rs` — this is the core loop. It: -1. Opens the global DB -2. Reads all project paths -3. Sets up `notify` watchers for each -4. Runs a tokio select loop: file events → mark dirty + reset debounce timer; 60s ticker → re-poll global DB for new projects; debounce timer fires → sync the dirty project; SIGTERM/SIGINT → shutdown. - -```rust -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use tokio::sync::mpsc; -use tokio::time::{self, Instant}; -use notify::{RecommendedWatcher, RecursiveMode, Watcher, Event}; - -/// Directories to ignore inside watched projects. -const IGNORED_DIRS: &[&str] = &[ - ".tracedecay", ".git", "node_modules", "target", ".build", - "__pycache__", ".next", "dist", "build", ".cache", -]; - -/// Run the daemon event loop. This function does not return until -/// a shutdown signal is received. -pub async fn run(foreground: bool) -> Result<()> { - if let Some(pid) = running_daemon_pid() { - return Err(TraceDecayError::Config { - message: format!("daemon already running (PID: {pid})"), - }); - } - - if !foreground { - daemonize()?; - } - - write_pid_file()?; - - // Set up graceful shutdown on SIGTERM/SIGINT - let shutdown = tokio::signal::ctrl_c(); - - let config = crate::user_config::UserConfig::load(); - let debounce = parse_duration(&config.daemon_debounce) - .unwrap_or(Duration::from_secs(15)); - - let result = run_loop(debounce, shutdown).await; - - remove_pid_file(); - result -} - -async fn run_loop( - debounce: Duration, - shutdown: impl std::future::Future>, -) -> Result<()> { - let (tx, mut rx) = mpsc::channel::(256); - - let mut watchers: HashMap = HashMap::new(); - let mut dirty: HashMap = HashMap::new(); - - // Initial project discovery - let project_paths = discover_projects().await; - for path in &project_paths { - if let Some(w) = create_watcher(path, tx.clone()) { - watchers.insert(path.clone(), w); - } - } - - daemon_log(&format!("started, watching {} projects", watchers.len())); - - let mut discovery_interval = time::interval(Duration::from_secs(60)); - discovery_interval.tick().await; // consume first immediate tick - - tokio::pin!(shutdown); - - loop { - // Find the next debounce deadline - let next_deadline = dirty.values().copied().min(); - let sleep = match next_deadline { - Some(deadline) => tokio::time::sleep_until(deadline), - None => tokio::time::sleep(Duration::from_secs(3600)), // park - }; - tokio::pin!(sleep); - - tokio::select! { - _ = &mut shutdown => { - daemon_log("shutting down (signal)"); - break; - } - Some(project_root) = rx.recv() => { - // File change in a project — mark dirty, reset timer - dirty.insert(project_root, Instant::now() + debounce); - } - _ = &mut sleep, if next_deadline.is_some() => { - // A debounce timer fired — sync all projects past their deadline - let now = Instant::now(); - let ready: Vec = dirty - .iter() - .filter(|(_, deadline)| **deadline <= now) - .map(|(path, _)| path.clone()) - .collect(); - for path in ready { - dirty.remove(&path); - sync_project(&path).await; - } - } - _ = discovery_interval.tick() => { - // Re-discover projects - let current = discover_projects().await; - let current_set: HashSet<&PathBuf> = current.iter().collect(); - let watched_set: HashSet<&PathBuf> = watchers.keys().collect(); - - // Add new projects - for path in current_set.difference(&watched_set) { - if let Some(w) = create_watcher(path, tx.clone()) { - daemon_log(&format!("discovered new project: {}", path.display())); - watchers.insert((*path).clone(), w); - } - } - // Remove stale projects - let stale: Vec = watched_set - .difference(¤t_set) - .map(|p| (*p).clone()) - .collect(); - for path in stale { - watchers.remove(&path); - dirty.remove(&path); - } - } - } - } - - Ok(()) -} - -/// Query the global DB for all tracked project paths. -async fn discover_projects() -> Vec { - let Some(gdb) = crate::global_db::GlobalDb::open().await else { - return Vec::new(); - }; - gdb.list_project_paths() - .await - .into_iter() - .filter_map(|s| { - let p = PathBuf::from(&s); - if p.is_dir() && crate::tracedecay::TraceDecay::is_initialized(&p) { - Some(p) - } else { - None - } - }) - .collect() -} - -/// Create a notify watcher for a project root. Sends the project root -/// path to `tx` on any relevant file event. -fn create_watcher(project_root: &Path, tx: mpsc::Sender) -> Option { - let root = project_root.to_path_buf(); - let mut watcher = notify::recommended_watcher(move |res: std::result::Result| { - let Ok(event) = res else { return }; - // Only care about create/modify/remove - if !matches!( - event.kind, - notify::EventKind::Create(_) - | notify::EventKind::Modify(_) - | notify::EventKind::Remove(_) - ) { - return; - } - // Check if any path in the event should be ignored - let dominated_by_ignored = event.paths.iter().all(|p| { - p.components().any(|c| { - IGNORED_DIRS.contains(&c.as_os_str().to_str().unwrap_or("")) - }) - }); - if dominated_by_ignored { - return; - } - let _ = tx.blocking_send(root.clone()); - }) - .ok()?; - watcher.watch(project_root, RecursiveMode::Recursive).ok()?; - Some(watcher) -} - -/// Run an incremental sync on a single project. Best-effort. -async fn sync_project(project_root: &Path) { - let start = std::time::Instant::now(); - let Ok(cg) = crate::tracedecay::TraceDecay::open(project_root).await else { - daemon_log(&format!("failed to open {}", project_root.display())); - return; - }; - match cg.sync().await { - Ok(result) => { - let ms = start.elapsed().as_millis(); - daemon_log(&format!( - "synced {} — {} added, {} modified, {} removed ({}ms)", - project_root.display(), - result.files_added, - result.files_modified, - result.files_removed, - ms - )); - // Best-effort update global DB - if let Some(gdb) = crate::global_db::GlobalDb::open().await { - let tokens = cg.get_tokens_saved().await.unwrap_or(0); - gdb.upsert(project_root, tokens).await; - } - } - Err(e) => { - daemon_log(&format!("sync failed for {}: {e}", project_root.display())); - } - } -} - -/// Append a timestamped line to the daemon log file. -fn daemon_log(msg: &str) { - let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); - let line = format!("[{now}] {msg}\n"); - // Also print to stderr if running in foreground - eprint!("{line}"); - if let Some(log_path) = log_file_path() { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_path) - { - let _ = f.write_all(line.as_bytes()); - } - } -} -``` - -Wait — this uses `chrono`. Let me avoid adding a new dep and just use `std::time` for the log timestamp. Replace the `daemon_log` function: - -```rust -/// Append a timestamped line to the daemon log file. -fn daemon_log(msg: &str) { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let line = format!("[{secs}] {msg}\n"); - eprint!("{line}"); - if let Some(log_path) = log_file_path() { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_path) - { - let _ = f.write_all(line.as_bytes()); - } - } -} -``` - -- [ ] **Step 2: Build** - -Run: `cargo build` - -Note: `notify` v7 will be pulled in from Cargo.toml. If there are API differences (v7 uses `Event` directly, not `DebouncedEvent`), adjust the `create_watcher` call accordingly. - -- [ ] **Step 3: Commit** -``` -feat: daemon core event loop with file watching and debounced sync -``` - ---- - -### Task 5: Daemon run/stop/status/autostart via daemon-kit - -**Files:** -- Modify: `src/daemon.rs` - -All daemonization, PID management, stop/status, and service installation is delegated to the `daemon-kit` crate. Add these thin wrapper functions: - -- [ ] **Step 1: Add run, stop, status, enable/disable_autostart functions** - -```rust -/// Start the daemon. Forks to background on Unix unless `foreground` is true. -/// On Windows, runs as a Windows Service. -pub async fn run(foreground: bool) -> Result<()> { - let daemon = build_daemon()?; - - let config = crate::user_config::UserConfig::load(); - let debounce = parse_duration(&config.daemon_debounce) - .unwrap_or(Duration::from_secs(15)); - - // daemon-kit handles fork/PID/detach; we pass it the event loop closure - daemon - .start(foreground, move || { - // Build a new tokio runtime inside the forked child - let rt = tokio::runtime::Runtime::new().map_err(|e| { - daemon_kit::DaemonError::Daemonize(format!("failed to create runtime: {e}")) - })?; - rt.block_on(async { - run_loop(debounce).await.map_err(|e| { - daemon_kit::DaemonError::Daemonize(e.to_string()) - }) - }) - }) - .map_err(|e| TraceDecayError::Config { - message: format!("daemon error: {e}"), - }) -} - -/// Stop the running daemon. -pub fn stop() -> Result<()> { - let daemon = build_daemon()?; - daemon.stop().map_err(|e| TraceDecayError::Config { - message: format!("{e}"), - })?; - eprintln!("tracedecay daemon stopped"); - Ok(()) -} - -/// Print daemon status and return exit code (0 = running, 1 = not running). -pub fn status() -> i32 { - match running_daemon_pid() { - Some(pid) => { - eprintln!("tracedecay daemon is running (PID: {pid})"); - 0 - } - None => { - eprintln!("tracedecay daemon is not running"); - 1 - } - } -} - -/// Install autostart service (launchd/systemd/Windows Service). -pub fn enable_autostart() -> Result<()> { - let daemon = build_daemon()?; - daemon.install_service().map_err(|e| TraceDecayError::Config { - message: format!("{e}"), - })?; - eprintln!("\x1b[32m✔\x1b[0m Autostart service installed"); - Ok(()) -} - -/// Remove autostart service. -pub fn disable_autostart() -> Result<()> { - let daemon = build_daemon()?; - daemon.uninstall_service().map_err(|e| TraceDecayError::Config { - message: format!("{e}"), - })?; - eprintln!("\x1b[32m✔\x1b[0m Autostart service removed"); - Ok(()) -} -``` - -- [ ] **Step 2: Build** - -Run: `cargo build` - -- [ ] **Step 3: Commit** -``` -feat: daemon run/stop/status/autostart via daemon-kit -``` - ---- - -### Task 6: CLI integration and doctor checks - -**Files:** -- Modify: `src/main.rs` -- Modify: `src/doctor.rs` - -- [ ] **Step 1: Add Commands::Daemon to main.rs** - -In the `Commands` enum, add: -```rust -/// Background file watcher daemon -Daemon { - /// Run in foreground (don't fork) - #[arg(long)] - foreground: bool, - /// Stop the running daemon - #[arg(long)] - stop: bool, - /// Show daemon status - #[arg(long)] - status: bool, - /// Install autostart service (launchd/systemd) - #[arg(long)] - enable_autostart: bool, - /// Remove autostart service - #[arg(long)] - disable_autostart: bool, -}, -``` - -- [ ] **Step 2: Add handler in the match block** - -In the `match command { ... }` block, add: -```rust -Commands::Daemon { foreground, stop, status, enable_autostart, disable_autostart } => { - if stop { - tracedecay::daemon::stop()?; - } else if status { - let code = tracedecay::daemon::status(); - std::process::exit(code); - } else if enable_autostart { - tracedecay::daemon::enable_autostart()?; - } else if disable_autostart { - tracedecay::daemon::disable_autostart()?; - } else { - tracedecay::daemon::run(foreground).await?; - } -} -``` - -- [ ] **Step 3: Add daemon checks to doctor.rs** - -Add a new function `check_daemon` and call it from `run_doctor`: - -```rust -fn check_daemon(dc: &mut DoctorCounters) { - eprintln!("\n\x1b[1mDaemon\x1b[0m"); - match crate::daemon::running_daemon_pid() { - Some(pid) => dc.pass(&format!("Daemon is running (PID: {pid})")), - None => dc.warn("Daemon is not running — run `tracedecay daemon` to start"), - } - if crate::daemon::is_autostart_enabled() { - #[cfg(target_os = "macos")] - dc.pass("Autostart enabled (launchd)"); - #[cfg(target_os = "linux")] - dc.pass("Autostart enabled (systemd)"); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - dc.pass("Autostart enabled"); - } else { - dc.warn("Autostart not configured — run `tracedecay daemon --enable-autostart`"); - } -} -``` - -In `run_doctor`, add `check_daemon(&mut dc);` before the network checks. - -- [ ] **Step 4: Build and test** - -Run: `cargo build && cargo test` - -- [ ] **Step 5: Manual test** - -```bash -./target/debug/tracedecay daemon --status -./target/debug/tracedecay daemon --foreground & -./target/debug/tracedecay daemon --status -./target/debug/tracedecay daemon --stop -./target/debug/tracedecay doctor | grep -A2 Daemon -``` - -- [ ] **Step 6: Commit** -``` -feat: daemon CLI subcommand and doctor integration -``` - ---- - -### Task 7: CHANGELOG and final verification - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Add CHANGELOG entry** - -Add a new `## [Unreleased]` or version section with: -```markdown -### Added -- **Daemon mode** — `tracedecay daemon` watches all tracked projects for file changes and runs incremental syncs automatically; debounce configurable via `daemon_debounce` in `~/.tracedecay/config.toml` (default `"15s"`) -- **Daemon management** — `--stop`, `--status`, `--foreground` flags for process control; PID file at `~/.tracedecay/daemon.pid` -- **Autostart service** — `--enable-autostart` / `--disable-autostart` generates and manages a launchd plist (macOS) or systemd user unit (Linux) -- **Doctor daemon checks** — `tracedecay doctor` now reports daemon running status and autostart configuration -``` - -- [ ] **Step 2: Full test suite** - -Run: `cargo test` - -- [ ] **Step 3: Release build** - -Run: `cargo build --release` - -- [ ] **Step 4: Commit** -``` -feat: daemon mode — background file watcher with auto-sync -``` diff --git a/docs/superpowers/specs/2026-03-27-daemon-mode-design.md b/docs/superpowers/specs/2026-03-27-daemon-mode-design.md deleted file mode 100644 index 6b103f92..00000000 --- a/docs/superpowers/specs/2026-03-27-daemon-mode-design.md +++ /dev/null @@ -1,197 +0,0 @@ -# Daemon Mode Design Spec - -> **Rebrand note:** The project has since been renamed **TraceDecay** (binary/crate `tracedecay`, MCP tools `tracedecay_*`). This dated design artifact keeps the TraceDecay-era names it was written with. - -## Goal - -A background daemon that watches all tracked tracedecay projects for file changes and automatically runs incremental syncs, keeping the code graph up-to-date without manual `tracedecay sync` invocations. - -## CLI Surface - -``` -tracedecay daemon # start (forks to background, writes PID file) -tracedecay daemon --foreground # stay in foreground (for debugging / service managers) -tracedecay daemon --stop # kill running daemon via PID file -tracedecay daemon --status # check if running -tracedecay daemon --enable-autostart # install launchd/systemd service -tracedecay daemon --disable-autostart # remove service -``` - -All flags are mutually exclusive. `--foreground` is useful for debugging and when the daemon is managed by an external service manager. - -## Config - -Add `daemon_debounce` to `~/.tracedecay/config.toml`: - -```toml -daemon_debounce = "15s" -``` - -A simple duration parser understands `s` (seconds) and `m` (minutes). Examples: `"15s"`, `"30s"`, `"1m"`, `"2m"`. Stored as `String` in `UserConfig`, parsed at runtime with `parse_duration()`. Default is `"15s"` if absent or unparseable. - -## Architecture - -### Components - -| Component | File | Responsibility | -|-----------|------|----------------| -| DaemonRunner | `src/daemon.rs` | Core event loop: project discovery, file watching, debounce, sync dispatch | -| PID management | `src/daemon.rs` | Write/read/check `~/.tracedecay/daemon.pid` | -| Duration parser | `src/daemon.rs` | Parse `"15s"` / `"1m"` strings into `Duration` | -| Service installer | `src/daemon.rs` | Generate launchd plist (macOS) / systemd user unit (Linux) | -| CLI integration | `src/main.rs` | `Commands::Daemon` enum variant with flags | -| Doctor integration | `src/doctor.rs` | Check if daemon is running | - -### Data Flow - -1. **Startup:** Open global DB, read all project paths from `projects` table, set up a `notify::RecommendedWatcher` watching each project root recursively. - -2. **Project discovery (every 60s):** Re-read the global DB projects table. Add watchers for new projects. Remove watchers for projects no longer in the DB. - -3. **File change event:** When `notify` fires an event, determine which project it belongs to (by matching the event path against watched project roots). Mark that project as "dirty" and start/reset its per-project debounce timer. - -4. **Debounce fires (default 15s after last change):** Open `TraceDecay::open()` for the dirty project, call `sync()`, log the result (files added/modified/removed, duration). Update global DB token count. - -5. **Filtering:** Ignore change events inside `.tracedecay/`, `.git/`, `node_modules/`, `target/`, `.build/`, and other common build output directories. Also ignore events for files that don't match any supported language extension. - -### Self-Daemonizing - -On `tracedecay daemon` (without `--foreground`): - -1. Fork the process using `fork()` (Unix) or equivalent -2. Detach from terminal (setsid, close stdin/stdout/stderr, redirect to log file `~/.tracedecay/daemon.log`) -3. Write PID to `~/.tracedecay/daemon.pid` -4. Enter the main event loop - -On `--foreground`: skip the fork, keep stderr/stdout attached, still write PID file. - -### PID File Management - -- **Path:** `~/.tracedecay/daemon.pid` -- **Write:** On daemon start, write the PID as a plain integer -- **Read:** On `--stop` and `--status`, read the PID and check if the process is alive (`kill(pid, 0)` on Unix) -- **Stale detection:** If PID file exists but the process is dead, treat as not running. On start, overwrite stale PID files. -- **Cleanup:** On graceful shutdown (SIGTERM/SIGINT), delete the PID file - -### `--stop` - -Read PID file, check process alive, send SIGTERM. Wait up to 5 seconds for process to exit. If still alive after 5s, send SIGKILL. Remove PID file. - -### `--status` - -Read PID file, check process alive. Print: -- "tracedecay daemon is running (PID: 12345)" or -- "tracedecay daemon is not running" - -Exit code 0 if running, 1 if not. - -### `--enable-autostart` - -**macOS (launchd):** - -Write `~/Library/LaunchAgents/com.tracedecay.daemon.plist`: -```xml - - - - - Label - com.tracedecay.daemon - ProgramArguments - - /path/to/tracedecay - daemon - --foreground - - RunAtLoad - - KeepAlive - - StandardOutPath - ~/.tracedecay/daemon.log - StandardErrorPath - ~/.tracedecay/daemon.log - - -``` - -Then run `launchctl load `. - -**Linux (systemd):** - -Write `~/.config/systemd/user/tracedecay-daemon.service`: -```ini -[Unit] -Description=tracedecay file watcher daemon - -[Service] -ExecStart=/path/to/tracedecay daemon --foreground -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -``` - -Then run `systemctl --user enable --now tracedecay-daemon.service`. - -### `--disable-autostart` - -**macOS:** `launchctl unload `, then delete the plist file. - -**Linux:** `systemctl --user disable --now tracedecay-daemon.service`, then delete the unit file. - -## Error Handling - -- **Project path gone:** Skip it on next discovery cycle, don't crash. Log a warning once. -- **sync() failure:** Log the error, continue watching. Don't retry immediately — wait for next file change. -- **Global DB unreachable:** Retry on next 60s poll cycle. Keep existing watchers running. -- **Watcher limit exhaustion:** If the OS file watch limit is hit, log a warning and suggest increasing `fs.inotify.max_user_watches` (Linux) or similar. Continue watching already-registered projects. -- **Permission errors:** Log and skip the project. - -## Doctor Integration - -Add a "Daemon" section to `tracedecay doctor` output: - -``` -Daemon - ✔ Daemon is running (PID: 12345) -``` -or -``` -Daemon - ! Daemon is not running — run `tracedecay daemon` to start -``` - -Also check if autostart is enabled: -``` - ✔ Autostart enabled (launchd) -``` -or -``` - ! Autostart not configured — run `tracedecay daemon --enable-autostart` -``` - -## Dependencies - -- `notify` crate (v7) — cross-platform file system watcher -- `nix` crate — Unix fork/setsid/signal handling (or use `libc` directly) - -## Logging - -The daemon writes to `~/.tracedecay/daemon.log`. Log format: - -``` -[2026-03-27 14:32:01] started, watching 3 projects -[2026-03-27 14:32:16] synced /Users/foo/myproject — 2 added, 1 modified, 0 removed (45ms) -[2026-03-27 14:33:01] discovered new project: /Users/foo/another -[2026-03-27 14:33:05] synced /Users/foo/another — 0 added, 0 modified, 0 removed (12ms) -[2026-03-27 14:35:00] shutting down (SIGTERM) -``` - -## Out of Scope - -- Windows service support (can be added later) -- Remote/network file system watching -- Per-project debounce overrides (global config only) -- Web UI or dashboard diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 21b318de..3c79b7af 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -270,11 +270,20 @@ fn config_error(message: impl Into) -> TraceDecayError { } } -/// Builds state and runs the dashboard server until interrupted. +/// Builds state and runs the dashboard server until `shutdown` resolves. /// Binds `host:port` (`port` 0 lets the OS pick) and prints the URL on /// stderr; the URL line on stdout is stable output for wrappers to parse. /// Pass `open: true` to also open the URL in the default browser (CLI --open). -pub async fn run(cg: &TraceDecay, host: &str, port: u16, open: bool) -> Result<()> { +pub async fn run_until_shutdown( + cg: &TraceDecay, + host: &str, + port: u16, + open: bool, + shutdown: F, +) -> Result<()> +where + F: std::future::Future + Send + 'static, +{ let state = build_state(cg).await; if state.lcm_scope != "global" { spawn_session_catch_up_ingest(state.project_root.clone()); @@ -297,13 +306,19 @@ pub async fn run(cg: &TraceDecay, host: &str, port: u16, open: bool) -> Result<( } axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = tokio::signal::ctrl_c().await; - }) + .with_graceful_shutdown(shutdown) .await .map_err(|e| config_error(format!("dashboard server error: {e}"))) } +/// Runs the dashboard server until interrupted by Ctrl-C. +pub async fn run(cg: &TraceDecay, host: &str, port: u16, open: bool) -> Result<()> { + run_until_shutdown(cg, host, port, open, async { + let _ = tokio::signal::ctrl_c().await; + }) + .await +} + /// Shared bind logic for both CLI `run` and the MCP `tracedecay_dashboard` tool /// (so port 0 allocation and URL formatting are consistent, no duplication). pub(crate) async fn bind_dashboard( diff --git a/tests/branch_drift_test.rs b/tests/branch_drift_test.rs index 1cf868a2..9d89631a 100644 --- a/tests/branch_drift_test.rs +++ b/tests/branch_drift_test.rs @@ -4,14 +4,69 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; use tracedecay::branch_meta::{save_branch_meta, BranchMeta}; +use tracedecay::config::USER_DATA_DIR_ENV; use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; +static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +struct HomeEnvGuard { + previous_home: Option, + previous_userprofile: Option, + previous_data_dir: Option, +} + +impl HomeEnvGuard { + fn set(home: &Path) -> Self { + let previous_home = std::env::var_os("HOME"); + let previous_userprofile = std::env::var_os("USERPROFILE"); + let previous_data_dir = std::env::var_os(USER_DATA_DIR_ENV); + let home = canonical_temp_path(home); + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + std::env::set_var(USER_DATA_DIR_ENV, home.join(".tracedecay")); + Self { + previous_home, + previous_userprofile, + previous_data_dir, + } + } +} + +impl Drop for HomeEnvGuard { + fn drop(&mut self) { + match self.previous_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.previous_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + match self.previous_data_dir.take() { + Some(value) => std::env::set_var(USER_DATA_DIR_ENV, value), + None => std::env::remove_var(USER_DATA_DIR_ENV), + } + } +} + +fn canonical_temp_path(path: &Path) -> PathBuf { + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } +} + fn git(project: &Path, args: &[&str]) { let status = Command::new("git") .args(["-c", "core.hooksPath=.git/no-hooks"]) @@ -47,6 +102,9 @@ fn project_data_dir(project: &Path) -> PathBuf { #[tokio::test] async fn sync_refuses_to_write_after_mid_session_branch_checkout() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); let dir = TempDir::new().unwrap(); let project = dir.path(); init_repo_on_main(project); @@ -94,6 +152,9 @@ async fn sync_refuses_to_write_after_mid_session_branch_checkout() { #[tokio::test] async fn no_drift_and_sync_allowed_while_on_opened_branch() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); let dir = TempDir::new().unwrap(); let project = dir.path(); init_repo_on_main(project); @@ -115,6 +176,9 @@ async fn no_drift_and_sync_allowed_while_on_opened_branch() { #[tokio::test] async fn sync_allowed_in_single_db_mode_without_git() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); // No git repo => no default branch detected => no branch metadata => // single-DB mode (serving_branch == None), exempt from the drift guard. let dir = TempDir::new().unwrap(); @@ -139,6 +203,9 @@ async fn sync_allowed_in_single_db_mode_without_git() { #[tokio::test] async fn branch_diagnostics_reports_stale_open_and_serving_state_after_checkout() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); let dir = TempDir::new().unwrap(); let project = dir.path(); init_repo_on_main(project); @@ -172,6 +239,9 @@ async fn branch_diagnostics_reports_stale_open_and_serving_state_after_checkout( #[tokio::test] async fn branch_diagnostics_reports_auto_tracked_live_branch() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); let dir = TempDir::new().unwrap(); let project = dir.path(); init_repo_on_main(project); @@ -206,6 +276,9 @@ async fn branch_diagnostics_reports_auto_tracked_live_branch() { #[tokio::test] async fn open_repairs_missing_tracked_branch_db_before_diagnostics() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let home = TempDir::new().unwrap(); + let _home_env = HomeEnvGuard::set(home.path()); let dir = TempDir::new().unwrap(); let project = dir.path(); init_repo_on_main(project); diff --git a/tests/dashboard_api_test.rs b/tests/dashboard_api_test.rs index 99174f07..7097451c 100644 --- a/tests/dashboard_api_test.rs +++ b/tests/dashboard_api_test.rs @@ -3,6 +3,7 @@ mod common; use std::fs; use std::path::Path; use std::process::Command; +use std::thread; use common::{ create_runtime, get_json, http_agent, pick_free_port, response_to_json, tempdir_or_panic, @@ -36,12 +37,51 @@ struct DashboardFixture { base_url: String, project_root: std::path::PathBuf, project_db_path: std::path::PathBuf, - server: tokio::task::JoinHandle<()>, + server: DashboardServer, } impl Drop for DashboardFixture { fn drop(&mut self) { - self.server.abort(); + self.server.stop(); + } +} + +struct DashboardServer { + shutdown: Option>, + thread: Option>, +} + +impl DashboardServer { + fn stop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Drop for DashboardServer { + fn drop(&mut self) { + self.stop(); + } +} + +fn spawn_dashboard_server(cg: TraceDecay, port: u16) -> DashboardServer { + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let thread = thread::spawn(move || { + let runtime = create_runtime(); + runtime.block_on(async move { + let _ = dashboard::run_until_shutdown(&cg, "127.0.0.1", port, false, async move { + let _ = shutdown_rx.await; + }) + .await; + }); + }); + DashboardServer { + shutdown: Some(shutdown), + thread: Some(thread), } } @@ -402,9 +442,7 @@ async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); let project_db_path = cg.store_layout().graph_db_path.clone(); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let server = spawn_dashboard_server(cg, port); let agent = http_agent(); wait_for_dashboard(&agent, &base_url).await; @@ -659,12 +697,10 @@ fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { Ok(cg) => cg, Err(err) => panic!("failed to reopen fixture project: {err}"), }; - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let mut server = spawn_dashboard_server(cg, port); wait_for_dashboard(&agent, &base_url).await; let (status, _capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); - server.abort(); + server.stop(); assert_eq!(status, 200); let repaired = count_in_project_db( &fixture, @@ -730,14 +766,12 @@ fn dashboard_reports_resolved_branch_db_path() { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let mut server = spawn_dashboard_server(cg, port); let agent = http_agent(); wait_for_dashboard(&agent, &base_url).await; let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); - server.abort(); + server.stop(); assert_eq!(status, 200); assert_eq!(capabilities["graph_db"], expected); }); @@ -828,9 +862,7 @@ fn dashboard_uses_project_memory_db_and_branch_graph_db() { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let mut server = spawn_dashboard_server(cg, port); let agent = http_agent(); wait_for_dashboard(&agent, &base_url).await; @@ -856,7 +888,7 @@ fn dashboard_uses_project_memory_db_and_branch_graph_db() { &agent, &format!("{base_url}/api/plugins/graph/search?q=feature_branch_symbol"), ); - server.abort(); + server.stop(); assert_eq!(status, 200); assert!( graph_search["total"].as_i64().unwrap_or_default() > 0, @@ -2115,9 +2147,7 @@ fn lcm_serves_project_session_store_without_global_override() { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let mut server = spawn_dashboard_server(cg, port); let agent = http_agent(); wait_for_dashboard(&agent, &base_url).await; @@ -2166,7 +2196,7 @@ fn lcm_serves_project_session_store_without_global_override() { "project-store search should match seeded messages" ); - server.abort(); + server.stop(); }); } @@ -2200,9 +2230,7 @@ fn lcm_project_store_wins_over_global_accounting_override() { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let mut server = spawn_dashboard_server(cg, port); let agent = http_agent(); wait_for_dashboard(&agent, &base_url).await; @@ -2230,7 +2258,7 @@ fn lcm_project_store_wins_over_global_accounting_override() { "expected resolved project session DB path, got {path}" ); - server.abort(); + server.stop(); }); } @@ -2264,18 +2292,15 @@ fn curation_preview_persists_across_dashboard_restarts() { .dashboard_root .join("curation_preview.json"); - async fn start_server(cg: TraceDecay) -> (String, tokio::task::JoinHandle<()>) { + async fn start_server(cg: TraceDecay) -> (String, DashboardServer) { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let server = tokio::spawn(async move { - let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; - }); + let server = spawn_dashboard_server(cg, port); (base_url, server) } - async fn stop_server(server: tokio::task::JoinHandle<()>) { - server.abort(); - let _ = server.await; + fn stop_server(mut server: DashboardServer) { + server.stop(); } async fn reopen_project(project_root: &Path) -> TraceDecay { @@ -2303,7 +2328,7 @@ fn curation_preview_persists_across_dashboard_restarts() { assert!(!preview["report"].is_null(), "dry-run must save a preview"); let saved_at = preview["saved_at"].clone(); assert!(saved_at.is_string(), "preview must carry saved_at"); - stop_server(server).await; + stop_server(server); assert!( sidecar.exists(), "dry-run must persist the preview sidecar at {}", @@ -2359,7 +2384,7 @@ fn curation_preview_persists_across_dashboard_restarts() { !sidecar.exists(), "apply must remove the persisted preview sidecar" ); - stop_server(server).await; + stop_server(server); // Server 3: nothing is restored after the apply cleared the sidecar. let cg = reopen_project(&project_root).await; @@ -2374,6 +2399,6 @@ fn curation_preview_persists_across_dashboard_restarts() { preview["report"].is_null(), "no preview may reappear after curation was applied" ); - stop_server(server).await; + stop_server(server); }); }