use std::path::PathBuf;
use std::sync::Arc;
#[cfg(any(test, feature = "test-hooks"))]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::SystemTime;
use sqry_core::graph::acquisition::{
AcquisitionOperation, AcquisitionSource, GraphAcquirer, GraphAcquisition,
GraphAcquisitionError, GraphAcquisitionMetadata, GraphAcquisitionRequest, GraphFreshness,
GraphIdentity, PluginSelectionStatus, ReloadOrigin,
};
use sqry_core::project::ProjectRootMode;
use crate::error::DaemonError;
use crate::ipc::tool_core;
use crate::workspace::{ServeVerdict, WorkspaceBuilder, WorkspaceKey, WorkspaceManager};
const RELOAD_WORKING_SET_BYTES: u64 = 2 * 1024 * 1024;
pub(crate) struct DaemonGraphProvider {
manager: Arc<WorkspaceManager>,
builder: Arc<dyn WorkspaceBuilder>,
tool_name: Option<&'static str>,
}
#[cfg(any(test, feature = "test-hooks"))]
static GLOBAL_ACQUIRE_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[cfg(any(test, feature = "test-hooks"))]
#[doc(hidden)]
pub fn acquire_counter_snapshot() -> usize {
GLOBAL_ACQUIRE_COUNTER.load(Ordering::Acquire)
}
#[cfg(any(test, feature = "test-hooks"))]
#[doc(hidden)]
pub fn acquire_counter_reset() -> usize {
GLOBAL_ACQUIRE_COUNTER.swap(0, Ordering::AcqRel)
}
impl DaemonGraphProvider {
pub(crate) fn new(manager: Arc<WorkspaceManager>, builder: Arc<dyn WorkspaceBuilder>) -> Self {
Self {
manager,
builder,
tool_name: None,
}
}
#[allow(dead_code)] pub(crate) fn with_tool_name(mut self, tool_name: &'static str) -> Self {
self.tool_name = Some(tool_name);
self
}
fn key_for(canonical_root: &std::path::Path) -> WorkspaceKey {
WorkspaceKey::new(canonical_root.to_path_buf(), ProjectRootMode::GitRoot, 0)
}
fn acquisition_from_parts(
&self,
graph: Arc<sqry_core::graph::CodeGraph>,
canonical_root: PathBuf,
request: &GraphAcquisitionRequest,
freshness: GraphFreshness,
source: AcquisitionSource,
) -> GraphAcquisition {
let (query_scope, is_file_scope) = scope_for_request(request, &canonical_root);
let tool_name = request.tool_name.or(self.tool_name);
GraphAcquisition {
graph,
workspace_root: canonical_root.clone(),
query_scope,
is_file_scope,
freshness,
identity: GraphIdentity {
snapshot_sha256: None,
manifest_built_at: None,
snapshot_format_version: None,
source_root: canonical_root,
plugin_selection_status: PluginSelectionStatus::Exact,
},
metadata: GraphAcquisitionMetadata {
acquisition_source: source,
tool_name,
notes: vec![],
},
}
}
}
impl GraphAcquirer for DaemonGraphProvider {
fn acquire(
&self,
request: GraphAcquisitionRequest,
) -> Result<GraphAcquisition, GraphAcquisitionError> {
#[cfg(any(test, feature = "test-hooks"))]
GLOBAL_ACQUIRE_COUNTER.fetch_add(1, Ordering::AcqRel);
let canonical_root = match tool_core::resolve_path_for_acquisition(&request.requested_path)
{
Ok(p) => p,
Err(err) => {
return Err(GraphAcquisitionError::InvalidPath {
path: request.requested_path.clone(),
reason: invalid_argument_reason(&err),
});
}
};
if matches!(request.operation, AcquisitionOperation::MutatingRebuild) {
return Err(GraphAcquisitionError::Internal {
reason: format!(
"daemon graph provider does not serve MutatingRebuild for {}; \
route through WorkspaceManager::get_or_load via the explicit \
rebuild_index flow",
canonical_root.display()
),
});
}
let key = Self::key_for(&canonical_root);
let now = SystemTime::now();
match self.manager.classify_for_serve(&key, now) {
Ok(ServeVerdict::Fresh { graph, state }) => {
let lifecycle_label = match state {
crate::workspace::WorkspaceState::Loaded => Some("loaded"),
crate::workspace::WorkspaceState::Rebuilding => Some("rebuilding"),
_ => Some("loaded"),
};
let freshness = GraphFreshness::Fresh { lifecycle_label };
Ok(self.acquisition_from_parts(
graph,
canonical_root,
&request,
freshness,
AcquisitionSource::DaemonReadOnly,
))
}
Ok(ServeVerdict::Stale {
graph,
age_hours,
last_good_at,
last_error,
}) => {
let freshness = GraphFreshness::Stale {
last_good_at: Some(rfc3339_utc(last_good_at)),
last_error,
age_hours: Some(age_hours as f64),
};
Ok(self.acquisition_from_parts(
graph,
canonical_root,
&request,
freshness,
AcquisitionSource::DaemonReadOnly,
))
}
Ok(ServeVerdict::NotReady { state }) => Err(GraphAcquisitionError::NotReady {
workspace_root: canonical_root,
lifecycle: format!("{state:?}"),
}),
Err(daemon_err) => {
self.handle_classify_error(&request, &key, canonical_root, daemon_err)
}
}
}
}
impl DaemonGraphProvider {
fn handle_classify_error(
&self,
request: &GraphAcquisitionRequest,
key: &WorkspaceKey,
canonical_root: PathBuf,
daemon_err: DaemonError,
) -> Result<GraphAcquisition, GraphAcquisitionError> {
match daemon_err {
DaemonError::WorkspaceEvicted { ref root } => {
debug_assert!(
matches!(request.operation, AcquisitionOperation::ReadOnlyQuery),
"MutatingRebuild must be rejected before classify_for_serve",
);
let original_lifecycle = "evicted".to_string();
let original_detail = format!("workspace {} evicted", root.display());
match self.manager.reload_from_disk_read_only(
key,
self.builder.as_ref(),
RELOAD_WORKING_SET_BYTES,
) {
Ok(graph) => {
let freshness = GraphFreshness::Reloaded {
original_lifecycle: ReloadOrigin::Evicted {
detail: original_detail,
},
final_lifecycle_label: "loaded",
reload_attempts: std::num::NonZeroU8::new(1).expect("1 is non-zero"),
};
Ok(self.acquisition_from_parts(
graph,
canonical_root,
request,
freshness,
AcquisitionSource::DaemonReloaded,
))
}
Err(reload_err) => Err(GraphAcquisitionError::Evicted {
workspace_root: canonical_root,
original_lifecycle,
reload_failure: Some(format!(
"evicted({original_detail}); reload: {reload_err}"
)),
}),
}
}
DaemonError::WorkspaceStaleExpired {
root: _,
age_hours,
cap_hours: _,
last_good_at: _,
last_error: _,
} => Err(GraphAcquisitionError::StaleExpired {
workspace_root: canonical_root,
age_hours: Some(age_hours as f64),
}),
DaemonError::WorkspaceBuildFailed { root: _, reason } => {
Err(GraphAcquisitionError::BuildFailed {
workspace_root: canonical_root,
reason,
})
}
DaemonError::WorkspaceNotLoaded { root: _ } => Err(GraphAcquisitionError::NoGraph {
workspace_root: canonical_root,
}),
other => Err(GraphAcquisitionError::Internal {
reason: format!("daemon classify_for_serve returned unexpected error: {other}"),
}),
}
}
}
fn invalid_argument_reason(err: &DaemonError) -> String {
match err {
DaemonError::InvalidArgument { reason } => reason.clone(),
other => other.to_string(),
}
}
fn rfc3339_utc(t: SystemTime) -> String {
chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn scope_for_request(
_request: &GraphAcquisitionRequest,
_canonical_root: &std::path::Path,
) -> (Option<PathBuf>, bool) {
(None, false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Arc;
use sqry_core::graph::CodeGraph;
use sqry_core::graph::unified::persistence::save_to_path;
use sqry_core::project::canonicalize_path;
use tempfile::TempDir;
use crate::config::DaemonConfig;
use crate::workspace::WorkspaceState;
#[derive(Debug, Default)]
struct InMemoryBuilder;
impl WorkspaceBuilder for InMemoryBuilder {
fn build(&self, _root: &Path) -> Result<CodeGraph, DaemonError> {
Ok(CodeGraph::new())
}
fn load_persisted(&self, _root: &Path) -> Result<CodeGraph, DaemonError> {
Ok(CodeGraph::new())
}
}
#[derive(Debug)]
struct ReloadFailsBuilder {
reason: String,
attempts: parking_lot::Mutex<u32>,
}
impl ReloadFailsBuilder {
fn new() -> Arc<Self> {
Arc::new(Self {
reason: "synthetic reload failure".to_string(),
attempts: parking_lot::Mutex::new(0),
})
}
}
impl WorkspaceBuilder for ReloadFailsBuilder {
fn build(&self, _root: &Path) -> Result<CodeGraph, DaemonError> {
Ok(CodeGraph::new())
}
fn load_persisted(&self, root: &Path) -> Result<CodeGraph, DaemonError> {
*self.attempts.lock() += 1;
Err(DaemonError::WorkspaceBuildFailed {
root: root.to_path_buf(),
reason: self.reason.clone(),
})
}
}
#[derive(Debug, Default)]
struct CountingLoadPersistedBuilder {
load_persisted_count: parking_lot::Mutex<u32>,
}
impl WorkspaceBuilder for CountingLoadPersistedBuilder {
fn build(&self, _root: &Path) -> Result<CodeGraph, DaemonError> {
Ok(CodeGraph::new())
}
fn load_persisted(&self, _root: &Path) -> Result<CodeGraph, DaemonError> {
*self.load_persisted_count.lock() += 1;
Ok(CodeGraph::new())
}
}
fn make_manager() -> Arc<WorkspaceManager> {
WorkspaceManager::new_without_reaper(Arc::new(DaemonConfig::default()))
}
fn make_request(path: PathBuf, operation: AcquisitionOperation) -> GraphAcquisitionRequest {
GraphAcquisitionRequest {
requested_path: path,
operation,
path_policy: sqry_core::graph::acquisition::PathPolicy::default(),
missing_graph_policy: sqry_core::graph::acquisition::MissingGraphPolicy::Error,
stale_policy: sqry_core::graph::acquisition::StalePolicy::default(),
plugin_selection_policy: sqry_core::graph::acquisition::PluginSelectionPolicy::default(
),
tool_name: Some("sga04_test"),
}
}
fn seed_persisted_snapshot(root: &Path) {
let graph_dir = root.join(".sqry").join("graph");
std::fs::create_dir_all(&graph_dir).unwrap();
let snapshot_path = graph_dir.join("snapshot.sqry");
save_to_path(&CodeGraph::new(), &snapshot_path).unwrap();
}
#[test]
fn daemon_provider_fresh_workspace_returns_fresh_acquisition() {
let tmp = TempDir::new().unwrap();
let root = canonicalize_path(tmp.path()).unwrap();
let manager = make_manager();
let key = WorkspaceKey::new(root.clone(), ProjectRootMode::GitRoot, 0);
manager.insert_workspace_in_state_for_test(key, WorkspaceState::Loaded);
let provider = DaemonGraphProvider::new(
Arc::clone(&manager),
Arc::new(InMemoryBuilder) as Arc<dyn WorkspaceBuilder>,
);
let acq = provider
.acquire(make_request(
root.clone(),
AcquisitionOperation::ReadOnlyQuery,
))
.expect("Loaded workspace must produce Fresh acquisition");
match acq.freshness {
GraphFreshness::Fresh { lifecycle_label } => {
assert_eq!(lifecycle_label, Some("loaded"));
}
other => panic!("expected Fresh, got {other:?}"),
}
assert_eq!(
acq.metadata.acquisition_source,
AcquisitionSource::DaemonReadOnly
);
assert_eq!(acq.workspace_root, root);
assert_eq!(acq.metadata.tool_name, Some("sga04_test"));
}
#[test]
fn daemon_provider_evicted_readonly_reloads_once() {
let tmp = TempDir::new().unwrap();
let root = canonicalize_path(tmp.path()).unwrap();
seed_persisted_snapshot(&root);
let manager = make_manager();
let key = WorkspaceKey::new(root.clone(), ProjectRootMode::GitRoot, 0);
manager.insert_workspace_in_state_for_test(key.clone(), WorkspaceState::Loaded);
assert!(manager.evict_for_test(&key));
let provider = DaemonGraphProvider::new(
Arc::clone(&manager),
Arc::new(InMemoryBuilder) as Arc<dyn WorkspaceBuilder>,
);
let acq = provider
.acquire(make_request(
root.clone(),
AcquisitionOperation::ReadOnlyQuery,
))
.expect("Evicted ReadOnlyQuery must reload and serve");
match acq.freshness {
GraphFreshness::Reloaded {
original_lifecycle,
final_lifecycle_label,
reload_attempts,
} => {
assert_eq!(reload_attempts.get(), 1);
assert_eq!(final_lifecycle_label, "loaded");
match original_lifecycle {
ReloadOrigin::Evicted { detail } => {
assert!(
detail.contains("evicted"),
"expected eviction detail, got: {detail}"
);
}
other => panic!("expected Evicted origin, got {other:?}"),
}
}
other => panic!("expected Reloaded freshness, got {other:?}"),
}
assert_eq!(
acq.metadata.acquisition_source,
AcquisitionSource::DaemonReloaded
);
}
#[test]
fn daemon_provider_repeated_eviction_returns_evicted_error_after_one_reload() {
let tmp = TempDir::new().unwrap();
let root = canonicalize_path(tmp.path()).unwrap();
let manager = make_manager();
let key = WorkspaceKey::new(root.clone(), ProjectRootMode::GitRoot, 0);
manager.insert_workspace_in_state_for_test(key.clone(), WorkspaceState::Loaded);
assert!(manager.evict_for_test(&key));
let builder = ReloadFailsBuilder::new();
let provider = DaemonGraphProvider::new(
Arc::clone(&manager),
Arc::clone(&builder) as Arc<dyn WorkspaceBuilder>,
);
let err = provider
.acquire(make_request(
root.clone(),
AcquisitionOperation::ReadOnlyQuery,
))
.expect_err("reload-fails builder must surface Evicted");
match err {
GraphAcquisitionError::Evicted {
workspace_root,
original_lifecycle,
reload_failure,
} => {
assert_eq!(workspace_root, root);
assert_eq!(original_lifecycle, "evicted");
let reload = reload_failure.expect("reload failure must be recorded");
assert!(
reload.contains("synthetic reload failure"),
"reload diagnostic must carry the builder's failure detail, got: {reload}"
);
assert!(
reload.contains("evicted"),
"reload diagnostic must carry the original eviction context, got: {reload}"
);
}
other => panic!("expected Evicted with reload_failure, got {other:?}"),
}
assert_eq!(*builder.attempts.lock(), 1);
}
#[test]
fn daemon_provider_mutating_rebuild_does_not_use_readonly_fallback() {
let tmp = TempDir::new().unwrap();
let root = canonicalize_path(tmp.path()).unwrap();
let manager = make_manager();
let key = WorkspaceKey::new(root.clone(), ProjectRootMode::GitRoot, 0);
manager.insert_workspace_in_state_for_test(key.clone(), WorkspaceState::Loaded);
assert!(manager.evict_for_test(&key));
let builder = Arc::new(CountingLoadPersistedBuilder::default());
let provider = DaemonGraphProvider::new(
Arc::clone(&manager),
Arc::clone(&builder) as Arc<dyn WorkspaceBuilder>,
);
let err = provider
.acquire(make_request(
root.clone(),
AcquisitionOperation::MutatingRebuild,
))
.expect_err("MutatingRebuild must not use read-only fallback");
match err {
GraphAcquisitionError::Internal { reason } => {
assert!(
reason.contains("MutatingRebuild"),
"internal error must explain the rejection, got: {reason}"
);
}
other => panic!("expected Internal rejection of MutatingRebuild, got {other:?}"),
}
assert_eq!(
*builder.load_persisted_count.lock(),
0,
"load_persisted MUST NOT be invoked for MutatingRebuild"
);
}
#[test]
fn daemon_provider_invalid_path_short_circuits_before_classify_for_serve() {
let manager = make_manager();
let builder = Arc::new(CountingLoadPersistedBuilder::default());
let provider = DaemonGraphProvider::new(
Arc::clone(&manager),
Arc::clone(&builder) as Arc<dyn WorkspaceBuilder>,
);
let err = provider
.acquire(make_request(
PathBuf::from("/this/path/does/not/exist/for/sga04"),
AcquisitionOperation::ReadOnlyQuery,
))
.expect_err("non-existent path must fail");
match err {
GraphAcquisitionError::InvalidPath { path, reason } => {
assert_eq!(path, PathBuf::from("/this/path/does/not/exist/for/sga04"));
assert!(
reason.contains("path_policy") || reason.contains("does not exist"),
"expected path-policy reason, got: {reason}"
);
}
other => panic!("expected InvalidPath, got {other:?}"),
}
assert_eq!(
*builder.load_persisted_count.lock(),
0,
"load_persisted must not run when the path is invalid"
);
}
#[test]
fn evict_for_test_is_not_reachable_via_public_re_export() {
let lib_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"))
.expect("read sqry-daemon/src/lib.rs");
assert!(
!lib_rs.contains("evict_for_test"),
"evict_for_test must NOT be re-exported through sqry-daemon's public API \
(release/IPC/MCP/HTTP surfaces would otherwise reach a test-only hook)"
);
let manager_rs = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/workspace/manager.rs"
))
.expect("read sqry-daemon/src/workspace/manager.rs");
let needle = "#[cfg(any(test, feature = \"test-hooks\"))]";
let fn_decl = "pub fn evict_for_test(";
let cfg_pos = manager_rs.find(needle).unwrap_or_else(|| {
panic!(
"expected `{needle}` somewhere in manager.rs to gate evict_for_test \
(SGA04 Gate-A blocker fix)"
)
});
let fn_pos = manager_rs
.find(fn_decl)
.unwrap_or_else(|| panic!("expected `{fn_decl}` definition in manager.rs"));
assert!(
cfg_pos < fn_pos,
"`{needle}` must appear BEFORE `{fn_decl}` so it gates the definition; \
evict_for_test must be unreachable in default release builds"
);
let between = &manager_rs[cfg_pos..fn_pos];
assert!(
between.matches("\nfn ").count() == 0
&& between.matches("\npub fn ").count() == 0
&& between.matches("\nstruct ").count() == 0
&& between.matches("\nimpl ").count() == 0,
"no other item may appear between the cfg gate and `{fn_decl}`; \
between segment was: {between:?}"
);
}
}