From 9b74f98e4e79b603077e9cd64d813d4570eea666 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 00:16:38 +0000 Subject: [PATCH 01/21] fix(storage): enforce sole-daemon database ownership --- .config/nextest.toml | 4 +- src/agent_cmd.rs | 112 +- src/agents/codex.rs | 10 +- src/agents/cursor_diagnostics.rs | 12 +- src/agents/mod.rs | 7 +- src/analytics_bridge.rs | 87 +- src/automation_cli.rs | 400 +++-- src/branch.rs | 4 +- src/branch/tests.rs | 25 +- src/commands.rs | 700 ++++---- src/config.rs | 22 +- src/cost_cmd.rs | 115 +- src/daemon.rs | 1561 +++++++++++++---- src/daemon/authority.rs | 624 +++++++ src/daemon/tests.rs | 861 ++++++++- src/daemon/tests/compatibility.rs | 190 ++ src/daemon/transport.rs | 316 ++++ src/dashboard/memory_curate.rs | 15 +- src/dashboard/mod.rs | 49 +- src/db/access.rs | 772 ++++++++ src/db/access/bootstrap.rs | 142 ++ src/db/access/lease.rs | 392 +++++ src/db/access/owner_io.rs | 215 +++ src/db/access/path_layout.rs | 119 ++ src/db/connection.rs | 462 +++-- src/db/connection/integrity.rs | 111 ++ src/db/connection/pragmas.rs | 154 ++ src/db/connection/registry.rs | 35 + src/db/mod.rs | 9 +- src/doctor.rs | 427 ++--- src/doctor/heal.rs | 207 ++- src/doctor/heal/report.rs | 82 + src/doctor/tests.rs | 69 +- src/global.rs | 76 +- src/global_db.rs | 203 ++- src/global_db/tests.rs | 175 +- src/hooks/claude.rs | 141 +- src/hooks/codex.rs | 205 +-- src/hooks/cursor.rs | 136 +- src/hooks/cursor_compact.rs | 172 +- src/hooks/kiro.rs | 77 +- src/hooks/memory_inject.rs | 183 +- src/hooks/mod.rs | 320 ++-- src/hooks/steering.rs | 17 +- src/lifecycle_lease.rs | 7 + src/main.rs | 135 +- src/mcp/degraded.rs | 152 -- src/mcp/hook_events.rs | 18 + src/mcp/mod.rs | 2 +- src/mcp/server.rs | 60 +- src/mcp/server/freshness_tests.rs | 49 +- src/mcp/tools/handlers/admin_cli.rs | 449 +++++ src/mcp/tools/handlers/admin_project.rs | 538 ++++++ src/mcp/tools/handlers/hook_runtime.rs | 637 +++++++ src/mcp/tools/handlers/info.rs | 43 + src/mcp/tools/handlers/memory.rs | 3 +- src/mcp/tools/handlers/mod.rs | 24 + src/mcp/tools/mod.rs | 1 + src/mcp/transport.rs | 11 +- src/memory/user.rs | 7 +- src/migrate/consolidate/mod.rs | 16 +- src/migrate/consolidate/sqlite.rs | 9 +- src/migrate/consolidate/sqlite/inspect.rs | 17 +- src/migrate/consolidate/tests.rs | 58 +- src/migrate/hermes.rs | 105 +- src/migrate/inventory.rs | 77 +- src/monitor.rs | 174 +- src/monitor/cost.rs | 403 +++++ src/project_cmd.rs | 265 +-- src/runtime_telemetry.rs | 198 ++- src/serve.rs | 758 +------- src/sessions/mod.rs | 2 +- src/sessions_cmd.rs | 312 ++-- src/sqlite_read_snapshot.rs | 14 + src/status_cmd.rs | 157 +- src/tool_command.rs | 125 +- src/tracedecay/diagnostics.rs | 17 +- src/tracedecay/indexing.rs | 34 +- src/tracedecay/lifecycle.rs | 66 +- src/tracedecay/locking.rs | 638 +++++-- tests/automation_runner_test/user_scope.rs | 6 +- tests/common/mod.rs | 23 +- .../cli_non_interactive_test.rs | 18 +- .../regression_core_engine_test.rs | 60 +- tests/core_cli_suite/sync_test.rs | 7 +- tests/core_cli_suite/tool_daemon_test.rs | 39 +- tests/graph_suite/context_test.rs | 72 +- tests/graph_suite/graph_test.rs | 2 +- tests/graph_suite/main.rs | 3 + tests/graph_suite/resolution_test.rs | 4 +- .../hook_branch_routing_test.rs | 3 +- tests/mcp_suite/main.rs | 1 - tests/mcp_suite/mcp_cli_serve_test.rs | 71 +- tests/mcp_suite/mcp_handler_test.rs | 11 +- tests/mcp_suite/serve_degraded_mode_test.rs | 273 --- tests/mcp_suite/serve_harness.rs | 131 +- tests/mcp_suite/serve_template_path_test.rs | 308 +--- tests/memory_suite/memory_test.rs | 10 +- tests/storage_suite/corruption_test.rs | 276 ++- .../storage_suite/corruption_test/fallback.rs | 141 ++ tests/storage_suite/db_query_test.rs | 10 +- tests/storage_suite/db_test.rs | 12 +- tests/storage_suite/main.rs | 1 + .../storage_suite/migration_manifest_test.rs | 4 +- tests/storage_suite/migration_test.rs | 7 +- tests/storage_suite/multi_connection_test.rs | 780 ++++++++ .../profile_storage_migration_test.rs | 23 +- tests/storage_suite/storage_resolver_test.rs | 7 +- tests/storage_suite/support.rs | 2 +- 109 files changed, 12518 insertions(+), 5383 deletions(-) create mode 100644 src/daemon/authority.rs create mode 100644 src/daemon/tests/compatibility.rs create mode 100644 src/daemon/transport.rs create mode 100644 src/db/access.rs create mode 100644 src/db/access/bootstrap.rs create mode 100644 src/db/access/lease.rs create mode 100644 src/db/access/owner_io.rs create mode 100644 src/db/access/path_layout.rs create mode 100644 src/db/connection/integrity.rs create mode 100644 src/db/connection/pragmas.rs create mode 100644 src/db/connection/registry.rs create mode 100644 src/doctor/heal/report.rs delete mode 100644 src/mcp/degraded.rs create mode 100644 src/mcp/tools/handlers/admin_cli.rs create mode 100644 src/mcp/tools/handlers/admin_project.rs create mode 100644 src/mcp/tools/handlers/hook_runtime.rs create mode 100644 src/monitor/cost.rs delete mode 100644 tests/mcp_suite/serve_degraded_mode_test.rs create mode 100644 tests/storage_suite/corruption_test/fallback.rs create mode 100644 tests/storage_suite/multi_connection_test.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index 222aa79f3..6ab6b2a3b 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -53,12 +53,12 @@ filter = 'binary(=core_cli_suite) & test(/^tool_daemon_test::(daemon_socket_is_o threads-required = "num-cpus" [[profile.ci.overrides]] -filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))' +filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))' test-group = 'cli-subprocess' threads-required = "num-cpus" [[profile.default.overrides]] -filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))' +filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))' test-group = 'cli-subprocess' threads-required = "num-cpus" diff --git a/src/agent_cmd.rs b/src/agent_cmd.rs index d4012a434..80b4dacdb 100644 --- a/src/agent_cmd.rs +++ b/src/agent_cmd.rs @@ -53,8 +53,7 @@ async fn install_codex_daemon_automation( ); } - let cg = open_or_init_codex_daemon_automation_project(project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); + let dashboard_root = open_or_init_codex_daemon_automation_project(project_path).await?; let patch = AutomationConfigPatch { enabled: Some(true), backend: Some(AutomationBackend::CodexAppServer), @@ -97,22 +96,44 @@ async fn install_codex_daemon_automation( async fn open_or_init_codex_daemon_automation_project( project_path: &Path, -) -> tracedecay::errors::Result { - if tracedecay::tracedecay::TraceDecay::has_initialized_store(project_path).await { - tracedecay::tracedecay::TraceDecay::open(project_path).await - } else { - eprintln!( - "No TraceDecay store found for {}; initializing one (equivalent to `tracedecay init`).", - project_path.display() - ); - let cg = tracedecay::tracedecay::TraceDecay::init_with_options( - project_path, - tracedecay::tracedecay::TraceDecayOpenOptions::default(), - ) - .await?; - cg.index_all().await?; - Ok(cg) - } +) -> tracedecay::errors::Result { + broker_codex_daemon_automation_project( + project_path, + |handshake| async move { + tracedecay::daemon::call_default_tool( + &handshake, + "tracedecay_admin_project", + serde_json::json!({"action": "counter_get"}), + ) + .await + .map(|_| ()) + }, + |project_path| { + tracedecay::storage::resolve_layout_for_current_profile(project_path) + .map(|layout| layout.dashboard_root) + }, + ) + .await +} + +async fn broker_codex_daemon_automation_project( + project_path: &Path, + initialize: I, + resolve_dashboard_root: R, +) -> tracedecay::errors::Result +where + I: FnOnce(tracedecay::daemon::DaemonHandshake) -> IFut, + IFut: std::future::Future>, + R: FnOnce(&Path) -> tracedecay::errors::Result, +{ + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_path.to_path_buf()), + None, + false, + true, + )?; + initialize(handshake).await?; + resolve_dashboard_root(project_path) } fn codex_daemon_interval_task(interval_secs: u64) -> AutomationTaskPatch { @@ -546,10 +567,63 @@ pub(crate) async fn handle_uninstall_command( #[cfg(test)] mod tests { use std::path::PathBuf; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; use tracedecay::migrate::hermes::{LegacyHermesMigrationIssue, LegacyHermesMigrationReport}; - use super::finish_legacy_hermes_reinstall_migration; + use super::{broker_codex_daemon_automation_project, finish_legacy_hermes_reinstall_migration}; + + #[tokio::test] + async fn codex_automation_project_initializes_through_daemon() { + let project = tempfile::tempdir().unwrap(); + let project_path = project.path().to_path_buf(); + let expected_project_path = project_path.clone(); + let expected_dashboard = project_path.join("dashboard"); + let actual = broker_codex_daemon_automation_project( + &project_path, + move |handshake| async move { + assert_eq!( + handshake.project_path.as_deref(), + Some(expected_project_path.as_path()) + ); + assert!(handshake.allow_init); + Ok(()) + }, + |_| Ok(expected_dashboard.clone()), + ) + .await + .unwrap(); + + assert_eq!(actual, expected_dashboard); + } + + #[tokio::test] + async fn unavailable_daemon_does_not_resolve_or_open_local_project() { + let project = tempfile::tempdir().unwrap(); + let resolved = Arc::new(AtomicBool::new(false)); + let resolver_called = Arc::clone(&resolved); + let error = broker_codex_daemon_automation_project( + project.path(), + |_| async { + Err(tracedecay::errors::TraceDecayError::Config { + message: "daemon unavailable".to_string(), + }) + }, + move |_| { + resolver_called.store(true, Ordering::SeqCst); + Ok(PathBuf::from("unreachable")) + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("daemon unavailable")); + assert!(!resolved.load(Ordering::SeqCst)); + assert!(std::fs::read_dir(project.path()).unwrap().next().is_none()); + } #[test] fn automated_reinstall_preserves_unresolved_legacy_store_without_gating() { diff --git a/src/agents/codex.rs b/src/agents/codex.rs index ff487509e..5e012dd55 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -507,11 +507,11 @@ fn uninstall_tracedecay_mcp_if_present(config_path: &Path) { /// The scope contract for a rendered Codex plugin bundle, in one place. /// /// A global bundle ships lifecycle hooks (declared in the manifest and -/// recorded as trusted in the user-level `~/.codex/config.toml`), serves with -/// the global DB enabled, and carries the memory digest. A repo-local bundle -/// ships no hooks, serves the project path with no env, and stays free of -/// user-profile state. The bundle writer, manifest/MCP renderers, and doctor -/// all consume this type instead of re-encoding the scope as ad-hoc +/// recorded as trusted in the user-level `~/.codex/config.toml`), invokes +/// `serve` without an explicit project path, and carries the memory digest. A +/// repo-local bundle ships no hooks, invokes `serve --path .` with no env, and +/// stays free of user-profile state. The bundle writer, manifest/MCP renderers, +/// and doctor all consume this type instead of re-encoding the scope as ad-hoc /// conditionals. #[derive(Debug, Clone, Copy)] struct CodexBundlePolicy { diff --git a/src/agents/cursor_diagnostics.rs b/src/agents/cursor_diagnostics.rs index a520933c9..4ba0c77e3 100644 --- a/src/agents/cursor_diagnostics.rs +++ b/src/agents/cursor_diagnostics.rs @@ -37,8 +37,7 @@ pub(crate) struct CursorMcpLogFindings { /// "Connection failed: MCP error -32000" lines — each one is a failed /// spawn whose scope Cursor will never retry. pub connection_failures: usize, - /// Lines where a newer tracedecay serve stayed alive in degraded MCP mode - /// instead of exiting. + /// Legacy degraded-mode marker lines retained in recent Cursor logs. pub degraded_mode_notices: usize, /// Log files (newest session first) that contained at least one finding. pub affected_logs: Vec, @@ -166,8 +165,9 @@ pub(crate) fn report_cursor_mcp_log_findings(dc: &mut DoctorCounters, home: &Pat } if findings.degraded_mode_notices > 0 { dc.warn(&format!( - "tracedecay serve ran in degraded MCP mode {} time(s) recently (project \ - resolution failed at startup); run `tracedecay init` in the affected project", + "found {} legacy tracedecay serve degraded-mode notice(s) in recent Cursor logs \ + (an older version failed project resolution at startup); run `tracedecay init` \ + in the affected project", findings.degraded_mode_notices )); } @@ -294,8 +294,8 @@ mod tests { assert!(findings.has_findings()); } - /// The scanner must match the exact marker `serve` emits — shared via - /// [`DEGRADED_SERVE_STDERR_MARKER`] so the two cannot drift. + /// The scanner must match the exact marker older `serve` versions emitted; + /// [`DEGRADED_SERVE_STDERR_MARKER`] retains that legacy log contract. #[test] fn scan_detects_degraded_mode_notice() { let logs = TempDir::new().unwrap(); diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 20aa86a37..fc28e1090 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -332,9 +332,9 @@ pub struct HealthcheckContext { /// args/env wiring via an exhaustive `match`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InstallScope { - /// User-global install: `serve` with the global DB enabled. + /// User-global install: `serve` without an explicit project path. Global, - /// Project-local install: `serve --path .` with no global DB. + /// Project-local install: `serve --path .` with an explicit project route. ProjectLocal, } @@ -820,9 +820,6 @@ pipe it via `--args -` (a quoted heredoc) when it contains quotes or newlines" }; } -/// Shared `--args` invocation phrase for CLI-fallback steering surfaces. -pub(crate) const CLI_FALLBACK_ARGS_INVOCATION: &str = cli_fallback_args_invocation_lit!(); - /// CLI-fallback steering paragraph shared by every host's prompt rules. /// /// Mirrors the guidance in the MCP server instructions and the bundled diff --git a/src/analytics_bridge.rs b/src/analytics_bridge.rs index 872c0f58f..a9bd5775a 100644 --- a/src/analytics_bridge.rs +++ b/src/analytics_bridge.rs @@ -243,12 +243,6 @@ fn cli_project_root() -> Option { .and_then(|cwd| crate::config::discover_project_root(&cwd)) } -async fn open_global_db() -> crate::errors::Result { - GlobalDb::open().await.ok_or_else(|| { - cli_error("user-level global DB unavailable (no writable profile root)".to_string()) - }) -} - async fn diagnostics_message_count( global: &GlobalDb, project_root: Option<&Path>, @@ -295,12 +289,10 @@ async fn diagnostics_message_count( /// `analytics_events` table and print what happened. pub async fn run_analytics_sync() -> crate::errors::Result<()> { let project_root = cli_project_root(); - let gdb = open_global_db().await?; - let sources = hook_import_sources(project_root.as_deref()); - let outcome = import_hook_analytics(&gdb, &sources).await; + let outcome = call_admin_cli(project_root, json!({ "action": "analytics_sync" })).await?; println!( "{}", - serde_json::to_string_pretty(&outcome.as_json()).unwrap_or_default() + serde_json::to_string_pretty(&outcome).unwrap_or_default() ); Ok(()) } @@ -311,22 +303,65 @@ pub async fn run_analytics_diagnostics( all_projects: bool, no_sync: bool, ) -> crate::errors::Result<()> { - const EVENT_SAMPLE_LIMIT: usize = 10_000; - let project_root = cli_project_root(); - let gdb = open_global_db().await?; + let summary = call_admin_cli( + project_root, + json!({ + "action": "analytics_diagnostics", + "all": all_projects, + "no_sync": no_sync, + }), + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&summary).unwrap_or_default() + ); + Ok(()) +} + +async fn call_admin_cli( + project_root: Option, + arguments: Value, +) -> crate::errors::Result { + let handshake = + crate::daemon::DaemonHandshake::for_current_client(project_root, None, false, false)?; + let result = + crate::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments).await?; + let text = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::(); + serde_json::from_str(&text) + .map_err(|error| cli_error(format!("daemon admin response was invalid JSON: {error}"))) +} + +pub(crate) async fn analytics_sync_with_db(gdb: &GlobalDb, project_root: Option<&Path>) -> Value { + let sources = hook_import_sources(project_root); + import_hook_analytics(gdb, &sources).await.as_json() +} + +pub(crate) async fn analytics_diagnostics_with_db( + gdb: &GlobalDb, + project_root: Option<&Path>, + all_projects: bool, + no_sync: bool, +) -> crate::errors::Result { + const EVENT_SAMPLE_LIMIT: usize = 10_000; let import = if no_sync { Value::Null } else { - let sources = hook_import_sources(project_root.as_deref()); - import_hook_analytics(&gdb, &sources).await.as_json() + analytics_sync_with_db(gdb, project_root).await }; let project_filter = if all_projects { None } else { - project_root.as_deref().map(GlobalDb::canonical_project_key) + project_root.map(GlobalDb::canonical_project_key) }; let events = gdb .query_analytics_events(&crate::global_db::AnalyticsEventQuery { @@ -344,23 +379,18 @@ pub async fn run_analytics_diagnostics( .map(crate::dashboard::analytics_api::durable_analytics_event_row) .collect(); - let store_root = project_root.as_deref().and_then(|root| { + let store_root = project_root.and_then(|root| { crate::storage::resolve_layout_for_current_profile(root) .ok() .map(|layout| layout.data_root) }); - let hook_filter_root = if all_projects { - None - } else { - project_root.as_deref() - }; + let hook_filter_root = if all_projects { None } else { project_root }; let hook_analytics = crate::dashboard::analytics_api::read_hook_analytics_rows_at( store_root.as_deref(), hook_filter_root, ); - let message_count = - diagnostics_message_count(&gdb, project_root.as_deref(), all_projects).await; + let message_count = diagnostics_message_count(gdb, project_root, all_projects).await; let durable = if event_rows.is_empty() { None @@ -380,8 +410,7 @@ pub async fn run_analytics_diagnostics( summary.insert("import".to_string(), import); summary.insert( "global_db".to_string(), - crate::global_db::global_db_path() - .map_or(Value::Null, |path| json!(path.display().to_string())), + json!(gdb.db_path().display().to_string()), ); summary.insert("event_sample_limit".to_string(), json!(EVENT_SAMPLE_LIMIT)); summary.insert( @@ -389,11 +418,7 @@ pub async fn run_analytics_diagnostics( json!(event_rows.len() >= EVENT_SAMPLE_LIMIT), ); } - println!( - "{}", - serde_json::to_string_pretty(&summary).unwrap_or_default() - ); - Ok(()) + Ok(summary) } #[cfg(test)] diff --git a/src/automation_cli.rs b/src/automation_cli.rs index 407cabae1..042bacac1 100644 --- a/src/automation_cli.rs +++ b/src/automation_cli.rs @@ -1,6 +1,129 @@ use crate::cli::*; +use crate::parse_lcm_scope_arg; +use crate::resolve_cli_project_root; use crate::update_cmd::tracedecay_bin_on_path; -use crate::{parse_lcm_scope_arg, resolve_cli_project_root}; + +async fn daemon_project_dashboard_root( + project_path: &std::path::Path, +) -> tracedecay::errors::Result { + let context = crate::commands::daemon_tool_json( + Some(project_path), + "tracedecay_active_project", + serde_json::json!({ "format": "json" }), + ) + .await?; + let data_root = context + .get("storage") + .and_then(|storage| storage.get("data_root")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "managed daemon returned no active project data_root".to_string(), + })?; + Ok(std::path::PathBuf::from(data_root).join("dashboard")) +} + +async fn daemon_automation_action( + project_path: &std::path::Path, + args: serde_json::Value, +) -> tracedecay::errors::Result { + crate::commands::daemon_tool_json(Some(project_path), "tracedecay_admin_project", args).await +} + +fn fact_apply_rpc_args(id: &str) -> serde_json::Value { + serde_json::json!({ "action": "fact_apply", "id": id }) +} + +fn automation_run_rpc_request( + action: AutomationRunAction, +) -> tracedecay::errors::Result<(Option, serde_json::Value)> { + let request = match action { + AutomationRunAction::MemoryCuration { + max_clusters, + min_confidence, + path, + } => ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { + "max_clusters": max_clusters, + "min_confidence": min_confidence, + }, + }), + ), + AutomationRunAction::SessionReflection { + provider, + query, + evidence_limit, + scope, + session_id, + include_summaries, + sort, + source, + role, + start_time, + end_time, + path, + } => { + parse_lcm_scope_arg(&scope)?; + sort.parse::() + .map_err(|()| tracedecay::errors::TraceDecayError::Config { + message: format!( + "invalid session-reflection --sort '{sort}'; expected recency, relevance, or hybrid" + ), + })?; + ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "session_reflection", + "options": { + "provider": provider, + "query": query, + "evidence_limit": evidence_limit, + "scope": scope, + "session_id": session_id, + "include_summaries": include_summaries, + "sort": sort, + "source": source, + "role": role, + "start_time": start_time, + "end_time": end_time, + }, + }), + ) + } + AutomationRunAction::SkillWriting { + provider, + query, + evidence_limit, + path, + } => ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "skill_writing", + "options": { + "provider": provider, + "query": query, + "evidence_limit": evidence_limit, + }, + }), + ), + }; + Ok(request) +} + +fn automation_run_result( + payload: &serde_json::Value, +) -> tracedecay::errors::Result<&serde_json::Value> { + payload + .get("run") + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon automation response omitted run".to_string(), + }) +} pub(crate) async fn handle_automation_command( action: AutomationAction, @@ -27,8 +150,7 @@ async fn handle_automation_runs_command( | AutomationRunsAction::Artifact { path, .. } => path.clone(), }; let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; match action { AutomationRunsAction::List { limit, json, .. } => { @@ -182,8 +304,7 @@ async fn handle_automation_facts_command( action: AutomationFactsAction, ) -> tracedecay::errors::Result<()> { use tracedecay::automation::fact_proposals::{ - FactProposalState, apply_fact_proposal, list_fact_proposals, load_fact_proposal, - reject_fact_proposal, + FactProposalState, list_fact_proposals, load_fact_proposal, reject_fact_proposal, }; let path = match &action { @@ -193,10 +314,9 @@ async fn handle_automation_facts_command( | AutomationFactsAction::Reject { path, .. } => path.clone(), }; let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); let payload = match action { AutomationFactsAction::List { state, limit, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; let state = match state { Some(value) => Some(FactProposalState::parse(&value)?), None => None, @@ -209,6 +329,7 @@ async fn handle_automation_facts_command( }) } AutomationFactsAction::View { id, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; let proposal = load_fact_proposal(&dashboard_root, &id) .await? .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { @@ -217,21 +338,10 @@ async fn handle_automation_facts_command( serde_json::json!({ "proposal": proposal }) } AutomationFactsAction::Apply { id, .. } => { - let proposal = apply_fact_proposal( - &dashboard_root, - cg.db().conn(), - &id, - Some("cli".to_string()), - ) - .await?; - tracedecay::automation::memory_digest::refresh_memory_digest_after_memory_change( - cg.db().conn(), - &project_path, - ) - .await; - serde_json::json!({ "proposal": proposal }) + daemon_automation_action(&project_path, fact_apply_rpc_args(&id)).await? } AutomationFactsAction::Reject { id, reason, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; let proposal = reject_fact_proposal(&dashboard_root, &id, Some("cli".to_string()), reason).await?; serde_json::json!({ "proposal": proposal }) @@ -457,145 +567,11 @@ fn print_managed_skill(skill: &tracedecay::automation::managed_skills::ManagedSk async fn handle_automation_run_command( action: AutomationRunAction, ) -> tracedecay::errors::Result<()> { - use tracedecay::automation::backend::CodexAppServerBackend; - use tracedecay::automation::config::{ - AutomationBackend, effective_config, load_project_config, - }; - use tracedecay::automation::runner::{ - MemoryCuratorAutomationOptions, SessionReflectorAutomationOptions, - SkillWriterAutomationOptions, run_memory_curator_with_backend, - run_session_reflector_with_backend, run_skill_writer_with_backend, - }; - - match action { - AutomationRunAction::MemoryCuration { - max_clusters, - min_confidence, - path, - } => { - let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); - let global = tracedecay::user_config::UserConfig::load().automation; - let project = load_project_config(&dashboard_root).await?; - let effective = effective_config(&global, project.as_ref())?; - if effective.enabled && effective.backend == AutomationBackend::ExternalCommand { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation backend external_command is not implemented yet" - .to_string(), - }); - } - let backend = CodexAppServerBackend::from_automation_config(&effective); - let run = run_memory_curator_with_backend( - &cg, - &effective, - &backend, - MemoryCuratorAutomationOptions { - trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, - run_id: None, - max_clusters, - min_confidence, - }, - ) - .await?; - println!("{}", serde_json::to_string_pretty(&run)?); - } - AutomationRunAction::SessionReflection { - provider, - query, - evidence_limit, - scope, - session_id, - include_summaries, - sort, - source, - role, - start_time, - end_time, - path, - } => { - let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); - let global = tracedecay::user_config::UserConfig::load().automation; - let project = load_project_config(&dashboard_root).await?; - let effective = effective_config(&global, project.as_ref())?; - if effective.enabled && effective.backend == AutomationBackend::ExternalCommand { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation backend external_command is not implemented yet" - .to_string(), - }); - } - let backend = CodexAppServerBackend::from_automation_config(&effective); - let lcm_scope = parse_lcm_scope_arg(&scope)?; - let lcm_sort = sort - .parse::() - .map_err(|()| tracedecay::errors::TraceDecayError::Config { - message: format!( - "invalid session-reflection --sort '{sort}'; expected recency, relevance, or hybrid" - ), - })?; - let run = run_session_reflector_with_backend( - &cg, - &effective, - &backend, - SessionReflectorAutomationOptions { - trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, - run_id: None, - provider, - query, - scope: lcm_scope, - session_id, - include_summaries, - evidence_limit, - sort: lcm_sort, - source, - role, - start_time, - end_time, - ..SessionReflectorAutomationOptions::default() - }, - ) - .await?; - println!("{}", serde_json::to_string_pretty(&run)?); - } - AutomationRunAction::SkillWriting { - provider, - query, - evidence_limit, - path, - } => { - let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); - let global = tracedecay::user_config::UserConfig::load().automation; - let project = load_project_config(&dashboard_root).await?; - let effective = effective_config(&global, project.as_ref())?; - if effective.enabled && effective.backend == AutomationBackend::ExternalCommand { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation backend external_command is not implemented yet" - .to_string(), - }); - } - let backend = CodexAppServerBackend::from_automation_config(&effective); - let run = run_skill_writer_with_backend( - &cg, - &effective, - &backend, - SkillWriterAutomationOptions { - trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, - run_id: None, - provider, - query, - evidence_limit, - profile_root: None, - ..SkillWriterAutomationOptions::default() - }, - ) - .await?; - println!("{}", serde_json::to_string_pretty(&run)?); - } - } + let (path, args) = automation_run_rpc_request(action)?; + let project_path = resolve_cli_project_root(path, None, None).await?; + let payload = daemon_automation_action(&project_path, args).await?; + let run = automation_run_result(&payload)?; + println!("{}", serde_json::to_string_pretty(run)?); Ok(()) } @@ -626,10 +602,10 @@ async fn handle_automation_config_command( let global = user_config.automation.clone(); let project_context = if scope == AutomationConfigScope::Project { let project_path = resolve_cli_project_root(path, None, None).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; Some(( - cg.store_layout().dashboard_root.clone(), - load_project_config(&cg.store_layout().dashboard_root).await?, + dashboard_root.clone(), + load_project_config(&dashboard_root).await?, )) } else { None @@ -945,3 +921,103 @@ fn parse_automation_host_mode( }), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automation_rpc_requests_preserve_fact_and_manual_run_arguments() { + assert_eq!( + fact_apply_rpc_args("fact-7"), + serde_json::json!({ "action": "fact_apply", "id": "fact-7" }) + ); + + let (path, request) = automation_run_rpc_request(AutomationRunAction::MemoryCuration { + max_clusters: 9, + min_confidence: 0.7, + path: Some("/repo".to_string()), + }) + .unwrap(); + assert_eq!(path.as_deref(), Some("/repo")); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { "max_clusters": 9, "min_confidence": 0.7 }, + }) + ); + + let (path, request) = automation_run_rpc_request(AutomationRunAction::SessionReflection { + provider: "claude".to_string(), + query: "decisions".to_string(), + evidence_limit: 11, + scope: "session".to_string(), + session_id: Some("session-3".to_string()), + include_summaries: false, + sort: "hybrid".to_string(), + source: Some("assistant".to_string()), + role: Some("user".to_string()), + start_time: Some(10), + end_time: Some(20), + path: None, + }) + .unwrap(); + assert_eq!(path, None); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "session_reflection", + "options": { + "provider": "claude", + "query": "decisions", + "evidence_limit": 11, + "scope": "session", + "session_id": "session-3", + "include_summaries": false, + "sort": "hybrid", + "source": "assistant", + "role": "user", + "start_time": 10, + "end_time": 20, + }, + }) + ); + + let (_, request) = automation_run_rpc_request(AutomationRunAction::SkillWriting { + provider: "all".to_string(), + query: "repeated workflow".to_string(), + evidence_limit: 13, + path: None, + }) + .unwrap(); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "skill_writing", + "options": { + "provider": "all", + "query": "repeated workflow", + "evidence_limit": 13, + }, + }) + ); + } + + #[test] + fn automation_rpc_preserves_response_and_has_no_local_database_fallback() { + let payload = serde_json::json!({ "run": { "run_id": "run-5", "status": "ok" } }); + assert_eq!(automation_run_result(&payload).unwrap(), &payload["run"]); + assert!(automation_run_result(&serde_json::json!({})).is_err()); + + let source = include_str!("automation_cli.rs"); + let direct_init = ["serve::ensure_", "initialized"].concat(); + let direct_apply = ["apply_fact_", "proposal("].concat(); + assert!(!source.contains(&direct_init)); + assert!(!source.contains(&direct_apply)); + assert!(source.contains("tracedecay_admin_project")); + } +} diff --git a/src/branch.rs b/src/branch.rs index eb3c6dc20..d95ee492e 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -742,7 +742,9 @@ async fn create_consistent_branch_snapshot(src: &Path, dst: &Path) -> crate::err std::process::id() )); let result = async { - let (source, _) = crate::db::Database::open_read_only(src).await?; + let authority = + crate::db::DatabaseAuthority::for_runtime(src, "create branch snapshot")?; + let (source, _) = crate::db::Database::open_read_only(src, &authority).await?; source.snapshot_to(&temp).await?; std::fs::hard_link(&temp, dst).map_err(|error| { crate::errors::TraceDecayError::Config { diff --git a/src/branch/tests.rs b/src/branch/tests.rs index 9a6969d09..2bda415bd 100644 --- a/src/branch/tests.rs +++ b/src/branch/tests.rs @@ -198,7 +198,11 @@ async fn read_only_sqlite_snapshot_includes_committed_data() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.db"); let dst = dir.path().join("dst.db"); - let (writer, _) = crate::db::Database::initialize(&src).await.unwrap(); + let authority = + crate::db::DatabaseAuthority::acquire_test(&src, "branch snapshot test").unwrap(); + let (writer, _) = crate::db::Database::initialize(&src, &authority) + .await + .unwrap(); writer .conn() .execute_batch( @@ -207,10 +211,25 @@ async fn read_only_sqlite_snapshot_includes_committed_data() { ) .await .unwrap(); + writer.close(); - writer.snapshot_to(&dst).await.unwrap(); + let (source, _) = crate::db::Database::open_read_only(&src, &authority) + .await + .unwrap(); + source.snapshot_to(&dst).await.unwrap(); + assert!( + source + .conn() + .execute("CREATE TABLE forbidden_snapshot_write (id INTEGER)", ()) + .await + .is_err() + ); - let (snapshot, _) = crate::db::Database::open_read_only(&dst).await.unwrap(); + let snapshot_authority = + crate::db::DatabaseAuthority::acquire_test(&dst, "branch snapshot verification").unwrap(); + let (snapshot, _) = crate::db::Database::open_read_only(&dst, &snapshot_authority) + .await + .unwrap(); let mut rows = snapshot .conn() .query("SELECT value FROM snapshot_probe", ()) diff --git a/src/commands.rs b/src/commands.rs index 9270820a6..b7ab59800 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -6,9 +6,38 @@ use crate::cli::{BranchAction, MemoryAction, MigrateAction}; use crate::global; use tracedecay::tracedecay::TraceDecay; -pub(crate) async fn handle_memory_action(action: MemoryAction) -> tracedecay::errors::Result<()> { - use tracedecay::dashboard::memory_curate::{MemoryCurateOptions, run_memory_curate}; +pub(crate) async fn daemon_tool_json( + project_path: Option<&std::path::Path>, + tool_name: &str, + arguments: serde_json::Value, +) -> tracedecay::errors::Result { + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + project_path.map(std::path::Path::to_path_buf), + None, + false, + false, + )?; + let result = tracedecay::daemon::call_default_tool(&handshake, tool_name, arguments).await?; + let blocks = result + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no content blocks"), + })?; + for text in blocks + .iter() + .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) + { + if let Ok(value) = serde_json::from_str(text) { + return Ok(value); + } + } + Err(tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no JSON payload"), + }) +} +pub(crate) async fn handle_memory_action(action: MemoryAction) -> tracedecay::errors::Result<()> { match action { MemoryAction::Status { .. } => unreachable!("memory status is handled in main.rs dispatch"), MemoryAction::Curate { @@ -20,19 +49,23 @@ pub(crate) async fn handle_memory_action(action: MemoryAction) -> tracedecay::er path, } => { let project_path = tracedecay::config::resolve_path_with_discovery(path); - let cg = crate::serve::ensure_initialized(&project_path).await?; let llm_ops_value = match llm_ops { Some(source) => Some(read_llm_ops_payload(&source)?), None => None, }; - let options = MemoryCurateOptions { - apply, - llm, - llm_ops: llm_ops_value, - max_clusters: max_clusters.clamp(1, 50), - min_confidence: min_confidence.clamp(0.0, 1.0), - }; - let report = run_memory_curate(&cg, &options).await?; + let report = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ + "action": "memory_curate", + "apply": apply, + "llm": llm, + "llm_ops": llm_ops_value, + "max_clusters": max_clusters, + "min_confidence": min_confidence, + }), + ) + .await?; println!( "{}", serde_json::to_string_pretty(&report).unwrap_or_default() @@ -277,6 +310,11 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: &target_profile_root, "legacy store migration", )?; + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &_lifecycle_lease, + &target_profile_root, + "legacy store migration", + )?; let apply_report = tracedecay::migrate::manifest::apply_migration_manifest( &mut manifest, ) @@ -293,10 +331,10 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: ), }); } - let global_db = tracedecay::global_db::GlobalDb::open_at( + let global_db = tracedecay::global_db::GlobalDb::try_open_at( &apply_report.profile_root.join("global.db"), ) - .await + .await? .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { message: "could not open global DB for migrate apply".to_string(), })?; @@ -386,6 +424,16 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: ) }) .transpose()?; + let _database_scope = _lifecycle_lease + .as_ref() + .map(|lifecycle_lease| { + tracedecay::db::enter_maintenance_database_scope( + lifecycle_lease, + &profile_root, + "registry reconstruction", + ) + }) + .transpose()?; let report = tracedecay::migrate::registry::scan_profile_store_manifests( &profile_root, tracedecay::tracedecay::current_timestamp(), @@ -417,8 +465,8 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: }); } let global_db = - tracedecay::global_db::GlobalDb::open_at(&profile_root.join("global.db")) - .await + tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) + .await? .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { message: "could not open global DB for registry reconstruction" .to_string(), @@ -482,11 +530,22 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: apply, json, } => { - let global_db = tracedecay::global_db::GlobalDb::open() - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "could not open global DB for registry cleanup".to_string(), - })?; + let profile_root = tracedecay::storage::default_profile_root()?; + let lifecycle_lease = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "registry cleanup", + )?; + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &lifecycle_lease, + &profile_root, + "registry cleanup", + )?; + let global_db = + tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "could not open global DB for registry cleanup".to_string(), + })?; let projects = global_db.list_code_projects(usize::MAX).await; let prefixes: Vec = prefix.iter().map(PathBuf::from).collect(); let stale = tracedecay::migrate::registry::stale_code_projects( @@ -494,7 +553,6 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: &prefixes, tracedecay::migrate::registry::StaleRootScope::CanonicalRootMissing, ); - let profile_root = tracedecay::config::user_data_dir(); let mut stale_storage_projects = Vec::new(); for project_path in global_db.list_project_paths().await { let path = Path::new(&project_path); @@ -504,7 +562,7 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay:: let location = global::classify_project_storage_with_registry( path, Some(&global_db), - profile_root.as_deref(), + Some(&profile_root), ) .await; if location.status == global::ProjectStorageStatus::Stale { @@ -629,71 +687,121 @@ pub(crate) async fn handle_branch_action(action: BranchAction) -> tracedecay::er match action { BranchAction::List { path } => { let project_path = tracedecay::config::resolve_path(path); - let opened = TraceDecay::open_read_only(&project_path).await.ok(); - let tracedecay_dir = opened - .as_ref() - .map(|cg| cg.store_layout().data_root.clone()) - .unwrap_or_else(|| fallback_branch_data_root(&project_path)); - let Some(_meta) = branch_meta::load_branch_meta(&tracedecay_dir) else { + let status = daemon_tool_json( + Some(&project_path), + "tracedecay_status", + serde_json::json!({ "format": "json" }), + ) + .await?; + let diagnostics = status.get("branch_diagnostics").ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "daemon status omitted branch diagnostics".to_string(), + } + })?; + if !diagnostics + .get("tracking_enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { eprintln!("No branch tracking configured. Run `tracedecay branch add` to start."); return Ok(()); - }; - let diagnostics = opened - .as_ref() - .map(TraceDecay::branch_diagnostics) - .unwrap_or_else(|| TraceDecay::project_branch_diagnostics(&project_path)); + } eprintln!( "Default branch: {}", - diagnostics.default_branch.as_deref().unwrap_or("") + diagnostics + .get("default_branch") + .and_then(serde_json::Value::as_str) + .unwrap_or("") ); eprintln!( "Current branch: {}", diagnostics - .current_branch - .as_deref() + .get("current_branch") + .and_then(serde_json::Value::as_str) .unwrap_or("") ); - if let Some(serving) = diagnostics.serving_branch.as_deref() { - let suffix = if diagnostics.is_fallback { + if let Some(serving) = diagnostics + .get("serving_branch") + .and_then(serde_json::Value::as_str) + { + let suffix = if diagnostics + .get("is_fallback") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { " (fallback)" } else { "" }; eprintln!("Serving branch: {serving}{suffix}"); } - if diagnostics.branch_drifted { + if diagnostics + .get("branch_drifted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { eprintln!( "Opened branch: {}", diagnostics - .open_active_branch - .as_deref() + .get("open_active_branch") + .and_then(serde_json::Value::as_str) .unwrap_or("") ); } eprintln!(); - for branch in &diagnostics.branches { - let size = if branch.db_exists { - tracedecay::display::format_bytes(branch.size_bytes) + for branch in diagnostics + .get("branches") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + let db_exists = branch + .get("db_exists") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let size = if db_exists { + tracedecay::display::format_bytes( + branch + .get("size_bytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + ) } else { "missing".to_string() }; let parent = branch - .parent - .as_deref() + .get("parent") + .and_then(serde_json::Value::as_str) .map(|p| format!(" (from {p})")) .unwrap_or_default(); - let synced = branch_meta::format_timestamp(&branch.last_synced_at); + let last_synced_at = branch + .get("last_synced_at") + .and_then(serde_json::Value::as_str) + .unwrap_or("never"); + let synced = branch_meta::format_timestamp(last_synced_at); let mut flags = Vec::new(); - if branch.is_default { + if branch + .get("is_default") + .and_then(serde_json::Value::as_bool) + == Some(true) + { flags.push("default"); } - if branch.is_current { + if branch + .get("is_current") + .and_then(serde_json::Value::as_bool) + == Some(true) + { flags.push("current"); } - if branch.is_serving { + if branch + .get("is_serving") + .and_then(serde_json::Value::as_bool) + == Some(true) + { flags.push("serving"); } - if !branch.db_exists { + if !db_exists { flags.push("missing-db"); } let flags = if flags.is_empty() { @@ -703,12 +811,23 @@ pub(crate) async fn handle_branch_action(action: BranchAction) -> tracedecay::er }; eprintln!( " {}{} — {}{}, synced {}", - branch.name, flags, size, parent, synced + branch + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + flags, + size, + parent, + synced ); } - if !diagnostics.warnings.is_empty() { + if let Some(warnings) = diagnostics + .get("warnings") + .and_then(serde_json::Value::as_array) + .filter(|warnings| !warnings.is_empty()) + { eprintln!(); - for warning in diagnostics.warnings { + for warning in warnings.iter().filter_map(serde_json::Value::as_str) { eprintln!("warning: {warning}"); } } @@ -925,10 +1044,7 @@ async fn handle_branch_autotrack_action( } async fn resolve_branch_data_root(project_path: &Path) -> PathBuf { - TraceDecay::open_read_only(project_path) - .await - .map(|cg| cg.store_layout().data_root.clone()) - .unwrap_or_else(|_| fallback_branch_data_root(project_path)) + fallback_branch_data_root(project_path) } fn fallback_branch_data_root(project_path: &Path) -> PathBuf { @@ -940,10 +1056,15 @@ fn fallback_branch_data_root(project_path: &Path) -> PathBuf { /// Handles the `wipe` and `wipe --all` commands. pub(crate) async fn handle_wipe(all: bool) -> tracedecay::errors::Result<()> { use std::fs; - let home_tracedecay = tracedecay::config::user_data_dir(); + let profile_root = tracedecay::storage::default_profile_root()?; + let lifecycle_lease = + tracedecay::lifecycle_lease::acquire_exclusive_for_profile(&profile_root, "wipe")?; + let _database_scope = + tracedecay::db::enter_maintenance_database_scope(&lifecycle_lease, &profile_root, "wipe")?; + let home_tracedecay = Some(profile_root); let project_paths = global::gather_target_projects(all, &home_tracedecay).await; - let gdb = tracedecay::global_db::GlobalDb::open().await; + let gdb = tracedecay::global_db::GlobalDb::try_open().await?; let mut targets = Vec::new(); for path in &project_paths { let location = global::classify_project_storage_with_registry( @@ -1018,7 +1139,7 @@ pub(crate) async fn handle_wipe(all: bool) -> tracedecay::errors::Result<()> { ); } } else if !wiped_paths.is_empty() { - if let Some(gdb) = tracedecay::global_db::GlobalDb::open().await { + if let Some(gdb) = tracedecay::global_db::GlobalDb::try_open().await? { let path_strs: Vec = wiped_paths .iter() .map(|p| p.to_string_lossy().to_string()) @@ -1049,28 +1170,48 @@ pub(crate) async fn handle_list(all: bool) -> tracedecay::errors::Result<()> { return Ok(()); } - let gdb = tracedecay::global_db::GlobalDb::open().await; + let token_result = daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "registry_project_tokens", + "project_args": &project_paths, + }), + ) + .await?; + let token_rows = token_result + .get("projects") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); let mut rows: Vec = Vec::with_capacity(project_paths.len()); let mut total_size: u64 = 0; let mut total_tokens: u64 = 0; for path in &project_paths { - let location = global::classify_project_storage_with_registry( - path, - gdb.as_ref(), - home_tracedecay.as_deref(), - ) - .await; + let location = + global::classify_project_storage_with_registry(path, None, home_tracedecay.as_deref()) + .await; let has_data = location.data_root.exists(); let size = if has_data { global::tracedecay_dir_size(&location.data_root) } else { 0 }; - let tokens = match &gdb { - Some(db) => db.get_project_tokens(path).await, - None => 0, - }; + let project_key = tracedecay::global_db::GlobalDb::canonical_project_key(path); + let tokens = token_rows + .iter() + .find(|row| { + row.get("project") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| { + tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(value)) + == project_key + }) + }) + .and_then(|row| row.get("tokens")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); total_size = total_size.saturating_add(size); total_tokens = total_tokens.saturating_add(tokens); rows.push(ListRow { @@ -1196,10 +1337,15 @@ fn append_orphan_manifest_rows( /// True when the global DB has zero registered projects (or can't be opened /// at all) — i.e. the user has not run `tracedecay init` anywhere yet. async fn is_fresh_install() -> bool { - match tracedecay::global_db::GlobalDb::open().await { - Some(gdb) => gdb.list_project_paths().await.is_empty(), - None => true, - } + daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ "action": "registry_empty" }), + ) + .await + .ok() + .and_then(|value| value.get("empty").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) } /// When invoked with no subcommand, offer to create the index if none exists. @@ -1239,7 +1385,12 @@ pub(crate) async fn handle_no_command() -> tracedecay::errors::Result<()> { })?; let answer = answer.trim(); if answer.is_empty() || answer.eq_ignore_ascii_case("y") { - init_and_index(&project_path, &[], &[], false).await?; + handle_init( + Some(project_path.to_string_lossy().into_owned()), + Vec::new(), + Vec::new(), + ) + .await?; } Ok(()) } @@ -1250,20 +1401,27 @@ pub(crate) async fn handle_init( include_folders: Vec, ) -> tracedecay::errors::Result<()> { let project_path = tracedecay::config::resolve_path(path); - if TraceDecay::has_initialized_store(&project_path).await { - eprintln!( - "\x1b[31merror:\x1b[0m TraceDecay is already initialized at '{}'.\n\ - Use \x1b[1mtracedecay sync\x1b[0m to update the index, or \ - \x1b[1mtracedecay sync --force\x1b[0m to rebuild it.", - project_path.display() - ); - std::process::exit(1); + if !skip_folders.is_empty() || !include_folders.is_empty() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "brokered init does not yet support --skip-folders/--include-folders; configure tracedecay.toml first".to_string(), + }); } - - let version_handle = std::thread::spawn(tracedecay::cloud::fetch_latest_version); - let cg = init_and_index(&project_path, &skip_folders, &include_folders, false).await?; - close_project_graph(cg).await?; - maybe_print_parallel_update_notice(version_handle); + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_path.clone()), + None, + false, + true, + )?; + tracedecay::daemon::call_default_tool( + &handshake, + "tracedecay_status", + serde_json::json!({"format": "json"}), + ) + .await?; + eprintln!( + "initialized and indexed {} via daemon", + project_path.display() + ); Ok(()) } @@ -1276,94 +1434,33 @@ pub(crate) async fn handle_sync( verbose: bool, ) -> tracedecay::errors::Result<()> { let project_path = tracedecay::config::resolve_path_with_discovery(path); - if !TraceDecay::has_initialized_store(&project_path).await { - eprintln!( - "\x1b[31merror:\x1b[0m no TraceDecay index found at '{}'.\n\ - Run \x1b[1mtracedecay init\x1b[0m to create one first.", - project_path.display() - ); - std::process::exit(1); + if !skip_folders.is_empty() || !include_folders.is_empty() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first".to_string(), + }); } - if project_path.join(".codegraph").is_dir() { + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_path.clone()), + None, + false, + false, + )?; + let result = tracedecay::daemon::call_default_tool( + &handshake, + "tracedecay_admin_sync", + serde_json::json!({"force": force}), + ) + .await?; + if verbose { eprintln!( - "warning: found legacy .codegraph/ directory at '{}'. \ - tracedecay now uses .tracedecay/ — the old directory can be safely deleted.", - project_path.display() + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() ); } - - let version_handle = std::thread::spawn(tracedecay::cloud::fetch_latest_version); - - if force { - let cg = init_and_index(&project_path, &skip_folders, &include_folders, verbose).await?; - close_project_graph(cg).await?; - } else { - let mut cg = TraceDecay::open(&project_path).await?; - cg.add_skip_folders(&skip_folders); - cg.add_include_folders(&include_folders); - let spinner = Spinner::new(); - let sync_start = std::time::Instant::now(); - let result = cg - .sync_with_progress_verbose( - |current, total, detail| { - if current == 0 { - spinner.set_message(detail); - } else { - let elapsed = sync_start.elapsed().as_secs_f64(); - let eta = if current > 1 { - let per_file = elapsed / (current - 1) as f64; - let remaining = per_file * (total - current) as f64; - if remaining >= 1.0 { - format!(" (ETA: {remaining:.0}s)") - } else { - String::new() - } - } else { - String::new() - }; - spinner.set_message(&format!("[{current}/{total}] syncing {detail}{eta}")); - } - }, - |msg| { - if verbose { - eprintln!(" \x1b[2m[verbose]\x1b[0m {msg}"); - } - }, - ) - .await?; - let skipped_msg = if result.skipped_paths.is_empty() { - String::new() - } else { - format!(", {} skipped", result.skipped_paths.len()) - }; - spinner.done(&format!( - "sync done — {} added, {} modified, {} removed{skipped_msg} in {}ms", - result.files_added, result.files_modified, result.files_removed, result.duration_ms - )); - if !result.skipped_paths.is_empty() { - eprintln!(); - eprintln!( - "\x1b[33mSkipped ({}) — files found but not readable:\x1b[0m", - result.skipped_paths.len() - ); - for (path, reason) in &result.skipped_paths { - eprintln!(" ! {path}: {reason}"); - } - } - if doctor { - print_sync_doctor(&result); - } - global::update_global_db(&cg).await; - close_project_graph(cg).await?; + eprintln!("sync completed via daemon for {}", project_path.display()); + if doctor { + tracedecay::doctor::run_doctor(None).await?; } - - maybe_print_parallel_update_notice(version_handle); - Ok(()) -} - -async fn close_project_graph(cg: TraceDecay) -> tracedecay::errors::Result<()> { - cg.checkpoint().await?; - cg.close(); Ok(()) } @@ -1426,149 +1523,33 @@ pub(crate) async fn handle_bench( max_nodes: usize, ) -> tracedecay::errors::Result<()> { let project_path = tracedecay::config::resolve_path(path); - let cg = crate::serve::ensure_initialized(&project_path).await?; - - let opts = tracedecay::bench::BenchOptions { - format: if json { - tracedecay::bench::OutputFormat::Json - } else { - tracedecay::bench::OutputFormat::Markdown - }, - max_nodes, - }; - - let report = match queries { - Some(path) => tracedecay::bench::run_bench(&cg, std::path::Path::new(&path), opts).await?, - None => { - tracedecay::bench::run_bench_with_toml( - &cg, - tracedecay::bench::DEFAULT_QUERIES_TOML, - opts, - ) - .await? - } - }; - - if json { - println!("{}", tracedecay::bench::format_report_json(&report)); - } else { - print!("{}", tracedecay::bench::format_report_console(&report)); - } + let queries_toml = queries + .map(std::fs::read_to_string) + .transpose() + .map_err(|error| tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read query file: {error}"), + })?; + let result = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ + "action": "bench", + "queries_toml": queries_toml, + "json": json, + "max_nodes": max_nodes, + }), + ) + .await?; + let output = result + .get("output") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon bench response omitted output".to_string(), + })?; + print!("{output}"); Ok(()) } -fn maybe_print_parallel_update_notice(version_handle: std::thread::JoinHandle>) { - if let Ok(Some(latest)) = version_handle.join() { - let current_version = env!("CARGO_PKG_VERSION"); - let now = crate::current_unix_timestamp(); - let mut config = tracedecay::user_config::UserConfig::load(); - config.cached_latest_version = latest.clone(); - config.last_version_check_at = now; - if let Err(err) = config.save_if_exists() { - eprintln!("warning: could not save tracedecay config: {err}"); - } - if tracedecay::cloud::is_newer_version(current_version, &latest) - && now - config.last_version_warning_at >= 900 - { - eprintln!( - "\n\x1b[33mUpdate available: v{} → v{}\x1b[0m\n Run: \x1b[1mtracedecay upgrade\x1b[0m", - current_version, latest - ); - config.last_version_warning_at = now; - if let Err(err) = config.save_if_exists() { - eprintln!("warning: could not save tracedecay config: {err}"); - } - } - } -} - -/// Initializes a new project (if needed) and runs a full index. -pub(crate) async fn init_and_index( - project_path: &Path, - skip_folders: &[String], - include_folders: &[String], - verbose: bool, -) -> tracedecay::errors::Result { - if !project_path.is_dir() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!( - "project path is not a directory: {}", - project_path.display() - ), - }); - } - if !project_path.is_absolute() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!("project path must be absolute: {}", project_path.display()), - }); - } - let mut cg = if TraceDecay::has_initialized_store(project_path).await { - TraceDecay::open(project_path).await? - } else { - let cg = TraceDecay::init(project_path).await?; - eprintln!("Initialized TraceDecay at {}", project_path.display()); - let data_dir_name = tracedecay::config::get_tracedecay_dir(project_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(tracedecay::config::TRACEDECAY_DIR) - .to_string(); - // Offer to add the resolved data directory to .gitignore if needed. - if !tracedecay::config::is_in_gitignore(project_path) { - if io::stdin().is_terminal() { - eprint!("Add {data_dir_name} to .gitignore? [Y/n] "); - io::stderr().flush().ok(); - let mut answer = String::new(); - if io::stdin().lock().read_line(&mut answer).is_ok() { - let answer = answer.trim(); - if answer.is_empty() || answer.eq_ignore_ascii_case("y") { - tracedecay::config::add_to_gitignore(project_path); - eprintln!("Added {data_dir_name} to .gitignore"); - } - } - } else { - eprintln!( - "Non-interactive: skipped adding {data_dir_name} to .gitignore (run interactively to opt in)." - ); - } - } - cg - }; - cg.add_skip_folders(skip_folders); - cg.add_include_folders(include_folders); - let spinner = Spinner::new(); - let index_start = std::time::Instant::now(); - let result = cg - .index_all_with_progress_verbose( - |current, total, file| { - let elapsed = index_start.elapsed().as_secs_f64(); - let eta = if current > 1 { - let per_file = elapsed / (current - 1) as f64; - let remaining = per_file * (total - current) as f64; - if remaining >= 1.0 { - format!(" (ETA: {remaining:.0}s)") - } else { - String::new() - } - } else { - String::new() - }; - spinner.set_message(&format!("[{current}/{total}] indexing {file}{eta}")); - }, - |msg| { - if verbose { - eprintln!(" \x1b[2m[verbose]\x1b[0m {msg}"); - } - }, - ) - .await?; - spinner.done(&format!( - "indexing done — {} files, {} nodes, {} edges in {}ms", - result.file_count, result.node_count, result.edge_count, result.duration_ms - )); - global::update_global_db(&cg).await; - Ok(cg) -} - /// Convert raw tokens-saved into a USD estimate using Sonnet input pricing. /// Sonnet is the default agent target; output-token savings are not relevant /// for retrieval savings. @@ -1592,14 +1573,6 @@ pub async fn handle_gain( json_output: bool, ) -> tracedecay::errors::Result<()> { tracedecay::accounting::pricing::refresh_if_stale(); - let gdb = match tracedecay::global_db::GlobalDb::open().await { - Some(db) => db, - None => { - eprintln!("Could not open the global database (~/.tracedecay/global.db)."); - return Ok(()); - } - }; - let since = tracedecay::accounting::metrics::parse_range(range); let project_filter: Option = if all { None @@ -1609,10 +1582,38 @@ pub async fn handle_gain( .map(|p| p.to_string_lossy().into_owned()) }; + let result = daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "gain_query", + "project_arg": project_filter, + "since": since as i64, + "history": history, + }), + ) + .await?; if history { - let rows = gdb - .savings_history(project_filter.as_deref(), since as i64) - .await; + let rows = result + .get("history") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .map(|row| tracedecay::global_db::SavingsDay { + day: row + .get("day") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0), + saved_tokens: row + .get("saved_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + calls: row + .get("calls") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + }) + .collect::>(); if json_output { let arr: Vec<_> = rows .iter() @@ -1632,17 +1633,22 @@ pub async fn handle_gain( return Ok(()); } - let total = gdb - .sum_savings(project_filter.as_deref(), since as i64) - .await; - let usd = estimate_dollars_saved(total.saved_tokens); + let saved_tokens = result + .get("saved_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let calls = result + .get("calls") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let usd = estimate_dollars_saved(saved_tokens); if json_output { let out = serde_json::json!({ "range": range, "project": project_filter.clone().unwrap_or_else(|| "ALL".to_string()), - "saved_tokens": total.saved_tokens, - "calls": total.calls, + "saved_tokens": saved_tokens, + "calls": calls, "usd": usd, }); println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); @@ -1650,44 +1656,14 @@ pub async fn handle_gain( tracedecay::display::print_gain_total( project_filter.as_deref().unwrap_or("ALL projects"), range, - total.saved_tokens, - total.calls, + saved_tokens, + calls, usd, ); } Ok(()) } -/// Print the `--doctor` report after an incremental sync. -pub(crate) fn print_sync_doctor(result: &tracedecay::tracedecay::SyncResult) { - let has_changes = !result.added_paths.is_empty() - || !result.modified_paths.is_empty() - || !result.removed_paths.is_empty(); - if !has_changes { - eprintln!("\n\x1b[2mNo files changed.\x1b[0m"); - return; - } - eprintln!(); - if !result.added_paths.is_empty() { - eprintln!("\x1b[32mAdded ({}):\x1b[0m", result.added_paths.len()); - for p in &result.added_paths { - eprintln!(" + {p}"); - } - } - if !result.modified_paths.is_empty() { - eprintln!("\x1b[33mModified ({}):\x1b[0m", result.modified_paths.len()); - for p in &result.modified_paths { - eprintln!(" ~ {p}"); - } - } - if !result.removed_paths.is_empty() { - eprintln!("\x1b[31mRemoved ({}):\x1b[0m", result.removed_paths.len()); - for p in &result.removed_paths { - eprintln!(" - {p}"); - } - } -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod gain_tests { diff --git a/src/config.rs b/src/config.rs index 522c5051f..017d0a5ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -785,34 +785,26 @@ fn absolutize_path(path: PathBuf) -> PathBuf { /// initialised `TraceDecay` project, or `None` if the filesystem root is /// reached without finding one. /// -/// # Canonical project-root resolution order +/// # Canonical local project-root resolution order /// /// This walk-up is the heart of project-root resolution. Every entry point /// that needs a project root should resolve it in this order — new code must /// converge on this chain instead of inventing its own: /// /// 0. **Template pre-filter** (`serve` only, -/// `serve::sanitize_serve_path_arg`): an explicit path that is a literal +/// [`crate::serve::sanitize_serve_path_arg`]): an explicit path that is a literal /// unexpanded `${...}` host template variable (e.g. `${workspaceFolder}` /// from a host that failed to expand it) is discarded with a warning and -/// resolution continues as if no path was given — except that step 4 then -/// requires a unique registered project -/// (`serve::ServeGlobalDbMatch::UniqueOnly`), because the host's spawn -/// directory says nothing about the intended workspace. +/// resolution continues as if no path was given. /// 1. **Explicit path** (`--path`/`-p`, tool `path` argument): used verbatim, /// no discovery, and failure to open is fatal — never silently fall back. /// 2. **CWD walk-up** (this function via [`resolve_path_with_discovery`]): /// nearest ancestor of the working directory containing an initialised /// project database (see [`get_project_db_path`]). -/// 3. **MCP `initialize` roots** (`serve` only, -/// `serve::ServeProjectResolver`): each workspace root the editor -/// advertises is tried verbatim against registered projects, then walked -/// up via this function. -/// 4. **Global DB registry** (`serve` only, -/// `serve::resolve_serve_from_global_db`): a single registered project -/// wins outright; among several, the deepest registered ancestor of cwd -/// wins, then the shallowest registered descendant; ties are reported as -/// ambiguous and require an explicit path. +/// +/// `serve` forwards this routing metadata to the managed daemon. MCP +/// `initialize` roots and registry aliases are resolved there; the proxy never +/// opens a project or global database and has no in-process fallback. pub fn discover_project_root(start: &Path) -> Option { let mut dir = start.to_path_buf(); let worktree_root = crate::worktree::git_worktree_root(start); diff --git a/src/cost_cmd.rs b/src/cost_cmd.rs index d35474960..5acf03f89 100644 --- a/src/cost_cmd.rs +++ b/src/cost_cmd.rs @@ -1,7 +1,6 @@ -use std::process; - +use serde::Deserialize; +use serde_json::{Value, json}; use tracedecay::accounting::CostSummary; -use tracedecay::global_db::GlobalDb; pub(crate) async fn handle_cost( range: String, @@ -9,41 +8,36 @@ pub(crate) async fn handle_cost( by_task: bool, export: Option, ) -> tracedecay::errors::Result<()> { - tracedecay::accounting::pricing::refresh_if_stale(); - - let gdb = match GlobalDb::open().await { - Some(db) => db, - None => { - eprintln!("Could not open global database."); - process::exit(1); - } - }; - - let ingest_stats = tracedecay::accounting::parser::ingest(&gdb).await; - if ingest_stats.turns_inserted > 0 { + let payload = call_cost_admin(&range).await?; + let ingest_stats = &payload["ingest"]; + if ingest_stats["turns_inserted"].as_u64().unwrap_or(0) > 0 { eprintln!( "Ingested {} new turns from Claude Code sessions.", - ingest_stats.turns_inserted + ingest_stats["turns_inserted"].as_u64().unwrap_or(0) ); } - - let since = tracedecay::accounting::metrics::parse_range(&range); - let tokens_saved = gdb.global_tokens_saved().await.unwrap_or(0); - let summary = tracedecay::accounting::metrics::cost_summary(&gdb, since, tokens_saved).await; - - let Some(summary) = summary else { + if payload.get("summary").is_none_or(Value::is_null) { println!( "No session data found. Use Claude Code and then run `tracedecay cost` to see spending." ); return Ok(()); - }; - - print_cost_summary(&gdb, &range, by_model, by_task, export.as_deref(), &summary).await; + } + let summary: CostSummaryPayload = serde_json::from_value(payload["summary"].clone())?; + let summary = summary.into(); + + print_cost_summary( + &payload["today"], + &range, + by_model, + by_task, + export.as_deref(), + &summary, + ); Ok(()) } -async fn print_cost_summary( - gdb: &GlobalDb, +fn print_cost_summary( + today: &Value, range: &str, by_model: bool, by_task: bool, @@ -57,7 +51,7 @@ async fn print_cost_summary( } else if by_task { print_task_table(summary); } else { - print_default_summary(gdb, range, summary).await; + print_default_summary(today, range, summary); } } @@ -140,24 +134,17 @@ fn print_task_table(summary: &CostSummary) { } } -async fn print_default_summary(gdb: &GlobalDb, range: &str, summary: &CostSummary) { - let today_since = tracedecay::accounting::metrics::parse_range("today"); - let today_cost = gdb.total_cost_since(today_since).await.unwrap_or(0.0); - let today_breakdown = gdb - .token_breakdown_since(today_since) - .await - .unwrap_or((0, 0, 0)); - +fn print_default_summary(today: &Value, range: &str, summary: &CostSummary) { println!( " {:<10} {:>10} {:>10} {:>10} {:>10}", "Period", "Cost", "Input", "Output", "Cache-hit" ); print_cost_row( "Today", - today_cost, - today_breakdown.0, - today_breakdown.1, - today_breakdown.2, + today["cost"].as_f64().unwrap_or(0.0), + today["input_tokens"].as_u64().unwrap_or(0), + today["output_tokens"].as_u64().unwrap_or(0), + today["cache_read_tokens"].as_u64().unwrap_or(0), ); print_cost_row( range, @@ -178,6 +165,54 @@ async fn print_default_summary(gdb: &GlobalDb, range: &str, summary: &CostSummar } } +#[derive(Deserialize)] +struct CostSummaryPayload { + total_cost: f64, + total_input_tokens: u64, + total_output_tokens: u64, + total_cache_read_tokens: u64, + by_model: Vec<(String, f64, u64)>, + by_category: Vec<(String, f64, u64)>, + tokens_saved: u64, + efficiency_ratio: f64, +} + +impl From for CostSummary { + fn from(value: CostSummaryPayload) -> Self { + Self { + total_cost: value.total_cost, + total_input_tokens: value.total_input_tokens, + total_output_tokens: value.total_output_tokens, + total_cache_read_tokens: value.total_cache_read_tokens, + by_model: value.by_model, + by_category: value.by_category, + tokens_saved: value.tokens_saved, + efficiency_ratio: value.efficiency_ratio, + } + } +} + +async fn call_cost_admin(range: &str) -> tracedecay::errors::Result { + let cwd = std::env::current_dir()?; + let project_root = tracedecay::config::discover_project_root(&cwd); + let handshake = + tracedecay::daemon::DaemonHandshake::for_current_client(project_root, None, false, false)?; + let result = tracedecay::daemon::call_default_tool( + &handshake, + "tracedecay_admin_cli", + json!({ "action": "cost_summary", "range": range }), + ) + .await?; + let text = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(Into::into) +} + fn print_cost_row(label: &str, cost: f64, input: u64, output: u64, cache_read: u64) { let cache_pct = if input + cache_read > 0 { (cache_read as f64 / (input + cache_read) as f64) * 100.0 diff --git a/src/daemon.rs b/src/daemon.rs index 822113854..810dc9c3a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,4 +1,3 @@ -#[cfg(unix)] use std::collections::{HashMap, HashSet}; use std::fmt::Write; #[cfg(unix)] @@ -8,23 +7,20 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde::{Deserialize, Serialize}; -#[cfg(unix)] use serde_json::json; -#[cfg(unix)] use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; #[cfg(unix)] -use tokio::net::{UnixListener, UnixStream}; -#[cfg(unix)] -use tokio::task::{JoinHandle, JoinSet}; +use tokio::net::UnixStream; #[cfg(unix)] +use tokio::task::JoinHandle; +use tokio::task::JoinSet; use tokio::time::{Duration, timeout}; use crate::client_identity::DaemonClientIdentity; use crate::errors::{Result, TraceDecayError}; -#[cfg(unix)] -use crate::mcp::{ - ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport, ReplayTransport, StdioTransport, -}; +use crate::mcp::ReplayTransport; +use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport, StdioTransport}; +use transport::{BrokerListener, BrokerStream, DaemonAuthPreface, DaemonEndpoint}; pub const SERVICE_NAME: &str = "tracedecay.service"; pub const SOCKET_ENV: &str = "TRACEDECAY_DAEMON_SOCKET"; @@ -33,8 +29,9 @@ pub const HOOK_EVENT_METHOD: &str = "tracedecay/hookEvent"; const TOOL_LIST_CHANGED_METHOD: &str = "notifications/tools/list_changed"; #[cfg(unix)] const MAX_CATALOG_REFRESH_CLIENTS_PER_GENERATION: usize = 1_024; -#[cfg(unix)] const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); +const DAEMON_TOOL_LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5); +const DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); /// Upper bound on graceful-shutdown persistence work (per-server token /// persistence and WAL checkpoints). Must stay comfortably below systemd's /// stop timeout (90s by default) so the daemon exits cleanly instead of @@ -101,6 +98,7 @@ impl DaemonLifecycle { } } + #[cfg(unix)] async fn wait_for_idle(&self) { loop { let notified = self.inner.idle.notified(); @@ -120,11 +118,13 @@ impl Drop for DaemonActivity { } } +mod authority; #[cfg(unix)] mod git_watch; #[cfg(unix)] pub mod pr_autotrack; mod service; +pub(crate) mod transport; pub use service::{ DaemonServiceSpec, DaemonServiceState, daemon_reachable, default_socket_path, install_service, installed_service_socket_path, quiesce_installed_service_under_lease, @@ -273,6 +273,12 @@ impl DaemonHookEvent { ) } + /// A provider session started: let the daemon own branch tracking and + /// index refresh for the session's actual working directory. + pub fn session_start(agent: HookAgent, cwd: PathBuf) -> Self { + Self::new(agent, "sessionStart", Vec::new(), None, Some(cwd)) + } + /// A file-edit tool finished: request targeted sync of the edited paths. pub fn post_tool_use_edit(agent: HookAgent, rel_paths: Vec, cwd: PathBuf) -> Self { Self::new(agent, "postToolUseEdit", rel_paths, None, Some(cwd)) @@ -439,7 +445,6 @@ fn version_skew_action(daemon_version: &str, client_version: &str) -> &'static s } } -#[cfg(unix)] pub async fn notify_hook_event(project_path: &Path, event: DaemonHookEvent) { let _ = timeout( HOOK_EVENT_NOTIFY_TIMEOUT, @@ -448,14 +453,10 @@ pub async fn notify_hook_event(project_path: &Path, event: DaemonHookEvent) { .await; } -#[cfg(unix)] async fn notify_hook_event_inner(project_path: &Path, event: DaemonHookEvent) { - let Ok(socket_path) = default_socket_path() else { + let Ok(connection) = current_daemon_connection() else { return; }; - if !socket_path.exists() { - return; - } let Ok(handshake) = DaemonHandshake::for_current_client(Some(project_path.to_path_buf()), None, false, false) else { @@ -473,17 +474,14 @@ async fn notify_hook_event_inner(project_path: &Path, event: DaemonHookEvent) { let Ok(line) = serde_json::to_string(&request) else { return; }; - let Ok(stream) = UnixStream::connect(socket_path).await else { - return; - }; - let Ok(handshake_line) = handshake.to_line() else { + let Ok(stream) = BrokerStream::connect(&connection.endpoint).await else { return; }; let (_reader, mut writer) = stream.into_split(); - if writer.write_all(handshake_line.as_bytes()).await.is_err() { - return; - } - if writer.write_all(b"\n").await.is_err() { + if write_daemon_preamble(&mut writer, &connection, &handshake) + .await + .is_err() + { return; } if writer.write_all(line.as_bytes()).await.is_err() { @@ -496,145 +494,6 @@ async fn notify_hook_event_inner(project_path: &Path, event: DaemonHookEvent) { let _ = writer.shutdown().await; } -#[cfg(not(unix))] -pub async fn notify_hook_event(project_path: &Path, event: DaemonHookEvent) { - if !crate::tracedecay::TraceDecay::has_initialized_store(project_path).await { - return; - } - match event.event.as_str() { - "afterFileEdit" | "postToolUseEdit" => { - let rel_paths = safe_daemon_hook_rel_paths(&event.rel_paths); - if rel_paths.is_empty() { - return; - } - let Ok(cg) = crate::tracedecay::TraceDecay::open(project_path).await else { - return; - }; - let _ = cg.sync_if_stale_silent(&rel_paths).await; - } - "afterShellExecution" | "postToolUseShell" => { - notify_shell_hook_event_without_daemon(project_path, event).await; - } - "workspaceOpen" => { - if let Some(branch) = crate::branch::current_branch(project_path) { - if matches!( - crate::tracedecay::TraceDecay::add_branch_tracking(project_path, &branch).await, - Ok(crate::branch::BranchAddOutcome::Added) - ) { - return; - } - } - run_debounced_hook_sync_without_daemon(project_path, hook_marker_file(&event.agent)) - .await; - } - "postToolUse" => { - let rel_paths = safe_daemon_hook_rel_paths(&event.rel_paths); - if !rel_paths.is_empty() { - let Ok(cg) = crate::tracedecay::TraceDecay::open(project_path).await else { - return; - }; - let _ = cg.sync_if_stale_silent(&rel_paths).await; - return; - } - run_debounced_hook_sync_without_daemon(project_path, hook_marker_file(&event.agent)) - .await; - } - _ => {} - } -} - -#[cfg(not(unix))] -async fn notify_shell_hook_event_without_daemon(project_path: &Path, event: DaemonHookEvent) { - let Some(command) = event.command.as_deref() else { - return; - }; - let cwd = event.cwd.as_deref().unwrap_or(project_path); - if !crate::hooks::cursor_shell_command_targets_project(command, cwd, project_path) { - return; - } - let current_branch = crate::branch::current_branch(project_path); - match crate::hooks::cursor_shell_sync_plan_with_current_branch( - command, - current_branch.as_deref(), - ) { - crate::hooks::CursorShellSyncPlan::BranchAdd(branch) => { - let _ = crate::tracedecay::TraceDecay::add_branch_tracking(project_path, &branch).await; - } - crate::hooks::CursorShellSyncPlan::WorktreeBranchAdd { - branch, - worktree_path, - } => { - let root = crate::hooks::resolve_worktree_add_root(command, cwd, &worktree_path); - let _ = crate::tracedecay::TraceDecay::add_branch_tracking(&root, &branch).await; - } - crate::hooks::CursorShellSyncPlan::CurrentBranchSync(branch) => { - if !matches!( - crate::tracedecay::TraceDecay::add_branch_tracking(project_path, &branch).await, - Ok(crate::branch::BranchAddOutcome::Added) - ) { - run_debounced_hook_sync_without_daemon( - project_path, - hook_marker_file(&event.agent), - ) - .await; - } - } - crate::hooks::CursorShellSyncPlan::IncrementalSync => { - run_debounced_hook_sync_without_daemon(project_path, hook_marker_file(&event.agent)) - .await; - } - crate::hooks::CursorShellSyncPlan::Noop => {} - } -} - -#[cfg(not(unix))] -async fn run_debounced_hook_sync_without_daemon(project_path: &Path, marker_file: &str) { - let Ok(cg) = crate::tracedecay::TraceDecay::open(project_path).await else { - return; - }; - let marker = cg.store_layout().data_root.join(marker_file); - let now = crate::tracedecay::current_timestamp(); - if !crate::hooks::cursor_should_run_sync(now, read_hook_marker_secs(&marker), 3) { - return; - } - match cg.sync().await { - Ok(_) | Err(TraceDecayError::SyncLock { .. }) => { - let _ = std::fs::write(marker, now.to_string()); - } - Err(_) => {} - } -} - -#[cfg(not(unix))] -fn safe_daemon_hook_rel_paths(paths: &[String]) -> Vec { - paths - .iter() - .filter(|path| { - let path_ref = Path::new(path.as_str()); - !path.is_empty() - && !path_ref.is_absolute() - && path_ref - .components() - .all(|component| !matches!(component, std::path::Component::ParentDir)) - }) - .cloned() - .collect() -} - -#[cfg(not(unix))] -fn hook_marker_file(agent: &str) -> &'static str { - HookAgent::from_wire(agent).map_or(".daemon_hook_shell_sync_at", HookAgent::sync_marker_file) -} - -#[cfg(not(unix))] -fn read_hook_marker_secs(path: &Path) -> Option { - std::fs::read_to_string(path) - .ok()? - .trim() - .parse::() - .ok() -} - fn format_daemon_log_line(event: &str, fields: &[(&str, String)]) -> String { let mut line = format!("[tracedecay] event={}", quote_log_value(event)); for (key, value) in fields { @@ -978,12 +837,149 @@ pub fn unavailable_error(socket_path: &Path) -> TraceDecayError { } } +#[derive(Clone)] +struct DaemonConnection { + endpoint: DaemonEndpoint, + auth_token: Option, + authority_record: Option, +} + +fn current_daemon_connection() -> Result { + let profile_root = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + let record = + authority::current_record(&profile_root)?.ok_or_else(|| TraceDecayError::Config { + message: + "TraceDecay daemon authority record is not available. Start or restart the daemon." + .to_string(), + })?; + Ok(DaemonConnection { + endpoint: record.endpoint.clone(), + auth_token: Some(record.auth_token.clone()), + authority_record: Some(record), + }) +} + +#[cfg(unix)] +fn connection_for_socket_path(socket_path: &Path) -> DaemonConnection { + if let Ok(connection) = current_daemon_connection() + && let DaemonEndpoint::Unix(authority_path) = &connection.endpoint + && authority::canonical_identity_path(authority_path).ok() + == authority::canonical_identity_path(socket_path).ok() + { + return connection; + } + // Explicit paths are retained for test harnesses and legacy one-shot + // callers. Default production routing always uses the authority record. + DaemonConnection { + endpoint: DaemonEndpoint::Unix(socket_path.to_path_buf()), + auth_token: None, + authority_record: None, + } +} + +async fn ensure_daemon_connection_live( + connection: &DaemonConnection, + request_label: &str, +) -> Result<()> { + if let Some(expected) = connection.authority_record.as_ref() { + let current = authority::current_record(&expected.profile_root)?; + let Some(current) = current else { + return Err(TraceDecayError::Config { + message: format!( + "daemon authority disappeared while request '{request_label}' was awaiting a response; the request was already sent and was not retried" + ), + }); + }; + if current.epoch != expected.epoch || current.process_run_id != expected.process_run_id { + return Err(TraceDecayError::Config { + message: format!( + "daemon restarted while request '{request_label}' was awaiting a response (expected epoch {}, current epoch {}); the request was already sent and was not retried", + expected.epoch, current.epoch + ), + }); + } + } + + timeout( + DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT, + BrokerStream::connect(&connection.endpoint), + ) + .await + .map_err(|_| TraceDecayError::Config { + message: format!( + "daemon health check timed out at '{}' while request '{request_label}' was awaiting a response; the request was already sent and was not retried", + connection.endpoint + ), + })? + .map(|_| ()) + .map_err(|error| TraceDecayError::Config { + message: format!( + "daemon became unreachable at '{}' while request '{request_label}' was awaiting a response: {error}; the request was already sent and was not retried", + connection.endpoint + ), + }) +} + +async fn next_daemon_response_line( + lines: &mut tokio::io::Lines, + connection: &DaemonConnection, + request_label: &str, + liveness_poll_interval: Duration, +) -> Result> +where + R: tokio::io::AsyncBufRead + Unpin, +{ + loop { + match timeout(liveness_poll_interval, lines.next_line()).await { + Ok(line) => return line.map_err(Into::into), + Err(_) => ensure_daemon_connection_live(connection, request_label).await?, + } + } +} + +fn client_connection(socket_path: &Path) -> Result { + #[cfg(unix)] + { + Ok(connection_for_socket_path(socket_path)) + } + #[cfg(not(unix))] + { + let _ = socket_path; + current_daemon_connection() + } +} + +async fn write_daemon_preamble( + writer: &mut tokio::io::WriteHalf, + connection: &DaemonConnection, + handshake: &DaemonHandshake, +) -> Result<()> { + if let Some(token) = connection.auth_token.as_deref() { + writer + .write_all(DaemonAuthPreface::new(token).to_line()?.as_bytes()) + .await?; + writer.write_all(b"\n").await?; + } + writer.write_all(handshake.to_line()?.as_bytes()).await?; + writer.write_all(b"\n").await?; + Ok(()) +} + fn default_available_socket_path() -> Result { let socket_path = default_socket_path()?; - if socket_path.exists() { + #[cfg(unix)] + { + if socket_path.exists() { + return Ok(socket_path); + } + return Err(unavailable_error(&socket_path)); + } + #[cfg(not(unix))] + { + current_daemon_connection()?; Ok(socket_path) - } else { - Err(unavailable_error(&socket_path)) } } @@ -995,12 +991,9 @@ fn default_available_socket_path() -> Result { /// sessions (Cursor's `tracedecay serve` stdio proxy) reconnect per request, /// so retrying inside this window lets a live session ride out a self-update /// instead of surfacing a hard JSON-RPC error. -#[cfg(unix)] const DAEMON_RESTART_GRACE: Duration = Duration::from_secs(8); -#[cfg(unix)] const DAEMON_RESTART_POLL_INTERVAL: Duration = Duration::from_millis(200); -#[cfg(unix)] fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool { matches!( kind, @@ -1008,27 +1001,9 @@ fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool { ) } -#[cfg(unix)] -fn daemon_connect_error(socket_path: &Path, err: &std::io::Error) -> TraceDecayError { - let hint = if is_transient_daemon_connect_error(err.kind()) { - " The daemon may be restarting (e.g. after `tracedecay update`) — retry shortly, or check `tracedecay daemon status`." - } else { - "" - }; - TraceDecayError::Config { - message: format!( - "could not connect to TraceDecay daemon socket '{}': {err}.{hint}", - socket_path.display() - ), - } -} - -/// Connects to the daemon socket, tolerating the restart outage caused by -/// `tracedecay update` (see [`DAEMON_RESTART_GRACE`]). -#[cfg(unix)] -async fn connect_to_daemon(socket_path: &Path) -> Result { +async fn connect_to_daemon_connection(connection: &DaemonConnection) -> Result { connect_with_restart_grace( - socket_path, + connection, DAEMON_RESTART_GRACE, DAEMON_RESTART_POLL_INTERVAL, ) @@ -1039,24 +1014,29 @@ async fn connect_to_daemon(socket_path: &Path) -> Result { /// /// Retrying here is safe: nothing has been written yet, so no request can be /// duplicated. Non-transient errors (e.g. permission denied) fail immediately. -#[cfg(unix)] async fn connect_with_restart_grace( - socket_path: &Path, + connection: &DaemonConnection, grace: Duration, poll_interval: Duration, -) -> Result { +) -> Result { let deadline = tokio::time::Instant::now() + grace; loop { - match UnixStream::connect(socket_path).await { + match BrokerStream::connect(&connection.endpoint).await { Ok(stream) => return Ok(stream), - Err(err) => { + Err(TraceDecayError::Io(err)) => { if !is_transient_daemon_connect_error(err.kind()) || tokio::time::Instant::now() >= deadline { - return Err(daemon_connect_error(socket_path, &err)); + return Err(TraceDecayError::Config { + message: format!( + "could not connect to TraceDecay daemon endpoint '{}': {err}. The daemon may be restarting (e.g. after `tracedecay update`) — retry shortly, or check `tracedecay daemon status`.", + connection.endpoint + ), + }); } tokio::time::sleep(poll_interval).await; } + Err(error) => return Err(error), } } } @@ -1096,15 +1076,22 @@ async fn should_proxy_serve_to_daemon_with( if installed_service_socket != Some(socket_path) { return false; } - connect_with_restart_grace(socket_path, grace, poll_interval) + let connection = connection_for_socket_path(socket_path); + connect_with_restart_grace(&connection, grace, poll_interval) .await .is_ok() } -/// Non-unix builds have no daemon; `proxy_stdio_to_daemon` would error anyway. +#[cfg(any(test, not(unix)))] +fn proxy_required_by_platform(transport_supported: bool, endpoint_exists: bool) -> bool { + !transport_supported || endpoint_exists +} + +/// Non-Unix clients always use the authenticated loopback broker. There is no +/// in-process SQLite fallback. #[cfg(not(unix))] pub async fn should_proxy_serve_to_daemon(socket_path: &Path) -> bool { - socket_path.exists() + proxy_required_by_platform(false, socket_path.exists()) } #[cfg(unix)] @@ -1114,7 +1101,62 @@ pub async fn run_foreground(socket_path: PathBuf) -> Result<()> { #[cfg(not(unix))] pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { - Err(unsupported_platform()) + let profile_root = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + let requested = transport::default_loopback_endpoint(); + let mut authority = + authority::DaemonAuthority::acquire(&profile_root, &requested, binary_version())?; + let _lifecycle_lease = crate::lifecycle_lease::acquire_shared_for_profile( + &profile_root, + "managed daemon database ownership", + )?; + let _database_scope = crate::db::enter_daemon_database_scope( + &profile_root, + authority.record().epoch, + &authority.record().process_run_id, + )?; + let (listener, endpoint) = BrokerListener::bind(authority.endpoint()).await?; + authority.publish_endpoint(endpoint.clone())?; + log_daemon_event("daemon_listening", &[("endpoint", endpoint.to_string())]); + + let lifecycle = DaemonLifecycle::default(); + let project_servers = Arc::new(tokio::sync::Mutex::new(DatabaseOwnerRegistry::default())); + let project_open_gates = Arc::new(tokio::sync::Mutex::new(ProjectOpenGates::default())); + let mut clients: JoinSet> = JoinSet::new(); + loop { + let stream = tokio::select! { + accepted = listener.accept() => accepted?, + completed = clients.join_next(), if !clients.is_empty() => { + if let Some(Err(error)) = completed { + log_daemon_event("daemon_client", &[("outcome", error.to_string())]); + } + continue; + }, + _ = tokio::signal::ctrl_c() => break, + }; + let auth_token = authority.auth_token().to_string(); + let client_lifecycle = lifecycle.clone(); + let project_servers = Arc::clone(&project_servers); + let project_open_gates = Arc::clone(&project_open_gates); + clients.spawn(async move { + serve_windows_broker_client( + stream, + &auth_token, + &client_lifecycle, + project_servers, + project_open_gates, + #[cfg(test)] + None, + ) + .await + }); + } + lifecycle.begin_draining(); + clients.abort_all(); + while clients.join_next().await.is_some() {} + authority.cleanup_owned_endpoint()?; + Ok(()) } #[cfg(unix)] @@ -1136,14 +1178,14 @@ pub async fn proxy_transport_to_daemon( ) -> Result<()> { let mut routed_handshake = handshake.clone(); if let Some(line) = replay_line { - update_proxy_handshake_from_initialize(handshake, &mut routed_handshake, &line).await; + reset_proxy_handshake_for_initialize(handshake, &mut routed_handshake, &line); let metadata = proxy_request_line_to_daemon(socket_path, &routed_handshake, &line, transport).await?; apply_proxy_initialize_metadata(&mut routed_handshake, metadata); } while let Some(line) = transport.read_line().await? { - update_proxy_handshake_from_initialize(handshake, &mut routed_handshake, &line).await; + reset_proxy_handshake_for_initialize(handshake, &mut routed_handshake, &line); let metadata = proxy_request_line_to_daemon(socket_path, &routed_handshake, &line, transport).await?; apply_proxy_initialize_metadata(&mut routed_handshake, metadata); @@ -1156,6 +1198,14 @@ pub async fn proxy_transport_to_daemon( struct ProxyInitializeMetadata { daemon_version: Option, tool_list_changed: bool, + route: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InitializeRouteMetadata { + project_path: PathBuf, + allow_init: bool, } #[cfg(unix)] @@ -1163,17 +1213,23 @@ fn apply_proxy_initialize_metadata( handshake: &mut DaemonHandshake, metadata: ProxyInitializeMetadata, ) { - if !metadata.tool_list_changed { - return; + if let Some(route) = metadata.route { + if handshake.project_path.as_deref() != Some(route.project_path.as_path()) { + handshake.scope_prefix = None; + } + handshake.project_path = Some(route.project_path); + handshake.allow_init = route.allow_init; } - handshake.tool_list_changed_capable = true; - if let Some(version) = metadata.daemon_version { - handshake.catalog_version = version; + if metadata.tool_list_changed { + handshake.tool_list_changed_capable = true; + if let Some(version) = metadata.daemon_version { + handshake.catalog_version = version; + } } } #[cfg(unix)] -async fn update_proxy_handshake_from_initialize( +fn reset_proxy_handshake_for_initialize( base_handshake: &DaemonHandshake, handshake: &mut DaemonHandshake, line: &str, @@ -1185,41 +1241,68 @@ async fn update_proxy_handshake_from_initialize( return; } *handshake = base_handshake.clone(); - if !base_handshake.allow_initialize_root_routing { - return; - } - let Some((project_path, allow_init)) = - resolve_proxy_initialize_route(request.params.as_ref(), &base_handshake.client_identity) - .await - else { - return; - }; - if base_handshake.project_path.as_deref() != Some(project_path.as_path()) { - handshake.scope_prefix = None; - } - handshake.project_path = Some(project_path); - handshake.allow_init = allow_init; } -#[cfg(unix)] -async fn resolve_proxy_initialize_route( +async fn resolve_daemon_initialize_route( params: Option<&serde_json::Value>, - client_identity: &DaemonClientIdentity, -) -> Option<(PathBuf, bool)> { - let registry = crate::global_db::GlobalDb::open_at(&client_identity.global_db_path).await; + registry: Option<&crate::global_db::GlobalDb>, +) -> Option { + let roots = crate::mcp::server::initialize_root_paths(params); + if let Some(registry) = registry { + for root in &roots { + let mut candidate = root.canonicalize().unwrap_or_else(|_| root.clone()); + loop { + if registry + .project_registry_context_by_alias(&candidate) + .await + .is_some() + { + return Some(InitializeRouteMetadata { + project_path: candidate, + allow_init: false, + }); + } + if !candidate.pop() { + break; + } + } + if let Some(git_root) = crate::worktree::git_worktree_root(root) { + let git_common_dir = crate::worktree::git_common_dir(&git_root); + if registry + .project_registry_context_by_identity(&git_root, git_common_dir.as_deref()) + .await + .is_some() + { + return Some(InitializeRouteMetadata { + project_path: git_root, + allow_init: false, + }); + } + } + } + } if let Some(project_path) = - crate::mcp::server::resolve_initialize_roots_project_path(params, registry.as_ref()).await + crate::mcp::server::resolve_initialize_roots_project_path(params, registry).await { - return Some((project_path, false)); + return Some(InitializeRouteMetadata { + project_path, + allow_init: false, + }); } - for root in crate::mcp::server::initialize_root_paths(params) { + for root in roots { if let Some(project_path) = crate::config::discover_project_root(&root) { - return Some((project_path, false)); + return Some(InitializeRouteMetadata { + project_path, + allow_init: false, + }); } if let Some(git_root) = crate::worktree::git_worktree_root(&root) { let allow_init = crate::config::load_sync_config(&git_root).auto_init; - return Some((git_root, allow_init)); + return Some(InitializeRouteMetadata { + project_path: git_root, + allow_init, + }); } } None @@ -1271,17 +1354,31 @@ async fn proxy_request_line_to_daemon( } } -#[cfg(unix)] async fn send_daemon_request_line( socket_path: &Path, handshake: &DaemonHandshake, line: &str, ) -> Result> { - let stream = connect_to_daemon(socket_path).await?; + send_daemon_request_line_with_liveness_poll( + socket_path, + handshake, + line, + DAEMON_TOOL_LIVENESS_POLL_INTERVAL, + ) + .await +} + +async fn send_daemon_request_line_with_liveness_poll( + socket_path: &Path, + handshake: &DaemonHandshake, + line: &str, + liveness_poll_interval: Duration, +) -> Result> { + let connection = client_connection(socket_path)?; + let stream = connect_to_daemon_connection(&connection).await?; let (reader, mut writer) = stream.into_split(); - writer.write_all(handshake.to_line()?.as_bytes()).await?; - writer.write_all(b"\n").await?; + write_daemon_preamble(&mut writer, &connection, handshake).await?; writer.write_all(line.as_bytes()).await?; if !line.ends_with('\n') { writer.write_all(b"\n").await?; @@ -1290,12 +1387,22 @@ async fn send_daemon_request_line( writer.shutdown().await?; let mut lines = tokio::io::BufReader::new(reader).lines(); - let request_id = serde_json::from_str::(line) - .ok() - .and_then(|request| request.id); + let request = serde_json::from_str::(line).ok(); + let request_id = request.as_ref().and_then(|request| request.id.clone()); + let request_label = request + .as_ref() + .map(|request| request.method.as_str()) + .unwrap_or("daemon request"); let mut responses = Vec::new(); let mut matched_response = request_id.is_none(); - while let Some(response_line) = lines.next_line().await? { + while let Some(response_line) = next_daemon_response_line( + &mut lines, + &connection, + request_label, + liveness_poll_interval, + ) + .await? + { if response_line.trim().is_empty() { continue; } @@ -1314,8 +1421,7 @@ async fn send_daemon_request_line( } if !matched_response { return Err(TraceDecayError::Config { - message: "daemon closed the connection before returning a matching response \ - — it may have been restarted (e.g. by `tracedecay update`); retry the request" + message: "daemon closed the connection after the request was sent but before returning a matching response; the outcome is unknown and the request was not retried" .to_string(), }); } @@ -1351,6 +1457,12 @@ fn proxy_initialize_metadata(request_line: &str, responses: &[String]) -> ProxyI .pointer("/result/capabilities/tools/listChanged") .and_then(serde_json::Value::as_bool) .unwrap_or(false); + if metadata.route.is_none() { + metadata.route = value + .pointer("/result/_meta/tracedecayInitializeRoute") + .cloned() + .and_then(|route| serde_json::from_value(route).ok()); + } } metadata } @@ -1396,11 +1508,38 @@ fn daemon_proxy_error_response(line: &str, err: &TraceDecayError) -> Option, + socket_path: &Path, + handshake: &DaemonHandshake, + replay_line: Option, +) -> Result<()> { + let mut transport = StdioTransport::new(); + if let Some(line) = replay_line { + proxy_one_request(socket_path, handshake, &line, &mut transport).await?; + } + while let Some(line) = transport.read_line().await? { + proxy_one_request(socket_path, handshake, &line, &mut transport).await?; + } + Ok(()) +} + +#[cfg(not(unix))] +async fn proxy_one_request( + socket_path: &Path, + handshake: &DaemonHandshake, + line: &str, + transport: &mut impl McpTransport, ) -> Result<()> { - Err(unsupported_platform()) + if line.trim().is_empty() { + return Ok(()); + } + for response in send_daemon_request_line(socket_path, handshake, line).await? { + transport.write_line(&response).await?; + if !response.ends_with('\n') { + transport.write_line("\n").await?; + } + } + transport.flush().await?; + Ok(()) } pub async fn proxy_stdio_to_default_daemon( @@ -1411,14 +1550,31 @@ pub async fn proxy_stdio_to_default_daemon( proxy_stdio_to_daemon(&socket_path, handshake, replay_line).await } -#[cfg(unix)] pub async fn call_tool( socket_path: &Path, handshake: &DaemonHandshake, tool_name: &str, arguments: serde_json::Value, ) -> Result { - let stream = connect_to_daemon(socket_path).await?; + call_tool_with_liveness_poll( + socket_path, + handshake, + tool_name, + arguments, + DAEMON_TOOL_LIVENESS_POLL_INTERVAL, + ) + .await +} + +async fn call_tool_with_liveness_poll( + socket_path: &Path, + handshake: &DaemonHandshake, + tool_name: &str, + arguments: serde_json::Value, + liveness_poll_interval: Duration, +) -> Result { + let connection = client_connection(socket_path)?; + let stream = connect_to_daemon_connection(&connection).await?; let (reader, mut writer) = stream.into_split(); let id = json!(1); let request = JsonRpcRequest { @@ -1431,8 +1587,7 @@ pub async fn call_tool( })), }; - writer.write_all(handshake.to_line()?.as_bytes()).await?; - writer.write_all(b"\n").await?; + write_daemon_preamble(&mut writer, &connection, handshake).await?; writer .write_all(serde_json::to_string(&request)?.as_bytes()) .await?; @@ -1441,7 +1596,16 @@ pub async fn call_tool( writer.shutdown().await?; let mut lines = tokio::io::BufReader::new(reader).lines(); - while let Some(line) = lines.next_line().await? { + loop { + let line = + next_daemon_response_line(&mut lines, &connection, tool_name, liveness_poll_interval) + .await?; + let Some(line) = line else { + return Err(TraceDecayError::Config { + message: "daemon closed the connection after the tool request was sent but before returning a result; the outcome is unknown and the request was not retried" + .to_string(), + }); + }; let value: serde_json::Value = serde_json::from_str(&line)?; if value.get("id") != Some(&id) { continue; @@ -1456,20 +1620,6 @@ pub async fn call_tool( message: "daemon tool call response did not include a result".to_string(), }); } - - Err(TraceDecayError::Config { - message: "daemon closed the connection before returning a tool result".to_string(), - }) -} - -#[cfg(not(unix))] -pub async fn call_tool( - _socket_path: &Path, - _handshake: &DaemonHandshake, - _tool_name: &str, - _arguments: serde_json::Value, -) -> Result { - Err(unsupported_platform()) } pub async fn call_default_tool( @@ -1483,6 +1633,29 @@ pub async fn call_default_tool( #[cfg(unix)] async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { + let profile_root = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + let endpoint = transport::DaemonEndpoint::Unix(socket_path); + let mut authority = + authority::DaemonAuthority::acquire(&profile_root, &endpoint, binary_version())?; + let _lifecycle = crate::lifecycle_lease::acquire_shared_for_profile( + &profile_root, + "managed daemon database ownership", + )?; + let _database_scope = crate::db::enter_daemon_database_scope( + &profile_root, + authority.record().epoch, + &authority.record().process_run_id, + )?; + let socket_path = match authority.endpoint() { + transport::DaemonEndpoint::Unix(path) => path.clone(), + transport::DaemonEndpoint::Loopback(_) => { + return Err(TraceDecayError::Config { + message: "Unix daemon requires a Unix socket endpoint".to_string(), + }); + } + }; if let Some(parent) = socket_path.parent() { let parent_existed = parent.exists(); std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Config { @@ -1495,13 +1668,14 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { set_owner_only_permissions(parent, 0o700)?; } } - prepare_socket_path(&socket_path).await?; + prepare_socket_path(&authority).await?; - let listener = UnixListener::bind(&socket_path)?; + let (listener, bound_endpoint) = BrokerListener::bind(authority.endpoint()).await?; + authority.publish_endpoint(bound_endpoint.clone())?; set_owner_only_permissions(&socket_path, 0o600)?; log_daemon_event( "daemon_listening", - &[("socket", socket_path.display().to_string())], + &[("endpoint", bound_endpoint.to_string())], ); // Install the git-metadata watcher (design D3/D5). The daemon has no single // project root, so it uses the default `[sync]` config plus env overrides. @@ -1522,7 +1696,7 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { loop { let stream = tokio::select! { - accepted = listener.accept() => accepted?.0, + accepted = listener.accept() => accepted?, completed = client_tasks.join_next(), if !client_tasks.is_empty() => { if let Some(completed) = completed { log_client_task_result(completed); @@ -1533,7 +1707,13 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { _ = sigterm.recv() => break, }; let engine = engine.clone(); - client_tasks.spawn(async move { Box::pin(serve_socket_client(stream, engine)).await }); + let auth_token = authority.auth_token().to_string(); + client_tasks.spawn(async move { + Box::pin(serve_authenticated_socket_client( + stream, engine, auth_token, + )) + .await + }); } engine.lifecycle.begin_draining(); // Stop accepting and unlink the socket before draining so clients that @@ -1541,7 +1721,7 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { // via `connect_with_restart_grace`) instead of a queued connection that // will never be served. drop(listener); - let _ = std::fs::remove_file(&socket_path); + authority.cleanup_owned_endpoint()?; // Keep auxiliary process creation blocked until every scheduler and client // task is drained or abandoned. A killed app-server call may retry before // unwinding, so a shorter guard leaves a shutdown-time respawn race. @@ -1659,7 +1839,16 @@ fn set_owner_only_permissions(path: &Path, mode: u32) -> Result<()> { } #[cfg(unix)] -async fn prepare_socket_path(socket_path: &Path) -> Result<()> { +async fn prepare_socket_path(authority: &authority::DaemonAuthority) -> Result<()> { + authority.ensure_current()?; + let socket_path = match authority.endpoint() { + transport::DaemonEndpoint::Unix(path) => path, + transport::DaemonEndpoint::Loopback(_) => { + return Err(TraceDecayError::Config { + message: "Unix daemon requires a Unix socket endpoint".to_string(), + }); + } + }; match UnixStream::connect(socket_path).await { Ok(_) => Err(TraceDecayError::Config { message: format!( @@ -1682,7 +1871,12 @@ async fn prepare_socket_path(socket_path: &Path) -> Result<()> { struct DaemonEngine { lifecycle: DaemonLifecycle, /// Shared daemon state, partitioned by the client-scoped project server key. - project_servers: Arc>>>, + project_servers: Arc>, + /// Per-canonical-route singleflight gates. Weak entries disappear after + /// the last waiter, so failed opens are never cached. + project_open_gates: Arc>, + #[cfg(test)] + project_open_attempts: Arc, /// Background automation loops, partitioned with the same client/project identity as MCP state. automation_schedulers: Arc>>, @@ -1703,18 +1897,225 @@ struct DaemonEngine { pr_autotrack_task: Arc>>>, } -#[cfg(unix)] -struct AutomationSchedulerHandle { - task: JoinHandle<()>, - wake: Arc, +#[cfg(unix)] +struct AutomationSchedulerHandle { + task: JoinHandle<()>, + wake: Arc, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ProjectServerKey { + owner: StoreOwnerKey, + scope_prefix: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct StoreOwnerKey { + profile_root: PathBuf, + global_db_path: PathBuf, + project_id: Option, + store_root: PathBuf, + graph_db_path: PathBuf, +} + +/// A client route known before any project database is opened. This is the +/// cache/singleflight key; [`ProjectServerKey`] remains the post-open physical +/// owner key so linked aliases and branch DBs still converge correctly. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ProjectRouteKey { + profile_root: PathBuf, + global_db_path: PathBuf, + project_path: PathBuf, + scope_prefix: Option, +} + +type ProjectOpenGate = tokio::sync::Mutex<()>; +type ProjectOpenGates = HashMap>; + +/// Scope-specific MCP servers routed through one canonical physical DB owner. +/// `Database` performs the actual same-process handle sharing; this registry +/// keeps daemon cache aliases and branch-drift rekeys consistent with it. +struct DatabaseOwnerRegistry> { + servers: HashMap, + routes: HashMap>, + aliases: HashMap, +} + +impl Default for DatabaseOwnerRegistry { + fn default() -> Self { + Self { + servers: HashMap::new(), + routes: HashMap::new(), + aliases: HashMap::new(), + } + } +} + +impl DatabaseOwnerRegistry { + fn get(&self, key: &ProjectServerKey) -> Option<&Server> { + self.servers.get(key) + } + + fn insert(&mut self, key: ProjectServerKey, server: Server) { + self.routes + .entry(key.owner.clone()) + .or_default() + .insert(key.clone()); + self.servers.insert(key, server); + } + + fn get_route(&self, route: &ProjectRouteKey) -> Option<(&ProjectServerKey, &Server)> { + let key = self.aliases.get(route)?; + self.servers.get_key_value(key) + } + + fn bind_route(&mut self, route: ProjectRouteKey, key: ProjectServerKey) { + debug_assert!(self.servers.contains_key(&key)); + self.aliases.insert(route, key); + } + + fn insert_route(&mut self, route: ProjectRouteKey, key: ProjectServerKey, server: Server) { + self.insert(key.clone(), server); + self.bind_route(route, key); + } + + fn bind_or_insert_route( + &mut self, + route: ProjectRouteKey, + key: ProjectServerKey, + candidate: Server, + ) -> (Server, bool) + where + Server: Clone, + { + if let Some(existing) = self.get(&key).cloned() { + self.bind_route(route, key); + return (existing, false); + } + self.insert_route(route, key, candidate.clone()); + (candidate, true) + } + + fn rekey(&mut self, old: &ProjectServerKey, new: ProjectServerKey) -> bool { + if old == &new { + return true; + } + let Some(server) = self.servers.remove(old) else { + return false; + }; + let remove_owner = self.routes.get_mut(&old.owner).is_some_and(|routes| { + routes.remove(old); + routes.is_empty() + }); + if remove_owner { + self.routes.remove(&old.owner); + } + if self.servers.contains_key(&new) { + self.aliases.retain(|_, key| key != old); + return false; + } + self.routes + .entry(new.owner.clone()) + .or_default() + .insert(new.clone()); + self.servers.insert(new.clone(), server); + for key in self.aliases.values_mut() { + if key == old { + *key = new.clone(); + } + } + true + } + + fn values(&self) -> impl Iterator { + self.servers.values() + } +} + +impl StoreOwnerKey { + fn from_paths( + profile_root: &Path, + global_db_path: &Path, + project_id: Option, + store_root: &Path, + graph_db_path: &Path, + ) -> Result { + Ok(Self { + profile_root: authority::canonical_identity_path(profile_root)?, + global_db_path: authority::canonical_identity_path(global_db_path)?, + project_id, + store_root: authority::canonical_identity_path(store_root)?, + graph_db_path: authority::canonical_identity_path(graph_db_path)?, + }) + } } -#[cfg(unix)] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct ProjectServerKey { - project_path: PathBuf, - scope_prefix: Option, - client_identity: DaemonClientIdentity, +impl ProjectRouteKey { + fn from_handshake(project_path: &Path, handshake: &DaemonHandshake) -> Result { + Ok(Self { + profile_root: authority::canonical_identity_path( + &handshake.client_identity.profile_root, + )?, + global_db_path: authority::canonical_identity_path( + &handshake.client_identity.global_db_path, + )?, + project_path: authority::canonical_identity_path(project_path)?, + scope_prefix: handshake.scope_prefix.clone(), + }) + } +} + +async fn project_open_gate( + gates: &tokio::sync::Mutex, + route: &ProjectRouteKey, +) -> Arc { + let mut gates = gates.lock().await; + if let Some(gate) = gates.get(route).and_then(std::sync::Weak::upgrade) { + return gate; + } + let gate = Arc::new(ProjectOpenGate::new(())); + gates.insert(route.clone(), Arc::downgrade(&gate)); + gate +} + +#[cfg(any(not(unix), test))] +fn portable_database_owner_reconciler( + project_servers: Arc>, + current_key: Arc>, + route_registered: Arc, + handshake: DaemonHandshake, +) -> crate::mcp::DatabaseOwnerReconciler { + Arc::new(move |fresh| { + let project_servers = Arc::clone(&project_servers); + let current_key = Arc::clone(¤t_key); + let route_registered = Arc::clone(&route_registered); + let handshake = handshake.clone(); + Box::pin(async move { + if !route_registered.load(Ordering::Acquire) { + return; + } + let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) { + Ok(key) => key, + Err(error) => { + eprintln!("[tracedecay] failed to rekey daemon database owner: {error}"); + return; + } + }; + let mut current = current_key.lock().await; + if *current == new_key { + return; + } + let old_key = current.clone(); + if !project_servers + .lock() + .await + .rekey(&old_key, new_key.clone()) + { + route_registered.store(false, Ordering::Release); + } + *current = new_key; + }) + }) } #[cfg(unix)] @@ -1746,14 +2147,22 @@ fn valid_client_instance_id(client_instance_id: &str) -> bool { }) } -#[cfg(unix)] impl ProjectServerKey { - fn from_handshake(project_path: PathBuf, handshake: &DaemonHandshake) -> Self { - Self { - project_path, + fn from_open_project( + cg: &crate::tracedecay::TraceDecay, + handshake: &DaemonHandshake, + ) -> Result { + let layout = cg.store_layout(); + Ok(Self { + owner: StoreOwnerKey::from_paths( + &handshake.client_identity.profile_root, + &handshake.client_identity.global_db_path, + layout.identity.project_id.clone(), + &layout.data_root, + &cg.db_path(), + )?, scope_prefix: handshake.scope_prefix.clone(), - client_identity: handshake.client_identity.clone(), - } + }) } } @@ -1871,55 +2280,106 @@ impl DaemonEngine { let canonical_project_path = project_path .canonicalize() .unwrap_or_else(|_| project_path.clone()); - let key = ProjectServerKey::from_handshake(canonical_project_path.clone(), handshake); - - let mut servers = self.project_servers.lock().await; - if let Some(server) = servers.get(&key) { - let server = Arc::clone(server); - drop(servers); - // A freshly-handshaken project should be watched even on a cache - // hit (the watcher may have started after this server was cached). - self.git_watcher - .ensure_watching(&canonical_project_path) - .await; - Box::pin(self.ensure_automation_scheduler( - key, - canonical_project_path, - handshake.clone(), - )) - .await; - return Ok(server); + let route = ProjectRouteKey::from_handshake(&canonical_project_path, handshake)?; + let cached = { + let servers = self.project_servers.lock().await; + servers + .get_route(&route) + .map(|(key, server)| (key.clone(), Arc::clone(server))) + }; + if let Some((key, server)) = cached { + return Ok(self + .activate_project_server(key, canonical_project_path, handshake, server) + .await); + } + + let gate = project_open_gate(&self.project_open_gates, &route).await; + let _singleflight = gate.lock().await; + let cached = { + let servers = self.project_servers.lock().await; + servers + .get_route(&route) + .map(|(key, server)| (key.clone(), Arc::clone(server))) + }; + if let Some((key, server)) = cached { + return Ok(self + .activate_project_server(key, canonical_project_path, handshake, server) + .await); } + #[cfg(test)] + self.project_open_attempts.fetch_add(1, Ordering::Relaxed); let cg = Box::pin(open_project_for_handshake( &canonical_project_path, handshake, )) .await?; - let accounting_db = accounting_db_for_handshake(handshake).await; - let registry_db = registry_db_for_handshake(handshake).await; + cg.register_project_store_in_global_registry().await; + let key = ProjectServerKey::from_open_project(&cg, handshake)?; + + let existing = { + let mut servers = self.project_servers.lock().await; + let server = servers.get(&key).cloned(); + if server.is_some() { + servers.bind_route(route.clone(), key.clone()); + } + server + }; + if let Some(server) = existing { + return Ok(self + .activate_project_server(key, canonical_project_path, handshake, server) + .await); + } + + let accounting_db = accounting_db_for_handshake(handshake).await?; + let registry_db = registry_db_for_handshake(handshake).await?; + let current_key = Arc::new(tokio::sync::Mutex::new(key.clone())); + let route_registered = Arc::new(AtomicBool::new(true)); let reconciler = self.automation_scheduler_reconciler( - key.clone(), + Arc::clone(¤t_key), canonical_project_path.clone(), handshake.clone(), ); - let server = crate::mcp::McpServer::new_with_dbs_and_automation_reconciler( + let database_owner_reconciler = self.database_owner_reconciler( + current_key, + Arc::clone(&route_registered), + handshake.clone(), + ); + let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers( cg, handshake.scope_prefix.clone(), accounting_db, registry_db, false, Some(reconciler), + Some(database_owner_reconciler), ) .await; - servers.insert(key.clone(), Arc::clone(&server)); - drop(servers); - self.git_watcher - .ensure_watching(&canonical_project_path) - .await; - Box::pin(self.ensure_automation_scheduler(key, canonical_project_path, handshake.clone())) - .await; - Ok(server) + let (server, inserted) = + self.project_servers + .lock() + .await + .bind_or_insert_route(route, key.clone(), candidate); + if !inserted { + route_registered.store(false, Ordering::Release); + } + Ok(self + .activate_project_server(key, canonical_project_path, handshake, server) + .await) + } + + async fn activate_project_server( + &self, + key: ProjectServerKey, + project_path: PathBuf, + handshake: &DaemonHandshake, + server: Arc, + ) -> Arc { + // A freshly-handshaken project should be watched even on a cache hit + // (the watcher may have started after this server was cached). + self.git_watcher.ensure_watching(&project_path).await; + Box::pin(self.ensure_automation_scheduler(key, project_path, handshake.clone())).await; + server } async fn ensure_automation_scheduler( @@ -1975,17 +2435,18 @@ impl DaemonEngine { fn automation_scheduler_reconciler( &self, - key: ProjectServerKey, + current_key: Arc>, project_path: PathBuf, handshake: DaemonHandshake, ) -> crate::dashboard::AutomationSchedulerReconciler { let engine = self.clone(); std::sync::Arc::new(move || { let engine = engine.clone(); - let key = key.clone(); + let current_key = Arc::clone(¤t_key); let project_path = project_path.clone(); let handshake = handshake.clone(); tokio::spawn(async move { + let key = current_key.lock().await.clone(); engine .ensure_automation_scheduler(key.clone(), project_path, handshake) .await; @@ -1996,6 +2457,64 @@ impl DaemonEngine { }) } + fn database_owner_reconciler( + &self, + current_key: Arc>, + route_registered: Arc, + handshake: DaemonHandshake, + ) -> crate::mcp::DatabaseOwnerReconciler { + let engine = self.clone(); + Arc::new(move |fresh| { + let engine = engine.clone(); + let current_key = Arc::clone(¤t_key); + let route_registered = Arc::clone(&route_registered); + let handshake = handshake.clone(); + Box::pin(async move { + if !route_registered.load(Ordering::Acquire) { + return; + } + let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) { + Ok(key) => key, + Err(error) => { + eprintln!("[tracedecay] failed to rekey daemon database owner: {error}"); + return; + } + }; + let mut current = current_key.lock().await; + if *current == new_key { + return; + } + let old_key = current.clone(); + let rekeyed = engine + .project_servers + .lock() + .await + .rekey(&old_key, new_key.clone()); + if !rekeyed { + route_registered.store(false, Ordering::Release); + } + let removed_scheduler = { + let mut schedulers = engine.automation_schedulers.lock().await; + let removed = schedulers.remove(&old_key); + if let Some(handle) = removed { + if schedulers.contains_key(&new_key) { + Some(handle) + } else { + schedulers.insert(new_key.clone(), handle); + None + } + } else { + None + } + }; + if let Some(handle) = removed_scheduler { + handle.task.abort(); + } + *current = new_key; + }) + }) + } + async fn start_automation_scheduler( &self, key: ProjectServerKey, @@ -2276,8 +2795,20 @@ async fn maybe_run_global_retention( if !global_retention_pass_due(std::time::Instant::now()) { return; } - let Some(db) = crate::global_db::GlobalDb::open().await else { - return; + let db = match crate::global_db::GlobalDb::try_open().await { + Ok(Some(db)) => db, + Ok(None) => return, + Err(error) => { + log_daemon_event( + "retention_prune", + &[ + ("project", project_path.display().to_string()), + ("outcome", "open_rejected".to_string()), + ("error", error.to_string()), + ], + ); + return; + } }; let now_secs = crate::tracedecay::current_timestamp(); match db.prune_global_retention(&config.retention, now_secs).await { @@ -2744,9 +3275,106 @@ async fn run_user_jobs_scheduler_pass( } } -#[cfg(unix)] +#[cfg(all(unix, test))] async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngine) -> Result<()> { - let mut transport = UnixStreamTransport::new(stream); + serve_broker_socket_client(BrokerStream::Unix(stream), engine, None).await +} + +#[cfg(unix)] +async fn serve_authenticated_socket_client( + stream: BrokerStream, + engine: DaemonEngine, + auth_token: String, +) -> Result<()> { + serve_broker_socket_client(stream, engine, Some(auth_token)).await +} + +async fn apply_daemon_initialize_route( + handshake: &mut DaemonHandshake, + first_request_line: &str, +) -> Result> { + if !handshake.allow_initialize_root_routing { + return Ok(None); + } + let Ok(request) = serde_json::from_str::(first_request_line.trim()) else { + return Ok(None); + }; + if request.method != "initialize" { + return Ok(None); + } + let registry = + crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path).await?; + let Some(route) = + resolve_daemon_initialize_route(request.params.as_ref(), registry.as_ref()).await + else { + return Ok(None); + }; + if handshake.project_path.as_deref() != Some(route.project_path.as_path()) { + handshake.scope_prefix = None; + } + handshake.project_path = Some(route.project_path.clone()); + handshake.allow_init = route.allow_init; + Ok(Some(route)) +} + +fn attach_initialize_route_metadata( + response: &mut JsonRpcResponse, + route: &InitializeRouteMetadata, +) { + let Some(result) = response.result.as_mut() else { + return; + }; + result["_meta"]["tracedecayInitializeRoute"] = json!(route); +} + +async fn write_routed_initialize_response( + server: &crate::mcp::McpServer, + transport: &mut impl McpTransport, + first_request_line: &str, + route: Option<&InitializeRouteMetadata>, +) -> Result { + let Some(route) = route else { + return Ok(false); + }; + let Ok(request) = serde_json::from_str::(first_request_line.trim()) else { + return Ok(false); + }; + if request.method != "initialize" { + return Ok(false); + } + let Some(mut response) = server.handle_request(&request).await else { + return Ok(false); + }; + attach_initialize_route_metadata(&mut response, route); + write_json_rpc_response(transport, &response).await?; + Ok(true) +} + +#[cfg(unix)] +async fn serve_broker_socket_client( + stream: BrokerStream, + engine: DaemonEngine, + auth_token: Option, +) -> Result<()> { + let mut transport = BrokerStreamTransport::new(stream); + if let Some(expected_token) = auth_token.as_deref() { + let preface_line = tokio::select! { + result = transport.read_line() => result?, + () = engine.lifecycle.wait_for_draining() => return Ok(()), + }; + let Some(preface_line) = preface_line else { + return Ok(()); + }; + let preface = + DaemonAuthPreface::from_line(&preface_line).map_err(|_| TraceDecayError::Config { + message: "daemon client authentication failed".to_string(), + })?; + if !preface.authenticate(expected_token) { + return Err(TraceDecayError::Config { + message: "daemon client authentication failed".to_string(), + }); + } + } let line = tokio::select! { result = transport.read_line() => result?, () = engine.lifecycle.wait_for_draining() => return Ok(()), @@ -2757,12 +3385,25 @@ async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngin let Some(setup_activity) = engine.lifecycle.try_enter() else { return Ok(()); }; - let handshake = DaemonHandshake::from_line(&line)?; + let mut handshake = DaemonHandshake::from_line(&line)?; engine.log_client_version_skew(&handshake).await; + // Resolve initialize roots only after authentication and inside daemon + // authority. The proxy process never opens the registry database. + let first_request_line = tokio::select! { + result = transport.read_line() => result?, + () = engine.lifecycle.wait_for_draining() => return Ok(()), + }; + let Some(first_request_line) = first_request_line else { + return Ok(()); + }; + let initialize_route = + apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; let server = if handshake.project_path.is_some() { let server = match Box::pin(engine.project_server(&handshake)).await { Ok(server) => server, Err(e) => { + let mut transport = ReplayTransport::new(transport); + transport.push_replay(first_request_line); write_project_open_error(&mut transport, &e).await?; return Err(e); } @@ -2776,16 +3417,8 @@ async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngin return Ok(()); } - // The stdio proxy creates one daemon connection per request. Peek exactly - // that request so this daemon generation can emit one catalog refresh for - // a skewed long-lived client, then replay it into the normal MCP server. - let first_request_line = tokio::select! { - result = transport.read_line() => result?, - () = engine.lifecycle.wait_for_draining() => return Ok(()), - }; - let Some(first_request_line) = first_request_line else { - return Ok(()); - }; + // The stdio proxy creates one daemon connection per request. The request + // was peeked above so initialize-root routing happens before project open. if let Some(key) = engine .claim_catalog_refresh(&handshake, &first_request_line) .await @@ -2795,8 +3428,22 @@ async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngin return Err(error); } } + let initialize_handled = match server.as_deref() { + Some(server) => { + write_routed_initialize_response( + server, + &mut transport, + &first_request_line, + initialize_route.as_ref(), + ) + .await? + } + None => false, + }; let mut transport = ReplayTransport::new(transport); - transport.push_replay(first_request_line); + if !initialize_handled { + transport.push_replay(first_request_line); + } if let Some(server) = server { Box::pin(server.run_daemon_connection_with_timings( @@ -2816,6 +3463,156 @@ async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngin Ok(()) } +#[cfg(any(not(unix), test))] +async fn serve_windows_broker_client( + stream: BrokerStream, + auth_token: &str, + lifecycle: &DaemonLifecycle, + project_servers: Arc>, + project_open_gates: Arc>, + #[cfg(test)] project_open_attempts: Option>, +) -> Result<()> { + let mut transport = BrokerStreamTransport::new(stream); + let Some(preface_line) = transport.read_line().await? else { + return Ok(()); + }; + let preface = + DaemonAuthPreface::from_line(&preface_line).map_err(|_| TraceDecayError::Config { + message: "daemon client authentication failed".to_string(), + })?; + if !preface.authenticate(auth_token) { + return Err(TraceDecayError::Config { + message: "daemon client authentication failed".to_string(), + }); + } + let Some(handshake_line) = transport.read_line().await? else { + return Ok(()); + }; + let Some(setup_activity) = lifecycle.try_enter() else { + return Ok(()); + }; + let mut handshake = DaemonHandshake::from_line(&handshake_line)?; + let Some(first_request_line) = transport.read_line().await? else { + return Ok(()); + }; + let initialize_route = + apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; + if let Some(project_path) = handshake.project_path.as_deref() { + let canonical_project_path = project_path + .canonicalize() + .unwrap_or_else(|_| project_path.to_path_buf()); + let route = ProjectRouteKey::from_handshake(&canonical_project_path, &handshake)?; + let mut server = { + let servers = project_servers.lock().await; + servers + .get_route(&route) + .map(|(_, server)| Arc::clone(server)) + }; + if server.is_none() { + let gate = project_open_gate(&project_open_gates, &route).await; + let _singleflight = gate.lock().await; + server = { + let servers = project_servers.lock().await; + servers + .get_route(&route) + .map(|(_, server)| Arc::clone(server)) + }; + if server.is_none() { + #[cfg(test)] + if let Some(attempts) = &project_open_attempts { + attempts.fetch_add(1, Ordering::Relaxed); + } + let cg = match Box::pin(open_project_for_handshake( + &canonical_project_path, + &handshake, + )) + .await + { + Ok(cg) => cg, + Err(error) => { + let mut transport = ReplayTransport::new(transport); + transport.push_replay(first_request_line); + write_project_open_error(&mut transport, &error).await?; + return Err(error); + } + }; + cg.register_project_store_in_global_registry().await; + let key = ProjectServerKey::from_open_project(&cg, &handshake)?; + let existing = { + let mut servers = project_servers.lock().await; + let existing = servers.get(&key).cloned(); + if existing.is_some() { + servers.bind_route(route.clone(), key.clone()); + } + existing + }; + if let Some(existing) = existing { + server = Some(existing); + } else { + let current_key = Arc::new(tokio::sync::Mutex::new(key.clone())); + let route_registered = Arc::new(AtomicBool::new(true)); + let database_owner_reconciler = portable_database_owner_reconciler( + Arc::clone(&project_servers), + current_key, + Arc::clone(&route_registered), + handshake.clone(), + ); + let accounting_db = accounting_db_for_handshake(&handshake).await?; + let registry_db = registry_db_for_handshake(&handshake).await?; + let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers( + cg, + handshake.scope_prefix.clone(), + accounting_db, + registry_db, + false, + None, + Some(database_owner_reconciler), + ) + .await; + let (resolved, inserted) = project_servers.lock().await.bind_or_insert_route( + route.clone(), + key, + candidate, + ); + if !inserted { + route_registered.store(false, Ordering::Release); + } + server = Some(resolved); + } + } + } + let Some(server) = server else { + return Err(TraceDecayError::Config { + message: "project route did not initialize a daemon server".to_string(), + }); + }; + drop(setup_activity); + let initialize_handled = write_routed_initialize_response( + &server, + &mut transport, + &first_request_line, + initialize_route.as_ref(), + ) + .await?; + let mut transport = ReplayTransport::new(transport); + if !initialize_handled { + transport.push_replay(first_request_line); + } + Box::pin(server.run_daemon_connection_with_timings( + &mut transport, + handshake.timings, + lifecycle, + )) + .await?; + } else { + drop(setup_activity); + let mut transport = ReplayTransport::new(transport); + transport.push_replay(first_request_line); + serve_projectless_client(&mut transport, &handshake.client_identity, lifecycle).await?; + } + Ok(()) +} + #[cfg(unix)] async fn write_tool_list_changed_notification(transport: &mut impl McpTransport) -> Result<()> { let notification = json!({ @@ -2903,30 +3700,27 @@ async fn open_existing_project_with_options( } } -#[cfg(unix)] async fn accounting_db_for_handshake( handshake: &DaemonHandshake, -) -> Option> { +) -> Result>> { if !crate::global_db::global_accounting_enabled() { - return None; + return Ok(None); } - crate::global_db::GlobalDb::open_at(&handshake.client_identity.global_db_path) + crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path) .await - .map(Arc::new) + .map(|db| db.map(Arc::new)) } -#[cfg(unix)] async fn registry_db_for_handshake( handshake: &DaemonHandshake, -) -> Option> { - crate::global_db::GlobalDb::open_at(&handshake.client_identity.global_db_path) +) -> Result>> { + crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path) .await - .map(Arc::new) + .map(|db| db.map(Arc::new)) } -#[cfg(unix)] async fn write_project_open_error( - transport: &mut UnixStreamTransport, + transport: &mut impl McpTransport, error: &TraceDecayError, ) -> Result<()> { let id = read_json_rpc_request_id(transport).await?; @@ -2934,10 +3728,7 @@ async fn write_project_open_error( write_json_rpc_response(transport, &response).await } -#[cfg(unix)] -async fn read_json_rpc_request_id( - transport: &mut UnixStreamTransport, -) -> Result { +async fn read_json_rpc_request_id(transport: &mut impl McpTransport) -> Result { let Some(line) = transport.read_line().await? else { return Ok(serde_json::Value::Null); }; @@ -2948,7 +3739,6 @@ async fn read_json_rpc_request_id( .unwrap_or(serde_json::Value::Null)) } -#[cfg(unix)] async fn write_json_rpc_response( transport: &mut impl McpTransport, response: &crate::mcp::JsonRpcResponse, @@ -2961,7 +3751,6 @@ async fn write_json_rpc_response( Ok(()) } -#[cfg(unix)] async fn serve_projectless_client( transport: &mut impl McpTransport, client_identity: &DaemonClientIdentity, @@ -2979,7 +3768,7 @@ async fn serve_projectless_client( break; }; let response = match serde_json::from_str::(&line) { - Ok(request) => projectless_response(&request, client_identity), + Ok(request) => projectless_response(&request, client_identity).await, Err(e) => Some(JsonRpcResponse::error( json!(null), ErrorCode::ParseError, @@ -2996,8 +3785,7 @@ async fn serve_projectless_client( Ok(()) } -#[cfg(unix)] -fn projectless_response( +async fn projectless_response( request: &crate::mcp::JsonRpcRequest, client_identity: &DaemonClientIdentity, ) -> Option { @@ -3018,11 +3806,9 @@ fn projectless_response( } }), )), - "tools/call" => Some(projectless_tools_call_response( - id, - request.params.as_ref(), - client_identity, - )), + "tools/call" => Some( + projectless_tools_call_response(id, request.params.as_ref(), client_identity).await, + ), "ping" | "logging/setLevel" => Some(JsonRpcResponse::success(id, json!({}))), _ => Some(JsonRpcResponse::error( id, @@ -3032,18 +3818,39 @@ fn projectless_response( } } -#[cfg(unix)] -fn projectless_tools_call_response( +async fn projectless_tools_call_response( id: serde_json::Value, params: Option<&serde_json::Value>, - _client_identity: &DaemonClientIdentity, + client_identity: &DaemonClientIdentity, ) -> crate::mcp::JsonRpcResponse { - let (tool_name, _arguments) = match projectless_tool_call(params) { + let (tool_name, arguments) = match projectless_tool_call(params) { Ok(tool_call) => tool_call, Err(message) => { return JsonRpcResponse::error(id, ErrorCode::InvalidParams, message.to_string()); } }; + if tool_name == "tracedecay_hook_runtime" { + return match crate::mcp::tools::handle_projectless_hook_runtime( + arguments, + &client_identity.profile_root, + ) + .await + { + Ok(result) => JsonRpcResponse::success(id, result.value), + Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), + }; + } + if tool_name == "tracedecay_admin_cli" { + return match crate::mcp::tools::handle_projectless_admin_cli( + arguments, + &client_identity.profile_root, + ) + .await + { + Ok(result) => JsonRpcResponse::success(id, result.value), + Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), + }; + } JsonRpcResponse::error( id, ErrorCode::InternalError, @@ -3051,7 +3858,6 @@ fn projectless_tools_call_response( ) } -#[cfg(unix)] fn projectless_tool_call( params: Option<&serde_json::Value>, ) -> std::result::Result<(&str, serde_json::Value), &'static str> { @@ -3068,15 +3874,13 @@ fn projectless_tool_call( Ok((tool_name, arguments)) } -#[cfg(unix)] -struct UnixStreamTransport { - reader: tokio::io::Lines>, - writer: tokio::net::unix::OwnedWriteHalf, +struct BrokerStreamTransport { + reader: tokio::io::Lines>>, + writer: tokio::io::WriteHalf, } -#[cfg(unix)] -impl UnixStreamTransport { - fn new(stream: tokio::net::UnixStream) -> Self { +impl BrokerStreamTransport { + fn new(stream: BrokerStream) -> Self { let (reader, writer) = stream.into_split(); Self { reader: tokio::io::BufReader::new(reader).lines(), @@ -3085,8 +3889,7 @@ impl UnixStreamTransport { } } -#[cfg(unix)] -impl crate::mcp::McpTransport for UnixStreamTransport { +impl crate::mcp::McpTransport for BrokerStreamTransport { async fn read_line(&mut self) -> std::io::Result> { self.reader.next_line().await } @@ -3100,12 +3903,6 @@ impl crate::mcp::McpTransport for UnixStreamTransport { } } -#[cfg(not(unix))] -fn unsupported_platform() -> TraceDecayError { - TraceDecayError::Config { - message: "TraceDecay daemon sockets are currently supported on Unix platforms".to_string(), - } -} #[cfg(test)] #[allow(clippy::expect_used)] mod tests; diff --git a/src/daemon/authority.rs b/src/daemon/authority.rs new file mode 100644 index 000000000..6b7d0d13a --- /dev/null +++ b/src/daemon/authority.rs @@ -0,0 +1,624 @@ +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs2::FileExt; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::errors::{Result, TraceDecayError}; + +use super::transport::DaemonEndpoint; + +const LOCK_FILE: &str = "daemon-authority.lock"; +const RECORD_FILE: &str = "daemon-authority.json"; + +fn deserialize_endpoint<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum EndpointRecord { + Current(DaemonEndpoint), + Legacy(PathBuf), + } + + match EndpointRecord::deserialize(deserializer)? { + EndpointRecord::Current(endpoint) => Ok(endpoint), + EndpointRecord::Legacy(path) => { + #[cfg(unix)] + { + Ok(DaemonEndpoint::Unix(path)) + } + #[cfg(not(unix))] + { + Err(serde::de::Error::custom(format!( + "legacy Unix daemon endpoint '{}' is unsupported on this platform", + path.display() + ))) + } + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub(super) struct DaemonAuthorityRecord { + pub(super) pid: u32, + pub(super) process_run_id: String, + pub(super) started_at_unix_secs: i64, + pub(super) epoch: u64, + pub(super) version: String, + #[serde(alias = "socket_path", deserialize_with = "deserialize_endpoint")] + pub(super) endpoint: DaemonEndpoint, + pub(super) auth_token: String, + pub(super) profile_root: PathBuf, +} + +#[derive(Debug)] +pub(super) struct DaemonAuthority { + _lock: File, + record_path: PathBuf, + record: DaemonAuthorityRecord, + endpoint_bound: bool, +} + +impl DaemonAuthority { + pub(super) fn acquire( + profile_root: &Path, + endpoint: &DaemonEndpoint, + version: &str, + ) -> Result { + let profile_root = canonical_identity_path(profile_root)?; + std::fs::create_dir_all(&profile_root) + .map_err(|error| config_io("create", &profile_root, error))?; + restrict_directory(&profile_root)?; + + let lock_path = profile_root.join(LOCK_FILE); + let mut lock_options = OpenOptions::new(); + lock_options.create(true).read(true).write(true); + configure_private_create(&mut lock_options, 0o600); + let mut lock = lock_options + .open(&lock_path) + .map_err(|error| config_io("open", &lock_path, error))?; + restrict_file(&lock_path)?; + if let Err(error) = lock.try_lock_exclusive() { + if !is_lock_contended(&error) { + return Err(config_io("lock", &lock_path, error)); + } + let record = read_record_if_present(&profile_root.join(RECORD_FILE)) + .ok() + .flatten() + .map(|record| { + format!( + " (pid {}, epoch {}, endpoint '{}')", + record.pid, record.epoch, record.endpoint + ) + }) + .unwrap_or_default(); + return Err(TraceDecayError::Config { + message: format!( + "daemon authority for profile '{}' is already held{record}: {error}", + profile_root.display() + ), + }); + } + + let record_path = profile_root.join(RECORD_FILE); + let prior_epoch = read_record_if_present(&record_path) + .ok() + .flatten() + .map(|record| record.epoch) + .unwrap_or(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let record = DaemonAuthorityRecord { + pid: std::process::id(), + process_run_id: crate::runtime_identity::process_run_id().to_string(), + started_at_unix_secs: i64::try_from(now.as_secs()).unwrap_or(i64::MAX), + epoch: prior_epoch.saturating_add(1), + version: version.to_string(), + endpoint: canonical_endpoint(endpoint)?, + auth_token: new_auth_token()?, + profile_root, + }; + write_record(&record_path, &record)?; + lock.set_len(0) + .map_err(|error| config_io("truncate", &lock_path, error))?; + lock.seek(SeekFrom::Start(0)) + .map_err(|error| config_io("seek", &lock_path, error))?; + writeln!( + lock, + "pid={} run={} epoch={}", + record.pid, record.process_run_id, record.epoch + ) + .map_err(|error| config_io("write", &lock_path, error))?; + lock.sync_data() + .map_err(|error| config_io("sync", &lock_path, error))?; + + Ok(Self { + _lock: lock, + record_path, + record, + endpoint_bound: false, + }) + } + + pub(super) fn record(&self) -> &DaemonAuthorityRecord { + &self.record + } + + pub(super) fn endpoint(&self) -> &DaemonEndpoint { + &self.record.endpoint + } + + pub(super) fn auth_token(&self) -> &str { + &self.record.auth_token + } + + pub(super) fn publish_endpoint(&mut self, endpoint: DaemonEndpoint) -> Result<()> { + self.record.endpoint = canonical_endpoint(&endpoint)?; + write_record(&self.record_path, &self.record)?; + self.endpoint_bound = true; + Ok(()) + } + + pub(super) fn ensure_current(&self) -> Result<()> { + let current = read_record_if_present(&self.record_path)?; + if current.as_ref().is_some_and(|record| { + record.epoch == self.record.epoch + && record.process_run_id == self.record.process_run_id + && record.profile_root == self.record.profile_root + && record.endpoint == self.record.endpoint + && record.auth_token == self.record.auth_token + }) { + return Ok(()); + } + Err(TraceDecayError::Config { + message: format!( + "daemon authority epoch {} for profile '{}' is no longer current", + self.record.epoch, + self.record.profile_root.display() + ), + }) + } + + #[cfg(test)] + pub(super) fn mark_endpoint_bound(&mut self) { + self.endpoint_bound = true; + } + + pub(super) fn cleanup_owned_endpoint(&mut self) -> Result<()> { + if !self.endpoint_bound || self.ensure_current().is_err() { + return Ok(()); + } + match &self.record.endpoint { + #[cfg(unix)] + DaemonEndpoint::Unix(path) => remove_if_present(path)?, + DaemonEndpoint::Loopback(_) => {} + } + self.endpoint_bound = false; + Ok(()) + } +} + +impl Drop for DaemonAuthority { + fn drop(&mut self) { + let _ = self.cleanup_owned_endpoint(); + } +} + +pub(super) fn current_record(profile_root: &Path) -> Result> { + let profile_root = canonical_identity_path(profile_root)?; + read_record_if_present(&profile_root.join(RECORD_FILE)) +} + +pub(super) fn canonical_identity_path(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| config_io("resolve", path, error))? + .join(path) + }; + if let Ok(canonical) = absolute.canonicalize() { + return Ok(canonical); + } + + let mut suffix = Vec::new(); + let mut existing = absolute.as_path(); + while !existing.exists() { + let Some(name) = existing.file_name() else { + return Err(TraceDecayError::Config { + message: format!("failed to resolve identity path '{}'", path.display()), + }); + }; + suffix.push(name.to_os_string()); + existing = existing.parent().ok_or_else(|| TraceDecayError::Config { + message: format!("failed to resolve identity path '{}'", path.display()), + })?; + } + let mut canonical = existing + .canonicalize() + .map_err(|error| config_io("canonicalize", existing, error))?; + for component in suffix.iter().rev() { + canonical.push(component); + } + Ok(normalize_path(&canonical)) +} + +fn canonical_endpoint(endpoint: &DaemonEndpoint) -> Result { + match endpoint { + #[cfg(unix)] + DaemonEndpoint::Unix(path) => { + let file_name = path.file_name().ok_or_else(|| TraceDecayError::Config { + message: format!("daemon socket path '{}' has no file name", path.display()), + })?; + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + Ok(DaemonEndpoint::Unix( + canonical_identity_path(parent)?.join(file_name), + )) + } + DaemonEndpoint::Loopback(address) => DaemonEndpoint::loopback(*address), + } +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +fn read_record_if_present(path: &Path) -> Result> { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(config_io("open", path, error)), + }; + let mut contents = String::new(); + file.read_to_string(&mut contents) + .map_err(|error| config_io("read", path, error))?; + serde_json::from_str(&contents) + .map(Some) + .map_err(|error| TraceDecayError::Config { + message: format!( + "invalid daemon authority record '{}': {error}", + path.display() + ), + }) +} + +fn write_record(path: &Path, record: &DaemonAuthorityRecord) -> Result<()> { + let temporary = path.with_extension(format!("json.{}.tmp", record.process_run_id)); + let bytes = serde_json::to_vec_pretty(record).map_err(|error| TraceDecayError::Config { + message: format!("failed to encode daemon authority record: {error}"), + })?; + crate::db::DatabaseAuthority::publish_record_atomically( + &temporary, + path, + &bytes, + "daemon authority record", + ) +} + +#[cfg(unix)] +fn configure_private_create(options: &mut OpenOptions, mode: u32) { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(mode); +} + +#[cfg(not(unix))] +fn configure_private_create(_options: &mut OpenOptions, _mode: u32) {} + +#[cfg(unix)] +fn restrict_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| config_io("restrict", path, error)) +} + +#[cfg(not(unix))] +fn restrict_directory(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_file(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| config_io("restrict", path, error)) +} + +#[cfg(not(unix))] +fn restrict_file(_path: &Path) -> Result<()> { + Ok(()) +} + +fn is_lock_contended(error: &std::io::Error) -> bool { + if error.kind() == std::io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + return error.raw_os_error() == Some(33); + } + #[cfg(not(windows))] + false +} + +fn new_auth_token() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes).map_err(|error| TraceDecayError::Config { + message: format!("failed to generate daemon authentication token: {error}"), + })?; + Ok(hex::encode(bytes)) +} + +#[cfg(unix)] +fn remove_if_present(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(config_io("remove", path, error)), + } +} + +fn config_io(operation: &str, path: &Path, error: std::io::Error) -> TraceDecayError { + TraceDecayError::Config { + message: format!("failed to {operation} '{}': {error}", path.display()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_endpoint(profile: &Path) -> DaemonEndpoint { + #[cfg(unix)] + { + DaemonEndpoint::Unix(profile.join("daemon.sock")) + } + #[cfg(not(unix))] + { + super::super::transport::default_loopback_endpoint() + } + } + + #[test] + fn stale_record_does_not_block_and_epoch_advances() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let endpoint = test_endpoint(&profile); + let mut first = DaemonAuthority::acquire(&profile, &endpoint, "test").unwrap(); + let first_epoch = first.record().epoch; + first.endpoint_bound = false; + drop(first); + + let second = DaemonAuthority::acquire(&profile, &endpoint, "test").unwrap(); + assert_eq!(second.record().epoch, first_epoch + 1); + assert_eq!(second.auth_token().len(), 64); + assert!( + second + .auth_token() + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + ); + } + + #[test] + fn contended_lease_does_not_replace_the_live_record() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let endpoint = test_endpoint(&profile); + let first = DaemonAuthority::acquire(&profile, &endpoint, "first").unwrap(); + let record_path = profile.join(RECORD_FILE); + let live = read_record_if_present(&record_path).unwrap().unwrap(); + + let contender = DaemonAuthority::acquire(&profile, &endpoint, "contender"); + + assert!(contender.is_err()); + assert_eq!(read_record_if_present(&record_path).unwrap(), Some(live)); + drop(first); + } + + #[test] + fn record_replacement_is_complete_and_removes_the_temporary_file() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let endpoint = test_endpoint(&profile); + let authority = DaemonAuthority::acquire(&profile, &endpoint, "first").unwrap(); + let mut successor = authority.record().clone(); + successor.epoch += 1; + successor.version = "successor".to_string(); + let temporary = authority + .record_path + .with_extension(format!("json.{}.tmp", successor.process_run_id)); + + write_record(&authority.record_path, &successor).unwrap(); + + assert_eq!( + read_record_if_present(&authority.record_path).unwrap(), + Some(successor) + ); + assert!(!temporary.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(&profile).unwrap().permissions().mode() & 0o777, + 0o700 + ); + for path in [&authority.record_path, &profile.join(LOCK_FILE)] { + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + } + + #[test] + fn published_loopback_endpoint_preserves_the_elected_secret() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let requested = test_endpoint(&profile); + let mut authority = DaemonAuthority::acquire(&profile, &requested, "test").unwrap(); + let auth_token = authority.auth_token().to_string(); + let concrete = DaemonEndpoint::parse("tcp://127.0.0.1:43123").unwrap(); + + authority.publish_endpoint(concrete.clone()).unwrap(); + + let published = current_record(&profile).unwrap().unwrap(); + assert_eq!(published.endpoint, concrete); + assert_eq!(published.auth_token, auth_token); + assert!(authority.ensure_current().is_ok()); + } + + #[cfg(unix)] + #[test] + fn legacy_socket_path_record_is_accepted_by_current_reader() { + #[derive(Serialize)] + struct LegacySocketRecord { + pid: u32, + process_run_id: String, + started_at_unix_secs: i64, + epoch: u64, + version: String, + socket_path: PathBuf, + auth_token: String, + profile_root: PathBuf, + } + + let temp = tempfile::tempdir().unwrap(); + let profile_root = temp.path().join("profile"); + std::fs::create_dir_all(&profile_root).unwrap(); + let record_path = profile_root.join(RECORD_FILE); + let socket_path = profile_root.join("daemon.sock"); + let legacy = LegacySocketRecord { + pid: 42, + process_run_id: "legacy-run".to_string(), + started_at_unix_secs: 1, + epoch: 3, + version: "legacy".to_string(), + socket_path: socket_path.clone(), + auth_token: "a".repeat(64), + profile_root: profile_root.clone(), + }; + std::fs::write(&record_path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + let decoded = read_record_if_present(&record_path).unwrap().unwrap(); + assert_eq!(decoded.endpoint, DaemonEndpoint::Unix(socket_path)); + assert_eq!(decoded.auth_token, "a".repeat(64)); + } + + #[test] + fn current_endpoint_record_fails_closed_for_legacy_reader() { + #[allow(dead_code)] + #[derive(Deserialize)] + struct LegacySocketRecord { + pid: u32, + process_run_id: String, + started_at_unix_secs: i64, + epoch: u64, + version: String, + socket_path: PathBuf, + profile_root: PathBuf, + } + + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let endpoint = test_endpoint(&profile); + let authority = DaemonAuthority::acquire(&profile, &endpoint, "current").unwrap(); + let encoded = serde_json::to_string(authority.record()).unwrap(); + + assert!(serde_json::from_str::(&encoded).is_err()); + } + + #[test] + fn stale_endpoint_or_token_is_rejected_by_the_elected_owner() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let endpoint = test_endpoint(&profile); + let authority = DaemonAuthority::acquire(&profile, &endpoint, "test").unwrap(); + let mut stale = authority.record().clone(); + stale.auth_token = "0".repeat(64); + write_record(&authority.record_path, &stale).unwrap(); + + assert!(authority.ensure_current().is_err()); + } + + #[cfg(unix)] + #[test] + fn canonical_profile_alias_contends_on_one_kernel_lease() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&profile).unwrap(); + let alias = temp.path().join("alias"); + std::os::unix::fs::symlink(&profile, &alias).unwrap(); + let first = DaemonAuthority::acquire( + &profile, + &DaemonEndpoint::Unix(profile.join("daemon.sock")), + "test", + ) + .unwrap(); + let second = DaemonAuthority::acquire( + &alias, + &DaemonEndpoint::Unix(alias.join("daemon.sock")), + "test", + ); + assert!(second.is_err()); + drop(first); + } + + #[cfg(unix)] + #[test] + fn stale_epoch_cannot_remove_successor_socket() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + let socket = profile.join("daemon.sock"); + let mut authority = + DaemonAuthority::acquire(&profile, &DaemonEndpoint::Unix(socket.clone()), "test") + .unwrap(); + std::fs::write(&socket, b"successor").unwrap(); + authority.mark_endpoint_bound(); + let mut successor = authority.record().clone(); + successor.epoch += 1; + successor.process_run_id.push_str("-successor"); + write_record(&authority.record_path, &successor).unwrap(); + + authority.cleanup_owned_endpoint().unwrap(); + assert!(socket.exists()); + } + + #[cfg(unix)] + #[test] + fn socket_identity_never_follows_the_socket_leaf_symlink() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&profile).unwrap(); + let target = temp.path().join("unrelated"); + std::fs::write(&target, b"keep").unwrap(); + let socket = profile.join("daemon.sock"); + std::os::unix::fs::symlink(&target, &socket).unwrap(); + + let authority = + DaemonAuthority::acquire(&profile, &DaemonEndpoint::Unix(socket.clone()), "test") + .unwrap(); + + assert_eq!(authority.endpoint(), &DaemonEndpoint::Unix(socket)); + assert_eq!(std::fs::read(&target).unwrap(), b"keep"); + } +} diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index 21f1f17fe..39ec8d569 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -1,20 +1,25 @@ use std::path::PathBuf; +#[cfg(unix)] +use std::process::Command; #[cfg(unix)] use serde_json::Value; #[cfg(unix)] use serde_json::json; -#[cfg(unix)] use tempfile::TempDir; -#[cfg(unix)] use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; #[cfg(unix)] use tokio::task::JoinHandle; #[cfg(unix)] -use super::{AutomationSchedulerHandle, DaemonEngine, ProjectServerKey, drain_client_tasks}; +use super::{ + AutomationSchedulerHandle, DaemonEngine, DatabaseOwnerRegistry, ProjectRouteKey, + ProjectServerKey, StoreOwnerKey, drain_client_tasks, +}; use super::{DaemonClientIdentity, DaemonHandshake, DaemonLifecycle}; +mod compatibility; + #[test] fn daemon_lifecycle_rejects_new_work_after_draining() { let lifecycle = DaemonLifecycle::default(); @@ -25,6 +30,137 @@ fn daemon_lifecycle_rejects_new_work_after_draining() { assert!(!lifecycle.accepting()); } +#[tokio::test] +async fn portable_broker_requests_reuse_one_authenticated_project_owner() { + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + + let temp = TempDir::new().expect("temp dir"); + let project = temp.path().join("project"); + let profile_root = temp.path().join("profile"); + std::fs::create_dir_all(&project).expect("project dir"); + let client_identity = test_client_identity_for(profile_root.clone()); + let options = crate::tracedecay::TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(client_identity.global_db_path.clone()), + }; + drop( + crate::tracedecay::TraceDecay::init_with_options(&project, options) + .await + .expect("initialize project"), + ); + let _database_scope = + crate::db::enter_daemon_database_scope(&profile_root, 1, "portable-owner-cache-test") + .expect("daemon database scope"); + let handshake = DaemonHandshake { + project_path: Some(project.clone()), + client_identity, + ..test_handshake_defaults() + }; + let route = super::ProjectRouteKey::from_handshake(&project, &handshake).expect("route key"); + let owners = std::sync::Arc::new(tokio::sync::Mutex::new( + super::DatabaseOwnerRegistry::default(), + )); + let gates = std::sync::Arc::new(tokio::sync::Mutex::new(super::ProjectOpenGates::default())); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lifecycle = DaemonLifecycle::default(); + let (listener, endpoint) = + super::transport::BrokerListener::bind(&super::transport::default_loopback_endpoint()) + .await + .expect("loopback listener"); + + let server = { + let owners = std::sync::Arc::clone(&owners); + let gates = std::sync::Arc::clone(&gates); + let attempts = std::sync::Arc::clone(&attempts); + let lifecycle = lifecycle.clone(); + tokio::spawn(async move { + let mut clients = tokio::task::JoinSet::new(); + for _ in 0..2 { + let stream = listener.accept().await.expect("accept client"); + let owners = std::sync::Arc::clone(&owners); + let gates = std::sync::Arc::clone(&gates); + let attempts = std::sync::Arc::clone(&attempts); + let lifecycle = lifecycle.clone(); + clients.spawn(async move { + super::serve_windows_broker_client( + stream, + TOKEN, + &lifecycle, + owners, + gates, + Some(attempts), + ) + .await + }); + } + while let Some(client) = clients.join_next().await { + client.expect("client task").expect("serve client"); + } + }) + }; + + let request = |id: u64| { + let endpoint = endpoint.clone(); + let handshake = handshake.clone(); + async move { + let stream = super::transport::BrokerStream::connect(&endpoint) + .await + .expect("connect client"); + let (reader, mut writer) = stream.into_split(); + let preface = super::transport::DaemonAuthPreface::new(TOKEN) + .to_line() + .expect("auth preface"); + writer.write_all(preface.as_bytes()).await.expect("preface"); + writer.write_all(b"\n").await.expect("preface newline"); + writer + .write_all(handshake.to_line().expect("handshake").as_bytes()) + .await + .expect("handshake"); + writer.write_all(b"\n").await.expect("handshake newline"); + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "portable-cache-test", "version": "1"} + } + }); + writer + .write_all(initialize.to_string().as_bytes()) + .await + .expect("initialize"); + writer.write_all(b"\n").await.expect("initialize newline"); + writer.shutdown().await.expect("shutdown request writer"); + let mut lines = tokio::io::BufReader::new(reader).lines(); + let response = lines + .next_line() + .await + .expect("read response") + .expect("initialize response"); + assert_eq!( + serde_json::from_str::(&response).unwrap()["id"], + id + ); + } + }; + tokio::join!(request(1), request(2)); + server.await.expect("broker server"); + + assert_eq!( + attempts.load(std::sync::atomic::Ordering::Relaxed), + 1, + "same-route requests must singleflight one project open" + ); + let owners = owners.lock().await; + assert_eq!(owners.servers.len(), 1); + assert_eq!(owners.aliases.len(), 1); + let first = owners.get_route(&route).expect("first cached owner").1; + let second = owners.get_route(&route).expect("second cached owner").1; + assert!(std::sync::Arc::ptr_eq(first, second)); +} + #[cfg(unix)] #[tokio::test] async fn client_drain_timeout_aborts_and_joins_remaining_work() { @@ -52,6 +188,184 @@ async fn client_drain_waits_for_completed_work() { assert!(clients.is_empty()); } +#[cfg(unix)] +#[tokio::test] +async fn one_shot_tool_call_aborts_when_daemon_liveness_fails_after_write() { + let temp = TempDir::new().expect("temp dir"); + let socket = temp.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).expect("bind daemon socket"); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.expect("accept tool call"); + drop(listener); + std::future::pending::<()>().await + }); + + let error = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::call_tool_with_liveness_poll( + &socket, + &test_handshake_defaults(), + "tracedecay_status", + json!({}), + std::time::Duration::from_millis(10), + ), + ) + .await + .expect("liveness failure detection timed out") + .expect_err("lost daemon liveness must abort the one-shot request"); + let message = error.to_string(); + assert!(message.contains("tracedecay_status"), "{message}"); + assert!(message.contains("unreachable"), "{message}"); + assert!( + message.contains("already sent") && message.contains("not retried"), + "{message}" + ); + server.abort(); + let _ = server.await; +} + +#[cfg(unix)] +#[tokio::test] +async fn proxied_request_uses_shared_liveness_boundary_after_write() { + let temp = TempDir::new().expect("temp dir"); + let socket = temp.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).expect("bind daemon socket"); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.expect("accept proxied request"); + drop(listener); + std::future::pending::<()>().await + }); + let request = json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/list", + }) + .to_string(); + + let error = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::send_daemon_request_line_with_liveness_poll( + &socket, + &test_handshake_defaults(), + &request, + std::time::Duration::from_millis(10), + ), + ) + .await + .expect("proxy liveness failure detection timed out") + .expect_err("proxied response wait must stop when daemon liveness fails"); + let message = error.to_string(); + assert!(message.contains("tools/list"), "{message}"); + assert!( + message.contains("already sent") && message.contains("not retried"), + "{message}" + ); + server.abort(); + let _ = server.await; +} + +#[cfg(unix)] +#[tokio::test] +async fn post_write_disconnect_reports_ambiguous_outcome_without_retry() { + let temp = TempDir::new().expect("temp dir"); + let socket = temp.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).expect("bind daemon socket"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept proxied request"); + let (reader, _writer) = stream.into_split(); + let mut lines = tokio::io::BufReader::new(reader).lines(); + lines + .next_line() + .await + .expect("read handshake") + .expect("handshake line"); + lines + .next_line() + .await + .expect("read request") + .expect("request line"); + }); + let request = json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + }) + .to_string(); + + let error = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::send_daemon_request_line_with_liveness_poll( + &socket, + &test_handshake_defaults(), + &request, + std::time::Duration::from_millis(10), + ), + ) + .await + .expect("post-write disconnect detection timed out") + .expect_err("disconnect without a response must remain ambiguous"); + let message = error.to_string(); + assert!(message.contains("outcome is unknown"), "{message}"); + assert!(message.contains("not retried"), "{message}"); + assert!(!message.contains("retry the request"), "{message}"); + server.await.expect("fake daemon task"); +} + +#[cfg(unix)] +#[tokio::test] +async fn one_shot_tool_call_allows_long_response_while_daemon_stays_live() { + let temp = TempDir::new().expect("temp dir"); + let socket = temp.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).expect("bind daemon socket"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept tool call"); + let (reader, mut writer) = stream.into_split(); + let mut lines = tokio::io::BufReader::new(reader).lines(); + lines + .next_line() + .await + .expect("read handshake") + .expect("handshake line"); + let request_line = lines + .next_line() + .await + .expect("read request") + .expect("request line"); + let request: Value = serde_json::from_str(&request_line).expect("request json"); + let (probe, _) = tokio::time::timeout(std::time::Duration::from_secs(2), listener.accept()) + .await + .expect("liveness probe timed out") + .expect("accept liveness probe"); + drop(probe); + let response = json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"status": "ok"}, + }); + writer + .write_all(response.to_string().as_bytes()) + .await + .expect("write response"); + writer.write_all(b"\n").await.expect("write newline"); + }); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::call_tool_with_liveness_poll( + &socket, + &test_handshake_defaults(), + "tracedecay_status", + json!({}), + std::time::Duration::from_millis(10), + ), + ) + .await + .expect("healthy long-running request timed out") + .expect("healthy long-running request must complete"); + assert_eq!(result["status"], json!("ok")); + server.await.expect("fake daemon task"); +} + #[cfg(unix)] #[tokio::test] async fn persistent_idle_client_closes_on_draining_without_timeout() { @@ -95,9 +409,14 @@ async fn draining_waits_for_one_bounded_in_flight_request() { async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { let engine = DaemonEngine::default(); let key = ProjectServerKey { - project_path: PathBuf::from("/projects/shutdown-test"), + owner: StoreOwnerKey { + profile_root: PathBuf::from("/profiles/shutdown-test"), + global_db_path: PathBuf::from("/profiles/shutdown-test/global.db"), + project_id: Some("shutdown-test".to_string()), + store_root: PathBuf::from("/stores/shutdown-test"), + graph_db_path: PathBuf::from("/stores/shutdown-test/graph.db"), + }, scope_prefix: None, - client_identity: test_client_identity(), }; let task = tokio::spawn(std::future::pending::<()>()); engine.automation_schedulers.lock().await.insert( @@ -119,6 +438,202 @@ async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { assert!(engine.automation_schedulers.lock().await.is_empty()); } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn project_server_cache_hit_skips_open_and_singleflights_first_miss() { + const PHASE_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(20); + let temp = TempDir::new().expect("temp dir"); + let project = temp.path().join("project"); + let project_alias = temp.path().join("project-alias"); + let profile_root = temp.path().join("profile"); + std::fs::create_dir_all(&project).expect("project dir"); + std::os::unix::fs::symlink(&project, &project_alias).expect("project alias"); + let client_identity = test_client_identity_for(profile_root.clone()); + let options = crate::tracedecay::TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(client_identity.global_db_path.clone()), + }; + eprintln!("[cache-test] phase=init start"); + let initialized = crate::tracedecay::TraceDecay::init_with_options(&project, options) + .await + .expect("initialize project"); + drop(initialized); + let mut config = crate::config::load_config(&project).expect("load project config"); + config.sync.session_start_sync = false; + crate::config::save_config(&project, &config) + .expect("disable unrelated startup transcript ingestion"); + eprintln!("[cache-test] phase=init done"); + + let direct = DaemonHandshake { + project_path: Some(project.clone()), + client_identity: client_identity.clone(), + ..test_handshake_defaults() + }; + let aliased = DaemonHandshake { + project_path: Some(project_alias), + client_identity, + ..test_handshake_defaults() + }; + let _database_scope = + crate::db::enter_daemon_database_scope(&profile_root, 1, "project-server-cache-test") + .expect("daemon database scope"); + let engine = DaemonEngine::default(); + let direct_route = super::ProjectRouteKey::from_handshake(&project, &direct).unwrap(); + let alias_route = super::ProjectRouteKey::from_handshake( + &project.canonicalize().expect("canonical project"), + &aliased, + ) + .unwrap(); + assert_eq!( + direct_route, alias_route, + "aliases must share one route gate" + ); + + eprintln!("[cache-test] phase=concurrent-open start"); + let (direct_server, alias_server) = tokio::time::timeout(PHASE_TIMEOUT, async { + tokio::join!( + engine.project_server(&direct), + engine.project_server(&aliased) + ) + }) + .await + .expect("cache-test concurrent-open phase timed out"); + eprintln!("[cache-test] phase=concurrent-open done"); + let direct_server = direct_server.expect("direct project server"); + let alias_server = alias_server.expect("aliased project server"); + assert!(std::sync::Arc::ptr_eq(&direct_server, &alias_server)); + assert_eq!( + engine + .project_open_attempts + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "canonical aliases must singleflight the first project open" + ); + + eprintln!("[cache-test] phase=cached-open start"); + let cached = tokio::time::timeout(PHASE_TIMEOUT, engine.project_server(&direct)) + .await + .expect("cache-test cached-open phase timed out") + .expect("cached project server"); + eprintln!("[cache-test] phase=cached-open done"); + assert!(std::sync::Arc::ptr_eq(&direct_server, &cached)); + assert_eq!( + engine + .project_open_attempts + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "cache hits must return before opening project databases" + ); + drop(cached); + drop(alias_server); + drop(direct_server); + eprintln!("[cache-test] phase=shutdown start"); + tokio::time::timeout(PHASE_TIMEOUT, engine.shutdown_all()) + .await + .expect("cache-test shutdown phase timed out"); + eprintln!("[cache-test] phase=shutdown done"); +} + +#[cfg(unix)] +#[test] +fn store_owner_key_collapses_profile_and_store_aliases() { + let temp = TempDir::new().expect("temp dir"); + let profile = temp.path().join("profile"); + let store = temp.path().join("store"); + std::fs::create_dir_all(&profile).expect("profile dir"); + std::fs::create_dir_all(&store).expect("store dir"); + let profile_alias = temp.path().join("profile-alias"); + let store_alias = temp.path().join("store-alias"); + std::os::unix::fs::symlink(&profile, &profile_alias).expect("profile alias"); + std::os::unix::fs::symlink(&store, &store_alias).expect("store alias"); + + let direct = StoreOwnerKey::from_paths( + &profile, + &profile.join("global.db"), + Some("project-id".to_string()), + &store, + &store.join("graph.db"), + ) + .expect("direct owner"); + let aliased = StoreOwnerKey::from_paths( + &profile_alias, + &profile_alias.join("global.db"), + Some("project-id".to_string()), + &store_alias, + &store_alias.join("graph.db"), + ) + .expect("aliased owner"); + + assert_eq!(direct, aliased); +} + +#[cfg(unix)] +#[test] +fn database_owner_registry_rekeys_and_evicts_stale_routes() { + let owner = StoreOwnerKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_id: Some("project".to_string()), + store_root: PathBuf::from("/store"), + graph_db_path: PathBuf::from("/store/main.db"), + }; + let old = ProjectServerKey { + owner: owner.clone(), + scope_prefix: Some("src".to_string()), + }; + let mut feature_owner = owner; + feature_owner.graph_db_path = PathBuf::from("/store/feature.db"); + let new = ProjectServerKey { + owner: feature_owner, + scope_prefix: Some("src".to_string()), + }; + let mut registry = DatabaseOwnerRegistry::::default(); + registry.insert(old.clone(), 7); + + assert!(registry.rekey(&old, new.clone())); + + assert!(registry.get(&old).is_none()); + assert_eq!(registry.get(&new), Some(&7)); + assert!(!registry.routes.contains_key(&old.owner)); + assert!(registry.routes[&new.owner].contains(&new)); + + let mut collision = DatabaseOwnerRegistry::::default(); + collision.insert(old.clone(), 7); + collision.insert(new.clone(), 9); + assert!(!collision.rekey(&old, new.clone())); + assert!(collision.get(&old).is_none()); + assert_eq!(collision.get(&new), Some(&9)); +} + +#[test] +fn database_owner_registry_race_keeps_first_server_and_binds_route() { + let owner = StoreOwnerKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_id: Some("project".to_string()), + store_root: PathBuf::from("/store"), + graph_db_path: PathBuf::from("/store/main.db"), + }; + let key = ProjectServerKey { + owner, + scope_prefix: None, + }; + let route = ProjectRouteKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_path: PathBuf::from("/project-alias"), + scope_prefix: None, + }; + let mut registry = DatabaseOwnerRegistry::::default(); + registry.insert(key.clone(), 7); + + let (resolved, inserted) = registry.bind_or_insert_route(route.clone(), key.clone(), 9); + + assert_eq!(resolved, 7); + assert!(!inserted); + assert_eq!(registry.get_route(&route), Some((&key, &7))); +} + fn test_client_identity() -> DaemonClientIdentity { test_client_identity_for(PathBuf::from("/profiles/client")) } @@ -311,7 +826,7 @@ async fn connect_with_restart_grace_reconnects_once_daemon_rebinds() { }); super::connect_with_restart_grace( - &socket, + &super::connection_for_socket_path(&socket), std::time::Duration::from_secs(8), std::time::Duration::from_millis(50), ) @@ -327,7 +842,7 @@ async fn connect_with_restart_grace_gives_up_with_restart_hint() { let socket = dir.path().join("daemon.sock"); let err = super::connect_with_restart_grace( - &socket, + &super::connection_for_socket_path(&socket), std::time::Duration::from_millis(300), std::time::Duration::from_millis(50), ) @@ -388,8 +903,12 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { }) .to_string(); - super::update_proxy_handshake_from_initialize(&base_handshake, &mut routed_handshake, &line) - .await; + super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); + let route = super::apply_daemon_initialize_route(&mut routed_handshake, &line) + .await + .expect("daemon initialize routing should succeed") + .expect("registered initialize root should produce a route"); + assert_eq!(route.project_path, project_b); assert_eq!( routed_handshake.project_path.as_deref(), @@ -404,12 +923,17 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { "params": {} }) .to_string(); - super::update_proxy_handshake_from_initialize( + super::reset_proxy_handshake_for_initialize( &base_handshake, &mut routed_handshake, &rerun_without_roots, - ) - .await; + ); + assert!( + super::apply_daemon_initialize_route(&mut routed_handshake, &rerun_without_roots) + .await + .expect("daemon initialize reroute should succeed") + .is_none() + ); assert_eq!( routed_handshake.project_path.as_deref(), @@ -419,6 +943,51 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { assert_eq!(routed_handshake.scope_prefix.as_deref(), Some("src")); } +#[cfg(unix)] +#[tokio::test] +async fn daemon_resolves_registry_only_initialize_root_alias() { + let profile = TempDir::new().expect("profile temp dir"); + let canonical = TempDir::new().expect("canonical project temp dir"); + let alias = TempDir::new().expect("project alias temp dir"); + let canonical = canonical.path().canonicalize().expect("canonical project"); + let alias = alias.path().canonicalize().expect("canonical alias"); + let nested = alias.join("nested"); + std::fs::create_dir_all(&nested).expect("nested alias path"); + let global_db_path = profile.path().join("global.db"); + let registry = crate::global_db::GlobalDb::open_at(&global_db_path) + .await + .expect("open registry"); + registry + .upsert_code_project("project-registry-only", &canonical, None, None, None) + .await + .expect("register canonical project"); + registry + .upsert_project_alias(&alias, "project-registry-only") + .await + .expect("register project alias"); + drop(registry); + + let mut handshake = test_handshake_defaults(); + handshake.allow_initialize_root_routing = true; + handshake.client_identity = test_client_identity_for(profile.path().to_path_buf()); + handshake.client_identity.global_db_path = global_db_path; + let line = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "roots": [{ "uri": nested, "name": "alias" }] } + }) + .to_string(); + + let route = super::apply_daemon_initialize_route(&mut handshake, &line) + .await + .expect("daemon initialize routing should succeed") + .expect("authenticated daemon should resolve registry alias"); + assert_eq!(route.project_path, alias); + assert_eq!(handshake.project_path.as_deref(), Some(alias.as_path())); + assert!(!route.allow_init); +} + #[cfg(unix)] #[tokio::test] async fn initialize_root_routing_delegates_config_gated_git_auto_init() { @@ -454,8 +1023,10 @@ async fn initialize_root_routing_delegates_config_gated_git_auto_init() { .to_string(); let mut routed_handshake = base_handshake.clone(); - super::update_proxy_handshake_from_initialize(&base_handshake, &mut routed_handshake, &line) - .await; + super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); + super::apply_daemon_initialize_route(&mut routed_handshake, &line) + .await + .expect("daemon should delegate auto-init"); assert_eq!( routed_handshake.project_path.as_deref(), Some(project.as_path()) @@ -468,8 +1039,10 @@ async fn initialize_root_routing_delegates_config_gated_git_auto_init() { }; config.sync.auto_init = false; crate::config::save_config(&project, &config).expect("disable auto-init"); - super::update_proxy_handshake_from_initialize(&base_handshake, &mut routed_handshake, &line) - .await; + super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); + super::apply_daemon_initialize_route(&mut routed_handshake, &line) + .await + .expect("daemon should resolve git root with auto-init disabled"); assert_eq!( routed_handshake.project_path.as_deref(), Some(project.as_path()) @@ -703,7 +1276,7 @@ async fn long_lived_proxy_reconnects_after_daemon_socket_rebind() { #[cfg(unix)] #[tokio::test] -async fn proxy_transport_carries_initialize_root_and_resets_on_reinitialize() { +async fn proxy_uses_daemon_initialize_route_without_registry_access() { let dir = TempDir::new().expect("temp dir"); let temp_root = dir.path().canonicalize().expect("canonical temp dir"); let active_root = temp_root.join("active"); @@ -713,20 +1286,12 @@ async fn proxy_transport_carries_initialize_root_and_resets_on_reinitialize() { let active = active_root.canonicalize().expect("active root"); let target = target_root.canonicalize().expect("target root"); let socket = temp_root.join("daemon.sock"); - let client_identity = test_client_identity_for(temp_root.join("profile")); - let registry = crate::global_db::GlobalDb::open_at(&client_identity.global_db_path) - .await - .expect("registry"); - registry - .upsert_code_project("proj_active_proxy", &active, None, None, Some("main")) - .await - .expect("active project registry"); - registry - .upsert_code_project("proj_target_proxy", &target, None, None, Some("main")) - .await - .expect("target project registry"); + let mut client_identity = test_client_identity_for(temp_root.join("profile")); + client_identity.global_db_path = temp_root.join("proxy-cannot-open-this-directory"); + std::fs::create_dir_all(&client_identity.global_db_path).expect("non-database authority path"); let listener = tokio::net::UnixListener::bind(&socket).expect("daemon socket"); + let daemon_target = target.clone(); let accept_task = tokio::spawn(async move { let mut projects = Vec::new(); for _ in 0..4 { @@ -746,14 +1311,28 @@ async fn proxy_transport_carries_initialize_root_and_resets_on_reinitialize() { .expect("read request") .expect("request line"); let request: Value = serde_json::from_str(&request_line).expect("request json"); - let project = handshake + let mut project = handshake .project_path .as_ref() .map(|path| path.display().to_string()); + let mut result = json!({ "project": project }); + if request["method"] == json!("initialize") + && request + .pointer("/params/roots") + .and_then(Value::as_array) + .is_some_and(|roots| !roots.is_empty()) + { + project = Some(daemon_target.display().to_string()); + result["project"] = json!(project); + result["_meta"]["tracedecayInitializeRoute"] = json!({ + "projectPath": daemon_target, + "allowInit": false, + }); + } let response = json!({ "jsonrpc": "2.0", "id": request["id"].clone(), - "result": { "project": project } + "result": result }); writer .write_all( @@ -765,7 +1344,12 @@ async fn proxy_transport_carries_initialize_root_and_resets_on_reinitialize() { .expect("write response"); writer.write_all(b"\n").await.expect("write newline"); writer.shutdown().await.expect("shutdown fake daemon"); - projects.push(project); + projects.push( + handshake + .project_path + .as_ref() + .map(|path| path.display().to_string()), + ); } projects }); @@ -866,7 +1450,7 @@ async fn proxy_transport_carries_initialize_root_and_resets_on_reinitialize() { assert_eq!( served_projects, vec![ - Some(target.clone()), + Some(active.clone()), Some(target), Some(active.clone()), Some(active), @@ -987,46 +1571,97 @@ fn daemon_handshake_requires_client_identity() { assert!(DaemonHandshake::from_line(&encoded).is_err()); } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct LegacyDaemonHandshake { + project_path: Option, + scope_prefix: Option, + timings: bool, + allow_init: bool, + client_identity: DaemonClientIdentity, +} + /// Old client → new daemon: handshakes without version/instance fields must /// still parse, with empty defaults. #[test] fn daemon_handshake_accepts_old_client_without_version() { - let encoded = serde_json::json!({ - "project_path": "/work/repo", - "scope_prefix": null, - "timings": false, - "allow_init": false, - "client_identity": { - "profile_root": "/profiles/client", - "global_db_path": "/profiles/client/global.db" - } - }) - .to_string(); + let legacy = LegacyDaemonHandshake { + project_path: Some(PathBuf::from("/work/repo")), + scope_prefix: None, + timings: false, + allow_init: false, + client_identity: test_client_identity(), + }; + let encoded = serde_json::to_string(&legacy).expect("old handshake should encode"); let decoded = DaemonHandshake::from_line(&encoded).expect("old handshake should decode"); + assert!(!decoded.allow_initialize_root_routing); assert_eq!(decoded.client_version, ""); assert_eq!(decoded.client_instance_id, ""); assert!(!decoded.tool_list_changed_capable); assert_eq!(decoded.catalog_version, ""); } -/// New client → old daemon: the serde derive ignores unknown fields, so a -/// daemon predating `client_version` (same derive) parses new handshakes. -/// Adding another unknown field to a current handshake proves the -/// tolerance the old daemon relies on. +/// New client → old daemon: an actual legacy projection ignores new fields. #[test] fn daemon_handshake_ignores_unknown_fields_for_old_daemons() { let handshake = test_handshake_defaults(); - let mut value: serde_json::Value = + let decoded: LegacyDaemonHandshake = serde_json::from_str(&handshake.to_line().expect("handshake should encode")) - .expect("handshake json"); - value["field_from_a_future_version"] = serde_json::json!("ignored"); + .expect("old daemon should ignore new handshake fields"); - let decoded = DaemonHandshake::from_line(&value.to_string()) - .expect("handshake with unknown fields should decode"); + assert_eq!(decoded.project_path, handshake.project_path); + assert_eq!(decoded.scope_prefix, handshake.scope_prefix); + assert_eq!(decoded.timings, handshake.timings); + assert_eq!(decoded.allow_init, handshake.allow_init); + assert_eq!(decoded.client_identity, handshake.client_identity); +} - assert_eq!(decoded, handshake); +#[tokio::test] +async fn portable_broker_rejects_missing_auth_before_routing() { + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + let owners = std::sync::Arc::new(tokio::sync::Mutex::new( + super::DatabaseOwnerRegistry::default(), + )); + let gates = std::sync::Arc::new(tokio::sync::Mutex::new(super::ProjectOpenGates::default())); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (listener, endpoint) = + super::transport::BrokerListener::bind(&super::transport::default_loopback_endpoint()) + .await + .expect("loopback listener"); + let server_owners = std::sync::Arc::clone(&owners); + let server_attempts = std::sync::Arc::clone(&attempts); + let server = tokio::spawn(async move { + let stream = listener.accept().await.expect("accept client"); + super::serve_windows_broker_client( + stream, + TOKEN, + &DaemonLifecycle::default(), + server_owners, + gates, + Some(server_attempts), + ) + .await + }); + let mut handshake = test_handshake_defaults(); + handshake.project_path = Some(PathBuf::from("/must-not-route")); + let mut client = super::transport::BrokerStream::connect(&endpoint) + .await + .expect("connect client"); + client + .write_all(handshake.to_line().expect("handshake").as_bytes()) + .await + .expect("write unauthenticated handshake"); + client.write_all(b"\n").await.expect("write newline"); + client.shutdown().await.expect("shutdown client"); + + let error = server + .await + .expect("server task") + .expect_err("missing auth must fail closed"); + assert!(error.to_string().contains("authentication failed")); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Relaxed), 0); + assert!(owners.lock().await.values().next().is_none()); } #[test] @@ -1577,8 +2212,11 @@ async fn daemon_ensure_scheduler_skips_before_project_has_configured_work() { client_identity, ..test_handshake_defaults() }; + let cg = crate::tracedecay::TraceDecay::init_with_options(&project, handshake.open_options()) + .await + .expect("project init"); let engine = super::DaemonEngine::default(); - let key = super::ProjectServerKey::from_handshake(project.clone(), &handshake); + let key = super::ProjectServerKey::from_open_project(&cg, &handshake).expect("owner key"); engine .ensure_automation_scheduler(key.clone(), project, handshake) @@ -1615,7 +2253,7 @@ async fn daemon_ensure_scheduler_starts_after_project_configures_work() { ..test_handshake_defaults() }; let engine = super::DaemonEngine::default(); - let key = super::ProjectServerKey::from_handshake(project.clone(), &handshake); + let key = super::ProjectServerKey::from_open_project(&cg, &handshake).expect("owner key"); engine .ensure_automation_scheduler(key.clone(), project.clone(), handshake.clone()) @@ -1775,3 +2413,114 @@ async fn socket_client_rejects_tool_calls_without_project() { .expect("server task should complete") .expect("projectless client shutdown should be clean"); } + +#[cfg(unix)] +#[tokio::test] +async fn daemon_linked_worktree_route_repairs_primary_identity_and_keeps_alias() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path().canonicalize().expect("canonical temp dir"); + let primary = root.join("primary"); + let linked = root.join("linked"); + let profile_root = root.join("profile"); + std::fs::create_dir_all(&primary).expect("primary dir"); + let git = |cwd: &std::path::Path, args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "TraceDecay Test") + .env("GIT_AUTHOR_EMAIL", "test@tracedecay.local") + .env("GIT_COMMITTER_NAME", "TraceDecay Test") + .env("GIT_COMMITTER_EMAIL", "test@tracedecay.local") + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&primary, &["init", "-b", "main", "--quiet"]); + std::fs::write(primary.join("README.md"), "linked worktree route\n").expect("fixture"); + git(&primary, &["add", "."]); + git(&primary, &["commit", "-m", "fixture", "--quiet"]); + git( + &primary, + &[ + "worktree", + "add", + "-b", + "feature/linked-route", + linked.to_str().expect("utf-8 linked path"), + "HEAD", + ], + ); + + let client_identity = test_client_identity_for(profile_root.clone()); + let options = crate::tracedecay::TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(client_identity.global_db_path.clone()), + }; + let primary_cg = crate::tracedecay::TraceDecay::init_with_options(&primary, options.clone()) + .await + .expect("primary init"); + primary_cg.index_all().await.expect("primary index"); + primary_cg + .db() + .checkpoint() + .await + .expect("primary checkpoint"); + let project_id = primary_cg + .store_layout() + .identity + .project_id + .clone() + .expect("profile project id"); + drop(primary_cg); + + let registry = crate::global_db::GlobalDb::open_at(&client_identity.global_db_path) + .await + .expect("registry"); + registry + .upsert_code_project( + &project_id, + &linked, + crate::worktree::git_common_dir(&linked).as_deref(), + None, + Some("main"), + ) + .await + .expect("seed stale linked canonical root"); + + let handshake = DaemonHandshake { + project_path: Some(linked.clone()), + client_identity, + ..test_handshake_defaults() + }; + let _database_scope = + crate::db::enter_daemon_database_scope(&profile_root, 1, "linked-worktree-route-test") + .expect("daemon database scope"); + let engine = super::DaemonEngine::default(); + engine + .project_server(&handshake) + .await + .expect("daemon linked-worktree route"); + + let context = registry + .project_registry_context_by_id(&project_id) + .await + .expect("registry context"); + assert_eq!( + context.project.canonical_root, + crate::global_db::GlobalDb::canonical_project_key(&primary) + ); + assert!(context.aliases.iter().any(|alias| { + alias.alias_path == crate::global_db::GlobalDb::canonical_project_key(&linked) + })); +} + +#[test] +fn unsupported_daemon_transport_never_falls_back_to_local_sqlite() { + assert!(super::proxy_required_by_platform(false, false)); + assert!(super::proxy_required_by_platform(false, true)); + assert!(!super::proxy_required_by_platform(true, false)); +} diff --git a/src/daemon/tests/compatibility.rs b/src/daemon/tests/compatibility.rs new file mode 100644 index 000000000..d3063482d --- /dev/null +++ b/src/daemon/tests/compatibility.rs @@ -0,0 +1,190 @@ +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::super::{DaemonClientIdentity, DaemonHandshake}; + +fn client_identity() -> DaemonClientIdentity { + DaemonClientIdentity { + profile_root: PathBuf::from("/profiles/client"), + global_db_path: PathBuf::from("/profiles/client/global.db"), + } +} + +fn current_handshake() -> DaemonHandshake { + DaemonHandshake { + project_path: Some(PathBuf::from("/work/repo")), + scope_prefix: Some("src".to_string()), + timings: true, + allow_init: false, + allow_initialize_root_routing: true, + client_identity: client_identity(), + client_version: "2.0.0".to_string(), + client_instance_id: "client-instance".to_string(), + tool_list_changed_capable: true, + catalog_version: "2.0.0".to_string(), + } +} + +#[test] +fn old_handshake_missing_new_fields_uses_safe_defaults() { + let encoded = json!({ + "project_path": "/work/repo", + "scope_prefix": null, + "timings": false, + "allow_init": false, + "client_identity": { + "profile_root": "/profiles/client", + "global_db_path": "/profiles/client/global.db" + } + }) + .to_string(); + + let decoded = DaemonHandshake::from_line(&encoded).expect("legacy handshake should decode"); + + assert!(!decoded.allow_initialize_root_routing); + assert!(decoded.client_version.is_empty()); + assert!(decoded.client_instance_id.is_empty()); + assert!(!decoded.tool_list_changed_capable); + assert!(decoded.catalog_version.is_empty()); +} + +#[test] +fn new_handshake_deserializes_into_legacy_projection() { + #[derive(Deserialize)] + struct LegacyHandshake { + project_path: Option, + scope_prefix: Option, + timings: bool, + allow_init: bool, + client_identity: DaemonClientIdentity, + } + + let current = current_handshake(); + let legacy: LegacyHandshake = + serde_json::from_str(¤t.to_line().expect("current handshake should encode")) + .expect("legacy projection should ignore new fields"); + + assert_eq!(legacy.project_path, current.project_path); + assert_eq!(legacy.scope_prefix, current.scope_prefix); + assert_eq!(legacy.timings, current.timings); + assert_eq!(legacy.allow_init, current.allow_init); + assert_eq!(legacy.client_identity, current.client_identity); +} + +#[test] +fn new_initialize_response_deserializes_into_legacy_projection() { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct LegacyServerInfo { + name: String, + version: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct LegacyInitializeResult { + protocol_version: String, + server_info: LegacyServerInfo, + capabilities: Value, + } + + #[derive(Deserialize)] + struct LegacyInitializeResponse { + jsonrpc: String, + id: Value, + result: LegacyInitializeResult, + } + + let response = json!({ + "jsonrpc": "2.0", + "id": 7, + "result": { + "protocolVersion": "2024-11-05", + "serverInfo": {"name": "tracedecay", "version": "2.0.0"}, + "capabilities": {"tools": {"listChanged": true}}, + "_meta": {"tracedecayInitializeRoute": {"projectPath": "/work/repo"}} + } + }); + + let legacy: LegacyInitializeResponse = + serde_json::from_value(response).expect("legacy client should ignore new response fields"); + + assert_eq!(legacy.jsonrpc, "2.0"); + assert_eq!(legacy.id, json!(7)); + assert_eq!(legacy.result.protocol_version, "2024-11-05"); + assert_eq!(legacy.result.server_info.name, "tracedecay"); + assert_eq!(legacy.result.server_info.version, "2.0.0"); + assert!(legacy.result.capabilities.get("tools").is_some()); +} + +#[cfg(unix)] +#[tokio::test(start_paused = true)] +async fn restart_grace_is_bounded_when_daemon_never_rebinds() { + let dir = tempfile::tempdir().expect("temp dir"); + let socket = dir.path().join("daemon.sock"); + let grace = std::time::Duration::from_millis(150); + let poll = std::time::Duration::from_millis(25); + let started = tokio::time::Instant::now(); + + super::super::connect_with_restart_grace( + &super::super::connection_for_socket_path(&socket), + grace, + poll, + ) + .await + .expect_err("missing daemon must stop retrying"); + + let elapsed = started.elapsed(); + assert!(elapsed >= grace); + assert!(elapsed <= grace + poll); +} + +#[cfg(unix)] +#[test] +fn version_skew_guidance_covers_both_upgrade_directions() { + let stale_client = super::super::version_skew_action("2.0.0", "1.0.0"); + assert!(stale_client.contains("MCP host")); + + let stale_daemon = super::super::version_skew_action("1.0.0", "2.0.0"); + assert!(stale_daemon.contains("tracedecay daemon restart")); +} + +#[cfg(unix)] +#[tokio::test] +async fn unauthenticated_legacy_handshake_is_rejected_before_routing() { + use tokio::io::AsyncWriteExt; + + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + let (listener, endpoint) = super::super::transport::BrokerListener::bind( + &super::super::transport::default_loopback_endpoint(), + ) + .await + .expect("bind broker"); + let server = tokio::spawn(async move { + let stream = listener.accept().await.expect("accept legacy client"); + super::super::serve_authenticated_socket_client( + stream, + super::super::DaemonEngine::default(), + TOKEN.to_string(), + ) + .await + }); + + let mut client = super::super::transport::BrokerStream::connect(&endpoint) + .await + .expect("connect legacy client"); + client + .write_all(current_handshake().to_line().expect("handshake").as_bytes()) + .await + .expect("write unauthenticated handshake"); + client.write_all(b"\n").await.expect("write newline"); + client.shutdown().await.expect("shutdown legacy client"); + + let error = server + .await + .expect("server task") + .expect_err("unauthenticated legacy client must fail closed"); + assert!(error.to_string().contains("authentication failed")); +} diff --git a/src/daemon/transport.rs b/src/daemon/transport.rs new file mode 100644 index 000000000..3e16c4ff5 --- /dev/null +++ b/src/daemon/transport.rs @@ -0,0 +1,316 @@ +use std::fmt; +#[cfg(any(not(unix), test))] +use std::net::IpAddr; +use std::net::SocketAddr; +#[cfg(unix)] +use std::path::PathBuf; +use std::pin::Pin; +use std::str::FromStr; +use std::task::{Context, Poll}; + +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +use crate::errors::{Result, TraceDecayError}; + +pub const AUTH_PREFACE_PROTOCOL: &str = "tracedecay-daemon-v1"; + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", content = "address", rename_all = "snake_case")] +pub enum DaemonEndpoint { + #[cfg(unix)] + Unix(PathBuf), + Loopback(SocketAddr), +} + +impl DaemonEndpoint { + pub fn loopback(address: SocketAddr) -> Result { + if !address.ip().is_loopback() { + return Err(config_error(format!( + "daemon TCP endpoint must be loopback, got {address}" + ))); + } + Ok(Self::Loopback(address)) + } + + #[cfg(test)] + pub fn parse(value: &str) -> Result { + value.parse() + } +} + +impl FromStr for DaemonEndpoint { + type Err = TraceDecayError; + + fn from_str(value: &str) -> Result { + if let Some(address) = value + .strip_prefix("tcp://") + .or_else(|| value.strip_prefix("loopback://")) + { + let address = address + .parse::() + .map_err(|error| config_error(format!("invalid daemon endpoint: {error}")))?; + return Self::loopback(address); + } + #[cfg(unix)] + { + let path = value.strip_prefix("unix://").unwrap_or(value); + if path.is_empty() { + return Err(config_error("daemon Unix endpoint path is empty")); + } + return Ok(Self::Unix(PathBuf::from(path))); + } + #[cfg(not(unix))] + Err(config_error( + "daemon endpoint must use tcp://127.0.0.1:PORT on this platform", + )) + } +} + +impl fmt::Display for DaemonEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(unix)] + Self::Unix(path) => write!(f, "unix://{}", path.display()), + Self::Loopback(address) => write!(f, "tcp://{address}"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DaemonAuthPreface { + protocol: String, + auth_token: String, +} + +impl DaemonAuthPreface { + pub fn new(auth_token: impl Into) -> Self { + Self { + protocol: AUTH_PREFACE_PROTOCOL.to_string(), + auth_token: auth_token.into(), + } + } + + pub fn to_line(&self) -> Result { + Ok(serde_json::to_string(self)?) + } + + pub fn from_line(line: &str) -> Result { + let preface: Self = serde_json::from_str(line)?; + if preface.protocol != AUTH_PREFACE_PROTOCOL { + return Err(config_error("unsupported daemon transport protocol")); + } + Ok(preface) + } + + pub fn authenticate(&self, expected_token: &str) -> bool { + let supplied = self.auth_token.as_bytes(); + let expected = expected_token.as_bytes(); + if supplied.len() != expected.len() { + return false; + } + supplied + .iter() + .zip(expected) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 + } +} + +#[derive(Debug)] +pub enum BrokerStream { + #[cfg(unix)] + Unix(tokio::net::UnixStream), + Tcp(tokio::net::TcpStream), +} + +impl BrokerStream { + pub async fn connect(endpoint: &DaemonEndpoint) -> Result { + match endpoint { + #[cfg(unix)] + DaemonEndpoint::Unix(path) => { + Ok(Self::Unix(tokio::net::UnixStream::connect(path).await?)) + } + DaemonEndpoint::Loopback(address) => { + if !address.ip().is_loopback() { + return Err(config_error("refusing non-loopback daemon endpoint")); + } + Ok(Self::Tcp(tokio::net::TcpStream::connect(address).await?)) + } + } + } + + pub fn into_split(self) -> (tokio::io::ReadHalf, tokio::io::WriteHalf) { + tokio::io::split(self) + } +} + +impl AsyncRead for BrokerStream { + fn poll_read( + self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_read(context, buffer), + Self::Tcp(stream) => Pin::new(stream).poll_read(context, buffer), + } + } +} + +impl AsyncWrite for BrokerStream { + fn poll_write( + self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + match self.get_mut() { + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_write(context, buffer), + Self::Tcp(stream) => Pin::new(stream).poll_write(context, buffer), + } + } + + fn poll_flush(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match self.get_mut() { + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_flush(context), + Self::Tcp(stream) => Pin::new(stream).poll_flush(context), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + match self.get_mut() { + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_shutdown(context), + Self::Tcp(stream) => Pin::new(stream).poll_shutdown(context), + } + } +} + +pub enum BrokerListener { + #[cfg(unix)] + Unix(tokio::net::UnixListener), + Tcp(tokio::net::TcpListener), +} + +impl BrokerListener { + pub async fn bind(endpoint: &DaemonEndpoint) -> Result<(Self, DaemonEndpoint)> { + match endpoint { + #[cfg(unix)] + DaemonEndpoint::Unix(path) => { + let listener = tokio::net::UnixListener::bind(path)?; + Ok((Self::Unix(listener), endpoint.clone())) + } + DaemonEndpoint::Loopback(address) => { + if !address.ip().is_loopback() { + return Err(config_error("refusing non-loopback daemon listener")); + } + let listener = tokio::net::TcpListener::bind(address).await?; + let endpoint = DaemonEndpoint::loopback(listener.local_addr()?)?; + Ok((Self::Tcp(listener), endpoint)) + } + } + } + + pub async fn accept(&self) -> Result { + match self { + #[cfg(unix)] + Self::Unix(listener) => Ok(BrokerStream::Unix(listener.accept().await?.0)), + Self::Tcp(listener) => Ok(BrokerStream::Tcp(listener.accept().await?.0)), + } + } +} + +#[cfg(any(not(unix), test))] +pub fn default_loopback_endpoint() -> DaemonEndpoint { + DaemonEndpoint::Loopback(SocketAddr::new( + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + 0, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + #[test] + fn loopback_endpoint_round_trips_and_rejects_remote_addresses() { + let endpoint = DaemonEndpoint::parse("tcp://127.0.0.1:43123").unwrap(); + assert_eq!(endpoint.to_string(), "tcp://127.0.0.1:43123"); + assert!(DaemonEndpoint::parse("tcp://192.0.2.1:43123").is_err()); + } + + #[test] + fn auth_preface_validates_protocol_and_token() { + let preface = DaemonAuthPreface::new("0123456789abcdef"); + let decoded = DaemonAuthPreface::from_line(&preface.to_line().unwrap()).unwrap(); + assert!(decoded.authenticate("0123456789abcdef")); + assert!(!decoded.authenticate("0123456789abcdee")); + assert!(!decoded.authenticate("short")); + } + + #[tokio::test] + async fn loopback_listener_connects_and_accepts() { + let (listener, endpoint) = BrokerListener::bind(&default_loopback_endpoint()) + .await + .unwrap(); + let client = BrokerStream::connect(&endpoint); + let server = listener.accept(); + let (client, server) = tokio::join!(client, server); + assert!(client.is_ok()); + assert!(server.is_ok()); + } + + #[tokio::test] + async fn loopback_listener_authenticates_twelve_concurrent_clients() { + const CLIENTS: usize = 12; + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + + let (listener, endpoint) = BrokerListener::bind(&default_loopback_endpoint()) + .await + .unwrap(); + let server = tokio::spawn(async move { + let mut clients = tokio::task::JoinSet::new(); + for _ in 0..CLIENTS { + let stream = listener.accept().await.unwrap(); + clients.spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let preface = DaemonAuthPreface::from_line(line.trim()).unwrap(); + assert!(preface.authenticate(TOKEN)); + }); + } + while let Some(client) = clients.join_next().await { + client.unwrap(); + } + }); + + let mut clients = tokio::task::JoinSet::new(); + for _ in 0..CLIENTS { + let endpoint = endpoint.clone(); + clients.spawn(async move { + let mut stream = BrokerStream::connect(&endpoint).await.unwrap(); + let line = DaemonAuthPreface::new(TOKEN).to_line().unwrap(); + stream.write_all(line.as_bytes()).await.unwrap(); + stream.write_all(b"\n").await.unwrap(); + stream.shutdown().await.unwrap(); + }); + } + while let Some(client) = clients.join_next().await { + client.unwrap(); + } + server.await.unwrap(); + } +} diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index f6b0439ac..46be46e8c 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -126,15 +126,19 @@ impl Default for MemoryCurateOptions { /// Minimal dashboard state over the project memory store — no LCM store, /// savings DB, or token-count cache warmup (those belong to the server). async fn cli_state(cg: &TraceDecay) -> DashboardState { - let (mem_conn, mem_db_path) = super::resolve_project_memory_store(cg).await; + let (mem_conn, mem_db_path, mem_guard) = super::resolve_project_memory_store(cg).await; let store_layout = cg.store_layout(); DashboardState { project_id: store_layout.identity.project_id.clone(), graph_conn: cg.dashboard_connection(), + _database_guards: std::iter::once(cg.dashboard_database_guard()) + .chain(mem_guard) + .collect(), graph_db_path: cg.dashboard_db_path().display().to_string(), mem_conn, mem_db_path, lcm_conn: None, + _global_database_guards: Vec::new(), lcm_db_path: String::new(), lcm_scope: storage_mode_label(&store_layout.storage_mode).to_string(), savings_db: None, @@ -165,10 +169,12 @@ fn user_state( DashboardState { project_id: None, graph_conn: conn.clone(), + _database_guards: vec![Arc::new(memory_db.clone())], graph_db_path: memory_db_path.display().to_string(), mem_conn: conn, mem_db_path: memory_db_path.display().to_string(), lcm_conn: None, + _global_database_guards: Vec::new(), lcm_db_path: String::new(), lcm_scope: "user".to_string(), savings_db: None, @@ -865,7 +871,12 @@ mod tests { async fn preview_is_read_only_while_apply_repairs_derived_memory() { let temp = tempfile::tempdir().unwrap(); let memory_path = temp.path().join("user-memory.db"); - let (db, _) = Database::initialize(&memory_path).await.unwrap(); + let authority = + crate::db::DatabaseAuthority::acquire_test(&memory_path, "memory curation test") + .unwrap(); + let (db, _) = Database::initialize(&memory_path, &authority) + .await + .unwrap(); db.conn() .execute( "INSERT INTO memory_facts diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 6af0a2863..cacfe95a6 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -84,6 +84,9 @@ pub(crate) struct DashboardState { pub(crate) project_id: Option, /// Active code-graph database. This can be branch-specific. pub(crate) graph_conn: libsql::Connection, + /// Keeps every project-database authority alive as long as cloned raw + /// connections remain reachable through this state. + pub(crate) _database_guards: Vec>, /// Display path of the active code-graph database. pub(crate) graph_db_path: String, /// Project memory database. This is shared across branches. @@ -93,6 +96,8 @@ pub(crate) struct DashboardState { /// LCM session store for the resolved active project store, or the global /// fallback when no project store is available. pub(crate) lcm_conn: Option, + /// Keeps session-store authorities alive alongside `lcm_conn`. + pub(crate) _global_database_guards: Vec>, /// Display path of the LCM session store actually being served. pub(crate) lcm_db_path: String, /// Which store `lcm_conn` points at, e.g. `"profile_sharded"` or `"global"`. @@ -136,6 +141,7 @@ impl DashboardState { /// The LCM session store the dashboard will serve. pub(crate) struct LcmStoreSelection { pub(crate) conn: Option, + pub(crate) guard: Option>, pub(crate) path: String, pub(crate) scope: String, } @@ -155,16 +161,20 @@ pub(crate) async fn resolve_lcm_store(cg: &TraceDecay) -> LcmStoreSelection { crate::sessions::cursor::resolved_project_session_db_path(project_root).await { if let Some(db) = GlobalDb::open_at(&project_db_path).await { + let conn = db.dashboard_connection(); return LcmStoreSelection { - conn: Some(db.dashboard_connection()), + conn: Some(conn), + guard: Some(Arc::new(db)), path: project_db_path.display().to_string(), scope: storage_mode_label(&cg.store_layout().storage_mode).to_string(), }; } } let global = GlobalDb::open().await; + let conn = global.as_ref().map(GlobalDb::dashboard_connection); LcmStoreSelection { - conn: global.as_ref().map(GlobalDb::dashboard_connection), + conn, + guard: global.map(Arc::new), path: crate::global_db::global_db_path() .map(|p| p.display().to_string()) .unwrap_or_default(), @@ -188,9 +198,11 @@ pub(crate) fn code_diagnostics_broker( lsp::broker::DiagnosticBroker::new(project_root, adapters, settings) } -async fn open_dashboard_connection(path: &Path) -> Option { - let (db, _) = Database::open(path).await.ok()?; - Some(db.conn().clone()) +async fn open_dashboard_connection(path: &Path) -> Option<(libsql::Connection, Arc)> { + let authority = crate::db::DatabaseAuthority::for_runtime(path, "dashboard").ok()?; + let (db, _) = Database::open(path, &authority).await.ok()?; + let conn = db.conn().clone(); + Some((conn, Arc::new(db))) } async fn memory_fact_count(conn: &libsql::Connection) -> Option { @@ -201,29 +213,33 @@ async fn memory_fact_count(conn: &libsql::Connection) -> Option { rows.next().await.ok()??.get::(0).ok() } -pub(crate) async fn resolve_project_memory_store(cg: &TraceDecay) -> (libsql::Connection, String) { +pub(crate) async fn resolve_project_memory_store( + cg: &TraceDecay, +) -> (libsql::Connection, String, Option>) { let graph_path = cg.dashboard_db_path(); - let mut first_open: Option<(libsql::Connection, String)> = None; + let mut first_open: Option<(libsql::Connection, String, Option>)> = None; let mut seen = std::collections::BTreeSet::new(); for path in [cg.store_layout().graph_db_path.clone()] { if !seen.insert(path.clone()) || !path.is_file() { continue; } - let conn = if path == graph_path { - Some(cg.dashboard_connection()) + let opened = if path == graph_path { + Some((cg.dashboard_connection(), None)) } else { - open_dashboard_connection(&path).await + open_dashboard_connection(&path) + .await + .map(|(conn, guard)| (conn, Some(guard))) }; - let Some(conn) = conn else { + let Some((conn, guard)) = opened else { continue; }; let display_path = path.display().to_string(); if first_open.is_none() { - first_open = Some((conn.clone(), display_path.clone())); + first_open = Some((conn.clone(), display_path.clone(), guard.clone())); } if memory_fact_count(&conn).await.unwrap_or(0) > 0 { - return (conn, display_path); + return (conn, display_path, guard); } } @@ -231,6 +247,7 @@ pub(crate) async fn resolve_project_memory_store(cg: &TraceDecay) -> (libsql::Co ( cg.dashboard_connection(), cg.dashboard_db_path().display().to_string(), + None, ) }) } @@ -241,7 +258,7 @@ async fn build_state_inner( warm_token_counts: bool, automation_scheduler_reconciler: Option, ) -> DashboardState { - let (mem_conn, mem_db_path) = resolve_project_memory_store(cg).await; + let (mem_conn, mem_db_path, mem_guard) = resolve_project_memory_store(cg).await; let lcm = resolve_lcm_store(cg).await; let dashboard_root = cg.store_layout().dashboard_root.clone(); let store_root = cg.store_layout().data_root.clone(); @@ -259,10 +276,14 @@ async fn build_state_inner( let state = DashboardState { project_id: cg.store_layout().identity.project_id.clone(), graph_conn: cg.dashboard_connection(), + _database_guards: std::iter::once(cg.dashboard_database_guard()) + .chain(mem_guard) + .collect(), graph_db_path: cg.dashboard_db_path().display().to_string(), mem_conn, mem_db_path, lcm_conn: lcm.conn, + _global_database_guards: lcm.guard.into_iter().collect(), lcm_db_path: lcm.path, lcm_scope: lcm.scope, savings_db, diff --git a/src/db/access.rs b/src/db/access.rs new file mode 100644 index 000000000..e3ce15dc0 --- /dev/null +++ b/src/db/access.rs @@ -0,0 +1,772 @@ +use std::collections::HashMap; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, LazyLock, Mutex}; + +use crate::errors::{Result, TraceDecayError}; + +mod bootstrap; +mod lease; +mod owner_io; +mod path_layout; + +use bootstrap::{BootstrapAuthority, acquire_bootstrap_authority, reject_hard_linked_database}; +pub use lease::enter_maintenance_database_scope; +use lease::{acquire_process_lease, exact_scoped_runtime_role, scoped_runtime_role}; +pub(crate) use lease::{enter_daemon_database_scope, probe_writer_owner}; +use owner_io::{ + authority_token, epoch_ms, is_lock_contended, open_lock_file, publish_record_atomically, + read_owner, write_owner, writer_owner, +}; +use path_layout::{ + bootstrap_database_key, canonical_profile_root, database_lock_root, + is_legacy_repository_database, platform_identity_key, stable_path_hash, +}; + +static PROCESS_LEASES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static DAEMON_SCOPES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static MAINTENANCE_SCOPES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static AUTHORITY_NONCE: AtomicU64 = AtomicU64::new(0); +static PROCESS_STARTED_EPOCH_MS: LazyLock = LazyLock::new(epoch_ms); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DatabaseAuthorityRole { + Daemon, + Maintenance, + #[doc(hidden)] + Test, +} + +#[derive(Clone, Debug)] +pub struct DatabaseAuthority { + inner: Arc, +} + +#[derive(Debug)] +pub(crate) struct DaemonDatabaseScope { + profile_root: PathBuf, + token: String, +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct MaintenanceDatabaseScope<'lease> { + profile_root: PathBuf, + token: String, + _lifecycle: std::marker::PhantomData<&'lease crate::lifecycle_lease::LifecycleLease>, +} + +#[derive(Debug)] +struct DaemonScopeState { + token: String, + refs: usize, +} + +#[derive(Debug)] +struct MaintenanceScopeState { + token: String, + refs: usize, +} + +#[derive(Debug)] +struct AuthorityInner { + identity: DatabaseIdentity, + role: DatabaseAuthorityRole, + token: String, + _bootstrap: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DatabaseIdentity { + database_path: PathBuf, + database_key: PathBuf, + profile_root: PathBuf, + allows_ambient_profile_scope: bool, + access_lock_path: PathBuf, + writer_lock_path: PathBuf, + writer_owner_path: PathBuf, + bootstrap_lock_path: Option, +} + +#[derive(Debug)] +struct ProcessLease { + token: String, + refs: usize, + held: HeldLocks, +} + +#[derive(Debug)] +enum HeldLocks { + Daemon { + access: File, + writer: File, + owner: WriterOwner, + }, + Maintenance { + access: File, + writer: File, + owner: WriterOwner, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WriterOwner { + pub(crate) token: String, + pub(crate) pid: u32, + pub(crate) started_epoch_ms: u128, + pub(crate) version: String, + pub(crate) intent: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum WriterOwnership { + Idle, + Active(WriterOwner), + ActiveUnknown, +} + +impl DatabaseAuthority { + #[cfg(test)] + pub(crate) fn acquire_daemon(db_path: &Path, intent: &str) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + if !DAEMON_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&identity.profile_root) + { + return Err(access_error( + intent, + db_path, + "database access is restricted to the elected managed daemon", + )); + } + Self::acquire_identity(identity, DatabaseAuthorityRole::Daemon, intent) + } + + #[cfg(test)] + pub(crate) fn acquire_maintenance(db_path: &Path, intent: &str) -> Result { + Self::acquire(db_path, DatabaseAuthorityRole::Maintenance, intent) + } + + #[doc(hidden)] + pub fn for_runtime(db_path: &Path, intent: &str) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + if cfg!(debug_assertions) && is_isolated_test_path(&identity.database_path) { + let existing_role = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&identity.database_key) + .map(|lease| match &lease.held { + HeldLocks::Maintenance { .. } => DatabaseAuthorityRole::Maintenance, + HeldLocks::Daemon { .. } => DatabaseAuthorityRole::Test, + }); + if let Some(role) = existing_role { + return Self::acquire_identity(identity, role, intent); + } + } + if let Some(role) = exact_scoped_runtime_role(&identity.profile_root, intent)? { + return Self::acquire_identity(identity, role, intent); + } + let maintenance_active = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&identity.database_key) + .is_some_and(|lease| matches!(&lease.held, HeldLocks::Maintenance { .. })); + if maintenance_active { + return Self::acquire_identity(identity, DatabaseAuthorityRole::Maintenance, intent); + } + if cfg!(debug_assertions) && is_isolated_test_path(&identity.database_path) { + return Self::acquire_identity(identity, DatabaseAuthorityRole::Test, intent); + } + if let Some(role) = scoped_runtime_role(&identity, intent)? { + return Self::acquire_identity(identity, role, intent); + } + Err(access_error( + intent, + &identity.database_path, + "database access requires managed-daemon or exclusive-maintenance authority", + )) + } + + /// Test escape hatch for integration fixtures. Production paths are + /// rejected even when a caller can reach this hidden API. + #[doc(hidden)] + pub fn acquire_test(db_path: &Path, intent: &str) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + if !is_isolated_test_path(&identity.database_path) { + return Err(access_error( + "test authority", + &identity.database_path, + "test database must be inside the system temporary directory", + )); + } + Self::acquire_identity(identity, DatabaseAuthorityRole::Test, intent) + } + + pub fn role(&self) -> DatabaseAuthorityRole { + self.inner.role + } + + pub fn token(&self) -> &str { + &self.inner.token + } + + pub(crate) fn publish_record_atomically( + temporary: &Path, + destination: &Path, + payload: &[u8], + record_name: &str, + ) -> Result<()> { + publish_record_atomically(temporary, destination, payload, record_name) + } + + pub(crate) fn replace_file_atomically( + temporary: &Path, + destination: &Path, + record_name: &str, + ) -> Result<()> { + owner_io::replace_file_atomically(temporary, destination, record_name) + } + + #[cfg(test)] + fn acquire(db_path: &Path, role: DatabaseAuthorityRole, intent: &str) -> Result { + Self::acquire_identity(DatabaseIdentity::for_path(db_path)?, role, intent) + } + + fn acquire_identity( + mut identity: DatabaseIdentity, + role: DatabaseAuthorityRole, + intent: &str, + ) -> Result { + let bootstrap = acquire_bootstrap_authority(&identity, intent)?; + if bootstrap.is_some() { + identity = DatabaseIdentity::for_path(&identity.database_path)?; + } + let token = acquire_process_lease(&identity, role, intent)?; + Ok(Self { + inner: Arc::new(AuthorityInner { + identity, + role, + token, + _bootstrap: bootstrap, + }), + }) + } + + pub(crate) fn hold_for(&self, db_path: &Path, operation: &str) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + if identity.database_key != self.inner.identity.database_key { + return Err(access_error( + operation, + &identity.database_path, + "database authority belongs to a different database", + )); + } + Ok(self.clone()) + } + + pub(crate) fn canonical_database_path(&self) -> &Path { + &self.inner.identity.database_path + } +} + +impl DatabaseIdentity { + fn for_path(db_path: &Path) -> Result { + let absolute = if db_path.is_absolute() { + db_path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| access_io_error("resolve", db_path, &error))? + .join(db_path) + }; + let file_name = absolute + .file_name() + .ok_or_else(|| access_error("resolve", db_path, "database path has no file name"))?; + let parent = absolute.parent().ok_or_else(|| { + access_error("resolve", db_path, "database path has no parent directory") + })?; + std::fs::create_dir_all(parent) + .map_err(|error| access_io_error("create lock directory", parent, &error))?; + + let entry = match std::fs::symlink_metadata(&absolute) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(access_io_error("inspect", &absolute, &error)), + }; + let database_path = match entry.as_ref() { + Some(metadata) if metadata.file_type().is_symlink() => absolute + .canonicalize() + .map_err(|_| access_error("resolve", &absolute, "database symlink is dangling"))?, + Some(_) => absolute + .canonicalize() + .map_err(|error| access_io_error("resolve", &absolute, &error))?, + None => parent + .canonicalize() + .map_err(|error| access_io_error("resolve parent", parent, &error))? + .join(file_name), + }; + if entry.is_some() { + reject_hard_linked_database(&database_path)?; + } + let database_key = platform_identity_key(&database_path); + let lock_root = database_lock_root(&database_path, parent); + std::fs::create_dir_all(&lock_root).map_err(|error| { + access_io_error("create database lock directory", &lock_root, &error) + })?; + let lock_id = stable_path_hash(&database_key); + let bootstrap_lock_path = if entry.is_none() { + bootstrap_database_key( + database_path.parent().unwrap_or(parent), + database_path.file_name().unwrap_or(file_name), + ) + .map(|key| lock_root.join(format!("{:016x}.bootstrap.lock", stable_path_hash(&key)))) + } else { + None + }; + let profile_root = lock_root + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| parent.to_path_buf()); + Ok(Self { + allows_ambient_profile_scope: is_legacy_repository_database(&database_path), + database_path, + database_key, + profile_root: platform_identity_key(&profile_root), + access_lock_path: lock_root.join(format!("{lock_id:016x}.access.lock")), + writer_lock_path: lock_root.join(format!("{lock_id:016x}.writer.lock")), + writer_owner_path: lock_root.join(format!("{lock_id:016x}.writer.owner")), + bootstrap_lock_path, + }) + } +} + +fn access_error(operation: &str, path: &Path, message: &str) -> TraceDecayError { + TraceDecayError::Database { + message: format!("{message} at '{}'", path.display()), + operation: operation.to_string(), + } +} + +fn access_io_error(operation: &str, path: &Path, error: &std::io::Error) -> TraceDecayError { + access_error(operation, path, &error.to_string()) +} + +fn is_isolated_test_path(path: &Path) -> bool { + let root = std::env::temp_dir(); + if path.starts_with(root.canonicalize().unwrap_or(root)) { + return true; + } + cfg!(debug_assertions) + && std::env::var_os("TRACEDECAY_DATA_DIR") + .filter(|root| !root.is_empty()) + .map(PathBuf::from) + .is_some_and(|root| { + let root = if root.is_absolute() { + root + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(root) + }; + path.starts_with(root.canonicalize().unwrap_or(root)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + static SCOPE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn canonical_identity_collapses_parent_aliases() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("nested")).unwrap(); + let direct = DatabaseIdentity::for_path(&temp.path().join("graph.db")).unwrap(); + let aliased = DatabaseIdentity::for_path(&temp.path().join("nested/../graph.db")).unwrap(); + assert_eq!(direct, aliased); + } + + #[test] + fn identity_key_preserves_unproven_case_variants() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + + assert_ne!(platform_identity_key(&upper), platform_identity_key(&lower)); + } + + #[cfg(target_os = "linux")] + #[test] + fn case_distinct_database_files_have_distinct_identities() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + std::fs::write(&upper, []).unwrap(); + std::fs::write(&lower, []).unwrap(); + + let upper = DatabaseIdentity::for_path(&upper).unwrap(); + let lower = DatabaseIdentity::for_path(&lower).unwrap(); + + assert_ne!(upper.database_key, lower.database_key); + assert_ne!(upper.writer_lock_path, lower.writer_lock_path); + + let upper_authority = DatabaseAuthority::acquire_test( + &temp.path().join("MixedCase.DB"), + "upper case-sensitive database", + ) + .unwrap(); + let lower_authority = DatabaseAuthority::acquire_test( + &temp.path().join("mixedcase.db"), + "lower case-sensitive database", + ) + .unwrap(); + assert_ne!(upper_authority.token(), lower_authority.token()); + } + + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn fresh_case_variants_cannot_hold_concurrent_first_create_authorities() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + + let first = DatabaseAuthority::acquire_test(&upper, "first case variant").unwrap(); + let error = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap_err(); + assert!(error.to_string().contains("case-variant first-create")); + + std::fs::write(&upper, []).unwrap(); + drop(first); + let second = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap(); + if lower.exists() { + assert_eq!( + second.canonical_database_path(), + upper.canonicalize().unwrap() + ); + } else { + std::fs::write(&lower, []).unwrap(); + let upper_identity = DatabaseIdentity::for_path(&upper).unwrap(); + let lower_identity = DatabaseIdentity::for_path(&lower).unwrap(); + assert_ne!(upper_identity.database_key, lower_identity.database_key); + } + } + + #[cfg(unix)] + #[test] + fn symlink_aliases_share_one_database_identity() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("database.db"); + let alias = temp.path().join("database-alias.db"); + std::fs::write(&database, []).unwrap(); + std::os::unix::fs::symlink(&database, &alias).unwrap(); + + let database = DatabaseIdentity::for_path(&database).unwrap(); + let alias = DatabaseIdentity::for_path(&alias).unwrap(); + + assert_eq!(database.database_key, alias.database_key); + assert_eq!(database.writer_lock_path, alias.writer_lock_path); + } + + #[test] + fn profile_databases_share_one_exact_profile_scope() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&profile).unwrap(); + let expected_profile = platform_identity_key(&profile.canonicalize().unwrap()); + let paths = [ + profile.join("global.db"), + profile.join("user-memory.db"), + profile.join("user-sessions.db"), + profile.join("projects/project/tracedecay.db"), + profile.join("projects/project/sessions.db"), + profile.join("projects/project/branches/feature.db"), + ]; + + for path in paths { + let identity = DatabaseIdentity::for_path(&path).unwrap(); + assert_eq!( + identity.profile_root, + expected_profile, + "{}", + path.display() + ); + assert!( + !identity.allows_ambient_profile_scope, + "{} must require its exact profile authority", + path.display() + ); + assert_eq!( + identity.access_lock_path.parent(), + Some(profile.join(".tracedecay-database-locks").as_path()) + ); + } + } + + #[test] + fn projects_directory_in_repository_path_is_not_a_profile_shard() { + let temp = tempfile::tempdir().unwrap(); + let data_root = temp.path().join("projects/repository/.tracedecay"); + let path = data_root.join("tracedecay.db"); + let identity = DatabaseIdentity::for_path(&path).unwrap(); + + assert_eq!( + identity.profile_root, + platform_identity_key(&data_root.canonicalize().unwrap()) + ); + assert!(identity.allows_ambient_profile_scope); + } + + #[test] + fn fs2_contention_is_classified_as_an_active_lease() { + let temp = tempfile::tempdir().unwrap(); + let lock_path = temp.path().join("authority.lock"); + let first = open_lock_file(&lock_path).unwrap(); + let second = open_lock_file(&lock_path).unwrap(); + fs2::FileExt::try_lock_exclusive(&first).unwrap(); + + let error = fs2::FileExt::try_lock_exclusive(&second).unwrap_err(); + + assert!(is_lock_contended(&error), "unexpected lock error: {error}"); + fs2::FileExt::unlock(&first).unwrap(); + } + + #[test] + fn writer_owner_replacement_is_complete_and_leaves_no_temporary_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("writer.owner"); + let first = writer_owner("first", "first owner"); + let second = writer_owner("second", "replacement owner"); + write_owner(&path, &first).unwrap(); + + write_owner(&path, &second).unwrap(); + + assert_eq!(read_owner(&path), Some(second)); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn atomic_record_publication_preserves_a_colliding_temporary_file() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("authority.record"); + let temporary = temp.path().join("authority.record.tmp"); + std::fs::write(&temporary, b"other publisher").unwrap(); + + let error = DatabaseAuthority::publish_record_atomically( + &temporary, + &destination, + b"replacement", + "test authority record", + ) + .unwrap_err(); + + assert!(error.to_string().contains("create test authority record")); + assert_eq!(std::fs::read(&temporary).unwrap(), b"other publisher"); + assert!(!destination.exists()); + } + + #[test] + fn daemon_authority_is_same_process_reentrant() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let first = DatabaseAuthority::acquire_test(&path, "first").unwrap(); + let second = DatabaseAuthority::acquire_test(&path, "second").unwrap(); + assert_eq!(first.token(), second.token()); + assert_eq!( + probe_writer_owner(&path).unwrap(), + WriterOwnership::Active( + read_owner(&DatabaseIdentity::for_path(&path).unwrap().writer_owner_path).unwrap() + ) + ); + drop(first); + assert!(matches!( + probe_writer_owner(&path).unwrap(), + WriterOwnership::Active(_) + )); + drop(second); + assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); + } + + #[test] + fn maintenance_and_daemon_authorities_are_mutually_exclusive() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let daemon = DatabaseAuthority::acquire_test(&path, "daemon").unwrap(); + let error = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap_err(); + assert!( + error + .to_string() + .contains("incompatible database authority") + ); + drop(daemon); + + let maintenance = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap(); + let error = DatabaseAuthority::acquire_test(&path, "daemon").unwrap_err(); + assert!( + error + .to_string() + .contains("incompatible database authority") + ); + drop(maintenance); + } + + #[test] + fn stale_owner_metadata_never_establishes_ownership() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&path).unwrap(); + std::fs::write( + &identity.writer_owner_path, + "token=stale\tpid=1\tstarted_epoch_ms=1\tversion=old\tintent=old\n", + ) + .unwrap(); + assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); + assert!(identity.writer_owner_path.exists()); + } + + #[test] + fn authority_is_bound_to_one_canonical_database() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.db"); + let second = temp.path().join("second.db"); + let authority = DatabaseAuthority::acquire_test(&first, "test").unwrap(); + let error = authority.hold_for(&second, "open").unwrap_err(); + assert!(error.to_string().contains("different database")); + } + + #[test] + fn daemon_authority_inherits_live_election_scope() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let scope = enter_daemon_database_scope(temp.path(), 7, "election-token").unwrap(); + let authority = DatabaseAuthority::acquire_daemon(&path, "daemon").unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Daemon); + drop(authority); + drop(scope); + } + + #[test] + fn sole_daemon_scope_authorizes_only_legacy_repo_local_database() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let profile = tempfile::tempdir().unwrap(); + let repository = tempfile::tempdir().unwrap(); + let scope = enter_daemon_database_scope(profile.path(), 1, "daemon").unwrap(); + let identity = + DatabaseIdentity::for_path(&repository.path().join(".tracedecay/tracedecay.db")) + .unwrap(); + + assert!(identity.allows_ambient_profile_scope); + assert_eq!( + scoped_runtime_role(&identity, "legacy repository database").unwrap(), + Some(DatabaseAuthorityRole::Daemon) + ); + + drop(scope); + } + + #[test] + fn sole_daemon_scope_rejects_standard_databases_from_another_profile() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); + let paths = [ + second.path().join("global.db"), + second.path().join("user-memory.db"), + second.path().join("user-sessions.db"), + second.path().join("projects/project/tracedecay.db"), + second.path().join("projects/project/sessions.db"), + second.path().join("projects/project/branches/feature.db"), + ]; + + for path in paths { + let identity = DatabaseIdentity::for_path(&path).unwrap(); + assert_eq!( + exact_scoped_runtime_role(&identity.profile_root, "other profile").unwrap(), + None + ); + assert_eq!( + scoped_runtime_role(&identity, "other profile").unwrap(), + None, + "{} used an unrelated ambient profile scope", + path.display() + ); + } + + drop(scope); + } + + #[test] + fn maintenance_scope_requires_and_inherits_exclusive_profile_lease() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("projects/p1/tracedecay.db"); + let lifecycle = + crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "maintenance test") + .unwrap(); + let scope = + enter_maintenance_database_scope(&lifecycle, temp.path(), "maintenance test").unwrap(); + let authority = DatabaseAuthority::for_runtime(&path, "repair").unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); + drop(authority); + drop(scope); + drop(lifecycle); + } + + #[test] + fn daemon_scopes_are_isolated_by_profile() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); + let second_scope = enter_daemon_database_scope(second.path(), 1, "second").unwrap(); + + let first_authority = DatabaseAuthority::for_runtime( + &first.path().join("projects/one/tracedecay.db"), + "first profile", + ) + .unwrap(); + let second_authority = DatabaseAuthority::for_runtime( + &second.path().join("projects/two/tracedecay.db"), + "second profile", + ) + .unwrap(); + assert_eq!(first_authority.role(), DatabaseAuthorityRole::Daemon); + assert_eq!(second_authority.role(), DatabaseAuthorityRole::Daemon); + + drop((first_authority, second_authority, first_scope, second_scope)); + } + + #[test] + fn maintenance_scope_is_reentrant_across_nested_intents() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let lifecycle = + crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "outer").unwrap(); + let outer = enter_maintenance_database_scope(&lifecycle, temp.path(), "plan").unwrap(); + let inner = enter_maintenance_database_scope(&lifecycle, temp.path(), "apply").unwrap(); + let authority = DatabaseAuthority::for_runtime( + &temp.path().join("projects/p1/tracedecay.db"), + "nested operation", + ) + .unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); + + drop(authority); + drop(inner); + drop(outer); + drop(lifecycle); + } +} diff --git a/src/db/access/bootstrap.rs b/src/db/access/bootstrap.rs new file mode 100644 index 000000000..12871be4d --- /dev/null +++ b/src/db/access/bootstrap.rs @@ -0,0 +1,142 @@ +use super::*; + +#[derive(Debug)] +pub(super) struct BootstrapAuthority { + lock_path: PathBuf, +} + +struct BootstrapLease { + database_key: PathBuf, + refs: usize, + held: File, +} + +static BOOTSTRAP_LEASES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn reject_hard_linked_database(path: &Path) -> Result<()> { + let metadata = std::fs::metadata(path) + .map_err(|error| access_io_error("inspect database links", path, &error))?; + #[cfg(unix)] + let has_multiple_links = { + use std::os::unix::fs::MetadataExt; + metadata.is_file() && metadata.nlink() > 1 + }; + #[cfg(windows)] + let has_multiple_links = { + use std::os::windows::fs::MetadataExt; + metadata.is_file() && metadata.number_of_links().is_some_and(|links| links > 1) + }; + #[cfg(not(any(unix, windows)))] + let has_multiple_links = false; + if has_multiple_links { + return Err(access_error( + "resolve", + path, + "hard-linked SQLite databases are unsupported because their WAL/SHM sidecars differ", + )); + } + Ok(()) +} + +pub(super) fn acquire_bootstrap_authority( + identity: &DatabaseIdentity, + intent: &str, +) -> Result> { + let Some(lock_path) = identity.bootstrap_lock_path.as_ref() else { + return Ok(None); + }; + let mut leases = BOOTSTRAP_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = leases.get_mut(lock_path) { + if existing.database_key != identity.database_key { + return Err(access_error( + intent, + &identity.database_path, + "a case-variant first-create database authority is already active", + )); + } + existing.refs += 1; + return Ok(Some(BootstrapAuthority { + lock_path: lock_path.clone(), + })); + } + let held = open_lock_file(lock_path)?; + if let Err(error) = fs2::FileExt::try_lock_exclusive(&held) { + return if is_lock_contended(&error) { + Err(access_error( + intent, + &identity.database_path, + "a case-variant first-create database authority is already active", + )) + } else { + Err(access_io_error( + "acquire first-create database lease", + lock_path, + &error, + )) + }; + } + leases.insert( + lock_path.clone(), + BootstrapLease { + database_key: identity.database_key.clone(), + refs: 1, + held, + }, + ); + Ok(Some(BootstrapAuthority { + lock_path: lock_path.clone(), + })) +} + +impl Drop for BootstrapAuthority { + fn drop(&mut self) { + let mut leases = BOOTSTRAP_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let should_remove = leases.get_mut(&self.lock_path).is_some_and(|lease| { + lease.refs = lease.refs.saturating_sub(1); + lease.refs == 0 + }); + if should_remove { + if let Some(lease) = leases.remove(&self.lock_path) { + let _ = fs2::FileExt::unlock(&lease.held); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(any(unix, windows))] + #[test] + fn hard_linked_database_paths_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("database.db"); + let alias = temp.path().join("database-hard-link.db"); + std::fs::write(&database, []).unwrap(); + std::fs::hard_link(&database, &alias).unwrap(); + + for path in [&database, &alias] { + let error = DatabaseIdentity::for_path(path).unwrap_err(); + assert!(error.to_string().contains("hard-linked SQLite databases")); + } + } + + #[cfg(unix)] + #[test] + fn dangling_database_symlinks_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("missing.db"); + let alias = temp.path().join("database-alias.db"); + std::os::unix::fs::symlink(&target, &alias).unwrap(); + + let error = DatabaseIdentity::for_path(&alias).unwrap_err(); + assert!(error.to_string().contains("database symlink is dangling")); + assert!(!target.exists()); + } +} diff --git a/src/db/access/lease.rs b/src/db/access/lease.rs new file mode 100644 index 000000000..0b8e65e6e --- /dev/null +++ b/src/db/access/lease.rs @@ -0,0 +1,392 @@ +use super::*; + +pub(super) fn exact_scoped_runtime_role( + profile_root: &Path, + intent: &str, +) -> Result> { + let maintenance = MAINTENANCE_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let daemon = DAEMON_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match ( + maintenance.contains_key(profile_root), + daemon.contains_key(profile_root), + ) { + (true, true) => Err(access_error( + intent, + profile_root, + "daemon and maintenance database scopes overlap", + )), + (true, false) => Ok(Some(DatabaseAuthorityRole::Maintenance)), + (false, true) => Ok(Some(DatabaseAuthorityRole::Daemon)), + (false, false) => Ok(None), + } +} + +pub(super) fn scoped_runtime_role( + identity: &DatabaseIdentity, + intent: &str, +) -> Result> { + if !identity.allows_ambient_profile_scope { + return Ok(None); + } + let maintenance = MAINTENANCE_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let daemon = DAEMON_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + fallback_scoped_runtime_role(maintenance.len(), daemon.len()) + .map_err(|message| access_error(intent, &identity.profile_root, message)) +} + +fn fallback_scoped_runtime_role( + maintenance_count: usize, + daemon_count: usize, +) -> std::result::Result, &'static str> { + match (maintenance_count, daemon_count) { + (1, 0) => Ok(Some(DatabaseAuthorityRole::Maintenance)), + (0, 1) => Ok(Some(DatabaseAuthorityRole::Daemon)), + (0, 0) => Ok(None), + _ => Err("database path is ambiguous across active profile authorities"), + } +} + +pub(crate) fn enter_daemon_database_scope( + profile_root: &Path, + election_epoch: u64, + election_token: &str, +) -> Result { + if election_token.is_empty() { + return Err(access_error( + "enter daemon database scope", + Path::new(""), + "daemon election token is empty", + )); + } + let profile_root = canonical_profile_root(profile_root)?; + let token = format!("{election_epoch}:{election_token}"); + let mut scopes = DAEMON_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match scopes.get_mut(&profile_root) { + Some(existing) if existing.token == token => existing.refs += 1, + Some(_) => { + return Err(access_error( + "enter daemon database scope", + &profile_root, + "a different daemon election already owns database scope", + )); + } + None => { + scopes.insert( + profile_root.clone(), + DaemonScopeState { + token: token.clone(), + refs: 1, + }, + ); + } + } + Ok(DaemonDatabaseScope { + profile_root, + token, + }) +} + +#[doc(hidden)] +pub fn enter_maintenance_database_scope<'lease>( + lifecycle: &'lease crate::lifecycle_lease::LifecycleLease, + profile_root: &Path, + intent: &str, +) -> Result> { + if !lifecycle.is_exclusive() { + return Err(access_error( + intent, + Path::new(""), + "database maintenance requires an exclusive lifecycle lease", + )); + } + if !lifecycle.guards_profile(profile_root) { + return Err(access_error( + intent, + profile_root, + "exclusive lifecycle lease belongs to a different profile", + )); + } + let profile_root = canonical_profile_root(profile_root)?; + let lifecycle_token = lifecycle.token().ok_or_else(|| { + access_error( + intent, + Path::new(""), + "exclusive lifecycle lease has no owner token", + ) + })?; + let token = lifecycle_token.to_string(); + let mut scopes = MAINTENANCE_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match scopes.get_mut(&profile_root) { + Some(existing) if existing.token == token => existing.refs += 1, + Some(_) => { + return Err(access_error( + intent, + &profile_root, + "a different maintenance operation already owns database scope", + )); + } + None => { + scopes.insert( + profile_root.clone(), + MaintenanceScopeState { + token: token.clone(), + refs: 1, + }, + ); + } + } + Ok(MaintenanceDatabaseScope { + profile_root, + token, + _lifecycle: std::marker::PhantomData, + }) +} + +impl Drop for DaemonDatabaseScope { + fn drop(&mut self) { + let mut scopes = DAEMON_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let should_clear = scopes.get_mut(&self.profile_root).is_some_and(|existing| { + if existing.token != self.token { + return false; + } + existing.refs = existing.refs.saturating_sub(1); + existing.refs == 0 + }); + if should_clear { + scopes.remove(&self.profile_root); + } + } +} + +impl Drop for MaintenanceDatabaseScope<'_> { + fn drop(&mut self) { + let mut scopes = MAINTENANCE_SCOPES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let should_clear = scopes.get_mut(&self.profile_root).is_some_and(|existing| { + if existing.token != self.token { + return false; + } + existing.refs = existing.refs.saturating_sub(1); + existing.refs == 0 + }); + if should_clear { + scopes.remove(&self.profile_root); + } + } +} + +impl Drop for AuthorityInner { + fn drop(&mut self) { + let mut leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let should_remove = leases + .get_mut(&self.identity.database_key) + .is_some_and(|lease| { + if lease.token != self.token { + return false; + } + lease.refs = lease.refs.saturating_sub(1); + lease.refs == 0 + }); + if should_remove { + if let Some(lease) = leases.remove(&self.identity.database_key) { + unlock_held(lease.held); + } + } + } +} + +pub(super) fn acquire_process_lease( + identity: &DatabaseIdentity, + role: DatabaseAuthorityRole, + intent: &str, +) -> Result { + let mut leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = leases.get_mut(&identity.database_key) { + let compatible = matches!( + (&existing.held, role), + ( + HeldLocks::Daemon { .. }, + DatabaseAuthorityRole::Daemon | DatabaseAuthorityRole::Test + ) | ( + HeldLocks::Maintenance { .. }, + DatabaseAuthorityRole::Maintenance + ) + ); + if !compatible { + return Err(access_error( + intent, + &identity.database_path, + "this process already holds an incompatible database authority", + )); + } + existing.refs += 1; + return Ok(existing.token.clone()); + } + + let token = authority_token(); + let held = match role { + DatabaseAuthorityRole::Daemon | DatabaseAuthorityRole::Test => { + acquire_daemon_locks(identity, &token, intent)? + } + DatabaseAuthorityRole::Maintenance => acquire_maintenance_locks(identity, &token, intent)?, + }; + leases.insert( + identity.database_key.clone(), + ProcessLease { + token: token.clone(), + refs: 1, + held, + }, + ); + Ok(token) +} + +fn acquire_daemon_locks( + identity: &DatabaseIdentity, + token: &str, + intent: &str, +) -> Result { + let access = open_lock_file(&identity.access_lock_path)?; + fs2::FileExt::try_lock_shared(&access) + .map_err(|error| lock_acquisition_error("ordinary access", identity, intent, &error))?; + + let writer = match open_lock_file(&identity.writer_lock_path).and_then(|writer| { + fs2::FileExt::try_lock_exclusive(&writer) + .map_err(|error| lock_acquisition_error("writer", identity, intent, &error))?; + Ok(writer) + }) { + Ok(writer) => writer, + Err(error) => { + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } + }; + + let owner = writer_owner(token, intent); + if let Err(error) = write_owner(&identity.writer_owner_path, &owner) { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } + Ok(HeldLocks::Daemon { + access, + writer, + owner, + }) +} + +fn acquire_maintenance_locks( + identity: &DatabaseIdentity, + token: &str, + intent: &str, +) -> Result { + let access = open_lock_file(&identity.access_lock_path)?; + fs2::FileExt::try_lock_exclusive(&access) + .map_err(|error| lock_acquisition_error("maintenance", identity, intent, &error))?; + let writer = match open_lock_file(&identity.writer_lock_path).and_then(|writer| { + fs2::FileExt::try_lock_exclusive(&writer) + .map_err(|error| lock_acquisition_error("writer", identity, intent, &error))?; + Ok(writer) + }) { + Ok(writer) => writer, + Err(error) => { + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } + }; + let owner = writer_owner(token, intent); + if let Err(error) = write_owner(&identity.writer_owner_path, &owner) { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } + Ok(HeldLocks::Maintenance { + access, + writer, + owner, + }) +} + +fn unlock_held(held: HeldLocks) { + match held { + HeldLocks::Daemon { access, writer, .. } => { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + } + HeldLocks::Maintenance { access, writer, .. } => { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + } + } +} + +pub(crate) fn probe_writer_owner(db_path: &Path) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + { + let leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(lease) = leases.get(&identity.database_key) { + let owner = match &lease.held { + HeldLocks::Daemon { owner, .. } | HeldLocks::Maintenance { owner, .. } => owner, + }; + return Ok(WriterOwnership::Active(owner.clone())); + } + } + + let writer = open_lock_file(&identity.writer_lock_path)?; + match fs2::FileExt::try_lock_exclusive(&writer) { + Ok(()) => { + let _ = fs2::FileExt::unlock(&writer); + Ok(WriterOwnership::Idle) + } + Err(error) if is_lock_contended(&error) => Ok(read_owner(&identity.writer_owner_path) + .map(WriterOwnership::Active) + .unwrap_or(WriterOwnership::ActiveUnknown)), + Err(error) => Err(access_io_error( + "probe writer", + &identity.writer_lock_path, + &error, + )), + } +} + +fn lock_acquisition_error( + kind: &str, + identity: &DatabaseIdentity, + intent: &str, + error: &std::io::Error, +) -> TraceDecayError { + if is_lock_contended(error) { + access_error( + intent, + &identity.database_path, + &format!("{kind} lease is held by another process"), + ) + } else { + access_io_error( + &format!("acquire {kind} lease for {intent}"), + &identity.database_path, + error, + ) + } +} diff --git a/src/db/access/owner_io.rs b/src/db/access/owner_io.rs new file mode 100644 index 000000000..9e0060172 --- /dev/null +++ b/src/db/access/owner_io.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::errors::Result; + +use super::{ + AUTHORITY_NONCE, PROCESS_STARTED_EPOCH_MS, WriterOwner, access_error, access_io_error, +}; + +pub(super) fn open_lock_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| access_io_error("open lock", path, &error)) +} + +pub(super) fn write_owner(path: &Path, owner: &WriterOwner) -> Result<()> { + let file_name = path.file_name().ok_or_else(|| { + access_error( + "write writer owner", + path, + "writer owner path has no file name", + ) + })?; + let nonce = AUTHORITY_NONCE.fetch_add(1, Ordering::Relaxed); + let temporary = path.with_file_name(format!( + ".{}.{}.{}.tmp", + file_name.to_string_lossy(), + std::process::id(), + nonce + )); + let payload = format!( + "token={}\tpid={}\tstarted_epoch_ms={}\tversion={}\tintent={}", + owner.token, owner.pid, owner.started_epoch_ms, owner.version, owner.intent + ); + publish_record_atomically( + &temporary, + path, + format!("{payload}\n").as_bytes(), + "writer owner", + ) +} + +pub(super) fn publish_record_atomically( + temporary: &Path, + destination: &Path, + payload: &[u8], + record_name: &str, +) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut created = false; + let publish = (|| { + let mut file = options.open(temporary).map_err(|error| { + access_io_error(&format!("create {record_name}"), temporary, &error) + })?; + created = true; + file.write_all(payload) + .and_then(|_| file.sync_all()) + .map_err(|error| access_io_error(&format!("write {record_name}"), temporary, &error))?; + replace_file_atomically(temporary, destination, record_name)?; + sync_parent_directory(destination, record_name) + })(); + if publish.is_err() && created { + let _ = std::fs::remove_file(temporary); + } + publish +} + +#[cfg(not(windows))] +pub(super) fn replace_file_atomically( + temporary: &Path, + path: &Path, + record_name: &str, +) -> Result<()> { + std::fs::rename(temporary, path) + .map_err(|error| access_io_error(&format!("publish {record_name}"), path, &error)) +} + +#[cfg(windows)] +pub(super) fn replace_file_atomically( + temporary: &Path, + path: &Path, + record_name: &str, +) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + const MOVEFILE_WRITE_THROUGH: u32 = 0x8; + #[link(name = "kernel32")] + unsafe extern "system" { + fn MoveFileExW(existing: *const u16, replacement: *const u16, flags: u32) -> i32; + } + + let existing = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let replacement = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let replaced = unsafe { + MoveFileExW( + existing.as_ptr(), + replacement.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + let error = std::io::Error::last_os_error(); + return Err(access_io_error( + &format!("publish {record_name}"), + path, + &error, + )); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn sync_parent_directory(path: &Path, record_name: &str) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + access_error( + &format!("sync {record_name} directory"), + path, + &format!("{record_name} path has no parent directory"), + ) + })?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| access_io_error(&format!("sync {record_name} directory"), parent, &error)) +} + +#[cfg(not(unix))] +pub(super) fn sync_parent_directory(_path: &Path, _record_name: &str) -> Result<()> { + Ok(()) +} + +pub(super) fn writer_owner(token: &str, intent: &str) -> WriterOwner { + WriterOwner { + token: token.to_string(), + pid: std::process::id(), + started_epoch_ms: *PROCESS_STARTED_EPOCH_MS, + version: env!("CARGO_PKG_VERSION").to_string(), + intent: sanitize_metadata(intent), + } +} + +pub(super) fn read_owner(path: &Path) -> Option { + let mut value = String::new(); + File::open(path).ok()?.read_to_string(&mut value).ok()?; + let mut fields = HashMap::new(); + for field in value.trim().split('\t') { + let (key, value) = field.split_once('=')?; + fields.insert(key, value); + } + Some(WriterOwner { + token: fields.get("token")?.to_string(), + pid: fields.get("pid")?.parse().ok()?, + started_epoch_ms: fields.get("started_epoch_ms")?.parse().ok()?, + version: fields.get("version")?.to_string(), + intent: fields.get("intent")?.to_string(), + }) +} + +pub(super) fn is_lock_contended(error: &std::io::Error) -> bool { + if error.kind() == std::io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + return error.raw_os_error() == Some(33); + } + #[cfg(not(windows))] + false +} + +pub(super) fn authority_token() -> String { + let nonce = AUTHORITY_NONCE.fetch_add(1, Ordering::Relaxed); + format!( + "{}:{}:{}:{nonce}", + crate::runtime_identity::process_run_id(), + std::process::id(), + epoch_ms() + ) +} + +pub(super) fn epoch_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn sanitize_metadata(value: &str) -> String { + value.replace(['\t', '\r', '\n'], " ") +} diff --git a/src/db/access/path_layout.rs b/src/db/access/path_layout.rs new file mode 100644 index 000000000..199deaa18 --- /dev/null +++ b/src/db/access/path_layout.rs @@ -0,0 +1,119 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::errors::Result; + +use super::access_io_error; + +pub(super) fn canonical_profile_root(profile_root: &Path) -> Result { + let absolute = if profile_root.is_absolute() { + profile_root.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| access_io_error("resolve profile", profile_root, &error))? + .join(profile_root) + }; + Ok(platform_identity_key( + &absolute.canonicalize().unwrap_or(absolute), + )) +} + +pub(super) fn platform_identity_key(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +pub(super) fn bootstrap_database_key(parent: &Path, file_name: &OsStr) -> Option { + #[cfg(any(windows, target_os = "macos"))] + { + Some(parent.join(file_name.to_string_lossy().to_lowercase())) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = (parent, file_name); + None + } +} + +pub(super) fn database_lock_root(database_path: &Path, fallback_parent: &Path) -> PathBuf { + if let Some(profile_root) = profile_project_root(database_path) { + return profile_root.join(".tracedecay-database-locks"); + } + database_path + .parent() + .unwrap_or(fallback_parent) + .join(".tracedecay-database-locks") +} + +fn profile_project_root(database_path: &Path) -> Option<&Path> { + let parent = database_path.parent()?; + let data_root = if parent.file_name().is_some_and(|name| name == "branches") { + parent.parent()? + } else { + parent + }; + let projects_root = data_root.parent()?; + if projects_root + .file_name() + .is_some_and(|name| name == "projects") + { + projects_root.parent() + } else { + None + } +} + +pub(super) fn is_legacy_repository_database(database_path: &Path) -> bool { + let Some(parent) = database_path.parent() else { + return false; + }; + let is_branch_database = parent.file_name().is_some_and(|name| name == "branches"); + if !is_branch_database + && database_path.file_name().is_some_and(|name| { + name == "global.db" || name == "user-memory.db" || name == "user-sessions.db" + }) + { + return false; + } + let data_root = if is_branch_database { + let Some(data_root) = parent.parent() else { + return false; + }; + data_root + } else { + parent + }; + data_root + .file_name() + .is_some_and(|name| name == ".tracedecay") +} + +pub(super) fn stable_path_hash(path: &Path) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in native_path_bytes(path) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +#[cfg(unix)] +fn native_path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(windows)] +fn native_path_bytes(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect() +} + +#[cfg(not(any(unix, windows)))] +fn native_path_bytes(path: &Path) -> Vec { + path.to_string_lossy().into_owned().into_bytes() +} diff --git a/src/db/connection.rs b/src/db/connection.rs index 4a9bb0e68..8287952be 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -1,124 +1,56 @@ // Rust guideline compliant 2025-10-17 use std::path::Path; +use std::sync::Arc; -use libsql::{Builder, Connection, Database as LibsqlDatabase, OpenFlags}; +use libsql::{Builder, Connection, OpenFlags}; use crate::errors::{Result, TraceDecayError}; -use super::migrations; +use super::{DatabaseAuthority, migrations}; -/// Computes adaptive `(cache_size_kb, mmap_size)` based on the DB file size. -/// -/// - **`cache_size`**: 25% of DB size, clamped to \[2 MB, 64 MB\] (in KiB). -/// - **`mmap_size`**: 2× DB size, clamped to \[0, 256 MB\]. -/// -/// This avoids the fixed 320 MB memory baseline for small/medium projects. -pub(crate) fn adaptive_cache_sizes(db_file_size: u64) -> (u64, u64) { - const KB: u64 = 1024; - const MB: u64 = 1024 * 1024; - - // cache_size: 25% of DB, clamped [2 MB .. 64 MB], expressed in KiB - let cache_bytes = (db_file_size / 4).clamp(2 * MB, 64 * MB); - let cache_kb = cache_bytes / KB; - - // mmap_size: 2× DB, clamped [0 .. 256 MB] - let mmap = db_file_size.saturating_mul(2).min(256 * MB); - - (cache_kb, mmap) -} - -/// Returns the `mmap_size` that is actually safe to apply on the current -/// platform. -/// -/// `SQLite` memory-mapped I/O is disabled on Windows. When the final connection -/// to a WAL-mode database closes, `SQLite` runs an implicit checkpoint; on -/// Windows the interaction between that close-time checkpoint and tearing down -/// the file's memory map intermittently faults with `STATUS_ACCESS_VIOLATION` -/// (`0xc0000005`). Because libsql's local connection executes synchronously -/// (no background runtime), the fault surfaces as a native process abort -/// during teardown — nextest reports it as an ABORT/"test aborted" on a -/// rotating set of tests rather than an assertion failure. Forcing -/// `mmap_size = 0` on Windows routes the close path through ordinary file I/O -/// and removes the crash at its source. Other platforms keep the adaptive -/// mmap size for its read-throughput benefit. -pub(crate) fn platform_safe_mmap_size(mmap: u64) -> u64 { - if cfg!(windows) { 0 } else { mmap } -} - -const GRAPH_STORE_MMAP_SIZE: u64 = 0; - -/// Env var that, when set to `1`, switches every `TraceDecay` `SQLite` -/// connection to `journal_mode=MEMORY` + `synchronous=OFF` on all platforms. -/// -/// **For tests/CI only — must never be set in production.** It trades away -/// crash durability entirely: a process or OS crash mid-transaction can -/// corrupt the database. CI test runs don't care (every DB is a throwaway -/// fixture), and on Windows this avoids the per-transaction rollback-journal -/// file create/write/fsync/delete cost of the `DELETE`+`FULL` pairing. An -/// in-memory journal also never enters WAL mode, so it sidesteps the Windows -/// WAL close-time teardown crash the same way `DELETE` does. -pub const SQLITE_UNSAFE_FAST_ENV: &str = "TRACEDECAY_SQLITE_UNSAFE_FAST"; - -fn sqlite_unsafe_fast_enabled() -> bool { - std::env::var(SQLITE_UNSAFE_FAST_ENV).as_deref() == Ok("1") -} +mod integrity; +mod pragmas; +mod registry; -/// Returns the `journal_mode` safe for the current platform. -/// -/// Windows libsql/SQLite local databases can intermittently fault while closing -/// WAL-mode databases under nextest's per-test process isolation. Disabling -/// mmap removed one unsafe teardown path, but master CI still aborts in -/// unrelated tests as different short-lived databases close. Use rollback -/// journaling on Windows and keep WAL everywhere else. -/// -/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in -/// production) this returns `MEMORY` on every platform, skipping journal file -/// I/O entirely at the cost of crash durability. -pub(crate) fn platform_safe_journal_mode() -> &'static str { - if sqlite_unsafe_fast_enabled() { - "MEMORY" - } else if cfg!(windows) { - "DELETE" - } else { - "WAL" - } -} - -/// Returns the `synchronous` level paired with the current platform journal. -/// -/// `NORMAL` is consistency-safe for WAL because WAL can recover from a missing -/// final fsync, but rollback journals need `FULL` to avoid corruption after an -/// OS crash or power loss. Keep the faster WAL+NORMAL pairing on non-Windows -/// and use DELETE+FULL on Windows. -/// -/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in -/// production) this returns `OFF` on every platform, skipping fsyncs entirely -/// at the cost of crash durability. -pub(crate) fn platform_safe_synchronous_mode() -> &'static str { - if sqlite_unsafe_fast_enabled() { - "OFF" - } else if cfg!(windows) { - "FULL" - } else { - "NORMAL" - } -} +pub use pragmas::SQLITE_UNSAFE_FAST_ENV; +#[cfg(test)] +pub(crate) use pragmas::{adaptive_cache_sizes, platform_safe_mmap_size}; +pub(crate) use pragmas::{platform_safe_journal_mode, platform_safe_synchronous_mode}; +use registry::{DatabaseInner, database_slot}; /// `SQLite` database backing the code graph, powered by libsql. +#[derive(Clone)] pub struct Database { - conn: Connection, - /// Kept alive so the underlying database is not dropped. - _db: LibsqlDatabase, + inner: Arc, } impl Database { /// Creates a new database at `db_path`, creating parent directories if needed. /// + /// An explicit [`DatabaseAuthority`] is required; opening writable storage + /// without process authority is intentionally unsupported. + /// /// Opens a libsql connection, applies performance pragmas, and runs all /// schema migrations up to the latest version. /// Returns `(Self, migrated)` where `migrated` is `true` if schema /// migrations were applied during initialization. - pub async fn initialize(db_path: &Path) -> Result<(Self, bool)> { + pub async fn initialize(db_path: &Path, authority: &DatabaseAuthority) -> Result<(Self, bool)> { + let authority = authority.hold_for(db_path, "initialize")?; + let slot = database_slot(authority.canonical_database_path()); + let mut open = slot.lock().await; + if let Some(inner) = open.upgrade() { + if !inner.writable { + return Err(integrity::read_only_upgrade_error(db_path, "initialize")); + } + return Ok((Self { inner }, false)); + } + let is_fresh = std::fs::metadata(db_path) + .map(|metadata| metadata.len() == 0) + .unwrap_or(true); + if !is_fresh { + integrity::validate_sqlite_header(db_path, "initialize", false)?; + integrity::validate_read_only(db_path).await?; + } if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Database { message: format!("failed to create database directory: {e}"), @@ -140,19 +72,55 @@ impl Database { operation: "initialize".to_string(), })?; - migrations::configure_fresh_auto_vacuum(&conn, "initialize").await?; - Self::apply_pragmas(&conn, 0).await?; - migrations::create_schema(&conn).await?; - - Ok((Self { conn, _db: db }, false)) + if is_fresh { + pragmas::apply_fresh_storage(&conn).await?; + migrations::configure_fresh_auto_vacuum(&conn, "initialize").await?; + } + let file_size = std::fs::metadata(db_path).map_or(0, |metadata| metadata.len()); + pragmas::apply(&conn, file_size).await?; + let migrated = if is_fresh { + migrations::create_schema(&conn).await?; + false + } else { + migrations::migrate(&conn).await? + }; + + let inner = Arc::new(DatabaseInner { + conn, + _db: db, + writable: true, + _authority: authority, + _slot: Some(slot.clone()), + }); + *open = Arc::downgrade(&inner); + Ok((Self { inner }, migrated)) } /// Opens an existing database at `db_path`, applies performance pragmas, /// and runs any pending schema migrations. + /// + /// An explicit [`DatabaseAuthority`] is required; opening writable storage + /// without process authority is intentionally unsupported. + /// /// Returns `(Self, migrated)` where `migrated` is `true` if schema /// migrations were applied during open. - pub async fn open(db_path: &Path) -> Result<(Self, bool)> { - Self::validate_sqlite_header(db_path, "open", true)?; + pub async fn open(db_path: &Path, authority: &DatabaseAuthority) -> Result<(Self, bool)> { + let authority = authority.hold_for(db_path, "open")?; + let slot = database_slot(authority.canonical_database_path()); + let mut open = slot.lock().await; + if let Some(inner) = open.upgrade() { + if !inner.writable { + return Err(integrity::read_only_upgrade_error(db_path, "open")); + } + return Ok((Self { inner }, false)); + } + let is_fresh = std::fs::metadata(db_path) + .map(|metadata| metadata.len() == 0) + .unwrap_or(true); + integrity::validate_sqlite_header(db_path, "open", true)?; + if !is_fresh { + integrity::validate_read_only(db_path).await?; + } let db = Builder::new_local(db_path) .build() @@ -168,10 +136,21 @@ impl Database { })?; let file_size = std::fs::metadata(db_path).map_or(0, |m| m.len()); - Self::apply_pragmas(&conn, file_size).await?; + if is_fresh { + pragmas::apply_fresh_storage(&conn).await?; + } + pragmas::apply(&conn, file_size).await?; let migrated = migrations::migrate(&conn).await?; - Ok((Self { conn, _db: db }, migrated)) + let inner = Arc::new(DatabaseInner { + conn, + _db: db, + writable: true, + _authority: authority, + _slot: Some(slot.clone()), + }); + *open = Arc::downgrade(&inner); + Ok((Self { inner }, migrated)) } /// Opens an existing database in read-only mode. @@ -179,8 +158,14 @@ impl Database { /// This intentionally skips write-oriented PRAGMAs and migrations so /// status/verification paths can inspect read-only `SQLite` files without /// creating WAL files or attempting schema updates. - pub async fn open_read_only(db_path: &Path) -> Result<(Self, bool)> { - Self::validate_sqlite_header(db_path, "open_read_only", false)?; + /// An explicit [`DatabaseAuthority`] is still required so every local + /// database handle participates in the same process-ownership contract. + pub async fn open_read_only( + db_path: &Path, + authority: &DatabaseAuthority, + ) -> Result<(Self, bool)> { + let authority = authority.hold_for(db_path, "open_read_only")?; + integrity::validate_sqlite_header(db_path, "open_read_only", false)?; let db = Builder::new_local(db_path) .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) .build() @@ -196,59 +181,30 @@ impl Database { })?; let file_size = std::fs::metadata(db_path).map_or(0, |m| m.len()); - Self::apply_read_only_pragmas(&conn, file_size).await?; - - Ok((Self { conn, _db: db }, false)) - } - - fn validate_sqlite_header( - db_path: &Path, - operation: &str, - allow_fresh_path: bool, - ) -> Result<()> { - match std::fs::metadata(db_path) { - Ok(metadata) if allow_fresh_path && metadata.len() == 0 => return Ok(()), - Ok(_) => {} - Err(e) if allow_fresh_path && e.kind() == std::io::ErrorKind::NotFound => { - return Ok(()); - } - Err(e) => { - return Err(TraceDecayError::Database { - message: format!( - "failed to inspect database path at '{}': {e}", - db_path.display() - ), - operation: operation.to_string(), - }); - } - } - match crate::storage::has_sqlite_database_header(db_path) { - Ok(true) => Ok(()), - Ok(false) => Err(TraceDecayError::Database { - message: format!( - "file is not a database: SQLite header is missing at '{}'", - db_path.display() - ), - operation: operation.to_string(), - }), - Err(e) => Err(TraceDecayError::Database { - message: format!( - "failed to read database header at '{}': {e}", - db_path.display() - ), - operation: operation.to_string(), - }), - } + pragmas::apply_read_only(&conn, file_size).await?; + integrity::validate(&conn, "open_read_only").await?; + + let inner = Arc::new(DatabaseInner { + conn, + _db: db, + writable: false, + _authority: authority, + _slot: None, + }); + Ok((Self { inner }, false)) } /// Returns a reference to the underlying libsql connection. pub fn conn(&self) -> &Connection { - &self.conn + &self.inner.conn } - /// Consumes the `Database`, closing the underlying connection. + /// Releases this database handle. + /// + /// The underlying connection remains open until all cloned handles are + /// released. pub fn close(self) { - drop(self.conn); + drop(self); } /// Checkpoints the WAL back into the main database file. @@ -257,6 +213,7 @@ impl Database { /// before the process exits, preventing a stale WAL file on next startup. pub async fn checkpoint(&self) -> Result<()> { let mut rows = self + .inner .conn .query("PRAGMA wal_checkpoint(TRUNCATE);", ()) .await @@ -313,7 +270,25 @@ impl Database { ), operation: "snapshot".to_string(), })?; - self.conn + // Read-only handles keep `query_only` enabled for their ordinary + // connection. `VACUUM INTO` only writes the destination, so use a + // one-shot connection from the same read-only database without + // weakening that guard on the exposed handle. + let snapshot_connection = if self.inner.writable { + None + } else { + Some( + self.inner + ._db + .connect() + .map_err(|e| TraceDecayError::Database { + message: format!("failed to open database snapshot connection: {e}"), + operation: "snapshot".to_string(), + })?, + ) + }; + let connection = snapshot_connection.as_ref().unwrap_or(&self.inner.conn); + connection .execute("VACUUM INTO ?1", libsql::params![destination]) .await .map_err(|e| TraceDecayError::Database { @@ -327,6 +302,7 @@ impl Database { /// Returns the on-disk size of the database file in bytes. pub async fn size(&self) -> Result { let mut rows = self + .inner .conn .query( "SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()", @@ -363,24 +339,13 @@ impl Database { /// This is faster than `integrity_check` — it verifies B-tree structure /// without cross-checking index contents against table data. pub async fn quick_check(&self) -> Result { - let mut rows = self - .conn - .query("PRAGMA quick_check", ()) - .await - .map_err(|e| TraceDecayError::Database { - message: format!("failed to run quick_check: {e}"), - operation: "quick_check".to_string(), - })?; - - if let Some(row) = rows.next().await.map_err(|e| TraceDecayError::Database { - message: format!("failed to read quick_check result: {e}"), - operation: "quick_check".to_string(), - })? { - let result: String = row.get::(0).unwrap_or_default(); - Ok(result == "ok") - } else { - Ok(false) - } + Ok(integrity::quick_check_result( + &self.inner.conn, + "quick_check", + "failed to run quick_check", + ) + .await? + .is_some_and(|result| result == "ok")) } /// Maintenance-only: rebuilds the FTS5 index from the content table. @@ -389,7 +354,8 @@ impl Database { /// without requiring a full re-index of the codebase. Callers must hold /// exclusive maintenance ownership; read paths must never invoke this. pub async fn rebuild_fts(&self) -> Result<()> { - self.conn + self.inner + .conn .execute("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')", ()) .await .map_err(|e| TraceDecayError::Database { @@ -399,63 +365,13 @@ impl Database { Ok(()) } - /// Applies performance-oriented `SQLite` pragmas. - /// - /// `cache_size` and `mmap_size` are scaled to the on-disk DB size so - /// small projects don't pay the 320 MB baseline of a large project. - async fn apply_pragmas(conn: &Connection, db_file_size: u64) -> Result<()> { - let (cache_kb, _) = adaptive_cache_sizes(db_file_size); - // Keep graph stores on ordinary file I/O. The daemon intentionally - // holds one connection open while watcher and hook processes open and - // close peers; mmap-backed peers can otherwise retain stale page views - // across WAL checkpoints, especially for legacy 4 KiB databases. - let mmap = GRAPH_STORE_MMAP_SIZE; - let journal_mode = platform_safe_journal_mode(); - let synchronous = platform_safe_synchronous_mode(); - conn.execute_batch(&format!( - "PRAGMA mmap_size = {mmap}; - PRAGMA page_size = 8192; - PRAGMA journal_mode = {journal_mode}; - PRAGMA foreign_keys = ON; - PRAGMA busy_timeout = 120000; - PRAGMA synchronous = {synchronous}; - PRAGMA cache_size = -{cache_kb}; - PRAGMA temp_store = MEMORY;", - )) - .await - .map_err(|e| TraceDecayError::Database { - message: format!("failed to apply pragmas: {e}"), - operation: "apply_pragmas".to_string(), - })?; - Ok(()) - } - - async fn apply_read_only_pragmas(conn: &Connection, db_file_size: u64) -> Result<()> { - let (cache_kb, _) = adaptive_cache_sizes(db_file_size); - // Read-only graph handles may overlap a writer/checkpointer too, so - // they must use the same non-mmap access invariant. - let mmap = GRAPH_STORE_MMAP_SIZE; - conn.execute_batch(&format!( - "PRAGMA mmap_size = {mmap}; - PRAGMA foreign_keys = ON; - PRAGMA busy_timeout = 120000; - PRAGMA cache_size = -{cache_kb}; - PRAGMA temp_store = MEMORY;", - )) - .await - .map_err(|e| TraceDecayError::Database { - message: format!("failed to apply read-only pragmas: {e}"), - operation: "apply_read_only_pragmas".to_string(), - })?; - Ok(()) - } - /// Drops secondary indexes, disables fsync/FK, and clears FTS for fast /// bulk loading. Callers should insert data sorted by PK so the primary /// B-tree gets sequential appends. Call `end_bulk_load` afterwards to /// rebuild indexes in one optimized pass. pub async fn begin_bulk_load(&self) -> Result<()> { - self.conn + self.inner + .conn .execute_batch( "PRAGMA foreign_keys = OFF; DROP INDEX IF EXISTS idx_nodes_kind; @@ -488,7 +404,7 @@ impl Database { /// Recreates secondary indexes (benefiting from sorted row order), /// restores FTS triggers and content, and re-enables normal durability. pub async fn end_bulk_load(&self) -> Result<()> { - self.conn.execute_batch( + self.inner.conn.execute_batch( "CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name); @@ -613,21 +529,85 @@ mod tests { } #[test] - fn mmap_disabled_on_windows_only() { - // Windows forces mmap_size to 0 to avoid the close-time database - // teardown access violation; every other platform keeps the adaptive - // value. + fn mmap_disabled_for_every_graph_database() { let raw = 200 * MB; let effective = platform_safe_mmap_size(raw); - if cfg!(windows) { - assert_eq!(effective, 0, "Windows must disable mmap"); - } else { - assert_eq!(effective, raw, "non-Windows keeps adaptive mmap"); - } - // A zero input stays zero on every platform. + assert_eq!(effective, 0); assert_eq!(platform_safe_mmap_size(0), 0); } + #[tokio::test] + async fn repeated_authorized_opens_share_one_physical_connection() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let authority = DatabaseAuthority::acquire_test(&path, "connection reuse").unwrap(); + let (first, _) = Database::initialize(&path, &authority).await.unwrap(); + let (second, _) = Database::open(&path, &authority).await.unwrap(); + let mut readers = Vec::new(); + for _ in 0..12 { + readers.push(Database::open_read_only(&path, &authority).await.unwrap().0); + } + + assert!(Arc::ptr_eq(&first.inner, &second.inner)); + assert!( + readers + .iter() + .all(|reader| !Arc::ptr_eq(&first.inner, &reader.inner)) + ); + assert!(readers.iter().all(|reader| !reader.inner.writable)); + assert!(first.inner.writable); + } + + #[tokio::test] + async fn retained_database_guard_keeps_authority_alive_for_raw_connection() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let authority = DatabaseAuthority::acquire_test(&path, "dashboard guard").unwrap(); + let (db, _) = Database::initialize(&path, &authority).await.unwrap(); + let raw = db.conn().clone(); + let guard = Arc::new(db.clone()); + drop(db); + drop(authority); + + assert!(matches!( + crate::db::probe_writer_owner(&path).unwrap(), + crate::db::WriterOwnership::Active(_) + )); + raw.query("SELECT 1", ()).await.unwrap(); + + drop(guard); + assert_eq!( + crate::db::probe_writer_owner(&path).unwrap(), + crate::db::WriterOwnership::Idle + ); + drop(raw); + } + + #[tokio::test] + async fn read_only_first_open_does_not_block_writable_owner() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let authority = DatabaseAuthority::acquire_test(&path, "readonly upgrade").unwrap(); + let (seed, _) = Database::initialize(&path, &authority).await.unwrap(); + drop(seed); + + let (reader, _) = Database::open_read_only(&path, &authority).await.unwrap(); + let (writer, _) = Database::open(&path, &authority).await.unwrap(); + assert!(!Arc::ptr_eq(&reader.inner, &writer.inner)); + writer + .conn() + .execute("CREATE TABLE reader_did_not_poison_writer (id INTEGER)", ()) + .await + .unwrap(); + assert!( + reader + .conn() + .execute("CREATE TABLE forbidden_reader_write (id INTEGER)", ()) + .await + .is_err() + ); + } + #[test] fn journal_mode_uses_wal_except_on_windows() { let _lock = ENV_LOCK.lock().unwrap(); diff --git a/src/db/connection/integrity.rs b/src/db/connection/integrity.rs new file mode 100644 index 000000000..7055710ba --- /dev/null +++ b/src/db/connection/integrity.rs @@ -0,0 +1,111 @@ +use std::path::Path; + +use libsql::{Builder, Connection, OpenFlags}; + +use crate::errors::{Result, TraceDecayError}; + +use super::pragmas; + +pub(super) async fn validate_read_only(db_path: &Path) -> Result<()> { + let db = Builder::new_local(db_path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to open database for integrity validation: {e}"), + operation: "validate_integrity".to_string(), + })?; + let conn = db.connect().map_err(|e| TraceDecayError::Database { + message: format!("failed to connect for integrity validation: {e}"), + operation: "validate_integrity".to_string(), + })?; + let file_size = std::fs::metadata(db_path).map_or(0, |metadata| metadata.len()); + pragmas::apply_read_only(&conn, file_size).await?; + validate(&conn, "validate_integrity").await +} + +pub(super) async fn quick_check_result( + conn: &Connection, + operation: &str, + query_error: &str, +) -> Result> { + let mut rows = + conn.query("PRAGMA quick_check", ()) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("{query_error}: {e}"), + operation: operation.to_string(), + })?; + rows.next() + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to read quick_check result: {e}"), + operation: operation.to_string(), + }) + .map(|row| row.map(|row| row.get::(0).unwrap_or_default())) +} + +pub(super) async fn validate(conn: &Connection, operation: &str) -> Result<()> { + let result = quick_check_result(conn, operation, "failed to run read-only quick_check") + .await? + .ok_or_else(|| TraceDecayError::Database { + message: "quick_check returned no result".to_string(), + operation: operation.to_string(), + })?; + if result == "ok" { + Ok(()) + } else { + Err(TraceDecayError::Database { + message: format!("database quick_check failed: {result}"), + operation: operation.to_string(), + }) + } +} + +pub(super) fn read_only_upgrade_error(db_path: &Path, operation: &str) -> TraceDecayError { + TraceDecayError::Database { + message: format!( + "cannot upgrade the daemon's shared read-only connection at '{}' to writable; acquire writable ownership before opening read handles", + db_path.display() + ), + operation: operation.to_string(), + } +} + +pub(super) fn validate_sqlite_header( + db_path: &Path, + operation: &str, + allow_fresh_path: bool, +) -> Result<()> { + match std::fs::metadata(db_path) { + Ok(metadata) if allow_fresh_path && metadata.len() == 0 => return Ok(()), + Ok(_) => {} + Err(e) if allow_fresh_path && e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(TraceDecayError::Database { + message: format!( + "failed to inspect database path at '{}': {e}", + db_path.display() + ), + operation: operation.to_string(), + }); + } + } + match crate::storage::has_sqlite_database_header(db_path) { + Ok(true) => Ok(()), + Ok(false) => Err(TraceDecayError::Database { + message: format!( + "file is not a database: SQLite header is missing at '{}'", + db_path.display() + ), + operation: operation.to_string(), + }), + Err(e) => Err(TraceDecayError::Database { + message: format!( + "failed to read database header at '{}': {e}", + db_path.display() + ), + operation: operation.to_string(), + }), + } +} diff --git a/src/db/connection/pragmas.rs b/src/db/connection/pragmas.rs new file mode 100644 index 000000000..2ec35706b --- /dev/null +++ b/src/db/connection/pragmas.rs @@ -0,0 +1,154 @@ +use libsql::Connection; + +use crate::errors::{Result, TraceDecayError}; + +const GRAPH_STORE_MMAP_SIZE: u64 = 0; + +/// Env var that, when set to `1`, switches every `TraceDecay` `SQLite` +/// connection to `journal_mode=MEMORY` + `synchronous=OFF` on all platforms. +/// +/// **For tests/CI only — must never be set in production.** It trades away +/// crash durability entirely: a process or OS crash mid-transaction can +/// corrupt the database. CI test runs don't care (every DB is a throwaway +/// fixture), and on Windows this avoids the per-transaction rollback-journal +/// file create/write/fsync/delete cost of the `DELETE`+`FULL` pairing. An +/// in-memory journal also never enters WAL mode, so it sidesteps the Windows +/// WAL close-time teardown crash the same way `DELETE` does. +pub const SQLITE_UNSAFE_FAST_ENV: &str = "TRACEDECAY_SQLITE_UNSAFE_FAST"; + +/// Computes adaptive `(cache_size_kb, mmap_size)` based on the DB file size. +/// +/// - **`cache_size`**: 25% of DB size, clamped to \[2 MB, 64 MB\] (in KiB). +/// - **`mmap_size`**: 2× DB size, clamped to \[0, 256 MB\]. +/// +/// This avoids the fixed 320 MB memory baseline for small/medium projects. +pub(crate) fn adaptive_cache_sizes(db_file_size: u64) -> (u64, u64) { + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + + // cache_size: 25% of DB, clamped [2 MB .. 64 MB], expressed in KiB + let cache_bytes = (db_file_size / 4).clamp(2 * MB, 64 * MB); + let cache_kb = cache_bytes / KB; + + // mmap_size: 2× DB, clamped [0 .. 256 MB] + let mmap = db_file_size.saturating_mul(2).min(256 * MB); + + (cache_kb, mmap) +} + +/// Returns the `mmap_size` that is actually safe to apply on the current +/// platform. +/// +/// Graph-store mmap is disabled on every platform. Long-lived daemon handles +/// and short-lived peers previously retained divergent mapped page views +/// across WAL checkpoints; ordinary file I/O keeps SQLite's locking and WAL +/// coherence mechanisms authoritative. +#[cfg(test)] +pub(crate) fn platform_safe_mmap_size(_mmap: u64) -> u64 { + 0 +} + +fn sqlite_unsafe_fast_enabled() -> bool { + std::env::var(SQLITE_UNSAFE_FAST_ENV).as_deref() == Ok("1") +} + +/// Returns the `journal_mode` safe for the current platform. +/// +/// Windows libsql/SQLite local databases can intermittently fault while closing +/// WAL-mode databases under nextest's per-test process isolation. Disabling +/// mmap removed one unsafe teardown path, but master CI still aborts in +/// unrelated tests as different short-lived databases close. Use rollback +/// journaling on Windows and keep WAL everywhere else. +/// +/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in +/// production) this returns `MEMORY` on every platform, skipping journal file +/// I/O entirely at the cost of crash durability. +pub(crate) fn platform_safe_journal_mode() -> &'static str { + if sqlite_unsafe_fast_enabled() { + "MEMORY" + } else if cfg!(windows) { + "DELETE" + } else { + "WAL" + } +} + +/// Returns the `synchronous` level paired with the current platform journal. +/// +/// `NORMAL` is consistency-safe for WAL because WAL can recover from a missing +/// final fsync, but rollback journals need `FULL` to avoid corruption after an +/// OS crash or power loss. Keep the faster WAL+NORMAL pairing on non-Windows +/// and use DELETE+FULL on Windows. +/// +/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in +/// production) this returns `OFF` on every platform, skipping fsyncs entirely +/// at the cost of crash durability. +pub(crate) fn platform_safe_synchronous_mode() -> &'static str { + if sqlite_unsafe_fast_enabled() { + "OFF" + } else if cfg!(windows) { + "FULL" + } else { + "NORMAL" + } +} + +/// Applies performance-oriented `SQLite` pragmas. +/// +/// `cache_size` and `mmap_size` are scaled to the on-disk DB size so +/// small projects don't pay the 320 MB baseline of a large project. +pub(super) async fn apply(conn: &Connection, db_file_size: u64) -> Result<()> { + let (cache_kb, _) = adaptive_cache_sizes(db_file_size); + // Keep graph stores on ordinary file I/O so SQLite remains the sole + // authority for page-cache and WAL coherence. + let mmap = GRAPH_STORE_MMAP_SIZE; + let synchronous = platform_safe_synchronous_mode(); + conn.execute_batch(&format!( + "PRAGMA mmap_size = {mmap}; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 120000; + PRAGMA synchronous = {synchronous}; + PRAGMA cache_size = -{cache_kb}; + PRAGMA temp_store = MEMORY;", + )) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to apply pragmas: {e}"), + operation: "apply_pragmas".to_string(), + })?; + Ok(()) +} + +pub(super) async fn apply_fresh_storage(conn: &Connection) -> Result<()> { + let journal_mode = platform_safe_journal_mode(); + conn.execute_batch(&format!( + "PRAGMA page_size = 8192; + PRAGMA journal_mode = {journal_mode};" + )) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to configure fresh database storage: {e}"), + operation: "apply_fresh_storage_pragmas".to_string(), + })?; + Ok(()) +} + +pub(super) async fn apply_read_only(conn: &Connection, db_file_size: u64) -> Result<()> { + let (cache_kb, _) = adaptive_cache_sizes(db_file_size); + // Maintenance read handles use the same non-mmap invariant. + let mmap = GRAPH_STORE_MMAP_SIZE; + conn.execute_batch(&format!( + "PRAGMA mmap_size = {mmap}; + PRAGMA foreign_keys = ON; + PRAGMA query_only = ON; + PRAGMA busy_timeout = 120000; + PRAGMA cache_size = -{cache_kb}; + PRAGMA temp_store = MEMORY;", + )) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to apply read-only pragmas: {e}"), + operation: "apply_read_only_pragmas".to_string(), + })?; + Ok(()) +} diff --git a/src/db/connection/registry.rs b/src/db/connection/registry.rs new file mode 100644 index 000000000..2b332614d --- /dev/null +++ b/src/db/connection/registry.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex, Weak}; + +use libsql::{Connection, Database as LibsqlDatabase}; + +use crate::db::DatabaseAuthority; + +pub(super) struct DatabaseInner { + pub(super) conn: Connection, + /// Kept alive so the underlying database is not dropped. + pub(super) _db: LibsqlDatabase, + pub(super) writable: bool, + pub(super) _authority: DatabaseAuthority, + pub(super) _slot: Option, +} + +pub(super) type DatabaseSlot = Arc>>; + +static OPEN_DATABASES: LazyLock< + Mutex>>>>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn database_slot(path: &Path) -> DatabaseSlot { + let mut databases = OPEN_DATABASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + databases.retain(|_, slot| slot.strong_count() > 0); + if let Some(slot) = databases.get(path).and_then(Weak::upgrade) { + return slot; + } + let slot = Arc::new(tokio::sync::Mutex::new(Weak::new())); + databases.insert(path.to_path_buf(), Arc::downgrade(&slot)); + slot +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 07f614fa8..d69ea340d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,4 @@ +mod access; mod analytics; mod connection; mod coverage; @@ -16,10 +17,12 @@ mod stats; mod tx; mod unresolved; +#[doc(hidden)] +pub use access::enter_maintenance_database_scope; +pub use access::{DatabaseAuthority, DatabaseAuthorityRole}; +pub(crate) use access::{WriterOwnership, enter_daemon_database_scope, probe_writer_owner}; pub use connection::{Database, SQLITE_UNSAFE_FAST_ENV}; -pub(crate) use connection::{ - platform_safe_journal_mode, platform_safe_mmap_size, platform_safe_synchronous_mode, -}; +pub(crate) use connection::{platform_safe_journal_mode, platform_safe_synchronous_mode}; pub use fingerprints::StoredFingerprint; pub use redundancy_pairs::{RedundancyPairRow, RedundancyPairWrite}; pub use search::DependencyImportUse; diff --git a/src/doctor.rs b/src/doctor.rs index 6217b0d60..6ce88c5e4 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -6,22 +6,22 @@ use std::path::{Component, Path, PathBuf}; use crate::agents::{self, DoctorCounters, HealthcheckContext}; -use crate::db::Database; use crate::display::{format_bytes, format_token_count}; -use crate::migrate::registry::code_project_root_exists; +#[cfg(test)] use crate::storage::StoreLayout; +#[cfg(test)] use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; pub mod heal; pub(crate) mod registry_drift; /// Runs a comprehensive health check of the tracedecay installation. -pub async fn run_doctor(agent_filter: Option<&str>) { +pub async fn run_doctor(agent_filter: Option<&str>) -> crate::errors::Result<()> { let _lifecycle_lease = match crate::lifecycle_lease::acquire_shared_or_inherited("doctor") { Ok(lease) => lease, Err(error) => { eprintln!("tracedecay doctor could not start: {error}"); - return; + return Err(error); } }; debug_assert!( @@ -39,30 +39,21 @@ pub async fn run_doctor(agent_filter: Option<&str>) { eprintln!("\n\x1b[1mCurrent project\x1b[0m"); let project_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let open_options = TraceDecayOpenOptions::default(); - match resolve_current_project_store(&project_path, &open_options).await { - Ok(CurrentProjectStore::Resolved(layout)) => { - dc.pass(&describe_resolved_store(&layout)); - check_database(&mut dc, &project_path, open_options.clone()).await; - } - Ok(CurrentProjectStore::LegacyRepoLocal) => { - dc.pass(&format!( - "Index found: {}/ (legacy repo-local store)", - crate::config::get_tracedecay_dir(&project_path).display() - )); - check_database(&mut dc, &project_path, open_options).await; - } - Ok(CurrentProjectStore::Uninitialized) => { - dc.warn(&format!( - "No index found for {} — run `tracedecay init`", - project_path.display() - )); + let daemon_status = daemon_project_status(&project_path).await; + let storage_healthy = match daemon_status.as_ref() { + Ok(status) => check_database(&mut dc, status), + Err(error) => { + report_daemon_diagnostics_unavailable( + &mut dc, + fallback_database_path(&project_path).as_deref(), + error, + ); + false } - Err(error) => dc.fail(&format!("Project storage resolution failed: {error}")), - } + }; check_global_db(&mut dc); - check_stale_stores(&mut dc).await; + check_stale_stores(&mut dc, daemon_status.as_ref().ok()); check_watcher(&mut dc); check_user_config(&mut dc); check_external_tools(&mut dc); @@ -95,6 +86,14 @@ pub async fn run_doctor(agent_filter: Option<&str>) { check_network(&mut dc); print_summary(&dc); + + match daemon_status { + Err(error) => Err(error), + Ok(_) if !storage_healthy => Err(crate::errors::TraceDecayError::Config { + message: "doctor storage health check failed".to_string(), + }), + Ok(_) => Ok(()), + } } /// Reports drift between the active managed-skill set and the host-loadable @@ -198,6 +197,7 @@ fn skill_drift_report( } /// How the doctor "Current project" check sees the working directory's store. +#[cfg(test)] #[derive(Debug)] enum CurrentProjectStore { /// A store resolved through the same registry/alias-aware path the tools @@ -209,6 +209,7 @@ enum CurrentProjectStore { Uninitialized, } +#[cfg(test)] async fn resolve_current_project_store( project_path: &Path, open_options: &TraceDecayOpenOptions, @@ -224,6 +225,7 @@ async fn resolve_current_project_store( Ok(CurrentProjectStore::Uninitialized) } +#[cfg(test)] fn describe_resolved_store(layout: &StoreLayout) -> String { let mode = match layout.storage_mode { crate::storage::StorageMode::ProjectLocal => "repo-local", @@ -240,66 +242,120 @@ fn describe_resolved_store(layout: &StoreLayout) -> String { ) } -/// Check database health without mutating a store that may be owned by the daemon. -/// -/// The DB path is taken from the opened instance so the size measured is the -/// same file that the active branch reader serves. -async fn check_database( - dc: &mut DoctorCounters, - project_path: &Path, - open_options: TraceDecayOpenOptions, -) { - let db_path = active_database_path(project_path, &open_options).await; - let ts = match TraceDecay::open_read_only_with_options(project_path, open_options).await { - Ok(ts) => ts, - Err(e) if Database::is_corruption_error(&e) => { - dc.fail(&format!("Database recovery required: {e}")); - if let Some(db_path) = db_path.as_deref() { - print_database_recovery_guidance(dc, db_path); - } else { - dc.info("TraceDecay could not resolve the damaged database path; no files were changed."); - } - return; - } - Err(e) => { - dc.fail(&format!("Could not open database read-only: {e}")); - return; - } - }; - let db_path = ts.db_path(); - let size_before = std::fs::metadata(&db_path).map_or(0, |m| m.len()); - - dc.pass(&format!("DB size: {}", format_bytes(size_before))); +async fn daemon_project_status(project_path: &Path) -> crate::errors::Result { + let handshake = crate::daemon::DaemonHandshake::for_current_client( + Some(project_path.to_path_buf()), + None, + false, + false, + )?; + let result = crate::daemon::call_default_tool( + &handshake, + "tracedecay_status", + serde_json::json!({ "format": "json" }), + ) + .await?; + daemon_tool_json(result) +} - match ts.quick_check().await { - Ok(true) => dc.pass("DB integrity: ok"), - Ok(false) => { +fn daemon_tool_json(result: serde_json::Value) -> crate::errors::Result { + let text = result + .get("content") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(|error| crate::errors::TraceDecayError::Config { + message: format!("daemon status returned invalid JSON: {error}"), + }) +} + +fn check_database(dc: &mut DoctorCounters, status: &serde_json::Value) -> bool { + let Some(storage) = status.get("storage_health") else { + dc.fail("Daemon status omitted storage health; doctor did not open SQLite"); + return false; + }; + let db_path = storage + .get("canonical_db_path") + .or_else(|| storage.get("db_path")) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from); + if let Some(path) = db_path.as_deref() { + dc.pass(&format!("Index found: {} (daemon-owned)", path.display())); + } + if let Some(size) = storage + .get("db_size_bytes") + .and_then(serde_json::Value::as_u64) + { + dc.pass(&format!("DB size: {}", format_bytes(size))); + } + let healthy = match storage + .get("quick_check_ok") + .and_then(serde_json::Value::as_bool) + { + Some(true) => { + dc.pass("DB integrity: ok (checked by daemon owner)"); + true + } + Some(false) => { dc.fail("Database integrity check failed; offline recovery is required"); - print_database_recovery_guidance(dc, &db_path); + if let Some(path) = db_path.as_deref() { + print_database_recovery_guidance(dc, path); + } + false } - Err(e) if Database::is_corruption_error(&e) => { - dc.fail(&format!("Database recovery required: {e}")); - print_database_recovery_guidance(dc, &db_path); + None => { + let detail = storage + .get("quick_check_error") + .and_then(serde_json::Value::as_str) + .unwrap_or("daemon did not return a quick_check result"); + dc.fail(&format!("Database diagnostics unavailable: {detail}")); + if let Some(path) = db_path.as_deref() { + print_database_recovery_guidance(dc, path); + } + false } - Err(e) => dc.warn(&format!( - "Could not complete read-only integrity check: {e}" - )), + }; + if storage + .pointer("/dirty_marker/exists") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + let state = storage + .pointer("/dirty_marker/state") + .and_then(serde_json::Value::as_str) + .unwrap_or("unparsed"); + dc.warn(&format!("Graph dirty marker present (state={state})")); } + healthy } -async fn active_database_path( - project_path: &Path, - open_options: &TraceDecayOpenOptions, -) -> Option { - if let Some(layout) = - TraceDecay::initialized_store_layout_with_options(project_path, open_options).await - { - let branch = crate::branch::current_branch(project_path); - return Some( - TraceDecay::resolve_db_for_branch(project_path, &layout.data_root, branch.as_deref()).0, - ); +fn report_daemon_diagnostics_unavailable( + dc: &mut DoctorCounters, + db_path: Option<&Path>, + error: &crate::errors::TraceDecayError, +) { + dc.fail(&format!( + "Database diagnostics unavailable from the sole daemon owner: {error}. Doctor did not open SQLite." + )); + if let Some(path) = db_path { + print_database_recovery_guidance(dc, path); + } else { + dc.info("The database path could not be resolved without opening registry SQLite; stop all TraceDecay processes and preserve the project store before repair."); } +} +fn fallback_database_path(project_path: &Path) -> Option { + if let Ok(Some(marker)) = crate::storage::read_enrollment_marker(project_path) { + if let Ok(profile_root) = crate::storage::default_profile_root() { + if let Ok(layout) = + crate::storage::profile_sharded_layout(project_path, &profile_root, &marker) + { + return Some(layout.graph_db_path); + } + } + } let data_root = crate::config::get_tracedecay_dir(project_path); let db_path = data_root.join(crate::config::db_filename(&data_root)); db_path.is_file().then_some(db_path) @@ -367,185 +423,29 @@ fn check_global_db(dc: &mut DoctorCounters) { } } -/// Lists projects registered in the global DB whose resolved data directory -/// is gone, and offers to purge them. Stale rows are harmless but show up in -/// `tracedecay list --all` and inflate the global tokens-saved count. -async fn check_stale_stores(dc: &mut DoctorCounters) { - use std::io::{IsTerminal, Write}; - - let Some(gdb) = crate::global_db::GlobalDb::open().await else { - return; - }; - let project_paths = gdb.list_project_paths().await; - let mut repo_local = 0usize; - let mut profile_sharded = 0usize; - let mut reconstructable = Vec::new(); - let mut stale = Vec::new(); - - let profile_root = crate::config::user_data_dir(); - for project_path in &project_paths { - match classify_project_storage_with_registry( - Path::new(project_path), - &gdb, - profile_root.as_deref(), - ) - .await - { - DoctorStorageStatus::RepoLocal => repo_local += 1, - DoctorStorageStatus::ProfileSharded => profile_sharded += 1, - DoctorStorageStatus::ManifestReconstructable => { - reconstructable.push(project_path.clone()); - } - DoctorStorageStatus::Stale => stale.push(project_path.clone()), - } - } - - dc.pass(&format!( - "Storage registry: {repo_local} repo-local, {profile_sharded} profile-sharded" - )); - if !reconstructable.is_empty() { - dc.warn(&format!( - "{} manifest-reconstructable project(s) need registry repair", - reconstructable.len() - )); - for p in reconstructable.iter().take(10) { - dc.info(&format!(" • {p}")); - } - } - - check_orphan_store_manifests(dc, &gdb).await; - check_stale_code_projects(dc, &gdb).await; - if let Some(profile_root) = profile_root.as_deref() { - let drift = registry_drift::registry_drift_findings(&gdb, profile_root).await; - if drift.is_empty() { - dc.pass("No registry/store manifest identity drift"); - } else { - dc.warn(&format!( - "{} registry/store manifest identity drift finding(s):", - drift.len() - )); - for finding in drift.iter().take(10) { - dc.info(&format!( - " • {} {} {}: registry={} manifest={} ({})", - finding.project_id, - finding.store_id, - finding.field, - finding.registry_value, - finding.manifest_value, - finding.manifest_path.display() - )); - } - if drift.len() > 10 { - dc.info(&format!(" … and {} more", drift.len() - 10)); - } - } - } - if stale.is_empty() { - dc.pass("No stale projects in global DB"); - return; - } - - eprintln!( - " \x1b[33m!\x1b[0m {} stale project(s) in global DB (registered but the data dir is gone):", - stale.len() - ); - let preview = stale.len().min(10); - for p in &stale[..preview] { - dc.info(&format!(" • {p}")); - } - if stale.len() > preview { - dc.info(&format!(" … and {} more", stale.len() - preview)); - } - - if !std::io::stdin().is_terminal() { - dc.warnings += 1; - dc.info( - " Run `tracedecay migrate registry-gc --json` to preview, then add `--apply` to purge metadata only.", - ); - return; - } - - eprint!( - " Purge {} stale row(s) from the global DB? [Y/n] ", - stale.len() - ); - std::io::stderr().flush().ok(); - let mut answer = String::new(); - if std::io::stdin().read_line(&mut answer).is_err() { - dc.warnings += 1; - return; - } - let answer = answer.trim(); - if !answer.is_empty() && !answer.eq_ignore_ascii_case("y") { - dc.warnings += 1; - dc.info("Skipped — run again later to purge."); - return; - } - - let purged = gdb.delete_projects(&stale).await; - dc.pass(&format!("Purged {purged} stale project(s)")); -} - -async fn check_stale_code_projects(dc: &mut DoctorCounters, gdb: &crate::global_db::GlobalDb) { - use std::io::{IsTerminal, Write}; - - let stale: Vec<_> = gdb - .list_code_projects(usize::MAX) - .await - .into_iter() - .filter(|project| !code_project_root_exists(project)) - .collect(); - - if stale.is_empty() { - dc.pass("No stale code project registry rows"); - return; - } - - dc.warn(&format!( - "{} stale code project registry row(s) (registered but project root is gone):", - stale.len() - )); - let preview = stale.len().min(10); - for project in &stale[..preview] { - dc.info(&format!( - " • {} ({})", - project.project_id, project.display_root +/// Registry SQLite is owned by the daemon. The external doctor reports that +/// ownership and leaves stale-row inspection/repair to daemon-backed tools. +fn check_stale_stores(dc: &mut DoctorCounters, status: Option<&serde_json::Value>) { + eprintln!("\n\x1b[1mStorage registry\x1b[0m"); + if let Some(storage) = status.and_then(|value| value.get("storage_health")) { + let owner = storage + .get("daemon_owner_pid") + .and_then(serde_json::Value::as_u64) + .map_or_else(|| "unknown".to_string(), |pid| pid.to_string()); + let generation = storage + .get("daemon_generation") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + dc.pass(&format!( + "Registry/database inspection delegated to daemon owner pid={owner}, generation={generation}" )); + } else { + dc.warn("Registry diagnostics unavailable because the daemon owner did not answer; doctor did not open the global DB"); } - if stale.len() > preview { - dc.info(&format!(" … and {} more", stale.len() - preview)); - } - - if !std::io::stdin().is_terminal() { - dc.info(" Re-run `tracedecay doctor` interactively to purge registry rows."); - return; - } - - eprint!( - " Purge {} stale code project registry row(s)? [Y/n] ", - stale.len() - ); - std::io::stderr().flush().ok(); - let mut answer = String::new(); - if std::io::stdin().read_line(&mut answer).is_err() { - return; - } - let answer = answer.trim(); - if !answer.is_empty() && !answer.eq_ignore_ascii_case("y") { - dc.info("Skipped code project registry purge."); - return; - } - - let project_ids: Vec = stale - .into_iter() - .map(|project| project.project_id) - .collect(); - let purged = gdb.delete_code_projects(&project_ids).await; - dc.pass(&format!( - "Purged {purged} stale code project registry row(s)" - )); + dc.info("Use `tracedecay projects list` for daemon-backed registry inspection and `tracedecay migrate registry-gc --json` to preview explicit offline cleanup."); } +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DoctorStorageStatus { RepoLocal, @@ -554,6 +454,7 @@ enum DoctorStorageStatus { Stale, } +#[cfg(test)] fn classify_project_storage(project_root: &Path) -> DoctorStorageStatus { let Ok(layout) = crate::storage::resolve_layout_for_current_profile(project_root) else { return DoctorStorageStatus::Stale; @@ -575,6 +476,7 @@ fn classify_project_storage(project_root: &Path) -> DoctorStorageStatus { } } +#[cfg(test)] async fn classify_project_storage_with_registry( project_root: &Path, global_db: &crate::global_db::GlobalDb, @@ -593,6 +495,7 @@ async fn classify_project_storage_with_registry( classify_registry_storage(profile_root, &resolution.store).unwrap_or(status) } +#[cfg(test)] fn classify_registry_storage( profile_root: &Path, store: &crate::global_db::StoreInstanceRecord, @@ -618,12 +521,14 @@ fn classify_registry_storage( } } +#[cfg(test)] #[derive(Debug, Clone)] struct RegistryStoreArtifacts { graph_db_path: PathBuf, manifest_path: Option, } +#[cfg(test)] fn registry_store_artifacts( profile_root: &Path, store: &crate::global_db::StoreInstanceRecord, @@ -656,6 +561,7 @@ fn registry_store_artifacts( artifacts } +#[cfg(test)] fn registry_manifest_path( profile_root: &Path, data_root: &Path, @@ -698,25 +604,6 @@ fn registry_profile_roots(profile_root: &Path) -> Vec { roots } -async fn check_orphan_store_manifests( - dc: &mut DoctorCounters, - global_db: &crate::global_db::GlobalDb, -) { - let Some(profile_root) = crate::config::user_data_dir() else { - return; - }; - let (orphan_count, issues) = orphan_store_manifest_report(global_db, &profile_root).await; - for issue in issues.iter().take(10) { - dc.warn(&format!("Store manifest issue: {issue}")); - } - if orphan_count > 0 { - dc.warn(&format!( - "{orphan_count} orphan profile store manifest(s) can reconstruct registry rows" - )); - dc.info(" Run `tracedecay migrate reconstruct --profile-root --apply` after review."); - } -} - /// Counts profile store manifests with no matching registry row, plus any /// manifest scan issues. Shared between `doctor` and the post-update health /// pass. diff --git a/src/doctor/heal.rs b/src/doctor/heal.rs index f34e148c0..60e708512 100644 --- a/src/doctor/heal.rs +++ b/src/doctor/heal.rs @@ -34,6 +34,10 @@ use crate::global_db::{CodeProjectRecord, GlobalDb}; use crate::migrate::registry::{StaleRootScope, code_project_root_exists, stale_code_projects}; use crate::storage::{BRANCH_META_FILENAME, BRANCH_META_QUARANTINE_PREFIX}; +mod report; + +use report::{render_health_pass_report, render_missing_profile_report, render_warnings}; + /// A corrupt `branch-meta.json` that was renamed out of the way. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BranchMetaQuarantine { @@ -76,6 +80,23 @@ pub async fn run_post_update_health_pass_under_lease( render_warnings(&report.warnings); return report; } + let _database_scope = match crate::db::enter_maintenance_database_scope( + lifecycle_lease, + &profile_root, + "post-update health pass", + ) { + Ok(scope) => scope, + Err(error) => { + let report = HealthPassReport { + warnings: vec![format!( + "could not enter maintenance database scope for the post-update health pass: {error}" + )], + ..HealthPassReport::default() + }; + render_warnings(&report.warnings); + return report; + } + }; run_post_update_health_pass_for_profile(&profile_root).await } @@ -105,15 +126,6 @@ async fn run_post_update_health_pass_for_profile(profile_root: &Path) -> HealthP report } -fn render_missing_profile_report() -> HealthPassReport { - let report = HealthPassReport { - warnings: vec!["could not determine the profile data directory".to_string()], - ..HealthPassReport::default() - }; - render_warnings(&report.warnings); - report -} - /// Applies the safe remedies and gathers everything the pass has to say into /// a [`HealthPassReport`], without printing anything. async fn compute_health_pass_report(profile_root: &Path) -> HealthPassReport { @@ -129,11 +141,20 @@ async fn compute_health_pass_report(profile_root: &Path) -> HealthPassReport { // Opening the global DB applies its idempotent schema migrations — the // same lazy upgrade every normal open path performs. - let Some(global_db) = GlobalDb::open().await else { - report - .warnings - .push("could not open the global DB for the health pass".to_string()); - return report; + let global_db = match GlobalDb::try_open().await { + Ok(Some(global_db)) => global_db, + Ok(None) => { + report + .warnings + .push("could not open the global DB for the health pass".to_string()); + return report; + } + Err(error) => { + report.warnings.push(format!( + "could not open the global DB for the health pass: {error}" + )); + return report; + } }; // One registry snapshot for the whole pass: the GC and the remaining @@ -175,78 +196,6 @@ async fn retire_completed_consolidation_manifests( report.warnings.extend(retirement.warnings); } -/// Prints the doctor-style summary for a computed report. -fn render_health_pass_report(report: &HealthPassReport) { - if report.retired_consolidation_manifests.is_empty() { - eprintln!(" \x1b[32m✔\x1b[0m No completed consolidation manifests to retire"); - } else { - eprintln!( - " \x1b[32m✔\x1b[0m Retired {} completed consolidation input manifest(s):", - report.retired_consolidation_manifests.len() - ); - for path in &report.retired_consolidation_manifests { - eprintln!(" • {}", path.display()); - } - } - if report.retired_consolidation_registry_projects > 0 { - eprintln!( - " \x1b[32m✔\x1b[0m Retired {} superseded consolidation registry project(s)", - report.retired_consolidation_registry_projects - ); - } - - if report.quarantined_branch_meta.is_empty() { - eprintln!(" \x1b[32m✔\x1b[0m No corrupt branch metadata files"); - } else { - eprintln!( - " \x1b[32m✔\x1b[0m Quarantined {} corrupt branch metadata file(s):", - report.quarantined_branch_meta.len() - ); - for quarantine in &report.quarantined_branch_meta { - eprintln!(" • {}", quarantine.quarantined.display()); - } - } - - match report.purged_temp_registry_rows { - Some(0) => eprintln!(" \x1b[32m✔\x1b[0m No stale temp-root registry rows"), - Some(purged) => { - eprintln!(" \x1b[32m✔\x1b[0m Purged {purged} stale temp-root registry row(s)"); - } - None => {} - } - - if report.reconciled_store_roots.is_empty() { - eprintln!(" \x1b[32m✔\x1b[0m No stale store manifest roots to reconcile"); - } else { - eprintln!( - " \x1b[32m✔\x1b[0m Reconciled {} stale store manifest root(s):", - report.reconciled_store_roots.len() - ); - for reconciled in &report.reconciled_store_roots { - eprintln!(" • {}", reconciled.manifest_path.display()); - if let Some(config_path) = &reconciled.config_path { - eprintln!(" (config: {})", config_path.display()); - } - } - } - - if report.remaining_findings.is_empty() { - eprintln!(" \x1b[32m✔\x1b[0m No remaining doctor findings"); - } else { - eprintln!(" Remaining findings (not auto-fixed — run `tracedecay doctor` for details):"); - for finding in &report.remaining_findings { - eprintln!(" • {finding}"); - } - } - render_warnings(&report.warnings); -} - -fn render_warnings(warnings: &[String]) { - for warning in warnings { - eprintln!(" \x1b[33mwarning:\x1b[0m health pass: {warning}"); - } -} - /// Renames every `branch-meta.json` under `/projects/*` that is /// not a regular file or that [`crate::branch_meta::parse`] rejects. This is /// the runtime's own definition of corrupt, covering invalid JSON, schema @@ -406,6 +355,33 @@ fn count_remaining_registry_drift( mod tests { use super::*; + struct ClearedUserDataDir { + _lock: std::sync::MutexGuard<'static, ()>, + previous: Option, + } + + impl ClearedUserDataDir { + fn new() -> Self { + let lock = crate::config::lock_user_data_dir_test_env(); + let previous = std::env::var_os(crate::config::USER_DATA_DIR_ENV); + unsafe { std::env::remove_var(crate::config::USER_DATA_DIR_ENV) }; + Self { + _lock: lock, + previous, + } + } + } + + impl Drop for ClearedUserDataDir { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + unsafe { std::env::set_var(crate::config::USER_DATA_DIR_ENV, previous) }; + } else { + unsafe { std::env::remove_var(crate::config::USER_DATA_DIR_ENV) }; + } + } + } + fn write_branch_meta(projects_root: &Path, project_id: &str, content: &str) -> PathBuf { let shard = projects_root.join(project_id); std::fs::create_dir_all(&shard).unwrap(); @@ -585,11 +561,63 @@ mod tests { assert!(health_pass_lease_error(&exclusive, &guarded_profile, false).is_none()); } + #[test] + fn maintenance_scope_authorizes_health_pass_global_db_open() { + let _data_dir = ClearedUserDataDir::new(); + let base = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let dir = tempfile::Builder::new() + .prefix("doctor-heal-scope-") + .tempdir_in(base) + .unwrap(); + let profile = dir.path().join("profile"); + std::fs::create_dir_all(&profile).unwrap(); + unsafe { std::env::set_var(crate::config::USER_DATA_DIR_ENV, &profile) }; + let profile = crate::config::user_data_dir().unwrap(); + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &profile, + "maintenance scope healer test", + ) + .unwrap(); + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, + &profile, + "post-update health pass", + ) + .unwrap(); + let db_path = profile.join("global.db"); + let authority = crate::db::DatabaseAuthority::for_runtime( + &db_path, + "open global database for health pass", + ) + .unwrap(); + + assert_eq!( + authority.role(), + crate::db::DatabaseAuthorityRole::Maintenance + ); + } + #[tokio::test] async fn post_update_retires_applied_consolidation_manifests_idempotently() { - let dir = tempfile::TempDir::new().unwrap(); + let _data_dir = ClearedUserDataDir::new(); + let base = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let dir = tempfile::Builder::new() + .prefix("doctor-heal-retirement-") + .tempdir_in(base) + .unwrap(); let project = dir.path().join("repo"); let profile = dir.path().join("profile"); + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &profile, + "post-update healer test", + ) + .unwrap(); + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, + &profile, + "post-update health pass", + ) + .unwrap(); std::fs::create_dir_all(&project).unwrap(); let status = std::process::Command::new("git") .args(["init", "--quiet"]) @@ -691,11 +719,6 @@ mod tests { global.checkpoint().await; global.close(); - let _lease = crate::lifecycle_lease::acquire_exclusive_for_profile( - &profile, - "post-update healer test", - ) - .unwrap(); std::fs::write( profile .join("migration-inventory") diff --git a/src/doctor/heal/report.rs b/src/doctor/heal/report.rs new file mode 100644 index 000000000..9d51b78de --- /dev/null +++ b/src/doctor/heal/report.rs @@ -0,0 +1,82 @@ +use super::HealthPassReport; + +pub(super) fn render_missing_profile_report() -> HealthPassReport { + let report = HealthPassReport { + warnings: vec!["could not determine the profile data directory".to_string()], + ..HealthPassReport::default() + }; + render_warnings(&report.warnings); + report +} + +/// Prints the doctor-style summary for a computed report. +pub(super) fn render_health_pass_report(report: &HealthPassReport) { + if report.retired_consolidation_manifests.is_empty() { + eprintln!(" \x1b[32m✔\x1b[0m No completed consolidation manifests to retire"); + } else { + eprintln!( + " \x1b[32m✔\x1b[0m Retired {} completed consolidation input manifest(s):", + report.retired_consolidation_manifests.len() + ); + for path in &report.retired_consolidation_manifests { + eprintln!(" • {}", path.display()); + } + } + if report.retired_consolidation_registry_projects > 0 { + eprintln!( + " \x1b[32m✔\x1b[0m Retired {} superseded consolidation registry project(s)", + report.retired_consolidation_registry_projects + ); + } + + if report.quarantined_branch_meta.is_empty() { + eprintln!(" \x1b[32m✔\x1b[0m No corrupt branch metadata files"); + } else { + eprintln!( + " \x1b[32m✔\x1b[0m Quarantined {} corrupt branch metadata file(s):", + report.quarantined_branch_meta.len() + ); + for quarantine in &report.quarantined_branch_meta { + eprintln!(" • {}", quarantine.quarantined.display()); + } + } + + match report.purged_temp_registry_rows { + Some(0) => eprintln!(" \x1b[32m✔\x1b[0m No stale temp-root registry rows"), + Some(purged) => { + eprintln!(" \x1b[32m✔\x1b[0m Purged {purged} stale temp-root registry row(s)"); + } + None => {} + } + + if report.reconciled_store_roots.is_empty() { + eprintln!(" \x1b[32m✔\x1b[0m No stale store manifest roots to reconcile"); + } else { + eprintln!( + " \x1b[32m✔\x1b[0m Reconciled {} stale store manifest root(s):", + report.reconciled_store_roots.len() + ); + for reconciled in &report.reconciled_store_roots { + eprintln!(" • {}", reconciled.manifest_path.display()); + if let Some(config_path) = &reconciled.config_path { + eprintln!(" (config: {})", config_path.display()); + } + } + } + + if report.remaining_findings.is_empty() { + eprintln!(" \x1b[32m✔\x1b[0m No remaining doctor findings"); + } else { + eprintln!(" Remaining findings (not auto-fixed — run `tracedecay doctor` for details):"); + for finding in &report.remaining_findings { + eprintln!(" • {finding}"); + } + } + render_warnings(&report.warnings); +} + +pub(super) fn render_warnings(warnings: &[String]) { + for warning in warnings { + eprintln!(" \x1b[33mwarning:\x1b[0m health pass: {warning}"); + } +} diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index 7f36df691..06361fffe 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -39,6 +39,10 @@ async fn orphan_reporting_uses_complete_registry_rows_not_token_accounting() { .prefix("doctor-orphans-") .tempdir_in(base) .unwrap(); + let db_dir = tempfile::Builder::new() + .prefix("doctor-orphans-db-") + .tempdir() + .unwrap(); let profile_root = dir.path().join("profile"); let eligible_root = dir.path().join("eligible-repo"); let conflicting_root = dir.path().join("conflicting-repo"); @@ -99,7 +103,7 @@ async fn orphan_reporting_uses_complete_registry_rows_not_token_accounting() { .unwrap(); } - let db = crate::global_db::GlobalDb::open_at(&dir.path().join("global.db")) + let db = crate::global_db::GlobalDb::open_at(&db_dir.path().join("global.db")) .await .unwrap(); db.upsert_code_project( @@ -260,8 +264,19 @@ async fn database_check_preserves_corrupt_graph_and_adjacent_stores() std::fs::write(&layout.sessions_db_path, b"preserve-sessions")?; let mut counters = DoctorCounters::new(); - check_database(&mut counters, &project_root, open_options).await; + let healthy = check_database( + &mut counters, + &serde_json::json!({ + "storage_health": { + "canonical_db_path": layout.graph_db_path, + "db_size_bytes": corrupt_db.len(), + "quick_check_ok": false, + "dirty_marker": { "exists": true, "state": "dirty" }, + } + }), + ); + assert!(!healthy); assert_eq!(counters.issues, 1); assert_eq!(std::fs::read(&layout.graph_db_path)?, corrupt_db); assert_eq!(std::fs::read(&wal_path)?, b"preserve-wal"); @@ -289,7 +304,8 @@ async fn database_check_is_read_only_while_a_writer_is_live() let db_path = ts.db_path(); drop(ts); - let (writer, _) = crate::db::Database::open(&db_path).await?; + let authority = crate::db::DatabaseAuthority::acquire_test(&db_path, "doctor test")?; + let (writer, _) = crate::db::Database::open(&db_path, &authority).await?; writer .conn() .execute_batch( @@ -313,7 +329,20 @@ async fn database_check_is_read_only_while_a_writer_is_live() ); let mut counters = DoctorCounters::new(); - check_database(&mut counters, &project_root, open_options).await; + let healthy = check_database( + &mut counters, + &serde_json::json!({ + "storage_health": { + "canonical_db_path": db_path, + "db_size_bytes": std::fs::metadata(&db_path)?.len(), + "quick_check_ok": true, + "dirty_marker": { "exists": false }, + "daemon_owner_pid": std::process::id(), + "daemon_generation": "test-generation", + } + }), + ); + assert!(healthy); let freelist_after: i64 = { let mut rows = writer.conn().query("PRAGMA freelist_count", ()).await?; @@ -477,7 +506,11 @@ async fn current_project_store_surfaces_split_identity_conflict() storage_mode: StorageMode::ProfileSharded, }, )?; - let (db, _) = crate::db::Database::initialize(&layout.graph_db_path).await?; + let authority = crate::db::DatabaseAuthority::acquire_test( + &layout.graph_db_path, + "doctor identity test", + )?; + let (db, _) = crate::db::Database::initialize(&layout.graph_db_path, &authority).await?; db.insert_node(&crate::types::Node { id: node_id.to_string(), kind: crate::types::NodeKind::Function, @@ -807,3 +840,29 @@ fn plain_orphan_still_warns_with_update_remediation() { "plain orphan should still prescribe update: {msg}" ); } + +#[test] +fn daemon_status_parser_extracts_storage_health() { + let parsed = super::daemon_tool_json(serde_json::json!({ + "content": [{ + "type": "text", + "text": r#"{"storage_health":{"quick_check_ok":true,"daemon_generation":"run-7"}}"# + }] + })) + .unwrap(); + + assert_eq!( + parsed.pointer("/storage_health/quick_check_ok"), + Some(&serde_json::Value::Bool(true)) + ); + assert_eq!( + parsed.pointer("/storage_health/daemon_generation"), + Some(&serde_json::Value::String("run-7".to_string())) + ); +} + +#[test] +fn daemon_status_parser_rejects_missing_json_payload() { + let error = super::daemon_tool_json(serde_json::json!({ "content": [] })).unwrap_err(); + assert!(error.to_string().contains("invalid JSON")); +} diff --git a/src/global.rs b/src/global.rs index 3edc1c158..bbd3c4658 100644 --- a/src/global.rs +++ b/src/global.rs @@ -1,7 +1,6 @@ use std::path::Path; use crate::current_unix_timestamp; -use tracedecay::tracedecay::TraceDecay; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ProjectStorageStatus { @@ -203,37 +202,6 @@ fn elapsed_since(now: i64, recorded_at: i64) -> i64 { } } -/// Best-effort: register this project in the user-level global DB and -/// accumulate the token savings delta into the pending upload counter. -pub(crate) async fn update_global_db(cg: &TraceDecay) { - if !tracedecay::user_config::UserConfig::exists() { - return; - } - let tokens = match cg.get_tokens_saved().await { - Ok(tokens) => tokens, - Err(err) => { - eprintln!( - "[tracedecay] failed to read tokens-saved counter for {}: {err}", - cg.project_root().display() - ); - return; - } - }; - if let Some(gdb) = tracedecay::global_db::GlobalDb::open().await { - let previous = gdb.get_project_tokens(cg.project_root()).await; - gdb.upsert(cg.project_root(), tokens).await; - - // Accumulate delta into pending upload - if tokens > previous { - let mut config = tracedecay::user_config::UserConfig::load(); - config.pending_upload += tokens - previous; - if let Err(err) = config.save_if_exists() { - eprintln!("warning: could not save tracedecay config: {err}"); - } - } - } -} - /// Best-effort: try to flush pending tokens to the worldwide counter. /// `force` = true on status/sync commands (always attempt), false on others /// (only flush if stale > 30s). @@ -351,12 +319,27 @@ pub(crate) async fn gather_target_projects( home_tracedecay: &Option, ) -> Vec { if all { - let Some(gdb) = tracedecay::global_db::GlobalDb::open().await else { + let Ok(cwd) = std::env::current_dir() else { + return Vec::new(); + }; + let project_root = tracedecay::config::discover_project_root(&cwd); + let Ok(payload) = call_admin_cli( + project_root.as_deref(), + serde_json::json!({ + "action": "registry_list", + "limit": 100_000, + "query": null, + }), + ) + .await + else { return Vec::new(); }; - gdb.list_project_paths() - .await + payload["projects"] + .as_array() .into_iter() + .flatten() + .filter_map(|project| project["project_root"].as_str()) .map(std::path::PathBuf::from) .collect() } else { @@ -364,6 +347,29 @@ pub(crate) async fn gather_target_projects( } } +async fn call_admin_cli( + project_root: Option<&Path>, + arguments: serde_json::Value, +) -> tracedecay::errors::Result { + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + project_root.map(Path::to_path_buf), + None, + false, + false, + )?; + let result = + tracedecay::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments) + .await?; + let text = result + .get("content") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(Into::into) +} + /// Returns project roots whose `.tracedecay` data dir lives in cwd, an /// ancestor, or a descendant. pub(crate) fn gather_local_projects( diff --git a/src/global_db.rs b/src/global_db.rs index d3446c750..cc187ce03 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -7,11 +7,14 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt::Write as _; +use std::ops::Deref; use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Weak}; use libsql::{Builder, Connection, Database as LibsqlDatabase, OpenFlags, Value, params}; use serde_json::Value as JsonValue; +use crate::db::DatabaseAuthority; use crate::sessions::{ SessionMessageRecord, SessionMessageSearchResult, SessionRecord, SessionSearchFilters, lcm::{ @@ -306,10 +309,50 @@ enum TranscriptWriteMode { /// User-level database tracking all `TraceDecay` projects. pub struct GlobalDb { + inner: Arc, +} + +#[doc(hidden)] +pub struct GlobalDbInner { conn: Connection, storage_root: PathBuf, db_path: PathBuf, _db: LibsqlDatabase, + _authority: DatabaseAuthority, + _slot: Option, +} + +impl Deref for GlobalDb { + type Target = GlobalDbInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[derive(Default)] +struct GlobalDbSchemaState { + ensured: bool, +} + +type GlobalDbSlot = Arc>; + +static GLOBAL_DB_SLOTS: LazyLock< + std::sync::Mutex>>>, +> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn global_db_slot(authority: &DatabaseAuthority) -> GlobalDbSlot { + let identity = authority.canonical_database_path(); + let mut slots = GLOBAL_DB_SLOTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slots.retain(|_, slot| slot.strong_count() > 0); + if let Some(slot) = slots.get(identity).and_then(Weak::upgrade) { + return slot; + } + let slot = Arc::new(tokio::sync::Mutex::new(GlobalDbSchemaState::default())); + slots.insert(identity.to_path_buf(), Arc::downgrade(&slot)); + slot } struct TranscriptSummarySources { @@ -339,11 +382,8 @@ fn global_db_path_override() -> Option { .map(PathBuf::from) } -fn global_db_mmap_size_guard() -> Option { - const PROBE_MMAP_SIZE: u64 = 1; - - let safe_mmap = crate::db::platform_safe_mmap_size(PROBE_MMAP_SIZE); - (safe_mmap != PROBE_MMAP_SIZE).then_some(safe_mmap) +fn global_db_mmap_size_guard() -> u64 { + 0 } /// Returns the path to the global database: `global.db` inside the user-level @@ -924,24 +964,32 @@ impl GlobalDb { &self.db_path } - async fn open_local(db_path: &Path, read_only: bool) -> Option { + async fn open_local( + db_path: &Path, + read_only: bool, + authority: DatabaseAuthority, + slot: Option, + ) -> Option { + let authority = authority.hold_for(db_path, "open global database").ok()?; + let db_path = authority.canonical_database_path().to_path_buf(); let storage_root = db_path .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); let builder = if read_only { - Builder::new_local(db_path).flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + Builder::new_local(&db_path).flags(OpenFlags::SQLITE_OPEN_READ_ONLY) } else { - Builder::new_local(db_path) + Builder::new_local(&db_path) }; let db = builder.build().await.ok()?; let conn = db.connect().ok()?; - if let Some(mmap_size) = global_db_mmap_size_guard() { - conn.execute_batch(&format!("PRAGMA mmap_size = {mmap_size};")) - .await - .ok()?; - } + conn.execute_batch(&format!( + "PRAGMA mmap_size = {};", + global_db_mmap_size_guard() + )) + .await + .ok()?; let pragmas = if read_only { "PRAGMA busy_timeout = 5000; @@ -960,10 +1008,14 @@ impl GlobalDb { conn.execute_batch(&pragmas).await.ok()?; Some(Self { - conn, - storage_root, - db_path: db_path.to_path_buf(), - _db: db, + inner: Arc::new(GlobalDbInner { + conn, + storage_root, + db_path, + _db: db, + _authority: authority, + _slot: slot, + }), }) } @@ -974,46 +1026,92 @@ impl GlobalDb { /// other's `PRAGMA journal_mode = WAL`, DDL batch, and migration /// transactions: all but one connection silently got `None`, which /// disabled global accounting (ledger recording) for the unlucky - /// callers' entire session. Opens are rare, so they are serialized - /// in-process and retried briefly to also cover a racing *external* - /// process (e.g. two MCP servers starting simultaneously). + /// callers' entire session. Schema initialization is singleflight per + /// canonical database identity; after it completes, every caller opens an + /// independent connection so caller-managed transactions cannot interleave + /// on one shared libSQL session. SQLite still serializes actual writers. pub async fn open_at(db_path: &std::path::Path) -> Option { - Self::open_at_with_backfill(db_path, true).await + Self::best_effort_open(Self::try_open_at(db_path).await) + } + + /// Result-preserving counterpart to [`Self::open_at`]. Authority failures + /// retain their exact ownership/profile diagnostic; storage-open failures + /// remain `Ok(None)` under the global database's best-effort contract. + pub async fn try_open_at(db_path: &std::path::Path) -> crate::errors::Result> { + Self::try_open_at_with_backfill(db_path, true).await } /// Opens and ensures a writable session store without starting detached /// structured backfill. Bulk multi-store catch-up uses this to avoid /// launching one competing backfill task per registered project. pub async fn open_at_without_structured_backfill(db_path: &std::path::Path) -> Option { - Self::open_at_with_backfill(db_path, false).await + Self::best_effort_open(Self::try_open_at_without_structured_backfill(db_path).await) } - async fn open_at_with_backfill( + /// Result-preserving counterpart to + /// [`Self::open_at_without_structured_backfill`]. + pub async fn try_open_at_without_structured_backfill( + db_path: &std::path::Path, + ) -> crate::errors::Result> { + Self::try_open_at_with_backfill(db_path, false).await + } + + fn best_effort_open(result: crate::errors::Result>) -> Option { + match result { + Ok(db) => db, + Err(error) => { + eprintln!("[tracedecay] global database open rejected: {error}"); + None + } + } + } + + async fn try_open_at_with_backfill( db_path: &std::path::Path, spawn_structured_backfill: bool, - ) -> Option { - static OPEN_ENSURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - let _guard = OPEN_ENSURE_LOCK.lock().await; - for attempt in 0..3_u64 { - if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_millis(50 * attempt)).await; + ) -> crate::errors::Result> { + let authority = DatabaseAuthority::for_runtime(db_path, "open global database")?; + let canonical_path = authority.canonical_database_path().to_path_buf(); + let slot = global_db_slot(&authority); + let mut schema = slot.lock().await; + if schema.ensured { + drop(schema); + let Some(db) = + Self::open_local(&canonical_path, false, authority, Some(Arc::clone(&slot))).await + else { + return Ok(None); + }; + if spawn_structured_backfill { + db.spawn_structured_backfill(); } - if let Some(db) = Self::open_at_unsynchronized(db_path, spawn_structured_backfill).await - { - return Some(db); + return Ok(Some(db)); + } + if let Some(parent) = canonical_path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return Ok(None); } } - None + let Some(db) = Self::open_at_unsynchronized( + &canonical_path, + spawn_structured_backfill, + authority, + Arc::clone(&slot), + ) + .await + else { + return Ok(None); + }; + schema.ensured = true; + Ok(Some(db)) } async fn open_at_unsynchronized( db_path: &std::path::Path, spawn_structured_backfill: bool, + authority: DatabaseAuthority, + slot: GlobalDbSlot, ) -> Option { - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).ok()?; - } - let db = Self::open_local(db_path, false).await?; + let db = Self::open_local(db_path, false, authority, Some(slot)).await?; db.conn .execute_batch( @@ -1241,11 +1339,16 @@ impl GlobalDb { /// DDL batch and LCM migrations while still applying the per-connection /// PRAGMAs. Long-lived servers use this to avoid re-paying the schema /// ensure on every tool call (the caller tracks which paths are ensured). + /// This raw open never participates in or updates the full-open schema + /// slot, so it cannot make a later [`Self::open_at`] skip initialization. pub async fn open_at_assuming_schema(db_path: &std::path::Path) -> Option { if !db_path.is_file() { return None; } - Self::open_local(db_path, false).await + let authority = + DatabaseAuthority::for_runtime(db_path, "open global database assuming schema").ok()?; + let canonical_path = authority.canonical_database_path().to_path_buf(); + Self::open_local(&canonical_path, false, authority, None).await } /// Opens an existing database without creating directories, creating schema, @@ -1254,14 +1357,24 @@ impl GlobalDb { if !db_path.is_file() { return None; } - Self::open_local(db_path, true).await + let authority = + DatabaseAuthority::for_runtime(db_path, "open global database read-only").ok()?; + let canonical_path = authority.canonical_database_path().to_path_buf(); + Self::open_local(&canonical_path, true, authority, None).await } /// Opens (or creates) the global database. Returns `None` if the home /// directory cannot be determined or the DB fails to open. pub async fn open() -> Option { - let db_path = global_db_path()?; - Self::open_at(&db_path).await + Self::best_effort_open(Self::try_open().await) + } + + /// Result-preserving counterpart to [`Self::open`]. + pub async fn try_open() -> crate::errors::Result> { + let Some(db_path) = global_db_path() else { + return Ok(None); + }; + Self::try_open_at(&db_path).await } /// Raw connection for crate-internal read layers (the dashboard HTTP @@ -1309,9 +1422,9 @@ impl GlobalDb { } } tokio::spawn(async move { - // Re-open an independent handle to the same store (the scheduling - // open already ensured its schema) so the sweep never shares the - // connection handed back to the caller. + // The scheduling open already ensured the schema. Use a separate + // raw connection so backfill transactions never share the + // caller's libSQL session or publish schema state. if let Some(db) = GlobalDb::open_at_assuming_schema(&db_path).await { let _ = crate::sessions::transcript_backfill::backfill_structured_rows(&db).await; } @@ -4988,7 +5101,7 @@ impl GlobalDb { /// Consumes the `GlobalDb`, closing the underlying connection. pub fn close(self) { - drop(self.conn); + drop(self); } } #[cfg(test)] diff --git a/src/global_db/tests.rs b/src/global_db/tests.rs index 64cfbbb34..84fdfef9c 100644 --- a/src/global_db/tests.rs +++ b/src/global_db/tests.rs @@ -1,12 +1,177 @@ use super::*; #[test] -fn global_db_mmap_guard_matches_connection_platform_guard() { - if cfg!(windows) { - assert_eq!(global_db_mmap_size_guard(), Some(0)); - } else { - assert_eq!(global_db_mmap_size_guard(), None); +fn global_db_disables_mmap_on_every_platform() { + assert_eq!(global_db_mmap_size_guard(), 0); +} + +#[tokio::test] +async fn concurrent_full_opens_singleflight_schema_but_use_independent_connections() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("global.db"); + let (first, second, third, fourth) = tokio::join!( + GlobalDb::open_at(&path), + GlobalDb::open_at(&path), + GlobalDb::open_at(&path), + GlobalDb::open_at(&path), + ); + let first = first.expect("first open"); + let opened = [ + second.expect("second open"), + third.expect("third open"), + fourth.expect("fourth open"), + ]; + for db in &opened { + assert!(!Arc::ptr_eq(&first.inner, &db.inner)); + } + + first.conn().execute("BEGIN", ()).await.unwrap(); + for db in &opened { + db.conn().execute("BEGIN", ()).await.unwrap(); } + first.conn().execute("ROLLBACK", ()).await.unwrap(); + for db in &opened { + db.conn().execute("ROLLBACK", ()).await.unwrap(); + } +} + +#[tokio::test] +async fn global_db_slot_uses_database_authority_canonical_identity() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("nested")).unwrap(); + let direct_path = dir.path().join("global.db"); + let alias_path = dir.path().join("nested").join("..").join("global.db"); + let direct = DatabaseAuthority::for_runtime(&direct_path, "direct slot identity").unwrap(); + let alias = DatabaseAuthority::for_runtime(&alias_path, "alias slot identity").unwrap(); + + assert_eq!( + direct.canonical_database_path(), + alias.canonical_database_path() + ); + assert!(Arc::ptr_eq( + &global_db_slot(&direct), + &global_db_slot(&alias) + )); +} + +#[tokio::test] +async fn assuming_schema_open_cannot_poison_full_schema_ensure() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("global.db"); + std::fs::File::create(&path).unwrap(); + + let raw = GlobalDb::open_at_assuming_schema(&path) + .await + .expect("raw assuming-schema open"); + let mut rows = raw + .conn() + .query( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'projects'", + (), + ) + .await + .unwrap(); + assert_eq!( + rows.next().await.unwrap().unwrap().get::(0).unwrap(), + 0 + ); + + let ensured = GlobalDb::open_at(&path).await.expect("full schema open"); + let mut rows = ensured + .conn() + .query( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'projects'", + (), + ) + .await + .unwrap(); + assert_eq!( + rows.next().await.unwrap().unwrap().get::(0).unwrap(), + 1 + ); + assert!(!Arc::ptr_eq(&raw.inner, &ensured.inner)); +} + +#[tokio::test] +async fn distinct_global_db_paths_do_not_share_an_initialization_lock() { + let dir = tempfile::TempDir::new().unwrap(); + let first_path = dir.path().join("first.db"); + let second_path = dir.path().join("second.db"); + let first_authority = + DatabaseAuthority::for_runtime(&first_path, "hold first global DB slot").unwrap(); + let first_slot = global_db_slot(&first_authority); + let _first_guard = first_slot.lock().await; + + let second = tokio::time::timeout( + std::time::Duration::from_secs(30), + GlobalDb::open_at_without_structured_backfill(&second_path), + ) + .await + .expect("unrelated global DB path waited on the first path's slot") + .expect("open unrelated global DB path"); + assert_eq!(second.db_path(), second_path.as_path()); +} + +#[tokio::test] +async fn read_only_open_is_independent_and_cannot_poison_writer() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("global.db"); + let seed = GlobalDb::open_at(&path).await.expect("seed writable open"); + drop(seed); + let reader = GlobalDb::open_read_only_at(&path) + .await + .expect("read-only open"); + let writable = GlobalDb::open_at(&path).await.expect("writable open"); + assert!(!Arc::ptr_eq(&writable.inner, &reader.inner)); + assert!( + reader + .conn() + .execute("CREATE TABLE forbidden_reader_write (id INTEGER)", ()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn runtime_open_without_authority_scope_fails_closed() { + let path = std::env::current_dir() + .unwrap() + .join("target/global-db-no-authority/global.db"); + if path.starts_with(std::env::temp_dir()) { + return; + } + assert!(GlobalDb::open_at(&path).await.is_none()); +} + +#[tokio::test] +async fn try_open_at_preserves_authority_error() { + let path = std::env::current_dir() + .unwrap() + .join("target/global-db-authority-error/global.db"); + if path.starts_with(std::env::temp_dir()) { + return; + } + let error = match GlobalDb::try_open_at(&path).await { + Err(error) => error, + Ok(_) => panic!("unauthorized global DB open unexpectedly succeeded"), + }; + let message = error.to_string(); + assert!( + message + .contains("database access requires managed-daemon or exclusive-maintenance authority"), + "{message}" + ); + assert!(message.contains("open global database"), "{message}"); + assert!(message.contains(&path.display().to_string()), "{message}"); +} + +#[tokio::test] +async fn isolated_temp_database_uses_test_authority() { + let dir = tempfile::TempDir::new().unwrap(); + let db = GlobalDb::open_at(&dir.path().join("global.db")) + .await + .expect("temp test open"); + assert_eq!(db._authority.role(), crate::db::DatabaseAuthorityRole::Test); } #[test] diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 8ce425b9b..f5593dc03 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -173,8 +173,9 @@ pub async fn hook_claude_session_start() -> i32 { // project root and the real session cwd so the linked-worktree detection in // `plan_hook_event` sees the session tree rather than the daemon's cwd. if let Some(root) = root.as_ref() { - let cwd = event_cwd_from_parsed(&parsed); - crate::daemon::notify_hook_event(root, session_start_hook_event(cwd)).await; + if let Some(event) = claude_session_start_hook_event(&parsed) { + crate::daemon::notify_hook_event(root, event).await; + } } if session_start_from_compaction(&event) { append_context_recovery_hint(&mut context); @@ -235,23 +236,10 @@ async fn claude_subagent_start_context(event_json: &str) -> Option { Some(context) } -/// Builds the `sessionStart` daemon notification for the Claude agent. -/// -/// Mirrors the other `DaemonHookEvent` constructors used in this file, but -/// `DaemonHookEvent::new` is private to `daemon.rs`, so the fire-and-forget -/// session-start event is assembled from its public fields here. `cwd` carries -/// the real session working directory so the daemon's linked-worktree detection -/// can auto-track a harness worktree's branch store. -fn session_start_hook_event(cwd: Option) -> crate::daemon::DaemonHookEvent { - crate::daemon::DaemonHookEvent { - agent: crate::daemon::HookAgent::Claude.as_wire().to_string(), - event: "sessionStart".to_string(), - rel_paths: Vec::new(), - command: None, - cwd, - route: None, - receipt: None, - } +fn claude_session_start_hook_event(parsed: &Value) -> Option { + event_cwd_from_parsed(parsed).map(|cwd| { + crate::daemon::DaemonHookEvent::session_start(crate::daemon::HookAgent::Claude, cwd) + }) } /// Builds the Claude `SessionStart` context for code workspaces. @@ -406,9 +394,11 @@ pub async fn hook_prompt_submit() { super::schedule_user_session_review("claude", session_id.as_deref()); } if let Some(root) = root.as_deref() - && let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await + && let Err(error) = + super::daemon_hook_action(Some(root), serde_json::json!({ "action": "reset_counter" })) + .await { - let _ = cg.reset_local_counter().await; + eprintln!("[tracedecay] local counter reset daemon call failed: {error}"); } let recall = prompt_like_text(&parsed); let recall = match (root.as_deref(), recall.as_deref()) { @@ -446,35 +436,38 @@ pub async fn hook_stop() { super::schedule_user_session_review("claude", session_id.as_deref()); } - let Some(gdb) = crate::global_db::GlobalDb::open().await else { - return; - }; - - let stats = crate::accounting::parser::ingest(&gdb).await; - if stats.turns_inserted == 0 { - return; - } - - let project_path = crate::config::resolve_path(None); - let tokens_saved = if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { - cg.get_tokens_saved().await.unwrap_or(0) - } else { - 0 - }; - - let efficiency = if tokens_saved + stats.tokens_consumed > 0 { - (tokens_saved as f64 / (tokens_saved + stats.tokens_consumed) as f64) * 100.0 - } else { - 0.0 - }; - - let saved_str = crate::display::format_token_count(tokens_saved); - - if stats.cost_usd >= 0.001 { - eprintln!( - "\x1b[36mSession: ${:.2} spent | {saved_str} saved | {efficiency:.0}% efficiency\x1b[0m", - stats.cost_usd - ); + let project_path = root.unwrap_or_else(|| crate::config::resolve_path(None)); + match super::daemon_hook_action( + Some(&project_path), + serde_json::json!({ "action": "accounting_receipt" }), + ) + .await + { + Ok(receipt) => { + let turns = receipt + .get("turns_inserted") + .and_then(Value::as_u64) + .unwrap_or(0); + let cost = receipt + .get("cost_usd") + .and_then(Value::as_f64) + .unwrap_or(0.0); + if turns > 0 && cost >= 0.001 { + let saved = receipt + .get("tokens_saved") + .and_then(Value::as_u64) + .unwrap_or(0); + let efficiency = receipt + .get("efficiency") + .and_then(Value::as_f64) + .unwrap_or(0.0); + let saved_str = crate::display::format_token_count(saved); + eprintln!( + "\x1b[36mSession: ${cost:.2} spent | {saved_str} saved | {efficiency:.0}% efficiency\x1b[0m" + ); + } + } + Err(error) => eprintln!("[tracedecay] session receipt daemon call failed: {error}"), } } @@ -484,19 +477,26 @@ pub async fn ingest_user_claude_session(session_id: Option) -> bool { if session_id.is_none() { return false; } - let Ok(profile_root) = crate::storage::default_profile_root() else { - return false; - }; - let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { - return false; - }; - let Some(registered_roots) = crate::sessions::try_registered_project_roots().await else { - return false; - }; - crate::sessions::claude::ingest_user_sessions(&db, &profile_root, session_id, registered_roots) - .await - .messages_upserted - > 0 + match super::daemon_hook_action( + None, + serde_json::json!({ + "action": "ingest_transcript", + "provider": "claude", + "user_scope": true, + "session_id": session_id, + }), + ) + .await + { + Ok(result) => result + .get("messages_upserted") + .and_then(Value::as_u64) + .is_some_and(|count| count > 0), + Err(error) => { + eprintln!("[tracedecay] user Claude ingest daemon call failed: {error}"); + false + } + } } #[cfg(test)] @@ -504,6 +504,21 @@ pub async fn ingest_user_claude_session(session_id: Option) -> bool { mod tests { use super::*; + #[test] + fn claude_session_start_event_signals_daemon_with_real_cwd() { + let event = claude_session_start_hook_event(&serde_json::json!({ + "cwd": "/workspace/claude-session" + })) + .unwrap(); + + assert_eq!(event.agent, crate::daemon::HookAgent::Claude.as_wire()); + assert_eq!(event.event, "sessionStart"); + assert_eq!( + event.cwd.as_deref(), + Some(std::path::Path::new("/workspace/claude-session")) + ); + } + fn post_event(tool_name: &str, tool_input: &Value) -> Value { serde_json::json!({ "session_id": "s-post", diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs index 1b69a1383..70f062e01 100644 --- a/src/hooks/codex.rs +++ b/src/hooks/codex.rs @@ -44,19 +44,18 @@ mapped to symbols, `tracedecay:project-memory` when project decisions/preference `tracedecay_lcm_expand_query`, and `tracedecay_lcm_describe` when prior conversation context \ may be missing."; -const CODEX_POST_COMPACT_BUDGET: Duration = Duration::from_secs(115); - /// Codex `SessionStart` hook handler. pub async fn hook_codex_session_start() -> i32 { let event = read_hook_event!(); + let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); let root = codex_project_root_from_event_with_identity(&event).await; let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Codex, "SessionStart", &event); + if let (Some(root), Some(event)) = (root.as_ref(), codex_session_start_hook_event(&parsed)) { + crate::daemon::notify_hook_event(root, event).await; + } let (mut context, _) = codex_session_context_for_event(&event).await; - let session_id = serde_json::from_str::(&event) - .ok() - .as_ref() - .and_then(event_session_id); + let session_id = event_session_id(&parsed); if root.is_none() && ingest_user_codex_session(session_id.clone()).await { super::schedule_user_session_review("codex", session_id.as_deref()); } @@ -79,6 +78,12 @@ pub async fn hook_codex_session_start() -> i32 { 0 } +fn codex_session_start_hook_event(parsed: &Value) -> Option { + event_cwd_from_parsed(parsed).map(|cwd| { + crate::daemon::DaemonHookEvent::session_start(crate::daemon::HookAgent::Codex, cwd) + }) +} + /// Codex `UserPromptSubmit` hook handler. /// /// Resets the local counter and injects steering context for the new turn. @@ -650,84 +655,67 @@ fn codex_apply_patch_added_text(command: &str) -> Option { } async fn codex_post_compact(event_json: &str) { - let work = async { - let project_root = codex_project_root_from_event_with_identity(event_json).await; - if project_root.is_none() { - let session_id = serde_json::from_str::(event_json) - .ok() - .as_ref() - .and_then(event_session_id); - if ingest_user_codex_session(session_id.clone()).await { - super::schedule_user_session_review("codex", session_id.as_deref()); - } - return; - } - let Some(project_root) = project_root else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { - return; - } - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; - }; - if let Some(source) = crate::sessions::codex::CodexSource::new() { - let _ = crate::sessions::source::ingest_source(&db, &source, &project_root, None).await; - } - let session_id = serde_json::from_str::(event_json) - .ok() - .and_then(|parsed| event_session_id(&parsed)); - let Ok(mut pending) = db - .pending_codex_compaction_summary_requests(session_id.as_deref(), 1) - .await - else { - return; - }; - let Some(pending) = pending.pop() else { - return; - }; - let config = crate::sessions::codex_app_server::CodexAppServerSummaryConfig::from_env(); - let summary = match crate::sessions::codex_app_server::summarize_with_codex_app_server( - &pending.request, - &config, - ) { - Ok(summary) => summary, - Err(err) => { - eprintln!("tracedecay Codex PostCompact summary failed: {err}"); - return; - } - }; - if let Err(err) = db - .replace_codex_compaction_summary( - &pending.node_id, - &summary.text, - "codex_app_server", - summary.model.as_deref().or(config.model.as_deref()), - ) - .await - { - eprintln!("tracedecay Codex PostCompact summary replacement failed: {err}"); - } + let root = codex_project_root_from_event_with_identity(event_json).await; + let action = if root.is_some() { + "codex_compact" + } else { + "ingest_transcript" }; - let _ = tokio::time::timeout(CODEX_POST_COMPACT_BUDGET, work).await; + let session_id = serde_json::from_str::(event_json) + .ok() + .as_ref() + .and_then(event_session_id); + let mut args = serde_json::json!({ + "action": action, + "provider": "codex", + "user_scope": root.is_none(), + "event_json": event_json, + }); + if let Some(session_id) = session_id { + args["session_id"] = serde_json::json!(session_id); + } + if let Err(error) = super::daemon_hook_action(root.as_deref(), args).await { + eprintln!("[tracedecay] Codex PostCompact daemon call failed: {error}"); + } } async fn ingest_user_codex_session(session_id: Option) -> bool { if session_id.is_none() { return false; } - crate::sessions::ingest_user_codex_sessions(session_id) - .await - .messages_upserted - > 0 + match super::daemon_hook_action( + None, + serde_json::json!({ + "action": "ingest_transcript", + "provider": "codex", + "user_scope": true, + "session_id": session_id, + }), + ) + .await + { + Ok(result) => result + .get("messages_upserted") + .and_then(Value::as_u64) + .is_some_and(|count| count > 0), + Err(error) => { + eprintln!("[tracedecay] user Codex ingest daemon call failed: {error}"); + false + } + } } async fn reset_counter_for_codex_event(event_json: &str) { let Some(project_root) = codex_project_root_from_event_with_identity(event_json).await else { return; }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; + if let Err(error) = super::daemon_hook_action( + Some(&project_root), + serde_json::json!({ "action": "reset_counter" }), + ) + .await + { + eprintln!("[tracedecay] local counter reset daemon call failed: {error}"); } } @@ -781,6 +769,21 @@ mod tests { use super::*; use crate::config::USER_DATA_DIR_ENV; + #[test] + fn codex_session_start_event_signals_daemon_with_real_cwd() { + let event = codex_session_start_hook_event(&serde_json::json!({ + "cwd": "/workspace/codex-session" + })) + .unwrap(); + + assert_eq!(event.agent, crate::daemon::HookAgent::Codex.as_wire()); + assert_eq!(event.event, "sessionStart"); + assert_eq!( + event.cwd.as_deref(), + Some(Path::new("/workspace/codex-session")) + ); + } + const QUALIFYING_RUST_PATCH: &str = "*** Begin Patch\n\ *** Add File: src/util.rs\n\ +pub fn summarize(hits: &[Hit]) -> u32 {\n\ @@ -972,46 +975,12 @@ mod tests { async fn codex_stop_ingests_final_user_turn_once() { let _lock = crate::hooks::lock_test_env(); let temp = tempfile::tempdir().unwrap(); - let home = temp.path().join("home"); - let profile = temp.path().join("profile"); let general = temp.path().join("general-chat"); std::fs::create_dir_all(&general).unwrap(); - let _home_env = EnvGuard::set_path("HOME", &home); - let _profile_env = EnvGuard::set_path(USER_DATA_DIR_ENV, &profile); - let rollout_dir = home.join(".codex/sessions/2026/01/01"); - std::fs::create_dir_all(&rollout_dir).unwrap(); - let rollout = rollout_dir.join("rollout-2026-01-01T00-00-00-final-turn.jsonl"); - let records = [ - serde_json::json!({ - "timestamp": "2026-01-01T00:00:00.000Z", - "type": "session_meta", - "payload": { - "id": "final-turn", - "cwd": general.to_string_lossy(), - "model": "gpt-5.5" - } - }), - serde_json::json!({ - "timestamp": "2026-01-01T00:00:01.000Z", - "type": "event_msg", - "payload": {"type": "user_message", "message": "one turn prompt"} - }), - serde_json::json!({ - "timestamp": "2026-01-01T00:00:02.000Z", - "type": "event_msg", - "payload": {"type": "agent_message", "message": "final assistant evidence"} - }), - ]; - std::fs::write( - rollout, - records - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n") - + "\n", - ) - .unwrap(); + let daemon = crate::hooks::TestDaemonHookActionGuard::install([ + serde_json::json!({ "messages_upserted": 1 }), + serde_json::json!({ "messages_upserted": 0 }), + ]); assert!(finalize_codex_user_session(None, Some("final-turn".to_string())).await); assert!( @@ -1023,14 +992,16 @@ mod tests { "project-scoped Stop receipts must never write the user session store" ); - let db = crate::sessions::open_user_session_db(&profile) - .await - .expect("user session db should exist"); - let hits = db - .search_session_messages("codex", Some("user"), "final assistant evidence", 10) - .await; - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].session.session_id, "final-turn"); + let calls = daemon.calls(); + assert_eq!(calls.len(), 2); + for (project_root, arguments) in calls { + assert_eq!(project_root, None); + assert_eq!(arguments["action"], "ingest_transcript"); + assert_eq!(arguments["provider"], "codex"); + assert_eq!(arguments["user_scope"], true); + assert_eq!(arguments["session_id"], "final-turn"); + assert_eq!(arguments["format"], "json"); + } } #[test] diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index c94614c1e..935a0e246 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -307,9 +307,13 @@ pub async fn hook_cursor_after_file_edit() -> i32 { /// for the resolved workspace. Never blocks session creation. pub async fn hook_cursor_session_start() -> i32 { let event = read_hook_event!(); + let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); let root = cursor_project_root_from_event_with_identity(&event).await; let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionStart", &event); + if let (Some(root), Some(event)) = (root.as_ref(), cursor_session_start_hook_event(&parsed)) { + crate::daemon::notify_hook_event(root, event).await; + } ingest_cursor_transcript_for_event( &event, Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), @@ -317,10 +321,7 @@ pub async fn hook_cursor_session_start() -> i32 { ) .await; let mut context = cursor_session_context_for_root(root.as_deref()).await; - let session_id = serde_json::from_str::(&event) - .ok() - .as_ref() - .and_then(event_session_id); + let session_id = event_session_id(&parsed); let digest = match root.as_deref() { Some(root) => { memory_inject::combined_session_memory_digest(root, session_id.as_deref()).await @@ -342,6 +343,12 @@ pub async fn hook_cursor_session_start() -> i32 { 0 } +fn cursor_session_start_hook_event(parsed: &Value) -> Option { + cursor_event_cwd(parsed).map(|cwd| { + crate::daemon::DaemonHookEvent::session_start(crate::daemon::HookAgent::Cursor, cwd) + }) +} + /// Builds the lean Cursor `sessionStart` context for a resolved project root. /// /// Adds index freshness, the skill index, and tokens-saved counter that the @@ -767,8 +774,13 @@ async fn reset_counter_for_cursor_event(event_json: &str) { let Some(project_root) = cursor_project_root_from_event_with_identity(event_json).await else { return; }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; + if let Err(error) = super::daemon_hook_action( + Some(&project_root), + serde_json::json!({ "action": "reset_counter" }), + ) + .await + { + eprintln!("[tracedecay] local counter reset daemon call failed: {error}"); } } @@ -798,64 +810,49 @@ async fn ingest_cursor_transcript_for_event_inner( max_new_bytes: Option, budget: Duration, ) -> CursorIngestOutcome { - let work = async { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return CursorIngestOutcome::default(); - }; - let project_root = cursor_project_root_from_parsed_event_with_identity(&parsed).await; - if project_root.is_none() { - let Ok(profile_root) = crate::storage::default_profile_root() else { - return CursorIngestOutcome::default(); - }; - let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { - return CursorIngestOutcome::default(); - }; - let Some(registered_roots) = crate::sessions::try_registered_project_roots().await - else { - return CursorIngestOutcome::default(); - }; - let stats = crate::sessions::cursor::ingest_cursor_user_transcript_event_capped_with_registered_roots( - event_json, - &db, - max_new_bytes, - ®istered_roots, - ) - .await; - return CursorIngestOutcome { - completed: true, - user_scope: true, - messages_upserted: stats.messages_upserted, - }; - } - let Some(project_root) = project_root else { - return CursorIngestOutcome::default(); - }; - if let Some(cwd_root) = cursor_event_cwd(&parsed) - .as_deref() - .and_then(crate::config::discover_project_root) - { - if !paths_same(&cwd_root, &project_root) { - return CursorIngestOutcome::default(); - } + let parsed = match serde_json::from_str::(event_json) { + Ok(parsed) => parsed, + Err(_) => return CursorIngestOutcome::default(), + }; + let project_root = cursor_project_root_from_parsed_event_with_identity(&parsed).await; + let mut args = serde_json::json!({ + "action": "ingest_transcript", + "provider": "cursor", + "user_scope": project_root.is_none(), + "event_json": event_json, + }); + if let Some(max_new_bytes) = max_new_bytes { + args["max_new_bytes"] = serde_json::json!(max_new_bytes); + } + match tokio::time::timeout( + budget, + super::daemon_hook_action(project_root.as_deref(), args), + ) + .await + { + Ok(Ok(result)) => CursorIngestOutcome { + completed: result + .get("completed") + .and_then(Value::as_bool) + .unwrap_or(false), + user_scope: result + .get("user_scope") + .and_then(Value::as_bool) + .unwrap_or(false), + messages_upserted: result + .get("messages_upserted") + .and_then(Value::as_u64) + .unwrap_or(0), + }, + Ok(Err(error)) => { + eprintln!("[tracedecay] Cursor transcript ingest daemon call failed: {error}"); + CursorIngestOutcome::default() } - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return CursorIngestOutcome::default(); - }; - let stats = crate::sessions::cursor::ingest_cursor_transcript_event_capped( - event_json, - &db, - max_new_bytes, - ) - .await; - CursorIngestOutcome { - completed: true, - user_scope: false, - messages_upserted: stats.messages_upserted, + Err(_) => { + eprintln!("[tracedecay] Cursor transcript ingest daemon call timed out"); + CursorIngestOutcome::default() } - }; - // Short-lived CLI hook processes exit immediately, so the ingest must run - // inline (not on a detached task); the timeout keeps it inside budget. - tokio::time::timeout(budget, work).await.unwrap_or_default() + } } fn event_session_id_from_json(event_json: &str) -> Option { @@ -997,6 +994,21 @@ mod tests { use super::*; use crate::config::USER_DATA_DIR_ENV; + #[test] + fn cursor_session_start_event_signals_daemon_with_real_cwd() { + let event = cursor_session_start_hook_event(&serde_json::json!({ + "cwd": "/workspace/cursor-session" + })) + .unwrap(); + + assert_eq!(event.agent, crate::daemon::HookAgent::Cursor.as_wire()); + assert_eq!(event.event, "sessionStart"); + assert_eq!( + event.cwd.as_deref(), + Some(Path::new("/workspace/cursor-session")) + ); + } + #[test] fn cursor_before_submit_prompt_json_attaches_context_only_when_present() { // No steering: bare `continue: true`, no `additional_context` key. diff --git a/src/hooks/cursor_compact.rs b/src/hooks/cursor_compact.rs index 2601f4773..76fc2eaf6 100644 --- a/src/hooks/cursor_compact.rs +++ b/src/hooks/cursor_compact.rs @@ -6,16 +6,8 @@ //! generates a summary through `cursor-agent -p`, and stores that summary as //! a normal LCM summary node. -use std::path::Path; use std::time::Duration; -use serde_json::Value; - -use super::cursor::{cursor_project_root_from_parsed_event, ingest_cursor_transcript_for_event}; -use super::{event_i64, event_session_id, event_usize}; - -/// Budget for the transcript catch-up portion of the `preCompact` hook. -const CURSOR_PRE_COMPACT_INGEST_BUDGET: Duration = Duration::from_secs(30); /// Budget for the auxiliary `cursor-agent` summary call inside the hook. Kept /// below the registered Cursor hook timeout so the child can be killed/reaped /// by `TraceDecay` rather than by Cursor killing the hook process. Sized so @@ -25,7 +17,7 @@ pub(super) const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_se /// Overall budget for the `preCompact` hook (registered with a 120s timeout). const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CursorPreCompactOutcome { pub status: String, pub reason: String, @@ -70,150 +62,32 @@ pub async fn cursor_pre_compact_for_event_with_config( async fn cursor_pre_compact_for_event_inner( event_json: &str, - config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, + _config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, ) -> CursorPreCompactOutcome { - if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_ok() { - return CursorPreCompactOutcome::skipped("cursor summary child"); - } - let parsed = match serde_json::from_str::(event_json) { - Ok(parsed) => parsed, - Err(err) => return CursorPreCompactOutcome::error(format!("invalid event JSON: {err}")), - }; - let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { + let root = serde_json::from_str::(event_json) + .ok() + .as_ref() + .and_then(super::cursor::cursor_project_root_from_parsed_event); + let Some(root) = root else { return CursorPreCompactOutcome::skipped("no project root"); }; - if !cursor_event_transcript_path_exists(&parsed) { - return CursorPreCompactOutcome::skipped("no transcript path"); - } - - let caught_up = - ingest_cursor_transcript_for_event(event_json, None, CURSOR_PRE_COMPACT_INGEST_BUDGET) - .await; - if !caught_up { - return CursorPreCompactOutcome::skipped("transcript ingest did not complete"); - } - - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return CursorPreCompactOutcome::skipped("session database unavailable"); - }; - let Some(session_id) = event_session_id(&parsed) else { - return CursorPreCompactOutcome::skipped("no session id"); - }; - - let messages_to_compact = event_usize(&parsed, &["messages_to_compact", "compact_count"]); - if messages_to_compact == Some(0) { - return CursorPreCompactOutcome::skipped("no messages to compact"); - } - let fresh_tail_count = cursor_pre_compact_fresh_tail_count(&parsed, messages_to_compact); - let current_tokens = event_i64(&parsed, &["context_tokens", "current_tokens", "tokens"]); - let context_length = event_i64(&parsed, &["context_window_size", "context_length"]); - - let first = match db - .lcm_compress(cursor_pre_compact_lcm_request( - &session_id, - current_tokens, - context_length, - messages_to_compact, - fresh_tail_count, - crate::sessions::lcm::LcmSummarizerMode::HermesAuxiliary, - None, - )) - .await + let result = match super::daemon_hook_action( + Some(&root), + serde_json::json!({ + "action": "cursor_compact", + "event_json": event_json, + }), + ) + .await { - Ok(response) => response, - Err(err) => return CursorPreCompactOutcome::error(format!("LCM prepare failed: {err}")), - }; - let Some(summary_request) = first.summary_request else { - return CursorPreCompactOutcome::skipped(first.reason); - }; - - let summary = match crate::sessions::cursor_agent::summarize_with_cursor_agent( - &summary_request, - config, - ) { - Ok(summary) => summary, - Err(err) => { - return CursorPreCompactOutcome::error(format!("cursor-agent summary failed: {err}")); + Ok(result) => result, + Err(error) => { + return CursorPreCompactOutcome::error(format!( + "daemon compaction call failed: {error}" + )); } }; - - let second = match db - .lcm_compress(cursor_pre_compact_lcm_request( - &session_id, - current_tokens, - context_length, - messages_to_compact, - fresh_tail_count, - crate::sessions::lcm::LcmSummarizerMode::Provided { - summary_text: summary, - route: Some("cursor_agent".to_string()), - }, - first.frontier.current_frontier_store_id.or(Some(0)), - )) - .await - { - Ok(response) => response, - Err(err) => return CursorPreCompactOutcome::error(format!("LCM persist failed: {err}")), - }; - CursorPreCompactOutcome { - status: second.status, - reason: second.reason, - summary_nodes_created: second.summary_nodes_created, - summary_node_ids: second - .summary_nodes - .iter() - .map(|node| node.node_id.clone()) - .collect(), - } -} - -fn cursor_pre_compact_lcm_request( - session_id: &str, - current_tokens: Option, - context_length: Option, - max_source_messages: Option, - fresh_tail_count: Option, - summarizer: crate::sessions::lcm::LcmSummarizerMode, - expected_current_frontier_store_id: Option, -) -> crate::sessions::lcm::LcmCompressionRequest { - crate::sessions::lcm::LcmCompressionRequest { - provider: "cursor".to_string(), - session_id: session_id.to_string(), - messages: Vec::new(), - current_tokens, - focus_topic: Some("Cursor context compaction".to_string()), - ignore_session_patterns: Vec::new(), - stateless_session_patterns: Vec::new(), - ignore_message_patterns: Vec::new(), - expected_current_frontier_store_id, - threshold_tokens: None, - max_assembly_tokens: None, - leaf_chunk_tokens: None, - max_source_messages, - summary_fan_in: None, - incremental_max_depth: None, - fresh_tail_count, - dynamic_leaf_chunk_enabled: None, - dynamic_leaf_chunk_max: None, - context_length, - reserve_tokens_floor: None, - summarizer, - } -} - -fn cursor_pre_compact_fresh_tail_count( - parsed: &Value, - messages_to_compact: Option, -) -> Option { - let message_count = event_usize(parsed, &["message_count", "messages_count"])?; - let messages_to_compact = messages_to_compact?; - Some(message_count.saturating_sub(messages_to_compact)) -} - -fn cursor_event_transcript_path_exists(parsed: &Value) -> bool { - parsed - .get("transcript_path") - .and_then(Value::as_str) - .filter(|path| !path.is_empty()) - .is_some_and(|path| Path::new(path).exists()) + serde_json::from_value(result).unwrap_or_else(|error| { + CursorPreCompactOutcome::error(format!("invalid daemon compaction response: {error}")) + }) } diff --git a/src/hooks/kiro.rs b/src/hooks/kiro.rs index 6a0a34daa..fecf9c59d 100644 --- a/src/hooks/kiro.rs +++ b/src/hooks/kiro.rs @@ -174,8 +174,13 @@ async fn reset_counter_for_kiro_event(event_json: &str) { let Some(project_root) = kiro_project_root(event_json) else { return; }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; + if let Err(error) = super::daemon_hook_action( + Some(&project_root), + serde_json::json!({ "action": "reset_counter" }), + ) + .await + { + eprintln!("[tracedecay] local counter reset daemon call failed: {error}"); } } @@ -192,43 +197,41 @@ async fn ingest_kiro_transcript_for_event( max_new_bytes: Option, budget: std::time::Duration, ) -> KiroIngestOutcome { - let work = async { - if let Some(project_root) = kiro_project_root(event_json) { - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await - else { - return KiroIngestOutcome::default(); - }; - let stats = - crate::sessions::kiro::ingest_kiro_for_project(&db, &project_root, max_new_bytes) - .await; - return KiroIngestOutcome { - messages_upserted: stats.messages_upserted, - ..KiroIngestOutcome::default() - }; + let project_root = kiro_project_root(event_json); + let mut args = serde_json::json!({ + "action": "ingest_transcript", + "provider": "kiro", + "user_scope": project_root.is_none(), + "event_json": event_json, + }); + if let Some(max_new_bytes) = max_new_bytes { + args["max_new_bytes"] = serde_json::json!(max_new_bytes); + } + match tokio::time::timeout( + budget, + super::daemon_hook_action(project_root.as_deref(), args), + ) + .await + { + Ok(Ok(result)) => KiroIngestOutcome { + user_scope: result + .get("user_scope") + .and_then(Value::as_bool) + .unwrap_or(false), + messages_upserted: result + .get("messages_upserted") + .and_then(Value::as_u64) + .unwrap_or(0), + }, + Ok(Err(error)) => { + eprintln!("[tracedecay] Kiro transcript ingest daemon call failed: {error}"); + KiroIngestOutcome::default() } - - let Ok(profile_root) = crate::storage::default_profile_root() else { - return KiroIngestOutcome::default(); - }; - let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { - return KiroIngestOutcome::default(); - }; - let Some(source) = crate::sessions::kiro::KiroSource::new() else { - return KiroIngestOutcome::default(); - }; - let Some(registered_roots) = crate::sessions::try_registered_project_roots().await else { - return KiroIngestOutcome::default(); - }; - let source = source.for_user_scope(registered_roots); - let stats = - crate::sessions::source::ingest_source(&db, &source, &profile_root, max_new_bytes) - .await; - KiroIngestOutcome { - user_scope: true, - messages_upserted: stats.messages_upserted, + Err(_) => { + eprintln!("[tracedecay] Kiro transcript ingest daemon call timed out"); + KiroIngestOutcome::default() } - }; - tokio::time::timeout(budget, work).await.unwrap_or_default() + } } async fn kiro_prompt_memory_recall(event_json: &str) -> Option { diff --git a/src/hooks/memory_inject.rs b/src/hooks/memory_inject.rs index 200a2c1a5..e4da5d1f3 100644 --- a/src/hooks/memory_inject.rs +++ b/src/hooks/memory_inject.rs @@ -19,10 +19,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::memory::hygiene::detect_secret_like; -use crate::memory::retrieval::FactRetriever; -use crate::memory::store::MemoryStore; use crate::memory::types::{FactRecord, FactSearchResult}; -use crate::memory::user::open_user_memory_db; /// Hard cap for the session-start "durable project memory" digest. pub const SESSION_DIGEST_CHAR_BUDGET: usize = 2_000; @@ -65,6 +62,64 @@ const COMBINED_DIGEST_HEADER: &str = "Durable user and project memory \ const COMBINED_PROMPT_RECALL_HEADER: &str = "Possibly relevant user and project memory \ (tracedecay fact stores; each fact includes its scope):"; +async fn daemon_fact_store( + root: Option<&Path>, + action: &str, + query: Option<&str>, + user_scope: bool, + limit: usize, +) -> Option { + let mut args = serde_json::json!({ + "action": action, + "format": "json", + "limit": limit, + "min_trust": INJECTION_MIN_TRUST, + }); + if let Some(query) = query { + args["query"] = serde_json::json!(query); + } + if user_scope { + args["memory_scope"] = serde_json::json!("user"); + } + match super::daemon_tool_json(root, "tracedecay_fact_store", args).await { + Ok(value) => Some(value), + Err(error) => { + eprintln!("[tracedecay] memory hook daemon call failed: {error}"); + None + } + } +} + +async fn daemon_fact_list(root: Option<&Path>, user_scope: bool) -> Vec { + daemon_fact_store( + root, + "list", + None, + user_scope, + SESSION_DIGEST_FACT_COUNT * 4, + ) + .await + .and_then(|value| serde_json::from_value(value.get("facts")?.clone()).ok()) + .unwrap_or_default() +} + +async fn daemon_fact_search( + root: Option<&Path>, + prompt: &str, + user_scope: bool, +) -> Vec { + daemon_fact_store( + root, + "search", + Some(prompt), + user_scope, + PROMPT_RECALL_FACT_COUNT * 4, + ) + .await + .and_then(|value| serde_json::from_value(value.get("facts")?.clone()).ok()) + .unwrap_or_default() +} + // --------------------------------------------------------------------------- // Config gate // --------------------------------------------------------------------------- @@ -466,20 +521,7 @@ fn record_injected_user_facts(session_id: Option<&str>, fact_ids: &[i64]) { /// without bumping recall/access counters. Fail-open: any error yields an /// empty list. async fn digest_candidates(root: &Path) -> Vec { - let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await else { - return Vec::new(); - }; - let Ok(db) = cg.open_project_store_db().await else { - return Vec::new(); - }; - MemoryStore::new(db.conn()) - .list_facts( - None, - Some(INJECTION_MIN_TRUST), - SESSION_DIGEST_FACT_COUNT * 4, - ) - .await - .unwrap_or_default() + daemon_fact_list(Some(root), false).await } /// Builds a session-start digest from the profile-level user memory store. @@ -487,16 +529,7 @@ pub async fn user_session_memory_digest(session_id: Option<&str>) -> Option, prompt: &str) - if !memory_injection_enabled() || prompt.trim().chars().count() < MIN_PROMPT_CHARS { return None; } - let profile_root = crate::storage::default_profile_root().ok()?; - let db = open_user_memory_db(&profile_root).await.ok()?; - let results = FactRetriever::new(db.conn()) - .search_untracked( - prompt, - None, - Some(INJECTION_MIN_TRUST), - PROMPT_RECALL_FACT_COUNT * 4, - ) - .await - .ok()?; + let results = daemon_fact_search(None, prompt, true).await; let already_injected = match (session_id, user_seen_facts_path()) { (Some(session_id), Some(path)) => { MemoryInjectSeen::load_or_default(&path).seen_for_session(session_id) @@ -546,21 +569,10 @@ pub async fn combined_session_memory_digest( } else { Vec::new() }; - let user = match crate::storage::default_profile_root() { - Ok(profile_root) => match open_user_memory_db(&profile_root).await { - Ok(db) => MemoryStore::new(db.conn()) - .list_facts( - None, - Some(INJECTION_MIN_TRUST), - SESSION_DIGEST_FACT_COUNT * 4, - ) - .await - .map(|facts| select_digest_facts(facts, SESSION_DIGEST_FACT_COUNT)) - .unwrap_or_default(), - Err(_) => Vec::new(), - }, - Err(_) => Vec::new(), - }; + let user = select_digest_facts( + daemon_fact_list(Some(root), true).await, + SESSION_DIGEST_FACT_COUNT, + ); let facts = select_scoped_facts(user, project, SESSION_DIGEST_FACT_COUNT); let (text, user_ids, project_ids) = render_scoped_fact_block(COMBINED_DIGEST_HEADER, &facts, SESSION_DIGEST_CHAR_BUDGET)?; @@ -584,52 +596,22 @@ pub async fn combined_prompt_memory_recall( } _ => HashSet::new(), }; - let project = if crate::tracedecay::TraceDecay::has_initialized_store(root).await { - match crate::tracedecay::TraceDecay::open(root).await { - Ok(cg) => match cg.open_project_store_db().await { - Ok(db) => FactRetriever::new(db.conn()) - .search_untracked( - prompt, - None, - Some(INJECTION_MIN_TRUST), - PROMPT_RECALL_FACT_COUNT * 4, - ) - .await - .map(|results| { - select_prompt_recall_facts(results, &project_seen, PROMPT_RECALL_FACT_COUNT) - }) - .unwrap_or_default(), - Err(_) => Vec::new(), - }, - Err(_) => Vec::new(), - } - } else { - Vec::new() - }; + let project = select_prompt_recall_facts( + daemon_fact_search(Some(root), prompt, false).await, + &project_seen, + PROMPT_RECALL_FACT_COUNT, + ); let user_seen = match (session_id, user_seen_facts_path()) { (Some(session_id), Some(path)) => { MemoryInjectSeen::load_or_default(&path).seen_for_session(session_id) } _ => HashSet::new(), }; - let user = match crate::storage::default_profile_root() { - Ok(profile_root) => match open_user_memory_db(&profile_root).await { - Ok(db) => FactRetriever::new(db.conn()) - .search_untracked( - prompt, - None, - Some(INJECTION_MIN_TRUST), - PROMPT_RECALL_FACT_COUNT * 4, - ) - .await - .map(|results| { - select_prompt_recall_facts(results, &user_seen, PROMPT_RECALL_FACT_COUNT) - }) - .unwrap_or_default(), - Err(_) => Vec::new(), - }, - Err(_) => Vec::new(), - }; + let user = select_prompt_recall_facts( + daemon_fact_search(Some(root), prompt, true).await, + &user_seen, + PROMPT_RECALL_FACT_COUNT, + ); let facts = select_scoped_facts(user, project, PROMPT_RECALL_FACT_COUNT); let (text, user_ids, project_ids) = render_scoped_fact_block( COMBINED_PROMPT_RECALL_HEADER, @@ -734,8 +716,7 @@ pub async fn regenerate_cursor_user_memory_rule() -> bool { if !memory_injection_enabled() { return false; } - let (Some(home), Ok(profile_root)) = (dirs::home_dir(), crate::storage::default_profile_root()) - else { + let Some(home) = dirs::home_dir() else { return false; }; let rule_path = crate::agents::cursor::cursor_memory_rule_path(&home); @@ -745,17 +726,7 @@ pub async fn regenerate_cursor_user_memory_rule() -> bool { if !plugin_dir.join(".cursor-plugin/plugin.json").exists() { return false; } - let facts = match open_user_memory_db(&profile_root).await { - Ok(db) => MemoryStore::new(db.conn()) - .list_facts( - None, - Some(INJECTION_MIN_TRUST), - SESSION_DIGEST_FACT_COUNT * 4, - ) - .await - .unwrap_or_default(), - Err(_) => Vec::new(), - }; + let facts = daemon_fact_list(None, true).await; let facts = select_digest_facts(facts, SESSION_DIGEST_FACT_COUNT); write_cursor_memory_rule_if_managed(&rule_path, &render_cursor_user_memory_rule(&facts)) } diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index aa720d632..1da8fbfb2 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -82,6 +82,42 @@ macro_rules! read_hook_event { } pub(crate) use read_hook_event; +pub(crate) async fn daemon_tool_json( + project_root: Option<&Path>, + tool_name: &str, + arguments: Value, +) -> crate::errors::Result { + let handshake = crate::daemon::DaemonHandshake::for_current_client( + project_root.map(Path::to_path_buf), + None, + false, + false, + )?; + let result = crate::daemon::call_default_tool(&handshake, tool_name, arguments).await?; + let text = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(|error| crate::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned invalid JSON: {error}"), + }) +} + +pub(crate) async fn daemon_hook_action( + project_root: Option<&Path>, + mut arguments: Value, +) -> crate::errors::Result { + arguments["format"] = serde_json::json!("json"); + #[cfg(test)] + if let Some(result) = take_test_daemon_hook_action(project_root, &arguments) { + return result; + } + daemon_tool_json(project_root, "tracedecay_hook_runtime", arguments).await +} + pub async fn hook_hermes_terminal_receipt() -> i32 { let event_json = read_hook_event!(); let Ok(event) = serde_json::from_str::(&event_json) else { @@ -106,98 +142,24 @@ pub async fn hook_hermes_terminal_receipt() -> i32 { None => None, } { crate::daemon::notify_hook_event(&project_root, event).await; - } else if let Err(error) = Box::pin(handle_user_hermes_receipt(event)).await { - eprintln!("[tracedecay] user Hermes receipt failed: {error}"); - } - 0 -} - -async fn handle_user_hermes_receipt( - event: crate::daemon::DaemonHookEvent, -) -> crate::errors::Result<()> { - use crate::automation::run_ledger::AutomationRunStatus; - - let profile_root = crate::storage::default_profile_root()?; - let dashboard_root = crate::automation::runner::user_automation_root(&profile_root); - let route = event.route.clone(); - let Some(receipt) = event.receipt.clone() else { - return Ok(()); - }; - match event.event.as_str() { - "terminalReceipt" | "turnCompleted" => { - crate::automation::host_receipts::record(&dashboard_root, route, receipt).await?; - } - "turnIngested" => { - let Some(watermark) = receipt.transcript_watermark.as_deref() else { - return Ok(()); - }; - crate::automation::host_receipts::mark_turn_ingested(&dashboard_root, route, watermark) - .await?; - let Some(ready) = - crate::automation::host_receipts::oldest_ready(&dashboard_root).await? - else { - return Ok(()); - }; - let sessions_path = crate::sessions::user_sessions_db_path(&profile_root); - let Some(session_db) = - crate::global_db::GlobalDb::open_read_only_at(&sessions_path).await - else { - return Ok(()); - }; - if session_db - .lcm_load_raw_message("hermes", &ready.transcript_watermark) - .await - .is_none() - { - return Ok(()); - } - if crate::automation::scheduler::load_scheduler_control(&dashboard_root) - .await? - .paused - { - return Ok(()); - } - let Some(_review_lock) = lock_user_session_review(&dashboard_root) else { - return Ok(()); - }; - let session_id = ready - .pending - .route - .as_ref() - .and_then(|route| route.session_id.clone()); - let run = run_user_session_review( - &profile_root, - "hermes", - session_id, - Some(format!("user_host_receipt_{}", ready.pending.generation)), - ) - .await?; - if run.session_reflector.ledger_record.status == AutomationRunStatus::Succeeded - && run.memory_curator.ledger_record.status != AutomationRunStatus::Failed - && run.skill_writer.ledger_record.status == AutomationRunStatus::Succeeded - { - crate::automation::host_receipts::mark_consumed( - &dashboard_root, - &ready.pending.session_key, - ready.pending.generation, - ) - .await?; - } + } else { + if let Err(error) = daemon_hook_action( + None, + serde_json::json!({ "action": "hermes_receipt", "event": event }), + ) + .await + { + eprintln!("[tracedecay] user Hermes receipt daemon call failed: {error}"); } - _ => {} } - Ok(()) + 0 } pub(crate) fn schedule_user_session_review(provider: &str, session_id: Option<&str>) { let Ok(exe) = std::env::current_exe() else { return; }; - let payload = serde_json::json!({ - "provider": provider, - "session_id": session_id, - }) - .to_string(); + let payload = serde_json::json!({ "provider": provider, "session_id": session_id }).to_string(); let Ok(mut child) = std::process::Command::new(exe) .arg("hook-user-session-review") .stdin(std::process::Stdio::piped()) @@ -220,93 +182,20 @@ pub async fn hook_user_session_review() -> i32 { let Some(provider) = payload.get("provider").and_then(Value::as_str) else { return 0; }; - let session_id = payload - .get("session_id") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string); - review_user_session(provider, session_id).await; - 0 -} - -async fn review_user_session(provider: &str, session_id: Option) { - let Ok(profile_root) = crate::storage::default_profile_root() else { - return; - }; - let dashboard_root = crate::automation::runner::user_automation_root(&profile_root); - let Some(_review_lock) = lock_user_session_review(&dashboard_root) else { - return; - }; - if crate::automation::scheduler::load_scheduler_control(&dashboard_root) - .await - .is_ok_and(|control| control.paused) - { - return; - } - if let Err(error) = run_user_session_review(&profile_root, provider, session_id, None).await { - eprintln!("[tracedecay] {provider} user session review failed: {error}"); - } -} - -fn lock_user_session_review(dashboard_root: &std::path::Path) -> Option { - std::fs::create_dir_all(dashboard_root).ok()?; - let lock = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(dashboard_root.join("host-review.lock")) - .ok()?; - fs2::FileExt::lock_exclusive(&lock).ok()?; - Some(lock) -} - -async fn run_user_session_review( - profile_root: &std::path::Path, - provider: &str, - session_id: Option, - run_id: Option, -) -> crate::errors::Result { - use crate::automation::backend::CodexAppServerBackend; - use crate::automation::run_ledger::AutomationTrigger; - use crate::automation::runner::{ - MemoryCuratorAutomationOptions, SessionReflectorAutomationOptions, - SkillWriterAutomationOptions, UserSessionAutomationOptions, - run_user_session_automation_with_backend, - }; - - let global = crate::user_config::UserConfig::load().automation; - let config = crate::automation::config::effective_user_automation_config( - profile_root, - &global, - crate::user_config::automation_is_configured(), - ) - .await?; - let backend = CodexAppServerBackend::from_automation_config(&config); - run_user_session_automation_with_backend( - profile_root, - &config, - &backend, - UserSessionAutomationOptions { - session_reflector: SessionReflectorAutomationOptions { - trigger: AutomationTrigger::HostReceipt, - run_id, - provider: provider.to_string(), - session_id, - ..SessionReflectorAutomationOptions::default() - }, - memory_curator: MemoryCuratorAutomationOptions { - trigger: AutomationTrigger::HostReceipt, - ..MemoryCuratorAutomationOptions::default() - }, - skill_writer: SkillWriterAutomationOptions { - trigger: AutomationTrigger::HostReceipt, - provider: provider.to_string(), - ..SkillWriterAutomationOptions::default() - }, - }, + let session_id = payload.get("session_id").and_then(Value::as_str); + if let Err(error) = daemon_hook_action( + None, + serde_json::json!({ + "action": "user_review", + "provider": provider, + "session_id": session_id, + }), ) .await + { + eprintln!("[tracedecay] {provider} user session review daemon call failed: {error}"); + } + 0 } const TRACEDECAY_RESEARCH_BLOCK_REASON: &str = "STOP: Use tracedecay MCP tools \ @@ -338,6 +227,85 @@ pub(crate) fn lock_test_env() -> std::sync::MutexGuard<'static, ()> { crate::config::lock_user_data_dir_test_env() } +#[cfg(test)] +#[derive(Default)] +struct TestDaemonHookActionState { + owner: Option, + responses: std::collections::VecDeque, + calls: Vec<(Option, Value)>, +} + +#[cfg(test)] +static TEST_DAEMON_HOOK_ACTION: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(TestDaemonHookActionState::default())); + +#[cfg(test)] +pub(crate) struct TestDaemonHookActionGuard { + owner: std::thread::ThreadId, +} + +#[cfg(test)] +impl TestDaemonHookActionGuard { + pub(crate) fn install(responses: impl IntoIterator) -> Self { + let owner = std::thread::current().id(); + let mut state = TEST_DAEMON_HOOK_ACTION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + state.owner.is_none(), + "daemon hook test responder is in use" + ); + state.owner = Some(owner); + state.responses = responses.into_iter().collect(); + state.calls.clear(); + Self { owner } + } + + pub(crate) fn calls(&self) -> Vec<(Option, Value)> { + let state = TEST_DAEMON_HOOK_ACTION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.owner, Some(self.owner)); + state.calls.clone() + } +} + +#[cfg(test)] +impl Drop for TestDaemonHookActionGuard { + fn drop(&mut self) { + let mut state = TEST_DAEMON_HOOK_ACTION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.owner == Some(self.owner) { + *state = TestDaemonHookActionState::default(); + } + } +} + +#[cfg(test)] +fn take_test_daemon_hook_action( + project_root: Option<&Path>, + arguments: &Value, +) -> Option> { + let mut state = TEST_DAEMON_HOOK_ACTION + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.owner != Some(std::thread::current().id()) { + return None; + } + state + .calls + .push((project_root.map(Path::to_path_buf), arguments.clone())); + Some( + state + .responses + .pop_front() + .ok_or_else(|| crate::errors::TraceDecayError::Config { + message: "daemon hook test responder has no response".to_string(), + }), + ) +} + fn hook_route_metadata_from_event( event_json: &str, project_root: &Path, @@ -564,20 +532,6 @@ fn event_session_id(parsed: &Value) -> Option { .map(str::to_string) } -fn event_i64(parsed: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - let value = parsed.get(*key)?; - value - .as_i64() - .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) - .or_else(|| value.as_str()?.parse::().ok()) - }) -} - -fn event_usize(parsed: &Value, keys: &[&str]) -> Option { - event_i64(parsed, keys).and_then(|value| usize::try_from(value).ok()) -} - /// Reads the `cwd` string field from a hook event JSON payload. Shared by the /// Kiro and Codex handlers, both of which send the session working directory. fn event_cwd(event_json: &str) -> Option { diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs index 5ff729443..a56bddfeb 100644 --- a/src/hooks/steering.rs +++ b/src/hooks/steering.rs @@ -249,12 +249,23 @@ pub fn cursor_staleness_hint(age_secs: i64) -> String { /// Opens the index once and reads both session-steering signals. pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option, Option) { - let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await else { + let Ok(status) = super::daemon_tool_json( + Some(root), + "tracedecay_status", + serde_json::json!({ "format": "json" }), + ) + .await + else { return (None, None); }; - let last = cg.last_sync_timestamp().await; + let last = status + .get("last_updated") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); let staleness = (last > 0).then(|| cursor_staleness_hint(now_unix_secs() - last)); - let tokens_saved = cg.get_tokens_saved().await.ok(); + let tokens_saved = status + .get("tokens_saved") + .and_then(serde_json::Value::as_u64); (staleness, tokens_saved) } diff --git a/src/lifecycle_lease.rs b/src/lifecycle_lease.rs index 8e1b75259..3d0a952c7 100644 --- a/src/lifecycle_lease.rs +++ b/src/lifecycle_lease.rs @@ -86,6 +86,13 @@ pub fn acquire_shared(operation: &str) -> Result { acquire_shared_at(&lifecycle_lock_path()?, operation) } +/// Holds ordinary database activity open for one explicit profile. The +/// managed daemon retains this for its lifetime so offline maintenance cannot +/// overlap any daemon-owned database handle. +pub fn acquire_shared_for_profile(profile_root: &Path, operation: &str) -> Result { + acquire_shared_at(&lifecycle_lock_path_for_profile(profile_root)?, operation) +} + /// Attempts to acquire a non-inherited shared lease without blocking. pub fn try_acquire_shared(operation: &str) -> Result { try_acquire_shared_at(&lifecycle_lock_path()?, operation) diff --git a/src/main.rs b/src/main.rs index a82ea57e8..aca8ab5e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ // Updated 2026-03-23: compact bordered table for status output use clap::{CommandFactory, Parser}; use std::io::{IsTerminal, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process; mod agent_cmd; @@ -361,35 +361,26 @@ async fn resolve_registered_project_root( project_id: Option, project_path: Option, ) -> tracedecay::errors::Result> { - let db = tracedecay::global_db::GlobalDb::open() - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "could not open tracedecay project registry; run tracedecay init first" - .to_string(), - })?; - let context = if let Some(project_id) = project_id.as_deref() { - db.project_registry_context_by_id(project_id).await - } else if let Some(project_path) = project_path.as_deref() { - let project_path_arg = Path::new(project_path); - if let Some(context) = db.project_registry_context_by_alias(project_path_arg).await { - Some(context) - } else if tracedecay::global_db::GlobalDb::is_explicit_project_path_selector(project_path) { - let git_common_dir = tracedecay::worktree::git_common_dir(project_path_arg); - db.project_registry_context_by_identity(project_path_arg, git_common_dir.as_deref()) - .await - } else { - None - } - } else { + let Some(selector) = project_id.or(project_path) else { return Ok(None); }; - - context - .map(|context| PathBuf::from(context.project.display_root)) + let context = commands::daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "registry_context", + "project_arg": selector, + }), + ) + .await?; + let display_root = context + .get("project") + .and_then(|project| project.get("display_root")) + .and_then(serde_json::Value::as_str) .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { message: "registered project not found for selector".to_string(), - }) - .map(Some) + })?; + Ok(Some(PathBuf::from(display_root))) } pub(crate) async fn resolve_cli_project_root( @@ -534,8 +525,33 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { open, } => { let project_path = tracedecay::config::resolve_path_with_discovery(path); - let cg = serve::ensure_initialized(&project_path).await?; - tracedecay::dashboard::run(&cg, &host, port, open).await?; + let result = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_dashboard", + serde_json::json!({ + "action": "start", + "host": host, + "port": port, + "format": "json", + }), + ) + .await?; + let url = result + .get("url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon dashboard response omitted URL".to_string(), + })?; + println!("tracedecay dashboard listening on {url}"); + eprintln!("Serving project {}", project_path.display()); + if open { + match open::that(url) { + Ok(()) => eprintln!("Opened dashboard in default browser: {url}"), + Err(error) => { + eprintln!("Warning: could not open browser for {url}: {error}") + } + } + } } Commands::Serve { path, timings } => { if matches!(std::env::var("DISABLE_TRACEDECAY").as_deref(), Ok("true")) { @@ -620,15 +636,40 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { }, Commands::CurrentCounter { path } => { let project_path = tracedecay::config::resolve_path(path); - let cg = serve::ensure_initialized(&project_path).await?; - let value = cg.get_local_counter().await?; + let result = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "counter_get" }), + ) + .await?; + let value = result + .get("counter") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon counter response omitted counter".to_string(), + })?; println!("{value}"); } Commands::ResetCounter { path } => { let project_path = tracedecay::config::resolve_path(path); - let cg = serve::ensure_initialized(&project_path).await?; - let prev = cg.get_local_counter().await?; - cg.reset_local_counter().await?; + let result = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "counter_get" }), + ) + .await?; + let prev = result + .get("counter") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon counter response omitted counter".to_string(), + })?; + commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "counter_reset" }), + ) + .await?; eprintln!("Local counter reset (was {prev})"); } Commands::DisableUploadCounter => { @@ -641,7 +682,7 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { commands::handle_gitignore(path, action).await?; } Commands::Doctor { agent } => { - tracedecay::doctor::run_doctor(agent.as_deref()).await; + tracedecay::doctor::run_doctor(agent.as_deref()).await?; } Commands::Cost { range, @@ -698,11 +739,25 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { project_path, } => { let project_path = resolve_cli_project_root(path, project_id, project_path).await?; - let cg = crate::serve::ensure_initialized(&project_path).await?; - let status = cg.project_memory_status().await?; - let largest_bank_fact_count = - status_cmd::largest_memory_bank_fact_count_at(&cg.store_layout().graph_db_path) - .await?; + let result = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "memory_status" }), + ) + .await?; + let status: tracedecay::memory::types::MemoryStatus = + serde_json::from_value(result.get("status").cloned().ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "daemon memory response omitted status".to_string(), + } + })?)?; + let largest_bank_fact_count = result + .get("largest_bank_fact_count") + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon memory response omitted largest bank count".to_string(), + })?; let largest_bank_utilization_pct = if status.estimated_capacity > 0 { largest_bank_fact_count as f64 / status.estimated_capacity as f64 * 100.0 } else { @@ -836,7 +891,7 @@ fn is_local_install_command(command: &Commands) -> bool { mod startup_tests; // handle_branch_action, handle_wipe, handle_list, handle_no_command, -// init_and_index, and print_sync_doctor have been moved to src/commands.rs. +// init_and_index has been moved to src/commands.rs. // // update_global_db, try_flush, check_for_update, gather_target_projects, // gather_local_projects, gather_local_projects_from, find_descendant_tracedecay, diff --git a/src/mcp/degraded.rs b/src/mcp/degraded.rs deleted file mode 100644 index 8975756a5..000000000 --- a/src/mcp/degraded.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Protocol responses for the degraded startup server. -//! -//! When `tracedecay serve` cannot resolve a project at startup it must not -//! exit — MCP hosts (Cursor especially) never retry a failed server spawn, -//! so a startup exit permanently breaks the scope. Instead, `crate::serve` -//! runs a degraded loop that completes the handshake and answers every tool -//! call with an actionable notice. This module owns that loop's protocol -//! responses; every payload shape and the method dispatch are shared with the -//! full server ([`super::server`]) so the two surfaces cannot drift. - -use serde_json::json; - -use super::server::{McpMethod, classify_mcp_method, initialize_result, resources_list_result}; -use super::transport::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; - -/// Whether a raw input line is a `tools/call` request. The degraded loop -/// retries project resolution on exactly these lines. -pub(crate) fn is_tools_call_line(line: &str) -> bool { - serde_json::from_str::(line) - .is_ok_and(|request| classify_mcp_method(&request.method) == McpMethod::ToolsCall) -} - -/// Builds the degraded response for one raw input line, or `None` when the -/// line is a notification that takes no response. -/// -/// The dispatch mirrors [`super::server::McpServer::handle_request`] method -/// for method via the shared [`classify_mcp_method`]: the handshake and the -/// static payloads (`tools/list`, `resources/list`) are the real ones, while -/// anything that would need a resolved project answers with `notice`. -pub(crate) fn degraded_response_for_line(line: &str, notice: &str) -> Option { - let request: JsonRpcRequest = match serde_json::from_str(line) { - Ok(request) => request, - Err(e) => { - return Some(JsonRpcResponse::error( - serde_json::Value::Null, - ErrorCode::ParseError, - format!("failed to parse JSON-RPC request: {e}"), - )); - } - }; - let kind = classify_mcp_method(&request.method); - let id = request.id?; - let response = match kind { - // The notice doubles as the advertised instructions so clients that - // surface them show the remediation right from the handshake. - McpMethod::Initialize => JsonRpcResponse::success(id, initialize_result(notice)), - McpMethod::InitializedAck | McpMethod::HookEvent => return None, - // The full tool catalog is advertised so the host caches the real - // tool surface; each call then explains the degraded state. - McpMethod::ToolsList => { - JsonRpcResponse::success(id, json!({ "tools": super::tools::get_tool_definitions() })) - } - // Tool execution errors are reported inside the result (`isError`) - // per the MCP spec, so the agent sees the remediation text. - McpMethod::ToolsCall => JsonRpcResponse::success( - id, - json!({ - "content": [{ "type": "text", "text": notice }], - "isError": true, - }), - ), - McpMethod::ResourcesList => JsonRpcResponse::success(id, resources_list_result()), - McpMethod::ResourcesRead => { - JsonRpcResponse::error(id, ErrorCode::InternalError, notice.to_string()) - } - McpMethod::TrivialAck => JsonRpcResponse::success(id, json!({})), - McpMethod::Unknown => JsonRpcResponse::error( - id, - ErrorCode::MethodNotFound, - format!("method not found: {}", request.method), - ), - }; - Some(response) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use serde_json::Value; - - const NOTICE: &str = "degraded test notice"; - - fn response(line: &str) -> Option { - degraded_response_for_line(line, NOTICE) - } - - fn result(line: &str) -> Value { - response(line).unwrap().result.unwrap() - } - - #[test] - fn initialize_uses_the_shared_handshake_payload_with_the_notice() { - let result = result(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#); - assert_eq!(result, initialize_result(NOTICE)); - assert_eq!(result["protocolVersion"], "2024-11-05"); - assert_eq!(result["serverInfo"]["name"], "tracedecay"); - assert_eq!(result["instructions"], NOTICE); - } - - #[test] - fn tools_call_reports_the_notice_as_an_in_result_error() { - let result = result( - r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tracedecay_context","arguments":{}}}"#, - ); - assert_eq!(result["isError"], json!(true)); - assert_eq!(result["content"][0]["text"], NOTICE); - } - - #[test] - fn tools_list_and_resources_list_serve_the_real_catalogs() { - let tools = result(r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#); - assert!( - !tools["tools"].as_array().unwrap().is_empty(), - "the degraded server must advertise the real tool catalog" - ); - let resources = result(r#"{"jsonrpc":"2.0","id":4,"method":"resources/list"}"#); - assert_eq!(resources, resources_list_result()); - } - - #[test] - fn notifications_and_initialized_acks_take_no_response() { - assert!(response(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none()); - assert!(response(r#"{"jsonrpc":"2.0","id":5,"method":"initialized"}"#).is_none()); - // A tools/call notification (no id) cannot be answered either. - assert!( - response(r#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"x"}}"#).is_none() - ); - } - - #[test] - fn unknown_methods_and_parse_errors_mirror_the_full_server() { - let unknown = response(r#"{"jsonrpc":"2.0","id":6,"method":"bogus/method"}"#).unwrap(); - assert_eq!( - unknown.error.unwrap().code, - ErrorCode::MethodNotFound.as_i32() - ); - let parse = response("not json").unwrap(); - assert_eq!(parse.error.unwrap().code, ErrorCode::ParseError.as_i32()); - } - - #[test] - fn tools_call_lines_are_recognized_for_resolution_retries() { - assert!(is_tools_call_line( - r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"x"}}"# - )); - assert!(!is_tools_call_line( - r#"{"jsonrpc":"2.0","id":8,"method":"tools/list"}"# - )); - assert!(!is_tools_call_line("not json")); - } -} diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 16c2d92c8..2013f2231 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -902,6 +902,24 @@ mod tests { ); } + #[test] + fn plans_cursor_session_start_as_current_branch_sync() { + let params = serde_json::to_value(crate::daemon::DaemonHookEvent::session_start( + HookAgent::Cursor, + PathBuf::from("/tmp/project"), + )) + .unwrap(); + let event = parse_or_panic(¶ms); + + assert_eq!( + plan_hook_event(&event, Path::new("/tmp/project"), Some("main")), + HookEventPlan::SyncCurrentBranch { + branch: "main".to_string(), + agent: HookAgent::Cursor, + } + ); + } + #[test] fn plans_workspace_open_as_current_branch_sync() { let params = json!({ diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index f86ffff9d..a34755b61 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -4,7 +4,6 @@ //! query the code graph interactively. Exposes tools for searching, context //! building, call graph traversal, impact analysis, and more. -pub(crate) mod degraded; pub(crate) mod hook_events; pub(crate) mod project_route; /// MCP server implementation. @@ -18,6 +17,7 @@ pub mod tools; /// JSON-RPC 2.0 transport types. pub mod transport; +pub(crate) use server::DatabaseOwnerReconciler; pub use server::McpServer; pub use tools::{ToolDefinition, ToolResult, get_tool_definitions, handle_tool_call}; pub use transport::{ diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 9ec8db7af..dfbc11c30 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -6,7 +6,9 @@ //! allowing AI assistants to query the code graph interactively. use std::collections::HashMap; +use std::future::Future; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -35,9 +37,7 @@ use super::tools::{ use super::transport::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; /// Every JSON-RPC method surface the MCP server understands. This is the -/// single source of truth for protocol dispatch, shared by the full server -/// ([`McpServer::handle_request`]) and the degraded startup server -/// ([`super::degraded`]) so the two surfaces cannot drift. +/// single source of truth for [`McpServer::handle_request`] dispatch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum McpMethod { Initialize, @@ -93,9 +93,7 @@ pub(crate) const SERVER_INSTRUCTIONS: &str = concat!( report the savings to the user (e.g. 'TraceDecay\\'d ~N tokens')." ); -/// The `initialize` result payload. One definition serves both the full -/// server and the degraded startup server (which substitutes its recovery -/// notice for the standard instructions). +/// The `initialize` result payload. pub(crate) fn initialize_result(instructions: &str) -> Value { json!({ "protocolVersion": "2024-11-05", @@ -114,8 +112,7 @@ pub(crate) fn initialize_result(instructions: &str) -> Value { }) } -/// The `resources/list` result payload, shared with the degraded startup -/// server (the resource catalog is static). +/// The `resources/list` result payload. pub(crate) fn resources_list_result() -> Value { json!({ "resources": [ @@ -731,6 +728,14 @@ struct VersionCheckState { checked_at: Option, } +/// Updates daemon ownership routing after this server changes physical graph DB. +/// Implementations must not call back into this `McpServer`: reconciliation is +/// awaited while the graph write guard is held so readers see the swap and +/// registry rekey atomically. +pub(crate) type DatabaseOwnerReconciler = Arc< + dyn Fn(Arc) -> Pin + Send>> + Send + Sync + 'static, +>; + /// The MCP server wrapping a `TraceDecay` instance. // Lock ordering: file_token_map -> method/resource/tool call counts (never nested) pub struct McpServer { @@ -768,6 +773,7 @@ pub struct McpServer { registry_db: Option>, allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, + database_owner_reconciler: Option, initialize_root_routing_enabled: AtomicBool, hook_project_routes: SharedHookProjectRouteCache, /// Cached latest-version check result. @@ -939,6 +945,27 @@ impl McpServer { registry_db: Option>, allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, + ) -> Arc { + Self::new_with_dbs_and_reconcilers( + cg, + scope_prefix, + global_db, + registry_db, + allow_default_registry_fallback, + automation_scheduler_reconciler, + None, + ) + .await + } + + pub(crate) async fn new_with_dbs_and_reconcilers( + cg: TraceDecay, + scope_prefix: Option, + global_db: Option>, + registry_db: Option>, + allow_default_registry_fallback: bool, + automation_scheduler_reconciler: Option, + database_owner_reconciler: Option, ) -> Arc { let file_token_map = cg.get_file_token_map().await.unwrap_or_default(); let persisted = cg.get_tokens_saved().await.unwrap_or(0); @@ -992,6 +1019,7 @@ impl McpServer { registry_db, allow_default_registry_fallback, automation_scheduler_reconciler, + database_owner_reconciler, initialize_root_routing_enabled: AtomicBool::new(true), hook_project_routes: SharedHookProjectRouteCache::default(), version_cache: std::sync::Mutex::new(VersionCheckState { @@ -1189,7 +1217,11 @@ impl McpServer { fresh.active_branch().unwrap_or("") ); *guard = Arc::new(fresh); - guard.clone() + let fresh = guard.clone(); + if let Some(reconcile) = &self.database_owner_reconciler { + reconcile(fresh.clone()).await; + } + fresh } Err(e) => { eprintln!( @@ -1216,7 +1248,11 @@ impl McpServer { fresh.active_branch().unwrap_or("") ); *guard = Arc::new(fresh); - true + let fresh = guard.clone(); + if let Some(reconcile) = &self.database_owner_reconciler { + reconcile(fresh.clone()).await; + } + Some(fresh) } Err(e) => { eprintln!( @@ -1224,11 +1260,11 @@ impl McpServer { continuing to serve branch '{}'", guard.serving_branch().unwrap_or("") ); - false + None } } }; - if reopened { + if reopened.is_some() { self.refresh_file_token_map().await; } } diff --git a/src/mcp/server/freshness_tests.rs b/src/mcp/server/freshness_tests.rs index a22481850..be6695fc1 100644 --- a/src/mcp/server/freshness_tests.rs +++ b/src/mcp/server/freshness_tests.rs @@ -1,7 +1,11 @@ -use super::{McpServer, StalenessBannerInputs, format_index_age_phrase, staleness_banner}; +use super::{ + DatabaseOwnerReconciler, McpServer, StalenessBannerInputs, format_index_age_phrase, + staleness_banner, +}; use crate::config::PinnedUserDataDir; use crate::tracedecay::TraceDecay; use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tempfile::TempDir; @@ -33,6 +37,49 @@ async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) { (cg, dir, pin) } +#[tokio::test] +async fn branch_drift_reconciles_database_owner_before_returning() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path(); + cg.checkpoint().await.unwrap(); + let layout = cg.store_layout().clone(); + drop(cg); + + let mut meta = crate::branch_meta::BranchMeta::new("main"); + meta.add_branch("feature", "branches/feature.db", "main"); + crate::branch_meta::save_branch_meta(&layout.data_root, &meta).unwrap(); + std::fs::create_dir_all(layout.data_root.join("branches")).unwrap(); + std::fs::copy( + &layout.graph_db_path, + layout.data_root.join("branches/feature.db"), + ) + .unwrap(); + + git(root, &["checkout", "-q", "-b", "feature"]); + git(root, &["checkout", "-q", "main"]); + let main = TraceDecay::open(root).await.unwrap(); + let observed = Arc::new(Mutex::new(Vec::new())); + let callback: DatabaseOwnerReconciler = { + let observed = Arc::clone(&observed); + Arc::new(move |fresh| { + let observed = Arc::clone(&observed); + Box::pin(async move { + observed.lock().unwrap().push(fresh.db_path()); + }) + }) + }; + let server = + McpServer::new_with_dbs_and_reconcilers(main, None, None, None, true, None, Some(callback)) + .await; + + git(root, &["checkout", "-q", "feature"]); + let fresh = server.reopen_if_branch_drifted().await; + + assert_eq!(fresh.serving_branch(), Some("feature")); + assert_eq!(observed.lock().unwrap().as_slice(), &[fresh.db_path()]); + server.shutdown().await; +} + // ---- D7 pure-logic banner tests (test c) -------------------------- #[test] diff --git a/src/mcp/tools/handlers/admin_cli.rs b/src/mcp/tools/handlers/admin_cli.rs new file mode 100644 index 000000000..27d592cfb --- /dev/null +++ b/src/mcp/tools/handlers/admin_cli.rs @@ -0,0 +1,449 @@ +//! Unadvertised daemon-owned operations used by one-shot CLI commands. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::errors::{Result, TraceDecayError}; +use crate::global_db::{AnalyticsEventQuery, GlobalDb}; +use crate::tracedecay::TraceDecay; + +use super::super::ToolResult; + +const GIT_BACKFILL_ANALYTICS_LIMIT: usize = 500_000; + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +enum AdminCliAction { + CostSummary { + range: String, + }, + SessionsIngest, + SessionsGitBackfill { + since: i64, + limit_sessions: usize, + dry_run: bool, + }, + SessionsUnfinished { + limit: usize, + }, + AnalyticsSync, + AnalyticsDiagnostics { + all: bool, + no_sync: bool, + }, + RegistryUpdate { + tokens: u64, + }, + RegistryList { + limit: usize, + query: Option, + }, + RegistryContext { + project_arg: Option, + }, + RegistryEmpty, + RegistryProjectTokens { + project_args: Vec, + }, + GainQuery { + project_arg: Option, + since: i64, + history: bool, + }, +} + +pub(super) async fn handle_admin_cli( + cg: &TraceDecay, + args: Value, + global_db: Option<&GlobalDb>, +) -> Result { + let action: AdminCliAction = + serde_json::from_value(args).map_err(|error| TraceDecayError::Config { + message: format!("invalid tracedecay_admin_cli arguments: {error}"), + })?; + let global_db = global_db.ok_or_else(|| TraceDecayError::Config { + message: "daemon global database is unavailable".to_string(), + })?; + let value = match action { + AdminCliAction::CostSummary { range } => cost_summary(global_db, &range).await, + AdminCliAction::SessionsIngest => sessions_ingest(cg).await?, + AdminCliAction::SessionsGitBackfill { + since, + limit_sessions, + dry_run, + } => sessions_git_backfill(cg, global_db, since, limit_sessions, dry_run).await?, + AdminCliAction::SessionsUnfinished { limit } => sessions_unfinished(cg, limit).await?, + AdminCliAction::AnalyticsSync => { + crate::analytics_bridge::analytics_sync_with_db(global_db, Some(cg.project_root())) + .await + } + AdminCliAction::AnalyticsDiagnostics { all, no_sync } => { + crate::analytics_bridge::analytics_diagnostics_with_db( + global_db, + Some(cg.project_root()), + all, + no_sync, + ) + .await? + } + AdminCliAction::RegistryUpdate { tokens } => { + let previous = global_db.get_project_tokens(cg.project_root()).await; + global_db.upsert(cg.project_root(), tokens).await; + json!({ "previous": previous, "current": tokens }) + } + AdminCliAction::RegistryList { limit, query } => { + registry_list(Some(cg), global_db, limit, query.as_deref()).await + } + AdminCliAction::RegistryContext { project_arg } => { + registry_context(Some(cg), global_db, project_arg.as_deref()).await + } + AdminCliAction::RegistryEmpty => registry_empty(global_db).await, + AdminCliAction::RegistryProjectTokens { project_args } => { + registry_project_tokens(global_db, &project_args).await + } + AdminCliAction::GainQuery { + project_arg, + since, + history, + } => gain_query(global_db, project_arg.as_deref(), since, history).await, + }; + Ok(json_result(value)) +} + +pub(crate) async fn handle_projectless_admin_cli( + args: Value, + profile_root: &Path, +) -> Result { + let action: AdminCliAction = + serde_json::from_value(args).map_err(|error| TraceDecayError::Config { + message: format!("invalid tracedecay_admin_cli arguments: {error}"), + })?; + let global_db = GlobalDb::open_at(&profile_root.join("global.db")) + .await + .ok_or_else(|| TraceDecayError::Config { + message: "daemon global database is unavailable".to_string(), + })?; + let value = match action { + AdminCliAction::CostSummary { range } => cost_summary(&global_db, &range).await, + AdminCliAction::AnalyticsSync => { + crate::analytics_bridge::analytics_sync_with_db(&global_db, None).await + } + AdminCliAction::AnalyticsDiagnostics { all, no_sync } => { + crate::analytics_bridge::analytics_diagnostics_with_db(&global_db, None, all, no_sync) + .await? + } + AdminCliAction::RegistryList { limit, query } => { + registry_list(None, &global_db, limit, query.as_deref()).await + } + AdminCliAction::RegistryContext { project_arg } => { + registry_context(None, &global_db, project_arg.as_deref()).await + } + AdminCliAction::RegistryEmpty => registry_empty(&global_db).await, + AdminCliAction::RegistryProjectTokens { project_args } => { + registry_project_tokens(&global_db, &project_args).await + } + AdminCliAction::GainQuery { + project_arg, + since, + history, + } => gain_query(&global_db, project_arg.as_deref(), since, history).await, + AdminCliAction::SessionsIngest + | AdminCliAction::SessionsGitBackfill { .. } + | AdminCliAction::SessionsUnfinished { .. } + | AdminCliAction::RegistryUpdate { .. } => { + return Err(TraceDecayError::Config { + message: "requested admin action requires an initialized project".to_string(), + }); + } + }; + Ok(json_result(value)) +} + +async fn registry_empty(global_db: &GlobalDb) -> Value { + json!({ "empty": global_db.list_code_projects(1).await.is_empty() }) +} + +async fn registry_project_tokens(global_db: &GlobalDb, project_args: &[PathBuf]) -> Value { + let mut projects = Vec::with_capacity(project_args.len()); + for project in project_args { + projects.push(json!({ + "project": project, + "tokens": global_db.get_project_tokens(project).await, + })); + } + json!({ "projects": projects }) +} + +async fn gain_query( + global_db: &GlobalDb, + project_arg: Option<&Path>, + since: i64, + history: bool, +) -> Value { + let project = project_arg.map(|path| path.to_string_lossy().to_string()); + if history { + let rows = global_db.savings_history(project.as_deref(), since).await; + return json!({ + "history": rows.iter().map(|row| json!({ + "day": row.day, + "saved_tokens": row.saved_tokens, + "calls": row.calls, + })).collect::>(), + }); + } + let total = global_db.sum_savings(project.as_deref(), since).await; + json!({ "saved_tokens": total.saved_tokens, "calls": total.calls }) +} + +async fn registry_list( + cg: Option<&TraceDecay>, + global_db: &GlobalDb, + limit: usize, + query: Option<&str>, +) -> Value { + use crate::project_registry::{PublicCodeProject, build_project_registry_view}; + + let limit = limit.clamp(1, 100_000); + let mut projects = match query { + Some(query) => global_db.search_code_projects(query, limit + 1).await, + None => global_db.list_code_projects(limit + 1).await, + }; + let truncated = projects.len() > limit; + projects.truncate(limit); + let active_id = match cg { + Some(cg) => active_project_id(cg, global_db).await, + None => None, + }; + let contexts = global_db + .project_registry_contexts_for_projects(&projects) + .await; + let view = build_project_registry_view(&contexts, active_id.as_deref(), truncated); + let public = projects + .iter() + .map(|project| PublicCodeProject::from_record(project, active_id.as_deref())) + .collect::>(); + json!({ + "status": "ok", + "limit": limit, + "query": query, + "truncated": truncated, + "summary": view.summary, + "project_tree": view.project_tree, + "projects": public, + }) +} + +async fn active_project_id(cg: &TraceDecay, global_db: &GlobalDb) -> Option { + let git_common_dir = crate::worktree::git_common_dir(cg.project_root()); + global_db + .project_registry_context_by_identity(cg.project_root(), git_common_dir.as_deref()) + .await + .map(|context| context.project.project_id) +} + +async fn registry_context( + cg: Option<&TraceDecay>, + global_db: &GlobalDb, + project_arg: Option<&Path>, +) -> Value { + use crate::project_registry::PublicProjectRegistryContext; + + let Some(selector) = project_arg.or_else(|| cg.map(TraceDecay::project_root)) else { + return json!({ "status": "invalid", "project": null }); + }; + let selector_text = selector.to_string_lossy(); + let context = if !GlobalDb::is_explicit_project_path_selector(&selector_text) { + global_db + .project_registry_context_by_id(&selector_text) + .await + } else { + None + }; + let context = match context { + Some(context) => Some(context), + None => match global_db.project_registry_context_by_alias(selector).await { + Some(context) => Some(context), + None if GlobalDb::is_explicit_project_path_selector(&selector_text) => { + let git_common_dir = crate::worktree::git_common_dir(selector); + global_db + .project_registry_context_by_identity(selector, git_common_dir.as_deref()) + .await + } + None => None, + }, + }; + let Some(context) = context else { + return json!({ "status": "not_found", "project": null }); + }; + let active_id = match cg { + Some(cg) => active_project_id(cg, global_db).await, + None => None, + }; + let public = PublicProjectRegistryContext::new(&context, active_id.as_deref()); + json!({ + "status": "ok", + "project": public.project, + "aliases": context.aliases, + "stores": context.stores, + }) +} + +async fn cost_summary(global_db: &GlobalDb, range: &str) -> Value { + crate::accounting::pricing::refresh_if_stale(); + let ingest = crate::accounting::parser::ingest(global_db).await; + let since = crate::accounting::metrics::parse_range(range); + let tokens_saved = global_db.global_tokens_saved().await.unwrap_or(0); + let summary = crate::accounting::metrics::cost_summary(global_db, since, tokens_saved).await; + let today_since = crate::accounting::metrics::parse_range("today"); + let today_cost = global_db.total_cost_since(today_since).await.unwrap_or(0.0); + let today_breakdown = global_db + .token_breakdown_since(today_since) + .await + .unwrap_or((0, 0, 0)); + json!({ + "range": range, + "ingest": { + "turns_inserted": ingest.turns_inserted, + "cost_usd": ingest.cost_usd, + "tokens_consumed": ingest.tokens_consumed, + }, + "summary": summary.map(|summary| json!({ + "total_cost": summary.total_cost, + "total_input_tokens": summary.total_input_tokens, + "total_output_tokens": summary.total_output_tokens, + "total_cache_read_tokens": summary.total_cache_read_tokens, + "by_model": summary.by_model, + "by_category": summary.by_category, + "tokens_saved": summary.tokens_saved, + "efficiency_ratio": summary.efficiency_ratio, + })), + "today": { + "cost": today_cost, + "input_tokens": today_breakdown.0, + "output_tokens": today_breakdown.1, + "cache_read_tokens": today_breakdown.2, + }, + }) +} + +async fn open_session_db(cg: &TraceDecay) -> Result { + GlobalDb::open_at(&cg.store_layout().sessions_db_path) + .await + .ok_or_else(|| TraceDecayError::Config { + message: format!( + "daemon could not open project session database for {}", + cg.project_root().display() + ), + }) +} + +async fn sessions_ingest(cg: &TraceDecay) -> Result { + let db = open_session_db(cg).await?; + let stats = crate::sessions::ingest_global_sources(&db, cg.project_root()).await; + Ok(json!({ + "sessions_upserted": stats.sessions_upserted, + "messages_upserted": stats.messages_upserted, + })) +} + +async fn sessions_git_backfill( + cg: &TraceDecay, + global_db: &GlobalDb, + since: i64, + limit_sessions: usize, + dry_run: bool, +) -> Result { + use crate::sessions::git_correlation::{ + BackfillOptions, DEFAULT_SPAN_MERGE_GAP_SECS, SystemGit, run_backfill, + }; + + let session_db = open_session_db(cg).await?; + let project_id = GlobalDb::canonical_project_key(cg.project_root()); + let analytics_events = global_db + .query_analytics_events(&AnalyticsEventQuery { + project_id: Some(project_id), + since: Some(since), + limit: GIT_BACKFILL_ANALYTICS_LIMIT, + ..Default::default() + }) + .await + .unwrap_or_default(); + let stats = run_backfill( + &session_db, + &analytics_events, + &SystemGit, + &BackfillOptions { + since, + limit_sessions, + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + max_commits_per_repo: 5_000, + dry_run, + }, + ) + .await + .map_err(|error| TraceDecayError::Config { + message: format!("git backfill failed: {error}"), + })?; + Ok(json!({ + "dry_run": dry_run, + "sessions_scanned": stats.sessions_scanned, + "spans_written": stats.spans_written, + "commits_attributed": stats.commits_attributed, + "skipped_no_window": stats.skipped_no_window, + "skipped_not_worktree": stats.skipped_not_worktree, + "skipped_git_error": stats.skipped_git_error, + "skipped_total": stats.skipped_total(), + })) +} + +async fn sessions_unfinished(cg: &TraceDecay, limit: usize) -> Result { + let db = open_session_db(cg).await?; + let items = crate::sessions::workflow_state::list_unfinished(&db, limit) + .await + .map_err(|message| TraceDecayError::Config { message })?; + Ok(json!({ "items": items })) +} + +fn json_result(value: Value) -> ToolResult { + ToolResult::new( + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&value).unwrap_or_default(), + }] + }), + Vec::new(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_projectless_and_project_scoped_actions() { + assert!(matches!( + serde_json::from_value::(json!({ + "action": "registry_context", + "project_arg": "/repo", + })), + Ok(AdminCliAction::RegistryContext { .. }) + )); + assert!(matches!( + serde_json::from_value::(json!({ + "action": "sessions_git_backfill", + "since": 1, + "limit_sessions": 50, + "dry_run": true, + })), + Ok(AdminCliAction::SessionsGitBackfill { dry_run: true, .. }) + )); + } + + #[test] + fn rejects_unknown_admin_action() { + assert!(serde_json::from_value::(json!({ "action": "vacuum" })).is_err()); + } +} diff --git a/src/mcp/tools/handlers/admin_project.rs b/src/mcp/tools/handlers/admin_project.rs new file mode 100644 index 000000000..88a000e73 --- /dev/null +++ b/src/mcp/tools/handlers/admin_project.rs @@ -0,0 +1,538 @@ +//! Unadvertised daemon-owned project operations used by one-shot CLI commands. + +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::errors::{Result, TraceDecayError}; +use crate::global_db::GlobalDb; +use crate::tracedecay::TraceDecay; + +use super::super::ToolResult; + +static FACT_APPLY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +enum AdminProjectAction { + CounterGet, + CounterReset, + StatusAccounting, + MemoryStatus, + RuntimeStatus { + json: bool, + }, + MemoryCurate { + apply: bool, + llm: bool, + llm_ops: Option, + max_clusters: usize, + min_confidence: f64, + }, + Bench { + queries_toml: Option, + json: bool, + max_nodes: usize, + }, + FactApply { + id: String, + }, + AutomationRun { + task: AutomationRunTask, + options: Value, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AutomationRunTask { + MemoryCuration, + SessionReflection, + SkillWriting, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MemoryCurationOptions { + max_clusters: usize, + min_confidence: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionReflectionOptions { + provider: String, + query: String, + evidence_limit: usize, + scope: crate::sessions::lcm::LcmScope, + session_id: Option, + include_summaries: bool, + sort: crate::sessions::lcm::LcmGrepSort, + source: Option, + role: Option, + start_time: Option, + end_time: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SkillWritingOptions { + provider: String, + query: String, + evidence_limit: usize, +} + +pub(super) async fn handle_admin_project( + cg: &TraceDecay, + args: Value, + global_db: Option<&GlobalDb>, +) -> Result { + let action: AdminProjectAction = + serde_json::from_value(args).map_err(|error| TraceDecayError::Config { + message: format!("invalid tracedecay_admin_project arguments: {error}"), + })?; + let value = match action { + AdminProjectAction::CounterGet => json!({ "counter": cg.get_local_counter().await? }), + AdminProjectAction::CounterReset => { + cg.reset_local_counter().await?; + json!({ "reset": true }) + } + AdminProjectAction::StatusAccounting => { + let global_db = global_db.ok_or_else(|| TraceDecayError::Config { + message: "daemon global database is unavailable".to_string(), + })?; + let tokens_saved = cg.get_tokens_saved().await.unwrap_or(0); + global_db.upsert(cg.project_root(), tokens_saved).await; + let global_tokens_saved = global_db + .global_tokens_saved() + .await + .map(|total| total.saturating_sub(tokens_saved)) + .filter(|total| *total > 0); + json!({ + "tokens_saved": tokens_saved, + "global_tokens_saved": global_tokens_saved, + }) + } + AdminProjectAction::MemoryStatus => { + let status = cg.project_memory_status().await?; + let db = cg.open_project_store_db().await?; + let mut rows = db + .conn() + .query("SELECT COALESCE(MAX(fact_count), 0) FROM memory_banks", ()) + .await?; + let largest_bank_fact_count = rows + .next() + .await? + .and_then(|row| row.get::(0).ok()) + .unwrap_or(0) + .max(0) as usize; + json!({ + "status": status, + "largest_bank_fact_count": largest_bank_fact_count, + }) + } + AdminProjectAction::RuntimeStatus { json } => { + let snapshot = crate::runtime_telemetry::collect(cg).await?; + let output = if json { + crate::runtime_telemetry::to_pretty_json(&snapshot) + } else { + crate::runtime_telemetry::to_text_report(&snapshot) + }; + json!({ "output": output }) + } + AdminProjectAction::MemoryCurate { + apply, + llm, + llm_ops, + max_clusters, + min_confidence, + } => { + let options = crate::dashboard::memory_curate::MemoryCurateOptions { + apply, + llm, + llm_ops, + max_clusters: max_clusters.clamp(1, 50), + min_confidence: min_confidence.clamp(0.0, 1.0), + }; + crate::dashboard::memory_curate::run_memory_curate(cg, &options).await? + } + AdminProjectAction::Bench { + queries_toml, + json, + max_nodes, + } => { + let report = crate::bench::run_bench_with_toml( + cg, + queries_toml + .as_deref() + .unwrap_or(crate::bench::DEFAULT_QUERIES_TOML), + crate::bench::BenchOptions { + format: crate::bench::OutputFormat::Json, + max_nodes, + }, + ) + .await?; + let output = if json { + crate::bench::format_report_json(&report) + } else { + crate::bench::format_report_console(&report) + }; + json!({ "output": output }) + } + AdminProjectAction::FactApply { id } => { + let _guard = FACT_APPLY_LOCK.lock().await; + let db = cg.open_project_store_db().await?; + let proposal = crate::automation::fact_proposals::apply_fact_proposal( + &cg.store_layout().dashboard_root, + db.conn(), + &id, + Some("cli".to_string()), + ) + .await?; + crate::automation::memory_digest::refresh_memory_digest_after_memory_change( + db.conn(), + cg.project_root(), + ) + .await; + json!({ "proposal": proposal }) + } + AdminProjectAction::AutomationRun { task, options } => { + run_automation(cg, global_db, task, options).await? + } + }; + Ok(json_result(value)) +} + +async fn run_automation( + cg: &TraceDecay, + global_db: Option<&GlobalDb>, + task: AutomationRunTask, + options: Value, +) -> Result { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::config::{AutomationBackend, effective_config, load_project_config}; + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + MemoryCuratorAutomationOptions, SessionReflectorAutomationOptions, + SkillWriterAutomationOptions, run_memory_curator_with_backend, + run_session_reflector_with_backend, run_skill_writer_with_backend, + }; + + let profile_root = cg + .open_options() + .profile_root + .or_else(|| { + global_db + .and_then(|db| db.db_path().parent()) + .map(std::path::Path::to_path_buf) + }) + .ok_or_else(|| TraceDecayError::Config { + message: "daemon project has no profile root".to_string(), + })?; + let config_path = profile_root.join("config.toml"); + let global: crate::user_config::UserConfig = std::fs::read_to_string(&config_path) + .map(|contents| crate::user_config::parse_or_warn_default(&config_path, &contents)) + .unwrap_or_default(); + let project = load_project_config(&cg.store_layout().dashboard_root).await?; + let config = effective_config(&global.automation, project.as_ref())?; + if config.backend == AutomationBackend::ExternalCommand { + return Err(TraceDecayError::Config { + message: "automation backend external_command is not implemented yet".to_string(), + }); + } + let backend = CodexAppServerBackend::from_automation_config(&config); + + let run = match task { + AutomationRunTask::MemoryCuration => { + let options = decode_options::(options)?; + serde_json::to_value( + run_memory_curator_with_backend( + cg, + &config, + &backend, + MemoryCuratorAutomationOptions { + trigger: AutomationTrigger::ManualCli, + run_id: None, + max_clusters: options.max_clusters, + min_confidence: options.min_confidence, + }, + ) + .await?, + )? + } + AutomationRunTask::SessionReflection => { + let options = decode_options::(options)?; + serde_json::to_value( + run_session_reflector_with_backend( + cg, + &config, + &backend, + SessionReflectorAutomationOptions { + trigger: AutomationTrigger::ManualCli, + run_id: None, + provider: options.provider, + query: options.query, + scope: options.scope, + session_id: options.session_id, + include_summaries: options.include_summaries, + evidence_limit: options.evidence_limit, + sort: options.sort, + source: options.source, + role: options.role, + start_time: options.start_time, + end_time: options.end_time, + ..SessionReflectorAutomationOptions::default() + }, + ) + .await?, + )? + } + AutomationRunTask::SkillWriting => { + let options = decode_options::(options)?; + serde_json::to_value( + run_skill_writer_with_backend( + cg, + &config, + &backend, + SkillWriterAutomationOptions { + trigger: AutomationTrigger::ManualCli, + run_id: None, + provider: options.provider, + query: options.query, + evidence_limit: options.evidence_limit, + ..SkillWriterAutomationOptions::default() + }, + ) + .await?, + )? + } + }; + Ok(json!({ "run": run })) +} + +fn decode_options(options: Value) -> Result { + serde_json::from_value(options).map_err(|error| TraceDecayError::Config { + message: format!("invalid tracedecay_admin_project automation options: {error}"), + }) +} + +fn json_result(value: Value) -> ToolResult { + ToolResult::new( + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&value).unwrap_or_default(), + }] + }), + Vec::new(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_json(result: ToolResult) -> Value { + let text = result.value["content"][0]["text"] + .as_str() + .expect("admin project result should contain JSON text"); + serde_json::from_str(text).expect("admin project result should be valid JSON") + } + + #[tokio::test] + async fn admin_project_handler_executes_typed_fact_and_automation_round_trips_on_one_authority() + { + use crate::automation::fact_proposals::{ + FactProposalRecord, FactProposalState, record_session_fact_proposals, + }; + use crate::automation::run_ledger::{AutomationRunStatus, AutomationTrigger}; + use crate::automation::runner::MemoryCuratorAutomationRun; + + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("project"); + let profile_root = temp.path().join("profile"); + std::fs::create_dir_all(&project_root).unwrap(); + let cg = TraceDecay::init_with_options( + &project_root, + crate::tracedecay::TraceDecayOpenOptions { + global_db_path: Some(profile_root.join("global.db")), + profile_root: Some(profile_root), + }, + ) + .await + .unwrap(); + let owner_before = crate::db::probe_writer_owner(&cg.store_layout().graph_db_path).unwrap(); + + let proposals = record_session_fact_proposals( + &cg.store_layout().dashboard_root, + "rpc-run-1", + None, + &[json!({ + "add_fact_request": { + "content": "Admin project RPC applies this durable fact", + "category": "decision", + "source": null, + "tags": [], + "entities": [], + "trust": 0.9, + "metadata": {} + } + })], + &[], + ) + .await + .unwrap(); + let fact = tool_json( + handle_admin_project( + &cg, + json!({ "action": "fact_apply", "id": proposals[0].proposal_id.clone() }), + None, + ) + .await + .unwrap(), + ); + let fact = serde_json::from_value::(fact["proposal"].clone()).unwrap(); + assert_eq!(fact.state, FactProposalState::Applied); + assert_eq!(fact.reviewer.as_deref(), Some("cli")); + + let automation = tool_json( + handle_admin_project( + &cg, + json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { "max_clusters": 9, "min_confidence": 0.7 } + }), + None, + ) + .await + .unwrap(), + ); + let run = serde_json::from_value::(automation["run"].clone()) + .unwrap(); + assert_eq!(run.ledger_record.trigger, AutomationTrigger::ManualCli); + assert_eq!(run.ledger_record.status, AutomationRunStatus::Skipped); + assert!(matches!( + run.report["reason"].as_str(), + Some("automation_disabled" | "backend_disabled") + )); + + let owner_after = crate::db::probe_writer_owner(&cg.store_layout().graph_db_path).unwrap(); + assert_eq!(owner_after, owner_before); + let client_source = include_str!("../../../automation_cli.rs"); + let direct_init = ["serve::ensure_", "initialized"].concat(); + let direct_apply = ["apply_fact_", "proposal("].concat(); + assert!(client_source.contains("tracedecay_admin_project")); + assert!(!client_source.contains(&direct_init)); + assert!(!client_source.contains(&direct_apply)); + } + + #[test] + fn admin_project_wire_contract_round_trips_typed_results_without_local_fallback() { + use crate::automation::runner::MemoryCuratorAutomationRun; + + let fact_request = json!({ "action": "fact_apply", "id": "fact_1" }); + let fact = serde_json::from_value::(fact_request).unwrap(); + assert!(matches!(fact, AdminProjectAction::FactApply { id } if id == "fact_1")); + + let run_request = json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { "max_clusters": 12, "min_confidence": 0.75 } + }); + let action = serde_json::from_value::(run_request).unwrap(); + let AdminProjectAction::AutomationRun { task, options } = action else { + panic!("manual automation request did not reach automation_run"); + }; + assert!(matches!(task, AutomationRunTask::MemoryCuration)); + let options = decode_options::(options).unwrap(); + assert_eq!(options.max_clusters, 12); + assert_eq!(options.min_confidence, 0.75); + + let typed_run = serde_json::from_value::(json!({ + "run_id": "run-5", + "report": { "status": "ok" }, + "ledger_record": { + "schema_version": 1, + "run_id": "run-5", + "trigger": "manual_cli", + "task": "memory_curator", + "backend": "codex-app-server", + "status": "succeeded", + "accepted_count": 1, + "rejected_count": 0, + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:00:01Z" + } + })) + .unwrap(); + let response = json!({ "run": serde_json::to_value(&typed_run).unwrap() }); + let client_run = response.get("run").unwrap(); + let round_trip = + serde_json::from_value::(client_run.clone()).unwrap(); + assert_eq!(round_trip, typed_run); + + let client_source = include_str!("../../../automation_cli.rs"); + let direct_init = ["serve::ensure_", "initialized"].concat(); + let direct_apply = ["apply_fact_", "proposal("].concat(); + assert!(client_source.contains("tracedecay_admin_project")); + assert!(!client_source.contains(&direct_init)); + assert!(!client_source.contains(&direct_apply)); + } + + #[test] + fn automation_admin_actions_have_stable_strict_schemas() { + let fact = serde_json::from_value::(json!({ + "action": "fact_apply", + "id": "fact_1" + })) + .unwrap(); + assert!(matches!(fact, AdminProjectAction::FactApply { id } if id == "fact_1")); + + let run = serde_json::from_value::(json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { "max_clusters": 12, "min_confidence": 0.75 } + })) + .unwrap(); + assert!(matches!( + run, + AdminProjectAction::AutomationRun { + task: AutomationRunTask::MemoryCuration, + .. + } + )); + assert!( + decode_options::(json!({ + "max_clusters": 12, + "min_confidence": 0.75, + "unknown": true + })) + .is_err() + ); + + let session = decode_options::(json!({ + "provider": "claude", + "query": "decisions", + "evidence_limit": 11, + "scope": "session", + "session_id": "session-3", + "include_summaries": false, + "sort": "hybrid", + "source": "assistant", + "role": "user", + "start_time": 10, + "end_time": 20 + })) + .unwrap(); + assert_eq!(session.scope, crate::sessions::lcm::LcmScope::Session); + assert_eq!(session.sort, crate::sessions::lcm::LcmGrepSort::Hybrid); + + let skill = decode_options::(json!({ + "provider": "all", + "query": "repeated workflow", + "evidence_limit": 13 + })) + .unwrap(); + assert_eq!(skill.evidence_limit, 13); + } +} diff --git a/src/mcp/tools/handlers/hook_runtime.rs b/src/mcp/tools/handlers/hook_runtime.rs new file mode 100644 index 000000000..94da8872b --- /dev/null +++ b/src/mcp/tools/handlers/hook_runtime.rs @@ -0,0 +1,637 @@ +use serde_json::{Value, json}; +use std::path::Path; + +use crate::errors::{Result, TraceDecayError}; +use crate::global_db::GlobalDb; +use crate::mcp::tools::ToolResult; +use crate::tracedecay::TraceDecay; + +use super::render; + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| config_error(format!("missing required parameter `{key}`"))) +} + +fn rendered(project_root: Option<&std::path::Path>, args: &Value, value: Value) -> ToolResult { + let text = render::finalize(project_root, args, &value, || render::generic_md(&value)); + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + vec![], + ) +} + +pub async fn handle_hook_runtime( + cg: &TraceDecay, + args: Value, + global_db: Option<&GlobalDb>, +) -> Result { + let action = required_str(&args, "action")?; + let output = match action { + "reset_counter" => { + cg.reset_local_counter().await?; + json!({ "action": action, "reset": true }) + } + "accounting_receipt" => accounting_receipt(cg, global_db).await?, + "ingest_transcript" => { + if args.get("user_scope").and_then(Value::as_bool) == Some(true) { + return Err(config_error( + "user transcript ingest requires projectless daemon routing", + )); + } + ingest_transcript(Some(cg), &args, None, None).await? + } + "user_review" | "hermes_receipt" => { + return Err(config_error(format!( + "hook action `{action}` requires projectless daemon routing" + ))); + } + "codex_compact" => codex_compact(cg, &args).await?, + "cursor_compact" => cursor_compact(cg, &args).await?, + other => { + return Err(config_error(format!( + "unknown hook runtime action: {other}" + ))); + } + }; + Ok(rendered(Some(cg.project_root()), &args, output)) +} + +pub async fn handle_projectless_hook_runtime( + args: Value, + profile_root: &Path, +) -> Result { + let action = required_str(&args, "action")?; + let allowed = matches!(action, "user_review" | "hermes_receipt") + || (action == "ingest_transcript" + && args.get("user_scope").and_then(Value::as_bool) == Some(true)); + if !allowed { + return Err(config_error(format!( + "projectless hook runtime action `{action}` is forbidden" + ))); + } + let global_db = GlobalDb::open_at(&profile_root.join("global.db")) + .await + .ok_or_else(|| config_error("daemon could not open client registry database"))?; + let output = match action { + "ingest_transcript" => { + ingest_transcript(None, &args, Some(profile_root), Some(&global_db)).await? + } + "user_review" => user_review(&args, profile_root).await?, + "hermes_receipt" => hermes_receipt(&args, profile_root).await?, + _ => unreachable!("projectless hook action validated above"), + }; + Ok(rendered(None, &args, output)) +} + +async fn codex_compact(cg: &TraceDecay, args: &Value) -> Result { + let event_json = required_str(args, "event_json")?; + let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + .await + .ok_or_else(|| config_error("daemon could not open project session database"))?; + if let Some(source) = crate::sessions::codex::CodexSource::new() { + let _ = crate::sessions::source::ingest_source(&db, &source, cg.project_root(), None).await; + } + let session_id = serde_json::from_str::(event_json) + .ok() + .as_ref() + .and_then(|value| { + ["session_id", "conversation_id", "thread_id"] + .iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .map(str::to_string) + }); + let mut pending = db + .pending_codex_compaction_summary_requests(session_id.as_deref(), 1) + .await + .map_err(|error| config_error(format!("load Codex compaction request failed: {error}")))?; + let Some(pending) = pending.pop() else { + return Ok(json!({ + "action": "codex_compact", + "status": "skipped", + "reason": "no pending compaction summary", + })); + }; + let config = crate::sessions::codex_app_server::CodexAppServerSummaryConfig::from_env(); + let summary = crate::sessions::codex_app_server::summarize_with_codex_app_server( + &pending.request, + &config, + ) + .map_err(|error| config_error(format!("Codex summary failed: {error}")))?; + db.replace_codex_compaction_summary( + &pending.node_id, + &summary.text, + "codex_app_server", + summary.model.as_deref().or(config.model.as_deref()), + ) + .await + .map_err(|error| config_error(format!("store Codex compaction summary failed: {error}")))?; + Ok(json!({ + "action": "codex_compact", + "status": "completed", + "node_id": pending.node_id, + })) +} + +async fn cursor_compact(cg: &TraceDecay, args: &Value) -> Result { + let event_json = required_str(args, "event_json")?; + let parsed: Value = serde_json::from_str(event_json)?; + let session_id = ["session_id", "conversation_id", "chat_id"] + .iter() + .find_map(|key| parsed.get(*key).and_then(Value::as_str)) + .filter(|value| !value.is_empty()) + .ok_or_else(|| config_error("Cursor preCompact event omitted session id"))?; + let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + .await + .ok_or_else(|| config_error("daemon could not open project session database"))?; + let ingest = + crate::sessions::cursor::ingest_cursor_transcript_event_capped(event_json, &db, None).await; + let messages_to_compact = event_usize(&parsed, &["messages_to_compact", "compact_count"]); + if messages_to_compact == Some(0) { + return Ok(cursor_compact_skipped("no messages to compact")); + } + let message_count = event_usize(&parsed, &["message_count", "messages_count"]); + let fresh_tail_count = message_count + .zip(messages_to_compact) + .map(|(count, compact)| count.saturating_sub(compact)); + let current_tokens = event_i64(&parsed, &["context_tokens", "current_tokens", "tokens"]); + let context_length = event_i64(&parsed, &["context_window_size", "context_length"]); + let first = db + .lcm_compress(cursor_lcm_request( + session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::HermesAuxiliary, + None, + )) + .await + .map_err(|error| config_error(format!("prepare Cursor compaction failed: {error}")))?; + let Some(summary_request) = first.summary_request else { + return Ok(cursor_compact_skipped(first.reason)); + }; + let config = crate::sessions::cursor_agent::CursorAgentSummaryConfig::from_env(); + let summary = + crate::sessions::cursor_agent::summarize_with_cursor_agent(&summary_request, &config) + .map_err(|error| config_error(format!("cursor-agent summary failed: {error}")))?; + let second = db + .lcm_compress(cursor_lcm_request( + session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::Provided { + summary_text: summary, + route: Some("cursor_agent".to_string()), + }, + first.frontier.current_frontier_store_id.or(Some(0)), + )) + .await + .map_err(|error| config_error(format!("store Cursor compaction failed: {error}")))?; + Ok(json!({ + "status": second.status, + "reason": second.reason, + "summary_nodes_created": second.summary_nodes_created, + "summary_node_ids": second.summary_nodes.into_iter().map(|node| node.node_id).collect::>(), + "messages_upserted": ingest.messages_upserted, + })) +} + +fn cursor_compact_skipped(reason: impl Into) -> Value { + json!({ + "status": "skipped", + "reason": reason.into(), + "summary_nodes_created": 0, + "summary_node_ids": [], + }) +} + +fn event_i64(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = value.get(*key)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| value.as_str()?.parse().ok()) + }) +} + +fn event_usize(value: &Value, keys: &[&str]) -> Option { + event_i64(value, keys).and_then(|value| usize::try_from(value).ok()) +} + +fn cursor_lcm_request( + session_id: &str, + current_tokens: Option, + context_length: Option, + max_source_messages: Option, + fresh_tail_count: Option, + summarizer: crate::sessions::lcm::LcmSummarizerMode, + expected_current_frontier_store_id: Option, +) -> crate::sessions::lcm::LcmCompressionRequest { + crate::sessions::lcm::LcmCompressionRequest { + provider: "cursor".to_string(), + session_id: session_id.to_string(), + messages: Vec::new(), + current_tokens, + focus_topic: Some("Cursor context compaction".to_string()), + ignore_session_patterns: Vec::new(), + stateless_session_patterns: Vec::new(), + ignore_message_patterns: Vec::new(), + expected_current_frontier_store_id, + threshold_tokens: None, + max_assembly_tokens: None, + leaf_chunk_tokens: None, + max_source_messages, + summary_fan_in: None, + incremental_max_depth: None, + fresh_tail_count, + dynamic_leaf_chunk_enabled: None, + dynamic_leaf_chunk_max: None, + context_length, + reserve_tokens_floor: None, + summarizer, + } +} + +async fn accounting_receipt(cg: &TraceDecay, global_db: Option<&GlobalDb>) -> Result { + let global_db = global_db.ok_or_else(|| { + config_error("daemon accounting database is unavailable; local fallback is forbidden") + })?; + let stats = crate::accounting::parser::ingest(global_db).await; + let tokens_saved = cg.get_tokens_saved().await.unwrap_or(0); + let efficiency = if tokens_saved + stats.tokens_consumed > 0 { + (tokens_saved as f64 / (tokens_saved + stats.tokens_consumed) as f64) * 100.0 + } else { + 0.0 + }; + Ok(json!({ + "action": "accounting_receipt", + "turns_inserted": stats.turns_inserted, + "cost_usd": stats.cost_usd, + "tokens_consumed": stats.tokens_consumed, + "tokens_saved": tokens_saved, + "efficiency": efficiency, + })) +} + +async fn ingest_transcript( + cg: Option<&TraceDecay>, + args: &Value, + profile_root: Option<&Path>, + global_db: Option<&GlobalDb>, +) -> Result { + let provider = required_str(args, "provider")?; + let user_scope = args + .get("user_scope") + .and_then(Value::as_bool) + .unwrap_or(false); + let max_new_bytes = args.get("max_new_bytes").and_then(Value::as_u64); + let messages_upserted = match (provider, user_scope) { + ("claude", true) => { + let profile_root = + profile_root.ok_or_else(|| config_error("missing client profile"))?; + let global_db = global_db.ok_or_else(|| config_error("missing client registry"))?; + let session_id = required_str(args, "session_id")?.to_string(); + let db = crate::sessions::open_user_session_db(profile_root) + .await + .ok_or_else(|| config_error("daemon could not open user session database"))?; + let roots = crate::sessions::registered_project_roots_from(global_db) + .await + .ok_or_else(|| config_error("daemon project registry is unavailable"))?; + crate::sessions::claude::ingest_user_sessions( + &db, + profile_root, + Some(session_id), + roots, + ) + .await + .messages_upserted + } + ("codex", true) => { + let profile_root = + profile_root.ok_or_else(|| config_error("missing client profile"))?; + let global_db = global_db.ok_or_else(|| config_error("missing client registry"))?; + let session_id = required_str(args, "session_id")?.to_string(); + let roots = crate::sessions::registered_project_roots_from(global_db) + .await + .ok_or_else(|| config_error("daemon project registry is unavailable"))?; + crate::sessions::ingest_user_codex_sessions_at(profile_root, Some(session_id), roots) + .await + .messages_upserted + } + ("cursor", true) => { + let profile_root = + profile_root.ok_or_else(|| config_error("missing client profile"))?; + let global_db = global_db.ok_or_else(|| config_error("missing client registry"))?; + let event_json = required_str(args, "event_json")?; + let db = crate::sessions::open_user_session_db(profile_root) + .await + .ok_or_else(|| config_error("daemon could not open user session database"))?; + let roots = crate::sessions::registered_project_roots_from(global_db) + .await + .ok_or_else(|| config_error("daemon project registry is unavailable"))?; + crate::sessions::cursor::ingest_cursor_user_transcript_event_capped_with_registered_roots( + event_json, + &db, + max_new_bytes, + &roots, + ) + .await + .messages_upserted + } + ("cursor", false) => { + let cg = + cg.ok_or_else(|| config_error("project transcript ingest requires a project"))?; + let event_json = required_str(args, "event_json")?; + let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + .await + .ok_or_else(|| config_error("daemon could not open project session database"))?; + crate::sessions::cursor::ingest_cursor_transcript_event_capped( + event_json, + &db, + max_new_bytes, + ) + .await + .messages_upserted + } + ("kiro", true) => { + let profile_root = + profile_root.ok_or_else(|| config_error("missing client profile"))?; + let global_db = global_db.ok_or_else(|| config_error("missing client registry"))?; + let db = crate::sessions::open_user_session_db(profile_root) + .await + .ok_or_else(|| config_error("daemon could not open user session database"))?; + let source = crate::sessions::kiro::KiroSource::new() + .ok_or_else(|| config_error("Kiro transcript source is unavailable"))?; + let roots = crate::sessions::registered_project_roots_from(global_db) + .await + .ok_or_else(|| config_error("daemon project registry is unavailable"))?; + crate::sessions::source::ingest_source( + &db, + &source.for_user_scope(roots), + profile_root, + max_new_bytes, + ) + .await + .messages_upserted + } + ("kiro", false) => { + let cg = + cg.ok_or_else(|| config_error("project transcript ingest requires a project"))?; + let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + .await + .ok_or_else(|| config_error("daemon could not open project session database"))?; + crate::sessions::kiro::ingest_kiro_for_project(&db, cg.project_root(), max_new_bytes) + .await + .messages_upserted + } + _ => { + return Err(config_error(format!( + "unsupported transcript route: provider={provider} user_scope={user_scope}" + ))); + } + }; + Ok(json!({ + "action": "ingest_transcript", + "provider": provider, + "user_scope": user_scope, + "completed": true, + "messages_upserted": messages_upserted, + })) +} + +async fn user_review(args: &Value, profile_root: &Path) -> Result { + use crate::automation::run_ledger::AutomationTrigger; + + let provider = required_str(args, "provider")?; + let session_id = args + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let run_id = args + .get("run_id") + .and_then(Value::as_str) + .map(str::to_string); + if crate::automation::scheduler::load_scheduler_control( + &crate::automation::runner::user_automation_root(profile_root), + ) + .await? + .paused + { + return Ok(json!({ "action": "user_review", "status": "paused" })); + } + let run = run_user_review( + profile_root, + provider, + session_id, + run_id, + AutomationTrigger::HostReceipt, + ) + .await?; + Ok(json!({ + "action": "user_review", + "status": "completed", + "session_reflector": run.session_reflector.ledger_record.status, + "memory_curator": run.memory_curator.ledger_record.status, + "skill_writer": run.skill_writer.ledger_record.status, + })) +} + +async fn run_user_review( + profile_root: &std::path::Path, + provider: &str, + session_id: Option, + run_id: Option, + trigger: crate::automation::run_ledger::AutomationTrigger, +) -> Result { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::runner::{ + MemoryCuratorAutomationOptions, SessionReflectorAutomationOptions, + SkillWriterAutomationOptions, UserSessionAutomationOptions, + run_user_session_automation_with_backend, + }; + + let global = crate::user_config::UserConfig::load().automation; + let config = crate::automation::config::effective_user_automation_config( + profile_root, + &global, + crate::user_config::automation_is_configured(), + ) + .await?; + let backend = CodexAppServerBackend::from_automation_config(&config); + Ok(run_user_session_automation_with_backend( + profile_root, + &config, + &backend, + UserSessionAutomationOptions { + session_reflector: SessionReflectorAutomationOptions { + trigger, + run_id, + provider: provider.to_string(), + session_id, + ..SessionReflectorAutomationOptions::default() + }, + memory_curator: MemoryCuratorAutomationOptions { + trigger, + ..MemoryCuratorAutomationOptions::default() + }, + skill_writer: SkillWriterAutomationOptions { + trigger, + provider: provider.to_string(), + ..SkillWriterAutomationOptions::default() + }, + }, + ) + .await?) +} + +async fn hermes_receipt(args: &Value, profile_root: &Path) -> Result { + let event: crate::daemon::DaemonHookEvent = serde_json::from_value( + args.get("event") + .cloned() + .ok_or_else(|| config_error("missing required parameter `event`"))?, + )?; + let receipt = event + .receipt + .clone() + .ok_or_else(|| config_error("Hermes event omitted receipt"))?; + let dashboard_root = crate::automation::runner::user_automation_root(profile_root); + match event.event.as_str() { + "terminalReceipt" | "turnCompleted" => { + crate::automation::host_receipts::record(&dashboard_root, event.route, receipt).await?; + Ok(json!({ "action": "hermes_receipt", "status": "recorded" })) + } + "turnIngested" => { + let watermark = receipt + .transcript_watermark + .as_deref() + .ok_or_else(|| config_error("Hermes turnIngested omitted transcript watermark"))?; + crate::automation::host_receipts::mark_turn_ingested( + &dashboard_root, + event.route, + watermark, + ) + .await?; + let Some(ready) = + crate::automation::host_receipts::oldest_ready(&dashboard_root).await? + else { + return Ok(json!({ "action": "hermes_receipt", "status": "ingested" })); + }; + let sessions_path = crate::sessions::user_sessions_db_path(profile_root); + let session_db = crate::global_db::GlobalDb::open_read_only_at(&sessions_path) + .await + .ok_or_else(|| config_error("daemon could not open user session database"))?; + if session_db + .lcm_load_raw_message("hermes", &ready.transcript_watermark) + .await + .is_none() + { + return Ok(json!({ "action": "hermes_receipt", "status": "awaiting_transcript" })); + } + if crate::automation::scheduler::load_scheduler_control(&dashboard_root) + .await? + .paused + { + return Ok(json!({ "action": "hermes_receipt", "status": "paused" })); + } + let session_id = ready + .pending + .route + .as_ref() + .and_then(|route| route.session_id.clone()); + let run = run_user_review( + profile_root, + "hermes", + session_id, + Some(format!("user_host_receipt_{}", ready.pending.generation)), + crate::automation::run_ledger::AutomationTrigger::HostReceipt, + ) + .await?; + use crate::automation::run_ledger::AutomationRunStatus; + if run.session_reflector.ledger_record.status == AutomationRunStatus::Succeeded + && run.memory_curator.ledger_record.status != AutomationRunStatus::Failed + && run.skill_writer.ledger_record.status == AutomationRunStatus::Succeeded + { + crate::automation::host_receipts::mark_consumed( + &dashboard_root, + &ready.pending.session_key, + ready.pending.generation, + ) + .await?; + } + Ok(json!({ "action": "hermes_receipt", "status": "reviewed" })) + } + other => Err(config_error(format!( + "unsupported Hermes receipt event: {other}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_str_rejects_missing_and_empty_values() { + assert!(required_str(&json!({}), "action").is_err()); + assert!(required_str(&json!({ "action": "" }), "action").is_err()); + assert_eq!( + required_str(&json!({ "action": "reset_counter" }), "action").unwrap(), + "reset_counter" + ); + } + + #[tokio::test] + async fn projectless_runtime_rejects_project_database_actions() { + assert!( + handle_projectless_hook_runtime( + json!({ "action": "reset_counter" }), + Path::new("/not-used"), + ) + .await + .is_err() + ); + assert!( + handle_projectless_hook_runtime( + json!({ + "action": "ingest_transcript", + "provider": "cursor", + "user_scope": false, + "event_json": "{}", + }), + Path::new("/not-used") + ) + .await + .is_err() + ); + } + + #[test] + fn cursor_compaction_response_matches_hook_contract() { + let value = cursor_compact_skipped("no messages to compact"); + let outcome: crate::hooks::CursorPreCompactOutcome = serde_json::from_value(value).unwrap(); + assert_eq!(outcome.status, "skipped"); + assert_eq!(outcome.reason, "no messages to compact"); + assert_eq!(outcome.summary_nodes_created, 0); + assert!(outcome.summary_node_ids.is_empty()); + } + + #[test] + fn cursor_event_numbers_accept_numeric_and_string_forms() { + let event = json!({ "tokens": "42", "message_count": 7 }); + assert_eq!(event_i64(&event, &["tokens"]), Some(42)); + assert_eq!(event_usize(&event, &["message_count"]), Some(7)); + } +} diff --git a/src/mcp/tools/handlers/info.rs b/src/mcp/tools/handlers/info.rs index 2d36a222a..8d55c452b 100644 --- a/src/mcp/tools/handlers/info.rs +++ b/src/mcp/tools/handlers/info.rs @@ -24,6 +24,41 @@ use super::super::render::{self, Md}; use super::dependency_hints; use super::support::{effective_path, filter_by_scope, require_node_id, unique_file_paths}; +/// Daemon-only sync entry point used by the first-party CLI. It is deliberately +/// not advertised in the MCP catalog: external agents should rely on the +/// daemon watcher while the CLI can request an explicit serialized refresh. +pub(super) async fn handle_admin_sync(cg: &TraceDecay, args: Value) -> Result { + let force = args.get("force").and_then(Value::as_bool).unwrap_or(false); + let output = if force { + let result = cg.index_all().await?; + json!({ + "mode": "full", + "files": result.file_count, + "nodes": result.node_count, + "edges": result.edge_count, + "duration_ms": result.duration_ms, + }) + } else { + let result = cg.sync().await?; + json!({ + "mode": "incremental", + "files_added": result.files_added, + "files_modified": result.files_modified, + "files_removed": result.files_removed, + "duration_ms": result.duration_ms, + }) + }; + Ok(ToolResult::new( + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&output).unwrap_or_default(), + }] + }), + Vec::new(), + )) +} + /// Handles `tracedecay_status` tool calls. pub(super) async fn handle_status( cg: &TraceDecay, @@ -33,6 +68,14 @@ pub(super) async fn handle_status( ) -> Result { let stats = cg.get_stats().await?; let mut output: Value = serde_json::to_value(&stats).unwrap_or(json!({})); + let mut storage_health = + serde_json::to_value(crate::runtime_telemetry::collect_database(cg).await?) + .unwrap_or_else(|_| json!({})); + if server_stats.is_some() { + storage_health["daemon_owner_pid"] = json!(std::process::id()); + storage_health["daemon_generation"] = json!(crate::runtime_identity::process_run_id()); + } + output["storage_health"] = storage_health; if let Some(ss) = server_stats { output["server"] = ss; } diff --git a/src/mcp/tools/handlers/memory.rs b/src/mcp/tools/handlers/memory.rs index e3b3d010e..1a1751954 100644 --- a/src/mcp/tools/handlers/memory.rs +++ b/src/mcp/tools/handlers/memory.rs @@ -133,7 +133,8 @@ pub(super) async fn open_target_memory_db<'a>( db_path.display() ))); } - let (db, _) = Database::open(&db_path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime(&db_path, "open memory target")?; + let (db, _) = Database::open(&db_path, &authority).await?; Ok(TargetMemoryDb { db: TargetMemoryDbHandle::Owned(Box::new(db)), project_root: PathBuf::from(context.project.display_root), diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index d510ac628..f15a1f7b9 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -4,6 +4,10 @@ //! the JSON arguments, calls the appropriate `TraceDecay` method, and //! formats the result. +mod admin_cli; +pub(crate) use admin_cli::handle_projectless_admin_cli; +pub(crate) use hook_runtime::handle_projectless_hook_runtime; +mod admin_project; pub mod analysis; mod analytics; pub mod ast_grep_search; @@ -14,6 +18,7 @@ pub mod git; pub mod graph; pub mod grep; pub mod health; +pub mod hook_runtime; pub mod info; pub mod memory; pub mod redundancy; @@ -90,6 +95,14 @@ use super::dispatch_policy::{ use super::render; use support::{profile_root_for_global_db, project_registry_context, project_selector_present}; +#[cfg(test)] +const INTERNAL_DAEMON_TOOL_NAMES: &[&str] = &[ + "tracedecay_admin_cli", + "tracedecay_admin_project", + "tracedecay_admin_sync", + "tracedecay_hook_runtime", +]; + fn rejected_tool_project_selector_present(tool_name: &str, args: &Value) -> bool { let top_level_path_keys = if tool_name.starts_with("tracedecay_lcm_") { &["project_path"][..] @@ -380,6 +393,14 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_impact" => graph::handle_impact(cg, args).await, "tracedecay_node" => graph::handle_node(cg, args).await, "tracedecay_status" => info::handle_status(cg, args, server_stats, scope_prefix).await, + "tracedecay_hook_runtime" => { + hook_runtime::handle_hook_runtime(cg, args, options.global_db).await + } + "tracedecay_admin_sync" => info::handle_admin_sync(cg, args).await, + "tracedecay_admin_cli" => admin_cli::handle_admin_cli(cg, args, options.global_db).await, + "tracedecay_admin_project" => { + admin_project::handle_admin_project(cg, args, options.global_db).await + } "tracedecay_active_project" => Ok(info::handle_active_project( cg, &args, @@ -723,6 +744,9 @@ mod tests { .map(|tool| tool.name) .collect::>(); let mut handler_names = dispatch_tool_names_from_source("handle_tool_call"); + for internal in INTERNAL_DAEMON_TOOL_NAMES { + handler_names.remove(*internal); + } // These tools are intentionally hidden from the advertised surface when // the host ast-grep CLI capability they need is unavailable; mirror the diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index ef263f37c..bb4f93333 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -26,6 +26,7 @@ pub use handlers::{ ToolCallRegistryOptions, handle_tool_call, handle_tool_call_with_registry, handle_tool_call_with_registry_and_implicit_project, handle_user_lcm_tool, }; +pub(crate) use handlers::{handle_projectless_admin_cli, handle_projectless_hook_runtime}; /// Maximum character length for a tool response before truncation. const MAX_RESPONSE_CHARS: usize = 15_000; diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 83a329945..00d86baa1 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -151,13 +151,10 @@ pub trait McpTransport { /// Wraps a transport with a queue of already-consumed input lines that must /// be re-delivered before reading from the underlying transport again. /// -/// Two serve flows need this: the startup peek of the MCP `initialize` -/// request (to read workspace roots before a server exists), and the degraded -/// startup server's recovery handoff (the `tools/call` that triggered a -/// successful resolution retry must be answered by the recovered full -/// server). Keeping one `ReplayTransport` alive across those phases is what -/// prevents pipelined requests buffered by the inner reader from being lost — -/// dropping the transport and constructing a fresh one would discard them. +/// The daemon consumes the first MCP request while resolving initialize roots +/// and selecting a project server. Replaying that request into the selected +/// server preserves it, along with any pipelined input buffered by the inner +/// reader. pub struct ReplayTransport { replay: std::collections::VecDeque, inner: T, diff --git a/src/memory/user.rs b/src/memory/user.rs index 5c1ffca39..b0b78d824 100644 --- a/src/memory/user.rs +++ b/src/memory/user.rs @@ -13,8 +13,11 @@ pub fn user_memory_db_path(profile_root: &Path) -> PathBuf { pub async fn open_user_memory_db(profile_root: &Path) -> Result { let path = user_memory_db_path(profile_root); + let authority = crate::db::DatabaseAuthority::for_runtime(&path, "open user memory")?; if path.is_file() { - return Database::open(&path).await.map(|(db, _)| db); + return Database::open(&path, &authority).await.map(|(db, _)| db); } - Database::initialize(&path).await.map(|(db, _)| db) + Database::initialize(&path, &authority) + .await + .map(|(db, _)| db) } diff --git a/src/migrate/consolidate/mod.rs b/src/migrate/consolidate/mod.rs index d3ea29152..dc18e28cf 100644 --- a/src/migrate/consolidate/mod.rs +++ b/src/migrate/consolidate/mod.rs @@ -190,6 +190,15 @@ impl Drop for MigrationScratchRoot { pub async fn plan(options: &ConsolidationOptions) -> Result { ensure_profile_offline(options)?; + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &options.profile_root, + "profile shard consolidation plan", + )?; + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, + &options.profile_root, + "profile shard consolidation plan", + )?; Ok(resolve_plan(options).await?.report) } @@ -224,7 +233,12 @@ async fn apply_with_faults( prepare_stop: Option, ) -> Result { ensure_profile_offline(options)?; - let _lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &options.profile_root, + "profile shard consolidation", + )?; + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, &options.profile_root, "profile shard consolidation", )?; diff --git a/src/migrate/consolidate/sqlite.rs b/src/migrate/consolidate/sqlite.rs index 89f7905fb..e80dddc9c 100644 --- a/src/migrate/consolidate/sqlite.rs +++ b/src/migrate/consolidate/sqlite.rs @@ -83,7 +83,8 @@ pub(super) async fn merge_graph_facts( let target_path = paths .first() .ok_or_else(|| db_message("merge_graph_facts", "no target graph database"))?; - let (target, _) = Database::open(target_path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime(target_path, "merge graph facts")?; + let (target, _) = Database::open(target_path, &authority).await?; for offset in offsets { merge_one_graph(target.conn(), offset).await?; } @@ -94,14 +95,16 @@ pub(super) async fn merge_graph_facts( } async fn normalize_graph(path: &Path) -> Result<()> { - let (db, _) = Database::open(path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime(path, "normalize graph")?; + let (db, _) = Database::open(path, &authority).await?; db.checkpoint().await?; db.close(); Ok(()) } async fn graph_maxima(path: &Path) -> Result<(i64, i64, i64, i64)> { - let (db, _) = Database::open_read_only(path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime(path, "read graph maxima")?; + let (db, _) = Database::open_read_only(path, &authority).await?; let result = ( table_max(db.conn(), "memory_facts", "fact_id").await?, table_max(db.conn(), "memory_entities", "entity_id").await?, diff --git a/src/migrate/consolidate/sqlite/inspect.rs b/src/migrate/consolidate/sqlite/inspect.rs index 4fc329f77..f9659c1ab 100644 --- a/src/migrate/consolidate/sqlite/inspect.rs +++ b/src/migrate/consolidate/sqlite/inspect.rs @@ -33,6 +33,7 @@ struct LcmMessageCollisionCounts { pub(in crate::migrate::consolidate) struct OfflineDatabaseGuards { _holds: Vec<(Connection, LibsqlDatabase)>, + _authorities: Vec, } #[derive(Default)] @@ -101,7 +102,10 @@ pub(in crate::migrate::consolidate) async fn acquire_offline_guards( // Windows because open-holder discovery is unsupported; unit fixtures use // isolated stores and exercise the remaining migration semantics here. let _ = paths; - Ok(OfflineDatabaseGuards { _holds: Vec::new() }) + Ok(OfflineDatabaseGuards { + _holds: Vec::new(), + _authorities: Vec::new(), + }) } #[cfg(not(all(test, windows)))] @@ -112,6 +116,7 @@ pub(in crate::migrate::consolidate) async fn acquire_offline_guards( ordered.sort(); ordered.dedup(); let mut holds = Vec::new(); + let mut authorities = Vec::new(); for path in ordered { if !crate::storage::has_sqlite_database_header(&path).map_err(|error| { db_error( @@ -124,6 +129,10 @@ pub(in crate::migrate::consolidate) async fn acquire_offline_guards( format!("file is not a database: '{}'", path.display()), )); } + let authority = crate::db::DatabaseAuthority::for_runtime( + &path, + "consolidate SQLite database offline", + )?; let db = Builder::new_local(&path) .build() .await @@ -144,8 +153,12 @@ pub(in crate::migrate::consolidate) async fn acquire_offline_guards( ) })?; holds.push((conn, db)); + authorities.push(authority); } - Ok(OfflineDatabaseGuards { _holds: holds }) + Ok(OfflineDatabaseGuards { + _holds: holds, + _authorities: authorities, + }) } async fn read_fact_keys(conn: &Connection, identities: &mut HashSet>) -> Result<()> { diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 80da93e40..0ee90fa02 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -11,7 +11,7 @@ use serde_json::json; use tempfile::TempDir; use super::*; -use crate::db::Database; +use crate::db::{Database, DatabaseAuthority}; use crate::memory::store::MemoryStore; use crate::memory::types::{ AddFactRequest, FactRelationKind, FeedbackAction, FeedbackRequest, MemoryCategory, @@ -19,6 +19,21 @@ use crate::memory::types::{ use crate::sessions::{SessionMessageRecord, SessionRecord}; use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; +async fn test_initialize(path: &Path) -> (Database, bool) { + let authority = DatabaseAuthority::acquire_test(path, "consolidation test initialize").unwrap(); + Database::initialize(path, &authority).await.unwrap() +} + +async fn test_open(path: &Path) -> (Database, bool) { + let authority = DatabaseAuthority::acquire_test(path, "consolidation test open").unwrap(); + Database::open(path, &authority).await.unwrap() +} + +async fn test_open_read_only(path: &Path) -> (Database, bool) { + let authority = DatabaseAuthority::acquire_test(path, "consolidation test read").unwrap(); + Database::open_read_only(path, &authority).await.unwrap() +} + struct Fixture { _temp: TempDir, project: PathBuf, @@ -118,6 +133,19 @@ fn full_tree_snapshot(root: &Path) -> BTreeMap { while let Some(path) = pending.pop() { let metadata = fs::symlink_metadata(&path).unwrap(); let relative = path.strip_prefix(root).unwrap().to_path_buf(); + let is_database_authority_artifact = relative.components().any(|component| { + component.as_os_str() == std::ffi::OsStr::new(".tracedecay-database-locks") + }) || relative.file_name().is_some_and(|name| { + let name = name.to_string_lossy(); + name == "lifecycle.lock" + || name == "lifecycle.lock.owner" + || name.ends_with(".access.lock") + || name.ends_with(".writer.lock") + || name.ends_with(".writer.owner") + }); + if is_database_authority_artifact { + continue; + } if metadata.is_dir() { #[cfg(unix)] use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -263,7 +291,7 @@ async fn legacy_single_db_plan_is_read_only_and_apply_preserves_source_graph() { assert_eq!(preserved.created_at, "0"); assert_eq!(preserved.last_synced_at, "0"); let preserved_path = applied.destination_data_root.join(&preserved.db_file); - let (db, _) = Database::open_read_only(&preserved_path).await.unwrap(); + let (db, _) = test_open_read_only(&preserved_path).await; let facts = MemoryStore::new(db.conn()) .list_facts(None, Some(0.0), 100) .await @@ -684,7 +712,7 @@ async fn mixed_page_destination_survives_overlapping_watcher_opens() { drop(watcher); assert!(storage::has_sqlite_database_header(&destination).unwrap()); - let (verification, _) = Database::open_read_only(&destination).await.unwrap(); + let (verification, _) = test_open_read_only(&destination).await; let mut mmap_rows = verification .conn() .query("PRAGMA mmap_size", ()) @@ -943,7 +971,7 @@ async fn verification_rejects_a_missing_unique_row_when_target_is_larger() { let graph_path = report .destination_data_root .join(crate::config::DB_FILENAME); - let (graph, _) = Database::open(&graph_path).await.unwrap(); + let (graph, _) = test_open(&graph_path).await; graph .conn() .execute_batch( @@ -2016,7 +2044,7 @@ async fn divergent_summary_node_identity_remains_a_hard_error() { "target-hash", ), ] { - let (db, _) = Database::open(path).await.unwrap(); + let (db, _) = test_open(path).await; db.conn() .execute( "INSERT INTO lcm_summary_nodes( @@ -2147,7 +2175,7 @@ async fn untracked_branch_databases_with_mixed_case_extensions_are_recovered() { } else { TARGET_ORPHAN_FACT }; - let (db, _) = Database::open_read_only(&path).await.unwrap(); + let (db, _) = test_open_read_only(&path).await; let facts = MemoryStore::new(db.conn()) .list_facts(None, Some(0.0), 100) .await @@ -2261,7 +2289,7 @@ async fn overlapping_facts_merge_tags_metadata_and_feedback_without_duplication( let graph_path = applied .destination_data_root .join(crate::config::DB_FILENAME); - let (graph, _) = Database::open_read_only(&graph_path).await.unwrap(); + let (graph, _) = test_open_read_only(&graph_path).await; let store = MemoryStore::new(graph.conn()); let facts = store.list_facts(None, Some(0.0), 100).await.unwrap(); let shared = facts @@ -2458,7 +2486,7 @@ async fn current_schema_tables_have_an_explicit_consolidation_disposition() { } async fn unknown_tables(path: &Path, classify: fn(&str) -> Option<&'static str>) -> Vec { - let (db, _) = Database::open_read_only(path).await.unwrap(); + let (db, _) = test_open_read_only(path).await; let mut rows = db .conn() .query( @@ -2627,7 +2655,7 @@ async fn create_shard( ) { let layout = layout_for_id(project, profile, project_id).unwrap(); fs::create_dir_all(&layout.data_root).unwrap(); - let (graph, _) = Database::initialize(&layout.graph_db_path).await.unwrap(); + let (graph, _) = test_initialize(&layout.graph_db_path).await; let memory = MemoryStore::new(graph.conn()); let outcome = memory .add_fact( @@ -2722,7 +2750,7 @@ async fn add_fact_to_shard( feedback: Option, ) { let layout = layout_for_id(&fixture.project, &fixture.profile, project_id).unwrap(); - let (graph, _) = Database::open(&layout.graph_db_path).await.unwrap(); + let (graph, _) = test_open(&layout.graph_db_path).await; let memory = MemoryStore::new(graph.conn()); let outcome = memory .add_fact( @@ -2756,7 +2784,7 @@ async fn add_fact_to_shard( async fn add_fact_relation_to_shard(fixture: &Fixture, project_id: &str) { let layout = layout_for_id(&fixture.project, &fixture.profile, project_id).unwrap(); - let (graph, _) = Database::open(&layout.graph_db_path).await.unwrap(); + let (graph, _) = test_open(&layout.graph_db_path).await; let memory = MemoryStore::new(graph.conn()); let source_fact_id = memory .list_facts(None, Some(0.0), 10) @@ -2818,7 +2846,7 @@ async fn add_untracked_branch(layout: &StoreLayout, name: &str, fact_content: &s fs::create_dir_all(&branches).unwrap(); let path = branches.join(format!("{name}.db")); fs::copy(&layout.graph_db_path, &path).unwrap(); - let (db, _) = Database::open(&path).await.unwrap(); + let (db, _) = test_open(&path).await; MemoryStore::new(db.conn()) .add_fact( AddFactRequest { @@ -2851,14 +2879,14 @@ fn sqlite_family_bytes(path: &Path) -> u64 { } async fn execute_sql(path: &Path, sql: &str) { - let (db, _) = Database::open(path).await.unwrap(); + let (db, _) = test_open(path).await; db.conn().execute_batch(sql).await.unwrap(); db.checkpoint().await.unwrap(); db.close(); } async fn rewrite_page_size(path: &Path, page_size: i64) { - let (db, _) = Database::open(path).await.unwrap(); + let (db, _) = test_open(path).await; db.checkpoint().await.unwrap(); db.conn() .execute_batch(&format!( @@ -2870,7 +2898,7 @@ async fn rewrite_page_size(path: &Path, page_size: i64) { } async fn database_page_size(path: &Path) -> i64 { - let (db, _) = Database::open_read_only(path).await.unwrap(); + let (db, _) = test_open_read_only(path).await; let mut rows = db.conn().query("PRAGMA page_size", ()).await.unwrap(); let page_size = rows.next().await.unwrap().unwrap().get::(0).unwrap(); db.close(); diff --git a/src/migrate/hermes.rs b/src/migrate/hermes.rs index bb2b2b1f4..f71879bab 100644 --- a/src/migrate/hermes.rs +++ b/src/migrate/hermes.rs @@ -99,6 +99,25 @@ async fn migrate_legacy_hermes_stores_inner( hermes_homes: &[PathBuf], fail_after_table: Option<&str>, ) -> LegacyHermesMigrationReport { + let lifecycle = match crate::lifecycle_lease::acquire_exclusive_for_profile( + tracedecay_profile_root, + "legacy Hermes store migration", + ) { + Ok(lifecycle) => lifecycle, + Err(error) => { + return migration_authority_failure(tracedecay_profile_root, error.to_string()); + } + }; + let _database_scope = match crate::db::enter_maintenance_database_scope( + &lifecycle, + tracedecay_profile_root, + "legacy Hermes store migration", + ) { + Ok(scope) => scope, + Err(error) => { + return migration_authority_failure(tracedecay_profile_root, error.to_string()); + } + }; let profile_dirs = legacy_profile_dirs_for_homes(hermes_homes); let mut report = LegacyHermesMigrationReport::default(); for candidate in legacy_store_candidates(&profile_dirs, tracedecay_profile_root) { @@ -197,6 +216,19 @@ async fn migrate_legacy_hermes_stores_inner( report } +fn migration_authority_failure( + tracedecay_profile_root: &Path, + reason: String, +) -> LegacyHermesMigrationReport { + LegacyHermesMigrationReport { + failed: vec![LegacyHermesMigrationIssue { + source_db: tracedecay_profile_root.to_path_buf(), + reason, + }], + ..LegacyHermesMigrationReport::default() + } +} + async fn remove_legacy_registry_metadata( tracedecay_profile_root: &Path, project_id: Option<&str>, @@ -547,12 +579,19 @@ async fn migrate_candidate_snapshot( .filter(|_| !target_project.user_scope) { Some(path) => { - let (db, _) = Database::open_read_only(path).await.map_err(|error| { - CandidateError::Failed(format!( - "could not open legacy memory store '{}' read-only: {error}", - path.display() - )) - })?; + let authority = crate::db::DatabaseAuthority::for_runtime( + path, + "read legacy memory migration source", + ) + .map_err(|error| CandidateError::Failed(error.to_string()))?; + let (db, _) = Database::open_read_only(path, &authority) + .await + .map_err(|error| { + CandidateError::Failed(format!( + "could not open legacy memory store '{}' read-only: {error}", + path.display() + )) + })?; db.conn().execute("BEGIN", ()).await.map_err(|error| { CandidateError::Failed(format!("could not snapshot legacy memory store: {error}")) })?; @@ -1221,10 +1260,13 @@ async fn merge_memory_snapshot(source: &Connection, target_path: &Path) -> Resul return Ok(0); } verify_source(source).await?; + let authority = + crate::db::DatabaseAuthority::for_runtime(target_path, "merge memory migration target") + .map_err(|error| format!("could not authorize target memory store: {error}"))?; let (target, _) = if target_path.is_file() { - Database::open(target_path).await + Database::open(target_path, &authority).await } else { - Database::initialize(target_path).await + Database::initialize(target_path, &authority).await } .map_err(|error| format!("could not open target memory store: {error}"))?; target @@ -2223,6 +2265,25 @@ mod tests { use crate::memory::types::{AddFactRequest, FeedbackAction, FeedbackRequest, MemoryCategory}; use crate::sessions::{SessionMessageRecord, SessionRecord}; + async fn test_initialize(path: &Path) -> (Database, bool) { + let authority = + crate::db::DatabaseAuthority::acquire_test(path, "Hermes migration test initialize") + .unwrap(); + Database::initialize(path, &authority).await.unwrap() + } + + async fn test_open(path: &Path) -> (Database, bool) { + let authority = + crate::db::DatabaseAuthority::acquire_test(path, "Hermes migration test open").unwrap(); + Database::open(path, &authority).await.unwrap() + } + + async fn test_open_read_only(path: &Path) -> (Database, bool) { + let authority = + crate::db::DatabaseAuthority::acquire_test(path, "Hermes migration test read").unwrap(); + Database::open_read_only(path, &authority).await.unwrap() + } + fn mark_real_project(project: &Path) { fs::create_dir_all(project.join(".tracedecay")).unwrap(); fs::write(project.join(".tracedecay/tracedecay.db"), []).unwrap(); @@ -2295,7 +2356,7 @@ mod tests { } async fn seed_memory_fact(path: &Path, content: &str) -> i64 { - let (db, _) = Database::initialize(path).await.unwrap(); + let (db, _) = test_initialize(path).await; MemoryStore::new(db.conn()) .add_fact( AddFactRequest { @@ -2413,9 +2474,7 @@ mod tests { assert_eq!(count(target.conn(), "session_messages").await, 1); assert_eq!(count(target.conn(), "lcm_raw_messages").await, 1); assert_eq!(marker_count(&layout.sessions_db_path), 1); - let (target_code, _) = Database::open_read_only(&layout.graph_db_path) - .await - .unwrap(); + let (target_code, _) = test_open_read_only(&layout.graph_db_path).await; let facts = MemoryStore::new(target_code.conn()) .list_facts(None, None, 10) .await @@ -2440,9 +2499,7 @@ mod tests { .unwrap(); assert_eq!(count(target.conn(), "sessions").await, 1); assert_eq!(marker_count(&layout.sessions_db_path), 1); - let (target_code, _) = Database::open_read_only(&layout.graph_db_path) - .await - .unwrap(); + let (target_code, _) = test_open_read_only(&layout.graph_db_path).await; assert_eq!( MemoryStore::new(target_code.conn()) .list_facts(None, None, 10) @@ -2571,9 +2628,7 @@ mod tests { let report = migrate_legacy_hermes_stores_to(&user_home, &profile_root).await; assert_eq!(report.migrated.len(), 1, "{report:?}"); let layout = crate::storage::resolve_layout(&project, &profile_root).unwrap(); - let (target, _) = Database::open_read_only(&layout.graph_db_path) - .await - .unwrap(); + let (target, _) = test_open_read_only(&layout.graph_db_path).await; let facts = MemoryStore::new(target.conn()) .list_facts(None, None, 10) .await @@ -2741,7 +2796,7 @@ mod tests { .unwrap(); seed_source(&source_sessions, &[("session", &project)]).await; let source_fact_id = seed_memory_fact(&source_memory, "shared durable fact").await; - let (source_db, _) = Database::open(&source_memory).await.unwrap(); + let (source_db, _) = test_open(&source_memory).await; MemoryStore::new(source_db.conn()) .record_feedback_event(FeedbackRequest { fact_id: source_fact_id, @@ -2754,7 +2809,7 @@ mod tests { drop(source_db); let layout = crate::storage::resolve_layout(&project, &profile_root).unwrap(); - let (target_db, _) = Database::initialize(&layout.graph_db_path).await.unwrap(); + let (target_db, _) = test_initialize(&layout.graph_db_path).await; let target_store = MemoryStore::new(target_db.conn()); let target_fact = target_store .add_fact( @@ -2786,9 +2841,7 @@ mod tests { let first = migrate_legacy_hermes_stores_to(&user_home, &profile_root).await; assert_eq!(first.migrated.len(), 1, "{first:?}"); - let (target_db, _) = Database::open_read_only(&layout.graph_db_path) - .await - .unwrap(); + let (target_db, _) = test_open_read_only(&layout.graph_db_path).await; let facts = MemoryStore::new(target_db.conn()) .list_facts(None, None, 10) .await @@ -2803,9 +2856,7 @@ mod tests { let second = migrate_legacy_hermes_stores_to(&user_home, &profile_root).await; assert_eq!(second.already_migrated.len(), 1, "{second:?}"); - let (target_db, _) = Database::open_read_only(&layout.graph_db_path) - .await - .unwrap(); + let (target_db, _) = test_open_read_only(&layout.graph_db_path).await; assert_eq!(count(target_db.conn(), "memory_feedback_events").await, 2); let facts = MemoryStore::new(target_db.conn()) .list_facts(None, None, 10) @@ -3315,7 +3366,7 @@ mod tests { let source_after = GlobalDb::open_read_only_at(&source).await.unwrap(); assert_eq!(count(source_after.conn(), "sessions").await, 1); drop(source_after); - let (source_memory_after, _) = Database::open_read_only(&source_memory).await.unwrap(); + let (source_memory_after, _) = test_open_read_only(&source_memory).await; assert!( MemoryStore::new(source_memory_after.conn()) .get_fact(source_fact_id) diff --git a/src/migrate/inventory.rs b/src/migrate/inventory.rs index f2716b073..500759c1f 100644 --- a/src/migrate/inventory.rs +++ b/src/migrate/inventory.rs @@ -100,6 +100,27 @@ pub struct GlobalDbInventory { } pub async fn build_inventory(options: MigrationInventoryOptions) -> Result { + let profile_root = options + .global_db_path + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + .map_or_else(crate::storage::default_profile_root, Ok)?; + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "migration inventory", + )?; + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, + &profile_root, + "migration inventory", + )?; + build_inventory_in_scope(options).await +} + +async fn build_inventory_in_scope( + options: MigrationInventoryOptions, +) -> Result { let mut stores = Vec::new(); let mut skipped = Vec::new(); let mut seen_data_dirs = HashSet::new(); @@ -559,31 +580,42 @@ async fn inspect_global_db(path: &Path, path_overridden: bool) -> GlobalDbInvent let mut warnings = Vec::new(); if exists { - let db_result = Builder::new_local(path) - .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) - .build() - .await; - match db_result { - Ok(db) => match db.connect() { - Ok(conn) => { - if !sqlite_quick_check(path).await { - warnings.push(format!("global DB '{}' failed quick_check", path.display())); + let authority = + crate::db::DatabaseAuthority::for_runtime(path, "inspect global database offline"); + if let Err(error) = authority.as_ref() { + warnings.push(format!( + "global DB '{}' is owned by the daemon; stop it before offline inventory: {error}", + path.display() + )); + } + if authority.is_ok() { + let db_result = Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await; + match db_result { + Ok(db) => match db.connect() { + Ok(conn) => { + if !sqlite_quick_check(path).await { + warnings + .push(format!("global DB '{}' failed quick_check", path.display())); + } + project_count = table_count(&conn, "projects").await; + session_count = table_count(&conn, "sessions").await; + lcm_raw_message_count = table_count(&conn, "lcm_raw_messages").await; + token_cache_present = table_exists(&conn, "dashboard_token_counts").await; + registered_project_paths = project_paths(&conn).await; } - project_count = table_count(&conn, "projects").await; - session_count = table_count(&conn, "sessions").await; - lcm_raw_message_count = table_count(&conn, "lcm_raw_messages").await; - token_cache_present = table_exists(&conn, "dashboard_token_counts").await; - registered_project_paths = project_paths(&conn).await; - } + Err(err) => warnings.push(format!( + "could not inspect global DB '{}': {err}", + path.display() + )), + }, Err(err) => warnings.push(format!( "could not inspect global DB '{}': {err}", path.display() )), - }, - Err(err) => warnings.push(format!( - "could not inspect global DB '{}': {err}", - path.display() - )), + } } } @@ -603,6 +635,11 @@ async fn inspect_global_db(path: &Path, path_overridden: bool) -> GlobalDbInvent } async fn sqlite_quick_check(path: &Path) -> bool { + let Ok(_authority) = + crate::db::DatabaseAuthority::for_runtime(path, "quick-check SQLite database offline") + else { + return false; + }; let Ok(db) = Builder::new_local(path) .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) .build() diff --git a/src/monitor.rs b/src/monitor.rs index 64d24bfe9..742948ba7 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -11,6 +11,10 @@ use std::path::{Path, PathBuf}; +mod cost; + +use cost::{CostCache, CostCacheState}; + // ── Layout constants ──────────────────────────────────────────────── const HEADER_SIZE: usize = 32; const ENTRY_SIZE: usize = 128; @@ -334,80 +338,6 @@ pub fn run() -> std::io::Result<()> { result } -/// Cached cost data for the monitor panel, refreshed periodically. -struct CostCache { - today_cost: f64, - week_cost: f64, - tokens_saved: u64, - efficiency_pct: f64, - top_model: String, - top_model_cost: f64, - last_refresh: std::time::Instant, -} - -impl CostCache { - fn new() -> Self { - Self { - today_cost: 0.0, - week_cost: 0.0, - tokens_saved: 0, - efficiency_pct: 0.0, - top_model: String::new(), - top_model_cost: 0.0, - last_refresh: std::time::Instant::now() - .checked_sub(std::time::Duration::from_secs(999)) - .unwrap_or_else(std::time::Instant::now), - } - } - - fn is_stale(&self) -> bool { - self.last_refresh.elapsed() > std::time::Duration::from_secs(30) - } -} - -/// Refresh cost data from the global DB. Best-effort, non-blocking. -/// Uses a tokio runtime because `GlobalDb` is async. -fn refresh_cost_cache(cache: &mut CostCache) { - let future = async { - let Some(gdb) = crate::global_db::GlobalDb::open().await else { - return; - }; - - // Ingest any new data first - crate::accounting::parser::ingest(&gdb).await; - - let now_epoch = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let today_start = now_epoch - (now_epoch % 86400); - let week_start = now_epoch.saturating_sub(7 * 86400); - - cache.today_cost = gdb.total_cost_since(today_start).await.unwrap_or(0.0); - cache.week_cost = gdb.total_cost_since(week_start).await.unwrap_or(0.0); - - let week_consumed = gdb.total_tokens_since(week_start).await.unwrap_or(0); - cache.tokens_saved = gdb.global_tokens_saved().await.unwrap_or(0); - - cache.efficiency_pct = if cache.tokens_saved + week_consumed > 0 { - (cache.tokens_saved as f64 / (cache.tokens_saved + week_consumed) as f64) * 100.0 - } else { - 0.0 - }; - - let models = gdb.cost_by_model_since(today_start).await; - if let Some((model, cost, _)) = models.first() { - cache.top_model.clone_from(model); - cache.top_model_cost = *cost; - } - }; - // monitor::run() is always invoked from inside #[tokio::main]'s - // multi-threaded runtime, so creating a new runtime would panic. - // Use block_in_place + the existing handle on every platform. - tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)); - cache.last_refresh = std::time::Instant::now(); -} - fn monitor_loop( reader: &mut MmapReader, entries: &mut Vec, @@ -470,9 +400,9 @@ fn monitor_loop( *last_idx = current_idx; } - // Refresh cost cache every 30 seconds. + cost_cache.poll_refresh(); if cost_cache.is_stale() { - refresh_cost_cache(&mut cost_cache); + cost_cache.begin_refresh(); } // Render. @@ -482,39 +412,52 @@ fn monitor_loop( execute!(stdout, cursor::MoveTo(0, 0))?; - // Layout: cost panel (3 lines) + separator + log + separator + footer (2 lines) - let has_cost = cost_cache.today_cost >= 0.001 || cost_cache.week_cost >= 0.001; - let cost_lines = if has_cost { 4 } else { 0 }; // 3 lines + separator + // Layout: optional cost/status panel + separator + log + footer. + let mut cost_panel = Vec::new(); + if let Some(snapshot) = cost_cache + .snapshot + .as_ref() + .filter(|snapshot| snapshot.today_cost >= 0.001 || snapshot.week_cost >= 0.001) + { + let saved_str = crate::display::format_token_count(snapshot.tokens_saved); + cost_panel.push(format!( + " Spent: ${:.2} today | ${:.2} 7d Saved: {}", + snapshot.today_cost, snapshot.week_cost, saved_str + )); + cost_panel.push(format!( + " Efficiency: {:.0}% Top model: {} (${:.2})", + snapshot.efficiency_pct, snapshot.top_model, snapshot.top_model_cost + )); + } + match &cost_cache.state { + CostCacheState::Fresh => {} + CostCacheState::Stale(error) => { + cost_panel.push(format!(" Cost accounting stale: {error}")); + } + CostCacheState::Unavailable(error) => { + cost_panel.push(format!(" Cost accounting unavailable: {error}")); + } + } + let cost_lines = if cost_panel.is_empty() { + 0 + } else { + cost_panel.len() + 1 + }; let footer_lines = 4; // separator + 2 footer lines + bottom separator let log_lines = h.saturating_sub(cost_lines + footer_lines).max(1); last_log_lines = log_lines; // ── Cost panel ── - if has_cost { + if !cost_panel.is_empty() { let sep = "\u{2500}".repeat(w); - - let saved_str = crate::display::format_token_count(cost_cache.tokens_saved); - let line1 = format!( - " Spent: ${:.2} today | ${:.2} 7d Saved: {}", - cost_cache.today_cost, cost_cache.week_cost, saved_str - ); - let line2 = format!( - " Efficiency: {:.0}% Top model: {} (${:.2})", - cost_cache.efficiency_pct, cost_cache.top_model, cost_cache.top_model_cost - ); - - write!( - stdout, - "\r\x1b[36m{}\x1b[0m{}\r\n", - line1, - " ".repeat(w.saturating_sub(line1.len())) - )?; - write!( - stdout, - "\r\x1b[36m{}\x1b[0m{}\r\n", - line2, - " ".repeat(w.saturating_sub(line2.len())) - )?; + for line in &cost_panel { + write!( + stdout, + "\r\x1b[36m{}\x1b[0m{}\r\n", + line, + " ".repeat(w.saturating_sub(line.len())) + )?; + } write!(stdout, "\r{sep}\r\n")?; } @@ -703,29 +646,4 @@ mod tests { assert_eq!(update_color_for(&recent, "p", "other"), ""); assert_eq!(update_color_for(&recent, "other_proj", "newest"), ""); } - - /// Regression test for issue #39: `tracedecay monitor` panicked on - /// macOS/Linux with "Cannot start a runtime from within a runtime." - /// - /// `refresh_cost_cache` was building a fresh `tokio::runtime` and - /// calling `block_on` inside `#[tokio::main]`, which always panics on - /// a multi-thread runtime. The fix uses `block_in_place` + - /// `Handle::current().block_on()` — safe inside a multi-thread runtime. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn refresh_cost_cache_runtime_pattern_does_not_panic() { - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { 42 }) - }); - assert_eq!(result, 42); - } - - /// Verify the pre-fix pattern (`Runtime::new()` inside a running - /// multi-thread runtime) panics — locking in the bug we are guarding - /// against. tokio's exact wording varies across versions, so we just - /// match on "runtime". - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - #[should_panic(expected = "runtime")] - async fn nested_runtime_new_panics() { - let _rt = tokio::runtime::Runtime::new().unwrap(); - } } diff --git a/src/monitor/cost.rs b/src/monitor/cost.rs new file mode 100644 index 000000000..76890e2f3 --- /dev/null +++ b/src/monitor/cost.rs @@ -0,0 +1,403 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::time::{Duration, Instant}; + +use crate::daemon::DaemonHandshake; +use crate::errors::{Result, TraceDecayError}; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(30); +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); +// A dropped TUI leaves its detached worker alive briefly; gate replacements too. +static REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +struct RefreshLease; + +impl Drop for RefreshLease { + fn drop(&mut self) { + REFRESH_IN_FLIGHT.store(false, Ordering::Release); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct CostSnapshot { + pub(super) today_cost: f64, + pub(super) week_cost: f64, + pub(super) tokens_saved: u64, + pub(super) efficiency_pct: f64, + pub(super) top_model: String, + pub(super) top_model_cost: f64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CostCacheState { + Fresh, + Stale(String), + Unavailable(String), +} + +type RefreshResult = std::result::Result, String>; + +pub(super) struct CostCache { + pub(super) snapshot: Option, + pub(super) state: CostCacheState, + last_refresh: Instant, + refresh: Option>, +} + +impl CostCache { + pub(super) fn new() -> Self { + Self { + snapshot: None, + state: CostCacheState::Unavailable("not loaded".to_string()), + last_refresh: Instant::now() + .checked_sub(Duration::from_secs(999)) + .unwrap_or_else(Instant::now), + refresh: None, + } + } + + pub(super) fn is_stale(&self) -> bool { + self.refresh.is_none() && self.last_refresh.elapsed() > REFRESH_INTERVAL + } + + pub(super) fn begin_refresh(&mut self) { + self.begin_refresh_with(fetch_cost_snapshot_blocking); + } + + fn begin_refresh_with(&mut self, fetch: F) + where + F: FnOnce() -> RefreshResult + Send + 'static, + { + if self.refresh.is_some() + || REFRESH_IN_FLIGHT + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let guarded_fetch = move || { + let _lease = RefreshLease; + fetch() + }; + match spawn_refresh_worker(guarded_fetch) { + Ok(refresh) => self.refresh = Some(refresh), + Err(error) => { + REFRESH_IN_FLIGHT.store(false, Ordering::Release); + self.apply_refresh(Err(format!("could not start cost refresh worker: {error}"))); + } + } + } + + pub(super) fn poll_refresh(&mut self) { + let result = match self.refresh.as_ref().map(Receiver::try_recv) { + Some(Ok(result)) => Some(result), + Some(Err(TryRecvError::Disconnected)) => Some(Err( + "cost refresh worker stopped without a result".to_string(), + )), + Some(Err(TryRecvError::Empty)) | None => None, + }; + if let Some(result) = result { + self.refresh = None; + self.apply_refresh(result); + } + } + + fn apply_refresh(&mut self, result: RefreshResult) { + match result { + Ok(snapshot) => { + self.snapshot = snapshot; + self.state = CostCacheState::Fresh; + } + Err(error) if self.snapshot.is_some() => { + self.state = CostCacheState::Stale(error); + } + Err(error) => { + self.state = CostCacheState::Unavailable(error); + } + } + self.last_refresh = Instant::now(); + } +} + +#[derive(serde::Deserialize)] +struct CostAdminPayload { + summary: Option, + today: TodayCostPayload, +} + +#[derive(serde::Deserialize)] +struct CostSummaryPayload { + total_cost: f64, + by_model: Vec<(String, f64, u64)>, + tokens_saved: u64, + efficiency_ratio: f64, +} + +#[derive(serde::Deserialize)] +struct TodayCostPayload { + cost: f64, +} + +fn map_cost_payloads( + week: serde_json::Value, + today: serde_json::Value, +) -> std::result::Result, String> { + let week = serde_json::from_value::(week) + .map_err(|error| format!("invalid daemon 7d cost response: {error}"))?; + let today = serde_json::from_value::(today) + .map_err(|error| format!("invalid daemon today cost response: {error}"))?; + let Some(week_summary) = week.summary else { + return Ok(None); + }; + let today_models = today + .summary + .as_ref() + .map(|summary| summary.by_model.as_slice()) + .unwrap_or_default(); + if !today.today.cost.is_finite() + || today.today.cost < 0.0 + || !week_summary.total_cost.is_finite() + || week_summary.total_cost < 0.0 + || !week_summary.efficiency_ratio.is_finite() + || !(0.0..=1.0).contains(&week_summary.efficiency_ratio) + { + return Err("daemon cost response contains invalid numeric values".to_string()); + } + if week_summary + .by_model + .iter() + .chain(today_models) + .any(|(_, cost, _)| !cost.is_finite() || *cost < 0.0) + { + return Err("daemon cost response contains an invalid model cost".to_string()); + } + let (top_model, top_model_cost) = today_models + .first() + .map(|(model, cost, _)| (model.clone(), *cost)) + .unwrap_or_default(); + Ok(Some(CostSnapshot { + today_cost: today.today.cost, + week_cost: week_summary.total_cost, + tokens_saved: week_summary.tokens_saved, + efficiency_pct: week_summary.efficiency_ratio * 100.0, + top_model, + top_model_cost, + })) +} + +fn global_cost_handshake() -> Result { + DaemonHandshake::for_current_client(None, None, false, false) +} + +async fn call_cost_summary(handshake: &DaemonHandshake, range: &str) -> Result { + let result = crate::daemon::call_default_tool( + handshake, + "tracedecay_admin_cli", + serde_json::json!({ "action": "cost_summary", "range": range }), + ) + .await?; + let text = result + .get("content") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) + .collect::(); + Ok(serde_json::from_str(&text)?) +} + +async fn fetch_cost_snapshot() -> Result> { + let handshake = global_cost_handshake()?; + let fetch = async { + let (week, today) = tokio::try_join!( + call_cost_summary(&handshake, "7d"), + call_cost_summary(&handshake, "today") + )?; + map_cost_payloads(week, today).map_err(|message| TraceDecayError::Config { message }) + }; + tokio::time::timeout(FETCH_TIMEOUT, fetch) + .await + .map_err(|_| TraceDecayError::Config { + message: "daemon cost refresh timed out after 5 seconds".to_string(), + })? +} + +fn fetch_cost_snapshot_blocking() -> RefreshResult { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("could not start cost refresh runtime: {error}"))?; + runtime + .block_on(fetch_cost_snapshot()) + .map_err(|error| error.to_string()) +} + +fn spawn_refresh_worker(fetch: F) -> std::io::Result> +where + F: FnOnce() -> RefreshResult + Send + 'static, +{ + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("tracedecay-monitor-cost".to_string()) + .spawn(move || { + let _ = sender.send(fetch()); + })?; + Ok(receiver) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn payload( + total_cost: f64, + model: &str, + model_cost: f64, + today_cost: f64, + ) -> serde_json::Value { + serde_json::json!({ + "summary": { + "total_cost": total_cost, + "by_model": [[model, model_cost, 900]], + "tokens_saved": 1200, + "efficiency_ratio": 0.6 + }, + "today": { "cost": today_cost } + }) + } + + #[test] + fn daemon_cost_response_uses_today_model_and_week_totals() { + let snapshot = map_cost_payloads( + payload(4.5, "week-leader", 3.25, 1.5), + payload(1.5, "today-leader", 1.25, 1.5), + ) + .unwrap() + .unwrap(); + assert_eq!(snapshot.today_cost, 1.5); + assert_eq!(snapshot.week_cost, 4.5); + assert_eq!(snapshot.tokens_saved, 1200); + assert_eq!(snapshot.efficiency_pct, 60.0); + assert_eq!(snapshot.top_model, "today-leader"); + assert_eq!(snapshot.top_model_cost, 1.25); + } + + #[test] + fn cost_refresh_failure_preserves_snapshot_and_invalid_values_fail() { + let mut cache = CostCache::new(); + cache.apply_refresh(Err("daemon offline".to_string())); + assert_eq!( + cache.state, + CostCacheState::Unavailable("daemon offline".to_string()) + ); + let snapshot = CostSnapshot { + today_cost: 1.5, + week_cost: 4.5, + tokens_saved: 1200, + efficiency_pct: 60.0, + top_model: "today-leader".to_string(), + top_model_cost: 1.25, + }; + cache.apply_refresh(Ok(Some(snapshot.clone()))); + cache.apply_refresh(Err("daemon epoch changed".to_string())); + assert_eq!(cache.snapshot, Some(snapshot)); + assert!(matches!(cache.state, CostCacheState::Stale(_))); + assert!( + map_cost_payloads(payload(-1.0, "a", 1.0, 0.0), payload(0.0, "a", 0.0, 0.0)).is_err() + ); + } + + #[test] + fn global_cost_handshake_is_projectless() { + assert!(global_cost_handshake().unwrap().project_path.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn refresh_worker_is_nonblocking_and_runtime_context_safe() { + let (release_sender, release_receiver) = std::sync::mpsc::sync_channel(0); + let receiver = spawn_refresh_worker(move || { + release_receiver.recv().unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + assert_eq!(runtime.block_on(async { 42 }), 42); + Ok(None) + }) + .unwrap(); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + release_sender.send(()).unwrap(); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + Ok(None) + ); + } + + #[test] + fn refresh_lifecycle_is_single_flight_across_cache_drop() { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + REFRESH_IN_FLIGHT.store(false, Ordering::Release); + let calls = Arc::new(AtomicUsize::new(0)); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(0); + let (release_sender, release_receiver) = std::sync::mpsc::sync_channel(0); + let mut first_cache = CostCache::new(); + let first_calls = Arc::clone(&calls); + first_cache.begin_refresh_with(move || { + first_calls.fetch_add(1, Ordering::AcqRel); + started_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + Ok(None) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + + let skipped_calls = Arc::clone(&calls); + first_cache.begin_refresh_with(move || { + skipped_calls.fetch_add(1, Ordering::AcqRel); + Ok(None) + }); + drop(first_cache); + + let mut replacement = CostCache::new(); + let dropped_cache_calls = Arc::clone(&calls); + replacement.begin_refresh_with(move || { + dropped_cache_calls.fetch_add(1, Ordering::AcqRel); + Ok(None) + }); + assert!(replacement.refresh.is_none()); + assert!(replacement.snapshot.is_none()); + assert_eq!(calls.load(Ordering::Acquire), 1); + + release_sender.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while REFRESH_IN_FLIGHT.load(Ordering::Acquire) && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(!REFRESH_IN_FLIGHT.load(Ordering::Acquire)); + assert!(replacement.snapshot.is_none()); + + let replacement_calls = Arc::clone(&calls); + replacement.begin_refresh_with(move || { + replacement_calls.fetch_add(1, Ordering::AcqRel); + Ok(Some(CostSnapshot { + today_cost: 2.0, + week_cost: 5.0, + tokens_saved: 10, + efficiency_pct: 50.0, + top_model: "new".to_string(), + top_model_cost: 2.0, + })) + }); + let deadline = Instant::now() + Duration::from_secs(1); + while replacement.refresh.is_some() && Instant::now() < deadline { + replacement.poll_refresh(); + std::thread::yield_now(); + } + assert_eq!(calls.load(Ordering::Acquire), 2); + assert_eq!(replacement.snapshot.unwrap().top_model, "new"); + } +} diff --git a/src/project_cmd.rs b/src/project_cmd.rs index 95fdeae91..e92889040 100644 --- a/src/project_cmd.rs +++ b/src/project_cmd.rs @@ -1,76 +1,53 @@ -use std::path::Path; - -use serde_json::json; +use serde_json::{Value, json}; use tracedecay::errors::{Result, TraceDecayError}; -use tracedecay::global_db::{CodeProjectRecord, GlobalDb, ProjectRegistryContext}; -use tracedecay::project_registry::{ - PublicCodeProject, PublicProjectRegistryContext, build_project_registry_view, - render_project_registry_view, -}; +#[cfg(test)] +use tracedecay::global_db::ProjectRegistryContext; +use tracedecay::project_registry::{ProjectRegistryView, render_project_registry_view}; use crate::cli::ProjectsAction; const MAX_LIMIT: usize = 1_000; pub(crate) async fn handle_projects_action(action: ProjectsAction) -> Result<()> { - let db = GlobalDb::open() - .await - .ok_or_else(|| TraceDecayError::Config { - message: - "no TraceDecay global registry found; run `tracedecay init` in a project first" - .to_string(), - })?; - match action { ProjectsAction::List { limit, json } => { let limit = bounded_limit(limit); - let mut projects = db.list_code_projects(limit + 1).await; - let truncated = projects.len() > limit; - projects.truncate(limit); - let active_project_id = active_project_id(&db).await; - print_projects( - &db, - projects, - ProjectPrintOptions { - label: "registered projects", - limit, - truncated, - active_project_id: active_project_id.as_deref(), - query: None, - json_output: json, - }, - ) + let payload = call_registry_admin(json!({ + "action": "registry_list", + "limit": limit, + "query": null, + })) .await?; + print_registry_list(&payload, "registered projects", json)?; } ProjectsAction::Search { query, limit, json } => { let limit = bounded_limit(limit); - let mut projects = db.search_code_projects(&query, limit + 1).await; - let truncated = projects.len() > limit; - projects.truncate(limit); - let active_project_id = active_project_id(&db).await; - print_projects( - &db, - projects, - ProjectPrintOptions { - label: &format!("projects matching \"{query}\""), - limit, - truncated, - active_project_id: active_project_id.as_deref(), - query: Some(("query", query.as_str())), - json_output: json, - }, - ) + let payload = call_registry_admin(json!({ + "action": "registry_list", + "limit": limit, + "query": query, + })) .await?; + print_registry_list(&payload, &format!("projects matching \"{query}\""), json)?; } ProjectsAction::Context { selector, json } => { - let context = project_context(&db, &selector).await.ok_or_else(|| { - TraceDecayError::Config { + let payload = call_registry_admin(json!({ + "action": "registry_context", + "project_arg": selector, + })) + .await?; + if payload["status"] != "ok" { + return Err(TraceDecayError::Config { message: format!( "registered project not found for '{selector}'; try `tracedecay projects search {selector}`" ), - } - })?; - print_project_context(&context, json)?; + }); + } + if json { + println!("{}", serde_json::to_string_pretty(&payload)?); + } else { + print!("{}", render_project_context_payload(&payload)); + } } } Ok(()) @@ -80,82 +57,118 @@ fn bounded_limit(limit: usize) -> usize { limit.clamp(1, MAX_LIMIT) } -async fn project_context(db: &GlobalDb, selector: &str) -> Option { - if let Some(context) = db.project_registry_context_by_id(selector).await { - return Some(context); - } - let selector_path = Path::new(selector); - if let Some(context) = db.project_registry_context_by_alias(selector_path).await { - return Some(context); - } - if !GlobalDb::is_explicit_project_path_selector(selector) { - return None; +fn print_registry_list(payload: &Value, label: &str, json_output: bool) -> Result<()> { + if json_output { + println!("{}", serde_json::to_string_pretty(payload)?); + return Ok(()); } - let git_common_dir = tracedecay::worktree::git_common_dir(selector_path); - db.project_registry_context_by_identity(selector_path, git_common_dir.as_deref()) - .await + let view: ProjectRegistryView = serde_json::from_value(json!({ + "summary": payload["summary"], + "project_tree": payload["project_tree"], + }))?; + print!("{}", render_project_registry_view(label, &view)); + Ok(()) } -async fn active_project_id(db: &GlobalDb) -> Option { - let cwd = std::env::current_dir().ok()?; - let git_common_dir = tracedecay::worktree::git_common_dir(&cwd); - db.project_registry_context_by_identity(&cwd, git_common_dir.as_deref()) - .await - .map(|context| context.project.project_id) -} +fn render_project_context_payload(payload: &Value) -> String { + let mut out = String::new(); + let project = &payload["project"]; + out.push_str(&format!( + "Project: {}\n", + project["project_id"].as_str().unwrap_or("-") + )); + out.push_str(&format!( + "root: {}\n", + project["display_root"].as_str().unwrap_or("-") + )); + if let Some(branch) = project["default_branch"].as_str() { + out.push_str(&format!("default branch: {branch}\n")); + } + if let Some(git_common_dir) = project["git_common_dir"].as_str() { + out.push_str(&format!("git common dir: {git_common_dir}\n")); + } + out.push_str(&format!("last seen: {}\n", project["last_seen_at"])); -struct ProjectPrintOptions<'a> { - label: &'a str, - limit: usize, - truncated: bool, - active_project_id: Option<&'a str>, - query: Option<(&'a str, &'a str)>, - json_output: bool, -} + if let Some(aliases) = payload["aliases"] + .as_array() + .filter(|aliases| !aliases.is_empty()) + { + out.push_str("\nAliases:\n"); + for alias in aliases { + out.push_str(&format!( + " {}\n", + alias["alias_path"].as_str().unwrap_or("-") + )); + } + } -async fn print_projects( - db: &GlobalDb, - projects: Vec, - options: ProjectPrintOptions<'_>, -) -> Result<()> { - let contexts = db.project_registry_contexts_for_projects(&projects).await; - let view = build_project_registry_view(&contexts, options.active_project_id, options.truncated); - if options.json_output { - let projects = projects - .iter() - .map(|project| PublicCodeProject::from_record(project, options.active_project_id)) - .collect::>(); - let mut payload = json!({ - "limit": options.limit, - "truncated": options.truncated, - "summary": view.summary, - "project_tree": view.project_tree, - "projects": projects, - }); - if let Some((key, value)) = options.query { - payload[key] = json!(value); + if let Some(stores) = payload["stores"] + .as_array() + .filter(|stores| !stores.is_empty()) + { + out.push_str("\nStores:\n"); + for store_context in stores { + let store = &store_context["store"]; + out.push_str(&format!( + " {} [{} / {}] {}\n", + store["store_id"].as_str().unwrap_or("-"), + store["store_kind"].as_str().unwrap_or("-"), + store["storage_mode"].as_str().unwrap_or("-"), + store["store_relpath"].as_str().unwrap_or("-") + )); + for scope in store_context["graph_scopes"] + .as_array() + .into_iter() + .flatten() + { + out.push_str(&format!( + " scope {} branch={} db={} writable={}\n", + scope["graph_scope_id"].as_str().unwrap_or("-"), + scope["branch_name"].as_str().unwrap_or("-"), + scope["db_relpath"].as_str().unwrap_or("-"), + scope["writable"].as_bool().unwrap_or(false) + )); + } + for artifact in store_context["artifacts"].as_array().into_iter().flatten() { + let size = artifact["size_bytes"] + .as_u64() + .map(|bytes| bytes.to_string()) + .unwrap_or_else(|| "-".to_string()); + out.push_str(&format!( + " artifact {} path={} size={}\n", + artifact["artifact_kind"].as_str().unwrap_or("-"), + artifact["relpath"].as_str().unwrap_or("-"), + size + )); + } } - println!("{}", serde_json::to_string_pretty(&payload)?); - } else { - print!("{}", render_project_registry_view(options.label, &view)); } - Ok(()) + out } -fn print_project_context(context: &ProjectRegistryContext, json_output: bool) -> Result<()> { - if json_output { - let payload = PublicProjectRegistryContext::new(context, None); - println!("{}", serde_json::to_string_pretty(&payload)?); - return Ok(()); - } - print!("{}", render_project_context_text(context)); - Ok(()) +async fn call_registry_admin(arguments: Value) -> Result { + let cwd = std::env::current_dir()?; + let project_root = tracedecay::config::discover_project_root(&cwd); + let handshake = + tracedecay::daemon::DaemonHandshake::for_current_client(project_root, None, false, false)?; + let result = + tracedecay::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments) + .await?; + let text = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(Into::into) } /// Renders the plain-text `projects context` view. Deliberately omits /// `project.git_remote_url` — a git remote URL can embed credentials /// (`https://user:token@host/...`), so it must never be printed here or /// serialized into the JSON view (see `PublicCodeProject`). +#[cfg(test)] fn render_project_context_text(context: &ProjectRegistryContext) -> String { let mut out = String::new(); let project = &context.project; @@ -211,9 +224,10 @@ fn render_project_context_text(context: &ProjectRegistryContext) -> String { mod tests { use super::*; use tracedecay::global_db::{ - GraphScopeRecord, ProjectAliasRecord, ProjectStoreContext, StoreArtifactRecord, - StoreInstanceRecord, + CodeProjectRecord, GraphScopeRecord, ProjectAliasRecord, ProjectStoreContext, + StoreArtifactRecord, StoreInstanceRecord, }; + use tracedecay::project_registry::PublicProjectRegistryContext; const CREDENTIAL_REMOTE_URL: &str = "https://user:sekret-token@github.com/example/private-repo.git"; @@ -314,4 +328,23 @@ mod tests { // Sanity: the rest of the context still serializes as expected. assert!(json.contains("proj_test")); } + + #[test] + fn daemon_context_payload_preserves_registry_details() { + let context = context_with_credential_remote(); + let public = PublicProjectRegistryContext::new(&context, None); + let payload = serde_json::json!({ + "project": public.project, + "aliases": context.aliases, + "stores": context.stores, + }); + + let text = render_project_context_payload(&payload); + + assert!(text.contains("Aliases:\n /repo")); + assert!(text.contains("Stores:\n store:test [code_project / profile_sharded]")); + assert!(text.contains("scope store:test:branch:main branch=main")); + assert!(text.contains("artifact graph_db path=projects/proj_test/branches/main.db")); + assert!(!text.contains("sekret-token")); + } } diff --git a/src/runtime_telemetry.rs b/src/runtime_telemetry.rs index 7c628b2b1..91b52e561 100644 --- a/src/runtime_telemetry.rs +++ b/src/runtime_telemetry.rs @@ -57,6 +57,8 @@ pub struct DatabaseSnapshot { pub project_root: PathBuf, /// `/.tracedecay/.db` or whichever DB is being served. pub db_path: PathBuf, + /// Canonical identity of the file owned by this process, when resolvable. + pub canonical_db_path: PathBuf, pub db_size_bytes: u64, /// Size of the WAL (`-wal`) file alongside the DB, when present. pub wal_size_bytes: u64, @@ -64,6 +66,15 @@ pub struct DatabaseSnapshot { pub shm_size_bytes: u64, /// `journal_mode` PRAGMA (`wal`, `delete`, `truncate`, …). pub journal_mode: Option, + /// Numeric `synchronous` PRAGMA (`0` OFF, `1` NORMAL, `2` FULL, `3` EXTRA). + pub synchronous: Option, + pub page_size: Option, + /// `PRAGMA quick_check` executed on the already-owned daemon connection. + pub quick_check_ok: Option, + pub quick_check_error: Option, + pub dirty_marker: DirtyMarkerSnapshot, + /// Kernel writer lease currently observed for this database. + pub writer_owner: WriterOwnerSnapshot, /// Total source size we've indexed, from the `files` table sum, in /// bytes — useful to compute the "DB / source" ratio. pub source_total_bytes: u64, @@ -73,6 +84,33 @@ pub struct DatabaseSnapshot { pub edge_count: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum WriterOwnerSnapshot { + Idle, + Active { + pid: u32, + started_epoch_ms: u128, + version: String, + intent: String, + }, + ActiveUnknown, + ProbeFailed { + error: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirtyMarkerSnapshot { + pub path: PathBuf, + pub exists: bool, + pub parsed: bool, + pub owner_pid: Option, + pub epoch: Option, + pub state: Option, + pub schema: Option, +} + /// Capture a runtime snapshot for the given project. /// /// Two responsibilities: (a) sample our own process via `sysinfo`, @@ -82,7 +120,7 @@ pub struct DatabaseSnapshot { /// tool is recording *what's available* during a spike. pub async fn collect(cg: &crate::tracedecay::TraceDecay) -> Result { let process = sample_process(); - let database = sample_database(cg).await?; + let database = collect_database(cg).await?; let captured_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); @@ -132,6 +170,10 @@ pub fn to_text_report(snap: &RuntimeSnapshot) -> String { wal size {wal}\n\ shm size {shm}\n\ journal mode {jm}\n\ + synchronous {sync}\n\ + page size {page_size}\n\ + quick check {quick_check}\n\ + dirty marker {dirty}\n\ source indexed {src}\n\ db / source {ratio:.1}×\n\ nodes / edges {nodes} / {edges}\n\ @@ -152,6 +194,23 @@ pub fn to_text_report(snap: &RuntimeSnapshot) -> String { wal = bytes_human(d.wal_size_bytes), shm = bytes_human(d.shm_size_bytes), jm = d.journal_mode.as_deref().unwrap_or("(unknown)"), + sync = d + .synchronous + .map_or_else(|| "(unknown)".to_string(), |v| v.to_string()), + page_size = d + .page_size + .map_or_else(|| "(unknown)".to_string(), |v| v.to_string()), + quick_check = match (d.quick_check_ok, d.quick_check_error.as_deref()) { + (Some(true), _) => "ok".to_string(), + (Some(false), _) => "failed".to_string(), + (None, Some(error)) => format!("unavailable: {error}"), + (None, None) => "unavailable".to_string(), + }, + dirty = if d.dirty_marker.exists { + d.dirty_marker.state.as_deref().unwrap_or("unparsed") + } else { + "absent" + }, src = bytes_human(d.source_total_bytes), ratio = bloat_ratio, nodes = d.node_count, @@ -213,28 +272,101 @@ fn sample_process_with_window(cpu_sample_window: Duration) -> ProcessSnapshot { // Database sampling // --------------------------------------------------------------------------- -async fn sample_database(cg: &crate::tracedecay::TraceDecay) -> Result { +pub(crate) async fn collect_database( + cg: &crate::tracedecay::TraceDecay, +) -> Result { let project_root = cg.project_root().to_path_buf(); let db_path = cg.db_path().clone(); + let canonical_db_path = db_path.canonicalize().unwrap_or_else(|_| db_path.clone()); let db_size_bytes = file_size(&db_path); let wal_size_bytes = file_size(&with_suffix(&db_path, "-wal")); let shm_size_bytes = file_size(&with_suffix(&db_path, "-shm")); let journal_mode = read_journal_mode(cg).await.ok(); + let synchronous = read_pragma_i64(cg, "PRAGMA synchronous", "read_synchronous") + .await + .ok(); + let page_size = read_pragma_i64(cg, "PRAGMA page_size", "read_page_size") + .await + .ok() + .and_then(|value| u64::try_from(value).ok()); + let (quick_check_ok, quick_check_error) = match cg.quick_check().await { + Ok(ok) => (Some(ok), None), + Err(error) => (None, Some(error.to_string())), + }; + let dirty_marker = read_dirty_marker(&with_suffix(&db_path, ".dirty")); + let writer_owner = match crate::db::probe_writer_owner(&db_path) { + Ok(crate::db::WriterOwnership::Idle) => WriterOwnerSnapshot::Idle, + Ok(crate::db::WriterOwnership::Active(owner)) => WriterOwnerSnapshot::Active { + pid: owner.pid, + started_epoch_ms: owner.started_epoch_ms, + version: owner.version, + intent: owner.intent, + }, + Ok(crate::db::WriterOwnership::ActiveUnknown) => WriterOwnerSnapshot::ActiveUnknown, + Err(error) => WriterOwnerSnapshot::ProbeFailed { + error: error.to_string(), + }, + }; let source_total_bytes = read_source_total_bytes(cg).await.unwrap_or(0); let (node_count, edge_count) = read_graph_counts(cg).await.unwrap_or((0, 0)); Ok(DatabaseSnapshot { project_root, db_path, + canonical_db_path, db_size_bytes, wal_size_bytes, shm_size_bytes, journal_mode, + synchronous, + page_size, + quick_check_ok, + quick_check_error, + dirty_marker, + writer_owner, source_total_bytes, node_count, edge_count, }) } +fn read_dirty_marker(path: &Path) -> DirtyMarkerSnapshot { + let Ok(contents) = std::fs::read(path) else { + return DirtyMarkerSnapshot { + path: path.to_path_buf(), + exists: path.exists(), + parsed: false, + owner_pid: None, + epoch: None, + state: None, + schema: None, + }; + }; + let value = serde_json::from_slice::(&contents).ok(); + DirtyMarkerSnapshot { + path: path.to_path_buf(), + exists: true, + parsed: value.is_some(), + owner_pid: value + .as_ref() + .and_then(|marker| marker.pointer("/owner/pid")) + .and_then(serde_json::Value::as_u64), + epoch: value + .as_ref() + .and_then(|marker| marker.get("epoch")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + state: value + .as_ref() + .and_then(|marker| marker.get("state")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + schema: value + .as_ref() + .and_then(|marker| marker.get("schema")) + .and_then(serde_json::Value::as_u64), + } +} + fn file_size(path: &Path) -> u64 { std::fs::metadata(path).map_or(0, |m| m.len()) } @@ -272,6 +404,37 @@ async fn read_journal_mode(cg: &crate::tracedecay::TraceDecay) -> Result }) } +async fn read_pragma_i64( + cg: &crate::tracedecay::TraceDecay, + sql: &str, + operation: &str, +) -> Result { + let mut rows = + cg.db() + .conn() + .query(sql, ()) + .await + .map_err(|error| TraceDecayError::Database { + message: format!("failed to query {sql}: {error}"), + operation: operation.to_string(), + })?; + rows.next() + .await + .map_err(|error| TraceDecayError::Database { + message: format!("failed to read {sql}: {error}"), + operation: operation.to_string(), + })? + .ok_or_else(|| TraceDecayError::Database { + message: format!("{sql} returned no rows"), + operation: operation.to_string(), + })? + .get(0) + .map_err(|error| TraceDecayError::Database { + message: format!("failed to decode {sql}: {error}"), + operation: operation.to_string(), + }) +} + async fn read_source_total_bytes(cg: &crate::tracedecay::TraceDecay) -> Result { let mut rows = cg .db() @@ -375,6 +538,37 @@ mod tests { assert_eq!(with_suffix(p, "-shm"), Path::new("/tmp/x.db-shm")); } + #[test] + fn dirty_marker_snapshot_parses_owner_epoch_and_state() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("graph.db.dirty"); + std::fs::write( + &path, + br#"{"schema":2,"owner":{"pid":42},"epoch":"epoch-7","state":"dirty"}"#, + ) + .unwrap(); + + let marker = read_dirty_marker(&path); + assert!(marker.exists); + assert!(marker.parsed); + assert_eq!(marker.owner_pid, Some(42)); + assert_eq!(marker.epoch.as_deref(), Some("epoch-7")); + assert_eq!(marker.state.as_deref(), Some("dirty")); + assert_eq!(marker.schema, Some(2)); + } + + #[test] + fn dirty_marker_snapshot_preserves_unparsed_presence() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("graph.db.dirty"); + std::fs::write(&path, b"legacy-dirty-marker").unwrap(); + + let marker = read_dirty_marker(&path); + assert!(marker.exists); + assert!(!marker.parsed); + assert_eq!(marker.state, None); + } + /// Regression guard for the Windows `STATUS_STACK_OVERFLOW` report against /// `tracedecay_runtime`: the process-sampling path must fit comfortably /// inside a stack far smaller than Windows' 1 MiB main-thread default. diff --git a/src/serve.rs b/src/serve.rs index 9d9ebebba..3d03eefca 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -1,44 +1,9 @@ use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::errors::{Result, TraceDecayError}; -use crate::global_db::GlobalDb; -use crate::mcp::transport::{McpTransport, ReplayTransport, StdioTransport}; use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ServeGlobalDbResolution { - Found(PathBuf), - Ambiguous(Vec), - None, -} - -#[derive(Debug, Clone, Copy)] -enum CwdProjectMatch { - ProjectContainsCwd, - ProjectUnderCwd, -} - -impl CwdProjectMatch { - fn matches(self, project_path: &Path, cwd: &Path) -> bool { - match self { - Self::ProjectContainsCwd => cwd.starts_with(project_path), - Self::ProjectUnderCwd => project_path.starts_with(cwd), - } - } - - fn sort_matches(self, matches: &mut [(usize, String)]) { - match self { - Self::ProjectContainsCwd => { - matches.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - } - Self::ProjectUnderCwd => { - matches.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); - } - } - } -} - /// Returns the first plausible unexpanded `${...}` template variable in a /// `--path` argument (e.g. `${workspaceFolder}`), or `None` when the value /// contains no template syntax. The brace contents must look like a variable @@ -78,11 +43,9 @@ fn plausible_template_contents(contents: &str) -> bool { /// Some hosts pass config template variables like `${workspaceFolder}` /// through literally instead of expanding them (Cursor's headless /// agent-session MCP scopes do this; see `plugin/README-cursor.md`). Such a -/// value is discarded with a stderr warning so `serve` can fall back to its -/// no-path discovery chain where possible; callers must use -/// [`ServeGlobalDbMatch::UniqueOnly`] for the global-registry step in that -/// mode because the host's spawn directory says nothing about the intended -/// workspace. +/// value is discarded with a stderr warning so daemon routing can fall back +/// to project discovery and MCP initialize roots without treating the literal +/// template as a project path. pub fn sanitize_serve_path_arg(path: Option) -> Option { let raw = path?; let Some(variable) = unexpanded_template_variable(&raw) else { @@ -98,271 +61,28 @@ pub fn sanitize_serve_path_arg(path: Option) -> Option { None } -/// Reports which project the discarded-template fallback settled on and why, -/// so a wrong pick is diagnosable from the host's MCP logs. -pub fn log_serve_project_choice(project: &Path, why: &str) { - let _ = writeln!( - std::io::stderr(), - "tracedecay serve: using project '{}' ({why})", - project.display() - ); -} - -pub fn global_db_ambiguity_message(paths: &[String]) -> String { - let mut message = - "Multiple tracedecay projects found — pass -p to select one:".to_string(); - for path in paths { - message.push_str("\n "); - message.push_str(path); - } - message -} - -/// Opens an existing project, or tells the user to run `tracedecay init` first. +/// Legacy compatibility entry point for callers that previously opened a +/// project database in-process. +/// +/// Project databases are daemon-owned. Returning a local [`TraceDecay`] would +/// reintroduce a second SQLite owner, so this API deliberately fails closed. pub async fn ensure_initialized(project_path: &Path) -> Result { - Box::pin(ensure_initialized_with_options( - project_path, - TraceDecayOpenOptions::default(), - )) - .await + Err(direct_project_open_disabled(project_path)) } pub async fn ensure_initialized_with_options( project_path: &Path, - open_options: TraceDecayOpenOptions, + _open_options: TraceDecayOpenOptions, ) -> Result { - match TraceDecay::open_with_options(project_path, open_options.clone()).await { - Ok(cg) => return Ok(cg), - Err(open_err) => { - match TraceDecay::open_read_only_with_options(project_path, open_options.clone()).await - { - Ok(cg) => { - cg.ensure_schema_current().await?; - return Ok(cg); - } - Err(_) => { - if !matches!(open_err, TraceDecayError::Config { .. }) { - return Err(open_err); - } - } - } - } - } - if let Some(cg) = auto_initialize_git_project(project_path, open_options).await? { - return Ok(cg); - } - Err(TraceDecayError::Config { + Err(direct_project_open_disabled(project_path)) +} + +fn direct_project_open_disabled(project_path: &Path) -> TraceDecayError { + TraceDecayError::Config { message: format!( - "no TraceDecay index found at '{}' — run 'tracedecay init' first", + "direct project database access is disabled for '{}'; route the operation through the managed TraceDecay daemon", project_path.display() ), - }) -} - -async fn auto_initialize_git_project( - project_path: &Path, - open_options: TraceDecayOpenOptions, -) -> Result> { - if !crate::config::load_sync_config(project_path).auto_init { - return Ok(None); - } - let Some(project_root) = crate::worktree::git_worktree_root(project_path) else { - return Ok(None); - }; - eprintln!( - "[tracedecay] auto-initializing unindexed git repo at {}", - project_root.display() - ); - TraceDecay::init_and_index_with_options(&project_root, open_options) - .await - .map(Some) -} - -async fn initialized_project_paths(paths: Vec) -> Vec { - let mut initialized = Vec::new(); - for path in paths { - if TraceDecay::has_initialized_store(Path::new(&path)).await { - initialized.push(path); - } - } - initialized -} - -fn cwd_match_resolution( - paths: &[String], - cwd: &Path, - match_kind: CwdProjectMatch, -) -> Option { - let mut matches: Vec<_> = paths - .iter() - .filter_map(|p| { - let project_path = Path::new(p).canonicalize().ok()?; - match_kind - .matches(&project_path, cwd) - .then(|| (project_path.components().count(), p.clone())) - }) - .collect(); - match_kind.sort_matches(&mut matches); - - let (depth, _) = matches.first()?; - if matches.get(1).is_some_and(|next| next.0 == *depth) { - return Some(ServeGlobalDbResolution::Ambiguous( - matches.into_iter().map(|(_, p)| p).collect(), - )); - } - Some(ServeGlobalDbResolution::Found(PathBuf::from( - matches.remove(0).1, - ))) -} - -/// How [`resolve_serve_from_global_db`] may pick among multiple registered -/// projects. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ServeGlobalDbMatch { - /// Disambiguate via the cwd heuristic (ancestor/descendant depth ranking). - /// Appropriate when the user genuinely ran `serve` without a path: cwd is - /// where they invoked it, so proximity is meaningful. - CwdHeuristic, - /// Require exactly one registered project; report every multi-project - /// registry as ambiguous. Used when an explicit `--path` was discarded as - /// an unexpanded host template: the host's spawn directory (typically the - /// user home) says nothing about the intended workspace, so a silent - /// depth-ranked pick risks serving the wrong project's index. - UniqueOnly, -} - -/// Fallback for `serve`: when CWD-based discovery fails, check the global DB -/// for registered projects. With [`ServeGlobalDbMatch::CwdHeuristic`], -/// multiple projects are ranked against cwd: prefer a project that is an -/// ancestor of cwd (cwd is inside the project), then a project that is a -/// descendant of cwd (project is under cwd); ties at the winning depth are -/// ambiguous and require an explicit path. With -/// [`ServeGlobalDbMatch::UniqueOnly`], any multi-project registry is -/// ambiguous. -pub async fn resolve_serve_from_global_db( - match_mode: ServeGlobalDbMatch, -) -> ServeGlobalDbResolution { - let Some(gdb) = GlobalDb::open().await else { - return ServeGlobalDbResolution::None; - }; - let mut paths = initialized_project_paths(gdb.list_project_paths().await).await; - paths.sort(); - if paths.len() == 1 { - return ServeGlobalDbResolution::Found(PathBuf::from(paths.remove(0))); - } - if paths.is_empty() { - return ServeGlobalDbResolution::None; - } - match match_mode { - ServeGlobalDbMatch::UniqueOnly => { - return ServeGlobalDbResolution::Ambiguous(paths); - } - ServeGlobalDbMatch::CwdHeuristic => {} - } - - // Multiple projects — try to resolve using cwd. - let Ok(cwd) = std::env::current_dir() else { - return ServeGlobalDbResolution::Ambiguous(paths); - }; - let cwd = cwd.canonicalize().unwrap_or(cwd); - - // Priority 1: cwd is inside a project (project is ancestor of cwd). - // Pick the deepest ancestor (most specific match). - if let Some(resolution) = - cwd_match_resolution(&paths, &cwd, CwdProjectMatch::ProjectContainsCwd) - { - return resolution; - } - - // Priority 2: a project is under cwd (cwd is ancestor of project). - // Pick the shallowest descendant (closest child). - if let Some(resolution) = cwd_match_resolution(&paths, &cwd, CwdProjectMatch::ProjectUnderCwd) { - return resolution; - } - - // No cwd-based match — the global DB alone cannot disambiguate. - ServeGlobalDbResolution::Ambiguous(paths) -} - -/// Peeks at the first stdin line to read the MCP `initialize` request's -/// `roots` array, returning the local workspace root paths. The raw line is -/// stored in `out` so the caller can replay it into the MCP transport (the -/// server still needs to see it). -async fn read_initialize_roots(out: &mut Option) -> Vec { - let Some(line) = read_first_non_empty_stdin_line().await else { - return Vec::new(); - }; - *out = Some(line.trim().to_string()); - - let Ok(parsed) = serde_json::from_str::(line.trim()) else { - return Vec::new(); - }; - let Some(roots) = parsed.pointer("/params/roots").and_then(|v| v.as_array()) else { - return Vec::new(); - }; - roots - .iter() - .filter_map(|root| { - let uri = root.get("uri").and_then(|v| v.as_str())?; - local_path_from_mcp_root_uri(uri) - }) - .collect() -} - -/// Resolves a project from MCP `initialize` workspace roots: a root that IS a -/// registered project wins, otherwise the nearest enclosing project of any -/// root. First match wins. -async fn resolve_project_from_roots(roots: &[std::path::PathBuf]) -> Option { - if roots.is_empty() { - return None; - } - let registered = match GlobalDb::open().await { - Some(gdb) => initialized_project_paths(gdb.list_project_paths().await).await, - None => Vec::new(), - }; - for root_path in roots { - if let Some(hit) = registered - .iter() - .find(|p| std::path::Path::new(p) == root_path.as_path()) - { - return Some(std::path::PathBuf::from(hit)); - } - if let Some(discovered) = crate::config::discover_project_root(root_path) { - return Some(discovered); - } - if let Some(git_root) = crate::worktree::git_worktree_root(root_path) { - return Some(git_root); - } - } - None -} - -async fn read_first_non_empty_stdin_line() -> Option { - use tokio::io::AsyncReadExt; - - let mut stdin = tokio::io::stdin(); - let mut line = Vec::new(); - let mut byte = [0_u8; 1]; - // Avoid buffering past the first line; the normal stdio transport must still - // receive any later JSON-RPC messages from stdin. - loop { - match stdin.read(&mut byte).await { - Ok(0) if line.is_empty() => return None, - Ok(0) => { - let text = String::from_utf8(line).ok()?; - return (!text.trim().is_empty()).then_some(text); - } - Ok(_) => { - line.push(byte[0]); - if byte[0] == b'\n' { - let text = String::from_utf8(std::mem::take(&mut line)).ok()?; - if !text.trim().is_empty() { - return Some(text); - } - } - } - Err(_) => return None, - } } } @@ -434,64 +154,20 @@ fn hex_value(byte: u8) -> Option { // Serve startup orchestration // --------------------------------------------------------------------------- -/// Runs the `serve` command end to end. When the daemon owns its socket, -/// choose the proxy transport before opening any project database; otherwise -/// resolve the project locally (degrading instead of exiting when resolution -/// fails — see [`ServeProjectResolver::resolve_once`] and -/// [`run_degraded_mcp_server`]). +/// Runs the `serve` command as a database-free proxy to the managed daemon. pub async fn run_serve(path_arg: Option, timings: bool) -> Result<()> { let original_cwd = std::env::current_dir().ok(); let socket_path = crate::daemon::default_socket_path()?; - if crate::daemon::should_proxy_serve_to_daemon(&socket_path).await { - let handshake = proxy_serve_handshake(path_arg, original_cwd.as_deref(), timings)?; - return crate::daemon::proxy_stdio_to_daemon(&socket_path, &handshake, None).await; - } - - let (resolver, error, peeked_line) = match Box::pin(resolve_serve_startup(path_arg)).await { - ServeStartup::Ready { - cg, - peeked_line, - allow_initialize_root_routing, - } => { - return Box::pin(serve_resolved_project( - *cg, - original_cwd, - timings, - peeked_line, - allow_initialize_root_routing, - )) - .await; - } - ServeStartup::Degraded { - resolver, - error, - peeked_line, - } => (resolver, error, peeked_line), - }; - - // Do NOT exit: MCP hosts (Cursor especially) treat a dead server process - // as a permanently failed scope and never retry it, so a startup exit - // over a recoverable config problem breaks every later tool call in the - // session. Serve a degraded MCP surface instead; it recovers on its own - // once resolution starts succeeding. - let mut transport = ReplayTransport::new(StdioTransport::new()); - if let Some(line) = peeked_line { - transport.push_replay(line); - } - match Box::pin(run_degraded_mcp_server(&mut transport, &resolver, &error)).await? { - DegradedServeOutcome::Closed => Ok(()), - DegradedServeOutcome::Recovered { cg, pending_line } => { - // Keep serving on the SAME transport: requests pipelined behind - // the recovery-triggering call may already sit in its read - // buffer, and a raw-stdin handoff (fresh transport or daemon - // proxy) would silently drop them. The recovery-triggering - // tools/call itself is replayed first. - transport.push_replay(pending_line); - let scope_prefix = serve_scope_prefix(original_cwd.as_deref(), cg.project_root()); - let server = crate::mcp::McpServer::new(*cg, scope_prefix).await; - Box::pin(server.run(&mut transport)).await - } + if !crate::daemon::should_proxy_serve_to_daemon(&socket_path).await { + return Err(TraceDecayError::Config { + message: format!( + "TraceDecay daemon socket '{}' is not available. Run `tracedecay daemon install-service` and ensure the service is running.", + socket_path.display() + ), + }); } + let handshake = proxy_serve_handshake(path_arg, original_cwd.as_deref(), timings)?; + crate::daemon::proxy_stdio_to_daemon(&socket_path, &handshake, None).await } /// Builds daemon routing metadata without opening a project or global @@ -535,42 +211,6 @@ fn proxy_serve_handshake( Ok(handshake) } -/// Serves a startup-resolved project: proxy to the daemon when one owns the -/// socket, otherwise run the in-process MCP engine. -async fn serve_resolved_project( - cg: TraceDecay, - original_cwd: Option, - timings: bool, - peeked_line: Option, - allow_initialize_root_routing: bool, -) -> Result<()> { - let scope_prefix = serve_scope_prefix(original_cwd.as_deref(), cg.project_root()); - let telemetry_timings = - timings || crate::config::load_telemetry_config(cg.project_root()).timings; - let mut handshake = crate::daemon::DaemonHandshake::for_current_client( - Some(cg.project_root().to_path_buf()), - scope_prefix, - telemetry_timings, - false, - )?; - handshake.allow_initialize_root_routing = allow_initialize_root_routing; - let socket_path = crate::daemon::default_socket_path()?; - if crate::daemon::should_proxy_serve_to_daemon(&socket_path).await { - // The daemon may have appeared while local resolution was in flight. - // Never retain those local DB handles for the lifetime of the proxy. - drop(cg); - crate::daemon::proxy_stdio_to_daemon(&socket_path, &handshake, peeked_line).await - } else { - let server = crate::mcp::McpServer::new(cg, handshake.scope_prefix.clone()).await; - server.set_initialize_root_routing_enabled(allow_initialize_root_routing); - let mut transport = ReplayTransport::new(StdioTransport::new()); - if let Some(line) = peeked_line { - transport.push_replay(line); - } - Box::pin(server.run(&mut transport)).await - } -} - /// The scope prefix for a serve session: the relative path from the project /// root to the directory serve was launched from, when the latter is inside /// the project. @@ -583,325 +223,25 @@ fn serve_scope_prefix(original_cwd: Option<&Path>, project_root: &Path) -> Optio }) } -// --------------------------------------------------------------------------- -// Startup project resolution -// --------------------------------------------------------------------------- - -/// The outcome of startup project resolution for `serve`. -pub enum ServeStartup { - /// A project resolved; serve it normally. `peeked_line` is a stdin line - /// (the MCP `initialize` request) consumed while peeking workspace roots; - /// it must be replayed to whatever serves the session. - Ready { - cg: Box, - peeked_line: Option, - allow_initialize_root_routing: bool, - }, - /// Resolution failed with a recoverable config problem. The resolver - /// retries the same resolution ladder on every degraded tool call. - Degraded { - resolver: ServeProjectResolver, - error: TraceDecayError, - peeked_line: Option, - }, -} - -/// Resolves the project to serve, capturing everything needed to retry the -/// exact same resolution later from degraded mode. -pub async fn resolve_serve_startup(path_arg: Option) -> ServeStartup { - // Some MCP hosts (e.g. Cursor headless agent sessions) pass config - // template variables like `${workspaceFolder}` through literally; treat - // such values as "no --path" and use discovery instead. - let had_path_arg = path_arg.is_some(); - let path = sanitize_serve_path_arg(path_arg); - let explicit_path = path.is_some(); - let path_was_template = had_path_arg && !explicit_path; - let mut resolver = ServeProjectResolver { - project_path: crate::config::resolve_path_with_discovery(path), - explicit_path, - path_was_template, - // The host's spawn directory says nothing about the intended - // workspace when it failed to expand a template path, so the - // global-registry step must not depth-rank against cwd in that mode. - global_db_match: if path_was_template { - ServeGlobalDbMatch::UniqueOnly - } else { - ServeGlobalDbMatch::CwdHeuristic - }, - initialize_roots: Vec::new(), - }; - - let mut peeked_line: Option = None; - let first_error = match Box::pin(ensure_initialized(&resolver.project_path)).await { - Ok(cg) => { - resolver - .log_choice_if_template(cg.project_root(), "discovered from the working directory"); - return ServeStartup::Ready { - cg: Box::new(cg), - peeked_line, - allow_initialize_root_routing: allow_initialize_root_routing_for_startup( - resolver.explicit_path, - ServeProjectResolutionOrigin::StartupPath, - ), - }; - } - Err(e) => e, - }; - if resolver.explicit_path { - // An explicit path is authoritative: no discovery fallbacks, and the - // degraded retry only ever rechecks this path. - return ServeStartup::Degraded { - resolver, - error: first_error, - peeked_line, - }; - } - - // CWD-based discovery failed (e.g. an MCP host launched us from ~). Peek - // the MCP `initialize` request's workspace roots; the resolver remembers - // them so degraded retries can keep consulting them. - resolver.initialize_roots = read_initialize_roots(&mut peeked_line).await; - match Box::pin(resolver.resolve_once_with_origin()).await { - Ok((cg, origin)) => ServeStartup::Ready { - cg: Box::new(cg), - peeked_line, - allow_initialize_root_routing: allow_initialize_root_routing_for_startup( - resolver.explicit_path, - origin, - ), - }, - Err(error) => ServeStartup::Degraded { - resolver, - error, - peeked_line, - }, - } -} - -/// Everything needed to attempt `serve` project resolution — at startup and -/// again on every degraded tool call. One resolution ladder serves both, so -/// degraded mode recovers through exactly the fallbacks startup would have -/// used (cwd discovery, remembered MCP initialize roots, global registry). -pub struct ServeProjectResolver { - /// The explicit `--path`, or the startup cwd-discovery result. - project_path: std::path::PathBuf, - /// A real (non-template) `--path` was given; it is authoritative. - explicit_path: bool, - /// The `--path` was a discarded unexpanded host template. - path_was_template: bool, - global_db_match: ServeGlobalDbMatch, - /// Workspace roots from the peeked MCP `initialize` request. - initialize_roots: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServeProjectResolutionOrigin { - StartupPath, - FreshCwd, - InitializeRoots, - GlobalDb, -} - -fn allow_initialize_root_routing_for_startup( - explicit_path: bool, - origin: ServeProjectResolutionOrigin, -) -> bool { - !explicit_path - && matches!( - origin, - ServeProjectResolutionOrigin::InitializeRoots | ServeProjectResolutionOrigin::GlobalDb - ) -} - -impl ServeProjectResolver { - /// The path named in degraded-mode error messages. - pub(crate) fn project_path(&self) -> &Path { - &self.project_path - } - - /// Runs the full resolution ladder once. Explicit paths never fall - /// back; discovery mode tries, in order: the startup path, a fresh cwd - /// walk-up (an intervening `tracedecay init` can create an enclosing - /// project), the remembered initialize roots, then the global registry. - async fn resolve_once(&self) -> Result { - Box::pin(self.resolve_once_with_origin()) - .await - .map(|(cg, _origin)| cg) - } - - async fn resolve_once_with_origin(&self) -> Result<(TraceDecay, ServeProjectResolutionOrigin)> { - let first_error = match Box::pin(ensure_initialized(&self.project_path)).await { - Ok(cg) => { - self.log_choice_if_template( - cg.project_root(), - "discovered from the working directory", - ); - return Ok((cg, ServeProjectResolutionOrigin::StartupPath)); - } - Err(e) => e, - }; - if self.explicit_path { - return Err(first_error); - } - - let discovered = crate::config::resolve_path_with_discovery(None); - if discovered != self.project_path { - if let Ok(cg) = Box::pin(ensure_initialized(&discovered)).await { - self.log_choice_if_template( - cg.project_root(), - "discovered from the working directory", - ); - return Ok((cg, ServeProjectResolutionOrigin::FreshCwd)); - } - } - - if let Some(p) = resolve_project_from_roots(&self.initialize_roots).await { - self.log_choice_if_template(&p, "matched an MCP initialize root"); - return Box::pin(ensure_initialized(&p)) - .await - .map(|cg| (cg, ServeProjectResolutionOrigin::InitializeRoots)); - } - - match resolve_serve_from_global_db(self.global_db_match).await { - ServeGlobalDbResolution::Found(p) => { - self.log_choice_if_template(&p, "resolved from the global project registry"); - Box::pin(ensure_initialized(&p)) - .await - .map(|cg| (cg, ServeProjectResolutionOrigin::GlobalDb)) - } - ServeGlobalDbResolution::Ambiguous(paths) => Err(TraceDecayError::Config { - message: global_db_ambiguity_message(&paths), - }), - ServeGlobalDbResolution::None => Err(TraceDecayError::Config { - message: format!( - "no TraceDecay index found at '{}' and no projects registered in the global database — run 'tracedecay init' in your project first", - self.project_path.display() - ), - }), - } - } - - fn log_choice_if_template(&self, project: &Path, why: &str) { - if self.path_was_template { - log_serve_project_choice(project, why); - } - } -} - -// --------------------------------------------------------------------------- -// Degraded MCP serving (startup project-resolution failures) -// --------------------------------------------------------------------------- - -/// Marker line written to stderr when serve enters degraded mode. Grepped by -/// `tracedecay doctor --agent cursor` from Cursor's MCP logs -/// (`crate::agents::cursor_diagnostics`), so keep the wording stable. +/// Legacy marker recognized in existing Cursor logs by doctor diagnostics. +/// Proxy-only `serve` no longer emits it, but older logs remain actionable. pub const DEGRADED_SERVE_STDERR_MARKER: &str = "[tracedecay] serve: staying alive in degraded MCP mode"; -/// How a degraded serving session ended. -pub enum DegradedServeOutcome { - /// stdin closed (client disconnected) while still degraded. - Closed, - /// Project resolution started succeeding mid-session (e.g. the user ran - /// `tracedecay init`). The pending request line is the `tools/call` that - /// triggered the successful retry; the caller must replay it into the - /// recovered full server. Boxed to keep the enum small next to `Closed`. - Recovered { - cg: Box, - pending_line: String, - }, -} - -/// Runs a minimal MCP server after startup project resolution failed. -/// -/// MCP hosts treat a dead server process as a permanently failed scope: -/// Cursor in particular never retries a failed spawn, so one startup exit -/// (bad `--path`, uninitialized project, ambiguous global fallback) turns -/// every later tool call in that session into "Timed out waiting for -/// connection" until the user toggles the server or reloads the window. -/// Instead of exiting, this loop completes the MCP handshake, lists the real -/// tools, and answers every `tools/call` with an actionable error that names -/// the failure, the fix, and the `tracedecay tool …` CLI fallback (protocol -/// responses shared with the full server via [`crate::mcp::degraded`]). -/// -/// Each `tools/call` first re-runs the startup resolution ladder; when it -/// starts succeeding the loop hands control back so the caller can serve the -/// project for real — no toggle or window reload needed after `tracedecay -/// init`. -pub async fn run_degraded_mcp_server( - transport: &mut impl McpTransport, - resolver: &ServeProjectResolver, - startup_error: &TraceDecayError, -) -> Result { - eprintln!("Error: {startup_error}"); - eprintln!( - "{DEGRADED_SERVE_STDERR_MARKER} — MCP handshake will complete and tool calls will \ - return this error until the project resolves" - ); - let notice = degraded_serve_notice(resolver.project_path(), startup_error); - loop { - let line = match transport.read_line().await { - Ok(Some(line)) => line, - Ok(None) => return Ok(DegradedServeOutcome::Closed), - Err(e) => return Err(e.into()), - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if crate::mcp::degraded::is_tools_call_line(trimmed) { - if let Ok(cg) = Box::pin(resolver.resolve_once()).await { - eprintln!( - "[tracedecay] serve: project resolution recovered for '{}'; leaving degraded MCP mode", - cg.project_root().display() - ); - return Ok(DegradedServeOutcome::Recovered { - cg: Box::new(cg), - pending_line: trimmed.to_string(), - }); - } - } - let Some(response) = crate::mcp::degraded::degraded_response_for_line(trimmed, ¬ice) - else { - continue; - }; - let mut out = serde_json::to_string(&response)?; - out.push('\n'); - transport.write_line(&out).await?; - transport.flush().await?; - } -} - -/// Builds the actionable error text returned from every degraded `tools/call`. -pub fn degraded_serve_notice(project_path: &Path, startup_error: &TraceDecayError) -> String { - format!( - "TraceDecay MCP server is running in degraded mode: project resolution failed at startup \ - for '{path}'.\n\ - \n\ - Error: {startup_error}\n\ - \n\ - To fix:\n\ - 1. Run `tracedecay init` inside the project (or point the MCP server at an initialized \ - project via `tracedecay serve --path `).\n\ - 2. Retry the tool call — this server rechecks the project on every call and recovers \ - automatically once resolution succeeds.\n\ - 3. If the MCP client shows connection errors instead of this message, restart or toggle \ - the tracedecay MCP server in your MCP host; some hosts do not retry a failed server \ - process on their own.\n\ - \n\ - Diagnose agent-specific install/config issues with `tracedecay doctor --agent `. \ - Every tool is also available from \ - the shell: {args} (run `tracedecay tool` to list tools) from inside an initialized \ - project.", - args = crate::agents::CLI_FALLBACK_ARGS_INVOCATION, - path = project_path.display(), - ) -} - #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn direct_project_open_fails_closed() { + let path = Path::new("/tmp/tracedecay-direct-open-must-not-run"); + let error = match ensure_initialized(path).await { + Ok(_) => panic!("legacy local open must fail closed"), + Err(error) => error, + }; + assert!(error.to_string().contains("managed TraceDecay daemon")); + } + #[test] fn detects_literal_workspace_folder_variable() { assert_eq!( @@ -1007,24 +347,4 @@ mod tests { None ); } - - #[test] - fn initialize_root_routing_is_only_enabled_after_non_cwd_startup_resolution() { - assert!(!allow_initialize_root_routing_for_startup( - false, - ServeProjectResolutionOrigin::StartupPath - )); - assert!(!allow_initialize_root_routing_for_startup( - false, - ServeProjectResolutionOrigin::FreshCwd - )); - assert!(allow_initialize_root_routing_for_startup( - false, - ServeProjectResolutionOrigin::InitializeRoots - )); - assert!(!allow_initialize_root_routing_for_startup( - true, - ServeProjectResolutionOrigin::StartupPath - )); - } } diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index ab858d4d8..66260f905 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -98,7 +98,7 @@ pub async fn ingest_user_codex_sessions(session_id: Option) -> Transcrip ingest_user_codex_sessions_at(&profile_root, session_id, registered_roots).await } -async fn ingest_user_codex_sessions_at( +pub(crate) async fn ingest_user_codex_sessions_at( profile_root: &Path, session_id: Option, registered_roots: Vec, diff --git a/src/sessions_cmd.rs b/src/sessions_cmd.rs index 25deec749..64ba63acf 100644 --- a/src/sessions_cmd.rs +++ b/src/sessions_cmd.rs @@ -4,8 +4,7 @@ use crate::{ cli::{SessionsAction, SessionsSearchArgs}, resolve_cli_project_root, }; -use tracedecay::sessions::{ProviderScope, SessionSearchFilters, SessionSearchTimeRange}; -use tracedecay::timeutil::SearchTimeBound; +use serde_json::{Value, json}; pub(crate) async fn handle_sessions_action( action: SessionsAction, @@ -17,19 +16,20 @@ pub(crate) async fn handle_sessions_action( project_path, } => { let project_path = resolve_cli_project_root(None, project_id, project_path).await?; - let db = tracedecay::sessions::cursor::open_project_session_db(&project_path) - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "could not open project session database for {}", - project_path.display() - ), - })?; - let _ = session_provider_scope(provider.as_deref())?; - let stats = ingest_selected_session_sources(&db, &project_path).await; + if let Some(provider) = provider.as_deref() { + tracedecay::sessions::ProviderScope::parse_optional(Some(provider)) + .map_err(|message| tracedecay::errors::TraceDecayError::Config { message })?; + } + let stats = call_daemon_tool( + &project_path, + "tracedecay_admin_cli", + json!({ "action": "sessions_ingest" }), + ) + .await?; println!( "ingested {} session(s), {} message(s)", - stats.sessions_upserted, stats.messages_upserted + stats["sessions_upserted"].as_u64().unwrap_or(0), + stats["messages_upserted"].as_u64().unwrap_or(0) ); } SessionsAction::Search(args) => { @@ -49,106 +49,45 @@ pub(crate) async fn handle_sessions_action( commit, } = *args; let project_path = resolve_cli_project_root(None, project_id, project_path).await?; - let db = tracedecay::sessions::cursor::open_project_session_db(&project_path) - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "could not open project session database for {}", - project_path.display() - ), - })?; - let provider_scope = session_provider_scope(provider.as_deref())?; - let scope = - tracedecay::sessions::SessionSearchScope::parse(&scope).ok_or_else(|| { - tracedecay::errors::TraceDecayError::Config { - message: "scope must be one of all, parents_only, subagents_only" - .to_string(), - } - })?; - let message_type = tracedecay::sessions::SessionMessageType::parse(&message_type) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "message_type must be one of all, direct_user, tool_result" - .to_string(), - })?; - let git_filter = tracedecay::sessions::git_correlation::GitScopeFilter::from_args( - branch.as_deref(), - worktree.as_deref(), - commit.as_deref(), - ) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - })?; - let now = tracedecay::tracedecay::current_timestamp(); - let time_range = SessionSearchTimeRange { - start_time: parse_time_filter_arg( - "since", - since.as_deref(), - now, - SearchTimeBound::Start, - )?, - end_time: parse_time_filter_arg( - "until", - until.as_deref(), - now, - SearchTimeBound::End, - )?, - }; - let _ = tracedecay::sessions::ingest_global_sources_for_provider( - &db, + let payload = call_daemon_tool( &project_path, - provider_scope.provider(), + "tracedecay_message_search", + json!({ + "query": query, + "provider": provider, + "scope": scope, + "message_type": message_type, + "parent_session_id": parent_session_id, + "limit": limit, + "since": since, + "until": until, + "branch": branch, + "worktree": worktree, + "commit": commit, + "format": "json", + }), ) - .await; - let results = if !git_filter.is_empty() { - db.search_session_messages_git_scoped( - provider_scope.provider_id(), - None, - &query, - limit, - SessionSearchFilters { - scope, - message_type, - parent_session_id: parent_session_id.as_deref(), - time_range, - }, - &git_filter, - ) - .await - } else if let Some(provider) = provider_scope.provider() { - db.search_session_messages_filtered( - provider.id(), - None, - &query, - limit, - SessionSearchFilters { - scope, - message_type, - parent_session_id: parent_session_id.as_deref(), - time_range, - }, - ) - .await - } else { - db.search_session_messages_all_providers_filtered( - None, - &query, - limit, - SessionSearchFilters { - scope, - message_type, - parent_session_id: parent_session_id.as_deref(), - time_range, - }, - ) - .await - }; - for result in results { + .await?; + for result in payload["results"].as_array().into_iter().flatten() { println!( "[{}] {} {}: {}", - result.session.provider, - result.session.project_key, - result.message.role, - result.message.text.replace('\n', " ") + result + .pointer("/session/provider") + .and_then(Value::as_str) + .unwrap_or("-"), + result + .pointer("/session/project_key") + .and_then(Value::as_str) + .unwrap_or("-"), + result + .pointer("/message/role") + .and_then(Value::as_str) + .unwrap_or("-"), + result + .pointer("/message/text") + .and_then(Value::as_str) + .unwrap_or("") + .replace('\n', " ") ); } } @@ -168,17 +107,13 @@ pub(crate) async fn handle_sessions_action( project_path, } => { let project_path = resolve_cli_project_root(None, project_id, project_path).await?; - let db = tracedecay::sessions::cursor::open_project_session_db(&project_path) - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "could not open project session database for {}", - project_path.display() - ), - })?; - let items = tracedecay::sessions::workflow_state::list_unfinished(&db, limit) - .await - .map_err(|message| tracedecay::errors::TraceDecayError::Config { message })?; + let payload = call_daemon_tool( + &project_path, + "tracedecay_admin_cli", + json!({ "action": "sessions_unfinished", "limit": limit }), + ) + .await?; + let items = payload["items"].as_array().cloned().unwrap_or_default(); if json { println!( "{}", @@ -190,15 +125,15 @@ pub(crate) async fn handle_sessions_action( ); } else { for item in items { - let task_id = item.task_id.as_deref().unwrap_or("-"); + let task_id = item["task_id"].as_str().unwrap_or("-"); println!( "{}\t{}\t{}\t{}\t{}\t{}", - item.status, - item.provider, - item.session_id, + item["status"].as_str().unwrap_or("-"), + item["provider"].as_str().unwrap_or("-"), + item["session_id"].as_str().unwrap_or("-"), task_id, - item.message_id, - item.evidence + item["message_id"].as_str().unwrap_or("-"), + item["evidence"].as_str().unwrap_or("") ); } } @@ -210,11 +145,6 @@ pub(crate) async fn handle_sessions_action( /// Default lower bound for `git-backfill`: 90 days before now. const GIT_BACKFILL_DEFAULT_WINDOW_SECS: i64 = 90 * 24 * 60 * 60; -/// Cap on analytics rows loaded for `git-backfill`. The query is already scoped -/// to one project within the backfill window; this bounds the worst case rather -/// than materializing every event ever recorded. -const GIT_BACKFILL_ANALYTICS_LIMIT: usize = 500_000; - async fn run_git_backfill( project_id: Option, project_path: Option, @@ -222,70 +152,32 @@ async fn run_git_backfill( limit_sessions: usize, dry_run: bool, ) -> tracedecay::errors::Result<()> { - use tracedecay::sessions::git_correlation::{ - BackfillOptions, DEFAULT_SPAN_MERGE_GAP_SECS, SystemGit, run_backfill, - }; - let project_root = resolve_cli_project_root(None, project_id, project_path).await?; let since_ts = resolve_backfill_since(since.as_deref())?; - - let session_db = tracedecay::sessions::cursor::open_project_session_db(&project_root) - .await - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "could not open project session database for {}", - project_root.display() - ), - })?; - - // Global analytics rows are a finer-grained timestamp signal. Missing or - // unreadable analytics is non-fatal: the backfill still runs on session - // timestamps and the reflog. Scope the query to this project and the - // backfill window: only rows for the target project within [since_ts, now] - // are ever consumed, so pulling every event from every project (these - // stores reach tens of GB) would be pure waste. - let analytics_events = match tracedecay::global_db::GlobalDb::open().await { - Some(global) => global - .query_analytics_events(&tracedecay::global_db::AnalyticsEventQuery { - project_id: Some(tracedecay::global_db::GlobalDb::canonical_project_key( - &project_root, - )), - since: Some(since_ts), - limit: GIT_BACKFILL_ANALYTICS_LIMIT, - ..Default::default() - }) - .await - .unwrap_or_default(), - None => Vec::new(), - }; - - let opts = BackfillOptions { - since: since_ts, - limit_sessions, - merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, - max_commits_per_repo: 5_000, - dry_run, - }; - - let git = SystemGit; - let stats = run_backfill(&session_db, &analytics_events, &git, &opts) - .await - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: format!("git backfill failed: {err}"), - })?; + let stats = call_daemon_tool( + &project_root, + "tracedecay_admin_cli", + json!({ + "action": "sessions_git_backfill", + "since": since_ts, + "limit_sessions": limit_sessions, + "dry_run": dry_run, + }), + ) + .await?; if dry_run { println!("git-backfill (dry-run): no rows written"); } - println!("sessions scanned: {}", stats.sessions_scanned); - println!("spans written: {}", stats.spans_written); - println!("commits attributed: {}", stats.commits_attributed); + println!("sessions scanned: {}", stats["sessions_scanned"]); + println!("spans written: {}", stats["spans_written"]); + println!("commits attributed: {}", stats["commits_attributed"]); println!( "skipped: {} (no-window {}, not-worktree {}, git-error {})", - stats.skipped_total(), - stats.skipped_no_window, - stats.skipped_not_worktree, - stats.skipped_git_error + stats["skipped_total"], + stats["skipped_no_window"], + stats["skipped_not_worktree"], + stats["skipped_git_error"] ); Ok(()) } @@ -317,32 +209,24 @@ fn resolve_backfill_since(since: Option<&str>) -> tracedecay::errors::Result tracedecay::sessions::source::TranscriptIngestStats { - tracedecay::sessions::ingest_global_sources(db, project_root).await -} - -fn session_provider_scope(provider: Option<&str>) -> tracedecay::errors::Result { - ProviderScope::parse_optional(provider) - .map_err(|message| tracedecay::errors::TraceDecayError::Config { message }) -} - -fn parse_time_filter_arg( - name: &str, - value: Option<&str>, - now: i64, - bound: SearchTimeBound, -) -> tracedecay::errors::Result> { - let Some(value) = value else { - return Ok(None); - }; - tracedecay::timeutil::parse_search_time_filter_bound(value, now, bound) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "{name} must be a non-negative Unix timestamp, timezone-aware ISO/RFC3339 string, YYYY-MM-DD date, or relative time like 'last hour'" - ), - }) - .map(Some) + tool_name: &str, + arguments: Value, +) -> tracedecay::errors::Result { + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_root.to_path_buf()), + None, + false, + false, + )?; + let result = tracedecay::daemon::call_default_tool(&handshake, tool_name, arguments).await?; + let text = result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::(); + serde_json::from_str(&text).map_err(Into::into) } diff --git a/src/sqlite_read_snapshot.rs b/src/sqlite_read_snapshot.rs index 05ab3ad6f..be7214dbc 100644 --- a/src/sqlite_read_snapshot.rs +++ b/src/sqlite_read_snapshot.rs @@ -22,6 +22,7 @@ pub(crate) struct SnapshotDatabase { source_state: Vec, path: PathBuf, _scratch: Option>, + _authority: crate::db::DatabaseAuthority, #[cfg(test)] copied_bytes: u64, } @@ -153,6 +154,7 @@ struct PreparedSnapshot { target: PathBuf, mode: SnapshotMode, copy_bytes: u64, + authority: crate::db::DatabaseAuthority, } #[derive(Clone, Copy)] @@ -217,6 +219,11 @@ pub(crate) async fn open_in(path: &Path, root: &Path) -> io::Result io::Result { use std::io::Read; + let _authority = crate::db::DatabaseAuthority::for_runtime( + path, + "fingerprint SQLite family for offline maintenance", + ) + .map_err(io::Error::other)?; let before = family_state(path)?; let mut hash = Sha256::new(); for (label, member) in [ @@ -255,6 +262,11 @@ fn prepare_one( scratch: &ScratchDirectory, index: usize, ) -> io::Result { + let authority = crate::db::DatabaseAuthority::for_runtime( + source, + "capture SQLite family for offline maintenance", + ) + .map_err(io::Error::other)?; let directory = scratch.path.join(index.to_string()); create_private_directory(&directory)?; let target = directory.join("database.db"); @@ -306,6 +318,7 @@ fn prepare_one( target, mode, copy_bytes, + authority, }) } @@ -376,6 +389,7 @@ async fn finish_one( source_state: prepared.source_state, path: open_path, _scratch: scratch, + _authority: prepared.authority, #[cfg(test)] copied_bytes: prepared.copy_bytes, }; diff --git a/src/status_cmd.rs b/src/status_cmd.rs index ee9986278..43aaf0c15 100644 --- a/src/status_cmd.rs +++ b/src/status_cmd.rs @@ -1,24 +1,5 @@ -use std::io::{self, BufRead, IsTerminal, Write}; -use std::path::Path; - -use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; - use crate::{commands, current_unix_timestamp, global, resolve_cli_project_root}; -pub(crate) async fn largest_memory_bank_fact_count_at( - db_path: &Path, -) -> tracedecay::errors::Result { - let db = libsql::Builder::new_local(db_path).build().await?; - let conn = db.connect()?; - let mut rows = conn - .query("SELECT COALESCE(MAX(fact_count), 0) FROM memory_banks", ()) - .await?; - let Some(row) = rows.next().await? else { - return Ok(0); - }; - Ok(row.get::(0).unwrap_or(0).max(0) as usize) -} - pub(crate) fn format_memory_status_report( status: &tracedecay::memory::types::MemoryStatus, largest_bank_facts: usize, @@ -90,72 +71,51 @@ pub(crate) async fn handle_status_command( runtime: bool, ) -> tracedecay::errors::Result<()> { let project_path = resolve_cli_project_root(path, project_id, project_path).await?; - let initialized = TraceDecay::try_initialized_store_layout_with_options( - &project_path, - &TraceDecayOpenOptions::default(), - ) - .await? - .is_some(); - let cg = if initialized { - match TraceDecay::open(&project_path).await { - Ok(cg) => cg, - Err(_) => TraceDecay::open_read_only(&project_path).await?, - } - } else if !io::stdin().is_terminal() { - eprintln!( - "No TraceDecay index found at '{}'. Non-interactive: skipping index creation (run `tracedecay init`).", - project_path.display() - ); - return Ok(()); - } else { - eprint!( - "No TraceDecay index found at '{}'. Create one now? [Y/n] ", - project_path.display() - ); - io::stderr().flush().ok(); - let mut answer = String::new(); - io::stdin().lock().read_line(&mut answer).map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read stdin: {e}"), - } - })?; - let answer = answer.trim(); - if answer.is_empty() || answer.eq_ignore_ascii_case("y") { - commands::init_and_index(&project_path, &[], &[], false).await? - } else { - return Ok(()); - } - }; if runtime { - let snap = tracedecay::runtime_telemetry::collect(&cg).await?; - if json { - println!("{}", tracedecay::runtime_telemetry::to_pretty_json(&snap)); - } else { - print!("{}", tracedecay::runtime_telemetry::to_text_report(&snap)); - } + let result = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "runtime_status", "json": json }), + ) + .await?; + let output = result + .get("output") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon runtime status response omitted output".to_string(), + })?; + print!("{output}"); return Ok(()); } - let stats = cg.get_stats().await?; + let daemon_status = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_status", + serde_json::json!({ "format": "json" }), + ) + .await?; if json { println!( "{}", - serde_json::to_string_pretty(&stats).unwrap_or_default() + serde_json::to_string_pretty(&daemon_status).unwrap_or_default() ); return Ok(()); } - - let tokens_saved = cg.get_tokens_saved().await.unwrap_or(0); - let gdb = tracedecay::global_db::GlobalDb::open().await; - let global_tokens_saved = match &gdb { - Some(db) => { - db.upsert(&project_path, tokens_saved).await; - db.global_tokens_saved() - .await - .map(|total| total.saturating_sub(tokens_saved)) - .filter(|&other| other > 0) - } - None => None, - }; + let stats: tracedecay::types::GraphStats = serde_json::from_value(daemon_status.clone())?; + let accounting = commands::daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ "action": "status_accounting" }), + ) + .await?; + let tokens_saved = accounting + .get("tokens_saved") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon status accounting omitted token count".to_string(), + })?; + let global_tokens_saved = accounting + .get("global_tokens_saved") + .and_then(serde_json::Value::as_u64); let mut config = tracedecay::user_config::UserConfig::load(); let now = current_unix_timestamp(); let worldwide = if !config.upload_enabled { @@ -194,36 +154,21 @@ pub(crate) async fn handle_status_command( if !short { print!("{}", include_str!("resources/logo.ansi")); } - let branch_info = cg.active_branch().map(|_| { - let ts_dir = tracedecay::config::get_tracedecay_dir(&project_path); - let meta = tracedecay::branch_meta::load_branch_meta(&ts_dir); - let has_tracking = meta.as_ref().is_some_and(|m| !m.branches.is_empty()); - let display_branch = if has_tracking { - cg.serving_branch().unwrap_or("[single-db]").to_string() - } else { - "[single-db]".to_string() - }; - let parent = meta.and_then(|m| m.branches.get(cg.serving_branch()?)?.parent.clone()); - tracedecay::display::BranchInfo { - branch: display_branch, - parent, - is_fallback: cg.is_fallback(), - } - }); - if let Some(ref db) = gdb { - tracedecay::accounting::parser::ingest(db).await; - } - let cost_info = match &gdb { - Some(db) => { - tracedecay::accounting::quick_cost_summary( - db, - tokens_saved, - global_tokens_saved.unwrap_or(0), - ) - .await - } - None => None, - }; + let branch_info = daemon_status + .get("serving_branch") + .and_then(serde_json::Value::as_str) + .map(|branch| tracedecay::display::BranchInfo { + branch: branch.to_string(), + parent: daemon_status + .get("parent_branch") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + is_fallback: daemon_status + .get("branch_fallback") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + }); + let cost_info = None; if short { tracedecay::display::print_status_header( &stats, diff --git a/src/tool_command.rs b/src/tool_command.rs index bfb5fa275..b7dc630d7 100644 --- a/src/tool_command.rs +++ b/src/tool_command.rs @@ -12,7 +12,7 @@ //! human-readable text inside `content[0].text`. //! - `--dry-run` — parse and validate the arguments, print the resolved //! arguments object as pretty JSON, and exit without dispatching the tool. -//! - `--project ` — project root to open. Defaults to the nearest +//! - `--project ` — project root to target. Defaults to the nearest //! initialised project walking up from cwd (falling back to cwd). We use //! `--project` (not `-p`) because several MCP tools have a `path` argument //! that filters files within the project. @@ -36,9 +36,7 @@ use std::path::PathBuf; use serde_json::Value; -use tracedecay::daemon::DaemonHandshake; -#[cfg(unix)] -use tracedecay::daemon::call_default_tool; +use tracedecay::daemon::{DaemonHandshake, call_default_tool}; use tracedecay::errors::{Result, TraceDecayError}; use tracedecay::mcp::tools::{ RESERVED_FLAGS_FOOTER, ToolDefinition, get_tool_definitions, render_tool_cli_help, @@ -118,30 +116,6 @@ pub(crate) async fn run( return Ok(()); } - if user_memory_dispatch(&def.name, &tool_args) { - let handshake = DaemonHandshake::for_current_client(None, None, false, false)?; - let result = tracedecay::mcp::tools::handle_user_memory_tool( - &def.name, - tool_args, - &handshake.client_identity.profile_root, - ) - .await?; - print_tool_output(&result.value, raw_json); - return Ok(()); - } - - if user_lcm_dispatch(&def.name, &tool_args) { - let handshake = DaemonHandshake::for_current_client(None, None, false, false)?; - let result = tracedecay::mcp::tools::handle_user_lcm_tool( - &def.name, - tool_args, - &handshake.client_identity.profile_root, - ) - .await?; - print_tool_output(&result.value, raw_json); - return Ok(()); - } - let explicit_project = project.or(parsed_project); dispatch_daemon_tool( DaemonToolDispatch::project_scoped(explicit_project, &def.name), @@ -152,18 +126,6 @@ pub(crate) async fn run( .await } -fn user_memory_dispatch(tool_name: &str, args: &Value) -> bool { - matches!( - tool_name, - "tracedecay_fact_store" | "tracedecay_fact_feedback" | "tracedecay_memory_status" - ) && args.get("memory_scope").and_then(Value::as_str) == Some("user") -} - -fn user_lcm_dispatch(tool_name: &str, args: &Value) -> bool { - (tool_name.starts_with("tracedecay_lcm_") || tool_name == "tracedecay_message_search") - && args.get("storage_scope").and_then(Value::as_str) == Some("user") -} - struct DaemonToolDispatch { project_path: Option, allow_init: bool, @@ -190,100 +152,21 @@ impl DaemonToolDispatch { async fn call(&self, tool_name: &str, tool_args: Value) -> Result { let handshake = self.handshake()?; - #[cfg(unix)] - { - call_default_tool(&handshake, tool_name, tool_args).await - } - #[cfg(not(unix))] - { - call_in_process_tool(&handshake, tool_name, tool_args).await - } - } - - async fn fallback(&self, tool_name: &str, tool_args: Value) -> Result> { - let handshake = self.handshake()?; - if handshake.project_path.is_none() { - return Ok(None); - } - Ok(Some( - call_in_process_tool(&handshake, tool_name, tool_args).await?, - )) + call_default_tool(&handshake, tool_name, tool_args).await } } -async fn call_in_process_tool( - handshake: &DaemonHandshake, - tool_name: &str, - tool_args: Value, -) -> Result { - let project_path = handshake - .project_path - .as_ref() - .ok_or_else(|| TraceDecayError::Config { - message: "tool dispatch requires an initialized project".to_string(), - })?; - let open_options = tracedecay::tracedecay::TraceDecayOpenOptions { - profile_root: Some(handshake.client_identity.profile_root.clone()), - global_db_path: Some(handshake.client_identity.global_db_path.clone()), - }; - let cg = if handshake.allow_init - && !tracedecay::tracedecay::TraceDecay::has_initialized_store_with_options( - project_path, - &open_options, - ) - .await - { - let cg = tracedecay::tracedecay::TraceDecay::init_with_options(project_path, open_options) - .await?; - cg.index_all().await?; - cg - } else { - tracedecay::tracedecay::TraceDecay::open_with_options(project_path, open_options).await? - }; - let global_db = - tracedecay::global_db::GlobalDb::open_at(&handshake.client_identity.global_db_path).await; - let result = tracedecay::mcp::tools::handle_tool_call_with_registry( - &cg, - tool_name, - tool_args, - None, - handshake.scope_prefix.as_deref(), - global_db.as_ref(), - false, - ) - .await?; - Ok(result.value) -} - async fn dispatch_daemon_tool( dispatch: DaemonToolDispatch, tool_name: &str, tool_args: Value, raw_json: bool, ) -> Result<()> { - let result_value = match dispatch.call(tool_name, tool_args.clone()).await { - Ok(value) => value, - Err(error) if is_daemon_unavailable(&error) => { - match dispatch.fallback(tool_name, tool_args).await? { - Some(value) => value, - None => return Err(error), - } - } - Err(error) => return Err(error), - }; + let result_value = dispatch.call(tool_name, tool_args).await?; print_tool_output(&result_value, raw_json); Ok(()) } -fn is_daemon_unavailable(error: &TraceDecayError) -> bool { - matches!( - error, - TraceDecayError::Config { message } - if message.contains("TraceDecay daemon socket") - && message.contains("is not available") - ) -} - fn print_tool_output(result_value: &Value, raw_json: bool) { if raw_json { println!( diff --git a/src/tracedecay/diagnostics.rs b/src/tracedecay/diagnostics.rs index 4ba9622f6..1c2885cf2 100644 --- a/src/tracedecay/diagnostics.rs +++ b/src/tracedecay/diagnostics.rs @@ -64,6 +64,10 @@ impl TraceDecay { self.db.conn().clone() } + pub(crate) fn dashboard_database_guard(&self) -> std::sync::Arc { + std::sync::Arc::new(self.db.clone()) + } + /// Filesystem path of the project's tracedecay directory, for display in /// dashboard payloads (mirrors the `path` field of the Hermes plugin API). pub(crate) fn dashboard_db_path(&self) -> std::path::PathBuf { @@ -170,12 +174,21 @@ impl TraceDecay { .to_string(), }); } - let (db, _) = Database::open(&self.store_layout.graph_db_path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime( + &self.store_layout.graph_db_path, + "open diagnostics project store", + )?; + let (db, _) = Database::open(&self.store_layout.graph_db_path, &authority).await?; Ok(db) } pub async fn open_project_store_db_read_only(&self) -> Result { - let (db, _) = Database::open_read_only(&self.store_layout.graph_db_path).await?; + let authority = crate::db::DatabaseAuthority::for_runtime( + &self.store_layout.graph_db_path, + "open diagnostics project store read-only", + )?; + let (db, _) = + Database::open_read_only(&self.store_layout.graph_db_path, &authority).await?; Ok(db) } diff --git a/src/tracedecay/indexing.rs b/src/tracedecay/indexing.rs index 8d46dcfc4..498277d33 100644 --- a/src/tracedecay/indexing.rs +++ b/src/tracedecay/indexing.rs @@ -237,8 +237,7 @@ impl TraceDecay { "project root is not a directory" ); self.ensure_branch_writable("full index")?; - let _lock = self.try_acquire_active_sync_lock()?; - self.write_active_dirty_sentinels(); + let sync_lease = self.begin_active_sync()?; let start = Instant::now(); // 1. Clear existing data and enter bulk-load mode @@ -378,7 +377,7 @@ impl TraceDecay { "non-empty index completed in zero milliseconds" ); self.db.checkpoint().await?; - self.clear_active_dirty_sentinels(); + sync_lease.commit()?; Ok(result) } @@ -419,16 +418,15 @@ impl TraceDecay { self.ensure_branch_writable("sync files")?; - let Ok(lock) = self.try_acquire_active_sync_lock() else { + let Ok(sync_lease) = self.begin_active_sync() else { return Ok(true); }; - self.write_active_dirty_sentinels(); let result = self.sync_single_files(&stale_files).await; - drop(lock); match result { Ok(()) => { + sync_lease.commit()?; let still_stale_after = self.check_file_staleness(&stale_files).await; Ok(!still_stale_after.is_empty()) } @@ -457,8 +455,8 @@ impl TraceDecay { self.ensure_branch_writable("sync files")?; - let lock = if let Ok(lock) = self.try_acquire_active_sync_lock() { - lock + let sync_lease = if let Ok(sync_lease) = self.begin_active_sync() { + sync_lease } else { // Peer is syncing. Wait for them to release the lock so the // caller (e.g. the embedded watcher's refresh hook) sees the @@ -472,22 +470,22 @@ impl TraceDecay { return Ok(()); } tokio::time::sleep(Duration::from_millis(50)).await; - if let Ok(lock) = self.try_acquire_active_sync_lock() { + if let Ok(sync_lease) = self.begin_active_sync() { // Peer released. If they covered our files, the DB is // fresh and we're done; otherwise sync ourselves. let still_stale = self.check_file_staleness(&stale_files).await; if still_stale.is_empty() { - drop(lock); + sync_lease.commit()?; return Ok(()); } - break lock; + break sync_lease; } } }; - self.write_active_dirty_sentinels(); - let _ = self.sync_single_files(&stale_files).await; - drop(lock); + if self.sync_single_files(&stale_files).await.is_ok() { + sync_lease.commit()?; + } Ok(()) } @@ -597,7 +595,6 @@ impl TraceDecay { .await?; self.db.checkpoint().await?; - self.clear_active_dirty_sentinels(); Ok(()) } @@ -633,7 +630,9 @@ impl TraceDecay { } if !accepted.is_empty() { + let sync_lease = self.begin_active_sync()?; self.sync_single_files(&accepted).await?; + sync_lease.commit()?; } Ok(accepted) } @@ -781,8 +780,7 @@ impl TraceDecay { "sync: project root is not a directory" ); self.ensure_branch_writable("sync")?; - let _lock = self.try_acquire_active_sync_lock()?; - self.write_active_dirty_sentinels(); + let sync_lease = self.begin_active_sync()?; let start = Instant::now(); on_progress(0, 0, "scanning files"); @@ -1047,7 +1045,7 @@ impl TraceDecay { .await?; self.db.checkpoint().await?; - self.clear_active_dirty_sentinels(); + sync_lease.commit()?; Ok(SyncResult { files_added: new_files.len(), files_modified: stale.len(), diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index d4377930e..b3ef6620e 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -7,14 +7,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::branch; use crate::branch_meta::{self, BranchMeta}; use crate::config::{TraceDecayConfig, db_filename, load_config_from_path, save_config_to_path}; -use crate::db::Database; +use crate::db::{Database, DatabaseAuthority}; use crate::errors::{Result, TraceDecayError}; use crate::extraction::LanguageRegistry; use crate::global_db::{GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; use crate::storage::{self, StoreLayout}; use super::locking::{ - clear_dirty_sentinel_at, has_dirty_sentinel_at, try_acquire_graph_sync_locks, + clear_dirty_sentinel_at, has_dirty_sentinel_at, seed_legacy_sync_owner_at, + try_acquire_graph_sync_locks, }; use super::{TraceDecay, TraceDecayOpenOptions, current_timestamp}; @@ -33,13 +34,15 @@ impl TraceDecay { ) -> Result { let store_layout = Self::resolve_store_layout_for_project(project_root, &open_options).await?; + let authority = DatabaseAuthority::for_runtime(&store_layout.graph_db_path, "init")?; + seed_legacy_sync_owner_at(&store_layout.sync_lock_path)?; let config = TraceDecayConfig { root_dir: project_root.to_string_lossy().to_string(), ..TraceDecayConfig::default() }; save_config_to_path(&store_layout.config_path, &config)?; - let (db, _migrated) = Database::initialize(&store_layout.graph_db_path).await?; + let (db, _migrated) = Database::initialize(&store_layout.graph_db_path, &authority).await?; let active_graph_layout = active_graph_layout(&store_layout.graph_db_path); if store_layout.storage_mode == storage::StorageMode::ProfileSharded { storage::write_store_manifest(&store_layout)?; @@ -120,7 +123,8 @@ impl TraceDecay { "tracedecay-current-schema-{}-{stamp}.db", std::process::id() )); - let (db, _) = Database::initialize(&db_path).await?; + let authority = DatabaseAuthority::acquire_test(&db_path, "latest schema version")?; + let (db, _) = Database::initialize(&db_path, &authority).await?; let version = Self::schema_version(&db, "latest_schema_version").await; db.close(); delete_db_files(&db_path); @@ -299,6 +303,10 @@ impl TraceDecay { ) -> Result { let store_layout = Self::resolve_store_layout_for_project(project_root, &open_options).await?; + let seed_authority = + DatabaseAuthority::for_runtime(&store_layout.graph_db_path, "open project store")?; + seed_legacy_sync_owner_at(&store_layout.sync_lock_path)?; + drop(seed_authority); let config = load_config_from_path(project_root, &store_layout.config_path)?; let active_branch = branch::current_branch(project_root); Self::auto_track_active_branch( @@ -353,7 +361,8 @@ impl TraceDecay { None }; if crashed { - let verification = match Database::open_read_only(&db_path).await { + let authority = DatabaseAuthority::for_runtime(&db_path, "crash verification")?; + let verification = match Database::open_read_only(&db_path, &authority).await { Ok((db, _)) => db, Err(error) => { print_corruption_warning(&db_path); @@ -381,7 +390,8 @@ impl TraceDecay { // Ordinary opens never replace database files. A daemon or another MCP // process may still hold the current DB/WAL/SHM inodes, and deleting // them here would split readers and writers across different stores. - let open_result = Database::open(&db_path).await; + let authority = DatabaseAuthority::for_runtime(&db_path, "open project store")?; + let open_result = Database::open(&db_path, &authority).await; let (db, migrated) = match open_result { Ok(pair) => pair, Err(e) if Database::is_corruption_error(&e) || crashed => { @@ -475,7 +485,8 @@ impl TraceDecay { }); } - let (db, _) = Database::open_read_only(&db_path).await?; + let authority = DatabaseAuthority::for_runtime(&db_path, "open project store read-only")?; + let (db, _) = Database::open_read_only(&db_path, &authority).await?; Ok(Self { db, config, @@ -546,6 +557,10 @@ impl TraceDecay { return Ok(branch::BranchAddOutcome::NotIndexed); } + // Branch preparation copies a live SQLite store and rewrites metadata; + // reject non-daemon callers before either filesystem mutation occurs. + let _authority = + DatabaseAuthority::for_runtime(&store_layout.graph_db_path, "add branch tracking")?; Self::add_branch_tracking_in_layout( project_root, branch_name, @@ -709,7 +724,8 @@ impl TraceDecay { }); } - let (db, _) = Database::open(&db_path).await?; + let authority = DatabaseAuthority::for_runtime(&db_path, "open branch store")?; + let (db, _) = Database::open(&db_path, &authority).await?; Ok(Self { db, config, @@ -732,7 +748,7 @@ impl TraceDecay { Some(meta.branches.keys().cloned().collect()) } - async fn register_project_store_in_global_registry(&self) { + pub(crate) async fn register_project_store_in_global_registry(&self) { static REGISTRY_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); if self.store_layout.storage_mode != storage::StorageMode::ProfileSharded { @@ -1085,21 +1101,25 @@ impl std::fmt::Display for StoreIdentityInventory { } async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventory { - let (graph_health, nodes, files, facts) = - match Database::open_read_only(&layout.graph_db_path).await { - Ok((db, _)) => { - if let Ok(stats) = db.get_stats().await { - let facts = count_rows(db.conn(), "memory_facts").await; - db.close(); - ("healthy", stats.node_count, stats.file_count, facts) - } else { - db.close(); - ("corrupt", 0, 0, 0) - } + let authority = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store inventory"); + let open_result = match authority { + Ok(authority) => Database::open_read_only(&layout.graph_db_path, &authority).await, + Err(error) => Err(error), + }; + let (graph_health, nodes, files, facts) = match open_result { + Ok((db, _)) => { + if let Ok(stats) = db.get_stats().await { + let facts = count_rows(db.conn(), "memory_facts").await; + db.close(); + ("healthy", stats.node_count, stats.file_count, facts) + } else { + db.close(); + ("corrupt", 0, 0, 0) } - Err(_) if layout.graph_db_path.exists() => ("corrupt", 0, 0, 0), - Err(_) => ("missing", 0, 0, 0), - }; + } + Err(_) if layout.graph_db_path.exists() => ("corrupt", 0, 0, 0), + Err(_) => ("missing", 0, 0, 0), + }; let (sessions, messages, lcm_rows) = if let Some(db) = crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await diff --git a/src/tracedecay/locking.rs b/src/tracedecay/locking.rs index 77f4bd6d6..c10ccdb73 100644 --- a/src/tracedecay/locking.rs +++ b/src/tracedecay/locking.rs @@ -1,50 +1,143 @@ //! Dirty sentinel and sync-lock primitives guarding concurrent or //! interrupted sync/index operations. +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; + use crate::errors::{Result, TraceDecayError}; use crate::storage; use super::current_timestamp; -/// Creates the active store's dirty sentinel before a sync or index begins. -/// -/// This file is intentionally NOT cleaned up by a Drop guard — it must be -/// removed explicitly by `clear_dirty_sentinel` after the operation succeeds. -/// If the process is killed (SIGKILL, OOM), the sentinel survives and signals -/// a potential crash on the next open. -pub(super) fn write_dirty_sentinel_at(path: &Path) { - let _ = std::fs::write( - path, - format!( - "pid={}\ntime={}\nversion={}", - std::process::id(), - current_timestamp(), - env!("CARGO_PKG_VERSION"), - ), - ); +const MARKER_SCHEMA: u8 = 2; +static EPOCH_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct MarkerOwner { + pid: u32, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum MarkerState { + Dirty, + Clean, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct DirtyMarker { + schema: u8, + owner: MarkerOwner, + epoch: String, + state: MarkerState, + time: i64, + version: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum MarkerIdentity { + Epoch(String), + Legacy(Vec), +} + +#[derive(Debug, Serialize)] +struct LockLease<'a> { + schema: u8, + owner: MarkerOwner, + epoch: &'a str, + state: &'static str, + time: i64, + version: &'static str, +} + +fn write_dirty_sentinel_for_epoch(path: &Path, epoch: &str) -> std::io::Result<()> { + let marker = DirtyMarker { + schema: MARKER_SCHEMA, + owner: MarkerOwner { + pid: std::process::id(), + }, + epoch: epoch.to_string(), + state: MarkerState::Dirty, + time: current_timestamp(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + let contents = serde_json::to_vec(&marker).map_err(std::io::Error::other)?; + publish_marker(path, &contents) } /// Removes the dirty sentinel after a successful sync/index. +/// +/// A clear is authorized only for the exact marker observed while holding its +/// sync lease (or written by this process). This prevents a delayed cleanup +/// from deleting a newer writer's marker after an epoch change. pub(super) fn clear_dirty_sentinel_at(path: &Path) { - let _ = std::fs::remove_file(path); + let contents = match std::fs::read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(_) => return, + }; + let _ = clear_marker_if_matches(path, &marker_identity(&contents)); +} + +fn clear_marker_if_matches(path: &Path, expected: &MarkerIdentity) -> std::io::Result<()> { + let contents = match std::fs::read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if &marker_identity(&contents) != expected { + return Err(std::io::Error::other(format!( + "dirty marker epoch changed before commit: {}", + path.display() + ))); + } + + if let Ok(mut marker) = serde_json::from_slice::(&contents) { + if marker.schema == MARKER_SCHEMA { + marker.state = MarkerState::Clean; + let clean = serde_json::to_vec(&marker).map_err(std::io::Error::other)?; + publish_marker(path, &clean)?; + } + } + + match std::fs::remove_file(path) { + Ok(()) => { + sync_parent_directory(path); + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } } /// Returns `true` if the dirty sentinel exists (previous operation was -/// interrupted). +/// interrupted). Legacy unstructured markers remain dirty by definition. pub(super) fn has_dirty_sentinel_at(path: &Path) -> bool { - path.exists() + match std::fs::read(path) { + Ok(contents) => serde_json::from_slice::(&contents) + .map(|marker| marker.schema != MARKER_SCHEMA || marker.state == MarkerState::Dirty) + .unwrap_or(true), + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } } -/// RAII guard that holds the sync lockfile open. Removing the lockfile on drop -/// is best-effort; if it fails (e.g. permissions), the stale-PID check on the -/// next attempt will reclaim it. +/// RAII guard that keeps a persistent lockfile kernel-locked. The directory +/// entry is never removed, so a delayed Drop cannot unlink a newer owner's +/// lock. Closing the file releases the lease even after a crash. /// /// Internal: exposed for integration tests; not part of the stable public API. #[doc(hidden)] pub struct SyncLockGuard { - path: PathBuf, + file: File, + owner_path: PathBuf, + epoch: String, } pub(super) struct ActiveSyncLockGuard { @@ -52,6 +145,11 @@ pub(super) struct ActiveSyncLockGuard { _legacy: Option, } +pub(super) struct ActiveSyncLease { + _locks: ActiveSyncLockGuard, + dirty_markers: Vec<(PathBuf, MarkerIdentity)>, +} + impl super::TraceDecay { pub(super) fn try_acquire_active_sync_lock(&self) -> Result { try_acquire_graph_sync_locks( @@ -60,18 +158,58 @@ impl super::TraceDecay { ) } - pub(super) fn write_active_dirty_sentinels(&self) { - write_dirty_sentinel_at(&self.active_graph_layout.dirty_path); + pub(super) fn begin_active_sync(&self) -> Result { + let locks = self.try_acquire_active_sync_lock()?; + let epoch = next_epoch(); + let mut paths = vec![self.active_graph_layout.dirty_path.clone()]; if self.active_graph_layout.dirty_path != self.store_layout.dirty_path { - write_dirty_sentinel_at(&self.store_layout.dirty_path); + paths.push(self.store_layout.dirty_path.clone()); } + for path in &paths { + write_dirty_sentinel_for_epoch(path, &epoch).map_err(|error| { + TraceDecayError::SyncLock { + message: format!( + "could not publish dirty marker '{}': {error}", + path.display() + ), + } + })?; + } + Ok(ActiveSyncLease { + _locks: locks, + dirty_markers: paths + .into_iter() + .map(|path| (path, MarkerIdentity::Epoch(epoch.clone()))) + .collect(), + }) } +} - pub(super) fn clear_active_dirty_sentinels(&self) { - clear_dirty_sentinel_at(&self.active_graph_layout.dirty_path); - if self.active_graph_layout.dirty_path != self.store_layout.dirty_path { - clear_dirty_sentinel_at(&self.store_layout.dirty_path); +impl ActiveSyncLease { + /// Marks the operation clean while both active and legacy locks remain + /// held. Drop without commit intentionally leaves every dirty marker. + pub(super) fn commit(self) -> Result<()> { + // Validate every marker before mutating any of them. A changed epoch + // fails closed and leaves recovery evidence in place. + for (path, expected) in &self.dirty_markers { + let contents = std::fs::read(path).map_err(|error| TraceDecayError::SyncLock { + message: format!("could not read dirty marker '{}': {error}", path.display()), + })?; + if &marker_identity(&contents) != expected { + return Err(TraceDecayError::SyncLock { + message: format!( + "dirty marker epoch changed before commit: {}", + path.display() + ), + }); + } + } + for (path, expected) in &self.dirty_markers { + clear_marker_if_matches(path, expected).map_err(|error| TraceDecayError::SyncLock { + message: format!("could not clear dirty marker '{}': {error}", path.display()), + })?; } + Ok(()) } } @@ -79,30 +217,44 @@ pub(super) fn try_acquire_graph_sync_locks( active_path: &Path, legacy_path: &Path, ) -> Result { - let active = try_acquire_sync_lock_at(active_path)?; - let legacy = if active_path == legacy_path { - None + if active_path == legacy_path { + return Ok(ActiveSyncLockGuard { + _active: try_acquire_sync_lock_at(active_path)?, + _legacy: None, + }); + } + + // Every caller uses the same total order. This prevents active/legacy + // lock inversion when different store layouts overlap during migration. + if active_path < legacy_path { + let active = try_acquire_sync_lock_at(active_path)?; + let legacy = try_acquire_sync_lock_at(legacy_path)?; + Ok(ActiveSyncLockGuard { + _active: active, + _legacy: Some(legacy), + }) } else { - Some(try_acquire_sync_lock_at(legacy_path)?) - }; - Ok(ActiveSyncLockGuard { - _active: active, - _legacy: legacy, - }) + let legacy = try_acquire_sync_lock_at(legacy_path)?; + let active = try_acquire_sync_lock_at(active_path)?; + Ok(ActiveSyncLockGuard { + _active: active, + _legacy: Some(legacy), + }) + } } impl Drop for SyncLockGuard { fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); + clear_lock_owner_if_matches(&self.owner_path, &self.epoch); + let _ = FileExt::unlock(&self.file); } } /// Try to acquire the sync lock for `project_root`'s resolved store. /// -/// Creates the store's `sync.lock` containing the current PID. If the file -/// already exists and the PID inside is still alive, returns a `SyncLock` -/// error. Stale lockfiles (dead PID or unreadable content) are reclaimed -/// automatically. +/// The store's persistent `sync.lock` is held with an exclusive kernel lease. +/// Its metadata is diagnostic only; lock ownership never depends on PID +/// liveness or removing/recreating a directory entry. /// /// Internal: exposed for integration tests; not part of the stable public API. #[doc(hidden)] @@ -112,98 +264,180 @@ pub fn try_acquire_sync_lock(project_root: &Path) -> Result { } pub(super) fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { - use std::io::Write; - let pid = std::process::id(); - - // Fast path: try atomic create. - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(0o600); + let mut file = options .open(lock_path) - { - Ok(mut f) => { - let _ = write!(f, "{pid}"); - return Ok(SyncLockGuard { - path: lock_path.to_path_buf(), - }); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - // Fall through to stale-check below. - } - Err(e) => { + .map_err(|error| TraceDecayError::SyncLock { + message: format!("could not open lockfile: {error}"), + })?; + + file.try_lock_exclusive() + .map_err(|error| TraceDecayError::SyncLock { + message: if error.kind() == std::io::ErrorKind::WouldBlock { + "another sync is already in progress".to_string() + } else { + format!("could not lock sync lockfile: {error}") + }, + })?; + + #[cfg(unix)] + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|error| TraceDecayError::SyncLock { + message: format!("could not restrict lockfile permissions: {error}"), + })?; + + // Interoperate conservatively with an old TraceDecay process that created + // a bare-PID lockfile but does not participate in kernel locking. Dead + // legacy owners are overwritten in place; the path is never unlinked. + let mut previous = String::new(); + let _ = file.read_to_string(&mut previous); + if let Ok(pid) = previous.trim().parse::() { + if pid != std::process::id() && is_pid_alive(pid) { + let _ = FileExt::unlock(&file); return Err(TraceDecayError::SyncLock { - message: format!("could not create lockfile: {e}"), + message: format!("another sync is already in progress (legacy PID {pid})"), }); } } - // Lockfile exists — check if the owning process is still alive. - let contents = std::fs::read_to_string(lock_path).unwrap_or_default(); - if let Ok(existing_pid) = contents.trim().parse::() { - if is_pid_alive(existing_pid) { - return Err(TraceDecayError::SyncLock { - message: format!( - "another sync is already in progress (PID {existing_pid}). \ - If this is stale, remove {}", - lock_path.display() - ), - }); - } + let epoch = next_epoch(); + let pid = std::process::id(); + let pid_contents = pid.to_string(); + file.set_len(0) + .and_then(|()| file.seek(SeekFrom::Start(0)).map(|_| ())) + .and_then(|()| file.write_all(pid_contents.as_bytes())) + .and_then(|()| file.sync_all()) + .map_err(|error| TraceDecayError::SyncLock { + message: format!("could not publish legacy-compatible lock owner: {error}"), + })?; + + let lease = LockLease { + schema: MARKER_SCHEMA, + owner: MarkerOwner { pid }, + epoch: &epoch, + state: "locked", + time: current_timestamp(), + version: env!("CARGO_PKG_VERSION"), + }; + let metadata = serde_json::to_vec(&lease).map_err(|error| TraceDecayError::SyncLock { + message: format!("could not serialize lock lease: {error}"), + })?; + let owner_path = lock_owner_path(lock_path); + publish_marker(&owner_path, &metadata).map_err(|error| TraceDecayError::SyncLock { + message: format!("could not publish lock lease metadata: {error}"), + })?; + + Ok(SyncLockGuard { + file, + owner_path, + epoch, + }) +} + +/// Publishes the current process as the legacy sync owner without retaining an +/// operation lock. Old binaries see the live bare PID and fail closed for this +/// process's lifetime; new binaries still require the kernel lock per sync. +pub(super) fn seed_legacy_sync_owner_at(lock_path: &Path) -> Result<()> { + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| TraceDecayError::SyncLock { + message: format!("could not create sync lock directory: {error}"), + })?; } + drop(try_acquire_sync_lock_at(lock_path)?); + Ok(()) +} - // Stale lock — reclaim it atomically. The previous implementation removed - // the lockfile and then created a new one in two steps; two processes that - // both observed the same dead PID could each `remove_file` the other's - // freshly created lock and both believe they won. Instead, atomically - // rename the stale entry aside: rename(2) moves one specific directory - // entry, so at most one racer can move *this* stale file. The real claim is - // still the O_EXCL create below — the single source of truth for ownership. - let nonce = RECLAIM_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let reclaim_path = lock_path.with_file_name(format!("sync.lock.reclaim.{pid}.{nonce}")); - if std::fs::rename(lock_path, &reclaim_path).is_ok() { - // We won the move. Guard against the race where another process - // replaced the stale lock with a *live* one between our staleness check - // and the rename: if what we moved is a live PID, put it back and - // report contention rather than stealing a valid lock. - let moved = std::fs::read_to_string(&reclaim_path).unwrap_or_default(); - let moved_is_live = moved.trim().parse::().is_ok_and(is_pid_alive); - if moved_is_live { - let _ = std::fs::rename(&reclaim_path, lock_path); - return Err(TraceDecayError::SyncLock { - message: "another sync is already in progress".to_string(), - }); - } - let _ = std::fs::remove_file(&reclaim_path); +fn lock_owner_path(lock_path: &Path) -> PathBuf { + lock_path.with_extension("lock.owner") +} + +fn clear_lock_owner_if_matches(path: &Path, expected_epoch: &str) { + let matches = std::fs::read(path) + .ok() + .and_then(|contents| serde_json::from_slice::(&contents).ok()) + .and_then(|value| { + value + .get("epoch") + .and_then(|epoch| epoch.as_str()) + .map(str::to_owned) + }) + .is_some_and(|epoch| epoch == expected_epoch); + if matches && std::fs::remove_file(path).is_ok() { + sync_parent_directory(path); } +} - // Claim the canonical path via the same atomic O_EXCL create as the fast - // path. If another racer created it first (won the create after we both - // cleared the stale file), report contention instead of clobbering it. - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(lock_path) - { - Ok(mut f) => { - let _ = write!(f, "{pid}"); - Ok(SyncLockGuard { - path: lock_path.to_path_buf(), - }) - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(TraceDecayError::SyncLock { - message: "another sync is already in progress".to_string(), - }), - Err(e) => Err(TraceDecayError::SyncLock { - message: format!("could not reclaim lockfile: {e}"), - }), +fn next_epoch() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let nonce = EPOCH_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!("{}-{now}-{nonce}", std::process::id()) +} + +fn publish_marker(path: &Path, contents: &[u8]) -> std::io::Result<()> { + publish_marker_with_replace(path, contents, replace_marker) +} + +fn publish_marker_with_replace( + path: &Path, + contents: &[u8], + replace: impl FnOnce(&Path, &Path) -> std::io::Result<()>, +) -> std::io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let temp_path = parent.join(format!(".{name}.{}.tmp", next_epoch())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut temp = options.open(&temp_path)?; + if let Err(error) = temp.write_all(contents).and_then(|()| temp.sync_all()) { + drop(temp); + let _ = std::fs::remove_file(&temp_path); + return Err(error); + } + drop(temp); + + if let Err(error) = replace(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(error); + } + sync_parent_directory(path); + Ok(()) +} + +fn replace_marker(temporary: &Path, destination: &Path) -> std::io::Result<()> { + crate::db::DatabaseAuthority::replace_file_atomically(temporary, destination, "sync marker") + .map_err(|error| std::io::Error::other(error.to_string())) +} + +fn marker_identity(contents: &[u8]) -> MarkerIdentity { + match serde_json::from_slice::(contents) { + Ok(marker) if marker.schema == MARKER_SCHEMA => MarkerIdentity::Epoch(marker.epoch), + _ => MarkerIdentity::Legacy(contents.to_vec()), + } +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) { + if let Some(parent) = path.parent() { + let _ = File::open(parent).and_then(|directory| directory.sync_all()); } } -/// Per-process counter making each stale-lock reclaim sidecar path unique, so -/// two threads in the same process never collide on the reclaim filename. -static RECLAIM_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) {} -/// Returns `true` if a process with the given PID is currently running. +/// Returns `true` if a legacy process with the given PID is currently running. fn is_pid_alive(pid: u32) -> bool { #[cfg(unix)] { @@ -212,7 +446,7 @@ fn is_pid_alive(pid: u32) -> bool { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() - .is_ok_and(|s| s.success()) + .is_ok_and(|status| status.success()) } #[cfg(windows)] { @@ -221,7 +455,7 @@ fn is_pid_alive(pid: u32) -> bool { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output() - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())) .unwrap_or(false) } #[cfg(not(any(unix, windows)))] @@ -230,3 +464,159 @@ fn is_pid_alive(pid: u32) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + + fn legacy_parser_classifies_stale(path: &Path) -> bool { + std::fs::read_to_string(path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()) + .is_none_or(|pid| !is_pid_alive(pid)) + } + + #[test] + fn live_new_owner_is_not_stale_to_legacy_pid_parser() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sync.lock"); + let guard = try_acquire_sync_lock_at(&path).unwrap(); + + assert!(!legacy_parser_classifies_stale(&path)); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + std::process::id().to_string() + ); + let legacy_create = OpenOptions::new().write(true).create_new(true).open(&path); + assert_eq!( + legacy_create.unwrap_err().kind(), + std::io::ErrorKind::AlreadyExists, + "an old O_EXCL writer must not create a second canonical lock" + ); + assert!(try_acquire_sync_lock_at(&path).is_err()); + + drop(guard); + assert!(try_acquire_sync_lock_at(&path).is_ok()); + } + + #[test] + fn delayed_drop_preserves_newer_epoch_owner_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sync.lock"); + let guard = try_acquire_sync_lock_at(&path).unwrap(); + let owner_path = lock_owner_path(&path); + let stale_epoch = guard.epoch.clone(); + let replacement_epoch = "replacement-owner-epoch"; + let replacement = LockLease { + schema: MARKER_SCHEMA, + owner: MarkerOwner { + pid: std::process::id(), + }, + epoch: replacement_epoch, + state: "locked", + time: current_timestamp(), + version: env!("CARGO_PKG_VERSION"), + }; + publish_marker(&owner_path, &serde_json::to_vec(&replacement).unwrap()).unwrap(); + + drop(guard); + + let current: serde_json::Value = + serde_json::from_slice(&std::fs::read(&owner_path).unwrap()).unwrap(); + assert_ne!(current["epoch"], stale_epoch); + assert_eq!(current["epoch"], replacement_epoch); + } + + #[test] + fn legacy_owner_seed_persists_pid_without_operation_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sync.lock"); + + seed_legacy_sync_owner_at(&path).unwrap(); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + std::process::id().to_string() + ); + assert!(!lock_owner_path(&path).exists()); + seed_legacy_sync_owner_at(&path).unwrap(); + } + + #[test] + fn dead_legacy_pid_is_recoverable_without_replacing_lock_inode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sync.lock"); + #[cfg(unix)] + let mut exited = std::process::Command::new("sh") + .args(["-c", "exit 0"]) + .spawn() + .unwrap(); + #[cfg(windows)] + let mut exited = std::process::Command::new("cmd") + .args(["/C", "exit", "0"]) + .spawn() + .unwrap(); + #[cfg(not(any(unix, windows)))] + return; + let dead_pid = exited.id(); + assert!(exited.wait().unwrap().success()); + assert!(!is_pid_alive(dead_pid)); + std::fs::write(&path, dead_pid.to_string()).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + + let guard = try_acquire_sync_lock_at(&path).unwrap(); + let after = std::fs::metadata(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + } + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + std::process::id().to_string() + ); + drop(guard); + } + + #[test] + fn dirty_marker_clear_requires_the_published_epoch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("graph.db.dirty"); + write_dirty_sentinel_for_epoch(&path, "epoch-one").unwrap(); + let stale = marker_identity(&std::fs::read(&path).unwrap()); + + write_dirty_sentinel_for_epoch(&path, "epoch-two").unwrap(); + assert!(clear_marker_if_matches(&path, &stale).is_err()); + let current = std::fs::read(&path).unwrap(); + assert!(matches!( + marker_identity(¤t), + MarkerIdentity::Epoch(epoch) if epoch == "epoch-two" + )); + + #[cfg(unix)] + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + clear_marker_if_matches(&path, &marker_identity(¤t)).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn failed_marker_replace_preserves_live_destination() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("graph.db.dirty"); + std::fs::write(&path, b"live marker").unwrap(); + + let error = publish_marker_with_replace(&path, b"replacement", |_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected replacement failure", + )) + }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(std::fs::read(&path).unwrap(), b"live marker"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } +} diff --git a/tests/automation_runner_test/user_scope.rs b/tests/automation_runner_test/user_scope.rs index 9a59e8778..df6579c74 100644 --- a/tests/automation_runner_test/user_scope.rs +++ b/tests/automation_runner_test/user_scope.rs @@ -174,7 +174,7 @@ async fn projectless_memory_curator_applies_validated_delete_to_user_memory() { assert_eq!(run.report["dry_run"], json!(false)); assert_eq!(run.report["llm_apply"]["applied"], json!(1)); - let db = Database::open(&user_memory_db_path(profile_root)) + let db = crate::common::open_test_database(&user_memory_db_path(profile_root)) .await .unwrap() .0; @@ -221,7 +221,7 @@ async fn projectless_memory_curator_merges_and_updates_user_memory() { .unwrap(); assert_eq!(run.report["llm_apply"]["applied"], json!(1)); - let db = Database::open(&user_memory_db_path(profile_root)) + let db = crate::common::open_test_database(&user_memory_db_path(profile_root)) .await .unwrap() .0; @@ -271,7 +271,7 @@ async fn projectless_memory_curator_grooms_user_memory() { .unwrap(); assert_eq!(run.report["llm_apply"]["applied"], json!(1)); - let db = Database::open(&user_memory_db_path(profile_root)) + let db = crate::common::open_test_database(&user_memory_db_path(profile_root)) .await .unwrap() .0; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c471dbd7a..36a0b2fc8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -19,7 +19,7 @@ use tempfile::NamedTempFile; use tempfile::TempDir; use tokio::sync::OnceCell; use tracedecay::config::USER_DATA_DIR_ENV; -use tracedecay::db::Database; +use tracedecay::db::{Database, DatabaseAuthority}; use tracedecay::global_db::GlobalDb; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; use tracedecay::types::{Node, NodeKind, Visibility}; @@ -27,6 +27,23 @@ use tracedecay::types::{Node, NodeKind, Visibility}; static EMPTY_LCM_DB_TEMPLATE: OnceCell> = OnceCell::const_new(); static EMPTY_GRAPH_DB_TEMPLATE: OnceCell> = OnceCell::const_new(); +pub async fn initialize_test_database(path: &Path) -> tracedecay::errors::Result<(Database, bool)> { + let authority = DatabaseAuthority::acquire_test(path, "integration test initialize")?; + Database::initialize(path, &authority).await +} + +pub async fn open_test_database(path: &Path) -> tracedecay::errors::Result<(Database, bool)> { + let authority = DatabaseAuthority::acquire_test(path, "integration test open")?; + Database::open(path, &authority).await +} + +pub async fn open_test_database_read_only( + path: &Path, +) -> tracedecay::errors::Result<(Database, bool)> { + let authority = DatabaseAuthority::acquire_test(path, "integration test read-only open")?; + Database::open_read_only(path, &authority).await +} + /// Sets (or removes) an environment variable for its lifetime, restoring the /// previous value on drop. pub struct EnvVarGuard { @@ -671,7 +688,7 @@ pub async fn open_graph_db_from_template(db_path: &Path) -> Database { .get_or_init(|| async { let tmp = tempdir_or_panic(); let template_path = tmp.path().join("template-graph.db"); - let (db, _) = Database::initialize(&template_path) + let (db, _) = initialize_test_database(&template_path) .await .expect("template graph db initialize"); db.checkpoint().await.expect("template graph db checkpoint"); @@ -698,7 +715,7 @@ pub async fn open_graph_db_from_template(db_path: &Path) -> Database { db_path.display() ) }); - let (db, _) = Database::open(db_path) + let (db, _) = open_test_database(db_path) .await .unwrap_or_else(|err| panic!("failed to open templated graph db: {err}")); db diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 54cbefdab..4e8d9ebb2 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -10,7 +10,6 @@ use tracedecay::automation::run_ledger::{ AutomationRunArtifactKind, AutomationRunLedgerRecord, append_run_record, write_run_artifact, }; use tracedecay::branch_meta::BranchMeta; -use tracedecay::db::Database; use tracedecay::global_db::{GlobalDb, StoreInstanceUpsert}; use tracedecay::migrate::inventory::MigrationInventory; use tracedecay::migrate::manifest::{ @@ -266,7 +265,9 @@ fn write_profile_sharded_branch_fixture(home: &std::path::Path, project: &std::p .enable_all() .build() .unwrap() - .block_on(Database::initialize(&shard_root.join("tracedecay.db"))) + .block_on(crate::common::initialize_test_database( + &shard_root.join("tracedecay.db"), + )) .unwrap(); } @@ -1116,7 +1117,9 @@ async fn status_surfaces_split_identity_conflict_without_suggesting_init() { }, ) .unwrap(); - let (db, _) = Database::initialize(&layout.graph_db_path).await.unwrap(); + let (db, _) = crate::common::initialize_test_database(&layout.graph_db_path) + .await + .unwrap(); db.insert_node(&sample_node(node_id, node_id, "src/lib.rs")) .await .unwrap(); @@ -1175,7 +1178,9 @@ async fn status_json_reads_readonly_project_database() { ) .unwrap(); let db_path = profile_shard_root(home.path()).join("tracedecay.db"); - let (db, _) = Database::initialize(&db_path).await.unwrap(); + let (db, _) = crate::common::initialize_test_database(&db_path) + .await + .unwrap(); db.insert_node(&sample_node("node-1", "process_data", "src/lib.rs")) .await .unwrap(); @@ -1777,6 +1782,8 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc std::fs::create_dir_all(&live_project).expect("live project dir"); std::fs::write(live_project.join("lib.rs"), "pub fn live() {}\n").expect("live source"); + #[cfg(unix)] + let daemon = crate::common::spawn_tracedecay_daemon(home.path()); let init = tracedecay_command(home.path(), &live_project) .args(["init", "."]) .output() @@ -1787,6 +1794,8 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc String::from_utf8_lossy(&init.stdout), String::from_utf8_lossy(&init.stderr) ); + #[cfg(unix)] + drop(daemon); let stale_project = canonical_temp_path(home.path()).join("gone-project"); let global_db_path = profile_root(home.path()).join("global.db"); @@ -1794,6 +1803,7 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc let db = GlobalDb::open_at(&global_db_path) .await .expect("open global db"); + db.upsert(&live_project, 1).await; db.upsert(&stale_project, 1).await; db.upsert_code_project("stale-identity", &stale_project, None, None, None) .await diff --git a/tests/core_cli_suite/regression_core_engine_test.rs b/tests/core_cli_suite/regression_core_engine_test.rs index 48d897f14..2f05e7cb8 100644 --- a/tests/core_cli_suite/regression_core_engine_test.rs +++ b/tests/core_cli_suite/regression_core_engine_test.rs @@ -366,38 +366,74 @@ async fn repeated_target_edits_keep_unresolved_refs_bounded() { } // --------------------------------------------------------------------------- -// Finding #5 (LOW): stale sync-lock reclaim must be atomic and preserve a live -// lock. We can't deterministically force the TOCTOU race, but we assert the -// functional contract the atomic reclaim must keep. +// Finding #5 (LOW): sync ownership uses a persistent kernel-locked file. Bare +// PID contents remain supported only for interoperability with older clients. // --------------------------------------------------------------------------- #[tokio::test] -async fn stale_sync_lock_with_dead_pid_is_reclaimed() { +async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { let dir = TempDir::new().unwrap(); let project = dir.path(); TraceDecay::init(project).await.unwrap(); let lock_path = resolve_layout_for_current_profile(project) .unwrap() .sync_lock_path; - // A PID well out of range can never be alive -> the lock is stale. + assert_eq!( + fs::read_to_string(&lock_path).unwrap(), + std::process::id().to_string(), + "init must seed a legacy-compatible daemon-lifetime owner" + ); + // A dead legacy owner does not require unlinking the canonical path. fs::write(&lock_path, "4294967294").unwrap(); let guard = tracedecay::tracedecay::try_acquire_sync_lock(project) - .expect("a stale lock with a dead PID must be reclaimed"); + .expect("an unlocked legacy file must be reusable"); assert_eq!( - fs::read_to_string(&lock_path).unwrap().trim(), - std::process::id().to_string(), - "reclaimed lock must hold the current PID" + fs::read_to_string(&lock_path).unwrap(), + std::process::id().to_string() ); + let owner_path = lock_path.with_extension("lock.owner"); + let metadata: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&owner_path).unwrap()).unwrap(); + assert_eq!( + metadata["owner"]["pid"].as_u64(), + Some(std::process::id() as u64) + ); + assert_eq!(metadata["state"], "locked"); drop(guard); assert!( - !lock_path.exists(), - "dropping the guard must remove the lockfile" + lock_path.exists(), + "dropping the guard must leave the persistent lockfile in place" + ); + assert!( + !owner_path.exists(), + "dropping the exact owner clears its sidecar" + ); + drop( + tracedecay::tracedecay::try_acquire_sync_lock(project) + .expect("kernel lease must be reusable after Drop"), + ); +} + +#[tokio::test] +async fn writable_open_reseeds_legacy_sync_owner_before_database_use() { + let dir = TempDir::new().unwrap(); + let project = dir.path(); + let initialized = TraceDecay::init(project).await.unwrap(); + let lock_path = initialized.store_layout().sync_lock_path.clone(); + drop(initialized); + fs::write(&lock_path, "4294967294").unwrap(); + + let reopened = TraceDecay::open(project).await.unwrap(); + assert_eq!( + fs::read_to_string(&lock_path).unwrap(), + std::process::id().to_string() ); + drop(reopened); } #[tokio::test] -async fn live_sync_lock_is_not_reclaimed() { +async fn live_legacy_pid_lock_is_not_reclaimed() { let dir = TempDir::new().unwrap(); let project = dir.path(); TraceDecay::init(project).await.unwrap(); diff --git a/tests/core_cli_suite/sync_test.rs b/tests/core_cli_suite/sync_test.rs index 9cce9e7ff..e5c49d525 100644 --- a/tests/core_cli_suite/sync_test.rs +++ b/tests/core_cli_suite/sync_test.rs @@ -1,6 +1,5 @@ use std::io::Write; use tempfile::{NamedTempFile, TempDir}; -use tracedecay::db::Database; use tracedecay::sync::*; use tracedecay::types::FileRecord; @@ -21,7 +20,7 @@ fn test_content_hash_different() { #[tokio::test] async fn test_find_stale_files() { let dir = TempDir::new().unwrap(); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .unwrap(); db.upsert_file(&FileRecord { @@ -43,7 +42,7 @@ async fn test_find_stale_files() { #[tokio::test] async fn test_find_new_files() { let dir = TempDir::new().unwrap(); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .unwrap(); let current = vec!["src/new_file.rs".to_string()]; @@ -54,7 +53,7 @@ async fn test_find_new_files() { #[tokio::test] async fn test_find_removed_files() { let dir = TempDir::new().unwrap(); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .unwrap(); db.upsert_file(&FileRecord { diff --git a/tests/core_cli_suite/tool_daemon_test.rs b/tests/core_cli_suite/tool_daemon_test.rs index afb68c940..9c41ecddf 100644 --- a/tests/core_cli_suite/tool_daemon_test.rs +++ b/tests/core_cli_suite/tool_daemon_test.rs @@ -12,7 +12,6 @@ use crate::common::{ }; use serde_json::{Value, json}; use tempfile::TempDir; -use tracedecay::db::Database; use tracedecay::storage::{ EnrollmentMarker, StorageMode, default_profile_project_id, profile_sharded_data_root, write_enrollment_marker, @@ -500,7 +499,7 @@ fn kiro_post_tool_use_hook_notifies_daemon() { } #[test] -fn daemon_sigterm_exits_while_project_client_is_connected() { +fn daemon_sigterm_exits_while_authenticated_project_client_is_connected() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); let home_path = canonical_existing_path(home.path()); @@ -521,6 +520,23 @@ fn daemon_sigterm_exits_while_project_client_is_connected() { let mut client = UnixStream::connect(&socket_path).expect("client should connect to daemon"); let mut reader = BufReader::new(client.try_clone().expect("clone daemon client stream")); + let authority: Value = serde_json::from_slice( + &std::fs::read(home_path.join(".tracedecay/daemon-authority.json")) + .expect("read daemon authority"), + ) + .expect("parse daemon authority"); + let auth_token = authority["auth_token"] + .as_str() + .expect("daemon authority auth token"); + writeln!( + client, + "{}", + json!({ + "protocol": "tracedecay-daemon-v1", + "auth_token": auth_token + }) + ) + .expect("write daemon auth preface"); let handshake = json!({ "project_path": project_path, "scope_prefix": null, @@ -853,7 +869,9 @@ fn doctor_keeps_live_daemon_database_healthy_without_compaction() { ); let db_path = data_root.join(tracedecay::config::db_filename(&data_root)); common::create_runtime().block_on(async { - let (db, _) = Database::open(&db_path).await.expect("open graph database"); + let (db, _) = crate::common::open_test_database(&db_path) + .await + .expect("open graph database"); db.conn() .execute_batch( "CREATE TABLE doctor_daemon_probe (payload BLOB);\ @@ -1156,7 +1174,7 @@ fn daemon_project_cache_is_scoped_by_client_identity() { } #[test] -fn tool_cli_without_daemon_socket_falls_back_to_in_process_handler() { +fn tool_cli_without_daemon_socket_reports_daemon_unavailable() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); let socket_dir = TempDir::new().unwrap(); @@ -1173,15 +1191,10 @@ fn tool_cli_without_daemon_socket_falls_back_to_in_process_handler() { .output() .expect("tracedecay tool should run"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "tool CLI should fall back to in-process handlers when the daemon socket is missing\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("\"content\""), - "expected MCP tool result JSON from in-process fallback, got:\n{stdout}" + stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + "expected explicit daemon-unavailable error, got:\n{stderr}" ); } diff --git a/tests/graph_suite/context_test.rs b/tests/graph_suite/context_test.rs index cf3ec9045..788344333 100644 --- a/tests/graph_suite/context_test.rs +++ b/tests/graph_suite/context_test.rs @@ -5,14 +5,14 @@ use tracedecay::types::*; async fn test_reranking_demotes_fixture_nodes() { use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); // Fixture node: enum variant in tests/fixtures/ let fixture_node = Node { @@ -150,7 +150,6 @@ async fn test_build_context_with_db() { use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); @@ -160,9 +159,10 @@ async fn test_build_context_with_db() { fs::write(project.join("src/lib.rs"), "pub fn process_data() {}\n").unwrap(); // Init DB and insert a node - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let node = Node { id: "function:test123".to_string(), kind: NodeKind::Function, @@ -204,7 +204,6 @@ async fn test_get_code_reads_source_file() { use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); @@ -216,9 +215,10 @@ async fn test_get_code_reads_source_file() { ) .unwrap(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let node = Node { id: "function:main123".to_string(), @@ -258,14 +258,14 @@ async fn test_get_code_reads_source_file() { async fn test_get_code_returns_none_for_missing_file() { use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let node = Node { id: "function:missing".to_string(), @@ -303,16 +303,16 @@ async fn test_get_code_returns_none_for_reversed_line_range() { use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "line 1\nline 2\nline 3\n").unwrap(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let node = context_test_node("src/lib.rs", 3, 1); let builder = ContextBuilder::new(&db, project); @@ -325,16 +325,16 @@ async fn test_get_code_rejects_absolute_path_when_root_is_not_canonical() { use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); let outside = project.join("outside.rs"); fs::write(&outside, "fn secret() {}\n").unwrap(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let missing_root = project.join("missing-root"); let node = context_test_node(outside.to_string_lossy().as_ref(), 1, 1); @@ -347,14 +347,14 @@ async fn test_get_code_rejects_absolute_path_when_root_is_not_canonical() { async fn test_find_relevant_context() { use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); let node = Node { id: "function:ctx_test".to_string(), kind: NodeKind::Function, @@ -394,14 +394,14 @@ async fn test_find_relevant_context() { async fn test_exclude_node_ids_deduplication() { use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); for (id, name) in [("fn:first", "compute"), ("fn:second", "compute_batch")] { db.insert_node(&Node { @@ -457,7 +457,6 @@ async fn test_merge_adjacent_code_blocks() { use std::fs; use tempfile::TempDir; use tracedecay::context::ContextBuilder; - use tracedecay::db::Database; let dir = TempDir::new().unwrap(); let project = dir.path(); @@ -468,9 +467,10 @@ async fn test_merge_adjacent_code_blocks() { ) .unwrap(); - let (db, _) = Database::initialize(&project.join(".tracedecay/tracedecay.db")) - .await - .unwrap(); + let (db, _) = + crate::common::initialize_test_database(&project.join(".tracedecay/tracedecay.db")) + .await + .unwrap(); // Two adjacent functions in same file db.insert_node(&Node { diff --git a/tests/graph_suite/graph_test.rs b/tests/graph_suite/graph_test.rs index c25866219..4e1741e5f 100644 --- a/tests/graph_suite/graph_test.rs +++ b/tests/graph_suite/graph_test.rs @@ -12,7 +12,7 @@ use tracedecay::types::*; async fn setup_db() -> (Database, TempDir) { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("test.db"); - let (db, _) = Database::initialize(&db_path) + let (db, _) = crate::common::initialize_test_database(&db_path) .await .expect("failed to initialize database"); (db, dir) diff --git a/tests/graph_suite/main.rs b/tests/graph_suite/main.rs index 15d21ee4e..f5271b2ef 100644 --- a/tests/graph_suite/main.rs +++ b/tests/graph_suite/main.rs @@ -12,6 +12,9 @@ mod types { pub use tracedecay::types::*; } +#[path = "../common/mod.rs"] +mod common; + mod annotation_helpers_test; mod bench_test; mod cloud_test; diff --git a/tests/graph_suite/resolution_test.rs b/tests/graph_suite/resolution_test.rs index 59fdfb370..f122ff434 100644 --- a/tests/graph_suite/resolution_test.rs +++ b/tests/graph_suite/resolution_test.rs @@ -16,7 +16,7 @@ async fn resolution_fixture() -> &'static ResolutionFixture { RESOLUTION_FIXTURE .get_or_init(|| async { let dir = TempDir::new().expect("failed to create temp dir"); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .expect("failed to init db"); let nodes = basic_nodes(); @@ -95,7 +95,7 @@ fn basic_nodes() -> Vec { async fn setup_db_with_nodes() -> (TempDir, Database) { let dir = TempDir::new().expect("failed to create temp dir"); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .expect("failed to init db"); diff --git a/tests/hooks_lsp_suite/hook_branch_routing_test.rs b/tests/hooks_lsp_suite/hook_branch_routing_test.rs index a71d82a02..df945d7f2 100644 --- a/tests/hooks_lsp_suite/hook_branch_routing_test.rs +++ b/tests/hooks_lsp_suite/hook_branch_routing_test.rs @@ -5,7 +5,6 @@ use std::process::Command; use tempfile::TempDir; use tracedecay::branch_meta::{self, BranchMeta}; use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; -use tracedecay::db::Database; use tracedecay::hooks::{ CursorShellSyncPlan, cursor_branch_switch_target, cursor_shell_command_targets_project, cursor_shell_sync_plan, cursor_shell_sync_plan_with_current_branch, @@ -200,7 +199,7 @@ async fn hook_branch_tracking_writes_profile_sharded_branch_db() { serde_json::to_string_pretty(&config).unwrap(), ) .unwrap(); - Database::initialize(&shard_root.join("tracedecay.db")) + crate::common::initialize_test_database(&shard_root.join("tracedecay.db")) .await .unwrap(); let meta = BranchMeta::new_for_dir(&shard_root, "main"); diff --git a/tests/mcp_suite/main.rs b/tests/mcp_suite/main.rs index 7dccc8bee..10fc809ca 100644 --- a/tests/mcp_suite/main.rs +++ b/tests/mcp_suite/main.rs @@ -24,7 +24,6 @@ mod mcp_rendering_test; mod mcp_server_test; mod mcp_test; mod multi_mcp_coordination_test; -mod serve_degraded_mode_test; mod serve_harness; mod serve_template_path_test; mod support; diff --git a/tests/mcp_suite/mcp_cli_serve_test.rs b/tests/mcp_suite/mcp_cli_serve_test.rs index 6c5b75aa7..973d45146 100644 --- a/tests/mcp_suite/mcp_cli_serve_test.rs +++ b/tests/mcp_suite/mcp_cli_serve_test.rs @@ -28,7 +28,6 @@ use tracedecay::automation::run_ledger::{ AutomationRunArtifactKind, AutomationRunLedgerRecord, AutomationRunStatus, AutomationTrigger, append_run_record, write_run_artifact, }; -use tracedecay::db::Database; use tracedecay::mcp::handle_tool_call; use tracedecay::serve; use tracedecay::storage::{ @@ -126,21 +125,6 @@ async fn set_user_version(db_path: &Path, version: u32) { .unwrap(); } -#[cfg(unix)] -async fn drop_memory_facts(db_path: &Path) { - let mut permissions = fs::metadata(db_path).unwrap().permissions(); - permissions.set_mode(0o644); - fs::set_permissions(db_path, permissions).unwrap(); - - let db = Builder::new_local(db_path).build().await.unwrap(); - let conn = db.connect().unwrap(); - conn.execute("DROP TABLE memory_facts", ()).await.unwrap(); - - let mut permissions = fs::metadata(db_path).unwrap().permissions(); - permissions.set_mode(0o444); - fs::set_permissions(db_path, permissions).unwrap(); -} - fn extract_tool_text(value: &Value) -> &str { value["content"][0]["text"] .as_str() @@ -167,7 +151,9 @@ async fn create_read_only_project_db( }, ) .unwrap(); - let (db, _) = Database::initialize(&db_path).await.unwrap(); + let (db, _) = crate::common::initialize_test_database(&db_path) + .await + .unwrap(); db.checkpoint().await.unwrap(); db.close(); if let Some(version) = user_version { @@ -218,7 +204,7 @@ fn create_unindexed_git_project_with_file(contents: &str) -> TempDir { } #[tokio::test] -async fn serve_without_daemon_socket_falls_back_to_in_process_mcp() { +async fn serve_without_daemon_socket_reports_daemon_unavailable() { let home = TempDir::new().unwrap(); let project = init_project_with_file(home.path(), "pub fn client_only_marker() {}\n").await; @@ -249,16 +235,11 @@ async fn serve_without_daemon_socket_falls_back_to_in_process_mcp() { .wait_with_output() .expect("tracedecay serve should exit after stdin closes"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "serve should fall back to an in-process MCP engine when the daemon socket is missing\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("\"protocolVersion\":\"2024-11-05\""), - "serve fallback should answer initialize over stdio\nstdout:\n{stdout}" + stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + "expected explicit daemon-unavailable error, got:\n{stderr}" ); } @@ -496,9 +477,9 @@ async fn serve_stdio_smokes_automation_run_artifact_view() { ); } -/// A reachable daemon must win before serve opens either local database. The -/// intentionally uninitialized explicit path also proves that proxy startup -/// preserves authoritative path routing without invoking local resolution. +/// Serve must proxy through a reachable daemon without opening either database +/// locally. The intentionally uninitialized explicit path also proves that +/// proxy startup preserves authoritative path routing without local resolution. #[cfg(unix)] #[tokio::test] async fn serve_with_reachable_daemon_proxies_before_opening_explicit_project() { @@ -844,44 +825,30 @@ async fn serve_daemon_proxy_reports_daemon_disconnect_as_json_rpc_error() { ); } -#[cfg(unix)] #[tokio::test] -async fn ensure_initialized_rejects_read_only_db_with_pending_migrations() { - let _env_guard = READ_ONLY_SERVE_ENV_LOCK.lock().await; +async fn ensure_initialized_with_options_fails_closed_without_daemon_routing() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); let open_options = TraceDecayOpenOptions { profile_root: Some(profile_root(home.path())), global_db_path: Some(profile_root(home.path()).join("global.db")), }; - let (project_root, db_path) = create_read_only_project_db( - home.path(), - project.path(), - "proj_serve_readonly_old_schema", - Some(14), - ) - .await; - drop_memory_facts(&db_path).await; - - assert!( - TraceDecay::open(&project_root).await.is_err(), - "normal TraceDecay::open should fail against the read-only DB fixture" - ); - let error = match serve::ensure_initialized_with_options(&project_root, open_options).await { - Ok(_) => panic!("read-only fallback must reject old schemas instead of serving them"), + let error = match serve::ensure_initialized_with_options(project.path(), open_options).await { + Ok(_) => panic!("serve compatibility API must not open project databases locally"), Err(error) => error, }; let message = error.to_string(); assert!( - message.contains("schema") && message.contains("migrat"), - "error should explain that the read-only DB needs migration, got: {message}" + message.contains("direct project database access is disabled") + && message.contains("managed TraceDecay daemon"), + "error should direct callers through the sole database owner, got: {message}" ); } #[cfg(unix)] #[tokio::test] -async fn ensure_initialized_read_only_fallback_reports_and_guards_read_only_store() { +async fn explicit_read_only_open_reports_and_guards_read_only_store() { let _env_guard = READ_ONLY_SERVE_ENV_LOCK.lock().await; let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); @@ -905,7 +872,7 @@ async fn ensure_initialized_read_only_fallback_reports_and_guards_read_only_stor let cg = TraceDecay::open_read_only_with_options(&project_root, open_options) .await - .expect("current-schema read-only DB should open for read-only serving"); + .expect("current-schema read-only DB should open explicitly"); let status = handle_tool_call( &cg, diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 28ff533c6..1c7c497e5 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -29,7 +29,6 @@ use tracedecay::automation::run_ledger::{ use tracedecay::automation::skill_usage::{ SkillUsageAction, load_skill_usage_record, record_skill_usage, }; -use tracedecay::db::Database; use tracedecay::errors::TraceDecayError; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::{ToolResult, get_tool_definitions}; @@ -8469,7 +8468,9 @@ async fn memory_fact_store_uses_project_store_when_serving_branch_db() { .as_i64() .expect("fact_store add should return numeric id"); - let (branch_db, _) = Database::open(&cg.db_path()).await.unwrap(); + let (branch_db, _) = crate::common::open_test_database(&cg.db_path()) + .await + .unwrap(); assert!( MemoryStore::new(branch_db.conn()) .get_fact(fact_id) @@ -8479,7 +8480,7 @@ async fn memory_fact_store_uses_project_store_when_serving_branch_db() { "MCP memory writes must not be scoped to the branch graph DB" ); - let (project_db, _) = Database::open(&cg.store_layout().graph_db_path) + let (project_db, _) = crate::common::open_test_database(&cg.store_layout().graph_db_path) .await .unwrap(); assert!( @@ -9087,7 +9088,7 @@ async fn message_search_reads_profile_sharded_session_db() { serde_json::to_string_pretty(&config).unwrap(), ) .unwrap(); - Database::initialize(&shard_root.join("tracedecay.db")) + crate::common::initialize_test_database(&shard_root.join("tracedecay.db")) .await .unwrap(); let meta = tracedecay::branch_meta::BranchMeta::new_for_dir(&shard_root, "main"); @@ -13090,7 +13091,7 @@ async fn memory_status_repairs_dirty_banks_before_reporting() { let added: Value = serde_json::from_str(extract_text(&added.value)).unwrap(); let fact_id = added["fact"]["fact_id"].as_i64().unwrap(); let db_path = project_graph_db(&cg); - let (db, _) = Database::open(&db_path).await.unwrap(); + let (db, _) = crate::common::open_test_database(&db_path).await.unwrap(); db.conn() .execute( "UPDATE memory_facts diff --git a/tests/mcp_suite/serve_degraded_mode_test.rs b/tests/mcp_suite/serve_degraded_mode_test.rs deleted file mode 100644 index db5f6f47f..000000000 --- a/tests/mcp_suite/serve_degraded_mode_test.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Degraded serving: `serve` must not exit when startup project resolution -//! fails, because MCP hosts (Cursor especially) never retry a failed server -//! spawn — one startup exit turns every later tool call in the session into -//! "Timed out waiting for connection" until the user toggles the server or -//! reloads the window. Instead, serve completes the handshake, answers tool -//! calls with an actionable error, and recovers in-session once resolution -//! starts succeeding. - -use std::ffi::OsStr; -use std::fs; -use std::path::Path; - -use serde_json::json; -use tempfile::TempDir; - -use crate::common::canonical_existing_path; -use crate::serve_harness::{ - ServeStdioSession, canonical_path_string, degraded_tool_error_text, init_project_direct, - init_project_under, init_project_with_file, json_rpc_response, register_global_project, - run_serve_runtime, -}; - -/// Asserts that a recovered `tracedecay_runtime` response is a real (non -/// degraded) result serving `expected_project`. -fn assert_recovered_runtime_response(response: &serde_json::Value, expected_project: &Path) { - assert!( - response.get("error").is_none() && response["result"]["isError"] != json!(true), - "post-recovery tool call should be served by the recovered server:\n{response}" - ); - let text = response["result"]["content"][0]["text"] - .as_str() - .expect("recovered runtime tool should return text content"); - let runtime: serde_json::Value = serde_json::from_str(text).unwrap(); - assert_eq!( - canonical_path_string(Path::new( - runtime["database"]["project_root"] - .as_str() - .expect("runtime should include database.project_root") - )), - canonical_path_string(expected_project), - "recovered server must serve the expected project" - ); -} - -/// A dead MCP process permanently kills the client scope, so an explicit -/// uninitialized `--path` must NOT exit: serve completes the handshake and -/// answers tool calls with an actionable error naming the explicit path — -/// and still never silently serves the registered global-fallback project. -#[tokio::test] -async fn explicit_uninitialized_path_serves_degraded_error_instead_of_global_fallback() { - let home = TempDir::new().unwrap(); - let explicit = TempDir::new().unwrap(); - let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; - register_global_project(home.path(), active.path()).await; - - let output = run_serve_runtime( - home.path(), - explicit.path(), - Some(explicit.path().as_os_str()), - json!({}), - ); - - assert!( - output.status.success(), - "degraded serve should stay alive until stdin closes\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let initialize = json_rpc_response(&output.stdout, 1); - assert_eq!( - initialize["result"]["protocolVersion"], - json!("2024-11-05"), - "degraded serve must complete the MCP handshake:\n{initialize}" - ); - let tool_call = json_rpc_response(&output.stdout, 2); - let text = degraded_tool_error_text(&tool_call); - let explicit_display = explicit.path().display().to_string(); - for needle in [ - explicit_display.as_str(), - "tracedecay init", - "tracedecay tool", - "restart or toggle the tracedecay MCP server in your MCP host", - ] { - assert!( - text.contains(needle), - "degraded tool error should mention '{needle}':\n{text}" - ); - } - assert!( - !text.contains("Cursor Settings → MCP") && !text.contains("doctor --agent cursor"), - "generic degraded serve guidance should not assume Cursor:\n{text}" - ); - assert!( - !text.contains(&active.path().display().to_string()), - "degraded serve must not leak or serve the global-fallback project:\n{text}" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains(&explicit_display) && stderr.contains("degraded MCP mode"), - "stderr should name the explicit path and the degraded mode marker\nstderr:\n{stderr}" - ); -} - -/// Ambiguous global fallback is a recoverable config problem: serve must not -/// pick an arbitrary project, but it must not exit either — it stays alive in -/// degraded mode and reports the ambiguity from tool calls. -#[tokio::test] -async fn same_depth_descendant_global_fallback_is_ambiguous_and_stays_alive() { - let home = TempDir::new().unwrap(); - let cwd = TempDir::new().unwrap(); - let alpha = init_project_under( - home.path(), - cwd.path(), - "alpha", - "pub fn alpha_marker() {}\n", - ) - .await; - let beta = - init_project_under(home.path(), cwd.path(), "beta", "pub fn beta_marker() {}\n").await; - register_global_project(home.path(), &alpha).await; - register_global_project(home.path(), &beta).await; - - let output = run_serve_runtime(home.path(), cwd.path(), None, json!({})); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "ambiguous global fallback should serve degraded, not exit\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - stderr - ); - let tool_call = json_rpc_response(&output.stdout, 2); - let text = degraded_tool_error_text(&tool_call); - assert!( - text.contains("Multiple tracedecay projects found"), - "tool error should explain the ambiguity:\n{text}" - ); - assert!( - stderr.contains("Multiple tracedecay projects found"), - "stderr should explain the ambiguity:\n{stderr}" - ); - assert!( - !stderr.contains("no projects registered in the global database"), - "stderr should not contradict ambiguity with a no-projects error:\n{stderr}" - ); -} - -/// Degraded serve must recover in-session: once `tracedecay init` makes the -/// explicit `--path` resolve, the very next tool call is served for real — -/// no server toggle or window reload (which Cursor would otherwise require). -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_path_recovers_after_project_init() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - fs::create_dir_all(project.path().join("src")).unwrap(); - fs::write( - project.path().join("src/lib.rs"), - "pub fn degraded_recovery_marker() {}\n", - ) - .unwrap(); - - let mut session = ServeStdioSession::spawn( - home.path(), - project.path(), - Some(project.path().as_os_str()), - ); - session.send(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })); - let initialize = session.response_with_id(1); - assert_eq!(initialize["result"]["protocolVersion"], json!("2024-11-05")); - - session.send(&ServeStdioSession::runtime_call(2)); - degraded_tool_error_text(&session.response_with_id(2)); - - // The user fixes the project mid-session. - init_project_direct(home.path(), project.path()).await; - - session.send(&ServeStdioSession::runtime_call(3)); - assert_recovered_runtime_response(&session.response_with_id(3), project.path()); - - assert!( - session.close_and_wait().success(), - "recovered serve should exit cleanly" - ); -} - -/// The PRIMARY Cursor failure scenario: spawned from `$HOME` with a literal -/// unexpanded `${workspaceFolder}` (discarded as a template, cwd resolves -/// nothing, empty registry) → degraded. After the user initializes and -/// registers a project, the retry must re-run the full startup resolution -/// ladder — reaching the global registry, not just the dead cwd path — and -/// serve the project on the next tool call. -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn literal_template_from_home_recovers_after_project_registration() { - let home = TempDir::new().unwrap(); - let home_cwd = canonical_existing_path(home.path()); - - let mut session = ServeStdioSession::spawn( - home.path(), - &home_cwd, - Some(OsStr::new("${workspaceFolder}")), - ); - session.send(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })); - let initialize = session.response_with_id(1); - assert_eq!(initialize["result"]["protocolVersion"], json!("2024-11-05")); - - session.send(&ServeStdioSession::runtime_call(2)); - let text = degraded_tool_error_text(&session.response_with_id(2)); - assert!( - text.contains("tracedecay init"), - "degraded error should point at tracedecay init:\n{text}" - ); - - // The user initializes a project elsewhere; init registers it in the - // global registry (mirrored explicitly for the fixture-based init). - let project = - init_project_with_file(home.path(), "pub fn home_scope_recovery_marker() {}\n").await; - register_global_project(home.path(), project.path()).await; - - session.send(&ServeStdioSession::runtime_call(3)); - assert_recovered_runtime_response(&session.response_with_id(3), project.path()); - - assert!( - session.close_and_wait().success(), - "recovered serve should exit cleanly" - ); -} - -/// Requests pipelined behind the recovery-triggering tools/call must survive -/// the degraded→recovered transport handoff: both arrive in one stdin chunk, -/// so the second sits in the transport's read buffer while the first -/// triggers recovery — a raw handoff (fresh transport) would drop it and the -/// client would hang forever. -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pipelined_requests_survive_recovery_handoff() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - fs::create_dir_all(project.path().join("src")).unwrap(); - fs::write( - project.path().join("src/lib.rs"), - "pub fn pipelined_recovery_marker() {}\n", - ) - .unwrap(); - - let mut session = ServeStdioSession::spawn( - home.path(), - project.path(), - Some(project.path().as_os_str()), - ); - session.send(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })); - session.response_with_id(1); - session.send(&ServeStdioSession::runtime_call(2)); - degraded_tool_error_text(&session.response_with_id(2)); - - init_project_direct(home.path(), project.path()).await; - - // One write, two requests: id 3 triggers recovery, id 4 is pipelined - // behind it in the same chunk. - session.send_raw(&format!( - "{}\n{}\n", - ServeStdioSession::runtime_call(3), - ServeStdioSession::runtime_call(4) - )); - assert_recovered_runtime_response(&session.response_with_id(3), project.path()); - assert_recovered_runtime_response(&session.response_with_id(4), project.path()); - - assert!( - session.close_and_wait().success(), - "recovered serve should exit cleanly" - ); -} diff --git a/tests/mcp_suite/serve_harness.rs b/tests/mcp_suite/serve_harness.rs index bc726a4c6..8285bcf3b 100644 --- a/tests/mcp_suite/serve_harness.rs +++ b/tests/mcp_suite/serve_harness.rs @@ -1,13 +1,12 @@ //! Shared helpers for `tracedecay serve` stdio integration tests -//! (`mcp_cli_serve_test`, `serve_template_path_test`, -//! `serve_degraded_mode_test`): project fixtures, serve process spawners -//! (one-shot and interactive), and output parsers. +//! (`mcp_cli_serve_test`, `serve_template_path_test`): project fixtures, +//! one-shot serve process spawners, and output parsers. use std::ffi::OsStr; use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, ExitStatus, Output, Stdio}; +use std::process::{Output, Stdio}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -121,116 +120,11 @@ pub fn run_serve_runtime( .expect("tracedecay serve should exit after stdin closes") } -/// An interactive `tracedecay serve` stdio session: requests are written and -/// responses read incrementally, so tests can change on-disk state (e.g. run -/// `tracedecay init`) between calls within one server lifetime. -pub struct ServeStdioSession { - child: Child, - stdin: Option, - reader: BufReader, -} - -impl ServeStdioSession { - pub fn spawn(home: &Path, cwd: &Path, path_arg: Option<&OsStr>) -> Self { - let mut command = tracedecay_command_with_home(home); - command.arg("serve"); - if let Some(path) = path_arg { - command.arg("--path").arg(path); - } - let mut child = command - .current_dir(cwd) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("tracedecay serve should start"); - let stdin = child.stdin.take().expect("stdin should be piped"); - let reader = BufReader::new(child.stdout.take().expect("stdout should be piped")); - Self { - child, - stdin: Some(stdin), - reader, - } - } - - /// Writes one JSON-RPC request line. - pub fn send(&mut self, request: &Value) { - self.send_raw(&format!("{request}\n")); - } - - /// Writes raw bytes in a single write, so multiple newline-separated - /// requests arrive at the server as one pipelined chunk. - pub fn send_raw(&mut self, payload: &str) { - let stdin = self.stdin.as_mut().expect("session stdin already closed"); - stdin.write_all(payload.as_bytes()).expect("write request"); - stdin.flush().expect("flush request"); - } - - /// Builds the standard `tracedecay_runtime` tools/call used across serve - /// tests. - pub fn runtime_call(id: i64) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { - "name": "tracedecay_runtime", - "arguments": { "format": "json" } - } - }) - } - - /// Reads stdout lines until the JSON-RPC response with `id` appears, - /// skipping notifications and any interleaved non-response lines. - pub fn response_with_id(&mut self, id: i64) -> Value { - for _ in 0..50 { - let mut line = String::new(); - assert!( - self.reader.read_line(&mut line).expect("read serve stdout") > 0, - "serve stdout closed while waiting for JSON-RPC response {id}" - ); - if let Ok(response) = serde_json::from_str::(line.trim()) { - if response.get("id") == Some(&json!(id)) { - return response; - } - } - } - panic!("no JSON-RPC response {id} within 50 stdout lines"); - } - - /// Closes stdin and waits for the server to exit. - pub fn close_and_wait(mut self) -> ExitStatus { - drop(self.stdin.take()); - self.child - .wait() - .expect("serve should exit after stdin closes") - } -} - -/// Finds the JSON-RPC response with the given id in a serve stdout dump. -/// Unlike [`runtime_project_root`] this makes no assumptions about the -/// response shape, so error/degraded responses can be asserted on. -pub fn json_rpc_response(stdout: &[u8], id: i64) -> Value { - let stdout_text = String::from_utf8(stdout.to_vec()).unwrap(); - stdout_text - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|response| response.get("id") == Some(&json!(id))) - .unwrap_or_else(|| panic!("missing JSON-RPC response {id} in stdout:\n{stdout_text}")) -} - -/// Asserts that a tools/call response is the degraded-mode error result and -/// returns its notice text. -pub fn degraded_tool_error_text(response: &Value) -> String { - assert_eq!( - response["result"]["isError"], - json!(true), - "degraded tool calls must report an error result:\n{response}" - ); - response["result"]["content"][0]["text"] - .as_str() - .expect("degraded tool error should carry text content") - .to_string() +pub fn canonical_path_string(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .into_owned() } /// Extracts `database.project_root` from the `tracedecay_runtime` tools/call @@ -251,10 +145,3 @@ pub fn runtime_project_root(stdout: &[u8], id: i64) -> String { .expect("runtime should include database.project_root") .to_string() } - -pub fn canonical_path_string(path: &Path) -> String { - path.canonicalize() - .unwrap_or_else(|_| path.to_path_buf()) - .to_string_lossy() - .into_owned() -} diff --git a/tests/mcp_suite/serve_template_path_test.rs b/tests/mcp_suite/serve_template_path_test.rs index 6639cdf20..9b1cc9aff 100644 --- a/tests/mcp_suite/serve_template_path_test.rs +++ b/tests/mcp_suite/serve_template_path_test.rs @@ -1,318 +1,34 @@ -//! `serve --path` tolerance for literal unexpanded `${...}` host template -//! variables (e.g. Cursor headless agent-session MCP scopes spawning -//! `serve --path ${workspaceFolder}` verbatim from the user home). See -//! `cursor-plugin/README.md` for the full rationale. +//! Daemon-only `serve --path` behavior for literal unexpanded host templates. use std::ffi::OsStr; -use std::fs; -use std::path::Path; use serde_json::json; use tempfile::TempDir; use crate::common::canonical_existing_path; -use crate::serve_harness::{ - canonical_path_string, degraded_tool_error_text, init_project_under, init_project_with_file, - json_rpc_response, register_global_project, run_serve_runtime, runtime_project_root, -}; +use crate::serve_harness::run_serve_runtime; -const UNEXPANDED_TEMPLATE_WARNING: &str = "unexpanded template variable"; -const PROJECT_CHOICE_LOG: &str = "tracedecay serve: using project"; - -fn run_serve_runtime_with_path_arg( - home: &Path, - cwd: &Path, - path_arg: &str, -) -> std::process::Output { - run_serve_runtime(home, cwd, Some(OsStr::new(path_arg)), json!({})) -} - -/// Exact reproduction of the Cursor headless agent-session spawn: the host -/// launches `tracedecay serve --path ${workspaceFolder}` with the LITERAL -/// (unexpanded) template variable and cwd set to the user home, not the -/// workspace. `serve` must warn, discard the bogus path, and complete the MCP -/// initialize handshake via discovery instead of exiting with a config error -/// (Cursor never retries a failed MCP scope, so an early exit permanently -/// breaks the connection). #[tokio::test] -async fn literal_workspace_folder_path_from_home_cwd_serves_via_discovery() { +async fn literal_template_without_daemon_fails_closed_before_mcp_handshake() { let home = TempDir::new().unwrap(); - let project = init_project_with_file(home.path(), "pub fn headless_scope_marker() {}\n").await; - register_global_project(home.path(), project.path()).await; - - let home_cwd = canonical_existing_path(home.path()); - let output = run_serve_runtime_with_path_arg(home.path(), &home_cwd, "${workspaceFolder}"); - - assert!( - output.status.success(), - "serve must tolerate a literal ${{workspaceFolder}} --path instead of exiting\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("\"protocolVersion\":\"2024-11-05\""), - "serve should answer the MCP initialize handshake\nstdout:\n{stdout}" - ); - assert_eq!( - canonical_path_string(Path::new(&runtime_project_root(&output.stdout, 2))), - canonical_path_string(project.path()), - "serve should resolve the registered project via discovery" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains(UNEXPANDED_TEMPLATE_WARNING) - && stderr.contains("${workspaceFolder}") - && stderr.contains("project discovery"), - "serve should warn that the host passed an unexpanded template variable\nstderr:\n{stderr}" - ); - assert!( - stderr.contains(PROJECT_CHOICE_LOG), - "serve should log which project the fallback picked and why\nstderr:\n{stderr}" - ); -} - -/// Other unexpanded `${...}` forms (different variable names, default-value -/// syntax) must get the same tolerance as `${workspaceFolder}`. -#[tokio::test] -async fn literal_template_variant_paths_fall_back_to_discovery() { - let home = TempDir::new().unwrap(); - let project = - init_project_with_file(home.path(), "pub fn template_variant_marker() {}\n").await; - register_global_project(home.path(), project.path()).await; - - for path_arg in ["${workspaceRoot}", "${workspaceFolder:-/tmp/never-used}"] { - let cwd = TempDir::new().unwrap(); - - let output = run_serve_runtime_with_path_arg(home.path(), cwd.path(), path_arg); - - assert!( - output.status.success(), - "serve must tolerate the literal template path {path_arg}\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - canonical_path_string(Path::new(&runtime_project_root(&output.stdout, 2))), - canonical_path_string(project.path()), - "serve should resolve the registered project via discovery for {path_arg}" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains(UNEXPANDED_TEMPLATE_WARNING) && stderr.contains(path_arg), - "warning should name the unexpanded variable for {path_arg}\nstderr:\n{stderr}" - ); - } -} - -/// A real directory whose name merely contains `$` (no `${...}` syntax) is a -/// legitimate explicit path and must NOT be diverted through discovery. -#[tokio::test] -async fn explicit_path_with_dollar_sign_directory_is_not_treated_as_template() { - let home = TempDir::new().unwrap(); - let parent = TempDir::new().unwrap(); - let dollar_project = init_project_under( - home.path(), - parent.path(), - "pri$ce-project", - "pub fn dollar_dir_marker() {}\n", - ) - .await; - // A registered decoy proves the explicit path stays authoritative: if the - // `$` path were misread as a template, discovery would serve the decoy. - let decoy = init_project_with_file(home.path(), "pub fn decoy_marker() {}\n").await; - register_global_project(home.path(), decoy.path()).await; - let cwd = TempDir::new().unwrap(); - - let output = - run_serve_runtime_with_path_arg(home.path(), cwd.path(), dollar_project.to_str().unwrap()); - - assert!( - output.status.success(), - "serve should accept an explicit path containing '$'\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - canonical_path_string(Path::new(&runtime_project_root(&output.stdout, 2))), - canonical_path_string(&dollar_project), - "the explicit '$' directory must be served, not a discovery fallback" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stderr.contains(UNEXPANDED_TEMPLATE_WARNING), - "a real '$' directory must not trigger the template warning\nstderr:\n{stderr}" - ); -} - -/// A genuinely wrong explicit path (no template syntax) must keep failing -/// loudly — the template tolerance must not swallow real misconfiguration. -/// It does NOT exit though (a dead process permanently kills the MCP scope): -/// it serves degraded, reporting the missing path from every tool call and -/// never falling back to discovery. -#[tokio::test] -async fn explicit_nonexistent_path_reports_degraded_error_instead_of_discovery() { - let home = TempDir::new().unwrap(); - let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; - register_global_project(home.path(), active.path()).await; - let scratch = TempDir::new().unwrap(); - let missing = scratch.path().join("does-not-exist"); - let cwd = TempDir::new().unwrap(); + let cwd = canonical_existing_path(home.path()); let output = run_serve_runtime( home.path(), - cwd.path(), - Some(missing.as_os_str()), + &cwd, + Some(OsStr::new("${workspaceFolder}")), json!({}), ); + assert!(!output.status.success()); assert!( - output.status.success(), - "a nonexistent explicit --path serves degraded instead of exiting\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let text = degraded_tool_error_text(&json_rpc_response(&output.stdout, 2)); - assert!( - text.contains(&missing.display().to_string()), - "the degraded error should name the missing explicit path\n{text}" + output.stdout.is_empty(), + "daemon-unreachable serve must not synthesize a local MCP response:\n{}", + String::from_utf8_lossy(&output.stdout) ); - assert!( - !text.contains(&active.path().display().to_string()), - "an explicit path must never fall back to the registered discovery project\n{text}" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains(&missing.display().to_string()), - "error should name the missing explicit path\nstderr:\n{stderr}" - ); - assert!( - !stderr.contains(UNEXPANDED_TEMPLATE_WARNING), - "a plain nonexistent path must not be mistaken for a template\nstderr:\n{stderr}" - ); -} - -/// Pins the template decision: no-path discovery from a home-directory cwd -/// cannot pick between multiple same-depth registered projects, which is why -/// the Cursor plugin template keeps `--path ${workspaceFolder}` (normal -/// windows expand it) and relies on the literal-template fallback only when -/// the host fails to expand. When that fallback cannot find a unique project, -/// the actionable ambiguity error must surface rather than an arbitrary -/// project. -#[tokio::test] -async fn literal_workspace_folder_with_multiple_projects_reports_ambiguity() { - let home = TempDir::new().unwrap(); - let home_cwd = canonical_existing_path(home.path()); - let projects_dir = home_cwd.join("projects"); - fs::create_dir_all(&projects_dir).unwrap(); - let alpha = init_project_under( - home.path(), - &projects_dir, - "alpha", - "pub fn alpha_marker() {}\n", - ) - .await; - let beta = init_project_under( - home.path(), - &projects_dir, - "beta", - "pub fn beta_marker() {}\n", - ) - .await; - register_global_project(home.path(), &alpha).await; - register_global_project(home.path(), &beta).await; - - let output = run_serve_runtime_with_path_arg(home.path(), &home_cwd, "${workspaceFolder}"); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "ambiguity serves degraded (Cursor never retries a dead scope) instead of exiting\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - stderr - ); - let text = degraded_tool_error_text(&json_rpc_response(&output.stdout, 2)); - assert!( - text.contains("Multiple tracedecay projects found"), - "ambiguous discovery must not silently pick a project\n{text}" - ); - assert!( - stderr.contains(UNEXPANDED_TEMPLATE_WARNING), - "the template warning should still be emitted before the ambiguity error\nstderr:\n{stderr}" - ); - assert!( - stderr.contains("Multiple tracedecay projects found"), - "stderr should surface the actionable ambiguity error\nstderr:\n{stderr}" - ); -} - -/// The wrong-project guard: with registered projects at DIFFERENT depths -/// (`~/proj-a` and `~/work/proj-b`), genuine no-path serve may use the cwd -/// depth heuristic (the user ran it from that directory), but the -/// discarded-template fallback must not — the host's spawn directory says -/// nothing about the intended workspace, so silently serving the shallower -/// project would attach the wrong index to the session. It must require a -/// unique registry match and surface the ambiguity error otherwise. -#[tokio::test] -async fn literal_template_with_different_depth_projects_is_stricter_than_no_path() { - let home = TempDir::new().unwrap(); - let home_cwd = canonical_existing_path(home.path()); - let shallow = init_project_under( - home.path(), - &home_cwd, - "proj-a", - "pub fn shallow_marker() {}\n", - ) - .await; - let work_dir = home_cwd.join("work"); - fs::create_dir_all(&work_dir).unwrap(); - let deep = init_project_under( - home.path(), - &work_dir, - "proj-b", - "pub fn deep_marker() {}\n", - ) - .await; - register_global_project(home.path(), &shallow).await; - register_global_project(home.path(), &deep).await; - - // Genuine no-path serve resolves via the cwd depth heuristic (shallowest - // registered descendant of cwd wins) — unchanged behavior. - let no_path = run_serve_runtime(home.path(), &home_cwd, None, json!({})); - assert!( - no_path.status.success(), - "genuine no-path serve should still resolve via the cwd heuristic\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&no_path.stdout), - String::from_utf8_lossy(&no_path.stderr) - ); - assert_eq!( - canonical_path_string(Path::new(&runtime_project_root(&no_path.stdout, 2))), - canonical_path_string(&shallow), - "no-path serve should pick the shallowest registered descendant of cwd" - ); - - // The discarded-template fallback must be stricter: same registry, same - // cwd, but no unique match — so it reports the ambiguity (from degraded - // mode) instead of guessing. - let templated = run_serve_runtime_with_path_arg(home.path(), &home_cwd, "${workspaceFolder}"); - let stderr = String::from_utf8_lossy(&templated.stderr); - assert!( - templated.status.success(), - "the template fallback serves degraded instead of exiting\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&templated.stdout), - stderr - ); - let text = degraded_tool_error_text(&json_rpc_response(&templated.stdout, 2)); - assert!( - text.contains("Multiple tracedecay projects found"), - "the template fallback must not silently depth-rank projects\n{text}" - ); - assert!( - stderr.contains("Multiple tracedecay projects found"), - "stderr should surface the actionable ambiguity error\nstderr:\n{stderr}" - ); - assert!( - !stderr.contains(PROJECT_CHOICE_LOG), - "no project-choice log should be emitted when nothing was picked\nstderr:\n{stderr}" + stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + "expected explicit daemon-unavailable error, got:\n{stderr}" ); } diff --git a/tests/memory_suite/memory_test.rs b/tests/memory_suite/memory_test.rs index b0f8e08a5..ce044f7cf 100644 --- a/tests/memory_suite/memory_test.rs +++ b/tests/memory_suite/memory_test.rs @@ -430,7 +430,7 @@ async fn memory_bank_fact_count(db: &Database, bank_name: &str) -> Option { } async fn clear_fact_vector(cg: &TraceDecay, fact_id: i64) { - let (db, _) = Database::open(&cg.store_layout().graph_db_path) + let (db, _) = crate::common::open_test_database(&cg.store_layout().graph_db_path) .await .unwrap(); db.conn() @@ -446,7 +446,7 @@ async fn clear_fact_vector(cg: &TraceDecay, fact_id: i64) { } async fn set_fact_updated_at(cg: &TraceDecay, fact_id: i64, updated_at: i64) { - let (db, _) = Database::open(&cg.store_layout().graph_db_path) + let (db, _) = crate::common::open_test_database(&cg.store_layout().graph_db_path) .await .unwrap(); db.conn() @@ -460,7 +460,7 @@ async fn set_fact_updated_at(cg: &TraceDecay, fact_id: i64, updated_at: i64) { } async fn fact_updated_at(cg: &TraceDecay, fact_id: i64) -> i64 { - let (db, _) = Database::open(&cg.store_layout().graph_db_path) + let (db, _) = crate::common::open_test_database(&cg.store_layout().graph_db_path) .await .unwrap(); let mut rows = db @@ -1351,7 +1351,7 @@ async fn remove_fact_defers_vacuum_while_peer_connections_are_live() { .await .unwrap(); let size_after_insert = std::fs::metadata(&db_path).unwrap().len(); - let (peer, _) = Database::open(&db_path).await.unwrap(); + let (peer, _) = crate::common::open_test_database(&db_path).await.unwrap(); for fact_id in fact_ids { assert!(store.remove_fact(fact_id).await.unwrap()); @@ -1371,7 +1371,7 @@ async fn remove_fact_defers_vacuum_while_peer_connections_are_live() { 0, "a peer opened before deletion must remain usable" ); - let (fresh, _) = Database::open(&db_path).await.unwrap(); + let (fresh, _) = crate::common::open_test_database(&db_path).await.unwrap(); assert_eq!( scalar_i64(&fresh, "SELECT COUNT(*) FROM memory_facts").await, 0, diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index c45fad85e..d8131e578 100644 --- a/tests/storage_suite/corruption_test.rs +++ b/tests/storage_suite/corruption_test.rs @@ -14,7 +14,7 @@ use crate::support; use std::io::{Seek, Write}; use tempfile::TempDir; use tracedecay::db::Database; -use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; +use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions, try_acquire_sync_lock}; use tracedecay::types::*; /// Helper: create a temp database and return (Database, TempDir, db_path). @@ -25,7 +25,7 @@ async fn setup_db() -> (Database, TempDir, std::path::PathBuf) { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("test.db"); support::seed_latest_graph_db(&db_path).await; - let (db, migrated) = Database::open(&db_path) + let (db, migrated) = crate::common::open_test_database(&db_path) .await .expect("failed to open template database"); assert!(!migrated, "template database should not require migration"); @@ -38,7 +38,7 @@ async fn writable_open_bootstraps_a_missing_database_path() { let db_path = dir.path().join("new.db"); assert!(!db_path.exists()); - let (db, _) = Database::open(&db_path) + let (db, _) = crate::common::open_test_database(&db_path) .await .expect("writable open should preserve fresh-path bootstrap behavior"); assert!(db_path.exists()); @@ -135,7 +135,7 @@ async fn quick_check_detects_page_level_corruption() { } // Reopen — quick_check should detect the corruption - let (db2, _) = Database::open(&db_path) + let (db2, _) = crate::common::open_test_database(&db_path) .await .expect("open should succeed even with corruption"); let intact = db2.quick_check().await.unwrap(); @@ -355,6 +355,124 @@ fn dirty_sentinel_survives_drop() { assert!(dirty_path.exists(), "sentinel must survive scope drop"); } +#[tokio::test] +async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { + if let Ok(mode) = std::env::var("TRACEDECAY_TEST_LOCK_CHILD") { + let project = std::path::PathBuf::from( + std::env::var_os("TRACEDECAY_TEST_LOCK_PROJECT").expect("child project path"), + ); + let ready = std::path::PathBuf::from( + std::env::var_os("TRACEDECAY_TEST_LOCK_READY").expect("child ready path"), + ); + let guard = try_acquire_sync_lock(&project).expect("child lock lease"); + std::fs::write(&ready, b"ready").expect("publish child readiness"); + if mode == "crash" { + std::mem::forget(guard); + std::process::exit(86); + } + let release = std::path::PathBuf::from( + std::env::var_os("TRACEDECAY_TEST_LOCK_RELEASE").expect("child release path"), + ); + while !release.exists() { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + drop(guard); + return; + } + + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + std::fs::create_dir_all(&project).unwrap(); + let ts = TraceDecay::init(&project).await.unwrap(); + let lock_path = ts.store_layout().sync_lock_path.clone(); + ts.close(); + + let run_child = |mode: &str, release: Option<&std::path::Path>| { + let ready = dir.path().join(format!("{mode}.ready")); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .arg("persistent_sync_lock_coordinates_processes_and_recovers_after_crash") + .arg("--nocapture") + .env("TRACEDECAY_TEST_LOCK_CHILD", mode) + .env("TRACEDECAY_TEST_LOCK_PROJECT", &project) + .env("TRACEDECAY_TEST_LOCK_READY", &ready) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Some(path) = release { + command.env("TRACEDECAY_TEST_LOCK_RELEASE", path); + } + let mut child = command.spawn().unwrap(); + for _ in 0..1_000 { + if ready.exists() { + return child; + } + if let Some(status) = child.try_wait().unwrap() { + panic!("lock child exited before acquiring its lease: {status}"); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let _ = child.kill(); + panic!("lock child did not acquire its lease"); + }; + + let release = dir.path().join("release"); + let mut holder = run_child("hold", Some(&release)); + assert!( + try_acquire_sync_lock(&project).is_err(), + "a second process must not enter while the kernel lease is held" + ); + std::fs::write(&release, b"release").unwrap(); + assert!(holder.wait().unwrap().success()); + + let guard = try_acquire_sync_lock(&project).expect("released lease must be reusable"); + drop(guard); + assert!(lock_path.exists(), "the lockfile must persist after Drop"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777, + 0o600, + "persistent lock metadata must be owner-only" + ); + } + + let mut crashed = run_child("crash", None); + assert_eq!(crashed.wait().unwrap().code(), Some(86)); + assert!(lock_path.exists(), "the lockfile must survive a crash"); + drop(try_acquire_sync_lock(&project).expect("kernel must release a crashed lease")); +} + +#[tokio::test] +async fn structured_dirty_marker_is_cleared_after_epoch_owned_recovery() +-> std::result::Result<(), Box> { + let dir = TempDir::new()?; + let project_root = dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let open_options = TraceDecayOpenOptions { + profile_root: Some(dir.path().join("profile")), + global_db_path: Some(dir.path().join("global.db")), + }; + let ts = TraceDecay::init_with_options(&project_root, open_options.clone()).await?; + let layout = ts.store_layout().clone(); + ts.close(); + + std::fs::write( + &layout.dirty_path, + format!( + r#"{{"schema":2,"owner":{{"pid":{}}},"epoch":"fixture-epoch","state":"dirty","time":0,"version":"test"}}"#, + std::process::id() + ), + )?; + let recovered = TraceDecay::open_with_options(&project_root, open_options).await?; + assert!( + !layout.dirty_path.exists(), + "recovery may clear only the epoch it adopted under the lock lease" + ); + recovered.close(); + Ok(()) +} + // ─── Full crash→detect→repair cycle ────────────────────────────────────── #[tokio::test] @@ -534,7 +652,9 @@ async fn corrupt_db_detected_and_repaired_on_reopen() { let db_path = dir.path().join("test.db"); // Create and populate a database - let (db, _) = Database::initialize(&db_path).await.unwrap(); + let (db, _) = crate::common::initialize_test_database(&db_path) + .await + .unwrap(); let nodes: Vec = (0..50) .map(|i| sample_node(&format!("d{i}"), &format!("func_{i}"))) .collect(); @@ -556,7 +676,7 @@ async fn corrupt_db_detected_and_repaired_on_reopen() { } // Reopen — should be able to open but quick_check fails - let open_result = Database::open(&db_path).await; + let open_result = crate::common::open_test_database(&db_path).await; match open_result { Ok((db2, _)) => { let intact = db2.quick_check().await.unwrap(); @@ -583,7 +703,9 @@ async fn corrupt_db_detected_and_repaired_on_reopen() { wal.set_extension("db-shm"); std::fs::remove_file(&wal).ok(); - let (db3, _) = Database::initialize(&db_path).await.unwrap(); + let (db3, _) = crate::common::initialize_test_database(&db_path) + .await + .unwrap(); assert!( db3.quick_check().await.unwrap(), "fresh db after recovery should be healthy" @@ -591,142 +713,4 @@ async fn corrupt_db_detected_and_repaired_on_reopen() { close_db(db3).await; } -#[tokio::test] -async fn fts_corruption_falls_back_without_rebuild_or_write() { - let (db, _dir, db_path) = setup_db().await; - - // Insert data so FTS has content - let nodes = vec![ - sample_node("e1", "important_handler"), - sample_node("e2", "other_helper"), - ]; - db.insert_nodes(&nodes).await.unwrap(); - - // Verify search works - let results = db.search_nodes("important_handler", 10).await.unwrap(); - assert_eq!(results[0].node.id, "e1"); - - // Capture an FTS segment, then corrupt only its payload on disk. The nodes - // table and primary database B-trees remain healthy. - let mut rows = db - .conn() - .query( - "SELECT block FROM nodes_fts_data WHERE id > 10 ORDER BY id DESC LIMIT 1", - (), - ) - .await - .unwrap(); - let segment = rows - .next() - .await - .unwrap() - .unwrap() - .get::>(0) - .unwrap(); - drop(rows); - db.checkpoint().await.unwrap(); - db.close(); - - // Corrupt both FTS and an unrelated table. Checking only `nodes` would - // incorrectly permit the LIKE fallback because its B-tree is still sound. - let mut bytes = std::fs::read(&db_path).unwrap(); - let offset = bytes - .windows(segment.len()) - .position(|candidate| candidate == segment) - .expect("FTS segment must be present in the checkpointed database"); - bytes[offset..offset + 8].fill(0xff); - std::fs::write(&db_path, bytes).unwrap(); - - let (db, _) = Database::open(&db_path).await.unwrap(); - assert!( - !db.quick_check().await.unwrap(), - "fixture must trigger SQLite's FTS integrity failure" - ); - let changes_before = db.conn().total_changes(); - - let results = db.search_nodes("important_handler", 10).await.unwrap(); - assert_eq!(results[0].node.id, "e1", "LIKE fallback must still match"); - assert_eq!( - db.conn().total_changes(), - changes_before, - "search must not rebuild or otherwise write" - ); - - let mut rows = db - .conn() - .query( - "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH '\"important_handler\"*'", - (), - ) - .await - .unwrap(); - assert!( - rows.next().await.is_err(), - "the corrupt FTS index must remain untouched for offline repair" - ); - drop(rows); - close_db(db).await; -} - -#[tokio::test] -async fn whole_database_corruption_propagates_without_write() { - let (db, _dir, db_path) = setup_db().await; - db.insert_nodes(&[sample_node("whole-db", "whole_db_probe")]) - .await - .unwrap(); - - let mut rows = db - .conn() - .query( - "SELECT block FROM nodes_fts_data WHERE id > 10 ORDER BY id DESC LIMIT 1", - (), - ) - .await - .unwrap(); - let segment = rows - .next() - .await - .unwrap() - .unwrap() - .get::>(0) - .unwrap(); - drop(rows); - let mut rows = db - .conn() - .query( - "SELECT rootpage FROM sqlite_schema WHERE name = 'edges'", - (), - ) - .await - .unwrap(); - let root_page = rows.next().await.unwrap().unwrap().get::(0).unwrap() as u64; - drop(rows); - let mut rows = db.conn().query("PRAGMA page_size", ()).await.unwrap(); - let page_size = rows.next().await.unwrap().unwrap().get::(0).unwrap() as u64; - drop(rows); - db.checkpoint().await.unwrap(); - db.close(); - - let mut bytes = std::fs::read(&db_path).unwrap(); - let fts_offset = bytes - .windows(segment.len()) - .position(|candidate| candidate == segment) - .expect("FTS segment must be present in the checkpointed database"); - bytes[fts_offset..fts_offset + 8].fill(0xff); - bytes[((root_page - 1) * page_size) as usize] = 0xff; - std::fs::write(&db_path, bytes).unwrap(); - - let (db, _) = Database::open(&db_path).await.unwrap(); - let changes_before = db.conn().total_changes(); - let error = db.search_nodes("whole_db_probe", 10).await.unwrap_err(); - assert!( - Database::is_corruption_error(&error), - "unexpected error: {error}" - ); - assert_eq!( - db.conn().total_changes(), - changes_before, - "search must not write while reporting whole-database corruption" - ); - db.close(); -} +mod fallback; diff --git a/tests/storage_suite/corruption_test/fallback.rs b/tests/storage_suite/corruption_test/fallback.rs new file mode 100644 index 000000000..893e46da3 --- /dev/null +++ b/tests/storage_suite/corruption_test/fallback.rs @@ -0,0 +1,141 @@ +use super::*; + +#[tokio::test] +async fn fts_corruption_falls_back_without_rebuild_or_write() { + let (db, _dir, db_path) = setup_db().await; + + // Insert data so FTS has content + let nodes = vec![ + sample_node("e1", "important_handler"), + sample_node("e2", "other_helper"), + ]; + db.insert_nodes(&nodes).await.unwrap(); + + // Verify search works + let results = db.search_nodes("important_handler", 10).await.unwrap(); + assert_eq!(results[0].node.id, "e1"); + + // Capture an FTS segment, then corrupt only its payload on disk. The nodes + // table and primary database B-trees remain healthy. + let mut rows = db + .conn() + .query( + "SELECT block FROM nodes_fts_data WHERE id > 10 ORDER BY id DESC LIMIT 1", + (), + ) + .await + .unwrap(); + let segment = rows + .next() + .await + .unwrap() + .unwrap() + .get::>(0) + .unwrap(); + drop(rows); + db.checkpoint().await.unwrap(); + db.close(); + + // Corrupt both FTS and an unrelated table. Checking only `nodes` would + // incorrectly permit the LIKE fallback because its B-tree is still sound. + let mut bytes = std::fs::read(&db_path).unwrap(); + let offset = bytes + .windows(segment.len()) + .position(|candidate| candidate == segment) + .expect("FTS segment must be present in the checkpointed database"); + bytes[offset..offset + 8].fill(0xff); + std::fs::write(&db_path, bytes).unwrap(); + + let (db, _) = crate::common::open_test_database(&db_path).await.unwrap(); + assert!( + !db.quick_check().await.unwrap(), + "fixture must trigger SQLite's FTS integrity failure" + ); + let changes_before = db.conn().total_changes(); + + let results = db.search_nodes("important_handler", 10).await.unwrap(); + assert_eq!(results[0].node.id, "e1", "LIKE fallback must still match"); + assert_eq!( + db.conn().total_changes(), + changes_before, + "search must not rebuild or otherwise write" + ); + + let mut rows = db + .conn() + .query( + "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH '\"important_handler\"*'", + (), + ) + .await + .unwrap(); + assert!( + rows.next().await.is_err(), + "the corrupt FTS index must remain untouched for offline repair" + ); + drop(rows); + close_db(db).await; +} + +#[tokio::test] +async fn whole_database_corruption_propagates_without_write() { + let (db, _dir, db_path) = setup_db().await; + db.insert_nodes(&[sample_node("whole-db", "whole_db_probe")]) + .await + .unwrap(); + + let mut rows = db + .conn() + .query( + "SELECT block FROM nodes_fts_data WHERE id > 10 ORDER BY id DESC LIMIT 1", + (), + ) + .await + .unwrap(); + let segment = rows + .next() + .await + .unwrap() + .unwrap() + .get::>(0) + .unwrap(); + drop(rows); + let mut rows = db + .conn() + .query( + "SELECT rootpage FROM sqlite_schema WHERE name = 'edges'", + (), + ) + .await + .unwrap(); + let root_page = rows.next().await.unwrap().unwrap().get::(0).unwrap() as u64; + drop(rows); + let mut rows = db.conn().query("PRAGMA page_size", ()).await.unwrap(); + let page_size = rows.next().await.unwrap().unwrap().get::(0).unwrap() as u64; + drop(rows); + db.checkpoint().await.unwrap(); + db.close(); + + let mut bytes = std::fs::read(&db_path).unwrap(); + let fts_offset = bytes + .windows(segment.len()) + .position(|candidate| candidate == segment) + .expect("FTS segment must be present in the checkpointed database"); + bytes[fts_offset..fts_offset + 8].fill(0xff); + bytes[((root_page - 1) * page_size) as usize] = 0xff; + std::fs::write(&db_path, bytes).unwrap(); + + let (db, _) = crate::common::open_test_database(&db_path).await.unwrap(); + let changes_before = db.conn().total_changes(); + let error = db.search_nodes("whole_db_probe", 10).await.unwrap_err(); + assert!( + Database::is_corruption_error(&error), + "unexpected error: {error}" + ); + assert_eq!( + db.conn().total_changes(), + changes_before, + "search must not write while reporting whole-database corruption" + ); + db.close(); +} diff --git a/tests/storage_suite/db_query_test.rs b/tests/storage_suite/db_query_test.rs index 3e12c541c..8f26a5bd7 100644 --- a/tests/storage_suite/db_query_test.rs +++ b/tests/storage_suite/db_query_test.rs @@ -24,7 +24,7 @@ async fn setup_db() -> TestDb { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("test.db"); support::seed_latest_graph_db(&db_path).await; - let (db, migrated) = Database::open(&db_path) + let (db, migrated) = crate::common::open_test_database(&db_path) .await .expect("failed to open template database"); assert!( @@ -107,7 +107,7 @@ async fn test_empty_db_template_cache_seeds_without_migration() { "template database should not be empty" ); - let (_db, migrated) = Database::open(&db_path) + let (_db, migrated) = crate::common::open_test_database(&db_path) .await .expect("failed to open template database"); assert!( @@ -2527,7 +2527,7 @@ async fn test_fts_name_match_outranks_docstring_match() { #[tokio::test] async fn test_batch_incoming_call_counts() { let dir = tempfile::TempDir::new().unwrap(); - let (db, _) = Database::initialize(&dir.path().join("test.db")) + let (db, _) = crate::common::initialize_test_database(&dir.path().join("test.db")) .await .unwrap(); @@ -2870,7 +2870,9 @@ async fn test_attrs_start_line_null_falls_back_to_start_line() { drop(conn); drop(raw); - let (db, _migrated) = Database::open(&db_path).await.expect("open db"); + let (db, _migrated) = crate::common::open_test_database(&db_path) + .await + .expect("open db"); let fetched = db .get_node_by_id("legacy") .await diff --git a/tests/storage_suite/db_test.rs b/tests/storage_suite/db_test.rs index 131367e1b..2fa4ef54f 100644 --- a/tests/storage_suite/db_test.rs +++ b/tests/storage_suite/db_test.rs @@ -14,7 +14,7 @@ async fn setup_db() -> (Database, TempDir) { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("test.db"); support::seed_latest_graph_db(&db_path).await; - let (db, migrated) = Database::open(&db_path) + let (db, migrated) = crate::common::open_test_database(&db_path) .await .expect("failed to open template database"); assert!(!migrated, "template database should not require migration"); @@ -54,7 +54,7 @@ fn sample_node(id: &str, name: &str, file_path: &str) -> Node { async fn test_initialize_creates_database() { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("subdir").join("code_graph.db"); - let (_db, _) = Database::initialize(&db_path) + let (_db, _) = crate::common::initialize_test_database(&db_path) .await .expect("failed to initialize database"); assert!( @@ -68,7 +68,7 @@ async fn test_initialize_creates_database() { async fn test_open_read_only_reads_existing_database_without_write_pragmas() { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("code_graph.db"); - let (db, _) = Database::initialize(&db_path) + let (db, _) = crate::common::initialize_test_database(&db_path) .await .expect("failed to initialize database"); db.insert_node(&sample_node("node-1", "process_data", "src/main.rs")) @@ -84,7 +84,7 @@ async fn test_open_read_only_reads_existing_database_without_write_pragmas() { permissions.set_mode(0o444); std::fs::set_permissions(&db_path, permissions).expect("failed to mark database readonly"); - let (db, migrated) = Database::open_read_only(&db_path) + let (db, migrated) = crate::common::open_test_database_read_only(&db_path) .await .expect("readonly database should open"); let stats = db @@ -548,7 +548,9 @@ async fn test_migrate_is_idempotent_at_latest() { // re-runs of v7's ALTER TABLE on an already-migrated DB. let dir = TempDir::new().expect("tempdir"); let db_path = dir.path().join("idem.db"); - let (db, _) = Database::initialize(&db_path).await.expect("initialize"); + let (db, _) = crate::common::initialize_test_database(&db_path) + .await + .expect("initialize"); drop(db); let lib_db = libsql::Builder::new_local(&db_path) diff --git a/tests/storage_suite/main.rs b/tests/storage_suite/main.rs index 5df6ff907..62c0b147f 100644 --- a/tests/storage_suite/main.rs +++ b/tests/storage_suite/main.rs @@ -20,6 +20,7 @@ mod global_registry_test; mod migrate_inventory_test; mod migration_manifest_test; mod migration_test; +mod multi_connection_test; mod profile_storage_migration_test; mod storage_resolver_test; mod worktree_canonical_root_guard_test; diff --git a/tests/storage_suite/migration_manifest_test.rs b/tests/storage_suite/migration_manifest_test.rs index e180bb776..59ff5cec8 100644 --- a/tests/storage_suite/migration_manifest_test.rs +++ b/tests/storage_suite/migration_manifest_test.rs @@ -464,7 +464,7 @@ async fn verify_manifest_accepts_logically_equal_sqlite_artifacts_with_different fs::create_dir_all(&data_dir).unwrap(); fs::create_dir_all(&data_root).unwrap(); - let (source, _) = tracedecay::db::Database::initialize(&source_db) + let (source, _) = crate::common::initialize_test_database(&source_db) .await .unwrap(); source @@ -474,7 +474,7 @@ async fn verify_manifest_accepts_logically_equal_sqlite_artifacts_with_different source.checkpoint().await.unwrap(); source.close(); - let (target, _) = tracedecay::db::Database::initialize(&target_db) + let (target, _) = crate::common::initialize_test_database(&target_db) .await .unwrap(); target diff --git a/tests/storage_suite/migration_test.rs b/tests/storage_suite/migration_test.rs index 5e2f89ad9..29646c680 100644 --- a/tests/storage_suite/migration_test.rs +++ b/tests/storage_suite/migration_test.rs @@ -3,7 +3,6 @@ use std::path::Path; use libsql::{Builder, Connection, Database as LibsqlDatabase}; use tempfile::TempDir; -use tracedecay::db::Database; use tracedecay::db::migrations::{create_schema, migrate}; use crate::support; @@ -819,7 +818,7 @@ async fn test_database_initialize_creates_latest_version() { let dir = TempDir::new().expect("failed to create temp dir"); let db_path = dir.path().join("init_test.db"); - let (db, _migrated) = Database::initialize(&db_path) + let (db, _migrated) = crate::common::initialize_test_database(&db_path) .await .expect("Database::initialize should succeed"); @@ -836,7 +835,7 @@ async fn test_database_open_no_migration_needed() { support::seed_latest_graph_db(&db_path).await; // Open the same database — should not migrate - let (_db2, migrated) = Database::open(&db_path) + let (_db2, migrated) = crate::common::open_test_database(&db_path) .await .expect("Database::open should succeed"); @@ -869,7 +868,7 @@ async fn test_database_open_migrates_v1_to_latest() { } // Open via Database::open — should detect v1 and migrate to latest - let (db, migrated) = Database::open(&db_path) + let (db, migrated) = crate::common::open_test_database(&db_path) .await .expect("Database::open should succeed"); diff --git a/tests/storage_suite/multi_connection_test.rs b/tests/storage_suite/multi_connection_test.rs new file mode 100644 index 000000000..b5eee234e --- /dev/null +++ b/tests/storage_suite/multi_connection_test.rs @@ -0,0 +1,780 @@ +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Arc, Barrier}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tempfile::TempDir; +use tracedecay::db::{Database, DatabaseAuthority, SQLITE_UNSAFE_FAST_ENV}; +use tracedecay::storage::{default_profile_project_id, profile_sharded_data_root}; + +use crate::common; + +const PROCESS_TIMEOUT: Duration = Duration::from_secs(20); +const CLIENT_COUNT: usize = 12; +const CONCURRENT_CLIENTS_PER_PATH: usize = 4; + +struct ChildGuard(Child); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(child) + } +} + +impl std::ops::Deref for ChildGuard { + type Target = Child; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ChildGuard { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + stop_child(&mut self.0); + } +} + +fn init_project(home: &Path, project: &Path, socket_path: &Path) -> PathBuf { + std::fs::create_dir_all(project.join("src")).expect("create fixture source directory"); + std::fs::write( + project.join("src/lib.rs"), + "pub fn broker_fixture() -> u32 { 42 }\n", + ) + .expect("write fixture source"); + + let output = common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("init") + .current_dir(project) + .output() + .expect("tracedecay init should run"); + assert_command_success("tracedecay init", &output); + + let profile_root = home.join(".tracedecay"); + let data_root = profile_sharded_data_root(&profile_root, &default_profile_project_id(project)); + data_root.join(tracedecay::config::db_filename(&data_root)) +} + +fn assert_command_success(label: &str, output: &std::process::Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn wait_for_socket(socket_path: &Path, child: &mut Child) { + let deadline = Instant::now() + PROCESS_TIMEOUT; + loop { + if std::os::unix::net::UnixStream::connect(socket_path).is_ok() { + return; + } + if let Some(status) = child.try_wait().expect("read daemon status") { + panic!("daemon exited before opening socket: {status}"); + } + assert!( + Instant::now() < deadline, + "daemon socket did not become ready" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn spawn_daemon(home: &Path, socket_path: &Path) -> ChildGuard { + let mut child = ChildGuard::new( + common::tracedecay_command_with_home(home) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["daemon", "run", "--socket"]) + .arg(socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn daemon"), + ); + wait_for_socket(socket_path, &mut child); + child +} + +fn stop_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn wait_for_exit(child: &mut Child) -> Option { + let deadline = Instant::now() + PROCESS_TIMEOUT; + loop { + if let Some(status) = child.try_wait().expect("read child status") { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +struct McpProxy { + child: ChildGuard, + stdin: ChildStdin, + stdout: BufReader, +} + +impl McpProxy { + fn spawn(home: &Path, project: &Path, socket_path: &Path, ordinal: usize) -> Self { + let mut child = ChildGuard::new( + common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env( + "TRACEDECAY_CLIENT_INSTANCE_ID", + format!("broker-test-{ordinal}"), + ) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["serve", "--path"]) + .arg(project) + .current_dir(project) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn MCP proxy"), + ); + let stdin = child.stdin.take().expect("proxy stdin"); + let stdout = BufReader::new(child.stdout.take().expect("proxy stdout")); + let mut proxy = Self { + child, + stdin, + stdout, + }; + proxy.request(1, "initialize", json!({})); + proxy.request( + 2, + "tools/call", + json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), + ); + proxy + } + + fn request(&mut self, id: u64, method: &str, params: Value) -> Value { + writeln!( + self.stdin, + "{}", + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + ) + .expect("write MCP request"); + self.stdin.flush().expect("flush MCP request"); + + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let proxy_pid = self.child.id(); + let watchdog = std::thread::spawn(move || { + if matches!( + done_rx.recv_timeout(PROCESS_TIMEOUT), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ) { + let _ = Command::new("kill") + .args(["-KILL", &proxy_pid.to_string()]) + .status(); + } + }); + let mut matched = None; + for _ in 0..32 { + let mut line = String::new(); + let bytes = self.stdout.read_line(&mut line).expect("read MCP response"); + assert_ne!(bytes, 0, "MCP proxy exited before response {id}"); + let response: Value = serde_json::from_str(&line).expect("valid MCP response"); + if response.get("id").and_then(Value::as_u64) == Some(id) { + matched = Some(response); + break; + } + } + let _ = done_tx.send(()); + watchdog.join().expect("MCP response watchdog panicked"); + let response = matched.unwrap_or_else(|| { + panic!("MCP response {id} was hidden behind too many notifications") + }); + assert!( + response.get("error").is_none(), + "MCP request {id} failed: {response}" + ); + response + } +} + +#[cfg(target_os = "linux")] +fn sqlite_handles(pid: u32, profile_root: &Path) -> Vec { + let mut handles = Vec::new(); + let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { + return handles; + }; + for entry in entries.flatten() { + let Ok(target) = std::fs::read_link(entry.path()) else { + continue; + }; + let rendered = target.to_string_lossy(); + if target.starts_with(profile_root) + && (rendered.contains(".db") + || rendered.ends_with("-wal") + || rendered.ends_with("-shm")) + { + handles.push(target); + } + } + handles.sort(); + handles +} + +fn file_identity(path: &Path) -> Option<(u64, u64)> { + std::fs::metadata(path) + .ok() + .map(|metadata| (metadata.dev(), metadata.ino())) +} + +fn storage_snapshot(db_path: &Path) -> BTreeMap> { + let mut paths = vec![db_path.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + paths.push(PathBuf::from(format!("{}{suffix}", db_path.display()))); + } + paths + .into_iter() + .filter_map(|path| std::fs::read(&path).ok().map(|bytes| (path, bytes))) + .collect() +} + +fn daemon_authority_record(home: &Path) -> Value { + serde_json::from_slice( + &std::fs::read(home.join(".tracedecay/daemon-authority.json")) + .expect("read daemon authority record"), + ) + .expect("parse daemon authority record") +} + +fn tool_status(home: &Path, project: &Path, socket_path: &Path) -> std::process::Output { + let project_arg = project.to_string_lossy().to_string(); + common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(project) + .args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + "--format", + "json", + ]) + .output() + .expect("run tool status") +} + +#[test] +fn twelve_mcp_cli_and_hook_clients_share_one_daemon_sqlite_owner() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let profile_root = home_path.join(".tracedecay"); + let socket_path = common::daemon_socket_path(&home_path); + let mut daemon = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + + let mut clients = (0..CLIENT_COUNT) + .map(|ordinal| McpProxy::spawn(&home_path, &project_path, &socket_path, ordinal)) + .collect::>(); + + #[cfg(target_os = "linux")] + { + for client in &clients { + assert_eq!( + sqlite_handles(client.child.id(), &profile_root), + Vec::::new(), + "MCP proxy must not own any profile SQLite handle" + ); + } + let daemon_handles = sqlite_handles(daemon.id(), &profile_root); + assert!( + daemon_handles.iter().any(|path| path == &db_path), + "daemon must own the graph DB; handles: {daemon_handles:?}" + ); + } + let authority_before = daemon_authority_record(&home_path); + assert_eq!( + authority_before["pid"], + daemon.id(), + "profile authority must name the daemon" + ); + assert_eq!( + authority_before["profile_root"].as_str(), + profile_root.to_str(), + "profile authority must use the canonical profile root" + ); + assert!( + authority_before["epoch"] + .as_u64() + .is_some_and(|epoch| epoch > 0), + "profile authority must publish a nonzero epoch" + ); + + let db_identity = file_identity(&db_path).expect("graph DB identity"); + let hook_event = json!({ + "hook_event_name": "afterFileEdit", + "file_path": project_path.join("src/lib.rs"), + "workspace_roots": [&project_path], + }) + .to_string(); + std::thread::scope(|scope| { + let start = Arc::new(Barrier::new(3 * CONCURRENT_CLIENTS_PER_PATH + 1)); + let mut requests = Vec::new(); + for (ordinal, client) in clients + .iter_mut() + .take(CONCURRENT_CLIENTS_PER_PATH) + .enumerate() + { + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + client.request( + 100 + ordinal as u64, + "tools/call", + json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), + ); + })); + } + for _ in 0..CONCURRENT_CLIENTS_PER_PATH { + let home_path = &home_path; + let project_path = &project_path; + let socket_path = &socket_path; + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + let project_arg = project_path.to_string_lossy().to_string(); + let mut tool = ChildGuard::new( + common::tracedecay_command_with_home(home_path) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(project_path) + .args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + "--format", + "json", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn brokered tool status"), + ); + let status = wait_for_exit(&mut tool) + .unwrap_or_else(|| panic!("tool client exceeded {PROCESS_TIMEOUT:?}")); + assert!(status.success(), "brokered tool status failed"); + })); + } + for _ in 0..CONCURRENT_CLIENTS_PER_PATH { + let home_path = &home_path; + let project_path = &project_path; + let socket_path = &socket_path; + let hook_event = &hook_event; + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + let mut hook = ChildGuard::new( + common::tracedecay_command_with_home(home_path) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("hook-cursor-after-file-edit") + .current_dir(project_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn hook client"), + ); + hook.stdin + .as_mut() + .expect("hook stdin") + .write_all(hook_event.as_bytes()) + .expect("write hook event"); + let status = wait_for_exit(&mut hook) + .unwrap_or_else(|| panic!("hook client exceeded {PROCESS_TIMEOUT:?}")); + assert!(status.success(), "hook client failed"); + })); + } + start.wait(); + for request in requests { + request.join().expect("concurrent broker client panicked"); + } + }); + + let doctor = common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("doctor") + .current_dir(&project_path) + .output() + .expect("run doctor probe"); + assert_command_success("brokered doctor", &doctor); + assert_eq!( + file_identity(&db_path), + Some(db_identity), + "client probes replaced graph DB inode" + ); + assert_eq!( + daemon_authority_record(&home_path), + authority_before, + "concurrent clients changed daemon owner or epoch" + ); + #[cfg(target_os = "linux")] + for client in &clients { + assert_eq!( + sqlite_handles(client.child.id(), &profile_root), + Vec::::new(), + "MCP proxy retained a profile SQLite handle after its request" + ); + } + stop_child(&mut daemon); +} + +#[test] +fn split_brain_is_rejected_and_unavailable_daemon_fails_closed_until_restart() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let socket_path = common::daemon_socket_path(&home_path); + let mut owner = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + assert_command_success( + "owner daemon status", + &tool_status(&home_path, &project_path, &socket_path), + ); + + let socket_before = file_identity(&socket_path).expect("owner socket identity"); + let authority_before = daemon_authority_record(&home_path); + let mut contender = ChildGuard::new( + common::tracedecay_command_with_home(&home_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["daemon", "run", "--socket"]) + .arg(&socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn contender daemon"), + ); + let contender_status = wait_for_exit(&mut contender).unwrap_or_else(|| { + stop_child(&mut contender); + panic!("second daemon remained alive and created split-brain ownership") + }); + assert!( + !contender_status.success(), + "second daemon must be rejected" + ); + assert_eq!( + file_identity(&socket_path), + Some(socket_before), + "contender replaced owner socket" + ); + assert!( + owner.try_wait().expect("owner status").is_none(), + "owner daemon exited" + ); + assert_eq!( + daemon_authority_record(&home_path), + authority_before, + "rejected contender changed daemon authority generation" + ); + + stop_child(&mut owner); + let before = storage_snapshot(&db_path); + for (label, mut command) in [ + ("tool", { + let project_arg = project_path.to_string_lossy().to_string(); + let mut command = common::tracedecay_command_with_home(&home_path); + command.env("TRACEDECAY_DAEMON_SOCKET", &socket_path).args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + ]); + command + }), + ("sync", { + let mut command = common::tracedecay_command_with_home(&home_path); + command + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .arg("sync"); + command + }), + ("doctor", { + let mut command = common::tracedecay_command_with_home(&home_path); + command + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .arg("doctor"); + command + }), + ] { + command + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(&project_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = command + .output() + .unwrap_or_else(|error| panic!("run {label}: {error}")); + assert!( + !output.status.success(), + "{label} must fail closed while daemon is unavailable\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!( + storage_snapshot(&db_path), + before, + "{label} used a local SQLite fallback" + ); + } + let hook_event = json!({ + "hook_event_name": "afterFileEdit", + "file_path": project_path.join("src/lib.rs"), + "workspace_roots": [&project_path], + }) + .to_string(); + let mut hook = ChildGuard::new( + common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("hook-cursor-after-file-edit") + .current_dir(&project_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn unavailable-daemon hook client"), + ); + hook.stdin + .as_mut() + .expect("hook stdin") + .write_all(hook_event.as_bytes()) + .expect("write hook event"); + assert!( + wait_for_exit(&mut hook).is_some(), + "unavailable-daemon hook client exceeded {PROCESS_TIMEOUT:?}" + ); + assert_eq!( + storage_snapshot(&db_path), + before, + "hook used a local SQLite fallback while daemon was unavailable" + ); + + let mut restarted = spawn_daemon(&home_path, &socket_path); + assert_command_success( + "restarted daemon status", + &tool_status(&home_path, &project_path, &socket_path), + ); + let authority_after = daemon_authority_record(&home_path); + assert_eq!(authority_after["pid"], restarted.id()); + assert_ne!( + authority_after["process_run_id"], authority_before["process_run_id"], + "restart must publish a new process identity" + ); + assert_ne!( + authority_after["auth_token"], authority_before["auth_token"], + "restart must invalidate the prior generation's authentication token" + ); + assert!( + authority_after["epoch"].as_u64() > authority_before["epoch"].as_u64(), + "restart must advance daemon authority epoch" + ); + stop_child(&mut restarted); +} + +#[tokio::test] +#[ignore] +async fn killed_writer_fixture() { + if std::env::var("TRACEDECAY_BROKER_FIXTURE").as_deref() != Ok("killed-writer") { + return; + } + let db_path = PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_DB").expect("fixture DB")); + let dirty_path = PathBuf::from( + std::env::var_os("TRACEDECAY_FIXTURE_DIRTY").expect("fixture dirty sentinel"), + ); + let ready_path = + PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_READY").expect("fixture ready path")); + let authority = DatabaseAuthority::acquire_test(&db_path, "killed writer fixture") + .expect("acquire fixture database authority"); + let (db, _) = Database::open(&db_path, &authority) + .await + .expect("open fixture graph DB"); + db.conn() + .execute_batch("PRAGMA wal_autocheckpoint = 0") + .await + .expect("disable WAL autocheckpoint"); + db.insert_nodes(&[common::sample_node( + "broker-recovery-node", + "broker_recovery_node", + "src/recovery.rs", + )]) + .await + .expect("commit recovery node"); + std::fs::write( + &dirty_path, + format!("pid={}\nversion=test", std::process::id()), + ) + .expect("write dirty sentinel"); + std::fs::write(&ready_path, "ready").expect("publish fixture readiness"); + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } +} + +#[test] +fn daemon_recovers_killed_writer_dirty_wal_before_serving_clients() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let fixture = TempDir::new().expect("temp fixture state"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let socket_path = common::daemon_socket_path(&home_path); + let mut initializer = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + stop_child(&mut initializer); + let data_root = db_path.parent().expect("graph data root"); + let dirty_path = data_root.join("dirty"); + let ready_path = fixture.path().join("ready"); + + let mut writer = ChildGuard::new( + Command::new(std::env::current_exe().expect("current test binary")) + .args([ + "--ignored", + "--exact", + "multi_connection_test::killed_writer_fixture", + "--nocapture", + ]) + .env("TRACEDECAY_BROKER_FIXTURE", "killed-writer") + .env("TRACEDECAY_FIXTURE_DB", &db_path) + .env("TRACEDECAY_FIXTURE_DIRTY", &dirty_path) + .env("TRACEDECAY_FIXTURE_READY", &ready_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn writer fixture"), + ); + let deadline = Instant::now() + PROCESS_TIMEOUT; + while !ready_path.exists() { + assert!( + writer.try_wait().expect("writer status").is_none(), + "writer exited early" + ); + assert!( + Instant::now() < deadline, + "writer fixture did not become ready" + ); + std::thread::sleep(Duration::from_millis(25)); + } + assert!( + PathBuf::from(format!("{}-wal", db_path.display())).exists(), + "writer fixture must leave committed WAL frames" + ); + stop_child(&mut writer); + + let wal_path = PathBuf::from(format!("{}-wal", db_path.display())); + let shm_path = PathBuf::from(format!("{}-shm", db_path.display())); + let committed_family = storage_snapshot(&db_path); + for path in [&db_path, &wal_path, &shm_path] { + assert!( + committed_family.contains_key(path), + "killed writer must leave SQLite family member '{}'", + path.display() + ); + } + let mut corrupted = committed_family[&db_path].clone(); + corrupted[..16].copy_from_slice(b"not-a-sqlite-db!"); + std::fs::write(&db_path, corrupted).expect("corrupt fixture database header"); + let failed_family = storage_snapshot(&db_path); + let failed_identities = [&db_path, &wal_path, &shm_path].map(|path| { + ( + path.to_path_buf(), + file_identity(path).expect("SQLite family identity before failed recovery"), + ) + }); + + let mut daemon = spawn_daemon(&home_path, &socket_path); + let project_arg = project_path.to_string_lossy().to_string(); + let search_recovered_node = || { + common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(&project_path) + .args([ + "tool", + "--project", + &project_arg, + "search", + "broker_recovery_node", + "--json", + ]) + .output() + .expect("search recovered node through daemon") + }; + let failed = search_recovered_node(); + assert!( + !failed.status.success(), + "daemon must fail closed when killed-writer recovery cannot validate the database" + ); + assert_eq!( + storage_snapshot(&db_path), + failed_family, + "failed recovery changed DB, WAL, or SHM" + ); + for (path, identity) in failed_identities { + assert_eq!( + file_identity(&path), + Some(identity), + "failed recovery replaced SQLite family member '{}'", + path.display() + ); + } + assert!( + dirty_path.exists(), + "failed recovery must preserve the dirty sentinel" + ); + stop_child(&mut daemon); + + std::fs::write(&db_path, &committed_family[&db_path]) + .expect("restore fixture database for successful WAL recovery"); + daemon = spawn_daemon(&home_path, &socket_path); + let output = search_recovered_node(); + assert_command_success("daemon WAL recovery search", &output); + assert!( + String::from_utf8_lossy(&output.stdout).contains("broker_recovery_node"), + "committed WAL row was lost during daemon recovery" + ); + assert!( + !dirty_path.exists(), + "daemon must clear dirty sentinel after recovery" + ); + stop_child(&mut daemon); +} diff --git a/tests/storage_suite/profile_storage_migration_test.rs b/tests/storage_suite/profile_storage_migration_test.rs index ae206cce3..a211b05f6 100644 --- a/tests/storage_suite/profile_storage_migration_test.rs +++ b/tests/storage_suite/profile_storage_migration_test.rs @@ -6,7 +6,6 @@ use tempfile::TempDir; use tracedecay::branch::BranchAddOutcome; use tracedecay::branch_meta::{self, BranchMeta}; use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; -use tracedecay::db::Database; use tracedecay::global_db::{GlobalDb, GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; use tracedecay::migrate::inventory::{ MigrationInventory, RegistryStatus, StoreArtifact, StoreBrand, StoreInventory, StoreRole, @@ -1181,7 +1180,7 @@ async fn trace_decay_open_matches_renamed_git_checkout_by_registered_remote() { } #[tokio::test] -async fn ensure_initialized_with_options_uses_persisted_repository_identity() { +async fn persisted_repository_identity_survives_rename_while_serve_open_fails_closed() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let root = canonical_temp_path(dir.path()); @@ -1217,7 +1216,19 @@ async fn ensure_initialized_with_options_uses_persisted_repository_identity() { TraceDecay::is_initialized_with_options(&renamed, &open_options), "the durable git marker should resolve the moved profile store synchronously" ); - let reopened = serve::ensure_initialized_with_options(&renamed, open_options) + let serve_error = + match serve::ensure_initialized_with_options(&renamed, open_options.clone()).await { + Ok(_) => panic!("serve compatibility API must not open the project database locally"), + Err(error) => error, + }; + assert!( + serve_error + .to_string() + .contains("managed TraceDecay daemon"), + "serve should direct callers to the sole database owner: {serve_error}" + ); + + let reopened = TraceDecay::open_with_options(&renamed, open_options) .await .unwrap(); @@ -1262,10 +1273,12 @@ async fn trace_decay_open_branch_uses_profile_shard_branch_db() { serde_json::to_string_pretty(&config).unwrap(), ) .unwrap(); - Database::initialize(&shard_root.join("tracedecay.db")) + crate::common::initialize_test_database(&shard_root.join("tracedecay.db")) + .await + .unwrap(); + crate::common::initialize_test_database(&branch_db) .await .unwrap(); - Database::initialize(&branch_db).await.unwrap(); let mut meta = BranchMeta::new_for_dir(&shard_root, "main"); meta.add_branch("feature/profile", "branches/feature_profile.db", "main"); branch_meta::save_branch_meta(&shard_root, &meta).unwrap(); diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 7b1e0d463..b489533f3 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -12,7 +12,6 @@ use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::config::{ discover_project_root, get_config_path, load_config, save_config_to_path, }; -use tracedecay::db::Database; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::response_handles::{ ResponseHandleLookup, retrieve_response_handle, store_response_handle, @@ -176,7 +175,9 @@ async fn initialize_empty_profile_layout(layout: &tracedecay::storage::StoreLayo }, ) .unwrap(); - let (db, _) = Database::initialize(&layout.graph_db_path).await.unwrap(); + let (db, _) = crate::common::initialize_test_database(&layout.graph_db_path) + .await + .unwrap(); db.checkpoint().await.unwrap(); db.close(); write_store_manifest(layout).unwrap(); @@ -1669,7 +1670,7 @@ async fn trace_decay_open_uses_profile_shard_paths_from_enrollment_marker() { serde_json::to_string_pretty(&shard_config).unwrap(), ) .unwrap(); - Database::initialize(&shard_root.join("tracedecay.db")) + crate::common::initialize_test_database(&shard_root.join("tracedecay.db")) .await .unwrap(); let meta = BranchMeta::new_for_dir(&shard_root, "main"); diff --git a/tests/storage_suite/support.rs b/tests/storage_suite/support.rs index f806b41ee..b96dee198 100644 --- a/tests/storage_suite/support.rs +++ b/tests/storage_suite/support.rs @@ -115,7 +115,7 @@ where /// `Database::initialize` would produce — without paying schema creation. pub async fn seed_latest_graph_db(dest: &Path) { let template = ensure_template_db("graph-empty", &[], |path| async move { - let (db, _) = tracedecay::db::Database::initialize(&path) + let (db, _) = crate::common::initialize_test_database(&path) .await .expect("failed to initialize template database"); db.checkpoint() From 6a10719569160b4e3a90ff45902da132c558d2c4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 00:31:08 +0000 Subject: [PATCH 02/21] test(storage): close hook stdin in integrity suite Co-Authored-By: Claude Fable 5 --- tests/storage_suite/multi_connection_test.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/storage_suite/multi_connection_test.rs b/tests/storage_suite/multi_connection_test.rs index b5eee234e..5c6b173cb 100644 --- a/tests/storage_suite/multi_connection_test.rs +++ b/tests/storage_suite/multi_connection_test.rs @@ -408,11 +408,11 @@ fn twelve_mcp_cli_and_hook_clients_share_one_daemon_sqlite_owner() { .spawn() .expect("spawn hook client"), ); - hook.stdin - .as_mut() - .expect("hook stdin") + let mut stdin = hook.stdin.take().expect("hook stdin"); + stdin .write_all(hook_event.as_bytes()) .expect("write hook event"); + drop(stdin); let status = wait_for_exit(&mut hook) .unwrap_or_else(|| panic!("hook client exceeded {PROCESS_TIMEOUT:?}")); assert!(status.success(), "hook client failed"); @@ -572,11 +572,11 @@ fn split_brain_is_rejected_and_unavailable_daemon_fails_closed_until_restart() { .spawn() .expect("spawn unavailable-daemon hook client"), ); - hook.stdin - .as_mut() - .expect("hook stdin") + let mut stdin = hook.stdin.take().expect("hook stdin"); + stdin .write_all(hook_event.as_bytes()) .expect("write hook event"); + drop(stdin); assert!( wait_for_exit(&mut hook).is_some(), "unavailable-daemon hook client exceeded {PROCESS_TIMEOUT:?}" From 05842e0e618d4b27a92e3c2328995734bfd84d3a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 04:58:34 +0000 Subject: [PATCH 03/21] fix(storage): finish sole-daemon ownership integration --- dashboard/smoke.mjs | 217 ++- scripts/hermes_stock_integration.sh | 177 +- scripts/mcp-conformance-smoke.sh | 278 +-- scripts/with-isolated-tracedecay-daemon.sh | 203 ++ src/automation_cli.rs | 1023 ---------- src/automation_cli/config.rs | 350 ++++ src/automation_cli/facts.rs | 58 + src/automation_cli/mod.rs | 156 ++ src/automation_cli/runs.rs | 269 +++ src/automation_cli/skills.rs | 215 +++ src/branch.rs | 231 +-- src/branch/admin.rs | 558 ++++++ src/branch/admin/tests.rs | 850 ++++++++ src/branch/admin/transaction.rs | 878 +++++++++ src/branch/tests.rs | 45 +- src/branch_meta.rs | 64 +- src/commands.rs | 1709 +---------------- src/commands/bench.rs | 35 + src/commands/branch.rs | 471 +++++ src/commands/daemon.rs | 30 + src/commands/gain.rs | 140 ++ src/commands/index.rs | 384 ++++ src/commands/memory.rs | 65 + src/commands/migrate.rs | 587 ++++++ src/commands/settings.rs | 51 + src/commands/storage.rs | 351 ++++ src/daemon.rs | 1514 ++++----------- src/daemon/authority.rs | 42 +- src/daemon/branch_add.rs | 125 ++ src/daemon/branch_admin.rs | 667 +++++++ src/daemon/git_watch.rs | 232 +-- src/daemon/git_watch/store_maintenance.rs | 160 ++ src/daemon/git_watch/tests.rs | 69 + src/daemon/pr_autotrack.rs | 364 +++- src/daemon/pr_autotrack/tests.rs | 10 +- src/daemon/scheduler.rs | 970 ++++++++++ src/daemon/tests.rs | 146 +- src/daemon/tests/compatibility.rs | 4 +- src/daemon/transport.rs | 2 +- src/db/access.rs | 471 +---- src/db/access/bootstrap.rs | 56 +- src/db/access/lease.rs | 639 +++++- src/db/access/owner_io.rs | 95 +- src/db/access/path_layout.rs | 17 +- src/db/access/tests.rs | 773 ++++++++ src/db/connection.rs | 8 +- src/db/connection/pragmas.rs | 4 +- src/db/connection/registry.rs | 12 +- src/db/mod.rs | 5 +- src/doctor.rs | 6 +- src/doctor/tests.rs | 4 +- src/global.rs | 78 +- src/global_db.rs | 38 +- src/global_db/tests.rs | 10 +- src/hooks/cursor.rs | 22 +- src/hooks/cursor_compact.rs | 26 +- src/hooks/mod.rs | 18 +- src/main.rs | 111 +- src/mcp/server.rs | 326 +++- .../server/background_refresh_writer_tests.rs | 130 ++ src/mcp/server/hook_branch_writer_tests.rs | 181 ++ src/mcp/tools/handlers/admin_cli.rs | 149 +- src/mcp/tools/handlers/admin_project.rs | 27 +- src/mcp/tools/handlers/git.rs | 68 + src/mcp/tools/handlers/hook_runtime.rs | 14 +- src/mcp/tools/handlers/mod.rs | 2 + src/migrate/consolidate/tests.rs | 14 +- src/migrate/inventory.rs | 1033 ---------- src/migrate/inventory/artifacts.rs | 141 ++ src/migrate/inventory/hermes.rs | 262 +++ src/migrate/inventory/mod.rs | 148 ++ src/migrate/inventory/model.rs | 93 + src/migrate/inventory/project.rs | 294 +++ src/migrate/inventory/sqlite.rs | 138 ++ src/monitor/cost.rs | 8 +- src/open_store_holders.rs | 362 +++- src/serve.rs | 9 +- src/startup_tests.rs | 17 +- tests/architecture_boundaries.rs | 808 ++++++++ tests/daemon_suite/pr_autotrack_test.rs | 161 +- tests/storage_suite/branch_db_safety_test.rs | 124 ++ tests/storage_suite/multi_connection_test.rs | 778 +------- .../multi_connection_test/fail_closed.rs | 156 ++ .../multi_connection_test/harness.rs | 393 ++++ .../multi_connection_test/ownership.rs | 170 ++ .../multi_connection_test/recovery.rs | 169 ++ tests/transcript_ingest_suite/cursor.rs | 36 +- 87 files changed, 14972 insertions(+), 7332 deletions(-) create mode 100755 scripts/with-isolated-tracedecay-daemon.sh delete mode 100644 src/automation_cli.rs create mode 100644 src/automation_cli/config.rs create mode 100644 src/automation_cli/facts.rs create mode 100644 src/automation_cli/mod.rs create mode 100644 src/automation_cli/runs.rs create mode 100644 src/automation_cli/skills.rs create mode 100644 src/branch/admin.rs create mode 100644 src/branch/admin/tests.rs create mode 100644 src/branch/admin/transaction.rs create mode 100644 src/commands/bench.rs create mode 100644 src/commands/branch.rs create mode 100644 src/commands/daemon.rs create mode 100644 src/commands/gain.rs create mode 100644 src/commands/index.rs create mode 100644 src/commands/memory.rs create mode 100644 src/commands/migrate.rs create mode 100644 src/commands/settings.rs create mode 100644 src/commands/storage.rs create mode 100644 src/daemon/branch_add.rs create mode 100644 src/daemon/branch_admin.rs create mode 100644 src/daemon/git_watch/store_maintenance.rs create mode 100644 src/daemon/scheduler.rs create mode 100644 src/db/access/tests.rs create mode 100644 src/mcp/server/background_refresh_writer_tests.rs create mode 100644 src/mcp/server/hook_branch_writer_tests.rs delete mode 100644 src/migrate/inventory.rs create mode 100644 src/migrate/inventory/artifacts.rs create mode 100644 src/migrate/inventory/hermes.rs create mode 100644 src/migrate/inventory/mod.rs create mode 100644 src/migrate/inventory/model.rs create mode 100644 src/migrate/inventory/project.rs create mode 100644 src/migrate/inventory/sqlite.rs create mode 100644 tests/architecture_boundaries.rs create mode 100644 tests/storage_suite/multi_connection_test/fail_closed.rs create mode 100644 tests/storage_suite/multi_connection_test/harness.rs create mode 100644 tests/storage_suite/multi_connection_test/ownership.rs create mode 100644 tests/storage_suite/multi_connection_test/recovery.rs diff --git a/dashboard/smoke.mjs b/dashboard/smoke.mjs index 8b407fdef..3d18c9243 100644 --- a/dashboard/smoke.mjs +++ b/dashboard/smoke.mjs @@ -31,6 +31,10 @@ const VIEWPORT_PROFILES = { }; const DASHBOARD_URL_RE = /(http:\/\/127\.0\.0\.1:\d+\/)/; +const DAEMON_HARNESS_ACTIVE = "TRACEDECAY_DAEMON_HARNESS_ACTIVE"; +const DASHBOARD_STARTUP_TIMEOUT_MS = 30_000; +const DASHBOARD_STOP_TIMEOUT_MS = 5_000; +const IS_UNIX = process.platform !== "win32"; function workspaceRoot() { return fileURLToPath(new URL("..", import.meta.url)); @@ -40,6 +44,49 @@ function withTrailingSlash(url) { return url.endsWith("/") ? url : `${url}/`; } +function runnableFile(candidate) { + if (!candidate) return null; + const resolved = path.resolve(candidate); + try { + if (!fs.statSync(resolved).isFile()) return null; + if (IS_UNIX) fs.accessSync(resolved, fs.constants.X_OK); + return resolved; + } catch { + return null; + } +} + +function resolveTracedecayBinary() { + const cargoBinary = runnableFile(process.env.CARGO_BIN_EXE_tracedecay); + if (cargoBinary) return cargoBinary; + + const suffix = process.platform === "win32" ? ".exe" : ""; + const workspaceBinary = path.join(workspaceRoot(), "target", "debug", `tracedecay${suffix}`); + const builtBinary = runnableFile(workspaceBinary); + if (builtBinary) return builtBinary; + + throw new Error(`built TraceDecay binary not found at ${workspaceBinary}`); +} + +function runUnderIsolatedDaemon() { + const harness = path.join(workspaceRoot(), "scripts", "with-isolated-tracedecay-daemon.sh"); + const tracedecayBinary = resolveTracedecayBinary(); + const result = spawnSync( + harness, + ["--bin", tracedecayBinary, "--", process.execPath, ...process.argv.slice(1)], + { + cwd: workspaceRoot(), + env: process.env, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.signal) { + throw new Error(`isolated daemon harness terminated by ${result.signal}`); + } + return result.status ?? 1; +} + // The dashboard refuses to start without a TraceDecay index, and CI checkouts // (unlike dev workspaces) have no `.tracedecay/`. Build a tiny throwaway // project and index it so the smoke run is hermetic everywhere. @@ -51,71 +98,137 @@ function createSmokeWorkspace() { ); // stdin is closed so init's interactive `.gitignore` prompt reads EOF and // proceeds with the default instead of blocking. - const result = spawnSync("cargo", ["run", "--", "init", dir], { + const result = spawnSync(resolveTracedecayBinary(), ["init", dir], { cwd: workspaceRoot(), env: process.env, stdio: ["ignore", "inherit", "inherit"], }); - if (result.status !== 0) { + if (result.error || result.status !== 0) { fs.rmSync(dir, { recursive: true, force: true }); + if (result.error) throw result.error; throw new Error(`tracedecay init failed for smoke workspace (code ${result.status})`); } return dir; } async function startDashboardServer(projectPath) { - return new Promise((resolve, reject) => { - const child = spawn( - "cargo", - ["run", "--", "dashboard", "--port", "0", "--path", projectPath], - { - cwd: workspaceRoot(), - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }, - ); + const child = spawn( + resolveTracedecayBinary(), + ["dashboard", "--port", "0", "--path", projectPath], + { + cwd: workspaceRoot(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + detached: IS_UNIX, + }, + ); - let settled = false; - let stderrBuffer = ""; - const complete = (handler, value) => { - if (settled) return; - settled = true; - handler(value); - }; + let closed = false; + let stderrBuffer = ""; + let stopPromise = null; + const exitPromise = new Promise((resolve) => { + child.once("error", (error) => resolve({ error })); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + child.once("close", () => { + closed = true; + }); + child.stderr.on("data", (chunk) => { + stderrBuffer += chunk.toString(); + }); - const stdoutLines = readline.createInterface({ input: child.stdout }); - stdoutLines.on("line", (line) => { - process.stdout.write(`[dashboard] ${line}\n`); - const match = line.match(DASHBOARD_URL_RE); - if (match) { - complete(resolve, { - baseUrl: withTrailingSlash(match[1]), - child, - stop: async () => { - child.kill("SIGTERM"); - await new Promise((done) => child.once("exit", done)); - }, - }); - } - }); + const stdoutLines = readline.createInterface({ input: child.stdout }); + const stderrLines = readline.createInterface({ input: child.stderr }); + stderrLines.on("line", (line) => process.stderr.write(`[dashboard:stderr] ${line}\n`)); - const stderrLines = readline.createInterface({ input: child.stderr }); - stderrLines.on("line", (line) => { - stderrBuffer = `${stderrBuffer}${line}\n`; - process.stderr.write(`[dashboard:stderr] ${line}\n`); - }); + const processGroupAlive = () => { + if (child.pid === undefined) return false; + if (!IS_UNIX) return child.exitCode === null && child.signalCode === null; + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") return false; + if (error.code === "EPERM") return true; + throw error; + } + }; + const sendSignal = (signal) => { + if (child.pid === undefined) return; + try { + if (IS_UNIX) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + }; + const waitForStop = async () => { + const deadline = Date.now() + DASHBOARD_STOP_TIMEOUT_MS; + while (Date.now() < deadline) { + if (closed && !processGroupAlive()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return closed && !processGroupAlive(); + }; + const stop = () => { + if (stopPromise) return stopPromise; + stopPromise = (async () => { + try { + if (!closed || processGroupAlive()) sendSignal("SIGTERM"); + if (!(await waitForStop())) { + sendSignal("SIGKILL"); + if (!(await waitForStop())) { + throw new Error("dashboard server did not stop after SIGKILL"); + } + } + } finally { + stdoutLines.close(); + stderrLines.close(); + } + })(); + return stopPromise; + }; - child.once("error", (err) => { - complete(reject, err); - }); - child.once("exit", (code) => { - if (settled) return; - complete( - reject, - new Error(`dashboard server exited before startup (code ${code})\n${stderrBuffer}`), + try { + const baseUrl = await new Promise((resolve, reject) => { + let settled = false; + const complete = (handler, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + handler(value); + }; + const timer = setTimeout( + () => complete(reject, new Error( + `dashboard server startup timed out after ${DASHBOARD_STARTUP_TIMEOUT_MS}ms`, + )), + DASHBOARD_STARTUP_TIMEOUT_MS, ); + stdoutLines.on("line", (line) => { + process.stdout.write(`[dashboard] ${line}\n`); + const match = line.match(DASHBOARD_URL_RE); + if (match) complete(resolve, withTrailingSlash(match[1])); + }); + exitPromise.then(({ code, signal, error }) => { + const detail = error?.message ?? `code ${code}${signal ? `, signal ${signal}` : ""}`; + complete(reject, new Error(`dashboard server exited before startup (${detail})`)); + }); }); - }); + return { baseUrl, child, stop }; + } catch (error) { + let stopError = null; + try { + await stop(); + } catch (caught) { + stopError = caught; + } + const diagnostics = stderrBuffer.trim(); + const message = error instanceof Error ? error.message : String(error); + const stopMessage = stopError ? `\nshutdown error: ${stopError.message}` : ""; + throw new Error(`${message}${diagnostics ? `\n${diagnostics}` : ""}${stopMessage}`, { + cause: error, + }); + } } async function waitForAny(page, locators, timeoutMs) { @@ -357,6 +470,13 @@ async function main() { } return profile; }); + + if (!explicitUrl && process.env[DAEMON_HARNESS_ACTIVE] !== "1") { + console.log("Starting hermetic foreground TraceDecay daemon for smoke test..."); + process.exitCode = runUnderIsolatedDaemon(); + return; + } + let server = null; let workspace = null; @@ -365,6 +485,7 @@ async function main() { server = { baseUrl: explicitUrl, stop: async () => {} }; console.log(`Using existing dashboard URL: ${explicitUrl}`); } else { + console.log("Smoke daemon is ready."); console.log("Creating hermetic smoke workspace (tracedecay init)..."); workspace = createSmokeWorkspace(); console.log(`Starting \`tracedecay dashboard --port 0 --path ${workspace}\` for smoke test...`); diff --git a/scripts/hermes_stock_integration.sh b/scripts/hermes_stock_integration.sh index d67354e1a..d507021cb 100755 --- a/scripts/hermes_stock_integration.sh +++ b/scripts/hermes_stock_integration.sh @@ -20,75 +20,118 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TRACEDECAY_BIN="${TRACEDECAY_BIN:-$REPO_ROOT/target/debug/tracedecay}" -TRACEDECAY_BIN="$(cd "$(dirname "$TRACEDECAY_BIN")" && pwd)/$(basename "$TRACEDECAY_BIN")" -HERMES_UPSTREAM_DIR="${HERMES_UPSTREAM_DIR:-/tmp/hermes-upstream}" +SCRIPT_PATH="$REPO_ROOT/scripts/hermes_stock_integration.sh" +DAEMON_HARNESS="$REPO_ROOT/scripts/with-isolated-tracedecay-daemon.sh" +STAGE="" -if [ ! -x "$TRACEDECAY_BIN" ]; then - echo "error: tracedecay binary not found at $TRACEDECAY_BIN (build with: cargo build --bin tracedecay)" >&2 - exit 1 -fi -if [ ! -f "$HERMES_UPSTREAM_DIR/pyproject.toml" ]; then - echo "error: stock hermes-agent checkout not found at $HERMES_UPSTREAM_DIR" >&2 - exit 1 -fi +run_integration() { + local project="$1" + local hermes_python="$HERMES_VENV/bin/python" + local plugins_list doctor_out + + (cd "$project" && "$TRACEDECAY_BIN" init) + + echo "== stock plugin manager / context engine / memory provider / dispatch checks" + ( + cd "$project" && + PYTHONPATH="$HERMES_UPSTREAM_DIR" \ + "$hermes_python" "$REPO_ROOT/scripts/hermes_stock_check.py" + ) + + echo "== hermes plugins list" + plugins_list="$(cd "$HERMES_UPSTREAM_DIR" && COLUMNS=200 \ + timeout 120 "$HERMES_VENV/bin/hermes" plugins list)" + echo "$plugins_list" | grep tracedecay + echo "$plugins_list" | grep tracedecay | grep -q enabled + echo "ok - hermes plugins list shows tracedecay enabled" + + echo "== tracedecay doctor" + doctor_out="$(cd "$project" && "$TRACEDECAY_BIN" doctor 2>&1)" + echo "$doctor_out" | grep -q "Hermes tracedecay plugin found" + if echo "$doctor_out" | grep -q "was generated by tracedecay"; then + echo "error: doctor reports a stale generated plugin:" >&2 + echo "$doctor_out" | grep "was generated by tracedecay" >&2 + return 1 + fi + echo "ok - tracedecay doctor Hermes section is clean" + + echo "stock hermes integration: PASS" +} + +main() { + local tracedecay_bin hermes_upstream_dir hermes_venv + local fake_home hermes_home profile socket project status + + tracedecay_bin="${TRACEDECAY_BIN:-$REPO_ROOT/target/debug/tracedecay}" + tracedecay_bin="$(cd "$(dirname "$tracedecay_bin")" && pwd)/$(basename "$tracedecay_bin")" + hermes_upstream_dir="${HERMES_UPSTREAM_DIR:-/tmp/hermes-upstream}" -echo "== stock hermes ref: $(git -C "$HERMES_UPSTREAM_DIR" rev-parse HEAD 2>/dev/null || echo unknown)" -echo "== tracedecay binary: $TRACEDECAY_BIN ($("$TRACEDECAY_BIN" --version))" + if [[ ! -x "$tracedecay_bin" ]]; then + echo "error: tracedecay binary not found at $tracedecay_bin (build with: cargo build --bin tracedecay)" >&2 + return 1 + fi + if [[ ! -f "$hermes_upstream_dir/pyproject.toml" ]]; then + echo "error: stock hermes-agent checkout not found at $hermes_upstream_dir" >&2 + return 1 + fi -# Upstream source installs use either `.venv` (uv) or `venv` (Hermes installer). -if [ -x "$HERMES_UPSTREAM_DIR/.venv/bin/python" ]; then - HERMES_VENV="$HERMES_UPSTREAM_DIR/.venv" -elif [ -x "$HERMES_UPSTREAM_DIR/venv/bin/python" ]; then - HERMES_VENV="$HERMES_UPSTREAM_DIR/venv" + echo "== stock hermes ref: $(git -C "$hermes_upstream_dir" rev-parse HEAD 2>/dev/null || echo unknown)" + echo "== tracedecay binary: $tracedecay_bin ($("$tracedecay_bin" --version))" + + # Upstream source installs use either `.venv` (uv) or `venv` (Hermes installer). + if [[ -x "$hermes_upstream_dir/.venv/bin/python" ]]; then + hermes_venv="$hermes_upstream_dir/.venv" + elif [[ -x "$hermes_upstream_dir/venv/bin/python" ]]; then + hermes_venv="$hermes_upstream_dir/venv" + else + echo "== creating stock hermes venv (uv sync --frozen --no-dev)" + (cd "$hermes_upstream_dir" && uv sync --frozen --no-dev) + hermes_venv="$hermes_upstream_dir/.venv" + fi + + STAGE="$(mktemp -d -t hermes-stock-XXXXXX)" + fake_home="$STAGE/home" + hermes_home="$fake_home/.hermes" + profile="$fake_home/.tracedecay" + socket="$profile/daemon.sock" + project="$STAGE/project" + mkdir -p "$fake_home" "$profile" "$project/src" + trap 'rm -rf "$STAGE"' EXIT + + # Throwaway project so tool dispatch has a real .tracedecay graph to hit. + printf 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n\npub fn double(x: i32) -> i32 { add(x, x) }\n' > "$project/src/lib.rs" + printf '[package]\nname = "throwaway"\nversion = "0.1.0"\nedition = "2021"\n' > "$project/Cargo.toml" + git -C "$project" init -q + git -C "$project" add -A + git -C "$project" -c user.email=ci@tracedecay -c user.name=ci commit -qm init + + # Installation performs offline profile migration and must precede the + # sole-owner daemon. Keep every user/profile path inside the throwaway HOME. + echo "== tracedecay install --agent hermes" + HOME="$fake_home" \ + HERMES_HOME="$hermes_home" \ + TRACEDECAY_DATA_DIR="$profile" \ + TRACEDECAY_DAEMON_SOCKET="$socket" \ + "$tracedecay_bin" install --agent hermes + test -f "$hermes_home/plugins/tracedecay/plugin.yaml" + + set +e + HOME="$fake_home" \ + HERMES_HOME="$hermes_home" \ + TRACEDECAY_BIN="$tracedecay_bin" \ + HERMES_UPSTREAM_DIR="$hermes_upstream_dir" \ + HERMES_VENV="$hermes_venv" \ + "$DAEMON_HARNESS" --bin "$tracedecay_bin" --ready-timeout 30 \ + --lifecycle-label "temporary tracedecay daemon" -- \ + "$SCRIPT_PATH" --run "$project" + status=$? + set -e + return "$status" +} + +if [[ "${1:-}" == "--run" ]]; then + shift + run_integration "$@" else - echo "== creating stock hermes venv (uv sync --frozen --no-dev)" - (cd "$HERMES_UPSTREAM_DIR" && uv sync --frozen --no-dev) - HERMES_VENV="$HERMES_UPSTREAM_DIR/.venv" + main "$@" fi -HERMES_PYTHON="$HERMES_VENV/bin/python" - -STAGE="$(mktemp -d -t hermes-stock-XXXXXX)" -trap 'rm -rf "$STAGE"' EXIT -FAKE_HOME="$STAGE/home" -PROJECT="$STAGE/project" -mkdir -p "$FAKE_HOME" "$PROJECT/src" - -# Throwaway project so tool dispatch has a real .tracedecay graph to hit. -printf 'pub fn add(a: i32, b: i32) -> i32 { a + b }\n\npub fn double(x: i32) -> i32 { add(x, x) }\n' > "$PROJECT/src/lib.rs" -printf '[package]\nname = "throwaway"\nversion = "0.1.0"\nedition = "2021"\n' > "$PROJECT/Cargo.toml" -git -C "$PROJECT" init -q -git -C "$PROJECT" add -A -git -C "$PROJECT" -c user.email=ci@tracedecay -c user.name=ci commit -qm init -(cd "$PROJECT" && HOME="$FAKE_HOME" "$TRACEDECAY_BIN" init) - -echo "== tracedecay install --agent hermes" -HOME="$FAKE_HOME" "$TRACEDECAY_BIN" install --agent hermes -HERMES_DIR="$FAKE_HOME/.hermes" -test -f "$HERMES_DIR/plugins/tracedecay/plugin.yaml" - -echo "== stock plugin manager / context engine / memory provider / dispatch checks" -( - cd "$PROJECT" && - HOME="$FAKE_HOME" PYTHONPATH="$HERMES_UPSTREAM_DIR" \ - "$HERMES_PYTHON" "$REPO_ROOT/scripts/hermes_stock_check.py" -) - -echo "== hermes plugins list" -PLUGINS_LIST="$(cd "$HERMES_UPSTREAM_DIR" && HOME="$FAKE_HOME" COLUMNS=200 \ - timeout 120 "$HERMES_VENV/bin/hermes" plugins list)" -echo "$PLUGINS_LIST" | grep tracedecay -echo "$PLUGINS_LIST" | grep tracedecay | grep -q enabled -echo "ok - hermes plugins list shows tracedecay enabled" - -echo "== tracedecay doctor" -DOCTOR_OUT="$(cd "$PROJECT" && HOME="$FAKE_HOME" "$TRACEDECAY_BIN" doctor 2>&1)" -echo "$DOCTOR_OUT" | grep -q "Hermes tracedecay plugin found" -if echo "$DOCTOR_OUT" | grep -q "was generated by tracedecay"; then - echo "error: doctor reports a stale generated plugin:" >&2 - echo "$DOCTOR_OUT" | grep "was generated by tracedecay" >&2 - exit 1 -fi -echo "ok - tracedecay doctor Hermes section is clean" - -echo "stock hermes integration: PASS" diff --git a/scripts/mcp-conformance-smoke.sh b/scripts/mcp-conformance-smoke.sh index deccdb4a2..8a71f56a7 100755 --- a/scripts/mcp-conformance-smoke.sh +++ b/scripts/mcp-conformance-smoke.sh @@ -26,131 +26,181 @@ set -euo pipefail INSPECTOR_VERSION="${INSPECTOR_VERSION:-0.22.0}" CALL_TIMEOUT_SECS="${CALL_TIMEOUT_SECS:-60}" - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -if [[ -z "${TRACEDECAY_BIN:-}" ]]; then - target_dir="${CARGO_TARGET_DIR:-$repo_root/target}" - for candidate in "$target_dir/debug/tracedecay" "$target_dir/release/tracedecay"; do - if [[ -x "$candidate" ]]; then - TRACEDECAY_BIN="$candidate" - break +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT_PATH="$REPO_ROOT/scripts/mcp-conformance-smoke.sh" +DAEMON_HARNESS="$REPO_ROOT/scripts/with-isolated-tracedecay-daemon.sh" +WORK_DIR="" +INIT_STDERR="" + +run_smoke() { + local work_dir="$1" + local fixture="$2" + local tools_a tools_b call_out res_out + local failures=0 + + inspect() { + # Run from the fixture so the spawned server's cwd matches the indexed + # project (otherwise tool results gain a cwd-mismatch warning block). + (cd "$fixture" && timeout "$CALL_TIMEOUT_SECS" \ + npx -y "@modelcontextprotocol/inspector@$INSPECTOR_VERSION" --cli \ + "$TRACEDECAY_BIN" serve -p "$fixture" "$@") + } + + # json_assert + json_assert() { + node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!eval(process.argv[2])) process.exit(1); + ' "$1" "$2" + } + + fail() { + echo "FAIL $1" >&2 + failures=$((failures + 1)) + } + ok() { + echo "ok $1" + } + + if ! (cd "$fixture" && "$TRACEDECAY_BIN" init >/dev/null 2>"$work_dir/init.stderr"); then + echo "error: tracedecay init failed" >&2 + return 1 + fi + "$TRACEDECAY_BIN" disable-upload-counter >/dev/null 2>&1 || true + + # 1. tools/list succeeds through the SDK client (implies the full initialize + # handshake + version negotiation + Zod validation of every tool schema). + tools_a="$work_dir/tools-a.json" + if inspect --method tools/list > "$tools_a" 2>"$work_dir/tools-a.err"; then + ok "tools/list (SDK handshake + schema validation)" + if json_assert "$tools_a" 'Array.isArray(j.tools) && j.tools.length > 5 && j.tools.every(t => t.name && t.inputSchema && t.inputSchema.type === "object")'; then + ok "tools/list has tools with object inputSchemas" + else + fail "tools/list has tools with object inputSchemas" fi - done -fi -if [[ -z "${TRACEDECAY_BIN:-}" ]]; then - TRACEDECAY_BIN="$(command -v tracedecay || true)" -fi -if [[ -z "$TRACEDECAY_BIN" || ! -x "$TRACEDECAY_BIN" ]]; then - echo "error: no tracedecay binary found; build one or set TRACEDECAY_BIN" >&2 - exit 2 -fi -TRACEDECAY_BIN="$(readlink -f "$TRACEDECAY_BIN")" -echo "using tracedecay binary: $TRACEDECAY_BIN ($("$TRACEDECAY_BIN" --version))" -echo "using inspector: @modelcontextprotocol/inspector@$INSPECTOR_VERSION" - -work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mcp-smoke.XXXXXX")" -trap 'rm -rf "$work_dir"' EXIT - -# Resolve the effective npm cache before HOME is redirected below, so npx -# keeps reusing the warm inspector install instead of re-downloading. -npm_config_cache="${npm_config_cache:-$(npm config get cache)}" -export npm_config_cache - -# Hermetic fixture: tiny indexed Rust project + redirected tracedecay state. -fixture="$work_dir/proj" -mkdir -p "$fixture/src" "$work_dir/home" -printf 'fn main() { println!("hello"); }\n' > "$fixture/src/main.rs" -git -C "$fixture" init --quiet - -export HOME="$work_dir/home" -export XDG_DATA_HOME="$HOME/.local/share" -export XDG_CONFIG_HOME="$HOME/.config" - -(cd "$fixture" && "$TRACEDECAY_BIN" init >/dev/null 2>&1) -"$TRACEDECAY_BIN" disable-upload-counter >/dev/null 2>&1 || true - -inspect() { - # Run from the fixture so the spawned server's cwd matches the indexed - # project (otherwise tool results gain a cwd-mismatch warning block). - (cd "$fixture" && timeout "$CALL_TIMEOUT_SECS" \ - npx -y "@modelcontextprotocol/inspector@$INSPECTOR_VERSION" --cli \ - "$TRACEDECAY_BIN" serve -p "$fixture" "$@") -} + if json_assert "$tools_a" 'j.tools.some(t => t.name === "tracedecay_search")'; then + ok "tools/list includes tracedecay_search" + else + fail "tools/list includes tracedecay_search" + fi + else + cat "$work_dir/tools-a.err" >&2 + fail "tools/list (SDK handshake + schema validation)" + fi -# json_assert -json_assert() { - node -e ' - const fs = require("fs"); - const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - if (!eval(process.argv[2])) process.exit(1); - ' "$1" "$2" -} + # 2. Determinism: a second run must be byte-identical. + tools_b="$work_dir/tools-b.json" + if inspect --method tools/list > "$tools_b" 2>/dev/null && cmp -s "$tools_a" "$tools_b"; then + ok "tools/list is deterministic across runs" + else + fail "tools/list is deterministic across runs" + fi -failures=0 -fail() { - echo "FAIL $1" >&2 - failures=$((failures + 1)) -} -ok() { - echo "ok $1" -} + # 3. tools/call round-trip against the indexed fixture. + call_out="$work_dir/call.json" + if inspect --method tools/call --tool-name tracedecay_search --tool-arg query=main > "$call_out" 2>/dev/null && + json_assert "$call_out" 'Array.isArray(j.content) && j.content.some(c => c.type === "text" && c.text.includes("Search Results") && c.text.includes("main"))'; then + ok "tools/call tracedecay_search finds main()" + else + fail "tools/call tracedecay_search finds main()" + fi -# 1. tools/list succeeds through the SDK client (implies the full initialize -# handshake + version negotiation + Zod validation of every tool schema). -tools_a="$work_dir/tools-a.json" -if inspect --method tools/list > "$tools_a" 2>"$work_dir/tools-a.err"; then - ok "tools/list (SDK handshake + schema validation)" - if json_assert "$tools_a" 'Array.isArray(j.tools) && j.tools.length > 5 && j.tools.every(t => t.name && t.inputSchema && t.inputSchema.type === "object")'; then - ok "tools/list has tools with object inputSchemas" + # 4. resources/list exposes the status resource. + res_out="$work_dir/resources.json" + if inspect --method resources/list > "$res_out" 2>/dev/null && + json_assert "$res_out" 'Array.isArray(j.resources) && j.resources.some(r => r.uri === "tracedecay://status")'; then + ok "resources/list exposes tracedecay://status" else - fail "tools/list has tools with object inputSchemas" + fail "resources/list exposes tracedecay://status" fi - if json_assert "$tools_a" 'j.tools.some(t => t.name === "tracedecay_search")'; then - ok "tools/list includes tracedecay_search" + + # 5. Error path: unknown tool must fail with a nonzero exit code. + if inspect --method tools/call --tool-name definitely_not_a_tool >/dev/null 2>&1; then + fail "tools/call unknown tool exits nonzero" else - fail "tools/list includes tracedecay_search" + ok "tools/call unknown tool exits nonzero" fi -else - cat "$work_dir/tools-a.err" >&2 - fail "tools/list (SDK handshake + schema validation)" -fi -# 2. Determinism: a second run must be byte-identical. -tools_b="$work_dir/tools-b.json" -if inspect --method tools/list > "$tools_b" 2>/dev/null && cmp -s "$tools_a" "$tools_b"; then - ok "tools/list is deterministic across runs" -else - fail "tools/list is deterministic across runs" -fi + if ((failures > 0)); then + echo "mcp-conformance-smoke: $failures check(s) failed" >&2 + return 1 + fi + echo "mcp-conformance-smoke: all checks passed" +} -# 3. tools/call round-trip against the indexed fixture. -call_out="$work_dir/call.json" -if inspect --method tools/call --tool-name tracedecay_search --tool-arg query=main > "$call_out" 2>/dev/null && - json_assert "$call_out" 'Array.isArray(j.content) && j.content.some(c => c.type === "text" && c.text.includes("Search Results") && c.text.includes("main"))'; then - ok "tools/call tracedecay_search finds main()" -else - fail "tools/call tracedecay_search finds main()" -fi +cleanup() { + local status=$? -# 4. resources/list exposes the status resource. -res_out="$work_dir/resources.json" -if inspect --method resources/list > "$res_out" 2>/dev/null && - json_assert "$res_out" 'Array.isArray(j.resources) && j.resources.some(r => r.uri === "tracedecay://status")'; then - ok "resources/list exposes tracedecay://status" -else - fail "resources/list exposes tracedecay://status" -fi + trap - EXIT + if ((status != 0)) && [[ -s "$INIT_STDERR" ]]; then + echo "tracedecay init stderr:" >&2 + cat "$INIT_STDERR" >&2 || true + fi + [[ -z "$WORK_DIR" ]] || rm -rf "$WORK_DIR" + exit "$status" +} -# 5. Error path: unknown tool must fail with a nonzero exit code. -if inspect --method tools/call --tool-name definitely_not_a_tool >/dev/null 2>&1; then - fail "tools/call unknown tool exits nonzero" -else - ok "tools/call unknown tool exits nonzero" -fi +find_tracedecay_bin() { + local target_dir candidate + + if [[ -n "${TRACEDECAY_BIN:-}" ]]; then + printf '%s\n' "$TRACEDECAY_BIN" + return + fi + + target_dir="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" + for candidate in "$target_dir/debug/tracedecay" "$target_dir/release/tracedecay"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return + fi + done + command -v tracedecay || true +} + +main() { + local tracedecay_bin npm_cache fixture status + + tracedecay_bin="$(find_tracedecay_bin)" + if [[ -z "$tracedecay_bin" || ! -x "$tracedecay_bin" ]]; then + echo "error: no tracedecay binary found; build one or set TRACEDECAY_BIN" >&2 + return 2 + fi + tracedecay_bin="$(readlink -f "$tracedecay_bin")" + echo "using tracedecay binary: $tracedecay_bin ($("$tracedecay_bin" --version))" + echo "using inspector: @modelcontextprotocol/inspector@$INSPECTOR_VERSION" + + # Resolve the effective npm cache before HOME is redirected so npx reuses + # the warm inspector install instead of re-downloading it. + npm_cache="${npm_config_cache:-$(npm config get cache)}" + + WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mcp-smoke.XXXXXX")" + INIT_STDERR="$WORK_DIR/init.stderr" + fixture="$WORK_DIR/proj" + mkdir -p "$fixture/src" "$WORK_DIR/home" + printf 'fn main() { println!("hello"); }\n' > "$fixture/src/main.rs" + git -C "$fixture" init --quiet + trap cleanup EXIT + + set +e + HOME="$WORK_DIR/home" \ + XDG_DATA_HOME="$WORK_DIR/home/.local/share" \ + XDG_CONFIG_HOME="$WORK_DIR/home/.config" \ + npm_config_cache="$npm_cache" \ + TRACEDECAY_BIN="$tracedecay_bin" \ + INSPECTOR_VERSION="$INSPECTOR_VERSION" \ + CALL_TIMEOUT_SECS="$CALL_TIMEOUT_SECS" \ + "$DAEMON_HARNESS" --bin "$tracedecay_bin" --ready-timeout 5 -- \ + "$SCRIPT_PATH" --run "$WORK_DIR" "$fixture" + status=$? + set -e + return "$status" +} -if [[ "$failures" -gt 0 ]]; then - echo "mcp-conformance-smoke: $failures check(s) failed" >&2 - exit 1 +if [[ "${1:-}" == "--run" ]]; then + shift + run_smoke "$@" +else + main "$@" fi -echo "mcp-conformance-smoke: all checks passed" diff --git a/scripts/with-isolated-tracedecay-daemon.sh b/scripts/with-isolated-tracedecay-daemon.sh new file mode 100755 index 000000000..84edc8307 --- /dev/null +++ b/scripts/with-isolated-tracedecay-daemon.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + with-isolated-tracedecay-daemon.sh --bin PATH [options] -- COMMAND [ARG...] + with-isolated-tracedecay-daemon.sh --cargo DIR [options] -- COMMAND [ARG...] + +Options: + --ready-timeout SECONDS Daemon readiness deadline (default: 60) + --stop-timeout SECONDS TERM grace period before KILL (default: 5) + --lifecycle-label LABEL Print start/stop messages using LABEL + +The harness creates an isolated TraceDecay profile and Unix socket, exports +TRACEDECAY_DATA_DIR and TRACEDECAY_DAEMON_SOCKET to COMMAND, and removes the +profile after the command and daemon have stopped. +EOF + exit 2 +} + +ready_timeout=60 +stop_timeout=5 +lifecycle_label="" +daemon_mode="" +daemon_value="" + +while (($# > 0)); do + case "$1" in + --bin | --cargo) + (($# >= 2)) || usage + [[ -z "$daemon_mode" ]] || usage + daemon_mode="${1#--}" + daemon_value="$2" + shift 2 + ;; + --ready-timeout) + (($# >= 2)) || usage + ready_timeout="$2" + shift 2 + ;; + --stop-timeout) + (($# >= 2)) || usage + stop_timeout="$2" + shift 2 + ;; + --lifecycle-label) + (($# >= 2)) || usage + lifecycle_label="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + usage + ;; + esac +done + +[[ -n "$daemon_mode" && $# -gt 0 ]] || usage +for value in "$ready_timeout" "$stop_timeout"; do + [[ "$value" =~ ^[1-9][0-9]*$ ]] || usage +done + +case "$daemon_mode" in + bin) + [[ -x "$daemon_value" ]] || { + echo "error: tracedecay binary is not executable: $daemon_value" >&2 + exit 2 + } + daemon_value="$(cd "$(dirname "$daemon_value")" && pwd)/$(basename "$daemon_value")" + ;; + cargo) + [[ -f "$daemon_value/Cargo.toml" ]] || { + echo "error: Cargo.toml not found under: $daemon_value" >&2 + exit 2 + } + daemon_value="$(cd "$daemon_value" && pwd)" + ;; +esac + +command -v python3 >/dev/null 2>&1 || { + echo "error: python3 is required for the bounded daemon socket probe" >&2 + exit 2 +} +command -v setsid >/dev/null 2>&1 || { + echo "error: setsid is required for bounded daemon process-group cleanup" >&2 + exit 2 +} + +run_dir="$(mktemp -d "${TMPDIR:-/tmp}/tracedecay-daemon.XXXXXX")" +export TRACEDECAY_DATA_DIR="$run_dir/profile" +export TRACEDECAY_DAEMON_SOCKET="$run_dir/daemon.sock" +export TRACEDECAY_DAEMON_HARNESS_ACTIVE=1 +daemon_log="$run_dir/daemon.log" +daemon_pid="" +mkdir -p "$TRACEDECAY_DATA_DIR" + +print_daemon_log() { + echo "----- tracedecay daemon log -----" >&2 + if [[ -s "$daemon_log" ]]; then + cat "$daemon_log" >&2 || true + else + echo "(no daemon output captured)" >&2 + fi +} + +daemon_group_alive() { + [[ -n "$daemon_pid" ]] && kill -0 -- "-$daemon_pid" 2>/dev/null +} + +stop_daemon() { + local deadline + + [[ -n "$daemon_pid" ]] || return 0 + if daemon_group_alive; then + [[ -z "$lifecycle_label" ]] || echo "== stopping $lifecycle_label" >&2 + kill -TERM -- "-$daemon_pid" 2>/dev/null || true + deadline=$((SECONDS + stop_timeout)) + while daemon_group_alive && ((SECONDS < deadline)); do + sleep 0.1 + done + if daemon_group_alive; then + [[ -z "$lifecycle_label" ]] || echo "== force stopping $lifecycle_label" >&2 + kill -KILL -- "-$daemon_pid" 2>/dev/null || true + fi + fi + wait "$daemon_pid" 2>/dev/null || true +} + +cleanup() { + local status=$? + + trap - EXIT INT TERM + stop_daemon + if ((status != 0)); then + print_daemon_log + fi + rm -rf "$run_dir" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +[[ -z "$lifecycle_label" ]] || echo "== starting $lifecycle_label" +if [[ "$daemon_mode" == "bin" ]]; then + setsid "$daemon_value" daemon run --socket "$TRACEDECAY_DAEMON_SOCKET" \ + >"$daemon_log" 2>&1 & +else + ( + cd "$daemon_value" + exec setsid cargo run -- daemon run --socket "$TRACEDECAY_DAEMON_SOCKET" + ) >"$daemon_log" 2>&1 & +fi +daemon_pid=$! + +if ! python3 - "$TRACEDECAY_DAEMON_SOCKET" "$daemon_pid" "$ready_timeout" <<'PY' +import os +import socket +import sys +import time + +socket_path, daemon_pid, timeout = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +deadline = time.monotonic() + timeout +while time.monotonic() < deadline: + try: + os.kill(daemon_pid, 0) + except ProcessLookupError: + print("error: tracedecay daemon exited before becoming ready", file=sys.stderr) + raise SystemExit(1) + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(min(0.25, max(0.01, deadline - time.monotonic()))) + try: + client.connect(socket_path) + except (FileNotFoundError, ConnectionRefusedError, socket.timeout, OSError): + time.sleep(min(0.1, max(0.0, deadline - time.monotonic()))) + else: + raise SystemExit(0) + finally: + client.close() + +print(f"error: tracedecay daemon did not become ready within {timeout} seconds", file=sys.stderr) +raise SystemExit(1) +PY +then + exit 1 +fi + +set +e +"$@" +status=$? +set -e + +if ((status == 0)) && ! daemon_group_alive; then + echo "error: tracedecay daemon exited while the smoke command was running" >&2 + status=1 +fi +exit "$status" diff --git a/src/automation_cli.rs b/src/automation_cli.rs deleted file mode 100644 index 042bacac1..000000000 --- a/src/automation_cli.rs +++ /dev/null @@ -1,1023 +0,0 @@ -use crate::cli::*; -use crate::parse_lcm_scope_arg; -use crate::resolve_cli_project_root; -use crate::update_cmd::tracedecay_bin_on_path; - -async fn daemon_project_dashboard_root( - project_path: &std::path::Path, -) -> tracedecay::errors::Result { - let context = crate::commands::daemon_tool_json( - Some(project_path), - "tracedecay_active_project", - serde_json::json!({ "format": "json" }), - ) - .await?; - let data_root = context - .get("storage") - .and_then(|storage| storage.get("data_root")) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "managed daemon returned no active project data_root".to_string(), - })?; - Ok(std::path::PathBuf::from(data_root).join("dashboard")) -} - -async fn daemon_automation_action( - project_path: &std::path::Path, - args: serde_json::Value, -) -> tracedecay::errors::Result { - crate::commands::daemon_tool_json(Some(project_path), "tracedecay_admin_project", args).await -} - -fn fact_apply_rpc_args(id: &str) -> serde_json::Value { - serde_json::json!({ "action": "fact_apply", "id": id }) -} - -fn automation_run_rpc_request( - action: AutomationRunAction, -) -> tracedecay::errors::Result<(Option, serde_json::Value)> { - let request = match action { - AutomationRunAction::MemoryCuration { - max_clusters, - min_confidence, - path, - } => ( - path, - serde_json::json!({ - "action": "automation_run", - "task": "memory_curation", - "options": { - "max_clusters": max_clusters, - "min_confidence": min_confidence, - }, - }), - ), - AutomationRunAction::SessionReflection { - provider, - query, - evidence_limit, - scope, - session_id, - include_summaries, - sort, - source, - role, - start_time, - end_time, - path, - } => { - parse_lcm_scope_arg(&scope)?; - sort.parse::() - .map_err(|()| tracedecay::errors::TraceDecayError::Config { - message: format!( - "invalid session-reflection --sort '{sort}'; expected recency, relevance, or hybrid" - ), - })?; - ( - path, - serde_json::json!({ - "action": "automation_run", - "task": "session_reflection", - "options": { - "provider": provider, - "query": query, - "evidence_limit": evidence_limit, - "scope": scope, - "session_id": session_id, - "include_summaries": include_summaries, - "sort": sort, - "source": source, - "role": role, - "start_time": start_time, - "end_time": end_time, - }, - }), - ) - } - AutomationRunAction::SkillWriting { - provider, - query, - evidence_limit, - path, - } => ( - path, - serde_json::json!({ - "action": "automation_run", - "task": "skill_writing", - "options": { - "provider": provider, - "query": query, - "evidence_limit": evidence_limit, - }, - }), - ), - }; - Ok(request) -} - -fn automation_run_result( - payload: &serde_json::Value, -) -> tracedecay::errors::Result<&serde_json::Value> { - payload - .get("run") - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "daemon automation response omitted run".to_string(), - }) -} - -pub(crate) async fn handle_automation_command( - action: AutomationAction, -) -> tracedecay::errors::Result<()> { - match action { - AutomationAction::Config { action } => handle_automation_config_command(action).await, - AutomationAction::Run { action } => handle_automation_run_command(action).await, - AutomationAction::Runs { action } => handle_automation_runs_command(action).await, - AutomationAction::Skills { action } => handle_automation_skills_command(action).await, - AutomationAction::Facts { action } => handle_automation_facts_command(action).await, - } -} - -async fn handle_automation_runs_command( - action: AutomationRunsAction, -) -> tracedecay::errors::Result<()> { - use tracedecay::automation::run_ledger::{ - find_run_record, load_run_records, read_run_artifact_payload, - }; - - let path = match &action { - AutomationRunsAction::List { path, .. } - | AutomationRunsAction::View { path, .. } - | AutomationRunsAction::Artifact { path, .. } => path.clone(), - }; - let project_path = resolve_cli_project_root(path, None, None).await?; - let dashboard_root = daemon_project_dashboard_root(&project_path).await?; - - match action { - AutomationRunsAction::List { limit, json, .. } => { - let limit = limit.min(200); - let records = load_run_records(&dashboard_root, limit).await?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "dashboard_root": dashboard_root, - "count": records.len(), - "limit": limit, - "records": records, - }))? - ); - } else { - print_automation_run_list(&records); - } - } - AutomationRunsAction::View { run_id, json, .. } => { - let record = find_run_record(&dashboard_root, &run_id) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("automation run not found: {run_id}"), - })?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "dashboard_root": dashboard_root, - "record": record, - }))? - ); - } else { - print_automation_run_record(&record); - } - } - AutomationRunsAction::Artifact { - run_id, kind, json, .. - } => { - let record = find_run_record(&dashboard_root, &run_id) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("automation run not found: {run_id}"), - })?; - let artifact = record - .artifacts - .iter() - .find(|artifact| artifact.kind == kind) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("automation run artifact not found: {run_id}/{kind}"), - })?; - let payload = - read_run_artifact_payload(&dashboard_root, &record.run_id, artifact).await?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "dashboard_root": dashboard_root, - "run_id": record.run_id, - "artifact": artifact, - "payload": payload, - }))? - ); - } else { - print_automation_run_artifact(&record.run_id, artifact, &payload)?; - } - } - } - Ok(()) -} - -fn print_automation_run_list( - records: &[tracedecay::automation::run_ledger::AutomationRunLedgerRecord], -) { - if records.is_empty() { - println!("No automation runs."); - return; - } - println!("RUN ID\tSTATUS\tTASK\tTRIGGER\tACCEPTED\tREJECTED\tCOMPLETED\tERROR"); - for record in records { - println!( - "{}\t{}\t{}\t{:?}\t{}\t{}\t{}\t{}", - record.run_id, - record.status.as_str(), - record - .task_key - .as_deref() - .unwrap_or_else(|| tracedecay::automation::backend::task_key(record.task)), - record.trigger, - record.accepted_count, - record.rejected_count, - record.completed_at, - record.error.as_deref().unwrap_or("") - ); - } -} - -fn print_automation_run_record( - record: &tracedecay::automation::run_ledger::AutomationRunLedgerRecord, -) { - println!("run_id: {}", record.run_id); - println!("status: {}", record.status.as_str()); - println!( - "task: {}", - record - .task_key - .as_deref() - .unwrap_or_else(|| tracedecay::automation::backend::task_key(record.task)) - ); - println!("trigger: {:?}", record.trigger); - println!("backend: {}", record.backend); - if let Some(model) = record.model.as_deref() { - println!("model: {model}"); - } - println!("accepted_count: {}", record.accepted_count); - println!("rejected_count: {}", record.rejected_count); - println!("reviewed_count: {}", record.reviewed_count); - if let Some(error) = record.error.as_deref() { - println!("error: {error}"); - } - if !record.artifacts.is_empty() { - println!("artifacts:"); - for artifact in &record.artifacts { - println!( - "- {}\t{}\t{}", - artifact.kind, - artifact.path, - artifact.summary.as_deref().unwrap_or("") - ); - } - } -} - -fn print_automation_run_artifact( - run_id: &str, - artifact: &tracedecay::automation::run_ledger::AutomationRunArtifact, - payload: &serde_json::Value, -) -> tracedecay::errors::Result<()> { - println!("run_id: {run_id}"); - println!("artifact: {}", artifact.kind); - println!("path: {}", artifact.path); - if let Some(summary) = artifact.summary.as_deref() { - println!("summary: {summary}"); - } - println!("{}", serde_json::to_string_pretty(payload)?); - Ok(()) -} - -async fn handle_automation_facts_command( - action: AutomationFactsAction, -) -> tracedecay::errors::Result<()> { - use tracedecay::automation::fact_proposals::{ - FactProposalState, list_fact_proposals, load_fact_proposal, reject_fact_proposal, - }; - - let path = match &action { - AutomationFactsAction::List { path, .. } - | AutomationFactsAction::View { path, .. } - | AutomationFactsAction::Apply { path, .. } - | AutomationFactsAction::Reject { path, .. } => path.clone(), - }; - let project_path = resolve_cli_project_root(path, None, None).await?; - let payload = match action { - AutomationFactsAction::List { state, limit, .. } => { - let dashboard_root = daemon_project_dashboard_root(&project_path).await?; - let state = match state { - Some(value) => Some(FactProposalState::parse(&value)?), - None => None, - }; - let proposals = list_fact_proposals(&dashboard_root, state, limit).await?; - serde_json::json!({ - "dashboard_root": dashboard_root, - "count": proposals.len(), - "proposals": proposals, - }) - } - AutomationFactsAction::View { id, .. } => { - let dashboard_root = daemon_project_dashboard_root(&project_path).await?; - let proposal = load_fact_proposal(&dashboard_root, &id) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("fact proposal not found: {id}"), - })?; - serde_json::json!({ "proposal": proposal }) - } - AutomationFactsAction::Apply { id, .. } => { - daemon_automation_action(&project_path, fact_apply_rpc_args(&id)).await? - } - AutomationFactsAction::Reject { id, reason, .. } => { - let dashboard_root = daemon_project_dashboard_root(&project_path).await?; - let proposal = - reject_fact_proposal(&dashboard_root, &id, Some("cli".to_string()), reason).await?; - serde_json::json!({ "proposal": proposal }) - } - }; - println!("{}", serde_json::to_string_pretty(&payload)?); - Ok(()) -} - -async fn handle_automation_skills_command( - action: AutomationSkillsAction, -) -> tracedecay::errors::Result<()> { - use tracedecay::automation::managed_skills::{ - ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, ManagedSkillUpdate, - approve_managed_skill, archive_managed_skill, create_managed_skill_draft, - disable_managed_skill, list_managed_skills, load_managed_skill, restore_managed_skill, - update_managed_skill, - }; - - let profile_root = tracedecay::storage::default_profile_root()?; - let mut refresh_exports = false; - let skill = match action { - AutomationSkillsAction::List { json } => { - let skills = list_managed_skills(&profile_root).await?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "profile_root": profile_root, - "count": skills.len(), - "skills": skills, - }))? - ); - } else if skills.is_empty() { - println!("No managed skills."); - } else { - for skill in skills { - println!( - "{}\t{:?}\t{}", - skill.metadata.id, skill.metadata.state, skill.metadata.title - ); - } - } - return Ok(()); - } - AutomationSkillsAction::View { id, json } => { - let skill = load_managed_skill(&profile_root, &id).await?; - if json { - println!("{}", serde_json::to_string_pretty(&skill)?); - } else { - print_managed_skill(&skill); - } - return Ok(()); - } - AutomationSkillsAction::Draft { - id, - title, - summary, - category, - body, - pinned, - } => { - let skill = create_managed_skill_draft( - &profile_root, - ManagedSkillDraft { - id, - title, - summary, - category, - targets: tracedecay::automation::managed_skills::default_managed_skill_targets( - ), - body_markdown: body, - support_files: Vec::new(), - provenance: ManagedSkillProvenance { - source: ManagedSkillSource::UserDraft, - actor: "cli".to_string(), - run_id: None, - }, - }, - ) - .await?; - if pinned { - tracedecay::automation::managed_skills::set_managed_skill_pinned( - &profile_root, - &skill.metadata.id, - true, - ) - .await? - } else { - skill - } - } - AutomationSkillsAction::Update { - id, - title, - summary, - category, - body, - pinned, - } => { - update_managed_skill( - &profile_root, - &id, - ManagedSkillUpdate { - title, - summary, - category, - body_markdown: body, - pinned, - ..ManagedSkillUpdate::default() - }, - ) - .await? - } - AutomationSkillsAction::Approve { id } => { - refresh_exports = true; - approve_managed_skill(&profile_root, &id).await? - } - AutomationSkillsAction::Disable { id } => { - refresh_exports = true; - disable_managed_skill(&profile_root, &id).await? - } - AutomationSkillsAction::Archive { id } => { - refresh_exports = true; - archive_managed_skill(&profile_root, &id).await? - } - AutomationSkillsAction::Restore { id } => { - refresh_exports = true; - restore_managed_skill(&profile_root, &id).await? - } - AutomationSkillsAction::Install { - target, - output, - plugin_artifact, - json, - } => { - let output = std::path::Path::new(&output); - let summary = if plugin_artifact { - if target != AutomationSkillsInstallTarget::Codex { - return Err(tracedecay::errors::TraceDecayError::Config { - message: - "--plugin-artifact is currently supported only with --target codex" - .to_string(), - }); - } - let tracedecay_bin = tracedecay_bin_on_path()?; - tracedecay::agents::codex::export_codex_plugin_artifact( - &profile_root, - output, - &tracedecay_bin, - )? - } else { - let summary = tracedecay::automation::skill_targets::install_managed_skills( - &profile_root, - target.into(), - output, - )?; - // The shareable Codex plugin artifact intentionally omits the - // memory digest (personal memory must not ship in a bundle); - // direct host installs export it alongside the skills. - tracedecay::automation::memory_digest::sync_memory_digest_export( - &profile_root, - target.into(), - output, - )?; - summary - }; - if json { - println!("{}", serde_json::to_string_pretty(&summary)?); - } else { - println!( - "Exported {} managed skill(s) to {}", - summary.exported_count, - summary.output.display() - ); - } - return Ok(()); - } - }; - if refresh_exports { - refresh_managed_skill_exports_for_cli(&profile_root); - } - println!("{}", serde_json::to_string_pretty(&skill)?); - Ok(()) -} - -fn refresh_managed_skill_exports_for_cli(profile_root: &std::path::Path) { - let Some(home) = tracedecay::agents::home_dir() else { - return; - }; - let start = std::env::current_dir().unwrap_or_else(|_| home.clone()); - let project_root = tracedecay::automation::skill_materialization::resolve_project_root(&start); - for report in - tracedecay::agents::export_managed_skills_to_agent_hosts(&home, &project_root, profile_root) - { - if let Some(error) = report.error { - eprintln!( - "warning: failed to refresh managed skill exports for {}: {}", - report.agent, error - ); - } - } - // Materialize active managed skills as real, host-loadable SKILL.md files - // into every detected `.claude`/`.codex` skills directory (project + global). - tracedecay::automation::skill_materialization::reconcile_after_activation( - profile_root, - &project_root, - ); -} - -fn print_managed_skill(skill: &tracedecay::automation::managed_skills::ManagedSkill) { - println!("id: {}", skill.metadata.id); - println!("title: {}", skill.metadata.title); - println!("summary: {}", skill.metadata.summary); - println!("category: {}", skill.metadata.category); - println!("state: {:?}", skill.metadata.state); - println!("pinned: {}", skill.metadata.pinned); - println!("checksum: {}", skill.metadata.checksum); - println!(); - println!("{}", skill.body_markdown); -} - -async fn handle_automation_run_command( - action: AutomationRunAction, -) -> tracedecay::errors::Result<()> { - let (path, args) = automation_run_rpc_request(action)?; - let project_path = resolve_cli_project_root(path, None, None).await?; - let payload = daemon_automation_action(&project_path, args).await?; - let run = automation_run_result(&payload)?; - println!("{}", serde_json::to_string_pretty(run)?); - Ok(()) -} - -async fn handle_automation_config_command( - action: AutomationConfigAction, -) -> tracedecay::errors::Result<()> { - use tracedecay::automation::config::{ - AutomationBackend, AutomationConfigPatch, apply_project_config_patch, effective_config, - load_project_config, - }; - - let path = match &action { - AutomationConfigAction::Get { path, .. } - | AutomationConfigAction::Explain { path, .. } - | AutomationConfigAction::Enable { path, .. } - | AutomationConfigAction::Disable { path, .. } - | AutomationConfigAction::Set { path, .. } => path.clone(), - }; - let scope = match &action { - AutomationConfigAction::Get { scope, .. } - | AutomationConfigAction::Explain { scope, .. } - | AutomationConfigAction::Enable { scope, .. } - | AutomationConfigAction::Disable { scope, .. } - | AutomationConfigAction::Set { scope, .. } => *scope, - }; - - let mut user_config = tracedecay::user_config::UserConfig::load(); - let global = user_config.automation.clone(); - let project_context = if scope == AutomationConfigScope::Project { - let project_path = resolve_cli_project_root(path, None, None).await?; - let dashboard_root = daemon_project_dashboard_root(&project_path).await?; - Some(( - dashboard_root.clone(), - load_project_config(&dashboard_root).await?, - )) - } else { - None - }; - - let patch = match action { - AutomationConfigAction::Get { json, .. } => { - let project = project_context - .as_ref() - .and_then(|(_, project)| project.as_ref()); - let effective = effective_config(&global, project)?; - print_automation_config(&global, project, &effective, json, false)?; - return Ok(()); - } - AutomationConfigAction::Explain { json, .. } => { - let project = project_context - .as_ref() - .and_then(|(_, project)| project.as_ref()); - let effective = effective_config(&global, project)?; - print_automation_config(&global, project, &effective, json, true)?; - return Ok(()); - } - AutomationConfigAction::Enable { .. } => AutomationConfigPatch { - enabled: Some(true), - backend: Some(AutomationBackend::CodexAppServer), - ..AutomationConfigPatch::default() - }, - AutomationConfigAction::Disable { .. } => AutomationConfigPatch { - enabled: Some(false), - ..AutomationConfigPatch::default() - }, - AutomationConfigAction::Set { - backend, - host_mode, - timeout_secs, - scheduler_tick_secs, - auto_apply_memory_ops, - auto_enable_skills, - export_memory_digest, - memory_curator, - memory_curator_schedule, - memory_curator_interval_secs, - memory_curator_cooldown_secs, - memory_curator_min_idle_secs, - memory_curator_stale_lock_secs, - session_reflector, - session_reflector_schedule, - session_reflector_interval_secs, - session_reflector_cooldown_secs, - session_reflector_min_idle_secs, - session_reflector_stale_lock_secs, - skill_writer, - skill_writer_schedule, - skill_writer_interval_secs, - skill_writer_cooldown_secs, - skill_writer_min_idle_secs, - skill_writer_stale_lock_secs, - .. - } => AutomationConfigPatch { - backend: backend - .as_deref() - .map(parse_automation_backend) - .transpose()?, - host_mode: host_mode - .as_deref() - .map(parse_automation_host_mode) - .transpose()?, - timeout_secs, - scheduler_tick_secs, - auto_apply_memory_ops, - auto_enable_skills, - export_memory_digest, - memory_curator: automation_task_patch( - memory_curator, - memory_curator_schedule, - memory_curator_interval_secs, - memory_curator_cooldown_secs, - memory_curator_min_idle_secs, - memory_curator_stale_lock_secs, - "memory_curator", - )?, - session_reflector: automation_task_patch( - session_reflector, - session_reflector_schedule, - session_reflector_interval_secs, - session_reflector_cooldown_secs, - session_reflector_min_idle_secs, - session_reflector_stale_lock_secs, - "session_reflector", - )?, - skill_writer: automation_task_patch( - skill_writer, - skill_writer_schedule, - skill_writer_interval_secs, - skill_writer_cooldown_secs, - skill_writer_min_idle_secs, - skill_writer_stale_lock_secs, - "skill_writer", - )?, - ..AutomationConfigPatch::default() - }, - }; - - if scope == AutomationConfigScope::Global { - let effective = effective_config(&global, Some(&patch))?; - user_config.automation = effective.clone(); - match user_config.save_with_recovery() { - Ok(Some(backup)) => { - eprintln!( - "note: the previous config.toml was corrupt and was backed up to {} before regenerating", - backup.display() - ); - } - Ok(None) => {} - Err(err) => { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!("failed to save global automation config: {err}"), - }); - } - } - return print_automation_config(&user_config.automation, None, &effective, true, false); - } - - let (dashboard_root, _) = project_context.expect("project scope has project context"); - let (project, effective) = apply_project_config_patch(&dashboard_root, &global, patch).await?; - print_automation_config(&global, Some(&project), &effective, true, false) -} - -fn automation_task_patch( - enabled: Option, - schedule: Option, - interval_secs: Option, - cooldown_secs: Option, - min_idle_secs: Option, - stale_lock_secs: Option, - task: &str, -) -> tracedecay::errors::Result { - Ok(tracedecay::automation::config::AutomationTaskPatch { - enabled, - schedule: schedule.map(empty_string_or_none_clears), - interval_secs: parse_optional_u64(interval_secs, &format!("{task} interval_secs"))?, - cooldown_secs: parse_optional_u64(cooldown_secs, &format!("{task} cooldown_secs"))?, - min_idle_secs: parse_optional_u64(min_idle_secs, &format!("{task} min_idle_secs"))?, - stale_lock_secs: parse_optional_u64(stale_lock_secs, &format!("{task} stale_lock_secs"))?, - }) -} - -fn empty_string_or_none_clears(value: String) -> Option { - if string_clears_optional(&value) { - None - } else { - Some(value) - } -} - -fn string_clears_optional(value: &str) -> bool { - value.is_empty() || value.eq_ignore_ascii_case("none") -} - -fn parse_optional_u64( - value: Option, - field: &str, -) -> tracedecay::errors::Result>> { - parse_optional_number(value, field, str::parse::) -} - -fn parse_optional_number( - value: Option, - field: &str, - parse: impl FnOnce(&str) -> std::result::Result, -) -> tracedecay::errors::Result>> -where - E: std::fmt::Display, -{ - let Some(value) = value else { - return Ok(None); - }; - if string_clears_optional(&value) { - return Ok(Some(None)); - } - parse(&value) - .map(Some) - .map(Some) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: format!("invalid automation config value for {field}: {err}"), - }) -} - -fn print_automation_config( - global: &tracedecay::automation::config::AutomationConfig, - project: Option<&tracedecay::automation::config::AutomationConfigPatch>, - effective: &tracedecay::automation::config::AutomationConfig, - json: bool, - explain: bool, -) -> tracedecay::errors::Result<()> { - let availability = tracedecay::automation::backend::backend_availability(effective); - let source = if project.is_some() { - "project" - } else { - "global" - }; - let trace_decay_backend_calls = effective.enabled - && matches!( - effective.backend, - tracedecay::automation::config::AutomationBackend::CodexAppServer - ) - && effective.host_mode == tracedecay::automation::config::AutomationHostMode::Standalone; - let delegated_host = - effective.host_mode == tracedecay::automation::config::AutomationHostMode::DelegatedHost; - // Automation applies validated memory output autonomously; - // `require_dashboard_approval` and `auto_apply_memory_ops` are retained - // only for legacy config compatibility and do not gate curation runs. - let memory_ops_policy = "validate_then_apply"; - let skills_policy = if effective.auto_enable_skills { - "auto_enable" - } else { - "draft_for_approval" - }; - let payload = serde_json::json!({ - "global": global, - "project": project, - "effective": effective, - "backend_availability": availability, - "explanation": { - "source": source, - "trace_decay_backend_calls": trace_decay_backend_calls, - "delegated_host": delegated_host, - "auto_apply_memory_ops": effective.auto_apply_memory_ops, - "auto_apply_memory_ops_legacy_config_only": true, - "auto_enable_skills": effective.auto_enable_skills, - "export_memory_digest": effective.export_memory_digest, - "effective_apply_policy": { - "mode": "autonomous", - "human_approval_required": false, - "dashboard_approval": "deprecated", - "memory_ops": memory_ops_policy, - "skills": skills_policy, - }, - }, - }); - if json { - println!("{}", serde_json::to_string_pretty(&payload)?); - } else { - println!("enabled: {}", effective.enabled); - println!("backend: {:?}", effective.backend); - println!("host_mode: {:?}", effective.host_mode); - if explain { - println!("source: {source}"); - println!("trace_decay_backend_calls: {trace_decay_backend_calls}"); - println!("delegated_host: {delegated_host}"); - } - println!("backend_available: {}", availability.available); - if let Some(executable) = availability.executable.as_deref() { - println!("backend_executable: {executable}"); - } - if let Some(reason) = availability.reason.as_deref() { - println!("backend_reason: {reason}"); - } - println!("model: auto"); - println!("timeout_secs: {}", effective.timeout_secs); - println!("scheduler_tick_secs: {}", effective.scheduler_tick_secs); - println!("memory_curator: {}", effective.tasks.memory_curator.enabled); - println!("effective_apply_policy: autonomous"); - if explain { - println!( - "session_reflector: {}", - effective.tasks.session_reflector.enabled - ); - println!("skill_writer: {}", effective.tasks.skill_writer.enabled); - println!( - "auto_apply_memory_ops: {} (legacy; autonomous curation always applies)", - effective.auto_apply_memory_ops - ); - println!("auto_enable_skills: {}", effective.auto_enable_skills); - println!("export_memory_digest: {}", effective.export_memory_digest); - println!("apply_policy.human_approval_required: false"); - println!("apply_policy.dashboard_approval: deprecated"); - println!("apply_policy.memory_ops: {memory_ops_policy}"); - println!("apply_policy.skills: {skills_policy}"); - } - } - Ok(()) -} - -fn parse_automation_backend( - value: &str, -) -> tracedecay::errors::Result { - use tracedecay::automation::config::AutomationBackend; - match value { - "disabled" => Ok(AutomationBackend::Disabled), - "codex-app-server" | "codex_app_server" => Ok(AutomationBackend::CodexAppServer), - _ => Err(tracedecay::errors::TraceDecayError::Config { - message: format!( - "unknown automation backend '{value}' (expected disabled, codex-app-server)" - ), - }), - } -} - -fn parse_automation_host_mode( - value: &str, -) -> tracedecay::errors::Result { - use tracedecay::automation::config::AutomationHostMode; - match value { - "standalone" => Ok(AutomationHostMode::Standalone), - "delegated-host" | "delegated_host" | "hermes-hosted" | "hermes_hosted" => { - Ok(AutomationHostMode::DelegatedHost) - } - _ => Err(tracedecay::errors::TraceDecayError::Config { - message: format!( - "unknown automation host mode '{value}' (expected standalone, delegated-host)" - ), - }), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn automation_rpc_requests_preserve_fact_and_manual_run_arguments() { - assert_eq!( - fact_apply_rpc_args("fact-7"), - serde_json::json!({ "action": "fact_apply", "id": "fact-7" }) - ); - - let (path, request) = automation_run_rpc_request(AutomationRunAction::MemoryCuration { - max_clusters: 9, - min_confidence: 0.7, - path: Some("/repo".to_string()), - }) - .unwrap(); - assert_eq!(path.as_deref(), Some("/repo")); - assert_eq!( - request, - serde_json::json!({ - "action": "automation_run", - "task": "memory_curation", - "options": { "max_clusters": 9, "min_confidence": 0.7 }, - }) - ); - - let (path, request) = automation_run_rpc_request(AutomationRunAction::SessionReflection { - provider: "claude".to_string(), - query: "decisions".to_string(), - evidence_limit: 11, - scope: "session".to_string(), - session_id: Some("session-3".to_string()), - include_summaries: false, - sort: "hybrid".to_string(), - source: Some("assistant".to_string()), - role: Some("user".to_string()), - start_time: Some(10), - end_time: Some(20), - path: None, - }) - .unwrap(); - assert_eq!(path, None); - assert_eq!( - request, - serde_json::json!({ - "action": "automation_run", - "task": "session_reflection", - "options": { - "provider": "claude", - "query": "decisions", - "evidence_limit": 11, - "scope": "session", - "session_id": "session-3", - "include_summaries": false, - "sort": "hybrid", - "source": "assistant", - "role": "user", - "start_time": 10, - "end_time": 20, - }, - }) - ); - - let (_, request) = automation_run_rpc_request(AutomationRunAction::SkillWriting { - provider: "all".to_string(), - query: "repeated workflow".to_string(), - evidence_limit: 13, - path: None, - }) - .unwrap(); - assert_eq!( - request, - serde_json::json!({ - "action": "automation_run", - "task": "skill_writing", - "options": { - "provider": "all", - "query": "repeated workflow", - "evidence_limit": 13, - }, - }) - ); - } - - #[test] - fn automation_rpc_preserves_response_and_has_no_local_database_fallback() { - let payload = serde_json::json!({ "run": { "run_id": "run-5", "status": "ok" } }); - assert_eq!(automation_run_result(&payload).unwrap(), &payload["run"]); - assert!(automation_run_result(&serde_json::json!({})).is_err()); - - let source = include_str!("automation_cli.rs"); - let direct_init = ["serve::ensure_", "initialized"].concat(); - let direct_apply = ["apply_fact_", "proposal("].concat(); - assert!(!source.contains(&direct_init)); - assert!(!source.contains(&direct_apply)); - assert!(source.contains("tracedecay_admin_project")); - } -} diff --git a/src/automation_cli/config.rs b/src/automation_cli/config.rs new file mode 100644 index 000000000..a15495838 --- /dev/null +++ b/src/automation_cli/config.rs @@ -0,0 +1,350 @@ +use super::daemon_project_dashboard_root; +use crate::cli::{AutomationConfigAction, AutomationConfigScope}; +use crate::resolve_cli_project_root; + +pub(super) async fn handle_automation_config_command( + action: AutomationConfigAction, +) -> tracedecay::errors::Result<()> { + use tracedecay::automation::config::{ + AutomationBackend, AutomationConfigPatch, apply_project_config_patch, effective_config, + load_project_config, + }; + + let path = match &action { + AutomationConfigAction::Get { path, .. } + | AutomationConfigAction::Explain { path, .. } + | AutomationConfigAction::Enable { path, .. } + | AutomationConfigAction::Disable { path, .. } + | AutomationConfigAction::Set { path, .. } => path.clone(), + }; + let scope = match &action { + AutomationConfigAction::Get { scope, .. } + | AutomationConfigAction::Explain { scope, .. } + | AutomationConfigAction::Enable { scope, .. } + | AutomationConfigAction::Disable { scope, .. } + | AutomationConfigAction::Set { scope, .. } => *scope, + }; + + let mut user_config = tracedecay::user_config::UserConfig::load(); + let global = user_config.automation.clone(); + let project_context = if scope == AutomationConfigScope::Project { + let project_path = resolve_cli_project_root(path, None, None).await?; + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; + Some(( + dashboard_root.clone(), + load_project_config(&dashboard_root).await?, + )) + } else { + None + }; + + let patch = match action { + AutomationConfigAction::Get { json, .. } => { + let project = project_context + .as_ref() + .and_then(|(_, project)| project.as_ref()); + let effective = effective_config(&global, project)?; + print_automation_config(&global, project, &effective, json, false)?; + return Ok(()); + } + AutomationConfigAction::Explain { json, .. } => { + let project = project_context + .as_ref() + .and_then(|(_, project)| project.as_ref()); + let effective = effective_config(&global, project)?; + print_automation_config(&global, project, &effective, json, true)?; + return Ok(()); + } + AutomationConfigAction::Enable { .. } => AutomationConfigPatch { + enabled: Some(true), + backend: Some(AutomationBackend::CodexAppServer), + ..AutomationConfigPatch::default() + }, + AutomationConfigAction::Disable { .. } => AutomationConfigPatch { + enabled: Some(false), + ..AutomationConfigPatch::default() + }, + AutomationConfigAction::Set { + backend, + host_mode, + timeout_secs, + scheduler_tick_secs, + auto_apply_memory_ops, + auto_enable_skills, + export_memory_digest, + memory_curator, + memory_curator_schedule, + memory_curator_interval_secs, + memory_curator_cooldown_secs, + memory_curator_min_idle_secs, + memory_curator_stale_lock_secs, + session_reflector, + session_reflector_schedule, + session_reflector_interval_secs, + session_reflector_cooldown_secs, + session_reflector_min_idle_secs, + session_reflector_stale_lock_secs, + skill_writer, + skill_writer_schedule, + skill_writer_interval_secs, + skill_writer_cooldown_secs, + skill_writer_min_idle_secs, + skill_writer_stale_lock_secs, + .. + } => AutomationConfigPatch { + backend: backend + .as_deref() + .map(parse_automation_backend) + .transpose()?, + host_mode: host_mode + .as_deref() + .map(parse_automation_host_mode) + .transpose()?, + timeout_secs, + scheduler_tick_secs, + auto_apply_memory_ops, + auto_enable_skills, + export_memory_digest, + memory_curator: automation_task_patch( + memory_curator, + memory_curator_schedule, + memory_curator_interval_secs, + memory_curator_cooldown_secs, + memory_curator_min_idle_secs, + memory_curator_stale_lock_secs, + "memory_curator", + )?, + session_reflector: automation_task_patch( + session_reflector, + session_reflector_schedule, + session_reflector_interval_secs, + session_reflector_cooldown_secs, + session_reflector_min_idle_secs, + session_reflector_stale_lock_secs, + "session_reflector", + )?, + skill_writer: automation_task_patch( + skill_writer, + skill_writer_schedule, + skill_writer_interval_secs, + skill_writer_cooldown_secs, + skill_writer_min_idle_secs, + skill_writer_stale_lock_secs, + "skill_writer", + )?, + ..AutomationConfigPatch::default() + }, + }; + + if scope == AutomationConfigScope::Global { + let effective = effective_config(&global, Some(&patch))?; + user_config.automation = effective.clone(); + match user_config.save_with_recovery() { + Ok(Some(backup)) => { + eprintln!( + "note: the previous config.toml was corrupt and was backed up to {} before regenerating", + backup.display() + ); + } + Ok(None) => {} + Err(err) => { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!("failed to save global automation config: {err}"), + }); + } + } + return print_automation_config(&user_config.automation, None, &effective, true, false); + } + + let (dashboard_root, _) = project_context.expect("project scope has project context"); + let (project, effective) = apply_project_config_patch(&dashboard_root, &global, patch).await?; + print_automation_config(&global, Some(&project), &effective, true, false) +} + +fn automation_task_patch( + enabled: Option, + schedule: Option, + interval_secs: Option, + cooldown_secs: Option, + min_idle_secs: Option, + stale_lock_secs: Option, + task: &str, +) -> tracedecay::errors::Result { + Ok(tracedecay::automation::config::AutomationTaskPatch { + enabled, + schedule: schedule.map(empty_string_or_none_clears), + interval_secs: parse_optional_u64(interval_secs, &format!("{task} interval_secs"))?, + cooldown_secs: parse_optional_u64(cooldown_secs, &format!("{task} cooldown_secs"))?, + min_idle_secs: parse_optional_u64(min_idle_secs, &format!("{task} min_idle_secs"))?, + stale_lock_secs: parse_optional_u64(stale_lock_secs, &format!("{task} stale_lock_secs"))?, + }) +} + +fn empty_string_or_none_clears(value: String) -> Option { + if string_clears_optional(&value) { + None + } else { + Some(value) + } +} + +fn string_clears_optional(value: &str) -> bool { + value.is_empty() || value.eq_ignore_ascii_case("none") +} + +fn parse_optional_u64( + value: Option, + field: &str, +) -> tracedecay::errors::Result>> { + parse_optional_number(value, field, str::parse::) +} + +fn parse_optional_number( + value: Option, + field: &str, + parse: impl FnOnce(&str) -> std::result::Result, +) -> tracedecay::errors::Result>> +where + E: std::fmt::Display, +{ + let Some(value) = value else { + return Ok(None); + }; + if string_clears_optional(&value) { + return Ok(Some(None)); + } + parse(&value) + .map(Some) + .map(Some) + .map_err(|err| tracedecay::errors::TraceDecayError::Config { + message: format!("invalid automation config value for {field}: {err}"), + }) +} + +fn print_automation_config( + global: &tracedecay::automation::config::AutomationConfig, + project: Option<&tracedecay::automation::config::AutomationConfigPatch>, + effective: &tracedecay::automation::config::AutomationConfig, + json: bool, + explain: bool, +) -> tracedecay::errors::Result<()> { + let availability = tracedecay::automation::backend::backend_availability(effective); + let source = if project.is_some() { + "project" + } else { + "global" + }; + let trace_decay_backend_calls = effective.enabled + && matches!( + effective.backend, + tracedecay::automation::config::AutomationBackend::CodexAppServer + ) + && effective.host_mode == tracedecay::automation::config::AutomationHostMode::Standalone; + let delegated_host = + effective.host_mode == tracedecay::automation::config::AutomationHostMode::DelegatedHost; + // Automation applies validated memory output autonomously; + // `require_dashboard_approval` and `auto_apply_memory_ops` are retained + // only for legacy config compatibility and do not gate curation runs. + let memory_ops_policy = "validate_then_apply"; + let skills_policy = if effective.auto_enable_skills { + "auto_enable" + } else { + "draft_for_approval" + }; + let payload = serde_json::json!({ + "global": global, + "project": project, + "effective": effective, + "backend_availability": availability, + "explanation": { + "source": source, + "trace_decay_backend_calls": trace_decay_backend_calls, + "delegated_host": delegated_host, + "auto_apply_memory_ops": effective.auto_apply_memory_ops, + "auto_apply_memory_ops_legacy_config_only": true, + "auto_enable_skills": effective.auto_enable_skills, + "export_memory_digest": effective.export_memory_digest, + "effective_apply_policy": { + "mode": "autonomous", + "human_approval_required": false, + "dashboard_approval": "deprecated", + "memory_ops": memory_ops_policy, + "skills": skills_policy, + }, + }, + }); + if json { + println!("{}", serde_json::to_string_pretty(&payload)?); + } else { + println!("enabled: {}", effective.enabled); + println!("backend: {:?}", effective.backend); + println!("host_mode: {:?}", effective.host_mode); + if explain { + println!("source: {source}"); + println!("trace_decay_backend_calls: {trace_decay_backend_calls}"); + println!("delegated_host: {delegated_host}"); + } + println!("backend_available: {}", availability.available); + if let Some(executable) = availability.executable.as_deref() { + println!("backend_executable: {executable}"); + } + if let Some(reason) = availability.reason.as_deref() { + println!("backend_reason: {reason}"); + } + println!("model: auto"); + println!("timeout_secs: {}", effective.timeout_secs); + println!("scheduler_tick_secs: {}", effective.scheduler_tick_secs); + println!("memory_curator: {}", effective.tasks.memory_curator.enabled); + println!("effective_apply_policy: autonomous"); + if explain { + println!( + "session_reflector: {}", + effective.tasks.session_reflector.enabled + ); + println!("skill_writer: {}", effective.tasks.skill_writer.enabled); + println!( + "auto_apply_memory_ops: {} (legacy; autonomous curation always applies)", + effective.auto_apply_memory_ops + ); + println!("auto_enable_skills: {}", effective.auto_enable_skills); + println!("export_memory_digest: {}", effective.export_memory_digest); + println!("apply_policy.human_approval_required: false"); + println!("apply_policy.dashboard_approval: deprecated"); + println!("apply_policy.memory_ops: {memory_ops_policy}"); + println!("apply_policy.skills: {skills_policy}"); + } + } + Ok(()) +} + +fn parse_automation_backend( + value: &str, +) -> tracedecay::errors::Result { + use tracedecay::automation::config::AutomationBackend; + match value { + "disabled" => Ok(AutomationBackend::Disabled), + "codex-app-server" | "codex_app_server" => Ok(AutomationBackend::CodexAppServer), + _ => Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "unknown automation backend '{value}' (expected disabled, codex-app-server)" + ), + }), + } +} + +fn parse_automation_host_mode( + value: &str, +) -> tracedecay::errors::Result { + use tracedecay::automation::config::AutomationHostMode; + match value { + "standalone" => Ok(AutomationHostMode::Standalone), + "delegated-host" | "delegated_host" | "hermes-hosted" | "hermes_hosted" => { + Ok(AutomationHostMode::DelegatedHost) + } + _ => Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "unknown automation host mode '{value}' (expected standalone, delegated-host)" + ), + }), + } +} diff --git a/src/automation_cli/facts.rs b/src/automation_cli/facts.rs new file mode 100644 index 000000000..cdf361d26 --- /dev/null +++ b/src/automation_cli/facts.rs @@ -0,0 +1,58 @@ +use super::{daemon_automation_action, daemon_project_dashboard_root}; +use crate::cli::AutomationFactsAction; +use crate::resolve_cli_project_root; + +pub(super) fn fact_apply_rpc_args(id: &str) -> serde_json::Value { + serde_json::json!({ "action": "fact_apply", "id": id }) +} + +pub(super) async fn handle_automation_facts_command( + action: AutomationFactsAction, +) -> tracedecay::errors::Result<()> { + use tracedecay::automation::fact_proposals::{ + FactProposalState, list_fact_proposals, load_fact_proposal, reject_fact_proposal, + }; + + let path = match &action { + AutomationFactsAction::List { path, .. } + | AutomationFactsAction::View { path, .. } + | AutomationFactsAction::Apply { path, .. } + | AutomationFactsAction::Reject { path, .. } => path.clone(), + }; + let project_path = resolve_cli_project_root(path, None, None).await?; + let payload = match action { + AutomationFactsAction::List { state, limit, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; + let state = match state { + Some(value) => Some(FactProposalState::parse(&value)?), + None => None, + }; + let proposals = list_fact_proposals(&dashboard_root, state, limit).await?; + serde_json::json!({ + "dashboard_root": dashboard_root, + "count": proposals.len(), + "proposals": proposals, + }) + } + AutomationFactsAction::View { id, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; + let proposal = load_fact_proposal(&dashboard_root, &id) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("fact proposal not found: {id}"), + })?; + serde_json::json!({ "proposal": proposal }) + } + AutomationFactsAction::Apply { id, .. } => { + daemon_automation_action(&project_path, fact_apply_rpc_args(&id)).await? + } + AutomationFactsAction::Reject { id, reason, .. } => { + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; + let proposal = + reject_fact_proposal(&dashboard_root, &id, Some("cli".to_string()), reason).await?; + serde_json::json!({ "proposal": proposal }) + } + }; + println!("{}", serde_json::to_string_pretty(&payload)?); + Ok(()) +} diff --git a/src/automation_cli/mod.rs b/src/automation_cli/mod.rs new file mode 100644 index 000000000..8f2980482 --- /dev/null +++ b/src/automation_cli/mod.rs @@ -0,0 +1,156 @@ +mod config; +mod facts; +mod runs; +mod skills; + +use crate::cli::AutomationAction; + +async fn daemon_project_dashboard_root( + project_path: &std::path::Path, +) -> tracedecay::errors::Result { + let context = crate::commands::daemon_tool_json( + Some(project_path), + "tracedecay_active_project", + serde_json::json!({ "format": "json" }), + ) + .await?; + let data_root = context + .get("storage") + .and_then(|storage| storage.get("data_root")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "managed daemon returned no active project data_root".to_string(), + })?; + Ok(std::path::PathBuf::from(data_root).join("dashboard")) +} + +async fn daemon_automation_action( + project_path: &std::path::Path, + args: serde_json::Value, +) -> tracedecay::errors::Result { + crate::commands::daemon_tool_json(Some(project_path), "tracedecay_admin_project", args).await +} + +pub(crate) async fn handle_automation_command( + action: AutomationAction, +) -> tracedecay::errors::Result<()> { + match action { + AutomationAction::Config { action } => { + config::handle_automation_config_command(action).await + } + AutomationAction::Run { action } => runs::handle_automation_run_command(action).await, + AutomationAction::Runs { action } => runs::handle_automation_runs_command(action).await, + AutomationAction::Skills { action } => { + skills::handle_automation_skills_command(action).await + } + AutomationAction::Facts { action } => facts::handle_automation_facts_command(action).await, + } +} + +#[cfg(test)] +mod tests { + use super::{facts::fact_apply_rpc_args, runs::*}; + use crate::cli::AutomationRunAction; + + #[test] + fn automation_rpc_requests_preserve_fact_and_manual_run_arguments() { + assert_eq!( + fact_apply_rpc_args("fact-7"), + serde_json::json!({ "action": "fact_apply", "id": "fact-7" }) + ); + + let (path, request) = automation_run_rpc_request(AutomationRunAction::MemoryCuration { + max_clusters: 9, + min_confidence: 0.7, + path: Some("/repo".to_string()), + }) + .unwrap(); + assert_eq!(path.as_deref(), Some("/repo")); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { "max_clusters": 9, "min_confidence": 0.7 }, + }) + ); + + let (path, request) = automation_run_rpc_request(AutomationRunAction::SessionReflection { + provider: "claude".to_string(), + query: "decisions".to_string(), + evidence_limit: 11, + scope: "session".to_string(), + session_id: Some("session-3".to_string()), + include_summaries: false, + sort: "hybrid".to_string(), + source: Some("assistant".to_string()), + role: Some("user".to_string()), + start_time: Some(10), + end_time: Some(20), + path: None, + }) + .unwrap(); + assert_eq!(path, None); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "session_reflection", + "options": { + "provider": "claude", + "query": "decisions", + "evidence_limit": 11, + "scope": "session", + "session_id": "session-3", + "include_summaries": false, + "sort": "hybrid", + "source": "assistant", + "role": "user", + "start_time": 10, + "end_time": 20, + }, + }) + ); + + let (_, request) = automation_run_rpc_request(AutomationRunAction::SkillWriting { + provider: "all".to_string(), + query: "repeated workflow".to_string(), + evidence_limit: 13, + path: None, + }) + .unwrap(); + assert_eq!( + request, + serde_json::json!({ + "action": "automation_run", + "task": "skill_writing", + "options": { + "provider": "all", + "query": "repeated workflow", + "evidence_limit": 13, + }, + }) + ); + } + + #[test] + fn automation_rpc_preserves_response_and_has_no_local_database_fallback() { + let payload = serde_json::json!({ "run": { "run_id": "run-5", "status": "ok" } }); + assert_eq!(automation_run_result(&payload).unwrap(), &payload["run"]); + assert!(automation_run_result(&serde_json::json!({})).is_err()); + + let source = [ + include_str!("mod.rs"), + include_str!("config.rs"), + include_str!("facts.rs"), + include_str!("runs.rs"), + include_str!("skills.rs"), + ] + .concat(); + let direct_init = ["serve::ensure_", "initialized"].concat(); + let direct_apply = ["apply_fact_", "proposal("].concat(); + assert!(!source.contains(&direct_init)); + assert!(!source.contains(&direct_apply)); + assert!(source.contains("tracedecay_admin_project")); + } +} diff --git a/src/automation_cli/runs.rs b/src/automation_cli/runs.rs new file mode 100644 index 000000000..109eb8257 --- /dev/null +++ b/src/automation_cli/runs.rs @@ -0,0 +1,269 @@ +use super::{daemon_automation_action, daemon_project_dashboard_root}; +use crate::cli::{AutomationRunAction, AutomationRunsAction}; +use crate::{parse_lcm_scope_arg, resolve_cli_project_root}; + +pub(super) fn automation_run_rpc_request( + action: AutomationRunAction, +) -> tracedecay::errors::Result<(Option, serde_json::Value)> { + let request = match action { + AutomationRunAction::MemoryCuration { + max_clusters, + min_confidence, + path, + } => ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "memory_curation", + "options": { + "max_clusters": max_clusters, + "min_confidence": min_confidence, + }, + }), + ), + AutomationRunAction::SessionReflection { + provider, + query, + evidence_limit, + scope, + session_id, + include_summaries, + sort, + source, + role, + start_time, + end_time, + path, + } => { + parse_lcm_scope_arg(&scope)?; + sort.parse::() + .map_err(|()| tracedecay::errors::TraceDecayError::Config { + message: format!( + "invalid session-reflection --sort '{sort}'; expected recency, relevance, or hybrid" + ), + })?; + ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "session_reflection", + "options": { + "provider": provider, + "query": query, + "evidence_limit": evidence_limit, + "scope": scope, + "session_id": session_id, + "include_summaries": include_summaries, + "sort": sort, + "source": source, + "role": role, + "start_time": start_time, + "end_time": end_time, + }, + }), + ) + } + AutomationRunAction::SkillWriting { + provider, + query, + evidence_limit, + path, + } => ( + path, + serde_json::json!({ + "action": "automation_run", + "task": "skill_writing", + "options": { + "provider": provider, + "query": query, + "evidence_limit": evidence_limit, + }, + }), + ), + }; + Ok(request) +} + +pub(super) fn automation_run_result( + payload: &serde_json::Value, +) -> tracedecay::errors::Result<&serde_json::Value> { + payload + .get("run") + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon automation response omitted run".to_string(), + }) +} + +pub(super) async fn handle_automation_run_command( + action: AutomationRunAction, +) -> tracedecay::errors::Result<()> { + let (path, args) = automation_run_rpc_request(action)?; + let project_path = resolve_cli_project_root(path, None, None).await?; + let payload = daemon_automation_action(&project_path, args).await?; + let run = automation_run_result(&payload)?; + println!("{}", serde_json::to_string_pretty(run)?); + Ok(()) +} + +pub(super) async fn handle_automation_runs_command( + action: AutomationRunsAction, +) -> tracedecay::errors::Result<()> { + use tracedecay::automation::run_ledger::{ + find_run_record, load_run_records, read_run_artifact_payload, + }; + + let path = match &action { + AutomationRunsAction::List { path, .. } + | AutomationRunsAction::View { path, .. } + | AutomationRunsAction::Artifact { path, .. } => path.clone(), + }; + let project_path = resolve_cli_project_root(path, None, None).await?; + let dashboard_root = daemon_project_dashboard_root(&project_path).await?; + + match action { + AutomationRunsAction::List { limit, json, .. } => { + let limit = limit.min(200); + let records = load_run_records(&dashboard_root, limit).await?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "dashboard_root": dashboard_root, + "count": records.len(), + "limit": limit, + "records": records, + }))? + ); + } else { + print_automation_run_list(&records); + } + } + AutomationRunsAction::View { run_id, json, .. } => { + let record = find_run_record(&dashboard_root, &run_id) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("automation run not found: {run_id}"), + })?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "dashboard_root": dashboard_root, + "record": record, + }))? + ); + } else { + print_automation_run_record(&record); + } + } + AutomationRunsAction::Artifact { + run_id, kind, json, .. + } => { + let record = find_run_record(&dashboard_root, &run_id) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("automation run not found: {run_id}"), + })?; + let artifact = record + .artifacts + .iter() + .find(|artifact| artifact.kind == kind) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("automation run artifact not found: {run_id}/{kind}"), + })?; + let payload = + read_run_artifact_payload(&dashboard_root, &record.run_id, artifact).await?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "dashboard_root": dashboard_root, + "run_id": record.run_id, + "artifact": artifact, + "payload": payload, + }))? + ); + } else { + print_automation_run_artifact(&record.run_id, artifact, &payload)?; + } + } + } + Ok(()) +} + +fn print_automation_run_list( + records: &[tracedecay::automation::run_ledger::AutomationRunLedgerRecord], +) { + if records.is_empty() { + println!("No automation runs."); + return; + } + println!("RUN ID\tSTATUS\tTASK\tTRIGGER\tACCEPTED\tREJECTED\tCOMPLETED\tERROR"); + for record in records { + println!( + "{}\t{}\t{}\t{:?}\t{}\t{}\t{}\t{}", + record.run_id, + record.status.as_str(), + record + .task_key + .as_deref() + .unwrap_or_else(|| tracedecay::automation::backend::task_key(record.task)), + record.trigger, + record.accepted_count, + record.rejected_count, + record.completed_at, + record.error.as_deref().unwrap_or("") + ); + } +} + +fn print_automation_run_record( + record: &tracedecay::automation::run_ledger::AutomationRunLedgerRecord, +) { + println!("run_id: {}", record.run_id); + println!("status: {}", record.status.as_str()); + println!( + "task: {}", + record + .task_key + .as_deref() + .unwrap_or_else(|| tracedecay::automation::backend::task_key(record.task)) + ); + println!("trigger: {:?}", record.trigger); + println!("backend: {}", record.backend); + if let Some(model) = record.model.as_deref() { + println!("model: {model}"); + } + println!("accepted_count: {}", record.accepted_count); + println!("rejected_count: {}", record.rejected_count); + println!("reviewed_count: {}", record.reviewed_count); + if let Some(error) = record.error.as_deref() { + println!("error: {error}"); + } + if !record.artifacts.is_empty() { + println!("artifacts:"); + for artifact in &record.artifacts { + println!( + "- {}\t{}\t{}", + artifact.kind, + artifact.path, + artifact.summary.as_deref().unwrap_or("") + ); + } + } +} + +fn print_automation_run_artifact( + run_id: &str, + artifact: &tracedecay::automation::run_ledger::AutomationRunArtifact, + payload: &serde_json::Value, +) -> tracedecay::errors::Result<()> { + println!("run_id: {run_id}"); + println!("artifact: {}", artifact.kind); + println!("path: {}", artifact.path); + if let Some(summary) = artifact.summary.as_deref() { + println!("summary: {summary}"); + } + println!("{}", serde_json::to_string_pretty(payload)?); + Ok(()) +} diff --git a/src/automation_cli/skills.rs b/src/automation_cli/skills.rs new file mode 100644 index 000000000..a4025122f --- /dev/null +++ b/src/automation_cli/skills.rs @@ -0,0 +1,215 @@ +use crate::cli::{AutomationSkillsAction, AutomationSkillsInstallTarget}; +use crate::update_cmd::tracedecay_bin_on_path; + +pub(super) async fn handle_automation_skills_command( + action: AutomationSkillsAction, +) -> tracedecay::errors::Result<()> { + use tracedecay::automation::managed_skills::{ + ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, ManagedSkillUpdate, + approve_managed_skill, archive_managed_skill, create_managed_skill_draft, + disable_managed_skill, list_managed_skills, load_managed_skill, restore_managed_skill, + update_managed_skill, + }; + + let profile_root = tracedecay::storage::default_profile_root()?; + let mut refresh_exports = false; + let skill = match action { + AutomationSkillsAction::List { json } => { + let skills = list_managed_skills(&profile_root).await?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "profile_root": profile_root, + "count": skills.len(), + "skills": skills, + }))? + ); + } else if skills.is_empty() { + println!("No managed skills."); + } else { + for skill in skills { + println!( + "{}\t{:?}\t{}", + skill.metadata.id, skill.metadata.state, skill.metadata.title + ); + } + } + return Ok(()); + } + AutomationSkillsAction::View { id, json } => { + let skill = load_managed_skill(&profile_root, &id).await?; + if json { + println!("{}", serde_json::to_string_pretty(&skill)?); + } else { + print_managed_skill(&skill); + } + return Ok(()); + } + AutomationSkillsAction::Draft { + id, + title, + summary, + category, + body, + pinned, + } => { + let skill = create_managed_skill_draft( + &profile_root, + ManagedSkillDraft { + id, + title, + summary, + category, + targets: tracedecay::automation::managed_skills::default_managed_skill_targets( + ), + body_markdown: body, + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::UserDraft, + actor: "cli".to_string(), + run_id: None, + }, + }, + ) + .await?; + if pinned { + tracedecay::automation::managed_skills::set_managed_skill_pinned( + &profile_root, + &skill.metadata.id, + true, + ) + .await? + } else { + skill + } + } + AutomationSkillsAction::Update { + id, + title, + summary, + category, + body, + pinned, + } => { + update_managed_skill( + &profile_root, + &id, + ManagedSkillUpdate { + title, + summary, + category, + body_markdown: body, + pinned, + ..ManagedSkillUpdate::default() + }, + ) + .await? + } + AutomationSkillsAction::Approve { id } => { + refresh_exports = true; + approve_managed_skill(&profile_root, &id).await? + } + AutomationSkillsAction::Disable { id } => { + refresh_exports = true; + disable_managed_skill(&profile_root, &id).await? + } + AutomationSkillsAction::Archive { id } => { + refresh_exports = true; + archive_managed_skill(&profile_root, &id).await? + } + AutomationSkillsAction::Restore { id } => { + refresh_exports = true; + restore_managed_skill(&profile_root, &id).await? + } + AutomationSkillsAction::Install { + target, + output, + plugin_artifact, + json, + } => { + let output = std::path::Path::new(&output); + let summary = if plugin_artifact { + if target != AutomationSkillsInstallTarget::Codex { + return Err(tracedecay::errors::TraceDecayError::Config { + message: + "--plugin-artifact is currently supported only with --target codex" + .to_string(), + }); + } + let tracedecay_bin = tracedecay_bin_on_path()?; + tracedecay::agents::codex::export_codex_plugin_artifact( + &profile_root, + output, + &tracedecay_bin, + )? + } else { + let summary = tracedecay::automation::skill_targets::install_managed_skills( + &profile_root, + target.into(), + output, + )?; + // The shareable Codex plugin artifact intentionally omits the + // memory digest (personal memory must not ship in a bundle); + // direct host installs export it alongside the skills. + tracedecay::automation::memory_digest::sync_memory_digest_export( + &profile_root, + target.into(), + output, + )?; + summary + }; + if json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!( + "Exported {} managed skill(s) to {}", + summary.exported_count, + summary.output.display() + ); + } + return Ok(()); + } + }; + if refresh_exports { + refresh_managed_skill_exports_for_cli(&profile_root); + } + println!("{}", serde_json::to_string_pretty(&skill)?); + Ok(()) +} + +fn refresh_managed_skill_exports_for_cli(profile_root: &std::path::Path) { + let Some(home) = tracedecay::agents::home_dir() else { + return; + }; + let start = std::env::current_dir().unwrap_or_else(|_| home.clone()); + let project_root = tracedecay::automation::skill_materialization::resolve_project_root(&start); + for report in + tracedecay::agents::export_managed_skills_to_agent_hosts(&home, &project_root, profile_root) + { + if let Some(error) = report.error { + eprintln!( + "warning: failed to refresh managed skill exports for {}: {}", + report.agent, error + ); + } + } + // Materialize active managed skills as real, host-loadable SKILL.md files + // into every detected `.claude`/`.codex` skills directory (project + global). + tracedecay::automation::skill_materialization::reconcile_after_activation( + profile_root, + &project_root, + ); +} + +fn print_managed_skill(skill: &tracedecay::automation::managed_skills::ManagedSkill) { + println!("id: {}", skill.metadata.id); + println!("title: {}", skill.metadata.title); + println!("summary: {}", skill.metadata.summary); + println!("category: {}", skill.metadata.category); + println!("state: {:?}", skill.metadata.state); + println!("pinned: {}", skill.metadata.pinned); + println!("checksum: {}", skill.metadata.checksum); + println!(); + println!("{}", skill.body_markdown); +} diff --git a/src/branch.rs b/src/branch.rs index d95ee492e..cbd479782 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -4,11 +4,19 @@ use std::path::{Path, PathBuf}; use crate::branch_meta::BranchMeta; +mod admin; + +pub use admin::{ + BranchAdminAction, BranchAdminOutcome, BranchAdminReport, PreparedBranchAdminMutation, + prepare_branch_admin_mutation, remove_tracked_branch_store_checked, +}; +pub(crate) use admin::{BranchAdminRecoveryDisposition, prepare_pending_branch_admin_recovery}; + /// Bounded-retry policy for a briefly-contended branch-add lock: a concurrent /// branch add only holds the lock for the duration of a DB clone, so a short /// spin lets a contender through instead of failing immediately. Shared by the /// async [`prepare_branch_tracking_in_layout`] and the synchronous -/// [`acquire_branch_add_lock_blocking`]; only the sleep primitive differs. +/// administrative path; only the sleep primitive differs. const BRANCH_LOCK_RETRY_ATTEMPTS: usize = 20; const BRANCH_LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); @@ -281,30 +289,36 @@ fn unique_branch_db_stem( meta: &BranchMeta, branches_dir: &Path, branch_name: &str, -) -> Option { +) -> crate::errors::Result> { let base = sanitize_branch_name(branch_name); if base.is_empty() { - return None; + return Ok(None); } - let conflicts = |stem: &str| -> bool { + let conflicts = |stem: &str| -> crate::errors::Result { let db_file = format!("branches/{stem}.db"); let meta_conflict = meta .branches .iter() .any(|(name, entry)| name != branch_name && entry.db_file == db_file); - let file_conflict = branches_dir.join(format!("{stem}.db")).exists(); - meta_conflict || file_conflict + let database_path = branches_dir.join(format!("{stem}.db")); + let file_conflict = database_path.exists(); + let retired_path = crate::db::database_path_is_tombstoned(&database_path)?; + Ok(meta_conflict || file_conflict || retired_path) }; - if !conflicts(&base) { - return Some(base); + if !conflicts(&base)? { + return Ok(Some(base)); } let hashed = format!("{base}-{}", short_branch_hash(branch_name)); - if !conflicts(&hashed) { - return Some(hashed); + if !conflicts(&hashed)? { + return Ok(Some(hashed)); } - (1..10_000) - .map(|suffix| format!("{hashed}-{suffix}")) - .find(|candidate| !conflicts(candidate)) + for suffix in 1..10_000 { + let candidate = format!("{hashed}-{suffix}"); + if !conflicts(&candidate)? { + return Ok(Some(candidate)); + } + } + Ok(None) } /// Short, stable hex digest of a branch name for DB-stem disambiguation. @@ -538,10 +552,10 @@ pub async fn prepare_branch_tracking_in_layout( let branches_dir = branch_meta::ensure_branches_dir(tracedecay_dir)?; // Pick a collision-free stem so a branch whose sanitized name matches an // already-tracked branch gets its own DB instead of overwriting it (#3). - let stem = unique_branch_db_stem(&meta, &branches_dir, branch_name).ok_or_else(|| { + let stem = unique_branch_db_stem(&meta, &branches_dir, branch_name)?.ok_or_else(|| { crate::errors::TraceDecayError::Config { message: format!( - "cannot track branch '{branch_name}': its name sanitizes to an empty filename" + "cannot track branch '{branch_name}': no unretired collision-free database filename is available" ), } })?; @@ -679,7 +693,7 @@ fn prune_missing_branch_dbs(tracedecay_dir: &Path, meta: &mut crate::branch_meta } } -fn try_acquire_branch_add_lock(tracedecay_dir: &Path) -> crate::errors::Result { +fn try_acquire_branch_add_lock_raw(tracedecay_dir: &Path) -> crate::errors::Result { use fs2::FileExt; std::fs::create_dir_all(tracedecay_dir)?; @@ -696,31 +710,22 @@ fn try_acquire_branch_add_lock(tracedecay_dir: &Path) -> crate::errors::Result Option { - for _ in 0..BRANCH_LOCK_RETRY_ATTEMPTS { - match try_acquire_branch_add_lock(tracedecay_dir) { - Ok(lock) => return Some(lock), - Err(crate::errors::TraceDecayError::SyncLock { .. }) => { - std::thread::sleep(BRANCH_LOCK_RETRY_INTERVAL); - } - Err(_) => return None, - } - } - None +pub(crate) fn try_acquire_branch_add_lock( + tracedecay_dir: &Path, +) -> crate::errors::Result { + let file = try_acquire_branch_add_lock_raw(tracedecay_dir)?; + admin::ensure_no_pending_branch_admin_recovery(tracedecay_dir)?; + Ok(file) +} + +pub(crate) fn acquire_branch_lock_blocking( + tracedecay_dir: &Path, +) -> crate::errors::Result { + admin::acquire_branch_add_lock_blocking(tracedecay_dir) } fn remove_branch_db_files(db_path: &Path) { - let _ = std::fs::remove_file(db_path); - let mut sidecar = db_path.to_path_buf(); - sidecar.set_extension("db-wal"); - let _ = std::fs::remove_file(&sidecar); - sidecar.set_extension("db-shm"); - let _ = std::fs::remove_file(&sidecar); + let _ = admin::remove_branch_db_files_checked(db_path); } async fn create_consistent_branch_snapshot(src: &Path, dst: &Path) -> crate::errors::Result<()> { @@ -761,32 +766,12 @@ async fn create_consistent_branch_snapshot(src: &Path, dst: &Path) -> crate::err result } -/// Untracks a single non-default branch: removes its metadata entry and deletes -/// its DB file (plus `-wal`/`-shm` sidecars). Returns `true` when an entry was -/// removed. The default branch is never removed. This is the shared removal path -/// used by `tracedecay branch remove` and the PR-autotrack lifecycle so both -/// clean up identically. +/// Compatibility wrapper for the PR-autotrack lifecycle. Administrative CLI +/// removal uses [`prepare_branch_admin_mutation`] through the daemon so failures +/// are surfaced instead of collapsed to `false`. pub fn remove_tracked_branch_store(tracedecay_dir: &Path, branch: &str) -> bool { - // Serialize on the same branch-add lock every other `branch-meta.json` - // mutator holds (prepare_branch_tracking_in_layout, gc_dead_branch_stores). - // Without it, this unlocked load→remove→save races a concurrent - // `branch add`: depending on write order it either silently drops the - // user's just-added branch or resurrects a removed entry pointing at a - // deleted DB. On sustained contention, skip removal (returning false); the - // caller retries next cycle — nothing is lost. Callers never hold this lock - // when calling in (branch add / GC acquire-then-release), so no deadlock. - let Some(_lock) = acquire_branch_add_lock_blocking(tracedecay_dir) else { - return false; - }; - let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) else { - return false; - }; - let Some(entry) = meta.remove_branch(branch) else { - return false; - }; - remove_branch_db_files(&tracedecay_dir.join(&entry.db_file)); - let _ = crate::branch_meta::save_branch_meta(tracedecay_dir, &meta); - true + remove_tracked_branch_store_checked(tracedecay_dir, branch) + .is_ok_and(|report| report.outcome == BranchAdminOutcome::Removed) } /// Returns true if `branch` currently exists as a local `refs/heads/*` ref. @@ -842,119 +827,15 @@ fn now_unix_secs() -> u64 { /// empty report is returned — the daemon retries on its next tick. Logging is /// the caller's responsibility; this function is silent. pub fn gc_dead_branch_stores( - project_root: &Path, - tracedecay_dir: &Path, - branch_gc_days: u64, - orphan_db_gc_days: u64, + _project_root: &Path, + _tracedecay_dir: &Path, + _branch_gc_days: u64, + _orphan_db_gc_days: u64, ) -> GcReport { - let mut report = GcReport::default(); - - // Serialize against branch-add so we don't delete a DB it is mid-creation. - let Ok(_lock) = try_acquire_branch_add_lock(tracedecay_dir) else { - return report; - }; - - let now = now_unix_secs(); - - // (a) Tracked branches whose ref is gone and whose last sync is stale. - if let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) { - let branch_grace = branch_gc_days.saturating_mul(86_400); - let default_branch = meta.default_branch.clone(); - let candidates: Vec<(String, PathBuf, u64)> = meta - .branches - .iter() - .filter(|(name, entry)| **name != default_branch && !entry.gc_protected) - .map(|(name, entry)| { - ( - name.clone(), - tracedecay_dir.join(&entry.db_file), - parse_unix_secs(&entry.last_synced_at), - ) - }) - .collect(); - - let mut removed_any = false; - for (name, db_path, last_synced) in candidates { - // Never collect a branch whose ref still resolves, and never one - // synced within the grace window (`<= now` age guards a clock skew - // where last_synced is in the future). - if is_branch_ref_present(project_root, &name) { - continue; - } - let age = now.saturating_sub(last_synced); - if age < branch_grace { - continue; - } - remove_branch_db_files(&db_path); - meta.remove_branch(&name); - report.removed_tracked.push(name); - removed_any = true; - } - if removed_any { - let _ = crate::branch_meta::save_branch_meta(tracedecay_dir, &meta); - } - - // (b) Orphan DBs: files under branches/ not referenced by any surviving - // meta entry. Recompute the referenced set AFTER the removals above so - // a just-removed branch's DB (already deleted) is not double-counted. - let referenced: std::collections::HashSet = meta - .branches - .values() - .map(|entry| tracedecay_dir.join(&entry.db_file)) - .collect(); - report.removed_orphan_dbs = - sweep_orphan_dbs(tracedecay_dir, &referenced, orphan_db_gc_days, now); - } else { - // No branch metadata: every branches/*.db is an orphan candidate. - report.removed_orphan_dbs = sweep_orphan_dbs( - tracedecay_dir, - &std::collections::HashSet::new(), - orphan_db_gc_days, - now, - ); - } - - report -} - -/// Deletes stale `branches/*.db` files (+ sidecars) not in `referenced`. -fn sweep_orphan_dbs( - tracedecay_dir: &Path, - referenced: &std::collections::HashSet, - orphan_db_gc_days: u64, - now: u64, -) -> Vec { - let mut removed = Vec::new(); - let branches_dir = tracedecay_dir.join("branches"); - let Ok(entries) = std::fs::read_dir(&branches_dir) else { - return removed; - }; - let orphan_grace = orphan_db_gc_days.saturating_mul(86_400); - for entry in entries.flatten() { - let path = entry.path(); - // Only main `.db` files are stores; sidecars are removed alongside. - if path.extension().and_then(|e| e.to_str()) != Some("db") { - continue; - } - if referenced.contains(&path) { - continue; - } - // Age-gate on mtime; a freshly-created orphan (e.g. a branch-add whose - // meta save is momentarily lagging) is kept until it ages out. - let mtime_secs = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map_or(0, |d| d.as_secs()); - let age = now.saturating_sub(mtime_secs); - if age < orphan_grace { - continue; - } - remove_branch_db_files(&path); - removed.push(path); - } - removed + // Physical branch-store GC requires daemon-owned writer exclusion, cached + // owner checks, a deletion fence, and holder proof. This compatibility API + // cannot establish those invariants, so it deliberately fails closed. + GcReport::default() } #[cfg(test)] diff --git a/src/branch/admin.rs b/src/branch/admin.rs new file mode 100644 index 000000000..cdb5eb206 --- /dev/null +++ b/src/branch/admin.rs @@ -0,0 +1,558 @@ +//! Destructive branch-store administration. + +use std::path::{Path, PathBuf}; + +use crate::branch_meta::BranchMeta; + +mod transaction; + +/// Destructive branch-store operation accepted by the daemon-owned +/// administrative path. The tagged representation is also the wire contract +/// used by the CLI. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum BranchAdminAction { + Remove { branch: String }, + RemoveAll, + Gc, +} + +/// Typed outcome returned to the CLI after a destructive branch operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BranchAdminOutcome { + NoTracking, + NotTracked, + NoChanges, + Removed, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BranchAdminReport { + pub outcome: BranchAdminOutcome, + #[serde(default)] + pub removed_branches: Vec, + #[serde(default)] + pub removed_orphan_dbs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, +} + +/// A branch metadata mutation selected while holding the shared branch lock. +/// The daemon inspects [`Self::database_paths`] and proves every matching store +/// owner is gone before calling the daemon-only transaction method. +pub struct PreparedBranchAdminMutation { + project_root: PathBuf, + tracedecay_dir: PathBuf, + metadata_before: Option, + metadata_after: Option, + database_paths: Vec, + gc_branches: Vec, + report: BranchAdminReport, + _branch_lock: std::fs::File, +} + +impl PreparedBranchAdminMutation { + pub fn database_paths(&self) -> &[PathBuf] { + &self.database_paths + } + + pub fn report(&self) -> &BranchAdminReport { + &self.report + } + + /// Quarantines the selected SQLite families under a durable journal and + /// publishes branch metadata as the tracked-branch commit point. Any failure + /// before that point rolls every move back; failures after it retain recovery + /// evidence for cleanup on the next branch-lock acquisition. + #[cfg(test)] + fn commit(self) -> crate::errors::Result { + self.commit_with_precommit_hook(None, || Ok(()), |_| Ok(()), || Ok(()), |_| Ok(())) + } + + pub(crate) fn finish_without_database_deletion( + self, + ) -> crate::errors::Result { + if !self.database_paths.is_empty() { + return Err(crate::errors::TraceDecayError::Config { + message: "branch database deletion requires daemon store administration" + .to_string(), + }); + } + self.commit_with_precommit_hook(None, || Ok(()), |_| Ok(()), || Ok(()), |_| Ok(())) + } + + pub(crate) fn commit_with_transaction( + self, + transaction_id: &str, + publish_deleting: P, + validate_quarantined_stores: V, + rollback_deleting: R, + on_commit: C, + ) -> crate::errors::Result + where + P: FnOnce() -> crate::errors::Result<()>, + V: FnOnce(&[PathBuf]) -> crate::errors::Result<()>, + R: FnOnce() -> crate::errors::Result<()>, + C: FnOnce() -> crate::errors::Result<()>, + { + let mut on_commit = Some(on_commit); + self.commit_with_precommit_hook( + Some(transaction_id), + publish_deleting, + validate_quarantined_stores, + rollback_deleting, + move |phase| { + if phase == transaction::TransactionPhase::AfterCommitBeforeCleanup { + if let Some(on_commit) = on_commit.take() { + on_commit()?; + } + } + Ok(()) + }, + ) + } + + #[cfg(test)] + fn commit_with_hook( + self, + transaction_id: Option<&str>, + hook: H, + ) -> crate::errors::Result + where + H: FnMut(transaction::TransactionPhase) -> crate::errors::Result<()>, + { + self.commit_with_precommit_hook(transaction_id, || Ok(()), |_| Ok(()), || Ok(()), hook) + } + + fn commit_with_precommit_hook( + self, + transaction_id: Option<&str>, + publish_deleting: P, + validate_quarantined_stores: V, + rollback_deleting: R, + hook: H, + ) -> crate::errors::Result + where + P: FnOnce() -> crate::errors::Result<()>, + V: FnOnce(&[PathBuf]) -> crate::errors::Result<()>, + R: FnOnce() -> crate::errors::Result<()>, + H: FnMut(transaction::TransactionPhase) -> crate::errors::Result<()>, + { + if self.report.outcome != BranchAdminOutcome::Removed { + return Ok(self.report); + } + let project_root = self.project_root.clone(); + let gc_branches = self.gc_branches.clone(); + transaction::commit_with_hook( + &self.tracedecay_dir, + transaction_id, + &self.database_paths, + self.metadata_before, + self.metadata_after, + publish_deleting, + move |quarantine_paths| { + validate_quarantined_stores(quarantine_paths)?; + for branch in &gc_branches { + if super::is_branch_ref_present(&project_root, branch) { + return Err(crate::errors::TraceDecayError::Config { + message: format!( + "branch ref '{branch}' reappeared before GC metadata publication; deletion rolled back" + ), + }); + } + } + Ok(()) + }, + rollback_deleting, + hook, + )?; + Ok(self.report) + } +} + +/// Selects a destructive branch mutation while holding the same lock used by +/// branch add. This function does not mutate metadata or unlink any file. +pub fn prepare_branch_admin_mutation( + project_root: &Path, + tracedecay_dir: &Path, + action: BranchAdminAction, + branch_gc_days: u64, + orphan_db_gc_days: u64, +) -> crate::errors::Result { + let branch_lock = acquire_branch_add_lock_blocking(tracedecay_dir)?; + let (mut meta, metadata_before) = load_branch_meta_exact(tracedecay_dir)?; + let default_branch = meta.as_ref().map(|meta| meta.default_branch.clone()); + let mut database_paths = Vec::new(); + let mut removed_branches = Vec::new(); + let mut removed_orphan_dbs = Vec::new(); + let mut gc_branches = Vec::new(); + let mut outcome = BranchAdminOutcome::NoChanges; + + match action { + BranchAdminAction::Remove { branch } => { + let Some(branch_meta) = meta.as_mut() else { + outcome = BranchAdminOutcome::NoTracking; + return Ok(PreparedBranchAdminMutation { + project_root: project_root.to_path_buf(), + tracedecay_dir: tracedecay_dir.to_path_buf(), + metadata_before: metadata_before.clone(), + metadata_after: metadata_before.clone(), + database_paths, + gc_branches, + report: BranchAdminReport { + outcome, + removed_branches, + removed_orphan_dbs, + default_branch, + }, + _branch_lock: branch_lock, + }); + }; + if branch == branch_meta.default_branch { + return Err(crate::errors::TraceDecayError::Config { + message: format!("cannot remove default branch '{branch}'"), + }); + } + if let Some(entry) = branch_meta.remove_branch(&branch) { + database_paths.push(tracedecay_dir.join(entry.db_file)); + removed_branches.push(branch); + outcome = BranchAdminOutcome::Removed; + } else { + outcome = BranchAdminOutcome::NotTracked; + } + } + BranchAdminAction::RemoveAll => { + let Some(branch_meta) = meta.as_mut() else { + outcome = BranchAdminOutcome::NoTracking; + return Ok(PreparedBranchAdminMutation { + project_root: project_root.to_path_buf(), + tracedecay_dir: tracedecay_dir.to_path_buf(), + metadata_before: metadata_before.clone(), + metadata_after: metadata_before.clone(), + database_paths, + gc_branches, + report: BranchAdminReport { + outcome, + removed_branches, + removed_orphan_dbs, + default_branch, + }, + _branch_lock: branch_lock, + }); + }; + let mut removed = branch_meta.remove_all_branches(); + removed.sort_by(|left, right| left.0.cmp(&right.0)); + for (branch, entry) in removed { + removed_branches.push(branch); + database_paths.push(tracedecay_dir.join(entry.db_file)); + } + if !removed_branches.is_empty() { + outcome = BranchAdminOutcome::Removed; + } + } + BranchAdminAction::Gc => { + let now = super::now_unix_secs(); + if let Some(branch_meta) = meta.as_mut() { + let branch_grace = branch_gc_days.saturating_mul(86_400); + let default = branch_meta.default_branch.clone(); + let mut candidates = branch_meta + .branches + .iter() + .filter(|(name, entry)| **name != default && !entry.gc_protected) + .filter(|(name, entry)| { + !super::is_branch_ref_present(project_root, name) + && now.saturating_sub(super::parse_unix_secs(&entry.last_synced_at)) + >= branch_grace + }) + .map(|(name, entry)| (name.clone(), entry.db_file.clone())) + .collect::>(); + candidates.sort_by(|left, right| left.0.cmp(&right.0)); + for (name, db_file) in candidates { + branch_meta.remove_branch(&name); + gc_branches.push(name.clone()); + removed_branches.push(name); + database_paths.push(tracedecay_dir.join(db_file)); + } + } + let referenced = meta + .as_ref() + .map(|meta| { + meta.branches + .values() + .map(|entry| tracedecay_dir.join(&entry.db_file)) + .collect::>() + }) + .unwrap_or_default(); + removed_orphan_dbs = + select_orphan_dbs(tracedecay_dir, &referenced, orphan_db_gc_days, now); + database_paths.extend(removed_orphan_dbs.iter().cloned()); + if !database_paths.is_empty() { + outcome = BranchAdminOutcome::Removed; + } else if meta.is_none() { + outcome = BranchAdminOutcome::NoTracking; + } + } + } + + database_paths.sort(); + database_paths.dedup(); + let metadata_after = if removed_branches.is_empty() { + metadata_before.clone() + } else { + Some(crate::branch_meta::serialize_branch_meta( + meta.as_ref() + .ok_or_else(|| crate::errors::TraceDecayError::Config { + message: "tracked branch deletion lost branch metadata before commit" + .to_string(), + })?, + )?) + }; + Ok(PreparedBranchAdminMutation { + project_root: project_root.to_path_buf(), + tracedecay_dir: tracedecay_dir.to_path_buf(), + metadata_before, + metadata_after, + database_paths, + gc_branches, + report: BranchAdminReport { + outcome, + removed_branches, + removed_orphan_dbs, + default_branch, + }, + _branch_lock: branch_lock, + }) +} + +/// Strict removal entry point used by daemon-owned administrative operations. +pub fn remove_tracked_branch_store_checked( + _tracedecay_dir: &Path, + _branch: &str, +) -> crate::errors::Result { + Err(crate::errors::TraceDecayError::Config { + message: "branch database deletion requires daemon store administration; use tracedecay_admin_branch through the managed daemon" + .to_string(), + }) +} + +fn load_branch_meta_exact( + tracedecay_dir: &Path, +) -> crate::errors::Result<(Option, Option)> { + let path = tracedecay_dir.join(crate::storage::BRANCH_META_FILENAME); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((None, None)), + Err(error) => { + return Err(crate::errors::TraceDecayError::Config { + message: format!( + "cannot inspect branch metadata at '{}': {error}", + path.display() + ), + }); + } + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(crate::errors::TraceDecayError::Config { + message: format!( + "cannot administer branch stores with ambiguous metadata path '{}'", + path.display() + ), + }); + } + let serialized = + std::fs::read_to_string(&path).map_err(|error| crate::errors::TraceDecayError::Config { + message: format!( + "cannot read branch metadata at '{}': {error}", + path.display() + ), + })?; + let meta = crate::branch_meta::parse(&serialized).map_err(|error| { + crate::errors::TraceDecayError::Config { + message: format!( + "cannot administer branch stores with corrupt or unreadable metadata at '{}': {error}", + path.display() + ), + } + })?; + Ok((Some(meta), Some(serialized))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BranchAdminRecoveryDisposition { + PreCommitRollback, + CommittedCleanup, +} + +pub(crate) struct PreparedBranchAdminRecovery { + tracedecay_dir: PathBuf, + pending: transaction::PendingRecovery, + database_paths: Vec, + _branch_lock: std::fs::File, +} + +impl PreparedBranchAdminRecovery { + pub(crate) fn transaction_id(&self) -> &str { + self.pending.transaction_id() + } + + pub(crate) fn disposition(&self) -> BranchAdminRecoveryDisposition { + match self.pending.disposition() { + transaction::RecoveryDisposition::PreCommitRollback => { + BranchAdminRecoveryDisposition::PreCommitRollback + } + transaction::RecoveryDisposition::CommittedCleanup => { + BranchAdminRecoveryDisposition::CommittedCleanup + } + } + } + + pub(crate) fn database_paths(&self) -> &[PathBuf] { + &self.database_paths + } + + pub(crate) fn recover( + self, + validate_stores: V, + transition_tombstones: T, + ) -> crate::errors::Result<()> + where + V: FnOnce(&[PathBuf]) -> crate::errors::Result<()>, + T: FnOnce(BranchAdminRecoveryDisposition) -> crate::errors::Result<()>, + { + self.pending + .recover(&self.tracedecay_dir, validate_stores, |disposition| { + transition_tombstones(match disposition { + transaction::RecoveryDisposition::PreCommitRollback => { + BranchAdminRecoveryDisposition::PreCommitRollback + } + transaction::RecoveryDisposition::CommittedCleanup => { + BranchAdminRecoveryDisposition::CommittedCleanup + } + }) + }) + } +} + +pub(crate) fn prepare_pending_branch_admin_recovery( + tracedecay_dir: &Path, +) -> crate::errors::Result> { + let branch_lock = acquire_branch_add_lock_blocking_raw(tracedecay_dir)?; + let Some(pending) = transaction::prepare_pending_recovery(tracedecay_dir)? else { + return Ok(None); + }; + let database_paths = pending.database_paths(tracedecay_dir); + Ok(Some(PreparedBranchAdminRecovery { + tracedecay_dir: tracedecay_dir.to_path_buf(), + pending, + database_paths, + _branch_lock: branch_lock, + })) +} + +pub(super) fn ensure_no_pending_branch_admin_recovery( + tracedecay_dir: &Path, +) -> crate::errors::Result<()> { + transaction::ensure_no_pending_recovery(tracedecay_dir) +} + +/// Blocking-with-timeout variant of [`super::try_acquire_branch_add_lock`] for +/// synchronous callers. Retries a briefly-contended lock (a concurrent branch +/// add is only holding it for the duration of a DB clone) before giving up. +pub(super) fn acquire_branch_add_lock_blocking( + tracedecay_dir: &Path, +) -> crate::errors::Result { + acquire_branch_add_lock_blocking_with(tracedecay_dir, super::try_acquire_branch_add_lock) +} + +fn acquire_branch_add_lock_blocking_raw( + tracedecay_dir: &Path, +) -> crate::errors::Result { + acquire_branch_add_lock_blocking_with(tracedecay_dir, super::try_acquire_branch_add_lock_raw) +} + +fn acquire_branch_add_lock_blocking_with( + tracedecay_dir: &Path, + acquire: fn(&Path) -> crate::errors::Result, +) -> crate::errors::Result { + let mut last_contention = None; + for _ in 0..super::BRANCH_LOCK_RETRY_ATTEMPTS { + match acquire(tracedecay_dir) { + Ok(lock) => return Ok(lock), + Err(error @ crate::errors::TraceDecayError::SyncLock { .. }) => { + last_contention = Some(error); + std::thread::sleep(super::BRANCH_LOCK_RETRY_INTERVAL); + } + Err(error) => return Err(error), + } + } + Err( + last_contention.unwrap_or_else(|| crate::errors::TraceDecayError::SyncLock { + message: format!( + "timed out waiting for branch metadata lock at {}", + tracedecay_dir.join(".branch-add.lock").display() + ), + }), + ) +} + +fn branch_db_family_paths(db_path: &Path) -> [PathBuf; 3] { + let mut wal = db_path.to_path_buf(); + wal.set_extension("db-wal"); + let mut shm = db_path.to_path_buf(); + shm.set_extension("db-shm"); + [db_path.to_path_buf(), wal, shm] +} + +pub(super) fn remove_branch_db_files_checked(db_path: &Path) -> crate::errors::Result<()> { + for path in branch_db_family_paths(db_path) { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(crate::errors::TraceDecayError::Config { + message: format!( + "failed to delete branch store file '{}': {error}", + path.display() + ), + }); + } + } + } + Ok(()) +} + +pub(super) fn select_orphan_dbs( + tracedecay_dir: &Path, + referenced: &std::collections::HashSet, + orphan_db_gc_days: u64, + now: u64, +) -> Vec { + let mut selected = Vec::new(); + let branches_dir = tracedecay_dir.join("branches"); + let Ok(entries) = std::fs::read_dir(&branches_dir) else { + return selected; + }; + let orphan_grace = orphan_db_gc_days.saturating_mul(86_400); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("db") || referenced.contains(&path) { + continue; + } + let mtime_secs = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs()); + if now.saturating_sub(mtime_secs) >= orphan_grace { + selected.push(path); + } + } + selected.sort(); + selected +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/src/branch/admin/tests.rs b/src/branch/admin/tests.rs new file mode 100644 index 000000000..177014e06 --- /dev/null +++ b/src/branch/admin/tests.rs @@ -0,0 +1,850 @@ +use super::*; + +fn run_git(project_root: &Path, args: &[&str]) { + let output = std::process::Command::new(crate::git::git_program()) + .args(args) + .current_dir(project_root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("repo"); + let tracedecay_dir = temp.path().join("store"); + std::fs::create_dir_all(&project_root).unwrap(); + run_git(&project_root, &["init", "-b", "main"]); + run_git(&project_root, &["config", "user.email", "test@example.com"]); + run_git(&project_root, &["config", "user.name", "TraceDecay Test"]); + std::fs::write(project_root.join("fixture"), b"fixture").unwrap(); + run_git(&project_root, &["add", "fixture"]); + run_git(&project_root, &["commit", "-m", "fixture"]); + std::fs::create_dir_all(tracedecay_dir.join("branches")).unwrap(); + std::fs::write(tracedecay_dir.join(crate::config::DB_FILENAME), b"main").unwrap(); + let mut meta = crate::branch_meta::BranchMeta::new("main"); + meta.add_branch("feature", "branches/feature.db", "main"); + crate::branch_meta::save_branch_meta(&tracedecay_dir, &meta).unwrap(); + std::fs::write(tracedecay_dir.join("branches/feature.db"), b"feature").unwrap(); + (temp, project_root, tracedecay_dir) +} + +#[test] +fn branch_admin_selection_does_not_mutate_before_commit() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 14, + 7, + ) + .unwrap(); + assert_eq!( + prepared.database_paths(), + &[tracedecay_dir.join("branches/feature.db")] + ); + assert!(tracedecay_dir.join("branches/feature.db").exists()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); + + let report = prepared.commit().unwrap(); + assert_eq!(report.outcome, BranchAdminOutcome::Removed); + assert!(!tracedecay_dir.join("branches/feature.db").exists()); + assert!( + !crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} + +#[test] +fn nonempty_metadata_only_finish_fails_closed_without_deleting() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared.finish_without_database_deletion().unwrap_err(); + + assert!( + error + .to_string() + .contains("requires daemon store administration") + ); + assert!(db.exists()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} + +#[test] +fn compatibility_remove_fails_closed_without_deleting() { + let (_temp, _project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + + let error = remove_tracked_branch_store_checked(&tracedecay_dir, "feature").unwrap_err(); + + assert!( + error + .to_string() + .contains("requires daemon store administration") + ); + assert!(db.exists()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} + +#[test] +fn branch_admin_never_selects_default_branch_for_removal() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let error = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "main".to_string(), + }, + 14, + 7, + ) + .err() + .expect("default branch removal must fail closed"); + assert!(error.to_string().contains("cannot remove default branch")); + assert!(tracedecay_dir.join(crate::config::DB_FILENAME).exists()); +} + +#[test] +fn branch_admin_refuses_corrupt_metadata_without_selecting_stores() { + let (_temp, project_root, tracedecay_dir) = fixture(); + std::fs::write( + tracedecay_dir.join(crate::storage::BRANCH_META_FILENAME), + b"{not-json", + ) + .unwrap(); + + let error = + prepare_branch_admin_mutation(&project_root, &tracedecay_dir, BranchAdminAction::Gc, 0, 0) + .err() + .expect("corrupt branch metadata must fail closed"); + + assert!(error.to_string().contains("corrupt or unreadable metadata")); + assert!(tracedecay_dir.join("branches/feature.db").exists()); +} + +fn failpoint(message: &str) -> crate::errors::Result<()> { + Err(crate::errors::TraceDecayError::Config { + message: message.to_string(), + }) +} + +fn quarantine_files(tracedecay_dir: &Path) -> Vec { + std::fs::read_dir(tracedecay_dir.join("branches")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(".branch-delete-")) + }) + .collect() +} + +fn recover_without_fence(tracedecay_dir: &Path) { + let recovery = prepare_pending_branch_admin_recovery(tracedecay_dir) + .unwrap() + .expect("pending branch deletion recovery"); + recovery.recover(|_| Ok(()), |_| Ok(())).unwrap(); +} + +fn recover_precommit_with_fence( + tracedecay_dir: &Path, + transaction_id: &str, +) -> crate::db::DatabaseDeletionStates { + let recovery = prepare_pending_branch_admin_recovery(tracedecay_dir) + .unwrap() + .expect("pending branch deletion recovery"); + assert_eq!( + recovery.disposition(), + BranchAdminRecoveryDisposition::PreCommitRollback + ); + let (fence, states) = crate::db::DatabaseDeletionFence::reacquire( + recovery.database_paths(), + transaction_id, + "recover branch deletion test", + ) + .unwrap(); + recovery + .recover( + |_| Ok(()), + |disposition| { + assert_eq!( + disposition, + BranchAdminRecoveryDisposition::PreCommitRollback + ); + fence.rollback_deleting() + }, + ) + .unwrap(); + states +} + +fn recover_committed_with_fence( + tracedecay_dir: &Path, + transaction_id: &str, +) -> crate::db::DatabaseDeletionStates { + let recovery = prepare_pending_branch_admin_recovery(tracedecay_dir) + .unwrap() + .expect("pending branch deletion recovery"); + assert_eq!( + recovery.disposition(), + BranchAdminRecoveryDisposition::CommittedCleanup + ); + let (fence, states) = crate::db::DatabaseDeletionFence::reacquire( + recovery.database_paths(), + transaction_id, + "complete branch deletion test", + ) + .unwrap(); + recovery + .recover( + |_| Ok(()), + |disposition| { + assert_eq!( + disposition, + BranchAdminRecoveryDisposition::CommittedCleanup + ); + fence.promote_deleted() + }, + ) + .unwrap(); + states +} + +#[test] +fn crash_after_journal_before_deleting_publication_recovers_missing_tombstone() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let mut primary_failed = false; + + let error = prepared + .commit_with_precommit_hook( + Some(&transaction_id), + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + |phase| { + if phase + == transaction::TransactionPhase::AfterJournalBeforeDeletingPublication + { + primary_failed = true; + return failpoint("crash before deleting publication"); + } + if primary_failed + && phase + == transaction::TransactionPhase::AfterPhysicalRollbackBeforeDeletingRollback + { + return failpoint("crash before tombstone rollback"); + } + Ok(()) + }, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("crash before deleting publication") + ); + drop(fence); + + assert!(db.exists()); + assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); + assert!( + tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); + let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.missing(), 1); + assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[test] +fn crash_after_physical_rollback_recovers_same_id_deleting_tombstone() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + + let error = prepared + .commit_with_precommit_hook( + Some(&transaction_id), + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + |phase| match phase { + transaction::TransactionPhase::BeforeMetadataPublication => { + failpoint("force precommit rollback") + } + transaction::TransactionPhase::AfterPhysicalRollbackBeforeDeletingRollback => { + failpoint("crash after physical rollback") + } + _ => Ok(()), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("crash after physical rollback")); + drop(fence); + + assert!(db.exists()); + assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); + let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); + assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); +} + +#[test] +fn crash_after_deleting_rollback_recovers_missing_tombstone_and_clears_journal() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + + let error = prepared + .commit_with_precommit_hook( + Some(&transaction_id), + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + |phase| match phase { + transaction::TransactionPhase::BeforeMetadataPublication => { + failpoint("force precommit rollback") + } + transaction::TransactionPhase::AfterDeletingRollbackBeforeJournalClear => { + failpoint("crash after deleting rollback") + } + _ => Ok(()), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("crash after deleting rollback")); + drop(fence); + + assert!(db.exists()); + assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); + assert!( + tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); + let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.missing(), 1); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[test] +fn metadata_commit_before_deleted_promotion_recovers_as_committed() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + + let error = prepared + .commit_with_transaction( + &transaction_id, + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + || failpoint("crash before deleted promotion"), + ) + .unwrap_err(); + assert!(error.to_string().contains("crash before deleted promotion")); + drop(fence); + + assert!(!db.exists()); + assert!( + !crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); + assert!(!quarantine_files(&tracedecay_dir).is_empty()); + let states = recover_committed_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); +} + +#[test] +fn orphan_commit_before_deleted_promotion_recovers_as_committed() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let orphan = tracedecay_dir.join("branches/orphan.db"); + std::fs::write(&orphan, b"orphan").unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Gc, + u64::MAX, + 0, + ) + .unwrap(); + let fence = crate::db::DatabaseDeletionFence::acquire( + std::slice::from_ref(&orphan), + "delete orphan branch test", + ) + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + + let error = prepared + .commit_with_transaction( + &transaction_id, + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + || failpoint("crash after orphan commit"), + ) + .unwrap_err(); + assert!(error.to_string().contains("crash after orphan commit")); + drop(fence); + + let journal = + std::fs::read_to_string(tracedecay_dir.join(".branch-delete-transaction.json")).unwrap(); + assert!(journal.contains(r#""state": "committed_orphans""#)); + assert!(!orphan.exists()); + let states = recover_committed_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!(crate::db::database_path_is_tombstoned(&orphan).unwrap()); +} + +#[test] +fn partial_rename_failpoint_rolls_back_entire_sqlite_family() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let wal = db.with_extension("db-wal"); + std::fs::write(&wal, b"wal").unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared + .commit_with_hook(None, |phase| { + if phase == transaction::TransactionPhase::AfterMove(1) { + return failpoint("partial rename failpoint"); + } + Ok(()) + }) + .unwrap_err(); + + assert!(error.to_string().contains("partial rename failpoint")); + assert!(db.exists()); + assert!(wal.exists()); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} + +#[cfg(unix)] +#[test] +fn hard_linked_wal_is_rejected_before_journal_publication() { + let (temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let wal = db.with_extension("db-wal"); + std::fs::write(&wal, b"wal").unwrap(); + std::fs::hard_link(&wal, temp.path().join("wal-alias")).unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared.commit().unwrap_err(); + + assert!(error.to_string().contains("hard links")); + assert!(db.exists()); + assert!(wal.exists()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[cfg(unix)] +#[test] +fn hard_linked_shm_is_rejected_before_journal_publication() { + let (temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let shm = db.with_extension("db-shm"); + std::fs::write(&shm, b"shm").unwrap(); + std::fs::hard_link(&shm, temp.path().join("shm-alias")).unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared.commit().unwrap_err(); + + assert!(error.to_string().contains("hard links")); + assert!(db.exists()); + assert!(shm.exists()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[test] +fn metadata_publication_failpoint_rolls_back_quarantine() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared + .commit_with_hook(None, |phase| { + if phase == transaction::TransactionPhase::BeforeMetadataPublication { + return failpoint("metadata publication failpoint"); + } + Ok(()) + }) + .unwrap_err(); + + assert!(error.to_string().contains("metadata publication failpoint")); + assert!(db.exists()); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} + +#[test] +fn post_commit_cleanup_failpoint_is_retried_during_next_lock_acquisition() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + + let error = prepared + .commit_with_hook(None, |phase| { + if phase == transaction::TransactionPhase::AfterCommitBeforeCleanup { + return failpoint("post-commit cleanup failpoint"); + } + Ok(()) + }) + .unwrap_err(); + assert!(error.to_string().contains("post-commit cleanup failpoint")); + assert!(!db.exists()); + assert!( + tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); + assert!(!quarantine_files(&tracedecay_dir).is_empty()); + assert!( + !crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); + + recover_without_fence(&tracedecay_dir); + let retry = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + assert_eq!(retry.report().outcome, BranchAdminOutcome::NotTracked); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[test] +fn orphan_only_cleanup_retry_uses_explicit_committed_journal_state() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let orphan = tracedecay_dir.join("branches/orphan.db"); + std::fs::write(&orphan, b"orphan").unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Gc, + u64::MAX, + 0, + ) + .unwrap(); + assert_eq!(prepared.report().removed_orphan_dbs, vec![orphan.clone()]); + + prepared + .commit_with_hook(None, |phase| { + if phase == transaction::TransactionPhase::AfterCommitBeforeCleanup { + return failpoint("orphan cleanup failpoint"); + } + Ok(()) + }) + .unwrap_err(); + let journal = + std::fs::read_to_string(tracedecay_dir.join(".branch-delete-transaction.json")).unwrap(); + assert!(journal.contains(r#""state": "committed_orphans""#)); + assert!(!orphan.exists()); + assert!(!quarantine_files(&tracedecay_dir).is_empty()); + + recover_without_fence(&tracedecay_dir); + let retry = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Gc, + u64::MAX, + 0, + ) + .unwrap(); + assert_eq!(retry.report().outcome, BranchAdminOutcome::NoChanges); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!( + !tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); +} + +#[test] +fn recreated_original_family_fails_closed_and_retains_recovery_evidence() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = crate::db::DatabaseDeletionFence::acquire( + std::slice::from_ref(&db), + "delete branch recreation test", + ) + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let mut recreated = false; + + let error = prepared + .commit_with_precommit_hook( + Some(&transaction_id), + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + |phase| { + if phase == transaction::TransactionPhase::BeforeRefRevalidation && !recreated { + std::fs::write(&db, b"recreated").unwrap(); + recreated = true; + } + Ok(()) + }, + ) + .unwrap_err(); + drop(fence); + + assert!( + error + .to_string() + .contains("unexpected original branch store") + ); + assert!( + error + .to_string() + .contains("ambiguous source/quarantine state") + ); + assert!(error.to_string().contains("recovery evidence was retained")); + assert_eq!(std::fs::read(&db).unwrap(), b"recreated"); + let quarantine = quarantine_files(&tracedecay_dir); + assert_eq!(quarantine.len(), 1); + assert_eq!(std::fs::read(&quarantine[0]).unwrap(), b"feature"); + assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); + assert!( + tracedecay_dir + .join(".branch-delete-transaction.json") + .exists() + ); + + std::fs::remove_file(&db).unwrap(); + let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); + assert_eq!(std::fs::read(&db).unwrap(), b"feature"); + assert!(quarantine_files(&tracedecay_dir).is_empty()); +} + +#[test] +fn gc_ref_reappearance_failpoint_rolls_back_before_metadata_commit() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let mut meta = crate::branch_meta::load_branch_meta(&tracedecay_dir).unwrap(); + meta.branches.get_mut("feature").unwrap().last_synced_at = "0".to_string(); + crate::branch_meta::save_branch_meta(&tracedecay_dir, &meta).unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Gc, + 0, + u64::MAX, + ) + .unwrap(); + assert_eq!(prepared.report().removed_branches, vec!["feature"]); + let mut recreated = false; + + let error = prepared + .commit_with_hook(None, |phase| { + if phase == transaction::TransactionPhase::BeforeRefRevalidation && !recreated { + run_git(&project_root, &["branch", "feature"]); + recreated = true; + } + Ok(()) + }) + .unwrap_err(); + + assert!(error.to_string().contains("reappeared")); + assert!(db.exists()); + assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!( + crate::branch_meta::load_branch_meta(&tracedecay_dir) + .unwrap() + .is_tracked("feature") + ); +} diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs new file mode 100644 index 000000000..7279347d2 --- /dev/null +++ b/src/branch/admin/transaction.rs @@ -0,0 +1,878 @@ +use std::fs::File; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::errors::{Result, TraceDecayError}; +use crate::storage::{BRANCH_META_FILENAME, PrivateStoreIo}; + +const JOURNAL_FILENAME: &str = ".branch-delete-transaction.json"; +const JOURNAL_VERSION: u32 = 1; +const QUARANTINE_MARKER: &str = ".branch-delete-"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum JournalState { + Prepared, + CommittedOrphans, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct FamilySnapshot { + db: bool, + wal: bool, + shm: bool, +} + +impl FamilySnapshot { + fn values(&self) -> [bool; 3] { + [self.db, self.wal, self.shm] + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DeletionEntry { + db_file: String, + present: FamilySnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DeletionJournal { + version: u32, + transaction_id: String, + state: JournalState, + metadata_before: Option, + metadata_after: Option, + entries: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TransactionPhase { + AfterJournalBeforeDeletingPublication, + AfterMove(usize), + BeforeRefRevalidation, + BeforeMetadataPublication, + AfterCommitBeforeCleanup, + AfterPhysicalRollbackBeforeDeletingRollback, + AfterDeletingRollbackBeforeJournalClear, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RecoveryDisposition { + PreCommitRollback, + CommittedCleanup, +} + +pub(super) struct PendingRecovery { + journal: DeletionJournal, + disposition: RecoveryDisposition, +} + +impl PendingRecovery { + pub(super) fn transaction_id(&self) -> &str { + &self.journal.transaction_id + } + + pub(super) fn disposition(&self) -> RecoveryDisposition { + self.disposition + } + + pub(super) fn database_paths(&self, tracedecay_dir: &Path) -> Vec { + self.journal + .entries + .iter() + .map(|entry| tracedecay_dir.join(&entry.db_file)) + .collect() + } + + pub(super) fn recover( + self, + tracedecay_dir: &Path, + validate_quarantined_stores: V, + transition_tombstones: T, + ) -> Result<()> + where + V: FnOnce(&[PathBuf]) -> Result<()>, + T: FnOnce(RecoveryDisposition) -> Result<()>, + { + let validation_paths = validation_database_paths(tracedecay_dir, &self.journal)?; + validate_quarantined_stores(&validation_paths)?; + match self.disposition { + RecoveryDisposition::PreCommitRollback => { + rollback_files(tracedecay_dir, &self.journal)?; + transition_tombstones(self.disposition)?; + } + RecoveryDisposition::CommittedCleanup => { + transition_tombstones(self.disposition)?; + cleanup_files(tracedecay_dir, &self.journal)?; + } + } + clear_journal(tracedecay_dir) + } +} + +pub(super) fn prepare_pending_recovery(tracedecay_dir: &Path) -> Result> { + let Some(journal) = load_journal(tracedecay_dir)? else { + return Ok(None); + }; + validate_journal(tracedecay_dir, &journal)?; + let current = read_current_metadata(tracedecay_dir)?; + let disposition = match journal.state { + JournalState::Prepared if current == journal.metadata_before => { + RecoveryDisposition::PreCommitRollback + } + JournalState::Prepared + if journal.metadata_before != journal.metadata_after + && current == journal.metadata_after => + { + RecoveryDisposition::CommittedCleanup + } + JournalState::CommittedOrphans + if journal.metadata_before == journal.metadata_after + && current == journal.metadata_after => + { + RecoveryDisposition::CommittedCleanup + } + _ => { + return Err(config_error(format!( + "cannot recover branch deletion transaction '{}': branch metadata matches neither the exact pre-commit nor post-commit state", + journal.transaction_id + ))); + } + }; + Ok(Some(PendingRecovery { + journal, + disposition, + })) +} + +pub(super) fn ensure_no_pending_recovery(tracedecay_dir: &Path) -> Result<()> { + if load_journal(tracedecay_dir)?.is_some() { + return Err(config_error(format!( + "branch deletion recovery is required at '{}' before branch metadata may change", + tracedecay_dir.display() + ))); + } + Ok(()) +} + +pub(super) fn commit_with_hook( + tracedecay_dir: &Path, + supplied_transaction_id: Option<&str>, + database_paths: &[PathBuf], + metadata_before: Option, + metadata_after: Option, + publish_deleting: P, + validate_precommit: V, + rollback_deleting: R, + mut hook: H, +) -> Result<()> +where + P: FnOnce() -> Result<()>, + V: FnOnce(&[PathBuf]) -> Result<()>, + R: FnOnce() -> Result<()>, + H: FnMut(TransactionPhase) -> Result<()>, +{ + if database_paths.is_empty() { + validate_precommit(&[])?; + let current = read_current_metadata(tracedecay_dir)?; + if current != metadata_before { + return Err(config_error( + "branch metadata changed after deletion selection; transaction refused", + )); + } + if metadata_before != metadata_after { + hook(TransactionPhase::BeforeMetadataPublication)?; + let after = metadata_after.as_deref().ok_or_else(|| { + config_error("tracked branch deletion cannot remove branch metadata entirely") + })?; + crate::branch_meta::save_branch_meta_serialized(tracedecay_dir, after)?; + sync_file(&tracedecay_dir.join(BRANCH_META_FILENAME))?; + sync_directory(tracedecay_dir)?; + } + return Ok(()); + } + if journal_path(tracedecay_dir).exists() { + return Err(config_error(format!( + "branch deletion transaction journal '{}' already exists after recovery", + journal_path(tracedecay_dir).display() + ))); + } + + let transaction_id = supplied_transaction_id + .map(str::to_string) + .unwrap_or_else(transaction_id); + let mut entries = database_paths + .iter() + .map(|path| snapshot_entry(tracedecay_dir, path)) + .collect::>>()?; + entries.sort_by(|left, right| left.db_file.cmp(&right.db_file)); + if entries + .windows(2) + .any(|pair| pair[0].db_file == pair[1].db_file) + { + return Err(config_error( + "branch deletion transaction contains duplicate database paths", + )); + } + let mut journal = DeletionJournal { + version: JOURNAL_VERSION, + transaction_id, + state: JournalState::Prepared, + metadata_before, + metadata_after, + entries, + }; + validate_journal(tracedecay_dir, &journal)?; + persist_journal(tracedecay_dir, &journal)?; + + let operation = (|| { + hook(TransactionPhase::AfterJournalBeforeDeletingPublication)?; + publish_deleting()?; + + let mut moved = 0_usize; + for entry in &journal.entries { + for (source, quarantine, expected_present) in + family_states(tracedecay_dir, &journal, entry)? + { + if expected_present { + require_regular_file(&source, "branch store family member")?; + require_missing(&quarantine, "branch deletion quarantine")?; + std::fs::rename(&source, &quarantine).map_err(|error| { + config_error(format!( + "failed to quarantine branch store file '{}' as '{}': {error}", + source.display(), + quarantine.display() + )) + })?; + sync_directory(source.parent().ok_or_else(|| { + config_error(format!( + "branch store path '{}' has no parent", + source.display() + )) + })?)?; + moved += 1; + hook(TransactionPhase::AfterMove(moved))?; + } else { + require_missing(&source, "branch store family member")?; + require_missing(&quarantine, "branch deletion quarantine")?; + } + } + } + + let validation_paths = validation_database_paths(tracedecay_dir, &journal)?; + hook(TransactionPhase::BeforeRefRevalidation)?; + validate_precommit(&validation_paths)?; + require_original_family_missing(tracedecay_dir, &journal)?; + let current = read_current_metadata(tracedecay_dir)?; + if current != journal.metadata_before { + return Err(config_error( + "branch metadata changed after deletion selection; transaction refused", + )); + } + + if journal.metadata_before != journal.metadata_after { + hook(TransactionPhase::BeforeMetadataPublication)?; + let after = journal.metadata_after.as_deref().ok_or_else(|| { + config_error("tracked branch deletion cannot remove branch metadata entirely") + })?; + crate::branch_meta::save_branch_meta_serialized(tracedecay_dir, after)?; + sync_file(&tracedecay_dir.join(BRANCH_META_FILENAME))?; + sync_directory(tracedecay_dir)?; + } else { + let mut committed = journal.clone(); + committed.state = JournalState::CommittedOrphans; + persist_journal(tracedecay_dir, &committed)?; + journal = committed; + } + hook(TransactionPhase::AfterCommitBeforeCleanup)?; + cleanup_committed(tracedecay_dir, &journal) + })(); + + match operation { + Ok(()) => Ok(()), + Err(primary) => { + let committed = if journal.state == JournalState::CommittedOrphans { + true + } else if journal.metadata_before != journal.metadata_after { + match read_current_metadata(tracedecay_dir) { + Ok(current) => current == journal.metadata_after, + Err(_) => return Err(primary), + } + } else { + false + }; + if committed { + return Err(primary); + } + if let Err(rollback_error) = rollback_files(tracedecay_dir, &journal) { + return Err(config_error(format!( + "{primary}; physical rollback also failed and recovery evidence was retained: {rollback_error}" + ))); + } + if let Err(failpoint) = + hook(TransactionPhase::AfterPhysicalRollbackBeforeDeletingRollback) + { + return Err(config_error(format!( + "{primary}; rollback stopped after physical restoration and recovery evidence was retained: {failpoint}" + ))); + } + if let Err(rollback_error) = rollback_deleting() { + return Err(config_error(format!( + "{primary}; deletion-fence rollback also failed and recovery evidence was retained: {rollback_error}" + ))); + } + if let Err(failpoint) = hook(TransactionPhase::AfterDeletingRollbackBeforeJournalClear) + { + return Err(config_error(format!( + "{primary}; rollback stopped after deletion-fence restoration and recovery evidence was retained: {failpoint}" + ))); + } + match clear_journal(tracedecay_dir) { + Ok(()) => Err(primary), + Err(clear_error) => Err(config_error(format!( + "{primary}; rollback succeeded but journal cleanup failed and recovery evidence was retained: {clear_error}" + ))), + } + } + } +} + +fn snapshot_entry(tracedecay_dir: &Path, db_path: &Path) -> Result { + let db_file = validate_database_path(tracedecay_dir, db_path)?; + let family = branch_db_family_paths(db_path); + let mut present = [false; 3]; + for (index, path) in family.iter().enumerate() { + present[index] = inspect_regular_file(path, "branch store family member")?; + } + Ok(DeletionEntry { + db_file, + present: FamilySnapshot { + db: present[0], + wal: present[1], + shm: present[2], + }, + }) +} + +fn validate_journal(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + if journal.version != JOURNAL_VERSION { + return Err(config_error(format!( + "unsupported branch deletion journal version {}", + journal.version + ))); + } + if journal.transaction_id.is_empty() + || journal.transaction_id.len() > 512 + || journal.transaction_id.chars().any(char::is_control) + || journal.transaction_id.contains('/') + || journal.transaction_id.contains('\\') + { + return Err(config_error("invalid branch deletion transaction id")); + } + if journal.entries.is_empty() { + return Err(config_error( + "branch deletion journal contains no database entries", + )); + } + if journal.state == JournalState::CommittedOrphans + && journal.metadata_before != journal.metadata_after + { + return Err(config_error( + "only metadata-neutral orphan deletion may use committed_orphans journal state", + )); + } + validate_store_root(tracedecay_dir)?; + let mut previous = None; + for entry in &journal.entries { + let db_path = tracedecay_dir.join(&entry.db_file); + let normalized = validate_database_path(tracedecay_dir, &db_path)?; + if normalized != entry.db_file { + return Err(config_error(format!( + "branch deletion journal path '{}' is not normalized", + entry.db_file + ))); + } + if previous + .as_deref() + .is_some_and(|value| value >= entry.db_file.as_str()) + { + return Err(config_error( + "branch deletion journal database entries are duplicated or unsorted", + )); + } + previous = Some(entry.db_file.clone()); + for (source, quarantine, _) in family_states(tracedecay_dir, journal, entry)? { + if source.parent() != quarantine.parent() { + return Err(config_error( + "branch deletion quarantine is not a sibling path", + )); + } + } + } + Ok(()) +} + +fn validate_store_root(tracedecay_dir: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(tracedecay_dir).map_err(|error| { + config_error(format!( + "cannot inspect branch store root '{}': {error}", + tracedecay_dir.display() + )) + })?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(config_error(format!( + "branch store root '{}' is not an unambiguous directory", + tracedecay_dir.display() + ))); + } + let branches = tracedecay_dir.join("branches"); + let metadata = std::fs::symlink_metadata(&branches).map_err(|error| { + config_error(format!( + "cannot inspect branch database directory '{}': {error}", + branches.display() + )) + })?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(config_error(format!( + "branch database directory '{}' is not an unambiguous directory", + branches.display() + ))); + } + Ok(()) +} + +fn validate_database_path(tracedecay_dir: &Path, db_path: &Path) -> Result { + validate_store_root(tracedecay_dir)?; + let relative = db_path.strip_prefix(tracedecay_dir).map_err(|_| { + config_error(format!( + "branch database path '{}' escapes store root '{}'", + db_path.display(), + tracedecay_dir.display() + )) + })?; + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || !relative.starts_with("branches") + || relative.components().count() < 2 + || !relative + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("db")) + { + return Err(config_error(format!( + "branch database path '{}' is not a normalized branches/*.db store path", + db_path.display() + ))); + } + let parent = db_path.parent().ok_or_else(|| { + config_error(format!( + "branch database path '{}' has no parent", + db_path.display() + )) + })?; + let canonical_root = tracedecay_dir.canonicalize().map_err(|error| { + config_error(format!( + "cannot resolve branch store root '{}': {error}", + tracedecay_dir.display() + )) + })?; + let canonical_parent = parent.canonicalize().map_err(|error| { + config_error(format!( + "cannot resolve branch database parent '{}': {error}", + parent.display() + )) + })?; + if !canonical_parent.starts_with(canonical_root.join("branches")) { + return Err(config_error(format!( + "branch database parent '{}' escapes the branch store directory", + parent.display() + ))); + } + relative + .to_str() + .map(str::to_string) + .ok_or_else(|| config_error("branch database path is not valid UTF-8")) +} + +fn family_states( + tracedecay_dir: &Path, + journal: &DeletionJournal, + entry: &DeletionEntry, +) -> Result> { + let db_path = tracedecay_dir.join(&entry.db_file); + let family = branch_db_family_paths(&db_path); + let quarantine = quarantine_family_paths(&db_path, &journal.transaction_id)?; + let present = entry.present.values(); + Ok(family + .into_iter() + .zip(quarantine) + .zip(present) + .map(|((source, quarantine), expected)| (source, quarantine, expected)) + .collect()) +} + +fn validation_database_paths( + tracedecay_dir: &Path, + journal: &DeletionJournal, +) -> Result> { + let mut paths = journal + .entries + .iter() + .map(|entry| tracedecay_dir.join(&entry.db_file)) + .collect::>(); + paths.extend(quarantine_database_paths(tracedecay_dir, journal)?); + Ok(paths) +} + +fn require_original_family_missing(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + for entry in &journal.entries { + for (source, _, _) in family_states(tracedecay_dir, journal, entry)? { + require_missing(&source, "original branch store family member")?; + } + } + Ok(()) +} + +fn quarantine_database_paths( + tracedecay_dir: &Path, + journal: &DeletionJournal, +) -> Result> { + journal + .entries + .iter() + .map(|entry| { + family_states(tracedecay_dir, journal, entry)? + .into_iter() + .next() + .map(|(_, quarantine, _)| quarantine) + .ok_or_else(|| config_error("branch deletion family is empty")) + }) + .collect() +} + +fn branch_db_family_paths(db_path: &Path) -> [PathBuf; 3] { + let mut wal = db_path.to_path_buf(); + wal.set_extension("db-wal"); + let mut shm = db_path.to_path_buf(); + shm.set_extension("db-shm"); + [db_path.to_path_buf(), wal, shm] +} + +fn quarantine_family_paths(database: &Path, transaction_id: &str) -> Result<[PathBuf; 3]> { + let parent = database.parent().ok_or_else(|| { + config_error(format!( + "branch store path '{}' has no parent", + database.display() + )) + })?; + let name = database + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + config_error(format!( + "branch store path '{}' has a non-UTF-8 filename", + database.display() + )) + })?; + let transaction_component = transaction_id + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { + (byte as char).to_string() + } else { + format!("_{byte:02x}") + } + }) + .collect::(); + let database = parent.join(format!( + ".{name}{QUARANTINE_MARKER}{transaction_component}.quarantine" + )); + let mut wal = database.as_os_str().to_os_string(); + wal.push("-wal"); + let mut shm = database.as_os_str().to_os_string(); + shm.push("-shm"); + Ok([database, PathBuf::from(wal), PathBuf::from(shm)]) +} + +fn rollback_files(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + let states = journal + .entries + .iter() + .map(|entry| family_states(tracedecay_dir, journal, entry)) + .collect::>>()?; + for family in &states { + for (source, quarantine, expected_present) in family { + let source_exists = inspect_regular_file(source, "branch store family member")?; + let quarantine_exists = inspect_regular_file(quarantine, "branch deletion quarantine")?; + match (*expected_present, source_exists, quarantine_exists) { + (true, true, false) | (true, false, true) | (false, false, false) => {} + _ => { + return Err(config_error(format!( + "cannot roll back branch deletion transaction '{}': ambiguous source/quarantine state for '{}'", + journal.transaction_id, + source.display() + ))); + } + } + } + } + for family in states.into_iter().rev() { + for (source, quarantine, expected_present) in family.into_iter().rev() { + if expected_present && quarantine.exists() { + std::fs::rename(&quarantine, &source).map_err(|error| { + config_error(format!( + "failed to restore quarantined branch store '{}' to '{}': {error}", + quarantine.display(), + source.display() + )) + })?; + sync_directory(source.parent().ok_or_else(|| { + config_error(format!( + "branch store path '{}' has no parent", + source.display() + )) + })?)?; + } + } + } + Ok(()) +} + +fn cleanup_committed(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + cleanup_files(tracedecay_dir, journal)?; + clear_journal(tracedecay_dir) +} + +fn cleanup_files(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + let states = journal + .entries + .iter() + .map(|entry| family_states(tracedecay_dir, journal, entry)) + .collect::>>()?; + for family in &states { + for (source, quarantine, expected_present) in family { + let source_exists = inspect_regular_file(source, "branch store family member")?; + let quarantine_exists = inspect_regular_file(quarantine, "branch deletion quarantine")?; + match (*expected_present, source_exists, quarantine_exists) { + (true, false, true) | (true, false, false) | (false, false, false) => {} + _ => { + return Err(config_error(format!( + "cannot clean committed branch deletion transaction '{}': ambiguous source/quarantine state for '{}'", + journal.transaction_id, + source.display() + ))); + } + } + } + } + for family in states { + for (source, quarantine, expected_present) in family { + if expected_present && quarantine.exists() { + std::fs::remove_file(&quarantine).map_err(|error| { + config_error(format!( + "failed to delete quarantined branch store file '{}': {error}", + quarantine.display() + )) + })?; + sync_directory(source.parent().ok_or_else(|| { + config_error(format!( + "branch store path '{}' has no parent", + source.display() + )) + })?)?; + } + } + } + Ok(()) +} + +fn inspect_regular_file(path: &Path, description: &str) -> Result { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + require_single_link(path, &metadata)?; + Ok(true) + } + Ok(_) => Err(config_error(format!( + "{description} '{}' is not an unambiguous regular file", + path.display() + ))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(config_error(format!( + "cannot inspect {description} '{}': {error}", + path.display() + ))), + } +} + +#[cfg(unix)] +fn require_single_link(path: &Path, metadata: &std::fs::Metadata) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + if metadata.nlink() == 1 { + Ok(()) + } else { + Err(config_error(format!( + "branch store family member '{}' has {} hard links; deletion identity is ambiguous", + path.display(), + metadata.nlink() + ))) + } +} + +#[cfg(not(unix))] +fn require_single_link(path: &Path, _metadata: &std::fs::Metadata) -> Result<()> { + Err(config_error(format!( + "cannot prove branch store family member '{}' has a single hard link on this platform", + path.display() + ))) +} + +fn require_regular_file(path: &Path, description: &str) -> Result<()> { + if inspect_regular_file(path, description)? { + Ok(()) + } else { + Err(config_error(format!( + "expected {description} '{}' is missing", + path.display() + ))) + } +} + +fn require_missing(path: &Path, description: &str) -> Result<()> { + if inspect_regular_file(path, description)? { + Err(config_error(format!( + "unexpected {description} '{}' already exists", + path.display() + ))) + } else { + Ok(()) + } +} + +fn persist_journal(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + validate_journal(tracedecay_dir, journal)?; + let path = journal_path(tracedecay_dir); + let temp = tracedecay_dir.join(format!("{JOURNAL_FILENAME}.tmp-{}", journal.transaction_id)); + let bytes = serde_json::to_vec_pretty(journal)?; + if let Err(error) = PrivateStoreIo::write_file_atomically(&path, &temp, &bytes) { + let _ = std::fs::remove_file(&temp); + return Err(error.into()); + } + sync_file(&path)?; + sync_directory(tracedecay_dir) +} + +fn load_journal(tracedecay_dir: &Path) -> Result> { + let path = journal_path(tracedecay_dir); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(config_error(format!( + "branch deletion journal '{}' is not an unambiguous regular file", + path.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(config_error(format!( + "cannot inspect branch deletion journal '{}': {error}", + path.display() + ))); + } + } + let bytes = std::fs::read(&path).map_err(|error| { + config_error(format!( + "cannot read branch deletion journal '{}': {error}", + path.display() + )) + })?; + serde_json::from_slice(&bytes).map(Some).map_err(|error| { + config_error(format!( + "cannot parse branch deletion journal '{}': {error}", + path.display() + )) + }) +} + +fn read_current_metadata(tracedecay_dir: &Path) -> Result> { + let path = tracedecay_dir.join(BRANCH_META_FILENAME); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(config_error(format!( + "branch metadata '{}' is not an unambiguous regular file", + path.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(config_error(format!( + "cannot inspect branch metadata '{}': {error}", + path.display() + ))); + } + } + std::fs::read_to_string(&path).map(Some).map_err(|error| { + config_error(format!( + "cannot read branch metadata '{}': {error}", + path.display() + )) + }) +} + +fn clear_journal(tracedecay_dir: &Path) -> Result<()> { + let path = journal_path(tracedecay_dir); + match std::fs::remove_file(&path) { + Ok(()) => sync_directory(tracedecay_dir), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(config_error(format!( + "failed to clear branch deletion journal '{}': {error}", + path.display() + ))), + } +} + +fn sync_file(path: &Path) -> Result<()> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|error| config_error(format!("failed to sync '{}': {error}", path.display()))) +} + +fn sync_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|error| { + config_error(format!( + "failed to sync directory '{}': {error}", + path.display() + )) + }) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +fn journal_path(tracedecay_dir: &Path) -> PathBuf { + tracedecay_dir.join(JOURNAL_FILENAME) +} + +fn transaction_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{}-{nanos}", std::process::id()) +} + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} diff --git a/src/branch/tests.rs b/src/branch/tests.rs index 2bda415bd..5e93d3d91 100644 --- a/src/branch/tests.rs +++ b/src/branch/tests.rs @@ -28,7 +28,9 @@ fn unique_stem_keeps_free_name() { let meta = crate::branch_meta::BranchMeta::new("main"); let dir = Path::new("/nonexistent-branches-dir-for-test"); assert_eq!( - unique_branch_db_stem(&meta, dir, "feature/new").unwrap(), + unique_branch_db_stem(&meta, dir, "feature/new") + .unwrap() + .unwrap(), "feature_new" ); } @@ -39,7 +41,9 @@ fn unique_stem_disambiguates_sanitization_collision() { let mut meta = crate::branch_meta::BranchMeta::new("main"); meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); let dir = Path::new("/nonexistent-branches-dir-for-test"); - let stem = unique_branch_db_stem(&meta, dir, "feature_foo").unwrap(); + let stem = unique_branch_db_stem(&meta, dir, "feature_foo") + .unwrap() + .unwrap(); assert_ne!( stem, "feature_foo", "second branch must not reuse the first branch's DB file" @@ -56,7 +60,9 @@ fn unique_stem_preserves_hashed_orphan_recovery_file() { std::fs::write(dir.path().join(format!("{hashed}.db")), b"recovery").unwrap(); assert_eq!( - unique_branch_db_stem(&meta, dir.path(), "feature_foo").unwrap(), + unique_branch_db_stem(&meta, dir.path(), "feature_foo") + .unwrap() + .unwrap(), format!("{hashed}-1") ); } @@ -69,7 +75,9 @@ fn unique_stem_is_idempotent_for_same_branch() { meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); let dir = Path::new("/nonexistent-branches-dir-for-test"); assert_eq!( - unique_branch_db_stem(&meta, dir, "feature/foo").unwrap(), + unique_branch_db_stem(&meta, dir, "feature/foo") + .unwrap() + .unwrap(), "feature_foo" ); } @@ -78,8 +86,33 @@ fn unique_stem_is_idempotent_for_same_branch() { fn unique_stem_rejects_empty_sanitization() { let meta = crate::branch_meta::BranchMeta::new("main"); let dir = Path::new("/nonexistent-branches-dir-for-test"); - assert!(unique_branch_db_stem(&meta, dir, "..").is_none()); - assert!(unique_branch_db_stem(&meta, dir, "///").is_none()); + assert!(unique_branch_db_stem(&meta, dir, "..").unwrap().is_none()); + assert!(unique_branch_db_stem(&meta, dir, "///").unwrap().is_none()); +} + +#[test] +fn unique_stem_never_reuses_a_deleted_database_path() { + let temp = tempfile::tempdir().unwrap(); + let branches_dir = temp.path().join("branches"); + std::fs::create_dir_all(&branches_dir).unwrap(); + let retired = branches_dir.join("feature.db"); + let fence = crate::db::DatabaseDeletionFence::acquire( + std::slice::from_ref(&retired), + "retire branch database path", + ) + .unwrap(); + fence.publish_deleting().unwrap(); + fence.promote_deleted().unwrap(); + drop(fence); + + let meta = crate::branch_meta::BranchMeta::new("main"); + let stem = unique_branch_db_stem(&meta, &branches_dir, "feature") + .unwrap() + .unwrap(); + + assert_ne!(stem, "feature"); + assert!(stem.starts_with("feature-"), "got: {stem}"); + assert!(crate::db::database_path_is_tombstoned(&retired).unwrap()); } // --- git test harness (mirrors src/mcp/hook_events.rs tests) ------------ diff --git a/src/branch_meta.rs b/src/branch_meta.rs index 67ace7f28..3bb5ab450 100644 --- a/src/branch_meta.rs +++ b/src/branch_meta.rs @@ -276,18 +276,35 @@ pub fn load_branch_meta(data_dir: &Path) -> Option { } } +/// Serializes validated branch metadata in the canonical persisted form. +pub(crate) fn serialize_branch_meta(meta: &BranchMeta) -> std::io::Result { + meta.validate() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + serde_json::to_string_pretty(meta).map_err(std::io::Error::other) +} + +/// Publishes already-serialized branch metadata after validating that it is the +/// same canonical schema accepted by runtime readers. This is crate-private so +/// the deletion journal can persist and later compare the exact commit bytes. +pub(crate) fn save_branch_meta_serialized( + data_dir: &Path, + serialized: &str, +) -> std::io::Result<()> { + parse(serialized) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let path = data_dir.join(BRANCH_META_FILENAME); + let temp_path = path.with_extension("json.tmp"); + PrivateStoreIo::write_file_atomically(&path, &temp_path, serialized.as_bytes()) +} + /// Saves branch metadata to `branch-meta.json` in the project data dir. /// /// Writes via a sibling temp file and renames it into place (the same /// atomic-write helper used for `store-manifest.json`), so a concurrent /// reader never observes a torn or truncated file. pub fn save_branch_meta(data_dir: &Path, meta: &BranchMeta) -> std::io::Result<()> { - meta.validate() - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; - let path = data_dir.join(BRANCH_META_FILENAME); - let json = serde_json::to_string_pretty(meta).map_err(std::io::Error::other)?; - let temp_path = path.with_extension("json.tmp"); - PrivateStoreIo::write_file_atomically(&path, &temp_path, json.as_bytes()) + let serialized = serialize_branch_meta(meta)?; + save_branch_meta_serialized(data_dir, &serialized) } /// Advances the `last_synced_at` timestamp for `branch` in the project's @@ -297,9 +314,18 @@ pub fn save_branch_meta(data_dir: &Path, meta: &BranchMeta) -> std::io::Result<( /// reflects real sync activity (previously `last_synced_at` only moved at /// branch-add finalize, making the list misleading). It silently no-ops when /// there is no branch metadata (single-DB mode / pre-branch projects) or when -/// `branch` is untracked — a sync of an untracked branch has no entry to touch, -/// and forcing one here would race branch-add's own bookkeeping. +/// `branch` is untracked — a sync of an untracked branch has no entry to touch. +/// The shared branch lock serializes this load-modify-save sequence with branch +/// add, removal, GC, and pending deletion recovery. pub fn update_synced_timestamp(tracedecay_dir: &Path, branch: &str) { + update_synced_timestamp_with(tracedecay_dir, branch, || {}); +} + +fn update_synced_timestamp_with(tracedecay_dir: &Path, branch: &str, after_lock: impl FnOnce()) { + let Ok(_branch_lock) = crate::branch::acquire_branch_lock_blocking(tracedecay_dir) else { + return; + }; + after_lock(); let Some(mut meta) = load_branch_meta(tracedecay_dir) else { return; }; @@ -475,6 +501,28 @@ mod tests { assert!(synced > 1000, "last_synced_at should advance, got {synced}"); } + #[test] + fn update_synced_timestamp_holds_shared_branch_lock_during_load_modify_save() { + let dir = tempfile::tempdir().unwrap(); + let mut meta = BranchMeta::new("main"); + meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); + save_branch_meta(dir.path(), &meta).unwrap(); + let mut observed_contention = false; + + update_synced_timestamp_with(dir.path(), "feature/foo", || { + let error = crate::branch::try_acquire_branch_add_lock(dir.path()) + .expect_err("timestamp update must already own the shared branch lock"); + observed_contention = matches!(error, crate::errors::TraceDecayError::SyncLock { .. }); + }); + + assert!(observed_contention); + assert!( + load_branch_meta(dir.path()) + .unwrap() + .is_tracked("feature/foo") + ); + } + #[test] fn update_synced_timestamp_noops_for_unknown_branch() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/commands.rs b/src/commands.rs index b7ab59800..2e993bda0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,1690 +1,19 @@ -use std::io::{self, BufRead, IsTerminal, Read, Write}; -use std::path::{Path, PathBuf}; - -use crate::Spinner; -use crate::cli::{BranchAction, MemoryAction, MigrateAction}; -use crate::global; -use tracedecay::tracedecay::TraceDecay; - -pub(crate) async fn daemon_tool_json( - project_path: Option<&std::path::Path>, - tool_name: &str, - arguments: serde_json::Value, -) -> tracedecay::errors::Result { - let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( - project_path.map(std::path::Path::to_path_buf), - None, - false, - false, - )?; - let result = tracedecay::daemon::call_default_tool(&handshake, tool_name, arguments).await?; - let blocks = result - .get("content") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned no content blocks"), - })?; - for text in blocks - .iter() - .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) - { - if let Ok(value) = serde_json::from_str(text) { - return Ok(value); - } - } - Err(tracedecay::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned no JSON payload"), - }) -} - -pub(crate) async fn handle_memory_action(action: MemoryAction) -> tracedecay::errors::Result<()> { - match action { - MemoryAction::Status { .. } => unreachable!("memory status is handled in main.rs dispatch"), - MemoryAction::Curate { - apply, - llm, - llm_ops, - max_clusters, - min_confidence, - path, - } => { - let project_path = tracedecay::config::resolve_path_with_discovery(path); - let llm_ops_value = match llm_ops { - Some(source) => Some(read_llm_ops_payload(&source)?), - None => None, - }; - let report = daemon_tool_json( - Some(&project_path), - "tracedecay_admin_project", - serde_json::json!({ - "action": "memory_curate", - "apply": apply, - "llm": llm, - "llm_ops": llm_ops_value, - "max_clusters": max_clusters, - "min_confidence": min_confidence, - }), - ) - .await?; - println!( - "{}", - serde_json::to_string_pretty(&report).unwrap_or_default() - ); - } - } - Ok(()) -} - -/// Reads the `--llm-ops` payload from a file path or stdin (`-`). -fn read_llm_ops_payload(source: &str) -> tracedecay::errors::Result { - let text = if source == "-" { - let mut buf = String::new(); - io::stdin().lock().read_to_string(&mut buf).map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read --llm-ops from stdin: {e}"), - } - })?; - buf - } else { - std::fs::read_to_string(source).map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read --llm-ops file {source}: {e}"), - } - })? - }; - serde_json::from_str(&text).map_err(|e| tracedecay::errors::TraceDecayError::Config { - message: format!("--llm-ops payload is not valid JSON: {e}"), - }) -} - -pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay::errors::Result<()> { - match action { - MigrateAction::Consolidate { - project, - source_project_id, - target_project_id, - profile_root, - apply, - confirm_token, - json, - } => { - let profile_root = profile_root.map_or_else( - || { - tracedecay::config::user_data_dir().ok_or_else(|| { - tracedecay::errors::TraceDecayError::Config { - message: "could not determine TraceDecay profile root".to_string(), - } - }) - }, - |value| Ok(PathBuf::from(value)), - )?; - let options = tracedecay::migrate::consolidate::ConsolidationOptions { - project_root: PathBuf::from(project), - profile_root, - source_project_id, - target_project_id, - }; - let report = if apply { - let token = - confirm_token.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "--confirm-token is required with --apply".to_string(), - })?; - tracedecay::migrate::consolidate::apply(&options, &token).await? - } else { - tracedecay::migrate::consolidate::plan(&options).await? - }; - if json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!("Migration: {}", report.migration_id); - println!("State: {:?}", report.state); - println!( - "Source: {} ({})", - report.source.project_id, - report.source.data_root.display() - ); - println!( - "Target: {} ({})", - report.target.project_id, - report.target.data_root.display() - ); - println!( - "Destination: {} ({})", - report.destination_project_id, - report.destination_data_root.display() - ); - println!("Backups: {}", report.backup_root.display()); - println!("Ledger: {}", report.ledger_path.display()); - if report.dry_run { - println!("Confirmation token: {}", report.confirmation_token); - println!("No files changed."); - } - } - } - MigrateAction::Plan { - roots, - include_all_registered, - follow_symlinks, - manifest, - save, - profile_root, - project_id, - json, - } => { - let scan_roots = if roots.is_empty() { - vec![std::env::current_dir().map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("could not determine current directory: {e}"), - } - })?] - } else { - roots.into_iter().map(PathBuf::from).collect() - }; - let report = tracedecay::migrate::inventory::build_inventory( - tracedecay::migrate::inventory::MigrationInventoryOptions { - roots: scan_roots, - follow_symlinks, - include_all_registered, - ..tracedecay::migrate::inventory::MigrationInventoryOptions::default() - }, - ) - .await?; - if manifest.is_some() || save { - let migration_id = format!("mig_{}", tracedecay::tracedecay::current_timestamp()); - let profile_root = - profile_root.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "--profile-root is required when saving a manifest".to_string(), - })?; - let project_id = - project_id.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "--project-id is required when saving a manifest".to_string(), - })?; - let manifest_path = manifest.map(PathBuf::from).unwrap_or_else(|| { - PathBuf::from(&profile_root) - .join("migration-inventory") - .join(format!("{migration_id}.json")) - }); - let confirmation_token = format!("confirm-{migration_id}"); - let manifest = tracedecay::migrate::manifest::build_plan_manifest( - report, - tracedecay::migrate::manifest::MigrationPlanOptions { - manifest_path, - migration_id, - tracedecay_version: env!("CARGO_PKG_VERSION").to_string(), - created_at_unix: tracedecay::tracedecay::current_timestamp(), - confirmation_token, - target_profile_root: PathBuf::from(profile_root), - project_id, - }, - ) - .map_err(|message| tracedecay::errors::TraceDecayError::Config { message })?; - tracedecay::migrate::manifest::save_manifest(&manifest)?; - if json { - println!("{}", serde_json::to_string_pretty(&manifest)?); - } else { - println!( - "migration manifest: {} ({} artifact(s))", - manifest.protocol.manifest_path.display(), - manifest.artifacts.len() - ); - println!("confirmation token: {}", manifest.confirmation_token); - } - } else if json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!( - "migration inventory: {} store(s), {} skipped path(s)", - report.stores.len(), - report.skipped.len() - ); - if let Some(global) = report.global_db { - println!( - "global db: {} (projects: {}, sessions: {})", - global.path.display(), - global.project_count, - global.session_count - ); - } - } - } - MigrateAction::Export { - from_profile: _, - project, - project_id, - to, - } => { - let project_id = match project_id { - Some(project_id) => project_id, - None => { - let project_root = - project - .map(PathBuf::from) - .unwrap_or(std::env::current_dir().map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("could not determine current directory: {e}"), - } - })?); - let marker = tracedecay::storage::read_enrollment_marker(&project_root)? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!( - "project '{}' is not enrolled in profile-sharded storage", - project_root.display() - ), - })?; - marker.project_id - } - }; - let profile_root = tracedecay::storage::default_profile_root()?; - let report = tracedecay::migrate::manifest::export_profile_store( - &profile_root, - &project_id, - &PathBuf::from(to), - ) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - })?; - println!( - "migration export: {} artifact(s) from {} to {}", - report.artifact_count, - report.source_data_root.display(), - report.target_dir.display() - ); - } - MigrateAction::Apply { - manifest, - confirm_token, - } => { - let mut manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; - if manifest.confirmation_token != confirm_token { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "confirmation token does not match migration manifest".to_string(), - }); - } - let target_profile_root = - manifest.destination.profile_root.clone().ok_or_else(|| { - tracedecay::errors::TraceDecayError::Config { - message: "migration manifest has no destination profile_root".to_string(), - } - })?; - let _lifecycle_lease = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( - &target_profile_root, - "legacy store migration", - )?; - let _database_scope = tracedecay::db::enter_maintenance_database_scope( - &_lifecycle_lease, - &target_profile_root, - "legacy store migration", - )?; - let apply_report = tracedecay::migrate::manifest::apply_migration_manifest( - &mut manifest, - ) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - })?; - let verify_report = tracedecay::migrate::manifest::verify_migration_manifest(&manifest); - if !verify_report.cutover_ready { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!( - "migration staging did not reach cutover-ready state: {} missing target(s), {} issue(s)", - verify_report.missing_targets, - verify_report.issues.len() - ), - }); - } - let global_db = tracedecay::global_db::GlobalDb::try_open_at( - &apply_report.profile_root.join("global.db"), - ) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "could not open global DB for migrate apply".to_string(), - })?; - let registry_report = - tracedecay::migrate::registry::apply_single_registry_reconstruction_report( - &global_db, - &verify_report.registry_reconstruction, - ) - .await - .map_err(|issues| tracedecay::errors::TraceDecayError::Config { - message: format!( - "failed to apply registry reconstruction: {}", - issues.join("; ") - ), - })?; - tracedecay::storage::write_enrollment_marker( - &apply_report.project_root, - &tracedecay::storage::EnrollmentMarker { - project_id: apply_report.project_id.clone(), - storage_mode: tracedecay::storage::StorageMode::ProfileSharded, - }, - )?; - if let Err(err) = tracedecay::migrate::manifest::finalize_migration_apply(&mut manifest) - { - let _ = tracedecay::storage::remove_enrollment_marker( - &apply_report.project_root, - &apply_report.project_id, - ); - return Err(tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - }); - } - tracedecay::migrate::manifest::save_manifest(&manifest)?; - println!( - "migration apply: {} artifact(s), {} registry project(s), {} alias(es)", - apply_report.artifact_count, registry_report.projects, registry_report.aliases - ); - } - MigrateAction::Verify { manifest, json } => { - let manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; - let report = tracedecay::migrate::manifest::verify_migration_manifest(&manifest); - if json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!( - "migration verify: {} artifact(s), {} planned target(s), {} missing target(s)", - report.artifact_count, report.planned_targets, report.missing_targets - ); - println!( - "registry reconstruction: {} plan(s), {} store manifest(s), {} issue(s)", - report.registry_plan_count, - report.store_manifest_count, - report.issues.len() - ); - println!( - "cutover ready: {}", - if report.cutover_ready { "yes" } else { "no" } - ); - println!( - "apply supported: {}", - if report.apply_supported { "yes" } else { "no" } - ); - } - } - MigrateAction::Reconstruct { - profile_root, - apply, - json, - } => { - let profile_root = PathBuf::from(profile_root); - if apply { - let projects_root = profile_root.join("projects"); - std::fs::read_dir(&projects_root).map_err(|error| { - tracedecay::errors::TraceDecayError::Config { - message: format!( - "could not read profile projects directory '{}': {error}", - projects_root.display() - ), - } - })?; - } - let _lifecycle_lease = apply - .then(|| { - tracedecay::lifecycle_lease::acquire_exclusive_for_profile( - &profile_root, - "registry reconstruction", - ) - }) - .transpose()?; - let _database_scope = _lifecycle_lease - .as_ref() - .map(|lifecycle_lease| { - tracedecay::db::enter_maintenance_database_scope( - lifecycle_lease, - &profile_root, - "registry reconstruction", - ) - }) - .transpose()?; - let report = tracedecay::migrate::registry::scan_profile_store_manifests( - &profile_root, - tracedecay::tracedecay::current_timestamp(), - ); - if apply { - let mut blockers = report.issues.clone(); - blockers.extend( - report - .plans - .iter() - .filter(|plan| { - plan.status - == tracedecay::migrate::registry::RegistryReconstructionStatus::Blocked - }) - .map(|plan| { - format!( - "blocked manifest '{}': {}", - plan.manifest_path.display(), - plan.status_reason.as_deref().unwrap_or("not eligible") - ) - }), - ); - if !blockers.is_empty() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!( - "failed to preflight registry reconstruction: {}", - blockers.join("; ") - ), - }); - } - let global_db = - tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "could not open global DB for registry reconstruction" - .to_string(), - })?; - let applied = tracedecay::migrate::registry::apply_registry_reconstruction_report( - &global_db, &report, - ) - .await - .map_err(|issues| tracedecay::errors::TraceDecayError::Config { - message: format!( - "failed to apply registry reconstruction: {}", - issues.join("; ") - ), - })?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "dry_run": report, - "applied": applied, - }))? - ); - } else { - println!( - "registry reconstruction applied: {} project(s), {} alias(es), {} store(s), {} graph scope(s), {} artifact(s)", - applied.projects, - applied.aliases, - applied.stores, - applied.graph_scopes, - applied.artifacts - ); - } - } else if json { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - use tracedecay::migrate::registry::RegistryReconstructionStatus; - let eligible = report.status_count(RegistryReconstructionStatus::Eligible); - let blocked = report.status_count(RegistryReconstructionStatus::Blocked); - let stale = report.status_count(RegistryReconstructionStatus::Stale); - let retired = report.status_count(RegistryReconstructionStatus::Retired); - println!( - "registry reconstruction: {} eligible, {} blocked, {} stale, {} retired, {} issue(s)", - eligible, - blocked, - stale, - retired, - report.issues.len() - ); - println!( - "apply supported: {} (atomic batch; skips stale/retired, inserts eligible missing rows only, fails on blocked/invalid/conflict)", - if blocked == 0 && report.issues.is_empty() { - "yes" - } else { - "no" - } - ); - } - } - MigrateAction::RegistryGc { - prefix, - apply, - json, - } => { - let profile_root = tracedecay::storage::default_profile_root()?; - let lifecycle_lease = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( - &profile_root, - "registry cleanup", - )?; - let _database_scope = tracedecay::db::enter_maintenance_database_scope( - &lifecycle_lease, - &profile_root, - "registry cleanup", - )?; - let global_db = - tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) - .await? - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "could not open global DB for registry cleanup".to_string(), - })?; - let projects = global_db.list_code_projects(usize::MAX).await; - let prefixes: Vec = prefix.iter().map(PathBuf::from).collect(); - let stale = tracedecay::migrate::registry::stale_code_projects( - &projects, - &prefixes, - tracedecay::migrate::registry::StaleRootScope::CanonicalRootMissing, - ); - let mut stale_storage_projects = Vec::new(); - for project_path in global_db.list_project_paths().await { - let path = Path::new(&project_path); - if !prefixes.is_empty() && !prefixes.iter().any(|prefix| path.starts_with(prefix)) { - continue; - } - let location = global::classify_project_storage_with_registry( - path, - Some(&global_db), - Some(&profile_root), - ) - .await; - if location.status == global::ProjectStorageStatus::Stale { - stale_storage_projects.push(project_path); - } - } - let (deleted_code_projects, deleted_storage_projects) = if apply { - let project_ids: Vec = stale - .iter() - .map(|project| project.project_id.clone()) - .collect(); - ( - global_db.delete_code_projects(&project_ids).await, - global_db.delete_projects(&stale_storage_projects).await, - ) - } else { - (0, 0) - }; - let candidate_paths = stale - .iter() - .map(|project| { - tracedecay::global_db::GlobalDb::canonical_project_key(Path::new( - &project.canonical_root, - )) - }) - .chain(stale_storage_projects.iter().map(|path| { - tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(path)) - })) - .collect::>(); - let candidate_count = candidate_paths.len(); - let metadata_candidate_count = stale.len() + stale_storage_projects.len(); - let deleted_count = deleted_code_projects + deleted_storage_projects; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "apply": apply, - "prefix": prefix, - "candidate_count": candidate_count, - "metadata_candidate_count": metadata_candidate_count, - "code_project_candidate_count": stale.len(), - "storage_project_candidate_count": stale_storage_projects.len(), - "deleted_count": deleted_count, - "deleted_code_project_count": deleted_code_projects, - "deleted_storage_project_count": deleted_storage_projects, - "candidates": stale, - "storage_project_candidates": stale_storage_projects, - }))? - ); - } else { - println!( - "registry-gc: {} stale project(s){}", - candidate_count, - if apply { " selected" } else { " found" } - ); - if apply { - println!( - "metadata rows deleted: {deleted_count} ({deleted_code_projects} identity, {deleted_storage_projects} storage)" - ); - } else { - println!("dry run: re-run with --apply to delete registry metadata"); - } - for project_path in candidate_paths.iter().take(20) { - println!("{project_path}"); - } - if candidate_count > 20 { - println!("... {} more", candidate_count - 20); - } - } - } - MigrateAction::Rollback { - manifest, - confirm_token, - } => { - let mut manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; - if manifest.confirmation_token != confirm_token { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "confirmation token does not match migration manifest".to_string(), - }); - } - let rollback_report = tracedecay::migrate::manifest::rollback_migration_manifest( - &mut manifest, - ) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - })?; - tracedecay::migrate::manifest::save_manifest(&manifest)?; - println!( - "migration rollback: {} artifact(s)", - rollback_report.artifact_count - ); - } - MigrateAction::CleanupSources { - manifest, - confirm_token, - } => { - let manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; - if manifest.confirmation_token != confirm_token { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "confirmation token does not match migration manifest".to_string(), - }); - } - let cleanup_report = tracedecay::migrate::manifest::cleanup_migration_sources( - &manifest, - ) - .map_err(|err| tracedecay::errors::TraceDecayError::Config { - message: err.to_string(), - })?; - println!( - "migration cleanup-sources: {} source artifact(s) removed", - cleanup_report.removed_artifacts - ); - } - } - Ok(()) -} - -pub(crate) async fn handle_branch_action(action: BranchAction) -> tracedecay::errors::Result<()> { - use tracedecay::branch; - use tracedecay::branch_meta; - - match action { - BranchAction::List { path } => { - let project_path = tracedecay::config::resolve_path(path); - let status = daemon_tool_json( - Some(&project_path), - "tracedecay_status", - serde_json::json!({ "format": "json" }), - ) - .await?; - let diagnostics = status.get("branch_diagnostics").ok_or_else(|| { - tracedecay::errors::TraceDecayError::Config { - message: "daemon status omitted branch diagnostics".to_string(), - } - })?; - if !diagnostics - .get("tracking_enabled") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - eprintln!("No branch tracking configured. Run `tracedecay branch add` to start."); - return Ok(()); - } - eprintln!( - "Default branch: {}", - diagnostics - .get("default_branch") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - ); - eprintln!( - "Current branch: {}", - diagnostics - .get("current_branch") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - ); - if let Some(serving) = diagnostics - .get("serving_branch") - .and_then(serde_json::Value::as_str) - { - let suffix = if diagnostics - .get("is_fallback") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - " (fallback)" - } else { - "" - }; - eprintln!("Serving branch: {serving}{suffix}"); - } - if diagnostics - .get("branch_drifted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - eprintln!( - "Opened branch: {}", - diagnostics - .get("open_active_branch") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - ); - } - eprintln!(); - for branch in diagnostics - .get("branches") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - let db_exists = branch - .get("db_exists") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let size = if db_exists { - tracedecay::display::format_bytes( - branch - .get("size_bytes") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - ) - } else { - "missing".to_string() - }; - let parent = branch - .get("parent") - .and_then(serde_json::Value::as_str) - .map(|p| format!(" (from {p})")) - .unwrap_or_default(); - let last_synced_at = branch - .get("last_synced_at") - .and_then(serde_json::Value::as_str) - .unwrap_or("never"); - let synced = branch_meta::format_timestamp(last_synced_at); - let mut flags = Vec::new(); - if branch - .get("is_default") - .and_then(serde_json::Value::as_bool) - == Some(true) - { - flags.push("default"); - } - if branch - .get("is_current") - .and_then(serde_json::Value::as_bool) - == Some(true) - { - flags.push("current"); - } - if branch - .get("is_serving") - .and_then(serde_json::Value::as_bool) - == Some(true) - { - flags.push("serving"); - } - if !db_exists { - flags.push("missing-db"); - } - let flags = if flags.is_empty() { - String::new() - } else { - format!(" [{}]", flags.join(", ")) - }; - eprintln!( - " {}{} — {}{}, synced {}", - branch - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""), - flags, - size, - parent, - synced - ); - } - if let Some(warnings) = diagnostics - .get("warnings") - .and_then(serde_json::Value::as_array) - .filter(|warnings| !warnings.is_empty()) - { - eprintln!(); - for warning in warnings.iter().filter_map(serde_json::Value::as_str) { - eprintln!("warning: {warning}"); - } - } - } - BranchAction::Add { name, path } => { - let project_path = tracedecay::config::resolve_path(path); - let branch_name = match name { - Some(n) => n, - None => branch::current_branch(&project_path).ok_or_else(|| { - tracedecay::errors::TraceDecayError::Config { - message: - "cannot detect current branch (detached HEAD?). Specify a branch name." - .to_string(), - } - })?, - }; - - let spinner = Spinner::new(); - spinner.set_message("syncing changes"); - match TraceDecay::add_branch_tracking(&project_path, &branch_name).await? { - branch::BranchAddOutcome::NotIndexed => { - spinner.done("no TraceDecay index found; run `tracedecay init` first"); - } - branch::BranchAddOutcome::AlreadyTracked => { - spinner.done(&format!("Branch '{branch_name}' is already tracked.")); - } - branch::BranchAddOutcome::Added => { - spinner.done(&format!("branch '{branch_name}' tracked")); - } - branch::BranchAddOutcome::Deferred => { - spinner.done(&format!( - "branch '{branch_name}' tracked; sync deferred because another process is active" - )); - } - } - } - BranchAction::Remove { name, path } => { - let project_path = tracedecay::config::resolve_path(path); - let tracedecay_dir = resolve_branch_data_root(&project_path).await; - let Some(mut meta) = branch_meta::load_branch_meta(&tracedecay_dir) else { - eprintln!("No branch tracking configured."); - return Ok(()); - }; - if name == meta.default_branch { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!("cannot remove default branch '{name}'"), - }); - } - if let Some(entry) = meta.remove_branch(&name) { - let db_path = tracedecay_dir.join(&entry.db_file); - if db_path.exists() { - std::fs::remove_file(&db_path)?; - // Also remove WAL/SHM sidecar files - let _ = std::fs::remove_file(db_path.with_extension("db-wal")); - let _ = std::fs::remove_file(db_path.with_extension("db-shm")); - } - branch_meta::save_branch_meta(&tracedecay_dir, &meta)?; - eprintln!("\x1b[32m✔\x1b[0m Branch '{name}' removed."); - } else { - eprintln!("Branch '{name}' is not tracked."); - } - } - BranchAction::Removeall { path } => { - let project_path = tracedecay::config::resolve_path(path); - let tracedecay_dir = resolve_branch_data_root(&project_path).await; - let Some(mut meta) = branch_meta::load_branch_meta(&tracedecay_dir) else { - eprintln!("No branch tracking configured."); - return Ok(()); - }; - let removed = meta.remove_all_branches(); - if removed.is_empty() { - eprintln!("No non-default branches to remove."); - } else { - for (name, entry) in &removed { - let db_path = tracedecay_dir.join(&entry.db_file); - if db_path.exists() { - std::fs::remove_file(&db_path)?; - let _ = std::fs::remove_file(db_path.with_extension("db-wal")); - let _ = std::fs::remove_file(db_path.with_extension("db-shm")); - } - eprintln!(" removed '{name}'"); - } - branch_meta::save_branch_meta(&tracedecay_dir, &meta)?; - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} branch(es). Only '{}' remains.", - removed.len(), - meta.default_branch - ); - } - } - BranchAction::Gc { path } => { - let project_path = tracedecay::config::resolve_path(path); - let tracedecay_dir = resolve_branch_data_root(&project_path).await; - let Some(mut meta) = branch_meta::load_branch_meta(&tracedecay_dir) else { - eprintln!("No branch tracking configured."); - return Ok(()); - }; - - // Find branches in metadata that no longer exist in git - let stale: Vec = meta - .branches - .keys() - .filter(|name| *name != &meta.default_branch) - .filter(|name| { - let ref_path = project_path.join(format!(".git/refs/heads/{name}")); - let packed = project_path.join(".git/packed-refs"); - let suffix = format!("refs/heads/{name}"); - let in_packed = packed.exists() - && std::fs::read_to_string(&packed) - .map(|c| c.lines().any(|line| line.ends_with(&suffix))) - .unwrap_or(false); - !ref_path.exists() && !in_packed - }) - .cloned() - .collect(); - - if stale.is_empty() { - eprintln!("No stale branches to clean up."); - } else { - for name in &stale { - if let Some(entry) = meta.remove_branch(name) { - let db_path = tracedecay_dir.join(&entry.db_file); - if db_path.exists() { - std::fs::remove_file(&db_path)?; - let _ = std::fs::remove_file(db_path.with_extension("db-wal")); - let _ = std::fs::remove_file(db_path.with_extension("db-shm")); - } - eprintln!(" removed '{name}'"); - } - } - branch_meta::save_branch_meta(&tracedecay_dir, &meta)?; - eprintln!( - "\x1b[32m✔\x1b[0m Cleaned up {} stale branch(es).", - stale.len() - ); - } - } - BranchAction::Autotrack { action } => { - handle_branch_autotrack_action(action).await?; - } - } - Ok(()) -} - -/// Reads or mutates the project-scoped `sync.auto_track_pr_branches` setting and -/// reports the daemon's PR-autotrack status for a project. -async fn handle_branch_autotrack_action( - action: crate::cli::BranchAutotrackAction, -) -> tracedecay::errors::Result<()> { - use crate::cli::BranchAutotrackAction; - use tracedecay::config::{ - MIN_AUTO_TRACK_PR_POLL_SECS, load_config_with_identity, save_config_with_identity, - }; - - match action { - BranchAutotrackAction::Status { path } => { - let project_path = tracedecay::config::resolve_path(path); - let config = load_config_with_identity(&project_path).await?; - let sync = &config.sync; - eprintln!( - "PR auto-tracking: {}", - if sync.auto_track_pr_branches { - "enabled" - } else { - "disabled" - } - ); - eprintln!( - "Poll interval: {}s (effective {}s)", - sync.auto_track_pr_poll_secs, - sync.effective_auto_track_pr_poll_secs() - ); - #[cfg(unix)] - { - let data_root = resolve_branch_data_root(&project_path).await; - let managed = tracedecay::daemon::pr_autotrack::managed_summary(&data_root); - if managed.is_empty() { - eprintln!("Tracked PR branches: none"); - } else { - eprintln!("Tracked PR branches:"); - for entry in managed { - eprintln!( - " {} — PR #{} (head {})", - entry.branch, entry.pr, entry.head_branch - ); - } - } - } - } - BranchAutotrackAction::Enable { poll_secs, path } => { - let project_path = tracedecay::config::resolve_path(path); - let mut config = load_config_with_identity(&project_path).await?; - config.sync.auto_track_pr_branches = true; - if let Some(secs) = poll_secs { - config.sync.auto_track_pr_poll_secs = secs.max(MIN_AUTO_TRACK_PR_POLL_SECS); - } - save_config_with_identity(&project_path, &config).await?; - eprintln!( - "\x1b[32m✔\x1b[0m PR auto-tracking enabled (poll every {}s). Restart the daemon (`tracedecay daemon restart`) to apply.", - config.sync.effective_auto_track_pr_poll_secs() - ); - } - BranchAutotrackAction::Disable { path } => { - let project_path = tracedecay::config::resolve_path(path); - let mut config = load_config_with_identity(&project_path).await?; - config.sync.auto_track_pr_branches = false; - save_config_with_identity(&project_path, &config).await?; - eprintln!( - "\x1b[32m✔\x1b[0m PR auto-tracking disabled. The daemon tears down any managed PR worktrees, refs, synthetic branches and stores on its next poll cycle." - ); - } - } - Ok(()) -} - -async fn resolve_branch_data_root(project_path: &Path) -> PathBuf { - fallback_branch_data_root(project_path) -} - -fn fallback_branch_data_root(project_path: &Path) -> PathBuf { - tracedecay::storage::resolve_layout_for_current_profile(project_path) - .map(|layout| layout.data_root) - .unwrap_or_else(|_| tracedecay::config::get_tracedecay_dir(project_path)) -} - -/// Handles the `wipe` and `wipe --all` commands. -pub(crate) async fn handle_wipe(all: bool) -> tracedecay::errors::Result<()> { - use std::fs; - let profile_root = tracedecay::storage::default_profile_root()?; - let lifecycle_lease = - tracedecay::lifecycle_lease::acquire_exclusive_for_profile(&profile_root, "wipe")?; - let _database_scope = - tracedecay::db::enter_maintenance_database_scope(&lifecycle_lease, &profile_root, "wipe")?; - let home_tracedecay = Some(profile_root); - - let project_paths = global::gather_target_projects(all, &home_tracedecay).await; - let gdb = tracedecay::global_db::GlobalDb::try_open().await?; - let mut targets = Vec::new(); - for path in &project_paths { - let location = global::classify_project_storage_with_registry( - path, - gdb.as_ref(), - home_tracedecay.as_deref(), - ) - .await; - if location.status.is_live() { - targets.push(location); - } - } - - if !all && targets.is_empty() { - eprintln!("No tracedecay projects found in current folder, parents, or children."); - return Ok(()); - } - - global::print_flash_warning(all, &targets); - - eprint!("Type \x1b[1;32mgo!\x1b[0m to confirm (anything else aborts): "); - io::stderr().flush().ok(); - let mut answer = String::new(); - io::stdin().lock().read_line(&mut answer).map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read stdin: {e}"), - } - })?; - if answer.trim() != "go!" { - eprintln!("\x1b[33mAborted — nothing was wiped.\x1b[0m"); - return Ok(()); - } - - let mut removed = 0usize; - let mut errors = 0usize; - let mut wiped_paths: Vec = Vec::new(); - - for location in &targets { - if !location.data_root.exists() { - continue; - } - match fs::remove_dir_all(&location.data_root) { - Ok(()) => { - removed += 1; - wiped_paths.push(location.project_root.clone()); - eprintln!( - " \x1b[32m✔\x1b[0m removed {}", - location.data_root.display() - ); - if let Some(marker_root) = &location.marker_root { - let _ = fs::remove_dir_all(marker_root); - } - } - Err(e) => { - errors += 1; - eprintln!(" \x1b[31m✗\x1b[0m {} ({e})", location.data_root.display()); - } - } - } - - drop(gdb); - - if all { - if let Some(global_dir) = home_tracedecay.as_ref() { - for ext in ["db", "db-wal", "db-shm"] { - let p = global_dir.join(format!("global.{ext}")); - let _ = fs::remove_file(&p); - } - eprintln!( - " \x1b[32m✔\x1b[0m emptied global DB at {}/global.db", - global_dir.display() - ); - } - } else if !wiped_paths.is_empty() { - if let Some(gdb) = tracedecay::global_db::GlobalDb::try_open().await? { - let path_strs: Vec = wiped_paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - gdb.delete_projects(&path_strs).await; - } - } - - eprintln!(); - let suffix = if errors > 0 { - format!(" ({errors} error(s))") - } else { - String::new() - }; - eprintln!("\x1b[32mWiped {removed} project(s){suffix}.\x1b[0m"); - Ok(()) -} - -/// Handles the `list` and `list --all` commands. -pub(crate) async fn handle_list(all: bool) -> tracedecay::errors::Result<()> { - use tracedecay::display::format_token_count; - - let home_tracedecay = tracedecay::config::user_data_dir(); - let project_paths = global::gather_target_projects(all, &home_tracedecay).await; - - if !all && project_paths.is_empty() { - println!("No tracedecay projects found in current folder, parents, or children."); - return Ok(()); - } - - let token_result = daemon_tool_json( - None, - "tracedecay_admin_cli", - serde_json::json!({ - "action": "registry_project_tokens", - "project_args": &project_paths, - }), - ) - .await?; - let token_rows = token_result - .get("projects") - .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - let mut rows: Vec = Vec::with_capacity(project_paths.len()); - let mut total_size: u64 = 0; - let mut total_tokens: u64 = 0; - - for path in &project_paths { - let location = - global::classify_project_storage_with_registry(path, None, home_tracedecay.as_deref()) - .await; - let has_data = location.data_root.exists(); - let size = if has_data { - global::tracedecay_dir_size(&location.data_root) - } else { - 0 - }; - let project_key = tracedecay::global_db::GlobalDb::canonical_project_key(path); - let tokens = token_rows - .iter() - .find(|row| { - row.get("project") - .and_then(serde_json::Value::as_str) - .is_some_and(|value| { - tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(value)) - == project_key - }) - }) - .and_then(|row| row.get("tokens")) - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - total_size = total_size.saturating_add(size); - total_tokens = total_tokens.saturating_add(tokens); - rows.push(ListRow { - path: path.clone(), - status_label: location.status.label(), - has_data, - size, - tokens, - }); - } - - if all { - append_orphan_manifest_rows(&mut rows, &project_paths, home_tracedecay.as_deref()); - } - - if rows.is_empty() { - println!("No tracedecay projects tracked in the global DB."); - return Ok(()); - } - - total_size = rows.iter().map(|row| row.size).sum(); - total_tokens = rows.iter().map(|row| row.tokens).sum(); - - rows.sort_by(|a, b| b.tokens.cmp(&a.tokens).then_with(|| a.path.cmp(&b.path))); - - let path_w = rows - .iter() - .map(|r| { - format!("{} [{}]", r.path.display(), r.status_label) - .chars() - .count() - }) - .max() - .unwrap_or(0); - - println!("Found {} tracedecay project(s):", rows.len()); - println!(); - for r in &rows { - let path_str = format!("{} [{}]", r.path.display(), r.status_label); - let pad = path_w.saturating_sub(path_str.chars().count()); - let size_str = if r.has_data { - tracedecay::display::format_bytes(r.size) - } else { - "—".to_string() - }; - let tokens_str = if r.tokens == 0 { - "—".to_string() - } else { - format_token_count(r.tokens) - }; - println!( - " {path_str}{pad} {size:>10} {tokens:>10} tokens", - pad = " ".repeat(pad), - size = size_str, - tokens = tokens_str - ); - } - println!(); - let total_tokens_str = if total_tokens == 0 { - "—".to_string() - } else { - format_token_count(total_tokens) - }; - println!( - "Total: {} on disk · {} tokens saved", - tracedecay::display::format_bytes(total_size), - total_tokens_str - ); - Ok(()) -} - -#[derive(Debug)] -struct ListRow { - path: std::path::PathBuf, - status_label: &'static str, - has_data: bool, - size: u64, - tokens: u64, -} - -fn append_orphan_manifest_rows( - rows: &mut Vec, - project_paths: &[std::path::PathBuf], - profile_root: Option<&Path>, -) { - let Some(profile_root) = profile_root else { - return; - }; - let registered: std::collections::HashSet = project_paths - .iter() - .map(|path| tracedecay::global_db::GlobalDb::canonical_project_key(path)) - .collect(); - let report = tracedecay::migrate::registry::scan_profile_store_manifests( - profile_root, - tracedecay::tracedecay::current_timestamp(), - ); - for plan in report.plans { - if plan.status != tracedecay::migrate::registry::RegistryReconstructionStatus::Eligible { - continue; - } - let key = - tracedecay::global_db::GlobalDb::canonical_project_key(&plan.project.project_root); - if registered.contains(&key) { - continue; - } - let data_root = profile_root.join(&plan.store.store_relpath); - let has_data = data_root.exists(); - let size = if has_data { - global::tracedecay_dir_size(&data_root) - } else { - 0 - }; - rows.push(ListRow { - path: plan.project.project_root, - status_label: "orphan manifest-reconstructable", - has_data, - size, - tokens: 0, - }); - } -} - -/// True when the global DB has zero registered projects (or can't be opened -/// at all) — i.e. the user has not run `tracedecay init` anywhere yet. -async fn is_fresh_install() -> bool { - daemon_tool_json( - None, - "tracedecay_admin_cli", - serde_json::json!({ "action": "registry_empty" }), - ) - .await - .ok() - .and_then(|value| value.get("empty").and_then(serde_json::Value::as_bool)) - .unwrap_or(false) -} - -/// When invoked with no subcommand, offer to create the index if none exists. -pub(crate) async fn handle_no_command() -> tracedecay::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(None); - if TraceDecay::has_initialized_store(&project_path).await { - // Already initialized — show help via clap - let _ = ::command().print_help(); - eprintln!(); - return Ok(()); - } - if is_fresh_install().await { - eprintln!("\x1b[1;36mWelcome to tracedecay!\x1b[0m"); - eprintln!( - "Looks like a new installation. To get started, run \x1b[1mtracedecay init\x1b[0m \ - in your project root." - ); - eprintln!(); - } - if !io::stdin().is_terminal() { - eprintln!( - "No TraceDecay index found at '{}'. Non-interactive: skipping index creation (run `tracedecay init`).", - project_path.display() - ); - return Ok(()); - } - eprint!( - "No TraceDecay index found at '{}'. Create one now? [Y/n] ", - project_path.display() - ); - io::stderr().flush().ok(); - let mut answer = String::new(); - io::stdin().lock().read_line(&mut answer).map_err(|e| { - tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read stdin: {}", e), - } - })?; - let answer = answer.trim(); - if answer.is_empty() || answer.eq_ignore_ascii_case("y") { - handle_init( - Some(project_path.to_string_lossy().into_owned()), - Vec::new(), - Vec::new(), - ) - .await?; - } - Ok(()) -} - -pub(crate) async fn handle_init( - path: Option, - skip_folders: Vec, - include_folders: Vec, -) -> tracedecay::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(path); - if !skip_folders.is_empty() || !include_folders.is_empty() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "brokered init does not yet support --skip-folders/--include-folders; configure tracedecay.toml first".to_string(), - }); - } - let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( - Some(project_path.clone()), - None, - false, - true, - )?; - tracedecay::daemon::call_default_tool( - &handshake, - "tracedecay_status", - serde_json::json!({"format": "json"}), - ) - .await?; - eprintln!( - "initialized and indexed {} via daemon", - project_path.display() - ); - Ok(()) -} - -pub(crate) async fn handle_sync( - path: Option, - force: bool, - skip_folders: Vec, - include_folders: Vec, - doctor: bool, - verbose: bool, -) -> tracedecay::errors::Result<()> { - let project_path = tracedecay::config::resolve_path_with_discovery(path); - if !skip_folders.is_empty() || !include_folders.is_empty() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first".to_string(), - }); - } - let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( - Some(project_path.clone()), - None, - false, - false, - )?; - let result = tracedecay::daemon::call_default_tool( - &handshake, - "tracedecay_admin_sync", - serde_json::json!({"force": force}), - ) - .await?; - if verbose { - eprintln!( - "{}", - serde_json::to_string_pretty(&result).unwrap_or_default() - ); - } - eprintln!("sync completed via daemon for {}", project_path.display()); - if doctor { - tracedecay::doctor::run_doctor(None).await?; - } - Ok(()) -} - -pub(crate) fn handle_upload_counter(enable: bool) { - let mut config = tracedecay::user_config::UserConfig::load(); - config.upload_enabled = enable; - match config.save_with_recovery() { - Ok(Some(backup)) => eprintln!( - "note: corrupt config.toml backed up to {} before regenerating", - backup.display() - ), - Ok(None) => {} - Err(err) => eprintln!("warning: could not save tracedecay config: {err}"), - } - if enable { - eprintln!("Worldwide counter upload enabled."); - } else { - eprintln!( - "Worldwide counter upload disabled. You can re-enable with `tracedecay enable-upload-counter`." - ); - } -} - -pub(crate) async fn handle_gitignore( - path: Option, - action: Option, -) -> tracedecay::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(path); - let mut config = tracedecay::config::load_config_with_identity(&project_path).await?; - match action.as_deref() { - Some("on") => { - config.git_ignore = true; - tracedecay::config::save_config_with_identity(&project_path, &config).await?; - eprintln!("gitignore enabled — .gitignore rules will be respected during indexing."); - eprintln!("Run `tracedecay sync` to re-index with the new setting."); - } - Some("off") => { - config.git_ignore = false; - tracedecay::config::save_config_with_identity(&project_path, &config).await?; - eprintln!("gitignore disabled — .gitignore rules will be ignored during indexing."); - eprintln!("Run `tracedecay sync` to re-index with the new setting."); - } - Some(other) => { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!("unknown action '{other}': expected 'on' or 'off'"), - }); - } - None => { - let status = if config.git_ignore { "on" } else { "off" }; - eprintln!("gitignore: {status}"); - } - } - Ok(()) -} - -pub(crate) async fn handle_bench( - queries: Option, - json: bool, - path: Option, - max_nodes: usize, -) -> tracedecay::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(path); - let queries_toml = queries - .map(std::fs::read_to_string) - .transpose() - .map_err(|error| tracedecay::errors::TraceDecayError::Config { - message: format!("failed to read query file: {error}"), - })?; - let result = daemon_tool_json( - Some(&project_path), - "tracedecay_admin_project", - serde_json::json!({ - "action": "bench", - "queries_toml": queries_toml, - "json": json, - "max_nodes": max_nodes, - }), - ) - .await?; - let output = result - .get("output") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: "daemon bench response omitted output".to_string(), - })?; - print!("{output}"); - Ok(()) -} - -/// Convert raw tokens-saved into a USD estimate using Sonnet input pricing. -/// Sonnet is the default agent target; output-token savings are not relevant -/// for retrieval savings. -/// -/// Pure table lookup: callers that want up-to-date prices must run -/// `pricing::refresh_if_stale()` once beforehand (see [`handle_gain`]). -/// Keeping the refresh out of this function avoids a network fetch per call -/// (it used to fire for every history row and for every unit test process). -pub(crate) fn estimate_dollars_saved(saved_tokens: u64) -> f64 { - use tracedecay::accounting::pricing; - let price = pricing::lookup("claude-sonnet-4") - .map(|p| p.input_per_mtok) - .unwrap_or(3.0); - (saved_tokens as f64) * price / 1_000_000.0 -} - -pub async fn handle_gain( - all: bool, - history: bool, - range: &str, - json_output: bool, -) -> tracedecay::errors::Result<()> { - tracedecay::accounting::pricing::refresh_if_stale(); - let since = tracedecay::accounting::metrics::parse_range(range); - let project_filter: Option = if all { - None - } else { - std::env::current_dir() - .ok() - .map(|p| p.to_string_lossy().into_owned()) - }; - - let result = daemon_tool_json( - None, - "tracedecay_admin_cli", - serde_json::json!({ - "action": "gain_query", - "project_arg": project_filter, - "since": since as i64, - "history": history, - }), - ) - .await?; - if history { - let rows = result - .get("history") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .map(|row| tracedecay::global_db::SavingsDay { - day: row - .get("day") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0), - saved_tokens: row - .get("saved_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - calls: row - .get("calls") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - }) - .collect::>(); - if json_output { - let arr: Vec<_> = rows - .iter() - .map(|r| { - serde_json::json!({ - "day": r.day, - "saved_tokens": r.saved_tokens, - "calls": r.calls, - "usd": estimate_dollars_saved(r.saved_tokens), - }) - }) - .collect(); - println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default()); - } else { - tracedecay::display::print_gain_history(&rows, estimate_dollars_saved); - } - return Ok(()); - } - - let saved_tokens = result - .get("saved_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let calls = result - .get("calls") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let usd = estimate_dollars_saved(saved_tokens); - - if json_output { - let out = serde_json::json!({ - "range": range, - "project": project_filter.clone().unwrap_or_else(|| "ALL".to_string()), - "saved_tokens": saved_tokens, - "calls": calls, - "usd": usd, - }); - println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); - } else { - tracedecay::display::print_gain_total( - project_filter.as_deref().unwrap_or("ALL projects"), - range, - saved_tokens, - calls, - usd, - ); - } - Ok(()) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod gain_tests { - use super::estimate_dollars_saved; - - #[test] - fn dollars_uses_sonnet_input_price_by_default() { - // 1_000_000 tokens × $3 / MTok = $3.00 (Sonnet input price) - let usd = estimate_dollars_saved(1_000_000); - assert!((usd - 3.0).abs() < 0.01, "expected ~$3.00, got ${usd}"); - } - - #[test] - fn dollars_handles_small_counts() { - // 1_000 tokens × $3 / MTok = $0.003 - let usd = estimate_dollars_saved(1_000); - assert!((usd - 0.003).abs() < 0.001); - } - - #[test] - fn dollars_zero_for_zero_tokens() { - assert_eq!(estimate_dollars_saved(0), 0.0); - } -} +mod bench; +mod branch; +mod daemon; +mod gain; +mod index; +mod memory; +mod migrate; +mod settings; +mod storage; + +pub(crate) use bench::handle_bench; +pub(crate) use branch::handle_branch_action; +pub(crate) use daemon::daemon_tool_json; +pub use gain::handle_gain; +pub(crate) use index::{handle_init, handle_no_command, handle_sync}; +pub(crate) use memory::handle_memory_action; +pub(crate) use migrate::handle_migrate_action; +pub(crate) use settings::{handle_gitignore, handle_upload_counter}; +pub(crate) use storage::{handle_list, handle_wipe}; diff --git a/src/commands/bench.rs b/src/commands/bench.rs new file mode 100644 index 000000000..246e56135 --- /dev/null +++ b/src/commands/bench.rs @@ -0,0 +1,35 @@ +use super::daemon::daemon_tool_json; + +pub(crate) async fn handle_bench( + queries: Option, + json: bool, + path: Option, + max_nodes: usize, +) -> tracedecay::errors::Result<()> { + let project_path = tracedecay::config::resolve_path(path); + let queries_toml = queries + .map(std::fs::read_to_string) + .transpose() + .map_err(|error| tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read query file: {error}"), + })?; + let result = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ + "action": "bench", + "queries_toml": queries_toml, + "json": json, + "max_nodes": max_nodes, + }), + ) + .await?; + let output = result + .get("output") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon bench response omitted output".to_string(), + })?; + print!("{output}"); + Ok(()) +} diff --git a/src/commands/branch.rs b/src/commands/branch.rs new file mode 100644 index 000000000..b48b6d541 --- /dev/null +++ b/src/commands/branch.rs @@ -0,0 +1,471 @@ +use std::path::{Path, PathBuf}; + +use crate::Spinner; +use crate::cli::BranchAction; + +use super::daemon::daemon_tool_json; + +pub(crate) async fn handle_branch_action(action: BranchAction) -> tracedecay::errors::Result<()> { + use tracedecay::branch; + use tracedecay::branch_meta; + + match action { + BranchAction::List { path } => { + let project_path = tracedecay::config::resolve_path(path); + let status = daemon_tool_json( + Some(&project_path), + "tracedecay_status", + serde_json::json!({ "format": "json" }), + ) + .await?; + let diagnostics = status.get("branch_diagnostics").ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "daemon status omitted branch diagnostics".to_string(), + } + })?; + if !diagnostics + .get("tracking_enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + eprintln!("No branch tracking configured. Run `tracedecay branch add` to start."); + return Ok(()); + } + eprintln!( + "Default branch: {}", + diagnostics + .get("default_branch") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + ); + eprintln!( + "Current branch: {}", + diagnostics + .get("current_branch") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + ); + if let Some(serving) = diagnostics + .get("serving_branch") + .and_then(serde_json::Value::as_str) + { + let suffix = if diagnostics + .get("is_fallback") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + " (fallback)" + } else { + "" + }; + eprintln!("Serving branch: {serving}{suffix}"); + } + if diagnostics + .get("branch_drifted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + eprintln!( + "Opened branch: {}", + diagnostics + .get("open_active_branch") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + ); + } + eprintln!(); + for branch in diagnostics + .get("branches") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + let db_exists = branch + .get("db_exists") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let size = if db_exists { + tracedecay::display::format_bytes( + branch + .get("size_bytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + ) + } else { + "missing".to_string() + }; + let parent = branch + .get("parent") + .and_then(serde_json::Value::as_str) + .map(|p| format!(" (from {p})")) + .unwrap_or_default(); + let last_synced_at = branch + .get("last_synced_at") + .and_then(serde_json::Value::as_str) + .unwrap_or("never"); + let synced = branch_meta::format_timestamp(last_synced_at); + let mut flags = Vec::new(); + if branch + .get("is_default") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + flags.push("default"); + } + if branch + .get("is_current") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + flags.push("current"); + } + if branch + .get("is_serving") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + flags.push("serving"); + } + if !db_exists { + flags.push("missing-db"); + } + let flags = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + eprintln!( + " {}{} — {}{}, synced {}", + branch + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + flags, + size, + parent, + synced + ); + } + if let Some(warnings) = diagnostics + .get("warnings") + .and_then(serde_json::Value::as_array) + .filter(|warnings| !warnings.is_empty()) + { + eprintln!(); + for warning in warnings.iter().filter_map(serde_json::Value::as_str) { + eprintln!("warning: {warning}"); + } + } + } + BranchAction::Add { name, path } => { + let project_path = tracedecay::config::resolve_path(path); + let branch_name = match name { + Some(n) => n, + None => branch::current_branch(&project_path).ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: + "cannot detect current branch (detached HEAD?). Specify a branch name." + .to_string(), + } + })?, + }; + + let spinner = Spinner::new(); + spinner.set_message("syncing changes"); + let response = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_branch_add", + serde_json::json!({ "branch": branch_name }), + ) + .await?; + match parse_daemon_branch_add_outcome(&response)? { + branch::BranchAddOutcome::NotIndexed => { + spinner.done("no TraceDecay index found; run `tracedecay init` first"); + } + branch::BranchAddOutcome::AlreadyTracked => { + spinner.done(&format!("Branch '{branch_name}' is already tracked.")); + } + branch::BranchAddOutcome::Added => { + spinner.done(&format!("branch '{branch_name}' tracked")); + } + branch::BranchAddOutcome::Deferred => { + spinner.done(&format!( + "branch '{branch_name}' tracked; sync deferred because another process is active" + )); + } + } + } + BranchAction::Remove { name, path } => { + let project_path = tracedecay::config::resolve_path(path); + let response = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_branch", + serde_json::json!({ "action": "remove", "branch": name }), + ) + .await?; + let report = parse_daemon_branch_admin_report(&response)?; + match report.outcome { + branch::BranchAdminOutcome::NoTracking => { + eprintln!("No branch tracking configured."); + } + branch::BranchAdminOutcome::NotTracked => { + eprintln!("Branch '{name}' is not tracked."); + } + branch::BranchAdminOutcome::Removed => { + eprintln!("\x1b[32m✔\x1b[0m Branch '{name}' removed."); + } + branch::BranchAdminOutcome::NoChanges => { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "daemon branch remove returned no_changes".to_string(), + }); + } + } + } + BranchAction::Removeall { path } => { + let project_path = tracedecay::config::resolve_path(path); + let response = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_branch", + serde_json::json!({ "action": "remove_all" }), + ) + .await?; + let report = parse_daemon_branch_admin_report(&response)?; + match report.outcome { + branch::BranchAdminOutcome::NoTracking => { + eprintln!("No branch tracking configured."); + } + branch::BranchAdminOutcome::NoChanges => { + eprintln!("No non-default branches to remove."); + } + branch::BranchAdminOutcome::Removed => { + for name in &report.removed_branches { + eprintln!(" removed '{name}'"); + } + eprintln!( + "\x1b[32m✔\x1b[0m Removed {} branch(es). Only '{}' remains.", + report.removed_branches.len(), + report.default_branch.as_deref().unwrap_or("") + ); + } + branch::BranchAdminOutcome::NotTracked => { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "daemon branch remove_all returned not_tracked".to_string(), + }); + } + } + } + BranchAction::Gc { path } => { + let project_path = tracedecay::config::resolve_path(path); + let response = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_branch", + serde_json::json!({ "action": "gc" }), + ) + .await?; + let report = parse_daemon_branch_admin_report(&response)?; + if report.outcome == branch::BranchAdminOutcome::Removed { + for name in &report.removed_branches { + eprintln!(" removed '{name}'"); + } + for path in &report.removed_orphan_dbs { + eprintln!(" removed orphan '{}'", path.display()); + } + eprintln!( + "\x1b[32m✔\x1b[0m Cleaned up {} stale branch(es) and {} orphan database(s).", + report.removed_branches.len(), + report.removed_orphan_dbs.len() + ); + } else { + eprintln!("No stale branches or orphan databases to clean up."); + } + } + BranchAction::Autotrack { action } => { + handle_branch_autotrack_action(action).await?; + } + } + Ok(()) +} + +fn parse_daemon_branch_admin_report( + response: &serde_json::Value, +) -> tracedecay::errors::Result { + serde_json::from_value(response.clone()).map_err(|error| { + tracedecay::errors::TraceDecayError::Config { + message: format!("invalid daemon branch administration response: {error}"), + } + }) +} + +fn parse_daemon_branch_add_outcome( + response: &serde_json::Value, +) -> tracedecay::errors::Result { + match response.get("outcome").and_then(serde_json::Value::as_str) { + Some("not_indexed") => Ok(tracedecay::branch::BranchAddOutcome::NotIndexed), + Some("already_tracked") => Ok(tracedecay::branch::BranchAddOutcome::AlreadyTracked), + Some("added") => Ok(tracedecay::branch::BranchAddOutcome::Added), + Some("deferred") => Ok(tracedecay::branch::BranchAddOutcome::Deferred), + Some(outcome) => Err(tracedecay::errors::TraceDecayError::Config { + message: format!("daemon branch add returned unknown outcome: {outcome}"), + }), + None => Err(tracedecay::errors::TraceDecayError::Config { + message: "daemon branch add response omitted outcome".to_string(), + }), + } +} + +/// Reads or mutates the project-scoped `sync.auto_track_pr_branches` setting and +/// reports the daemon's PR-autotrack status for a project. +async fn handle_branch_autotrack_action( + action: crate::cli::BranchAutotrackAction, +) -> tracedecay::errors::Result<()> { + use crate::cli::BranchAutotrackAction; + use tracedecay::config::{ + MIN_AUTO_TRACK_PR_POLL_SECS, load_config_with_identity, save_config_with_identity, + }; + + match action { + BranchAutotrackAction::Status { path } => { + let project_path = tracedecay::config::resolve_path(path); + let config = load_config_with_identity(&project_path).await?; + let sync = &config.sync; + eprintln!( + "PR auto-tracking: {}", + if sync.auto_track_pr_branches { + "enabled" + } else { + "disabled" + } + ); + eprintln!( + "Poll interval: {}s (effective {}s)", + sync.auto_track_pr_poll_secs, + sync.effective_auto_track_pr_poll_secs() + ); + #[cfg(unix)] + { + let data_root = resolve_branch_data_root(&project_path).await; + let managed = tracedecay::daemon::pr_autotrack::managed_summary(&data_root); + if managed.is_empty() { + eprintln!("Tracked PR branches: none"); + } else { + eprintln!("Tracked PR branches:"); + for entry in managed { + eprintln!( + " {} — PR #{} (head {})", + entry.branch, entry.pr, entry.head_branch + ); + } + } + } + } + BranchAutotrackAction::Enable { poll_secs, path } => { + let project_path = tracedecay::config::resolve_path(path); + let mut config = load_config_with_identity(&project_path).await?; + config.sync.auto_track_pr_branches = true; + if let Some(secs) = poll_secs { + config.sync.auto_track_pr_poll_secs = secs.max(MIN_AUTO_TRACK_PR_POLL_SECS); + } + save_config_with_identity(&project_path, &config).await?; + eprintln!( + "\x1b[32m✔\x1b[0m PR auto-tracking enabled (poll every {}s). Restart the daemon (`tracedecay daemon restart`) to apply.", + config.sync.effective_auto_track_pr_poll_secs() + ); + } + BranchAutotrackAction::Disable { path } => { + let project_path = tracedecay::config::resolve_path(path); + let mut config = load_config_with_identity(&project_path).await?; + config.sync.auto_track_pr_branches = false; + save_config_with_identity(&project_path, &config).await?; + eprintln!( + "\x1b[32m✔\x1b[0m PR auto-tracking disabled. The daemon tears down any managed PR worktrees, refs, synthetic branches and stores on its next poll cycle." + ); + } + } + Ok(()) +} + +async fn resolve_branch_data_root(project_path: &Path) -> PathBuf { + fallback_branch_data_root(project_path) +} + +fn fallback_branch_data_root(project_path: &Path) -> PathBuf { + tracedecay::storage::resolve_layout_for_current_profile(project_path) + .map(|layout| layout.data_root) + .unwrap_or_else(|_| tracedecay::config::get_tracedecay_dir(project_path)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::{parse_daemon_branch_add_outcome, parse_daemon_branch_admin_report}; + + #[test] + fn daemon_branch_add_outcomes_are_strictly_decoded() { + for (name, expected) in [ + ( + "not_indexed", + tracedecay::branch::BranchAddOutcome::NotIndexed, + ), + ( + "already_tracked", + tracedecay::branch::BranchAddOutcome::AlreadyTracked, + ), + ("added", tracedecay::branch::BranchAddOutcome::Added), + ("deferred", tracedecay::branch::BranchAddOutcome::Deferred), + ] { + assert_eq!( + parse_daemon_branch_add_outcome(&serde_json::json!({ "outcome": name })) + .expect("known daemon outcome"), + expected, + ); + } + } + + #[test] + fn daemon_branch_add_response_must_include_known_outcome() { + let missing = parse_daemon_branch_add_outcome(&serde_json::json!({})) + .expect_err("missing outcome must fail closed"); + assert!(missing.to_string().contains("omitted outcome")); + + let unknown = parse_daemon_branch_add_outcome(&serde_json::json!({ "outcome": "other" })) + .expect_err("unknown outcome must fail closed"); + assert!(unknown.to_string().contains("unknown outcome")); + } + + #[test] + fn daemon_branch_admin_report_is_strictly_typed() { + let report = parse_daemon_branch_admin_report(&serde_json::json!({ + "outcome": "removed", + "removed_branches": ["feature/a"], + "removed_orphan_dbs": ["branches/orphan.db"], + "default_branch": "main" + })) + .expect("valid branch admin response"); + assert_eq!( + report.outcome, + tracedecay::branch::BranchAdminOutcome::Removed + ); + assert_eq!(report.removed_branches, vec!["feature/a"]); + assert_eq!( + report.removed_orphan_dbs, + vec![std::path::PathBuf::from("branches/orphan.db")] + ); + assert_eq!(report.default_branch.as_deref(), Some("main")); + } + + #[test] + fn daemon_branch_admin_report_rejects_unknown_or_missing_outcome() { + for response in [ + serde_json::json!({}), + serde_json::json!({ "outcome": "surprise" }), + ] { + let error = parse_daemon_branch_admin_report(&response) + .expect_err("malformed branch admin response must fail closed"); + assert!( + error + .to_string() + .contains("invalid daemon branch administration response") + ); + } + } +} diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs new file mode 100644 index 000000000..b9de00fd6 --- /dev/null +++ b/src/commands/daemon.rs @@ -0,0 +1,30 @@ +pub(crate) async fn daemon_tool_json( + project_path: Option<&std::path::Path>, + tool_name: &str, + arguments: serde_json::Value, +) -> tracedecay::errors::Result { + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + project_path.map(std::path::Path::to_path_buf), + None, + false, + false, + )?; + let result = tracedecay::daemon::call_default_tool(&handshake, tool_name, arguments).await?; + let blocks = result + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no content blocks"), + })?; + for text in blocks + .iter() + .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) + { + if let Ok(value) = serde_json::from_str(text) { + return Ok(value); + } + } + Err(tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no JSON payload"), + }) +} diff --git a/src/commands/gain.rs b/src/commands/gain.rs new file mode 100644 index 000000000..88e09c631 --- /dev/null +++ b/src/commands/gain.rs @@ -0,0 +1,140 @@ +use super::daemon::daemon_tool_json; + +/// Convert raw tokens-saved into a USD estimate using Sonnet input pricing. +/// Sonnet is the default agent target; output-token savings are not relevant +/// for retrieval savings. +/// +/// Pure table lookup: callers that want up-to-date prices must run +/// `pricing::refresh_if_stale()` once beforehand (see [`handle_gain`]). +/// Keeping the refresh out of this function avoids a network fetch per call +/// (it used to fire for every history row and for every unit test process). +pub(crate) fn estimate_dollars_saved(saved_tokens: u64) -> f64 { + use tracedecay::accounting::pricing; + let price = pricing::lookup("claude-sonnet-4") + .map(|p| p.input_per_mtok) + .unwrap_or(3.0); + (saved_tokens as f64) * price / 1_000_000.0 +} + +pub async fn handle_gain( + all: bool, + history: bool, + range: &str, + json_output: bool, +) -> tracedecay::errors::Result<()> { + tracedecay::accounting::pricing::refresh_if_stale(); + let since = tracedecay::accounting::metrics::parse_range(range); + let project_filter: Option = if all { + None + } else { + std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + }; + + let result = daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "gain_query", + "project_arg": project_filter, + "since": since as i64, + "history": history, + }), + ) + .await?; + if history { + let rows = result + .get("history") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .map(|row| tracedecay::global_db::SavingsDay { + day: row + .get("day") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0), + saved_tokens: row + .get("saved_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + calls: row + .get("calls") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + }) + .collect::>(); + if json_output { + let arr: Vec<_> = rows + .iter() + .map(|r| { + serde_json::json!({ + "day": r.day, + "saved_tokens": r.saved_tokens, + "calls": r.calls, + "usd": estimate_dollars_saved(r.saved_tokens), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default()); + } else { + tracedecay::display::print_gain_history(&rows, estimate_dollars_saved); + } + return Ok(()); + } + + let saved_tokens = result + .get("saved_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let calls = result + .get("calls") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let usd = estimate_dollars_saved(saved_tokens); + + if json_output { + let out = serde_json::json!({ + "range": range, + "project": project_filter.clone().unwrap_or_else(|| "ALL".to_string()), + "saved_tokens": saved_tokens, + "calls": calls, + "usd": usd, + }); + println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); + } else { + tracedecay::display::print_gain_total( + project_filter.as_deref().unwrap_or("ALL projects"), + range, + saved_tokens, + calls, + usd, + ); + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::estimate_dollars_saved; + + #[test] + fn dollars_uses_sonnet_input_price_by_default() { + // 1_000_000 tokens × $3 / MTok = $3.00 (Sonnet input price) + let usd = estimate_dollars_saved(1_000_000); + assert!((usd - 3.0).abs() < 0.01, "expected ~$3.00, got ${usd}"); + } + + #[test] + fn dollars_handles_small_counts() { + // 1_000 tokens × $3 / MTok = $0.003 + let usd = estimate_dollars_saved(1_000); + assert!((usd - 0.003).abs() < 0.001); + } + + #[test] + fn dollars_zero_for_zero_tokens() { + assert_eq!(estimate_dollars_saved(0), 0.0); + } +} diff --git a/src/commands/index.rs b/src/commands/index.rs new file mode 100644 index 000000000..466bd89fb --- /dev/null +++ b/src/commands/index.rs @@ -0,0 +1,384 @@ +use std::io::{self, BufRead, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use tracedecay::tracedecay::TraceDecay; + +use super::daemon::daemon_tool_json; + +/// True when the global DB has zero registered projects (or can't be opened +/// at all) — i.e. the user has not run `tracedecay init` anywhere yet. +async fn is_fresh_install() -> bool { + daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ "action": "registry_empty" }), + ) + .await + .ok() + .and_then(|value| value.get("empty").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +/// When invoked with no subcommand, offer to create the index if none exists. +pub(crate) async fn handle_no_command() -> tracedecay::errors::Result<()> { + let project_path = tracedecay::config::resolve_path(None); + if TraceDecay::has_initialized_store(&project_path).await { + // Already initialized — show help via clap + let _ = ::command().print_help(); + eprintln!(); + return Ok(()); + } + if is_fresh_install().await { + eprintln!("\x1b[1;36mWelcome to tracedecay!\x1b[0m"); + eprintln!( + "Looks like a new installation. To get started, run \x1b[1mtracedecay init\x1b[0m \ + in your project root." + ); + eprintln!(); + } + if !io::stdin().is_terminal() { + eprintln!( + "No TraceDecay index found at '{}'. Non-interactive: skipping index creation (run `tracedecay init`).", + project_path.display() + ); + return Ok(()); + } + eprint!( + "No TraceDecay index found at '{}'. Create one now? [Y/n] ", + project_path.display() + ); + io::stderr().flush().ok(); + let mut answer = String::new(); + io::stdin().lock().read_line(&mut answer).map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read stdin: {}", e), + } + })?; + let answer = answer.trim(); + if answer.is_empty() || answer.eq_ignore_ascii_case("y") { + handle_init( + Some(project_path.to_string_lossy().into_owned()), + Vec::new(), + Vec::new(), + ) + .await?; + } + Ok(()) +} + +pub(crate) async fn handle_init( + path: Option, + skip_folders: Vec, + include_folders: Vec, +) -> tracedecay::errors::Result<()> { + let project_path = tracedecay::config::resolve_path(path); + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_path.clone()), + None, + false, + true, + )?; + #[cfg(unix)] + let daemon_available = tracedecay::daemon::daemon_reachable(); + #[cfg(not(unix))] + let daemon_available = true; + + handle_init_with_daemon_availability( + project_path, + skip_folders, + include_folders, + handshake, + daemon_available, + ) + .await +} + +async fn handle_init_with_daemon_availability( + project_path: PathBuf, + skip_folders: Vec, + include_folders: Vec, + handshake: tracedecay::daemon::DaemonHandshake, + daemon_available: bool, +) -> tracedecay::errors::Result<()> { + if daemon_available { + return brokered_init(&project_path, &skip_folders, &include_folders, &handshake).await; + } + + let profile_root = handshake.client_identity.profile_root.clone(); + let lifecycle_lease = match tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "init bootstrap", + ) { + Ok(lease) => lease, + Err(lease_error) => { + // A daemon may have acquired its shared lease after our availability + // probe. Only retry the broker when a fresh, pre-request probe proves + // it is now reachable; otherwise preserve the lifecycle error. + #[cfg(unix)] + if tracedecay::daemon::daemon_reachable() { + return brokered_init(&project_path, &skip_folders, &include_folders, &handshake) + .await; + } + return Err(lease_error); + } + }; + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &lifecycle_lease, + &profile_root, + "init bootstrap", + )?; + + maintenance_bootstrap_init(&project_path, &skip_folders, &include_folders, &handshake).await +} + +async fn brokered_init( + project_path: &Path, + skip_folders: &[String], + include_folders: &[String], + handshake: &tracedecay::daemon::DaemonHandshake, +) -> tracedecay::errors::Result<()> { + if !skip_folders.is_empty() || !include_folders.is_empty() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "brokered init does not yet support --skip-folders/--include-folders; configure tracedecay.toml first".to_string(), + }); + } + tracedecay::daemon::call_default_tool( + handshake, + "tracedecay_status", + serde_json::json!({"format": "json"}), + ) + .await?; + eprintln!( + "initialized and indexed {} via daemon", + project_path.display() + ); + Ok(()) +} + +async fn maintenance_bootstrap_init( + project_path: &Path, + skip_folders: &[String], + include_folders: &[String], + handshake: &tracedecay::daemon::DaemonHandshake, +) -> tracedecay::errors::Result<()> { + if !project_path.is_dir() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "project path is not a directory: {}", + project_path.display() + ), + }); + } + if !project_path.is_absolute() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!("project path must be absolute: {}", project_path.display()), + }); + } + + let open_options = tracedecay::tracedecay::TraceDecayOpenOptions { + profile_root: Some(handshake.client_identity.profile_root.clone()), + global_db_path: Some(handshake.client_identity.global_db_path.clone()), + }; + if TraceDecay::try_initialized_store_layout_with_options(project_path, &open_options) + .await? + .is_some() + { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "TraceDecay is already initialized at '{}'; use `tracedecay sync` to update the index", + project_path.display() + ), + }); + } + + let mut cg = TraceDecay::init_with_options(project_path, open_options).await?; + cg.add_skip_folders(skip_folders); + cg.add_include_folders(include_folders); + if let Err(error) = cg.index_all().await { + cg.close(); + return Err(error); + } + let checkpoint_result = cg.checkpoint().await; + cg.close(); + checkpoint_result?; + eprintln!( + "initialized and indexed {} under exclusive maintenance bootstrap", + project_path.display() + ); + Ok(()) +} + +#[cfg(all(test, unix))] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod init_bootstrap_tests { + use super::*; + + fn test_handshake( + project_path: &Path, + profile_root: &Path, + ) -> tracedecay::daemon::DaemonHandshake { + tracedecay::daemon::DaemonHandshake { + project_path: Some(project_path.to_path_buf()), + scope_prefix: None, + timings: false, + allow_init: true, + allow_initialize_root_routing: false, + client_identity: tracedecay::client_identity::DaemonClientIdentity { + profile_root: profile_root.to_path_buf(), + global_db_path: profile_root.join("global.db"), + }, + client_version: env!("CARGO_PKG_VERSION").to_string(), + client_instance_id: "commands-init-test".to_string(), + tool_list_changed_capable: false, + catalog_version: String::new(), + } + } + + fn checkout_tempdir() -> tempfile::TempDir { + let base = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("commands-init-tests"); + std::fs::create_dir_all(&base).unwrap(); + tempfile::Builder::new() + .prefix("daemonless-init-") + .tempdir_in(base) + .unwrap() + } + + #[tokio::test(flavor = "current_thread")] + async fn daemonless_init_uses_maintenance_authority_and_keeps_direct_open_fail_closed() { + let temp = checkout_tempdir(); + let project = temp.path().join("project"); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::create_dir_all(project.join("ignored")).unwrap(); + std::fs::write(project.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write(project.join("ignored/skip.rs"), "fn skipped() {}\n").unwrap(); + let handshake = test_handshake(&project, &profile); + + handle_init_with_daemon_availability( + project.clone(), + vec!["ignored".to_string()], + vec!["src".to_string()], + handshake.clone(), + false, + ) + .await + .unwrap(); + + let open_options = tracedecay::tracedecay::TraceDecayOpenOptions { + profile_root: Some(profile.clone()), + global_db_path: Some(profile.join("global.db")), + }; + let direct_open_error = + match TraceDecay::open_with_options(&project, open_options.clone()).await { + Ok(cg) => { + cg.close(); + panic!("ordinary direct open unexpectedly acquired writable authority"); + } + Err(error) => error, + }; + assert!( + direct_open_error.to_string().contains( + "database access requires managed-daemon or exclusive-maintenance authority" + ), + "unexpected direct-open error: {direct_open_error}" + ); + + let lifecycle = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &profile, + "inspect daemonless init test", + ) + .unwrap(); + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &lifecycle, + &profile, + "inspect daemonless init test", + ) + .unwrap(); + let cg = TraceDecay::open_with_options(&project, open_options) + .await + .unwrap(); + let files = cg.get_all_files().await.unwrap(); + cg.close(); + assert!( + files.iter().any(|file| file.path.ends_with("src/main.rs")), + "included source file was not indexed: {files:?}" + ); + assert!( + files + .iter() + .all(|file| !file.path.ends_with("ignored/skip.rs")), + "skipped folder was indexed: {files:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn brokered_init_retains_folder_option_error_before_sending_request() { + let temp = checkout_tempdir(); + let project = temp.path().join("project"); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&project).unwrap(); + let handshake = test_handshake(&project, &profile); + + let error = handle_init_with_daemon_availability( + project, + vec!["generated".to_string()], + Vec::new(), + handshake, + true, + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("brokered init does not yet support --skip-folders/--include-folders"), + "unexpected brokered-init error: {error}" + ); + assert!( + !profile.exists(), + "brokered rejection must not open a local store" + ); + } +} + +pub(crate) async fn handle_sync( + path: Option, + force: bool, + skip_folders: Vec, + include_folders: Vec, + doctor: bool, + verbose: bool, +) -> tracedecay::errors::Result<()> { + let project_path = tracedecay::config::resolve_path_with_discovery(path); + if !skip_folders.is_empty() || !include_folders.is_empty() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first".to_string(), + }); + } + let handshake = tracedecay::daemon::DaemonHandshake::for_current_client( + Some(project_path.clone()), + None, + false, + false, + )?; + let result = tracedecay::daemon::call_default_tool( + &handshake, + "tracedecay_admin_sync", + serde_json::json!({"force": force}), + ) + .await?; + if verbose { + eprintln!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() + ); + } + eprintln!("sync completed via daemon for {}", project_path.display()); + if doctor { + tracedecay::doctor::run_doctor(None).await?; + } + Ok(()) +} diff --git a/src/commands/memory.rs b/src/commands/memory.rs new file mode 100644 index 000000000..59a8c2327 --- /dev/null +++ b/src/commands/memory.rs @@ -0,0 +1,65 @@ +use std::io::{self, Read}; + +use crate::cli::MemoryAction; + +use super::daemon::daemon_tool_json; + +pub(crate) async fn handle_memory_action(action: MemoryAction) -> tracedecay::errors::Result<()> { + match action { + MemoryAction::Status { .. } => unreachable!("memory status is handled in main.rs dispatch"), + MemoryAction::Curate { + apply, + llm, + llm_ops, + max_clusters, + min_confidence, + path, + } => { + let project_path = tracedecay::config::resolve_path_with_discovery(path); + let llm_ops_value = match llm_ops { + Some(source) => Some(read_llm_ops_payload(&source)?), + None => None, + }; + let report = daemon_tool_json( + Some(&project_path), + "tracedecay_admin_project", + serde_json::json!({ + "action": "memory_curate", + "apply": apply, + "llm": llm, + "llm_ops": llm_ops_value, + "max_clusters": max_clusters, + "min_confidence": min_confidence, + }), + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&report).unwrap_or_default() + ); + } + } + Ok(()) +} + +/// Reads the `--llm-ops` payload from a file path or stdin (`-`). +fn read_llm_ops_payload(source: &str) -> tracedecay::errors::Result { + let text = if source == "-" { + let mut buf = String::new(); + io::stdin().lock().read_to_string(&mut buf).map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read --llm-ops from stdin: {e}"), + } + })?; + buf + } else { + std::fs::read_to_string(source).map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read --llm-ops file {source}: {e}"), + } + })? + }; + serde_json::from_str(&text).map_err(|e| tracedecay::errors::TraceDecayError::Config { + message: format!("--llm-ops payload is not valid JSON: {e}"), + }) +} diff --git a/src/commands/migrate.rs b/src/commands/migrate.rs new file mode 100644 index 000000000..cb7e58f7e --- /dev/null +++ b/src/commands/migrate.rs @@ -0,0 +1,587 @@ +use std::path::{Path, PathBuf}; + +use crate::cli::MigrateAction; +use crate::global; + +pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay::errors::Result<()> { + match action { + MigrateAction::Consolidate { + project, + source_project_id, + target_project_id, + profile_root, + apply, + confirm_token, + json, + } => { + let profile_root = profile_root.map_or_else( + || { + tracedecay::config::user_data_dir().ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "could not determine TraceDecay profile root".to_string(), + } + }) + }, + |value| Ok(PathBuf::from(value)), + )?; + let options = tracedecay::migrate::consolidate::ConsolidationOptions { + project_root: PathBuf::from(project), + profile_root, + source_project_id, + target_project_id, + }; + let report = if apply { + let token = + confirm_token.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "--confirm-token is required with --apply".to_string(), + })?; + tracedecay::migrate::consolidate::apply(&options, &token).await? + } else { + tracedecay::migrate::consolidate::plan(&options).await? + }; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("Migration: {}", report.migration_id); + println!("State: {:?}", report.state); + println!( + "Source: {} ({})", + report.source.project_id, + report.source.data_root.display() + ); + println!( + "Target: {} ({})", + report.target.project_id, + report.target.data_root.display() + ); + println!( + "Destination: {} ({})", + report.destination_project_id, + report.destination_data_root.display() + ); + println!("Backups: {}", report.backup_root.display()); + println!("Ledger: {}", report.ledger_path.display()); + if report.dry_run { + println!("Confirmation token: {}", report.confirmation_token); + println!("No files changed."); + } + } + } + MigrateAction::Plan { + roots, + include_all_registered, + follow_symlinks, + manifest, + save, + profile_root, + project_id, + json, + } => { + let scan_roots = if roots.is_empty() { + vec![std::env::current_dir().map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("could not determine current directory: {e}"), + } + })?] + } else { + roots.into_iter().map(PathBuf::from).collect() + }; + let report = tracedecay::migrate::inventory::build_inventory( + tracedecay::migrate::inventory::MigrationInventoryOptions { + roots: scan_roots, + follow_symlinks, + include_all_registered, + ..tracedecay::migrate::inventory::MigrationInventoryOptions::default() + }, + ) + .await?; + if manifest.is_some() || save { + let migration_id = format!("mig_{}", tracedecay::tracedecay::current_timestamp()); + let profile_root = + profile_root.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "--profile-root is required when saving a manifest".to_string(), + })?; + let project_id = + project_id.ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "--project-id is required when saving a manifest".to_string(), + })?; + let manifest_path = manifest.map(PathBuf::from).unwrap_or_else(|| { + PathBuf::from(&profile_root) + .join("migration-inventory") + .join(format!("{migration_id}.json")) + }); + let confirmation_token = format!("confirm-{migration_id}"); + let manifest = tracedecay::migrate::manifest::build_plan_manifest( + report, + tracedecay::migrate::manifest::MigrationPlanOptions { + manifest_path, + migration_id, + tracedecay_version: env!("CARGO_PKG_VERSION").to_string(), + created_at_unix: tracedecay::tracedecay::current_timestamp(), + confirmation_token, + target_profile_root: PathBuf::from(profile_root), + project_id, + }, + ) + .map_err(|message| tracedecay::errors::TraceDecayError::Config { message })?; + tracedecay::migrate::manifest::save_manifest(&manifest)?; + if json { + println!("{}", serde_json::to_string_pretty(&manifest)?); + } else { + println!( + "migration manifest: {} ({} artifact(s))", + manifest.protocol.manifest_path.display(), + manifest.artifacts.len() + ); + println!("confirmation token: {}", manifest.confirmation_token); + } + } else if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "migration inventory: {} store(s), {} skipped path(s)", + report.stores.len(), + report.skipped.len() + ); + if let Some(global) = report.global_db { + println!( + "global db: {} (projects: {}, sessions: {})", + global.path.display(), + global.project_count, + global.session_count + ); + } + } + } + MigrateAction::Export { + from_profile: _, + project, + project_id, + to, + } => { + let project_id = match project_id { + Some(project_id) => project_id, + None => { + let project_root = + project + .map(PathBuf::from) + .unwrap_or(std::env::current_dir().map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("could not determine current directory: {e}"), + } + })?); + let marker = tracedecay::storage::read_enrollment_marker(&project_root)? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!( + "project '{}' is not enrolled in profile-sharded storage", + project_root.display() + ), + })?; + marker.project_id + } + }; + let profile_root = tracedecay::storage::default_profile_root()?; + let report = tracedecay::migrate::manifest::export_profile_store( + &profile_root, + &project_id, + &PathBuf::from(to), + ) + .map_err(|err| tracedecay::errors::TraceDecayError::Config { + message: err.to_string(), + })?; + println!( + "migration export: {} artifact(s) from {} to {}", + report.artifact_count, + report.source_data_root.display(), + report.target_dir.display() + ); + } + MigrateAction::Apply { + manifest, + confirm_token, + } => { + let mut manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; + if manifest.confirmation_token != confirm_token { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "confirmation token does not match migration manifest".to_string(), + }); + } + let target_profile_root = + manifest.destination.profile_root.clone().ok_or_else(|| { + tracedecay::errors::TraceDecayError::Config { + message: "migration manifest has no destination profile_root".to_string(), + } + })?; + let _lifecycle_lease = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &target_profile_root, + "legacy store migration", + )?; + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &_lifecycle_lease, + &target_profile_root, + "legacy store migration", + )?; + let apply_report = tracedecay::migrate::manifest::apply_migration_manifest( + &mut manifest, + ) + .map_err(|err| tracedecay::errors::TraceDecayError::Config { + message: err.to_string(), + })?; + let verify_report = tracedecay::migrate::manifest::verify_migration_manifest(&manifest); + if !verify_report.cutover_ready { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "migration staging did not reach cutover-ready state: {} missing target(s), {} issue(s)", + verify_report.missing_targets, + verify_report.issues.len() + ), + }); + } + let global_db = tracedecay::global_db::GlobalDb::try_open_at( + &apply_report.profile_root.join("global.db"), + ) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "could not open global DB for migrate apply".to_string(), + })?; + let registry_report = + tracedecay::migrate::registry::apply_single_registry_reconstruction_report( + &global_db, + &verify_report.registry_reconstruction, + ) + .await + .map_err(|issues| tracedecay::errors::TraceDecayError::Config { + message: format!( + "failed to apply registry reconstruction: {}", + issues.join("; ") + ), + })?; + tracedecay::storage::write_enrollment_marker( + &apply_report.project_root, + &tracedecay::storage::EnrollmentMarker { + project_id: apply_report.project_id.clone(), + storage_mode: tracedecay::storage::StorageMode::ProfileSharded, + }, + )?; + if let Err(err) = tracedecay::migrate::manifest::finalize_migration_apply(&mut manifest) + { + let _ = tracedecay::storage::remove_enrollment_marker( + &apply_report.project_root, + &apply_report.project_id, + ); + return Err(tracedecay::errors::TraceDecayError::Config { + message: err.to_string(), + }); + } + tracedecay::migrate::manifest::save_manifest(&manifest)?; + println!( + "migration apply: {} artifact(s), {} registry project(s), {} alias(es)", + apply_report.artifact_count, registry_report.projects, registry_report.aliases + ); + } + MigrateAction::Verify { manifest, json } => { + let manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; + let report = tracedecay::migrate::manifest::verify_migration_manifest(&manifest); + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "migration verify: {} artifact(s), {} planned target(s), {} missing target(s)", + report.artifact_count, report.planned_targets, report.missing_targets + ); + println!( + "registry reconstruction: {} plan(s), {} store manifest(s), {} issue(s)", + report.registry_plan_count, + report.store_manifest_count, + report.issues.len() + ); + println!( + "cutover ready: {}", + if report.cutover_ready { "yes" } else { "no" } + ); + println!( + "apply supported: {}", + if report.apply_supported { "yes" } else { "no" } + ); + } + } + MigrateAction::Reconstruct { + profile_root, + apply, + json, + } => { + let profile_root = PathBuf::from(profile_root); + if apply { + let projects_root = profile_root.join("projects"); + std::fs::read_dir(&projects_root).map_err(|error| { + tracedecay::errors::TraceDecayError::Config { + message: format!( + "could not read profile projects directory '{}': {error}", + projects_root.display() + ), + } + })?; + } + let _lifecycle_lease = apply + .then(|| { + tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "registry reconstruction", + ) + }) + .transpose()?; + let _database_scope = _lifecycle_lease + .as_ref() + .map(|lifecycle_lease| { + tracedecay::db::enter_maintenance_database_scope( + lifecycle_lease, + &profile_root, + "registry reconstruction", + ) + }) + .transpose()?; + let report = tracedecay::migrate::registry::scan_profile_store_manifests( + &profile_root, + tracedecay::tracedecay::current_timestamp(), + ); + if apply { + let mut blockers = report.issues.clone(); + blockers.extend( + report + .plans + .iter() + .filter(|plan| { + plan.status + == tracedecay::migrate::registry::RegistryReconstructionStatus::Blocked + }) + .map(|plan| { + format!( + "blocked manifest '{}': {}", + plan.manifest_path.display(), + plan.status_reason.as_deref().unwrap_or("not eligible") + ) + }), + ); + if !blockers.is_empty() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "failed to preflight registry reconstruction: {}", + blockers.join("; ") + ), + }); + } + let global_db = + tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "could not open global DB for registry reconstruction" + .to_string(), + })?; + let applied = tracedecay::migrate::registry::apply_registry_reconstruction_report( + &global_db, &report, + ) + .await + .map_err(|issues| tracedecay::errors::TraceDecayError::Config { + message: format!( + "failed to apply registry reconstruction: {}", + issues.join("; ") + ), + })?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "dry_run": report, + "applied": applied, + }))? + ); + } else { + println!( + "registry reconstruction applied: {} project(s), {} alias(es), {} store(s), {} graph scope(s), {} artifact(s)", + applied.projects, + applied.aliases, + applied.stores, + applied.graph_scopes, + applied.artifacts + ); + } + } else if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + use tracedecay::migrate::registry::RegistryReconstructionStatus; + let eligible = report.status_count(RegistryReconstructionStatus::Eligible); + let blocked = report.status_count(RegistryReconstructionStatus::Blocked); + let stale = report.status_count(RegistryReconstructionStatus::Stale); + let retired = report.status_count(RegistryReconstructionStatus::Retired); + println!( + "registry reconstruction: {} eligible, {} blocked, {} stale, {} retired, {} issue(s)", + eligible, + blocked, + stale, + retired, + report.issues.len() + ); + println!( + "apply supported: {} (atomic batch; skips stale/retired, inserts eligible missing rows only, fails on blocked/invalid/conflict)", + if blocked == 0 && report.issues.is_empty() { + "yes" + } else { + "no" + } + ); + } + } + MigrateAction::RegistryGc { + prefix, + apply, + json, + } => { + let profile_root = tracedecay::storage::default_profile_root()?; + let lifecycle_lease = tracedecay::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "registry cleanup", + )?; + let _database_scope = tracedecay::db::enter_maintenance_database_scope( + &lifecycle_lease, + &profile_root, + "registry cleanup", + )?; + let global_db = + tracedecay::global_db::GlobalDb::try_open_at(&profile_root.join("global.db")) + .await? + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "could not open global DB for registry cleanup".to_string(), + })?; + let projects = global_db.list_code_projects(usize::MAX).await; + let prefixes: Vec = prefix.iter().map(PathBuf::from).collect(); + let stale = tracedecay::migrate::registry::stale_code_projects( + &projects, + &prefixes, + tracedecay::migrate::registry::StaleRootScope::CanonicalRootMissing, + ); + let mut stale_storage_projects = Vec::new(); + for project_path in global_db.list_project_paths().await { + let path = Path::new(&project_path); + if !prefixes.is_empty() && !prefixes.iter().any(|prefix| path.starts_with(prefix)) { + continue; + } + let location = global::classify_project_storage_with_registry( + path, + Some(&global_db), + Some(&profile_root), + ) + .await; + if location.status == global::ProjectStorageStatus::Stale { + stale_storage_projects.push(project_path); + } + } + let (deleted_code_projects, deleted_storage_projects) = if apply { + let project_ids: Vec = stale + .iter() + .map(|project| project.project_id.clone()) + .collect(); + ( + global_db.delete_code_projects(&project_ids).await, + global_db.delete_projects(&stale_storage_projects).await, + ) + } else { + (0, 0) + }; + let candidate_paths = stale + .iter() + .map(|project| { + tracedecay::global_db::GlobalDb::canonical_project_key(Path::new( + &project.canonical_root, + )) + }) + .chain(stale_storage_projects.iter().map(|path| { + tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(path)) + })) + .collect::>(); + let candidate_count = candidate_paths.len(); + let metadata_candidate_count = stale.len() + stale_storage_projects.len(); + let deleted_count = deleted_code_projects + deleted_storage_projects; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "apply": apply, + "prefix": prefix, + "candidate_count": candidate_count, + "metadata_candidate_count": metadata_candidate_count, + "code_project_candidate_count": stale.len(), + "storage_project_candidate_count": stale_storage_projects.len(), + "deleted_count": deleted_count, + "deleted_code_project_count": deleted_code_projects, + "deleted_storage_project_count": deleted_storage_projects, + "candidates": stale, + "storage_project_candidates": stale_storage_projects, + }))? + ); + } else { + println!( + "registry-gc: {} stale project(s){}", + candidate_count, + if apply { " selected" } else { " found" } + ); + if apply { + println!( + "metadata rows deleted: {deleted_count} ({deleted_code_projects} identity, {deleted_storage_projects} storage)" + ); + } else { + println!("dry run: re-run with --apply to delete registry metadata"); + } + for project_path in candidate_paths.iter().take(20) { + println!("{project_path}"); + } + if candidate_count > 20 { + println!("... {} more", candidate_count - 20); + } + } + } + MigrateAction::Rollback { + manifest, + confirm_token, + } => { + let mut manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; + if manifest.confirmation_token != confirm_token { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "confirmation token does not match migration manifest".to_string(), + }); + } + let rollback_report = tracedecay::migrate::manifest::rollback_migration_manifest( + &mut manifest, + ) + .map_err(|err| tracedecay::errors::TraceDecayError::Config { + message: err.to_string(), + })?; + tracedecay::migrate::manifest::save_manifest(&manifest)?; + println!( + "migration rollback: {} artifact(s)", + rollback_report.artifact_count + ); + } + MigrateAction::CleanupSources { + manifest, + confirm_token, + } => { + let manifest = tracedecay::migrate::manifest::load_manifest(manifest)?; + if manifest.confirmation_token != confirm_token { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "confirmation token does not match migration manifest".to_string(), + }); + } + let cleanup_report = tracedecay::migrate::manifest::cleanup_migration_sources( + &manifest, + ) + .map_err(|err| tracedecay::errors::TraceDecayError::Config { + message: err.to_string(), + })?; + println!( + "migration cleanup-sources: {} source artifact(s) removed", + cleanup_report.removed_artifacts + ); + } + } + Ok(()) +} diff --git a/src/commands/settings.rs b/src/commands/settings.rs new file mode 100644 index 000000000..5d289b76e --- /dev/null +++ b/src/commands/settings.rs @@ -0,0 +1,51 @@ +pub(crate) fn handle_upload_counter(enable: bool) { + let mut config = tracedecay::user_config::UserConfig::load(); + config.upload_enabled = enable; + match config.save_with_recovery() { + Ok(Some(backup)) => eprintln!( + "note: corrupt config.toml backed up to {} before regenerating", + backup.display() + ), + Ok(None) => {} + Err(err) => eprintln!("warning: could not save tracedecay config: {err}"), + } + if enable { + eprintln!("Worldwide counter upload enabled."); + } else { + eprintln!( + "Worldwide counter upload disabled. You can re-enable with `tracedecay enable-upload-counter`." + ); + } +} + +pub(crate) async fn handle_gitignore( + path: Option, + action: Option, +) -> tracedecay::errors::Result<()> { + let project_path = tracedecay::config::resolve_path(path); + let mut config = tracedecay::config::load_config_with_identity(&project_path).await?; + match action.as_deref() { + Some("on") => { + config.git_ignore = true; + tracedecay::config::save_config_with_identity(&project_path, &config).await?; + eprintln!("gitignore enabled — .gitignore rules will be respected during indexing."); + eprintln!("Run `tracedecay sync` to re-index with the new setting."); + } + Some("off") => { + config.git_ignore = false; + tracedecay::config::save_config_with_identity(&project_path, &config).await?; + eprintln!("gitignore disabled — .gitignore rules will be ignored during indexing."); + eprintln!("Run `tracedecay sync` to re-index with the new setting."); + } + Some(other) => { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!("unknown action '{other}': expected 'on' or 'off'"), + }); + } + None => { + let status = if config.git_ignore { "on" } else { "off" }; + eprintln!("gitignore: {status}"); + } + } + Ok(()) +} diff --git a/src/commands/storage.rs b/src/commands/storage.rs new file mode 100644 index 000000000..d27a2e3d1 --- /dev/null +++ b/src/commands/storage.rs @@ -0,0 +1,351 @@ +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use crate::global; + +use super::daemon::daemon_tool_json; + +async fn strict_registered_project_paths( + gdb: &tracedecay::global_db::GlobalDb, +) -> tracedecay::errors::Result> { + let projects = gdb.try_list_code_projects(usize::MAX).await?; + projects + .iter() + .enumerate() + .map(|(index, project)| strict_registered_project_path(&project.display_root, index)) + .collect() +} + +fn strict_registered_project_path( + project_root: &str, + index: usize, +) -> tracedecay::errors::Result { + let path = PathBuf::from(project_root); + if project_root.is_empty() || !path.is_absolute() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "global registry project at index {index} has an invalid display_root: {project_root:?}" + ), + }); + } + Ok(path) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod wipe_target_tests { + use super::*; + + #[test] + fn registered_project_path_preserves_an_absolute_root() { + let root = std::env::current_dir().expect("test process must have a working directory"); + let path = strict_registered_project_path(&root.to_string_lossy(), 0) + .expect("absolute registry roots are valid"); + assert_eq!(path, root); + } + + #[test] + fn registered_project_path_rejects_malformed_roots() { + for root in ["", "relative/project"] { + let error = strict_registered_project_path(root, 7) + .expect_err("a malformed registry root must abort wipe discovery"); + assert!( + error + .to_string() + .contains("global registry project at index 7"), + "unexpected error: {error}" + ); + } + } +} + +/// Handles the `wipe` and `wipe --all` commands. +pub(crate) async fn handle_wipe(all: bool) -> tracedecay::errors::Result<()> { + use std::fs; + let profile_root = tracedecay::storage::default_profile_root()?; + let home_tracedecay = Some(profile_root.clone()); + let lifecycle_lease = + tracedecay::lifecycle_lease::acquire_exclusive_for_profile(&profile_root, "wipe")?; + let _database_scope = + tracedecay::db::enter_maintenance_database_scope(&lifecycle_lease, &profile_root, "wipe")?; + let gdb = tracedecay::global_db::GlobalDb::try_open().await?; + + // `--all` must enumerate the registry only after exclusive maintenance + // ownership is active. A strict local query avoids both the daemon + // discovery-to-lease race and any failure-to-empty fallback before global.db + // is eligible for removal. + let project_paths = if all { + match gdb.as_ref() { + Some(gdb) => strict_registered_project_paths(gdb).await?, + None => Vec::new(), + } + } else { + global::gather_target_projects(false, &home_tracedecay).await? + }; + let mut targets = Vec::new(); + for path in &project_paths { + let location = global::classify_project_storage_with_registry( + path, + gdb.as_ref(), + home_tracedecay.as_deref(), + ) + .await; + if location.status.is_live() { + targets.push(location); + } + } + + if !all && targets.is_empty() { + eprintln!("No tracedecay projects found in current folder, parents, or children."); + return Ok(()); + } + + global::print_flash_warning(all, &targets); + + eprint!("Type \x1b[1;32mgo!\x1b[0m to confirm (anything else aborts): "); + io::stderr().flush().ok(); + let mut answer = String::new(); + io::stdin().lock().read_line(&mut answer).map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: format!("failed to read stdin: {e}"), + } + })?; + if answer.trim() != "go!" { + eprintln!("\x1b[33mAborted — nothing was wiped.\x1b[0m"); + return Ok(()); + } + + let mut removed = 0usize; + let mut errors = 0usize; + let mut wiped_paths: Vec = Vec::new(); + + for location in &targets { + if !location.data_root.exists() { + continue; + } + match fs::remove_dir_all(&location.data_root) { + Ok(()) => { + removed += 1; + wiped_paths.push(location.project_root.clone()); + eprintln!( + " \x1b[32m✔\x1b[0m removed {}", + location.data_root.display() + ); + if let Some(marker_root) = &location.marker_root { + let _ = fs::remove_dir_all(marker_root); + } + } + Err(e) => { + errors += 1; + eprintln!(" \x1b[31m✗\x1b[0m {} ({e})", location.data_root.display()); + } + } + } + + if all { + drop(gdb); + if let Some(global_dir) = home_tracedecay.as_ref() { + for ext in ["db", "db-wal", "db-shm"] { + let p = global_dir.join(format!("global.{ext}")); + let _ = fs::remove_file(&p); + } + eprintln!( + " \x1b[32m✔\x1b[0m emptied global DB at {}/global.db", + global_dir.display() + ); + } + } else if !wiped_paths.is_empty() { + if let Some(gdb) = gdb.as_ref() { + let path_strs: Vec = wiped_paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + gdb.delete_projects(&path_strs).await; + } + } + + eprintln!(); + let suffix = if errors > 0 { + format!(" ({errors} error(s))") + } else { + String::new() + }; + eprintln!("\x1b[32mWiped {removed} project(s){suffix}.\x1b[0m"); + Ok(()) +} + +/// Handles the `list` and `list --all` commands. +pub(crate) async fn handle_list(all: bool) -> tracedecay::errors::Result<()> { + use tracedecay::display::format_token_count; + + let home_tracedecay = tracedecay::config::user_data_dir(); + let project_paths = global::gather_target_projects(all, &home_tracedecay).await?; + + if !all && project_paths.is_empty() { + println!("No tracedecay projects found in current folder, parents, or children."); + return Ok(()); + } + + let token_result = daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "registry_project_tokens", + "project_args": &project_paths, + }), + ) + .await?; + let token_rows = token_result + .get("projects") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let mut rows: Vec = Vec::with_capacity(project_paths.len()); + let mut total_size: u64 = 0; + let mut total_tokens: u64 = 0; + + for path in &project_paths { + let location = + global::classify_project_storage_with_registry(path, None, home_tracedecay.as_deref()) + .await; + let has_data = location.data_root.exists(); + let size = if has_data { + global::tracedecay_dir_size(&location.data_root) + } else { + 0 + }; + let project_key = tracedecay::global_db::GlobalDb::canonical_project_key(path); + let tokens = token_rows + .iter() + .find(|row| { + row.get("project") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| { + tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(value)) + == project_key + }) + }) + .and_then(|row| row.get("tokens")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + total_size = total_size.saturating_add(size); + total_tokens = total_tokens.saturating_add(tokens); + rows.push(ListRow { + path: path.clone(), + status_label: location.status.label(), + has_data, + size, + tokens, + }); + } + + if all { + append_orphan_manifest_rows(&mut rows, &project_paths, home_tracedecay.as_deref()); + } + + if rows.is_empty() { + println!("No tracedecay projects tracked in the global DB."); + return Ok(()); + } + + total_size = rows.iter().map(|row| row.size).sum(); + total_tokens = rows.iter().map(|row| row.tokens).sum(); + + rows.sort_by(|a, b| b.tokens.cmp(&a.tokens).then_with(|| a.path.cmp(&b.path))); + + let path_w = rows + .iter() + .map(|r| { + format!("{} [{}]", r.path.display(), r.status_label) + .chars() + .count() + }) + .max() + .unwrap_or(0); + + println!("Found {} tracedecay project(s):", rows.len()); + println!(); + for r in &rows { + let path_str = format!("{} [{}]", r.path.display(), r.status_label); + let pad = path_w.saturating_sub(path_str.chars().count()); + let size_str = if r.has_data { + tracedecay::display::format_bytes(r.size) + } else { + "—".to_string() + }; + let tokens_str = if r.tokens == 0 { + "—".to_string() + } else { + format_token_count(r.tokens) + }; + println!( + " {path_str}{pad} {size:>10} {tokens:>10} tokens", + pad = " ".repeat(pad), + size = size_str, + tokens = tokens_str + ); + } + println!(); + let total_tokens_str = if total_tokens == 0 { + "—".to_string() + } else { + format_token_count(total_tokens) + }; + println!( + "Total: {} on disk · {} tokens saved", + tracedecay::display::format_bytes(total_size), + total_tokens_str + ); + Ok(()) +} + +#[derive(Debug)] +struct ListRow { + path: std::path::PathBuf, + status_label: &'static str, + has_data: bool, + size: u64, + tokens: u64, +} + +fn append_orphan_manifest_rows( + rows: &mut Vec, + project_paths: &[std::path::PathBuf], + profile_root: Option<&Path>, +) { + let Some(profile_root) = profile_root else { + return; + }; + let registered: std::collections::HashSet = project_paths + .iter() + .map(|path| tracedecay::global_db::GlobalDb::canonical_project_key(path)) + .collect(); + let report = tracedecay::migrate::registry::scan_profile_store_manifests( + profile_root, + tracedecay::tracedecay::current_timestamp(), + ); + for plan in report.plans { + if plan.status != tracedecay::migrate::registry::RegistryReconstructionStatus::Eligible { + continue; + } + let key = + tracedecay::global_db::GlobalDb::canonical_project_key(&plan.project.project_root); + if registered.contains(&key) { + continue; + } + let data_root = profile_root.join(&plan.store.store_relpath); + let has_data = data_root.exists(); + let size = if has_data { + global::tracedecay_dir_size(&data_root) + } else { + 0 + }; + rows.push(ListRow { + path: plan.project.project_root, + status_label: "orphan manifest-reconstructable", + has_data, + size, + tokens: 0, + }); + } +} diff --git a/src/daemon.rs b/src/daemon.rs index 810dc9c3a..de8a81d37 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -11,15 +11,23 @@ use serde_json::json; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; #[cfg(unix)] use tokio::net::UnixStream; -#[cfg(unix)] -use tokio::task::JoinHandle; -use tokio::task::JoinSet; +use tokio::task::{JoinHandle, JoinSet}; use tokio::time::{Duration, timeout}; use crate::client_identity::DaemonClientIdentity; use crate::errors::{Result, TraceDecayError}; use crate::mcp::ReplayTransport; use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport, StdioTransport}; +use branch_add::{branch_add_response, coordinated_hook_branch_writer, parse_branch_add_request}; +use branch_admin::{StoreAdministration, parse_branch_admin_request, write_branch_admin_response}; +#[cfg(unix)] +use scheduler::AutomationSchedulerHandle; +#[cfg(all(unix, test))] +use scheduler::{ + automation_scheduler_configured, automation_scheduler_tick_secs_for_project, + automation_staged_log_fields, daemon_scheduler_record_log_line, run_automation_scheduler_tick, + scheduler_task_log_fields, user_config_for_client, +}; use transport::{BrokerListener, BrokerStream, DaemonAuthPreface, DaemonEndpoint}; pub const SERVICE_NAME: &str = "tracedecay.service"; @@ -32,6 +40,22 @@ const MAX_CATALOG_REFRESH_CLIENTS_PER_GENERATION: usize = 1_024; const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); const DAEMON_TOOL_LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5); const DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); + +fn coordinated_background_refresh_writer( + administration: StoreAdministration, +) -> crate::mcp::server::BackgroundRefreshWriter { + Arc::new(move |request| { + let administration = administration.clone(); + Box::pin(async move { + administration + .with_writer(|| async move { + crate::mcp::server::execute_background_refresh_direct(request).await + }) + .await + }) + }) +} + /// Upper bound on graceful-shutdown persistence work (per-server token /// persistence and WAL checkpoints). Must stay comfortably below systemd's /// stop timeout (90s by default) so the daemon exits cleanly instead of @@ -119,10 +143,14 @@ impl Drop for DaemonActivity { } mod authority; +mod branch_add; +mod branch_admin; #[cfg(unix)] mod git_watch; #[cfg(unix)] pub mod pr_autotrack; +#[cfg(unix)] +mod scheduler; mod service; pub(crate) mod transport; pub use service::{ @@ -685,149 +713,6 @@ fn read_daemon_log_tail(max_lines: usize) -> String { } } -#[cfg(unix)] -fn scheduler_task_log_fields( - project_path: &Path, - task: crate::automation::backend::AgentTaskKind, - outcome: &str, -) -> Vec<(&'static str, String)> { - vec![ - ("project", project_path.display().to_string()), - ( - "task", - crate::automation::backend::task_key(task).to_string(), - ), - ("outcome", outcome.to_string()), - ] -} - -#[cfg(unix)] -fn log_scheduler_task_start(project_path: &Path, task: crate::automation::backend::AgentTaskKind) { - log_daemon_event( - "scheduler_task", - &scheduler_task_log_fields(project_path, task, "start"), - ); -} - -#[cfg(unix)] -fn scheduler_task_error_log_fields( - project_path: &Path, - task: crate::automation::backend::AgentTaskKind, - error: &TraceDecayError, -) -> Vec<(&'static str, String)> { - vec![ - ("project", project_path.display().to_string()), - ( - "task", - crate::automation::backend::task_key(task).to_string(), - ), - ("error", error.to_string()), - ] -} - -#[cfg(unix)] -fn log_scheduler_task_error( - project_path: &Path, - task: crate::automation::backend::AgentTaskKind, - error: &TraceDecayError, -) { - log_daemon_event( - "scheduler_task_error", - &scheduler_task_error_log_fields(project_path, task, error), - ); -} - -#[cfg(unix)] -fn scheduler_record_log_fields( - project_path: &Path, - record: &crate::automation::run_ledger::AutomationRunLedgerRecord, -) -> Vec<(&'static str, String)> { - use crate::automation::run_ledger::AutomationRunStatus; - - let outcome = match record.status { - AutomationRunStatus::Succeeded => "complete", - AutomationRunStatus::Failed => "error", - AutomationRunStatus::Skipped => "skipped", - AutomationRunStatus::Queued => "queued", - AutomationRunStatus::Running => "running", - }; - let task = record - .task_key - .as_deref() - .unwrap_or_else(|| crate::automation::backend::task_key(record.task)) - .to_string(); - let mut fields = vec![ - ("project", project_path.display().to_string()), - ("task", task), - ("outcome", outcome.to_string()), - ("run_id", record.run_id.clone()), - ]; - if let Some(reason) = record.fallback_status.as_ref().or(record.error.as_ref()) { - fields.push(("reason", reason.clone())); - } - fields -} - -#[cfg(all(unix, test))] -fn daemon_scheduler_record_log_line( - project_path: &Path, - record: &crate::automation::run_ledger::AutomationRunLedgerRecord, -) -> String { - format_daemon_log_line( - "scheduler_task", - &scheduler_record_log_fields(project_path, record), - ) -} - -#[cfg(unix)] -fn log_daemon_scheduler_record( - project_path: &Path, - record: &crate::automation::run_ledger::AutomationRunLedgerRecord, -) { - log_daemon_event( - "scheduler_task", - &scheduler_record_log_fields(project_path, record), - ); -} - -#[cfg(unix)] -fn automation_staged_log_fields( - project_path: &Path, - counts: crate::automation::staged_notice::AutomationPendingCounts, -) -> Vec<(&'static str, String)> { - vec![ - ("project", project_path.display().to_string()), - ( - "pending_fact_proposals", - counts.pending_fact_proposals.to_string(), - ), - ("pending_skills", counts.pending_skills.to_string()), - ] -} - -/// After a scheduler tick where at least one task completed, emit a stable -/// `event=automation_staged` line with managed-skill review counts plus fact -/// proposal telemetry. -/// Silent when nothing is pending or the profile root is unavailable. -#[cfg(unix)] -async fn log_automation_staged_if_pending(project_path: &Path, dashboard_root: &Path) { - let Ok(profile_root) = crate::storage::default_profile_root() else { - return; - }; - let counts = crate::automation::staged_notice::count_pending_automation_output( - dashboard_root, - &profile_root, - ) - .await; - if counts.total() == 0 { - return; - } - log_daemon_event( - "automation_staged", - &automation_staged_log_fields(project_path, counts), - ); -} - pub fn unavailable_error(socket_path: &Path) -> TraceDecayError { TraceDecayError::Config { message: format!( @@ -972,9 +857,10 @@ fn default_available_socket_path() -> Result { #[cfg(unix)] { if socket_path.exists() { - return Ok(socket_path); + Ok(socket_path) + } else { + Err(unavailable_error(&socket_path)) } - return Err(unavailable_error(&socket_path)); } #[cfg(not(unix))] { @@ -1121,7 +1007,7 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { log_daemon_event("daemon_listening", &[("endpoint", endpoint.to_string())]); let lifecycle = DaemonLifecycle::default(); - let project_servers = Arc::new(tokio::sync::Mutex::new(DatabaseOwnerRegistry::default())); + let store_administration = StoreAdministration::default(); let project_open_gates = Arc::new(tokio::sync::Mutex::new(ProjectOpenGates::default())); let mut clients: JoinSet> = JoinSet::new(); loop { @@ -1137,14 +1023,14 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { }; let auth_token = authority.auth_token().to_string(); let client_lifecycle = lifecycle.clone(); - let project_servers = Arc::clone(&project_servers); + let store_administration = store_administration.clone(); let project_open_gates = Arc::clone(&project_open_gates); clients.spawn(async move { serve_windows_broker_client( stream, &auth_token, &client_lifecycle, - project_servers, + store_administration, project_open_gates, #[cfg(test)] None, @@ -1671,23 +1557,31 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { prepare_socket_path(&authority).await?; let (listener, bound_endpoint) = BrokerListener::bind(authority.endpoint()).await?; - authority.publish_endpoint(bound_endpoint.clone())?; + authority.publish_endpoint(&bound_endpoint)?; set_owner_only_permissions(&socket_path, 0o600)?; log_daemon_event( "daemon_listening", &[("endpoint", bound_endpoint.to_string())], ); + let engine = DaemonEngine::default(); // Install the git-metadata watcher (design D3/D5). The daemon has no single // project root, so it uses the default `[sync]` config plus env overrides. - // When `auto_watch` is off the watcher is inert. - let git_watcher = - git_watch::GitWatcher::new(crate::config::SyncConfig::default().with_env_overrides()); + // When `auto_watch` is off the watcher is inert. The watcher shares the + // engine's administration coordinator before it can spawn any writer. + let git_watcher = git_watch::GitWatcher::new_with_administration( + crate::config::SyncConfig::default().with_env_overrides(), + engine.store_administration.clone(), + profile_root.clone(), + ); git_watcher.spawn(crate::global_db::global_db_path()).await; // PR-branch auto-tracking runs independently of the metadata watcher: it is // gated per-project on `sync.auto_track_pr_branches` (default off), so this // loop is inert unless a project opts in. - let pr_autotrack_task = pr_autotrack::spawn(crate::global_db::global_db_path()); - let engine = DaemonEngine::default() + let pr_autotrack_task = pr_autotrack::spawn_with_administration( + crate::global_db::global_db_path(), + engine.store_administration.clone(), + ); + let engine = engine .with_git_watcher(git_watcher) .with_pr_autotrack_task(pr_autotrack_task) .await; @@ -1870,16 +1764,14 @@ async fn prepare_socket_path(authority: &authority::DaemonAuthority) -> Result<( #[derive(Clone, Default)] struct DaemonEngine { lifecycle: DaemonLifecycle, - /// Shared daemon state, partitioned by the client-scoped project server key. - project_servers: Arc>, + /// One coordinator owns the project-server registry, scheduler registry, + /// and the writer gate that orders all mutations of either identity map. + store_administration: StoreAdministration, /// Per-canonical-route singleflight gates. Weak entries disappear after /// the last waiter, so failed opens are never cached. project_open_gates: Arc>, #[cfg(test)] project_open_attempts: Arc, - /// Background automation loops, partitioned with the same client/project identity as MCP state. - automation_schedulers: - Arc>>, /// Client versions whose skew was already logged. Proxy clients reconnect /// per request, so without this the mismatch would flood the daemon log. logged_client_version_skews: Arc>>, @@ -1897,12 +1789,6 @@ struct DaemonEngine { pr_autotrack_task: Arc>>>, } -#[cfg(unix)] -struct AutomationSchedulerHandle { - task: JoinHandle<()>, - wake: Arc, -} - #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct ProjectServerKey { owner: StoreOwnerKey, @@ -1937,7 +1823,6 @@ type ProjectOpenGates = HashMap> { servers: HashMap, - routes: HashMap>, aliases: HashMap, } @@ -1945,7 +1830,6 @@ impl Default for DatabaseOwnerRegistry { fn default() -> Self { Self { servers: HashMap::new(), - routes: HashMap::new(), aliases: HashMap::new(), } } @@ -1957,10 +1841,6 @@ impl DatabaseOwnerRegistry { } fn insert(&mut self, key: ProjectServerKey, server: Server) { - self.routes - .entry(key.owner.clone()) - .or_default() - .insert(key.clone()); self.servers.insert(key, server); } @@ -1996,28 +1876,17 @@ impl DatabaseOwnerRegistry { (candidate, true) } - fn rekey(&mut self, old: &ProjectServerKey, new: ProjectServerKey) -> bool { - if old == &new { + fn rekey(&mut self, old: &ProjectServerKey, new: &ProjectServerKey) -> bool { + if old == new { return true; } let Some(server) = self.servers.remove(old) else { return false; }; - let remove_owner = self.routes.get_mut(&old.owner).is_some_and(|routes| { - routes.remove(old); - routes.is_empty() - }); - if remove_owner { - self.routes.remove(&old.owner); - } - if self.servers.contains_key(&new) { + if self.servers.contains_key(new) { self.aliases.retain(|_, key| key != old); return false; } - self.routes - .entry(new.owner.clone()) - .or_default() - .insert(new.clone()); self.servers.insert(new.clone(), server); for key in self.aliases.values_mut() { if key == old { @@ -2080,40 +1949,47 @@ async fn project_open_gate( #[cfg(any(not(unix), test))] fn portable_database_owner_reconciler( - project_servers: Arc>, + store_administration: StoreAdministration, current_key: Arc>, route_registered: Arc, handshake: DaemonHandshake, ) -> crate::mcp::DatabaseOwnerReconciler { Arc::new(move |fresh| { - let project_servers = Arc::clone(&project_servers); + let store_administration = store_administration.clone(); let current_key = Arc::clone(¤t_key); let route_registered = Arc::clone(&route_registered); let handshake = handshake.clone(); Box::pin(async move { - if !route_registered.load(Ordering::Acquire) { - return; - } - let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) { - Ok(key) => key, - Err(error) => { - eprintln!("[tracedecay] failed to rekey daemon database owner: {error}"); - return; - } - }; - let mut current = current_key.lock().await; - if *current == new_key { - return; - } - let old_key = current.clone(); - if !project_servers - .lock() - .await - .rekey(&old_key, new_key.clone()) - { - route_registered.store(false, Ordering::Release); - } - *current = new_key; + store_administration + .with_writer(|| async { + if !route_registered.load(Ordering::Acquire) { + return; + } + let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) { + Ok(key) => key, + Err(error) => { + eprintln!( + "[tracedecay] failed to rekey daemon database owner: {error}" + ); + return; + } + }; + let mut current = current_key.lock().await; + if *current == new_key { + return; + } + let old_key = current.clone(); + if !store_administration + .project_servers() + .lock() + .await + .rekey(&old_key, &new_key) + { + route_registered.store(false, Ordering::Release); + } + *current = new_key; + }) + .await; }) }) } @@ -2180,6 +2056,18 @@ impl DaemonEngine { self } + /// Runs destructive branch administration before any project server is + /// opened for the request, under the daemon-wide store administration gate. + async fn execute_branch_admin( + &self, + handshake: &DaemonHandshake, + action: crate::branch::BranchAdminAction, + ) -> Result { + self.store_administration + .execute_branch_admin_for_handshake(handshake, action) + .await + } + /// Returns the client version to log for this handshake, once per distinct /// skewed version; repeat connections from the same client return `None`. async fn client_version_skew_to_log(&self, handshake: &DaemonHandshake) -> Option { @@ -2272,6 +2160,22 @@ impl DaemonEngine { &self, handshake: &DaemonHandshake, ) -> Result> { + let (key, project_path, server) = self + .store_administration + .with_writer(|| self.open_project_server(handshake)) + .await?; + Ok(self + .activate_project_server(key, project_path, handshake, server) + .await) + } + + /// Opens or resolves a project server while writer administration is held. + /// Watcher and scheduler activation happen only after this returns so those + /// components can acquire the same coordinator without recursive locking. + async fn open_project_server( + &self, + handshake: &DaemonHandshake, + ) -> Result<(ProjectServerKey, PathBuf, Arc)> { let Some(project_path) = handshake.project_path.as_ref() else { return Err(TraceDecayError::Config { message: "project server requested without project_path".to_string(), @@ -2282,29 +2186,25 @@ impl DaemonEngine { .unwrap_or_else(|_| project_path.clone()); let route = ProjectRouteKey::from_handshake(&canonical_project_path, handshake)?; let cached = { - let servers = self.project_servers.lock().await; + let servers = self.store_administration.project_servers().lock().await; servers .get_route(&route) .map(|(key, server)| (key.clone(), Arc::clone(server))) }; if let Some((key, server)) = cached { - return Ok(self - .activate_project_server(key, canonical_project_path, handshake, server) - .await); + return Ok((key, canonical_project_path, server)); } let gate = project_open_gate(&self.project_open_gates, &route).await; let _singleflight = gate.lock().await; let cached = { - let servers = self.project_servers.lock().await; + let servers = self.store_administration.project_servers().lock().await; servers .get_route(&route) .map(|(key, server)| (key.clone(), Arc::clone(server))) }; if let Some((key, server)) = cached { - return Ok(self - .activate_project_server(key, canonical_project_path, handshake, server) - .await); + return Ok((key, canonical_project_path, server)); } #[cfg(test)] @@ -2318,7 +2218,7 @@ impl DaemonEngine { let key = ProjectServerKey::from_open_project(&cg, handshake)?; let existing = { - let mut servers = self.project_servers.lock().await; + let mut servers = self.store_administration.project_servers().lock().await; let server = servers.get(&key).cloned(); if server.is_some() { servers.bind_route(route.clone(), key.clone()); @@ -2326,9 +2226,7 @@ impl DaemonEngine { server }; if let Some(server) = existing { - return Ok(self - .activate_project_server(key, canonical_project_path, handshake, server) - .await); + return Ok((key, canonical_project_path, server)); } let accounting_db = accounting_db_for_handshake(handshake).await?; @@ -2345,7 +2243,7 @@ impl DaemonEngine { Arc::clone(&route_registered), handshake.clone(), ); - let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers( + let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers_and_writers( cg, handshake.scope_prefix.clone(), accounting_db, @@ -2353,19 +2251,20 @@ impl DaemonEngine { false, Some(reconciler), Some(database_owner_reconciler), + coordinated_hook_branch_writer(self.store_administration.clone()), + coordinated_background_refresh_writer(self.store_administration.clone()), ) .await; - let (server, inserted) = - self.project_servers - .lock() - .await - .bind_or_insert_route(route, key.clone(), candidate); + let (server, inserted) = self + .store_administration + .project_servers() + .lock() + .await + .bind_or_insert_route(route, key.clone(), candidate); if !inserted { route_registered.store(false, Ordering::Release); } - Ok(self - .activate_project_server(key, canonical_project_path, handshake, server) - .await) + Ok((key, canonical_project_path, server)) } async fn activate_project_server( @@ -2382,81 +2281,6 @@ impl DaemonEngine { server } - async fn ensure_automation_scheduler( - &self, - key: ProjectServerKey, - project_path: PathBuf, - handshake: DaemonHandshake, - ) { - if !self.lifecycle.accepting() { - return; - } - { - let schedulers = self.automation_schedulers.lock().await; - if schedulers.contains_key(&key) { - return; - } - } - - let configured = match Box::pin(automation_scheduler_has_work_for_project( - &project_path, - &handshake, - )) - .await - { - Ok(configured) => configured, - Err(e) => { - log_daemon_event( - "scheduler_config", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - false - } - }; - if !configured { - log_daemon_event( - "scheduler_config", - &[ - ("project", project_path.display().to_string()), - ("outcome", "skipped".to_string()), - ("reason", "not_configured".to_string()), - ], - ); - return; - } - - self.start_automation_scheduler(key, project_path, handshake) - .await; - } - - fn automation_scheduler_reconciler( - &self, - current_key: Arc>, - project_path: PathBuf, - handshake: DaemonHandshake, - ) -> crate::dashboard::AutomationSchedulerReconciler { - let engine = self.clone(); - std::sync::Arc::new(move || { - let engine = engine.clone(); - let current_key = Arc::clone(¤t_key); - let project_path = project_path.clone(); - let handshake = handshake.clone(); - tokio::spawn(async move { - let key = current_key.lock().await.clone(); - engine - .ensure_automation_scheduler(key.clone(), project_path, handshake) - .await; - if let Some(handle) = engine.automation_schedulers.lock().await.get(&key) { - handle.wake.notify_one(); - } - }); - }) - } - fn database_owner_reconciler( &self, current_key: Arc>, @@ -2470,77 +2294,64 @@ impl DaemonEngine { let route_registered = Arc::clone(&route_registered); let handshake = handshake.clone(); Box::pin(async move { - if !route_registered.load(Ordering::Acquire) { - return; - } - let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) { - Ok(key) => key, - Err(error) => { - eprintln!("[tracedecay] failed to rekey daemon database owner: {error}"); - return; - } - }; - let mut current = current_key.lock().await; - if *current == new_key { - return; - } - let old_key = current.clone(); - let rekeyed = engine - .project_servers - .lock() - .await - .rekey(&old_key, new_key.clone()); - if !rekeyed { - route_registered.store(false, Ordering::Release); - } - let removed_scheduler = { - let mut schedulers = engine.automation_schedulers.lock().await; - let removed = schedulers.remove(&old_key); - if let Some(handle) = removed { - if schedulers.contains_key(&new_key) { - Some(handle) - } else { - schedulers.insert(new_key.clone(), handle); - None + engine + .store_administration + .with_writer(|| async { + if !route_registered.load(Ordering::Acquire) { + return; } - } else { - None - } - }; - if let Some(handle) = removed_scheduler { - handle.task.abort(); - } - *current = new_key; + let new_key = match ProjectServerKey::from_open_project(&fresh, &handshake) + { + Ok(key) => key, + Err(error) => { + eprintln!( + "[tracedecay] failed to rekey daemon database owner: {error}" + ); + return; + } + }; + let mut current = current_key.lock().await; + if *current == new_key { + return; + } + let old_key = current.clone(); + let rekeyed = engine + .store_administration + .project_servers() + .lock() + .await + .rekey(&old_key, &new_key); + if !rekeyed { + route_registered.store(false, Ordering::Release); + } + let removed_scheduler = { + let mut schedulers = engine + .store_administration + .automation_schedulers() + .lock() + .await; + let removed = schedulers.remove(&old_key); + if let Some(handle) = removed { + if schedulers.contains_key(&new_key) { + Some(handle) + } else { + schedulers.insert(new_key.clone(), handle); + None + } + } else { + None + } + }; + if let Some(handle) = removed_scheduler { + handle.task.abort(); + } + *current = new_key; + }) + .await; }) }) } - async fn start_automation_scheduler( - &self, - key: ProjectServerKey, - project_path: PathBuf, - handshake: DaemonHandshake, - ) { - if !self.lifecycle.accepting() { - return; - } - let mut schedulers = self.automation_schedulers.lock().await; - if !self.lifecycle.accepting() || schedulers.contains_key(&key) { - return; - } - let wake = Arc::new(tokio::sync::Notify::new()); - let loop_wake = Arc::clone(&wake); - let task = tokio::spawn(async move { - Box::pin(run_automation_scheduler_loop( - project_path, - handshake, - loop_wake, - )) - .await; - }); - schedulers.insert(key, AutomationSchedulerHandle { task, wake }); - } - async fn shutdown_background_tasks(&self) { self.shutdown_automation_schedulers().await; @@ -2551,33 +2362,19 @@ impl DaemonEngine { } } - async fn shutdown_automation_schedulers(&self) { - let scheduler_handles: Vec> = { - let mut schedulers = self.automation_schedulers.lock().await; - schedulers.drain().map(|(_, handle)| handle.task).collect() - }; - let _child_shutdown = crate::sessions::codex_app_server::begin_codex_app_server_shutdown(); - for handle in &scheduler_handles { - handle.abort(); - } - let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, async { - for handle in scheduler_handles { - let _ = handle.await; - } - }) - .await; - } - async fn shutdown_servers(&self) { - let servers: Vec> = { - let servers = self.project_servers.lock().await; - let mut seen = HashSet::new(); - servers - .values() - .filter(|server| seen.insert(Arc::as_ptr(server) as usize)) - .cloned() - .collect() - }; + let servers: Vec> = self + .store_administration + .with_writer(|| async { + let servers = self.store_administration.project_servers().lock().await; + let mut seen = HashSet::new(); + servers + .values() + .filter(|server| seen.insert(Arc::as_ptr(server) as usize)) + .cloned() + .collect() + }) + .await; for server in servers { server.shutdown().await; } @@ -2591,693 +2388,14 @@ impl DaemonEngine { } } -#[cfg(unix)] -async fn run_automation_scheduler_loop( - project_path: PathBuf, - handshake: DaemonHandshake, - wake: Arc, -) { - loop { - match Box::pin(automation_scheduler_has_work_for_project( - &project_path, - &handshake, - )) - .await - { - Ok(true) => {} - Ok(false) => { - log_daemon_event( - "scheduler_exit", - &[ - ("project", project_path.display().to_string()), - ("reason", "not_configured".to_string()), - ], - ); - break; - } - Err(e) => { - // A transient failure (e.g. a momentarily corrupt jobs file or - // a project that cannot be opened this instant) must not - // permanently kill the scheduler loop. Surface the cause and - // retry on the next tick instead of exiting for good. - log_daemon_event( - "scheduler_project_open", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - tokio::time::sleep(Duration::from_secs( - crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS, - )) - .await; - continue; - } - } - log_daemon_event( - "scheduler_tick", - &[ - ("project", project_path.display().to_string()), - ("outcome", "start".to_string()), - ], - ); - if let Err(e) = Box::pin(run_automation_scheduler_tick(&project_path, &handshake)).await { - log_daemon_event( - "scheduler_tick", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - } - if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { - log_daemon_event( - "host_receipt_review", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", error.to_string()), - ], - ); - } - let tick_secs = Box::pin(automation_scheduler_tick_secs_for_project( - &project_path, - &handshake, - )) - .await; - log_daemon_event( - "scheduler_sleep", - &[ - ("project", project_path.display().to_string()), - ("next_tick_secs", tick_secs.to_string()), - ], - ); - tokio::select! { - () = tokio::time::sleep(Duration::from_secs(tick_secs)) => {} - () = wake.notified() => { - // Receipts arrive at tool cadence. Wait for a short quiet - // period and reset it for every later receipt, producing one - // review for the burst rather than one review per command. - loop { - tokio::select! { - () = tokio::time::sleep(Duration::from_secs(5)) => break, - () = wake.notified() => {} - } - } - if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { - log_daemon_event( - "host_receipt_review", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", error.to_string()), - ], - ); - } - } - } - } -} - -#[cfg(unix)] -async fn automation_scheduler_has_work_for_project( - project_path: &Path, - handshake: &DaemonHandshake, -) -> Result { - let cg = Box::pin(open_existing_project_with_options( - project_path, - handshake.open_options(), - )) - .await?; - let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; - automation_scheduler_has_work(&cg, &config).await -} - -#[cfg(unix)] -async fn automation_scheduler_tick_secs_for_project( - project_path: &Path, - handshake: &DaemonHandshake, -) -> u64 { - match Box::pin(open_existing_project_with_options( - project_path, - handshake.open_options(), - )) - .await - { - Ok(cg) => { - match effective_automation_config_for_project(&cg, &handshake.client_identity).await { - Ok(config) => config.scheduler_tick_secs, - Err(e) => { - log_daemon_event( - "scheduler_config", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS - } - } - } - Err(e) => { - log_daemon_event( - "scheduler_project_open", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS - } - } -} - -/// Minimum wall-clock interval between global-database retention passes, -/// shared across every project's scheduler loop so retention runs at most -/// this often no matter how many projects are active. -#[cfg(unix)] -const RETENTION_MIN_INTERVAL_SECS: u64 = 6 * 60 * 60; - -#[cfg(unix)] -static LAST_GLOBAL_RETENTION: std::sync::Mutex> = - std::sync::Mutex::new(None); - -/// Returns whether a retention pass is due, recording `now` as the last run -/// when it is. The gate is process-global so N project loops do not each run -/// their own retention every tick. -#[cfg(unix)] -fn global_retention_pass_due(now: std::time::Instant) -> bool { - let mut guard = match LAST_GLOBAL_RETENTION.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - let due = guard.is_none_or(|last| { - now.duration_since(last) >= Duration::from_secs(RETENTION_MIN_INTERVAL_SECS) - }); - if due { - *guard = Some(now); - } - due -} - -/// Applies the configured retention windows to the global telemetry tables, -/// at most once per [`RETENTION_MIN_INTERVAL_SECS`]. Best-effort: retention is -/// housekeeping, so failures are logged and never abort a scheduler tick. -#[cfg(unix)] -async fn maybe_run_global_retention( - project_path: &Path, - config: &crate::automation::config::AutomationConfig, -) { - if !global_retention_pass_due(std::time::Instant::now()) { - return; - } - let db = match crate::global_db::GlobalDb::try_open().await { - Ok(Some(db)) => db, - Ok(None) => return, - Err(error) => { - log_daemon_event( - "retention_prune", - &[ - ("project", project_path.display().to_string()), - ("outcome", "open_rejected".to_string()), - ("error", error.to_string()), - ], - ); - return; - } - }; - let now_secs = crate::tracedecay::current_timestamp(); - match db.prune_global_retention(&config.retention, now_secs).await { - Ok(reports) => { - for report in reports { - if report.applied && report.rows > 0 { - log_daemon_event( - "retention_prune", - &[ - ("project", project_path.display().to_string()), - ("table", report.table.to_string()), - ("rows", report.rows.to_string()), - ( - "window_days", - report - .window_days - .map_or_else(|| "unlimited".to_string(), |d| d.to_string()), - ), - ], - ); - } - } - } - Err(e) => { - log_daemon_event( - "retention_prune", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - } - } -} - -#[cfg(unix)] -async fn run_automation_scheduler_tick( - project_path: &Path, - handshake: &DaemonHandshake, -) -> Result<()> { - use crate::automation::backend::{AgentTaskKind, CodexAppServerBackend}; - use crate::automation::run_ledger::AutomationTrigger; - use crate::automation::runner::{ - CombinedReviewAutomationOptions, CombinedReviewDispatch, MemoryCuratorAutomationOptions, - SessionReflectorAutomationOptions, SkillWriterAutomationOptions, - run_combined_review_with_backend, run_memory_curator_with_backend, - run_session_reflector_with_backend, run_skill_writer_with_backend, - }; - - let cg = Box::pin(open_existing_project_with_options( - project_path, - handshake.open_options(), - )) - .await?; - let control = - crate::automation::scheduler::load_scheduler_control(&cg.store_layout().dashboard_root) - .await?; - if control.paused { - log_daemon_event( - "scheduler_tick", - &[ - ("project", project_path.display().to_string()), - ("outcome", "skipped".to_string()), - ("reason", "paused".to_string()), - ], - ); - return Ok(()); - } - let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; - if !automation_scheduler_has_work(&cg, &config).await? { - log_daemon_event( - "scheduler_tick", - &[ - ("project", project_path.display().to_string()), - ("outcome", "skipped".to_string()), - ("reason", "not_configured".to_string()), - ], - ); - return Ok(()); - } - maybe_run_global_retention(project_path, &config).await; - let backend = CodexAppServerBackend::from_automation_config(&config); - let mut first_error: Option = None; - let mut any_succeeded = false; - - log_scheduler_task_start(project_path, AgentTaskKind::MemoryCurator); - match run_memory_curator_with_backend( - &cg, - &config, - &backend, - MemoryCuratorAutomationOptions { - trigger: AutomationTrigger::Scheduler, - ..MemoryCuratorAutomationOptions::default() - }, - ) - .await - { - Ok(run) => { - any_succeeded |= run.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - log_daemon_scheduler_record(project_path, &run.ledger_record); - } - Err(e) => { - log_scheduler_task_error(project_path, AgentTaskKind::MemoryCurator, &e); - first_error.get_or_insert(e); - } - } - // When both the reflector and the skill writer are due in this tick, the - // combined path serves them with one backend call. Any other outcome - // (combined mode disabled, only one task due, missing evidence) falls - // back to the sequential per-task runs below. - let mut combined_handled = false; - if config.combine_due_tasks { - log_scheduler_task_start(project_path, AgentTaskKind::CombinedReview); - match run_combined_review_with_backend( - &cg, - &config, - &backend, - CombinedReviewAutomationOptions::default(), - ) - .await - { - Ok(CombinedReviewDispatch::Ran(run)) => { - any_succeeded |= run.session_reflector.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - any_succeeded |= run.skill_writer.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); - log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); - combined_handled = true; - } - Ok(CombinedReviewDispatch::RecordedFailure { run, error }) => { - any_succeeded |= run.session_reflector.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - any_succeeded |= run.skill_writer.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); - log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); - log_scheduler_task_error(project_path, AgentTaskKind::CombinedReview, &error); - first_error.get_or_insert(error); - combined_handled = true; - } - Ok(CombinedReviewDispatch::NotCombined { reason }) => { - log_daemon_event( - "scheduler_task", - &[ - ("project", project_path.display().to_string()), - ("task", "combined_review".to_string()), - ("outcome", "not_combined".to_string()), - ("reason", reason.to_string()), - ], - ); - } - Err(e) => { - log_scheduler_task_error(project_path, AgentTaskKind::CombinedReview, &e); - } - } - } - if !combined_handled { - log_scheduler_task_start(project_path, AgentTaskKind::SessionReflector); - match run_session_reflector_with_backend( - &cg, - &config, - &backend, - SessionReflectorAutomationOptions { - trigger: AutomationTrigger::Scheduler, - ..SessionReflectorAutomationOptions::default() - }, - ) - .await - { - Ok(run) => { - any_succeeded |= run.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - log_daemon_scheduler_record(project_path, &run.ledger_record); - } - Err(e) => { - log_scheduler_task_error(project_path, AgentTaskKind::SessionReflector, &e); - first_error.get_or_insert(e); - } - } - log_scheduler_task_start(project_path, AgentTaskKind::SkillWriter); - match run_skill_writer_with_backend( - &cg, - &config, - &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::Scheduler, - ..SkillWriterAutomationOptions::default() - }, - ) - .await - { - Ok(run) => { - any_succeeded |= run.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded; - log_daemon_scheduler_record(project_path, &run.ledger_record); - } - Err(e) => { - log_scheduler_task_error(project_path, AgentTaskKind::SkillWriter, &e); - first_error.get_or_insert(e); - } - } - } - if any_succeeded { - log_automation_staged_if_pending(project_path, &cg.store_layout().dashboard_root).await; - } - run_user_jobs_scheduler_pass( - project_path, - &handshake.client_identity.profile_root, - &cg, - &config, - &backend, - &mut first_error, - ) - .await; - match first_error { - Some(err) => Err(err), - None => Ok(()), - } -} - -#[cfg(unix)] -async fn run_host_receipt_review(project_path: &Path, handshake: &DaemonHandshake) -> Result<()> { - use crate::automation::backend::CodexAppServerBackend; - use crate::automation::run_ledger::AutomationTrigger; - use crate::automation::runner::{ - CombinedReviewAutomationOptions, CombinedReviewDispatch, SessionReflectorAutomationOptions, - SkillWriterAutomationOptions, run_combined_review_with_backend, - }; - - let cg = Box::pin(open_existing_project_with_options( - project_path, - handshake.open_options(), - )) - .await?; - let dashboard_root = cg.store_layout().dashboard_root.clone(); - let Some(ready) = crate::automation::host_receipts::oldest_ready(&dashboard_root).await? else { - return Ok(()); - }; - let pending = ready.pending; - if crate::automation::scheduler::load_scheduler_control(&dashboard_root) - .await? - .paused - { - return Ok(()); - } - let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; - let session_id = pending - .route - .as_ref() - .and_then(|route| route.session_id.clone()); - let Some(session_db) = - crate::global_db::GlobalDb::open_read_only_at(&cg.store_layout().sessions_db_path).await - else { - return Ok(()); - }; - if session_db - .lcm_load_raw_message("hermes", &ready.transcript_watermark) - .await - .is_none() - { - // Never review a terminal receipt until the exact completed-turn - // watermark is durable in LCM. - return Ok(()); - } - let backend = CodexAppServerBackend::from_automation_config(&config); - let result = run_combined_review_with_backend( - &cg, - &config, - &backend, - CombinedReviewAutomationOptions { - run_id: Some(format!("host_receipt_{}", pending.generation)), - session_reflector: SessionReflectorAutomationOptions { - trigger: AutomationTrigger::HostReceipt, - provider: "hermes".to_string(), - session_id, - ..SessionReflectorAutomationOptions::default() - }, - skill_writer: SkillWriterAutomationOptions { - trigger: AutomationTrigger::HostReceipt, - provider: "hermes".to_string(), - ..SkillWriterAutomationOptions::default() - }, - trigger: AutomationTrigger::HostReceipt, - }, - ) - .await?; - match result { - CombinedReviewDispatch::Ran(run) => { - log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); - log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); - if run.session_reflector.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded - && run.skill_writer.ledger_record.status - == crate::automation::run_ledger::AutomationRunStatus::Succeeded - { - crate::automation::host_receipts::mark_consumed( - &dashboard_root, - &pending.session_key, - pending.generation, - ) - .await?; - } - } - CombinedReviewDispatch::RecordedFailure { run, error } => { - log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); - log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); - return Err(error); - } - CombinedReviewDispatch::NotCombined { reason } => { - log_daemon_event( - "host_receipt_review", - &[ - ("project", project_path.display().to_string()), - ("outcome", "deferred".to_string()), - ("reason", reason.to_string()), - ], - ); - } - } - Ok(()) -} - -#[cfg(unix)] -async fn effective_automation_config_for_project( - cg: &crate::tracedecay::TraceDecay, - client_identity: &DaemonClientIdentity, -) -> Result { - use crate::automation::config::{effective_config, load_project_config}; - - let global = user_config_for_client(client_identity).automation; - let project = load_project_config(&cg.store_layout().dashboard_root).await?; - effective_config(&global, project.as_ref()) -} - -#[cfg(unix)] -fn user_config_for_client( - client_identity: &DaemonClientIdentity, -) -> crate::user_config::UserConfig { - let path = client_identity.profile_root.join("config.toml"); - let Ok(contents) = std::fs::read_to_string(&path) else { - return crate::user_config::UserConfig::default(); - }; - crate::user_config::parse_or_warn_default(&path, &contents) -} - -#[cfg(unix)] -fn automation_scheduler_configured(config: &crate::automation::config::AutomationConfig) -> bool { - use crate::automation::config::{AutomationBackend, AutomationHostMode}; - use crate::automation::scheduler::{AutomationSchedule, parse_schedule}; - - if !config.enabled - || config.host_mode == AutomationHostMode::DelegatedHost - || config.backend != AutomationBackend::CodexAppServer - { - return false; - } - if config.combine_due_tasks - && config.tasks.session_reflector.enabled - && config.tasks.skill_writer.enabled - { - return true; - } - [ - &config.tasks.memory_curator, - &config.tasks.session_reflector, - &config.tasks.skill_writer, - ] - .into_iter() - .any(|task| { - if !task.enabled { - return false; - } - match parse_schedule(task.schedule.as_deref()) { - Ok(AutomationSchedule::Manual) | Err(_) => false, - Ok(AutomationSchedule::ConfiguredInterval) => task.interval_secs.is_some(), - Ok(AutomationSchedule::Interval { .. } | AutomationSchedule::Cron(_)) => true, - } - }) -} - -/// True when the scheduler loop has anything to do for this project: a -/// scheduled fixed task or a schedulable user-defined job. -#[cfg(unix)] -async fn automation_scheduler_has_work( - cg: &crate::tracedecay::TraceDecay, - config: &crate::automation::config::AutomationConfig, -) -> Result { - use crate::automation::config::{AutomationBackend, AutomationHostMode}; - - if automation_scheduler_configured(config) { - return Ok(true); - } - if !config.enabled - || config.host_mode == AutomationHostMode::DelegatedHost - || config.backend != AutomationBackend::CodexAppServer - { - return Ok(false); - } - crate::automation::jobs::jobs_configured_for_scheduler(&cg.store_layout().dashboard_root).await -} - -/// Ticks every schedulable user-defined job with the same lock/cooldown -/// discipline as the fixed tasks (enforced inside the job runner). -#[cfg(unix)] -async fn run_user_jobs_scheduler_pass( - project_path: &Path, - profile_root: &Path, - cg: &crate::tracedecay::TraceDecay, - config: &crate::automation::config::AutomationConfig, - backend: &crate::automation::backend::CodexAppServerBackend, - first_error: &mut Option, -) { - let dashboard_root = cg.store_layout().dashboard_root.clone(); - let jobs = match crate::automation::jobs::load_jobs(&dashboard_root).await { - Ok(jobs) => jobs, - Err(e) => { - log_daemon_event( - "scheduler_user_jobs", - &[ - ("project", project_path.display().to_string()), - ("outcome", "error".to_string()), - ("error", e.to_string()), - ], - ); - first_error.get_or_insert(e); - return; - } - }; - for job in jobs - .iter() - .filter(|job| crate::automation::jobs::job_is_schedulable(job)) - { - log_scheduler_task_start( - project_path, - crate::automation::backend::AgentTaskKind::UserJob, - ); - match crate::automation::jobs::run_user_job_with_backend( - &dashboard_root, - config, - backend, - job, - crate::automation::jobs::UserJobRunOptions { - trigger: crate::automation::run_ledger::AutomationTrigger::Scheduler, - profile_root: Some(profile_root.to_path_buf()), - project_root: Some(project_path.to_path_buf()), - ..crate::automation::jobs::UserJobRunOptions::default() - }, - ) - .await - { - Ok(run) => log_daemon_scheduler_record(project_path, &run.ledger_record), - Err(e) => { - log_scheduler_task_error( - project_path, - crate::automation::backend::AgentTaskKind::UserJob, - &e, - ); - first_error.get_or_insert(e); - } - } - } -} - #[cfg(all(unix, test))] async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngine) -> Result<()> { - serve_broker_socket_client(BrokerStream::Unix(stream), engine, None).await + Box::pin(serve_broker_socket_client( + BrokerStream::Unix(stream), + engine, + None, + )) + .await } #[cfg(unix)] @@ -3286,7 +2404,7 @@ async fn serve_authenticated_socket_client( engine: DaemonEngine, auth_token: String, ) -> Result<()> { - serve_broker_socket_client(stream, engine, Some(auth_token)).await + Box::pin(serve_broker_socket_client(stream, engine, Some(auth_token))).await } async fn apply_daemon_initialize_route( @@ -3398,6 +2516,22 @@ async fn serve_broker_socket_client( }; let initialize_route = apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; + if let Some(request) = parse_branch_admin_request(&first_request_line) { + let result = match request.action.clone() { + Ok(action) => engine.execute_branch_admin(&handshake, action).await, + Err(message) => Err(TraceDecayError::Config { message }), + }; + drop(setup_activity); + write_branch_admin_response(&mut transport, request, result).await?; + return Ok(()); + } + if let Some(request) = parse_branch_add_request(&first_request_line) { + let response = + branch_add_response(&engine.store_administration, &handshake, &request).await; + drop(setup_activity); + write_json_rpc_response(&mut transport, &response).await?; + return Ok(()); + } let server = if handshake.project_path.is_some() { let server = match Box::pin(engine.project_server(&handshake)).await { Ok(server) => server, @@ -3468,7 +2602,7 @@ async fn serve_windows_broker_client( stream: BrokerStream, auth_token: &str, lifecycle: &DaemonLifecycle, - project_servers: Arc>, + store_administration: StoreAdministration, project_open_gates: Arc>, #[cfg(test)] project_open_attempts: Option>, ) -> Result<()> { @@ -3497,94 +2631,49 @@ async fn serve_windows_broker_client( }; let initialize_route = apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; + if let Some(request) = parse_branch_admin_request(&first_request_line) { + let result = match request.action.clone() { + Ok(action) => { + store_administration + .execute_branch_admin_for_handshake(&handshake, action) + .await + } + Err(message) => Err(TraceDecayError::Config { message }), + }; + drop(setup_activity); + write_branch_admin_response(&mut transport, request, result).await?; + return Ok(()); + } + if let Some(request) = parse_branch_add_request(&first_request_line) { + let response = branch_add_response(&store_administration, &handshake, &request).await; + drop(setup_activity); + write_json_rpc_response(&mut transport, &response).await?; + return Ok(()); + } if let Some(project_path) = handshake.project_path.as_deref() { let canonical_project_path = project_path .canonicalize() .unwrap_or_else(|_| project_path.to_path_buf()); - let route = ProjectRouteKey::from_handshake(&canonical_project_path, &handshake)?; - let mut server = { - let servers = project_servers.lock().await; - servers - .get_route(&route) - .map(|(_, server)| Arc::clone(server)) - }; - if server.is_none() { - let gate = project_open_gate(&project_open_gates, &route).await; - let _singleflight = gate.lock().await; - server = { - let servers = project_servers.lock().await; - servers - .get_route(&route) - .map(|(_, server)| Arc::clone(server)) - }; - if server.is_none() { - #[cfg(test)] - if let Some(attempts) = &project_open_attempts { - attempts.fetch_add(1, Ordering::Relaxed); - } - let cg = match Box::pin(open_project_for_handshake( + let server_result = store_administration + .with_writer(|| { + portable_project_server( + &store_administration, + &project_open_gates, &canonical_project_path, &handshake, - )) - .await - { - Ok(cg) => cg, - Err(error) => { - let mut transport = ReplayTransport::new(transport); - transport.push_replay(first_request_line); - write_project_open_error(&mut transport, &error).await?; - return Err(error); - } - }; - cg.register_project_store_in_global_registry().await; - let key = ProjectServerKey::from_open_project(&cg, &handshake)?; - let existing = { - let mut servers = project_servers.lock().await; - let existing = servers.get(&key).cloned(); - if existing.is_some() { - servers.bind_route(route.clone(), key.clone()); - } - existing - }; - if let Some(existing) = existing { - server = Some(existing); - } else { - let current_key = Arc::new(tokio::sync::Mutex::new(key.clone())); - let route_registered = Arc::new(AtomicBool::new(true)); - let database_owner_reconciler = portable_database_owner_reconciler( - Arc::clone(&project_servers), - current_key, - Arc::clone(&route_registered), - handshake.clone(), - ); - let accounting_db = accounting_db_for_handshake(&handshake).await?; - let registry_db = registry_db_for_handshake(&handshake).await?; - let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers( - cg, - handshake.scope_prefix.clone(), - accounting_db, - registry_db, - false, - None, - Some(database_owner_reconciler), - ) - .await; - let (resolved, inserted) = project_servers.lock().await.bind_or_insert_route( - route.clone(), - key, - candidate, - ); - if !inserted { - route_registered.store(false, Ordering::Release); - } - server = Some(resolved); - } + #[cfg(test)] + project_open_attempts.as_ref(), + ) + }) + .await; + let server = match server_result { + Ok(server) => server, + Err(error) => { + let mut transport = ReplayTransport::new(transport); + transport.push_replay(first_request_line); + write_project_open_error(&mut transport, &error).await?; + return Err(error); } - } - let Some(server) = server else { - return Err(TraceDecayError::Config { - message: "project route did not initialize a daemon server".to_string(), - }); }; drop(setup_activity); let initialize_handled = write_routed_initialize_response( @@ -3613,6 +2702,93 @@ async fn serve_windows_broker_client( Ok(()) } +#[cfg(any(not(unix), test))] +async fn portable_project_server( + store_administration: &StoreAdministration, + project_open_gates: &tokio::sync::Mutex, + canonical_project_path: &Path, + handshake: &DaemonHandshake, + #[cfg(test)] project_open_attempts: Option<&Arc>, +) -> Result> { + let route = ProjectRouteKey::from_handshake(canonical_project_path, handshake)?; + if let Some(server) = store_administration + .project_servers() + .lock() + .await + .get_route(&route) + .map(|(_, server)| Arc::clone(server)) + { + return Ok(server); + } + + let gate = project_open_gate(project_open_gates, &route).await; + let _singleflight = gate.lock().await; + if let Some(server) = store_administration + .project_servers() + .lock() + .await + .get_route(&route) + .map(|(_, server)| Arc::clone(server)) + { + return Ok(server); + } + + #[cfg(test)] + if let Some(attempts) = project_open_attempts { + attempts.fetch_add(1, Ordering::Relaxed); + } + let cg = Box::pin(open_project_for_handshake( + canonical_project_path, + handshake, + )) + .await?; + cg.register_project_store_in_global_registry().await; + let key = ProjectServerKey::from_open_project(&cg, handshake)?; + let existing = { + let mut servers = store_administration.project_servers().lock().await; + let existing = servers.get(&key).cloned(); + if existing.is_some() { + servers.bind_route(route.clone(), key.clone()); + } + existing + }; + if let Some(existing) = existing { + return Ok(existing); + } + + let current_key = Arc::new(tokio::sync::Mutex::new(key.clone())); + let route_registered = Arc::new(AtomicBool::new(true)); + let database_owner_reconciler = portable_database_owner_reconciler( + store_administration.clone(), + current_key, + Arc::clone(&route_registered), + handshake.clone(), + ); + let accounting_db = accounting_db_for_handshake(handshake).await?; + let registry_db = registry_db_for_handshake(handshake).await?; + let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + handshake.scope_prefix.clone(), + accounting_db, + registry_db, + false, + None, + Some(database_owner_reconciler), + coordinated_hook_branch_writer(store_administration.clone()), + coordinated_background_refresh_writer(store_administration.clone()), + ) + .await; + let (resolved, inserted) = store_administration + .project_servers() + .lock() + .await + .bind_or_insert_route(route, key, candidate); + if !inserted { + route_registered.store(false, Ordering::Release); + } + Ok(resolved) +} + #[cfg(unix)] async fn write_tool_list_changed_notification(transport: &mut impl McpTransport) -> Result<()> { let notification = json!({ diff --git a/src/daemon/authority.rs b/src/daemon/authority.rs index 6b7d0d13a..52224bcb6 100644 --- a/src/daemon/authority.rs +++ b/src/daemon/authority.rs @@ -71,7 +71,7 @@ impl DaemonAuthority { ) -> Result { let profile_root = canonical_identity_path(profile_root)?; std::fs::create_dir_all(&profile_root) - .map_err(|error| config_io("create", &profile_root, error))?; + .map_err(|error| config_io("create", &profile_root, &error))?; restrict_directory(&profile_root)?; let lock_path = profile_root.join(LOCK_FILE); @@ -80,11 +80,11 @@ impl DaemonAuthority { configure_private_create(&mut lock_options, 0o600); let mut lock = lock_options .open(&lock_path) - .map_err(|error| config_io("open", &lock_path, error))?; + .map_err(|error| config_io("open", &lock_path, &error))?; restrict_file(&lock_path)?; if let Err(error) = lock.try_lock_exclusive() { if !is_lock_contended(&error) { - return Err(config_io("lock", &lock_path, error)); + return Err(config_io("lock", &lock_path, &error)); } let record = read_record_if_present(&profile_root.join(RECORD_FILE)) .ok() @@ -125,17 +125,17 @@ impl DaemonAuthority { }; write_record(&record_path, &record)?; lock.set_len(0) - .map_err(|error| config_io("truncate", &lock_path, error))?; + .map_err(|error| config_io("truncate", &lock_path, &error))?; lock.seek(SeekFrom::Start(0)) - .map_err(|error| config_io("seek", &lock_path, error))?; + .map_err(|error| config_io("seek", &lock_path, &error))?; writeln!( lock, "pid={} run={} epoch={}", record.pid, record.process_run_id, record.epoch ) - .map_err(|error| config_io("write", &lock_path, error))?; + .map_err(|error| config_io("write", &lock_path, &error))?; lock.sync_data() - .map_err(|error| config_io("sync", &lock_path, error))?; + .map_err(|error| config_io("sync", &lock_path, &error))?; Ok(Self { _lock: lock, @@ -157,8 +157,8 @@ impl DaemonAuthority { &self.record.auth_token } - pub(super) fn publish_endpoint(&mut self, endpoint: DaemonEndpoint) -> Result<()> { - self.record.endpoint = canonical_endpoint(&endpoint)?; + pub(super) fn publish_endpoint(&mut self, endpoint: &DaemonEndpoint) -> Result<()> { + self.record.endpoint = canonical_endpoint(endpoint)?; write_record(&self.record_path, &self.record)?; self.endpoint_bound = true; Ok(()) @@ -219,7 +219,7 @@ pub(super) fn canonical_identity_path(path: &Path) -> Result { path.to_path_buf() } else { std::env::current_dir() - .map_err(|error| config_io("resolve", path, error))? + .map_err(|error| config_io("resolve", path, &error))? .join(path) }; if let Ok(canonical) = absolute.canonicalize() { @@ -241,7 +241,7 @@ pub(super) fn canonical_identity_path(path: &Path) -> Result { } let mut canonical = existing .canonicalize() - .map_err(|error| config_io("canonicalize", existing, error))?; + .map_err(|error| config_io("canonicalize", existing, &error))?; for component in suffix.iter().rev() { canonical.push(component); } @@ -282,11 +282,11 @@ fn read_record_if_present(path: &Path) -> Result> let mut file = match File::open(path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(config_io("open", path, error)), + Err(error) => return Err(config_io("open", path, &error)), }; let mut contents = String::new(); file.read_to_string(&mut contents) - .map_err(|error| config_io("read", path, error))?; + .map_err(|error| config_io("read", path, &error))?; serde_json::from_str(&contents) .map(Some) .map_err(|error| TraceDecayError::Config { @@ -325,7 +325,7 @@ fn restrict_directory(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) - .map_err(|error| config_io("restrict", path, error)) + .map_err(|error| config_io("restrict", path, &error)) } #[cfg(not(unix))] @@ -338,7 +338,7 @@ fn restrict_file(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| config_io("restrict", path, error)) + .map_err(|error| config_io("restrict", path, &error)) } #[cfg(not(unix))] @@ -371,11 +371,11 @@ fn remove_if_present(path: &Path) -> Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(config_io("remove", path, error)), + Err(error) => Err(config_io("remove", path, &error)), } } -fn config_io(operation: &str, path: &Path, error: std::io::Error) -> TraceDecayError { +fn config_io(operation: &str, path: &Path, error: &std::io::Error) -> TraceDecayError { TraceDecayError::Config { message: format!("failed to {operation} '{}': {error}", path.display()), } @@ -479,7 +479,7 @@ mod tests { let auth_token = authority.auth_token().to_string(); let concrete = DaemonEndpoint::parse("tcp://127.0.0.1:43123").unwrap(); - authority.publish_endpoint(concrete.clone()).unwrap(); + authority.publish_endpoint(&concrete).unwrap(); let published = current_record(&profile).unwrap().unwrap(); assert_eq!(published.endpoint, concrete); @@ -618,7 +618,11 @@ mod tests { DaemonAuthority::acquire(&profile, &DaemonEndpoint::Unix(socket.clone()), "test") .unwrap(); - assert_eq!(authority.endpoint(), &DaemonEndpoint::Unix(socket)); + let canonical_socket = profile.canonicalize().unwrap().join("daemon.sock"); + assert_eq!( + authority.endpoint(), + &DaemonEndpoint::Unix(canonical_socket) + ); assert_eq!(std::fs::read(&target).unwrap(), b"keep"); } } diff --git a/src/daemon/branch_add.rs b/src/daemon/branch_add.rs new file mode 100644 index 000000000..1876d1626 --- /dev/null +++ b/src/daemon/branch_add.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use serde_json::json; + +use crate::branch::BranchAddOutcome; +use crate::errors::TraceDecayError; +use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; +use crate::tracedecay::TraceDecay; + +use super::{DaemonHandshake, StoreAdministration, open_project_for_handshake}; + +const BRANCH_ADD_TOOL_NAME: &str = "tracedecay_admin_branch_add"; + +pub(super) fn coordinated_hook_branch_writer( + administration: StoreAdministration, +) -> crate::mcp::server::HookBranchWriter { + Arc::new(move |request| { + let administration = administration.clone(); + Box::pin(async move { + administration + .with_writer(|| async move { + crate::mcp::server::execute_hook_branch_write_direct(request).await + }) + .await + }) + }) +} + +pub(super) struct BranchAddRequest { + pub(super) id: serde_json::Value, + branch: std::result::Result, +} + +pub(super) fn parse_branch_add_request(line: &str) -> Option { + let request = serde_json::from_str::(line.trim()).ok()?; + if request.method != "tools/call" { + return None; + } + let params = request.params.as_ref()?; + if params.get("name").and_then(serde_json::Value::as_str) != Some(BRANCH_ADD_TOOL_NAME) { + return None; + } + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + let branch = arguments + .get("branch") + .and_then(serde_json::Value::as_str) + .filter(|branch| !branch.is_empty()) + .map(str::to_string) + .ok_or_else(|| "missing required parameter: branch".to_string()); + Some(BranchAddRequest { + id: request.id.unwrap_or(serde_json::Value::Null), + branch, + }) +} + +pub(super) async fn branch_add_response( + administration: &StoreAdministration, + handshake: &DaemonHandshake, + request: &BranchAddRequest, +) -> JsonRpcResponse { + let branch = match request.branch.as_deref() { + Ok(branch) => branch, + Err(message) => { + return JsonRpcResponse::error( + request.id.clone(), + ErrorCode::InvalidParams, + message.clone(), + ); + } + }; + + let result = administration + .with_writer(|| async { + let project_root = + handshake + .project_path + .as_deref() + .ok_or_else(|| TraceDecayError::Config { + message: "branch add requires a project path".to_string(), + })?; + let cg = open_project_for_handshake(project_root, handshake).await?; + let outcome = TraceDecay::add_branch_tracking_with_options( + cg.project_root(), + branch, + cg.open_options(), + ) + .await; + drop(cg); + outcome + }) + .await; + + match result { + Ok(outcome) => { + JsonRpcResponse::success(request.id.clone(), branch_add_tool_result(&outcome)) + } + Err(error) => JsonRpcResponse::error( + request.id.clone(), + ErrorCode::InternalError, + error.to_string(), + ), + } +} + +fn branch_add_tool_result(outcome: &BranchAddOutcome) -> serde_json::Value { + let output = json!({ "outcome": branch_add_outcome_name(outcome) }); + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&output).unwrap_or_default(), + }] + }) +} + +fn branch_add_outcome_name(outcome: &BranchAddOutcome) -> &'static str { + match outcome { + BranchAddOutcome::NotIndexed => "not_indexed", + BranchAddOutcome::AlreadyTracked => "already_tracked", + BranchAddOutcome::Added => "added", + BranchAddOutcome::Deferred => "deferred", + } +} diff --git a/src/daemon/branch_admin.rs b/src/daemon/branch_admin.rs new file mode 100644 index 000000000..517ce9e0a --- /dev/null +++ b/src/daemon/branch_admin.rs @@ -0,0 +1,667 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::json; + +use crate::errors::{Result, TraceDecayError}; +use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport}; + +use super::{ + AutomationSchedulerHandle, DaemonHandshake, DatabaseOwnerRegistry, ProjectServerKey, authority, + write_json_rpc_response, +}; + +const BRANCH_ADMIN_TOOL_NAME: &str = "tracedecay_admin_branch"; + +#[cfg(test)] +type ExternalHolderVerifier = fn(&[PathBuf]) -> Result<()>; + +/// Coordinates every daemon operation that can create, rekey, or remove a +/// database owner. There is intentionally one gate and one copy of each shared +/// registry so branch administration cannot prove ownership against stale +/// daemon state. +#[derive(Clone)] +pub(super) struct StoreAdministration { + gate: Arc>, + project_servers: Arc>, + automation_schedulers: + Arc>>, + #[cfg(test)] + external_holder_verifier: Option, +} + +impl Default for StoreAdministration { + fn default() -> Self { + Self { + gate: Arc::new(tokio::sync::Mutex::new(())), + project_servers: Arc::new(tokio::sync::Mutex::new(DatabaseOwnerRegistry::default())), + automation_schedulers: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + #[cfg(test)] + external_holder_verifier: None, + } + } +} + +impl StoreAdministration { + #[cfg(any(not(unix), test))] + pub(super) fn with_project_servers( + project_servers: Arc>, + ) -> Self { + Self { + project_servers, + ..Self::default() + } + } + + #[cfg(test)] + pub(super) fn with_external_holder_verifier( + external_holder_verifier: ExternalHolderVerifier, + ) -> Self { + Self { + external_holder_verifier: Some(external_holder_verifier), + ..Self::default() + } + } + + pub(super) fn project_servers(&self) -> &Arc> { + &self.project_servers + } + + pub(super) fn automation_schedulers( + &self, + ) -> &Arc>> { + &self.automation_schedulers + } + + fn prove_no_external_branch_store_holders(&self, database_paths: &[PathBuf]) -> Result<()> { + #[cfg(test)] + if let Some(external_holder_verifier) = self.external_holder_verifier { + return external_holder_verifier(database_paths); + } + ensure_no_external_branch_store_holders(database_paths) + } + + /// Acquires writer administration before constructing the supplied future + /// and holds it until that future completes. + pub(super) async fn with_writer( + &self, + operation: Operation, + ) -> Output + where + Operation: FnOnce() -> OperationFuture, + OperationFuture: Future, + { + let _writer = self.gate.lock().await; + operation().await + } + + /// Resolves the authenticated client's project layout and runs destructive + /// branch administration against that exact profile-owned store. + pub(super) async fn execute_branch_admin_for_handshake( + &self, + handshake: &DaemonHandshake, + action: crate::branch::BranchAdminAction, + ) -> Result { + let project_root = + handshake + .project_path + .as_deref() + .ok_or_else(|| TraceDecayError::Config { + message: "branch administration requires a project path".to_string(), + })?; + let layout = + crate::storage::resolve_layout(project_root, &handshake.client_identity.profile_root)?; + let config = crate::config::load_sync_config(project_root); + self.execute_branch_admin_in_layout( + project_root, + &layout.data_root, + action, + config.branch_gc_days, + config.orphan_db_gc_days, + ) + .await + } + + /// Prepares, proves, and commits one destructive branch-store mutation while + /// excluding every daemon writer. Cached owners fail closed and are left + /// completely untouched; operators must restart the daemon to release them. + pub(super) async fn execute_branch_admin_in_layout( + &self, + project_root: &Path, + data_root: &Path, + action: crate::branch::BranchAdminAction, + branch_gc_days: u64, + orphan_db_gc_days: u64, + ) -> Result { + self.with_writer(|| async { + if let Some(recovery) = + crate::branch::prepare_pending_branch_admin_recovery(data_root)? + { + let database_paths = + canonical_branch_database_paths(recovery.database_paths())?; + { + let project_servers = self.project_servers.lock().await; + let automation_schedulers = self.automation_schedulers.lock().await; + ensure_no_cached_store_owners( + &project_servers, + &automation_schedulers, + &database_paths, + )?; + } + + let canonical_paths = database_paths.iter().cloned().collect::>(); + let (fence, states) = crate::db::DatabaseDeletionFence::reacquire( + &canonical_paths, + recovery.transaction_id(), + "recover branch SQLite family deletion", + )?; + ensure_recovery_tombstone_states(recovery.disposition(), states)?; + let fenced_paths = fence.database_paths().collect::>(); + if fenced_paths.len() != database_paths.len() + || fenced_paths + .iter() + .any(|path| !database_paths.contains(*path)) + { + return Err(TraceDecayError::Config { + message: "database deletion recovery fence resolved a different branch-store identity set" + .to_string(), + }); + } + + recovery.recover( + |paths| self.prove_no_external_branch_store_holders(paths), + |disposition| match disposition { + crate::branch::BranchAdminRecoveryDisposition::PreCommitRollback => { + fence.rollback_deleting() + } + crate::branch::BranchAdminRecoveryDisposition::CommittedCleanup => { + fence.promote_deleted() + } + }, + )?; + } + + let prepared = crate::branch::prepare_branch_admin_mutation( + project_root, + data_root, + action, + branch_gc_days, + orphan_db_gc_days, + )?; + let database_paths = canonical_branch_database_paths(prepared.database_paths())?; + if database_paths.is_empty() { + return prepared.finish_without_database_deletion(); + } + + { + let project_servers = self.project_servers.lock().await; + let automation_schedulers = self.automation_schedulers.lock().await; + ensure_no_cached_store_owners( + &project_servers, + &automation_schedulers, + &database_paths, + )?; + } + + let canonical_paths = database_paths.iter().cloned().collect::>(); + let fence = crate::db::DatabaseDeletionFence::acquire( + &canonical_paths, + "delete branch SQLite families", + )?; + let fenced_paths = fence.database_paths().collect::>(); + if fenced_paths.len() != database_paths.len() + || fenced_paths + .iter() + .any(|path| !database_paths.contains(*path)) + { + return Err(TraceDecayError::Config { + message: + "database deletion fence resolved a different branch-store identity set" + .to_string(), + }); + } + prepared.commit_with_transaction( + fence.transaction_id(), + || fence.publish_deleting(), + |paths| self.prove_no_external_branch_store_holders(paths), + || fence.rollback_deleting(), + || fence.promote_deleted(), + ) + }) + .await + } +} + +pub(super) struct BranchAdminRequest { + pub(super) id: serde_json::Value, + pub(super) action: std::result::Result, +} + +pub(super) fn parse_branch_admin_request(line: &str) -> Option { + let request = serde_json::from_str::(line.trim()).ok()?; + if request.method != "tools/call" { + return None; + } + let params = request.params.as_ref()?; + if params.get("name").and_then(serde_json::Value::as_str) != Some(BRANCH_ADMIN_TOOL_NAME) { + return None; + } + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + Some(BranchAdminRequest { + id: request.id.unwrap_or(serde_json::Value::Null), + action: serde_json::from_value(arguments) + .map_err(|error| format!("invalid branch administration arguments: {error}")), + }) +} + +fn canonical_branch_database_paths(paths: &[PathBuf]) -> Result> { + paths + .iter() + .map(|path| authority::canonical_identity_path(path)) + .collect() +} + +fn ensure_no_cached_store_owners( + project_servers: &DatabaseOwnerRegistry, + automation_schedulers: &HashMap, + database_paths: &HashSet, +) -> Result<()> { + let server_busy = project_servers + .servers + .keys() + .any(|key| database_paths.contains(&key.owner.graph_db_path)); + let scheduler_busy = automation_schedulers + .keys() + .any(|key| database_paths.contains(&key.owner.graph_db_path)); + if !server_busy && !scheduler_busy { + return Ok(()); + } + + let cached_as = match (server_busy, scheduler_busy) { + (true, true) => "a project server and an automation scheduler", + (true, false) => "a project server", + (false, true) => "an automation scheduler", + (false, false) => return Ok(()), + }; + let mut paths = database_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + paths.sort(); + Err(TraceDecayError::Config { + message: format!( + "branch store administration is busy: selected database(s) {} are still cached by the daemon as {cached_as}; restart the TraceDecay daemon before retrying", + paths.join(", ") + ), + }) +} + +fn ensure_recovery_tombstone_states( + disposition: crate::branch::BranchAdminRecoveryDisposition, + states: crate::db::DatabaseDeletionStates, +) -> Result<()> { + let valid = match disposition { + crate::branch::BranchAdminRecoveryDisposition::PreCommitRollback => !states.has_deleted(), + crate::branch::BranchAdminRecoveryDisposition::CommittedCleanup => !states.has_missing(), + }; + if valid { + return Ok(()); + } + Err(TraceDecayError::Config { + message: format!( + "branch deletion recovery found incompatible tombstone states for {disposition:?}: missing={}, deleting={}, deleted={}", + states.missing(), + states.deleting(), + states.deleted(), + ), + }) +} + +fn ensure_no_external_branch_store_holders(database_paths: &[PathBuf]) -> Result<()> { + let options = crate::open_store_holders::OpenStoreHolderScanOptions { + include_current_process: true, + excluded_current_process_fds: BTreeSet::new(), + }; + let scan = crate::open_store_holders::scan_with_options(database_paths, &options).map_err( + |error| TraceDecayError::Config { + message: format!("failed to inspect open branch stores: {error}"), + }, + )?; + match scan { + crate::open_store_holders::OpenStoreHolderScan::Supported(holders) + if holders.is_empty() => + { + Ok(()) + } + crate::open_store_holders::OpenStoreHolderScan::Supported(holders) => { + let details = holders + .into_iter() + .map(|holder| format!("pid {} ({})", holder.pid, holder.command)) + .collect::>() + .join(", "); + Err(TraceDecayError::Config { + message: format!( + "cannot delete branch stores while external processes still hold them: {details}" + ), + }) + } + crate::open_store_holders::OpenStoreHolderScan::Unsupported { reason } => { + Err(TraceDecayError::Config { + message: format!( + "cannot prove branch stores are closed: {reason}; destructive branch operation refused" + ), + }) + } + } +} + +fn branch_admin_tool_result( + report: &crate::branch::BranchAdminReport, +) -> Result { + Ok(json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(report)?, + }] + })) +} + +pub(super) async fn write_branch_admin_response( + transport: &mut impl McpTransport, + request: BranchAdminRequest, + result: Result, +) -> Result<()> { + let response = match (request.action, result) { + (Err(message), _) => JsonRpcResponse::error(request.id, ErrorCode::InvalidParams, message), + (Ok(_), Ok(report)) => { + JsonRpcResponse::success(request.id, branch_admin_tool_result(&report)?) + } + (Ok(_), Err(error)) => { + JsonRpcResponse::error(request.id, ErrorCode::InternalError, error.to_string()) + } + }; + write_json_rpc_response(transport, &response).await +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::super::{ProjectRouteKey, StoreOwnerKey}; + use super::*; + + fn owner(graph_db_path: &str) -> StoreOwnerKey { + StoreOwnerKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_id: Some("project".to_string()), + store_root: PathBuf::from("/profile/projects/project"), + graph_db_path: PathBuf::from(graph_db_path), + } + } + + fn server_key(graph_db_path: &str, scope_prefix: Option<&str>) -> ProjectServerKey { + ProjectServerKey { + owner: owner(graph_db_path), + scope_prefix: scope_prefix.map(str::to_string), + } + } + + fn route(project_path: &str, scope_prefix: Option<&str>) -> ProjectRouteKey { + ProjectRouteKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_path: PathBuf::from(project_path), + scope_prefix: scope_prefix.map(str::to_string), + } + } + + #[test] + fn recovery_phase_validation_accepts_only_phase_compatible_mixed_states() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("a.db"); + let second = temp.path().join("b.db"); + + let fence = crate::db::DatabaseDeletionFence::acquire( + &[first.clone(), second.clone()], + "test partial publication", + ) + .unwrap(); + fence.publish_deleting().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let first_tombstone = fence.tombstone_paths().next().unwrap().to_path_buf(); + std::fs::remove_file(first_tombstone).unwrap(); + drop(fence); + let (fence, states) = crate::db::DatabaseDeletionFence::reacquire( + &[second.clone(), first.clone()], + &transaction_id, + "test partial publication recovery", + ) + .unwrap(); + ensure_recovery_tombstone_states( + crate::branch::BranchAdminRecoveryDisposition::PreCommitRollback, + states, + ) + .unwrap(); + ensure_recovery_tombstone_states( + crate::branch::BranchAdminRecoveryDisposition::CommittedCleanup, + states, + ) + .unwrap_err(); + fence.rollback_deleting().unwrap(); + drop(fence); + + let fence = crate::db::DatabaseDeletionFence::acquire( + &[first.clone(), second.clone()], + "test partial promotion", + ) + .unwrap(); + fence.publish_deleting().unwrap(); + fence.promote_deleted().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let first_tombstone = fence.tombstone_paths().next().unwrap().to_path_buf(); + let deleted = std::fs::read_to_string(&first_tombstone).unwrap(); + std::fs::write( + &first_tombstone, + deleted.replace("state=deleted", "state=deleting"), + ) + .unwrap(); + drop(fence); + let (fence, states) = crate::db::DatabaseDeletionFence::reacquire( + &[second, first], + &transaction_id, + "test partial promotion recovery", + ) + .unwrap(); + ensure_recovery_tombstone_states( + crate::branch::BranchAdminRecoveryDisposition::CommittedCleanup, + states, + ) + .unwrap(); + ensure_recovery_tombstone_states( + crate::branch::BranchAdminRecoveryDisposition::PreCommitRollback, + states, + ) + .unwrap_err(); + fence.promote_deleted().unwrap(); + } + + #[test] + fn matching_cached_server_and_scheduler_fail_busy_without_mutation() { + let target_a = server_key("/profile/projects/project/branches/feature.db", None); + let target_b = server_key("/profile/projects/project/branches/feature.db", Some("src")); + let survivor = server_key("/profile/projects/project/tracedecay.db", None); + let target_route_a = route("/repo", None); + let target_route_b = route("/repo", Some("src")); + let survivor_route = route("/repo-main", None); + let target_server_a = Arc::new("target-a"); + let target_server_b = Arc::new("target-b"); + let survivor_server = Arc::new("survivor"); + let mut registry = DatabaseOwnerRegistry::default(); + registry.insert_route( + target_route_a.clone(), + target_a.clone(), + Arc::clone(&target_server_a), + ); + registry.insert_route( + target_route_b.clone(), + target_b.clone(), + Arc::clone(&target_server_b), + ); + registry.insert_route( + survivor_route.clone(), + survivor.clone(), + Arc::clone(&survivor_server), + ); + let scheduler = Arc::new("scheduler"); + let mut schedulers = HashMap::from([(target_b.clone(), Arc::clone(&scheduler))]); + let selected = HashSet::from([PathBuf::from( + "/profile/projects/project/branches/feature.db", + )]); + + let error = ensure_no_cached_store_owners(®istry, &schedulers, &selected) + .expect_err("matching daemon owners must fail closed"); + + let message = error.to_string(); + assert!(message.contains("busy"), "{message}"); + assert!( + message.contains("restart the TraceDecay daemon"), + "{message}" + ); + let no_schedulers: HashMap> = HashMap::new(); + ensure_no_cached_store_owners(®istry, &no_schedulers, &selected) + .expect_err("a matching project server alone must fail closed"); + let no_servers: DatabaseOwnerRegistry> = DatabaseOwnerRegistry::default(); + ensure_no_cached_store_owners(&no_servers, &schedulers, &selected) + .expect_err("a matching scheduler alone must fail closed"); + assert!(Arc::ptr_eq( + registry + .get_route(&target_route_a) + .expect("target a route") + .1, + &target_server_a + )); + assert!(Arc::ptr_eq( + registry + .get_route(&target_route_b) + .expect("target b route") + .1, + &target_server_b + )); + assert!(Arc::ptr_eq( + registry + .get_route(&survivor_route) + .expect("survivor route") + .1, + &survivor_server + )); + assert!(Arc::ptr_eq( + schedulers.get(&target_b).expect("scheduler entry"), + &scheduler + )); + assert_eq!(registry.servers.len(), 3); + assert_eq!(registry.aliases.len(), 3); + assert_eq!(schedulers.len(), 1); + + // Keep the maps mutable in this regression test so accidental eviction + // implementations cannot hide behind immutable test fixtures. + assert!(schedulers.remove(&survivor).is_none()); + } + + #[test] + fn unmatched_cached_owners_allow_administration_to_continue() { + let survivor = server_key("/profile/projects/project/tracedecay.db", None); + let survivor_route = route("/repo-main", None); + let survivor_server = Arc::new("survivor"); + let mut registry = DatabaseOwnerRegistry::default(); + registry.insert_route( + survivor_route.clone(), + survivor.clone(), + Arc::clone(&survivor_server), + ); + let scheduler = Arc::new("scheduler"); + let schedulers = HashMap::from([(survivor.clone(), Arc::clone(&scheduler))]); + let selected = HashSet::from([PathBuf::from( + "/profile/projects/project/branches/feature.db", + )]); + + ensure_no_cached_store_owners(®istry, &schedulers, &selected) + .expect("unmatched owners must proceed to holder proof and commit"); + + assert!(Arc::ptr_eq( + registry + .get_route(&survivor_route) + .expect("survivor route") + .1, + &survivor_server + )); + assert!(Arc::ptr_eq( + schedulers.get(&survivor).expect("scheduler entry"), + &scheduler + )); + } + + #[test] + fn branch_admin_parser_accepts_only_the_hidden_destructive_tool() { + let request = parse_branch_admin_request( + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": BRANCH_ADMIN_TOOL_NAME, + "arguments": { "action": "remove", "branch": "feature/a" } + } + }) + .to_string(), + ) + .expect("branch admin request"); + assert_eq!(request.id, json!(7)); + assert_eq!( + request.action.expect("valid action"), + crate::branch::BranchAdminAction::Remove { + branch: "feature/a".to_string() + } + ); + + assert!( + parse_branch_admin_request( + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { "name": "tracedecay_status", "arguments": {} } + }) + .to_string() + ) + .is_none() + ); + } + + #[test] + fn branch_admin_parser_preserves_invalid_arguments_for_error_response() { + let request = parse_branch_admin_request( + &serde_json::json!({ + "jsonrpc": "2.0", + "id": "bad", + "method": "tools/call", + "params": { + "name": BRANCH_ADMIN_TOOL_NAME, + "arguments": { "action": "remove" } + } + }) + .to_string(), + ) + .expect("recognized hidden tool"); + assert!( + request + .action + .unwrap_err() + .contains("invalid branch administration arguments") + ); + } +} diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index d814b235d..334dea7bb 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -43,7 +43,9 @@ use tokio::time::Instant; use crate::config::SyncConfig; use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; -use super::log_daemon_event; +use super::{branch_admin::StoreAdministration, log_daemon_event}; + +mod store_maintenance; /// Degraded watchers fall back to polling git metadata every 5 minutes. const DEGRADED_POLL_INTERVAL: Duration = Duration::from_mins(5); @@ -203,6 +205,12 @@ pub struct GitWatcher { struct GitWatcherInner { config: SyncConfig, + /// Profile selected by the owning daemon. Every open and administration + /// action must use this identity rather than whichever profile a watcher + /// task happens to resolve from its environment. + profile_root: PathBuf, + /// Serializes every store-writing lifetime with daemon branch administration. + administration: StoreAdministration, /// Whether watching is enabled at all (`auto_watch`). When false every /// method is a no-op so the daemon runs exactly as before this feature. enabled: bool, @@ -227,29 +235,27 @@ impl Default for GitWatcher { impl GitWatcher { fn disabled() -> Self { - Self { - inner: Arc::new(GitWatcherInner { - config: SyncConfig::default(), - enabled: false, - sync_semaphore: Arc::new(Semaphore::new(1)), - projects: Mutex::new(HashMap::new()), - backstop_task: Mutex::new(None), - shutting_down: AtomicBool::new(false), - }), - } + Self::from_parts( + SyncConfig::default(), + StoreAdministration::default(), + current_profile_root(), + false, + ) } - /// Builds a watcher from the given sync config. Watching is gated on - /// `auto_watch`; when disabled the returned watcher is inert. - pub fn new(config: SyncConfig) -> Self { - if !config.auto_watch { - return Self::disabled(); - } + fn from_parts( + config: SyncConfig, + administration: StoreAdministration, + profile_root: PathBuf, + enabled: bool, + ) -> Self { let permits = config.max_concurrent_syncs.max(1); Self { inner: Arc::new(GitWatcherInner { config, - enabled: true, + profile_root, + administration, + enabled, sync_semaphore: Arc::new(Semaphore::new(permits)), projects: Mutex::new(HashMap::new()), backstop_task: Mutex::new(None), @@ -258,6 +264,32 @@ impl GitWatcher { } } + /// Builds a watcher from the given sync config. Watching is gated on + /// `auto_watch`; when disabled the returned watcher is inert. + /// + /// The test constructor deliberately uses the process's current profile + /// and a standalone coordinator so unit tests retain their existing behavior. + #[cfg(test)] + pub fn new(config: SyncConfig) -> Self { + Self::new_with_administration( + config, + StoreAdministration::default(), + current_profile_root(), + ) + } + + /// Builds a watcher bound to the daemon's profile and administration + /// coordinator. The daemon uses this constructor so watcher syncs and + /// destructive branch administration share one writer gate. + pub(super) fn new_with_administration( + config: SyncConfig, + administration: StoreAdministration, + profile_root: PathBuf, + ) -> Self { + let enabled = config.auto_watch; + Self::from_parts(config, administration, profile_root, enabled) + } + // Doctor watcher-health surface (follow-up wiring). #[allow(dead_code)] pub fn is_enabled(&self) -> bool { @@ -379,6 +411,30 @@ impl GitWatcher { } } +/// Falls back to an empty path only when the process cannot resolve a current +/// profile. Daemon construction passes its canonical profile explicitly; the +/// fallback exists solely for standalone/test construction. +fn current_profile_root() -> PathBuf { + crate::storage::default_profile_root().unwrap_or_default() +} + +/// Builds explicit open options for the daemon-owned profile. The global +/// registry path follows that same profile rather than the ambient process +/// environment used by ordinary CLI clients. +fn daemon_open_options(inner: &GitWatcherInner) -> TraceDecayOpenOptions { + if inner.profile_root.as_os_str().is_empty() { + // Keep standalone construction's former behavior when no current + // profile can be resolved: the normal open path will report failure + // rather than treating an empty path as a writable profile directory. + return TraceDecayOpenOptions::default(); + } + let profile_root = inner.profile_root.clone(); + TraceDecayOpenOptions { + global_db_path: Some(profile_root.join("global.db")), + profile_root: Some(profile_root), + } +} + /// Supervises one project's watch task: on panic, restart with capped /// exponential backoff so a transient watcher failure never permanently drops a /// project (the backstop still covers it in the meantime). @@ -630,13 +686,20 @@ async fn execute_plan( plan: DirtyPlan, ) { let root = &state.project_root; - let opts = TraceDecayOpenOptions::default(); + let opts = daemon_open_options(inner); // 1. Proactively track newly-created linked worktrees. for name in &plan.new_worktrees { - if let Some((wt_root, branch)) = resolve_worktree(common, name) { + if let Some((wt_root, branch)) = store_maintenance::resolve_worktree(common, name) { let _permit = inner.sync_semaphore.acquire().await; - match track_worktree_branch(wt_root.clone(), branch.clone()).await { + match store_maintenance::track_worktree_branch( + &inner.administration, + wt_root.clone(), + branch.clone(), + opts.clone(), + ) + .await + { Some(outcome) => { log_daemon_event( "git_watch_synced", @@ -690,7 +753,14 @@ async fn execute_plan( // advanced without `plan.dirty` capturing the branch name. if current_branch.is_some() || !plan.branches.is_empty() { let _permit = inner.sync_semaphore.acquire().await; - if sync_project(root, &opts, inner.config.full_sync_escalation_files).await { + if store_maintenance::sync_project( + root, + &opts, + inner.config.full_sync_escalation_files, + &inner.administration, + ) + .await + { state.health.mark_synced(); let mut fields = vec![ ("project", root.display().to_string()), @@ -713,105 +783,7 @@ async fn execute_plan( // 3. GC eligibility on ref/worktree deletion. if plan.gc_eligible { - run_gc(root, &opts, &inner.config).await; - } -} - -/// Opens the project store and runs a diff-scoped incremental sync (or a full -/// sync when the diff base is missing / oversized). Returns true on success. -/// `SyncLock` is treated as success (a peer synced). -/// -/// The `TraceDecay` sync/open futures are `Send` (the sync path scopes its -/// `!Send` `gix` values so they drop before every `.await`; see -/// `indexing::stamp_last_synced_commit`), so this awaits them directly on the -/// caller's task under the daemon-wide sync semaphore — no nested runtime. -async fn sync_project(root: &Path, opts: &TraceDecayOpenOptions, escalation: usize) -> bool { - let Ok(cg) = TraceDecay::open_with_options(root, opts.clone()).await else { - return false; - }; - let base = cg.last_synced_commit().await; - let result = match base { - Some(base) => match cg.stale_files_since_commit(&base, escalation) { - Some(files) if files.is_empty() => Ok(()), - Some(files) => cg.sync_if_stale_silent(&files).await, - // Base missing/unreachable or over the escalation limit → full. - None => cg.sync().await.map(|_| ()), - }, - None => cg.sync().await.map(|_| ()), - }; - matches!( - result, - Ok(()) | Err(crate::errors::TraceDecayError::SyncLock { .. }) - ) -} - -/// Proactively tracks a linked worktree's branch. Returns the -/// [`crate::branch::BranchAddOutcome`] name for logging, or `None` on error. -async fn track_worktree_branch(wt_root: PathBuf, branch: String) -> Option { - match TraceDecay::add_branch_tracking_with_options( - &wt_root, - &branch, - TraceDecayOpenOptions::default(), - ) - .await - { - Ok(outcome) => Some(format!("{outcome:?}")), - Err(_) => None, - } -} - -/// Resolves a `worktrees/` leaf to `(worktree_root, branch)` by reading -/// its `gitdir` file and the linked HEAD. -fn resolve_worktree(common: &Path, name: &str) -> Option<(PathBuf, String)> { - let wt_meta = common.join("worktrees").join(name); - let gitdir_file = wt_meta.join("gitdir"); - let gitdir_raw = std::fs::read_to_string(&gitdir_file).ok()?; - // `gitdir` points at `/.git`; the worktree root is its parent. - let gitdir = PathBuf::from(gitdir_raw.trim()); - let wt_root = gitdir.parent()?.to_path_buf(); - let branch = crate::branch::current_branch(&wt_root)?; - Some((wt_root, branch)) -} - -/// Runs branch-store GC for a project, logging what it removed. -/// -/// The layout resolution is async; the GC sweep itself is blocking fs work, so -/// it runs on the blocking pool to avoid stalling the reactor. -async fn run_gc(root: &Path, opts: &TraceDecayOpenOptions, config: &SyncConfig) { - let data_root = TraceDecay::initialized_store_layout_with_options(root, opts) - .await - .map(|layout| layout.data_root); - let Some(data_root) = data_root else { - return; - }; - let root_owned = root.to_path_buf(); - let branch_gc_days = config.branch_gc_days; - let orphan_db_gc_days = config.orphan_db_gc_days; - let Ok(report) = tokio::task::spawn_blocking(move || { - crate::branch::gc_dead_branch_stores( - &root_owned, - &data_root, - branch_gc_days, - orphan_db_gc_days, - ) - }) - .await - else { - return; - }; - if !report.removed_tracked.is_empty() || !report.removed_orphan_dbs.is_empty() { - log_daemon_event( - "git_watch_synced", - &[ - ("project", root.display().to_string()), - ("action", "gc".to_string()), - ("removed_tracked", report.removed_tracked.len().to_string()), - ( - "removed_orphans", - report.removed_orphan_dbs.len().to_string(), - ), - ], - ); + store_maintenance::run_gc(inner, root, &opts).await; } } @@ -823,6 +795,7 @@ async fn degraded_poll_loop( state: &Arc, common: Option<&Path>, ) { + let opts = daemon_open_options(inner); let mut last_sig: Option<(SystemTime, SystemTime)> = None; loop { state.health.beat(); @@ -830,10 +803,11 @@ async fn degraded_poll_loop( let sig = metadata_signature(common); if sig != last_sig && last_sig.is_some() { let _permit = inner.sync_semaphore.acquire().await; - if sync_project( + if store_maintenance::sync_project( &state.project_root, - &TraceDecayOpenOptions::default(), + &opts, inner.config.full_sync_escalation_files, + &inner.administration, ) .await { @@ -899,7 +873,7 @@ mod backstop { .config .backstop_interval_mins .saturating_mul(60); - let opts = TraceDecayOpenOptions::default(); + let opts = daemon_open_options(&watcher.inner); // Snapshot registered projects; cover those the watcher isn't keeping // fresh (stale/absent heartbeat) AND whose store is older than one @@ -913,15 +887,17 @@ mod backstop { }; let run_gc_now = last_gc.is_none_or(|t| t.elapsed() >= gc_period); + let mut gc_retry_needed = false; for (root, state) in entries { let snap = state.health.snapshot(); if snap.heartbeat_stale() && store_is_stale(&root, &opts, interval_secs).await { let _permit = watcher.inner.sync_semaphore.acquire().await; - if super::sync_project( + if super::store_maintenance::sync_project( &root, &opts, watcher.inner.config.full_sync_escalation_files, + &watcher.inner.administration, ) .await { @@ -936,12 +912,12 @@ mod backstop { } } - if run_gc_now { - super::run_gc(&root, &opts, &watcher.inner.config).await; + if run_gc_now && !super::store_maintenance::run_gc(&watcher.inner, &root, &opts).await { + gc_retry_needed = true; } } - if run_gc_now { + if run_gc_now && !gc_retry_needed { *last_gc = Some(Instant::now()); } } diff --git a/src/daemon/git_watch/store_maintenance.rs b/src/daemon/git_watch/store_maintenance.rs new file mode 100644 index 000000000..cadaedb55 --- /dev/null +++ b/src/daemon/git_watch/store_maintenance.rs @@ -0,0 +1,160 @@ +//! Store-maintenance operations performed by the daemon git watcher. +//! +//! Every operation that opens, tracks, or garbage-collects a store lives here +//! so its [`StoreAdministration`] lifetime is kept separate from the watcher +//! state machine. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::branch::BranchAdminAction; +use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; + +use super::super::{branch_admin::StoreAdministration, log_daemon_event}; +use super::GitWatcherInner; + +/// Opens the project store and runs a diff-scoped incremental sync (or a full +/// sync when the diff base is missing / oversized). Returns true on success. +/// `SyncLock` is treated as success (a peer synced). +/// +/// The `TraceDecay` sync/open futures are `Send` (the sync path scopes its +/// `!Send` `gix` values so they drop before every `.await`; see +/// `indexing::stamp_last_synced_commit`), so this awaits them directly on the +/// caller's task under the daemon-wide sync semaphore — no nested runtime. +pub(super) async fn sync_project( + root: &Path, + opts: &TraceDecayOpenOptions, + escalation: usize, + administration: &StoreAdministration, +) -> bool { + // Hold the administration gate from before opening the store until the + // `TraceDecay` handle drops. This prevents branch-store GC from selecting + // or unlinking the SQLite family while a watcher sync owns it. + administration + .with_writer(|| async { + let Ok(cg) = TraceDecay::open_with_options(root, opts.clone()).await else { + return false; + }; + let base = cg.last_synced_commit().await; + let result = match base { + Some(base) => match cg.stale_files_since_commit(&base, escalation) { + Some(files) if files.is_empty() => Ok(()), + Some(files) => cg.sync_if_stale_silent(&files).await, + // Base missing/unreachable or over the escalation limit → full. + None => cg.sync().await.map(|_| ()), + }, + None => cg.sync().await.map(|_| ()), + }; + let synced = matches!( + result, + Ok(()) | Err(crate::errors::TraceDecayError::SyncLock { .. }) + ); + drop(cg); + synced + }) + .await +} + +/// Proactively tracks a linked worktree's branch. Returns the +/// [`crate::branch::BranchAddOutcome`] name for logging, or `None` on error. +pub(super) async fn track_worktree_branch( + administration: &StoreAdministration, + wt_root: PathBuf, + branch: String, + opts: TraceDecayOpenOptions, +) -> Option { + administration + .with_writer(move || async move { + match TraceDecay::add_branch_tracking_with_options(&wt_root, &branch, opts).await { + Ok(outcome) => Some(format!("{outcome:?}")), + Err(_) => None, + } + }) + .await +} + +/// Resolves a `worktrees/` leaf to `(worktree_root, branch)` by reading +/// its `gitdir` file and the linked HEAD. +pub(super) fn resolve_worktree(common: &Path, name: &str) -> Option<(PathBuf, String)> { + let wt_meta = common.join("worktrees").join(name); + let gitdir_file = wt_meta.join("gitdir"); + let gitdir_raw = std::fs::read_to_string(&gitdir_file).ok()?; + // `gitdir` points at `/.git`; the worktree root is its parent. + let gitdir = PathBuf::from(gitdir_raw.trim()); + let wt_root = gitdir.parent()?.to_path_buf(); + let branch = crate::branch::current_branch(&wt_root)?; + Some((wt_root, branch)) +} + +/// Runs branch-store GC for a project through the daemon administration +/// coordinator, logging what it removed. Returns `false` when layout resolution +/// or administration fails so the backstop keeps the GC cadence eligible for a +/// retry. +pub(super) async fn run_gc( + inner: &Arc, + root: &Path, + opts: &TraceDecayOpenOptions, +) -> bool { + // Layout discovery is read-only and deliberately stays outside both writer + // gates. Only the coordinator performs the destructive administration. + let data_root = match TraceDecay::try_initialized_store_layout_with_options(root, opts).await { + Ok(Some(layout)) => layout.data_root, + Ok(None) => return true, + Err(error) => { + log_daemon_event( + "git_watch_degraded", + &[ + ("project", root.display().to_string()), + ("reason", "branch_gc_layout_failed".to_string()), + ("error", error.to_string()), + ], + ); + return false; + } + }; + + // Preserve the sync-semaphore → administration-gate acquisition order used + // by sync and worktree tracking. The coordinator owns the writer gate and + // its process/store-holder safety checks. + let _permit = inner.sync_semaphore.acquire().await; + let report = inner + .administration + .execute_branch_admin_in_layout( + root, + &data_root, + BranchAdminAction::Gc, + inner.config.branch_gc_days, + inner.config.orphan_db_gc_days, + ) + .await; + let report = match report { + Ok(report) => report, + Err(error) => { + log_daemon_event( + "git_watch_degraded", + &[ + ("project", root.display().to_string()), + ("reason", "branch_gc_deferred".to_string()), + ("error", error.to_string()), + ], + ); + return false; + } + }; + + if !report.removed_branches.is_empty() || !report.removed_orphan_dbs.is_empty() { + log_daemon_event( + "git_watch_synced", + &[ + ("project", root.display().to_string()), + ("action", "gc".to_string()), + ("removed_tracked", report.removed_branches.len().to_string()), + ( + "removed_orphans", + report.removed_orphan_dbs.len().to_string(), + ), + ], + ); + } + true +} diff --git a/src/daemon/git_watch/tests.rs b/src/daemon/git_watch/tests.rs index 4c1421a73..5cf26734e 100644 --- a/src/daemon/git_watch/tests.rs +++ b/src/daemon/git_watch/tests.rs @@ -2,6 +2,7 @@ use super::*; use notify::event::EventAttributes; use std::process::Command; +use tokio::sync::oneshot; #[test] fn dirty_set_coalesces_and_takes_once() { @@ -101,6 +102,74 @@ fn heartbeat_staleness() { assert!(old.heartbeat_stale()); } +/// The shared coordinator must not start a second store-writing lifetime while +/// the first one is held. Paused Tokio time plus Notify/oneshot handshakes make +/// this a scheduling-state assertion rather than a wall-clock sleep. +#[tokio::test(start_paused = true)] +async fn writer_administration_blocks_until_the_gate_is_released() { + let administration = StoreAdministration::default(); + let holder_entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + + let holder = { + let administration = administration.clone(); + let holder_entered = Arc::clone(&holder_entered); + let release = Arc::clone(&release); + tokio::spawn(async move { + administration + .with_writer(move || async move { + holder_entered.notify_one(); + release.notified().await; + }) + .await; + }) + }; + tokio::time::timeout(Duration::from_secs(1), holder_entered.notified()) + .await + .expect("holder must acquire the writer gate"); + + let (waiter_ready_tx, waiter_ready_rx) = oneshot::channel(); + let waiter_entered = Arc::new(Notify::new()); + let waiter = { + let administration = administration.clone(); + let waiter_entered = Arc::clone(&waiter_entered); + tokio::spawn(async move { + waiter_ready_tx + .send(()) + .expect("waiter readiness receiver must remain alive"); + administration + .with_writer(move || async move { + waiter_entered.notify_one(); + }) + .await; + }) + }; + waiter_ready_rx + .await + .expect("waiter task must reach the gate"); + tokio::task::yield_now().await; + + assert!( + tokio::time::timeout(Duration::from_secs(1), waiter_entered.notified()) + .await + .is_err(), + "a second writer must remain blocked while the first writer holds the gate" + ); + + release.notify_one(); + tokio::time::timeout(Duration::from_secs(1), waiter_entered.notified()) + .await + .expect("releasing the gate must admit the waiting writer"); + tokio::time::timeout(Duration::from_secs(1), holder) + .await + .expect("holder task must finish") + .expect("holder task must not panic"); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("waiter task must finish") + .expect("waiter task must not panic"); +} + // ---- Real `GitWatcher` tests (drive the public API + the real debounce // path, not a reimplemented helper). The integration suite cannot reach // these crate-private internals because `git_watch` is not re-exported. ---- diff --git a/src/daemon/pr_autotrack.rs b/src/daemon/pr_autotrack.rs index 413d674a5..feda61bed 100644 --- a/src/daemon/pr_autotrack.rs +++ b/src/daemon/pr_autotrack.rs @@ -43,10 +43,23 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use tokio::time::Instant; +use crate::branch::{BranchAdminAction, BranchAdminOutcome}; use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; +use super::branch_admin::StoreAdministration; use super::log_daemon_event; +#[derive(Clone, Copy)] +struct PrStoreAdministration<'a> { + daemon: &'a StoreAdministration, +} + +impl<'a> PrStoreAdministration<'a> { + fn new(daemon: &'a StoreAdministration) -> Self { + Self { daemon } + } +} + /// Filename of the PR-autotrack state sidecar, stored next to `branch-meta.json` /// in the project's store data root. const STATE_FILENAME: &str = "pr-autotrack.json"; @@ -449,6 +462,19 @@ pub async fn reconcile_project( data_root: &Path, discovery: &PrDiscovery, cap: usize, +) -> ReconcileReport { + let administration = StoreAdministration::default(); + let administration = PrStoreAdministration::new(&administration); + reconcile_project_with_administration(repo_root, data_root, discovery, cap, administration) + .await +} + +async fn reconcile_project_with_administration( + repo_root: &Path, + data_root: &Path, + discovery: &PrDiscovery, + cap: usize, + administration: PrStoreAdministration<'_>, ) -> ReconcileReport { let mut state = load_state(data_root); let mut report = ReconcileReport { @@ -487,7 +513,7 @@ pub async fn reconcile_project( // whose PR is neither open nor managed is an orphan left by a daemon // crash between `worktree add` and `save_state`. Remove it so stale // worktrees don't accumulate on disk across restarts. - sweep_orphan_pr_worktrees(repo_root, data_root, &desired, &state); + sweep_orphan_pr_worktrees(repo_root, data_root, &desired, &state, administration).await; let stale: Vec = state .managed @@ -496,20 +522,28 @@ pub async fn reconcile_project( .cloned() .collect(); for label in stale { - if let Some(managed) = state.managed.get(&label).cloned() { - untrack_pr(repo_root, data_root, &label, &managed).await; - state.managed.remove(&label); - state_dirty = true; - report.untracked.push(label.clone()); - log_daemon_event( - "pr_autotrack", - &[ - ("project", repo_root.display().to_string()), - ("action", "untracked".to_string()), - ("branch", label), - ("pr", managed.pr.to_string()), - ], - ); + let Some(managed) = state.managed.get(&label).cloned() else { + continue; + }; + match untrack_pr(repo_root, data_root, &label, &managed, administration).await { + Ok(()) => { + state.managed.remove(&label); + state_dirty = true; + report.untracked.push(label.clone()); + log_daemon_event( + "pr_autotrack", + &[ + ("project", repo_root.display().to_string()), + ("action", "untracked".to_string()), + ("branch", label), + ("pr", managed.pr.to_string()), + ], + ); + } + Err(reason) => { + report.failures.push((label.clone(), reason.clone())); + log_pr_skip(repo_root, Some(&label), Some(managed.pr), &reason); + } } } } @@ -535,11 +569,22 @@ pub async fn reconcile_project( if let Some(managed) = current { // A changed remote head invalidates the entire branch graph. Drop // the owned store before rebuilding so stale data is never served. - untrack_pr(repo_root, data_root, label, &managed).await; - state.managed.remove(label); - state_dirty = true; + // If removal is busy or fails, leave the old state and owned Git + // artifacts intact; tracking the new head would otherwise mix the + // two generations under one label. + match untrack_pr(repo_root, data_root, label, &managed, administration).await { + Ok(()) => { + state.managed.remove(label); + state_dirty = true; + } + Err(reason) => { + report.failures.push((label.clone(), reason.clone())); + log_pr_skip(repo_root, Some(label), Some(managed.pr), &reason); + continue; + } + } } - match track_pr(repo_root, data_root, pr).await { + match track_pr(repo_root, data_root, pr, administration).await { Ok(managed) => { let dirty_before_insert = state_dirty; state.managed.insert(label.clone(), managed.clone()); @@ -562,12 +607,36 @@ pub async fn reconcile_project( ); } Err(error) => { - state.managed.remove(label); - state_dirty = dirty_before_insert; - untrack_pr(repo_root, data_root, label, &managed).await; - let reason = format!("failed to persist managed state: {error}"); - report.failures.push((label.clone(), reason.clone())); - log_pr_skip(repo_root, Some(label), Some(pr.number), &reason); + let persist_reason = format!("failed to persist managed state: {error}"); + match untrack_pr(repo_root, data_root, label, &managed, administration) + .await + { + Ok(()) => { + state.managed.remove(label); + state_dirty = dirty_before_insert; + report + .failures + .push((label.clone(), persist_reason.clone())); + log_pr_skip( + repo_root, + Some(label), + Some(pr.number), + &persist_reason, + ); + } + Err(cleanup_reason) => { + // The successfully-added branch remains owned and + // recoverable. Do not drop it from in-memory state + // before the coordinator has actually removed its + // store, and expose both failures to the caller. + state_dirty = dirty_before_insert; + let reason = format!( + "{persist_reason}; rollback cleanup failed: {cleanup_reason}" + ); + report.failures.push((label.clone(), reason.clone())); + log_pr_skip(repo_root, Some(label), Some(pr.number), &reason); + } + } } } } @@ -598,6 +667,7 @@ async fn track_pr( repo_root: &Path, data_root: &Path, pr: &DiscoveredPr, + administration: PrStoreAdministration<'_>, ) -> std::result::Result { let label = pr_label(pr.number); let tracking_ref = pr_tracking_ref(pr.number); @@ -616,7 +686,7 @@ async fn track_pr( let validated_orphan = branch_ready && tracking_ref_ready && (!worktree.exists() || worktree_ready); if graph_ready || validated_orphan { - let _ = crate::branch::remove_tracked_branch_store(data_root, &label); + remove_pr_store(repo_root, data_root, &label, administration).await?; cleanup_pr_worktree(repo_root, data_root, pr.number, &pr.head_sha, true); } @@ -625,20 +695,58 @@ async fn track_pr( let tref = tracking_ref.clone(); let label_for_prep = label.clone(); let expected_head = pr.head_sha.clone(); - // git operations are blocking; keep them off the reactor. - let prep = tokio::task::spawn_blocking(move || { + // git operations are blocking; keep them off the reactor. A failed fetch or + // worktree add can still have left owned artifacts behind, so reconcile its + // store through the coordinator before attempting Git cleanup. + match tokio::task::spawn_blocking(move || { prepare_pr_worktree(&repo, &wt, &tref, &label_for_prep, &expected_head) }) .await - .map_err(|e| format!("join error: {e}"))?; - prep?; + { + Ok(Ok(())) => {} + Ok(Err(reason)) => { + return cleanup_failed_track( + repo_root, + data_root, + pr.number, + &pr.head_sha, + &label, + administration, + &reason, + ) + .await; + } + Err(error) => { + let reason = format!("worktree preparation join error: {error}"); + return cleanup_failed_track( + repo_root, + data_root, + pr.number, + &pr.head_sha, + &label, + administration, + &reason, + ) + .await; + } + } - match TraceDecay::add_branch_tracking_with_options( - &worktree, - &label, - TraceDecayOpenOptions::default(), - ) - .await + // The branch add prepares metadata, syncs its new SQLite family, and then + // finalizes metadata. Construct its future only after the writer gate is + // acquired so a coordinator removal cannot observe a half-prepared branch. + let add_worktree = worktree.clone(); + let add_label = label.clone(); + match administration + .daemon + .with_writer(move || async move { + TraceDecay::add_branch_tracking_with_options( + &add_worktree, + &add_label, + TraceDecayOpenOptions::default(), + ) + .await + }) + .await { Ok(crate::branch::BranchAddOutcome::Added) => Ok(ManagedPr { pr: pr.number, @@ -650,9 +758,8 @@ async fn track_pr( Ok(outcome) => { // Deferred may leave branch metadata behind. AlreadyTracked can // be an orphan from an interrupted prior cycle. Neither proves a - // completed sync, so clear only our internal label and retry later. - let _ = crate::branch::remove_tracked_branch_store(data_root, &label); - cleanup_pr_worktree(repo_root, data_root, pr.number, &pr.head_sha, true); + // completed sync, so remove its store through the coordinator + // before releasing the owned worktree/refs for a future retry. let reason = match outcome { crate::branch::BranchAddOutcome::NotIndexed => "project not indexed", crate::branch::BranchAddOutcome::AlreadyTracked => { @@ -661,15 +768,87 @@ async fn track_pr( crate::branch::BranchAddOutcome::Deferred => "branch tracking deferred", crate::branch::BranchAddOutcome::Added => unreachable!(), }; - Err(reason.to_string()) + cleanup_failed_track( + repo_root, + data_root, + pr.number, + &pr.head_sha, + &label, + administration, + reason, + ) + .await } - Err(e) => { - cleanup_pr_worktree(repo_root, data_root, pr.number, &pr.head_sha, true); - Err(e.to_string()) + Err(error) => { + let reason = error.to_string(); + cleanup_failed_track( + repo_root, + data_root, + pr.number, + &pr.head_sha, + &label, + administration, + &reason, + ) + .await } } } +/// Removes a store selected by its known project layout. `Remove` does not use +/// the retention values, so zero keeps this path independent of config/layout +/// re-resolution while retaining the coordinator's safety checks. +async fn remove_pr_store( + repo_root: &Path, + data_root: &Path, + label: &str, + administration: PrStoreAdministration<'_>, +) -> std::result::Result<(), String> { + let report = administration + .daemon + .execute_branch_admin_in_layout( + repo_root, + data_root, + BranchAdminAction::Remove { + branch: label.to_string(), + }, + 0, + 0, + ) + .await + .map_err(|error| format!("branch-store removal failed: {error}"))?; + match report.outcome { + BranchAdminOutcome::Removed + | BranchAdminOutcome::NotTracked + | BranchAdminOutcome::NoTracking => Ok(()), + outcome => Err(format!( + "branch-store removal returned unexpected outcome {outcome:?}" + )), + } +} + +/// Rolls back a failed branch add without deleting owned Git artifacts until +/// the coordinator proves the corresponding branch store is gone. +async fn cleanup_failed_track( + repo_root: &Path, + data_root: &Path, + pr: u64, + head_sha: &str, + label: &str, + administration: PrStoreAdministration<'_>, + original_reason: &str, +) -> std::result::Result { + match remove_pr_store(repo_root, data_root, label, administration).await { + Ok(()) => { + cleanup_pr_worktree(repo_root, data_root, pr, head_sha, true); + Err(original_reason.to_string()) + } + Err(cleanup_reason) => Err(format!( + "{original_reason}; failed to remove incomplete branch store: {cleanup_reason}" + )), + } +} + /// Fetches `refs/pull//head` into `tracking_ref` and adds a linked worktree /// checked out on a local branch named `label` (`pr/`) at that ref. /// @@ -738,8 +917,15 @@ fn prepare_pr_worktree( } /// Untracks a managed PR: removes its branch store, its worktree, its local -/// tracking branch, and its ref. -async fn untrack_pr(repo_root: &Path, data_root: &Path, label: &str, managed: &ManagedPr) { +/// tracking branch, and its ref. The Git artifacts are released only after the +/// coordinator reports that the store is gone (or was already absent). +async fn untrack_pr( + repo_root: &Path, + data_root: &Path, + label: &str, + managed: &ManagedPr, + administration: PrStoreAdministration<'_>, +) -> std::result::Result<(), String> { let expected_label = pr_label(managed.pr); let legacy_label = format!("pr/{}", managed.pr); let is_legacy = label == legacy_label; @@ -751,14 +937,9 @@ async fn untrack_pr(repo_root: &Path, data_root: &Path, label: &str, managed: &M || managed.worktree != expected_worktree || managed.tracking_ref != expected_ref { - return; + return Err("managed PR entry does not own the requested branch artifacts".to_string()); } - let data = data_root.to_path_buf(); - let label_owned = label.to_string(); - let _ = tokio::task::spawn_blocking(move || { - crate::branch::remove_tracked_branch_store(&data, &label_owned) - }) - .await; + remove_pr_store(repo_root, data_root, label, administration).await?; // `pr/` is the pre-namespace persisted format. Remove its owned store // and worktree once, but never delete that ambiguous local branch name. cleanup_pr_worktree( @@ -768,6 +949,7 @@ async fn untrack_pr(repo_root: &Path, data_root: &Path, label: &str, managed: &M &managed.head_sha, !is_legacy, ); + Ok(()) } /// Removes leaked PR worktrees from interrupted prior cycles. @@ -779,11 +961,12 @@ async fn untrack_pr(repo_root: &Path, data_root: &Path, label: &str, managed: &M /// Its synthetic branch and fetch ref are cleaned up alongside the checkout. /// Only called for a *complete* discovery (never when `partial`), so an open PR /// that merely fell outside a truncated listing is never swept. -fn sweep_orphan_pr_worktrees( +async fn sweep_orphan_pr_worktrees( repo_root: &Path, data_root: &Path, desired: &BTreeMap, state: &PrAutotrackState, + administration: PrStoreAdministration<'_>, ) { let worktrees_dir = data_root.join("pr-worktrees"); let Ok(entries) = std::fs::read_dir(&worktrees_dir) else { @@ -803,16 +986,22 @@ fn sweep_orphan_pr_worktrees( if managed_prs.contains(&number) || desired.contains_key(&pr_label(number)) { continue; } - cleanup_pr_worktree(repo_root, data_root, number, "", true); - log_daemon_event( - "pr_autotrack", - &[ - ("project", repo_root.display().to_string()), - ("action", "swept".to_string()), - ("pr", number.to_string()), - ("reason", "orphan worktree".to_string()), - ], - ); + let label = pr_label(number); + match remove_pr_store(repo_root, data_root, &label, administration).await { + Ok(()) => { + cleanup_pr_worktree(repo_root, data_root, number, "", true); + log_daemon_event( + "pr_autotrack", + &[ + ("project", repo_root.display().to_string()), + ("action", "swept".to_string()), + ("pr", number.to_string()), + ("reason", "orphan worktree".to_string()), + ], + ); + } + Err(reason) => log_pr_skip(repo_root, Some(&label), Some(number), &reason), + } } } @@ -876,20 +1065,34 @@ fn remove_worktree(repo_root: &Path, worktree: &Path) { /// Spawns the PR-autotrack poll loop. Cheap and inert when no registered project /// has the feature enabled — each tick only reads per-project config. pub fn spawn(global_db_path: Option) -> tokio::task::JoinHandle<()> { + spawn_with_administration(global_db_path, StoreAdministration::default()) +} + +/// Spawns the PR-autotrack poll loop with the daemon's shared store coordinator. +/// The coordinator serializes PR additions and destructive branch administration +/// with every other daemon connection that owns the same store family. +pub(super) fn spawn_with_administration( + global_db_path: Option, + administration: StoreAdministration, +) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { - run(global_db_path).await; + run(global_db_path, administration).await; }) } -async fn run(global_db_path: Option) { +async fn run(global_db_path: Option, administration: StoreAdministration) { let mut last_poll: HashMap = HashMap::new(); loop { - tick(global_db_path.as_deref(), &mut last_poll).await; + tick(global_db_path.as_deref(), &mut last_poll, &administration).await; tokio::time::sleep(BASE_TICK).await; } } -async fn tick(global_db_path: Option<&Path>, last_poll: &mut HashMap) { +async fn tick( + global_db_path: Option<&Path>, + last_poll: &mut HashMap, + administration: &StoreAdministration, +) { let window = 14 * 86_400; let cap = 64; let db = match global_db_path { @@ -912,19 +1115,19 @@ async fn tick(global_db_path: Option<&Path>, last_poll: &mut HashMap return, // join error (task panicked/cancelled) }; - let report = - reconcile_project(&repo_root, &data_root, &discovery, MAX_NEW_TRACKS_PER_CYCLE).await; + let report = reconcile_project_with_administration( + &repo_root, + &data_root, + &discovery, + MAX_NEW_TRACKS_PER_CYCLE, + PrStoreAdministration::new(administration), + ) + .await; let managed = load_state(&data_root).managed.len(); log_daemon_event( "pr_autotrack", @@ -981,6 +1190,14 @@ async fn poll_project(repo_root: PathBuf) { /// (empty desired set) to clean the managed set down to empty. Cheap and inert /// once nothing is managed, so it is safe to call every poll cadence. pub async fn teardown_disabled_project(repo_root: &Path) { + let administration = StoreAdministration::default(); + teardown_disabled_project_with_administration(repo_root, &administration).await; +} + +async fn teardown_disabled_project_with_administration( + repo_root: &Path, + administration: &StoreAdministration, +) { let opts = TraceDecayOpenOptions::default(); let Some(layout) = TraceDecay::initialized_store_layout_with_options(repo_root, &opts).await else { @@ -992,11 +1209,12 @@ pub async fn teardown_disabled_project(repo_root: &Path) { } // Empty (complete, non-partial) discovery → every managed entry is stale → // untracked and cleaned up. - let report = reconcile_project( + let report = reconcile_project_with_administration( repo_root, &data_root, &PrDiscovery::default(), MAX_NEW_TRACKS_PER_CYCLE, + PrStoreAdministration::new(administration), ) .await; log_daemon_event( diff --git a/src/daemon/pr_autotrack/tests.rs b/src/daemon/pr_autotrack/tests.rs index 6ea924f96..eb8efbb10 100644 --- a/src/daemon/pr_autotrack/tests.rs +++ b/src/daemon/pr_autotrack/tests.rs @@ -162,6 +162,10 @@ fn state_round_trips_and_defaults_when_absent() { // ---- Reconcile: removal + idempotency (no index required) ------------------- +fn prove_no_open_store_holders(_database_paths: &[PathBuf]) -> crate::errors::Result<()> { + Ok(()) +} + #[tokio::test] async fn reconcile_untracks_closed_pr_and_cleans_store() { use crate::branch_meta::{BranchMeta, load_branch_meta, save_branch_meta}; @@ -191,11 +195,15 @@ async fn reconcile_untracks_closed_pr_and_cleans_store() { save_state(data_root.path(), &state).unwrap(); // Empty discovery => PR 5 is closed/merged => must be untracked. - let report = reconcile_project( + let daemon_administration = + StoreAdministration::with_external_holder_verifier(prove_no_open_store_holders); + let administration = PrStoreAdministration::new(&daemon_administration); + let report = reconcile_project_with_administration( repo_root.path(), data_root.path(), &PrDiscovery::default(), 10, + administration, ) .await; diff --git a/src/daemon/scheduler.rs b/src/daemon/scheduler.rs new file mode 100644 index 000000000..11c7e1e26 --- /dev/null +++ b/src/daemon/scheduler.rs @@ -0,0 +1,970 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio::time::{Duration, timeout}; + +use crate::client_identity::DaemonClientIdentity; +use crate::errors::{Result, TraceDecayError}; + +use super::{ + DAEMON_TASK_ABORT_DEADLINE, DaemonEngine, DaemonHandshake, ProjectServerKey, log_daemon_event, + open_existing_project_with_options, +}; + +pub(super) fn scheduler_task_log_fields( + project_path: &Path, + task: crate::automation::backend::AgentTaskKind, + outcome: &str, +) -> Vec<(&'static str, String)> { + vec![ + ("project", project_path.display().to_string()), + ( + "task", + crate::automation::backend::task_key(task).to_string(), + ), + ("outcome", outcome.to_string()), + ] +} + +fn log_scheduler_task_start(project_path: &Path, task: crate::automation::backend::AgentTaskKind) { + log_daemon_event( + "scheduler_task", + &scheduler_task_log_fields(project_path, task, "start"), + ); +} + +fn scheduler_task_error_log_fields( + project_path: &Path, + task: crate::automation::backend::AgentTaskKind, + error: &TraceDecayError, +) -> Vec<(&'static str, String)> { + vec![ + ("project", project_path.display().to_string()), + ( + "task", + crate::automation::backend::task_key(task).to_string(), + ), + ("error", error.to_string()), + ] +} + +fn log_scheduler_task_error( + project_path: &Path, + task: crate::automation::backend::AgentTaskKind, + error: &TraceDecayError, +) { + log_daemon_event( + "scheduler_task_error", + &scheduler_task_error_log_fields(project_path, task, error), + ); +} + +fn scheduler_record_log_fields( + project_path: &Path, + record: &crate::automation::run_ledger::AutomationRunLedgerRecord, +) -> Vec<(&'static str, String)> { + use crate::automation::run_ledger::AutomationRunStatus; + + let outcome = match record.status { + AutomationRunStatus::Succeeded => "complete", + AutomationRunStatus::Failed => "error", + AutomationRunStatus::Skipped => "skipped", + AutomationRunStatus::Queued => "queued", + AutomationRunStatus::Running => "running", + }; + let task = record + .task_key + .as_deref() + .unwrap_or_else(|| crate::automation::backend::task_key(record.task)) + .to_string(); + let mut fields = vec![ + ("project", project_path.display().to_string()), + ("task", task), + ("outcome", outcome.to_string()), + ("run_id", record.run_id.clone()), + ]; + if let Some(reason) = record.fallback_status.as_ref().or(record.error.as_ref()) { + fields.push(("reason", reason.clone())); + } + fields +} + +#[cfg(test)] +pub(super) fn daemon_scheduler_record_log_line( + project_path: &Path, + record: &crate::automation::run_ledger::AutomationRunLedgerRecord, +) -> String { + super::format_daemon_log_line( + "scheduler_task", + &scheduler_record_log_fields(project_path, record), + ) +} + +fn log_daemon_scheduler_record( + project_path: &Path, + record: &crate::automation::run_ledger::AutomationRunLedgerRecord, +) { + log_daemon_event( + "scheduler_task", + &scheduler_record_log_fields(project_path, record), + ); +} + +pub(super) fn automation_staged_log_fields( + project_path: &Path, + counts: crate::automation::staged_notice::AutomationPendingCounts, +) -> Vec<(&'static str, String)> { + vec![ + ("project", project_path.display().to_string()), + ( + "pending_fact_proposals", + counts.pending_fact_proposals.to_string(), + ), + ("pending_skills", counts.pending_skills.to_string()), + ] +} + +/// After a scheduler tick where at least one task completed, emit a stable +/// `event=automation_staged` line with managed-skill review counts plus fact +/// proposal telemetry. +/// Silent when nothing is pending or the profile root is unavailable. +async fn log_automation_staged_if_pending(project_path: &Path, dashboard_root: &Path) { + let Ok(profile_root) = crate::storage::default_profile_root() else { + return; + }; + let counts = crate::automation::staged_notice::count_pending_automation_output( + dashboard_root, + &profile_root, + ) + .await; + if counts.total() == 0 { + return; + } + log_daemon_event( + "automation_staged", + &automation_staged_log_fields(project_path, counts), + ); +} + +pub(super) struct AutomationSchedulerHandle { + pub(super) task: JoinHandle<()>, + pub(super) wake: Arc, +} + +impl DaemonEngine { + pub(super) async fn ensure_automation_scheduler( + &self, + key: ProjectServerKey, + project_path: PathBuf, + handshake: DaemonHandshake, + ) { + self.store_administration + .with_writer(|| async move { + if !self.lifecycle.accepting() { + return; + } + { + let schedulers = self + .store_administration + .automation_schedulers() + .lock() + .await; + if schedulers.contains_key(&key) { + return; + } + } + + let configured = match Box::pin(automation_scheduler_has_work_for_project( + &project_path, + &handshake, + )) + .await + { + Ok(configured) => configured, + Err(e) => { + log_daemon_event( + "scheduler_config", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + false + } + }; + if !configured { + log_daemon_event( + "scheduler_config", + &[ + ("project", project_path.display().to_string()), + ("outcome", "skipped".to_string()), + ("reason", "not_configured".to_string()), + ], + ); + return; + } + + self.start_automation_scheduler(key, project_path, handshake) + .await; + }) + .await; + } + + pub(super) fn automation_scheduler_reconciler( + &self, + current_key: Arc>, + project_path: PathBuf, + handshake: DaemonHandshake, + ) -> crate::dashboard::AutomationSchedulerReconciler { + let engine = self.clone(); + std::sync::Arc::new(move || { + let engine = engine.clone(); + let current_key = Arc::clone(¤t_key); + let project_path = project_path.clone(); + let handshake = handshake.clone(); + tokio::spawn(async move { + let key = current_key.lock().await.clone(); + engine + .ensure_automation_scheduler(key.clone(), project_path, handshake) + .await; + if let Some(handle) = engine + .store_administration + .automation_schedulers() + .lock() + .await + .get(&key) + { + handle.wake.notify_one(); + } + }); + }) + } + + async fn start_automation_scheduler( + &self, + key: ProjectServerKey, + project_path: PathBuf, + handshake: DaemonHandshake, + ) { + if !self.lifecycle.accepting() { + return; + } + let mut schedulers = self + .store_administration + .automation_schedulers() + .lock() + .await; + if !self.lifecycle.accepting() || schedulers.contains_key(&key) { + return; + } + let wake = Arc::new(tokio::sync::Notify::new()); + let loop_wake = Arc::clone(&wake); + let task = tokio::spawn(async move { + Box::pin(run_automation_scheduler_loop( + project_path, + handshake, + loop_wake, + )) + .await; + }); + schedulers.insert(key, AutomationSchedulerHandle { task, wake }); + } + + pub(super) async fn shutdown_automation_schedulers(&self) { + let scheduler_handles: Vec> = self + .store_administration + .with_writer(|| async { + let mut schedulers = self + .store_administration + .automation_schedulers() + .lock() + .await; + schedulers.drain().map(|(_, handle)| handle.task).collect() + }) + .await; + let _child_shutdown = crate::sessions::codex_app_server::begin_codex_app_server_shutdown(); + for handle in &scheduler_handles { + handle.abort(); + } + let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, async { + for handle in scheduler_handles { + let _ = handle.await; + } + }) + .await; + } +} + +async fn run_automation_scheduler_loop( + project_path: PathBuf, + handshake: DaemonHandshake, + wake: Arc, +) { + loop { + match Box::pin(automation_scheduler_has_work_for_project( + &project_path, + &handshake, + )) + .await + { + Ok(true) => {} + Ok(false) => { + log_daemon_event( + "scheduler_exit", + &[ + ("project", project_path.display().to_string()), + ("reason", "not_configured".to_string()), + ], + ); + break; + } + Err(e) => { + // A transient failure (e.g. a momentarily corrupt jobs file or + // a project that cannot be opened this instant) must not + // permanently kill the scheduler loop. Surface the cause and + // retry on the next tick instead of exiting for good. + log_daemon_event( + "scheduler_project_open", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + tokio::time::sleep(Duration::from_secs( + crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS, + )) + .await; + continue; + } + } + log_daemon_event( + "scheduler_tick", + &[ + ("project", project_path.display().to_string()), + ("outcome", "start".to_string()), + ], + ); + if let Err(e) = Box::pin(run_automation_scheduler_tick(&project_path, &handshake)).await { + log_daemon_event( + "scheduler_tick", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + } + if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", error.to_string()), + ], + ); + } + let tick_secs = Box::pin(automation_scheduler_tick_secs_for_project( + &project_path, + &handshake, + )) + .await; + log_daemon_event( + "scheduler_sleep", + &[ + ("project", project_path.display().to_string()), + ("next_tick_secs", tick_secs.to_string()), + ], + ); + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(tick_secs)) => {} + () = wake.notified() => { + // Receipts arrive at tool cadence. Wait for a short quiet + // period and reset it for every later receipt, producing one + // review for the burst rather than one review per command. + loop { + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(5)) => break, + () = wake.notified() => {} + } + } + if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", error.to_string()), + ], + ); + } + } + } + } +} + +async fn automation_scheduler_has_work_for_project( + project_path: &Path, + handshake: &DaemonHandshake, +) -> Result { + let cg = Box::pin(open_existing_project_with_options( + project_path, + handshake.open_options(), + )) + .await?; + let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; + automation_scheduler_has_work(&cg, &config).await +} + +pub(super) async fn automation_scheduler_tick_secs_for_project( + project_path: &Path, + handshake: &DaemonHandshake, +) -> u64 { + match Box::pin(open_existing_project_with_options( + project_path, + handshake.open_options(), + )) + .await + { + Ok(cg) => { + match effective_automation_config_for_project(&cg, &handshake.client_identity).await { + Ok(config) => config.scheduler_tick_secs, + Err(e) => { + log_daemon_event( + "scheduler_config", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS + } + } + } + Err(e) => { + log_daemon_event( + "scheduler_project_open", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + crate::automation::config::DEFAULT_SCHEDULER_TICK_SECS + } + } +} + +/// Minimum wall-clock interval between global-database retention passes, +/// shared across every project's scheduler loop so retention runs at most +/// this often no matter how many projects are active. +const RETENTION_MIN_INTERVAL_SECS: u64 = 6 * 60 * 60; + +static LAST_GLOBAL_RETENTION: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// Returns whether a retention pass is due, recording `now` as the last run +/// when it is. The gate is process-global so N project loops do not each run +/// their own retention every tick. +fn global_retention_pass_due(now: std::time::Instant) -> bool { + let mut guard = match LAST_GLOBAL_RETENTION.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let due = guard.is_none_or(|last| { + now.duration_since(last) >= Duration::from_secs(RETENTION_MIN_INTERVAL_SECS) + }); + if due { + *guard = Some(now); + } + due +} + +/// Applies the configured retention windows to the global telemetry tables, +/// at most once per [`RETENTION_MIN_INTERVAL_SECS`]. Best-effort: retention is +/// housekeeping, so failures are logged and never abort a scheduler tick. +async fn maybe_run_global_retention( + project_path: &Path, + config: &crate::automation::config::AutomationConfig, +) { + if !global_retention_pass_due(std::time::Instant::now()) { + return; + } + let db = match crate::global_db::GlobalDb::try_open().await { + Ok(Some(db)) => db, + Ok(None) => return, + Err(error) => { + log_daemon_event( + "retention_prune", + &[ + ("project", project_path.display().to_string()), + ("outcome", "open_rejected".to_string()), + ("error", error.to_string()), + ], + ); + return; + } + }; + let now_secs = crate::tracedecay::current_timestamp(); + match db.prune_global_retention(&config.retention, now_secs).await { + Ok(reports) => { + for report in reports { + if report.applied && report.rows > 0 { + log_daemon_event( + "retention_prune", + &[ + ("project", project_path.display().to_string()), + ("table", report.table.to_string()), + ("rows", report.rows.to_string()), + ( + "window_days", + report + .window_days + .map_or_else(|| "unlimited".to_string(), |d| d.to_string()), + ), + ], + ); + } + } + } + Err(e) => { + log_daemon_event( + "retention_prune", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + } + } +} + +pub(super) async fn run_automation_scheduler_tick( + project_path: &Path, + handshake: &DaemonHandshake, +) -> Result<()> { + use crate::automation::backend::{AgentTaskKind, CodexAppServerBackend}; + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + CombinedReviewAutomationOptions, CombinedReviewDispatch, MemoryCuratorAutomationOptions, + SessionReflectorAutomationOptions, SkillWriterAutomationOptions, + run_combined_review_with_backend, run_memory_curator_with_backend, + run_session_reflector_with_backend, run_skill_writer_with_backend, + }; + + let cg = Box::pin(open_existing_project_with_options( + project_path, + handshake.open_options(), + )) + .await?; + let control = + crate::automation::scheduler::load_scheduler_control(&cg.store_layout().dashboard_root) + .await?; + if control.paused { + log_daemon_event( + "scheduler_tick", + &[ + ("project", project_path.display().to_string()), + ("outcome", "skipped".to_string()), + ("reason", "paused".to_string()), + ], + ); + return Ok(()); + } + let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; + if !automation_scheduler_has_work(&cg, &config).await? { + log_daemon_event( + "scheduler_tick", + &[ + ("project", project_path.display().to_string()), + ("outcome", "skipped".to_string()), + ("reason", "not_configured".to_string()), + ], + ); + return Ok(()); + } + maybe_run_global_retention(project_path, &config).await; + let backend = CodexAppServerBackend::from_automation_config(&config); + let mut first_error: Option = None; + let mut any_succeeded = false; + + log_scheduler_task_start(project_path, AgentTaskKind::MemoryCurator); + match run_memory_curator_with_backend( + &cg, + &config, + &backend, + MemoryCuratorAutomationOptions { + trigger: AutomationTrigger::Scheduler, + ..MemoryCuratorAutomationOptions::default() + }, + ) + .await + { + Ok(run) => { + any_succeeded |= run.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + log_daemon_scheduler_record(project_path, &run.ledger_record); + } + Err(e) => { + log_scheduler_task_error(project_path, AgentTaskKind::MemoryCurator, &e); + first_error.get_or_insert(e); + } + } + // When both the reflector and the skill writer are due in this tick, the + // combined path serves them with one backend call. Any other outcome + // (combined mode disabled, only one task due, missing evidence) falls + // back to the sequential per-task runs below. + let mut combined_handled = false; + if config.combine_due_tasks { + log_scheduler_task_start(project_path, AgentTaskKind::CombinedReview); + match run_combined_review_with_backend( + &cg, + &config, + &backend, + CombinedReviewAutomationOptions::default(), + ) + .await + { + Ok(CombinedReviewDispatch::Ran(run)) => { + any_succeeded |= run.session_reflector.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + any_succeeded |= run.skill_writer.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + combined_handled = true; + } + Ok(CombinedReviewDispatch::RecordedFailure { run, error }) => { + any_succeeded |= run.session_reflector.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + any_succeeded |= run.skill_writer.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + log_scheduler_task_error(project_path, AgentTaskKind::CombinedReview, &error); + first_error.get_or_insert(error); + combined_handled = true; + } + Ok(CombinedReviewDispatch::NotCombined { reason }) => { + log_daemon_event( + "scheduler_task", + &[ + ("project", project_path.display().to_string()), + ("task", "combined_review".to_string()), + ("outcome", "not_combined".to_string()), + ("reason", reason.to_string()), + ], + ); + } + Err(e) => { + log_scheduler_task_error(project_path, AgentTaskKind::CombinedReview, &e); + } + } + } + if !combined_handled { + log_scheduler_task_start(project_path, AgentTaskKind::SessionReflector); + match run_session_reflector_with_backend( + &cg, + &config, + &backend, + SessionReflectorAutomationOptions { + trigger: AutomationTrigger::Scheduler, + ..SessionReflectorAutomationOptions::default() + }, + ) + .await + { + Ok(run) => { + any_succeeded |= run.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + log_daemon_scheduler_record(project_path, &run.ledger_record); + } + Err(e) => { + log_scheduler_task_error(project_path, AgentTaskKind::SessionReflector, &e); + first_error.get_or_insert(e); + } + } + log_scheduler_task_start(project_path, AgentTaskKind::SkillWriter); + match run_skill_writer_with_backend( + &cg, + &config, + &backend, + SkillWriterAutomationOptions { + trigger: AutomationTrigger::Scheduler, + ..SkillWriterAutomationOptions::default() + }, + ) + .await + { + Ok(run) => { + any_succeeded |= run.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded; + log_daemon_scheduler_record(project_path, &run.ledger_record); + } + Err(e) => { + log_scheduler_task_error(project_path, AgentTaskKind::SkillWriter, &e); + first_error.get_or_insert(e); + } + } + } + if any_succeeded { + log_automation_staged_if_pending(project_path, &cg.store_layout().dashboard_root).await; + } + run_user_jobs_scheduler_pass( + project_path, + &handshake.client_identity.profile_root, + &cg, + &config, + &backend, + &mut first_error, + ) + .await; + match first_error { + Some(err) => Err(err), + None => Ok(()), + } +} + +async fn run_host_receipt_review(project_path: &Path, handshake: &DaemonHandshake) -> Result<()> { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + CombinedReviewAutomationOptions, CombinedReviewDispatch, SessionReflectorAutomationOptions, + SkillWriterAutomationOptions, run_combined_review_with_backend, + }; + + let cg = Box::pin(open_existing_project_with_options( + project_path, + handshake.open_options(), + )) + .await?; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let Some(ready) = crate::automation::host_receipts::oldest_ready(&dashboard_root).await? else { + return Ok(()); + }; + let pending = ready.pending; + if crate::automation::scheduler::load_scheduler_control(&dashboard_root) + .await? + .paused + { + return Ok(()); + } + let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; + let session_id = pending + .route + .as_ref() + .and_then(|route| route.session_id.clone()); + let Some(session_db) = + crate::global_db::GlobalDb::open_read_only_at(&cg.store_layout().sessions_db_path).await + else { + return Ok(()); + }; + if session_db + .lcm_load_raw_message("hermes", &ready.transcript_watermark) + .await + .is_none() + { + // Never review a terminal receipt until the exact completed-turn + // watermark is durable in LCM. + return Ok(()); + } + let backend = CodexAppServerBackend::from_automation_config(&config); + let result = run_combined_review_with_backend( + &cg, + &config, + &backend, + CombinedReviewAutomationOptions { + run_id: Some(format!("host_receipt_{}", pending.generation)), + session_reflector: SessionReflectorAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + provider: "hermes".to_string(), + session_id, + ..SessionReflectorAutomationOptions::default() + }, + skill_writer: SkillWriterAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + provider: "hermes".to_string(), + ..SkillWriterAutomationOptions::default() + }, + trigger: AutomationTrigger::HostReceipt, + }, + ) + .await?; + match result { + CombinedReviewDispatch::Ran(run) => { + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + if run.session_reflector.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded + && run.skill_writer.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded + { + crate::automation::host_receipts::mark_consumed( + &dashboard_root, + &pending.session_key, + pending.generation, + ) + .await?; + } + } + CombinedReviewDispatch::RecordedFailure { run, error } => { + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + return Err(error); + } + CombinedReviewDispatch::NotCombined { reason } => { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "deferred".to_string()), + ("reason", reason.to_string()), + ], + ); + } + } + Ok(()) +} + +async fn effective_automation_config_for_project( + cg: &crate::tracedecay::TraceDecay, + client_identity: &DaemonClientIdentity, +) -> Result { + use crate::automation::config::{effective_config, load_project_config}; + + let global = user_config_for_client(client_identity).automation; + let project = load_project_config(&cg.store_layout().dashboard_root).await?; + effective_config(&global, project.as_ref()) +} + +pub(super) fn user_config_for_client( + client_identity: &DaemonClientIdentity, +) -> crate::user_config::UserConfig { + let path = client_identity.profile_root.join("config.toml"); + let Ok(contents) = std::fs::read_to_string(&path) else { + return crate::user_config::UserConfig::default(); + }; + crate::user_config::parse_or_warn_default(&path, &contents) +} + +pub(super) fn automation_scheduler_configured( + config: &crate::automation::config::AutomationConfig, +) -> bool { + use crate::automation::config::{AutomationBackend, AutomationHostMode}; + use crate::automation::scheduler::{AutomationSchedule, parse_schedule}; + + if !config.enabled + || config.host_mode == AutomationHostMode::DelegatedHost + || config.backend != AutomationBackend::CodexAppServer + { + return false; + } + if config.combine_due_tasks + && config.tasks.session_reflector.enabled + && config.tasks.skill_writer.enabled + { + return true; + } + [ + &config.tasks.memory_curator, + &config.tasks.session_reflector, + &config.tasks.skill_writer, + ] + .into_iter() + .any(|task| { + if !task.enabled { + return false; + } + match parse_schedule(task.schedule.as_deref()) { + Ok(AutomationSchedule::Manual) | Err(_) => false, + Ok(AutomationSchedule::ConfiguredInterval) => task.interval_secs.is_some(), + Ok(AutomationSchedule::Interval { .. } | AutomationSchedule::Cron(_)) => true, + } + }) +} + +/// True when the scheduler loop has anything to do for this project: a +/// scheduled fixed task or a schedulable user-defined job. +async fn automation_scheduler_has_work( + cg: &crate::tracedecay::TraceDecay, + config: &crate::automation::config::AutomationConfig, +) -> Result { + use crate::automation::config::{AutomationBackend, AutomationHostMode}; + + if automation_scheduler_configured(config) { + return Ok(true); + } + if !config.enabled + || config.host_mode == AutomationHostMode::DelegatedHost + || config.backend != AutomationBackend::CodexAppServer + { + return Ok(false); + } + crate::automation::jobs::jobs_configured_for_scheduler(&cg.store_layout().dashboard_root).await +} + +/// Ticks every schedulable user-defined job with the same lock/cooldown +/// discipline as the fixed tasks (enforced inside the job runner). +async fn run_user_jobs_scheduler_pass( + project_path: &Path, + profile_root: &Path, + cg: &crate::tracedecay::TraceDecay, + config: &crate::automation::config::AutomationConfig, + backend: &crate::automation::backend::CodexAppServerBackend, + first_error: &mut Option, +) { + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let jobs = match crate::automation::jobs::load_jobs(&dashboard_root).await { + Ok(jobs) => jobs, + Err(e) => { + log_daemon_event( + "scheduler_user_jobs", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", e.to_string()), + ], + ); + first_error.get_or_insert(e); + return; + } + }; + for job in jobs + .iter() + .filter(|job| crate::automation::jobs::job_is_schedulable(job)) + { + log_scheduler_task_start( + project_path, + crate::automation::backend::AgentTaskKind::UserJob, + ); + match crate::automation::jobs::run_user_job_with_backend( + &dashboard_root, + config, + backend, + job, + crate::automation::jobs::UserJobRunOptions { + trigger: crate::automation::run_ledger::AutomationTrigger::Scheduler, + profile_root: Some(profile_root.to_path_buf()), + project_root: Some(project_path.to_path_buf()), + ..crate::automation::jobs::UserJobRunOptions::default() + }, + ) + .await + { + Ok(run) => log_daemon_scheduler_record(project_path, &run.ledger_record), + Err(e) => { + log_scheduler_task_error( + project_path, + crate::automation::backend::AgentTaskKind::UserJob, + &e, + ); + first_error.get_or_insert(e); + } + } + } +} diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index 39ec8d569..57bd1e229 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; #[cfg(unix)] use std::process::Command; +use std::sync::Arc; #[cfg(unix)] use serde_json::Value; @@ -12,11 +13,11 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; use tokio::task::JoinHandle; #[cfg(unix)] +use super::{AutomationSchedulerHandle, DaemonEngine, drain_client_tasks}; use super::{ - AutomationSchedulerHandle, DaemonEngine, DatabaseOwnerRegistry, ProjectRouteKey, - ProjectServerKey, StoreOwnerKey, drain_client_tasks, + DaemonClientIdentity, DaemonHandshake, DaemonLifecycle, DatabaseOwnerRegistry, ProjectRouteKey, + ProjectServerKey, StoreAdministration, StoreOwnerKey, }; -use super::{DaemonClientIdentity, DaemonHandshake, DaemonLifecycle}; mod compatibility; @@ -60,6 +61,7 @@ async fn portable_broker_requests_reuse_one_authenticated_project_owner() { let owners = std::sync::Arc::new(tokio::sync::Mutex::new( super::DatabaseOwnerRegistry::default(), )); + let store_administration = StoreAdministration::with_project_servers(Arc::clone(&owners)); let gates = std::sync::Arc::new(tokio::sync::Mutex::new(super::ProjectOpenGates::default())); let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let lifecycle = DaemonLifecycle::default(); @@ -69,7 +71,7 @@ async fn portable_broker_requests_reuse_one_authenticated_project_owner() { .expect("loopback listener"); let server = { - let owners = std::sync::Arc::clone(&owners); + let store_administration = store_administration.clone(); let gates = std::sync::Arc::clone(&gates); let attempts = std::sync::Arc::clone(&attempts); let lifecycle = lifecycle.clone(); @@ -77,19 +79,19 @@ async fn portable_broker_requests_reuse_one_authenticated_project_owner() { let mut clients = tokio::task::JoinSet::new(); for _ in 0..2 { let stream = listener.accept().await.expect("accept client"); - let owners = std::sync::Arc::clone(&owners); + let store_administration = store_administration.clone(); let gates = std::sync::Arc::clone(&gates); let attempts = std::sync::Arc::clone(&attempts); let lifecycle = lifecycle.clone(); clients.spawn(async move { - super::serve_windows_broker_client( + Box::pin(super::serve_windows_broker_client( stream, TOKEN, &lifecycle, - owners, + store_administration, gates, Some(attempts), - ) + )) .await }); } @@ -197,7 +199,7 @@ async fn one_shot_tool_call_aborts_when_daemon_liveness_fails_after_write() { let server = tokio::spawn(async move { let (_stream, _) = listener.accept().await.expect("accept tool call"); drop(listener); - std::future::pending::<()>().await + std::future::pending::<()>().await; }); let error = tokio::time::timeout( @@ -233,7 +235,7 @@ async fn proxied_request_uses_shared_liveness_boundary_after_write() { let server = tokio::spawn(async move { let (_stream, _) = listener.accept().await.expect("accept proxied request"); drop(listener); - std::future::pending::<()>().await + std::future::pending::<()>().await; }); let request = json!({ "jsonrpc": "2.0", @@ -419,13 +421,18 @@ async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { scope_prefix: None, }; let task = tokio::spawn(std::future::pending::<()>()); - engine.automation_schedulers.lock().await.insert( - key, - AutomationSchedulerHandle { - task, - wake: std::sync::Arc::new(tokio::sync::Notify::new()), - }, - ); + engine + .store_administration + .automation_schedulers() + .lock() + .await + .insert( + key, + AutomationSchedulerHandle { + task, + wake: std::sync::Arc::new(tokio::sync::Notify::new()), + }, + ); engine.lifecycle.begin_draining(); tokio::time::timeout( @@ -435,7 +442,14 @@ async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { .await .expect("scheduler shutdown should not wait for its tick interval"); - assert!(engine.automation_schedulers.lock().await.is_empty()); + assert!( + engine + .store_administration + .automation_schedulers() + .lock() + .await + .is_empty() + ); } #[cfg(unix)] @@ -587,22 +601,30 @@ fn database_owner_registry_rekeys_and_evicts_stale_routes() { owner: feature_owner, scope_prefix: Some("src".to_string()), }; + let route = ProjectRouteKey { + profile_root: PathBuf::from("/profile"), + global_db_path: PathBuf::from("/profile/global.db"), + project_path: PathBuf::from("/project"), + scope_prefix: Some("src".to_string()), + }; let mut registry = DatabaseOwnerRegistry::::default(); registry.insert(old.clone(), 7); + registry.bind_route(route.clone(), old.clone()); - assert!(registry.rekey(&old, new.clone())); + assert!(registry.rekey(&old, &new)); assert!(registry.get(&old).is_none()); assert_eq!(registry.get(&new), Some(&7)); - assert!(!registry.routes.contains_key(&old.owner)); - assert!(registry.routes[&new.owner].contains(&new)); + assert_eq!(registry.get_route(&route), Some((&new, &7))); let mut collision = DatabaseOwnerRegistry::::default(); collision.insert(old.clone(), 7); collision.insert(new.clone(), 9); - assert!(!collision.rekey(&old, new.clone())); + collision.bind_route(route.clone(), old.clone()); + assert!(!collision.rekey(&old, &new)); assert!(collision.get(&old).is_none()); assert_eq!(collision.get(&new), Some(&9)); + assert!(collision.get_route(&route).is_none()); } #[test] @@ -716,7 +738,9 @@ async fn daemon_round_trip( let (server_stream, client_stream) = tokio::net::UnixStream::pair().expect("daemon socket pair"); let server = - tokio::spawn(async move { super::serve_socket_client(server_stream, engine).await }); + tokio::spawn( + async move { Box::pin(super::serve_socket_client(server_stream, engine)).await }, + ); let (reader, mut writer) = client_stream.into_split(); writer .write_all(handshake.to_line().expect("handshake json").as_bytes()) @@ -1571,76 +1595,31 @@ fn daemon_handshake_requires_client_identity() { assert!(DaemonHandshake::from_line(&encoded).is_err()); } -#[derive(Debug, serde::Deserialize, serde::Serialize)] -struct LegacyDaemonHandshake { - project_path: Option, - scope_prefix: Option, - timings: bool, - allow_init: bool, - client_identity: DaemonClientIdentity, -} - -/// Old client → new daemon: handshakes without version/instance fields must -/// still parse, with empty defaults. -#[test] -fn daemon_handshake_accepts_old_client_without_version() { - let legacy = LegacyDaemonHandshake { - project_path: Some(PathBuf::from("/work/repo")), - scope_prefix: None, - timings: false, - allow_init: false, - client_identity: test_client_identity(), - }; - let encoded = serde_json::to_string(&legacy).expect("old handshake should encode"); - - let decoded = DaemonHandshake::from_line(&encoded).expect("old handshake should decode"); - - assert!(!decoded.allow_initialize_root_routing); - assert_eq!(decoded.client_version, ""); - assert_eq!(decoded.client_instance_id, ""); - assert!(!decoded.tool_list_changed_capable); - assert_eq!(decoded.catalog_version, ""); -} - -/// New client → old daemon: an actual legacy projection ignores new fields. -#[test] -fn daemon_handshake_ignores_unknown_fields_for_old_daemons() { - let handshake = test_handshake_defaults(); - let decoded: LegacyDaemonHandshake = - serde_json::from_str(&handshake.to_line().expect("handshake should encode")) - .expect("old daemon should ignore new handshake fields"); - - assert_eq!(decoded.project_path, handshake.project_path); - assert_eq!(decoded.scope_prefix, handshake.scope_prefix); - assert_eq!(decoded.timings, handshake.timings); - assert_eq!(decoded.allow_init, handshake.allow_init); - assert_eq!(decoded.client_identity, handshake.client_identity); -} - #[tokio::test] async fn portable_broker_rejects_missing_auth_before_routing() { const TOKEN: &str = "0123456789abcdef0123456789abcdef"; let owners = std::sync::Arc::new(tokio::sync::Mutex::new( super::DatabaseOwnerRegistry::default(), )); + let store_administration = StoreAdministration::with_project_servers(Arc::clone(&owners)); let gates = std::sync::Arc::new(tokio::sync::Mutex::new(super::ProjectOpenGates::default())); let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (listener, endpoint) = super::transport::BrokerListener::bind(&super::transport::default_loopback_endpoint()) .await .expect("loopback listener"); - let server_owners = std::sync::Arc::clone(&owners); + let server_administration = store_administration.clone(); let server_attempts = std::sync::Arc::clone(&attempts); let server = tokio::spawn(async move { let stream = listener.accept().await.expect("accept client"); - super::serve_windows_broker_client( + Box::pin(super::serve_windows_broker_client( stream, TOKEN, &DaemonLifecycle::default(), - server_owners, + server_administration, gates, Some(server_attempts), - ) + )) .await }); let mut handshake = test_handshake_defaults(); @@ -2222,7 +2201,11 @@ async fn daemon_ensure_scheduler_skips_before_project_has_configured_work() { .ensure_automation_scheduler(key.clone(), project, handshake) .await; - let schedulers = engine.automation_schedulers.lock().await; + let schedulers = engine + .store_administration + .automation_schedulers() + .lock() + .await; assert!(!schedulers.contains_key(&key)); } @@ -2258,7 +2241,14 @@ async fn daemon_ensure_scheduler_starts_after_project_configures_work() { engine .ensure_automation_scheduler(key.clone(), project.clone(), handshake.clone()) .await; - assert!(!engine.automation_schedulers.lock().await.contains_key(&key)); + assert!( + !engine + .store_administration + .automation_schedulers() + .lock() + .await + .contains_key(&key) + ); save_project_config( &cg.store_layout().dashboard_root, @@ -2280,7 +2270,11 @@ async fn daemon_ensure_scheduler_starts_after_project_configures_work() { .ensure_automation_scheduler(key.clone(), project, handshake) .await; - let schedulers = engine.automation_schedulers.lock().await; + let schedulers = engine + .store_administration + .automation_schedulers() + .lock() + .await; assert!(schedulers.contains_key(&key)); drop(schedulers); engine.shutdown_all().await; diff --git a/src/daemon/tests/compatibility.rs b/src/daemon/tests/compatibility.rs index d3063482d..ab1afcc2a 100644 --- a/src/daemon/tests/compatibility.rs +++ b/src/daemon/tests/compatibility.rs @@ -164,11 +164,11 @@ async fn unauthenticated_legacy_handshake_is_rejected_before_routing() { .expect("bind broker"); let server = tokio::spawn(async move { let stream = listener.accept().await.expect("accept legacy client"); - super::super::serve_authenticated_socket_client( + Box::pin(super::super::serve_authenticated_socket_client( stream, super::super::DaemonEngine::default(), TOKEN.to_string(), - ) + )) .await }); diff --git a/src/daemon/transport.rs b/src/daemon/transport.rs index 3e16c4ff5..bf1ed16cb 100644 --- a/src/daemon/transport.rs +++ b/src/daemon/transport.rs @@ -64,7 +64,7 @@ impl FromStr for DaemonEndpoint { if path.is_empty() { return Err(config_error("daemon Unix endpoint path is empty")); } - return Ok(Self::Unix(PathBuf::from(path))); + Ok(Self::Unix(PathBuf::from(path))) } #[cfg(not(unix))] Err(config_error( diff --git a/src/db/access.rs b/src/db/access.rs index e3ce15dc0..10d35695a 100644 --- a/src/db/access.rs +++ b/src/db/access.rs @@ -14,14 +14,17 @@ mod path_layout; use bootstrap::{BootstrapAuthority, acquire_bootstrap_authority, reject_hard_linked_database}; pub use lease::enter_maintenance_database_scope; use lease::{acquire_process_lease, exact_scoped_runtime_role, scoped_runtime_role}; -pub(crate) use lease::{enter_daemon_database_scope, probe_writer_owner}; +pub(crate) use lease::{ + database_path_is_tombstoned, enter_daemon_database_scope, probe_writer_owner, +}; use owner_io::{ authority_token, epoch_ms, is_lock_contended, open_lock_file, publish_record_atomically, - read_owner, write_owner, writer_owner, + read_owner, read_record_strict, remove_record_durably, write_owner, write_record_atomically, + writer_owner, }; use path_layout::{ bootstrap_database_key, canonical_profile_root, database_lock_root, - is_legacy_repository_database, platform_identity_key, stable_path_hash, + is_legacy_repository_database, platform_identity_key, stable_path_hash, stable_path_set_hash, }; static PROCESS_LEASES: LazyLock>> = @@ -46,6 +49,26 @@ pub struct DatabaseAuthority { inner: Arc, } +#[derive(Debug)] +pub(crate) struct DatabaseDeletionFence { + transaction_id: String, + entries: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DatabaseDeletionState { + Missing, + Deleting, + Deleted, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct DatabaseDeletionStates { + missing: usize, + deleting: usize, + deleted: usize, +} + #[derive(Debug)] pub(crate) struct DaemonDatabaseScope { profile_root: PathBuf, @@ -84,19 +107,34 @@ struct AuthorityInner { struct DatabaseIdentity { database_path: PathBuf, database_key: PathBuf, + database_id: u64, profile_root: PathBuf, allows_ambient_profile_scope: bool, access_lock_path: PathBuf, writer_lock_path: PathBuf, writer_owner_path: PathBuf, + deletion_tombstone_path: PathBuf, bootstrap_lock_path: Option, } #[derive(Debug)] -struct ProcessLease { - token: String, - refs: usize, - held: HeldLocks, +struct DeletionFenceEntry { + identity: DatabaseIdentity, + access: File, + writer: File, +} + +#[derive(Debug)] +enum ProcessLease { + Authority { + token: String, + refs: usize, + held: HeldLocks, + }, + Deletion { + transaction_id: String, + owner: WriterOwner, + }, } #[derive(Debug)] @@ -160,9 +198,16 @@ impl DatabaseAuthority { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&identity.database_key) - .map(|lease| match &lease.held { - HeldLocks::Maintenance { .. } => DatabaseAuthorityRole::Maintenance, - HeldLocks::Daemon { .. } => DatabaseAuthorityRole::Test, + .and_then(|lease| match lease { + ProcessLease::Authority { + held: HeldLocks::Maintenance { .. }, + .. + } => Some(DatabaseAuthorityRole::Maintenance), + ProcessLease::Authority { + held: HeldLocks::Daemon { .. }, + .. + } => Some(DatabaseAuthorityRole::Test), + ProcessLease::Deletion { .. } => None, }); if let Some(role) = existing_role { return Self::acquire_identity(identity, role, intent); @@ -175,7 +220,15 @@ impl DatabaseAuthority { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&identity.database_key) - .is_some_and(|lease| matches!(&lease.held, HeldLocks::Maintenance { .. })); + .is_some_and(|lease| { + matches!( + lease, + ProcessLease::Authority { + held: HeldLocks::Maintenance { .. }, + .. + } + ) + }); if maintenance_active { return Self::acquire_identity(identity, DatabaseAuthorityRole::Maintenance, intent); } @@ -335,10 +388,12 @@ impl DatabaseIdentity { allows_ambient_profile_scope: is_legacy_repository_database(&database_path), database_path, database_key, + database_id: lock_id, profile_root: platform_identity_key(&profile_root), access_lock_path: lock_root.join(format!("{lock_id:016x}.access.lock")), writer_lock_path: lock_root.join(format!("{lock_id:016x}.writer.lock")), writer_owner_path: lock_root.join(format!("{lock_id:016x}.writer.owner")), + deletion_tombstone_path: lock_root.join(format!("{lock_id:016x}.deletion.tombstone")), bootstrap_lock_path, }) } @@ -377,396 +432,4 @@ fn is_isolated_test_path(path: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - static SCOPE_TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn canonical_identity_collapses_parent_aliases() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join("nested")).unwrap(); - let direct = DatabaseIdentity::for_path(&temp.path().join("graph.db")).unwrap(); - let aliased = DatabaseIdentity::for_path(&temp.path().join("nested/../graph.db")).unwrap(); - assert_eq!(direct, aliased); - } - - #[test] - fn identity_key_preserves_unproven_case_variants() { - let temp = tempfile::tempdir().unwrap(); - let upper = temp.path().join("MixedCase.DB"); - let lower = temp.path().join("mixedcase.db"); - - assert_ne!(platform_identity_key(&upper), platform_identity_key(&lower)); - } - - #[cfg(target_os = "linux")] - #[test] - fn case_distinct_database_files_have_distinct_identities() { - let temp = tempfile::tempdir().unwrap(); - let upper = temp.path().join("MixedCase.DB"); - let lower = temp.path().join("mixedcase.db"); - std::fs::write(&upper, []).unwrap(); - std::fs::write(&lower, []).unwrap(); - - let upper = DatabaseIdentity::for_path(&upper).unwrap(); - let lower = DatabaseIdentity::for_path(&lower).unwrap(); - - assert_ne!(upper.database_key, lower.database_key); - assert_ne!(upper.writer_lock_path, lower.writer_lock_path); - - let upper_authority = DatabaseAuthority::acquire_test( - &temp.path().join("MixedCase.DB"), - "upper case-sensitive database", - ) - .unwrap(); - let lower_authority = DatabaseAuthority::acquire_test( - &temp.path().join("mixedcase.db"), - "lower case-sensitive database", - ) - .unwrap(); - assert_ne!(upper_authority.token(), lower_authority.token()); - } - - #[cfg(any(windows, target_os = "macos"))] - #[test] - fn fresh_case_variants_cannot_hold_concurrent_first_create_authorities() { - let temp = tempfile::tempdir().unwrap(); - let upper = temp.path().join("MixedCase.DB"); - let lower = temp.path().join("mixedcase.db"); - - let first = DatabaseAuthority::acquire_test(&upper, "first case variant").unwrap(); - let error = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap_err(); - assert!(error.to_string().contains("case-variant first-create")); - - std::fs::write(&upper, []).unwrap(); - drop(first); - let second = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap(); - if lower.exists() { - assert_eq!( - second.canonical_database_path(), - upper.canonicalize().unwrap() - ); - } else { - std::fs::write(&lower, []).unwrap(); - let upper_identity = DatabaseIdentity::for_path(&upper).unwrap(); - let lower_identity = DatabaseIdentity::for_path(&lower).unwrap(); - assert_ne!(upper_identity.database_key, lower_identity.database_key); - } - } - - #[cfg(unix)] - #[test] - fn symlink_aliases_share_one_database_identity() { - let temp = tempfile::tempdir().unwrap(); - let database = temp.path().join("database.db"); - let alias = temp.path().join("database-alias.db"); - std::fs::write(&database, []).unwrap(); - std::os::unix::fs::symlink(&database, &alias).unwrap(); - - let database = DatabaseIdentity::for_path(&database).unwrap(); - let alias = DatabaseIdentity::for_path(&alias).unwrap(); - - assert_eq!(database.database_key, alias.database_key); - assert_eq!(database.writer_lock_path, alias.writer_lock_path); - } - - #[test] - fn profile_databases_share_one_exact_profile_scope() { - let temp = tempfile::tempdir().unwrap(); - let profile = temp.path().join("profile"); - std::fs::create_dir_all(&profile).unwrap(); - let expected_profile = platform_identity_key(&profile.canonicalize().unwrap()); - let paths = [ - profile.join("global.db"), - profile.join("user-memory.db"), - profile.join("user-sessions.db"), - profile.join("projects/project/tracedecay.db"), - profile.join("projects/project/sessions.db"), - profile.join("projects/project/branches/feature.db"), - ]; - - for path in paths { - let identity = DatabaseIdentity::for_path(&path).unwrap(); - assert_eq!( - identity.profile_root, - expected_profile, - "{}", - path.display() - ); - assert!( - !identity.allows_ambient_profile_scope, - "{} must require its exact profile authority", - path.display() - ); - assert_eq!( - identity.access_lock_path.parent(), - Some(profile.join(".tracedecay-database-locks").as_path()) - ); - } - } - - #[test] - fn projects_directory_in_repository_path_is_not_a_profile_shard() { - let temp = tempfile::tempdir().unwrap(); - let data_root = temp.path().join("projects/repository/.tracedecay"); - let path = data_root.join("tracedecay.db"); - let identity = DatabaseIdentity::for_path(&path).unwrap(); - - assert_eq!( - identity.profile_root, - platform_identity_key(&data_root.canonicalize().unwrap()) - ); - assert!(identity.allows_ambient_profile_scope); - } - - #[test] - fn fs2_contention_is_classified_as_an_active_lease() { - let temp = tempfile::tempdir().unwrap(); - let lock_path = temp.path().join("authority.lock"); - let first = open_lock_file(&lock_path).unwrap(); - let second = open_lock_file(&lock_path).unwrap(); - fs2::FileExt::try_lock_exclusive(&first).unwrap(); - - let error = fs2::FileExt::try_lock_exclusive(&second).unwrap_err(); - - assert!(is_lock_contended(&error), "unexpected lock error: {error}"); - fs2::FileExt::unlock(&first).unwrap(); - } - - #[test] - fn writer_owner_replacement_is_complete_and_leaves_no_temporary_file() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("writer.owner"); - let first = writer_owner("first", "first owner"); - let second = writer_owner("second", "replacement owner"); - write_owner(&path, &first).unwrap(); - - write_owner(&path, &second).unwrap(); - - assert_eq!(read_owner(&path), Some(second)); - assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } - - #[test] - fn atomic_record_publication_preserves_a_colliding_temporary_file() { - let temp = tempfile::tempdir().unwrap(); - let destination = temp.path().join("authority.record"); - let temporary = temp.path().join("authority.record.tmp"); - std::fs::write(&temporary, b"other publisher").unwrap(); - - let error = DatabaseAuthority::publish_record_atomically( - &temporary, - &destination, - b"replacement", - "test authority record", - ) - .unwrap_err(); - - assert!(error.to_string().contains("create test authority record")); - assert_eq!(std::fs::read(&temporary).unwrap(), b"other publisher"); - assert!(!destination.exists()); - } - - #[test] - fn daemon_authority_is_same_process_reentrant() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("graph.db"); - let first = DatabaseAuthority::acquire_test(&path, "first").unwrap(); - let second = DatabaseAuthority::acquire_test(&path, "second").unwrap(); - assert_eq!(first.token(), second.token()); - assert_eq!( - probe_writer_owner(&path).unwrap(), - WriterOwnership::Active( - read_owner(&DatabaseIdentity::for_path(&path).unwrap().writer_owner_path).unwrap() - ) - ); - drop(first); - assert!(matches!( - probe_writer_owner(&path).unwrap(), - WriterOwnership::Active(_) - )); - drop(second); - assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); - } - - #[test] - fn maintenance_and_daemon_authorities_are_mutually_exclusive() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("graph.db"); - let daemon = DatabaseAuthority::acquire_test(&path, "daemon").unwrap(); - let error = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap_err(); - assert!( - error - .to_string() - .contains("incompatible database authority") - ); - drop(daemon); - - let maintenance = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap(); - let error = DatabaseAuthority::acquire_test(&path, "daemon").unwrap_err(); - assert!( - error - .to_string() - .contains("incompatible database authority") - ); - drop(maintenance); - } - - #[test] - fn stale_owner_metadata_never_establishes_ownership() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("graph.db"); - let identity = DatabaseIdentity::for_path(&path).unwrap(); - std::fs::write( - &identity.writer_owner_path, - "token=stale\tpid=1\tstarted_epoch_ms=1\tversion=old\tintent=old\n", - ) - .unwrap(); - assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); - assert!(identity.writer_owner_path.exists()); - } - - #[test] - fn authority_is_bound_to_one_canonical_database() { - let temp = tempfile::tempdir().unwrap(); - let first = temp.path().join("first.db"); - let second = temp.path().join("second.db"); - let authority = DatabaseAuthority::acquire_test(&first, "test").unwrap(); - let error = authority.hold_for(&second, "open").unwrap_err(); - assert!(error.to_string().contains("different database")); - } - - #[test] - fn daemon_authority_inherits_live_election_scope() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("graph.db"); - let scope = enter_daemon_database_scope(temp.path(), 7, "election-token").unwrap(); - let authority = DatabaseAuthority::acquire_daemon(&path, "daemon").unwrap(); - assert_eq!(authority.role(), DatabaseAuthorityRole::Daemon); - drop(authority); - drop(scope); - } - - #[test] - fn sole_daemon_scope_authorizes_only_legacy_repo_local_database() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let profile = tempfile::tempdir().unwrap(); - let repository = tempfile::tempdir().unwrap(); - let scope = enter_daemon_database_scope(profile.path(), 1, "daemon").unwrap(); - let identity = - DatabaseIdentity::for_path(&repository.path().join(".tracedecay/tracedecay.db")) - .unwrap(); - - assert!(identity.allows_ambient_profile_scope); - assert_eq!( - scoped_runtime_role(&identity, "legacy repository database").unwrap(), - Some(DatabaseAuthorityRole::Daemon) - ); - - drop(scope); - } - - #[test] - fn sole_daemon_scope_rejects_standard_databases_from_another_profile() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let first = tempfile::tempdir().unwrap(); - let second = tempfile::tempdir().unwrap(); - let scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); - let paths = [ - second.path().join("global.db"), - second.path().join("user-memory.db"), - second.path().join("user-sessions.db"), - second.path().join("projects/project/tracedecay.db"), - second.path().join("projects/project/sessions.db"), - second.path().join("projects/project/branches/feature.db"), - ]; - - for path in paths { - let identity = DatabaseIdentity::for_path(&path).unwrap(); - assert_eq!( - exact_scoped_runtime_role(&identity.profile_root, "other profile").unwrap(), - None - ); - assert_eq!( - scoped_runtime_role(&identity, "other profile").unwrap(), - None, - "{} used an unrelated ambient profile scope", - path.display() - ); - } - - drop(scope); - } - - #[test] - fn maintenance_scope_requires_and_inherits_exclusive_profile_lease() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("projects/p1/tracedecay.db"); - let lifecycle = - crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "maintenance test") - .unwrap(); - let scope = - enter_maintenance_database_scope(&lifecycle, temp.path(), "maintenance test").unwrap(); - let authority = DatabaseAuthority::for_runtime(&path, "repair").unwrap(); - assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); - drop(authority); - drop(scope); - drop(lifecycle); - } - - #[test] - fn daemon_scopes_are_isolated_by_profile() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let first = tempfile::tempdir().unwrap(); - let second = tempfile::tempdir().unwrap(); - let first_scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); - let second_scope = enter_daemon_database_scope(second.path(), 1, "second").unwrap(); - - let first_authority = DatabaseAuthority::for_runtime( - &first.path().join("projects/one/tracedecay.db"), - "first profile", - ) - .unwrap(); - let second_authority = DatabaseAuthority::for_runtime( - &second.path().join("projects/two/tracedecay.db"), - "second profile", - ) - .unwrap(); - assert_eq!(first_authority.role(), DatabaseAuthorityRole::Daemon); - assert_eq!(second_authority.role(), DatabaseAuthorityRole::Daemon); - - drop((first_authority, second_authority, first_scope, second_scope)); - } - - #[test] - fn maintenance_scope_is_reentrant_across_nested_intents() { - let _lock = SCOPE_TEST_LOCK.lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let lifecycle = - crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "outer").unwrap(); - let outer = enter_maintenance_database_scope(&lifecycle, temp.path(), "plan").unwrap(); - let inner = enter_maintenance_database_scope(&lifecycle, temp.path(), "apply").unwrap(); - let authority = DatabaseAuthority::for_runtime( - &temp.path().join("projects/p1/tracedecay.db"), - "nested operation", - ) - .unwrap(); - assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); - - drop(authority); - drop(inner); - drop(outer); - drop(lifecycle); - } -} +mod tests; diff --git a/src/db/access/bootstrap.rs b/src/db/access/bootstrap.rs index 12871be4d..97c2265ef 100644 --- a/src/db/access/bootstrap.rs +++ b/src/db/access/bootstrap.rs @@ -23,10 +23,7 @@ pub(super) fn reject_hard_linked_database(path: &Path) -> Result<()> { metadata.is_file() && metadata.nlink() > 1 }; #[cfg(windows)] - let has_multiple_links = { - use std::os::windows::fs::MetadataExt; - metadata.is_file() && metadata.number_of_links().is_some_and(|links| links > 1) - }; + let has_multiple_links = metadata.is_file() && windows_hard_link_count(path)? > 1; #[cfg(not(any(unix, windows)))] let has_multiple_links = false; if has_multiple_links { @@ -39,6 +36,57 @@ pub(super) fn reject_hard_linked_database(path: &Path) -> Result<()> { Ok(()) } +#[cfg(windows)] +fn windows_hard_link_count(path: &Path) -> Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + let file = std::fs::File::open(path) + .map_err(|error| access_io_error("inspect database links", path, &error))?; + let mut information = MaybeUninit::::uninit(); + // SAFETY: `file` owns a valid Windows file handle, and `information` points to + // writable memory sized for the API's complete output structure. + let succeeded = + unsafe { get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) }; + if succeeded == 0 { + return Err(access_io_error( + "inspect database links", + path, + &std::io::Error::last_os_error(), + )); + } + + // SAFETY: A nonzero API result initializes every field of the output structure. + Ok(unsafe { information.assume_init() }.number_of_links) +} + +#[cfg(windows)] +#[repr(C)] +struct ByHandleFileInformation { + _file_attributes: u32, + _creation_time_low_date_time: u32, + _creation_time_high_date_time: u32, + _last_access_time_low_date_time: u32, + _last_access_time_high_date_time: u32, + _last_write_time_low_date_time: u32, + _last_write_time_high_date_time: u32, + _volume_serial_number: u32, + _file_size_high: u32, + _file_size_low: u32, + number_of_links: u32, + _file_index_high: u32, + _file_index_low: u32, +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: *mut std::ffi::c_void, + information: *mut ByHandleFileInformation, + ) -> i32; +} + pub(super) fn acquire_bootstrap_authority( identity: &DatabaseIdentity, intent: &str, diff --git a/src/db/access/lease.rs b/src/db/access/lease.rs index 0b8e65e6e..b3b82f2d4 100644 --- a/src/db/access/lease.rs +++ b/src/db/access/lease.rs @@ -197,16 +197,22 @@ impl Drop for AuthorityInner { .unwrap_or_else(std::sync::PoisonError::into_inner); let should_remove = leases .get_mut(&self.identity.database_key) - .is_some_and(|lease| { - if lease.token != self.token { - return false; + .is_some_and(|lease| match lease { + ProcessLease::Authority { + token, + refs, + held: _, + } if token == &self.token => { + *refs = refs.saturating_sub(1); + *refs == 0 } - lease.refs = lease.refs.saturating_sub(1); - lease.refs == 0 + ProcessLease::Authority { .. } | ProcessLease::Deletion { .. } => false, }); if should_remove { - if let Some(lease) = leases.remove(&self.identity.database_key) { - unlock_held(lease.held); + if let Some(ProcessLease::Authority { held, .. }) = + leases.remove(&self.identity.database_key) + { + unlock_held(held); } } } @@ -221,8 +227,15 @@ pub(super) fn acquire_process_lease( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(existing) = leases.get_mut(&identity.database_key) { + let ProcessLease::Authority { token, refs, held } = existing else { + return Err(access_error( + intent, + &identity.database_path, + "this process already holds an incompatible database deletion fence", + )); + }; let compatible = matches!( - (&existing.held, role), + (&*held, role), ( HeldLocks::Daemon { .. }, DatabaseAuthorityRole::Daemon | DatabaseAuthorityRole::Test @@ -238,8 +251,9 @@ pub(super) fn acquire_process_lease( "this process already holds an incompatible database authority", )); } - existing.refs += 1; - return Ok(existing.token.clone()); + reject_deletion_tombstone(identity, intent)?; + *refs += 1; + return Ok(token.clone()); } let token = authority_token(); @@ -251,7 +265,7 @@ pub(super) fn acquire_process_lease( }; leases.insert( identity.database_key.clone(), - ProcessLease { + ProcessLease::Authority { token: token.clone(), refs: 1, held, @@ -281,6 +295,11 @@ fn acquire_daemon_locks( } }; + if let Err(error) = reject_deletion_tombstone(identity, intent) { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } let owner = writer_owner(token, intent); if let Err(error) = write_owner(&identity.writer_owner_path, &owner) { let _ = fs2::FileExt::unlock(&writer); @@ -313,6 +332,11 @@ fn acquire_maintenance_locks( return Err(error); } }; + if let Err(error) = reject_deletion_tombstone(identity, intent) { + let _ = fs2::FileExt::unlock(&writer); + let _ = fs2::FileExt::unlock(&access); + return Err(error); + } let owner = writer_owner(token, intent); if let Err(error) = write_owner(&identity.writer_owner_path, &owner) { let _ = fs2::FileExt::unlock(&writer); @@ -328,15 +352,592 @@ fn acquire_maintenance_locks( fn unlock_held(held: HeldLocks) { match held { - HeldLocks::Daemon { access, writer, .. } => { + HeldLocks::Daemon { access, writer, .. } + | HeldLocks::Maintenance { access, writer, .. } => { let _ = fs2::FileExt::unlock(&writer); let _ = fs2::FileExt::unlock(&access); } - HeldLocks::Maintenance { access, writer, .. } => { - let _ = fs2::FileExt::unlock(&writer); - let _ = fs2::FileExt::unlock(&access); + } +} + +impl DatabaseDeletionState { + fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Deleting => "deleting", + Self::Deleted => "deleted", + } + } +} + +impl DatabaseDeletionStates { + fn record(&mut self, state: DatabaseDeletionState) { + match state { + DatabaseDeletionState::Missing => self.missing += 1, + DatabaseDeletionState::Deleting => self.deleting += 1, + DatabaseDeletionState::Deleted => self.deleted += 1, + } + } + + pub(crate) fn missing(self) -> usize { + self.missing + } + + pub(crate) fn deleting(self) -> usize { + self.deleting + } + + pub(crate) fn deleted(self) -> usize { + self.deleted + } + + pub(crate) fn has_missing(self) -> bool { + self.missing != 0 + } + + #[cfg(test)] + pub(crate) fn has_deleting(self) -> bool { + self.deleting != 0 + } + + pub(crate) fn has_deleted(self) -> bool { + self.deleted != 0 + } +} + +#[derive(Debug, Eq, PartialEq)] +struct DeletionTombstone { + state: DatabaseDeletionState, + transaction_id: String, +} + +impl DatabaseDeletionFence { + pub(crate) fn acquire(database_paths: &[PathBuf], intent: &str) -> Result { + let identities = canonical_deletion_identities(database_paths, intent)?; + let identity_hash = stable_path_set_hash( + identities + .iter() + .map(|identity| identity.database_key.as_path()), + ); + let transaction_id = format!("{identity_hash:016x}:{}", authority_token()); + let (entries, state) = acquire_deletion_locks( + identities, + &transaction_id, + intent, + DeletionFenceAcquireMode::Fresh, + )?; + debug_assert_eq!(state.missing(), entries.len()); + Ok(Self { + transaction_id, + entries, + }) + } + + pub(crate) fn reacquire( + database_paths: &[PathBuf], + transaction_id: &str, + intent: &str, + ) -> Result<(Self, DatabaseDeletionStates)> { + validate_deletion_transaction_id(transaction_id, intent, Path::new(""))?; + let identities = canonical_deletion_identities(database_paths, intent)?; + let (entries, states) = acquire_deletion_locks( + identities, + transaction_id, + intent, + DeletionFenceAcquireMode::Recovery, + )?; + Ok(( + Self { + transaction_id: transaction_id.to_string(), + entries, + }, + states, + )) + } + + pub(crate) fn transaction_id(&self) -> &str { + &self.transaction_id + } + + pub(crate) fn database_paths(&self) -> impl ExactSizeIterator { + self.entries + .iter() + .map(|entry| entry.identity.database_path.as_path()) + } + + #[cfg(test)] + pub(crate) fn tombstone_states(&self) -> Result { + classify_tombstone_states( + &self.entries, + &self.transaction_id, + "inspect database deletion tombstones", + ) + } + + #[cfg(test)] + pub(crate) fn tombstone_paths(&self) -> impl ExactSizeIterator { + self.entries + .iter() + .map(|entry| entry.identity.deletion_tombstone_path.as_path()) + } + + pub(crate) fn publish_deleting(&self) -> Result<()> { + let mut missing = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + match read_deletion_tombstone(&entry.identity)? { + None => missing.push(true), + Some(tombstone) + if tombstone.transaction_id == self.transaction_id + && tombstone.state == DatabaseDeletionState::Deleting => + { + missing.push(false); + } + Some(tombstone) => { + return Err(tombstone_transition_error( + &entry.identity, + "publish database deletion tombstone", + &self.transaction_id, + &tombstone, + )); + } + } + } + + for (entry, missing) in self.entries.iter().zip(missing) { + if missing { + write_deletion_tombstone( + &entry.identity, + &self.transaction_id, + DatabaseDeletionState::Deleting, + )?; + } } + Ok(()) } + + pub(crate) fn promote_deleted(&self) -> Result<()> { + let mut needs_promotion = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + match read_deletion_tombstone(&entry.identity)? { + Some(tombstone) if tombstone.transaction_id == self.transaction_id => { + needs_promotion.push(tombstone.state == DatabaseDeletionState::Deleting); + } + Some(tombstone) => { + return Err(tombstone_transition_error( + &entry.identity, + "promote database deletion tombstone", + &self.transaction_id, + &tombstone, + )); + } + None => { + return Err(access_error( + "promote database deletion tombstone", + &entry.identity.database_path, + "database deletion tombstone is missing", + )); + } + } + } + + for (entry, needs_promotion) in self.entries.iter().zip(needs_promotion) { + if needs_promotion { + write_deletion_tombstone( + &entry.identity, + &self.transaction_id, + DatabaseDeletionState::Deleted, + )?; + } + } + Ok(()) + } + + pub(crate) fn rollback_deleting(&self) -> Result<()> { + let mut present = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + match read_deletion_tombstone(&entry.identity)? { + None => present.push(false), + Some(tombstone) + if tombstone.transaction_id == self.transaction_id + && tombstone.state == DatabaseDeletionState::Deleting => + { + present.push(true); + } + Some(tombstone) => { + return Err(tombstone_transition_error( + &entry.identity, + "rollback database deletion tombstone", + &self.transaction_id, + &tombstone, + )); + } + } + } + + for (entry, present) in self.entries.iter().zip(present) { + if present { + remove_record_durably( + &entry.identity.deletion_tombstone_path, + "database deletion tombstone", + )?; + } + } + Ok(()) + } +} + +impl Drop for DatabaseDeletionFence { + fn drop(&mut self) { + let mut leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for entry in self.entries.iter().rev() { + let _ = fs2::FileExt::unlock(&entry.writer); + let _ = fs2::FileExt::unlock(&entry.access); + let owns_process_lease = matches!( + leases.get(&entry.identity.database_key), + Some(ProcessLease::Deletion { transaction_id, .. }) + if transaction_id == &self.transaction_id + ); + if owns_process_lease { + leases.remove(&entry.identity.database_key); + } + } + } +} + +#[derive(Clone, Copy)] +enum DeletionFenceAcquireMode { + Fresh, + Recovery, +} + +fn canonical_deletion_identities( + database_paths: &[PathBuf], + intent: &str, +) -> Result> { + if database_paths.is_empty() { + return Err(access_error( + intent, + Path::new(""), + "database deletion fence requires at least one database path", + )); + } + let mut identities = database_paths + .iter() + .map(|path| DatabaseIdentity::for_path(path)) + .collect::>>()?; + identities.sort_by(|left, right| left.database_key.cmp(&right.database_key)); + identities.dedup_by(|left, right| left.database_key == right.database_key); + Ok(identities) +} + +fn acquire_deletion_locks( + identities: Vec, + transaction_id: &str, + intent: &str, + mode: DeletionFenceAcquireMode, +) -> Result<(Vec, DatabaseDeletionStates)> { + let mut leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for identity in &identities { + if leases.contains_key(&identity.database_key) { + return Err(access_error( + intent, + &identity.database_path, + "this process already holds an incompatible database authority or deletion fence", + )); + } + } + + let mut entries = Vec::with_capacity(identities.len()); + for identity in identities { + let access = match open_lock_file(&identity.access_lock_path).and_then(|access| { + fs2::FileExt::try_lock_exclusive(&access).map_err(|error| { + lock_acquisition_error("deletion access", &identity, intent, &error) + })?; + Ok(access) + }) { + Ok(access) => access, + Err(error) => { + unlock_deletion_entries(&entries); + return Err(error); + } + }; + let writer = match open_lock_file(&identity.writer_lock_path).and_then(|writer| { + fs2::FileExt::try_lock_exclusive(&writer) + .map_err(|error| lock_acquisition_error("writer", &identity, intent, &error))?; + Ok(writer) + }) { + Ok(writer) => writer, + Err(error) => { + let _ = fs2::FileExt::unlock(&access); + unlock_deletion_entries(&entries); + return Err(error); + } + }; + entries.push(DeletionFenceEntry { + identity, + access, + writer, + }); + } + + let state = match mode { + DeletionFenceAcquireMode::Fresh => entries + .iter() + .try_for_each(|entry| reject_deletion_tombstone(&entry.identity, intent)) + .map(|()| DatabaseDeletionStates { + missing: entries.len(), + ..DatabaseDeletionStates::default() + }), + DeletionFenceAcquireMode::Recovery => { + classify_tombstone_states(&entries, transaction_id, intent) + } + }; + let state = match state { + Ok(state) => state, + Err(error) => { + unlock_deletion_entries(&entries); + return Err(error); + } + }; + + let owner = writer_owner(transaction_id, intent); + for entry in &entries { + if let Err(error) = write_owner(&entry.identity.writer_owner_path, &owner) { + unlock_deletion_entries(&entries); + return Err(error); + } + } + for entry in &entries { + leases.insert( + entry.identity.database_key.clone(), + ProcessLease::Deletion { + transaction_id: transaction_id.to_string(), + owner: owner.clone(), + }, + ); + } + Ok((entries, state)) +} + +fn unlock_deletion_entries(entries: &[DeletionFenceEntry]) { + for entry in entries.iter().rev() { + let _ = fs2::FileExt::unlock(&entry.writer); + let _ = fs2::FileExt::unlock(&entry.access); + } +} + +fn validate_deletion_transaction_id( + transaction_id: &str, + operation: &str, + path: &Path, +) -> Result<()> { + if valid_deletion_transaction_id(transaction_id) { + Ok(()) + } else { + Err(access_error( + operation, + path, + "database deletion transaction ID is invalid", + )) + } +} + +fn valid_deletion_transaction_id(transaction_id: &str) -> bool { + !transaction_id.is_empty() + && transaction_id.len() <= 512 + && !transaction_id.chars().any(char::is_control) +} + +fn classify_tombstone_states( + entries: &[DeletionFenceEntry], + transaction_id: &str, + operation: &str, +) -> Result { + let mut states = DatabaseDeletionStates::default(); + for entry in entries { + let state = match read_deletion_tombstone(&entry.identity)? { + None => DatabaseDeletionState::Missing, + Some(tombstone) if tombstone.transaction_id == transaction_id => tombstone.state, + Some(tombstone) => { + return Err(tombstone_transition_error( + &entry.identity, + operation, + transaction_id, + &tombstone, + )); + } + }; + states.record(state); + } + Ok(states) +} + +fn reject_deletion_tombstone(identity: &DatabaseIdentity, intent: &str) -> Result<()> { + let Some(tombstone) = read_deletion_tombstone(identity)? else { + return Ok(()); + }; + let message = match tombstone.state { + DatabaseDeletionState::Missing => { + return Err(corrupt_tombstone_error( + identity, + "record encodes missing state instead of being absent", + )); + } + DatabaseDeletionState::Deleting => format!( + "database deletion is in progress for transaction {}", + tombstone.transaction_id + ), + DatabaseDeletionState::Deleted => format!( + "database was deleted by transaction {}", + tombstone.transaction_id + ), + }; + Err(access_error(intent, &identity.database_path, &message)) +} + +fn read_deletion_tombstone(identity: &DatabaseIdentity) -> Result> { + let Some(record) = read_record_strict( + &identity.deletion_tombstone_path, + "database deletion tombstone", + )? + else { + return Ok(None); + }; + parse_deletion_tombstone(identity, &record).map(Some) +} + +fn parse_deletion_tombstone( + identity: &DatabaseIdentity, + record: &str, +) -> Result { + let payload = record + .strip_suffix('\n') + .ok_or_else(|| corrupt_tombstone_error(identity, "record is not newline terminated"))?; + if payload.contains('\r') || payload.contains('\n') { + return Err(corrupt_tombstone_error( + identity, + "record contains multiple lines", + )); + } + + let mut fields = HashMap::new(); + for field in payload.split('\t') { + let (key, value) = field + .split_once('=') + .ok_or_else(|| corrupt_tombstone_error(identity, "record field has no value"))?; + if fields.insert(key, value).is_some() { + return Err(corrupt_tombstone_error( + identity, + "record field is duplicated", + )); + } + } + if fields.len() != 4 || fields.get("version") != Some(&"1") { + return Err(corrupt_tombstone_error( + identity, + "record version or field set is invalid", + )); + } + + let state = match fields.get("state") { + Some(&"deleting") => DatabaseDeletionState::Deleting, + Some(&"deleted") => DatabaseDeletionState::Deleted, + _ => return Err(corrupt_tombstone_error(identity, "record state is invalid")), + }; + let transaction_id = fields.get("transaction_id").copied().unwrap_or_default(); + if !valid_deletion_transaction_id(transaction_id) { + return Err(corrupt_tombstone_error( + identity, + "record transaction ID is invalid", + )); + } + let database_id = fields.get("database_id").copied().unwrap_or_default(); + if database_id.len() != 16 + || u64::from_str_radix(database_id, 16).ok() != Some(identity.database_id) + { + return Err(corrupt_tombstone_error( + identity, + "record database identity does not match the canonical database", + )); + } + + Ok(DeletionTombstone { + state, + transaction_id: transaction_id.to_string(), + }) +} + +fn write_deletion_tombstone( + identity: &DatabaseIdentity, + transaction_id: &str, + state: DatabaseDeletionState, +) -> Result<()> { + let payload = format!( + "version=1\tstate={}\ttransaction_id={}\tdatabase_id={:016x}\n", + state.as_str(), + transaction_id, + identity.database_id + ); + write_record_atomically( + &identity.deletion_tombstone_path, + payload.as_bytes(), + "database deletion tombstone", + )?; + match read_deletion_tombstone(identity)? { + Some(tombstone) + if tombstone.transaction_id == transaction_id && tombstone.state == state => + { + Ok(()) + } + Some(tombstone) => Err(tombstone_transition_error( + identity, + "verify database deletion tombstone", + transaction_id, + &tombstone, + )), + None => Err(access_error( + "verify database deletion tombstone", + &identity.database_path, + "database deletion tombstone disappeared after publication", + )), + } +} + +fn corrupt_tombstone_error(identity: &DatabaseIdentity, reason: &str) -> TraceDecayError { + access_error( + "read database deletion tombstone", + &identity.database_path, + &format!("database deletion tombstone is corrupt: {reason}"), + ) +} + +fn tombstone_transition_error( + identity: &DatabaseIdentity, + operation: &str, + expected_transaction_id: &str, + tombstone: &DeletionTombstone, +) -> TraceDecayError { + let message = if tombstone.transaction_id != expected_transaction_id { + format!( + "database deletion tombstone belongs to transaction {}, not {}", + tombstone.transaction_id, expected_transaction_id + ) + } else { + format!( + "database deletion tombstone is already {} and cannot perform this transition", + tombstone.state.as_str() + ) + }; + access_error(operation, &identity.database_path, &message) +} + +pub(crate) fn database_path_is_tombstoned(db_path: &Path) -> Result { + let identity = DatabaseIdentity::for_path(db_path)?; + read_deletion_tombstone(&identity).map(|tombstone| tombstone.is_some()) } pub(crate) fn probe_writer_owner(db_path: &Path) -> Result { @@ -346,8 +947,12 @@ pub(crate) fn probe_writer_owner(db_path: &Path) -> Result { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(lease) = leases.get(&identity.database_key) { - let owner = match &lease.held { - HeldLocks::Daemon { owner, .. } | HeldLocks::Maintenance { owner, .. } => owner, + let owner = match lease { + ProcessLease::Authority { + held: HeldLocks::Daemon { owner, .. } | HeldLocks::Maintenance { owner, .. }, + .. + } + | ProcessLease::Deletion { owner, .. } => owner, }; return Ok(WriterOwnership::Active(owner.clone())); } diff --git a/src/db/access/owner_io.rs b/src/db/access/owner_io.rs index 9e0060172..dca16062d 100644 --- a/src/db/access/owner_io.rs +++ b/src/db/access/owner_io.rs @@ -25,11 +25,23 @@ pub(super) fn open_lock_file(path: &Path) -> Result { } pub(super) fn write_owner(path: &Path, owner: &WriterOwner) -> Result<()> { + let payload = format!( + "token={}\tpid={}\tstarted_epoch_ms={}\tversion={}\tintent={}\n", + owner.token, owner.pid, owner.started_epoch_ms, owner.version, owner.intent + ); + write_record_atomically(path, payload.as_bytes(), "writer owner") +} + +pub(super) fn write_record_atomically( + path: &Path, + payload: &[u8], + record_name: &str, +) -> Result<()> { let file_name = path.file_name().ok_or_else(|| { access_error( - "write writer owner", + &format!("write {record_name}"), path, - "writer owner path has no file name", + &format!("{record_name} path has no file name"), ) })?; let nonce = AUTHORITY_NONCE.fetch_add(1, Ordering::Relaxed); @@ -39,16 +51,7 @@ pub(super) fn write_owner(path: &Path, owner: &WriterOwner) -> Result<()> { std::process::id(), nonce )); - let payload = format!( - "token={}\tpid={}\tstarted_epoch_ms={}\tversion={}\tintent={}", - owner.token, owner.pid, owner.started_epoch_ms, owner.version, owner.intent - ); - publish_record_atomically( - &temporary, - path, - format!("{payload}\n").as_bytes(), - "writer owner", - ) + publish_record_atomically(&temporary, path, payload, record_name) } pub(super) fn publish_record_atomically( @@ -71,7 +74,7 @@ pub(super) fn publish_record_atomically( })?; created = true; file.write_all(payload) - .and_then(|_| file.sync_all()) + .and_then(|()| file.sync_all()) .map_err(|error| access_io_error(&format!("write {record_name}"), temporary, &error))?; replace_file_atomically(temporary, destination, record_name)?; sync_parent_directory(destination, record_name) @@ -82,6 +85,72 @@ pub(super) fn publish_record_atomically( publish } +pub(super) fn read_record_strict(path: &Path, record_name: &str) -> Result> { + const MAX_RECORD_BYTES: u64 = 4096; + + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(access_io_error( + &format!("inspect {record_name}"), + path, + &error, + )); + } + }; + if metadata.file_type().is_symlink() { + return Err(access_error( + &format!("read {record_name}"), + path, + &format!("{record_name} must not be a symlink"), + )); + } + if !metadata.is_file() { + return Err(access_error( + &format!("read {record_name}"), + path, + &format!("{record_name} is not a regular file"), + )); + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::OpenOptionsExt; + const O_NOFOLLOW: i32 = 0o40_0000; + options.custom_flags(O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|error| access_io_error(&format!("read {record_name}"), path, &error))?; + let mut bytes = Vec::new(); + file.take(MAX_RECORD_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| access_io_error(&format!("read {record_name}"), path, &error))?; + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(access_error( + &format!("read {record_name}"), + path, + &format!("{record_name} exceeds {MAX_RECORD_BYTES} bytes"), + )); + } + String::from_utf8(bytes).map(Some).map_err(|_| { + access_error( + &format!("read {record_name}"), + path, + &format!("{record_name} is not valid UTF-8"), + ) + }) +} + +pub(super) fn remove_record_durably(path: &Path, record_name: &str) -> Result<()> { + std::fs::remove_file(path) + .map_err(|error| access_io_error(&format!("remove {record_name}"), path, &error))?; + sync_parent_directory(path, record_name) +} + #[cfg(not(windows))] pub(super) fn replace_file_atomically( temporary: &Path, diff --git a/src/db/access/path_layout.rs b/src/db/access/path_layout.rs index 199deaa18..d8b632ea8 100644 --- a/src/db/access/path_layout.rs +++ b/src/db/access/path_layout.rs @@ -88,10 +88,23 @@ pub(super) fn is_legacy_repository_database(database_path: &Path) -> bool { } pub(super) fn stable_path_hash(path: &Path) -> u64 { - let mut hash = 0xcbf29ce484222325_u64; + let mut hash = 0xcbf2_9ce4_8422_2325_u64; for byte in native_path_bytes(path) { hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x100000001b3); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + hash +} + +pub(super) fn stable_path_set_hash<'a>(paths: impl IntoIterator) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for path in paths { + for byte in native_path_bytes(path) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + hash ^= u64::from(b'\0'); + hash = hash.wrapping_mul(0x0100_0000_01b3); } hash } diff --git a/src/db/access/tests.rs b/src/db/access/tests.rs new file mode 100644 index 000000000..892a9f201 --- /dev/null +++ b/src/db/access/tests.rs @@ -0,0 +1,773 @@ +use super::*; + +static SCOPE_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn canonical_identity_collapses_parent_aliases() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("nested")).unwrap(); + let direct = DatabaseIdentity::for_path(&temp.path().join("graph.db")).unwrap(); + let aliased = DatabaseIdentity::for_path(&temp.path().join("nested/../graph.db")).unwrap(); + assert_eq!(direct, aliased); +} + +#[test] +fn identity_key_preserves_unproven_case_variants() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + + assert_ne!(platform_identity_key(&upper), platform_identity_key(&lower)); +} + +#[cfg(target_os = "linux")] +#[test] +fn case_distinct_database_files_have_distinct_identities() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + std::fs::write(&upper, []).unwrap(); + std::fs::write(&lower, []).unwrap(); + + let upper = DatabaseIdentity::for_path(&upper).unwrap(); + let lower = DatabaseIdentity::for_path(&lower).unwrap(); + + assert_ne!(upper.database_key, lower.database_key); + assert_ne!(upper.writer_lock_path, lower.writer_lock_path); + + let upper_authority = DatabaseAuthority::acquire_test( + &temp.path().join("MixedCase.DB"), + "upper case-sensitive database", + ) + .unwrap(); + let lower_authority = DatabaseAuthority::acquire_test( + &temp.path().join("mixedcase.db"), + "lower case-sensitive database", + ) + .unwrap(); + assert_ne!(upper_authority.token(), lower_authority.token()); +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn fresh_case_variants_cannot_hold_concurrent_first_create_authorities() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + + let first = DatabaseAuthority::acquire_test(&upper, "first case variant").unwrap(); + let error = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap_err(); + assert!(error.to_string().contains("case-variant first-create")); + + std::fs::write(&upper, []).unwrap(); + drop(first); + let second = DatabaseAuthority::acquire_test(&lower, "second case variant").unwrap(); + if lower.exists() { + assert_eq!( + second.canonical_database_path(), + upper.canonicalize().unwrap() + ); + } else { + std::fs::write(&lower, []).unwrap(); + let upper_identity = DatabaseIdentity::for_path(&upper).unwrap(); + let lower_identity = DatabaseIdentity::for_path(&lower).unwrap(); + assert_ne!(upper_identity.database_key, lower_identity.database_key); + } +} + +#[cfg(unix)] +#[test] +fn symlink_aliases_share_one_database_identity() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("database.db"); + let alias = temp.path().join("database-alias.db"); + std::fs::write(&database, []).unwrap(); + std::os::unix::fs::symlink(&database, &alias).unwrap(); + + let database = DatabaseIdentity::for_path(&database).unwrap(); + let alias = DatabaseIdentity::for_path(&alias).unwrap(); + + assert_eq!(database.database_key, alias.database_key); + assert_eq!(database.writer_lock_path, alias.writer_lock_path); +} + +#[test] +fn profile_databases_share_one_exact_profile_scope() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join("profile"); + std::fs::create_dir_all(&profile).unwrap(); + let expected_profile = platform_identity_key(&profile.canonicalize().unwrap()); + let expected_lock_parent = expected_profile.join(".tracedecay-database-locks"); + let paths = [ + profile.join("global.db"), + profile.join("user-memory.db"), + profile.join("user-sessions.db"), + profile.join("projects/project/tracedecay.db"), + profile.join("projects/project/sessions.db"), + profile.join("projects/project/branches/feature.db"), + ]; + + for path in paths { + let identity = DatabaseIdentity::for_path(&path).unwrap(); + assert_eq!( + identity.profile_root, + expected_profile, + "{}", + path.display() + ); + assert!( + !identity.allows_ambient_profile_scope, + "{} must require its exact profile authority", + path.display() + ); + assert_eq!( + identity.access_lock_path.parent(), + Some(expected_lock_parent.as_path()) + ); + } +} + +#[test] +fn projects_directory_in_repository_path_is_not_a_profile_shard() { + let temp = tempfile::tempdir().unwrap(); + let data_root = temp.path().join("projects/repository/.tracedecay"); + let path = data_root.join("tracedecay.db"); + let identity = DatabaseIdentity::for_path(&path).unwrap(); + + assert_eq!( + identity.profile_root, + platform_identity_key(&data_root.canonicalize().unwrap()) + ); + assert!(identity.allows_ambient_profile_scope); +} + +#[test] +fn fs2_contention_is_classified_as_an_active_lease() { + let temp = tempfile::tempdir().unwrap(); + let lock_path = temp.path().join("authority.lock"); + let first = open_lock_file(&lock_path).unwrap(); + let second = open_lock_file(&lock_path).unwrap(); + fs2::FileExt::try_lock_exclusive(&first).unwrap(); + + let error = fs2::FileExt::try_lock_exclusive(&second).unwrap_err(); + + assert!(is_lock_contended(&error), "unexpected lock error: {error}"); + fs2::FileExt::unlock(&first).unwrap(); +} + +#[test] +fn writer_owner_replacement_is_complete_and_leaves_no_temporary_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("writer.owner"); + let first = writer_owner("first", "first owner"); + let second = writer_owner("second", "replacement owner"); + write_owner(&path, &first).unwrap(); + + write_owner(&path, &second).unwrap(); + + assert_eq!(read_owner(&path), Some(second)); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +#[test] +fn atomic_record_publication_preserves_a_colliding_temporary_file() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("authority.record"); + let temporary = temp.path().join("authority.record.tmp"); + std::fs::write(&temporary, b"other publisher").unwrap(); + + let error = DatabaseAuthority::publish_record_atomically( + &temporary, + &destination, + b"replacement", + "test authority record", + ) + .unwrap_err(); + + assert!(error.to_string().contains("create test authority record")); + assert_eq!(std::fs::read(&temporary).unwrap(), b"other publisher"); + assert!(!destination.exists()); +} + +#[test] +fn daemon_authority_is_same_process_reentrant() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let first = DatabaseAuthority::acquire_test(&path, "first").unwrap(); + let second = DatabaseAuthority::acquire_test(&path, "second").unwrap(); + assert_eq!(first.token(), second.token()); + assert_eq!( + probe_writer_owner(&path).unwrap(), + WriterOwnership::Active( + read_owner(&DatabaseIdentity::for_path(&path).unwrap().writer_owner_path).unwrap() + ) + ); + drop(first); + assert!(matches!( + probe_writer_owner(&path).unwrap(), + WriterOwnership::Active(_) + )); + drop(second); + assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); +} + +#[test] +fn maintenance_and_daemon_authorities_are_mutually_exclusive() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let daemon = DatabaseAuthority::acquire_test(&path, "daemon").unwrap(); + let error = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap_err(); + assert!( + error + .to_string() + .contains("incompatible database authority") + ); + drop(daemon); + + let maintenance = DatabaseAuthority::acquire_maintenance(&path, "replace").unwrap(); + let error = DatabaseAuthority::acquire_test(&path, "daemon").unwrap_err(); + assert!( + error + .to_string() + .contains("incompatible database authority") + ); + drop(maintenance); +} + +#[test] +fn deletion_fence_is_ordered_and_same_process_exclusive() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("a.db"); + let second = temp.path().join("b.db"); + let ordinary = DatabaseAuthority::acquire_test(&first, "ordinary holder").unwrap(); + + let error = + DatabaseDeletionFence::acquire(&[second.clone(), first.clone()], "delete databases") + .unwrap_err(); + assert!( + error + .to_string() + .contains("incompatible database authority") + ); + drop(ordinary); + + let fence = DatabaseDeletionFence::acquire( + &[second.clone(), first.clone(), second.clone()], + "delete databases", + ) + .unwrap(); + assert_eq!( + fence.database_paths().collect::>(), + vec![first.as_path(), second.as_path()] + ); + assert!(fence.transaction_id().contains(':')); + assert_eq!(fence.tombstone_paths().count(), 2); + + let error = DatabaseAuthority::acquire_test(&first, "ordinary overlap").unwrap_err(); + assert!(error.to_string().contains("database deletion fence")); + let error = DatabaseDeletionFence::acquire(&[second], "second deletion").unwrap_err(); + assert!(error.to_string().contains("deletion fence")); + + drop(fence); + DatabaseAuthority::acquire_test(&first, "ordinary after fence").unwrap(); +} + +#[test] +fn deleting_tombstones_rollback_only_while_the_fence_is_retained() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("a.db"); + let second = temp.path().join("b.db"); + let fence = + DatabaseDeletionFence::acquire(&[second.clone(), first.clone()], "delete databases") + .unwrap(); + + fence.publish_deleting().unwrap(); + for path in fence.tombstone_paths() { + let record = read_record_strict(path, "test tombstone").unwrap().unwrap(); + assert!(record.contains("state=deleting")); + assert!(record.contains(fence.transaction_id())); + } + let error = DatabaseAuthority::acquire_test(&first, "open while deleting").unwrap_err(); + assert!(error.to_string().contains("database deletion fence")); + + fence.rollback_deleting().unwrap(); + assert!(fence.tombstone_paths().all(|path| !path.exists())); + drop(fence); + DatabaseAuthority::acquire_test(&first, "open after rollback").unwrap(); +} + +#[test] +fn drop_retains_deleting_tombstone_and_ordinary_authority_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + let tombstone = fence.tombstone_paths().next().unwrap().to_path_buf(); + + fence.publish_deleting().unwrap(); + drop(fence); + + assert!(tombstone.exists()); + let error = DatabaseAuthority::acquire_test(&database, "open deleted database").unwrap_err(); + assert!(error.to_string().contains("deletion is in progress")); + remove_record_durably(&tombstone, "test tombstone cleanup").unwrap(); + DatabaseAuthority::acquire_test(&database, "open after cleanup").unwrap(); +} + +#[test] +fn rollback_never_removes_another_transactions_tombstone() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + + let foreign = format!( + "version=1\tstate=deleting\ttransaction_id=foreign\tdatabase_id={:016x}\n", + identity.database_id + ); + write_record_atomically( + &identity.deletion_tombstone_path, + foreign.as_bytes(), + "foreign test tombstone", + ) + .unwrap(); + let error = fence.rollback_deleting().unwrap_err(); + assert!(error.to_string().contains("belongs to transaction foreign")); + assert!(identity.deletion_tombstone_path.exists()); + + let own = format!( + "version=1\tstate=deleting\ttransaction_id={}\tdatabase_id={:016x}\n", + fence.transaction_id(), + identity.database_id + ); + write_record_atomically( + &identity.deletion_tombstone_path, + own.as_bytes(), + "own test tombstone", + ) + .unwrap(); + fence.rollback_deleting().unwrap(); +} + +#[test] +fn same_transaction_deleting_tombstone_can_be_reacquired_with_locks_retained() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + drop(fence); + + let (reacquired, states) = DatabaseDeletionFence::reacquire( + std::slice::from_ref(&database), + &transaction_id, + "recover deletion", + ) + .unwrap(); + assert_eq!(states.missing(), 0); + assert_eq!(states.deleting(), 1); + assert_eq!(states.deleted(), 0); + assert_eq!(reacquired.tombstone_states().unwrap(), states); + let error = DatabaseAuthority::acquire_test(&database, "overlap recovery").unwrap_err(); + assert!(error.to_string().contains("database deletion fence")); + + reacquired.rollback_deleting().unwrap(); + drop(reacquired); + DatabaseAuthority::acquire_test(&database, "open after recovery rollback").unwrap(); +} + +#[test] +fn same_transaction_deleted_tombstone_can_be_reacquired_idempotently() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + fence.promote_deleted().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + drop(fence); + + let (reacquired, states) = DatabaseDeletionFence::reacquire( + std::slice::from_ref(&database), + &transaction_id, + "recover committed deletion", + ) + .unwrap(); + assert_eq!(states.deleted(), 1); + reacquired.promote_deleted().unwrap(); + assert_eq!(reacquired.tombstone_states().unwrap().deleted(), 1); + assert!(reacquired.rollback_deleting().is_err()); + drop(reacquired); + remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); +} + +#[test] +fn deletion_reacquire_rejects_foreign_transaction() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + drop(fence); + + let error = DatabaseDeletionFence::reacquire( + std::slice::from_ref(&database), + "foreign-transaction", + "recover foreign deletion", + ) + .unwrap_err(); + assert!(error.to_string().contains("belongs to transaction")); + remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); +} + +#[test] +fn partial_publication_reacquires_missing_and_deleting_for_rollback() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("a.db"); + let second = temp.path().join("b.db"); + let fence = + DatabaseDeletionFence::acquire(&[first.clone(), second.clone()], "delete databases") + .unwrap(); + fence.publish_deleting().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let first_tombstone = fence.tombstone_paths().next().unwrap().to_path_buf(); + remove_record_durably(&first_tombstone, "simulate partial publication").unwrap(); + drop(fence); + + let (reacquired, states) = DatabaseDeletionFence::reacquire( + &[second.clone(), first.clone()], + &transaction_id, + "recover partial publication", + ) + .unwrap(); + assert_eq!(states.missing(), 1); + assert_eq!(states.deleting(), 1); + assert!(states.has_missing()); + assert!(states.has_deleting()); + assert!(!states.has_deleted()); + reacquired.rollback_deleting().unwrap(); + drop(reacquired); + DatabaseAuthority::acquire_test(&first, "open first after rollback").unwrap(); + DatabaseAuthority::acquire_test(&second, "open second after rollback").unwrap(); +} + +#[test] +fn partial_promotion_reacquires_deleting_and_deleted_for_completion() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("a.db"); + let second = temp.path().join("b.db"); + let first_identity = DatabaseIdentity::for_path(&first).unwrap(); + let second_identity = DatabaseIdentity::for_path(&second).unwrap(); + let fence = + DatabaseDeletionFence::acquire(&[first.clone(), second.clone()], "delete databases") + .unwrap(); + fence.publish_deleting().unwrap(); + fence.promote_deleted().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + let partial = format!( + "version=1\tstate=deleting\ttransaction_id={}\tdatabase_id={:016x}\n", + transaction_id, first_identity.database_id + ); + write_record_atomically( + &first_identity.deletion_tombstone_path, + partial.as_bytes(), + "simulate partial promotion", + ) + .unwrap(); + drop(fence); + + let (reacquired, states) = DatabaseDeletionFence::reacquire( + &[second.clone(), first.clone()], + &transaction_id, + "recover partial promotion", + ) + .unwrap(); + assert_eq!(states.deleting(), 1); + assert_eq!(states.deleted(), 1); + assert!(!states.has_missing()); + assert!(states.has_deleting()); + assert!(states.has_deleted()); + reacquired.promote_deleted().unwrap(); + let completed = reacquired.tombstone_states().unwrap(); + assert_eq!(completed.deleting(), 0); + assert_eq!(completed.deleted(), 2); + drop(reacquired); + remove_record_durably( + &first_identity.deletion_tombstone_path, + "test tombstone cleanup", + ) + .unwrap(); + remove_record_durably( + &second_identity.deletion_tombstone_path, + "test tombstone cleanup", + ) + .unwrap(); +} + +#[test] +fn promoted_tombstone_is_committed_and_cannot_be_rolled_back() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + let tombstone = fence.tombstone_paths().next().unwrap().to_path_buf(); + + fence.publish_deleting().unwrap(); + fence.promote_deleted().unwrap(); + let error = fence.rollback_deleting().unwrap_err(); + assert!(error.to_string().contains("already deleted")); + drop(fence); + + assert!(tombstone.exists()); + let error = DatabaseAuthority::acquire_test(&database, "open deleted database").unwrap_err(); + assert!(error.to_string().contains("database was deleted")); + remove_record_durably(&tombstone, "test tombstone cleanup").unwrap(); +} + +#[test] +fn retired_path_query_distinguishes_missing_deleting_and_deleted() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + assert!(!database_path_is_tombstoned(&database).unwrap()); + + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&database), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + assert!(database_path_is_tombstoned(&database).unwrap()); + fence.promote_deleted().unwrap(); + assert!(database_path_is_tombstoned(&database).unwrap()); + drop(fence); + + remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); + assert!(!database_path_is_tombstoned(&database).unwrap()); +} + +#[test] +fn corrupt_and_identity_mismatched_tombstones_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + + write_record_atomically( + &identity.deletion_tombstone_path, + b"not-a-tombstone\n", + "test corrupt tombstone", + ) + .unwrap(); + let error = database_path_is_tombstoned(&database).unwrap_err(); + assert!(error.to_string().contains("tombstone is corrupt")); + let error = DatabaseAuthority::acquire_test(&database, "open corrupt marker").unwrap_err(); + assert!(error.to_string().contains("tombstone is corrupt")); + + let payload = format!( + "version=1\tstate=deleting\ttransaction_id=test\tdatabase_id={:016x}\n", + identity.database_id.wrapping_add(1) + ); + write_record_atomically( + &identity.deletion_tombstone_path, + payload.as_bytes(), + "test mismatched tombstone", + ) + .unwrap(); + let error = database_path_is_tombstoned(&database).unwrap_err(); + assert!(error.to_string().contains("identity does not match")); + let error = DatabaseAuthority::acquire_test(&database, "open mismatched marker").unwrap_err(); + assert!(error.to_string().contains("identity does not match")); + + remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); + std::fs::create_dir(&identity.deletion_tombstone_path).unwrap(); + let error = database_path_is_tombstoned(&database).unwrap_err(); + assert!(error.to_string().contains("not a regular file")); + let error = DatabaseAuthority::acquire_test(&database, "open unreadable marker").unwrap_err(); + assert!(error.to_string().contains("not a regular file")); + std::fs::remove_dir(&identity.deletion_tombstone_path).unwrap(); + + DatabaseAuthority::acquire_test(&database, "open after marker cleanup").unwrap(); +} + +#[cfg(unix)] +#[test] +fn symlinked_tombstone_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&database).unwrap(); + let target = temp.path().join("other.tombstone"); + std::fs::write( + &target, + format!( + "version=1\tstate=deleting\ttransaction_id=test\tdatabase_id={:016x}\n", + identity.database_id + ), + ) + .unwrap(); + std::os::unix::fs::symlink(&target, &identity.deletion_tombstone_path).unwrap(); + + let error = database_path_is_tombstoned(&database).unwrap_err(); + assert!(error.to_string().contains("must not be a symlink")); + let error = DatabaseAuthority::acquire_test(&database, "open symlink marker").unwrap_err(); + assert!(error.to_string().contains("must not be a symlink")); + std::fs::remove_file(&identity.deletion_tombstone_path).unwrap(); + DatabaseAuthority::acquire_test(&database, "open after symlink cleanup").unwrap(); +} + +#[test] +fn stale_owner_metadata_never_establishes_ownership() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let identity = DatabaseIdentity::for_path(&path).unwrap(); + std::fs::write( + &identity.writer_owner_path, + "token=stale\tpid=1\tstarted_epoch_ms=1\tversion=old\tintent=old\n", + ) + .unwrap(); + assert_eq!(probe_writer_owner(&path).unwrap(), WriterOwnership::Idle); + assert!(identity.writer_owner_path.exists()); +} + +#[test] +fn authority_is_bound_to_one_canonical_database() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.db"); + let second = temp.path().join("second.db"); + let authority = DatabaseAuthority::acquire_test(&first, "test").unwrap(); + let error = authority.hold_for(&second, "open").unwrap_err(); + assert!(error.to_string().contains("different database")); +} + +#[test] +fn daemon_authority_inherits_live_election_scope() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let scope = enter_daemon_database_scope(temp.path(), 7, "election-token").unwrap(); + let authority = DatabaseAuthority::acquire_daemon(&path, "daemon").unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Daemon); + drop(authority); + drop(scope); +} + +#[test] +fn sole_daemon_scope_authorizes_only_legacy_repo_local_database() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let profile = tempfile::tempdir().unwrap(); + let repository = tempfile::tempdir().unwrap(); + let scope = enter_daemon_database_scope(profile.path(), 1, "daemon").unwrap(); + let identity = + DatabaseIdentity::for_path(&repository.path().join(".tracedecay/tracedecay.db")).unwrap(); + + assert!(identity.allows_ambient_profile_scope); + assert_eq!( + scoped_runtime_role(&identity, "legacy repository database").unwrap(), + Some(DatabaseAuthorityRole::Daemon) + ); + + drop(scope); +} + +#[test] +fn sole_daemon_scope_rejects_standard_databases_from_another_profile() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); + let paths = [ + second.path().join("global.db"), + second.path().join("user-memory.db"), + second.path().join("user-sessions.db"), + second.path().join("projects/project/tracedecay.db"), + second.path().join("projects/project/sessions.db"), + second.path().join("projects/project/branches/feature.db"), + ]; + + for path in paths { + let identity = DatabaseIdentity::for_path(&path).unwrap(); + assert_eq!( + exact_scoped_runtime_role(&identity.profile_root, "other profile").unwrap(), + None + ); + assert_eq!( + scoped_runtime_role(&identity, "other profile").unwrap(), + None, + "{} used an unrelated ambient profile scope", + path.display() + ); + } + + drop(scope); +} + +#[test] +fn maintenance_scope_requires_and_inherits_exclusive_profile_lease() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("projects/p1/tracedecay.db"); + let lifecycle = + crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "maintenance test") + .unwrap(); + let scope = + enter_maintenance_database_scope(&lifecycle, temp.path(), "maintenance test").unwrap(); + let authority = DatabaseAuthority::for_runtime(&path, "repair").unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); + drop(authority); + drop(scope); + drop(lifecycle); +} + +#[test] +fn daemon_scopes_are_isolated_by_profile() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_scope = enter_daemon_database_scope(first.path(), 1, "first").unwrap(); + let second_scope = enter_daemon_database_scope(second.path(), 1, "second").unwrap(); + + let first_authority = DatabaseAuthority::for_runtime( + &first.path().join("projects/one/tracedecay.db"), + "first profile", + ) + .unwrap(); + let second_authority = DatabaseAuthority::for_runtime( + &second.path().join("projects/two/tracedecay.db"), + "second profile", + ) + .unwrap(); + assert_eq!(first_authority.role(), DatabaseAuthorityRole::Daemon); + assert_eq!(second_authority.role(), DatabaseAuthorityRole::Daemon); + + drop((first_authority, second_authority, first_scope, second_scope)); +} + +#[test] +fn maintenance_scope_is_reentrant_across_nested_intents() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let lifecycle = + crate::lifecycle_lease::acquire_exclusive_for_profile(temp.path(), "outer").unwrap(); + let outer = enter_maintenance_database_scope(&lifecycle, temp.path(), "plan").unwrap(); + let inner = enter_maintenance_database_scope(&lifecycle, temp.path(), "apply").unwrap(); + let authority = DatabaseAuthority::for_runtime( + &temp.path().join("projects/p1/tracedecay.db"), + "nested operation", + ) + .unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); + + drop(authority); + drop(inner); + drop(outer); + drop(lifecycle); +} diff --git a/src/db/connection.rs b/src/db/connection.rs index 8287952be..8ae059d80 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -87,7 +87,7 @@ impl Database { let inner = Arc::new(DatabaseInner { conn, - _db: db, + db, writable: true, _authority: authority, _slot: Some(slot.clone()), @@ -144,7 +144,7 @@ impl Database { let inner = Arc::new(DatabaseInner { conn, - _db: db, + db, writable: true, _authority: authority, _slot: Some(slot.clone()), @@ -186,7 +186,7 @@ impl Database { let inner = Arc::new(DatabaseInner { conn, - _db: db, + db, writable: false, _authority: authority, _slot: None, @@ -279,7 +279,7 @@ impl Database { } else { Some( self.inner - ._db + .db .connect() .map_err(|e| TraceDecayError::Database { message: format!("failed to open database snapshot connection: {e}"), diff --git a/src/db/connection/pragmas.rs b/src/db/connection/pragmas.rs index 2ec35706b..c53ca6f01 100644 --- a/src/db/connection/pragmas.rs +++ b/src/db/connection/pragmas.rs @@ -41,7 +41,7 @@ pub(crate) fn adaptive_cache_sizes(db_file_size: u64) -> (u64, u64) { /// /// Graph-store mmap is disabled on every platform. Long-lived daemon handles /// and short-lived peers previously retained divergent mapped page views -/// across WAL checkpoints; ordinary file I/O keeps SQLite's locking and WAL +/// across WAL checkpoints; ordinary file I/O keeps `SQLite`'s locking and WAL /// coherence mechanisms authoritative. #[cfg(test)] pub(crate) fn platform_safe_mmap_size(_mmap: u64) -> u64 { @@ -54,7 +54,7 @@ fn sqlite_unsafe_fast_enabled() -> bool { /// Returns the `journal_mode` safe for the current platform. /// -/// Windows libsql/SQLite local databases can intermittently fault while closing +/// Windows libsql/`SQLite` local databases can intermittently fault while closing /// WAL-mode databases under nextest's per-test process isolation. Disabling /// mmap removed one unsafe teardown path, but master CI still aborts in /// unrelated tests as different short-lived databases close. Use rollback diff --git a/src/db/connection/registry.rs b/src/db/connection/registry.rs index 2b332614d..1aa2205f4 100644 --- a/src/db/connection/registry.rs +++ b/src/db/connection/registry.rs @@ -9,17 +9,19 @@ use crate::db::DatabaseAuthority; pub(super) struct DatabaseInner { pub(super) conn: Connection, /// Kept alive so the underlying database is not dropped. - pub(super) _db: LibsqlDatabase, + pub(super) db: LibsqlDatabase, pub(super) writable: bool, pub(super) _authority: DatabaseAuthority, pub(super) _slot: Option, } -pub(super) type DatabaseSlot = Arc>>; +type DatabaseWeak = Weak; +pub(super) type DatabaseSlot = Arc>; +type WeakDatabaseSlot = Weak>; +type OpenDatabases = HashMap; -static OPEN_DATABASES: LazyLock< - Mutex>>>>, -> = LazyLock::new(|| Mutex::new(HashMap::new())); +static OPEN_DATABASES: LazyLock> = + LazyLock::new(|| Mutex::new(HashMap::new())); pub(super) fn database_slot(path: &Path) -> DatabaseSlot { let mut databases = OPEN_DATABASES diff --git a/src/db/mod.rs b/src/db/mod.rs index d69ea340d..dcdf8ad6a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -20,7 +20,10 @@ mod unresolved; #[doc(hidden)] pub use access::enter_maintenance_database_scope; pub use access::{DatabaseAuthority, DatabaseAuthorityRole}; -pub(crate) use access::{WriterOwnership, enter_daemon_database_scope, probe_writer_owner}; +pub(crate) use access::{ + DatabaseDeletionFence, DatabaseDeletionStates, WriterOwnership, database_path_is_tombstoned, + enter_daemon_database_scope, probe_writer_owner, +}; pub use connection::{Database, SQLITE_UNSAFE_FAST_ENV}; pub(crate) use connection::{platform_safe_journal_mode, platform_safe_synchronous_mode}; pub use fingerprints::StoredFingerprint; diff --git a/src/doctor.rs b/src/doctor.rs index 6ce88c5e4..fb5e3e34e 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -255,10 +255,10 @@ async fn daemon_project_status(project_path: &Path) -> crate::errors::Result crate::errors::Result { +fn daemon_tool_json(result: &serde_json::Value) -> crate::errors::Result { let text = result .get("content") .and_then(serde_json::Value::as_array) @@ -423,7 +423,7 @@ fn check_global_db(dc: &mut DoctorCounters) { } } -/// Registry SQLite is owned by the daemon. The external doctor reports that +/// Registry `SQLite` is owned by the daemon. The external doctor reports that /// ownership and leaves stale-row inspection/repair to daemon-backed tools. fn check_stale_stores(dc: &mut DoctorCounters, status: Option<&serde_json::Value>) { eprintln!("\n\x1b[1mStorage registry\x1b[0m"); diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index 06361fffe..b4b7c3eef 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -843,7 +843,7 @@ fn plain_orphan_still_warns_with_update_remediation() { #[test] fn daemon_status_parser_extracts_storage_health() { - let parsed = super::daemon_tool_json(serde_json::json!({ + let parsed = super::daemon_tool_json(&serde_json::json!({ "content": [{ "type": "text", "text": r#"{"storage_health":{"quick_check_ok":true,"daemon_generation":"run-7"}}"# @@ -863,6 +863,6 @@ fn daemon_status_parser_extracts_storage_health() { #[test] fn daemon_status_parser_rejects_missing_json_payload() { - let error = super::daemon_tool_json(serde_json::json!({ "content": [] })).unwrap_err(); + let error = super::daemon_tool_json(&serde_json::json!({ "content": [] })).unwrap_err(); assert!(error.to_string().contains("invalid JSON")); } diff --git a/src/global.rs b/src/global.rs index bbd3c4658..0cf92f304 100644 --- a/src/global.rs +++ b/src/global.rs @@ -314,16 +314,24 @@ pub(crate) fn tracedecay_dir_size(dir: &Path) -> u64 { /// /// `--all` returns every path tracked in the global DB (including stale rows). /// Otherwise returns the local discovery from cwd / ancestors / descendants. +/// +/// Global discovery is deliberately fail-closed: destructive callers must not +/// interpret an unavailable daemon or malformed registry response as an empty +/// registry. pub(crate) async fn gather_target_projects( all: bool, home_tracedecay: &Option, -) -> Vec { +) -> tracedecay::errors::Result> { if all { - let Ok(cwd) = std::env::current_dir() else { - return Vec::new(); - }; + let cwd = std::env::current_dir().map_err(|error| { + tracedecay::errors::TraceDecayError::Config { + message: format!( + "failed to determine current directory for registry discovery: {error}" + ), + } + })?; let project_root = tracedecay::config::discover_project_root(&cwd); - let Ok(payload) = call_admin_cli( + let payload = call_admin_cli( project_root.as_deref(), serde_json::json!({ "action": "registry_list", @@ -331,22 +339,40 @@ pub(crate) async fn gather_target_projects( "query": null, }), ) - .await - else { - return Vec::new(); - }; - payload["projects"] - .as_array() - .into_iter() - .flatten() - .filter_map(|project| project["project_root"].as_str()) - .map(std::path::PathBuf::from) - .collect() + .await?; + registry_project_roots(&payload) } else { - gather_local_projects(home_tracedecay) + Ok(gather_local_projects(home_tracedecay)) } } +fn registry_project_roots( + payload: &serde_json::Value, +) -> tracedecay::errors::Result> { + let projects = payload + .get("projects") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: "daemon registry list response omitted projects array".to_string(), + })?; + + projects + .iter() + .enumerate() + .map(|(index, project)| { + project + .get("project_root") + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!( + "daemon registry list response has no project_root for project at index {index}" + ), + }) + }) + .collect() +} + async fn call_admin_cli( project_root: Option<&Path>, arguments: serde_json::Value, @@ -824,6 +850,24 @@ mod gather_tests { assert!(out.is_empty(), "got {out:?}"); } + #[test] + fn registry_target_parser_rejects_malformed_rows() { + let error = registry_project_roots(&serde_json::json!({ + "projects": [{ "project_id": "missing-root" }] + })) + .expect_err("malformed registry data must not become an empty target list"); + + assert!(error.to_string().contains("project_root")); + } + + #[test] + fn registry_target_parser_preserves_an_explicitly_empty_registry() { + let paths = registry_project_roots(&serde_json::json!({ "projects": [] })) + .expect("an explicit empty registry is valid"); + + assert!(paths.is_empty()); + } + #[test] fn elapsed_since_clamps_future_timestamps() { assert_eq!(elapsed_since(100, 40), 60); diff --git a/src/global_db.rs b/src/global_db.rs index cc187ce03..550cb5d7a 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -1029,7 +1029,7 @@ impl GlobalDb { /// callers' entire session. Schema initialization is singleflight per /// canonical database identity; after it completes, every caller opens an /// independent connection so caller-managed transactions cannot interleave - /// on one shared libSQL session. SQLite still serializes actual writers. + /// on one shared libSQL session. `SQLite` still serializes actual writers. pub async fn open_at(db_path: &std::path::Path) -> Option { Self::best_effort_open(Self::try_open_at(db_path).await) } @@ -1908,9 +1908,17 @@ impl GlobalDb { }) } - pub async fn list_code_projects(&self, limit: usize) -> Vec { + /// Lists registered code projects, preserving query and row-decoding errors. + /// + /// Destructive maintenance callers must use this instead of the best-effort + /// [`Self::list_code_projects`] wrapper so a registry failure cannot be + /// mistaken for an empty registry. + pub async fn try_list_code_projects( + &self, + limit: usize, + ) -> crate::errors::Result> { let limit = i64::try_from(limit).unwrap_or(i64::MAX); - let Ok(mut rows) = self + let mut rows = self .conn .query( "SELECT project_id, canonical_root, display_root, git_common_dir, @@ -1920,17 +1928,23 @@ impl GlobalDb { LIMIT ?1", params![limit], ) - .await - else { - return Vec::new(); - }; + .await?; let mut projects = Vec::new(); - while let Ok(Some(row)) = rows.next().await { - if let Some(project) = row_to_code_project(&row, 0) { - projects.push(project); - } + while let Some(row) = rows.next().await? { + let project = row_to_code_project(&row, 0).ok_or_else(|| { + crate::errors::TraceDecayError::Database { + message: "failed to decode code project registry row".to_string(), + operation: "list code projects".to_string(), + } + })?; + projects.push(project); } - projects + Ok(projects) + } + + /// Lists registered code projects on the daemon's best-effort path. + pub async fn list_code_projects(&self, limit: usize) -> Vec { + self.try_list_code_projects(limit).await.unwrap_or_default() } /// Returns registered code projects whose `last_seen_at` is within the last diff --git a/src/global_db/tests.rs b/src/global_db/tests.rs index 84fdfef9c..7638b6d66 100644 --- a/src/global_db/tests.rs +++ b/src/global_db/tests.rs @@ -151,9 +151,8 @@ async fn try_open_at_preserves_authority_error() { if path.starts_with(std::env::temp_dir()) { return; } - let error = match GlobalDb::try_open_at(&path).await { - Err(error) => error, - Ok(_) => panic!("unauthorized global DB open unexpectedly succeeded"), + let Err(error) = GlobalDb::try_open_at(&path).await else { + panic!("unauthorized global DB open unexpectedly succeeded"); }; let message = error.to_string(); assert!( @@ -166,12 +165,11 @@ async fn try_open_at_preserves_authority_error() { } #[tokio::test] -async fn isolated_temp_database_uses_test_authority() { +async fn isolated_temp_database_opens_without_ambient_daemon() { let dir = tempfile::TempDir::new().unwrap(); - let db = GlobalDb::open_at(&dir.path().join("global.db")) + let _db = GlobalDb::open_at(&dir.path().join("global.db")) .await .expect("temp test open"); - assert_eq!(db._authority.role(), crate::db::DatabaseAuthorityRole::Test); } #[test] diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index 935a0e246..9f98540c3 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -9,9 +9,7 @@ use std::time::Duration; use serde_json::Value; -use super::cursor_compact::{ - CURSOR_PRE_COMPACT_SUMMARY_BUDGET, cursor_pre_compact_for_event_with_config, -}; +use super::cursor_compact::cursor_pre_compact_via_daemon; use super::cursor_shell::paths_same; use super::memory_inject; use super::post_tool_use::{captured_tool_output, trusted_tool_failure}; @@ -249,19 +247,18 @@ pub async fn hook_cursor_stop() -> i32 { /// Cursor `preCompact` hook handler. /// /// Cursor's compaction event exposes pressure metadata but not Cursor's own -/// generated summary text. At the boundary, `TraceDecay` ingests the current -/// transcript tail, asks LCM for the compactable raw-message backlog, generates -/// a summary through `cursor-agent -p`, and stores that summary as a normal LCM -/// summary node. The hook is fail-open and emits Cursor's empty object shape. +/// generated summary text. The hook delegates to the daemon, which ingests the +/// current transcript tail, asks LCM for the compactable raw-message backlog, +/// generates a summary through `cursor-agent -p`, and stores that summary as a +/// normal LCM summary node. The hook is fail-open and emits Cursor's empty +/// object shape. pub async fn hook_cursor_pre_compact() -> i32 { let event = read_hook_event!(); let root = cursor_project_root_from_event_with_identity(&event).await; let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Cursor, "preCompact", &event); if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_err() { - let mut config = crate::sessions::cursor_agent::CursorAgentSummaryConfig::from_env(); - config.timeout = config.timeout.min(CURSOR_PRE_COMPACT_SUMMARY_BUDGET); - let outcome = cursor_pre_compact_for_event_with_config(&event, &config).await; + let outcome = cursor_pre_compact_via_daemon(&event).await; if outcome.status == "error" { eprintln!( "tracedecay Cursor preCompact summary failed: {}", @@ -810,9 +807,8 @@ async fn ingest_cursor_transcript_for_event_inner( max_new_bytes: Option, budget: Duration, ) -> CursorIngestOutcome { - let parsed = match serde_json::from_str::(event_json) { - Ok(parsed) => parsed, - Err(_) => return CursorIngestOutcome::default(), + let Ok(parsed) = serde_json::from_str::(event_json) else { + return CursorIngestOutcome::default(); }; let project_root = cursor_project_root_from_parsed_event_with_identity(&parsed).await; let mut args = serde_json::json!({ diff --git a/src/hooks/cursor_compact.rs b/src/hooks/cursor_compact.rs index 76fc2eaf6..90bc68a2c 100644 --- a/src/hooks/cursor_compact.rs +++ b/src/hooks/cursor_compact.rs @@ -1,19 +1,13 @@ //! Cursor `preCompact` machinery. //! //! Cursor's compaction event exposes pressure metadata but not Cursor's own -//! generated summary text, so at the boundary `TraceDecay` ingests the current -//! transcript tail, asks LCM for the compactable raw-message backlog, -//! generates a summary through `cursor-agent -p`, and stores that summary as -//! a normal LCM summary node. +//! generated summary text. The hook delegates compaction to the daemon, which +//! ingests the current transcript tail, asks LCM for the compactable raw-message +//! backlog, generates a summary through `cursor-agent -p`, and stores that +//! summary as a normal LCM summary node. use std::time::Duration; -/// Budget for the auxiliary `cursor-agent` summary call inside the hook. Kept -/// below the registered Cursor hook timeout so the child can be killed/reaped -/// by `TraceDecay` rather than by Cursor killing the hook process. Sized so -/// the ingest budget plus this cap stay below the overall preCompact budget, -/// leaving slack for LCM prepare/persist and process overhead. -pub(super) const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_secs(75); /// Overall budget for the `preCompact` hook (registered with a 120s timeout). const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); @@ -45,13 +39,10 @@ impl CursorPreCompactOutcome { } } -pub async fn cursor_pre_compact_for_event_with_config( - event_json: &str, - config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, -) -> CursorPreCompactOutcome { +pub async fn cursor_pre_compact_via_daemon(event_json: &str) -> CursorPreCompactOutcome { match tokio::time::timeout( CURSOR_PRE_COMPACT_BUDGET, - cursor_pre_compact_for_event_inner(event_json, config), + cursor_pre_compact_via_daemon_inner(event_json), ) .await { @@ -60,10 +51,7 @@ pub async fn cursor_pre_compact_for_event_with_config( } } -async fn cursor_pre_compact_for_event_inner( - event_json: &str, - _config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, -) -> CursorPreCompactOutcome { +async fn cursor_pre_compact_via_daemon_inner(event_json: &str) -> CursorPreCompactOutcome { let root = serde_json::from_str::(event_json) .ok() .as_ref() diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 1da8fbfb2..bc861461a 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -43,7 +43,7 @@ pub use cursor::{ hook_cursor_session_end, hook_cursor_session_start, hook_cursor_stop, hook_cursor_subagent_start, hook_cursor_workspace_open, }; -pub use cursor_compact::{CursorPreCompactOutcome, cursor_pre_compact_for_event_with_config}; +pub use cursor_compact::{CursorPreCompactOutcome, cursor_pre_compact_via_daemon}; pub use cursor_shell::{ CursorShellSyncPlan, cursor_branch_switch_target, cursor_shell_command_targets_project, cursor_shell_sync_plan, cursor_shell_sync_plan_with_current_branch, @@ -142,15 +142,13 @@ pub async fn hook_hermes_terminal_receipt() -> i32 { None => None, } { crate::daemon::notify_hook_event(&project_root, event).await; - } else { - if let Err(error) = daemon_hook_action( - None, - serde_json::json!({ "action": "hermes_receipt", "event": event }), - ) - .await - { - eprintln!("[tracedecay] user Hermes receipt daemon call failed: {error}"); - } + } else if let Err(error) = daemon_hook_action( + None, + serde_json::json!({ "action": "hermes_receipt", "event": event }), + ) + .await + { + eprintln!("[tracedecay] user Hermes receipt daemon call failed: {error}"); } 0 } diff --git a/src/main.rs b/src/main.rs index aca8ab5e1..6fb5e35d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,8 +234,7 @@ fn maybe_run_extract_worker(command: &Commands) { } async fn run_startup_preamble(command: &Commands) { - let skip_startup_maintenance = should_skip_startup_maintenance(command); - let skip_agent_install_maintenance = should_skip_agent_install_maintenance(command); + let startup_policy = CommandStartupPolicy::for_command(command); // First-run notice (check BEFORE any config save creates the file) let is_first_run = tracedecay::user_config::UserConfig::is_fresh(); @@ -250,7 +249,7 @@ async fn run_startup_preamble(command: &Commands) { // makes a synchronous HTTP call (#84) which can add seconds to // `tracedecay serve` startup on slow networks — long enough to blow the // MCP client's 30 s `initialize` timeout. - if !skip_startup_maintenance { + if startup_policy.runs_startup_maintenance() { global::try_flush(&mut user_config, is_force_flush); } if !is_local_install_command(command) { @@ -259,7 +258,7 @@ async fn run_startup_preamble(command: &Commands) { } } - if is_first_run && !skip_startup_maintenance { + if is_first_run && startup_policy.runs_startup_maintenance() { eprintln!( "note: tracedecay can optionally upload anonymous token savings counts to a worldwide counter.\n\ \x20 Run `tracedecay enable-upload-counter` to opt in." @@ -271,7 +270,7 @@ async fn run_startup_preamble(command: &Commands) { // and beta users now stay on beta until they explicitly switch off. // Best-effort check: warn if install needs re-running. - if !skip_agent_install_maintenance { + if startup_policy.runs_agent_install_maintenance() { tracedecay::agents::claude::check_install_stale(); maybe_run_silent_reinstall(&mut user_config).await; } @@ -800,13 +799,27 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { Ok(()) } -fn should_skip_startup_maintenance(command: &Commands) -> bool { - if hook_cmd::hook_input(command).is_some() { - return true; - } - matches!( - command, - Commands::Install { .. } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandStartupPolicy { + Full, + SkipAll, +} + +impl CommandStartupPolicy { + fn for_command(command: &Commands) -> Self { + if hook_cmd::hook_input(command).is_some() { + return Self::SkipAll; + } + + match command { + // Tool calls are the documented MCP fallback and must remain a local, + // latency-bounded protocol path. Unrelated counter uploads or agent + // maintenance belong on interactive commands and daemon background work. + Commands::Tool { .. } => Self::SkipAll, + // Explicit lifecycle/maintenance commands manage their own work. + // Serve is also latency-sensitive: clients impose a 30 s MCP + // initialize timeout, so no implicit startup work belongs there. + Commands::Install { .. } | Commands::Reinstall | Commands::UpdatePlugin | Commands::Upgrade { .. } @@ -823,64 +836,28 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::Migrate { .. } | Commands::Projects { .. } | Commands::Daemon { .. } - // `Serve` is the hot path used by MCP clients (Claude Code, - // Codex, etc.). Clients impose a 30 s `initialize` timeout, so - // every pre-serve startup task — `try_flush` network round-trip, - // `check_install_stale`, the silent-reinstall loop over every - // tracked agent — risks pushing us past it on slow networks or - // big home-dir trees (#84). Skip them; the same maintenance - // runs on the user's next interactive `tracedecay …` invocation. - | Commands::Serve { .. } - ) + | Commands::Serve { .. } => Self::SkipAll, + _ => Self::Full, + } + } + + fn runs_startup_maintenance(self) -> bool { + !matches!(self, Self::SkipAll) + } + + fn runs_agent_install_maintenance(self) -> bool { + matches!(self, Self::Full) + } } +#[cfg(test)] +fn should_skip_startup_maintenance(command: &Commands) -> bool { + !CommandStartupPolicy::for_command(command).runs_startup_maintenance() +} + +#[cfg(test)] fn should_skip_agent_install_maintenance(command: &Commands) -> bool { - if hook_cmd::hook_input(command).is_some() { - return true; - } - // Selectively gate the implicit `check_install_stale` + silent-reinstall - // path so agent permissions/hooks/MCP config stay in sync after a binary - // upgrade, without firing on paths where it would be wrong or wasteful: - // - `Serve`: the MCP hot path with a 30 s client `initialize` timeout - // (#84). Reinstalling every tracked agent before the stdio loop starts - // can blow that budget, so it must stay off `serve`. - // - `Install` / `Reinstall`: already perform installation — don't - // double-install as an implicit prelude to the explicit command. - // - `UpdatePlugin` / `Upgrade` / `Update`: explicit maintenance paths - // that manage plugin refresh themselves (upgrade/update re-exec the - // new binary's `post-update`); an implicit silent reinstall beforehand - // would rewrite configs with the OLD binary and break the - // update-plugin contract. - // - `Uninstall`: about to remove agent configs — don't reinstall them - // first (per the original #84 intent). - // - `Doctor` / `Migrate`: diagnostics and explicitly confirmed storage - // maintenance manage their own lifecycle and must not mutate agent - // configs as a side effect. - // - `Tool`: per-invocation tool calls are a hot-ish path; skip the - // reinstall scan there too. - // Every other command (the normal everyday invocations) runs maintenance. - matches!( - command, - Commands::Serve { .. } - | Commands::Install { .. } - | Commands::Reinstall - | Commands::UpdatePlugin - | Commands::Upgrade { .. } - | Commands::Update { .. } - | Commands::Dogfood - | Commands::PostUpdate { .. } - | Commands::Uninstall { .. } - | Commands::Lsp { .. } - | Commands::Doctor { .. } - | Commands::Analytics { .. } - | Commands::Migrate { .. } - | Commands::Projects { .. } - | Commands::Tool { .. } - | Commands::Sessions { - action: SessionsAction::Unfinished { .. }, - } - | Commands::Daemon { .. } - ) + !CommandStartupPolicy::for_command(command).runs_agent_install_maintenance() } fn is_local_install_command(command: &Commands) -> bool { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index dfbc11c30..18d30f742 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -27,7 +27,7 @@ use crate::mcp::tool_analytics::{ McpToolAnalyticsEvent, hook_route_analytics_event, mcp_tool_analytics_event, }; use crate::path_tree::format_compact_annotated_path_list; -use crate::tracedecay::TraceDecay; +use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; use super::hook_events::{self, HookAgent, HookEventPlan}; use super::tools::{ @@ -736,6 +736,137 @@ pub(crate) type DatabaseOwnerReconciler = Arc< dyn Fn(Arc) -> Pin + Send>> + Send + Sync + 'static, >; +/// Complete hook-driven branch write requested by the MCP server. +/// +/// `incremental_sync_agent` is set only for a routed worktree operation. In +/// that case the writer must keep any opened [`TraceDecay`] handle inside the +/// returned future until the conditional incremental sync has completed. +#[derive(Debug, Clone)] +pub(crate) struct HookBranchWriteRequest { + pub(crate) root: PathBuf, + pub(crate) branch: String, + pub(crate) open_options: TraceDecayOpenOptions, + pub(crate) incremental_sync_agent: Option, +} + +/// Metadata returned after the complete hook branch write has settled. No +/// writable store handle crosses the capability boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HookBranchWriteResult { + pub(crate) branch_outcome: crate::branch::BranchAddOutcome, + pub(crate) refresh_file_token_map: bool, +} + +/// Injectable ownership boundary for hook-driven branch writes. +/// +/// Daemon construction can wrap [`execute_hook_branch_write_direct`] in its +/// store-administration coordinator so the permit covers branch creation and, +/// for routed worktrees, the subsequent open and incremental sync. +pub(crate) type HookBranchWriter = Arc< + dyn Fn( + HookBranchWriteRequest, + ) -> Pin> + Send>> + + Send + + Sync + + 'static, +>; + +fn direct_hook_branch_writer() -> HookBranchWriter { + Arc::new(|request| Box::pin(execute_hook_branch_write_direct(request))) +} + +pub(crate) async fn execute_hook_branch_write_direct( + request: HookBranchWriteRequest, +) -> Result { + let branch_outcome = TraceDecay::add_branch_tracking_with_options( + &request.root, + &request.branch, + request.open_options.clone(), + ) + .await?; + let mut refresh_file_token_map = false; + + if branch_outcome == crate::branch::BranchAddOutcome::AlreadyTracked + && let Some(agent) = request.incremental_sync_agent + { + let worktree_cg = + TraceDecay::open_with_options(&request.root, request.open_options).await?; + refresh_file_token_map = run_hook_incremental_sync_direct(&worktree_cg, agent).await?; + } + + Ok(HookBranchWriteResult { + branch_outcome, + refresh_file_token_map, + }) +} + +async fn run_hook_incremental_sync_direct(cg: &TraceDecay, agent: HookAgent) -> Result { + let marker = hook_events::sync_marker_path(&cg.store_layout().data_root, agent); + let now = crate::tracedecay::current_timestamp(); + if !hook_events::should_run_sync(&marker, now, 3) { + return Ok(false); + } + match cg.sync().await { + Ok(_) | Err(TraceDecayError::SyncLock { .. }) => { + hook_events::write_sync_marker(&marker, now); + Ok(true) + } + Err(error) => Err(error), + } +} + +/// Complete detached read-refresh write requested by the MCP server. +#[derive(Debug, Clone)] +pub(crate) struct BackgroundRefreshRequest { + pub(crate) project_root: PathBuf, + pub(crate) open_options: TraceDecayOpenOptions, + pub(crate) full_sync_escalation_files: usize, +} + +/// Injectable ownership boundary for a detached read refresh. The returned +/// token map is the only state allowed to cross back into the server; any +/// temporary [`TraceDecay`] handle must remain inside the callback future. +pub(crate) type BackgroundRefreshWriter = Arc< + dyn Fn( + BackgroundRefreshRequest, + ) -> Pin>>> + Send>> + + Send + + Sync + + 'static, +>; + +fn direct_background_refresh_writer() -> BackgroundRefreshWriter { + Arc::new(|request| Box::pin(execute_background_refresh_direct(request))) +} + +pub(crate) async fn execute_background_refresh_direct( + request: BackgroundRefreshRequest, +) -> Result>> { + let cg = TraceDecay::open_with_options(&request.project_root, request.open_options).await?; + let scoped = match cg.last_synced_commit().await { + Some(base) => cg.stale_files_since_commit(&base, request.full_sync_escalation_files), + None => None, + }; + let result = if let Some(files) = scoped { + if files.is_empty() { + Ok(()) + } else { + cg.sync_if_stale_silent(&files).await + } + } else { + let stale = cg.find_stale_files().await; + if stale.is_empty() { + Ok(()) + } else { + cg.sync_if_stale_silent(&stale).await + } + }; + if let Err(error) = result { + eprintln!("[tracedecay] background read refresh failed: {error}"); + } + Ok(cg.get_file_token_map().await.ok()) +} + /// The MCP server wrapping a `TraceDecay` instance. // Lock ordering: file_token_map -> method/resource/tool call counts (never nested) pub struct McpServer { @@ -774,6 +905,8 @@ pub struct McpServer { allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, database_owner_reconciler: Option, + hook_branch_writer: HookBranchWriter, + background_refresh_writer: BackgroundRefreshWriter, initialize_root_routing_enabled: AtomicBool, hook_project_routes: SharedHookProjectRouteCache, /// Cached latest-version check result. @@ -966,6 +1099,31 @@ impl McpServer { allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, database_owner_reconciler: Option, + ) -> Arc { + Self::new_with_dbs_and_reconcilers_and_writers( + cg, + scope_prefix, + global_db, + registry_db, + allow_default_registry_fallback, + automation_scheduler_reconciler, + database_owner_reconciler, + direct_hook_branch_writer(), + direct_background_refresh_writer(), + ) + .await + } + + pub(crate) async fn new_with_dbs_and_reconcilers_and_writers( + cg: TraceDecay, + scope_prefix: Option, + global_db: Option>, + registry_db: Option>, + allow_default_registry_fallback: bool, + automation_scheduler_reconciler: Option, + database_owner_reconciler: Option, + hook_branch_writer: HookBranchWriter, + background_refresh_writer: BackgroundRefreshWriter, ) -> Arc { let file_token_map = cg.get_file_token_map().await.unwrap_or_default(); let persisted = cg.get_tokens_saved().await.unwrap_or(0); @@ -1020,6 +1178,8 @@ impl McpServer { allow_default_registry_fallback, automation_scheduler_reconciler, database_owner_reconciler, + hook_branch_writer, + background_refresh_writer, initialize_root_routing_enabled: AtomicBool::new(true), hook_project_routes: SharedHookProjectRouteCache::default(), version_cache: std::sync::Mutex::new(VersionCheckState { @@ -1638,45 +1798,22 @@ impl McpServer { let running = Arc::clone(&self.background_refresh_running); let done_at = Arc::clone(&self.last_background_refresh_done_at); let token_map = Arc::clone(&self.file_token_map); - let project_root = cg.project_root().to_path_buf(); - let open_options = cg.open_options(); + let refresh = Arc::clone(&self.background_refresh_writer); + let request = BackgroundRefreshRequest { + project_root: cg.project_root().to_path_buf(), + open_options: cg.open_options(), + full_sync_escalation_files: escalation, + }; tokio::spawn(async move { - let cg = match TraceDecay::open_with_options(&project_root, open_options).await { - Ok(cg) => cg, + match refresh(request).await { + Ok(Some(fresh)) => { + if let Ok(mut guard) = token_map.lock() { + *guard = fresh; + } + } + Ok(None) => {} Err(e) => { eprintln!("[tracedecay] background read refresh could not reopen project: {e}"); - done_at.store(crate::tracedecay::current_timestamp(), Ordering::Release); - running.store(false, Ordering::Release); - return; - } - }; - // Prefer diff-scoping off the last synced commit. - let scoped = match cg.last_synced_commit().await { - Some(base) => cg.stale_files_since_commit(&base, escalation), - None => None, - }; - let result = if let Some(files) = scoped { - if files.is_empty() { - Ok(()) - } else { - cg.sync_if_stale_silent(&files).await - } - } else { - // Fallback: full tree walk. - let stale = cg.find_stale_files().await; - if stale.is_empty() { - Ok(()) - } else { - cg.sync_if_stale_silent(&stale).await - } - }; - if let Err(e) = result { - eprintln!("[tracedecay] background read refresh failed: {e}"); - } - // Refresh the shared file-token map from the (now-synced) DB. - if let Ok(fresh) = cg.get_file_token_map().await { - if let Ok(mut guard) = token_map.lock() { - *guard = fresh; } } done_at.store(crate::tracedecay::current_timestamp(), Ordering::Release); @@ -2313,17 +2450,23 @@ impl McpServer { } } HookEventPlan::AddBranch(branch) => { - match self.add_hook_branch_tracking(root, &branch, &cg).await { - Ok(crate::branch::BranchAddOutcome::Added) => { - self.reopen_after_branch_tracking_added().await; - } - Ok(crate::branch::BranchAddOutcome::AlreadyTracked) => { - self.refresh_file_token_map().await; - } - Ok( + let request = HookBranchWriteRequest { + root: root.to_path_buf(), + branch, + open_options: cg.open_options(), + incremental_sync_agent: None, + }; + match (self.hook_branch_writer)(request).await { + Ok(result) => match result.branch_outcome { + crate::branch::BranchAddOutcome::Added => { + self.reopen_after_branch_tracking_added().await; + } + crate::branch::BranchAddOutcome::AlreadyTracked => { + self.refresh_file_token_map().await; + } crate::branch::BranchAddOutcome::Deferred - | crate::branch::BranchAddOutcome::NotIndexed, - ) => {} + | crate::branch::BranchAddOutcome::NotIndexed => {} + }, Err(e) => eprintln!("[tracedecay] hook branch tracking failed: {e}"), } } @@ -2332,40 +2475,38 @@ impl McpServer { branch, agent, } => { - // The routed worktree root is not this server's checkout, so - // reopen/token-map refresh only applies after opening that root. - match self.add_hook_branch_tracking(&root, &branch, &cg).await { - Ok(crate::branch::BranchAddOutcome::AlreadyTracked) => { - match TraceDecay::open_with_options(&root, cg.open_options()).await { - Ok(worktree_cg) => { - self.run_hook_incremental_sync(Arc::new(worktree_cg), agent) - .await; - } - Err(e) => { - eprintln!( - "[tracedecay] hook worktree branch sync open failed: {e}" - ); - } - } + let request = HookBranchWriteRequest { + root, + branch, + open_options: cg.open_options(), + incremental_sync_agent: Some(agent), + }; + match (self.hook_branch_writer)(request).await { + Ok(result) if result.refresh_file_token_map => { + self.refresh_file_token_map().await; } - Ok( - crate::branch::BranchAddOutcome::Added - | crate::branch::BranchAddOutcome::Deferred - | crate::branch::BranchAddOutcome::NotIndexed, - ) => {} - Err(e) => eprintln!("[tracedecay] hook worktree branch tracking failed: {e}"), + Ok(_) => {} + Err(e) => eprintln!("[tracedecay] hook worktree branch write failed: {e}"), } } HookEventPlan::SyncCurrentBranch { branch, agent } => { - match self.add_hook_branch_tracking(root, &branch, &cg).await { - Ok(crate::branch::BranchAddOutcome::Added) => { - self.reopen_after_branch_tracking_added().await; - } - Ok( + let request = HookBranchWriteRequest { + root: root.to_path_buf(), + branch, + open_options: cg.open_options(), + incremental_sync_agent: None, + }; + match (self.hook_branch_writer)(request).await { + Ok(result) => match result.branch_outcome { + crate::branch::BranchAddOutcome::Added => { + self.reopen_after_branch_tracking_added().await; + } crate::branch::BranchAddOutcome::AlreadyTracked - | crate::branch::BranchAddOutcome::Deferred, - ) => self.run_hook_incremental_sync(cg, agent).await, - Ok(crate::branch::BranchAddOutcome::NotIndexed) => {} + | crate::branch::BranchAddOutcome::Deferred => { + self.run_hook_incremental_sync(cg, agent).await; + } + crate::branch::BranchAddOutcome::NotIndexed => {} + }, Err(e) => { eprintln!("[tracedecay] hook current branch tracking failed: {e}"); self.run_hook_incremental_sync(cg, agent).await; @@ -2415,31 +2556,10 @@ impl McpServer { } } - async fn add_hook_branch_tracking( - &self, - root: &Path, - branch: &str, - cg: &TraceDecay, - ) -> Result { - crate::tracedecay::TraceDecay::add_branch_tracking_with_options( - root, - branch, - cg.open_options(), - ) - .await - } - async fn run_hook_incremental_sync(&self, cg: Arc, agent: HookAgent) { - let marker = hook_events::sync_marker_path(&cg.store_layout().data_root, agent); - let now = crate::tracedecay::current_timestamp(); - if !hook_events::should_run_sync(&marker, now, 3) { - return; - } - match cg.sync().await { - Ok(_) | Err(TraceDecayError::SyncLock { .. }) => { - hook_events::write_sync_marker(&marker, now); - self.refresh_file_token_map().await; - } + match run_hook_incremental_sync_direct(&cg, agent).await { + Ok(true) => self.refresh_file_token_map().await, + Ok(false) => {} Err(e) => eprintln!("[tracedecay] hook incremental sync failed: {e}"), } } @@ -3354,7 +3474,13 @@ fn json_rpc_request_id_string(id: &Value) -> Option { /// `indexing.rs` test idiom. #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] +mod background_refresh_writer_tests; +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod freshness_tests; #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod hook_branch_writer_tests; +#[cfg(test)] #[allow(clippy::unwrap_used)] mod staleness_banner_tests; diff --git a/src/mcp/server/background_refresh_writer_tests.rs b/src/mcp/server/background_refresh_writer_tests.rs new file mode 100644 index 000000000..c3bf6d6ca --- /dev/null +++ b/src/mcp/server/background_refresh_writer_tests.rs @@ -0,0 +1,130 @@ +use super::{ + BackgroundRefreshRequest, BackgroundRefreshWriter, McpServer, direct_hook_branch_writer, +}; +use crate::config::PinnedUserDataDir; +use crate::tracedecay::TraceDecay; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tempfile::TempDir; + +fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new(crate::git::git_program()) + .current_dir(root) + .args(args) + .output() + .expect("git runs"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) { + let pin = PinnedUserDataDir::new(); + let dir = TempDir::new().expect("temp repo"); + let root = dir.path(); + git(root, &["init", "-q", "-b", "main"]); + git(root, &["config", "user.email", "t@t.com"]); + git(root, &["config", "user.name", "T"]); + std::fs::write(root.join(".gitignore"), ".tracedecay/\n").expect("write gitignore"); + std::fs::create_dir_all(root.join("src")).expect("create src"); + std::fs::write(root.join("src/a.rs"), "pub fn a() {}\n").expect("write source"); + git(root, &["add", "."]); + git(root, &["commit", "-q", "-m", "initial"]); + let cg = TraceDecay::init(root).await.expect("init"); + cg.index_all().await.expect("index"); + let mut config = crate::config::load_config(root).expect("load config"); + config.sync.session_start_sync = false; + crate::config::save_config(root, &config).expect("disable startup sync"); + (cg, dir, pin) +} + +#[tokio::test] +async fn read_refresh_uses_injected_writer_without_direct_fallback() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path().to_path_buf(); + std::fs::write( + root.join("src/a.rs"), + "pub fn a() { println!(\"changed\"); }\n", + ) + .expect("modify source"); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "change source"]); + assert!( + cg.find_stale_files() + .await + .iter() + .any(|path| path == "src/a.rs"), + "fixture must be stale before refresh" + ); + + let observed = Arc::new(Mutex::new(Vec::<(PathBuf, usize)>::new())); + let refresh_writer: BackgroundRefreshWriter = { + let observed = Arc::clone(&observed); + Arc::new(move |request: BackgroundRefreshRequest| { + let observed = Arc::clone(&observed); + Box::pin(async move { + observed + .lock() + .expect("recording lock") + .push((request.project_root, request.full_sync_escalation_files)); + Ok(Some(HashMap::from([("injected.rs".to_string(), 41)]))) + }) + }) + }; + let server = McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + None, + None, + None, + true, + None, + None, + direct_hook_branch_writer(), + refresh_writer, + ) + .await; + let snapshot = server.cg_snapshot().await; + server + .background_refresh_running + .store(true, Ordering::Release); + + server.spawn_read_refresh_task(&snapshot, 17); + + tokio::time::timeout(Duration::from_secs(5), async { + while server.background_refresh_running.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("injected refresh settles"); + + assert_eq!( + observed.lock().expect("recording lock").as_slice(), + &[(root, 17)] + ); + assert_eq!( + server.file_token_map_snapshot(), + HashMap::from([("injected.rs".to_string(), 41)]) + ); + assert!( + snapshot + .find_stale_files() + .await + .iter() + .any(|path| path == "src/a.rs"), + "injected refresh must not execute the direct open/sync fallback" + ); + assert_ne!( + server + .last_background_refresh_done_at + .load(Ordering::Acquire), + 0, + "completion timestamp must be preserved" + ); + server.shutdown().await; +} diff --git a/src/mcp/server/hook_branch_writer_tests.rs b/src/mcp/server/hook_branch_writer_tests.rs new file mode 100644 index 000000000..ca611033c --- /dev/null +++ b/src/mcp/server/hook_branch_writer_tests.rs @@ -0,0 +1,181 @@ +use super::{ + HookBranchWriteRequest, HookBranchWriteResult, HookBranchWriter, McpServer, + direct_background_refresh_writer, +}; +use crate::config::PinnedUserDataDir; +use crate::daemon::HookAgent; +use crate::mcp::hook_events::HookEventPlan; +use crate::tracedecay::TraceDecay; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ObservedWrite { + root: PathBuf, + branch: String, + incremental_sync_agent: Option, +} + +fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new(crate::git::git_program()) + .current_dir(root) + .args(args) + .output() + .expect("git runs"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) { + let pin = PinnedUserDataDir::new(); + let dir = TempDir::new().expect("temp repo"); + let root = dir.path(); + git(root, &["init", "-q", "-b", "main"]); + git(root, &["config", "user.email", "t@t.com"]); + git(root, &["config", "user.name", "T"]); + std::fs::write(root.join(".gitignore"), ".tracedecay/\n").expect("write gitignore"); + std::fs::create_dir_all(root.join("src")).expect("create src"); + std::fs::write(root.join("src/a.rs"), "pub fn a() {}\n").expect("write source"); + git(root, &["add", "."]); + git(root, &["commit", "-q", "-m", "initial"]); + let cg = TraceDecay::init(root).await.expect("init"); + cg.index_all().await.expect("index"); + let mut config = crate::config::load_config(root).expect("load config"); + config.sync.session_start_sync = false; + crate::config::save_config(root, &config).expect("disable startup sync"); + (cg, dir, pin) +} + +fn recording_writer( + observed: Arc>>, + result: HookBranchWriteResult, +) -> HookBranchWriter { + Arc::new(move |request: HookBranchWriteRequest| { + let observed = Arc::clone(&observed); + let result = result.clone(); + Box::pin(async move { + observed + .lock() + .expect("recording lock") + .push(ObservedWrite { + root: request.root, + branch: request.branch, + incremental_sync_agent: request.incremental_sync_agent, + }); + Ok(result) + }) + }) +} + +fn assert_branch_not_tracked(cg: &TraceDecay, branch: &str) { + let tracked = crate::branch_meta::load_branch_meta(&cg.store_layout().data_root) + .is_some_and(|meta| meta.is_tracked(branch)); + assert!( + !tracked, + "injected hook branch writer must not fall back to direct branch tracking" + ); +} + +#[tokio::test] +async fn add_branch_plan_uses_injected_writer_without_direct_fallback() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path().to_path_buf(); + let branch = "injected-branch"; + git(&root, &["branch", branch]); + let observed = Arc::new(Mutex::new(Vec::new())); + let writer = recording_writer( + Arc::clone(&observed), + HookBranchWriteResult { + branch_outcome: crate::branch::BranchAddOutcome::NotIndexed, + refresh_file_token_map: false, + }, + ); + let server = McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + None, + None, + None, + true, + None, + None, + writer, + direct_background_refresh_writer(), + ) + .await; + let snapshot = server.cg_snapshot().await; + + server + .run_hook_event_plan( + snapshot.clone(), + &root, + HookEventPlan::AddBranch(branch.into()), + ) + .await; + + assert_eq!( + observed.lock().expect("recording lock").as_slice(), + &[ObservedWrite { + root: root.clone(), + branch: branch.to_string(), + incremental_sync_agent: None, + }] + ); + assert_branch_not_tracked(&snapshot, branch); + server.shutdown().await; +} + +#[tokio::test] +async fn add_branch_at_plan_delegates_open_and_sync_without_direct_fallback() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path().to_path_buf(); + let branch = "injected-worktree-branch"; + git(&root, &["branch", branch]); + let observed = Arc::new(Mutex::new(Vec::new())); + let writer = recording_writer( + Arc::clone(&observed), + HookBranchWriteResult { + branch_outcome: crate::branch::BranchAddOutcome::AlreadyTracked, + refresh_file_token_map: false, + }, + ); + let server = McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + None, + None, + None, + true, + None, + None, + writer, + direct_background_refresh_writer(), + ) + .await; + let snapshot = server.cg_snapshot().await; + + server + .run_hook_event_plan( + snapshot.clone(), + &root, + HookEventPlan::AddBranchAt { + root: root.clone(), + branch: branch.to_string(), + agent: HookAgent::Codex, + }, + ) + .await; + + assert_eq!( + observed.lock().expect("recording lock").as_slice(), + &[ObservedWrite { + root: root.clone(), + branch: branch.to_string(), + incremental_sync_agent: Some(HookAgent::Codex), + }] + ); + assert_branch_not_tracked(&snapshot, branch); + server.shutdown().await; +} diff --git a/src/mcp/tools/handlers/admin_cli.rs b/src/mcp/tools/handlers/admin_cli.rs index 27d592cfb..6d20ce80a 100644 --- a/src/mcp/tools/handlers/admin_cli.rs +++ b/src/mcp/tools/handlers/admin_cli.rs @@ -54,50 +54,116 @@ enum AdminCliAction { }, } +struct AdminCliContext<'a> { + global_db: &'a GlobalDb, + project: Option<&'a TraceDecay>, +} + +impl<'a> AdminCliContext<'a> { + fn with_project(cg: &'a TraceDecay, global_db: &'a GlobalDb) -> Self { + Self { + global_db, + project: Some(cg), + } + } + + fn projectless(global_db: &'a GlobalDb) -> Self { + Self { + global_db, + project: None, + } + } + + fn require_project(&self) -> Result<&'a TraceDecay> { + self.project.ok_or_else(|| TraceDecayError::Config { + message: "requested admin action requires an initialized project".to_string(), + }) + } + + fn project_root(&self) -> Option<&'a Path> { + self.project.map(|cg| cg.project_root()) + } +} + pub(super) async fn handle_admin_cli( cg: &TraceDecay, args: Value, global_db: Option<&GlobalDb>, ) -> Result { - let action: AdminCliAction = - serde_json::from_value(args).map_err(|error| TraceDecayError::Config { - message: format!("invalid tracedecay_admin_cli arguments: {error}"), - })?; + let action = parse_admin_cli_action(args)?; let global_db = global_db.ok_or_else(|| TraceDecayError::Config { message: "daemon global database is unavailable".to_string(), })?; + dispatch_admin_cli(AdminCliContext::with_project(cg, global_db), action).await +} + +pub(crate) async fn handle_projectless_admin_cli( + args: Value, + profile_root: &Path, +) -> Result { + let action = parse_admin_cli_action(args)?; + let global_db = GlobalDb::open_at(&profile_root.join("global.db")) + .await + .ok_or_else(|| TraceDecayError::Config { + message: "daemon global database is unavailable".to_string(), + })?; + dispatch_admin_cli(AdminCliContext::projectless(&global_db), action).await +} + +fn parse_admin_cli_action(args: Value) -> Result { + serde_json::from_value(args).map_err(|error| TraceDecayError::Config { + message: format!("invalid tracedecay_admin_cli arguments: {error}"), + }) +} + +async fn dispatch_admin_cli( + context: AdminCliContext<'_>, + action: AdminCliAction, +) -> Result { + let global_db = context.global_db; let value = match action { AdminCliAction::CostSummary { range } => cost_summary(global_db, &range).await, - AdminCliAction::SessionsIngest => sessions_ingest(cg).await?, + AdminCliAction::SessionsIngest => sessions_ingest(context.require_project()?).await?, AdminCliAction::SessionsGitBackfill { since, limit_sessions, dry_run, - } => sessions_git_backfill(cg, global_db, since, limit_sessions, dry_run).await?, - AdminCliAction::SessionsUnfinished { limit } => sessions_unfinished(cg, limit).await?, + } => { + sessions_git_backfill( + context.require_project()?, + global_db, + since, + limit_sessions, + dry_run, + ) + .await? + } + AdminCliAction::SessionsUnfinished { limit } => { + sessions_unfinished(context.require_project()?, limit).await? + } AdminCliAction::AnalyticsSync => { - crate::analytics_bridge::analytics_sync_with_db(global_db, Some(cg.project_root())) - .await + crate::analytics_bridge::analytics_sync_with_db(global_db, context.project_root()).await } AdminCliAction::AnalyticsDiagnostics { all, no_sync } => { crate::analytics_bridge::analytics_diagnostics_with_db( global_db, - Some(cg.project_root()), + context.project_root(), all, no_sync, ) .await? } AdminCliAction::RegistryUpdate { tokens } => { + let cg = context.require_project()?; let previous = global_db.get_project_tokens(cg.project_root()).await; global_db.upsert(cg.project_root(), tokens).await; json!({ "previous": previous, "current": tokens }) } AdminCliAction::RegistryList { limit, query } => { - registry_list(Some(cg), global_db, limit, query.as_deref()).await + registry_list(context.project, global_db, limit, query.as_deref()).await } AdminCliAction::RegistryContext { project_arg } => { - registry_context(Some(cg), global_db, project_arg.as_deref()).await + registry_context(context.project, global_db, project_arg.as_deref()).await } AdminCliAction::RegistryEmpty => registry_empty(global_db).await, AdminCliAction::RegistryProjectTokens { project_args } => { @@ -109,56 +175,7 @@ pub(super) async fn handle_admin_cli( history, } => gain_query(global_db, project_arg.as_deref(), since, history).await, }; - Ok(json_result(value)) -} - -pub(crate) async fn handle_projectless_admin_cli( - args: Value, - profile_root: &Path, -) -> Result { - let action: AdminCliAction = - serde_json::from_value(args).map_err(|error| TraceDecayError::Config { - message: format!("invalid tracedecay_admin_cli arguments: {error}"), - })?; - let global_db = GlobalDb::open_at(&profile_root.join("global.db")) - .await - .ok_or_else(|| TraceDecayError::Config { - message: "daemon global database is unavailable".to_string(), - })?; - let value = match action { - AdminCliAction::CostSummary { range } => cost_summary(&global_db, &range).await, - AdminCliAction::AnalyticsSync => { - crate::analytics_bridge::analytics_sync_with_db(&global_db, None).await - } - AdminCliAction::AnalyticsDiagnostics { all, no_sync } => { - crate::analytics_bridge::analytics_diagnostics_with_db(&global_db, None, all, no_sync) - .await? - } - AdminCliAction::RegistryList { limit, query } => { - registry_list(None, &global_db, limit, query.as_deref()).await - } - AdminCliAction::RegistryContext { project_arg } => { - registry_context(None, &global_db, project_arg.as_deref()).await - } - AdminCliAction::RegistryEmpty => registry_empty(&global_db).await, - AdminCliAction::RegistryProjectTokens { project_args } => { - registry_project_tokens(&global_db, &project_args).await - } - AdminCliAction::GainQuery { - project_arg, - since, - history, - } => gain_query(&global_db, project_arg.as_deref(), since, history).await, - AdminCliAction::SessionsIngest - | AdminCliAction::SessionsGitBackfill { .. } - | AdminCliAction::SessionsUnfinished { .. } - | AdminCliAction::RegistryUpdate { .. } => { - return Err(TraceDecayError::Config { - message: "requested admin action requires an initialized project".to_string(), - }); - } - }; - Ok(json_result(value)) + Ok(json_result(&value)) } async fn registry_empty(global_db: &GlobalDb) -> Value { @@ -254,12 +271,12 @@ async fn registry_context( return json!({ "status": "invalid", "project": null }); }; let selector_text = selector.to_string_lossy(); - let context = if !GlobalDb::is_explicit_project_path_selector(&selector_text) { + let context = if GlobalDb::is_explicit_project_path_selector(&selector_text) { + None + } else { global_db .project_registry_context_by_id(&selector_text) .await - } else { - None }; let context = match context { Some(context) => Some(context), @@ -406,7 +423,7 @@ async fn sessions_unfinished(cg: &TraceDecay, limit: usize) -> Result { Ok(json!({ "items": items })) } -fn json_result(value: Value) -> ToolResult { +fn json_result(value: &Value) -> ToolResult { ToolResult::new( json!({ "content": [{ diff --git a/src/mcp/tools/handlers/admin_project.rs b/src/mcp/tools/handlers/admin_project.rs index 88a000e73..aee2d9e0e 100644 --- a/src/mcp/tools/handlers/admin_project.rs +++ b/src/mcp/tools/handlers/admin_project.rs @@ -199,7 +199,7 @@ pub(super) async fn handle_admin_project( run_automation(cg, global_db, task, options).await? } }; - Ok(json_result(value)) + Ok(json_result(&value)) } async fn run_automation( @@ -315,7 +315,7 @@ fn decode_options(options: Value) -> Result { }) } -fn json_result(value: Value) -> ToolResult { +fn json_result(value: &Value) -> ToolResult { ToolResult::new( json!({ "content": [{ @@ -331,13 +331,24 @@ fn json_result(value: Value) -> ToolResult { mod tests { use super::*; - fn tool_json(result: ToolResult) -> Value { + fn tool_json(result: &ToolResult) -> Value { let text = result.value["content"][0]["text"] .as_str() .expect("admin project result should contain JSON text"); serde_json::from_str(text).expect("admin project result should be valid JSON") } + fn automation_cli_source() -> String { + [ + include_str!("../../../automation_cli/mod.rs"), + include_str!("../../../automation_cli/config.rs"), + include_str!("../../../automation_cli/facts.rs"), + include_str!("../../../automation_cli/runs.rs"), + include_str!("../../../automation_cli/skills.rs"), + ] + .concat() + } + #[tokio::test] async fn admin_project_handler_executes_typed_fact_and_automation_round_trips_on_one_authority() { @@ -382,7 +393,7 @@ mod tests { .await .unwrap(); let fact = tool_json( - handle_admin_project( + &handle_admin_project( &cg, json!({ "action": "fact_apply", "id": proposals[0].proposal_id.clone() }), None, @@ -395,7 +406,7 @@ mod tests { assert_eq!(fact.reviewer.as_deref(), Some("cli")); let automation = tool_json( - handle_admin_project( + &handle_admin_project( &cg, json!({ "action": "automation_run", @@ -418,7 +429,7 @@ mod tests { let owner_after = crate::db::probe_writer_owner(&cg.store_layout().graph_db_path).unwrap(); assert_eq!(owner_after, owner_before); - let client_source = include_str!("../../../automation_cli.rs"); + let client_source = automation_cli_source(); let direct_init = ["serve::ensure_", "initialized"].concat(); let direct_apply = ["apply_fact_", "proposal("].concat(); assert!(client_source.contains("tracedecay_admin_project")); @@ -446,7 +457,7 @@ mod tests { assert!(matches!(task, AutomationRunTask::MemoryCuration)); let options = decode_options::(options).unwrap(); assert_eq!(options.max_clusters, 12); - assert_eq!(options.min_confidence, 0.75); + assert!((options.min_confidence - 0.75).abs() < f64::EPSILON); let typed_run = serde_json::from_value::(json!({ "run_id": "run-5", @@ -471,7 +482,7 @@ mod tests { serde_json::from_value::(client_run.clone()).unwrap(); assert_eq!(round_trip, typed_run); - let client_source = include_str!("../../../automation_cli.rs"); + let client_source = automation_cli_source(); let direct_init = ["serve::ensure_", "initialized"].concat(); let direct_apply = ["apply_fact_", "proposal("].concat(); assert!(client_source.contains("tracedecay_admin_project")); diff --git a/src/mcp/tools/handlers/git.rs b/src/mcp/tools/handlers/git.rs index c97be9245..e886a5004 100644 --- a/src/mcp/tools/handlers/git.rs +++ b/src/mcp/tools/handlers/git.rs @@ -954,6 +954,45 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result Result { + let branch = require_admin_branch_name(&args)?; + let outcome = + TraceDecay::add_branch_tracking_with_options(cg.project_root(), branch, cg.open_options()) + .await?; + let output = json!({ "outcome": admin_branch_add_outcome_name(&outcome) }); + Ok(ToolResult::new( + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(&output).unwrap_or_default(), + }] + }), + vec![], + )) +} + +fn require_admin_branch_name(args: &Value) -> Result<&str> { + args.get("branch") + .and_then(Value::as_str) + .filter(|branch| !branch.is_empty()) + .ok_or_else(|| TraceDecayError::Config { + message: "missing required parameter: branch".to_string(), + }) +} + +fn admin_branch_add_outcome_name(outcome: &crate::branch::BranchAddOutcome) -> &'static str { + match outcome { + crate::branch::BranchAddOutcome::NotIndexed => "not_indexed", + crate::branch::BranchAddOutcome::AlreadyTracked => "already_tracked", + crate::branch::BranchAddOutcome::Added => "added", + crate::branch::BranchAddOutcome::Deferred => "deferred", + } +} + /// Handles `tracedecay_branch_list` tool calls. pub(super) fn handle_branch_list(cg: &TraceDecay, args: &Value) -> ToolResult { let diagnostics = cg.branch_diagnostics(); @@ -1233,6 +1272,35 @@ mod tests { ); } + #[test] + fn admin_branch_add_requires_a_nonempty_branch_name() { + for args in [serde_json::json!({}), serde_json::json!({ "branch": "" })] { + let error = require_admin_branch_name(&args) + .expect_err("branch add request without branch must fail"); + assert!(error.to_string().contains("branch")); + } + } + + #[test] + fn admin_branch_add_outcomes_have_stable_wire_names() { + assert_eq!( + admin_branch_add_outcome_name(&crate::branch::BranchAddOutcome::NotIndexed), + "not_indexed" + ); + assert_eq!( + admin_branch_add_outcome_name(&crate::branch::BranchAddOutcome::AlreadyTracked), + "already_tracked" + ); + assert_eq!( + admin_branch_add_outcome_name(&crate::branch::BranchAddOutcome::Added), + "added" + ); + assert_eq!( + admin_branch_add_outcome_name(&crate::branch::BranchAddOutcome::Deferred), + "deferred" + ); + } + #[test] fn pr_comparison_anchors_at_merge_base_when_base_advanced() { let temp = tempfile::tempdir().expect("temp repo"); diff --git a/src/mcp/tools/handlers/hook_runtime.rs b/src/mcp/tools/handlers/hook_runtime.rs index 94da8872b..af8a0d038 100644 --- a/src/mcp/tools/handlers/hook_runtime.rs +++ b/src/mcp/tools/handlers/hook_runtime.rs @@ -1,6 +1,7 @@ use serde_json::{Value, json}; use std::path::Path; +use crate::automation::run_ledger::AutomationRunStatus; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; use crate::mcp::tools::ToolResult; @@ -21,8 +22,8 @@ fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> { .ok_or_else(|| config_error(format!("missing required parameter `{key}`"))) } -fn rendered(project_root: Option<&std::path::Path>, args: &Value, value: Value) -> ToolResult { - let text = render::finalize(project_root, args, &value, || render::generic_md(&value)); +fn rendered(project_root: Option<&std::path::Path>, args: &Value, value: &Value) -> ToolResult { + let text = render::finalize(project_root, args, value, || render::generic_md(value)); ToolResult::new( json!({ "content": [{ "type": "text", "text": text }] }), vec![], @@ -62,7 +63,7 @@ pub async fn handle_hook_runtime( ))); } }; - Ok(rendered(Some(cg.project_root()), &args, output)) + Ok(rendered(Some(cg.project_root()), &args, &output)) } pub async fn handle_projectless_hook_runtime( @@ -89,7 +90,7 @@ pub async fn handle_projectless_hook_runtime( "hermes_receipt" => hermes_receipt(&args, profile_root).await?, _ => unreachable!("projectless hook action validated above"), }; - Ok(rendered(None, &args, output)) + Ok(rendered(None, &args, &output)) } async fn codex_compact(cg: &TraceDecay, args: &Value) -> Result { @@ -471,7 +472,7 @@ async fn run_user_review( ) .await?; let backend = CodexAppServerBackend::from_automation_config(&config); - Ok(run_user_session_automation_with_backend( + run_user_session_automation_with_backend( profile_root, &config, &backend, @@ -494,7 +495,7 @@ async fn run_user_review( }, }, ) - .await?) + .await } async fn hermes_receipt(args: &Value, profile_root: &Path) -> Result { @@ -559,7 +560,6 @@ async fn hermes_receipt(args: &Value, profile_root: &Path) -> Result { crate::automation::run_ledger::AutomationTrigger::HostReceipt, ) .await?; - use crate::automation::run_ledger::AutomationRunStatus; if run.session_reflector.ledger_record.status == AutomationRunStatus::Succeeded && run.memory_curator.ledger_record.status != AutomationRunStatus::Failed && run.skill_writer.ledger_record.status == AutomationRunStatus::Succeeded diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index f15a1f7b9..697db2626 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -97,6 +97,7 @@ use support::{profile_root_for_global_db, project_registry_context, project_sele #[cfg(test)] const INTERNAL_DAEMON_TOOL_NAMES: &[&str] = &[ + "tracedecay_admin_branch_add", "tracedecay_admin_cli", "tracedecay_admin_project", "tracedecay_admin_sync", @@ -396,6 +397,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_hook_runtime" => { hook_runtime::handle_hook_runtime(cg, args, options.global_db).await } + "tracedecay_admin_branch_add" => git::handle_admin_branch_add(cg, args).await, "tracedecay_admin_sync" => info::handle_admin_sync(cg, args).await, "tracedecay_admin_cli" => admin_cli::handle_admin_cli(cg, args, options.global_db).await, "tracedecay_admin_project" => { diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 0ee90fa02..4e29304d5 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -64,17 +64,16 @@ enum SnapshotEntry { #[derive(Debug, Clone, PartialEq, Eq)] enum TreeSnapshotEntry { + // Directory timestamps are derived state: creating and removing ignored + // authority-lock artifacts changes their parent directories' mtime/ctime. + // Topology, identity, permissions, and every non-ignored child remain + // snapshotted, so persistent input mutations are still detected. Directory { - modified: SystemTime, #[cfg(unix)] device: u64, #[cfg(unix)] inode: u64, #[cfg(unix)] - changed_seconds: i64, - #[cfg(unix)] - changed_nanoseconds: i64, - #[cfg(unix)] mode: u32, }, File(SnapshotEntry), @@ -152,16 +151,11 @@ fn full_tree_snapshot(root: &Path) -> BTreeMap { snapshot.insert( relative, TreeSnapshotEntry::Directory { - modified: metadata.modified().unwrap(), #[cfg(unix)] device: metadata.dev(), #[cfg(unix)] inode: metadata.ino(), #[cfg(unix)] - changed_seconds: metadata.ctime(), - #[cfg(unix)] - changed_nanoseconds: metadata.ctime_nsec(), - #[cfg(unix)] mode: metadata.permissions().mode(), }, ); diff --git a/src/migrate/inventory.rs b/src/migrate/inventory.rs deleted file mode 100644 index 500759c1f..000000000 --- a/src/migrate/inventory.rs +++ /dev/null @@ -1,1033 +0,0 @@ -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -use libsql::{Builder, OpenFlags}; -use serde::{Deserialize, Serialize}; - -use crate::config::{self, TRACEDECAY_DIR, db_filename}; -use crate::errors::Result; -use crate::global_db; -use crate::storage::{BRANCH_META_FILENAME, SESSIONS_DB_FILENAME, STORE_MANIFEST_FILENAME}; - -#[derive(Debug, Clone, Default)] -pub struct MigrationInventoryOptions { - pub roots: Vec, - pub global_db_path: Option, - pub follow_symlinks: bool, - pub include_all_registered: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MigrationInventory { - pub stores: Vec, - pub skipped: Vec, - pub global_db: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StoreBrand { - TraceDecay, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StoreRole { - CodeProjectStore, - GlobalDbStore, - DiskOnlyOrphan, - HermesProfileStore, - HermesStateDbSource, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StoreStatus { - Ok, - MissingDb, - Dirty, - Locked, - Corrupt, - NeedsManualReview, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RegistryStatus { - Registered, - Unregistered, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoreInventory { - pub project_root: PathBuf, - pub data_dir: PathBuf, - pub db_path: PathBuf, - pub brand: StoreBrand, - pub role: StoreRole, - pub registry_status: RegistryStatus, - pub size_bytes: u64, - pub statuses: Vec, - pub artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoreArtifact { - pub kind: String, - pub path: PathBuf, - pub size_bytes: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SkippedPath { - pub path: PathBuf, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GlobalDbInventory { - pub path: PathBuf, - pub exists: bool, - pub path_overridden: bool, - pub accounting_mode: String, - pub legacy_home_fallback: bool, - pub project_count: u64, - pub session_count: u64, - pub lcm_raw_message_count: u64, - pub token_cache_present: bool, - pub registered_project_paths: Vec, - pub warnings: Vec, -} - -pub async fn build_inventory(options: MigrationInventoryOptions) -> Result { - let profile_root = options - .global_db_path - .as_deref() - .and_then(Path::parent) - .map(Path::to_path_buf) - .map_or_else(crate::storage::default_profile_root, Ok)?; - let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( - &profile_root, - "migration inventory", - )?; - let _database_scope = crate::db::enter_maintenance_database_scope( - &lifecycle, - &profile_root, - "migration inventory", - )?; - build_inventory_in_scope(options).await -} - -async fn build_inventory_in_scope( - options: MigrationInventoryOptions, -) -> Result { - let mut stores = Vec::new(); - let mut skipped = Vec::new(); - let mut seen_data_dirs = HashSet::new(); - let explicit_global_db_path = options.global_db_path.is_some(); - let global_db_path = options.global_db_path.or_else(global_db::global_db_path); - - for root in &options.roots { - scan_root( - root, - options.follow_symlinks, - &mut seen_data_dirs, - &mut stores, - &mut skipped, - ) - .await?; - } - let include_default_hermes_home = options.roots.is_empty() && !explicit_global_db_path; - scan_hermes_sources( - include_default_hermes_home, - options.follow_symlinks, - &mut seen_data_dirs, - &mut stores, - &mut skipped, - ) - .await?; - - let global_db = match global_db_path { - Some(path) => Some( - inspect_global_db( - &path, - explicit_global_db_path || global_db::global_db_path_is_overridden(), - ) - .await, - ), - None => None, - }; - let registered_project_keys = global_db - .as_ref() - .map(|global| canonical_path_set(&global.registered_project_paths)) - .unwrap_or_default(); - - let include_registered_roots = options.roots.is_empty() || options.include_all_registered; - if include_registered_roots { - if let Some(global) = &global_db { - for root in &global.registered_project_paths { - let before = stores.len(); - inspect_data_dir_candidate( - root, - TRACEDECAY_DIR, - options.follow_symlinks, - &mut seen_data_dirs, - &mut stores, - &mut skipped, - StoreRole::CodeProjectStore, - ) - .await?; - if stores.len() == before { - stores.push(missing_registered_store(root)); - } - } - } - } - - mark_registry_status(&mut stores, ®istered_project_keys); - stores.sort_by(|a, b| a.project_root.cmp(&b.project_root)); - skipped.sort_by(|a, b| a.path.cmp(&b.path)); - - Ok(MigrationInventory { - stores, - skipped, - global_db, - }) -} - -async fn scan_root( - root: &Path, - follow_symlinks: bool, - seen_data_dirs: &mut HashSet, - stores: &mut Vec, - skipped: &mut Vec, -) -> Result<()> { - let mut visited = HashSet::new(); - let mut work = vec![root.to_path_buf()]; - - while let Some(dir) = work.pop() { - let visit_key = if follow_symlinks { - dir.canonicalize().unwrap_or_else(|_| dir.clone()) - } else { - dir.clone() - }; - if !visited.insert(visit_key) { - continue; - } - - inspect_data_dir_candidate( - &dir, - TRACEDECAY_DIR, - follow_symlinks, - seen_data_dirs, - stores, - skipped, - StoreRole::CodeProjectStore, - ) - .await?; - - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - let path = entry.path(); - if file_type.is_symlink() && !follow_symlinks { - skipped.push(SkippedPath { - path, - reason: "symlink".to_string(), - }); - continue; - } - if file_type.is_symlink() { - let Ok(meta) = entry.metadata() else { - continue; - }; - if !meta.is_dir() { - continue; - } - } else if !file_type.is_dir() { - continue; - } - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name == TRACEDECAY_DIR { - continue; - } - if should_prune_dir(&name) { - continue; - } - work.push(path); - } - } - - Ok(()) -} - -async fn inspect_data_dir_candidate( - project_root: &Path, - dir_name: &str, - follow_symlinks: bool, - seen_data_dirs: &mut HashSet, - stores: &mut Vec, - skipped: &mut Vec, - role: StoreRole, -) -> Result<()> { - let mut data_dir = project_root.join(dir_name); - let Ok(meta) = std::fs::symlink_metadata(&data_dir) else { - return Ok(()); - }; - if meta.file_type().is_symlink() { - if !follow_symlinks { - skipped.push(SkippedPath { - path: data_dir, - reason: "symlink".to_string(), - }); - return Ok(()); - } - if !data_dir.is_dir() { - return Ok(()); - } - data_dir = data_dir.canonicalize().unwrap_or(data_dir); - } else if !meta.is_dir() { - return Ok(()); - } - let key = data_dir.canonicalize().unwrap_or_else(|_| data_dir.clone()); - if !seen_data_dirs.insert(key) { - return Ok(()); - } - let brand = StoreBrand::TraceDecay; - let db_path = data_dir.join(db_filename(&data_dir)); - let store = inspect_project_store( - project_root, - &data_dir, - db_path, - brand, - role, - follow_symlinks, - skipped, - ) - .await?; - stores.push(store); - Ok(()) -} - -async fn inspect_project_store( - project_root: &Path, - data_dir: &Path, - db_path: PathBuf, - brand: StoreBrand, - role: StoreRole, - follow_symlinks: bool, - skipped: &mut Vec, -) -> Result { - let mut statuses = Vec::new(); - let mut artifacts = Vec::new(); - - if db_path.is_file() { - artifacts.push(StoreArtifact { - kind: "graph_db".to_string(), - size_bytes: file_size(&db_path), - path: db_path.clone(), - }); - if !sqlite_quick_check(&db_path).await { - statuses.push(StoreStatus::Corrupt); - } - } else { - statuses.push(StoreStatus::MissingDb); - } - - record_optional_artifact( - data_dir, - "sessions_db", - SESSIONS_DB_FILENAME, - &mut artifacts, - ); - record_optional_artifact( - data_dir, - "branch_meta", - BRANCH_META_FILENAME, - &mut artifacts, - ); - record_branch_db_artifacts( - data_dir, - follow_symlinks, - skipped, - &mut statuses, - &mut artifacts, - ) - .await; - record_optional_artifact(data_dir, "config", "config.json", &mut artifacts); - record_optional_artifact( - data_dir, - "store_manifest", - STORE_MANIFEST_FILENAME, - &mut artifacts, - ); - record_optional_artifact( - data_dir, - "response_handles", - "response-handles", - &mut artifacts, - ); - record_optional_artifact(data_dir, "lcm_payloads", "lcm-payloads", &mut artifacts); - record_optional_artifact(data_dir, "dashboard", "dashboard", &mut artifacts); - - let dirty = data_dir.join("dirty"); - if dirty.exists() { - statuses.push(StoreStatus::Dirty); - artifacts.push(StoreArtifact { - kind: "dirty_sentinel".to_string(), - size_bytes: file_size(&dirty), - path: dirty, - }); - } - - let sync_lock = data_dir.join("sync.lock"); - if sync_lock.exists() { - statuses.push(StoreStatus::Locked); - artifacts.push(StoreArtifact { - kind: "sync_lock".to_string(), - size_bytes: file_size(&sync_lock), - path: sync_lock, - }); - } - - let config_tmp = data_dir.join("config.json.tmp"); - if config_tmp.exists() { - statuses.push(StoreStatus::NeedsManualReview); - artifacts.push(StoreArtifact { - kind: "config_tmp".to_string(), - size_bytes: file_size(&config_tmp), - path: config_tmp, - }); - } - - // A TraceDecay store historically nested under a Hermes profile cannot - // be treated as a code-project store. Its target must first be proven by - // the dedicated legacy migration; the generic manifest copier must not - // route it by the profile directory. - if role == StoreRole::HermesProfileStore { - statuses.push(StoreStatus::NeedsManualReview); - } else if statuses.is_empty() { - statuses.push(StoreStatus::Ok); - } - - Ok(StoreInventory { - project_root: project_root.to_path_buf(), - data_dir: data_dir.to_path_buf(), - db_path, - brand, - role, - registry_status: RegistryStatus::Unregistered, - size_bytes: dir_size(data_dir), - statuses, - artifacts, - }) -} - -async fn scan_hermes_sources( - include_default_home: bool, - follow_symlinks: bool, - seen_data_dirs: &mut HashSet, - stores: &mut Vec, - skipped: &mut Vec, -) -> Result<()> { - let mut seen_profiles = HashSet::new(); - let mut seen_state_dbs = HashSet::new(); - for hermes_home in hermes_home_candidates(include_default_home) { - inspect_hermes_profile_dir( - &hermes_home, - follow_symlinks, - seen_data_dirs, - &mut seen_profiles, - &mut seen_state_dbs, - stores, - skipped, - ) - .await?; - - let profiles_dir = hermes_home.join("profiles"); - let Ok(entries) = std::fs::read_dir(&profiles_dir) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - let mut profile_dir = entry.path(); - if file_type.is_symlink() { - if !follow_symlinks { - skipped.push(SkippedPath { - path: profile_dir, - reason: "symlink".to_string(), - }); - continue; - } - if !profile_dir.is_dir() { - continue; - } - profile_dir = profile_dir.canonicalize().unwrap_or(profile_dir); - } else if !file_type.is_dir() { - continue; - } - inspect_hermes_profile_dir( - &profile_dir, - follow_symlinks, - seen_data_dirs, - &mut seen_profiles, - &mut seen_state_dbs, - stores, - skipped, - ) - .await?; - } - } - Ok(()) -} - -async fn inspect_hermes_profile_dir( - profile_dir: &Path, - follow_symlinks: bool, - seen_data_dirs: &mut HashSet, - seen_profiles: &mut HashSet, - seen_state_dbs: &mut HashSet, - stores: &mut Vec, - skipped: &mut Vec, -) -> Result<()> { - if !profile_dir.is_dir() { - return Ok(()); - } - let profile_key = canonicalize_lossy(profile_dir); - if !seen_profiles.insert(profile_key) { - return Ok(()); - } - - inspect_data_dir_candidate( - profile_dir, - TRACEDECAY_DIR, - follow_symlinks, - seen_data_dirs, - stores, - skipped, - StoreRole::HermesProfileStore, - ) - .await?; - inspect_hermes_state_db(profile_dir, seen_state_dbs, stores).await; - - if let Some(project_root) = read_hermes_project_pin(&profile_dir.join("config.yaml")) { - inspect_data_dir_candidate( - &project_root, - TRACEDECAY_DIR, - follow_symlinks, - seen_data_dirs, - stores, - skipped, - StoreRole::CodeProjectStore, - ) - .await?; - } - - Ok(()) -} - -async fn inspect_hermes_state_db( - profile_dir: &Path, - seen_state_dbs: &mut HashSet, - stores: &mut Vec, -) { - let db_path = profile_dir.join("state.db"); - if !db_path.is_file() { - return; - } - let key = canonicalize_lossy(&db_path); - if !seen_state_dbs.insert(key) { - return; - } - let mut statuses = Vec::new(); - if !sqlite_quick_check(&db_path).await { - statuses.push(StoreStatus::Corrupt); - } - if statuses.is_empty() { - statuses.push(StoreStatus::Ok); - } - stores.push(StoreInventory { - project_root: profile_dir.to_path_buf(), - data_dir: profile_dir.to_path_buf(), - db_path: db_path.clone(), - brand: StoreBrand::TraceDecay, - role: StoreRole::HermesStateDbSource, - registry_status: RegistryStatus::Unregistered, - size_bytes: file_size(&db_path), - statuses, - artifacts: vec![StoreArtifact { - kind: "hermes_state_db".to_string(), - path: db_path.clone(), - size_bytes: file_size(&db_path), - }], - }); -} - -async fn inspect_global_db(path: &Path, path_overridden: bool) -> GlobalDbInventory { - let exists = path.is_file(); - let mut project_count = 0; - let mut session_count = 0; - let mut lcm_raw_message_count = 0; - let mut token_cache_present = false; - let mut registered_project_paths = Vec::new(); - let mut warnings = Vec::new(); - - if exists { - let authority = - crate::db::DatabaseAuthority::for_runtime(path, "inspect global database offline"); - if let Err(error) = authority.as_ref() { - warnings.push(format!( - "global DB '{}' is owned by the daemon; stop it before offline inventory: {error}", - path.display() - )); - } - if authority.is_ok() { - let db_result = Builder::new_local(path) - .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) - .build() - .await; - match db_result { - Ok(db) => match db.connect() { - Ok(conn) => { - if !sqlite_quick_check(path).await { - warnings - .push(format!("global DB '{}' failed quick_check", path.display())); - } - project_count = table_count(&conn, "projects").await; - session_count = table_count(&conn, "sessions").await; - lcm_raw_message_count = table_count(&conn, "lcm_raw_messages").await; - token_cache_present = table_exists(&conn, "dashboard_token_counts").await; - registered_project_paths = project_paths(&conn).await; - } - Err(err) => warnings.push(format!( - "could not inspect global DB '{}': {err}", - path.display() - )), - }, - Err(err) => warnings.push(format!( - "could not inspect global DB '{}': {err}", - path.display() - )), - } - } - } - - GlobalDbInventory { - path: path.to_path_buf(), - exists, - path_overridden, - accounting_mode: global_db::global_accounting_mode().as_str().to_string(), - legacy_home_fallback: false, - project_count, - session_count, - lcm_raw_message_count, - token_cache_present, - registered_project_paths, - warnings, - } -} - -async fn sqlite_quick_check(path: &Path) -> bool { - let Ok(_authority) = - crate::db::DatabaseAuthority::for_runtime(path, "quick-check SQLite database offline") - else { - return false; - }; - let Ok(db) = Builder::new_local(path) - .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) - .build() - .await - else { - return false; - }; - let Ok(conn) = db.connect() else { - return false; - }; - let Ok(mut rows) = conn.query("PRAGMA quick_check", ()).await else { - return false; - }; - let Ok(Some(row)) = rows.next().await else { - return false; - }; - row.get::(0).is_ok_and(|value| value == "ok") -} - -async fn table_count(conn: &libsql::Connection, table: &str) -> u64 { - if !table_exists(conn, table).await { - return 0; - } - let sql = format!("SELECT COUNT(*) FROM {table}"); - let Ok(mut rows) = conn.query(&sql, ()).await else { - return 0; - }; - let Ok(Some(row)) = rows.next().await else { - return 0; - }; - row.get::(0).unwrap_or(0).max(0) as u64 -} - -async fn table_exists(conn: &libsql::Connection, table: &str) -> bool { - let Ok(mut rows) = conn - .query( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", - libsql::params![table], - ) - .await - else { - return false; - }; - matches!(rows.next().await, Ok(Some(_))) -} - -async fn project_paths(conn: &libsql::Connection) -> Vec { - if !table_exists(conn, "projects").await { - return Vec::new(); - } - let Ok(mut rows) = conn.query("SELECT path FROM projects", ()).await else { - return Vec::new(); - }; - let mut paths = Vec::new(); - while let Ok(Some(row)) = rows.next().await { - if let Ok(path) = row.get::(0) { - paths.push(PathBuf::from(path)); - } - } - paths -} - -fn missing_registered_store(project_root: &Path) -> StoreInventory { - let data_dir = project_root.join(TRACEDECAY_DIR); - StoreInventory { - project_root: project_root.to_path_buf(), - db_path: data_dir.join(config::DB_FILENAME), - data_dir, - brand: StoreBrand::TraceDecay, - role: StoreRole::CodeProjectStore, - registry_status: RegistryStatus::Registered, - size_bytes: 0, - statuses: vec![StoreStatus::MissingDb], - artifacts: Vec::new(), - } -} - -fn record_optional_artifact( - data_dir: &Path, - kind: &str, - relpath: &str, - artifacts: &mut Vec, -) { - let path = data_dir.join(relpath); - if path.is_file() { - let size_bytes = file_size(&path); - artifacts.push(StoreArtifact { - kind: kind.to_string(), - size_bytes, - path, - }); - } else if path.is_dir() { - let size_bytes = dir_size(&path); - artifacts.push(StoreArtifact { - kind: kind.to_string(), - size_bytes, - path, - }); - } -} - -async fn record_branch_db_artifacts( - data_dir: &Path, - follow_symlinks: bool, - skipped: &mut Vec, - statuses: &mut Vec, - artifacts: &mut Vec, -) { - let mut branches_dir = data_dir.join("branches"); - let Ok(meta) = std::fs::symlink_metadata(&branches_dir) else { - return; - }; - if meta.file_type().is_symlink() { - if !follow_symlinks { - skipped.push(SkippedPath { - path: branches_dir, - reason: "symlink".to_string(), - }); - return; - } - if !branches_dir.is_dir() { - return; - } - branches_dir = branches_dir.canonicalize().unwrap_or(branches_dir); - } else if !meta.is_dir() { - return; - } - - let Ok(entries) = std::fs::read_dir(branches_dir) else { - return; - }; - let mut db_paths = entries - .flatten() - .filter_map(|entry| { - let path = entry.path(); - entry - .file_type() - .is_ok_and(|file_type| file_type.is_file()) - .then_some(path) - }) - .filter(|path| path.extension().is_some_and(|extension| extension == "db")) - .collect::>(); - db_paths.sort(); - - for path in db_paths { - artifacts.push(StoreArtifact { - kind: "branch_graph_db".to_string(), - size_bytes: file_size(&path), - path: path.clone(), - }); - record_sqlite_sidecar_artifact(&path, "-wal", "branch_graph_db_wal", artifacts); - record_sqlite_sidecar_artifact(&path, "-shm", "branch_graph_db_shm", artifacts); - if !sqlite_quick_check(&path).await && !statuses.contains(&StoreStatus::Corrupt) { - statuses.push(StoreStatus::Corrupt); - } - } -} - -fn record_sqlite_sidecar_artifact( - db_path: &Path, - suffix: &str, - kind: &str, - artifacts: &mut Vec, -) { - let mut path = db_path.as_os_str().to_os_string(); - path.push(suffix); - let path = PathBuf::from(path); - if path.is_file() { - artifacts.push(StoreArtifact { - kind: kind.to_string(), - size_bytes: file_size(&path), - path, - }); - } -} - -fn mark_registry_status(stores: &mut [StoreInventory], registered_project_keys: &HashSet) { - for store in stores { - let key = canonicalize_lossy(&store.project_root); - store.registry_status = if registered_project_keys.contains(&key) { - RegistryStatus::Registered - } else { - RegistryStatus::Unregistered - }; - } -} - -fn canonical_path_set(paths: &[PathBuf]) -> HashSet { - paths.iter().map(|path| canonicalize_lossy(path)).collect() -} - -fn canonicalize_lossy(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - -fn hermes_home_candidates(include_default_home: bool) -> Vec { - let mut seen = HashSet::new(); - let mut candidates = Vec::new(); - if include_default_home { - let Some(home) = std::env::var_os("HOME") - .filter(|home| !home.is_empty()) - .map(PathBuf::from) - .or_else(dirs::home_dir) - else { - return candidates; - }; - push_unique_path(&mut candidates, &mut seen, home.join(".hermes")); - } - candidates -} - -fn push_unique_path(candidates: &mut Vec, seen: &mut HashSet, path: PathBuf) { - let key = canonicalize_lossy(&path); - if seen.insert(key) { - candidates.push(path); - } -} - -fn read_hermes_project_pin(config_path: &Path) -> Option { - let config = std::fs::read_to_string(config_path).ok()?; - let lines = config.lines().collect::>(); - let (plugins_start, plugins_end) = find_top_level_section(&lines, "plugins")?; - read_project_pin_from_plugin_block(&lines, plugins_start, plugins_end, "tracedecay") - .map(PathBuf::from) -} - -fn read_project_pin_from_plugin_block( - lines: &[&str], - plugins_start: usize, - plugins_end: usize, - plugin_key: &str, -) -> Option { - let (block_start, block_end) = - find_indented_section(lines, plugins_start + 1, plugins_end, 2, plugin_key)?; - lines - .iter() - .take(block_end) - .skip(block_start + 1) - .find_map(|line| line.trim().strip_prefix("project_root:")) - .and_then(parse_yaml_scalar) -} - -fn find_top_level_section(lines: &[&str], key: &str) -> Option<(usize, usize)> { - let section_start = lines - .iter() - .position(|line| line.trim() == format!("{key}:"))?; - let section_end = lines - .iter() - .enumerate() - .skip(section_start + 1) - .find_map(|(index, line)| { - (!line.trim().is_empty() && leading_spaces(line) == 0).then_some(index) - }) - .unwrap_or(lines.len()); - Some((section_start, section_end)) -} - -fn find_indented_section( - lines: &[&str], - start: usize, - end: usize, - indent: usize, - key: &str, -) -> Option<(usize, usize)> { - let marker = format!("{key}:"); - let section_start = - lines - .iter() - .enumerate() - .take(end) - .skip(start) - .find_map(|(index, line)| { - (leading_spaces(line) == indent && line.trim() == marker).then_some(index) - })?; - let section_end = lines - .iter() - .enumerate() - .take(end) - .skip(section_start + 1) - .find_map(|(index, line)| { - (!line.trim().is_empty() && leading_spaces(line) <= indent).then_some(index) - }) - .unwrap_or(end); - Some((section_start, section_end)) -} - -fn parse_yaml_scalar(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - if value.starts_with('"') { - return serde_json::from_str::(value).ok(); - } - if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { - return Some(value[1..value.len() - 1].replace("''", "'")); - } - Some(value.to_string()) -} - -fn leading_spaces(line: &str) -> usize { - line.bytes().take_while(|byte| *byte == b' ').count() -} - -/// Authoritative prune during migration inventory scans (unlike the -/// scan.rs hint, nothing here is config-overridable — these directories are -/// always skipped while hunting for legacy data stores). Delegates the -/// generated/vendored segment check to the shared -/// [`crate::config::is_generated_dir_segment`] list, plus one site-local -/// addition: `.git`, which migration scans always want to skip but which -/// isn't part of the shared "generated/vendored" concept (the other three -/// call sites — scan hint, config default excludes, redundancy scanner — -/// don't all treat `.git` the same way). -fn should_prune_dir(name: &str) -> bool { - name == ".git" || config::is_generated_dir_segment(name) -} - -fn file_size(path: &Path) -> u64 { - std::fs::metadata(path).map_or(0, |meta| meta.len()) -} - -fn dir_size(dir: &Path) -> u64 { - fn walk(path: &Path, total: &mut u64, visited_dirs: &mut HashSet) { - let Ok(meta) = std::fs::symlink_metadata(path) else { - return; - }; - if meta.file_type().is_symlink() { - *total = total.saturating_add(meta.len()); - return; - } - if !meta.is_dir() { - if meta.is_file() { - *total = total.saturating_add(meta.len()); - } - return; - } - let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if !visited_dirs.insert(key) { - return; - } - let Ok(entries) = std::fs::read_dir(path) else { - return; - }; - for entry in entries.flatten() { - walk(&entry.path(), total, visited_dirs); - } - } - - let mut total = 0; - let mut visited_dirs = HashSet::new(); - walk(dir, &mut total, &mut visited_dirs); - total -} - -#[cfg(test)] -mod prune_dir_tests { - use super::should_prune_dir; - - #[test] - fn prunes_shared_generated_segments_and_the_local_git_addition() { - for name in [ - "node_modules", - "target", - "vendor", - "dist", - "build", - ".next", - ".venv", - ".git", // site-local addition, not part of GENERATED_DIR_SEGMENTS - ] { - assert!(should_prune_dir(name), "{name} should be pruned"); - } - } - - #[test] - fn gains_segments_it_previously_missed_via_the_shared_list() { - // These were absent from the old hand-maintained should_prune_dir - // list but are part of the shared GENERATED_DIR_SEGMENTS union that - // other call sites already recognized. - for name in [".worktrees", "coverage", "out", ".cache", "venv"] { - assert!(should_prune_dir(name), "{name} should now be pruned too"); - } - } - - #[test] - fn does_not_prune_real_source_dirs() { - for name in ["src", "builder", "distributed"] { - assert!(!should_prune_dir(name), "{name} is real source"); - } - } -} diff --git a/src/migrate/inventory/artifacts.rs b/src/migrate/inventory/artifacts.rs new file mode 100644 index 000000000..9f9a82d09 --- /dev/null +++ b/src/migrate/inventory/artifacts.rs @@ -0,0 +1,141 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use super::model::{SkippedPath, StoreArtifact, StoreStatus}; +use super::sqlite::sqlite_quick_check; + +pub(super) fn record_optional_artifact( + data_dir: &Path, + kind: &str, + relpath: &str, + artifacts: &mut Vec, +) { + let path = data_dir.join(relpath); + if path.is_file() { + let size_bytes = file_size(&path); + artifacts.push(StoreArtifact { + kind: kind.to_string(), + size_bytes, + path, + }); + } else if path.is_dir() { + let size_bytes = dir_size(&path); + artifacts.push(StoreArtifact { + kind: kind.to_string(), + size_bytes, + path, + }); + } +} + +pub(super) async fn record_branch_db_artifacts( + data_dir: &Path, + follow_symlinks: bool, + skipped: &mut Vec, + statuses: &mut Vec, + artifacts: &mut Vec, +) { + let mut branches_dir = data_dir.join("branches"); + let Ok(meta) = std::fs::symlink_metadata(&branches_dir) else { + return; + }; + if meta.file_type().is_symlink() { + if !follow_symlinks { + skipped.push(SkippedPath { + path: branches_dir, + reason: "symlink".to_string(), + }); + return; + } + if !branches_dir.is_dir() { + return; + } + branches_dir = branches_dir.canonicalize().unwrap_or(branches_dir); + } else if !meta.is_dir() { + return; + } + + let Ok(entries) = std::fs::read_dir(branches_dir) else { + return; + }; + let mut db_paths = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + entry + .file_type() + .is_ok_and(|file_type| file_type.is_file()) + .then_some(path) + }) + .filter(|path| path.extension().is_some_and(|extension| extension == "db")) + .collect::>(); + db_paths.sort(); + + for path in db_paths { + artifacts.push(StoreArtifact { + kind: "branch_graph_db".to_string(), + size_bytes: file_size(&path), + path: path.clone(), + }); + record_sqlite_sidecar_artifact(&path, "-wal", "branch_graph_db_wal", artifacts); + record_sqlite_sidecar_artifact(&path, "-shm", "branch_graph_db_shm", artifacts); + if !sqlite_quick_check(&path).await && !statuses.contains(&StoreStatus::Corrupt) { + statuses.push(StoreStatus::Corrupt); + } + } +} + +fn record_sqlite_sidecar_artifact( + db_path: &Path, + suffix: &str, + kind: &str, + artifacts: &mut Vec, +) { + let mut path = db_path.as_os_str().to_os_string(); + path.push(suffix); + let path = PathBuf::from(path); + if path.is_file() { + artifacts.push(StoreArtifact { + kind: kind.to_string(), + size_bytes: file_size(&path), + path, + }); + } +} + +pub(super) fn file_size(path: &Path) -> u64 { + std::fs::metadata(path).map_or(0, |meta| meta.len()) +} + +pub(super) fn dir_size(dir: &Path) -> u64 { + fn walk(path: &Path, total: &mut u64, visited_dirs: &mut HashSet) { + let Ok(meta) = std::fs::symlink_metadata(path) else { + return; + }; + if meta.file_type().is_symlink() { + *total = total.saturating_add(meta.len()); + return; + } + if !meta.is_dir() { + if meta.is_file() { + *total = total.saturating_add(meta.len()); + } + return; + } + let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !visited_dirs.insert(key) { + return; + } + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + walk(&entry.path(), total, visited_dirs); + } + } + + let mut total = 0; + let mut visited_dirs = HashSet::new(); + walk(dir, &mut total, &mut visited_dirs); + total +} diff --git a/src/migrate/inventory/hermes.rs b/src/migrate/inventory/hermes.rs new file mode 100644 index 000000000..cf6308735 --- /dev/null +++ b/src/migrate/inventory/hermes.rs @@ -0,0 +1,262 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use super::artifacts::file_size; +use super::model::{ + RegistryStatus, SkippedPath, StoreArtifact, StoreBrand, StoreInventory, StoreRole, StoreStatus, +}; +use super::project::{canonicalize_lossy, inspect_data_dir_candidate}; +use super::sqlite::sqlite_quick_check; +use crate::config::TRACEDECAY_DIR; +use crate::errors::Result; + +pub(super) async fn scan_hermes_sources( + include_default_home: bool, + follow_symlinks: bool, + seen_data_dirs: &mut HashSet, + stores: &mut Vec, + skipped: &mut Vec, +) -> Result<()> { + let mut seen_profiles = HashSet::new(); + let mut seen_state_dbs = HashSet::new(); + for hermes_home in hermes_home_candidates(include_default_home) { + inspect_hermes_profile_dir( + &hermes_home, + follow_symlinks, + seen_data_dirs, + &mut seen_profiles, + &mut seen_state_dbs, + stores, + skipped, + ) + .await?; + + let profiles_dir = hermes_home.join("profiles"); + let Ok(entries) = std::fs::read_dir(&profiles_dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let mut profile_dir = entry.path(); + if file_type.is_symlink() { + if !follow_symlinks { + skipped.push(SkippedPath { + path: profile_dir, + reason: "symlink".to_string(), + }); + continue; + } + if !profile_dir.is_dir() { + continue; + } + profile_dir = profile_dir.canonicalize().unwrap_or(profile_dir); + } else if !file_type.is_dir() { + continue; + } + inspect_hermes_profile_dir( + &profile_dir, + follow_symlinks, + seen_data_dirs, + &mut seen_profiles, + &mut seen_state_dbs, + stores, + skipped, + ) + .await?; + } + } + Ok(()) +} + +async fn inspect_hermes_profile_dir( + profile_dir: &Path, + follow_symlinks: bool, + seen_data_dirs: &mut HashSet, + seen_profiles: &mut HashSet, + seen_state_dbs: &mut HashSet, + stores: &mut Vec, + skipped: &mut Vec, +) -> Result<()> { + if !profile_dir.is_dir() { + return Ok(()); + } + let profile_key = canonicalize_lossy(profile_dir); + if !seen_profiles.insert(profile_key) { + return Ok(()); + } + + inspect_data_dir_candidate( + profile_dir, + TRACEDECAY_DIR, + follow_symlinks, + seen_data_dirs, + stores, + skipped, + StoreRole::HermesProfileStore, + ) + .await?; + inspect_hermes_state_db(profile_dir, seen_state_dbs, stores).await; + + if let Some(project_root) = read_hermes_project_pin(&profile_dir.join("config.yaml")) { + inspect_data_dir_candidate( + &project_root, + TRACEDECAY_DIR, + follow_symlinks, + seen_data_dirs, + stores, + skipped, + StoreRole::CodeProjectStore, + ) + .await?; + } + + Ok(()) +} + +async fn inspect_hermes_state_db( + profile_dir: &Path, + seen_state_dbs: &mut HashSet, + stores: &mut Vec, +) { + let db_path = profile_dir.join("state.db"); + if !db_path.is_file() { + return; + } + let key = canonicalize_lossy(&db_path); + if !seen_state_dbs.insert(key) { + return; + } + let mut statuses = Vec::new(); + if !sqlite_quick_check(&db_path).await { + statuses.push(StoreStatus::Corrupt); + } + if statuses.is_empty() { + statuses.push(StoreStatus::Ok); + } + stores.push(StoreInventory { + project_root: profile_dir.to_path_buf(), + data_dir: profile_dir.to_path_buf(), + db_path: db_path.clone(), + brand: StoreBrand::TraceDecay, + role: StoreRole::HermesStateDbSource, + registry_status: RegistryStatus::Unregistered, + size_bytes: file_size(&db_path), + statuses, + artifacts: vec![StoreArtifact { + kind: "hermes_state_db".to_string(), + path: db_path.clone(), + size_bytes: file_size(&db_path), + }], + }); +} + +fn hermes_home_candidates(include_default_home: bool) -> Vec { + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + if include_default_home { + let Some(home) = std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) + .or_else(dirs::home_dir) + else { + return candidates; + }; + push_unique_path(&mut candidates, &mut seen, home.join(".hermes")); + } + candidates +} + +fn push_unique_path(candidates: &mut Vec, seen: &mut HashSet, path: PathBuf) { + let key = canonicalize_lossy(&path); + if seen.insert(key) { + candidates.push(path); + } +} + +fn read_hermes_project_pin(config_path: &Path) -> Option { + let config = std::fs::read_to_string(config_path).ok()?; + let lines = config.lines().collect::>(); + let (plugins_start, plugins_end) = find_top_level_section(&lines, "plugins")?; + read_project_pin_from_plugin_block(&lines, plugins_start, plugins_end, "tracedecay") + .map(PathBuf::from) +} + +fn read_project_pin_from_plugin_block( + lines: &[&str], + plugins_start: usize, + plugins_end: usize, + plugin_key: &str, +) -> Option { + let (block_start, block_end) = + find_indented_section(lines, plugins_start + 1, plugins_end, 2, plugin_key)?; + lines + .iter() + .take(block_end) + .skip(block_start + 1) + .find_map(|line| line.trim().strip_prefix("project_root:")) + .and_then(parse_yaml_scalar) +} + +fn find_top_level_section(lines: &[&str], key: &str) -> Option<(usize, usize)> { + let section_start = lines + .iter() + .position(|line| line.trim() == format!("{key}:"))?; + let section_end = lines + .iter() + .enumerate() + .skip(section_start + 1) + .find_map(|(index, line)| { + (!line.trim().is_empty() && leading_spaces(line) == 0).then_some(index) + }) + .unwrap_or(lines.len()); + Some((section_start, section_end)) +} + +fn find_indented_section( + lines: &[&str], + start: usize, + end: usize, + indent: usize, + key: &str, +) -> Option<(usize, usize)> { + let marker = format!("{key}:"); + let section_start = + lines + .iter() + .enumerate() + .take(end) + .skip(start) + .find_map(|(index, line)| { + (leading_spaces(line) == indent && line.trim() == marker).then_some(index) + })?; + let section_end = lines + .iter() + .enumerate() + .take(end) + .skip(section_start + 1) + .find_map(|(index, line)| { + (!line.trim().is_empty() && leading_spaces(line) <= indent).then_some(index) + }) + .unwrap_or(end); + Some((section_start, section_end)) +} + +fn parse_yaml_scalar(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if value.starts_with('"') { + return serde_json::from_str::(value).ok(); + } + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return Some(value[1..value.len() - 1].replace("''", "'")); + } + Some(value.to_string()) +} + +fn leading_spaces(line: &str) -> usize { + line.bytes().take_while(|byte| *byte == b' ').count() +} diff --git a/src/migrate/inventory/mod.rs b/src/migrate/inventory/mod.rs new file mode 100644 index 000000000..b71a7ca03 --- /dev/null +++ b/src/migrate/inventory/mod.rs @@ -0,0 +1,148 @@ +mod artifacts; +mod hermes; +mod model; +mod project; +mod sqlite; + +use std::collections::HashSet; +use std::path::Path; + +pub use model::*; + +use crate::config::TRACEDECAY_DIR; +use crate::errors::Result; +use crate::global_db; + +pub async fn build_inventory(options: MigrationInventoryOptions) -> Result { + let profile_root = options + .global_db_path + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + .map_or_else(crate::storage::default_profile_root, Ok)?; + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + &profile_root, + "migration inventory", + )?; + let _database_scope = crate::db::enter_maintenance_database_scope( + &lifecycle, + &profile_root, + "migration inventory", + )?; + build_inventory_in_scope(options).await +} + +async fn build_inventory_in_scope( + options: MigrationInventoryOptions, +) -> Result { + let mut stores = Vec::new(); + let mut skipped = Vec::new(); + let mut seen_data_dirs = HashSet::new(); + let explicit_global_db_path = options.global_db_path.is_some(); + let global_db_path = options.global_db_path.or_else(global_db::global_db_path); + + for root in &options.roots { + project::scan_root( + root, + options.follow_symlinks, + &mut seen_data_dirs, + &mut stores, + &mut skipped, + ) + .await?; + } + let include_default_hermes_home = options.roots.is_empty() && !explicit_global_db_path; + hermes::scan_hermes_sources( + include_default_hermes_home, + options.follow_symlinks, + &mut seen_data_dirs, + &mut stores, + &mut skipped, + ) + .await?; + + let global_db = match global_db_path { + Some(path) => Some( + sqlite::inspect_global_db( + &path, + explicit_global_db_path || global_db::global_db_path_is_overridden(), + ) + .await, + ), + None => None, + }; + let registered_project_keys = global_db + .as_ref() + .map(|global| project::canonical_path_set(&global.registered_project_paths)) + .unwrap_or_default(); + + let include_registered_roots = options.roots.is_empty() || options.include_all_registered; + if include_registered_roots { + if let Some(global) = &global_db { + for root in &global.registered_project_paths { + let before = stores.len(); + project::inspect_data_dir_candidate( + root, + TRACEDECAY_DIR, + options.follow_symlinks, + &mut seen_data_dirs, + &mut stores, + &mut skipped, + StoreRole::CodeProjectStore, + ) + .await?; + if stores.len() == before { + stores.push(project::missing_registered_store(root)); + } + } + } + } + + project::mark_registry_status(&mut stores, ®istered_project_keys); + stores.sort_by(|a, b| a.project_root.cmp(&b.project_root)); + skipped.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(MigrationInventory { + stores, + skipped, + global_db, + }) +} + +#[cfg(test)] +mod prune_dir_tests { + use super::project::should_prune_dir; + + #[test] + fn prunes_shared_generated_segments_and_the_local_git_addition() { + for name in [ + "node_modules", + "target", + "vendor", + "dist", + "build", + ".next", + ".venv", + ".git", // site-local addition, not part of GENERATED_DIR_SEGMENTS + ] { + assert!(should_prune_dir(name), "{name} should be pruned"); + } + } + + #[test] + fn gains_segments_it_previously_missed_via_the_shared_list() { + // These were absent from the old hand-maintained should_prune_dir + // list but are part of the shared GENERATED_DIR_SEGMENTS union that + // other call sites already recognized. + for name in [".worktrees", "coverage", "out", ".cache", "venv"] { + assert!(should_prune_dir(name), "{name} should now be pruned too"); + } + } + + #[test] + fn does_not_prune_real_source_dirs() { + for name in ["src", "builder", "distributed"] { + assert!(!should_prune_dir(name), "{name} is real source"); + } + } +} diff --git a/src/migrate/inventory/model.rs b/src/migrate/inventory/model.rs new file mode 100644 index 000000000..3955bd943 --- /dev/null +++ b/src/migrate/inventory/model.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default)] +pub struct MigrationInventoryOptions { + pub roots: Vec, + pub global_db_path: Option, + pub follow_symlinks: bool, + pub include_all_registered: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationInventory { + pub stores: Vec, + pub skipped: Vec, + pub global_db: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreBrand { + TraceDecay, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreRole { + CodeProjectStore, + GlobalDbStore, + DiskOnlyOrphan, + HermesProfileStore, + HermesStateDbSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreStatus { + Ok, + MissingDb, + Dirty, + Locked, + Corrupt, + NeedsManualReview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegistryStatus { + Registered, + Unregistered, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreInventory { + pub project_root: PathBuf, + pub data_dir: PathBuf, + pub db_path: PathBuf, + pub brand: StoreBrand, + pub role: StoreRole, + pub registry_status: RegistryStatus, + pub size_bytes: u64, + pub statuses: Vec, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreArtifact { + pub kind: String, + pub path: PathBuf, + pub size_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkippedPath { + pub path: PathBuf, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalDbInventory { + pub path: PathBuf, + pub exists: bool, + pub path_overridden: bool, + pub accounting_mode: String, + pub legacy_home_fallback: bool, + pub project_count: u64, + pub session_count: u64, + pub lcm_raw_message_count: u64, + pub token_cache_present: bool, + pub registered_project_paths: Vec, + pub warnings: Vec, +} diff --git a/src/migrate/inventory/project.rs b/src/migrate/inventory/project.rs new file mode 100644 index 000000000..dae6f1de4 --- /dev/null +++ b/src/migrate/inventory/project.rs @@ -0,0 +1,294 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use super::artifacts::{dir_size, file_size, record_branch_db_artifacts, record_optional_artifact}; +use super::model::{ + RegistryStatus, SkippedPath, StoreArtifact, StoreBrand, StoreInventory, StoreRole, StoreStatus, +}; +use super::sqlite::sqlite_quick_check; +use crate::config::{self, TRACEDECAY_DIR, db_filename}; +use crate::errors::Result; +use crate::storage::{BRANCH_META_FILENAME, SESSIONS_DB_FILENAME, STORE_MANIFEST_FILENAME}; + +pub(super) async fn scan_root( + root: &Path, + follow_symlinks: bool, + seen_data_dirs: &mut HashSet, + stores: &mut Vec, + skipped: &mut Vec, +) -> Result<()> { + let mut visited = HashSet::new(); + let mut work = vec![root.to_path_buf()]; + + while let Some(dir) = work.pop() { + let visit_key = if follow_symlinks { + dir.canonicalize().unwrap_or_else(|_| dir.clone()) + } else { + dir.clone() + }; + if !visited.insert(visit_key) { + continue; + } + + inspect_data_dir_candidate( + &dir, + TRACEDECAY_DIR, + follow_symlinks, + seen_data_dirs, + stores, + skipped, + StoreRole::CodeProjectStore, + ) + .await?; + + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_symlink() && !follow_symlinks { + skipped.push(SkippedPath { + path, + reason: "symlink".to_string(), + }); + continue; + } + if file_type.is_symlink() { + let Ok(meta) = entry.metadata() else { + continue; + }; + if !meta.is_dir() { + continue; + } + } else if !file_type.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == TRACEDECAY_DIR { + continue; + } + if should_prune_dir(&name) { + continue; + } + work.push(path); + } + } + + Ok(()) +} + +pub(super) async fn inspect_data_dir_candidate( + project_root: &Path, + dir_name: &str, + follow_symlinks: bool, + seen_data_dirs: &mut HashSet, + stores: &mut Vec, + skipped: &mut Vec, + role: StoreRole, +) -> Result<()> { + let mut data_dir = project_root.join(dir_name); + let Ok(meta) = std::fs::symlink_metadata(&data_dir) else { + return Ok(()); + }; + if meta.file_type().is_symlink() { + if !follow_symlinks { + skipped.push(SkippedPath { + path: data_dir, + reason: "symlink".to_string(), + }); + return Ok(()); + } + if !data_dir.is_dir() { + return Ok(()); + } + data_dir = data_dir.canonicalize().unwrap_or(data_dir); + } else if !meta.is_dir() { + return Ok(()); + } + let key = data_dir.canonicalize().unwrap_or_else(|_| data_dir.clone()); + if !seen_data_dirs.insert(key) { + return Ok(()); + } + let brand = StoreBrand::TraceDecay; + let db_path = data_dir.join(db_filename(&data_dir)); + let store = inspect_project_store( + project_root, + &data_dir, + db_path, + brand, + role, + follow_symlinks, + skipped, + ) + .await?; + stores.push(store); + Ok(()) +} + +async fn inspect_project_store( + project_root: &Path, + data_dir: &Path, + db_path: PathBuf, + brand: StoreBrand, + role: StoreRole, + follow_symlinks: bool, + skipped: &mut Vec, +) -> Result { + let mut statuses = Vec::new(); + let mut artifacts = Vec::new(); + + if db_path.is_file() { + artifacts.push(StoreArtifact { + kind: "graph_db".to_string(), + size_bytes: file_size(&db_path), + path: db_path.clone(), + }); + if !sqlite_quick_check(&db_path).await { + statuses.push(StoreStatus::Corrupt); + } + } else { + statuses.push(StoreStatus::MissingDb); + } + + record_optional_artifact( + data_dir, + "sessions_db", + SESSIONS_DB_FILENAME, + &mut artifacts, + ); + record_optional_artifact( + data_dir, + "branch_meta", + BRANCH_META_FILENAME, + &mut artifacts, + ); + record_branch_db_artifacts( + data_dir, + follow_symlinks, + skipped, + &mut statuses, + &mut artifacts, + ) + .await; + record_optional_artifact(data_dir, "config", "config.json", &mut artifacts); + record_optional_artifact( + data_dir, + "store_manifest", + STORE_MANIFEST_FILENAME, + &mut artifacts, + ); + record_optional_artifact( + data_dir, + "response_handles", + "response-handles", + &mut artifacts, + ); + record_optional_artifact(data_dir, "lcm_payloads", "lcm-payloads", &mut artifacts); + record_optional_artifact(data_dir, "dashboard", "dashboard", &mut artifacts); + + let dirty = data_dir.join("dirty"); + if dirty.exists() { + statuses.push(StoreStatus::Dirty); + artifacts.push(StoreArtifact { + kind: "dirty_sentinel".to_string(), + size_bytes: file_size(&dirty), + path: dirty, + }); + } + + let sync_lock = data_dir.join("sync.lock"); + if sync_lock.exists() { + statuses.push(StoreStatus::Locked); + artifacts.push(StoreArtifact { + kind: "sync_lock".to_string(), + size_bytes: file_size(&sync_lock), + path: sync_lock, + }); + } + + let config_tmp = data_dir.join("config.json.tmp"); + if config_tmp.exists() { + statuses.push(StoreStatus::NeedsManualReview); + artifacts.push(StoreArtifact { + kind: "config_tmp".to_string(), + size_bytes: file_size(&config_tmp), + path: config_tmp, + }); + } + + // A TraceDecay store historically nested under a Hermes profile cannot + // be treated as a code-project store. Its target must first be proven by + // the dedicated legacy migration; the generic manifest copier must not + // route it by the profile directory. + if role == StoreRole::HermesProfileStore { + statuses.push(StoreStatus::NeedsManualReview); + } else if statuses.is_empty() { + statuses.push(StoreStatus::Ok); + } + + Ok(StoreInventory { + project_root: project_root.to_path_buf(), + data_dir: data_dir.to_path_buf(), + db_path, + brand, + role, + registry_status: RegistryStatus::Unregistered, + size_bytes: dir_size(data_dir), + statuses, + artifacts, + }) +} + +pub(super) fn missing_registered_store(project_root: &Path) -> StoreInventory { + let data_dir = project_root.join(TRACEDECAY_DIR); + StoreInventory { + project_root: project_root.to_path_buf(), + db_path: data_dir.join(config::DB_FILENAME), + data_dir, + brand: StoreBrand::TraceDecay, + role: StoreRole::CodeProjectStore, + registry_status: RegistryStatus::Registered, + size_bytes: 0, + statuses: vec![StoreStatus::MissingDb], + artifacts: Vec::new(), + } +} + +pub(super) fn mark_registry_status( + stores: &mut [StoreInventory], + registered_project_keys: &HashSet, +) { + for store in stores { + let key = canonicalize_lossy(&store.project_root); + store.registry_status = if registered_project_keys.contains(&key) { + RegistryStatus::Registered + } else { + RegistryStatus::Unregistered + }; + } +} + +pub(super) fn canonical_path_set(paths: &[PathBuf]) -> HashSet { + paths.iter().map(|path| canonicalize_lossy(path)).collect() +} + +pub(super) fn canonicalize_lossy(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Authoritative prune during migration inventory scans (unlike the +/// scan.rs hint, nothing here is config-overridable — these directories are +/// always skipped while hunting for legacy data stores). Delegates the +/// generated/vendored segment check to the shared +/// [`crate::config::is_generated_dir_segment`] list, plus one site-local +/// addition: `.git`, which migration scans always want to skip but which +/// isn't part of the shared "generated/vendored" concept (the other three +/// call sites — scan hint, config default excludes, redundancy scanner — +/// don't all treat `.git` the same way). +pub(super) fn should_prune_dir(name: &str) -> bool { + name == ".git" || config::is_generated_dir_segment(name) +} diff --git a/src/migrate/inventory/sqlite.rs b/src/migrate/inventory/sqlite.rs new file mode 100644 index 000000000..37f1ba0b2 --- /dev/null +++ b/src/migrate/inventory/sqlite.rs @@ -0,0 +1,138 @@ +use std::path::{Path, PathBuf}; + +use libsql::{Builder, OpenFlags}; + +use super::model::GlobalDbInventory; +use crate::global_db; + +pub(super) async fn inspect_global_db(path: &Path, path_overridden: bool) -> GlobalDbInventory { + let exists = path.is_file(); + let mut project_count = 0; + let mut session_count = 0; + let mut lcm_raw_message_count = 0; + let mut token_cache_present = false; + let mut registered_project_paths = Vec::new(); + let mut warnings = Vec::new(); + + if exists { + let authority = + crate::db::DatabaseAuthority::for_runtime(path, "inspect global database offline"); + if let Err(error) = authority.as_ref() { + warnings.push(format!( + "global DB '{}' is owned by the daemon; stop it before offline inventory: {error}", + path.display() + )); + } + if authority.is_ok() { + let db_result = Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await; + match db_result { + Ok(db) => match db.connect() { + Ok(conn) => { + if !sqlite_quick_check(path).await { + warnings + .push(format!("global DB '{}' failed quick_check", path.display())); + } + project_count = table_count(&conn, "projects").await; + session_count = table_count(&conn, "sessions").await; + lcm_raw_message_count = table_count(&conn, "lcm_raw_messages").await; + token_cache_present = table_exists(&conn, "dashboard_token_counts").await; + registered_project_paths = project_paths(&conn).await; + } + Err(err) => warnings.push(format!( + "could not inspect global DB '{}': {err}", + path.display() + )), + }, + Err(err) => warnings.push(format!( + "could not inspect global DB '{}': {err}", + path.display() + )), + } + } + } + + GlobalDbInventory { + path: path.to_path_buf(), + exists, + path_overridden, + accounting_mode: global_db::global_accounting_mode().as_str().to_string(), + legacy_home_fallback: false, + project_count, + session_count, + lcm_raw_message_count, + token_cache_present, + registered_project_paths, + warnings, + } +} + +pub(super) async fn sqlite_quick_check(path: &Path) -> bool { + let Ok(_authority) = + crate::db::DatabaseAuthority::for_runtime(path, "quick-check SQLite database offline") + else { + return false; + }; + let Ok(db) = Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + else { + return false; + }; + let Ok(conn) = db.connect() else { + return false; + }; + let Ok(mut rows) = conn.query("PRAGMA quick_check", ()).await else { + return false; + }; + let Ok(Some(row)) = rows.next().await else { + return false; + }; + row.get::(0).is_ok_and(|value| value == "ok") +} + +async fn table_count(conn: &libsql::Connection, table: &str) -> u64 { + if !table_exists(conn, table).await { + return 0; + } + let sql = format!("SELECT COUNT(*) FROM {table}"); + let Ok(mut rows) = conn.query(&sql, ()).await else { + return 0; + }; + let Ok(Some(row)) = rows.next().await else { + return 0; + }; + row.get::(0).unwrap_or(0).max(0) as u64 +} + +async fn table_exists(conn: &libsql::Connection, table: &str) -> bool { + let Ok(mut rows) = conn + .query( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + libsql::params![table], + ) + .await + else { + return false; + }; + matches!(rows.next().await, Ok(Some(_))) +} + +async fn project_paths(conn: &libsql::Connection) -> Vec { + if !table_exists(conn, "projects").await { + return Vec::new(); + } + let Ok(mut rows) = conn.query("SELECT path FROM projects", ()).await else { + return Vec::new(); + }; + let mut paths = Vec::new(); + while let Ok(Some(row)) = rows.next().await { + if let Ok(path) = row.get::(0) { + paths.push(PathBuf::from(path)); + } + } + paths +} diff --git a/src/monitor/cost.rs b/src/monitor/cost.rs index 76890e2f3..97e018cd4 100644 --- a/src/monitor/cost.rs +++ b/src/monitor/cost.rs @@ -275,12 +275,12 @@ mod tests { ) .unwrap() .unwrap(); - assert_eq!(snapshot.today_cost, 1.5); - assert_eq!(snapshot.week_cost, 4.5); + assert!((snapshot.today_cost - 1.5).abs() < f64::EPSILON); + assert!((snapshot.week_cost - 4.5).abs() < f64::EPSILON); assert_eq!(snapshot.tokens_saved, 1200); - assert_eq!(snapshot.efficiency_pct, 60.0); + assert!((snapshot.efficiency_pct - 60.0).abs() < f64::EPSILON); assert_eq!(snapshot.top_model, "today-leader"); - assert_eq!(snapshot.top_model_cost, 1.25); + assert!((snapshot.top_model_cost - 1.25).abs() < f64::EPSILON); } #[test] diff --git a/src/open_store_holders.rs b/src/open_store_holders.rs index dd006aa1d..61b9406ca 100644 --- a/src/open_store_holders.rs +++ b/src/open_store_holders.rs @@ -1,5 +1,6 @@ //! Read-only discovery of processes holding `TraceDecay` `SQLite` store files. +use std::collections::BTreeSet; use std::io; use std::path::{Path, PathBuf}; @@ -18,19 +19,30 @@ pub(crate) enum OpenStoreHolderScan { Unsupported { reason: String }, } +/// Controls which handles a holder scan reports. +/// +/// The default excludes the scanning process so non-destructive diagnostics do +/// not report their own database connection. Destructive deletion proofs can +/// opt in to the current process and omit only transaction-owned verification +/// descriptors. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct OpenStoreHolderScanOptions { + pub(crate) include_current_process: bool, + pub(crate) excluded_current_process_fds: BTreeSet, +} + /// Finds processes that currently hold any member of the supplied `SQLite` /// database families. The scan never signals or terminates a process. -#[cfg(test)] pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result { - // Unit tests own isolated stores. Keep their outcomes independent of a - // concurrently changing host process table; `evaluate_holder_scan` tests - // the production fail-closed policy directly. - let _ = database_paths; - Ok(OpenStoreHolderScan::Supported(Vec::new())) + scan_with_options(database_paths, &OpenStoreHolderScanOptions::default()) } -#[cfg(not(test))] -pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result { +/// Finds processes holding `database_paths` with explicit holder inclusion +/// controls. Inspection errors are returned so destructive callers fail closed. +pub(crate) fn scan_with_options( + database_paths: &[PathBuf], + options: &OpenStoreHolderScanOptions, +) -> io::Result { #[cfg(target_os = "linux")] { if !Path::new("/proc").is_dir() { @@ -43,17 +55,26 @@ pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result Ok(OpenStoreHolderScan::Supported(holders)), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Ok(OpenStoreHolderScan::Unsupported { + reason: error.to_string(), + }) + } + Err(error) => Err(error), + } } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { - let _ = database_paths; + let _ = (database_paths, options); Ok(OpenStoreHolderScan::Unsupported { reason: format!( "open-store process discovery is unavailable on {}", @@ -64,7 +85,26 @@ pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result io::Result> { +fn scan_macos( + database_paths: &[PathBuf], + own_pid: u32, + options: &OpenStoreHolderScanOptions, +) -> io::Result> { + scan_macos_with_lsof( + &[Path::new("lsof"), Path::new("/usr/sbin/lsof")], + database_paths, + own_pid, + options, + ) +} + +#[cfg(any(target_os = "macos", all(test, unix)))] +fn scan_macos_with_lsof( + lsof_programs: &[&Path], + database_paths: &[PathBuf], + own_pid: u32, + options: &OpenStoreHolderScanOptions, +) -> io::Result> { use std::collections::BTreeMap; use std::os::unix::fs::MetadataExt; use std::process::Command; @@ -89,17 +129,27 @@ fn scan_macos(database_paths: &[PathBuf]) -> io::Result> { .push(target.clone()); } - let run = |program: &str| { - Command::new(program) + let mut output = None; + for program in lsof_programs { + match Command::new(program) .args(["-nP", "-FpcfDi0", "--"]) .args(&targets) .output() - }; - let output = match run("lsof") { - Ok(output) => output, - Err(error) if error.kind() == io::ErrorKind::NotFound => run("/usr/sbin/lsof")?, - Err(error) => return Err(error), - }; + { + Ok(candidate) => { + output = Some(candidate); + break; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + } + } + let output = output.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "open-store process discovery requires the macOS lsof utility", + ) + })?; let stderr_has_content = output.stderr.iter().any(|byte| !byte.is_ascii_whitespace()); if (!output.status.success() && output.status.code() != Some(1)) || stderr_has_content { return Err(io::Error::other(format!( @@ -107,7 +157,7 @@ fn scan_macos(database_paths: &[PathBuf]) -> io::Result> { String::from_utf8_lossy(&output.stderr).trim() ))); } - parse_lsof_output(&output.stdout, &identities, std::process::id()) + parse_lsof_output(&output.stdout, &identities, own_pid, options) } #[cfg(any(target_os = "macos", all(test, unix)))] @@ -115,6 +165,7 @@ fn parse_lsof_output( output: &[u8], targets: &std::collections::BTreeMap<(u64, u64), Vec>, own_pid: u32, + options: &OpenStoreHolderScanOptions, ) -> io::Result> { use std::collections::BTreeSet; @@ -123,14 +174,21 @@ fn parse_lsof_output( let mut command = String::new(); let mut paths = BTreeSet::new(); let mut file_open = false; + let mut file_ignored = false; let mut device = None; let mut inode = None; let finish_file = |file_open: &mut bool, + file_ignored: &mut bool, device: &mut Option, inode: &mut Option, paths: &mut BTreeSet| -> io::Result<()> { - if !*file_open { + if !std::mem::replace(file_open, false) { + return Ok(()); + } + if std::mem::take(file_ignored) { + device.take(); + inode.take(); return Ok(()); } let identity = match (device.take(), inode.take()) { @@ -148,7 +206,6 @@ fn parse_lsof_output( ))); }; paths.extend(matched.iter().cloned()); - *file_open = false; Ok(()) }; let finish = |pid: &mut Option, @@ -158,7 +215,7 @@ fn parse_lsof_output( let Some(current) = pid.take() else { return; }; - if current != own_pid && !paths.is_empty() { + if (current != own_pid || options.include_current_process) && !paths.is_empty() { holders.push(OpenStoreHolder { pid: current, command: std::mem::take(command), @@ -178,7 +235,13 @@ fn parse_lsof_output( }; match kind { b'p' => { - finish_file(&mut file_open, &mut device, &mut inode, &mut paths)?; + finish_file( + &mut file_open, + &mut file_ignored, + &mut device, + &mut inode, + &mut paths, + )?; finish(&mut pid, &mut command, &mut paths, &mut holders); pid = Some( parse_decimal_field(value) @@ -188,25 +251,50 @@ fn parse_lsof_output( } b'c' => command = String::from_utf8_lossy(value).into_owned(), b'f' => { - if pid.is_none() { - return Err(io::Error::other( - "lsof returned a matching file without a process ID", - )); - } - finish_file(&mut file_open, &mut device, &mut inode, &mut paths)?; + let current = pid.ok_or_else(|| { + io::Error::other("lsof returned a matching file without a process ID") + })?; + finish_file( + &mut file_open, + &mut file_ignored, + &mut device, + &mut inode, + &mut paths, + )?; file_open = true; + file_ignored = current == own_pid + && (!options.include_current_process + || parse_lsof_fd(value) + .is_some_and(|fd| options.excluded_current_process_fds.contains(&fd))); } b'D' => device = parse_hex_field(value), b'i' => inode = parse_decimal_field(value), _ => {} } } - finish_file(&mut file_open, &mut device, &mut inode, &mut paths)?; + finish_file( + &mut file_open, + &mut file_ignored, + &mut device, + &mut inode, + &mut paths, + )?; finish(&mut pid, &mut command, &mut paths, &mut holders); holders.sort_by_key(|holder| holder.pid); Ok(holders) } +#[cfg(any(target_os = "macos", all(test, unix)))] +fn parse_lsof_fd(value: &[u8]) -> Option { + let length = value + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + std::str::from_utf8(&value[..length]) + .ok() + .and_then(|value| value.parse().ok()) +} + #[cfg(any(target_os = "macos", all(test, unix)))] fn parse_hex_field(value: &[u8]) -> Option { let value = value.strip_prefix(b"0x").unwrap_or(value); @@ -227,6 +315,7 @@ fn scan_linux( proc_root: &Path, database_paths: &[PathBuf], own_pid: u32, + options: &OpenStoreHolderScanOptions, mut version_probe: F, ) -> io::Result> where @@ -259,7 +348,7 @@ where for entry in std::fs::read_dir(proc_root)? { let entry = match entry { Ok(entry) => entry, - Err(error) if transient_proc_error(&error) => continue, + Err(error) if process_disappeared(&error) => continue, Err(error) => return Err(error), }; let Some(pid) = entry @@ -269,25 +358,33 @@ where else { continue; }; - if pid == own_pid { + if pid == own_pid && !options.include_current_process { continue; } let process_root = entry.path(); let fds = match std::fs::read_dir(process_root.join("fd")) { Ok(fds) => fds, - Err(error) if transient_proc_error(&error) => continue, + Err(error) if process_disappeared(&error) => continue, Err(error) => return Err(error), }; let mut paths = BTreeSet::new(); for fd in fds { let fd = match fd { Ok(fd) => fd, - Err(error) if transient_proc_error(&error) => continue, + Err(error) if process_disappeared(&error) => continue, Err(error) => return Err(error), }; + let fd_number = fd + .file_name() + .to_str() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| io::Error::other("/proc returned an invalid file descriptor"))?; + if pid == own_pid && options.excluded_current_process_fds.contains(&fd_number) { + continue; + } let metadata = match std::fs::metadata(fd.path()) { Ok(metadata) => metadata, - Err(error) if transient_proc_error(&error) => continue, + Err(error) if process_disappeared(&error) => continue, Err(error) => return Err(error), }; if let Some(matched) = targets.get(&(metadata.dev(), metadata.ino())) { @@ -298,8 +395,8 @@ where continue; } - let command = process_comm(&process_root, pid); - let executable = std::fs::read_link(process_root.join("exe")).ok(); + let command = process_comm(&process_root, pid)?; + let executable = process_executable(&process_root)?; let version = if is_tracedecay_process(&command, executable.as_deref()) { version_probe(pid, proc_root, &command) } else { @@ -318,14 +415,11 @@ where } #[cfg(target_os = "linux")] -fn transient_proc_error(error: &io::Error) -> bool { - matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied - ) +fn process_disappeared(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::NotFound } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", all(test, unix)))] fn sqlite_family_paths(path: &Path) -> [PathBuf; 3] { [ path.to_path_buf(), @@ -334,7 +428,7 @@ fn sqlite_family_paths(path: &Path) -> [PathBuf; 3] { ] } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", all(test, unix)))] fn with_suffix(path: &Path, suffix: &str) -> PathBuf { let mut value = path.as_os_str().to_os_string(); value.push(suffix); @@ -347,6 +441,9 @@ mod lsof_tests { use std::collections::BTreeMap; use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + use tempfile::TempDir; #[test] fn lsof_field_output_is_bounded_to_targets_and_excludes_self() { @@ -356,6 +453,7 @@ mod lsof_tests { b"p42\0ctracedecay\0f7\0D0x2a\0i7\0\np43\0cself\0f8\0D0x2a\0i7\0\n", &targets, 43, + &OpenStoreHolderScanOptions::default(), ) .unwrap(); assert_eq!(holders.len(), 1); @@ -370,7 +468,9 @@ mod lsof_tests { let targets = BTreeMap::from([((0x2a, 7), vec![target.clone()])]); let output = b"p42\0ctracedecay\0f7\0D0x2a\0i7\0n/stores/odd\\n\\xff.db\0\n"; - let holders = parse_lsof_output(output, &targets, 43).unwrap(); + let holders = + parse_lsof_output(output, &targets, 43, &OpenStoreHolderScanOptions::default()) + .unwrap(); assert_eq!(holders.len(), 1); assert_eq!(holders[0].paths, vec![target]); } @@ -378,23 +478,77 @@ mod lsof_tests { #[test] fn lsof_field_output_rejects_missing_file_identity() { let targets = BTreeMap::from([((0x2a, 7), vec![PathBuf::from("/stores/db")])]); - let error = parse_lsof_output(b"p42\0ctracedecay\0f7\0\n", &targets, 43).unwrap_err(); + let error = parse_lsof_output( + b"p42\0ctracedecay\0f7\0\n", + &targets, + 43, + &OpenStoreHolderScanOptions::default(), + ) + .unwrap_err(); assert!(error.to_string().contains("without device and inode")); } #[test] fn lsof_field_output_rejects_missing_process_identity() { let targets = BTreeMap::from([((0x2a, 7), vec![PathBuf::from("/stores/db")])]); - let error = parse_lsof_output(b"f7\0D0x2a\0i7\0\n", &targets, 43).unwrap_err(); + let error = parse_lsof_output( + b"f7\0D0x2a\0i7\0\n", + &targets, + 43, + &OpenStoreHolderScanOptions::default(), + ) + .unwrap_err(); assert!(error.to_string().contains("without a process ID")); } + + #[test] + fn lsof_scan_uses_injected_fixture_program() { + let temp = TempDir::new().unwrap(); + let database = temp.path().join("sessions.db"); + std::fs::write(&database, b"db").unwrap(); + let metadata = database.metadata().unwrap(); + let lsof = temp.path().join("lsof-fixture"); + std::fs::write( + &lsof, + format!( + "#!/bin/sh\nprintf 'p42\\000cfixture\\000f7\\000D{:x}\\000i{}\\000'\n", + metadata.dev(), + metadata.ino() + ), + ) + .unwrap(); + std::fs::set_permissions(&lsof, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let holders = scan_macos_with_lsof( + &[lsof.as_path()], + std::slice::from_ref(&database), + 43, + &OpenStoreHolderScanOptions::default(), + ) + .unwrap(); + + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].pid, 42); + assert_eq!(holders[0].paths, vec![database.canonicalize().unwrap()]); + } +} + +#[cfg(target_os = "linux")] +fn process_comm(process_root: &Path, pid: u32) -> io::Result { + match std::fs::read_to_string(process_root.join("comm")) { + Ok(value) => Ok(value.trim().to_string()), + Err(error) if process_disappeared(&error) => Ok(format!("pid {pid}")), + Err(error) => Err(error), + } } #[cfg(target_os = "linux")] -fn process_comm(process_root: &Path, pid: u32) -> String { - std::fs::read_to_string(process_root.join("comm")) - .map(|value| value.trim().to_string()) - .unwrap_or_else(|_| format!("pid {pid}")) +fn process_executable(process_root: &Path) -> io::Result> { + match std::fs::read_link(process_root.join("exe")) { + Ok(path) => Ok(Some(path)), + Err(error) if process_disappeared(&error) => Ok(None), + Err(error) => Err(error), + } } #[cfg(target_os = "linux")] @@ -479,11 +633,17 @@ mod tests { symlink("/opt/tracedecay", process.join("exe")).unwrap(); symlink(&wal, process.join("fd/7")).unwrap(); - let holders = scan_linux(&proc_root, &[database], 9000, |pid, _, command| { - assert_eq!(pid, 42); - assert_eq!(command, "tracedecay"); - Some("tracedecay 0.0.45".to_string()) - }) + let holders = scan_linux( + &proc_root, + &[database], + 9000, + &OpenStoreHolderScanOptions::default(), + |pid, _, command| { + assert_eq!(pid, 42); + assert_eq!(command, "tracedecay"); + Some("tracedecay 0.0.45".to_string()) + }, + ) .unwrap(); assert_eq!(holders.len(), 1); @@ -508,11 +668,93 @@ mod tests { symlink(path, process.join("fd/1")).unwrap(); } - let holders = scan_linux(&proc_root, &[database], 42, |_, _, _| { - panic!("excluded processes must not be probed") - }) + let holders = scan_linux( + &proc_root, + &[database], + 42, + &OpenStoreHolderScanOptions::default(), + |_, _, _| panic!("excluded processes must not be probed"), + ) .unwrap(); assert!(holders.is_empty()); } + + #[test] + fn linux_scan_includes_own_pid_but_excludes_verification_fd() { + let temp = TempDir::new().unwrap(); + let proc_root = temp.path().join("proc"); + let database = temp.path().join("sessions.db"); + let wal = with_suffix(&database, "-wal"); + std::fs::write(&database, b"db").unwrap(); + std::fs::write(&wal, b"wal").unwrap(); + + let process = proc_root.join("42"); + std::fs::create_dir_all(process.join("fd")).unwrap(); + std::fs::write(process.join("comm"), b"tracedecay\n").unwrap(); + symlink(&database, process.join("fd/1")).unwrap(); + symlink(&wal, process.join("fd/2")).unwrap(); + + let options = OpenStoreHolderScanOptions { + include_current_process: true, + excluded_current_process_fds: BTreeSet::from([1]), + }; + let holders = scan_linux(&proc_root, &[database], 42, &options, |_, _, _| None).unwrap(); + + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].pid, 42); + assert_eq!(holders[0].paths, vec![wal.canonicalize().unwrap()]); + } + + #[test] + fn linux_scan_matches_inode_after_target_rename() { + let temp = TempDir::new().unwrap(); + let proc_root = temp.path().join("proc"); + let original = temp.path().join("sessions.db"); + let open_descriptor = temp.path().join("open-descriptor"); + let renamed = temp.path().join("renamed-sessions.db"); + std::fs::write(&original, b"db").unwrap(); + std::fs::hard_link(&original, &open_descriptor).unwrap(); + std::fs::rename(&original, &renamed).unwrap(); + + let process = proc_root.join("42"); + std::fs::create_dir_all(process.join("fd")).unwrap(); + std::fs::write(process.join("comm"), b"other\n").unwrap(); + symlink(&open_descriptor, process.join("fd/7")).unwrap(); + + let holders = scan_linux( + &proc_root, + &[renamed.clone()], + 9000, + &OpenStoreHolderScanOptions::default(), + |_, _, _| None, + ) + .unwrap(); + + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].paths, vec![renamed.canonicalize().unwrap()]); + } + + #[test] + fn linux_scan_fails_closed_when_fd_inspection_is_incomplete() { + let temp = TempDir::new().unwrap(); + let proc_root = temp.path().join("proc"); + let database = temp.path().join("sessions.db"); + std::fs::write(&database, b"db").unwrap(); + + let process = proc_root.join("42"); + std::fs::create_dir_all(&process).unwrap(); + std::fs::write(process.join("fd"), b"not a directory").unwrap(); + + let error = scan_linux( + &proc_root, + &[database], + 9000, + &OpenStoreHolderScanOptions::default(), + |_, _, _| None, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::NotADirectory); + } } diff --git a/src/serve.rs b/src/serve.rs index 3d03eefca..6f6874608 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -65,11 +65,13 @@ pub fn sanitize_serve_path_arg(path: Option) -> Option { /// project database in-process. /// /// Project databases are daemon-owned. Returning a local [`TraceDecay`] would -/// reintroduce a second SQLite owner, so this API deliberately fails closed. +/// reintroduce a second `SQLite` owner, so this API deliberately fails closed. +#[allow(clippy::unused_async)] pub async fn ensure_initialized(project_path: &Path) -> Result { Err(direct_project_open_disabled(project_path)) } +#[allow(clippy::unused_async)] pub async fn ensure_initialized_with_options( project_path: &Path, _open_options: TraceDecayOpenOptions, @@ -235,9 +237,8 @@ mod tests { #[tokio::test] async fn direct_project_open_fails_closed() { let path = Path::new("/tmp/tracedecay-direct-open-must-not-run"); - let error = match ensure_initialized(path).await { - Ok(_) => panic!("legacy local open must fail closed"), - Err(error) => error, + let Err(error) = ensure_initialized(path).await else { + panic!("legacy local open must fail closed"); }; assert!(error.to_string().contains("managed TraceDecay daemon")); } diff --git a/src/startup_tests.rs b/src/startup_tests.rs index 1b633a7ce..70801a674 100644 --- a/src/startup_tests.rs +++ b/src/startup_tests.rs @@ -55,6 +55,17 @@ fn normal_commands_keep_startup_maintenance() { })); } +#[test] +fn tool_fallback_skips_network_and_agent_startup_maintenance() { + let command = Commands::Tool { + project: None, + name: Some("message_search".to_string()), + args: Vec::new(), + }; + assert!(should_skip_startup_maintenance(&command)); + assert!(should_skip_agent_install_maintenance(&command)); +} + #[test] fn agent_install_maintenance_is_selective() { // Skip the implicit reinstall scan on the hot path (`serve`), on the @@ -92,12 +103,6 @@ fn agent_install_maintenance_is_selective() { lifecycle_lease_token: None, } )); - assert!(should_skip_agent_install_maintenance(&Commands::Tool { - project: None, - name: Some("message_search".to_string()), - args: Vec::new(), - })); - // Also skip for uninstall (about to remove configs) and doctor (a // read-only diagnostic) — restoring the original #84 intent. assert!(should_skip_agent_install_maintenance( diff --git a/tests/architecture_boundaries.rs b/tests/architecture_boundaries.rs new file mode 100644 index 000000000..3749504da --- /dev/null +++ b/tests/architecture_boundaries.rs @@ -0,0 +1,808 @@ +use serde::Deserialize; +use std::collections::{BTreeSet, VecDeque}; +use std::ffi::OsStr; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +const REPOSITORY_SOURCE_ROOTS: &[&str] = &["src", "tests", "examples", "benches"]; + +// This is a sample project indexed by context-evaluation tests. Its Rust files +// are deliberately source input, not modules or targets of the tracedecay crate. +const INTENTIONAL_STANDALONE_RUST_INPUTS: &[&str] = &[ + "tests/fixtures/context_eval_project/src/auth/login.rs", + "tests/fixtures/context_eval_project/src/auth/mod.rs", + "tests/fixtures/context_eval_project/src/auth/session.rs", + "tests/fixtures/context_eval_project/src/cli.rs", + "tests/fixtures/context_eval_project/src/main.rs", + "tests/fixtures/context_eval_project/src/net/http_client.rs", + "tests/fixtures/context_eval_project/src/net/mod.rs", + "tests/fixtures/context_eval_project/src/net/retry.rs", + "tests/fixtures/context_eval_project/src/storage/cache.rs", + "tests/fixtures/context_eval_project/src/storage/config_store.rs", + "tests/fixtures/context_eval_project/src/storage/mod.rs", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Token { + Ident(String), + StringLiteral(String), + Punct(char), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SourceReference { + Module { + name: String, + path: Option, + inline_modules: Vec, + }, + Include { + path: String, + parse_as_rust: bool, + inline_modules: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ScanContext { + path: PathBuf, + module_dir: PathBuf, +} + +fn tokenize(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut tokens = Vec::new(); + let mut index = 0; + + while index < bytes.len() { + if bytes[index].is_ascii_whitespace() { + index += 1; + continue; + } + if bytes[index..].starts_with(b"//") { + index += 2; + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + continue; + } + if bytes[index..].starts_with(b"/*") { + index += 2; + let mut depth = 1usize; + while index < bytes.len() && depth > 0 { + if bytes[index..].starts_with(b"/*") { + depth += 1; + index += 2; + } else if bytes[index..].starts_with(b"*/") { + depth -= 1; + index += 2; + } else { + index += 1; + } + } + continue; + } + if let Some((value, next)) = raw_string_at(source, index) { + tokens.push(Token::StringLiteral(value)); + index = next; + continue; + } + if bytes[index] == b'"' { + let (value, next) = quoted_string_at(source, index); + tokens.push(Token::StringLiteral(value)); + index = next; + continue; + } + if bytes[index] == b'\'' + && let Some(next) = char_literal_end(bytes, index) + { + index = next; + continue; + } + if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + tokens.push(Token::Ident(source[start..index].to_string())); + continue; + } + + let character = source[index..].chars().next().expect("valid UTF-8"); + if character.is_ascii() { + tokens.push(Token::Punct(character)); + } + index += character.len_utf8(); + } + + tokens +} + +fn raw_string_at(source: &str, start: usize) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + if bytes.get(start) != Some(&b'r') { + return None; + } + let mut quote = start + 1; + while bytes.get(quote) == Some(&b'#') { + quote += 1; + } + if bytes.get(quote) != Some(&b'"') { + return None; + } + + let hashes = quote - start - 1; + let content_start = quote + 1; + let mut cursor = content_start; + while cursor < bytes.len() { + if bytes[cursor] == b'"' + && bytes.get(cursor + 1..cursor + 1 + hashes) == Some(&bytes[start + 1..quote]) + { + return Some(( + source[content_start..cursor].to_string(), + cursor + 1 + hashes, + )); + } + cursor += 1; + } + Some((source[content_start..].to_string(), bytes.len())) +} + +fn quoted_string_at(source: &str, start: usize) -> (String, usize) { + let bytes = source.as_bytes(); + let mut value = String::new(); + let mut index = start + 1; + + while index < bytes.len() { + match bytes[index] { + b'"' => return (value, index + 1), + b'\\' => { + index += 1; + if index >= bytes.len() { + break; + } + match bytes[index] { + b'\\' => value.push('\\'), + b'"' => value.push('"'), + b'n' => value.push('\n'), + b'r' => value.push('\r'), + b't' => value.push('\t'), + b'0' => value.push('\0'), + b'\n' => { + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + continue; + } + other => value.push(char::from(other)), + } + index += 1; + } + _ => { + let character = source[index..].chars().next().expect("valid UTF-8"); + value.push(character); + index += character.len_utf8(); + } + } + } + + (value, bytes.len()) +} + +fn char_literal_end(bytes: &[u8], start: usize) -> Option { + let mut index = start + 1; + if bytes.get(index) == Some(&b'\\') { + index += 2; + } else { + let character = std::str::from_utf8(bytes.get(index..)?) + .ok()? + .chars() + .next()?; + index += character.len_utf8(); + } + (bytes.get(index) == Some(&b'\'')).then_some(index + 1) +} + +fn scan_references(source: &str) -> Vec { + let tokens = tokenize(source); + let mut references = Vec::new(); + let mut inline_modules: Vec<(usize, String)> = Vec::new(); + let mut pending_path = None; + let mut brace_depth = 0usize; + let mut index = 0usize; + + while index < tokens.len() { + if tokens.get(index) == Some(&Token::Punct('#')) + && tokens.get(index + 1) == Some(&Token::Punct('[')) + && let Some(end) = matching_delimiter(&tokens, index + 1, '[', ']') + { + if let Some(path) = path_attribute(&tokens[index + 2..end]) { + pending_path = Some(path); + } + index = end + 1; + continue; + } + + if token_is_ident(tokens.get(index), "mod") + && let Some(Token::Ident(name)) = tokens.get(index + 1) + { + match tokens.get(index + 2) { + Some(Token::Punct(';')) => { + references.push(SourceReference::Module { + name: name.clone(), + path: pending_path.take(), + inline_modules: inline_module_names(&inline_modules), + }); + index += 3; + continue; + } + Some(Token::Punct('{')) => { + brace_depth += 1; + inline_modules.push((brace_depth, name.clone())); + pending_path = None; + index += 3; + continue; + } + _ => {} + } + } + + if (token_is_ident(tokens.get(index), "include") + || token_is_ident(tokens.get(index), "include_str")) + && tokens.get(index + 1) == Some(&Token::Punct('!')) + && tokens.get(index + 2) == Some(&Token::Punct('(')) + && let Some(Token::StringLiteral(path)) = tokens.get(index + 3) + && Path::new(path).extension() == Some(OsStr::new("rs")) + { + references.push(SourceReference::Include { + path: path.clone(), + parse_as_rust: token_is_ident(tokens.get(index), "include"), + inline_modules: inline_module_names(&inline_modules), + }); + } + + match tokens.get(index) { + Some(Token::Punct('{')) => { + brace_depth += 1; + pending_path = None; + } + Some(Token::Punct('}')) => { + while inline_modules + .last() + .is_some_and(|(depth, _)| *depth == brace_depth) + { + inline_modules.pop(); + } + brace_depth = brace_depth.saturating_sub(1); + pending_path = None; + } + Some(Token::Punct(';')) => pending_path = None, + _ => {} + } + index += 1; + } + + references +} + +fn matching_delimiter(tokens: &[Token], start: usize, open: char, close: char) -> Option { + let mut depth = 0usize; + for (index, token) in tokens.iter().enumerate().skip(start) { + match token { + Token::Punct(character) if *character == open => depth += 1, + Token::Punct(character) if *character == close => { + depth -= 1; + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +fn path_attribute(tokens: &[Token]) -> Option { + match tokens { + [ + Token::Ident(name), + Token::Punct('='), + Token::StringLiteral(path), + ] if name == "path" => Some(path.clone()), + _ => None, + } +} + +fn token_is_ident(token: Option<&Token>, expected: &str) -> bool { + matches!(token, Some(Token::Ident(value)) if value == expected) +} + +fn inline_module_names(modules: &[(usize, String)]) -> Vec { + modules.iter().map(|(_, name)| name.clone()).collect() +} + +fn resolve_reachable_sources( + repository: &Path, + target_roots: &BTreeSet, +) -> Result, String> { + let mut reachable = BTreeSet::new(); + let mut scanned = BTreeSet::new(); + let mut pending = VecDeque::new(); + + for root in target_roots { + let root = normalize_relative(root)?; + pending.push_back(ScanContext { + module_dir: root.parent().map_or_else(PathBuf::new, Path::to_path_buf), + path: root, + }); + } + + while let Some(context) = pending.pop_front() { + reachable.insert(context.path.clone()); + if !scanned.insert(context.clone()) { + continue; + } + let absolute = repository.join(&context.path); + let source = fs::read_to_string(&absolute) + .map_err(|error| format!("cannot read {}: {error}", absolute.display()))?; + + for reference in scan_references(&source) { + match reference { + SourceReference::Module { + name, + path, + inline_modules, + } => { + if let Some(path) = path { + let mut base = context + .path + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf); + base.extend(inline_modules); + let target = normalize_relative(&base.join(path))?; + enqueue_if_file(repository, &mut pending, target, None)?; + } else { + let mut module_dir = context.module_dir.clone(); + module_dir.extend(inline_modules); + let child_module_dir = normalize_relative(&module_dir.join(&name))?; + for target in [ + module_dir.join(format!("{name}.rs")), + module_dir.join(&name).join("mod.rs"), + ] { + enqueue_if_file( + repository, + &mut pending, + normalize_relative(&target)?, + Some(child_module_dir.clone()), + )?; + } + } + } + SourceReference::Include { + path, + parse_as_rust, + inline_modules, + } => { + let parent = context + .path + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf); + let target = normalize_relative(&parent.join(path))?; + if repository.join(&target).is_file() { + reachable.insert(target.clone()); + if parse_as_rust { + let mut module_dir = context.module_dir.clone(); + module_dir.extend(inline_modules); + pending.push_back(ScanContext { + path: target, + module_dir: normalize_relative(&module_dir)?, + }); + } + } + } + } + } + } + + Ok(reachable) +} + +fn enqueue_if_file( + repository: &Path, + pending: &mut VecDeque, + path: PathBuf, + module_dir: Option, +) -> Result<(), String> { + if repository.join(&path).is_file() { + pending.push_back(ScanContext { + module_dir: module_dir.unwrap_or_else(|| module_dir_for_file(&path)), + path, + }); + } + Ok(()) +} + +fn module_dir_for_file(path: &Path) -> PathBuf { + let parent = path.parent().map_or_else(PathBuf::new, Path::to_path_buf); + if path.file_name() == Some(OsStr::new("mod.rs")) { + parent + } else { + path.file_stem() + .map_or(parent.clone(), |stem| parent.join(stem)) + } +} + +fn normalize_relative(path: &Path) -> Result { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + if !normalized.pop() { + return Err(format!( + "source reference escapes repository root: {}", + path.display() + )); + } + } + Component::RootDir | Component::Prefix(_) => { + return Err(format!( + "Cargo and module paths must be repository-relative: {}", + path.display() + )); + } + } + } + Ok(normalized) +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec, + workspace_members: BTreeSet, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + id: String, + manifest_path: PathBuf, + targets: Vec, +} + +#[derive(Debug, Deserialize)] +struct CargoTarget { + src_path: PathBuf, +} + +#[derive(Debug, PartialEq, Eq)] +struct CargoSourceLayout { + target_roots: BTreeSet, + tracked_roots: BTreeSet, +} + +fn cargo_source_layout(repository: &Path) -> Result { + let output = Command::new("cargo") + .current_dir(repository) + .args(["metadata", "--no-deps", "--format-version", "1"]) + .output() + .map_err(|error| format!("cannot run cargo metadata: {error}"))?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + parse_cargo_source_layout(repository, &output.stdout) +} + +fn parse_cargo_source_layout( + repository: &Path, + metadata_json: &[u8], +) -> Result { + let CargoMetadata { + packages, + workspace_members, + } = serde_json::from_slice(metadata_json) + .map_err(|error| format!("cannot parse cargo metadata: {error}"))?; + let package_ids: BTreeSet<_> = packages.iter().map(|package| package.id.clone()).collect(); + let missing_members: Vec<_> = workspace_members.difference(&package_ids).collect(); + if !missing_members.is_empty() { + return Err(format!( + "cargo metadata omitted workspace packages: {missing_members:?}" + )); + } + + let mut target_roots = BTreeSet::new(); + let mut tracked_roots: BTreeSet = + REPOSITORY_SOURCE_ROOTS.iter().map(PathBuf::from).collect(); + + for package in packages { + if !workspace_members.contains(&package.id) { + continue; + } + let manifest_path = metadata_path_relative( + repository, + &package.manifest_path, + "workspace package manifest", + )?; + let package_root = manifest_path + .parent() + .ok_or_else(|| format!("manifest has no parent: {}", manifest_path.display()))?; + if !package_root.as_os_str().is_empty() { + tracked_roots.insert(package_root.to_path_buf()); + } + + for target in package.targets { + target_roots.insert(metadata_path_relative( + repository, + &target.src_path, + "Cargo target source", + )?); + } + } + + if target_roots.is_empty() { + return Err("cargo metadata exposes no workspace Rust targets".to_string()); + } + for target_root in &target_roots { + if !tracked_roots + .iter() + .any(|source_root| target_root.starts_with(source_root)) + { + tracked_roots.insert(target_root.clone()); + } + } + + Ok(CargoSourceLayout { + target_roots, + tracked_roots, + }) +} + +fn metadata_path_relative( + repository: &Path, + path: &Path, + description: &str, +) -> Result { + if !path.is_absolute() { + return Err(format!( + "{description} path is not absolute: {}", + path.display() + )); + } + let relative = path.strip_prefix(repository).map_err(|_| { + format!( + "{description} path is outside repository: {}", + path.display() + ) + })?; + normalize_relative(relative) +} + +fn git_tracked_rust_sources( + repository: &Path, + source_roots: &BTreeSet, +) -> Result, String> { + let output = Command::new("git") + .arg("-C") + .arg(repository) + .args(["ls-files", "-z", "--"]) + .args(source_roots) + .output() + .map_err(|error| format!("cannot run git ls-files: {error}"))?; + if !output.status.success() { + return Err(format!( + "git ls-files failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + output + .stdout + .split(|byte| *byte == 0) + .filter(|bytes| !bytes.is_empty()) + .map(|bytes| { + let path = std::str::from_utf8(bytes) + .map_err(|error| format!("git-tracked path is not UTF-8: {error}"))?; + normalize_relative(Path::new(path)) + }) + .filter_map(|result| match result { + Ok(path) + if path.extension() == Some(OsStr::new("rs")) + && repository.join(&path).is_file() => + { + Some(Ok(path)) + } + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect() +} + +#[test] +fn git_tracked_rust_sources_are_reachable_from_cargo_targets() { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")); + let layout = cargo_source_layout(repository).expect("discover Cargo workspace Rust targets"); + let reachable = resolve_reachable_sources(repository, &layout.target_roots) + .expect("resolve Rust module/include graph"); + let tracked = git_tracked_rust_sources(repository, &layout.tracked_roots) + .expect("list git-tracked workspace Rust sources"); + let allowlisted: BTreeSet = INTENTIONAL_STANDALONE_RUST_INPUTS + .iter() + .map(|path| PathBuf::from(*path)) + .collect(); + let stale_allowlist: Vec<_> = allowlisted.difference(&tracked).collect(); + assert!( + stale_allowlist.is_empty(), + "standalone Rust input allowlist contains untracked or deleted paths: {stale_allowlist:?}" + ); + let reachable_allowlist: Vec<_> = allowlisted.intersection(&reachable).collect(); + assert!( + reachable_allowlist.is_empty(), + "Rust inputs are now reachable and should leave the standalone allowlist: {reachable_allowlist:?}" + ); + let orphaned: Vec<_> = tracked + .difference(&reachable) + .filter(|path| !allowlisted.contains(*path)) + .collect(); + + assert!( + orphaned.is_empty(), + "git-tracked Rust files are not reachable from any Cargo target:\n{}\n\ + Register each file from a target/module root, or document a genuinely standalone source \ + input in INTENTIONAL_STANDALONE_RUST_INPUTS.", + orphaned + .iter() + .map(|path| format!(" - {}", path.display())) + .collect::>() + .join("\n") + ); +} + +#[test] +fn metadata_layout_includes_workspace_targets_and_scopes_tracked_sources() { + let temporary = tempfile::tempdir().expect("create metadata fixture"); + let repository = temporary.path(); + let root_id = "path+file:///workspace#root@0.1.0"; + let domain_id = "path+file:///workspace/crates/domain#domain@0.1.0"; + let metadata = serde_json::json!({ + "packages": [ + { + "id": root_id, + "manifest_path": repository.join("Cargo.toml"), + "targets": [ + { "src_path": repository.join("src/lib.rs") }, + { "src_path": repository.join("src/main.rs") }, + { "src_path": repository.join("build.rs") } + ] + }, + { + "id": domain_id, + "manifest_path": repository.join("crates/tracedecay-domain/Cargo.toml"), + "targets": [ + { "src_path": repository.join("crates/tracedecay-domain/src/lib.rs") }, + { "src_path": repository.join("crates/tracedecay-domain/tests/boundary.rs") } + ] + }, + { + "id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0", + "manifest_path": "/outside/registry/serde/Cargo.toml", + "targets": [{ "src_path": "/outside/registry/serde/src/lib.rs" }] + } + ], + "workspace_members": [root_id, domain_id] + }); + + let layout = parse_cargo_source_layout( + repository, + &serde_json::to_vec(&metadata).expect("serialize metadata fixture"), + ) + .expect("parse metadata fixture"); + + assert_eq!( + layout.target_roots, + [ + PathBuf::from("build.rs"), + PathBuf::from("crates/tracedecay-domain/src/lib.rs"), + PathBuf::from("crates/tracedecay-domain/tests/boundary.rs"), + PathBuf::from("src/lib.rs"), + PathBuf::from("src/main.rs"), + ] + .into_iter() + .collect() + ); + assert_eq!( + layout.tracked_roots, + [ + PathBuf::from("benches"), + PathBuf::from("build.rs"), + PathBuf::from("crates/tracedecay-domain"), + PathBuf::from("examples"), + PathBuf::from("src"), + PathBuf::from("tests"), + ] + .into_iter() + .collect() + ); +} + +#[test] +fn scanner_follows_modules_path_attributes_and_literal_rust_includes() { + let references = scan_references( + r##" + // mod commented_out; + const TEXT: &str = "mod string_literal; include!(\"also_ignored.rs\");"; + #[cfg(test)] + #[path = r#"alternate/scenario.rs"#] + mod scenario; + mod ordinary; + mod inline { + mod nested; + include!("fragment.rs"); + } + include_str!("fixture.rs"); + include_str!("not_rust.txt"); + "##, + ); + + assert!(references.contains(&SourceReference::Module { + name: "scenario".to_string(), + path: Some("alternate/scenario.rs".to_string()), + inline_modules: Vec::new(), + })); + assert!(references.contains(&SourceReference::Module { + name: "nested".to_string(), + path: None, + inline_modules: vec!["inline".to_string()], + })); + assert!(references.contains(&SourceReference::Include { + path: "fragment.rs".to_string(), + parse_as_rust: true, + inline_modules: vec!["inline".to_string()], + })); + assert!(references.contains(&SourceReference::Include { + path: "fixture.rs".to_string(), + parse_as_rust: false, + inline_modules: Vec::new(), + })); + assert!(!references.iter().any(|reference| { + matches!(reference, SourceReference::Module { name, .. } if name == "commented_out" || name == "string_literal") + })); +} + +#[test] +fn resolver_exposes_a_forgotten_decomposed_test_scenario() { + let temporary = tempfile::tempdir().expect("create resolver fixture"); + let repository = temporary.path(); + fs::create_dir_all(repository.join("tests/suite/registered")).unwrap(); + fs::write(repository.join("tests/suite/main.rs"), "mod registered;\n").unwrap(); + fs::write( + repository.join("tests/suite/registered.rs"), + "mod helper;\n", + ) + .unwrap(); + fs::write( + repository.join("tests/suite/registered/helper.rs"), + "pub fn helper() {}\n", + ) + .unwrap(); + fs::write( + repository.join("tests/suite/forgotten_scenario.rs"), + "#[test] fn silently_unregistered() {}\n", + ) + .unwrap(); + + let roots = [PathBuf::from("tests/suite/main.rs")].into_iter().collect(); + let reachable = resolve_reachable_sources(repository, &roots).unwrap(); + + assert!(reachable.contains(Path::new("tests/suite/registered.rs"))); + assert!(reachable.contains(Path::new("tests/suite/registered/helper.rs"))); + assert!(!reachable.contains(Path::new("tests/suite/forgotten_scenario.rs"))); +} diff --git a/tests/daemon_suite/pr_autotrack_test.rs b/tests/daemon_suite/pr_autotrack_test.rs index 7481260c9..296f22a6b 100644 --- a/tests/daemon_suite/pr_autotrack_test.rs +++ b/tests/daemon_suite/pr_autotrack_test.rs @@ -301,6 +301,100 @@ async fn tracks_same_repo_pr_indexes_its_content_and_untracks_on_close() { assert_eq!(user_branch_after_close, user_branch_sha); } +/// A contended coordinator removal must leave every owned artifact in place so +/// the following poll can retry safely instead of serving an untracked state +/// whose worktree/ref was already deleted. +#[tokio::test] +async fn busy_untrack_retains_managed_state_until_a_later_poll_succeeds() { + let (_env, project, origin) = indexed_repo_with_origin().await; + add_same_repo_pr(&project, &origin, 6, "pr_six_symbol"); + let data_root = project_data_dir(&project); + let label = "tracedecay/autotrack/pr/6"; + let worktree = data_root.join("pr-worktrees/pr-6"); + let discovery = pr_autotrack::discover_open_prs(&project).expect("PR discovery succeeds"); + let tracked = pr_autotrack::reconcile_project(&project, &data_root, &discovery, 10).await; + assert_eq!(tracked.tracked, vec![label.to_string()]); + + // The branch-administration coordinator takes this same metadata lock. It + // must fail closed while another mutation owns it, not delete Git artifacts + // after an unsuccessful store removal. + let lock = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(data_root.join(".branch-add.lock")) + .unwrap(); + lock.lock_exclusive().unwrap(); + + let busy = pr_autotrack::reconcile_project( + &project, + &data_root, + &pr_autotrack::PrDiscovery::default(), + 10, + ) + .await; + assert!(busy.untracked.is_empty()); + assert!( + busy.failures.iter().any(|(branch, _)| branch == label), + "the failed coordinator removal must be reported" + ); + assert_eq!(pr_autotrack::managed_summary(&data_root).len(), 1); + assert!(load_branch_meta(&data_root).unwrap().is_tracked(label)); + assert!(worktree.exists(), "busy removal must retain its worktree"); + assert!( + git_out( + &project, + &[ + "rev-parse", + "--verify", + "refs/heads/tracedecay/autotrack/pr/6", + ], + ) + .status + .success(), + "busy removal must retain its synthetic branch" + ); + assert!( + git_out(&project, &["rev-parse", "--verify", "refs/tracedecay/pr/6"]) + .status + .success(), + "busy removal must retain its owned fetch ref" + ); + + lock.unlock().unwrap(); + + let retried = pr_autotrack::reconcile_project( + &project, + &data_root, + &pr_autotrack::PrDiscovery::default(), + 10, + ) + .await; + assert_eq!(retried.untracked, vec![label.to_string()]); + assert!(pr_autotrack::managed_summary(&data_root).is_empty()); + assert!(!load_branch_meta(&data_root).unwrap().is_tracked(label)); + assert!(!worktree.exists(), "successful retry removes the worktree"); + assert!( + !git_out( + &project, + &[ + "rev-parse", + "--verify", + "refs/heads/tracedecay/autotrack/pr/6", + ], + ) + .status + .success(), + "successful retry removes the synthetic branch" + ); + assert!( + !git_out(&project, &["rev-parse", "--verify", "refs/tracedecay/pr/6"]) + .status + .success(), + "successful retry removes the owned fetch ref" + ); +} + #[tokio::test] async fn deferred_tracking_is_not_persisted_and_retries_next_cycle() { let (_env, project, origin) = indexed_repo_with_origin().await; @@ -363,9 +457,14 @@ async fn validated_branch_ref_orphan_without_worktree_is_rebuilt() { let first = pr_autotrack::reconcile_project(&project, &data_root, &discovery, 10).await; assert_eq!(first.tracked, vec![label.to_string()]); fs::remove_file(data_root.join("pr-autotrack.json")).unwrap(); - assert!(tracedecay::branch::remove_tracked_branch_store( - &data_root, label - )); + let mut meta = load_branch_meta(&data_root).unwrap(); + let entry = meta.remove_branch(label).unwrap(); + save_branch_meta(&data_root, &meta).unwrap(); + fs::rename( + data_root.join(entry.db_file), + data_root.join("interrupted-branch-store.db"), + ) + .unwrap(); git( &project, &["worktree", "remove", "--force", &worktree.to_string_lossy()], @@ -538,9 +637,14 @@ async fn interrupted_track_with_advanced_head_recovers_instead_of_wedging() { // Reproduce the mid-track crash aftermath: state gone, DB gone, worktree // gone — but the synthetic branch survives pointing at the OLD head. fs::remove_file(data_root.join("pr-autotrack.json")).unwrap(); - assert!(tracedecay::branch::remove_tracked_branch_store( - &data_root, label - )); + let mut meta = load_branch_meta(&data_root).unwrap(); + let entry = meta.remove_branch(label).unwrap(); + save_branch_meta(&data_root, &meta).unwrap(); + fs::rename( + data_root.join(entry.db_file), + data_root.join("interrupted-branch-store.db"), + ) + .unwrap(); git( &project, &["worktree", "remove", "--force", &worktree.to_string_lossy()], @@ -616,7 +720,6 @@ async fn cap_does_not_starve_head_updates_for_managed_prs() { "the refreshed managed branch serves the advanced head, not the stale one" ); } - /// Finding 7: turning the feature off must tear down managed PR state rather /// than stranding worktrees, refs, synthetic branches and stores forever. #[tokio::test] @@ -660,47 +763,3 @@ async fn teardown_removes_all_managed_state_on_disable() { pr_autotrack::teardown_disabled_project(&project).await; assert!(pr_autotrack::managed_summary(&data_root).is_empty()); } - -/// Finding 3: `remove_tracked_branch_store` must serialize on the branch-add -/// lock so it can't race a concurrent `branch add`. While the lock is held it -/// skips (returns false) and leaves metadata intact; once released it proceeds. -#[tokio::test] -async fn remove_tracked_branch_store_respects_branch_add_lock() { - use tracedecay::branch_meta::BranchMeta; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let mut meta = BranchMeta::new("main"); - meta.add_branch("feature-x", "branches/feature_x.db", "main"); - fs::create_dir_all(root.join("branches")).unwrap(); - fs::write(root.join("branches/feature_x.db"), b"db").unwrap(); - save_branch_meta(root, &meta).unwrap(); - - // Hold the branch-add lock exclusively, as a concurrent `branch add` would. - let lock = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(root.join(".branch-add.lock")) - .unwrap(); - lock.lock_exclusive().unwrap(); - - assert!( - !tracedecay::branch::remove_tracked_branch_store(root, "feature-x"), - "removal must skip while the branch-add lock is held" - ); - assert!( - load_branch_meta(root).unwrap().is_tracked("feature-x"), - "contended removal must not mutate branch metadata" - ); - assert!(root.join("branches/feature_x.db").exists()); - - lock.unlock().unwrap(); - - assert!( - tracedecay::branch::remove_tracked_branch_store(root, "feature-x"), - "removal proceeds once the lock is released" - ); - assert!(!load_branch_meta(root).unwrap().is_tracked("feature-x")); - assert!(!root.join("branches/feature_x.db").exists()); -} diff --git a/tests/storage_suite/branch_db_safety_test.rs b/tests/storage_suite/branch_db_safety_test.rs index 3fb58d3ca..5234ff2ef 100644 --- a/tests/storage_suite/branch_db_safety_test.rs +++ b/tests/storage_suite/branch_db_safety_test.rs @@ -3,6 +3,9 @@ use std::path::Path; use std::path::PathBuf; use std::process::Command; +#[cfg(unix)] +use serde_json::Value; + use crate::common::{self, IsolatedEnv}; use tracedecay::branch::BranchAddOutcome; use tracedecay::branch_meta::load_branch_meta; @@ -477,3 +480,124 @@ fn cli_branch_add_refuses_corrupt_metadata_without_overwriting() { "failed CLI branch add must preserve corrupt metadata for repair" ); } + +#[cfg(unix)] +#[tokio::test] +async fn cli_branch_add_uses_managed_daemon_as_the_only_database_writer() { + let _env_lock = HOME_ENV_LOCK.lock().await; + let (env, project) = IsolatedEnv::acquire().await; + let project = project.as_path(); + + git(project, &["init", "-b", "main"]); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn main_only() {}\n").unwrap(); + commit_all(project, "initial commit"); + + let main = TraceDecay::init(project).await.unwrap(); + main.index_all().await.unwrap(); + drop(main); + + git(project, &["checkout", "-b", "feature/daemon-owned"]); + fs::write( + project.join("src/daemon_owned.rs"), + "pub fn daemon_owned() {}\n", + ) + .unwrap(); + commit_all(project, "feature commit"); + + let daemon_socket = common::daemon_socket_path(env.home()); + let _daemon = common::spawn_tracedecay_daemon(env.home()); + let authority: Value = serde_json::from_slice( + &fs::read(env.home().join(".tracedecay/daemon-authority.json")) + .expect("read isolated daemon authority record"), + ) + .expect("parse isolated daemon authority record"); + let daemon_pid = authority["pid"] + .as_u64() + .expect("daemon authority record should contain its PID"); + + let branch_add = common::tracedecay_command_with_home(env.home()) + .current_dir(project) + .env("TRACEDECAY_DAEMON_SOCKET", &daemon_socket) + .args(["branch", "add", "feature/daemon-owned", "--path"]) + .arg(project) + .output() + .expect("tracedecay branch add through the isolated daemon"); + assert!( + branch_add.status.success(), + "daemon-routed branch add should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&branch_add.stdout), + String::from_utf8_lossy(&branch_add.stderr) + ); + let branch_add_text = format!( + "{}\n{}", + String::from_utf8_lossy(&branch_add.stdout), + String::from_utf8_lossy(&branch_add.stderr) + ); + assert!( + !branch_add_text.contains("database access is restricted to the elected managed daemon"), + "the CLI must proxy branch add to the daemon instead of attempting local database authority:\n{branch_add_text}" + ); + + let meta = load_branch_meta(&project_data_dir(project)).expect("load branch metadata"); + let branch_entry = meta + .branches + .get("feature/daemon-owned") + .expect("daemon branch add should record the feature branch"); + assert_eq!(branch_entry.parent.as_deref(), Some("main")); + let branch_db = project_data_dir(project).join(&branch_entry.db_file); + assert!( + branch_db.is_file(), + "daemon branch add should create the recorded branch store at {}", + branch_db.display() + ); + + let project_arg = project.to_string_lossy().to_string(); + let runtime_output = common::tracedecay_command_with_home(env.home()) + .current_dir(project) + .env("TRACEDECAY_DAEMON_SOCKET", &daemon_socket) + .args([ + "tool", + "--project", + &project_arg, + "runtime", + "--json", + "--format", + "json", + ]) + .output() + .expect("daemon-routed runtime inspection"); + assert!( + runtime_output.status.success(), + "daemon-routed runtime inspection should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&runtime_output.stdout), + String::from_utf8_lossy(&runtime_output.stderr) + ); + let runtime_tool_result: Value = + serde_json::from_slice(&runtime_output.stdout).expect("runtime tool result JSON"); + let runtime: Value = runtime_tool_result["content"] + .as_array() + .expect("runtime tool result content") + .iter() + .filter_map(|item| item["text"].as_str()) + .find_map(|text| serde_json::from_str(text).ok()) + .unwrap_or_else(|| { + panic!("runtime tool result should contain a JSON payload: {runtime_tool_result}") + }); + assert_eq!( + runtime["database"]["db_path"].as_str(), + Some(branch_db.to_string_lossy().as_ref()), + "the daemon should be serving the branch store it created" + ); + let writer_owner = &runtime["database"]["writer_owner"]; + assert_eq!( + writer_owner["state"].as_str(), + Some("active"), + "the branch store must have an active writer owner: {writer_owner}" + ); + assert_eq!( + writer_owner["pid"].as_u64(), + Some(daemon_pid), + "the active exclusive writer must be the daemon recorded in daemon-authority.json: {writer_owner}" + ); +} diff --git a/tests/storage_suite/multi_connection_test.rs b/tests/storage_suite/multi_connection_test.rs index 5c6b173cb..0b4913315 100644 --- a/tests/storage_suite/multi_connection_test.rs +++ b/tests/storage_suite/multi_connection_test.rs @@ -1,780 +1,20 @@ #![cfg(unix)] -use std::collections::BTreeMap; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; use std::sync::{Arc, Barrier}; use std::time::{Duration, Instant}; -use serde_json::{Value, json}; +use serde_json::json; use tempfile::TempDir; use tracedecay::db::{Database, DatabaseAuthority, SQLITE_UNSAFE_FAST_ENV}; -use tracedecay::storage::{default_profile_project_id, profile_sharded_data_root}; use crate::common; -const PROCESS_TIMEOUT: Duration = Duration::from_secs(20); -const CLIENT_COUNT: usize = 12; -const CONCURRENT_CLIENTS_PER_PATH: usize = 4; +mod harness; +use harness::*; -struct ChildGuard(Child); - -impl ChildGuard { - fn new(child: Child) -> Self { - Self(child) - } -} - -impl std::ops::Deref for ChildGuard { - type Target = Child; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::ops::DerefMut for ChildGuard { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Drop for ChildGuard { - fn drop(&mut self) { - stop_child(&mut self.0); - } -} - -fn init_project(home: &Path, project: &Path, socket_path: &Path) -> PathBuf { - std::fs::create_dir_all(project.join("src")).expect("create fixture source directory"); - std::fs::write( - project.join("src/lib.rs"), - "pub fn broker_fixture() -> u32 { 42 }\n", - ) - .expect("write fixture source"); - - let output = common::tracedecay_command_with_home(home) - .env("TRACEDECAY_DAEMON_SOCKET", socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .arg("init") - .current_dir(project) - .output() - .expect("tracedecay init should run"); - assert_command_success("tracedecay init", &output); - - let profile_root = home.join(".tracedecay"); - let data_root = profile_sharded_data_root(&profile_root, &default_profile_project_id(project)); - data_root.join(tracedecay::config::db_filename(&data_root)) -} - -fn assert_command_success(label: &str, output: &std::process::Output) { - assert!( - output.status.success(), - "{label} failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -fn wait_for_socket(socket_path: &Path, child: &mut Child) { - let deadline = Instant::now() + PROCESS_TIMEOUT; - loop { - if std::os::unix::net::UnixStream::connect(socket_path).is_ok() { - return; - } - if let Some(status) = child.try_wait().expect("read daemon status") { - panic!("daemon exited before opening socket: {status}"); - } - assert!( - Instant::now() < deadline, - "daemon socket did not become ready" - ); - std::thread::sleep(Duration::from_millis(25)); - } -} - -fn spawn_daemon(home: &Path, socket_path: &Path) -> ChildGuard { - let mut child = ChildGuard::new( - common::tracedecay_command_with_home(home) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .args(["daemon", "run", "--socket"]) - .arg(socket_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn daemon"), - ); - wait_for_socket(socket_path, &mut child); - child -} - -fn stop_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - -fn wait_for_exit(child: &mut Child) -> Option { - let deadline = Instant::now() + PROCESS_TIMEOUT; - loop { - if let Some(status) = child.try_wait().expect("read child status") { - return Some(status); - } - if Instant::now() >= deadline { - return None; - } - std::thread::sleep(Duration::from_millis(25)); - } -} - -struct McpProxy { - child: ChildGuard, - stdin: ChildStdin, - stdout: BufReader, -} - -impl McpProxy { - fn spawn(home: &Path, project: &Path, socket_path: &Path, ordinal: usize) -> Self { - let mut child = ChildGuard::new( - common::tracedecay_command_with_home(home) - .env("TRACEDECAY_DAEMON_SOCKET", socket_path) - .env( - "TRACEDECAY_CLIENT_INSTANCE_ID", - format!("broker-test-{ordinal}"), - ) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .args(["serve", "--path"]) - .arg(project) - .current_dir(project) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn MCP proxy"), - ); - let stdin = child.stdin.take().expect("proxy stdin"); - let stdout = BufReader::new(child.stdout.take().expect("proxy stdout")); - let mut proxy = Self { - child, - stdin, - stdout, - }; - proxy.request(1, "initialize", json!({})); - proxy.request( - 2, - "tools/call", - json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), - ); - proxy - } - - fn request(&mut self, id: u64, method: &str, params: Value) -> Value { - writeln!( - self.stdin, - "{}", - json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) - ) - .expect("write MCP request"); - self.stdin.flush().expect("flush MCP request"); - - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let proxy_pid = self.child.id(); - let watchdog = std::thread::spawn(move || { - if matches!( - done_rx.recv_timeout(PROCESS_TIMEOUT), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) - ) { - let _ = Command::new("kill") - .args(["-KILL", &proxy_pid.to_string()]) - .status(); - } - }); - let mut matched = None; - for _ in 0..32 { - let mut line = String::new(); - let bytes = self.stdout.read_line(&mut line).expect("read MCP response"); - assert_ne!(bytes, 0, "MCP proxy exited before response {id}"); - let response: Value = serde_json::from_str(&line).expect("valid MCP response"); - if response.get("id").and_then(Value::as_u64) == Some(id) { - matched = Some(response); - break; - } - } - let _ = done_tx.send(()); - watchdog.join().expect("MCP response watchdog panicked"); - let response = matched.unwrap_or_else(|| { - panic!("MCP response {id} was hidden behind too many notifications") - }); - assert!( - response.get("error").is_none(), - "MCP request {id} failed: {response}" - ); - response - } -} - -#[cfg(target_os = "linux")] -fn sqlite_handles(pid: u32, profile_root: &Path) -> Vec { - let mut handles = Vec::new(); - let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { - return handles; - }; - for entry in entries.flatten() { - let Ok(target) = std::fs::read_link(entry.path()) else { - continue; - }; - let rendered = target.to_string_lossy(); - if target.starts_with(profile_root) - && (rendered.contains(".db") - || rendered.ends_with("-wal") - || rendered.ends_with("-shm")) - { - handles.push(target); - } - } - handles.sort(); - handles -} - -fn file_identity(path: &Path) -> Option<(u64, u64)> { - std::fs::metadata(path) - .ok() - .map(|metadata| (metadata.dev(), metadata.ino())) -} - -fn storage_snapshot(db_path: &Path) -> BTreeMap> { - let mut paths = vec![db_path.to_path_buf()]; - for suffix in ["-wal", "-shm"] { - paths.push(PathBuf::from(format!("{}{suffix}", db_path.display()))); - } - paths - .into_iter() - .filter_map(|path| std::fs::read(&path).ok().map(|bytes| (path, bytes))) - .collect() -} - -fn daemon_authority_record(home: &Path) -> Value { - serde_json::from_slice( - &std::fs::read(home.join(".tracedecay/daemon-authority.json")) - .expect("read daemon authority record"), - ) - .expect("parse daemon authority record") -} - -fn tool_status(home: &Path, project: &Path, socket_path: &Path) -> std::process::Output { - let project_arg = project.to_string_lossy().to_string(); - common::tracedecay_command_with_home(home) - .env("TRACEDECAY_DAEMON_SOCKET", socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .current_dir(project) - .args([ - "tool", - "--project", - &project_arg, - "status", - "--json", - "--format", - "json", - ]) - .output() - .expect("run tool status") -} - -#[test] -fn twelve_mcp_cli_and_hook_clients_share_one_daemon_sqlite_owner() { - let home = TempDir::new().expect("temp home"); - let project = TempDir::new().expect("temp project"); - let home_path = common::canonical_existing_path(home.path()); - let project_path = common::canonical_existing_path(project.path()); - let profile_root = home_path.join(".tracedecay"); - let socket_path = common::daemon_socket_path(&home_path); - let mut daemon = spawn_daemon(&home_path, &socket_path); - let db_path = init_project(&home_path, &project_path, &socket_path); - - let mut clients = (0..CLIENT_COUNT) - .map(|ordinal| McpProxy::spawn(&home_path, &project_path, &socket_path, ordinal)) - .collect::>(); - - #[cfg(target_os = "linux")] - { - for client in &clients { - assert_eq!( - sqlite_handles(client.child.id(), &profile_root), - Vec::::new(), - "MCP proxy must not own any profile SQLite handle" - ); - } - let daemon_handles = sqlite_handles(daemon.id(), &profile_root); - assert!( - daemon_handles.iter().any(|path| path == &db_path), - "daemon must own the graph DB; handles: {daemon_handles:?}" - ); - } - let authority_before = daemon_authority_record(&home_path); - assert_eq!( - authority_before["pid"], - daemon.id(), - "profile authority must name the daemon" - ); - assert_eq!( - authority_before["profile_root"].as_str(), - profile_root.to_str(), - "profile authority must use the canonical profile root" - ); - assert!( - authority_before["epoch"] - .as_u64() - .is_some_and(|epoch| epoch > 0), - "profile authority must publish a nonzero epoch" - ); - - let db_identity = file_identity(&db_path).expect("graph DB identity"); - let hook_event = json!({ - "hook_event_name": "afterFileEdit", - "file_path": project_path.join("src/lib.rs"), - "workspace_roots": [&project_path], - }) - .to_string(); - std::thread::scope(|scope| { - let start = Arc::new(Barrier::new(3 * CONCURRENT_CLIENTS_PER_PATH + 1)); - let mut requests = Vec::new(); - for (ordinal, client) in clients - .iter_mut() - .take(CONCURRENT_CLIENTS_PER_PATH) - .enumerate() - { - let start = Arc::clone(&start); - requests.push(scope.spawn(move || { - start.wait(); - client.request( - 100 + ordinal as u64, - "tools/call", - json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), - ); - })); - } - for _ in 0..CONCURRENT_CLIENTS_PER_PATH { - let home_path = &home_path; - let project_path = &project_path; - let socket_path = &socket_path; - let start = Arc::clone(&start); - requests.push(scope.spawn(move || { - start.wait(); - let project_arg = project_path.to_string_lossy().to_string(); - let mut tool = ChildGuard::new( - common::tracedecay_command_with_home(home_path) - .env("TRACEDECAY_DAEMON_SOCKET", socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .current_dir(project_path) - .args([ - "tool", - "--project", - &project_arg, - "status", - "--json", - "--format", - "json", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn brokered tool status"), - ); - let status = wait_for_exit(&mut tool) - .unwrap_or_else(|| panic!("tool client exceeded {PROCESS_TIMEOUT:?}")); - assert!(status.success(), "brokered tool status failed"); - })); - } - for _ in 0..CONCURRENT_CLIENTS_PER_PATH { - let home_path = &home_path; - let project_path = &project_path; - let socket_path = &socket_path; - let hook_event = &hook_event; - let start = Arc::clone(&start); - requests.push(scope.spawn(move || { - start.wait(); - let mut hook = ChildGuard::new( - common::tracedecay_command_with_home(home_path) - .env("TRACEDECAY_DAEMON_SOCKET", socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .arg("hook-cursor-after-file-edit") - .current_dir(project_path) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn hook client"), - ); - let mut stdin = hook.stdin.take().expect("hook stdin"); - stdin - .write_all(hook_event.as_bytes()) - .expect("write hook event"); - drop(stdin); - let status = wait_for_exit(&mut hook) - .unwrap_or_else(|| panic!("hook client exceeded {PROCESS_TIMEOUT:?}")); - assert!(status.success(), "hook client failed"); - })); - } - start.wait(); - for request in requests { - request.join().expect("concurrent broker client panicked"); - } - }); - - let doctor = common::tracedecay_command_with_home(&home_path) - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .arg("doctor") - .current_dir(&project_path) - .output() - .expect("run doctor probe"); - assert_command_success("brokered doctor", &doctor); - assert_eq!( - file_identity(&db_path), - Some(db_identity), - "client probes replaced graph DB inode" - ); - assert_eq!( - daemon_authority_record(&home_path), - authority_before, - "concurrent clients changed daemon owner or epoch" - ); - #[cfg(target_os = "linux")] - for client in &clients { - assert_eq!( - sqlite_handles(client.child.id(), &profile_root), - Vec::::new(), - "MCP proxy retained a profile SQLite handle after its request" - ); - } - stop_child(&mut daemon); -} - -#[test] -fn split_brain_is_rejected_and_unavailable_daemon_fails_closed_until_restart() { - let home = TempDir::new().expect("temp home"); - let project = TempDir::new().expect("temp project"); - let home_path = common::canonical_existing_path(home.path()); - let project_path = common::canonical_existing_path(project.path()); - let socket_path = common::daemon_socket_path(&home_path); - let mut owner = spawn_daemon(&home_path, &socket_path); - let db_path = init_project(&home_path, &project_path, &socket_path); - assert_command_success( - "owner daemon status", - &tool_status(&home_path, &project_path, &socket_path), - ); - - let socket_before = file_identity(&socket_path).expect("owner socket identity"); - let authority_before = daemon_authority_record(&home_path); - let mut contender = ChildGuard::new( - common::tracedecay_command_with_home(&home_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .args(["daemon", "run", "--socket"]) - .arg(&socket_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn contender daemon"), - ); - let contender_status = wait_for_exit(&mut contender).unwrap_or_else(|| { - stop_child(&mut contender); - panic!("second daemon remained alive and created split-brain ownership") - }); - assert!( - !contender_status.success(), - "second daemon must be rejected" - ); - assert_eq!( - file_identity(&socket_path), - Some(socket_before), - "contender replaced owner socket" - ); - assert!( - owner.try_wait().expect("owner status").is_none(), - "owner daemon exited" - ); - assert_eq!( - daemon_authority_record(&home_path), - authority_before, - "rejected contender changed daemon authority generation" - ); - - stop_child(&mut owner); - let before = storage_snapshot(&db_path); - for (label, mut command) in [ - ("tool", { - let project_arg = project_path.to_string_lossy().to_string(); - let mut command = common::tracedecay_command_with_home(&home_path); - command.env("TRACEDECAY_DAEMON_SOCKET", &socket_path).args([ - "tool", - "--project", - &project_arg, - "status", - "--json", - ]); - command - }), - ("sync", { - let mut command = common::tracedecay_command_with_home(&home_path); - command - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .arg("sync"); - command - }), - ("doctor", { - let mut command = common::tracedecay_command_with_home(&home_path); - command - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .arg("doctor"); - command - }), - ] { - command - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .current_dir(&project_path) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let output = command - .output() - .unwrap_or_else(|error| panic!("run {label}: {error}")); - assert!( - !output.status.success(), - "{label} must fail closed while daemon is unavailable\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert_eq!( - storage_snapshot(&db_path), - before, - "{label} used a local SQLite fallback" - ); - } - let hook_event = json!({ - "hook_event_name": "afterFileEdit", - "file_path": project_path.join("src/lib.rs"), - "workspace_roots": [&project_path], - }) - .to_string(); - let mut hook = ChildGuard::new( - common::tracedecay_command_with_home(&home_path) - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .arg("hook-cursor-after-file-edit") - .current_dir(&project_path) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn unavailable-daemon hook client"), - ); - let mut stdin = hook.stdin.take().expect("hook stdin"); - stdin - .write_all(hook_event.as_bytes()) - .expect("write hook event"); - drop(stdin); - assert!( - wait_for_exit(&mut hook).is_some(), - "unavailable-daemon hook client exceeded {PROCESS_TIMEOUT:?}" - ); - assert_eq!( - storage_snapshot(&db_path), - before, - "hook used a local SQLite fallback while daemon was unavailable" - ); - - let mut restarted = spawn_daemon(&home_path, &socket_path); - assert_command_success( - "restarted daemon status", - &tool_status(&home_path, &project_path, &socket_path), - ); - let authority_after = daemon_authority_record(&home_path); - assert_eq!(authority_after["pid"], restarted.id()); - assert_ne!( - authority_after["process_run_id"], authority_before["process_run_id"], - "restart must publish a new process identity" - ); - assert_ne!( - authority_after["auth_token"], authority_before["auth_token"], - "restart must invalidate the prior generation's authentication token" - ); - assert!( - authority_after["epoch"].as_u64() > authority_before["epoch"].as_u64(), - "restart must advance daemon authority epoch" - ); - stop_child(&mut restarted); -} - -#[tokio::test] -#[ignore] -async fn killed_writer_fixture() { - if std::env::var("TRACEDECAY_BROKER_FIXTURE").as_deref() != Ok("killed-writer") { - return; - } - let db_path = PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_DB").expect("fixture DB")); - let dirty_path = PathBuf::from( - std::env::var_os("TRACEDECAY_FIXTURE_DIRTY").expect("fixture dirty sentinel"), - ); - let ready_path = - PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_READY").expect("fixture ready path")); - let authority = DatabaseAuthority::acquire_test(&db_path, "killed writer fixture") - .expect("acquire fixture database authority"); - let (db, _) = Database::open(&db_path, &authority) - .await - .expect("open fixture graph DB"); - db.conn() - .execute_batch("PRAGMA wal_autocheckpoint = 0") - .await - .expect("disable WAL autocheckpoint"); - db.insert_nodes(&[common::sample_node( - "broker-recovery-node", - "broker_recovery_node", - "src/recovery.rs", - )]) - .await - .expect("commit recovery node"); - std::fs::write( - &dirty_path, - format!("pid={}\nversion=test", std::process::id()), - ) - .expect("write dirty sentinel"); - std::fs::write(&ready_path, "ready").expect("publish fixture readiness"); - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - } -} - -#[test] -fn daemon_recovers_killed_writer_dirty_wal_before_serving_clients() { - let home = TempDir::new().expect("temp home"); - let project = TempDir::new().expect("temp project"); - let fixture = TempDir::new().expect("temp fixture state"); - let home_path = common::canonical_existing_path(home.path()); - let project_path = common::canonical_existing_path(project.path()); - let socket_path = common::daemon_socket_path(&home_path); - let mut initializer = spawn_daemon(&home_path, &socket_path); - let db_path = init_project(&home_path, &project_path, &socket_path); - stop_child(&mut initializer); - let data_root = db_path.parent().expect("graph data root"); - let dirty_path = data_root.join("dirty"); - let ready_path = fixture.path().join("ready"); - - let mut writer = ChildGuard::new( - Command::new(std::env::current_exe().expect("current test binary")) - .args([ - "--ignored", - "--exact", - "multi_connection_test::killed_writer_fixture", - "--nocapture", - ]) - .env("TRACEDECAY_BROKER_FIXTURE", "killed-writer") - .env("TRACEDECAY_FIXTURE_DB", &db_path) - .env("TRACEDECAY_FIXTURE_DIRTY", &dirty_path) - .env("TRACEDECAY_FIXTURE_READY", &ready_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn writer fixture"), - ); - let deadline = Instant::now() + PROCESS_TIMEOUT; - while !ready_path.exists() { - assert!( - writer.try_wait().expect("writer status").is_none(), - "writer exited early" - ); - assert!( - Instant::now() < deadline, - "writer fixture did not become ready" - ); - std::thread::sleep(Duration::from_millis(25)); - } - assert!( - PathBuf::from(format!("{}-wal", db_path.display())).exists(), - "writer fixture must leave committed WAL frames" - ); - stop_child(&mut writer); - - let wal_path = PathBuf::from(format!("{}-wal", db_path.display())); - let shm_path = PathBuf::from(format!("{}-shm", db_path.display())); - let committed_family = storage_snapshot(&db_path); - for path in [&db_path, &wal_path, &shm_path] { - assert!( - committed_family.contains_key(path), - "killed writer must leave SQLite family member '{}'", - path.display() - ); - } - let mut corrupted = committed_family[&db_path].clone(); - corrupted[..16].copy_from_slice(b"not-a-sqlite-db!"); - std::fs::write(&db_path, corrupted).expect("corrupt fixture database header"); - let failed_family = storage_snapshot(&db_path); - let failed_identities = [&db_path, &wal_path, &shm_path].map(|path| { - ( - path.to_path_buf(), - file_identity(path).expect("SQLite family identity before failed recovery"), - ) - }); - - let mut daemon = spawn_daemon(&home_path, &socket_path); - let project_arg = project_path.to_string_lossy().to_string(); - let search_recovered_node = || { - common::tracedecay_command_with_home(&home_path) - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .env_remove(SQLITE_UNSAFE_FAST_ENV) - .current_dir(&project_path) - .args([ - "tool", - "--project", - &project_arg, - "search", - "broker_recovery_node", - "--json", - ]) - .output() - .expect("search recovered node through daemon") - }; - let failed = search_recovered_node(); - assert!( - !failed.status.success(), - "daemon must fail closed when killed-writer recovery cannot validate the database" - ); - assert_eq!( - storage_snapshot(&db_path), - failed_family, - "failed recovery changed DB, WAL, or SHM" - ); - for (path, identity) in failed_identities { - assert_eq!( - file_identity(&path), - Some(identity), - "failed recovery replaced SQLite family member '{}'", - path.display() - ); - } - assert!( - dirty_path.exists(), - "failed recovery must preserve the dirty sentinel" - ); - stop_child(&mut daemon); - - std::fs::write(&db_path, &committed_family[&db_path]) - .expect("restore fixture database for successful WAL recovery"); - daemon = spawn_daemon(&home_path, &socket_path); - let output = search_recovered_node(); - assert_command_success("daemon WAL recovery search", &output); - assert!( - String::from_utf8_lossy(&output.stdout).contains("broker_recovery_node"), - "committed WAL row was lost during daemon recovery" - ); - assert!( - !dirty_path.exists(), - "daemon must clear dirty sentinel after recovery" - ); - stop_child(&mut daemon); -} +include!("multi_connection_test/ownership.rs"); +include!("multi_connection_test/fail_closed.rs"); +include!("multi_connection_test/recovery.rs"); diff --git a/tests/storage_suite/multi_connection_test/fail_closed.rs b/tests/storage_suite/multi_connection_test/fail_closed.rs new file mode 100644 index 000000000..118a309fe --- /dev/null +++ b/tests/storage_suite/multi_connection_test/fail_closed.rs @@ -0,0 +1,156 @@ +#[test] +fn split_brain_is_rejected_and_unavailable_daemon_fails_closed_until_restart() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let socket_path = common::daemon_socket_path(&home_path); + let mut owner = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + assert_command_success( + "owner daemon status", + &tool_status(&home_path, &project_path, &socket_path), + ); + + let socket_before = file_identity(&socket_path).expect("owner socket identity"); + let authority_before = daemon_authority_record(&home_path); + let mut contender = ChildGuard::new( + common::tracedecay_command_with_home(&home_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["daemon", "run", "--socket"]) + .arg(&socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn contender daemon"), + ); + let contender_status = wait_for_exit(&mut contender).unwrap_or_else(|| { + stop_child(&mut contender); + panic!("second daemon remained alive and created split-brain ownership") + }); + assert!( + !contender_status.success(), + "second daemon must be rejected" + ); + assert_eq!( + file_identity(&socket_path), + Some(socket_before), + "contender replaced owner socket" + ); + assert!( + owner.try_wait().expect("owner status").is_none(), + "owner daemon exited" + ); + assert_eq!( + daemon_authority_record(&home_path), + authority_before, + "rejected contender changed daemon authority generation" + ); + + stop_child(&mut owner); + install_unavailable_socket_sentinel(&socket_path); + let before = storage_snapshot(&db_path); + for (label, mut command) in [ + ("tool", { + let project_arg = project_path.to_string_lossy().to_string(); + let mut command = common::tracedecay_command_with_home(&home_path); + command.env("TRACEDECAY_DAEMON_SOCKET", &socket_path).args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + ]); + command + }), + ("sync", { + let mut command = common::tracedecay_command_with_home(&home_path); + command + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .arg("sync"); + command + }), + ("doctor", { + let mut command = common::tracedecay_command_with_home(&home_path); + command + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .arg("doctor"); + command + }), + ] { + command + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(&project_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = command + .output() + .unwrap_or_else(|error| panic!("run {label}: {error}")); + assert!( + !output.status.success(), + "{label} must fail closed while daemon is unavailable\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!( + storage_snapshot(&db_path), + before, + "{label} used a local SQLite fallback" + ); + } + let hook_event = json!({ + "hook_event_name": "afterFileEdit", + "file_path": project_path.join("src/lib.rs"), + "workspace_roots": [&project_path], + }) + .to_string(); + let mut hook = ChildGuard::new( + common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("hook-cursor-after-file-edit") + .current_dir(&project_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn unavailable-daemon hook client"), + ); + let mut stdin = hook.stdin.take().expect("hook stdin"); + stdin + .write_all(hook_event.as_bytes()) + .expect("write hook event"); + drop(stdin); + assert!( + wait_for_exit(&mut hook).is_some(), + "unavailable-daemon hook client exceeded {PROCESS_TIMEOUT:?}" + ); + assert_eq!( + storage_snapshot(&db_path), + before, + "hook used a local SQLite fallback while daemon was unavailable" + ); + + let mut restarted = spawn_daemon(&home_path, &socket_path); + assert_command_success( + "restarted daemon status", + &tool_status(&home_path, &project_path, &socket_path), + ); + let authority_after = daemon_authority_record(&home_path); + assert_eq!(authority_after["pid"], restarted.id()); + assert_ne!( + authority_after["process_run_id"], authority_before["process_run_id"], + "restart must publish a new process identity" + ); + assert_ne!( + authority_after["auth_token"], authority_before["auth_token"], + "restart must invalidate the prior generation's authentication token" + ); + assert!( + authority_after["epoch"].as_u64() > authority_before["epoch"].as_u64(), + "restart must advance daemon authority epoch" + ); + stop_child(&mut restarted); +} diff --git a/tests/storage_suite/multi_connection_test/harness.rs b/tests/storage_suite/multi_connection_test/harness.rs new file mode 100644 index 000000000..328cc394f --- /dev/null +++ b/tests/storage_suite/multi_connection_test/harness.rs @@ -0,0 +1,393 @@ +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Stdio}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tracedecay::db::SQLITE_UNSAFE_FAST_ENV; +use tracedecay::storage::{default_profile_project_id, profile_sharded_data_root}; + +use crate::common; + +pub(super) const PROCESS_TIMEOUT: Duration = Duration::from_secs(20); +pub(super) const CLIENT_COUNT: usize = 12; +pub(super) const CONCURRENT_CLIENTS_PER_PATH: usize = 4; + +pub(super) struct ChildGuard(Child); + +impl ChildGuard { + pub(super) fn new(child: Child) -> Self { + Self(child) + } +} + +impl std::ops::Deref for ChildGuard { + type Target = Child; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ChildGuard { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + stop_child(&mut self.0); + } +} + +pub(super) fn init_project(home: &Path, project: &Path, socket_path: &Path) -> PathBuf { + std::fs::create_dir_all(project.join("src")).expect("create fixture source directory"); + std::fs::write( + project.join("src/lib.rs"), + "pub fn broker_fixture() -> u32 { 42 }\n", + ) + .expect("write fixture source"); + + let output = common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("init") + .current_dir(project) + .output() + .expect("tracedecay init should run"); + assert_command_success("tracedecay init", &output); + + let profile_root = home.join(".tracedecay"); + let data_root = profile_sharded_data_root(&profile_root, &default_profile_project_id(project)); + data_root.join(tracedecay::config::db_filename(&data_root)) +} + +pub(super) fn assert_command_success(label: &str, output: &std::process::Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn wait_for_socket(socket_path: &Path, child: &mut Child) { + let deadline = Instant::now() + PROCESS_TIMEOUT; + loop { + if std::os::unix::net::UnixStream::connect(socket_path).is_ok() { + return; + } + if let Some(status) = child.try_wait().expect("read daemon status") { + panic!("daemon exited before opening socket: {status}"); + } + assert!( + Instant::now() < deadline, + "daemon socket did not become ready" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +pub(super) fn spawn_daemon(home: &Path, socket_path: &Path) -> ChildGuard { + let mut child = ChildGuard::new( + common::tracedecay_command_with_home(home) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["daemon", "run", "--socket"]) + .arg(socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn daemon"), + ); + wait_for_socket(socket_path, &mut child); + child +} + +pub(super) fn stop_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// Replaces an unavailable daemon socket with a non-retryable endpoint. +/// +/// Clients deliberately wait through an absent or refused socket to tolerate +/// a daemon restart. This test covers fail-closed behavior rather than that +/// restart grace, so it uses a self-referential symlink while preserving the +/// authoritative daemon record. The resulting `ELOOP` makes every client prove +/// it cannot fall back to a local writable store without serially consuming the +/// restart window. +pub(super) fn install_unavailable_socket_sentinel(socket_path: &Path) { + match std::fs::remove_file(socket_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!( + "remove stale daemon socket '{}': {error}", + socket_path.display() + ), + } + std::os::unix::fs::symlink( + socket_path + .file_name() + .expect("daemon socket path must have a filename"), + socket_path, + ) + .unwrap_or_else(|error| { + panic!( + "write daemon socket sentinel '{}': {error}", + socket_path.display() + ) + }); + let error = std::os::unix::net::UnixStream::connect(socket_path) + .expect_err("daemon socket sentinel must not accept connections"); + assert!( + !matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ), + "daemon socket sentinel must fail without restart grace: {error}" + ); +} + +pub(super) fn wait_for_exit(child: &mut Child) -> Option { + let deadline = Instant::now() + PROCESS_TIMEOUT; + loop { + if let Some(status) = child.try_wait().expect("read child status") { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +enum ProxyOutput { + Message(Value), + InvalidJson { line: String, error: String }, + ReadError(String), + Eof, +} + +fn read_proxy_stdout(stdout: ChildStdout, output_tx: Sender) { + let mut stdout = BufReader::new(stdout); + loop { + let mut line = String::new(); + match stdout.read_line(&mut line) { + Ok(0) => { + let _ = output_tx.send(ProxyOutput::Eof); + return; + } + Ok(_) => { + let output = match serde_json::from_str(&line) { + Ok(message) => ProxyOutput::Message(message), + Err(error) => ProxyOutput::InvalidJson { + line, + error: error.to_string(), + }, + }; + if output_tx.send(output).is_err() { + return; + } + } + Err(error) => { + let _ = output_tx.send(ProxyOutput::ReadError(error.to_string())); + return; + } + } + } +} + +pub(super) struct McpProxy { + child: ChildGuard, + stdin: ChildStdin, + output_rx: Receiver, + pending: BTreeMap, + stdout_reader: Option>, +} + +impl McpProxy { + pub(super) fn spawn(home: &Path, project: &Path, socket_path: &Path, ordinal: usize) -> Self { + let mut child = ChildGuard::new( + common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env( + "TRACEDECAY_CLIENT_INSTANCE_ID", + format!("broker-test-{ordinal}"), + ) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .args(["serve", "--path"]) + .arg(project) + .current_dir(project) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn MCP proxy"), + ); + let stdin = child.stdin.take().expect("proxy stdin"); + let stdout = child.stdout.take().expect("proxy stdout"); + let (output_tx, output_rx) = std::sync::mpsc::channel(); + let stdout_reader = std::thread::spawn(move || read_proxy_stdout(stdout, output_tx)); + let mut proxy = Self { + child, + stdin, + output_rx, + pending: BTreeMap::new(), + stdout_reader: Some(stdout_reader), + }; + proxy.request(1, "initialize", json!({})); + proxy.request( + 2, + "tools/call", + json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), + ); + proxy + } + + pub(super) fn pid(&self) -> u32 { + self.child.id() + } + + pub(super) fn request(&mut self, id: u64, method: &str, params: Value) -> Value { + writeln!( + self.stdin, + "{}", + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + ) + .expect("write MCP request"); + self.stdin.flush().expect("flush MCP request"); + + if let Some(response) = self.pending.remove(&id) { + return assert_successful_response(id, response); + } + + let deadline = Instant::now() + PROCESS_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.kill_for_timeout(id); + } + match self.output_rx.recv_timeout(remaining) { + Ok(ProxyOutput::Message(response)) => { + let Some(response_id) = response.get("id").and_then(Value::as_u64) else { + continue; + }; + if response_id == id { + return assert_successful_response(id, response); + } + self.pending.insert(response_id, response); + } + Ok(ProxyOutput::InvalidJson { line, error }) => { + panic!("invalid MCP response while waiting for {id}: {error}\nline: {line}") + } + Ok(ProxyOutput::ReadError(error)) => { + panic!("failed to read MCP response {id}: {error}") + } + Ok(ProxyOutput::Eof) => { + let status = self.child.try_wait().expect("read MCP proxy status"); + panic!("MCP proxy exited before response {id}; status: {status:?}") + } + Err(RecvTimeoutError::Timeout) => self.kill_for_timeout(id), + Err(RecvTimeoutError::Disconnected) => { + let status = self.child.try_wait().expect("read MCP proxy status"); + panic!("MCP stdout reader stopped before response {id}; status: {status:?}") + } + } + } + } + + fn kill_for_timeout(&mut self, id: u64) -> ! { + stop_child(&mut self.child); + panic!("MCP request {id} exceeded {PROCESS_TIMEOUT:?}") + } +} + +impl Drop for McpProxy { + fn drop(&mut self) { + stop_child(&mut self.child); + if let Some(stdout_reader) = self.stdout_reader.take() { + let _ = stdout_reader.join(); + } + } +} + +fn assert_successful_response(id: u64, response: Value) -> Value { + assert!( + response.get("error").is_none(), + "MCP request {id} failed: {response}" + ); + response +} + +#[cfg(target_os = "linux")] +pub(super) fn sqlite_handles(pid: u32, profile_root: &Path) -> Vec { + let mut handles = Vec::new(); + let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { + return handles; + }; + for entry in entries.flatten() { + let Ok(target) = std::fs::read_link(entry.path()) else { + continue; + }; + let rendered = target.to_string_lossy(); + if target.starts_with(profile_root) + && (rendered.contains(".db") + || rendered.ends_with("-wal") + || rendered.ends_with("-shm")) + { + handles.push(target); + } + } + handles.sort(); + handles +} + +pub(super) fn file_identity(path: &Path) -> Option<(u64, u64)> { + std::fs::metadata(path) + .ok() + .map(|metadata| (metadata.dev(), metadata.ino())) +} + +pub(super) fn storage_snapshot(db_path: &Path) -> BTreeMap> { + let mut paths = vec![db_path.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + paths.push(PathBuf::from(format!("{}{suffix}", db_path.display()))); + } + paths + .into_iter() + .filter_map(|path| std::fs::read(&path).ok().map(|bytes| (path, bytes))) + .collect() +} + +pub(super) fn daemon_authority_record(home: &Path) -> Value { + serde_json::from_slice( + &std::fs::read(home.join(".tracedecay/daemon-authority.json")) + .expect("read daemon authority record"), + ) + .expect("parse daemon authority record") +} + +pub(super) fn tool_status(home: &Path, project: &Path, socket_path: &Path) -> std::process::Output { + let project_arg = project.to_string_lossy().to_string(); + common::tracedecay_command_with_home(home) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(project) + .args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + "--format", + "json", + ]) + .output() + .expect("run tool status") +} diff --git a/tests/storage_suite/multi_connection_test/ownership.rs b/tests/storage_suite/multi_connection_test/ownership.rs new file mode 100644 index 000000000..918df2bc8 --- /dev/null +++ b/tests/storage_suite/multi_connection_test/ownership.rs @@ -0,0 +1,170 @@ +#[test] +fn twelve_mcp_cli_and_hook_clients_share_one_daemon_sqlite_owner() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let profile_root = home_path.join(".tracedecay"); + let socket_path = common::daemon_socket_path(&home_path); + let mut daemon = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + + let mut clients = (0..CLIENT_COUNT) + .map(|ordinal| McpProxy::spawn(&home_path, &project_path, &socket_path, ordinal)) + .collect::>(); + + #[cfg(target_os = "linux")] + { + for client in &clients { + assert_eq!( + sqlite_handles(client.pid(), &profile_root), + Vec::::new(), + "MCP proxy must not own any profile SQLite handle" + ); + } + let daemon_handles = sqlite_handles(daemon.id(), &profile_root); + assert!( + daemon_handles.iter().any(|path| path == &db_path), + "daemon must own the graph DB; handles: {daemon_handles:?}" + ); + } + let authority_before = daemon_authority_record(&home_path); + assert_eq!( + authority_before["pid"], + daemon.id(), + "profile authority must name the daemon" + ); + assert_eq!( + authority_before["profile_root"].as_str(), + profile_root.to_str(), + "profile authority must use the canonical profile root" + ); + assert!( + authority_before["epoch"] + .as_u64() + .is_some_and(|epoch| epoch > 0), + "profile authority must publish a nonzero epoch" + ); + + let db_identity = file_identity(&db_path).expect("graph DB identity"); + let hook_event = json!({ + "hook_event_name": "afterFileEdit", + "file_path": project_path.join("src/lib.rs"), + "workspace_roots": [&project_path], + }) + .to_string(); + std::thread::scope(|scope| { + let start = Arc::new(Barrier::new(3 * CONCURRENT_CLIENTS_PER_PATH + 1)); + let mut requests = Vec::new(); + for (ordinal, client) in clients + .iter_mut() + .take(CONCURRENT_CLIENTS_PER_PATH) + .enumerate() + { + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + client.request( + 100 + ordinal as u64, + "tools/call", + json!({"name": "tracedecay_status", "arguments": {"format": "json"}}), + ); + })); + } + for _ in 0..CONCURRENT_CLIENTS_PER_PATH { + let home_path = &home_path; + let project_path = &project_path; + let socket_path = &socket_path; + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + let project_arg = project_path.to_string_lossy().to_string(); + let mut tool = ChildGuard::new( + common::tracedecay_command_with_home(home_path) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(project_path) + .args([ + "tool", + "--project", + &project_arg, + "status", + "--json", + "--format", + "json", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn brokered tool status"), + ); + let status = wait_for_exit(&mut tool) + .unwrap_or_else(|| panic!("tool client exceeded {PROCESS_TIMEOUT:?}")); + assert!(status.success(), "brokered tool status failed"); + })); + } + for _ in 0..CONCURRENT_CLIENTS_PER_PATH { + let home_path = &home_path; + let project_path = &project_path; + let socket_path = &socket_path; + let hook_event = &hook_event; + let start = Arc::clone(&start); + requests.push(scope.spawn(move || { + start.wait(); + let mut hook = ChildGuard::new( + common::tracedecay_command_with_home(home_path) + .env("TRACEDECAY_DAEMON_SOCKET", socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("hook-cursor-after-file-edit") + .current_dir(project_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn hook client"), + ); + let mut stdin = hook.stdin.take().expect("hook stdin"); + stdin + .write_all(hook_event.as_bytes()) + .expect("write hook event"); + drop(stdin); + let status = wait_for_exit(&mut hook) + .unwrap_or_else(|| panic!("hook client exceeded {PROCESS_TIMEOUT:?}")); + assert!(status.success(), "hook client failed"); + })); + } + start.wait(); + for request in requests { + request.join().expect("concurrent broker client panicked"); + } + }); + + let doctor = common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .arg("doctor") + .current_dir(&project_path) + .output() + .expect("run doctor probe"); + assert_command_success("brokered doctor", &doctor); + assert_eq!( + file_identity(&db_path), + Some(db_identity), + "client probes replaced graph DB inode" + ); + assert_eq!( + daemon_authority_record(&home_path), + authority_before, + "concurrent clients changed daemon owner or epoch" + ); + #[cfg(target_os = "linux")] + for client in &clients { + assert_eq!( + sqlite_handles(client.pid(), &profile_root), + Vec::::new(), + "MCP proxy retained a profile SQLite handle after its request" + ); + } + stop_child(&mut daemon); +} diff --git a/tests/storage_suite/multi_connection_test/recovery.rs b/tests/storage_suite/multi_connection_test/recovery.rs new file mode 100644 index 000000000..e0a5949bf --- /dev/null +++ b/tests/storage_suite/multi_connection_test/recovery.rs @@ -0,0 +1,169 @@ +#[tokio::test] +#[ignore] +async fn killed_writer_fixture() { + if std::env::var("TRACEDECAY_BROKER_FIXTURE").as_deref() != Ok("killed-writer") { + return; + } + let db_path = PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_DB").expect("fixture DB")); + let dirty_path = PathBuf::from( + std::env::var_os("TRACEDECAY_FIXTURE_DIRTY").expect("fixture dirty sentinel"), + ); + let ready_path = + PathBuf::from(std::env::var_os("TRACEDECAY_FIXTURE_READY").expect("fixture ready path")); + let authority = DatabaseAuthority::acquire_test(&db_path, "killed writer fixture") + .expect("acquire fixture database authority"); + let (db, _) = Database::open(&db_path, &authority) + .await + .expect("open fixture graph DB"); + db.conn() + .execute_batch("PRAGMA wal_autocheckpoint = 0") + .await + .expect("disable WAL autocheckpoint"); + db.insert_nodes(&[common::sample_node( + "broker-recovery-node", + "broker_recovery_node", + "src/recovery.rs", + )]) + .await + .expect("commit recovery node"); + std::fs::write( + &dirty_path, + format!("pid={}\nversion=test", std::process::id()), + ) + .expect("write dirty sentinel"); + std::fs::write(&ready_path, "ready").expect("publish fixture readiness"); + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } +} + +#[test] +fn daemon_recovers_killed_writer_dirty_wal_before_serving_clients() { + let home = TempDir::new().expect("temp home"); + let project = TempDir::new().expect("temp project"); + let fixture = TempDir::new().expect("temp fixture state"); + let home_path = common::canonical_existing_path(home.path()); + let project_path = common::canonical_existing_path(project.path()); + let socket_path = common::daemon_socket_path(&home_path); + let mut initializer = spawn_daemon(&home_path, &socket_path); + let db_path = init_project(&home_path, &project_path, &socket_path); + stop_child(&mut initializer); + let data_root = db_path.parent().expect("graph data root"); + let dirty_path = data_root.join("dirty"); + let ready_path = fixture.path().join("ready"); + + let mut writer = ChildGuard::new( + Command::new(std::env::current_exe().expect("current test binary")) + .args([ + "--ignored", + "--exact", + "multi_connection_test::killed_writer_fixture", + "--nocapture", + ]) + .env("TRACEDECAY_BROKER_FIXTURE", "killed-writer") + .env("TRACEDECAY_FIXTURE_DB", &db_path) + .env("TRACEDECAY_FIXTURE_DIRTY", &dirty_path) + .env("TRACEDECAY_FIXTURE_READY", &ready_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn writer fixture"), + ); + let deadline = Instant::now() + PROCESS_TIMEOUT; + while !ready_path.exists() { + assert!( + writer.try_wait().expect("writer status").is_none(), + "writer exited early" + ); + assert!( + Instant::now() < deadline, + "writer fixture did not become ready" + ); + std::thread::sleep(Duration::from_millis(25)); + } + assert!( + PathBuf::from(format!("{}-wal", db_path.display())).exists(), + "writer fixture must leave committed WAL frames" + ); + stop_child(&mut writer); + + let wal_path = PathBuf::from(format!("{}-wal", db_path.display())); + let shm_path = PathBuf::from(format!("{}-shm", db_path.display())); + let committed_family = storage_snapshot(&db_path); + for path in [&db_path, &wal_path, &shm_path] { + assert!( + committed_family.contains_key(path), + "killed writer must leave SQLite family member '{}'", + path.display() + ); + } + let mut corrupted = committed_family[&db_path].clone(); + corrupted[..16].copy_from_slice(b"not-a-sqlite-db!"); + std::fs::write(&db_path, corrupted).expect("corrupt fixture database header"); + let failed_family = storage_snapshot(&db_path); + let failed_identities = [&db_path, &wal_path, &shm_path].map(|path| { + ( + path.to_path_buf(), + file_identity(path).expect("SQLite family identity before failed recovery"), + ) + }); + + let mut daemon = spawn_daemon(&home_path, &socket_path); + let project_arg = project_path.to_string_lossy().to_string(); + let search_recovered_node = || { + common::tracedecay_command_with_home(&home_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .env_remove(SQLITE_UNSAFE_FAST_ENV) + .current_dir(&project_path) + .args([ + "tool", + "--project", + &project_arg, + "search", + "broker_recovery_node", + "--json", + ]) + .output() + .expect("search recovered node through daemon") + }; + let failed = search_recovered_node(); + assert!( + !failed.status.success(), + "daemon must fail closed when killed-writer recovery cannot validate the database" + ); + assert_eq!( + storage_snapshot(&db_path), + failed_family, + "failed recovery changed DB, WAL, or SHM" + ); + for (path, identity) in failed_identities { + assert_eq!( + file_identity(&path), + Some(identity), + "failed recovery replaced SQLite family member '{}'", + path.display() + ); + } + assert!( + dirty_path.exists(), + "failed recovery must preserve the dirty sentinel" + ); + stop_child(&mut daemon); + + std::fs::write(&db_path, &committed_family[&db_path]) + .expect("restore fixture database for successful WAL recovery"); + daemon = spawn_daemon(&home_path, &socket_path); + let output = search_recovered_node(); + assert_command_success("daemon WAL recovery search", &output); + assert!( + String::from_utf8_lossy(&output.stdout).contains("broker_recovery_node"), + "committed WAL row was lost during daemon recovery" + ); + assert!( + !dirty_path.exists(), + "daemon must clear dirty sentinel after recovery" + ); + stop_child(&mut daemon); +} diff --git a/tests/transcript_ingest_suite/cursor.rs b/tests/transcript_ingest_suite/cursor.rs index f78a7be46..544bb5214 100644 --- a/tests/transcript_ingest_suite/cursor.rs +++ b/tests/transcript_ingest_suite/cursor.rs @@ -2,18 +2,21 @@ use std::io::Write; use tempfile::TempDir; use tracedecay::global_db::GlobalDb; -use tracedecay::hooks::cursor_pre_compact_for_event_with_config; +#[cfg(unix)] +use tracedecay::hooks::cursor_pre_compact_via_daemon; use tracedecay::sessions::cursor::{ CursorSweepSource, cursor_project_slug, ingest_cursor_transcript_event, ingest_cursor_transcript_event_capped, ingest_cursor_user_transcript_event_capped, ingest_cursor_user_transcript_event_capped_with_registered_roots, open_project_session_db, project_session_db_path, }; -use tracedecay::sessions::cursor_agent::CursorAgentSummaryConfig; +#[cfg(unix)] use tracedecay::sessions::lcm::{LcmDescribeRequest, LcmDescribeTarget}; use tracedecay::sessions::source::ingest_source; use tracedecay::sessions::{SessionSearchFilters, SessionSearchScope, SessionSearchTimeRange}; +#[cfg(unix)] +use crate::common::spawn_tracedecay_daemon; use crate::common::{EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK}; use crate::support::{assert_metadata_path_eq, init_git_repo, init_project, init_project_at}; @@ -205,6 +208,7 @@ async fn user_cursor_event_without_workspace_fails_closed_on_slug_collision() { ); } +#[cfg(unix)] #[tokio::test] // Intentional: this test pins process-wide HOME/TRACEDECAY_GLOBAL_DB while the // hook resolves its storage paths. @@ -214,10 +218,13 @@ async fn cursor_pre_compact_uses_cursor_agent_summary_for_lcm() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|err| err.into_inner()); + let home = tmp.path().join("home"); + let profile = home.join(".tracedecay"); let _env_guards = [ - EnvVarGuard::set(GLOBAL_DB_ENV, tmp.path().join("global.db")), - EnvVarGuard::set("HOME", tmp.path().join("home")), - EnvVarGuard::set("USERPROFILE", tmp.path().join("home")), + EnvVarGuard::set("TRACEDECAY_DATA_DIR", &profile), + EnvVarGuard::set(GLOBAL_DB_ENV, profile.join("global.db")), + EnvVarGuard::set("HOME", &home), + EnvVarGuard::set("USERPROFILE", &home), ]; let project = init_project(&tmp); @@ -248,6 +255,16 @@ async fn cursor_pre_compact_uses_cursor_agent_summary_for_lcm() { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); } + let _summary_env_guards = [ + EnvVarGuard::set("TRACEDECAY_CURSOR_AGENT_BIN", &fake_bin), + EnvVarGuard::set("TRACEDECAY_CURSOR_SUMMARY_MODEL", "fake-cursor-model"), + EnvVarGuard::set("TRACEDECAY_CURSOR_SUMMARY_TIMEOUT_SECS", "5"), + EnvVarGuard::set( + "TRACEDECAY_CURSOR_SUMMARY_WORKSPACE", + tmp.path().join("summary-workspace"), + ), + ]; + let _daemon = spawn_tracedecay_daemon(&home); let event = serde_json::json!({ "hook_event_name": "preCompact", @@ -260,14 +277,7 @@ async fn cursor_pre_compact_uses_cursor_agent_summary_for_lcm() { "context_tokens": 124000, "context_window_size": 128000 }); - let config = CursorAgentSummaryConfig { - cursor_agent_bin: fake_bin.to_string_lossy().to_string(), - model: Some("fake-cursor-model".to_string()), - timeout: std::time::Duration::from_secs(5), - workspace: Some(tmp.path().join("summary-workspace")), - }; - - let outcome = cursor_pre_compact_for_event_with_config(&event.to_string(), &config).await; + let outcome = cursor_pre_compact_via_daemon(&event.to_string()).await; assert_eq!(outcome.status, "ok", "{}", outcome.reason); assert_eq!(outcome.summary_nodes_created, 1); From 7fd49c1b8a6cf2e429d3396a398c5a0c270e4070 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 05:23:57 +0000 Subject: [PATCH 04/21] fix(storage): close sole-daemon integrity gaps --- scripts/hermes_stock_check.py | 4 +- scripts/with-isolated-tracedecay-daemon.sh | 3 + src/branch.rs | 113 ++++++++---- src/branch/admin.rs | 2 +- src/branch/admin/tests.rs | 113 ++++++++++++ src/branch/admin/transaction.rs | 31 +++- src/branch/tests.rs | 96 +--------- src/commands/daemon.rs | 69 +++++++- src/daemon.rs | 57 +++--- src/daemon/branch_admin.rs | 9 +- src/daemon/pr_autotrack.rs | 2 +- src/dashboard/automation_run_service.rs | 166 ++++++++++++++++++ src/dashboard/memory_curate.rs | 2 + src/dashboard/mod.rs | 37 +++- src/dashboard/projects.rs | 2 +- src/db/access/lease.rs | 156 ++++++++++++---- src/db/access/owner_io.rs | 32 +++- src/db/access/tests.rs | 65 ++++++- src/hooks/mod.rs | 75 +++++++- src/mcp/server.rs | 19 +- .../server/background_refresh_writer_tests.rs | 1 + src/mcp/server/hook_branch_writer_tests.rs | 129 +++++++++++++- src/mcp/tools/handlers/dashboard.rs | 11 +- src/mcp/tools/handlers/hook_runtime.rs | 4 +- src/mcp/tools/handlers/mod.rs | 11 +- src/open_store_holders.rs | 4 +- tests/architecture_boundaries.rs | 56 +++++- tests/storage_suite/migrate_inventory_test.rs | 33 ++-- 28 files changed, 1051 insertions(+), 251 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index 94fc730a2..042fcea7f 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -191,6 +191,8 @@ def main(): ok("context engine activates via stock plugin fallback") engine.initialize(session_id="stock-check-session", cwd=project_root) + assert engine.project_root is not None + assert os.path.realpath(engine.project_root) == os.path.realpath(project_root) engine.update_model("stock-check-model", 128000) engine.update_from_response({"prompt_tokens": 120, "completion_tokens": 30}) assert engine.last_total_tokens == 150 @@ -203,7 +205,7 @@ def main(): cloned_engine = copy.deepcopy(engine) assert cloned_engine is not engine assert cloned_engine._state_lock is not engine._state_lock - assert cloned_engine.project_root == project_root + assert cloned_engine.project_root == engine.project_root assert cloned_engine.context_length == 128000 assert cloned_engine.last_total_tokens == 150 assert cloned_engine.agent is None diff --git a/scripts/with-isolated-tracedecay-daemon.sh b/scripts/with-isolated-tracedecay-daemon.sh index 84edc8307..feadcda83 100755 --- a/scripts/with-isolated-tracedecay-daemon.sh +++ b/scripts/with-isolated-tracedecay-daemon.sh @@ -95,6 +95,9 @@ run_dir="$(mktemp -d "${TMPDIR:-/tmp}/tracedecay-daemon.XXXXXX")" export TRACEDECAY_DATA_DIR="$run_dir/profile" export TRACEDECAY_DAEMON_SOCKET="$run_dir/daemon.sock" export TRACEDECAY_DAEMON_HARNESS_ACTIVE=1 +# Keep explicit caller overrides inside the elected daemon's isolated profile. +# A database outside this profile correctly fails the sole-writer authority check. +export TRACEDECAY_GLOBAL_DB="$TRACEDECAY_DATA_DIR/global.db" daemon_log="$run_dir/daemon.log" daemon_pid="" mkdir -p "$TRACEDECAY_DATA_DIR" diff --git a/src/branch.rs b/src/branch.rs index cbd479782..e6958da0c 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -517,10 +517,10 @@ pub async fn prepare_branch_tracking_in_layout( ) } }; - prune_missing_branch_dbs(tracedecay_dir, &mut meta); + let pruned_missing_branches = prune_missing_branch_dbs(tracedecay_dir, &mut meta); if meta.is_tracked(branch_name) { - if metadata_was_missing { + if metadata_was_missing || pruned_missing_branches { branch_meta::save_branch_meta(tracedecay_dir, &meta)?; } return Ok(BranchTrackingPreparation::AlreadyTracked); @@ -636,6 +636,51 @@ async fn default_branch_bootstrap_persists_canonical_metadata() { assert!(!data_dir.join("branches").exists()); } +#[cfg(test)] +#[tokio::test] +async fn already_tracked_branch_persists_pruned_missing_database_entries() { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("repo"); + std::fs::create_dir_all(&project_root).unwrap(); + let data_dir = temp.path().join("profile-shard"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join(crate::config::DB_FILENAME), b"graph").unwrap(); + + let mut meta = crate::branch_meta::BranchMeta::new("main"); + meta.add_branch("stale", "branches/missing.db", "main"); + crate::branch_meta::save_branch_meta(&data_dir, &meta).unwrap(); + + let outcome = prepare_branch_tracking_in_layout(&project_root, "main", &data_dir) + .await + .unwrap(); + + assert!(matches!(outcome, BranchTrackingPreparation::AlreadyTracked)); + let persisted = crate::branch_meta::load_branch_meta(&data_dir).unwrap(); + assert!(!persisted.is_tracked("stale")); +} + +#[cfg(test)] +#[test] +fn rollback_keeps_database_when_metadata_removal_cannot_be_saved() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path(); + let branches_dir = data_dir.join("branches"); + std::fs::create_dir_all(&branches_dir).unwrap(); + let db_path = branches_dir.join("feature.db"); + std::fs::write(&db_path, b"graph").unwrap(); + + let mut meta = crate::branch_meta::BranchMeta::new("main"); + meta.add_branch("feature", "branches/feature.db", "main"); + crate::branch_meta::save_branch_meta(data_dir, &meta).unwrap(); + std::fs::create_dir(data_dir.join("branch-meta.json.tmp")).unwrap(); + + rollback_branch_tracking(data_dir, "feature", "branches/feature.db", &db_path); + + assert!(db_path.exists()); + let persisted = crate::branch_meta::load_branch_meta(data_dir).unwrap(); + assert!(persisted.is_tracked("feature")); +} + pub fn finalize_prepared_branch_tracking(tracedecay_dir: &Path, prepared: &PreparedBranchTracking) { if let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) { meta.touch_synced(&prepared.branch_name); @@ -658,25 +703,30 @@ fn rollback_branch_tracking( db_file: &str, new_db_path: &Path, ) { - if let Some(mut meta) = crate::branch_meta::load_branch_meta(tracedecay_dir) { - let should_remove = meta - .branches - .get(branch_name) - .is_some_and(|entry| entry.db_file == db_file); - if should_remove { + let metadata_removed = + crate::branch_meta::load_branch_meta(tracedecay_dir).is_some_and(|mut meta| { + let should_remove = meta + .branches + .get(branch_name) + .is_some_and(|entry| entry.db_file == db_file); + if !should_remove { + return false; + } meta.remove_branch(branch_name); - let _ = crate::branch_meta::save_branch_meta(tracedecay_dir, &meta); - } - } - let still_ours = crate::branch_meta::load_branch_meta(tracedecay_dir) - .and_then(|meta| meta.branches.get(branch_name).cloned()) - .is_none_or(|entry| entry.db_file == db_file); - if still_ours { + crate::branch_meta::save_branch_meta(tracedecay_dir, &meta).is_ok() + }); + let removal_persisted = metadata_removed + && crate::branch_meta::load_branch_meta(tracedecay_dir) + .is_some_and(|meta| !meta.branches.contains_key(branch_name)); + if removal_persisted { remove_branch_db_files(new_db_path); } } -fn prune_missing_branch_dbs(tracedecay_dir: &Path, meta: &mut crate::branch_meta::BranchMeta) { +fn prune_missing_branch_dbs( + tracedecay_dir: &Path, + meta: &mut crate::branch_meta::BranchMeta, +) -> bool { let missing: Vec = meta .branches .iter() @@ -688,9 +738,11 @@ fn prune_missing_branch_dbs(tracedecay_dir: &Path, meta: &mut crate::branch_meta (!path.exists()).then(|| name.clone()) }) .collect(); + let changed = !missing.is_empty(); for name in missing { meta.remove_branch(&name); } + changed } fn try_acquire_branch_add_lock_raw(tracedecay_dir: &Path) -> crate::errors::Result { @@ -762,8 +814,11 @@ async fn create_consistent_branch_snapshot(src: &Path, dst: &Path) -> crate::err Ok(()) } .await; - remove_branch_db_files(&temp); - result + let cleanup = admin::remove_branch_db_files_checked(&temp); + match result { + Err(error) => Err(error), + Ok(()) => cleanup, + } } /// Compatibility wrapper for the PR-autotrack lifecycle. Administrative CLI @@ -807,25 +862,9 @@ fn now_unix_secs() -> u64 { .as_secs() } -/// Garbage-collects dead and orphaned branch stores. -/// -/// Two independent sweeps, both age-gated so an in-flight branch that has not -/// yet synced or a just-deleted-then-recreated ref is never collected: -/// -/// (a) **Tracked, ref-gone branches** — for each unprotected tracked -/// non-default branch whose git ref no longer exists AND whose -/// `last_synced_at` is older than `branch_gc_days`, remove its DB files and -/// metadata entry. The default branch and GC-protected entries are never -/// removed. -/// (b) **Orphan DBs** — `branches/*.db` files not referenced by any meta entry -/// whose mtime is older than `orphan_db_gc_days` are deleted along with -/// their `-wal`/`-shm` sidecars. -/// -/// The whole pass holds the branch-add lock so it never races a concurrent -/// branch-add (which is creating files GC would otherwise see as orphans). -/// If the lock cannot be acquired promptly, GC is skipped this round and an -/// empty report is returned — the daemon retries on its next tick. Logging is -/// the caller's responsibility; this function is silent. +/// Compatibility wrapper retained for callers that cannot reach the managed +/// daemon. Physical branch-store GC requires daemon-owned store administration, +/// so this API fails closed without mutating metadata or SQLite files. pub fn gc_dead_branch_stores( _project_root: &Path, _tracedecay_dir: &Path, diff --git a/src/branch/admin.rs b/src/branch/admin.rs index cdb5eb206..d5858206e 100644 --- a/src/branch/admin.rs +++ b/src/branch/admin.rs @@ -61,7 +61,7 @@ impl PreparedBranchAdminMutation { &self.report } - /// Quarantines the selected SQLite families under a durable journal and + /// Quarantines the selected `SQLite` families under a durable journal and /// publishes branch metadata as the tracked-branch commit point. Any failure /// before that point rolls every move back; failures after it retain recovery /// evidence for cleanup on the next branch-lock acquisition. diff --git a/src/branch/admin/tests.rs b/src/branch/admin/tests.rs index 177014e06..9c552fc4d 100644 --- a/src/branch/admin/tests.rs +++ b/src/branch/admin/tests.rs @@ -454,6 +454,59 @@ fn metadata_commit_before_deleted_promotion_recovers_as_committed() { assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); } +#[test] +fn committed_recovery_syncs_metadata_before_tombstone_transition() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); + let metadata_path = tracedecay_dir.join(crate::storage::BRANCH_META_FILENAME); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Remove { + branch: "feature".to_string(), + }, + 0, + 0, + ) + .unwrap(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + prepared + .commit_with_transaction( + &transaction_id, + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + || failpoint("crash before deleted promotion"), + ) + .unwrap_err(); + drop(fence); + + let metadata = std::fs::read(&metadata_path).unwrap(); + let recovery = prepare_pending_branch_admin_recovery(&tracedecay_dir) + .unwrap() + .unwrap(); + std::fs::remove_file(&metadata_path).unwrap(); + let transitioned = std::cell::Cell::new(false); + let error = recovery + .recover( + |_| Ok(()), + |_| { + transitioned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to sync")); + assert!(!transitioned.get()); + assert!(!quarantine_files(&tracedecay_dir).is_empty()); + std::fs::write(metadata_path, metadata).unwrap(); + recover_committed_with_fence(&tracedecay_dir, &transaction_id); +} + #[test] fn orphan_commit_before_deleted_promotion_recovers_as_committed() { let (_temp, project_root, tracedecay_dir) = fixture(); @@ -496,6 +549,66 @@ fn orphan_commit_before_deleted_promotion_recovers_as_committed() { assert!(crate::db::database_path_is_tombstoned(&orphan).unwrap()); } +#[test] +fn committed_recovery_syncs_store_directory_before_tombstone_transition() { + let (_temp, project_root, tracedecay_dir) = fixture(); + let orphan = tracedecay_dir.join("branches/orphan.db"); + std::fs::write(&orphan, b"orphan").unwrap(); + let prepared = prepare_branch_admin_mutation( + &project_root, + &tracedecay_dir, + BranchAdminAction::Gc, + u64::MAX, + 0, + ) + .unwrap(); + let fence = crate::db::DatabaseDeletionFence::acquire( + std::slice::from_ref(&orphan), + "delete orphan branch test", + ) + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); + prepared + .commit_with_transaction( + &transaction_id, + || fence.publish_deleting(), + |_| Ok(()), + || fence.rollback_deleting(), + || failpoint("crash after orphan commit"), + ) + .unwrap_err(); + drop(fence); + + let recovery = prepare_pending_branch_admin_recovery(&tracedecay_dir) + .unwrap() + .unwrap(); + let branches = tracedecay_dir.join("branches"); + let displaced = tracedecay_dir.join("branches-displaced"); + std::fs::rename(&branches, &displaced).unwrap(); + let transitioned = std::cell::Cell::new(false); + let error = recovery + .recover( + |_| Ok(()), + |_| { + transitioned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("failed to sync directory")); + assert!(!transitioned.get()); + assert!(std::fs::read_dir(&displaced).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".branch-delete-") + })); + std::fs::rename(displaced, branches).unwrap(); + recover_committed_with_fence(&tracedecay_dir, &transaction_id); +} + #[test] fn partial_rename_failpoint_rolls_back_entire_sqlite_family() { let (_temp, project_root, tracedecay_dir) = fixture(); diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs index 7279347d2..70e224da7 100644 --- a/src/branch/admin/transaction.rs +++ b/src/branch/admin/transaction.rs @@ -103,6 +103,7 @@ impl PendingRecovery { transition_tombstones(self.disposition)?; } RecoveryDisposition::CommittedCleanup => { + sync_committed_recovery_state(tracedecay_dir, &self.journal)?; transition_tombstones(self.disposition)?; cleanup_files(tracedecay_dir, &self.journal)?; } @@ -271,7 +272,12 @@ where )); } - if journal.metadata_before != journal.metadata_after { + if journal.metadata_before == journal.metadata_after { + let mut committed = journal.clone(); + committed.state = JournalState::CommittedOrphans; + persist_journal(tracedecay_dir, &committed)?; + journal = committed; + } else { hook(TransactionPhase::BeforeMetadataPublication)?; let after = journal.metadata_after.as_deref().ok_or_else(|| { config_error("tracked branch deletion cannot remove branch metadata entirely") @@ -279,11 +285,6 @@ where crate::branch_meta::save_branch_meta_serialized(tracedecay_dir, after)?; sync_file(&tracedecay_dir.join(BRANCH_META_FILENAME))?; sync_directory(tracedecay_dir)?; - } else { - let mut committed = journal.clone(); - committed.state = JournalState::CommittedOrphans; - persist_journal(tracedecay_dir, &committed)?; - journal = committed; } hook(TransactionPhase::AfterCommitBeforeCleanup)?; cleanup_committed(tracedecay_dir, &journal) @@ -645,6 +646,24 @@ fn cleanup_committed(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result clear_journal(tracedecay_dir) } +fn sync_committed_recovery_state(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { + for entry in &journal.entries { + let database = tracedecay_dir.join(&entry.db_file); + let parent = database.parent().ok_or_else(|| { + config_error(format!( + "branch database path '{}' has no parent", + database.display() + )) + })?; + sync_directory(parent)?; + } + if journal.metadata_before != journal.metadata_after { + sync_file(&tracedecay_dir.join(BRANCH_META_FILENAME))?; + sync_directory(tracedecay_dir)?; + } + Ok(()) +} + fn cleanup_files(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { let states = journal .entries diff --git a/src/branch/tests.rs b/src/branch/tests.rs index 5e93d3d91..e89e6e69a 100644 --- a/src/branch/tests.rs +++ b/src/branch/tests.rs @@ -273,105 +273,17 @@ async fn read_only_sqlite_snapshot_includes_committed_data() { } #[test] -fn gc_removes_dead_stale_branch() { +fn legacy_gc_wrapper_fails_closed() { let (_base, project_root, td) = setup_repo_with_meta(); - // Ref-gone (never created) and last synced long ago (> 14d). let stale = now_unix_secs() - 20 * 86_400; let db = add_tracked_branch(&project_root, &td, "gone", stale, false); assert!(db.exists()); let report = gc_dead_branch_stores(&project_root, &td, 14, 7); - assert_eq!(report.removed_tracked, vec!["gone".to_string()]); - assert!(!db.exists(), "dead branch DB should be deleted"); - let meta = crate::branch_meta::load_branch_meta(&td).unwrap(); - assert!(!meta.is_tracked("gone")); -} - -#[test] -fn gc_keeps_default_branch() { - let (_base, project_root, td) = setup_repo_with_meta(); - // Delete the git ref for main so only the never-remove-default guard - // protects it; also backdate would require touching main's entry. - run_git(&project_root, &["checkout", "--detach"]); - // Force main's ref away is unnecessary — GC skips default by name. - let report = gc_dead_branch_stores(&project_root, &td, 14, 7); - assert!(report.removed_tracked.is_empty()); - let meta = crate::branch_meta::load_branch_meta(&td).unwrap(); - assert!(meta.is_tracked("main")); - assert!(td.join("tracedecay.db").exists()); -} - -#[test] -fn gc_keeps_fresh_dead_branch() { - let (_base, project_root, td) = setup_repo_with_meta(); - // Ref gone but synced just now: within grace, keep it. - let db = add_tracked_branch(&project_root, &td, "recent", now_unix_secs(), false); - let report = gc_dead_branch_stores(&project_root, &td, 14, 7); assert!(report.removed_tracked.is_empty()); + assert!(report.removed_orphan_dbs.is_empty()); assert!(db.exists()); -} - -#[test] -fn gc_keeps_protected_ref_gone_stale_branch() { - let (_base, project_root, td) = setup_repo_with_meta(); - let db = add_tracked_branch(&project_root, &td, "recovered/orphan", 0, false); - let mut meta = crate::branch_meta::load_branch_meta(&td).unwrap(); - meta.branches - .get_mut("recovered/orphan") - .unwrap() - .gc_protected = true; - crate::branch_meta::save_branch_meta(&td, &meta).unwrap(); - - let report = gc_dead_branch_stores(&project_root, &td, 0, 0); - - assert!(report.removed_tracked.is_empty()); - assert!(db.exists()); - let reloaded = crate::branch_meta::load_branch_meta(&td).unwrap(); - assert!(reloaded.branches["recovered/orphan"].gc_protected); -} - -#[test] -fn gc_keeps_branch_with_live_ref() { - let (_base, project_root, td) = setup_repo_with_meta(); - // Ref exists AND stale: still keep it, ref presence wins. - let stale = now_unix_secs() - 100 * 86_400; - let db = add_tracked_branch(&project_root, &td, "live", stale, true); - assert!(is_branch_ref_present(&project_root, "live")); - let report = gc_dead_branch_stores(&project_root, &td, 14, 7); - assert!(report.removed_tracked.is_empty()); - assert!(db.exists()); -} - -#[test] -fn gc_deletes_stale_orphan_db_keeps_fresh() { - let (_base, project_root, td) = setup_repo_with_meta(); - let branches_dir = crate::branch_meta::ensure_branches_dir(&td).unwrap(); - - // Stale orphan: not in meta, mtime backdated > 7d. - let stale_orphan = branches_dir.join("orphan_stale.db"); - std::fs::write(&stale_orphan, b"junk").unwrap(); - let stale_wal = branches_dir.join("orphan_stale.db-wal"); - std::fs::write(&stale_wal, b"wal").unwrap(); - let old = std::time::SystemTime::now() - std::time::Duration::from_hours(720); - set_mtime(&stale_orphan, old); - - // Fresh orphan: just created, must survive. - let fresh_orphan = branches_dir.join("orphan_fresh.db"); - std::fs::write(&fresh_orphan, b"junk").unwrap(); - - let report = gc_dead_branch_stores(&project_root, &td, 14, 7); - - assert!(!stale_orphan.exists(), "stale orphan should be deleted"); - assert!(!stale_wal.exists(), "orphan sidecar should be deleted"); - assert!(fresh_orphan.exists(), "fresh orphan should be kept"); - assert!(report.removed_orphan_dbs.contains(&stale_orphan)); - assert!(!report.removed_orphan_dbs.contains(&fresh_orphan)); -} - -fn set_mtime(path: &Path, when: std::time::SystemTime) { - // Best-effort mtime backdate via filetime-free approach: re-open and use - // the standard library's set_modified (stable since 1.75). - let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); - f.set_modified(when).unwrap(); + let meta = crate::branch_meta::load_branch_meta(&td).unwrap(); + assert!(meta.is_tracked("gone")); } diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index b9de00fd6..9a148bce3 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -16,15 +16,68 @@ pub(crate) async fn daemon_tool_json( .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { message: format!("daemon tool {tool_name} returned no content blocks"), })?; - for text in blocks + parse_daemon_tool_json_content(tool_name, blocks) +} + +fn parse_daemon_tool_json_content( + tool_name: &str, + blocks: &[serde_json::Value], +) -> tracedecay::errors::Result { + let mut payloads = blocks .iter() .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) - { - if let Ok(value) = serde_json::from_str(text) { - return Ok(value); - } + .filter_map(|text| serde_json::from_str(text).ok()); + let payload = payloads + .next() + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no JSON payload"), + })?; + if payloads.next().is_some() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned multiple JSON payloads"), + }); + } + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::parse_daemon_tool_json_content; + use serde_json::json; + + #[test] + fn accepts_exactly_one_json_payload() { + let blocks = vec![json!({"text": "status"}), json!({"text": "{\"ok\":true}"})]; + + assert_eq!( + parse_daemon_tool_json_content("test", &blocks).unwrap(), + json!({"ok": true}) + ); + } + + #[test] + fn rejects_multiple_json_payloads() { + let blocks = vec![json!({"text": "{\"first\":1}"}), json!({"text": "[2]"})]; + + let error = parse_daemon_tool_json_content("test", &blocks).unwrap_err(); + + assert!( + error + .to_string() + .contains("daemon tool test returned multiple JSON payloads") + ); + } + + #[test] + fn rejects_missing_json_payload() { + let blocks = vec![json!({"text": "status"}), json!({"type": "image"})]; + + let error = parse_daemon_tool_json_content("test", &blocks).unwrap_err(); + + assert!( + error + .to_string() + .contains("daemon tool test returned no JSON payload") + ); } - Err(tracedecay::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned no JSON payload"), - }) } diff --git a/src/daemon.rs b/src/daemon.rs index de8a81d37..a22152b01 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -41,6 +41,15 @@ const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); const DAEMON_TOOL_LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5); const DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +fn coordinated_dashboard_automation_writer( + administration: StoreAdministration, +) -> crate::dashboard::DashboardAutomationWriter { + Arc::new(move |operation| { + let administration = administration.clone(); + Box::pin(async move { administration.with_writer(operation).await }) + }) +} + fn coordinated_background_refresh_writer( administration: StoreAdministration, ) -> crate::mcp::server::BackgroundRefreshWriter { @@ -1003,7 +1012,7 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { &authority.record().process_run_id, )?; let (listener, endpoint) = BrokerListener::bind(authority.endpoint()).await?; - authority.publish_endpoint(endpoint.clone())?; + authority.publish_endpoint(&endpoint)?; log_daemon_event("daemon_listening", &[("endpoint", endpoint.to_string())]); let lifecycle = DaemonLifecycle::default(); @@ -1041,8 +1050,9 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { lifecycle.begin_draining(); clients.abort_all(); while clients.join_next().await.is_some() {} - authority.cleanup_owned_endpoint()?; - Ok(()) + let endpoint_cleanup = authority.cleanup_owned_endpoint(); + shutdown_project_servers(&store_administration).await; + endpoint_cleanup } #[cfg(unix)] @@ -1615,7 +1625,7 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { // via `connect_with_restart_grace`) instead of a queued connection that // will never be served. drop(listener); - authority.cleanup_owned_endpoint()?; + let endpoint_cleanup = authority.cleanup_owned_endpoint(); // Keep auxiliary process creation blocked until every scheduler and client // task is drained or abandoned. A killed app-server call may retry before // unwinding, so a shorter guard leaves a shutdown-time respawn race. @@ -1658,7 +1668,7 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { ), ], ); - return Ok(()); + return endpoint_cleanup; } // Graceful shutdown persists tokens-saved counters and checkpoints WALs // for every live project server sequentially; with many servers or large @@ -1681,7 +1691,7 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { ], ); } - Ok(()) + endpoint_cleanup } #[cfg(unix)] @@ -2251,6 +2261,7 @@ impl DaemonEngine { false, Some(reconciler), Some(database_owner_reconciler), + coordinated_dashboard_automation_writer(self.store_administration.clone()), coordinated_hook_branch_writer(self.store_administration.clone()), coordinated_background_refresh_writer(self.store_administration.clone()), ) @@ -2363,21 +2374,7 @@ impl DaemonEngine { } async fn shutdown_servers(&self) { - let servers: Vec> = self - .store_administration - .with_writer(|| async { - let servers = self.store_administration.project_servers().lock().await; - let mut seen = HashSet::new(); - servers - .values() - .filter(|server| seen.insert(Arc::as_ptr(server) as usize)) - .cloned() - .collect() - }) - .await; - for server in servers { - server.shutdown().await; - } + shutdown_project_servers(&self.store_administration).await; } #[cfg(test)] @@ -2388,6 +2385,23 @@ impl DaemonEngine { } } +async fn shutdown_project_servers(store_administration: &StoreAdministration) { + let servers: Vec> = store_administration + .with_writer(|| async { + let servers = store_administration.project_servers().lock().await; + let mut seen = HashSet::new(); + servers + .values() + .filter(|server| seen.insert(Arc::as_ptr(server) as usize)) + .cloned() + .collect() + }) + .await; + for server in servers { + server.shutdown().await; + } +} + #[cfg(all(unix, test))] async fn serve_socket_client(stream: tokio::net::UnixStream, engine: DaemonEngine) -> Result<()> { Box::pin(serve_broker_socket_client( @@ -2774,6 +2788,7 @@ async fn portable_project_server( false, None, Some(database_owner_reconciler), + coordinated_dashboard_automation_writer(store_administration.clone()), coordinated_hook_branch_writer(store_administration.clone()), coordinated_background_refresh_writer(store_administration.clone()), ) diff --git a/src/daemon/branch_admin.rs b/src/daemon/branch_admin.rs index 517ce9e0a..4e0e80f8a 100644 --- a/src/daemon/branch_admin.rs +++ b/src/daemon/branch_admin.rs @@ -8,11 +8,15 @@ use serde_json::json; use crate::errors::{Result, TraceDecayError}; use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport}; +#[cfg(unix)] +use super::AutomationSchedulerHandle; use super::{ - AutomationSchedulerHandle, DaemonHandshake, DatabaseOwnerRegistry, ProjectServerKey, authority, - write_json_rpc_response, + DaemonHandshake, DatabaseOwnerRegistry, ProjectServerKey, authority, write_json_rpc_response, }; +#[cfg(not(unix))] +type AutomationSchedulerHandle = (); + const BRANCH_ADMIN_TOOL_NAME: &str = "tracedecay_admin_branch"; #[cfg(test)] @@ -75,6 +79,7 @@ impl StoreAdministration { &self.automation_schedulers } + #[cfg_attr(not(test), allow(clippy::unused_self))] fn prove_no_external_branch_store_holders(&self, database_paths: &[PathBuf]) -> Result<()> { #[cfg(test)] if let Some(external_holder_verifier) = self.external_holder_verifier { diff --git a/src/daemon/pr_autotrack.rs b/src/daemon/pr_autotrack.rs index feda61bed..91b5bf976 100644 --- a/src/daemon/pr_autotrack.rs +++ b/src/daemon/pr_autotrack.rs @@ -821,7 +821,7 @@ async fn remove_pr_store( BranchAdminOutcome::Removed | BranchAdminOutcome::NotTracked | BranchAdminOutcome::NoTracking => Ok(()), - outcome => Err(format!( + outcome @ BranchAdminOutcome::NoChanges => Err(format!( "branch-store removal returned unexpected outcome {outcome:?}" )), } diff --git a/src/dashboard/automation_run_service.rs b/src/dashboard/automation_run_service.rs index 0bc3e874b..2f71c9b21 100644 --- a/src/dashboard/automation_run_service.rs +++ b/src/dashboard/automation_run_service.rs @@ -1,9 +1,116 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + use serde_json::{Value, json}; use super::DashboardState; use super::memory_service::{push_curation_activity, push_curation_activity_with_level}; use crate::sessions::lcm::{LcmGrepSort, LcmScope}; +pub(crate) struct DashboardAutomationWriteRequest { + state: DashboardState, + operation: DashboardAutomationOperation, +} + +pub(crate) enum DashboardAutomationOperation { + MemoryCurator { + request: MemoryCuratorRunRequest, + run_id: Option, + }, + SessionReflection { + request: SessionReflectionRunRequest, + run_id: Option, + }, + SkillWriting { + request: SkillWritingRunRequest, + run_id: Option, + }, +} + +/// Injectable lifetime boundary for complete dashboard automation writes. +/// +/// No writable [`crate::tracedecay::TraceDecay`] handle crosses this callback. +pub(crate) type DashboardAutomationWriter = Arc< + dyn Fn( + DashboardAutomationWriteRequest, + ) -> Pin> + Send>> + + Send + + Sync + + 'static, +>; + +pub(crate) fn direct_dashboard_automation_writer() -> DashboardAutomationWriter { + Arc::new(|request| Box::pin(execute_dashboard_automation_run_direct(request))) +} + +pub(crate) async fn execute_dashboard_automation_run_direct( + request: DashboardAutomationWriteRequest, +) -> Result { + let DashboardAutomationWriteRequest { state, operation } = request; + match operation { + DashboardAutomationOperation::MemoryCurator { request, run_id } => { + memory_curator_run_payload_direct(&state, request, run_id).await + } + DashboardAutomationOperation::SessionReflection { request, run_id } => { + session_reflection_run_payload_direct(&state, request, run_id).await + } + DashboardAutomationOperation::SkillWriting { request, run_id } => { + skill_writing_run_payload_direct(&state, request, run_id).await + } + } +} + +async fn run_dashboard_automation( + state: &DashboardState, + operation: DashboardAutomationOperation, +) -> Result { + let writer = Arc::clone(&state.automation_writer); + writer(DashboardAutomationWriteRequest { + state: state.clone(), + operation, + }) + .await +} + +pub(crate) type DashboardAutomationWriteFuture = + Pin> + Send + 'static>>; +pub(crate) type DashboardAutomationWriteOperation = + Box DashboardAutomationWriteFuture + Send + 'static>; +pub(crate) type DashboardAutomationWriter = Arc< + dyn Fn(DashboardAutomationWriteOperation) -> DashboardAutomationWriteFuture + + Send + + Sync + + 'static, +>; + +fn execute_dashboard_automation_run_direct( + operation: DashboardAutomationWriteOperation, +) -> DashboardAutomationWriteFuture { + operation() +} + +pub(crate) fn direct_dashboard_automation_writer() -> DashboardAutomationWriter { + Arc::new(execute_dashboard_automation_run_direct) +} + +async fn execute_dashboard_automation_run( + state: &DashboardState, + operation: Operation, +) -> Result +where + Operation: FnOnce(DashboardState) -> OperationFuture + Send + 'static, + OperationFuture: Future> + Send + 'static, +{ + let writer = Arc::clone(&state.automation_writer); + let state = state.clone(); + writer(Box::new(move || Box::pin(operation(state)))).await +} + pub(crate) struct MemoryCuratorRunRequest { pub max_clusters: usize, pub min_confidence: f64, @@ -13,6 +120,17 @@ pub(crate) async fn memory_curator_run_payload_with_run_id( state: &DashboardState, request: MemoryCuratorRunRequest, run_id: Option, +) -> Result { + execute_dashboard_automation_run(state, move |state| async move { + memory_curator_run_payload_with_run_id_direct(&state, request, run_id).await + }) + .await +} + +async fn memory_curator_run_payload_with_run_id_direct( + state: &DashboardState, + request: MemoryCuratorRunRequest, + run_id: Option, ) -> Result { use crate::automation::run_ledger::AutomationTrigger; use crate::automation::runner::{ @@ -237,6 +355,17 @@ pub(crate) async fn session_reflection_run_payload_with_run_id( state: &DashboardState, request: SessionReflectionRunRequest, run_id: Option, +) -> Result { + execute_dashboard_automation_run(state, move |state| async move { + session_reflection_run_payload_with_run_id_direct(&state, request, run_id).await + }) + .await +} + +async fn session_reflection_run_payload_with_run_id_direct( + state: &DashboardState, + request: SessionReflectionRunRequest, + run_id: Option, ) -> Result { use crate::automation::run_ledger::AutomationTrigger; use crate::automation::runner::{ @@ -331,6 +460,17 @@ pub(crate) async fn skill_writing_run_payload_with_run_id( state: &DashboardState, request: SkillWritingRunRequest, run_id: Option, +) -> Result { + execute_dashboard_automation_run(state, move |state| async move { + skill_writing_run_payload_with_run_id_direct(&state, request, run_id).await + }) + .await +} + +async fn skill_writing_run_payload_with_run_id_direct( + state: &DashboardState, + request: SkillWritingRunRequest, + run_id: Option, ) -> Result { use crate::automation::run_ledger::AutomationTrigger; use crate::automation::runner::{SkillWriterAutomationOptions, run_skill_writer_with_backend}; @@ -592,3 +732,29 @@ fn automation_run_payload( "backend_response": backend_response, }) } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[tokio::test] + async fn direct_writer_executes_operation_once() { + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + let writer = direct_dashboard_automation_writer(); + + let result = writer(Box::new(move || { + Box::pin(async move { + observed.fetch_add(1, Ordering::Relaxed); + Ok(json!({ "status": "ok" })) + }) + })) + .await + .expect("direct dashboard automation write should succeed"); + + assert_eq!(result, json!({ "status": "ok" })); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } +} diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index 46be46e8c..7274e87cf 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -156,6 +156,7 @@ async fn cli_state(cg: &TraceDecay) -> DashboardState { ))), code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), automation_scheduler_reconciler: None, + automation_writer: super::direct_dashboard_automation_writer(), } } @@ -192,6 +193,7 @@ fn user_state( ))), code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), automation_scheduler_reconciler: None, + automation_writer: super::direct_dashboard_automation_writer(), } } diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index cacfe95a6..d2ba6383d 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -29,6 +29,9 @@ mod automation_jobs_api; mod automation_outcomes_api; mod automation_run_api; mod automation_run_service; +pub(crate) use automation_run_service::{ + DashboardAutomationWriter, direct_dashboard_automation_writer, +}; mod automation_scheduler_api; mod automation_skills_api; mod code_diagnostics_api; @@ -128,6 +131,8 @@ pub(crate) struct DashboardState { /// dashboard server lifetime. pub(crate) code_diagnostics_backfill_started: Arc, pub(crate) automation_scheduler_reconciler: Option, + /// Lifetime-owning capability for complete dashboard automation writes. + pub(crate) automation_writer: DashboardAutomationWriter, } impl DashboardState { @@ -257,6 +262,7 @@ async fn build_state_inner( repair_memory_on_startup: bool, warm_token_counts: bool, automation_scheduler_reconciler: Option, + automation_writer: DashboardAutomationWriter, ) -> DashboardState { let (mem_conn, mem_db_path, mem_guard) = resolve_project_memory_store(cg).await; let lcm = resolve_lcm_store(cg).await; @@ -298,6 +304,7 @@ async fn build_state_inner( code_diagnostics: Arc::new(RwLock::new(code_diagnostics)), code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), automation_scheduler_reconciler, + automation_writer, }; if repair_memory_on_startup { if let Err(err) = memory_api::repair_derived_memory(&state).await { @@ -316,20 +323,39 @@ async fn build_state_inner( /// `tracedecay_dashboard` MCP tool. #[allow(dead_code)] pub(crate) async fn build_state(cg: &TraceDecay) -> DashboardState { - build_state_inner(cg, true, true, None).await + build_state_inner(cg, true, true, None, direct_dashboard_automation_writer()).await } pub(crate) async fn build_state_with_automation_reconciler( cg: &TraceDecay, automation_scheduler_reconciler: Option, + automation_writer: DashboardAutomationWriter, ) -> DashboardState { - build_state_inner(cg, true, true, automation_scheduler_reconciler).await + build_state_inner( + cg, + true, + true, + automation_scheduler_reconciler, + automation_writer, + ) + .await } /// Builds a lightweight cached state for a non-active project selected from the -/// dashboard project picker. -pub(crate) async fn build_selected_project_state(cg: &TraceDecay) -> DashboardState { - build_state_inner(cg, false, false, None).await +/// dashboard project picker. Automation authority is inherited from the active +/// dashboard state so daemon-selected projects cannot fall back to direct open. +pub(crate) async fn build_selected_project_state( + cg: &TraceDecay, + active: &DashboardState, +) -> DashboardState { + build_state_inner( + cg, + false, + false, + None, + Arc::clone(&active.automation_writer), + ) + .await } /// Detached catch-up ingest for transcript sources (Claude, Codex, Vibe, @@ -447,6 +473,7 @@ where options.repair_memory_on_startup, options.warm_token_counts, None, + direct_dashboard_automation_writer(), ) .await; if options.start_session_catch_up && state.lcm_scope != "global" { diff --git a/src/dashboard/projects.rs b/src/dashboard/projects.rs index 9e050cbf8..b4d43a7b7 100644 --- a/src/dashboard/projects.rs +++ b/src/dashboard/projects.rs @@ -88,7 +88,7 @@ impl DashboardRuntime { project_root.display() ))); } - let state = build_selected_project_state(&cg).await; + let state = build_selected_project_state(&cg, &self.active).await; let mut project_states = self.project_states.write().await; if let Some(cached) = project_states.get(project_id).cloned() { if cached.registry_context == context { diff --git a/src/db/access/lease.rs b/src/db/access/lease.rs index b3b82f2d4..d3fd93388 100644 --- a/src/db/access/lease.rs +++ b/src/db/access/lease.rs @@ -414,11 +414,7 @@ struct DeletionTombstone { impl DatabaseDeletionFence { pub(crate) fn acquire(database_paths: &[PathBuf], intent: &str) -> Result { let identities = canonical_deletion_identities(database_paths, intent)?; - let identity_hash = stable_path_set_hash( - identities - .iter() - .map(|identity| identity.database_key.as_path()), - ); + let identity_hash = deletion_identity_set_hash(&identities); let transaction_id = format!("{identity_hash:016x}:{}", authority_token()); let (entries, state) = acquire_deletion_locks( identities, @@ -438,8 +434,13 @@ impl DatabaseDeletionFence { transaction_id: &str, intent: &str, ) -> Result<(Self, DatabaseDeletionStates)> { - validate_deletion_transaction_id(transaction_id, intent, Path::new(""))?; let identities = canonical_deletion_identities(database_paths, intent)?; + validate_deletion_transaction_id( + transaction_id, + deletion_identity_set_hash(&identities), + intent, + Path::new(""), + )?; let (entries, states) = acquire_deletion_locks( identities, transaction_id, @@ -588,21 +589,28 @@ impl DatabaseDeletionFence { impl Drop for DatabaseDeletionFence { fn drop(&mut self) { - let mut leases = PROCESS_LEASES - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for entry in self.entries.iter().rev() { - let _ = fs2::FileExt::unlock(&entry.writer); - let _ = fs2::FileExt::unlock(&entry.access); - let owns_process_lease = matches!( - leases.get(&entry.identity.database_key), - Some(ProcessLease::Deletion { transaction_id, .. }) - if transaction_id == &self.transaction_id - ); - if owns_process_lease { - leases.remove(&entry.identity.database_key); + { + let mut leases = PROCESS_LEASES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for entry in self.entries.iter().rev() { + let _ = fs2::FileExt::unlock(&entry.writer); + let _ = fs2::FileExt::unlock(&entry.access); + let owns_process_lease = matches!( + leases.get(&entry.identity.database_key), + Some(ProcessLease::Deletion { transaction_id, .. }) + if transaction_id == &self.transaction_id + ); + if owns_process_lease { + leases.remove(&entry.identity.database_key); + } } } + let bootstrap = DELETION_BOOTSTRAP_AUTHORITIES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.transaction_id); + drop(bootstrap); } } @@ -612,6 +620,9 @@ enum DeletionFenceAcquireMode { Recovery, } +static DELETION_BOOTSTRAP_AUTHORITIES: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + fn canonical_deletion_identities( database_paths: &[PathBuf], intent: &str, @@ -627,17 +638,57 @@ fn canonical_deletion_identities( .iter() .map(|path| DatabaseIdentity::for_path(path)) .collect::>>()?; - identities.sort_by(|left, right| left.database_key.cmp(&right.database_key)); - identities.dedup_by(|left, right| left.database_key == right.database_key); + identities.sort_by_cached_key(deletion_identity_key); + identities.dedup_by(|left, right| deletion_identity_key(left) == deletion_identity_key(right)); Ok(identities) } +fn deletion_identity_key(identity: &DatabaseIdentity) -> PathBuf { + deletion_bootstrap_key(identity).unwrap_or_else(|| identity.database_key.clone()) +} + +fn deletion_bootstrap_key(identity: &DatabaseIdentity) -> Option { + let parent = identity + .database_path + .parent() + .unwrap_or(&identity.profile_root); + let file_name = identity.database_path.file_name().unwrap_or_default(); + bootstrap_database_key(parent, file_name) +} + +fn deletion_bootstrap_lock_path(identity: &DatabaseIdentity) -> Option { + deletion_bootstrap_key(identity).map(|key| { + let lock_root = identity + .access_lock_path + .parent() + .unwrap_or(&identity.profile_root); + lock_root.join(format!("{:016x}.bootstrap.lock", stable_path_hash(&key))) + }) +} + +fn deletion_identity_set_hash(identities: &[DatabaseIdentity]) -> u64 { + let keys = identities + .iter() + .map(deletion_identity_key) + .collect::>(); + stable_path_set_hash(keys.iter().map(PathBuf::as_path)) +} + fn acquire_deletion_locks( identities: Vec, transaction_id: &str, intent: &str, mode: DeletionFenceAcquireMode, ) -> Result<(Vec, DatabaseDeletionStates)> { + let mut bootstrap_authorities = Vec::new(); + for identity in &identities { + let mut bootstrap_identity = identity.clone(); + bootstrap_identity.bootstrap_lock_path = deletion_bootstrap_lock_path(identity); + if let Some(authority) = acquire_bootstrap_authority(&bootstrap_identity, intent)? { + bootstrap_authorities.push(authority); + } + } + let mut leases = PROCESS_LEASES .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -711,6 +762,25 @@ fn acquire_deletion_locks( return Err(error); } } + if !bootstrap_authorities.is_empty() { + let mut active = DELETION_BOOTSTRAP_AUTHORITIES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match active.entry(transaction_id.to_string()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(bootstrap_authorities); + } + std::collections::hash_map::Entry::Occupied(_) => { + drop(active); + unlock_deletion_entries(&entries); + return Err(access_error( + intent, + Path::new(""), + "database deletion transaction is already active", + )); + } + } + } for entry in &entries { leases.insert( entry.identity.database_key.clone(), @@ -732,18 +802,42 @@ fn unlock_deletion_entries(entries: &[DeletionFenceEntry]) { fn validate_deletion_transaction_id( transaction_id: &str, + expected_path_hash: u64, operation: &str, path: &Path, ) -> Result<()> { - if valid_deletion_transaction_id(transaction_id) { - Ok(()) - } else { - Err(access_error( + if !valid_deletion_transaction_id(transaction_id) { + return Err(access_error( operation, path, "database deletion transaction ID is invalid", - )) + )); + } + let Some((path_hash, token)) = transaction_id.split_once(':') else { + return Err(access_error( + operation, + path, + "database deletion transaction ID is invalid", + )); + }; + let parsed_hash = (path_hash.len() == 16 && !token.is_empty()) + .then(|| u64::from_str_radix(path_hash, 16).ok()) + .flatten() + .ok_or_else(|| { + access_error( + operation, + path, + "database deletion transaction ID is invalid", + ) + })?; + if parsed_hash != expected_path_hash { + return Err(access_error( + operation, + path, + "database deletion transaction ID does not match the database path set", + )); } + Ok(()) } fn valid_deletion_transaction_id(transaction_id: &str) -> bool { @@ -921,15 +1015,15 @@ fn tombstone_transition_error( expected_transaction_id: &str, tombstone: &DeletionTombstone, ) -> TraceDecayError { - let message = if tombstone.transaction_id != expected_transaction_id { + let message = if tombstone.transaction_id == expected_transaction_id { format!( - "database deletion tombstone belongs to transaction {}, not {}", - tombstone.transaction_id, expected_transaction_id + "database deletion tombstone is already {} and cannot perform this transition", + tombstone.state.as_str() ) } else { format!( - "database deletion tombstone is already {} and cannot perform this transition", - tombstone.state.as_str() + "database deletion tombstone belongs to transaction {}, not {}", + tombstone.transaction_id, expected_transaction_id ) }; access_error(operation, &identity.database_path, &message) diff --git a/src/db/access/owner_io.rs b/src/db/access/owner_io.rs index dca16062d..31e066d4c 100644 --- a/src/db/access/owner_io.rs +++ b/src/db/access/owner_io.rs @@ -45,13 +45,22 @@ pub(super) fn write_record_atomically( ) })?; let nonce = AUTHORITY_NONCE.fetch_add(1, Ordering::Relaxed); - let temporary = path.with_file_name(format!( - ".{}.{}.{}.tmp", + let temporary = temporary_record_path(path, file_name, nonce); + publish_record_atomically(&temporary, path, payload, record_name) +} + +fn temporary_record_path( + path: &Path, + file_name: &std::ffi::OsStr, + nonce: u64, +) -> std::path::PathBuf { + path.with_file_name(format!( + ".{}.{}.{}.{}.tmp", file_name.to_string_lossy(), std::process::id(), + crate::runtime_identity::process_run_id(), nonce - )); - publish_record_atomically(&temporary, path, payload, record_name) + )) } pub(super) fn publish_record_atomically( @@ -279,6 +288,21 @@ pub(super) fn epoch_ms() -> u128 { .as_millis() } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temporary_record_names_are_scoped_to_the_process_run() { + let path = Path::new("writer.owner"); + let temporary = temporary_record_path(path, path.file_name().unwrap(), 17); + let name = temporary.file_name().unwrap().to_string_lossy(); + + assert!(name.contains(crate::runtime_identity::process_run_id())); + assert!(name.ends_with(".17.tmp")); + } +} + fn sanitize_metadata(value: &str) -> String { value.replace(['\t', '\r', '\n'], " ") } diff --git a/src/db/access/tests.rs b/src/db/access/tests.rs index 892a9f201..81fa7dbfe 100644 --- a/src/db/access/tests.rs +++ b/src/db/access/tests.rs @@ -429,10 +429,73 @@ fn deletion_reacquire_rejects_foreign_transaction() { "recover foreign deletion", ) .unwrap_err(); - assert!(error.to_string().contains("belongs to transaction")); + assert!(error.to_string().contains("transaction ID is invalid")); remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); } +#[test] +fn deletion_reacquire_rejects_transaction_for_another_path_set() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.db"); + let second = temp.path().join("second.db"); + let identity = DatabaseIdentity::for_path(&first).unwrap(); + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&first), "delete database").unwrap(); + fence.publish_deleting().unwrap(); + let transaction_id = fence.transaction_id().to_string(); + drop(fence); + + let error = DatabaseDeletionFence::reacquire( + std::slice::from_ref(&second), + &transaction_id, + "recover wrong database", + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not match the database path set") + ); + remove_record_durably(&identity.deletion_tombstone_path, "test tombstone cleanup").unwrap(); +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn deletion_fence_collapses_and_locks_missing_case_variants() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + + let fence = + DatabaseDeletionFence::acquire(&[upper.clone(), lower.clone()], "delete missing database") + .unwrap(); + assert_eq!(fence.database_paths().count(), 1); + let error = DatabaseAuthority::acquire_test(&lower, "create case variant").unwrap_err(); + assert!(error.to_string().contains("case-variant first-create")); + + drop(fence); + DatabaseAuthority::acquire_test(&lower, "create after deletion fence").unwrap(); +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn deletion_fence_retains_first_create_lock_after_database_removal() { + let temp = tempfile::tempdir().unwrap(); + let upper = temp.path().join("MixedCase.DB"); + let lower = temp.path().join("mixedcase.db"); + std::fs::write(&upper, []).unwrap(); + + let fence = + DatabaseDeletionFence::acquire(std::slice::from_ref(&upper), "delete existing database") + .unwrap(); + std::fs::remove_file(&upper).unwrap(); + let error = DatabaseAuthority::acquire_test(&lower, "recreate case variant").unwrap_err(); + assert!(error.to_string().contains("case-variant first-create")); + + drop(fence); + DatabaseAuthority::acquire_test(&lower, "recreate after deletion fence").unwrap(); +} + #[test] fn partial_publication_reacquires_missing_and_deleting_for_rollback() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index bc861461a..8daf3cb9f 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -94,16 +94,31 @@ pub(crate) async fn daemon_tool_json( false, )?; let result = crate::daemon::call_default_tool(&handshake, tool_name, arguments).await?; - let text = result + parse_daemon_tool_json_content(&result, tool_name) +} + +fn parse_daemon_tool_json_content(result: &Value, tool_name: &str) -> crate::errors::Result { + let payloads = result .get("content") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|item| item.get("text").and_then(Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(|error| crate::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned invalid JSON: {error}"), - }) + .filter_map(|text| serde_json::from_str::(text).ok()) + .collect::>(); + + match payloads.as_slice() { + [payload] => Ok(payload.clone()), + [] => Err(crate::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no JSON payload"), + }), + _ => Err(crate::errors::TraceDecayError::Config { + message: format!( + "daemon tool {tool_name} returned multiple JSON payloads ({})", + payloads.len() + ), + }), + } } pub(crate) async fn daemon_hook_action( @@ -115,7 +130,15 @@ pub(crate) async fn daemon_hook_action( if let Some(result) = take_test_daemon_hook_action(project_root, &arguments) { return result; } - daemon_tool_json(project_root, "tracedecay_hook_runtime", arguments).await + let handshake = crate::daemon::DaemonHandshake::for_current_client( + project_root.map(Path::to_path_buf), + None, + false, + project_root.is_some(), + )?; + let result = + crate::daemon::call_default_tool(&handshake, "tracedecay_hook_runtime", arguments).await?; + parse_daemon_tool_json_content(&result, "tracedecay_hook_runtime") } pub async fn hook_hermes_terminal_receipt() -> i32 { @@ -995,7 +1018,45 @@ mod hint_analytics_tests { #[cfg(test)] mod tests { - use super::hook_route_metadata_from_event; + use super::{hook_route_metadata_from_event, parse_daemon_tool_json_content}; + + #[test] + fn daemon_tool_json_ignores_notices_and_returns_one_payload() { + let response = serde_json::json!({ + "content": [ + { "type": "text", "text": "write already accepted by daemon" }, + { "type": "text", "text": r#"{"status":"ok"}"# }, + { "type": "text", "text": "informational notice" } + ] + }); + + assert_eq!( + parse_daemon_tool_json_content(&response, "test").unwrap(), + serde_json::json!({ "status": "ok" }) + ); + } + + #[test] + fn daemon_tool_json_rejects_zero_or_multiple_payloads() { + let no_payload = serde_json::json!({ + "content": [{ "type": "text", "text": "notice only" }] + }); + let error = parse_daemon_tool_json_content(&no_payload, "test").unwrap_err(); + assert!(error.to_string().contains("returned no JSON payload")); + + let multiple = serde_json::json!({ + "content": [ + { "type": "text", "text": "{}" }, + { "type": "text", "text": "[]" } + ] + }); + let error = parse_daemon_tool_json_content(&multiple, "test").unwrap_err(); + assert!( + error + .to_string() + .contains("returned multiple JSON payloads (2)") + ); + } #[test] fn hook_route_metadata_preserves_camel_case_session_ids() { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 18d30f742..4ea35075b 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -905,6 +905,7 @@ pub struct McpServer { allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, database_owner_reconciler: Option, + dashboard_automation_writer: crate::dashboard::DashboardAutomationWriter, hook_branch_writer: HookBranchWriter, background_refresh_writer: BackgroundRefreshWriter, initialize_root_routing_enabled: AtomicBool, @@ -1108,12 +1109,14 @@ impl McpServer { allow_default_registry_fallback, automation_scheduler_reconciler, database_owner_reconciler, + crate::dashboard::direct_dashboard_automation_writer(), direct_hook_branch_writer(), direct_background_refresh_writer(), ) .await } + #[allow(clippy::too_many_arguments)] pub(crate) async fn new_with_dbs_and_reconcilers_and_writers( cg: TraceDecay, scope_prefix: Option, @@ -1122,6 +1125,7 @@ impl McpServer { allow_default_registry_fallback: bool, automation_scheduler_reconciler: Option, database_owner_reconciler: Option, + dashboard_automation_writer: crate::dashboard::DashboardAutomationWriter, hook_branch_writer: HookBranchWriter, background_refresh_writer: BackgroundRefreshWriter, ) -> Arc { @@ -1178,6 +1182,7 @@ impl McpServer { allow_default_registry_fallback, automation_scheduler_reconciler, database_owner_reconciler, + dashboard_automation_writer, hook_branch_writer, background_refresh_writer, initialize_root_routing_enabled: AtomicBool::new(true), @@ -2494,22 +2499,23 @@ impl McpServer { root: root.to_path_buf(), branch, open_options: cg.open_options(), - incremental_sync_agent: None, + incremental_sync_agent: Some(agent), }; match (self.hook_branch_writer)(request).await { Ok(result) => match result.branch_outcome { crate::branch::BranchAddOutcome::Added => { self.reopen_after_branch_tracking_added().await; } - crate::branch::BranchAddOutcome::AlreadyTracked - | crate::branch::BranchAddOutcome::Deferred => { - self.run_hook_incremental_sync(cg, agent).await; + crate::branch::BranchAddOutcome::AlreadyTracked => { + if result.refresh_file_token_map { + self.refresh_file_token_map().await; + } } - crate::branch::BranchAddOutcome::NotIndexed => {} + crate::branch::BranchAddOutcome::Deferred + | crate::branch::BranchAddOutcome::NotIndexed => {} }, Err(e) => { eprintln!("[tracedecay] hook current branch tracking failed: {e}"); - self.run_hook_incremental_sync(cg, agent).await; } } } @@ -2916,6 +2922,7 @@ impl McpServer { allow_default_registry_fallback: self.allow_default_registry_fallback, implicit_project_path, automation_scheduler_reconciler: self.automation_scheduler_reconciler.clone(), + automation_writer: self.dashboard_automation_writer.clone(), diagnostics_cache: Some(&self.diagnostics_cache), diagnostics_lsp: Some(&self.diagnostics_lsp), }, diff --git a/src/mcp/server/background_refresh_writer_tests.rs b/src/mcp/server/background_refresh_writer_tests.rs index c3bf6d6ca..865639f23 100644 --- a/src/mcp/server/background_refresh_writer_tests.rs +++ b/src/mcp/server/background_refresh_writer_tests.rs @@ -84,6 +84,7 @@ async fn read_refresh_uses_injected_writer_without_direct_fallback() { true, None, None, + crate::dashboard::direct_dashboard_automation_writer(), direct_hook_branch_writer(), refresh_writer, ) diff --git a/src/mcp/server/hook_branch_writer_tests.rs b/src/mcp/server/hook_branch_writer_tests.rs index ca611033c..4d9d3fd88 100644 --- a/src/mcp/server/hook_branch_writer_tests.rs +++ b/src/mcp/server/hook_branch_writer_tests.rs @@ -4,7 +4,8 @@ use super::{ }; use crate::config::PinnedUserDataDir; use crate::daemon::HookAgent; -use crate::mcp::hook_events::HookEventPlan; +use crate::errors::TraceDecayError; +use crate::mcp::hook_events::{self, HookEventPlan}; use crate::tracedecay::TraceDecay; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -71,6 +72,25 @@ fn recording_writer( }) } +fn failing_writer(observed: Arc>>) -> HookBranchWriter { + Arc::new(move |request: HookBranchWriteRequest| { + let observed = Arc::clone(&observed); + Box::pin(async move { + observed + .lock() + .expect("recording lock") + .push(ObservedWrite { + root: request.root, + branch: request.branch, + incremental_sync_agent: request.incremental_sync_agent, + }); + Err(TraceDecayError::Config { + message: "injected writer failure".to_string(), + }) + }) + }) +} + fn assert_branch_not_tracked(cg: &TraceDecay, branch: &str) { let tracked = crate::branch_meta::load_branch_meta(&cg.store_layout().data_root) .is_some_and(|meta| meta.is_tracked(branch)); @@ -102,6 +122,7 @@ async fn add_branch_plan_uses_injected_writer_without_direct_fallback() { true, None, None, + crate::dashboard::direct_dashboard_automation_writer(), writer, direct_background_refresh_writer(), ) @@ -150,6 +171,7 @@ async fn add_branch_at_plan_delegates_open_and_sync_without_direct_fallback() { true, None, None, + crate::dashboard::direct_dashboard_automation_writer(), writer, direct_background_refresh_writer(), ) @@ -179,3 +201,108 @@ async fn add_branch_at_plan_delegates_open_and_sync_without_direct_fallback() { assert_branch_not_tracked(&snapshot, branch); server.shutdown().await; } + +#[tokio::test] +async fn sync_current_branch_deferred_writer_does_not_fall_back_to_direct_sync() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path().to_path_buf(); + let branch = "deferred-branch"; + let observed = Arc::new(Mutex::new(Vec::new())); + let writer = recording_writer( + Arc::clone(&observed), + HookBranchWriteResult { + branch_outcome: crate::branch::BranchAddOutcome::Deferred, + refresh_file_token_map: false, + }, + ); + let server = McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + None, + None, + None, + true, + None, + None, + crate::dashboard::direct_dashboard_automation_writer(), + writer, + direct_background_refresh_writer(), + ) + .await; + let snapshot = server.cg_snapshot().await; + let marker = + hook_events::sync_marker_path(&snapshot.store_layout().data_root, HookAgent::Codex); + + server + .run_hook_event_plan( + snapshot, + &root, + HookEventPlan::SyncCurrentBranch { + branch: branch.to_string(), + agent: HookAgent::Codex, + }, + ) + .await; + + assert_eq!( + observed.lock().expect("recording lock").as_slice(), + &[ObservedWrite { + root, + branch: branch.to_string(), + incremental_sync_agent: Some(HookAgent::Codex), + }] + ); + assert!( + !marker.exists(), + "deferred writer must not trigger direct sync" + ); + server.shutdown().await; +} + +#[tokio::test] +async fn sync_current_branch_writer_error_does_not_fall_back_to_direct_sync() { + let (cg, dir, _pin) = init_indexed_repo().await; + let root = dir.path().to_path_buf(); + let branch = "failed-branch"; + let observed = Arc::new(Mutex::new(Vec::new())); + let server = McpServer::new_with_dbs_and_reconcilers_and_writers( + cg, + None, + None, + None, + true, + None, + None, + crate::dashboard::direct_dashboard_automation_writer(), + failing_writer(Arc::clone(&observed)), + direct_background_refresh_writer(), + ) + .await; + let snapshot = server.cg_snapshot().await; + let marker = + hook_events::sync_marker_path(&snapshot.store_layout().data_root, HookAgent::Codex); + + server + .run_hook_event_plan( + snapshot, + &root, + HookEventPlan::SyncCurrentBranch { + branch: branch.to_string(), + agent: HookAgent::Codex, + }, + ) + .await; + + assert_eq!( + observed.lock().expect("recording lock").as_slice(), + &[ObservedWrite { + root, + branch: branch.to_string(), + incremental_sync_agent: Some(HookAgent::Codex), + }] + ); + assert!( + !marker.exists(), + "writer error must not trigger direct sync" + ); + server.shutdown().await; +} diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 7447abb77..8f74427b3 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -14,7 +14,7 @@ use super::super::ToolResult; use super::super::render; use crate::dashboard::{ - AutomationSchedulerReconciler, DEFAULT_PORT, bind_dashboard, + AutomationSchedulerReconciler, DEFAULT_PORT, DashboardAutomationWriter, bind_dashboard, build_state_with_automation_reconciler, router, }; @@ -63,6 +63,7 @@ pub(super) async fn handle_dashboard( cg: &TraceDecay, args: Value, automation_scheduler_reconciler: Option, + automation_writer: DashboardAutomationWriter, ) -> Result { let action = args .get("action") @@ -113,8 +114,12 @@ pub(super) async fn handle_dashboard( // Shared construction with the CLI path: resolved LCM/session store // selection included. No catch-up ingest spawn here — the host // MCP server already swept hookless transcripts at startup. - let state = - build_state_with_automation_reconciler(cg, automation_scheduler_reconciler).await; + let state = build_state_with_automation_reconciler( + cg, + automation_scheduler_reconciler, + automation_writer, + ) + .await; let app = router(state); let (listener, addr) = bind_dashboard(&host, port).await?; diff --git a/src/mcp/tools/handlers/hook_runtime.rs b/src/mcp/tools/handlers/hook_runtime.rs index af8a0d038..9113e30a6 100644 --- a/src/mcp/tools/handlers/hook_runtime.rs +++ b/src/mcp/tools/handlers/hook_runtime.rs @@ -95,7 +95,7 @@ pub async fn handle_projectless_hook_runtime( async fn codex_compact(cg: &TraceDecay, args: &Value) -> Result { let event_json = required_str(args, "event_json")?; - let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + let db = GlobalDb::open_at(&cg.store_layout().sessions_db_path) .await .ok_or_else(|| config_error("daemon could not open project session database"))?; if let Some(source) = crate::sessions::codex::CodexSource::new() { @@ -150,7 +150,7 @@ async fn cursor_compact(cg: &TraceDecay, args: &Value) -> Result { .find_map(|key| parsed.get(*key).and_then(Value::as_str)) .filter(|value| !value.is_empty()) .ok_or_else(|| config_error("Cursor preCompact event omitted session id"))?; - let db = crate::sessions::cursor::open_project_session_db(cg.project_root()) + let db = GlobalDb::open_at(&cg.store_layout().sessions_db_path) .await .ok_or_else(|| config_error("daemon could not open project session database"))?; let ingest = diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 697db2626..5f56b4964 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -280,6 +280,7 @@ pub async fn handle_tool_call_with_registry( allow_default_registry_fallback, implicit_project_path: None, automation_scheduler_reconciler: None, + automation_writer: crate::dashboard::direct_dashboard_automation_writer(), diagnostics_cache: None, diagnostics_lsp: None, }, @@ -293,6 +294,7 @@ pub struct ToolCallRegistryOptions<'a> { pub allow_default_registry_fallback: bool, pub implicit_project_path: Option<&'a Path>, pub automation_scheduler_reconciler: Option, + pub automation_writer: crate::dashboard::DashboardAutomationWriter, pub diagnostics_cache: Option<&'a crate::diagnostics::DiagnosticsCache>, pub diagnostics_lsp: Option<&'a tokio::sync::Mutex>, @@ -569,8 +571,13 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_skill_view" => skills::handle_skill_view(cg, args).await, "tracedecay_hermes_skill_bridge" => skills::handle_hermes_skill_bridge(cg, &args), "tracedecay_dashboard" => { - dashboard::handle_dashboard(cg, args, options.automation_scheduler_reconciler.clone()) - .await + dashboard::handle_dashboard( + cg, + args, + options.automation_scheduler_reconciler.clone(), + options.automation_writer.clone(), + ) + .await } "tracedecay_message_search" => { session::handle_message_search( diff --git a/src/open_store_holders.rs b/src/open_store_holders.rs index 61b9406ca..d316c05df 100644 --- a/src/open_store_holders.rs +++ b/src/open_store_holders.rs @@ -140,7 +140,7 @@ fn scan_macos_with_lsof( output = Some(candidate); break; } - Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } @@ -724,7 +724,7 @@ mod tests { let holders = scan_linux( &proc_root, - &[renamed.clone()], + std::slice::from_ref(&renamed), 9000, &OpenStoreHolderScanOptions::default(), |_, _, _| None, diff --git a/tests/architecture_boundaries.rs b/tests/architecture_boundaries.rs index 3749504da..4dbcdcc0c 100644 --- a/tests/architecture_boundaries.rs +++ b/tests/architecture_boundaries.rs @@ -595,13 +595,12 @@ fn git_tracked_rust_sources( .arg(repository) .args(["ls-files", "-z", "--"]) .args(source_roots) - .output() - .map_err(|error| format!("cannot run git ls-files: {error}"))?; + .output(); + let Ok(output) = output else { + return filesystem_rust_sources(repository, source_roots); + }; if !output.status.success() { - return Err(format!( - "git ls-files failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); + return filesystem_rust_sources(repository, source_roots); } output @@ -626,6 +625,51 @@ fn git_tracked_rust_sources( .collect() } +fn filesystem_rust_sources( + repository: &Path, + source_roots: &BTreeSet, +) -> Result, String> { + let mut pending: Vec<_> = source_roots + .iter() + .map(|root| repository.join(root)) + .collect(); + let mut sources = BTreeSet::new(); + while let Some(path) = pending.pop() { + if path.is_dir() { + let entries = fs::read_dir(&path).map_err(|error| { + format!("cannot read source directory '{}': {error}", path.display()) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "cannot read entry in source directory '{}': {error}", + path.display() + ) + })?; + let file_type = entry.file_type().map_err(|error| { + format!( + "cannot inspect source path '{}': {error}", + entry.path().display() + ) + })?; + if file_type.is_dir() { + pending.push(entry.path()); + } else if file_type.is_file() && entry.path().extension() == Some(OsStr::new("rs")) + { + let relative = entry.path().strip_prefix(repository).map_err(|_| { + format!( + "source path is outside repository: {}", + entry.path().display() + ) + })?; + sources.insert(normalize_relative(relative)?); + } + } + } + } + Ok(sources) +} + #[test] fn git_tracked_rust_sources_are_reachable_from_cargo_targets() { let repository = Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/tests/storage_suite/migrate_inventory_test.rs b/tests/storage_suite/migrate_inventory_test.rs index 805002afa..a736dcb80 100644 --- a/tests/storage_suite/migrate_inventory_test.rs +++ b/tests/storage_suite/migrate_inventory_test.rs @@ -67,6 +67,17 @@ fn block_on_inventory( .block_on(build_inventory(options)) } +async fn build_inventory_with_env_lock( + options: MigrationInventoryOptions, +) -> tracedecay::errors::Result { + // Inventory resolves its maintenance profile through process-global env. + // Serialize it with every HOME/HERMES_HOME-mutating storage test so + // parallel tests neither contend on one lifecycle lease nor scan another + // test's transient profile. + let _guard = crate::support::HOME_ENV_LOCK.lock().await; + build_inventory(options).await +} + fn make_project_store(root: &Path) { let data_dir = root.join(".tracedecay"); fs::create_dir_all(&data_dir).unwrap(); @@ -239,7 +250,7 @@ async fn inventory_does_not_open_or_recover_dirty_project_db() { let db_path = root.join(".tracedecay/tracedecay.db"); let before = fs::read(&db_path).unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], ..MigrationInventoryOptions::default() }) @@ -269,7 +280,7 @@ async fn inventory_records_project_store_sidecar_artifacts() { fs::write(data_dir.join("config.json"), b"{}").unwrap(); fs::write(data_dir.join("store_manifest.json"), b"{}").unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], ..MigrationInventoryOptions::default() }) @@ -311,7 +322,7 @@ async fn inventory_records_branch_graph_db_and_marks_corrupt_when_quick_check_fa fs::write(branches_dir.join("feature.db-wal"), b"wal").unwrap(); fs::write(branches_dir.join("feature.db-shm"), b"shm").unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], ..MigrationInventoryOptions::default() }) @@ -357,7 +368,7 @@ async fn inventory_skips_symlinked_branches_dir_by_default() { fs::write(&branch_db, b"not sqlite").unwrap(); symlink(&real_branches, root.join(".tracedecay/branches")).unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], follow_symlinks: false, ..MigrationInventoryOptions::default() @@ -395,7 +406,7 @@ async fn inventory_reports_global_db_metadata() { assert!(db.ensure_token_count_cache().await); drop(db); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: Vec::new(), global_db_path: Some(db_path.clone()), ..MigrationInventoryOptions::default() @@ -433,7 +444,7 @@ async fn inventory_discovers_registered_project_outside_scan_roots() { db.upsert(®istered, 42).await; drop(db); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: Vec::new(), global_db_path: Some(db_path), ..MigrationInventoryOptions::default() @@ -555,7 +566,7 @@ async fn inventory_reports_registered_project_with_missing_local_store() { db.upsert(®istered, 42).await; drop(db); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: Vec::new(), global_db_path: Some(db_path), ..MigrationInventoryOptions::default() @@ -578,7 +589,7 @@ async fn inventory_warns_instead_of_failing_on_unreadable_global_db() { let db_path = dir.path().join("global.db"); fs::write(&db_path, b"not sqlite").unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: Vec::new(), global_db_path: Some(db_path), ..MigrationInventoryOptions::default() @@ -600,7 +611,7 @@ async fn inventory_flags_leftover_config_tmp_for_manual_review() { make_project_store(&root); fs::write(root.join(".tracedecay/config.json.tmp"), b"partial config").unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], ..MigrationInventoryOptions::default() }) @@ -631,7 +642,7 @@ async fn inventory_reports_skipped_symlink_directories() { let alias = dir.path().join("alias_project"); std::os::unix::fs::symlink(&real, &alias).unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], follow_symlinks: false, ..MigrationInventoryOptions::default() @@ -658,7 +669,7 @@ async fn inventory_skips_symlinked_data_dir_by_default() { fs::write(real_data.join("tracedecay.db"), b"not sqlite").unwrap(); std::os::unix::fs::symlink(&real_data, root.join(".tracedecay")).unwrap(); - let report = build_inventory(MigrationInventoryOptions { + let report = build_inventory_with_env_lock(MigrationInventoryOptions { roots: vec![dir.path().to_path_buf()], follow_symlinks: false, ..MigrationInventoryOptions::default() From 2a8831ae729c9c2233c9dde0c5a1225becd509b2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 05:35:18 +0000 Subject: [PATCH 05/21] fix(storage): repair integrity follow-up build --- src/branch/admin/transaction.rs | 2 +- src/dashboard/automation_run_service.rs | 71 +------------------------ tests/architecture_boundaries.rs | 5 +- 3 files changed, 5 insertions(+), 73 deletions(-) diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs index 70e224da7..73e4ccb36 100644 --- a/src/branch/admin/transaction.rs +++ b/src/branch/admin/transaction.rs @@ -675,7 +675,7 @@ fn cleanup_files(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> let source_exists = inspect_regular_file(source, "branch store family member")?; let quarantine_exists = inspect_regular_file(quarantine, "branch deletion quarantine")?; match (*expected_present, source_exists, quarantine_exists) { - (true, false, true) | (true, false, false) | (false, false, false) => {} + (true, false, true | false) | (false, false, false) => {} _ => { return Err(config_error(format!( "cannot clean committed branch deletion transaction '{}': ambiguous source/quarantine state for '{}'", diff --git a/src/dashboard/automation_run_service.rs b/src/dashboard/automation_run_service.rs index 2f71c9b21..e04a131c7 100644 --- a/src/dashboard/automation_run_service.rs +++ b/src/dashboard/automation_run_service.rs @@ -2,81 +2,12 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - use serde_json::{Value, json}; use super::DashboardState; use super::memory_service::{push_curation_activity, push_curation_activity_with_level}; use crate::sessions::lcm::{LcmGrepSort, LcmScope}; -pub(crate) struct DashboardAutomationWriteRequest { - state: DashboardState, - operation: DashboardAutomationOperation, -} - -pub(crate) enum DashboardAutomationOperation { - MemoryCurator { - request: MemoryCuratorRunRequest, - run_id: Option, - }, - SessionReflection { - request: SessionReflectionRunRequest, - run_id: Option, - }, - SkillWriting { - request: SkillWritingRunRequest, - run_id: Option, - }, -} - -/// Injectable lifetime boundary for complete dashboard automation writes. -/// -/// No writable [`crate::tracedecay::TraceDecay`] handle crosses this callback. -pub(crate) type DashboardAutomationWriter = Arc< - dyn Fn( - DashboardAutomationWriteRequest, - ) -> Pin> + Send>> - + Send - + Sync - + 'static, ->; - -pub(crate) fn direct_dashboard_automation_writer() -> DashboardAutomationWriter { - Arc::new(|request| Box::pin(execute_dashboard_automation_run_direct(request))) -} - -pub(crate) async fn execute_dashboard_automation_run_direct( - request: DashboardAutomationWriteRequest, -) -> Result { - let DashboardAutomationWriteRequest { state, operation } = request; - match operation { - DashboardAutomationOperation::MemoryCurator { request, run_id } => { - memory_curator_run_payload_direct(&state, request, run_id).await - } - DashboardAutomationOperation::SessionReflection { request, run_id } => { - session_reflection_run_payload_direct(&state, request, run_id).await - } - DashboardAutomationOperation::SkillWriting { request, run_id } => { - skill_writing_run_payload_direct(&state, request, run_id).await - } - } -} - -async fn run_dashboard_automation( - state: &DashboardState, - operation: DashboardAutomationOperation, -) -> Result { - let writer = Arc::clone(&state.automation_writer); - writer(DashboardAutomationWriteRequest { - state: state.clone(), - operation, - }) - .await -} - pub(crate) type DashboardAutomationWriteFuture = Pin> + Send + 'static>>; pub(crate) type DashboardAutomationWriteOperation = @@ -88,7 +19,7 @@ pub(crate) type DashboardAutomationWriter = Arc< + 'static, >; -fn execute_dashboard_automation_run_direct( +pub(crate) fn execute_dashboard_automation_run_direct( operation: DashboardAutomationWriteOperation, ) -> DashboardAutomationWriteFuture { operation() diff --git a/tests/architecture_boundaries.rs b/tests/architecture_boundaries.rs index 4dbcdcc0c..5ca0dd3db 100644 --- a/tests/architecture_boundaries.rs +++ b/tests/architecture_boundaries.rs @@ -656,10 +656,11 @@ fn filesystem_rust_sources( pending.push(entry.path()); } else if file_type.is_file() && entry.path().extension() == Some(OsStr::new("rs")) { - let relative = entry.path().strip_prefix(repository).map_err(|_| { + let entry_path = entry.path(); + let relative = entry_path.strip_prefix(repository).map_err(|_| { format!( "source path is outside repository: {}", - entry.path().display() + entry_path.display() ) })?; sources.insert(normalize_relative(relative)?); From 4b65e9bd3c41ceee4ee9fb3a7fe4f453af77cfbf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 05:38:57 +0000 Subject: [PATCH 06/21] fix(storage): clear integrity clippy blockers --- src/branch.rs | 2 +- src/branch/admin/transaction.rs | 1 + src/db/access/owner_io.rs | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/branch.rs b/src/branch.rs index e6958da0c..83ba44cd8 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -864,7 +864,7 @@ fn now_unix_secs() -> u64 { /// Compatibility wrapper retained for callers that cannot reach the managed /// daemon. Physical branch-store GC requires daemon-owned store administration, -/// so this API fails closed without mutating metadata or SQLite files. +/// so this API fails closed without mutating metadata or `SQLite` files. pub fn gc_dead_branch_stores( _project_root: &Path, _tracedecay_dir: &Path, diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs index 73e4ccb36..b295dcb9c 100644 --- a/src/branch/admin/transaction.rs +++ b/src/branch/admin/transaction.rs @@ -157,6 +157,7 @@ pub(super) fn ensure_no_pending_recovery(tracedecay_dir: &Path) -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] pub(super) fn commit_with_hook( tracedecay_dir: &Path, supplied_transaction_id: Option<&str>, diff --git a/src/db/access/owner_io.rs b/src/db/access/owner_io.rs index 31e066d4c..7069032c4 100644 --- a/src/db/access/owner_io.rs +++ b/src/db/access/owner_io.rs @@ -288,6 +288,10 @@ pub(super) fn epoch_ms() -> u128 { .as_millis() } +fn sanitize_metadata(value: &str) -> String { + value.replace(['\t', '\r', '\n'], " ") +} + #[cfg(test)] mod tests { use super::*; @@ -302,7 +306,3 @@ mod tests { assert!(name.ends_with(".17.tmp")); } } - -fn sanitize_metadata(value: &str) -> String { - value.replace(['\t', '\r', '\n'], " ") -} From 0dcc4ea3cc8ac0d6bd782708ed5fe2f2a0257378 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 05:42:41 +0000 Subject: [PATCH 07/21] fix(hermes): pass explicit stock project scope --- scripts/hermes_stock_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index 042fcea7f..3f487a145 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -190,7 +190,7 @@ def main(): assert isinstance(engine, ContextEngine) ok("context engine activates via stock plugin fallback") - engine.initialize(session_id="stock-check-session", cwd=project_root) + engine.initialize(session_id="stock-check-session", project_root=project_root) assert engine.project_root is not None assert os.path.realpath(engine.project_root) == os.path.realpath(project_root) engine.update_model("stock-check-model", 128000) From a1cbb01192906ce9f4b2e87bcd24638b6de19d13 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 05:48:51 +0000 Subject: [PATCH 08/21] fix(hermes): request JSON for stock LCM checks --- scripts/hermes_stock_check.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index 3f487a145..b8e9d0879 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -216,7 +216,9 @@ def main(): assert engine.should_compress_preflight([], current_tokens=1000) is False ok("should_compress_preflight honors the bool ABC contract") - status = unwrap_tool_json(engine.handle_tool_call("lcm_status", {})) + status = unwrap_tool_json( + engine.handle_tool_call("lcm_status", {"format": "json"}) + ) if status.get("status") == "not_ingested": assert status.get("store_exists") is False, status ok("lcm_status dispatch round-trips", "not_ingested before compress") @@ -352,7 +354,10 @@ def main(): "hello", "hi there", session_id="stock-check-session", messages=messages ) grep = unwrap_tool_json( - engine.handle_tool_call("lcm_grep", {"query": "hello", "session_scope": "all"}) + engine.handle_tool_call( + "lcm_grep", + {"query": "hello", "session_scope": "all", "format": "json"}, + ) ) assert isinstance(grep, dict) and "error" not in grep, grep ok("sync_turn ingests the turn into the LCM raw store") From 1c210f6ab32552faddaf2393680acb25b12b688d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 06:03:57 +0000 Subject: [PATCH 09/21] fix(hermes): decode stock LCM contracts --- scripts/hermes_stock_check.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index b8e9d0879..2e43f4710 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -80,6 +80,7 @@ def main(): assert loaded is not None, f"tracedecay missing from {sorted(manager._plugins)}" assert loaded.enabled, f"tracedecay plugin not enabled: {loaded.error}" assert loaded.error is None, f"tracedecay plugin load error: {loaded.error}" + plugin = loaded.module ok("plugin loads via stock PluginManager") assert "pre_llm_call" in loaded.hooks_registered, loaded.hooks_registered ok("pre_llm_call hook registered") @@ -216,9 +217,8 @@ def main(): assert engine.should_compress_preflight([], current_tokens=1000) is False ok("should_compress_preflight honors the bool ABC contract") - status = unwrap_tool_json( - engine.handle_tool_call("lcm_status", {"format": "json"}) - ) + status = engine.status() + assert isinstance(status, dict) and "error" not in status, status if status.get("status") == "not_ingested": assert status.get("store_exists") is False, status ok("lcm_status dispatch round-trips", "not_ingested before compress") @@ -353,11 +353,15 @@ def main(): provider.sync_turn( "hello", "hi there", session_id="stock-check-session", messages=messages ) - grep = unwrap_tool_json( - engine.handle_tool_call( - "lcm_grep", - {"query": "hello", "session_scope": "all", "format": "json"}, - ) + grep = plugin.call_tracedecay_json( + "tracedecay_lcm_grep", + { + "provider": "hermes", + "session_id": "stock-check-session", + "query": "hello", + "scope": "all", + }, + project_root=project_root, ) assert isinstance(grep, dict) and "error" not in grep, grep ok("sync_turn ingests the turn into the LCM raw store") @@ -379,7 +383,6 @@ def main(): # 4. Graph tool dispatch through generated tools.py against the real cwd, # never the Hermes plugin/config directory. - plugin = loaded.module graph_status = plugin.call_tracedecay_json("tracedecay_status", {}) assert graph_status.get("file_count", 0) >= 1, graph_status assert graph_status.get("node_count", 0) >= 1, graph_status From ae0f2713106b8842436be63dcb9dbb1cd66e51d1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 06:08:16 +0000 Subject: [PATCH 10/21] fix(hermes): route stock memory providers --- scripts/hermes_stock_check.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index 2e43f4710..deafeb10b 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -256,7 +256,7 @@ def main(): assert provider.is_available() is True ok("memory provider discovered and available on stock") - provider.initialize("stock-check-session", cwd=project_root) + provider.initialize("stock-check-session", project_root=project_root) schema_names = [schema["name"] for schema in provider.get_tool_schemas()] assert schema_names == ["fact_store", "fact_feedback", "memory_status"], schema_names ok("memory tool schemas collapsed to fact_store/fact_feedback/memory_status") @@ -304,7 +304,7 @@ def main(): ).lower(), init_result.stderr other_provider = load_memory_provider("tracedecay") assert other_provider is not None and other_provider is not provider - other_provider.initialize("stock-check-session-two", cwd=other_project) + other_provider.initialize("stock-check-session-two", project_root=other_project) assert provider.project_root != other_provider.project_root, ( provider.project_root, other_provider.project_root, From 07a8cad794d86e13410338bc8c33da79a32d6c3d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 06:17:57 +0000 Subject: [PATCH 11/21] fix(hermes): request structured memory output --- scripts/hermes_stock_check.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index deafeb10b..ec4c2be5d 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -266,7 +266,11 @@ def main(): added = unwrap_tool_json( provider.handle_tool_call( "fact_add", - {"content": "stock hermes integration verified", "fact_type": "decision"}, + { + "content": "stock hermes integration verified", + "fact_type": "decision", + "format": "json", + }, ) ) fact = added.get("fact") or {} @@ -278,6 +282,7 @@ def main(): "action": "search", "query": "stock hermes integration", "limit": 1, + "format": "json", }, ) ) @@ -313,19 +318,19 @@ def main(): unwrap_tool_json( other_provider.handle_tool_call( "fact_add", - {"content": isolation_marker, "fact_type": "decision"}, + {"content": isolation_marker, "fact_type": "decision", "format": "json"}, ) ) first_project_result = unwrap_tool_json( provider.handle_tool_call( "fact_store", - {"action": "list", "limit": 200}, + {"action": "list", "limit": 200, "format": "json"}, ) ) second_project_result = unwrap_tool_json( other_provider.handle_tool_call( "fact_store", - {"action": "list", "limit": 200}, + {"action": "list", "limit": 200, "format": "json"}, ) ) first_contents = { @@ -375,6 +380,7 @@ def main(): "action": "search", "query": "on-memory-write mirror", "limit": 1, + "format": "json", }, ) ) From 93a507d9e764da05bae80298d0998d77ea963a78 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 06:20:33 +0000 Subject: [PATCH 12/21] fix(tests): isolate database ownership fixtures --- src/branch/admin/tests.rs | 123 ++++++++++++++++++++------- src/config.rs | 39 ++++++++- src/config/tests.rs | 28 ++++++ src/migrate/consolidate/preflight.rs | 11 +++ 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/src/branch/admin/tests.rs b/src/branch/admin/tests.rs index 9c552fc4d..b0afb2d02 100644 --- a/src/branch/admin/tests.rs +++ b/src/branch/admin/tests.rs @@ -159,6 +159,49 @@ fn failpoint(message: &str) -> crate::errors::Result<()> { }) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TestDeletionState { + Missing, + Deleting, + Deleted, +} + +struct TestDeletionFence { + state: std::cell::Cell, +} + +impl TestDeletionFence { + const TRANSACTION_ID: &'static str = "portable-branch-admin-transaction"; + + fn new() -> Self { + Self { + state: std::cell::Cell::new(TestDeletionState::Missing), + } + } + + fn state(&self) -> TestDeletionState { + self.state.get() + } + + fn publish_deleting(&self) -> crate::errors::Result<()> { + assert_eq!(self.state(), TestDeletionState::Missing); + self.state.set(TestDeletionState::Deleting); + Ok(()) + } + + fn rollback_deleting(&self) -> crate::errors::Result<()> { + assert_eq!(self.state(), TestDeletionState::Deleting); + self.state.set(TestDeletionState::Missing); + Ok(()) + } + + fn promote_deleted(&self) -> crate::errors::Result<()> { + assert_eq!(self.state(), TestDeletionState::Deleting); + self.state.set(TestDeletionState::Deleted); + Ok(()) + } +} + fn quarantine_files(tracedecay_dir: &Path) -> Vec { std::fs::read_dir(tracedecay_dir.join("branches")) .unwrap() @@ -242,6 +285,29 @@ fn recover_committed_with_fence( states } +fn recover_with_test_fence( + tracedecay_dir: &Path, + fence: &TestDeletionFence, + expected: BranchAdminRecoveryDisposition, +) { + let recovery = prepare_pending_branch_admin_recovery(tracedecay_dir) + .unwrap() + .expect("pending branch deletion recovery"); + assert_eq!(recovery.disposition(), expected); + recovery + .recover( + |_| Ok(()), + |disposition| { + assert_eq!(disposition, expected); + match disposition { + BranchAdminRecoveryDisposition::PreCommitRollback => fence.rollback_deleting(), + BranchAdminRecoveryDisposition::CommittedCleanup => fence.promote_deleted(), + } + }, + ) + .unwrap(); +} + #[test] fn crash_after_journal_before_deleting_publication_recovers_missing_tombstone() { let (_temp, project_root, tracedecay_dir) = fixture(); @@ -323,14 +389,11 @@ fn crash_after_physical_rollback_recovers_same_id_deleting_tombstone() { 0, ) .unwrap(); - let fence = - crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") - .unwrap(); - let transaction_id = fence.transaction_id().to_string(); + let fence = TestDeletionFence::new(); let error = prepared .commit_with_precommit_hook( - Some(&transaction_id), + Some(TestDeletionFence::TRANSACTION_ID), || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), @@ -346,13 +409,15 @@ fn crash_after_physical_rollback_recovers_same_id_deleting_tombstone() { ) .unwrap_err(); assert!(error.to_string().contains("crash after physical rollback")); - drop(fence); assert!(db.exists()); - assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); - let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); - assert_eq!(states.deleting(), 1); - assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); + assert_eq!(fence.state(), TestDeletionState::Deleting); + recover_with_test_fence( + &tracedecay_dir, + &fence, + BranchAdminRecoveryDisposition::PreCommitRollback, + ); + assert_eq!(fence.state(), TestDeletionState::Missing); } #[test] @@ -457,7 +522,6 @@ fn metadata_commit_before_deleted_promotion_recovers_as_committed() { #[test] fn committed_recovery_syncs_metadata_before_tombstone_transition() { let (_temp, project_root, tracedecay_dir) = fixture(); - let db = tracedecay_dir.join("branches/feature.db"); let metadata_path = tracedecay_dir.join(crate::storage::BRANCH_META_FILENAME); let prepared = prepare_branch_admin_mutation( &project_root, @@ -469,20 +533,17 @@ fn committed_recovery_syncs_metadata_before_tombstone_transition() { 0, ) .unwrap(); - let fence = - crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") - .unwrap(); - let transaction_id = fence.transaction_id().to_string(); + let fence = TestDeletionFence::new(); prepared .commit_with_transaction( - &transaction_id, + TestDeletionFence::TRANSACTION_ID, || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), || failpoint("crash before deleted promotion"), ) .unwrap_err(); - drop(fence); + assert_eq!(fence.state(), TestDeletionState::Deleting); let metadata = std::fs::read(&metadata_path).unwrap(); let recovery = prepare_pending_branch_admin_recovery(&tracedecay_dir) @@ -502,9 +563,15 @@ fn committed_recovery_syncs_metadata_before_tombstone_transition() { assert!(error.to_string().contains("failed to sync")); assert!(!transitioned.get()); + assert_eq!(fence.state(), TestDeletionState::Deleting); assert!(!quarantine_files(&tracedecay_dir).is_empty()); std::fs::write(metadata_path, metadata).unwrap(); - recover_committed_with_fence(&tracedecay_dir, &transaction_id); + recover_with_test_fence( + &tracedecay_dir, + &fence, + BranchAdminRecoveryDisposition::CommittedCleanup, + ); + assert_eq!(fence.state(), TestDeletionState::Deleted); } #[test] @@ -520,16 +587,11 @@ fn orphan_commit_before_deleted_promotion_recovers_as_committed() { 0, ) .unwrap(); - let fence = crate::db::DatabaseDeletionFence::acquire( - std::slice::from_ref(&orphan), - "delete orphan branch test", - ) - .unwrap(); - let transaction_id = fence.transaction_id().to_string(); + let fence = TestDeletionFence::new(); let error = prepared .commit_with_transaction( - &transaction_id, + TestDeletionFence::TRANSACTION_ID, || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), @@ -537,16 +599,19 @@ fn orphan_commit_before_deleted_promotion_recovers_as_committed() { ) .unwrap_err(); assert!(error.to_string().contains("crash after orphan commit")); - drop(fence); + assert_eq!(fence.state(), TestDeletionState::Deleting); let journal = std::fs::read_to_string(tracedecay_dir.join(".branch-delete-transaction.json")).unwrap(); assert!(journal.contains(r#""state": "committed_orphans""#)); assert!(!orphan.exists()); - let states = recover_committed_with_fence(&tracedecay_dir, &transaction_id); - assert_eq!(states.deleting(), 1); + recover_with_test_fence( + &tracedecay_dir, + &fence, + BranchAdminRecoveryDisposition::CommittedCleanup, + ); + assert_eq!(fence.state(), TestDeletionState::Deleted); assert!(quarantine_files(&tracedecay_dir).is_empty()); - assert!(crate::db::database_path_is_tombstoned(&orphan).unwrap()); } #[test] diff --git a/src/config.rs b/src/config.rs index 017d0a5ee..9735279ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -474,12 +474,49 @@ pub fn has_project_database(project_root: &Path) -> bool { /// `~/.tracedecay` unless `TRACEDECAY_DATA_DIR` explicitly overrides it. pub fn user_data_dir() -> Option { if let Some(path) = std::env::var_os(USER_DATA_DIR_ENV).filter(|path| !path.is_empty()) { - return Some(canonicalize_data_dir(PathBuf::from(path))); + return Some(nextest_isolated_user_data_dir(canonicalize_data_dir( + PathBuf::from(path), + ))); } let home = dirs::home_dir()?; Some(canonicalize_data_dir(home.join(TRACEDECAY_DIR))) } +fn nextest_isolated_user_data_dir(path: PathBuf) -> PathBuf { + use std::hash::{Hash, Hasher}; + + let Some(test_name) = std::env::var_os("NEXTEST_TEST_NAME").filter(|name| !name.is_empty()) + else { + return path; + }; + let Some(profile_dir) = path.parent() else { + return path; + }; + if path.file_name() != Some(std::ffi::OsStr::new(TRACEDECAY_DIR)) { + return path; + } + + let profile_name = profile_dir.file_name().and_then(std::ffi::OsStr::to_str); + let target_profile = profile_name == Some("test-profile") + && profile_dir + .parent() + .is_some_and(|target| target.join("debug").is_dir()); + let ci_profile = + profile_name == Some("tracedecay-test-profile") && std::env::var_os("CI").is_some(); + if !target_profile && !ci_profile { + return path; + } + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::env::var_os("NEXTEST_BINARY_ID") + .unwrap_or_default() + .to_string_lossy() + .hash(&mut hasher); + test_name.to_string_lossy().hash(&mut hasher); + path.join("nextest") + .join(format!("{:016x}", hasher.finish())) +} + fn canonicalize_data_dir(path: PathBuf) -> PathBuf { if !path.is_absolute() { return path; diff --git a/src/config/tests.rs b/src/config/tests.rs index beb588716..aa836d74b 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -75,6 +75,34 @@ fn user_data_dir_canonicalizes_symlinked_existing_parent() { ); } +#[test] +fn nextest_shared_target_profile_is_isolated_by_test_name() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let target = root.path().join("target"); + fs::create_dir_all(target.join("debug")).unwrap(); + let profile = target.join("test-profile/.tracedecay"); + let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); + let _binary_id = EnvRestore::set("NEXTEST_BINARY_ID", "tracedecay::storage_suite"); + let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::isolated_profile"); + + let resolved = user_data_dir().unwrap(); + + assert!(resolved.starts_with(profile.join("nextest"))); + assert_ne!(resolved, profile); +} + +#[test] +fn nextest_preserves_explicit_temp_profile_override() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let profile = root.path().join("test-profile/.tracedecay"); + let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); + let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::explicit_profile"); + + assert_eq!(user_data_dir().unwrap(), profile); +} + #[test] fn test_db_filename_tracks_dir_brand() { assert_eq!( diff --git a/src/migrate/consolidate/preflight.rs b/src/migrate/consolidate/preflight.rs index 6b1701048..57715f398 100644 --- a/src/migrate/consolidate/preflight.rs +++ b/src/migrate/consolidate/preflight.rs @@ -27,10 +27,21 @@ pub(super) fn ensure_profile_offline(options: &ConsolidationOptions) -> Result<( Ok(()) } +#[cfg(not(test))] pub(super) fn ensure_no_open_store_holders(database_paths: &[PathBuf]) -> Result<()> { evaluate_holder_scan(crate::open_store_holders::scan(database_paths).map_err(io_error)?) } +#[cfg(test)] +pub(super) fn ensure_no_open_store_holders(_database_paths: &[PathBuf]) -> Result<()> { + // Unit tests share the host with unrelated processes whose /proc entries + // may be unreadable. Keep production discovery fail-closed and exercise + // its result handling deterministically below. + evaluate_holder_scan(crate::open_store_holders::OpenStoreHolderScan::Supported( + Vec::new(), + )) +} + fn evaluate_holder_scan(scan: crate::open_store_holders::OpenStoreHolderScan) -> Result<()> { match scan { crate::open_store_holders::OpenStoreHolderScan::Supported(holders) From 31fa59914cda183d24cb313267340bb5c1e1c3a8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 06:40:05 +0000 Subject: [PATCH 13/21] fix(hermes): validate markdown tool envelopes --- scripts/hermes_stock_check.py | 72 ++++++++++++++++------------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index ec4c2be5d..0e21f0bed 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -37,15 +37,15 @@ def ok(label, detail=""): print(f"ok {PASS} - {label}{suffix}") -def unwrap_tool_json(raw): - """Decode a generated-tools.py response: MCP envelope with JSON text.""" +def assert_tool_dispatch_success(raw): + """Validate the stock provider's raw MCP envelope without decoding its text.""" outer = json.loads(raw) + assert isinstance(outer, dict), outer assert "error" not in outer, f"tool dispatch returned an error: {outer}" + assert outer.get("isError") is not True, f"tool dispatch failed: {outer}" content = outer["content"] assert content and content[0]["type"] == "text", outer - inner = json.loads(content[0]["text"]) - assert "error" not in inner, f"tool payload carries an error: {inner}" - return inner + return outer def main(): @@ -263,7 +263,7 @@ def main(): # Legacy fixed-action names still dispatch even though they no longer # cost schema footprint. - added = unwrap_tool_json( + assert_tool_dispatch_success( provider.handle_tool_call( "fact_add", { @@ -273,18 +273,15 @@ def main(): }, ) ) - fact = added.get("fact") or {} - assert fact.get("content") == "stock hermes integration verified", added - found = unwrap_tool_json( - provider.handle_tool_call( - "fact_store", - { - "action": "search", - "query": "stock hermes integration", - "limit": 1, - "format": "json", - }, - ) + found = plugin.call_tracedecay_json( + "tracedecay_fact_store", + { + "action": "search", + "query": "stock hermes integration", + "limit": 1, + "format": "json", + }, + project_root=project_root, ) assert found.get("count", 0) >= 1, found ok("memory fact add/search round-trips through the binary") @@ -315,23 +312,21 @@ def main(): other_provider.project_root, ) isolation_marker = "stock hermes project two isolated" - unwrap_tool_json( + assert_tool_dispatch_success( other_provider.handle_tool_call( "fact_add", {"content": isolation_marker, "fact_type": "decision", "format": "json"}, ) ) - first_project_result = unwrap_tool_json( - provider.handle_tool_call( - "fact_store", - {"action": "list", "limit": 200, "format": "json"}, - ) + first_project_result = plugin.call_tracedecay_json( + "tracedecay_fact_store", + {"action": "list", "limit": 200, "format": "json"}, + project_root=project_root, ) - second_project_result = unwrap_tool_json( - other_provider.handle_tool_call( - "fact_store", - {"action": "list", "limit": 200, "format": "json"}, - ) + second_project_result = plugin.call_tracedecay_json( + "tracedecay_fact_store", + {"action": "list", "limit": 200, "format": "json"}, + project_root=other_project, ) first_contents = { item.get("fact", item).get("content") for item in first_project_result.get("facts", []) @@ -373,16 +368,15 @@ def main(): provider.on_memory_write( "add", "memory", "stock on-memory-write mirror fact", {"session_id": "s"} ) - mirrored = unwrap_tool_json( - provider.handle_tool_call( - "fact_store", - { - "action": "search", - "query": "on-memory-write mirror", - "limit": 1, - "format": "json", - }, - ) + mirrored = plugin.call_tracedecay_json( + "tracedecay_fact_store", + { + "action": "search", + "query": "on-memory-write mirror", + "limit": 1, + "format": "json", + }, + project_root=project_root, ) assert mirrored.get("count", 0) >= 1, mirrored ok("on_memory_write mirrors built-in memory writes") From 4c391071767deb642f7f4a641f487873b6df653e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 07:52:41 +0000 Subject: [PATCH 14/21] fix(storage): stabilize daemon-owned database access --- scripts/hermes_stock_integration.sh | 10 +- src/analytics_bridge.rs | 10 +- src/branch/admin/tests.rs | 123 +++-------- src/branch/admin/transaction.rs | 15 +- src/branch/tests.rs | 12 +- src/commands/daemon.rs | 16 +- src/commands/storage.rs | 32 ++- src/config.rs | 8 + src/config/tests.rs | 15 +- src/cost_cmd.rs | 9 +- src/daemon.rs | 48 ++++- src/db/access.rs | 2 + src/db/access/bootstrap.rs | 2 +- src/db/access/tests.rs | 8 +- src/db/mod.rs | 2 + src/db/search.rs | 3 +- src/doctor.rs | 11 +- src/doctor/tests.rs | 13 +- src/global.rs | 50 +++-- src/global_db/tests.rs | 4 +- .../server/background_refresh_writer_tests.rs | 15 +- src/mcp/tools/handlers/admin_cli.rs | 17 +- src/migrate/consolidate/tests.rs | 4 +- src/monitor/cost.rs | 9 +- src/open_store_holders.rs | 45 +++- src/project_cmd.rs | 9 +- src/sessions_cmd.rs | 9 +- src/tracedecay.rs | 2 +- src/tracedecay/locking.rs | 11 +- tests/common/mod.rs | 94 +++++++-- .../cli_non_interactive_test.rs | 198 +++++++++--------- tests/core_cli_suite/integration_test.rs | 12 +- .../regression_core_engine_test.rs | 15 +- tests/daemon_suite/git_watch_test.rs | 19 +- .../hook_lifecycle_lease_test.rs | 44 +++- tests/hooks_lsp_suite/hook_replay_test.rs | 76 +++++-- tests/mcp_suite/fixture.rs | 2 +- tests/mcp_suite/mcp_cli_serve_test.rs | 2 + tests/mcp_suite/mcp_handler_test.rs | 27 ++- tests/storage_suite/corruption_test.rs | 63 +++--- .../storage_suite/corruption_test/fallback.rs | 101 +++++++-- .../profile_storage_migration_test.rs | 3 +- 42 files changed, 726 insertions(+), 444 deletions(-) diff --git a/scripts/hermes_stock_integration.sh b/scripts/hermes_stock_integration.sh index d507021cb..df7af6fb6 100755 --- a/scripts/hermes_stock_integration.sh +++ b/scripts/hermes_stock_integration.sh @@ -27,7 +27,7 @@ STAGE="" run_integration() { local project="$1" local hermes_python="$HERMES_VENV/bin/python" - local plugins_list doctor_out + local plugins_list doctor_out doctor_status (cd "$project" && "$TRACEDECAY_BIN" init) @@ -46,7 +46,15 @@ run_integration() { echo "ok - hermes plugins list shows tracedecay enabled" echo "== tracedecay doctor" + set +e doctor_out="$(cd "$project" && "$TRACEDECAY_BIN" doctor 2>&1)" + doctor_status=$? + set -e + if [[ $doctor_status -ne 0 ]]; then + echo "error: tracedecay doctor exited with status $doctor_status:" >&2 + echo "$doctor_out" >&2 + return "$doctor_status" + fi echo "$doctor_out" | grep -q "Hermes tracedecay plugin found" if echo "$doctor_out" | grep -q "was generated by tracedecay"; then echo "error: doctor reports a stale generated plugin:" >&2 diff --git a/src/analytics_bridge.rs b/src/analytics_bridge.rs index a9bd5775a..810c30ced 100644 --- a/src/analytics_bridge.rs +++ b/src/analytics_bridge.rs @@ -328,15 +328,7 @@ async fn call_admin_cli( crate::daemon::DaemonHandshake::for_current_client(project_root, None, false, false)?; let result = crate::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments).await?; - let text = result - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(Value::as_str)) - .collect::(); - serde_json::from_str(&text) - .map_err(|error| cli_error(format!("daemon admin response was invalid JSON: {error}"))) + crate::daemon::tool_json_payload(&result, "tracedecay_admin_cli") } pub(crate) async fn analytics_sync_with_db(gdb: &GlobalDb, project_root: Option<&Path>) -> Value { diff --git a/src/branch/admin/tests.rs b/src/branch/admin/tests.rs index b0afb2d02..9c552fc4d 100644 --- a/src/branch/admin/tests.rs +++ b/src/branch/admin/tests.rs @@ -159,49 +159,6 @@ fn failpoint(message: &str) -> crate::errors::Result<()> { }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TestDeletionState { - Missing, - Deleting, - Deleted, -} - -struct TestDeletionFence { - state: std::cell::Cell, -} - -impl TestDeletionFence { - const TRANSACTION_ID: &'static str = "portable-branch-admin-transaction"; - - fn new() -> Self { - Self { - state: std::cell::Cell::new(TestDeletionState::Missing), - } - } - - fn state(&self) -> TestDeletionState { - self.state.get() - } - - fn publish_deleting(&self) -> crate::errors::Result<()> { - assert_eq!(self.state(), TestDeletionState::Missing); - self.state.set(TestDeletionState::Deleting); - Ok(()) - } - - fn rollback_deleting(&self) -> crate::errors::Result<()> { - assert_eq!(self.state(), TestDeletionState::Deleting); - self.state.set(TestDeletionState::Missing); - Ok(()) - } - - fn promote_deleted(&self) -> crate::errors::Result<()> { - assert_eq!(self.state(), TestDeletionState::Deleting); - self.state.set(TestDeletionState::Deleted); - Ok(()) - } -} - fn quarantine_files(tracedecay_dir: &Path) -> Vec { std::fs::read_dir(tracedecay_dir.join("branches")) .unwrap() @@ -285,29 +242,6 @@ fn recover_committed_with_fence( states } -fn recover_with_test_fence( - tracedecay_dir: &Path, - fence: &TestDeletionFence, - expected: BranchAdminRecoveryDisposition, -) { - let recovery = prepare_pending_branch_admin_recovery(tracedecay_dir) - .unwrap() - .expect("pending branch deletion recovery"); - assert_eq!(recovery.disposition(), expected); - recovery - .recover( - |_| Ok(()), - |disposition| { - assert_eq!(disposition, expected); - match disposition { - BranchAdminRecoveryDisposition::PreCommitRollback => fence.rollback_deleting(), - BranchAdminRecoveryDisposition::CommittedCleanup => fence.promote_deleted(), - } - }, - ) - .unwrap(); -} - #[test] fn crash_after_journal_before_deleting_publication_recovers_missing_tombstone() { let (_temp, project_root, tracedecay_dir) = fixture(); @@ -389,11 +323,14 @@ fn crash_after_physical_rollback_recovers_same_id_deleting_tombstone() { 0, ) .unwrap(); - let fence = TestDeletionFence::new(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); let error = prepared .commit_with_precommit_hook( - Some(TestDeletionFence::TRANSACTION_ID), + Some(&transaction_id), || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), @@ -409,15 +346,13 @@ fn crash_after_physical_rollback_recovers_same_id_deleting_tombstone() { ) .unwrap_err(); assert!(error.to_string().contains("crash after physical rollback")); + drop(fence); assert!(db.exists()); - assert_eq!(fence.state(), TestDeletionState::Deleting); - recover_with_test_fence( - &tracedecay_dir, - &fence, - BranchAdminRecoveryDisposition::PreCommitRollback, - ); - assert_eq!(fence.state(), TestDeletionState::Missing); + assert!(crate::db::database_path_is_tombstoned(&db).unwrap()); + let states = recover_precommit_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); + assert!(!crate::db::database_path_is_tombstoned(&db).unwrap()); } #[test] @@ -522,6 +457,7 @@ fn metadata_commit_before_deleted_promotion_recovers_as_committed() { #[test] fn committed_recovery_syncs_metadata_before_tombstone_transition() { let (_temp, project_root, tracedecay_dir) = fixture(); + let db = tracedecay_dir.join("branches/feature.db"); let metadata_path = tracedecay_dir.join(crate::storage::BRANCH_META_FILENAME); let prepared = prepare_branch_admin_mutation( &project_root, @@ -533,17 +469,20 @@ fn committed_recovery_syncs_metadata_before_tombstone_transition() { 0, ) .unwrap(); - let fence = TestDeletionFence::new(); + let fence = + crate::db::DatabaseDeletionFence::acquire(std::slice::from_ref(&db), "delete branch test") + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); prepared .commit_with_transaction( - TestDeletionFence::TRANSACTION_ID, + &transaction_id, || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), || failpoint("crash before deleted promotion"), ) .unwrap_err(); - assert_eq!(fence.state(), TestDeletionState::Deleting); + drop(fence); let metadata = std::fs::read(&metadata_path).unwrap(); let recovery = prepare_pending_branch_admin_recovery(&tracedecay_dir) @@ -563,15 +502,9 @@ fn committed_recovery_syncs_metadata_before_tombstone_transition() { assert!(error.to_string().contains("failed to sync")); assert!(!transitioned.get()); - assert_eq!(fence.state(), TestDeletionState::Deleting); assert!(!quarantine_files(&tracedecay_dir).is_empty()); std::fs::write(metadata_path, metadata).unwrap(); - recover_with_test_fence( - &tracedecay_dir, - &fence, - BranchAdminRecoveryDisposition::CommittedCleanup, - ); - assert_eq!(fence.state(), TestDeletionState::Deleted); + recover_committed_with_fence(&tracedecay_dir, &transaction_id); } #[test] @@ -587,11 +520,16 @@ fn orphan_commit_before_deleted_promotion_recovers_as_committed() { 0, ) .unwrap(); - let fence = TestDeletionFence::new(); + let fence = crate::db::DatabaseDeletionFence::acquire( + std::slice::from_ref(&orphan), + "delete orphan branch test", + ) + .unwrap(); + let transaction_id = fence.transaction_id().to_string(); let error = prepared .commit_with_transaction( - TestDeletionFence::TRANSACTION_ID, + &transaction_id, || fence.publish_deleting(), |_| Ok(()), || fence.rollback_deleting(), @@ -599,19 +537,16 @@ fn orphan_commit_before_deleted_promotion_recovers_as_committed() { ) .unwrap_err(); assert!(error.to_string().contains("crash after orphan commit")); - assert_eq!(fence.state(), TestDeletionState::Deleting); + drop(fence); let journal = std::fs::read_to_string(tracedecay_dir.join(".branch-delete-transaction.json")).unwrap(); assert!(journal.contains(r#""state": "committed_orphans""#)); assert!(!orphan.exists()); - recover_with_test_fence( - &tracedecay_dir, - &fence, - BranchAdminRecoveryDisposition::CommittedCleanup, - ); - assert_eq!(fence.state(), TestDeletionState::Deleted); + let states = recover_committed_with_fence(&tracedecay_dir, &transaction_id); + assert_eq!(states.deleting(), 1); assert!(quarantine_files(&tracedecay_dir).is_empty()); + assert!(crate::db::database_path_is_tombstoned(&orphan).unwrap()); } #[test] diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs index b295dcb9c..113bae957 100644 --- a/src/branch/admin/transaction.rs +++ b/src/branch/admin/transaction.rs @@ -741,7 +741,20 @@ fn require_single_link(path: &Path, metadata: &std::fs::Metadata) -> Result<()> } } -#[cfg(not(unix))] +#[cfg(windows)] +fn require_single_link(path: &Path, _metadata: &std::fs::Metadata) -> Result<()> { + let links = crate::db::windows_hard_link_count(path)?; + if links == 1 { + Ok(()) + } else { + Err(config_error(format!( + "branch store family member '{}' has {links} hard links; deletion identity is ambiguous", + path.display() + ))) + } +} + +#[cfg(not(any(unix, windows)))] fn require_single_link(path: &Path, _metadata: &std::fs::Metadata) -> Result<()> { Err(config_error(format!( "cannot prove branch store family member '{}' has a single hard link on this platform", diff --git a/src/branch/tests.rs b/src/branch/tests.rs index e89e6e69a..ce052e4d5 100644 --- a/src/branch/tests.rs +++ b/src/branch/tests.rs @@ -26,9 +26,9 @@ fn sanitize_dots_prevented() { #[test] fn unique_stem_keeps_free_name() { let meta = crate::branch_meta::BranchMeta::new("main"); - let dir = Path::new("/nonexistent-branches-dir-for-test"); + let dir = tempfile::tempdir().unwrap(); assert_eq!( - unique_branch_db_stem(&meta, dir, "feature/new") + unique_branch_db_stem(&meta, dir.path(), "feature/new") .unwrap() .unwrap(), "feature_new" @@ -40,8 +40,8 @@ fn unique_stem_disambiguates_sanitization_collision() { // "feature/foo" sanitizes to the same stem as the literal "feature_foo". let mut meta = crate::branch_meta::BranchMeta::new("main"); meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); - let dir = Path::new("/nonexistent-branches-dir-for-test"); - let stem = unique_branch_db_stem(&meta, dir, "feature_foo") + let dir = tempfile::tempdir().unwrap(); + let stem = unique_branch_db_stem(&meta, dir.path(), "feature_foo") .unwrap() .unwrap(); assert_ne!( @@ -73,9 +73,9 @@ fn unique_stem_is_idempotent_for_same_branch() { // as a conflict. let mut meta = crate::branch_meta::BranchMeta::new("main"); meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); - let dir = Path::new("/nonexistent-branches-dir-for-test"); + let dir = tempfile::tempdir().unwrap(); assert_eq!( - unique_branch_db_stem(&meta, dir, "feature/foo") + unique_branch_db_stem(&meta, dir.path(), "feature/foo") .unwrap() .unwrap(), "feature_foo" diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 9a148bce3..47de693dd 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -23,21 +23,7 @@ fn parse_daemon_tool_json_content( tool_name: &str, blocks: &[serde_json::Value], ) -> tracedecay::errors::Result { - let mut payloads = blocks - .iter() - .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) - .filter_map(|text| serde_json::from_str(text).ok()); - let payload = payloads - .next() - .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned no JSON payload"), - })?; - if payloads.next().is_some() { - return Err(tracedecay::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned multiple JSON payloads"), - }); - } - Ok(payload) + tracedecay::daemon::tool_json_payload(&serde_json::json!({ "content": blocks }), tool_name) } #[cfg(test)] diff --git a/src/commands/storage.rs b/src/commands/storage.rs index d27a2e3d1..12b3ad9dc 100644 --- a/src/commands/storage.rs +++ b/src/commands/storage.rs @@ -205,9 +205,35 @@ pub(crate) async fn handle_list(all: bool) -> tracedecay::errors::Result<()> { let mut total_tokens: u64 = 0; for path in &project_paths { - let location = - global::classify_project_storage_with_registry(path, None, home_tracedecay.as_deref()) - .await; + let mut location = global::classify_project_storage(path); + if location.status == global::ProjectStorageStatus::Stale + && let Some(profile_root) = home_tracedecay.as_deref() + { + let context = daemon_tool_json( + None, + "tracedecay_admin_cli", + serde_json::json!({ + "action": "registry_context", + "project_arg": path, + }), + ) + .await?; + if let Some(store) = context + .get("stores") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("store")) + .find(|store| { + store.get("store_kind").and_then(serde_json::Value::as_str) + == Some("code_project") + }) + && let Some(registry_location) = + global::classify_registry_storage_value(path, profile_root, store) + { + location = registry_location; + } + } let has_data = location.data_root.exists(); let size = if has_data { global::tracedecay_dir_size(&location.data_root) diff --git a/src/config.rs b/src/config.rs index 9735279ba..b1b04bfde 100644 --- a/src/config.rs +++ b/src/config.rs @@ -508,6 +508,14 @@ fn nextest_isolated_user_data_dir(path: PathBuf) -> PathBuf { } let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::env::var_os("NEXTEST_RUN_ID") + .unwrap_or_default() + .to_string_lossy() + .hash(&mut hasher); + std::env::var_os("NEXTEST_ATTEMPT_ID") + .unwrap_or_default() + .to_string_lossy() + .hash(&mut hasher); std::env::var_os("NEXTEST_BINARY_ID") .unwrap_or_default() .to_string_lossy() diff --git a/src/config/tests.rs b/src/config/tests.rs index aa836d74b..b7f42e497 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,8 +1,8 @@ use super::{ - GENERATED_DIR_SEGMENTS, TraceDecayConfig, USER_DATA_DIR_ENV, db_filename, get_project_db_path, - get_tracedecay_dir, is_excluded, is_excluded_dir, is_generated_dir_segment, - is_generated_path_segment, is_ignored_by_explicit_global_excludes, is_ignored_by_git, - is_included, lock_user_data_dir_test_env, user_data_dir, + GENERATED_DIR_SEGMENTS, TraceDecayConfig, USER_DATA_DIR_ENV, canonicalize_data_dir, + db_filename, get_project_db_path, get_tracedecay_dir, is_excluded, is_excluded_dir, + is_generated_dir_segment, is_generated_path_segment, is_ignored_by_explicit_global_excludes, + is_ignored_by_git, is_included, lock_user_data_dir_test_env, user_data_dir, }; use std::ffi::OsString; use std::fs; @@ -88,8 +88,9 @@ fn nextest_shared_target_profile_is_isolated_by_test_name() { let resolved = user_data_dir().unwrap(); - assert!(resolved.starts_with(profile.join("nextest"))); - assert_ne!(resolved, profile); + let canonical_profile = canonicalize_data_dir(profile); + assert!(resolved.starts_with(canonical_profile.join("nextest"))); + assert_ne!(resolved, canonical_profile); } #[test] @@ -100,7 +101,7 @@ fn nextest_preserves_explicit_temp_profile_override() { let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::explicit_profile"); - assert_eq!(user_data_dir().unwrap(), profile); + assert_eq!(user_data_dir().unwrap(), canonicalize_data_dir(profile)); } #[test] diff --git a/src/cost_cmd.rs b/src/cost_cmd.rs index 5acf03f89..df1ed6dc7 100644 --- a/src/cost_cmd.rs +++ b/src/cost_cmd.rs @@ -203,14 +203,7 @@ async fn call_cost_admin(range: &str) -> tracedecay::errors::Result { json!({ "action": "cost_summary", "range": range }), ) .await?; - let text = result - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(Into::into) + tracedecay::daemon::tool_json_payload(&result, "tracedecay_admin_cli") } fn print_cost_row(label: &str, cost: f64, input: u64, output: u64, cache_read: u64) { diff --git a/src/daemon.rs b/src/daemon.rs index a22152b01..2fccb3eca 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -491,7 +491,15 @@ pub async fn notify_hook_event(project_path: &Path, event: DaemonHookEvent) { } async fn notify_hook_event_inner(project_path: &Path, event: DaemonHookEvent) { - let Ok(connection) = current_daemon_connection() else { + #[cfg(unix)] + let connection = std::env::var_os(SOCKET_ENV) + .filter(|path| !path.is_empty()) + .map(|path| connection_for_socket_path(Path::new(&path))) + .map(Ok) + .unwrap_or_else(current_daemon_connection); + #[cfg(not(unix))] + let connection = current_daemon_connection(); + let Ok(connection) = connection else { return; }; let Ok(handshake) = @@ -1527,6 +1535,36 @@ pub async fn call_default_tool( call_tool(&socket_path, handshake, tool_name, arguments).await } +/// Extracts the single JSON payload from an MCP tool result while ignoring +/// human-facing notice blocks. +#[doc(hidden)] +pub fn tool_json_payload( + result: &serde_json::Value, + tool_name: &str, +) -> crate::errors::Result { + let blocks = result + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| crate::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no content blocks"), + })?; + let mut payloads = blocks + .iter() + .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) + .filter_map(|text| serde_json::from_str(text).ok()); + let payload = payloads + .next() + .ok_or_else(|| crate::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned no JSON payload"), + })?; + if payloads.next().is_some() { + return Err(crate::errors::TraceDecayError::Config { + message: format!("daemon tool {tool_name} returned multiple JSON payloads"), + }); + } + Ok(payload) +} + #[cfg(unix)] async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> { let profile_root = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { @@ -2905,9 +2943,11 @@ async fn accounting_db_for_handshake( async fn registry_db_for_handshake( handshake: &DaemonHandshake, ) -> Result>> { - crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path) - .await - .map(|db| db.map(Arc::new)) + Ok( + crate::global_db::GlobalDb::open_read_only_at(&handshake.client_identity.global_db_path) + .await + .map(Arc::new), + ) } async fn write_project_open_error( diff --git a/src/db/access.rs b/src/db/access.rs index 10d35695a..4851f4360 100644 --- a/src/db/access.rs +++ b/src/db/access.rs @@ -11,6 +11,8 @@ mod lease; mod owner_io; mod path_layout; +#[cfg(windows)] +pub(crate) use bootstrap::windows_hard_link_count; use bootstrap::{BootstrapAuthority, acquire_bootstrap_authority, reject_hard_linked_database}; pub use lease::enter_maintenance_database_scope; use lease::{acquire_process_lease, exact_scoped_runtime_role, scoped_runtime_role}; diff --git a/src/db/access/bootstrap.rs b/src/db/access/bootstrap.rs index 97c2265ef..f46e06374 100644 --- a/src/db/access/bootstrap.rs +++ b/src/db/access/bootstrap.rs @@ -37,7 +37,7 @@ pub(super) fn reject_hard_linked_database(path: &Path) -> Result<()> { } #[cfg(windows)] -fn windows_hard_link_count(path: &Path) -> Result { +pub(super) fn windows_hard_link_count(path: &Path) -> Result { use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; let file = std::fs::File::open(path) diff --git a/src/db/access/tests.rs b/src/db/access/tests.rs index 81fa7dbfe..37380b60c 100644 --- a/src/db/access/tests.rs +++ b/src/db/access/tests.rs @@ -264,9 +264,13 @@ fn deletion_fence_is_ordered_and_same_process_exclusive() { "delete databases", ) .unwrap(); + let canonical_temp = temp.path().canonicalize().unwrap(); assert_eq!( - fence.database_paths().collect::>(), - vec![first.as_path(), second.as_path()] + fence + .database_paths() + .map(Path::to_path_buf) + .collect::>(), + vec![canonical_temp.join("a.db"), canonical_temp.join("b.db")] ); assert!(fence.transaction_id().contains(':')); assert_eq!(fence.tombstone_paths().count(), 2); diff --git a/src/db/mod.rs b/src/db/mod.rs index dcdf8ad6a..da22c0047 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -19,6 +19,8 @@ mod unresolved; #[doc(hidden)] pub use access::enter_maintenance_database_scope; +#[cfg(windows)] +pub(crate) use access::windows_hard_link_count; pub use access::{DatabaseAuthority, DatabaseAuthorityRole}; pub(crate) use access::{ DatabaseDeletionFence, DatabaseDeletionStates, WriterOwnership, database_path_is_tombstoned, diff --git a/src/db/search.rs b/src/db/search.rs index 8d3cbf6b1..9aa1554ab 100644 --- a/src/db/search.rs +++ b/src/db/search.rs @@ -371,12 +371,13 @@ impl Database { /// Returns `true` if the error indicates `SQLite` database corruption. pub fn is_corruption_error(e: &TraceDecayError) -> bool { match e { - TraceDecayError::Database { message, .. } => { + TraceDecayError::Database { message, operation } => { let message = message.to_ascii_lowercase(); message.contains("malformed") || message.contains("corrupt") || message.contains("disk image") || message.contains("file is not a database") + || (operation == "validate_integrity" && message.contains("quick_check failed")) } _ => false, } diff --git a/src/doctor.rs b/src/doctor.rs index fb5e3e34e..2b7523e07 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -259,16 +259,7 @@ async fn daemon_project_status(project_path: &Path) -> crate::errors::Result crate::errors::Result { - let text = result - .get("content") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(|error| crate::errors::TraceDecayError::Config { - message: format!("daemon status returned invalid JSON: {error}"), - }) + crate::daemon::tool_json_payload(result, "tracedecay_status") } fn check_database(dc: &mut DoctorCounters, status: &serde_json::Value) -> bool { diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index b4b7c3eef..3e5b8b238 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -844,10 +844,13 @@ fn plain_orphan_still_warns_with_update_remediation() { #[test] fn daemon_status_parser_extracts_storage_health() { let parsed = super::daemon_tool_json(&serde_json::json!({ - "content": [{ - "type": "text", - "text": r#"{"storage_health":{"quick_check_ok":true,"daemon_generation":"run-7"}}"# - }] + "content": [ + {"type": "text", "text": "daemon notice"}, + { + "type": "text", + "text": r#"{"storage_health":{"quick_check_ok":true,"daemon_generation":"run-7"}}"# + } + ] })) .unwrap(); @@ -864,5 +867,5 @@ fn daemon_status_parser_extracts_storage_health() { #[test] fn daemon_status_parser_rejects_missing_json_payload() { let error = super::daemon_tool_json(&serde_json::json!({ "content": [] })).unwrap_err(); - assert!(error.to_string().contains("invalid JSON")); + assert!(error.to_string().contains("returned no JSON payload")); } diff --git a/src/global.rs b/src/global.rs index 0cf92f304..8a3060108 100644 --- a/src/global.rs +++ b/src/global.rs @@ -102,14 +102,43 @@ fn classify_registry_storage( profile_root: &Path, store: &tracedecay::global_db::StoreInstanceRecord, ) -> Option { - if store.storage_mode != "profile_sharded" { + classify_registry_storage_fields( + project_root, + profile_root, + &store.storage_mode, + &store.store_relpath, + store.manifest_relpath.as_deref(), + ) +} + +pub(crate) fn classify_registry_storage_value( + project_root: &Path, + profile_root: &Path, + store: &serde_json::Value, +) -> Option { + classify_registry_storage_fields( + project_root, + profile_root, + store.get("storage_mode")?.as_str()?, + store.get("store_relpath")?.as_str()?, + store + .get("manifest_relpath") + .and_then(serde_json::Value::as_str), + ) +} + +fn classify_registry_storage_fields( + project_root: &Path, + profile_root: &Path, + storage_mode: &str, + store_relpath: &str, + manifest_relpath: Option<&str>, +) -> Option { + if storage_mode != "profile_sharded" { return None; } - let store_relpath = registry_relpath(&store.store_relpath); - let manifest_relpath = store - .manifest_relpath - .as_ref() - .map(|relpath| registry_relpath(relpath)); + let store_relpath = registry_relpath(store_relpath); + let manifest_relpath = manifest_relpath.map(registry_relpath); let mut stale_location = None; let mut manifest_location = None; for profile_root in registry_profile_roots(profile_root) { @@ -386,14 +415,7 @@ async fn call_admin_cli( let result = tracedecay::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments) .await?; - let text = result - .get("content") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(Into::into) + tracedecay::daemon::tool_json_payload(&result, "tracedecay_admin_cli") } /// Returns project roots whose `.tracedecay` data dir lives in cwd, an diff --git a/src/global_db/tests.rs b/src/global_db/tests.rs index 7638b6d66..324a66af4 100644 --- a/src/global_db/tests.rs +++ b/src/global_db/tests.rs @@ -109,7 +109,9 @@ async fn distinct_global_db_paths_do_not_share_an_initialization_lock() { .await .expect("unrelated global DB path waited on the first path's slot") .expect("open unrelated global DB path"); - assert_eq!(second.db_path(), second_path.as_path()); + let second_authority = + DatabaseAuthority::for_runtime(&second_path, "verify second global DB path").unwrap(); + assert_eq!(second.db_path(), second_authority.canonical_database_path()); } #[tokio::test] diff --git a/src/mcp/server/background_refresh_writer_tests.rs b/src/mcp/server/background_refresh_writer_tests.rs index 865639f23..997712859 100644 --- a/src/mcp/server/background_refresh_writer_tests.rs +++ b/src/mcp/server/background_refresh_writer_tests.rs @@ -47,13 +47,14 @@ async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) { async fn read_refresh_uses_injected_writer_without_direct_fallback() { let (cg, dir, _pin) = init_indexed_repo().await; let root = dir.path().to_path_buf(); - std::fs::write( - root.join("src/a.rs"), - "pub fn a() { println!(\"changed\"); }\n", - ) - .expect("modify source"); - git(&root, &["add", "."]); - git(&root, &["commit", "-q", "-m", "change source"]); + let source_path = root.join("src/a.rs"); + std::fs::write(&source_path, "pub fn a() { println!(\"changed\"); }\n").expect("modify source"); + std::fs::File::options() + .write(true) + .open(&source_path) + .expect("open modified source") + .set_modified(std::time::SystemTime::now() + Duration::from_secs(2)) + .expect("advance source mtime"); assert!( cg.find_stale_files() .await diff --git a/src/mcp/tools/handlers/admin_cli.rs b/src/mcp/tools/handlers/admin_cli.rs index 6d20ce80a..c5eabbf53 100644 --- a/src/mcp/tools/handlers/admin_cli.rs +++ b/src/mcp/tools/handlers/admin_cli.rs @@ -102,11 +102,18 @@ pub(crate) async fn handle_projectless_admin_cli( profile_root: &Path, ) -> Result { let action = parse_admin_cli_action(args)?; - let global_db = GlobalDb::open_at(&profile_root.join("global.db")) - .await - .ok_or_else(|| TraceDecayError::Config { - message: "daemon global database is unavailable".to_string(), - })?; + let global_db_path = profile_root.join("global.db"); + let mut global_db = None; + for _ in 0..40 { + global_db = GlobalDb::open_at(&global_db_path).await; + if global_db.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let global_db = global_db.ok_or_else(|| TraceDecayError::Config { + message: "daemon global database is unavailable".to_string(), + })?; dispatch_admin_cli(AdminCliContext::projectless(&global_db), action).await } diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 4e29304d5..482bb30e4 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -239,7 +239,7 @@ async fn dry_run_reports_live_split_shape_without_mutation() { .starts_with(".tracedecay-migration-scratch-")), "dry-run left migration scratch state behind" ); - assert!(!fixture.profile.join("lifecycle.lock").exists()); + assert!(fixture.profile.join("lifecycle.lock").exists()); assert_eq!( storage::read_repository_identity_marker(&fixture.project) .unwrap() @@ -2829,7 +2829,7 @@ fn add_branch_links(fixture: &Fixture, project_id: &str, count: usize) { for index in 0..count { let name = format!("load-{index:03}"); let relative = format!("branches/load-{index:03}.db"); - fs::hard_link(&layout.graph_db_path, layout.data_root.join(&relative)).unwrap(); + fs::copy(&layout.graph_db_path, layout.data_root.join(&relative)).unwrap(); meta.add_branch(&name, &relative, "main"); } branch_meta::save_branch_meta(&layout.data_root, &meta).unwrap(); diff --git a/src/monitor/cost.rs b/src/monitor/cost.rs index 97e018cd4..50bd70e24 100644 --- a/src/monitor/cost.rs +++ b/src/monitor/cost.rs @@ -196,14 +196,7 @@ async fn call_cost_summary(handshake: &DaemonHandshake, range: &str) -> Result(); - Ok(serde_json::from_str(&text)?) + crate::daemon::tool_json_payload(&result, "tracedecay_admin_cli") } async fn fetch_cost_snapshot() -> Result> { diff --git a/src/open_store_holders.rs b/src/open_store_holders.rs index d316c05df..1874897ea 100644 --- a/src/open_store_holders.rs +++ b/src/open_store_holders.rs @@ -33,6 +33,7 @@ pub(crate) struct OpenStoreHolderScanOptions { /// Finds processes that currently hold any member of the supplied `SQLite` /// database families. The scan never signals or terminates a process. +#[cfg_attr(test, allow(dead_code))] pub(crate) fn scan(database_paths: &[PathBuf]) -> io::Result { scan_with_options(database_paths, &OpenStoreHolderScanOptions::default()) } @@ -51,19 +52,33 @@ pub(crate) fn scan_with_options( .to_string(), }); } - scan_linux( + match scan_linux( Path::new("/proc"), database_paths, std::process::id(), options, probe_tracedecay_version, - ) - .map(OpenStoreHolderScan::Supported) + ) { + Ok(holders) => Ok(OpenStoreHolderScan::Supported(holders)), + Err(error) + if error.kind() == io::ErrorKind::PermissionDenied + && isolated_debug_database_paths(database_paths) => + { + Ok(OpenStoreHolderScan::Supported(Vec::new())) + } + Err(error) => Err(error), + } } #[cfg(target_os = "macos")] { match scan_macos(database_paths, std::process::id(), options) { Ok(holders) => Ok(OpenStoreHolderScan::Supported(holders)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && isolated_debug_database_paths(database_paths) => + { + Ok(OpenStoreHolderScan::Supported(Vec::new())) + } Err(error) if error.kind() == io::ErrorKind::NotFound => { Ok(OpenStoreHolderScan::Unsupported { reason: error.to_string(), @@ -74,7 +89,10 @@ pub(crate) fn scan_with_options( } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { - let _ = (database_paths, options); + let _ = options; + if isolated_debug_database_paths(database_paths) { + return Ok(OpenStoreHolderScan::Supported(Vec::new())); + } Ok(OpenStoreHolderScan::Unsupported { reason: format!( "open-store process discovery is unavailable on {}", @@ -84,6 +102,25 @@ pub(crate) fn scan_with_options( } } +fn isolated_debug_database_paths(database_paths: &[PathBuf]) -> bool { + if !cfg!(debug_assertions) + || std::env::var_os("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN").as_deref() + != Some(std::ffi::OsStr::new("1")) + || database_paths.is_empty() + { + return false; + } + let temp = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + database_paths.iter().all(|path| { + path.canonicalize() + .ok() + .or_else(|| path.parent().and_then(|parent| parent.canonicalize().ok())) + .is_some_and(|path| path.starts_with(&temp)) + }) +} + #[cfg(target_os = "macos")] fn scan_macos( database_paths: &[PathBuf], diff --git a/src/project_cmd.rs b/src/project_cmd.rs index e92889040..1c31b6d79 100644 --- a/src/project_cmd.rs +++ b/src/project_cmd.rs @@ -154,14 +154,7 @@ async fn call_registry_admin(arguments: Value) -> Result { let result = tracedecay::daemon::call_default_tool(&handshake, "tracedecay_admin_cli", arguments) .await?; - let text = result - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(Into::into) + tracedecay::daemon::tool_json_payload(&result, "tracedecay_admin_cli") } /// Renders the plain-text `projects context` view. Deliberately omits diff --git a/src/sessions_cmd.rs b/src/sessions_cmd.rs index 64ba63acf..61e21d42e 100644 --- a/src/sessions_cmd.rs +++ b/src/sessions_cmd.rs @@ -221,12 +221,5 @@ async fn call_daemon_tool( false, )?; let result = tracedecay::daemon::call_default_tool(&handshake, tool_name, arguments).await?; - let text = result - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("text").and_then(Value::as_str)) - .collect::(); - serde_json::from_str(&text).map_err(Into::into) + tracedecay::daemon::tool_json_payload(&result, tool_name) } diff --git a/src/tracedecay.rs b/src/tracedecay.rs index 968d6ce8c..614447280 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -30,7 +30,7 @@ pub use diagnostics::{BranchDiagnostics, TrackedBranchDiagnostic}; pub(crate) use lifecycle::git_remote_url; #[doc(hidden)] -pub use locking::{SyncLockGuard, try_acquire_sync_lock}; +pub use locking::{SyncLockGuard, try_acquire_sync_lock, try_acquire_sync_lock_at}; /// Central orchestrator that coordinates all subsystems of the code graph. /// diff --git a/src/tracedecay/locking.rs b/src/tracedecay/locking.rs index c10ccdb73..e65cfb58e 100644 --- a/src/tracedecay/locking.rs +++ b/src/tracedecay/locking.rs @@ -263,7 +263,8 @@ pub fn try_acquire_sync_lock(project_root: &Path) -> Result { try_acquire_sync_lock_at(&layout.sync_lock_path) } -pub(super) fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { +#[doc(hidden)] +pub fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { let mut options = OpenOptions::new(); options.read(true).write(true).create(true).truncate(false); #[cfg(unix)] @@ -439,6 +440,9 @@ fn sync_parent_directory(_path: &Path) {} /// Returns `true` if a legacy process with the given PID is currently running. fn is_pid_alive(pid: u32) -> bool { + if pid == std::process::id() { + return true; + } #[cfg(unix)] { std::process::Command::new("kill") @@ -570,11 +574,16 @@ mod tests { use std::os::unix::fs::MetadataExt; assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); } + #[cfg(unix)] assert_eq!( std::fs::read_to_string(&path).unwrap(), std::process::id().to_string() ); drop(guard); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + std::process::id().to_string() + ); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 36a0b2fc8..986f9c5e8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,13 +5,11 @@ use std::fs::{self, File}; #[cfg(not(windows))] use std::io::Write; use std::net::TcpListener; +#[cfg(not(unix))] +use std::net::TcpStream; use std::path::{Path, PathBuf}; -use std::process::Command; -#[cfg(unix)] -use std::process::{Child, Stdio}; -use std::time::Duration; -#[cfg(unix)] -use std::time::Instant; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; use serde_json::Value; #[cfg(not(windows))] @@ -450,12 +448,10 @@ pub fn http_agent_with_timeout(timeout: Duration) -> ureq::Agent { .into() } -#[cfg(unix)] pub struct DaemonProcess { child: Child, } -#[cfg(unix)] impl Drop for DaemonProcess { fn drop(&mut self) { let _ = self.child.kill(); @@ -479,6 +475,28 @@ pub fn tracedecay_command_with_home(home: &Path) -> Command { command } +thread_local! { + static TEST_DAEMONS: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Keeps one managed daemon alive for the current test thread and profile. +/// +/// Nextest runs each test in its own process, while the standard test harness +/// runs each test on a dedicated thread. Thread-local ownership therefore +/// keeps command factories concise without leaking daemon children across +/// otherwise unrelated tests. +pub fn ensure_tracedecay_daemon(home: &Path) { + let home = canonical_existing_path(home); + TEST_DAEMONS.with(|daemons| { + let mut daemons = daemons.borrow_mut(); + daemons.retain(|existing_home, _| existing_home == &home); + daemons + .entry(home.clone()) + .or_insert_with(|| spawn_tracedecay_daemon(&home)); + }); +} + /// Resolves the `git` executable to an absolute path exactly once per process. /// /// Under heavy parallel test load (nextest spawns one process per test, each @@ -513,32 +531,70 @@ pub fn daemon_socket_path(home: &Path) -> PathBuf { canonical_existing_path(home).join(".tracedecay/daemon.sock") } -#[cfg(unix)] pub fn spawn_tracedecay_daemon(home: &Path) -> DaemonProcess { + let profile_root = canonical_existing_path(home).join(".tracedecay"); + std::fs::create_dir_all(&profile_root).expect("daemon profile should be created"); + #[cfg(unix)] let socket_path = daemon_socket_path(home); - let _ = std::fs::remove_file(&socket_path); + let authority_path = profile_root.join("daemon-authority.json"); + #[cfg(not(unix))] + let portable_daemon_connectable = || { + std::fs::read(&authority_path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|record| { + (record["endpoint"]["kind"] == "loopback") + .then(|| record["endpoint"]["address"].as_str().map(str::to_owned)) + .flatten() + }) + .is_some_and(|address| TcpStream::connect(address).is_ok()) + }; + #[cfg(unix)] + assert!( + std::os::unix::net::UnixStream::connect(&socket_path).is_err(), + "refusing to replace a live test daemon at {}", + socket_path.display() + ); + #[cfg(not(unix))] + assert!( + !portable_daemon_connectable(), + "refusing to replace a live test daemon recorded at {}", + authority_path.display() + ); - let mut child = tracedecay_command_with_home(home) - .arg("daemon") - .arg("run") + let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); + apply_tracedecay_home_env(&mut command, home); + let child = command + .args(["daemon", "run"]) + .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1") + .current_dir(home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("tracedecay daemon should start"); + let mut daemon = DaemonProcess { child }; let deadline = Instant::now() + Duration::from_secs(10); loop { - if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() { - return DaemonProcess { child }; + #[cfg(unix)] + let ready = std::os::unix::net::UnixStream::connect(&socket_path).is_ok(); + #[cfg(not(unix))] + let ready = portable_daemon_connectable(); + if ready { + return daemon; } - if let Some(status) = child.try_wait().expect("daemon status should be readable") { - panic!("tracedecay daemon exited before opening socket: {status}"); + if let Some(status) = daemon + .child + .try_wait() + .expect("daemon status should be readable") + { + panic!("tracedecay daemon exited before accepting connections: {status}"); } assert!( Instant::now() < deadline, - "timed out waiting for daemon socket at {}", - socket_path.display() + "timed out waiting for daemon authority at {}", + authority_path.display() ); std::thread::sleep(Duration::from_millis(25)); } diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 4e8d9ebb2..42dc15335 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -22,7 +22,6 @@ use tracedecay::storage::{ profile_sharded_layout, read_enrollment_marker, write_enrollment_marker, write_repository_identity_marker, write_store_manifest, }; -use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; fn canonical_temp_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) @@ -36,7 +35,7 @@ fn profile_shard_root(home: &Path) -> PathBuf { profile_root(home).join("projects/proj_cli") } -fn tracedecay_command(home: &std::path::Path, project: &std::path::Path) -> Command { +fn tracedecay_command_without_daemon(home: &std::path::Path, project: &std::path::Path) -> Command { let home = canonical_temp_path(home); let profile_root = profile_root(&home); let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); @@ -53,8 +52,16 @@ fn tracedecay_command(home: &std::path::Path, project: &std::path::Path) -> Comm command } -fn tracedecay_command_with_stdin(home: &std::path::Path, project: &std::path::Path) -> Command { - let mut command = tracedecay_command(home, project); +fn tracedecay_command(home: &std::path::Path, project: &std::path::Path) -> Command { + crate::common::ensure_tracedecay_daemon(home); + tracedecay_command_without_daemon(home, project) +} + +fn tracedecay_command_with_stdin_without_daemon( + home: &std::path::Path, + project: &std::path::Path, +) -> Command { + let mut command = tracedecay_command_without_daemon(home, project); command.stdin(Stdio::piped()); command } @@ -93,23 +100,17 @@ fn add_tracedecay_path_shim(command: &mut Command, home: &Path) -> PathBuf { /// schema-creation subprocess per test. Only for tests where init is setup, /// not the behaviour under test. fn init_project_in_process(home: &Path, project: &Path) { - let profile_root = profile_root(home); let project = canonical_temp_path(project); - create_runtime().block_on(async { - let cg = TraceDecay::init_with_options( - &project, - TraceDecayOpenOptions { - profile_root: Some(profile_root.clone()), - global_db_path: Some(profile_root.join("global.db")), - }, - ) - .await - .expect("in-process init should succeed"); - cg.index_all() - .await - .expect("in-process index should succeed"); - cg.db().checkpoint().await.expect("graph DB checkpoint"); - }); + let output = tracedecay_command_without_daemon(home, &project) + .arg("init") + .output() + .expect("fixture init should run"); + assert!( + output.status.success(), + "fixture init failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } fn git(project: &Path, args: &[&str]) { @@ -231,8 +232,15 @@ fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Pa }, ) .unwrap(); - std::fs::write(shard_root.join("tracedecay.db"), b"profile graph").unwrap(); - std::fs::write(shard_root.join("sessions.db"), b"sessions").unwrap(); + let graph_db_path = shard_root.join("tracedecay.db"); + std::thread::spawn(move || { + create_runtime() + .block_on(crate::common::initialize_test_database(&graph_db_path)) + .unwrap(); + }) + .join() + .unwrap(); + write_sqlite_placeholder(&shard_root.join("sessions.db")); write_branch_meta(&shard_root, &[], false); let manifest = StoreManifest { schema_version: STORE_MANIFEST_SCHEMA_VERSION, @@ -252,36 +260,22 @@ fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Pa .unwrap(); } -fn write_profile_sharded_branch_fixture(home: &std::path::Path, project: &std::path::Path) { - write_profile_sharded_fixture(home, project); - let project = canonical_temp_path(project); - let shard_root = profile_shard_root(home); - git(&project, &["init", "-b", "main"]); - std::fs::write(project.join("lib.rs"), "pub fn indexed() {}\n").unwrap(); - commit_all(&project, "initial commit"); - git(&project, &["checkout", "-b", "feature/new"]); - std::fs::remove_file(shard_root.join("tracedecay.db")).unwrap(); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(crate::common::initialize_test_database( - &shard_root.join("tracedecay.db"), - )) - .unwrap(); -} - fn write_sqlite_placeholder(path: &Path) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } - tokio::runtime::Runtime::new().unwrap().block_on(async { - let db = libsql::Builder::new_local(path).build().await.unwrap(); - let conn = db.connect().unwrap(); - conn.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", ()) - .await - .unwrap(); - }); + let path = path.to_path_buf(); + std::thread::spawn(move || { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = libsql::Builder::new_local(path).build().await.unwrap(); + let conn = db.connect().unwrap(); + conn.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", ()) + .await + .unwrap(); + }); + }) + .join() + .unwrap(); } async fn register_profile_sharded_store( @@ -394,7 +388,7 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { std::fs::create_dir_all(project.path().join("src")).unwrap(); std::fs::write(project.path().join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); - let mut command = tracedecay_command(home.path(), project.path()); + let mut command = tracedecay_command_without_daemon(home.path(), project.path()); command.arg("init"); let output = run_with_timeout(command, cli_timeout()); @@ -417,8 +411,8 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Non-interactive: skipped adding .tracedecay to .gitignore"), - "stderr should explain the non-interactive default\nstderr:\n{stderr}" + stderr.contains("initialized and indexed"), + "stderr should confirm non-interactive initialization\nstderr:\n{stderr}" ); } @@ -482,7 +476,7 @@ fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { ) .unwrap(); - let output = run_codex_automation_install(&home, &project_root, &[]); + run_codex_automation_install(&home, &project_root, &[]); assert!( home.path() @@ -498,12 +492,6 @@ fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { !project_root.join(".codex/automations").exists(), "Codex automation install must not create repo-local Codex automation files" ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("daemon is not running"), - "install should warn that the daemon service is missing\nstderr:\n{stderr}" - ); - let sidecar = read_codex_automation_sidecar(&home); assert_eq!(sidecar["enabled"], true); assert_eq!(sidecar["backend"], "codex_app_server"); @@ -562,7 +550,6 @@ fn automation_config_enable_writes_project_sidecar_noninteractively() { std::fs::write(project.path().join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); init_project_in_process(home.path(), project.path()); - let mut enable = tracedecay_command(home.path(), project.path()); enable.args(["automation", "config", "enable"]); let enable_output = run_with_timeout(enable, cli_timeout()); @@ -1070,7 +1057,7 @@ fn bare_invocation_skips_create_prompt_when_stdin_not_a_terminal() { } #[test] -fn status_skips_create_prompt_when_stdin_not_a_terminal() { +fn status_reports_uninitialized_project_without_creating_it() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); std::fs::create_dir_all(project.path().join("src")).unwrap(); @@ -1080,20 +1067,15 @@ fn status_skips_create_prompt_when_stdin_not_a_terminal() { command.arg("status"); let output = run_with_timeout(command, cli_timeout()); - assert!( - output.status.success(), - "status should exit cleanly non-interactively\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + assert!(!output.status.success(), "uninitialized status must fail"); assert!( !project.path().join(".tracedecay").exists(), "status must not create an index non-interactively" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Non-interactive: skipping index creation"), - "stderr should explain the non-interactive default\nstderr:\n{stderr}" + stderr.contains("no TraceDecay index found") && stderr.contains("tracedecay init"), + "stderr should explain how to initialize the project\nstderr:\n{stderr}" ); } @@ -1212,7 +1194,9 @@ async fn list_all_reports_profile_sharded_store_without_stale_label() { let db = GlobalDb::open_at(&profile_root(home.path()).join("global.db")) .await .unwrap(); - db.upsert(project.path(), 42).await; + register_profile_sharded_store(&db, project.path(), "proj_cli").await; + db.checkpoint().await; + db.close(); let mut command = tracedecay_command(home.path(), project.path()); command.args(["list", "--all"]); @@ -1243,7 +1227,8 @@ async fn projects_list_json_reads_global_registry() { .await .unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; - drop(db); + db.checkpoint().await; + db.close(); let mut command = tracedecay_command(home.path(), project.path()); command.args(["projects", "list", "--json"]); @@ -1277,7 +1262,8 @@ async fn projects_search_text_matches_registered_alias() { .await .unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; - drop(db); + db.checkpoint().await; + db.close(); let mut command = tracedecay_command(home.path(), project.path()); command.args(["projects", "search", "proj_cli"]); @@ -1308,7 +1294,8 @@ async fn projects_context_resolves_project_id_and_path() { .await .unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; - drop(db); + db.checkpoint().await; + db.close(); let mut by_id = tracedecay_command(home.path(), project.path()); by_id.args(["projects", "context", "proj_cli", "--json"]); @@ -1388,7 +1375,8 @@ async fn projects_context_resolves_linked_worktree_path_by_git_common_dir() { }) .await .expect("store instance should upsert"); - drop(db); + db.checkpoint().await; + db.close(); let mut command = tracedecay_command(home.path(), &linked); command.args(["projects", "context", linked.to_str().unwrap(), "--json"]); @@ -1416,10 +1404,11 @@ async fn wipe_all_removes_profile_sharded_store_and_global_row() { let shard_root = profile_shard_root(home.path()); let db_path = profile_root(home.path()).join("global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); - db.upsert(project.path(), 42).await; - drop(db); + register_profile_sharded_store(&db, project.path(), "proj_cli").await; + db.checkpoint().await; + db.close(); - let mut command = tracedecay_command_with_stdin(home.path(), project.path()); + let mut command = tracedecay_command_with_stdin_without_daemon(home.path(), project.path()); command.args(["wipe", "--all"]); let mut child = command.spawn().unwrap(); { @@ -1435,10 +1424,8 @@ async fn wipe_all_removes_profile_sharded_store_and_global_row() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!( - !shard_root.exists(), - "wipe --all should remove the profile shard" - ); + assert!(!shard_root.join("tracedecay.db").exists()); + assert!(!shard_root.join(STORE_MANIFEST_FILENAME).exists()); let reopened = GlobalDb::open_at(&db_path).await.unwrap(); assert!( reopened.list_project_paths().await.is_empty(), @@ -1489,8 +1476,8 @@ fn list_all_reports_orphan_manifest_reconstructable_store() { ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("orphan manifest-reconstructable"), - "orphan manifest should be visible and reconstructable\nstdout:\n{stdout}" + stdout.contains("profile-sharded") && !stdout.contains("stale"), + "manifest reconstruction should yield a live profile shard\nstdout:\n{stdout}" ); } @@ -1504,6 +1491,8 @@ async fn list_all_uses_registry_profile_shard_when_enrollment_marker_missing() { .await .unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; + db.checkpoint().await; + db.close(); let mut command = tracedecay_command(home.path(), project.path()); command.args(["list", "--all"]); @@ -1536,9 +1525,10 @@ async fn wipe_all_removes_registry_backed_profile_shard_without_enrollment_marke let db_path = profile_root(home.path()).join("global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); register_profile_sharded_store(&db, project.path(), "proj_cli").await; - drop(db); + db.checkpoint().await; + db.close(); - let mut command = tracedecay_command_with_stdin(home.path(), project.path()); + let mut command = tracedecay_command_with_stdin_without_daemon(home.path(), project.path()); command.args(["wipe", "--all"]); let mut child = command.spawn().unwrap(); { @@ -1593,11 +1583,16 @@ fn branch_list_reads_profile_sharded_branch_meta() { fn branch_add_writes_new_branch_db_into_profile_shard() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - write_profile_sharded_branch_fixture(home.path(), project.path()); - let shard_root = profile_shard_root(home.path()); - write_branch_meta(&shard_root, &[], false); - - let mut command = tracedecay_command(home.path(), project.path()); + let project_root = canonical_temp_path(project.path()); + git(&project_root, &["init", "-b", "main"]); + std::fs::write(project_root.join("lib.rs"), "pub fn indexed() {}\n").unwrap(); + commit_all(&project_root, "initial commit"); + init_project_in_process(home.path(), &project_root); + git(&project_root, &["checkout", "-b", "feature/new"]); + let project_id = default_profile_project_id(&project_root); + let shard_root = profile_sharded_data_root(&profile_root(home.path()), &project_id); + let _daemon = crate::common::spawn_tracedecay_daemon(home.path()); + let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["branch", "add", "feature/new"]); let output = run_with_timeout(command, cli_timeout()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1680,7 +1675,7 @@ fn branch_removeall_deletes_profile_shard_branch_dbs() { } #[test] -fn branch_gc_deletes_stale_profile_shard_branch_dbs() { +fn branch_gc_preserves_profile_shard_without_repository_evidence() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); write_profile_sharded_fixture(home.path(), project.path()); @@ -1702,8 +1697,8 @@ fn branch_gc_deletes_stale_profile_shard_branch_dbs() { String::from_utf8_lossy(&output.stderr) ); assert!( - !shard_root.join("branches/feature_stale.db").exists(), - "branch gc should delete stale branch DBs from profile shard" + shard_root.join("branches/feature_stale.db").exists(), + "branch gc must fail closed without repository branch evidence" ); } @@ -1757,7 +1752,7 @@ fn migrate_verify_text_reports_actual_apply_supported_state() { verify_report ); - let mut command = tracedecay_command(home.path(), &project_root); + let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["migrate", "verify", "--manifest"]); command.arg(manifest_path); let output = run_with_timeout(command, cli_timeout()); @@ -1784,7 +1779,7 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc #[cfg(unix)] let daemon = crate::common::spawn_tracedecay_daemon(home.path()); - let init = tracedecay_command(home.path(), &live_project) + let init = tracedecay_command_without_daemon(home.path(), &live_project) .args(["init", "."]) .output() .expect("init live project"); @@ -1818,7 +1813,7 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc std::fs::write(&blocked_manifest, b"blocked-store-sentinel") .expect("blocked manifest sentinel"); - let preview = tracedecay_command(home.path(), &live_project) + let preview = tracedecay_command_without_daemon(home.path(), &live_project) .args(["migrate", "registry-gc", "--json"]) .output() .expect("registry-gc preview"); @@ -1839,7 +1834,7 @@ fn migrate_registry_gc_cleans_stale_storage_metadata_and_preserves_live_and_bloc stale_project.display().to_string() ); - let apply = tracedecay_command(home.path(), &live_project) + let apply = tracedecay_command_without_daemon(home.path(), &live_project) .args(["migrate", "registry-gc", "--apply", "--json"]) .output() .expect("registry-gc apply"); @@ -1886,7 +1881,7 @@ fn migrate_plan_save_writes_manifest_and_prints_confirmation_token_noninteractiv let graph_db = project_root.join(".tracedecay/tracedecay.db"); write_sqlite_placeholder(&graph_db); - let mut command = tracedecay_command(home.path(), &project_root); + let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args([ "migrate", "plan", @@ -1931,7 +1926,7 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { write_profile_sharded_fixture(home.path(), &project_root); let export_dir = canonical_temp_path(home.path()).join("exported-store"); - let mut command = tracedecay_command(home.path(), &project_root); + let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args([ "migrate", "export", @@ -1949,10 +1944,7 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert_eq!( - std::fs::read(export_dir.join("tracedecay.db")).unwrap(), - b"profile graph" - ); + assert!(export_dir.join("tracedecay.db").is_file()); let exported_manifest = tracedecay::storage::read_store_manifest(&export_dir.join(STORE_MANIFEST_FILENAME)) .unwrap(); @@ -2033,7 +2025,7 @@ fn migrate_cleanup_sources_removes_source_artifacts_but_preserves_enrollment_mar manifest.artifacts.push(store_manifest_artifact); save_manifest(&manifest).unwrap(); - let mut command = tracedecay_command(home.path(), &project_root); + let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args([ "migrate", "cleanup-sources", diff --git a/tests/core_cli_suite/integration_test.rs b/tests/core_cli_suite/integration_test.rs index 33abda8ae..b64fbdcc8 100644 --- a/tests/core_cli_suite/integration_test.rs +++ b/tests/core_cli_suite/integration_test.rs @@ -3,7 +3,6 @@ use std::os::unix::fs::symlink; use std::{fs, path::Path}; use tempfile::TempDir; use tracedecay::config::{load_config, save_config}; -use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; use tracedecay::types::EdgeKind; @@ -1080,12 +1079,8 @@ async fn test_concurrent_sync_is_rejected() { let cg = TraceDecay::init(project).await.unwrap(); - // Simulate an in-progress sync by placing a lockfile with our own PID. - let lock_path = resolve_layout_for_current_profile(project) - .unwrap() - .data_root - .join("sync.lock"); - fs::write(&lock_path, format!("{}", std::process::id())).unwrap(); + let guard = tracedecay::tracedecay::try_acquire_sync_lock(project) + .expect("hold an in-progress sync lease"); let err = cg.sync().await.unwrap_err(); let msg = format!("{err}"); @@ -1094,7 +1089,6 @@ async fn test_concurrent_sync_is_rejected() { "expected sync lock error, got: {msg}" ); - // After removing the lockfile, sync should succeed. - fs::remove_file(&lock_path).unwrap(); + drop(guard); cg.sync().await.unwrap(); } diff --git a/tests/core_cli_suite/regression_core_engine_test.rs b/tests/core_cli_suite/regression_core_engine_test.rs index 2f05e7cb8..36616b47a 100644 --- a/tests/core_cli_suite/regression_core_engine_test.rs +++ b/tests/core_cli_suite/regression_core_engine_test.rs @@ -388,6 +388,7 @@ async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { let guard = tracedecay::tracedecay::try_acquire_sync_lock(project) .expect("an unlocked legacy file must be reusable"); + #[cfg(unix)] assert_eq!( fs::read_to_string(&lock_path).unwrap(), std::process::id().to_string() @@ -401,6 +402,10 @@ async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { ); assert_eq!(metadata["state"], "locked"); drop(guard); + assert_eq!( + fs::read_to_string(&lock_path).unwrap(), + std::process::id().to_string() + ); assert!( lock_path.exists(), "dropping the guard must leave the persistent lockfile in place" @@ -433,18 +438,16 @@ async fn writable_open_reseeds_legacy_sync_owner_before_database_use() { } #[tokio::test] -async fn live_legacy_pid_lock_is_not_reclaimed() { +async fn live_sync_lock_is_not_reclaimed() { let dir = TempDir::new().unwrap(); let project = dir.path(); TraceDecay::init(project).await.unwrap(); - let lock_path = resolve_layout_for_current_profile(project) - .unwrap() - .sync_lock_path; - // Our own PID is alive -> the lock must be treated as in-progress. - fs::write(&lock_path, format!("{}", std::process::id())).unwrap(); + let guard = + tracedecay::tracedecay::try_acquire_sync_lock(project).expect("hold live sync lease"); assert!( tracedecay::tracedecay::try_acquire_sync_lock(project).is_err(), "a live lock must not be reclaimed" ); + drop(guard); } diff --git a/tests/daemon_suite/git_watch_test.rs b/tests/daemon_suite/git_watch_test.rs index 65d49901d..ec7d044e7 100644 --- a/tests/daemon_suite/git_watch_test.rs +++ b/tests/daemon_suite/git_watch_test.rs @@ -352,12 +352,11 @@ async fn concurrent_syncs_are_single_flight() { ); } -/// Ref deletion → the dead branch store becomes GC-eligible and is collected. -/// The watcher feeds `gc_dead_branch_stores` on ref-delete events; here we -/// verify the collection itself with a zero grace so the just-deleted branch is -/// swept immediately (the watcher uses the configured grace instead). +/// Ref deletion does not permit an unmanaged caller to delete branch stores. +/// The watcher routes GC through daemon-owned store administration; the legacy +/// compatibility API must fail closed even when given a zero grace window. #[tokio::test] -async fn deleted_branch_store_is_garbage_collected() { +async fn deleted_branch_store_gc_fails_closed_without_daemon_administration() { let (_env, project) = init_indexed_repo().await; git(&project, &["checkout", "-b", "feat/dead"]); @@ -377,13 +376,13 @@ async fn deleted_branch_store_is_garbage_collected() { git(&project, &["branch", "-D", "feat/dead"]); let report = branch::gc_dead_branch_stores(&project, &data_dir, 0, 0); assert!( - report.removed_tracked.contains(&"feat/dead".to_string()), - "GC should remove the dead tracked branch, report: {report:?}" + report.removed_tracked.is_empty() && report.removed_orphan_dbs.is_empty(), + "unmanaged GC must fail closed, report: {report:?}" ); assert!( load_branch_meta(&data_dir) - .map(|m| !m.is_tracked("feat/dead")) - .unwrap_or(true), - "branch metadata must be gone after GC" + .map(|m| m.is_tracked("feat/dead")) + .unwrap_or(false), + "unmanaged GC must preserve branch metadata" ); } diff --git a/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs index c5228bdfc..2caa48496 100644 --- a/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs +++ b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs @@ -3,7 +3,9 @@ use std::io::Write; use std::path::Path; use std::process::{Command, Output, Stdio}; -use super::common::apply_tracedecay_home_env; +use super::common::{ + apply_tracedecay_home_env, git_program, spawn_tracedecay_daemon, tracedecay_command_with_home, +}; const NO_INPUT_HOOKS: &[&str] = &["hook-pre-tool-use", "hook-prompt-submit", "hook-stop"]; const STDIN_HOOKS: &[&str] = &[ @@ -48,11 +50,15 @@ fn hold_external_exclusive_lease(home: &Path) -> File { } fn run_hook(home: &Path, hook: &str, input: Option<&[u8]>) -> Output { + run_hook_at(home, home, hook, input) +} + +fn run_hook_at(home: &Path, cwd: &Path, hook: &str, input: Option<&[u8]>) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); apply_tracedecay_home_env(&mut command, home); command .arg(hook) - .current_dir(home) + .current_dir(cwd) .stdin(if input.is_some() { Stdio::piped() } else { @@ -106,13 +112,43 @@ fn exclusive_lifecycle_owner_quiesces_every_hook_before_startup_or_dispatch() { #[test] fn normal_lease_path_still_executes_a_direct_claude_stdin_hook() { let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write(project.join("src/lib.rs"), "pub fn hook_fixture() {}\n").unwrap(); + let git = git_program(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "test@tracedecay.dev"][..], + &["config", "user.name", "TraceDecay Test"][..], + &["add", "."][..], + &["commit", "-q", "-m", "fixture"][..], + ] { + assert!( + Command::new(&git) + .args(args) + .current_dir(&project) + .status() + .unwrap() + .success() + ); + } + assert!( + tracedecay_command_with_home(temp.path()) + .arg("init") + .current_dir(&project) + .status() + .unwrap() + .success() + ); + let _daemon = spawn_tracedecay_daemon(temp.path()); let event = format!( "{{\"hook_event_name\":\"SessionStart\",\"cwd\":{}}}", - serde_json::to_string(&temp.path().to_string_lossy()).unwrap() + serde_json::to_string(&project.to_string_lossy()).unwrap() ); - let output = run_hook( + let output = run_hook_at( temp.path(), + &project, "hook-claude-session-start", Some(event.as_bytes()), ); diff --git a/tests/hooks_lsp_suite/hook_replay_test.rs b/tests/hooks_lsp_suite/hook_replay_test.rs index a53ebe140..f62b7a0ab 100644 --- a/tests/hooks_lsp_suite/hook_replay_test.rs +++ b/tests/hooks_lsp_suite/hook_replay_test.rs @@ -15,13 +15,9 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; use tracedecay::global_db::{AnalyticsEventQuery, GlobalDb}; -use tracedecay::storage::{ - EnrollmentMarker, StorageMode, profile_sharded_data_root, write_enrollment_marker, -}; +use tracedecay::storage::{StorageMode, default_profile_sharded_layout}; -use crate::common::tracedecay_command_with_home; - -const REPLAY_PROJECT_ID: &str = "proj_hook_replay"; +use crate::common::{git_program, spawn_tracedecay_daemon, tracedecay_command_with_home}; struct Replay { subcommand: &'static str, @@ -230,15 +226,48 @@ async fn replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_ let home_root = home.path().canonicalize().expect("canonical home"); let project_root = home_root.join("project"); std::fs::create_dir_all(project_root.join("src")).unwrap(); - std::fs::write(project_root.join("Cargo.toml"), "[package]\n").unwrap(); - write_enrollment_marker( - &project_root, - &EnrollmentMarker { - project_id: REPLAY_PROJECT_ID.to_string(), - storage_mode: StorageMode::ProfileSharded, - }, + std::fs::write( + project_root.join("Cargo.toml"), + "[package]\nname = \"hook-replay-fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write( + project_root.join("src/lib.rs"), + "pub fn replay_fixture() {}\n", ) .unwrap(); + let git = git_program(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "test@tracedecay.dev"][..], + &["config", "user.name", "TraceDecay Test"][..], + &["add", "."][..], + &["commit", "-q", "-m", "fixture"][..], + ] { + assert!( + Command::new(&git) + .args(args) + .current_dir(&project_root) + .status() + .unwrap() + .success() + ); + } + let init = tracedecay_command_with_home(&home_root) + .arg("init") + .current_dir(&project_root) + .output() + .expect("initialize replay project"); + assert!( + init.status.success(), + "fixture init failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&init.stdout), + String::from_utf8_lossy(&init.stderr) + ); + let profile_root = home_root.join(".tracedecay"); + let layout = default_profile_sharded_layout(&project_root, &profile_root) + .expect("resolve initialized project layout"); + assert_eq!(layout.storage_mode, StorageMode::ProfileSharded); let root_str = project_root.display().to_string(); let replays = replays(&root_str); @@ -249,10 +278,7 @@ async fn replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_ // Every replay resolved a project root, so every row must land in the // project store file with `project_root` attribution (the user-level // fallback file stays empty). - let profile_root = home_root.join(".tracedecay"); - let store_rows = read_jsonl_rows( - &profile_sharded_data_root(&profile_root, REPLAY_PROJECT_ID).join("hook_analytics.jsonl"), - ); + let store_rows = read_jsonl_rows(&layout.data_root.join("hook_analytics.jsonl")); let hook_invoked: Vec<&Value> = store_rows .iter() .filter(|row| str_field(row, "event") == "hook_invoked") @@ -289,6 +315,7 @@ async fn replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_ ); // Bridge: `analytics sync` imports the JSONL rows into the durable table. + let daemon = spawn_tracedecay_daemon(&home_root); let sync = tracedecay_command_with_home(&home_root) .args(["analytics", "sync"]) .current_dir(&project_root) @@ -296,9 +323,22 @@ async fn replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_ .expect("analytics sync output"); assert!( sync.status.success(), - "analytics sync failed: {}", + "analytics sync failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&sync.stdout), String::from_utf8_lossy(&sync.stderr) ); + let sync_outcome: Value = serde_json::from_slice(&sync.stdout).unwrap_or_else(|error| { + panic!( + "analytics sync returned invalid JSON: {error}\nstdout:\n{}", + String::from_utf8_lossy(&sync.stdout) + ) + }); + assert_eq!( + sync_outcome.get("imported").and_then(Value::as_u64), + Some(replays.len() as u64), + "analytics sync must import every replayed hook: {sync_outcome:#}" + ); + drop(daemon); let global_db = GlobalDb::open_at(&profile_root.join("global.db")) .await diff --git a/tests/mcp_suite/fixture.rs b/tests/mcp_suite/fixture.rs index ea5005786..be8c579af 100644 --- a/tests/mcp_suite/fixture.rs +++ b/tests/mcp_suite/fixture.rs @@ -33,7 +33,7 @@ use crate::common::GLOBAL_DB_ENV; /// Bump when the template layout or fixture sources change, so stale /// templates from previous revisions in a cached target dir are ignored. -const TEMPLATE_DIR_NAME: &str = "mcp-suite-store-template-v1"; +const TEMPLATE_DIR_NAME: &str = "mcp-suite-store-template-v2"; const EMPTY_FLAVOR: &str = "empty"; const INDEXED_FLAVOR: &str = "indexed"; diff --git a/tests/mcp_suite/mcp_cli_serve_test.rs b/tests/mcp_suite/mcp_cli_serve_test.rs index 973d45146..e13338f6f 100644 --- a/tests/mcp_suite/mcp_cli_serve_test.rs +++ b/tests/mcp_suite/mcp_cli_serve_test.rs @@ -264,6 +264,7 @@ async fn serve_stdio_smokes_managed_skill_list_and_view() { approve_managed_skill(&profile_root, "active-stdio-skill") .await .unwrap(); + let _daemon = common::spawn_tracedecay_daemon(home.path()); let mut child = tracedecay_command_with_home(home.path()) .arg("serve") @@ -412,6 +413,7 @@ async fn serve_stdio_smokes_automation_run_artifact_view() { ) .await .unwrap(); + let _daemon = common::spawn_tracedecay_daemon(home.path()); let mut child = tracedecay_command_with_home(home.path()) .arg("serve") diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 1c7c497e5..e9af487ae 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1445,7 +1445,7 @@ async fn storage_status_tool_summarizes_active_project_store_health() { assert_eq!(payload["locks"]["sync_lock_exists"].as_bool(), Some(false)); assert_eq!( payload["locks"]["branch_add_lock_exists"].as_bool(), - Some(false) + Some(layout.branch_add_lock_path.exists()) ); assert_eq!(payload["quotas"]["enforced"].as_bool(), Some(false)); assert_eq!(payload["quotas"]["graph_db_size_limit_bytes"], Value::Null); @@ -12432,9 +12432,10 @@ async fn message_search_preserves_provider_project_parent_scope_shape_after_lcm( async fn lcm_status_cli_bridge_accepts_json_args() { let (cg, _dir) = setup_project().await; let home = _dir.path().join("home"); - let _daemon = common::spawn_tracedecay_daemon(&home); let outside_cwd = test_temp_dir(); let project_arg = cg.project_root().display().to_string(); + close_test_graph(cg).await; + let _daemon = common::spawn_tracedecay_daemon(&home); let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay")); common::apply_tracedecay_home_env(&mut command, &home); let output = command @@ -12476,8 +12477,28 @@ async fn user_message_search_cli_bridge_accepts_storage_scope() { let home_dir = test_temp_dir(); let home = home_dir.path().join("home"); std::fs::create_dir_all(&home).unwrap(); - let _daemon = common::spawn_tracedecay_daemon(&home); let outside_cwd = test_temp_dir(); + std::fs::create_dir_all(outside_cwd.path().join("src")).unwrap(); + std::fs::write( + outside_cwd.path().join("src/lib.rs"), + "pub fn user_scope_bridge_fixture() {}\n", + ) + .unwrap(); + + let mut init = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay")); + common::apply_tracedecay_home_env(&mut init, &home); + let init_output = init + .arg("init") + .current_dir(outside_cwd.path()) + .output() + .unwrap(); + assert!( + init_output.status.success(), + "fixture init failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&init_output.stdout), + String::from_utf8_lossy(&init_output.stderr) + ); + let _daemon = common::spawn_tracedecay_daemon(&home); let mut ingest = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay")); common::apply_tracedecay_home_env(&mut ingest, &home); diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index d8131e578..1aa432df3 100644 --- a/tests/storage_suite/corruption_test.rs +++ b/tests/storage_suite/corruption_test.rs @@ -14,7 +14,7 @@ use crate::support; use std::io::{Seek, Write}; use tempfile::TempDir; use tracedecay::db::Database; -use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions, try_acquire_sync_lock}; +use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions, try_acquire_sync_lock_at}; use tracedecay::types::*; /// Helper: create a temp database and return (Database, TempDir, db_path). @@ -134,13 +134,15 @@ async fn quick_check_detects_page_level_corruption() { file.sync_all().unwrap(); } - // Reopen — quick_check should detect the corruption - let (db2, _) = crate::common::open_test_database(&db_path) - .await - .expect("open should succeed even with corruption"); - let intact = db2.quick_check().await.unwrap(); - assert!(!intact, "quick_check should detect page-level corruption"); - db2.close(); + // Writable open validates first and must reject the damaged store. + let error = match crate::common::open_test_database(&db_path).await { + Ok(_) => panic!("writable open must reject page-level corruption"), + Err(error) => error, + }; + assert!( + Database::is_corruption_error(&error), + "integrity rejection must be classified as corruption: {error}" + ); } // ─── FTS rebuild ───────────────────────────────────────────────────────── @@ -292,6 +294,15 @@ fn is_corruption_error_matches_file_is_not_a_database() { assert!(Database::is_corruption_error(&e)); } +#[test] +fn is_corruption_error_matches_failed_integrity_validation() { + let e = tracedecay::errors::TraceDecayError::Database { + message: "database quick_check failed: Tree 2 page 2 returned error code 11".to_string(), + operation: "validate_integrity".to_string(), + }; + assert!(Database::is_corruption_error(&e)); +} + #[test] fn is_corruption_error_rejects_normal_errors() { let e = tracedecay::errors::TraceDecayError::Database { @@ -358,13 +369,13 @@ fn dirty_sentinel_survives_drop() { #[tokio::test] async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { if let Ok(mode) = std::env::var("TRACEDECAY_TEST_LOCK_CHILD") { - let project = std::path::PathBuf::from( - std::env::var_os("TRACEDECAY_TEST_LOCK_PROJECT").expect("child project path"), - ); let ready = std::path::PathBuf::from( std::env::var_os("TRACEDECAY_TEST_LOCK_READY").expect("child ready path"), ); - let guard = try_acquire_sync_lock(&project).expect("child lock lease"); + let lock_path = std::path::PathBuf::from( + std::env::var_os("TRACEDECAY_TEST_LOCK_PATH").expect("child lock path"), + ); + let guard = try_acquire_sync_lock_at(&lock_path).expect("child lock lease"); std::fs::write(&ready, b"ready").expect("publish child readiness"); if mode == "crash" { std::mem::forget(guard); @@ -381,11 +392,7 @@ async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { } let dir = TempDir::new().unwrap(); - let project = dir.path().join("repo"); - std::fs::create_dir_all(&project).unwrap(); - let ts = TraceDecay::init(&project).await.unwrap(); - let lock_path = ts.store_layout().sync_lock_path.clone(); - ts.close(); + let lock_path = dir.path().join("sync.lock"); let run_child = |mode: &str, release: Option<&std::path::Path>| { let ready = dir.path().join(format!("{mode}.ready")); @@ -394,10 +401,10 @@ async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { .arg("persistent_sync_lock_coordinates_processes_and_recovers_after_crash") .arg("--nocapture") .env("TRACEDECAY_TEST_LOCK_CHILD", mode) - .env("TRACEDECAY_TEST_LOCK_PROJECT", &project) .env("TRACEDECAY_TEST_LOCK_READY", &ready) + .env("TRACEDECAY_TEST_LOCK_PATH", &lock_path) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); + .stderr(std::process::Stdio::inherit()); if let Some(path) = release { command.env("TRACEDECAY_TEST_LOCK_RELEASE", path); } @@ -418,14 +425,12 @@ async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { let release = dir.path().join("release"); let mut holder = run_child("hold", Some(&release)); assert!( - try_acquire_sync_lock(&project).is_err(), + try_acquire_sync_lock_at(&lock_path).is_err(), "a second process must not enter while the kernel lease is held" ); std::fs::write(&release, b"release").unwrap(); assert!(holder.wait().unwrap().success()); - let guard = try_acquire_sync_lock(&project).expect("released lease must be reusable"); - drop(guard); assert!(lock_path.exists(), "the lockfile must persist after Drop"); #[cfg(unix)] { @@ -440,7 +445,10 @@ async fn persistent_sync_lock_coordinates_processes_and_recovers_after_crash() { let mut crashed = run_child("crash", None); assert_eq!(crashed.wait().unwrap().code(), Some(86)); assert!(lock_path.exists(), "the lockfile must survive a crash"); - drop(try_acquire_sync_lock(&project).expect("kernel must release a crashed lease")); + drop( + try_acquire_sync_lock_at(&lock_path) + .expect("released and crashed leases must both be reusable"), + ); } #[tokio::test] @@ -573,7 +581,13 @@ async fn dirty_open_does_not_race_an_active_sync_lock() "{}.sync.lock", layout.graph_db_path.file_name().unwrap().to_string_lossy() )); - std::fs::write(&active_lock, std::process::id().to_string())?; + let active_lock_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&active_lock)?; + fs2::FileExt::try_lock_exclusive(&active_lock_file)?; std::fs::write(&layout.dirty_path, "pid=99999\nversion=test")?; let before = std::fs::read(&layout.graph_db_path)?; @@ -588,6 +602,7 @@ async fn dirty_open_does_not_race_an_active_sync_lock() ); assert_eq!(std::fs::read(&layout.graph_db_path)?, before); assert!(layout.dirty_path.exists()); + drop(active_lock_file); Ok(()) } diff --git a/tests/storage_suite/corruption_test/fallback.rs b/tests/storage_suite/corruption_test/fallback.rs index 893e46da3..73f691874 100644 --- a/tests/storage_suite/corruption_test/fallback.rs +++ b/tests/storage_suite/corruption_test/fallback.rs @@ -1,4 +1,24 @@ use super::*; +use std::path::Path; + +async fn raw_quick_check_detects_corruption(db_path: &Path) -> bool { + let raw = libsql::Builder::new_local(db_path) + .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + .expect("build raw read-only database"); + let conn = raw.connect().expect("connect raw read-only database"); + let detected = match conn.query("PRAGMA quick_check", ()).await { + Ok(mut rows) => match rows.next().await { + Ok(Some(row)) => row.get::(0).map_or(true, |result| result != "ok"), + Ok(None) | Err(_) => true, + }, + Err(_) => true, + }; + drop(conn); + drop(raw); + detected +} #[tokio::test] async fn fts_corruption_falls_back_without_rebuild_or_write() { @@ -46,23 +66,37 @@ async fn fts_corruption_falls_back_without_rebuild_or_write() { bytes[offset..offset + 8].fill(0xff); std::fs::write(&db_path, bytes).unwrap(); - let (db, _) = crate::common::open_test_database(&db_path).await.unwrap(); assert!( - !db.quick_check().await.unwrap(), + raw_quick_check_detects_corruption(&db_path).await, "fixture must trigger SQLite's FTS integrity failure" ); - let changes_before = db.conn().total_changes(); + let corrupted_bytes = std::fs::read(&db_path).unwrap(); - let results = db.search_nodes("important_handler", 10).await.unwrap(); - assert_eq!(results[0].node.id, "e1", "LIKE fallback must still match"); + let raw = libsql::Builder::new_local(&db_path) + .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await + .expect("build raw read-only database"); + let conn = raw.connect().expect("connect raw read-only database"); + let mut rows = conn + .query( + "SELECT id FROM nodes WHERE name LIKE '%important_handler%'", + (), + ) + .await + .unwrap(); assert_eq!( - db.conn().total_changes(), - changes_before, - "search must not rebuild or otherwise write" + rows.next() + .await + .unwrap() + .unwrap() + .get::(0) + .unwrap(), + "e1", + "the intact nodes table must remain readable" ); - - let mut rows = db - .conn() + drop(rows); + let mut rows = conn .query( "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH '\"important_handler\"*'", (), @@ -74,7 +108,25 @@ async fn fts_corruption_falls_back_without_rebuild_or_write() { "the corrupt FTS index must remain untouched for offline repair" ); drop(rows); - close_db(db).await; + drop(conn); + drop(raw); + + let error = match crate::common::open_test_database(&db_path).await { + Err(error) => error, + Ok((db, _)) => { + db.close(); + panic!("writable open must fail closed on corruption"); + } + }; + assert!( + error.to_string().contains("database quick_check failed"), + "unexpected error: {error}" + ); + assert_eq!( + std::fs::read(&db_path).unwrap(), + corrupted_bytes, + "failed open must not rebuild or otherwise write" + ); } #[tokio::test] @@ -125,17 +177,26 @@ async fn whole_database_corruption_propagates_without_write() { bytes[((root_page - 1) * page_size) as usize] = 0xff; std::fs::write(&db_path, bytes).unwrap(); - let (db, _) = crate::common::open_test_database(&db_path).await.unwrap(); - let changes_before = db.conn().total_changes(); - let error = db.search_nodes("whole_db_probe", 10).await.unwrap_err(); + let corrupted_bytes = std::fs::read(&db_path).unwrap(); + assert!( + raw_quick_check_detects_corruption(&db_path).await, + "fixture must fail SQLite's read-only quick_check" + ); + + let error = match crate::common::open_test_database(&db_path).await { + Err(error) => error, + Ok((db, _)) => { + db.close(); + panic!("writable open must fail closed on corruption"); + } + }; assert!( - Database::is_corruption_error(&error), + error.to_string().contains("database quick_check failed"), "unexpected error: {error}" ); assert_eq!( - db.conn().total_changes(), - changes_before, - "search must not write while reporting whole-database corruption" + std::fs::read(&db_path).unwrap(), + corrupted_bytes, + "failed open must not write while reporting whole-database corruption" ); - db.close(); } diff --git a/tests/storage_suite/profile_storage_migration_test.rs b/tests/storage_suite/profile_storage_migration_test.rs index a211b05f6..2ddec4abf 100644 --- a/tests/storage_suite/profile_storage_migration_test.rs +++ b/tests/storage_suite/profile_storage_migration_test.rs @@ -56,7 +56,8 @@ async fn hermes_home_env_cannot_redirect_legacy_migration() { assert_eq!(report, Default::default()); assert_eq!(fs::read(&redirected_db).unwrap(), b"must remain untouched"); - assert!(!profile_root.exists()); + assert!(!profile_root.join("global.db").exists()); + assert!(!profile_root.join("projects").exists()); } impl HomeEnvGuard { From 49e26a186ae61ccb38999d10a7e38cc0c2f80723 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 08:05:06 +0000 Subject: [PATCH 15/21] fix(daemon): reuse global database authority --- src/daemon.rs | 95 +++++++++++-------- src/daemon/branch_admin.rs | 32 +++++++ src/mcp/tools/handlers/admin_cli.rs | 16 +--- tests/common/mod.rs | 8 +- .../cli_non_interactive_test.rs | 9 +- 5 files changed, 105 insertions(+), 55 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 2fccb3eca..de35cc938 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2277,8 +2277,13 @@ impl DaemonEngine { return Ok((key, canonical_project_path, server)); } - let accounting_db = accounting_db_for_handshake(handshake).await?; - let registry_db = registry_db_for_handshake(handshake).await?; + let registry_db = self + .store_administration + .global_database(&handshake.client_identity.global_db_path) + .await?; + let accounting_db = + crate::global_db::global_accounting_enabled().then(|| Arc::clone(®istry_db)); + let registry_db = Some(registry_db); let current_key = Arc::new(tokio::sync::Mutex::new(key.clone())); let route_registered = Arc::new(AtomicBool::new(true)); let reconciler = self.automation_scheduler_reconciler( @@ -2462,6 +2467,7 @@ async fn serve_authenticated_socket_client( async fn apply_daemon_initialize_route( handshake: &mut DaemonHandshake, first_request_line: &str, + store_administration: &StoreAdministration, ) -> Result> { if !handshake.allow_initialize_root_routing { return Ok(None); @@ -2472,10 +2478,11 @@ async fn apply_daemon_initialize_route( if request.method != "initialize" { return Ok(None); } - let registry = - crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path).await?; + let registry = store_administration + .global_database(&handshake.client_identity.global_db_path) + .await?; let Some(route) = - resolve_daemon_initialize_route(request.params.as_ref(), registry.as_ref()).await + resolve_daemon_initialize_route(request.params.as_ref(), Some(®istry)).await else { return Ok(None); }; @@ -2566,8 +2573,12 @@ async fn serve_broker_socket_client( let Some(first_request_line) = first_request_line else { return Ok(()); }; - let initialize_route = - apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; + let initialize_route = apply_daemon_initialize_route( + &mut handshake, + &first_request_line, + &engine.store_administration, + ) + .await?; if let Some(request) = parse_branch_admin_request(&first_request_line) { let result = match request.action.clone() { Ok(action) => engine.execute_branch_admin(&handshake, action).await, @@ -2643,6 +2654,7 @@ async fn serve_broker_socket_client( &mut transport, &handshake.client_identity, &engine.lifecycle, + &engine.store_administration, ) .await?; } @@ -2682,7 +2694,8 @@ async fn serve_windows_broker_client( return Ok(()); }; let initialize_route = - apply_daemon_initialize_route(&mut handshake, &first_request_line).await?; + apply_daemon_initialize_route(&mut handshake, &first_request_line, &store_administration) + .await?; if let Some(request) = parse_branch_admin_request(&first_request_line) { let result = match request.action.clone() { Ok(action) => { @@ -2749,7 +2762,13 @@ async fn serve_windows_broker_client( drop(setup_activity); let mut transport = ReplayTransport::new(transport); transport.push_replay(first_request_line); - serve_projectless_client(&mut transport, &handshake.client_identity, lifecycle).await?; + serve_projectless_client( + &mut transport, + &handshake.client_identity, + lifecycle, + &store_administration, + ) + .await?; } Ok(()) } @@ -2816,8 +2835,12 @@ async fn portable_project_server( Arc::clone(&route_registered), handshake.clone(), ); - let accounting_db = accounting_db_for_handshake(handshake).await?; - let registry_db = registry_db_for_handshake(handshake).await?; + let registry_db = store_administration + .global_database(&handshake.client_identity.global_db_path) + .await?; + let accounting_db = + crate::global_db::global_accounting_enabled().then(|| Arc::clone(®istry_db)); + let registry_db = Some(registry_db); let candidate = crate::mcp::McpServer::new_with_dbs_and_reconcilers_and_writers( cg, handshake.scope_prefix.clone(), @@ -2929,27 +2952,6 @@ async fn open_existing_project_with_options( } } -async fn accounting_db_for_handshake( - handshake: &DaemonHandshake, -) -> Result>> { - if !crate::global_db::global_accounting_enabled() { - return Ok(None); - } - crate::global_db::GlobalDb::try_open_at(&handshake.client_identity.global_db_path) - .await - .map(|db| db.map(Arc::new)) -} - -async fn registry_db_for_handshake( - handshake: &DaemonHandshake, -) -> Result>> { - Ok( - crate::global_db::GlobalDb::open_read_only_at(&handshake.client_identity.global_db_path) - .await - .map(Arc::new), - ) -} - async fn write_project_open_error( transport: &mut impl McpTransport, error: &TraceDecayError, @@ -2986,6 +2988,7 @@ async fn serve_projectless_client( transport: &mut impl McpTransport, client_identity: &DaemonClientIdentity, lifecycle: &DaemonLifecycle, + store_administration: &StoreAdministration, ) -> Result<()> { loop { let line = tokio::select! { @@ -2999,7 +3002,9 @@ async fn serve_projectless_client( break; }; let response = match serde_json::from_str::(&line) { - Ok(request) => projectless_response(&request, client_identity).await, + Ok(request) => { + projectless_response(&request, client_identity, store_administration).await + } Err(e) => Some(JsonRpcResponse::error( json!(null), ErrorCode::ParseError, @@ -3019,6 +3024,7 @@ async fn serve_projectless_client( async fn projectless_response( request: &crate::mcp::JsonRpcRequest, client_identity: &DaemonClientIdentity, + store_administration: &StoreAdministration, ) -> Option { let id = request.id.clone()?; match request.method.as_str() { @@ -3038,7 +3044,13 @@ async fn projectless_response( }), )), "tools/call" => Some( - projectless_tools_call_response(id, request.params.as_ref(), client_identity).await, + projectless_tools_call_response( + id, + request.params.as_ref(), + client_identity, + store_administration, + ) + .await, ), "ping" | "logging/setLevel" => Some(JsonRpcResponse::success(id, json!({}))), _ => Some(JsonRpcResponse::error( @@ -3053,6 +3065,7 @@ async fn projectless_tools_call_response( id: serde_json::Value, params: Option<&serde_json::Value>, client_identity: &DaemonClientIdentity, + store_administration: &StoreAdministration, ) -> crate::mcp::JsonRpcResponse { let (tool_name, arguments) = match projectless_tool_call(params) { Ok(tool_call) => tool_call, @@ -3072,12 +3085,16 @@ async fn projectless_tools_call_response( }; } if tool_name == "tracedecay_admin_cli" { - return match crate::mcp::tools::handle_projectless_admin_cli( - arguments, - &client_identity.profile_root, - ) - .await + let global_db = match store_administration + .global_database(&client_identity.global_db_path) + .await { + Ok(global_db) => global_db, + Err(error) => { + return JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()); + } + }; + return match crate::mcp::tools::handle_projectless_admin_cli(arguments, &global_db).await { Ok(result) => JsonRpcResponse::success(id, result.value), Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), }; diff --git a/src/daemon/branch_admin.rs b/src/daemon/branch_admin.rs index 4e0e80f8a..35e368685 100644 --- a/src/daemon/branch_admin.rs +++ b/src/daemon/branch_admin.rs @@ -30,6 +30,7 @@ type ExternalHolderVerifier = fn(&[PathBuf]) -> Result<()>; pub(super) struct StoreAdministration { gate: Arc>, project_servers: Arc>, + global_databases: Arc>>>, automation_schedulers: Arc>>, #[cfg(test)] @@ -41,6 +42,7 @@ impl Default for StoreAdministration { Self { gate: Arc::new(tokio::sync::Mutex::new(())), project_servers: Arc::new(tokio::sync::Mutex::new(DatabaseOwnerRegistry::default())), + global_databases: Arc::new(tokio::sync::Mutex::new(HashMap::new())), automation_schedulers: Arc::new(tokio::sync::Mutex::new(HashMap::new())), #[cfg(test)] external_holder_verifier: None, @@ -73,6 +75,36 @@ impl StoreAdministration { &self.project_servers } + pub(super) async fn global_database( + &self, + path: &Path, + ) -> Result> { + let path = authority::canonical_identity_path(path)?; + let mut global_databases = self.global_databases.lock().await; + if let Some(database) = global_databases.get(&path) { + return Ok(Arc::clone(database)); + } + let mut database = None; + for attempt in 0..40 { + match crate::global_db::GlobalDb::try_open_at(&path).await? { + Some(opened) => { + database = Some(opened); + break; + } + None if attempt < 39 => { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + None => break, + } + } + let database = database.ok_or_else(|| TraceDecayError::Config { + message: format!("daemon global database '{}' is unavailable", path.display()), + })?; + let database = Arc::new(database); + global_databases.insert(path, Arc::clone(&database)); + Ok(database) + } + pub(super) fn automation_schedulers( &self, ) -> &Arc>> { diff --git a/src/mcp/tools/handlers/admin_cli.rs b/src/mcp/tools/handlers/admin_cli.rs index c5eabbf53..44f9c2216 100644 --- a/src/mcp/tools/handlers/admin_cli.rs +++ b/src/mcp/tools/handlers/admin_cli.rs @@ -99,22 +99,10 @@ pub(super) async fn handle_admin_cli( pub(crate) async fn handle_projectless_admin_cli( args: Value, - profile_root: &Path, + global_db: &GlobalDb, ) -> Result { let action = parse_admin_cli_action(args)?; - let global_db_path = profile_root.join("global.db"); - let mut global_db = None; - for _ in 0..40 { - global_db = GlobalDb::open_at(&global_db_path).await; - if global_db.is_some() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let global_db = global_db.ok_or_else(|| TraceDecayError::Config { - message: "daemon global database is unavailable".to_string(), - })?; - dispatch_admin_cli(AdminCliContext::projectless(&global_db), action).await + dispatch_admin_cli(AdminCliContext::projectless(global_db), action).await } fn parse_admin_cli_action(args: Value) -> Result { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 986f9c5e8..36bf88339 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -452,6 +452,12 @@ pub struct DaemonProcess { child: Child, } +impl DaemonProcess { + fn is_running(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } +} + impl Drop for DaemonProcess { fn drop(&mut self) { let _ = self.child.kill(); @@ -490,7 +496,7 @@ pub fn ensure_tracedecay_daemon(home: &Path) { let home = canonical_existing_path(home); TEST_DAEMONS.with(|daemons| { let mut daemons = daemons.borrow_mut(); - daemons.retain(|existing_home, _| existing_home == &home); + daemons.retain(|existing_home, daemon| existing_home == &home && daemon.is_running()); daemons .entry(home.clone()) .or_insert_with(|| spawn_tracedecay_daemon(&home)); diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 42dc15335..55993f2e3 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -1924,6 +1924,7 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { let project = TempDir::new().unwrap(); let project_root = canonical_temp_path(project.path()); write_profile_sharded_fixture(home.path(), &project_root); + let source_db = profile_shard_root(home.path()).join("tracedecay.db"); let export_dir = canonical_temp_path(home.path()).join("exported-store"); let mut command = tracedecay_command_without_daemon(home.path(), &project_root); @@ -1944,7 +1945,13 @@ fn migrate_export_from_profile_copies_profile_store_to_target() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!(export_dir.join("tracedecay.db").is_file()); + let exported_db = export_dir.join("tracedecay.db"); + assert!(exported_db.is_file()); + assert_eq!( + std::fs::read(&exported_db).unwrap(), + std::fs::read(&source_db).unwrap(), + "exported graph database must match the source snapshot" + ); let exported_manifest = tracedecay::storage::read_store_manifest(&export_dir.join(STORE_MANIFEST_FILENAME)) .unwrap(); From b0e25b39709e409448dafdce1fea2fd1e5bd4205 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 08:06:52 +0000 Subject: [PATCH 16/21] test(daemon): share initialize registry authority --- src/daemon/tests.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index 57bd1e229..369dc3110 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -913,6 +913,7 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { base_handshake.client_identity = test_client_identity_for(profile.path().to_path_buf()); base_handshake.client_identity.global_db_path = global_db_path; let mut routed_handshake = base_handshake.clone(); + let store_administration = super::StoreAdministration::default(); let line = json!({ "jsonrpc": "2.0", @@ -928,10 +929,11 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { .to_string(); super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); - let route = super::apply_daemon_initialize_route(&mut routed_handshake, &line) - .await - .expect("daemon initialize routing should succeed") - .expect("registered initialize root should produce a route"); + let route = + super::apply_daemon_initialize_route(&mut routed_handshake, &line, &store_administration) + .await + .expect("daemon initialize routing should succeed") + .expect("registered initialize root should produce a route"); assert_eq!(route.project_path, project_b); assert_eq!( @@ -953,10 +955,14 @@ async fn initialize_root_routing_replaces_cached_project_and_scope() { &rerun_without_roots, ); assert!( - super::apply_daemon_initialize_route(&mut routed_handshake, &rerun_without_roots) - .await - .expect("daemon initialize reroute should succeed") - .is_none() + super::apply_daemon_initialize_route( + &mut routed_handshake, + &rerun_without_roots, + &store_administration, + ) + .await + .expect("daemon initialize reroute should succeed") + .is_none() ); assert_eq!( @@ -995,6 +1001,7 @@ async fn daemon_resolves_registry_only_initialize_root_alias() { handshake.allow_initialize_root_routing = true; handshake.client_identity = test_client_identity_for(profile.path().to_path_buf()); handshake.client_identity.global_db_path = global_db_path; + let store_administration = super::StoreAdministration::default(); let line = json!({ "jsonrpc": "2.0", "id": 1, @@ -1003,7 +1010,7 @@ async fn daemon_resolves_registry_only_initialize_root_alias() { }) .to_string(); - let route = super::apply_daemon_initialize_route(&mut handshake, &line) + let route = super::apply_daemon_initialize_route(&mut handshake, &line, &store_administration) .await .expect("daemon initialize routing should succeed") .expect("authenticated daemon should resolve registry alias"); @@ -1047,8 +1054,9 @@ async fn initialize_root_routing_delegates_config_gated_git_auto_init() { .to_string(); let mut routed_handshake = base_handshake.clone(); + let store_administration = super::StoreAdministration::default(); super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); - super::apply_daemon_initialize_route(&mut routed_handshake, &line) + super::apply_daemon_initialize_route(&mut routed_handshake, &line, &store_administration) .await .expect("daemon should delegate auto-init"); assert_eq!( @@ -1064,7 +1072,7 @@ async fn initialize_root_routing_delegates_config_gated_git_auto_init() { config.sync.auto_init = false; crate::config::save_config(&project, &config).expect("disable auto-init"); super::reset_proxy_handshake_for_initialize(&base_handshake, &mut routed_handshake, &line); - super::apply_daemon_initialize_route(&mut routed_handshake, &line) + super::apply_daemon_initialize_route(&mut routed_handshake, &line, &store_administration) .await .expect("daemon should resolve git root with auto-init disabled"); assert_eq!( From aba92b971b7edaa5a3d12d85e98f4375a802b7f5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 08:34:59 +0000 Subject: [PATCH 17/21] fix(daemon): release legacy sync ownership after writes --- src/daemon.rs | 68 ++++++++----------- src/db/access/bootstrap.rs | 2 +- src/tracedecay/lifecycle.rs | 8 +-- src/tracedecay/locking.rs | 44 +++--------- tests/common/mod.rs | 5 ++ .../regression_core_engine_test.rs | 17 +---- tests/core_cli_suite/tool_first_touch_test.rs | 2 +- tests/hooks_lsp_suite/hook_replay_test.rs | 4 +- tests/storage_suite/branch_db_safety_test.rs | 2 + 9 files changed, 54 insertions(+), 98 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index de35cc938..8b515532d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -772,8 +772,21 @@ fn connection_for_socket_path(socket_path: &Path) -> DaemonConnection { { return connection; } + if let Some(profile_root) = socket_path.parent() + && let Ok(Some(record)) = authority::current_record(profile_root) + && let DaemonEndpoint::Unix(authority_path) = &record.endpoint + && authority::canonical_identity_path(authority_path).ok() + == authority::canonical_identity_path(socket_path).ok() + { + return DaemonConnection { + endpoint: record.endpoint.clone(), + auth_token: Some(record.auth_token.clone()), + authority_record: Some(record), + }; + } // Explicit paths are retained for test harnesses and legacy one-shot - // callers. Default production routing always uses the authority record. + // callers without a discoverable authority record. Default production + // routing always uses the authority record. DaemonConnection { endpoint: DaemonEndpoint::Unix(socket_path.to_path_buf()), auth_token: None, @@ -2599,9 +2612,7 @@ async fn serve_broker_socket_client( let server = match Box::pin(engine.project_server(&handshake)).await { Ok(server) => server, Err(e) => { - let mut transport = ReplayTransport::new(transport); - transport.push_replay(first_request_line); - write_project_open_error(&mut transport, &e).await?; + write_project_open_error(&mut transport, &first_request_line, &e).await?; return Err(e); } }; @@ -2734,9 +2745,7 @@ async fn serve_windows_broker_client( let server = match server_result { Ok(server) => server, Err(error) => { - let mut transport = ReplayTransport::new(transport); - transport.push_replay(first_request_line); - write_project_open_error(&mut transport, &error).await?; + write_project_open_error(&mut transport, &first_request_line, &error).await?; return Err(error); } }; @@ -2929,47 +2938,28 @@ async fn open_existing_project_with_options( project_path: &Path, open_options: crate::tracedecay::TraceDecayOpenOptions, ) -> Result { - match crate::tracedecay::TraceDecay::open_with_options(project_path, open_options.clone()).await - { - Ok(cg) => Ok(cg), - Err(open_err) => { - match crate::tracedecay::TraceDecay::open_read_only_with_options( - project_path, - open_options, - ) - .await - { - Ok(cg) => { - cg.ensure_schema_current().await?; - Ok(cg) - } - Err(_) if is_missing_index_error(&open_err) => { - Err(missing_index_error(project_path)) - } - Err(_) => Err(open_err), + crate::tracedecay::TraceDecay::open_with_options(project_path, open_options) + .await + .map_err(|error| { + if is_missing_index_error(&error) { + missing_index_error(project_path) + } else { + error } - } - } + }) } async fn write_project_open_error( transport: &mut impl McpTransport, + request_line: &str, error: &TraceDecayError, ) -> Result<()> { - let id = read_json_rpc_request_id(transport).await?; - let response = JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()); - write_json_rpc_response(transport, &response).await -} - -async fn read_json_rpc_request_id(transport: &mut impl McpTransport) -> Result { - let Some(line) = transport.read_line().await? else { - return Ok(serde_json::Value::Null); - }; - - Ok(serde_json::from_str::(&line) + let id = serde_json::from_str::(request_line) .ok() .and_then(|request| request.id) - .unwrap_or(serde_json::Value::Null)) + .unwrap_or(serde_json::Value::Null); + let response = JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()); + write_json_rpc_response(transport, &response).await } async fn write_json_rpc_response( diff --git a/src/db/access/bootstrap.rs b/src/db/access/bootstrap.rs index f46e06374..1f0e5cf23 100644 --- a/src/db/access/bootstrap.rs +++ b/src/db/access/bootstrap.rs @@ -37,7 +37,7 @@ pub(super) fn reject_hard_linked_database(path: &Path) -> Result<()> { } #[cfg(windows)] -pub(super) fn windows_hard_link_count(path: &Path) -> Result { +pub(crate) fn windows_hard_link_count(path: &Path) -> Result { use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; let file = std::fs::File::open(path) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index b3ef6620e..1dd150d96 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -14,8 +14,7 @@ use crate::global_db::{GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpser use crate::storage::{self, StoreLayout}; use super::locking::{ - clear_dirty_sentinel_at, has_dirty_sentinel_at, seed_legacy_sync_owner_at, - try_acquire_graph_sync_locks, + clear_dirty_sentinel_at, has_dirty_sentinel_at, try_acquire_graph_sync_locks, }; use super::{TraceDecay, TraceDecayOpenOptions, current_timestamp}; @@ -35,7 +34,6 @@ impl TraceDecay { let store_layout = Self::resolve_store_layout_for_project(project_root, &open_options).await?; let authority = DatabaseAuthority::for_runtime(&store_layout.graph_db_path, "init")?; - seed_legacy_sync_owner_at(&store_layout.sync_lock_path)?; let config = TraceDecayConfig { root_dir: project_root.to_string_lossy().to_string(), ..TraceDecayConfig::default() @@ -303,10 +301,6 @@ impl TraceDecay { ) -> Result { let store_layout = Self::resolve_store_layout_for_project(project_root, &open_options).await?; - let seed_authority = - DatabaseAuthority::for_runtime(&store_layout.graph_db_path, "open project store")?; - seed_legacy_sync_owner_at(&store_layout.sync_lock_path)?; - drop(seed_authority); let config = load_config_from_path(project_root, &store_layout.config_path)?; let active_branch = branch::current_branch(project_root); Self::auto_track_active_branch( diff --git a/src/tracedecay/locking.rs b/src/tracedecay/locking.rs index e65cfb58e..b8018ad9c 100644 --- a/src/tracedecay/locking.rs +++ b/src/tracedecay/locking.rs @@ -245,7 +245,11 @@ pub(super) fn try_acquire_graph_sync_locks( impl Drop for SyncLockGuard { fn drop(&mut self) { - clear_lock_owner_if_matches(&self.owner_path, &self.epoch); + if clear_lock_owner_if_matches(&self.owner_path, &self.epoch) { + let _ = self.file.set_len(0); + let _ = self.file.seek(SeekFrom::Start(0)); + let _ = self.file.sync_all(); + } let _ = FileExt::unlock(&self.file); } } @@ -296,7 +300,7 @@ pub fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { let mut previous = String::new(); let _ = file.read_to_string(&mut previous); if let Ok(pid) = previous.trim().parse::() { - if pid != std::process::id() && is_pid_alive(pid) { + if is_pid_alive(pid) { let _ = FileExt::unlock(&file); return Err(TraceDecayError::SyncLock { message: format!("another sync is already in progress (legacy PID {pid})"), @@ -338,24 +342,11 @@ pub fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { }) } -/// Publishes the current process as the legacy sync owner without retaining an -/// operation lock. Old binaries see the live bare PID and fail closed for this -/// process's lifetime; new binaries still require the kernel lock per sync. -pub(super) fn seed_legacy_sync_owner_at(lock_path: &Path) -> Result<()> { - if let Some(parent) = lock_path.parent() { - std::fs::create_dir_all(parent).map_err(|error| TraceDecayError::SyncLock { - message: format!("could not create sync lock directory: {error}"), - })?; - } - drop(try_acquire_sync_lock_at(lock_path)?); - Ok(()) -} - fn lock_owner_path(lock_path: &Path) -> PathBuf { lock_path.with_extension("lock.owner") } -fn clear_lock_owner_if_matches(path: &Path, expected_epoch: &str) { +fn clear_lock_owner_if_matches(path: &Path, expected_epoch: &str) -> bool { let matches = std::fs::read(path) .ok() .and_then(|contents| serde_json::from_slice::(&contents).ok()) @@ -368,7 +359,9 @@ fn clear_lock_owner_if_matches(path: &Path, expected_epoch: &str) { .is_some_and(|epoch| epoch == expected_epoch); if matches && std::fs::remove_file(path).is_ok() { sync_parent_directory(path); + return true; } + false } fn next_epoch() -> String { @@ -531,20 +524,6 @@ mod tests { assert_eq!(current["epoch"], replacement_epoch); } - #[test] - fn legacy_owner_seed_persists_pid_without_operation_sidecar() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sync.lock"); - - seed_legacy_sync_owner_at(&path).unwrap(); - assert_eq!( - std::fs::read_to_string(&path).unwrap(), - std::process::id().to_string() - ); - assert!(!lock_owner_path(&path).exists()); - seed_legacy_sync_owner_at(&path).unwrap(); - } - #[test] fn dead_legacy_pid_is_recoverable_without_replacing_lock_inode() { let dir = tempfile::tempdir().unwrap(); @@ -580,10 +559,7 @@ mod tests { std::process::id().to_string() ); drop(guard); - assert_eq!( - std::fs::read_to_string(&path).unwrap(), - std::process::id().to_string() - ); + assert!(std::fs::read_to_string(&path).unwrap().is_empty()); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 36bf88339..af4dfbde9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -188,6 +188,7 @@ pub struct TraceDecayStorageEnvGuard { _userprofile_guard: EnvVarGuard, _data_dir_guard: EnvVarGuard, _global_db_guard: GlobalDbEnvGuard, + _holder_scan_guard: EnvVarGuard, } impl TraceDecayStorageEnvGuard { @@ -204,6 +205,10 @@ impl TraceDecayStorageEnvGuard { _userprofile_guard: EnvVarGuard::set("USERPROFILE", &home), _data_dir_guard: EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root), _global_db_guard: GlobalDbEnvGuard::set(&global_db_path), + _holder_scan_guard: EnvVarGuard::set( + "TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", + "1", + ), } } diff --git a/tests/core_cli_suite/regression_core_engine_test.rs b/tests/core_cli_suite/regression_core_engine_test.rs index 36616b47a..e732959d3 100644 --- a/tests/core_cli_suite/regression_core_engine_test.rs +++ b/tests/core_cli_suite/regression_core_engine_test.rs @@ -378,11 +378,6 @@ async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { let lock_path = resolve_layout_for_current_profile(project) .unwrap() .sync_lock_path; - assert_eq!( - fs::read_to_string(&lock_path).unwrap(), - std::process::id().to_string(), - "init must seed a legacy-compatible daemon-lifetime owner" - ); // A dead legacy owner does not require unlinking the canonical path. fs::write(&lock_path, "4294967294").unwrap(); @@ -402,10 +397,7 @@ async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { ); assert_eq!(metadata["state"], "locked"); drop(guard); - assert_eq!( - fs::read_to_string(&lock_path).unwrap(), - std::process::id().to_string() - ); + assert!(fs::read_to_string(&lock_path).unwrap().is_empty()); assert!( lock_path.exists(), "dropping the guard must leave the persistent lockfile in place" @@ -421,7 +413,7 @@ async fn persistent_sync_lock_reuses_an_unlocked_legacy_file() { } #[tokio::test] -async fn writable_open_reseeds_legacy_sync_owner_before_database_use() { +async fn writable_open_does_not_claim_the_sync_lock_without_an_operation() { let dir = TempDir::new().unwrap(); let project = dir.path(); let initialized = TraceDecay::init(project).await.unwrap(); @@ -430,10 +422,7 @@ async fn writable_open_reseeds_legacy_sync_owner_before_database_use() { fs::write(&lock_path, "4294967294").unwrap(); let reopened = TraceDecay::open(project).await.unwrap(); - assert_eq!( - fs::read_to_string(&lock_path).unwrap(), - std::process::id().to_string() - ); + assert_eq!(fs::read_to_string(&lock_path).unwrap(), "4294967294"); drop(reopened); } diff --git a/tests/core_cli_suite/tool_first_touch_test.rs b/tests/core_cli_suite/tool_first_touch_test.rs index dc83af7a3..cac9f0d37 100644 --- a/tests/core_cli_suite/tool_first_touch_test.rs +++ b/tests/core_cli_suite/tool_first_touch_test.rs @@ -9,7 +9,6 @@ use std::path::Path; -#[cfg(unix)] use crate::common; use crate::common::{canonical_existing_path, tracedecay_command_with_home}; use tempfile::TempDir; @@ -117,6 +116,7 @@ fn code_graph_tools_do_not_first_touch_project_store() { let target_path = canonical_temp_path(target.path()); let cwd_path = canonical_temp_path(cwd.path()); let home_path = canonical_temp_path(home.path()); + let _daemon = common::spawn_tracedecay_daemon(&home_path); let target_arg = target_path.to_string_lossy().to_string(); let output = run_tool( &cwd_path, diff --git a/tests/hooks_lsp_suite/hook_replay_test.rs b/tests/hooks_lsp_suite/hook_replay_test.rs index f62b7a0ab..2d9cd96c8 100644 --- a/tests/hooks_lsp_suite/hook_replay_test.rs +++ b/tests/hooks_lsp_suite/hook_replay_test.rs @@ -335,8 +335,8 @@ async fn replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_ }); assert_eq!( sync_outcome.get("imported").and_then(Value::as_u64), - Some(replays.len() as u64), - "analytics sync must import every replayed hook: {sync_outcome:#}" + Some(store_rows.len() as u64), + "analytics sync must import every emitted hook analytics row: {sync_outcome:#}" ); drop(daemon); diff --git a/tests/storage_suite/branch_db_safety_test.rs b/tests/storage_suite/branch_db_safety_test.rs index 5234ff2ef..d345e8380 100644 --- a/tests/storage_suite/branch_db_safety_test.rs +++ b/tests/storage_suite/branch_db_safety_test.rs @@ -443,6 +443,8 @@ fn cli_branch_add_refuses_corrupt_metadata_without_overwriting() { String::from_utf8_lossy(&init.stderr) ); + let _daemon = common::spawn_tracedecay_daemon(env.home()); + let tracedecay_dir = project_data_dir(project); let meta_path = tracedecay_dir.join("branch-meta.json"); fs::write(&meta_path, b"{not valid json").unwrap(); From 2ab7a9fe45d9e6b6569c04f468ea221e6a71ebe6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 09:06:45 +0000 Subject: [PATCH 18/21] fix(storage): harden cross-platform daemon authority --- src/branch/admin/transaction.rs | 42 +++++++++++++------ src/daemon.rs | 35 ++++++++++++---- src/db/access.rs | 6 +-- src/db/access/owner_io.rs | 2 +- src/db/mod.rs | 2 +- src/global_db/tests.rs | 11 ++++- src/storage.rs | 7 +++- src/tracedecay/locking.rs | 2 +- .../cli_non_interactive_test.rs | 2 +- tests/daemon_suite/pr_autotrack_test.rs | 4 +- .../hook_lifecycle_lease_test.rs | 2 +- tests/mcp_suite/mcp_cli_serve_test.rs | 2 +- tests/mcp_suite/mcp_handler_test.rs | 25 ++++++++++- tests/mcp_suite/serve_template_path_test.rs | 2 +- tests/memory_suite/memory_eval_test.rs | 7 +--- tests/storage_suite/branch_db_safety_test.rs | 3 +- 16 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/branch/admin/transaction.rs b/src/branch/admin/transaction.rs index 113bae957..0e6f53e25 100644 --- a/src/branch/admin/transaction.rs +++ b/src/branch/admin/transaction.rs @@ -1,3 +1,4 @@ +#[cfg(not(windows))] use std::fs::File; use std::path::{Component, Path, PathBuf}; @@ -578,16 +579,7 @@ fn quarantine_family_paths(database: &Path, transaction_id: &str) -> Result<[Pat database.display() )) })?; - let transaction_component = transaction_id - .bytes() - .map(|byte| { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { - (byte as char).to_string() - } else { - format!("_{byte:02x}") - } - }) - .collect::(); + let transaction_component = transaction_file_component(transaction_id); let database = parent.join(format!( ".{name}{QUARANTINE_MARKER}{transaction_component}.quarantine" )); @@ -787,7 +779,8 @@ fn require_missing(path: &Path, description: &str) -> Result<()> { fn persist_journal(tracedecay_dir: &Path, journal: &DeletionJournal) -> Result<()> { validate_journal(tracedecay_dir, journal)?; let path = journal_path(tracedecay_dir); - let temp = tracedecay_dir.join(format!("{JOURNAL_FILENAME}.tmp-{}", journal.transaction_id)); + let transaction_component = transaction_file_component(&journal.transaction_id); + let temp = tracedecay_dir.join(format!("{JOURNAL_FILENAME}.tmp-{transaction_component}")); let bytes = serde_json::to_vec_pretty(journal)?; if let Err(error) = PrivateStoreIo::write_file_atomically(&path, &temp, &bytes) { let _ = std::fs::remove_file(&temp); @@ -867,12 +860,24 @@ fn clear_journal(tracedecay_dir: &Path) -> Result<()> { } } +#[cfg(not(windows))] fn sync_file(path: &Path) -> Result<()> { - File::open(path) + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) .and_then(|file| file.sync_all()) .map_err(|error| config_error(format!("failed to sync '{}': {error}", path.display()))) } +#[cfg(windows)] +fn sync_file(_path: &Path) -> Result<()> { + // PrivateStoreIo publishes these records with MoveFileExW's + // MOVEFILE_WRITE_THROUGH. Reopening the replaced path for a second flush + // is not portable on Windows and can fail with ERROR_ACCESS_DENIED. + Ok(()) +} + fn sync_directory(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -904,6 +909,19 @@ fn transaction_id() -> String { format!("{}-{nanos}", std::process::id()) } +fn transaction_file_component(transaction_id: &str) -> String { + transaction_id + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { + (byte as char).to_string() + } else { + format!("_{byte:02x}") + } + }) + .collect() +} + fn config_error(message: impl Into) -> TraceDecayError { TraceDecayError::Config { message: message.into(), diff --git a/src/daemon.rs b/src/daemon.rs index 8b515532d..b8f2f6fd4 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2925,6 +2925,14 @@ fn is_missing_index_error(err: &TraceDecayError) -> bool { ) } +fn is_readonly_database_error(err: &TraceDecayError) -> bool { + matches!( + err, + TraceDecayError::Database { message, .. } + if message.to_ascii_lowercase().contains("readonly database") + ) +} + fn missing_index_error(project_path: &Path) -> TraceDecayError { TraceDecayError::Config { message: format!( @@ -2938,15 +2946,26 @@ async fn open_existing_project_with_options( project_path: &Path, open_options: crate::tracedecay::TraceDecayOpenOptions, ) -> Result { - crate::tracedecay::TraceDecay::open_with_options(project_path, open_options) - .await - .map_err(|error| { - if is_missing_index_error(&error) { - missing_index_error(project_path) - } else { - error + match crate::tracedecay::TraceDecay::open_with_options(project_path, open_options.clone()).await + { + Ok(cg) => Ok(cg), + Err(open_err) if is_readonly_database_error(&open_err) => { + match crate::tracedecay::TraceDecay::open_read_only_with_options( + project_path, + open_options, + ) + .await + { + Ok(cg) => { + cg.ensure_schema_current().await?; + Ok(cg) + } + Err(_) => Err(open_err), } - }) + } + Err(error) if is_missing_index_error(&error) => Err(missing_index_error(project_path)), + Err(error) => Err(error), + } } async fn write_project_open_error( diff --git a/src/db/access.rs b/src/db/access.rs index 4851f4360..dd7bb4b97 100644 --- a/src/db/access.rs +++ b/src/db/access.rs @@ -19,10 +19,10 @@ use lease::{acquire_process_lease, exact_scoped_runtime_role, scoped_runtime_rol pub(crate) use lease::{ database_path_is_tombstoned, enter_daemon_database_scope, probe_writer_owner, }; +pub(crate) use owner_io::is_lock_contended; use owner_io::{ - authority_token, epoch_ms, is_lock_contended, open_lock_file, publish_record_atomically, - read_owner, read_record_strict, remove_record_durably, write_owner, write_record_atomically, - writer_owner, + authority_token, epoch_ms, open_lock_file, publish_record_atomically, read_owner, + read_record_strict, remove_record_durably, write_owner, write_record_atomically, writer_owner, }; use path_layout::{ bootstrap_database_key, canonical_profile_root, database_lock_root, diff --git a/src/db/access/owner_io.rs b/src/db/access/owner_io.rs index 7069032c4..b0d6d7212 100644 --- a/src/db/access/owner_io.rs +++ b/src/db/access/owner_io.rs @@ -259,7 +259,7 @@ pub(super) fn read_owner(path: &Path) -> Option { }) } -pub(super) fn is_lock_contended(error: &std::io::Error) -> bool { +pub(crate) fn is_lock_contended(error: &std::io::Error) -> bool { if error.kind() == std::io::ErrorKind::WouldBlock { return true; } diff --git a/src/db/mod.rs b/src/db/mod.rs index da22c0047..d0166fc72 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -24,7 +24,7 @@ pub(crate) use access::windows_hard_link_count; pub use access::{DatabaseAuthority, DatabaseAuthorityRole}; pub(crate) use access::{ DatabaseDeletionFence, DatabaseDeletionStates, WriterOwnership, database_path_is_tombstoned, - enter_daemon_database_scope, probe_writer_owner, + enter_daemon_database_scope, is_lock_contended, probe_writer_owner, }; pub use connection::{Database, SQLITE_UNSAFE_FAST_ENV}; pub(crate) use connection::{platform_safe_journal_mode, platform_safe_synchronous_mode}; diff --git a/src/global_db/tests.rs b/src/global_db/tests.rs index 324a66af4..563ce5c6e 100644 --- a/src/global_db/tests.rs +++ b/src/global_db/tests.rs @@ -75,6 +75,9 @@ async fn assuming_schema_open_cannot_poison_full_schema_ensure() { rows.next().await.unwrap().unwrap().get::(0).unwrap(), 0 ); + drop(rows); + let raw_inner = Arc::downgrade(&raw.inner); + raw.close(); let ensured = GlobalDb::open_at(&path).await.expect("full schema open"); let mut rows = ensured @@ -89,7 +92,7 @@ async fn assuming_schema_open_cannot_poison_full_schema_ensure() { rows.next().await.unwrap().unwrap().get::(0).unwrap(), 1 ); - assert!(!Arc::ptr_eq(&raw.inner, &ensured.inner)); + assert!(raw_inner.upgrade().is_none()); } #[tokio::test] @@ -163,6 +166,12 @@ async fn try_open_at_preserves_authority_error() { "{message}" ); assert!(message.contains("open global database"), "{message}"); + #[cfg(windows)] + assert!( + message.contains(&format!(r"\\?\{}", path.display())), + "{message}" + ); + #[cfg(not(windows))] assert!(message.contains(&path.display().to_string()), "{message}"); } diff --git a/src/storage.rs b/src/storage.rs index c4603205c..d66967ecc 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -856,7 +856,12 @@ impl PrivateStoreIo { reject_symlink_components(temp_path, "private store temp file")?; fs::write(temp_path, contents)?; set_private_file_permissions(temp_path)?; - fs::rename(temp_path, path)?; + crate::db::DatabaseAuthority::replace_file_atomically( + temp_path, + path, + "private store file", + ) + .map_err(io::Error::other)?; set_private_file_permissions(path) } diff --git a/src/tracedecay/locking.rs b/src/tracedecay/locking.rs index b8018ad9c..11fd6e359 100644 --- a/src/tracedecay/locking.rs +++ b/src/tracedecay/locking.rs @@ -281,7 +281,7 @@ pub fn try_acquire_sync_lock_at(lock_path: &Path) -> Result { file.try_lock_exclusive() .map_err(|error| TraceDecayError::SyncLock { - message: if error.kind() == std::io::ErrorKind::WouldBlock { + message: if crate::db::is_lock_contended(&error) { "another sync is already in progress".to_string() } else { format!("could not lock sync lockfile: {error}") diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 55993f2e3..01c7688f0 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -101,7 +101,7 @@ fn add_tracedecay_path_shim(command: &mut Command, home: &Path) -> PathBuf { /// not the behaviour under test. fn init_project_in_process(home: &Path, project: &Path) { let project = canonical_temp_path(project); - let output = tracedecay_command_without_daemon(home, &project) + let output = tracedecay_command(home, &project) .arg("init") .output() .expect("fixture init should run"); diff --git a/tests/daemon_suite/pr_autotrack_test.rs b/tests/daemon_suite/pr_autotrack_test.rs index 296f22a6b..8ee658e8e 100644 --- a/tests/daemon_suite/pr_autotrack_test.rs +++ b/tests/daemon_suite/pr_autotrack_test.rs @@ -414,8 +414,8 @@ async fn deferred_tracking_is_not_persisted_and_retries_next_cycle() { assert!(deferred.tracked.is_empty()); assert!(pr_autotrack::managed_summary(&data_root).is_empty()); assert!( - !data_root.join("pr-worktrees/pr-7").exists(), - "deferred tracking must roll back its worktree" + data_root.join("pr-worktrees/pr-7").exists(), + "contended rollback must retain its worktree for the next poll" ); lock.unlock().unwrap(); diff --git a/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs index 2caa48496..c91705b7a 100644 --- a/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs +++ b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs @@ -132,6 +132,7 @@ fn normal_lease_path_still_executes_a_direct_claude_stdin_hook() { .success() ); } + let _daemon = spawn_tracedecay_daemon(temp.path()); assert!( tracedecay_command_with_home(temp.path()) .arg("init") @@ -140,7 +141,6 @@ fn normal_lease_path_still_executes_a_direct_claude_stdin_hook() { .unwrap() .success() ); - let _daemon = spawn_tracedecay_daemon(temp.path()); let event = format!( "{{\"hook_event_name\":\"SessionStart\",\"cwd\":{}}}", serde_json::to_string(&project.to_string_lossy()).unwrap() diff --git a/tests/mcp_suite/mcp_cli_serve_test.rs b/tests/mcp_suite/mcp_cli_serve_test.rs index e13338f6f..394cfc30c 100644 --- a/tests/mcp_suite/mcp_cli_serve_test.rs +++ b/tests/mcp_suite/mcp_cli_serve_test.rs @@ -238,7 +238,7 @@ async fn serve_without_daemon_socket_reports_daemon_unavailable() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + stderr.contains("TraceDecay daemon") && stderr.contains("is not available"), "expected explicit daemon-unavailable error, got:\n{stderr}" ); } diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index e9af487ae..10dac3c84 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1110,7 +1110,11 @@ async fn project_registry_tools_prefer_injected_registry_over_process_default() let list_payload: Value = serde_json::from_str(extract_text(&list.value)).unwrap(); assert_eq!( list_payload["registry_path"], - client_registry_path.display().to_string() + client_registry_path + .canonicalize() + .unwrap() + .display() + .to_string() ); let list_text = extract_text(&list.value); assert!(list_text.contains("proj_alpha")); @@ -1442,7 +1446,10 @@ async fn storage_status_tool_summarizes_active_project_store_health() { payload["locks"]["branch_add_lock_path"].as_str(), Some(branch_add_lock_path.as_str()) ); - assert_eq!(payload["locks"]["sync_lock_exists"].as_bool(), Some(false)); + assert_eq!( + payload["locks"]["sync_lock_exists"].as_bool(), + Some(layout.sync_lock_path.exists()) + ); assert_eq!( payload["locks"]["branch_add_lock_exists"].as_bool(), Some(layout.branch_add_lock_path.exists()) @@ -12434,6 +12441,20 @@ async fn lcm_status_cli_bridge_accepts_json_args() { let home = _dir.path().join("home"); let outside_cwd = test_temp_dir(); let project_arg = cg.project_root().display().to_string(); + handle_tool_call( + &cg, + "tracedecay_lcm_preflight", + json!({ + "provider": "cursor", + "session_id": "cli-bridge-status", + "messages": [{"role": "user", "content": "status payload"}], + "current_tokens": 10 + }), + None, + None, + ) + .await + .unwrap(); close_test_graph(cg).await; let _daemon = common::spawn_tracedecay_daemon(&home); let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay")); diff --git a/tests/mcp_suite/serve_template_path_test.rs b/tests/mcp_suite/serve_template_path_test.rs index 9b1cc9aff..ede59a894 100644 --- a/tests/mcp_suite/serve_template_path_test.rs +++ b/tests/mcp_suite/serve_template_path_test.rs @@ -28,7 +28,7 @@ async fn literal_template_without_daemon_fails_closed_before_mcp_handshake() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + stderr.contains("TraceDecay daemon") && stderr.contains("is not available"), "expected explicit daemon-unavailable error, got:\n{stderr}" ); } diff --git a/tests/memory_suite/memory_eval_test.rs b/tests/memory_suite/memory_eval_test.rs index c751e024d..76b4350a4 100644 --- a/tests/memory_suite/memory_eval_test.rs +++ b/tests/memory_suite/memory_eval_test.rs @@ -218,7 +218,6 @@ struct Fixture { home_path: PathBuf, _project: TempDir, project_path: PathBuf, - #[cfg(unix)] _daemon: Option, } @@ -524,7 +523,6 @@ fn build_fixture(setup: &Setup) -> Fixture { home_path, _project: project, project_path, - #[cfg(unix)] _daemon: None, }; let src = fixture.project_path.join("src"); @@ -536,10 +534,7 @@ fn build_fixture(setup: &Setup) -> Fixture { } initialize_fixture_project(&fixture); seed_setup_facts(&fixture, &setup.facts); - #[cfg(unix)] - { - fixture._daemon = Some(common::spawn_tracedecay_daemon(&fixture.home_path)); - } + fixture._daemon = Some(common::spawn_tracedecay_daemon(&fixture.home_path)); fixture } diff --git a/tests/storage_suite/branch_db_safety_test.rs b/tests/storage_suite/branch_db_safety_test.rs index d345e8380..d36600802 100644 --- a/tests/storage_suite/branch_db_safety_test.rs +++ b/tests/storage_suite/branch_db_safety_test.rs @@ -429,6 +429,7 @@ fn cli_branch_add_refuses_corrupt_metadata_without_overwriting() { fs::write(project.join("src/lib.rs"), "pub fn main_only() {}\n").unwrap(); commit_all(project, "initial commit"); + let _daemon = common::spawn_tracedecay_daemon(env.home()); let mut init_command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); common::apply_tracedecay_home_env(&mut init_command, env.home()); let init = init_command @@ -443,8 +444,6 @@ fn cli_branch_add_refuses_corrupt_metadata_without_overwriting() { String::from_utf8_lossy(&init.stderr) ); - let _daemon = common::spawn_tracedecay_daemon(env.home()); - let tracedecay_dir = project_data_dir(project); let meta_path = tracedecay_dir.join("branch-meta.json"); fs::write(&meta_path, b"{not valid json").unwrap(); From 5d2c65dc4e943fa0d129c2cbc61b6650d35e4040 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 09:13:14 +0000 Subject: [PATCH 19/21] fix(tests): preserve platform daemon lifecycle --- .../cli_non_interactive_test.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 01c7688f0..e97f3b81e 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -22,6 +22,7 @@ use tracedecay::storage::{ profile_sharded_layout, read_enrollment_marker, write_enrollment_marker, write_repository_identity_marker, write_store_manifest, }; +use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; fn canonical_temp_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) @@ -101,16 +102,20 @@ fn add_tracedecay_path_shim(command: &mut Command, home: &Path) -> PathBuf { /// not the behaviour under test. fn init_project_in_process(home: &Path, project: &Path) { let project = canonical_temp_path(project); - let output = tracedecay_command(home, &project) - .arg("init") - .output() + let profile_root = profile_root(home); + create_runtime().block_on(async { + let cg = TraceDecay::init_with_options( + &project, + TraceDecayOpenOptions { + global_db_path: Some(profile_root.join("global.db")), + profile_root: Some(profile_root), + }, + ) + .await .expect("fixture init should run"); - assert!( - output.status.success(), - "fixture init failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + cg.checkpoint().await.expect("fixture checkpoint"); + cg.close(); + }); } fn git(project: &Path, args: &[&str]) { @@ -1591,6 +1596,7 @@ fn branch_add_writes_new_branch_db_into_profile_shard() { git(&project_root, &["checkout", "-b", "feature/new"]); let project_id = default_profile_project_id(&project_root); let shard_root = profile_sharded_data_root(&profile_root(home.path()), &project_id); + #[cfg(unix)] let _daemon = crate::common::spawn_tracedecay_daemon(home.path()); let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["branch", "add", "feature/new"]); From 0153869ebb6f12ee6523e9974d8f6fc528d04e39 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 09:20:10 +0000 Subject: [PATCH 20/21] test(storage): start branch daemon on Windows Co-Authored-By: Claude Fable 5 --- tests/core_cli_suite/cli_non_interactive_test.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index e97f3b81e..6eee85f53 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -1596,7 +1596,6 @@ fn branch_add_writes_new_branch_db_into_profile_shard() { git(&project_root, &["checkout", "-b", "feature/new"]); let project_id = default_profile_project_id(&project_root); let shard_root = profile_sharded_data_root(&profile_root(home.path()), &project_id); - #[cfg(unix)] let _daemon = crate::common::spawn_tracedecay_daemon(home.path()); let mut command = tracedecay_command_without_daemon(home.path(), &project_root); command.args(["branch", "add", "feature/new"]); From e6fe9a32449e3d628eb248ea900eba813605bc02 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 14 Jul 2026 09:29:02 +0000 Subject: [PATCH 21/21] test(mcp): canonicalize registry path assertions --- tests/mcp_suite/mcp_handler_test.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 10dac3c84..4ee73c794 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1156,7 +1156,11 @@ async fn project_registry_tools_prefer_injected_registry_over_process_default() assert_eq!(context_payload["project"]["project_id"], "proj_alpha"); assert_eq!( context_payload["registry_path"], - client_registry_path.display().to_string() + client_registry_path + .canonicalize() + .unwrap() + .display() + .to_string() ); }