sqry-daemon 9.0.16

sqry daemon (sqryd) — persistent code-graph service
Documentation
//! JSON-RPC method dispatch for Phase 8a.
//!
//! Phase 8a exposes four daemon management methods:
//!
//! - `daemon/status` — [`daemon_status`]
//! - `daemon/load` — [`daemon_load`]
//! - `daemon/unload` — [`daemon_unload`]
//! - `daemon/stop` — [`daemon_stop`]
//!
//! MCP Robustness (CLI_REBUILD_2) adds:
//!
//! - `daemon/rebuild` — [`daemon_rebuild`]
//! - `daemon/cancel_rebuild` — [`daemon_cancel_rebuild`]
//!
//! Phase 8b will add 14 MCP tool methods on the same router; the
//! [`MethodError`] enum below already carries every variant the tool
//! methods will need.

pub mod daemon_cancel_rebuild;
pub mod daemon_load;
pub mod daemon_rebuild;
pub mod daemon_status;
pub mod daemon_stop;
pub mod daemon_unload;
pub(crate) mod tool_dispatch;

use std::sync::Arc;

use serde_json::json;
use sqry_core::query::executor::QueryExecutor;
use thiserror::Error;
use tokio::task::JoinError;
use tokio_util::sync::CancellationToken;

use crate::config::DaemonConfig;
use crate::error::DaemonError;
use crate::rebuild::RebuildDispatcher;
use crate::workspace::{WorkspaceBuilder, WorkspaceManager};

use super::protocol::{JsonRpcId, JsonRpcPayload, JsonRpcRequest, JsonRpcResponse};
use super::shim_registry::ShimRegistry;

/// Shared context each per-connection task + per-method handler needs.
///
/// `pub(crate)` because the only callers are the router + method
/// handlers within this crate. External test code constructs it via
/// [`IpcServer::bind`] and the test helper
/// [`super::super::ipc::server::IpcServer::spawn_for_tests`].
#[derive(Clone)]
pub(crate) struct HandlerContext {
    pub manager: Arc<WorkspaceManager>,
    #[allow(dead_code)] // held so Phase 8b can drive rebuilds from tool-method paths
    pub dispatcher: Arc<RebuildDispatcher>,
    pub workspace_builder: Arc<dyn WorkspaceBuilder>,
    /// Shared query executor used by Task 7's tool-dispatch handlers.
    /// Task 4's `WorkspaceContext` captures a clone of this `Arc` and hands it
    /// to the `inner::execute_*` functions in `sqry-mcp::daemon_adapter`.
    #[allow(dead_code)] // consumed by Phase 8b Task 7's tool-dispatch handlers
    pub tool_executor: Arc<QueryExecutor>,
    /// Shim-connection registry. U10's router consumes this on every
    /// `ShimRegister` first frame via
    /// [`ShimRegistry::try_register_bounded`] under
    /// `ctx.config.max_shim_connections`; the resulting `ShimHandle`
    /// lives for the byte-pump host call and its RAII `Drop` removes
    /// the entry when the host returns.
    pub shim_registry: Arc<ShimRegistry>,
    pub shutdown: CancellationToken,
    pub config: Arc<DaemonConfig>,
    pub daemon_version: &'static str,
}

impl std::fmt::Debug for HandlerContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HandlerContext")
            .field("daemon_version", &self.daemon_version)
            .finish_non_exhaustive()
    }
}

/// Method-layer error. Converts into a JSON-RPC error response via
/// [`MethodError::into_jsonrpc_response`].
#[derive(Debug, Error)]
pub enum MethodError {
    /// `-32601` — method name not registered in the dispatcher.
    #[error("method not found: {0}")]
    MethodNotFound(String),

    /// `-32602` — params failed typed deserialisation or semantic
    /// validation (e.g., `index_root` is a file).
    #[error("invalid params: {0}")]
    InvalidParams(#[source] serde_json::Error),

    /// `-32602` — structured validation error carrying the rmcp
    /// [`sqry_mcp::error::RpcError`] payload verbatim. Used by the
    /// Phase 8b tool-dispatch path so the daemon emits a JSON-RPC
    /// error whose `message` + `data` fields match the rmcp
    /// `SqryServer` wire form bit-for-bit.
    #[error("invalid params: {message}")]
    InvalidParamsStructured {
        message: String,
        kind: String,
        retryable: bool,
        retry_after_ms: Option<u64>,
        details: Option<serde_json::Value>,
    },

    /// `-32600` — typically surfaces from inner validators that
    /// discover a request-shape error after the top-level validator
    /// has already accepted the request. Phase 8b/8c surface this
    /// when tool-method params fail cross-field validation.
    #[error("invalid request: {0}")]
    #[allow(dead_code)] // consumed by Phase 8b
    InvalidRequest(String),

    /// Daemon-specific error. Code derived from
    /// [`DaemonError::jsonrpc_code`] + [`DaemonError::error_data`].
    #[error(transparent)]
    Daemon(#[from] DaemonError),

    /// `-32603` — unexpected internal failure (serialisation, plugin
    /// registry unavailable, etc.).
    #[error("internal: {0}")]
    Internal(#[from] anyhow::Error),

    /// `-32603` — a blocking builder task failed to join (non-panic
    /// cancellation). Panics are mapped to a structured
    /// [`DaemonError::WorkspaceBuildFailed`] upstream of this variant.
    #[error("spawn_blocking join error: {0}")]
    JoinError(#[from] JoinError),
}

impl MethodError {
    /// Build the wire-level JSON-RPC response for this error.
    #[must_use]
    pub fn into_jsonrpc_response(self, id: Option<JsonRpcId>) -> JsonRpcResponse {
        let (code, message, data) = match self {
            Self::MethodNotFound(ref m) => (
                -32601,
                "Method not found".to_string(),
                Some(json!({ "method": m })),
            ),
            Self::InvalidParams(e) => (
                -32602,
                "Invalid params".to_string(),
                Some(json!({ "reason": e.to_string() })),
            ),
            Self::InvalidParamsStructured {
                message,
                kind,
                retryable,
                retry_after_ms,
                details,
            } => (
                -32602,
                message,
                // Mirrors the rmcp `rpc_error_to_mcp` data envelope at
                // `sqry-mcp/src/server.rs:1330-1343` so both transports emit
                // identical `error.data` payloads for -32602 responses.
                Some(json!({
                    "kind": kind,
                    "retryable": retryable,
                    "retry_after_ms": retry_after_ms,
                    "details": details,
                })),
            ),
            Self::InvalidRequest(ref reason) => (
                -32600,
                "Invalid Request".to_string(),
                Some(json!({ "reason": reason })),
            ),
            Self::Daemon(d) => match d.jsonrpc_code() {
                Some(code) => (code, d.to_string(), d.error_data()),
                None => (
                    -32603,
                    "Internal error".to_string(),
                    Some(json!({ "reason": d.to_string() })),
                ),
            },
            Self::Internal(e) => (
                -32603,
                "Internal error".to_string(),
                Some(json!({ "reason": e.to_string() })),
            ),
            Self::JoinError(e) => (
                -32603,
                "Internal error".to_string(),
                Some(json!({ "reason": format!("blocking task join: {e}") })),
            ),
        };
        JsonRpcResponse::error(id, code, message, data)
    }
}

/// Extract a human-readable reason string from a panicked
/// [`JoinError`]. Consumes the error because `into_panic` consumes it.
/// Returns `"<non-string panic payload>"` when the panic payload is
/// not a `&str`/`String`.
#[must_use]
pub fn format_panic_payload(join_err: JoinError) -> String {
    if join_err.is_panic() {
        let any = join_err.into_panic();
        if let Some(s) = any.downcast_ref::<&'static str>() {
            return (*s).to_owned();
        }
        if let Some(s) = any.downcast_ref::<String>() {
            return s.clone();
        }
        return "<non-string panic payload>".to_owned();
    }
    // Cancellation or other non-panic join failure.
    format!("task join failure: {join_err}")
}

/// Dispatch a validated, typed [`JsonRpcRequest`]. Returns `None` for
/// notifications (no response expected).
pub(crate) async fn dispatch(ctx: &HandlerContext, req: JsonRpcRequest) -> Option<JsonRpcResponse> {
    let id = req.id.clone()?;

    let result = match req.method.as_str() {
        "daemon/status" => daemon_status::handle(ctx, req.params).await,
        "daemon/load" => daemon_load::handle(ctx, req.params).await,
        "daemon/unload" => daemon_unload::handle(ctx, req.params).await,
        "daemon/stop" => daemon_stop::handle(ctx, req.params).await,
        "daemon/rebuild" => daemon_rebuild::handle(ctx, req.params).await,
        "daemon/cancel_rebuild" => daemon_cancel_rebuild::handle(ctx, req.params).await,
        // Phase 8b Task 7 — the 14 MCP tool methods. Each is gated on
        // `WorkspaceManager::classify_for_serve` inside
        // `tool_dispatch::classify_and_build`; NotReady verdicts surface
        // as -32001 `WorkspaceBuildFailed`, Stale verdicts carry a
        // `_stale_warning` plus `meta.stale = true`, Fresh verdicts
        // serve from the live (or rebuild-in-flight) graph.
        "semantic_search" | "relation_query" | "direct_callers" | "direct_callees"
        | "find_unused" | "find_cycles" | "is_node_in_cycle" | "trace_path" | "subgraph"
        | "export_graph" | "complexity_metrics" | "semantic_diff" | "dependency_impact"
        | "show_dependencies" => {
            tool_dispatch::dispatch_tool(ctx, req.method.as_str(), req.params).await
        }
        other => Err(MethodError::MethodNotFound(other.to_owned())),
    };

    Some(match result {
        Ok(envelope_json) => JsonRpcResponse {
            jsonrpc: super::protocol::JsonRpcVersion,
            id: Some(id),
            payload: JsonRpcPayload::Success {
                result: envelope_json,
            },
        },
        Err(e) => e.into_jsonrpc_response(Some(id)),
    })
}

/// Helper for the batch path — builds an internal-error response with
/// structured context. Not a method handler; exposed here so the
/// batch loop in [`super::router`] can use it.
#[must_use]
pub(crate) fn internal_error_response(id: Option<JsonRpcId>, reason: &str) -> JsonRpcResponse {
    JsonRpcResponse::error(
        id,
        -32603,
        "Internal error",
        Some(json!({ "reason": reason })),
    )
}