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 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 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
279pub 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
287pub 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 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#[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))] async 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))] pub 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))] fn 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 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
523pub 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
574pub async fn scoped_delivery_request(
578 request: &crate::core::a2a::task::delivery_authority::SignedDeliveryRequest,
579) -> anyhow::Result<serde_json::Value> {
580 scoped_delivery_request_at(&daemon::daemon_addr(), request).await
583}
584
585pub(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
611pub 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
666pub 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
728pub 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
749pub 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
792pub 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}