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