Skip to main content

lean_ctx/
daemon_client.rs

1use anyhow::{Context, Result};
2use std::sync::OnceLock;
3use std::time::Duration;
4use tokio::io::{AsyncReadExt, AsyncWriteExt};
5use tokio::runtime::Runtime;
6
7use crate::daemon;
8use crate::ipc;
9
10static DELIVERY_RUNTIME: OnceLock<Result<Runtime, String>> = OnceLock::new();
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(not(unix), allow(dead_code))] // the daemon route is cfg(unix) in read_cmd
14pub enum DaemonToolCallOutcome {
15    Unavailable,
16    Completed { text: String, is_error: bool },
17    Uncertain { message: String },
18}
19
20/// Cross-agent delivery lookups are an optional cache tier. They must never
21/// inherit the daemon client's normal control-plane timeout on an interactive
22/// read or shell path.
23const BEST_EFFORT_DELIVERY_IPC_TIMEOUT: Duration = Duration::from_millis(250);
24const DAEMON_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
25const DAEMON_IO_TIMEOUT: Duration = Duration::from_secs(10);
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28enum RequestSendState {
29    NotSent,
30    PossiblyDispatched,
31}
32
33#[derive(Debug, PartialEq, Eq)]
34enum DaemonRequestOutcome {
35    Unavailable(String),
36    Completed(String),
37    Uncertain(String),
38}
39
40fn delivery_runtime() -> Result<&'static Runtime> {
41    match DELIVERY_RUNTIME.get_or_init(|| Runtime::new().map_err(|error| error.to_string())) {
42        Ok(runtime) => Ok(runtime),
43        Err(error) => Err(anyhow::anyhow!("initialize delivery IPC runtime: {error}")),
44    }
45}
46
47/// Safely run an async future on the delivery runtime from any thread context.
48/// When called from inside a tokio runtime (e.g. tool handler async pipeline /
49/// prefetch re-entry), spawns a scoped thread to avoid "Cannot start a runtime
50/// from within a runtime". When called from outside (CLI), blocks directly.
51fn delivery_block_on<F, O>(fut: F) -> Option<O>
52where
53    F: std::future::Future<Output = O> + Send,
54    O: Send,
55{
56    let rt = delivery_runtime().ok()?;
57    if tokio::runtime::Handle::try_current().is_ok() {
58        let mut result = None;
59        std::thread::scope(|s| {
60            s.spawn(|| {
61                result = Some(rt.block_on(fut));
62            });
63        });
64        result
65    } else {
66        Some(rt.block_on(fut))
67    }
68}
69
70/// Send an HTTP request to the daemon over the IPC channel.
71/// Returns the response body as a string.
72pub async fn daemon_request(method: &str, path: &str, body: &str) -> Result<String> {
73    match daemon_request_observed(method, path, body).await {
74        DaemonRequestOutcome::Completed(body) => Ok(body),
75        DaemonRequestOutcome::Unavailable(message) | DaemonRequestOutcome::Uncertain(message) => {
76            Err(anyhow::anyhow!(message))
77        }
78    }
79}
80
81async fn daemon_request_observed(method: &str, path: &str, body: &str) -> DaemonRequestOutcome {
82    let addr = daemon::daemon_addr();
83    daemon_request_at_observed(&addr, method, path, body).await
84}
85
86async fn daemon_request_at_observed(
87    addr: &ipc::DaemonAddr,
88    method: &str,
89    path: &str,
90    body: &str,
91) -> DaemonRequestOutcome {
92    if !addr.is_listening() {
93        return DaemonRequestOutcome::Unavailable(format!(
94            "Daemon endpoint not found at {}. Is the daemon running?",
95            addr.display()
96        ));
97    }
98
99    let request = format_http_request(method, path, body);
100    let addr_display = addr.display();
101
102    #[cfg(unix)]
103    {
104        daemon_request_with_connector(ipc::connect(addr), &addr_display, request).await
105    }
106
107    #[cfg(windows)]
108    {
109        daemon_request_with_connector(ipc::connect(addr), &addr_display, request).await
110    }
111}
112
113async fn daemon_request_with_connector<S, F, E>(
114    connect: F,
115    addr_display: &str,
116    request: String,
117) -> DaemonRequestOutcome
118where
119    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
120    F: std::future::Future<Output = std::result::Result<S, E>>,
121    E: std::fmt::Display,
122{
123    let stream = match tokio::time::timeout(DAEMON_CONNECT_TIMEOUT, connect).await {
124        Ok(Ok(stream)) => stream,
125        Ok(Err(error)) => {
126            return request_send_failure(
127                RequestSendState::NotSent,
128                format!("cannot connect to daemon at {addr_display}: {error}"),
129            );
130        }
131        Err(_) => {
132            return request_send_failure(
133                RequestSendState::NotSent,
134                format!(
135                    "connect to daemon timed out ({}s)",
136                    DAEMON_CONNECT_TIMEOUT.as_secs()
137                ),
138            );
139        }
140    };
141
142    daemon_request_on_stream(stream, request).await
143}
144
145async fn daemon_request_on_stream<S>(mut stream: S, request: String) -> DaemonRequestOutcome
146where
147    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
148{
149    // Reserve this state before write_all: every write failure is possibly
150    // dispatched and therefore cannot be retried or reported as unavailable.
151    let send_state = RequestSendState::PossiblyDispatched;
152
153    match tokio::time::timeout(DAEMON_IO_TIMEOUT, stream.write_all(request.as_bytes())).await {
154        Ok(Ok(())) => {}
155        Ok(Err(error)) => {
156            return request_send_failure(
157                send_state,
158                format!("failed to write request to daemon: {error}"),
159            );
160        }
161        Err(_) => return request_send_failure(send_state, "write to daemon timed out".into()),
162    }
163
164    let mut response_buf = Vec::with_capacity(4096);
165    match tokio::time::timeout(DAEMON_IO_TIMEOUT, stream.read_to_end(&mut response_buf)).await {
166        Ok(Ok(_)) => {}
167        Ok(Err(error)) => {
168            return DaemonRequestOutcome::Uncertain(format!(
169                "failed to read response from daemon: {error}"
170            ));
171        }
172        Err(_) => return DaemonRequestOutcome::Uncertain("read from daemon timed out".into()),
173    }
174
175    match parse_http_response(&response_buf) {
176        Ok(body) => DaemonRequestOutcome::Completed(body),
177        Err(error) => DaemonRequestOutcome::Uncertain(format!("{error:#}")),
178    }
179}
180
181fn request_send_failure(state: RequestSendState, message: String) -> DaemonRequestOutcome {
182    match state {
183        RequestSendState::NotSent => DaemonRequestOutcome::Unavailable(message),
184        RequestSendState::PossiblyDispatched => DaemonRequestOutcome::Uncertain(message),
185    }
186}
187
188/// Check if the daemon is reachable by hitting /health.
189pub async fn daemon_health_check() -> bool {
190    match daemon_request("GET", "/health", "").await {
191        Ok(body) => body.trim() == "ok",
192        Err(_) => false,
193    }
194}
195
196/// Call a tool on the daemon's REST API.
197pub async fn daemon_tool_call(name: &str, arguments: Option<&serde_json::Value>) -> Result<String> {
198    daemon_request(
199        "POST",
200        "/v1/tools/call",
201        &format_daemon_tool_call_body(name, arguments),
202    )
203    .await
204}
205
206fn format_daemon_tool_call_body(name: &str, arguments: Option<&serde_json::Value>) -> String {
207    // The project this process acts for (`--root`, LEAN_CTX_PROJECT_ROOT, its
208    // working directory): the daemon runs the call in that project's server,
209    // not in the session of whichever project it served last. Outside any
210    // project (home, `/`) nothing is named and the daemon's own root serves.
211    let args: Vec<String> = std::env::args().collect();
212    let root = crate::core::command_root::detect_project_root(&args);
213    let project_root =
214        (!crate::core::pathutil::is_broad_or_unsafe_root(std::path::Path::new(&root)))
215            .then_some(root.as_str());
216    let rights_file = std::env::var_os(crate::core::session_rights::ENV_FILE)
217        .map(|raw| raw.to_string_lossy().into_owned());
218    tool_call_body(name, arguments, project_root, rights_file.as_deref())
219}
220
221fn tool_call_body(
222    name: &str,
223    arguments: Option<&serde_json::Value>,
224    project_root: Option<&str>,
225    rights_file: Option<&str>,
226) -> String {
227    let mut body = serde_json::json!({
228        "name": name,
229        "arguments": arguments,
230    });
231    if let Some(root) = project_root {
232        body["projectRoot"] = serde_json::Value::String(root.to_string());
233    }
234    // The session's rights travel with the call (process-v1 §9): the shared
235    // daemon does not see this process's environment.
236    if let Some(rights) = rights_file {
237        body["rightsFile"] = serde_json::Value::String(rights.to_string());
238    }
239    body.to_string()
240}
241
242fn format_http_request(method: &str, path: &str, body: &str) -> String {
243    if body.is_empty() {
244        format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
245    } else {
246        let content_length = body.len();
247        format!(
248            "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {content_length}\r\nConnection: close\r\n\r\n{body}"
249        )
250    }
251}
252
253fn parse_http_response(raw: &[u8]) -> Result<String> {
254    let response_str = std::str::from_utf8(raw).context("daemon response is not valid UTF-8")?;
255
256    let Some(header_end) = response_str.find("\r\n\r\n") else {
257        anyhow::bail!("malformed HTTP response from daemon (no header boundary)");
258    };
259
260    let headers = &response_str[..header_end];
261    let body = &response_str[header_end + 4..];
262
263    let status_line = headers.lines().next().unwrap_or("");
264    let Some(status_code) = status_line
265        .split_whitespace()
266        .nth(1)
267        .and_then(|s| s.parse::<u16>().ok())
268    else {
269        anyhow::bail!("malformed HTTP response from daemon (invalid status line)");
270    };
271
272    if !(200..300).contains(&status_code) {
273        anyhow::bail!("daemon returned HTTP {status_code}: {body}");
274    }
275
276    Ok(body.to_string())
277}
278
279/// Attempt to connect to the daemon. Returns `None` if not running.
280pub async fn try_daemon_request(method: &str, path: &str, body: &str) -> Option<String> {
281    if !daemon::is_daemon_running() {
282        return None;
283    }
284    daemon_request(method, path, body).await.ok()
285}
286
287/// Tell a *running* daemon to drop its in-memory read cache (`SessionCache`).
288/// Returns `true` if a daemon was reached. Never auto-starts a daemon — if none
289/// is running there is no cache to flush. Force-rebuild CLI commands call this so
290/// `ctx_read` map/signatures stop serving pre-rebuild output from the daemon's
291/// long-lived cache, which CLI index rebuilds otherwise can't reach (#420).
292pub fn notify_cache_clear() -> bool {
293    if !daemon::is_daemon_running() {
294        return false;
295    }
296    let Ok(rt) = tokio::runtime::Runtime::new() else {
297        return false;
298    };
299    let body =
300        format_daemon_tool_call_body("ctx_cache", Some(&serde_json::json!({ "action": "clear" })));
301    rt.block_on(async {
302        try_daemon_request("POST", "/v1/tools/call", &body)
303            .await
304            .is_some()
305    })
306}
307
308async fn ensure_daemon_ready() -> bool {
309    let addr = daemon::daemon_addr();
310    let mut ready = addr.is_listening() && daemon_health_check().await;
311
312    if !ready {
313        // Connect-only for shadow-mode hooks (#566): a hook child reaches a live
314        // daemon via the `ready` fast-path above (full detector parity), but when
315        // none is listening it must bail to the standalone fallback instead of
316        // auto-starting one. This guard MUST stay inside `if !ready` — hoisting it
317        // to the top of the function would also block hooks from reusing a running
318        // daemon, silently regressing loop/bounce/adaptive parity.
319        if crate::core::runtime_flags::hook_child_enabled() {
320            return false;
321        }
322
323        let lock = crate::core::startup_guard::try_acquire_lock(
324            "daemon-start",
325            Duration::from_millis(1200),
326            Duration::from_secs(5),
327        );
328
329        if let Some(g) = lock {
330            g.touch();
331            let mut did_start = false;
332
333            if !daemon::is_daemon_running() {
334                if daemon::start_daemon(&[]).is_ok() {
335                    did_start = true;
336                } else {
337                    return false;
338                }
339            }
340
341            for _ in 0..60 {
342                if addr.is_listening() && daemon_health_check().await {
343                    ready = true;
344                    break;
345                }
346                tokio::time::sleep(Duration::from_millis(50)).await;
347            }
348
349            if ready && did_start && crate::core::protocol::meta_visible() {
350                eprintln!("\x1b[2m▸ daemon auto-started\x1b[0m");
351            }
352        } else {
353            for _ in 0..60 {
354                if addr.is_listening() && daemon_health_check().await {
355                    ready = true;
356                    break;
357                }
358                tokio::time::sleep(Duration::from_millis(50)).await;
359            }
360        }
361    }
362
363    ready
364}
365
366/// Blocking helper for CLI commands: routes a tool call through the daemon.
367///
368/// Returns `None` if no daemon can serve the call (caller then renders the tool
369/// locally / standalone). Behaviour splits on caller identity:
370///
371/// - **Normal CLI invocation**: connects to a running daemon, and *auto-starts*
372///   one if none is listening (so the long-lived `LeanCtxServer` state — caches,
373///   indexes, detectors — is reused across commands).
374/// - **Shadow-mode hook child** (`LEAN_CTX_HOOK_CHILD` set): *connect-only*. It
375///   reuses an already-running daemon for full parity (the `ready` fast-path
376///   below routes straight through `/v1/tools/call` → `call_tool_guarded`, so the
377///   in-memory `LoopDetector`, correction-loop auto-degrade, bounce tracker and
378///   adaptive thresholds all fire on the daemon's long-lived state — #566), but
379///   it MUST NEVER auto-start a daemon. A hook fires once per intercepted
380///   read/grep as a fresh process; auto-starting from there would spawn daemons
381///   uncontrollably. With no live daemon it returns `None` and the caller falls
382///   back to the enriched standalone path (disk-backed learning sinks + Context
383///   IR from #550/#569).
384#[allow(clippy::needless_pass_by_value)]
385pub fn try_daemon_tool_call_blocking(
386    name: &str,
387    arguments: Option<serde_json::Value>,
388) -> Option<String> {
389    if std::env::var_os("__LEAN_CTX_NO_DAEMON").is_some() {
390        return None;
391    }
392
393    let rt = Runtime::new().ok()?;
394    if !rt.block_on(ensure_daemon_ready()) {
395        return None;
396    }
397
398    if let Some(out) = rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
399    {
400        return Some(out);
401    }
402
403    for _ in 0..5 {
404        std::thread::sleep(Duration::from_millis(50));
405        if let Some(out) =
406            rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
407        {
408            return Some(out);
409        }
410    }
411
412    None
413}
414
415#[cfg_attr(not(unix), allow(dead_code))] // the daemon route is cfg(unix) in read_cmd
416async fn daemon_tool_call_observed(
417    name: &str,
418    arguments: Option<&serde_json::Value>,
419) -> DaemonRequestOutcome {
420    daemon_request_observed(
421        "POST",
422        "/v1/tools/call",
423        &format_daemon_tool_call_body(name, arguments),
424    )
425    .await
426}
427
428#[cfg_attr(not(unix), allow(dead_code))] // the daemon route is cfg(unix) in read_cmd
429pub fn try_daemon_tool_call_blocking_observed(
430    name: &str,
431    arguments: Option<serde_json::Value>,
432) -> DaemonToolCallOutcome {
433    if std::env::var_os("__LEAN_CTX_NO_DAEMON").is_some() {
434        return DaemonToolCallOutcome::Unavailable;
435    }
436
437    let Some(outcome) = delivery_block_on(async move {
438        if !ensure_daemon_ready().await {
439            return DaemonToolCallOutcome::Unavailable;
440        }
441        observed_tool_call_outcome(daemon_tool_call_observed(name, arguments.as_ref()).await)
442    }) else {
443        return DaemonToolCallOutcome::Unavailable;
444    };
445    outcome
446}
447
448#[cfg_attr(not(unix), allow(dead_code))] // the daemon route is cfg(unix) in read_cmd
449fn observed_tool_call_outcome(outcome: DaemonRequestOutcome) -> DaemonToolCallOutcome {
450    match outcome {
451        DaemonRequestOutcome::Unavailable(_) => DaemonToolCallOutcome::Unavailable,
452        DaemonRequestOutcome::Uncertain(message) => DaemonToolCallOutcome::Uncertain { message },
453        DaemonRequestOutcome::Completed(body) => {
454            let value: serde_json::Value = match serde_json::from_str(&body) {
455                Ok(value) => value,
456                Err(_) => {
457                    return DaemonToolCallOutcome::Uncertain {
458                        message: "invalid daemon tool response".into(),
459                    };
460                }
461            };
462            let Some(result) = value.get("result") else {
463                return DaemonToolCallOutcome::Uncertain {
464                    message: "daemon tool response has no result".into(),
465                };
466            };
467            let parsed: rmcp::model::CallToolResult = match serde_json::from_value(result.clone()) {
468                Ok(parsed) => parsed,
469                Err(_) => {
470                    return DaemonToolCallOutcome::Uncertain {
471                        message: "daemon tool response has an invalid result".into(),
472                    };
473                }
474            };
475            let is_error = parsed.is_error.unwrap_or(false);
476            let empty_text = result
477                .get("content")
478                .and_then(serde_json::Value::as_array)
479                .is_some_and(|content| {
480                    content.iter().all(|item| {
481                        item.get("type").and_then(serde_json::Value::as_str) == Some("text")
482                            && item.get("text").and_then(serde_json::Value::as_str) == Some("")
483                    })
484                });
485            let text = if empty_text {
486                String::new()
487            } else {
488                // Preserve the legacy JSON representation for valid non-text
489                // content instead of silently dropping images/resources.
490                unwrap_mcp_tool_text(&body).unwrap_or(body)
491            };
492
493            DaemonToolCallOutcome::Completed { text, is_error }
494        }
495    }
496}
497
498fn unwrap_mcp_tool_text(body: &str) -> Option<String> {
499    let v: serde_json::Value = serde_json::from_str(body).ok()?;
500    let result = v.get("result")?;
501
502    if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
503        let mut texts: Vec<String> = Vec::new();
504        for item in content {
505            if let Some(text) = item.get("text").and_then(|t| t.as_str())
506                && !text.is_empty()
507            {
508                texts.push(text.to_string());
509            }
510        }
511        if !texts.is_empty() {
512            return Some(texts.join("\n"));
513        }
514    }
515
516    if let Some(text) = result.get("text").and_then(|t| t.as_str()) {
517        return Some(text.to_string());
518    }
519
520    result.as_str().map(std::string::ToString::to_string)
521}
522
523/// Like `try_daemon_tool_call_blocking`, but unwraps MCP JSON responses to text for CLI output.
524pub fn try_daemon_tool_call_blocking_text(
525    name: &str,
526    arguments: Option<serde_json::Value>,
527) -> Option<String> {
528    let body = try_daemon_tool_call_blocking(name, arguments)?;
529    let trimmed = body.trim_start();
530    if !trimmed.starts_with('{') {
531        return Some(body);
532    }
533    Some(match unwrap_mcp_tool_text(&body) {
534        Some(text) if !text.is_empty() => text,
535        Some(_) | None => body,
536    })
537}
538
539pub fn scoped_delivery_check_blocking(
540    profile: &crate::core::a2a::task::delivery_authority::DeliverySigningProfileV1,
541    path: &str,
542    blake3: [u8; 12],
543    conversation_id: &str,
544) -> anyhow::Result<Option<crate::core::ocla::types::DeliveryRecord>> {
545    let request = profile.sign_request(
546        crate::core::a2a::task::delivery_authority::DeliveryOperation::Check {
547            path: path.into(),
548            blake3,
549            conversation_id: Some(conversation_id.into()),
550        },
551    )?;
552    delivery_block_on(async {
553        let response = scoped_delivery_request(&request).await?;
554        decode_scoped_delivery_hit(&request, response)
555    })
556    .ok_or_else(|| anyhow::anyhow!("delivery runtime unavailable"))?
557}
558
559pub fn scoped_delivery_record_blocking(
560    profile: &crate::core::a2a::task::delivery_authority::DeliverySigningProfileV1,
561    entry: crate::core::ocla::types::DeliveryEntry,
562) -> anyhow::Result<()> {
563    let request = profile.sign_request(
564        crate::core::a2a::task::delivery_authority::DeliveryOperation::Record { entry },
565    )?;
566    delivery_block_on(async {
567        let response = scoped_delivery_request(&request).await?;
568        let _: crate::core::ocla::types::DeliveryRecordResult = serde_json::from_value(response)?;
569        Ok(())
570    })
571    .ok_or_else(|| anyhow::anyhow!("delivery runtime unavailable"))?
572}
573
574/// Send a signed scoped request without falling back to any legacy endpoint.
575/// The caller provisions the signing identity and grants; this never creates
576/// authority or starts a daemon implicitly.
577pub async fn scoped_delivery_request(
578    request: &crate::core::a2a::task::delivery_authority::SignedDeliveryRequest,
579) -> anyhow::Result<serde_json::Value> {
580    // The bounded IPC exchange is authoritative. A PID-file probe adds work
581    // to every read and can unlink a live socket when stale metadata survives.
582    scoped_delivery_request_at(&daemon::daemon_addr(), request).await
583}
584
585/// Explicit local endpoint, used by isolated hosts and transport acceptance tests.
586pub(crate) async fn scoped_delivery_request_at(
587    addr: &ipc::DaemonAddr,
588    request: &crate::core::a2a::task::delivery_authority::SignedDeliveryRequest,
589) -> anyhow::Result<serde_json::Value> {
590    anyhow::ensure!(
591        request.authority.action == request.request.action()
592            && request.authority.request_digest == request.bound_request_digest(),
593        "scoped delivery request does not match its signed authority"
594    );
595    let body = serde_json::to_string(request)?;
596    let response = tokio::time::timeout(
597        Duration::from_secs(3),
598        daemon_request_at_observed(addr, "POST", "/ocla/v1/delivery/scoped", &body),
599    )
600    .await
601    .map_err(|_| anyhow::anyhow!("scoped delivery timed out"))?;
602    let response = match response {
603        DaemonRequestOutcome::Completed(body) => body,
604        DaemonRequestOutcome::Unavailable(message) | DaemonRequestOutcome::Uncertain(message) => {
605            anyhow::bail!(message);
606        }
607    };
608    Ok(serde_json::from_str(&response)?)
609}
610
611/// Decode a scoped hit only when its namespace, content and privacy match the
612/// signed lookup. A malformed or legacy response is an error, never a cache miss.
613pub fn decode_scoped_delivery_hit(
614    request: &crate::core::a2a::task::delivery_authority::SignedDeliveryRequest,
615    response: serde_json::Value,
616) -> anyhow::Result<Option<crate::core::ocla::types::DeliveryRecord>> {
617    use crate::core::a2a::task::delivery_authority::DeliveryOperation;
618    let DeliveryOperation::Check {
619        path,
620        blake3,
621        conversation_id,
622    } = &request.request
623    else {
624        anyhow::bail!("scoped delivery response requires a lookup request");
625    };
626    #[derive(serde::Deserialize)]
627    #[serde(deny_unknown_fields)]
628    struct LookupResponse {
629        hit: bool,
630        record: Option<crate::core::ocla::types::DeliveryRecord>,
631    }
632    let response: LookupResponse = serde_json::from_value(response)?;
633    anyhow::ensure!(
634        response.hit == response.record.is_some(),
635        "inconsistent delivery response"
636    );
637    let Some(record) = response.record else {
638        return Ok(None);
639    };
640    let scope = lean_ctx_ocla::delivery_scope::DeliveryScopeV1::new(
641        request.authority.tenant_id.clone(),
642        request.authority.project_id.clone(),
643    )
644    .map_err(anyhow::Error::msg)?;
645    let access = record
646        .access
647        .as_ref()
648        .ok_or_else(|| anyhow::anyhow!("unscoped delivery response"))?;
649    anyhow::ensure!(
650        record.path == *path
651            && record.blake3 == *blake3
652            && access.privacy.permits(
653                &access.scope,
654                &scope,
655                &record.agent_id,
656                &request.authority.sender
657            )
658            && conversation_id
659                .as_ref()
660                .is_none_or(|id| *id != record.conversation_id),
661        "delivery response does not match authorized lookup"
662    );
663    Ok(Some(record))
664}
665
666/// Check the daemon's cross-agent delivery registry for a content hash.
667/// Returns `None` if daemon unreachable or no hit. Connect-only — never
668/// auto-starts a daemon (delivery is best-effort).
669pub fn try_delivery_check_blocking(
670    blake3: &[u8; 12],
671    mtime: u64,
672    path: &str,
673    requester_agent_id: Option<&str>,
674    requester_conversation_id: Option<&str>,
675) -> Option<crate::core::ocla::types::DeliveryRecord> {
676    if !daemon::is_daemon_running() {
677        return None;
678    }
679    let body = serde_json::json!({
680        "blake3": blake3,
681        "mtime": mtime,
682        "path": path,
683        "requester_agent_id": requester_agent_id,
684        "requester_conversation_id": requester_conversation_id,
685    });
686    let body_str = body.to_string();
687    let resp = delivery_block_on(async move {
688        tokio::time::timeout(
689            BEST_EFFORT_DELIVERY_IPC_TIMEOUT,
690            try_daemon_request("POST", "/ocla/v1/delivery/check", &body_str),
691        )
692        .await
693        .ok()
694        .flatten()
695    })??;
696    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
697    if !v.get("hit")?.as_bool()? {
698        return None;
699    }
700    Some(crate::core::ocla::types::DeliveryRecord {
701        access: None,
702        blake3: *blake3,
703        path: v.get("path")?.as_str()?.to_string(),
704        line_count: v.get("line_count")?.as_u64()? as u32,
705        token_count: v.get("token_count").and_then(serde_json::Value::as_u64)?,
706        agent_id: v.get("agent_id")?.as_str()?.to_string(),
707        conversation_id: v.get("conversation_id")?.as_str()?.to_string(),
708        read_at: v.get("read_at")?.as_u64()?,
709        mtime: v
710            .get("mtime")
711            .and_then(serde_json::Value::as_u64)
712            .unwrap_or(mtime),
713        fresh: v
714            .get("fresh")
715            .and_then(serde_json::Value::as_bool)
716            .unwrap_or(true),
717        relay_content: v
718            .get("relay_content")
719            .and_then(serde_json::Value::as_str)
720            .map(String::from),
721        relay_mode: v
722            .get("relay_mode")
723            .and_then(serde_json::Value::as_str)
724            .map(String::from),
725    })
726}
727
728/// Record a delivery in the daemon's cross-agent registry.
729/// Fire-and-forget: errors and slow daemon responses are intentionally dropped.
730pub fn try_delivery_record_blocking(entry: &crate::core::ocla::types::DeliveryEntry) {
731    if !daemon::is_daemon_running() {
732        return;
733    }
734    let Ok(body) = serde_json::to_string(entry) else {
735        return;
736    };
737    let Ok(rt) = delivery_runtime() else {
738        return;
739    };
740    drop(rt.spawn(async move {
741        let _ = tokio::time::timeout(
742            Duration::from_secs(3),
743            try_daemon_request("POST", "/ocla/v1/delivery/record", &body),
744        )
745        .await;
746    }));
747}
748
749/// Check the daemon's generalized cross-agent cache (all DeliveryKinds).
750/// Returns the cached entry on hit, None on miss or daemon unreachable.
751pub fn try_cache_check_blocking(
752    key: &crate::core::ocla::cache_types::CacheKey,
753    validator: &crate::core::ocla::cache_types::CacheValidator,
754    requester_agent_id: Option<&str>,
755    requester_conversation_id: Option<&str>,
756) -> Option<crate::core::ocla::cache_types::DeliveryEntryV2> {
757    if !daemon::is_daemon_running() {
758        return None;
759    }
760    let validator_str = match validator {
761        crate::core::ocla::cache_types::CacheValidator::Immutable => "immutable".into(),
762        crate::core::ocla::cache_types::CacheValidator::File { mtime_ns } => {
763            format!("file:{mtime_ns}")
764        }
765        crate::core::ocla::cache_types::CacheValidator::Directory { mtime_ns } => {
766            format!("directory:{mtime_ns}")
767        }
768    };
769    let body = serde_json::json!({
770        "key": key.0,
771        "validator": validator_str,
772        "requester_agent_id": requester_agent_id,
773        "requester_conversation_id": requester_conversation_id,
774    });
775    let body_str = body.to_string();
776    let resp = delivery_block_on(async move {
777        tokio::time::timeout(
778            BEST_EFFORT_DELIVERY_IPC_TIMEOUT,
779            try_daemon_request("POST", "/ocla/v1/cache/check", &body_str),
780        )
781        .await
782        .ok()
783        .flatten()
784    })??;
785    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
786    if !v.get("hit")?.as_bool()? {
787        return None;
788    }
789    serde_json::from_value(v.get("entry")?.clone()).ok()
790}
791
792/// Record a generalized cache entry via daemon IPC. Fire-and-forget.
793pub fn try_cache_record_blocking(entry: &crate::core::ocla::cache_types::DeliveryEntryV2) {
794    if !daemon::is_daemon_running() {
795        return;
796    }
797    let Ok(body) = serde_json::to_string(entry) else {
798        return;
799    };
800    let Ok(rt) = delivery_runtime() else { return };
801    drop(rt.spawn(async move {
802        let _ = tokio::time::timeout(
803            Duration::from_secs(3),
804            try_daemon_request("POST", "/ocla/v1/cache/record", &body),
805        )
806        .await;
807    }));
808}
809
810#[cfg(test)]
811mod tests {
812    use std::{
813        future::Future,
814        io,
815        pin::Pin,
816        sync::{
817            Arc,
818            atomic::{AtomicUsize, Ordering},
819        },
820        task::{Context, Poll},
821    };
822
823    use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf};
824    use tokio::runtime::Runtime;
825
826    use super::{
827        DaemonRequestOutcome, DaemonToolCallOutcome, daemon_request_on_stream,
828        daemon_request_with_connector, delivery_runtime, format_http_request,
829        observed_tool_call_outcome,
830    };
831
832    fn block_on<F: Future>(future: F) -> F::Output {
833        Runtime::new()
834            .expect("test runtime initializes")
835            .block_on(future)
836    }
837
838    fn http_response(body: &str) -> String {
839        format!(
840            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
841            body.len()
842        )
843    }
844
845    async fn exchange(response_body: &str) -> DaemonRequestOutcome {
846        let (client, mut server) = tokio::io::duplex(4096);
847        let response = http_response(response_body);
848        let server_task = tokio::spawn(async move {
849            let mut request = [0u8; 4096];
850            let read = server
851                .read(&mut request)
852                .await
853                .expect("duplex server reads request");
854            assert!(read > 0);
855            server
856                .write_all(response.as_bytes())
857                .await
858                .expect("duplex server writes response");
859            server.shutdown().await.expect("duplex server shuts down");
860        });
861
862        let outcome =
863            daemon_request_on_stream(client, format_http_request("POST", "/v1/tools/call", "{}"))
864                .await;
865        server_task.await.expect("duplex server task joins");
866        outcome
867    }
868
869    struct PartialWriteThenError {
870        inner: DuplexStream,
871        write_calls: Arc<AtomicUsize>,
872    }
873
874    impl AsyncRead for PartialWriteThenError {
875        fn poll_read(
876            mut self: Pin<&mut Self>,
877            cx: &mut Context<'_>,
878            read_buf: &mut ReadBuf<'_>,
879        ) -> Poll<io::Result<()>> {
880            Pin::new(&mut self.inner).poll_read(cx, read_buf)
881        }
882    }
883
884    impl AsyncWrite for PartialWriteThenError {
885        fn poll_write(
886            mut self: Pin<&mut Self>,
887            cx: &mut Context<'_>,
888            buf: &[u8],
889        ) -> Poll<io::Result<usize>> {
890            if self.write_calls.fetch_add(1, Ordering::SeqCst) == 0 {
891                let partial = buf.len().min(4);
892                Pin::new(&mut self.inner).poll_write(cx, &buf[..partial])
893            } else {
894                Poll::Ready(Err(io::Error::new(
895                    io::ErrorKind::BrokenPipe,
896                    "injected partial write failure",
897                )))
898            }
899        }
900
901        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
902            Poll::Ready(Ok(()))
903        }
904
905        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
906            Poll::Ready(Ok(()))
907        }
908    }
909
910    struct PartialReadThenError {
911        response: Vec<u8>,
912        offset: usize,
913    }
914
915    impl AsyncRead for PartialReadThenError {
916        fn poll_read(
917            mut self: Pin<&mut Self>,
918            _cx: &mut Context<'_>,
919            read_buf: &mut ReadBuf<'_>,
920        ) -> Poll<io::Result<()>> {
921            if self.offset < self.response.len() {
922                let end = (self.offset + read_buf.remaining()).min(self.response.len());
923                read_buf.put_slice(&self.response[self.offset..end]);
924                self.offset = end;
925                Poll::Ready(Ok(()))
926            } else {
927                Poll::Ready(Err(io::Error::new(
928                    io::ErrorKind::ConnectionReset,
929                    "injected partial read failure",
930                )))
931            }
932        }
933    }
934
935    impl AsyncWrite for PartialReadThenError {
936        fn poll_write(
937            self: Pin<&mut Self>,
938            _cx: &mut Context<'_>,
939            buf: &[u8],
940        ) -> Poll<io::Result<usize>> {
941            Poll::Ready(Ok(buf.len()))
942        }
943
944        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
945            Poll::Ready(Ok(()))
946        }
947
948        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
949            Poll::Ready(Ok(()))
950        }
951    }
952
953    #[test]
954    fn delivery_runtime_is_shared() {
955        let first = delivery_runtime().expect("shared delivery runtime initializes");
956        let second = delivery_runtime().expect("shared delivery runtime remains available");
957        assert!(std::ptr::eq(first, second));
958    }
959
960    #[test]
961    fn observed_success_text_is_completed() {
962        let body = r#"{"result":{"content":[{"type":"text","text":"hello"}]}}"#;
963        let outcome = observed_tool_call_outcome(block_on(exchange(body)));
964        assert_eq!(
965            outcome,
966            DaemonToolCallOutcome::Completed {
967                text: "hello".into(),
968                is_error: false,
969            }
970        );
971    }
972
973    #[test]
974    fn observed_empty_text_is_completed_empty() {
975        let body = r#"{"result":{"content":[{"type":"text","text":""}]}}"#;
976        let outcome = observed_tool_call_outcome(block_on(exchange(body)));
977        assert_eq!(
978            outcome,
979            DaemonToolCallOutcome::Completed {
980                text: String::new(),
981                is_error: false,
982            }
983        );
984    }
985
986    #[test]
987    fn invalid_terminal_payload_is_uncertain_and_legacy_empty_fallback_is_preserved() {
988        for body in [
989            "garbage",
990            "null",
991            "[]",
992            r#"{"result":{"content":[{"type":"text","text":7}]}}"#,
993        ] {
994            assert!(matches!(
995                observed_tool_call_outcome(DaemonRequestOutcome::Completed(body.to_owned())),
996                DaemonToolCallOutcome::Uncertain { .. }
997            ));
998        }
999        let body = r#"{"result":{"content":[],"text":"legacy fallback"}}"#;
1000        assert_eq!(
1001            super::unwrap_mcp_tool_text(body),
1002            Some("legacy fallback".to_owned())
1003        );
1004        assert_eq!(
1005            observed_tool_call_outcome(DaemonRequestOutcome::Completed(
1006                r#"{"result":{"content":[]}}"#.to_owned()
1007            )),
1008            DaemonToolCallOutcome::Completed {
1009                text: String::new(),
1010                is_error: false
1011            }
1012        );
1013    }
1014
1015    #[test]
1016    fn observed_tool_error_is_terminal_completed_error() {
1017        let body = r#"{"result":{"content":[{"type":"text","text":"denied"}],"isError":true}}"#;
1018        let outcome = observed_tool_call_outcome(block_on(exchange(body)));
1019        assert_eq!(
1020            outcome,
1021            DaemonToolCallOutcome::Completed {
1022                text: "denied".into(),
1023                is_error: true,
1024            }
1025        );
1026    }
1027
1028    #[test]
1029    fn connect_failure_is_unavailable_before_dispatch() {
1030        let outcome = block_on(daemon_request_with_connector(
1031            async {
1032                Err::<DuplexStream, _>(io::Error::new(
1033                    io::ErrorKind::NotFound,
1034                    "injected connect failure",
1035                ))
1036            },
1037            "test-daemon",
1038            format_http_request("POST", "/v1/tools/call", "{}"),
1039        ));
1040        assert!(matches!(
1041            outcome,
1042            DaemonRequestOutcome::Unavailable(message)
1043                if message.contains("cannot connect to daemon")
1044        ));
1045    }
1046
1047    #[test]
1048    fn partial_write_is_uncertain_and_connector_runs_once() {
1049        let (client, _server) = tokio::io::duplex(1024);
1050        let connector_calls = Arc::new(AtomicUsize::new(0));
1051        let write_calls = Arc::new(AtomicUsize::new(0));
1052        let stream = PartialWriteThenError {
1053            inner: client,
1054            write_calls: Arc::clone(&write_calls),
1055        };
1056        let connector_calls_for_future = Arc::clone(&connector_calls);
1057        let outcome = block_on(daemon_request_with_connector(
1058            async move {
1059                connector_calls_for_future.fetch_add(1, Ordering::SeqCst);
1060                Ok::<_, io::Error>(stream)
1061            },
1062            "test-daemon",
1063            format_http_request("POST", "/v1/tools/call", "{}"),
1064        ));
1065        assert!(matches!(
1066            outcome,
1067            DaemonRequestOutcome::Uncertain(message)
1068                if message.contains("failed to write request")
1069        ));
1070        assert_eq!(connector_calls.load(Ordering::SeqCst), 1);
1071        assert_eq!(write_calls.load(Ordering::SeqCst), 2);
1072    }
1073
1074    #[test]
1075    fn partial_read_is_uncertain_after_dispatch() {
1076        let stream = PartialReadThenError {
1077            response: b"HTTP/1.1 200 OK\r\n\r\n{\"result\"".to_vec(),
1078            offset: 0,
1079        };
1080        let outcome = block_on(daemon_request_on_stream(
1081            stream,
1082            format_http_request("POST", "/v1/tools/call", "{}"),
1083        ));
1084        assert!(matches!(
1085            outcome,
1086            DaemonRequestOutcome::Uncertain(message)
1087                if message.contains("failed to read response")
1088        ));
1089    }
1090
1091    #[test]
1092    fn non_success_http_is_uncertain_not_unavailable() {
1093        let (client, mut server) = tokio::io::duplex(4096);
1094        let response = "HTTP/1.1 503 Service Unavailable\r\n\r\nrejected".to_string();
1095        let request = format_http_request("POST", "/v1/tools/call", "{}");
1096        let expected_request = request.clone();
1097        let outcome = block_on(async move {
1098            let server_task = tokio::spawn(async move {
1099                let mut received = vec![0u8; expected_request.len()];
1100                server
1101                    .read_exact(&mut received)
1102                    .await
1103                    .expect("server reads request");
1104                assert_eq!(received, expected_request.as_bytes());
1105                server
1106                    .write_all(response.as_bytes())
1107                    .await
1108                    .expect("server writes rejection");
1109                server.shutdown().await.expect("server shuts down");
1110            });
1111            let outcome = daemon_request_on_stream(client, request).await;
1112            server_task.await.expect("server task joins");
1113            outcome
1114        });
1115        assert!(
1116            matches!(outcome, DaemonRequestOutcome::Uncertain(message) if message.contains("503"))
1117        );
1118    }
1119}