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))] pub enum DaemonToolCallOutcome {
15 Unavailable,
16 Completed { text: String, is_error: bool },
17 Uncertain { message: String },
18}
19
20const 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
47fn 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
70pub 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 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
188pub 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
196pub 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 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
271pub 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
279pub 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 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#[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))] async 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))] pub 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))] fn 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 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
515pub 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
566pub async fn scoped_delivery_request(
570 request: &crate::core::a2a::task::delivery_authority::SignedDeliveryRequest,
571) -> anyhow::Result<serde_json::Value> {
572 scoped_delivery_request_at(&daemon::daemon_addr(), request).await
575}
576
577pub(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
603pub 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
658pub 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
720pub 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
741pub 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
784pub 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}