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
12fn delivery_runtime() -> Result<&'static Runtime> {
13    match DELIVERY_RUNTIME.get_or_init(|| Runtime::new().map_err(|error| error.to_string())) {
14        Ok(runtime) => Ok(runtime),
15        Err(error) => Err(anyhow::anyhow!("initialize delivery IPC runtime: {error}")),
16    }
17}
18
19/// Safely run an async future on the delivery runtime from any thread context.
20/// When called from inside a tokio runtime (e.g. tool handler async pipeline /
21/// prefetch re-entry), spawns a scoped thread to avoid "Cannot start a runtime
22/// from within a runtime". When called from outside (CLI), blocks directly.
23fn delivery_block_on<F, O>(fut: F) -> Option<O>
24where
25    F: std::future::Future<Output = O> + Send,
26    O: Send,
27{
28    let rt = delivery_runtime().ok()?;
29    if tokio::runtime::Handle::try_current().is_ok() {
30        let mut result = None;
31        std::thread::scope(|s| {
32            s.spawn(|| {
33                result = Some(rt.block_on(fut));
34            });
35        });
36        result
37    } else {
38        Some(rt.block_on(fut))
39    }
40}
41
42/// Send an HTTP request to the daemon over the IPC channel.
43/// Returns the response body as a string.
44pub async fn daemon_request(method: &str, path: &str, body: &str) -> Result<String> {
45    use tokio::time::timeout;
46
47    const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
48    const IO_TIMEOUT: Duration = Duration::from_secs(10);
49
50    let addr = daemon::daemon_addr();
51    if !addr.is_listening() {
52        anyhow::bail!(
53            "Daemon endpoint not found at {}. Is the daemon running?",
54            addr.display()
55        );
56    }
57
58    let request = format_http_request(method, path, body);
59
60    #[cfg(unix)]
61    {
62        let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
63            .await
64            .with_context(|| {
65                format!(
66                    "connect to daemon timed out ({}s)",
67                    CONNECT_TIMEOUT.as_secs()
68                )
69            })?
70            .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
71
72        timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
73            .await
74            .context("write to daemon timed out")?
75            .context("failed to write request to daemon")?;
76
77        let mut response_buf = Vec::with_capacity(4096);
78        timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
79            .await
80            .context("read from daemon timed out")?
81            .context("failed to read response from daemon")?;
82
83        parse_http_response(&response_buf)
84    }
85
86    #[cfg(windows)]
87    {
88        let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
89            .await
90            .with_context(|| {
91                format!(
92                    "connect to daemon timed out ({}s)",
93                    CONNECT_TIMEOUT.as_secs()
94                )
95            })?
96            .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
97
98        timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
99            .await
100            .context("write to daemon timed out")?
101            .context("failed to write request to daemon")?;
102
103        let mut response_buf = Vec::with_capacity(4096);
104        timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
105            .await
106            .context("read from daemon timed out")?
107            .context("failed to read response from daemon")?;
108
109        parse_http_response(&response_buf)
110    }
111}
112
113/// Check if the daemon is reachable by hitting /health.
114pub async fn daemon_health_check() -> bool {
115    match daemon_request("GET", "/health", "").await {
116        Ok(body) => body.trim() == "ok",
117        Err(_) => false,
118    }
119}
120
121/// Call a tool on the daemon's REST API.
122pub async fn daemon_tool_call(name: &str, arguments: Option<&serde_json::Value>) -> Result<String> {
123    let body = serde_json::json!({
124        "name": name,
125        "arguments": arguments,
126    });
127    daemon_request("POST", "/v1/tools/call", &body.to_string()).await
128}
129
130fn format_http_request(method: &str, path: &str, body: &str) -> String {
131    if body.is_empty() {
132        format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
133    } else {
134        let content_length = body.len();
135        format!(
136            "{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}"
137        )
138    }
139}
140
141fn parse_http_response(raw: &[u8]) -> Result<String> {
142    let response_str = std::str::from_utf8(raw).context("daemon response is not valid UTF-8")?;
143
144    let Some(header_end) = response_str.find("\r\n\r\n") else {
145        anyhow::bail!("malformed HTTP response from daemon (no header boundary)");
146    };
147
148    let headers = &response_str[..header_end];
149    let body = &response_str[header_end + 4..];
150
151    let status_line = headers.lines().next().unwrap_or("");
152    let status_code = status_line
153        .split_whitespace()
154        .nth(1)
155        .and_then(|s| s.parse::<u16>().ok())
156        .unwrap_or(0);
157
158    if status_code >= 400 {
159        anyhow::bail!("daemon returned HTTP {status_code}: {body}");
160    }
161
162    Ok(body.to_string())
163}
164
165/// Attempt to connect to the daemon. Returns `None` if not running.
166pub async fn try_daemon_request(method: &str, path: &str, body: &str) -> Option<String> {
167    if !daemon::is_daemon_running() {
168        return None;
169    }
170    daemon_request(method, path, body).await.ok()
171}
172
173/// Tell a *running* daemon to drop its in-memory read cache (`SessionCache`).
174/// Returns `true` if a daemon was reached. Never auto-starts a daemon — if none
175/// is running there is no cache to flush. Force-rebuild CLI commands call this so
176/// `ctx_read` map/signatures stop serving pre-rebuild output from the daemon's
177/// long-lived cache, which CLI index rebuilds otherwise can't reach (#420).
178pub fn notify_cache_clear() -> bool {
179    if !daemon::is_daemon_running() {
180        return false;
181    }
182    let Ok(rt) = tokio::runtime::Runtime::new() else {
183        return false;
184    };
185    let body = serde_json::json!({
186        "name": "ctx_cache",
187        "arguments": { "action": "clear" },
188    });
189    rt.block_on(async {
190        try_daemon_request("POST", "/v1/tools/call", &body.to_string())
191            .await
192            .is_some()
193    })
194}
195
196/// Blocking helper for CLI commands: routes a tool call through the daemon.
197///
198/// Returns `None` if no daemon can serve the call (caller then renders the tool
199/// locally / standalone). Behaviour splits on caller identity:
200///
201/// - **Normal CLI invocation**: connects to a running daemon, and *auto-starts*
202///   one if none is listening (so the long-lived `LeanCtxServer` state — caches,
203///   indexes, detectors — is reused across commands).
204/// - **Shadow-mode hook child** (`LEAN_CTX_HOOK_CHILD` set): *connect-only*. It
205///   reuses an already-running daemon for full parity (the `ready` fast-path
206///   below routes straight through `/v1/tools/call` → `call_tool_guarded`, so the
207///   in-memory `LoopDetector`, correction-loop auto-degrade, bounce tracker and
208///   adaptive thresholds all fire on the daemon's long-lived state — #566), but
209///   it MUST NEVER auto-start a daemon. A hook fires once per intercepted
210///   read/grep as a fresh process; auto-starting from there would spawn daemons
211///   uncontrollably. With no live daemon it returns `None` and the caller falls
212///   back to the enriched standalone path (disk-backed learning sinks + Context
213///   IR from #550/#569).
214#[allow(clippy::needless_pass_by_value)]
215pub fn try_daemon_tool_call_blocking(
216    name: &str,
217    arguments: Option<serde_json::Value>,
218) -> Option<String> {
219    use std::time::Duration;
220
221    if std::env::var_os("__LEAN_CTX_NO_DAEMON").is_some() {
222        return None;
223    }
224
225    let rt = Runtime::new().ok()?;
226
227    let addr = daemon::daemon_addr();
228    let mut ready = addr.is_listening() && rt.block_on(async { daemon_health_check().await });
229
230    if !ready {
231        // Connect-only for shadow-mode hooks (#566): a hook child reaches a live
232        // daemon via the `ready` fast-path above (full detector parity), but when
233        // none is listening it must bail to the standalone fallback instead of
234        // auto-starting one. This guard MUST stay inside `if !ready` — hoisting it
235        // to the top of the function would also block hooks from reusing a running
236        // daemon, silently regressing loop/bounce/adaptive parity.
237        if crate::core::runtime_flags::hook_child_enabled() {
238            return None;
239        }
240
241        let lock = crate::core::startup_guard::try_acquire_lock(
242            "daemon-start",
243            Duration::from_millis(1200),
244            Duration::from_secs(5),
245        );
246
247        if let Some(g) = lock {
248            g.touch();
249            let mut did_start = false;
250
251            if !daemon::is_daemon_running() {
252                if daemon::start_daemon(&[]).is_ok() {
253                    did_start = true;
254                } else {
255                    return None;
256                }
257            }
258
259            for _ in 0..60 {
260                if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
261                    ready = true;
262                    break;
263                }
264                std::thread::sleep(Duration::from_millis(50));
265            }
266
267            if ready && did_start && crate::core::protocol::meta_visible() {
268                eprintln!("\x1b[2m▸ daemon auto-started\x1b[0m");
269            }
270        } else {
271            for _ in 0..60 {
272                if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
273                    ready = true;
274                    break;
275                }
276                std::thread::sleep(Duration::from_millis(50));
277            }
278        }
279    }
280
281    if !ready {
282        return None;
283    }
284
285    if let Some(out) = rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
286    {
287        return Some(out);
288    }
289
290    for _ in 0..5 {
291        std::thread::sleep(Duration::from_millis(50));
292        if let Some(out) =
293            rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
294        {
295            return Some(out);
296        }
297    }
298
299    None
300}
301
302fn unwrap_mcp_tool_text(body: &str) -> Option<String> {
303    let v: serde_json::Value = serde_json::from_str(body).ok()?;
304    let result = v.get("result")?;
305
306    if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
307        let mut texts: Vec<String> = Vec::new();
308        for item in content {
309            if let Some(text) = item.get("text").and_then(|t| t.as_str())
310                && !text.is_empty()
311            {
312                texts.push(text.to_string());
313            }
314        }
315        if !texts.is_empty() {
316            return Some(texts.join("\n"));
317        }
318    }
319
320    if let Some(text) = result.get("text").and_then(|t| t.as_str()) {
321        return Some(text.to_string());
322    }
323
324    result.as_str().map(std::string::ToString::to_string)
325}
326
327/// Like `try_daemon_tool_call_blocking`, but unwraps MCP JSON responses to text for CLI output.
328pub fn try_daemon_tool_call_blocking_text(
329    name: &str,
330    arguments: Option<serde_json::Value>,
331) -> Option<String> {
332    let body = try_daemon_tool_call_blocking(name, arguments)?;
333    let trimmed = body.trim_start();
334    if !trimmed.starts_with('{') {
335        return Some(body);
336    }
337    Some(unwrap_mcp_tool_text(&body).unwrap_or(body))
338}
339
340/// Check the daemon's cross-agent delivery registry for a content hash.
341/// Returns `None` if daemon unreachable or no hit. Connect-only — never
342/// auto-starts a daemon (delivery is best-effort).
343pub fn try_delivery_check_blocking(
344    blake3: &[u8; 12],
345    mtime: u64,
346    path: &str,
347    requester_agent_id: Option<&str>,
348    requester_conversation_id: Option<&str>,
349) -> Option<crate::core::ocla::types::DeliveryRecord> {
350    if !daemon::is_daemon_running() {
351        return None;
352    }
353    let body = serde_json::json!({
354        "blake3": blake3,
355        "mtime": mtime,
356        "path": path,
357        "requester_agent_id": requester_agent_id,
358        "requester_conversation_id": requester_conversation_id,
359    });
360    let body_str = body.to_string();
361    let resp = delivery_block_on(async move {
362        try_daemon_request("POST", "/ocla/v1/delivery/check", &body_str).await
363    })??;
364    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
365    if !v.get("hit")?.as_bool()? {
366        return None;
367    }
368    Some(crate::core::ocla::types::DeliveryRecord {
369        blake3: *blake3,
370        path: v.get("path")?.as_str()?.to_string(),
371        line_count: v.get("line_count")?.as_u64()? as u32,
372        token_count: v.get("token_count").and_then(serde_json::Value::as_u64)?,
373        agent_id: v.get("agent_id")?.as_str()?.to_string(),
374        conversation_id: v.get("conversation_id")?.as_str()?.to_string(),
375        read_at: v.get("read_at")?.as_u64()?,
376        mtime: v
377            .get("mtime")
378            .and_then(serde_json::Value::as_u64)
379            .unwrap_or(mtime),
380        fresh: v
381            .get("fresh")
382            .and_then(serde_json::Value::as_bool)
383            .unwrap_or(true),
384        relay_content: v
385            .get("relay_content")
386            .and_then(serde_json::Value::as_str)
387            .map(String::from),
388        relay_mode: v
389            .get("relay_mode")
390            .and_then(serde_json::Value::as_str)
391            .map(String::from),
392    })
393}
394
395/// Record a delivery in the daemon's cross-agent registry.
396/// Fire-and-forget: errors and slow daemon responses are intentionally dropped.
397pub fn try_delivery_record_blocking(entry: &crate::core::ocla::types::DeliveryEntry) {
398    if !daemon::is_daemon_running() {
399        return;
400    }
401    let Ok(body) = serde_json::to_string(entry) else {
402        return;
403    };
404    let Ok(rt) = delivery_runtime() else {
405        return;
406    };
407    drop(rt.spawn(async move {
408        let _ = tokio::time::timeout(
409            Duration::from_secs(3),
410            try_daemon_request("POST", "/ocla/v1/delivery/record", &body),
411        )
412        .await;
413    }));
414}
415
416/// Check the daemon's generalized cross-agent cache (all DeliveryKinds).
417/// Returns the cached entry on hit, None on miss or daemon unreachable.
418pub fn try_cache_check_blocking(
419    key: &crate::core::ocla::cache_types::CacheKey,
420    validator: &crate::core::ocla::cache_types::CacheValidator,
421    requester_agent_id: Option<&str>,
422    requester_conversation_id: Option<&str>,
423) -> Option<crate::core::ocla::cache_types::DeliveryEntryV2> {
424    if !daemon::is_daemon_running() {
425        return None;
426    }
427    let validator_str = match validator {
428        crate::core::ocla::cache_types::CacheValidator::Immutable => "immutable".into(),
429        crate::core::ocla::cache_types::CacheValidator::File { mtime_ns } => {
430            format!("file:{mtime_ns}")
431        }
432        crate::core::ocla::cache_types::CacheValidator::Directory { mtime_ns } => {
433            format!("directory:{mtime_ns}")
434        }
435    };
436    let body = serde_json::json!({
437        "key": key.0,
438        "validator": validator_str,
439        "requester_agent_id": requester_agent_id,
440        "requester_conversation_id": requester_conversation_id,
441    });
442    let body_str = body.to_string();
443    let resp = delivery_block_on(async move {
444        try_daemon_request("POST", "/ocla/v1/cache/check", &body_str).await
445    })??;
446    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
447    if !v.get("hit")?.as_bool()? {
448        return None;
449    }
450    serde_json::from_value(v.get("entry")?.clone()).ok()
451}
452
453/// Record a generalized cache entry via daemon IPC. Fire-and-forget.
454pub fn try_cache_record_blocking(entry: &crate::core::ocla::cache_types::DeliveryEntryV2) {
455    if !daemon::is_daemon_running() {
456        return;
457    }
458    let Ok(body) = serde_json::to_string(entry) else {
459        return;
460    };
461    let Ok(rt) = delivery_runtime() else { return };
462    drop(rt.spawn(async move {
463        let _ = tokio::time::timeout(
464            Duration::from_secs(3),
465            try_daemon_request("POST", "/ocla/v1/cache/record", &body),
466        )
467        .await;
468    }));
469}
470
471#[cfg(test)]
472mod tests {
473    use super::delivery_runtime;
474
475    #[test]
476    fn delivery_runtime_is_shared() {
477        let first = delivery_runtime().expect("shared delivery runtime initializes");
478        let second = delivery_runtime().expect("shared delivery runtime remains available");
479        assert!(std::ptr::eq(first, second));
480    }
481}