Skip to content

Commit ecdd1ab

Browse files
authored
feat(daemon): Tier B IPC client
Adds the Tier B daemon IPC client and hardens familiar status integration with platform-safe Unix socket handling, shorter socket timeouts, cached status reads, non-misleading live badges, and shared COVEN_HOME test locking.\n\nValidation:\n- git diff --check\n- cargo test -p claurst-core coven_daemon --lib\n- cargo test -p claurst-core coven_shared --lib\n- cargo test -p claurst-tui familiar_live_badge --lib\n- cargo check -p claurst-tui
1 parent 5906638 commit ecdd1ab

4 files changed

Lines changed: 448 additions & 14 deletions

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
//! Tier B daemon IPC — async-free HTTP-over-Unix-socket client.
2+
//!
3+
//! Talks to the Coven daemon at `~/.coven/coven.sock` using raw
4+
//! `UnixStream` + hand-written HTTP/1.0 requests. No tokio dependency
5+
//! is added; all calls are blocking and degrade gracefully when the
6+
//! daemon is absent.
7+
8+
#[cfg(unix)]
9+
use std::io::{Read, Write};
10+
#[cfg(unix)]
11+
use std::os::unix::net::UnixStream;
12+
#[cfg(unix)]
13+
use std::path::PathBuf;
14+
#[cfg(unix)]
15+
use std::time::Duration;
16+
17+
use serde::Deserialize;
18+
19+
#[cfg(unix)]
20+
use crate::coven_shared::coven_home;
21+
22+
// ---------------------------------------------------------------------------
23+
// Public data types
24+
// ---------------------------------------------------------------------------
25+
26+
/// Condensed view of a familiar's live status from the daemon.
27+
#[derive(Debug, Clone)]
28+
pub struct FamiliarStatus {
29+
pub id: String,
30+
pub display_name: String,
31+
pub emoji: String,
32+
pub status: String,
33+
pub active_sessions: u32,
34+
pub memory_freshness: String,
35+
}
36+
37+
/// Condensed view of a running (non-archived) daemon session.
38+
#[derive(Debug, Clone)]
39+
pub struct DaemonSession {
40+
pub id: String,
41+
pub harness: String,
42+
pub title: String,
43+
pub status: String,
44+
pub project_root: String,
45+
}
46+
47+
// ---------------------------------------------------------------------------
48+
// Raw JSON shapes (private — only used for deserialization)
49+
// ---------------------------------------------------------------------------
50+
51+
#[derive(Deserialize)]
52+
struct RawFamiliar {
53+
#[serde(default)]
54+
id: String,
55+
#[serde(default)]
56+
display_name: Option<String>,
57+
#[serde(default)]
58+
emoji: Option<String>,
59+
#[serde(default)]
60+
status: Option<String>,
61+
#[serde(default)]
62+
active_sessions: Option<u32>,
63+
#[serde(default)]
64+
memory_freshness: Option<String>,
65+
}
66+
67+
#[derive(Deserialize)]
68+
struct RawSession {
69+
#[serde(default)]
70+
id: String,
71+
#[serde(default)]
72+
harness: Option<String>,
73+
#[serde(default)]
74+
title: Option<String>,
75+
#[serde(default)]
76+
status: Option<String>,
77+
#[serde(default)]
78+
project_root: Option<String>,
79+
#[serde(default)]
80+
archived_at: Option<String>,
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
// DaemonClient
85+
// ---------------------------------------------------------------------------
86+
87+
/// Blocking HTTP-over-Unix-socket client for the Coven daemon.
88+
pub struct DaemonClient {
89+
#[cfg(unix)]
90+
sock_path: PathBuf,
91+
}
92+
93+
impl DaemonClient {
94+
/// Create a client targeting the default socket path.
95+
///
96+
/// Returns `None` when the socket file does not exist (daemon is not
97+
/// running / not installed). Never panics.
98+
pub fn new() -> Option<Self> {
99+
#[cfg(unix)]
100+
{
101+
let home = coven_home()?;
102+
let sock = home.join("coven.sock");
103+
if sock.exists() {
104+
Some(Self { sock_path: sock })
105+
} else {
106+
None
107+
}
108+
}
109+
#[cfg(not(unix))]
110+
{
111+
None
112+
}
113+
}
114+
115+
// -- internal helpers ---------------------------------------------------
116+
117+
/// Open a fresh `UnixStream` connection with a short timeout.
118+
#[cfg(unix)]
119+
fn connect(&self) -> std::io::Result<UnixStream> {
120+
let stream = UnixStream::connect(&self.sock_path)?;
121+
let timeout = Duration::from_millis(200);
122+
stream.set_read_timeout(Some(timeout))?;
123+
stream.set_write_timeout(Some(timeout))?;
124+
Ok(stream)
125+
}
126+
127+
/// Send a minimal HTTP/1.0 GET and return the body string.
128+
///
129+
/// HTTP/1.0 is used so the server closes the connection after the
130+
/// response — no need to parse `Content-Length` or chunked encoding.
131+
fn get(&self, path: &str) -> Option<String> {
132+
#[cfg(unix)]
133+
{
134+
let mut stream = self.connect().ok()?;
135+
let request = format!(
136+
"GET {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n",
137+
path
138+
);
139+
stream.write_all(request.as_bytes()).ok()?;
140+
stream.flush().ok()?;
141+
142+
let mut raw = Vec::new();
143+
stream.read_to_end(&mut raw).ok()?;
144+
145+
let response = String::from_utf8_lossy(&raw);
146+
147+
// Split on the blank line that separates headers from body.
148+
if let Some(idx) = response.find("\r\n\r\n") {
149+
// Verify the response has a 2xx status code.
150+
let status_line = response.lines().next().unwrap_or("");
151+
let status_code = status_line.split_whitespace().nth(1)?.parse::<u16>().ok()?;
152+
if !(200..300).contains(&status_code) {
153+
return None;
154+
}
155+
Some(response[idx + 4..].to_string())
156+
} else {
157+
None
158+
}
159+
}
160+
#[cfg(not(unix))]
161+
{
162+
let _ = path;
163+
None
164+
}
165+
}
166+
167+
// -- public API ---------------------------------------------------------
168+
169+
/// Quick liveness check — returns `true` if the daemon responds with 200.
170+
pub fn is_online(&self) -> bool {
171+
self.get("/api/v1/familiars").is_some()
172+
}
173+
174+
/// Fetch all familiar statuses. Returns an empty `Vec` on any error.
175+
pub fn familiar_statuses(&self) -> Vec<FamiliarStatus> {
176+
let body = match self.get("/api/v1/familiars") {
177+
Some(b) => b,
178+
None => return Vec::new(),
179+
};
180+
let raw: Vec<RawFamiliar> = match serde_json::from_str(&body) {
181+
Ok(v) => v,
182+
Err(_) => return Vec::new(),
183+
};
184+
raw.into_iter()
185+
.map(|r| FamiliarStatus {
186+
display_name: r.display_name.unwrap_or_else(|| r.id.clone()),
187+
emoji: r.emoji.unwrap_or_default(),
188+
status: r.status.unwrap_or_else(|| "unknown".to_string()),
189+
active_sessions: r.active_sessions.unwrap_or(0),
190+
memory_freshness: r.memory_freshness.unwrap_or_default(),
191+
id: r.id,
192+
})
193+
.collect()
194+
}
195+
196+
/// Fetch non-archived sessions. Returns an empty `Vec` on any error.
197+
pub fn active_sessions(&self) -> Vec<DaemonSession> {
198+
let body = match self.get("/api/v1/sessions") {
199+
Some(b) => b,
200+
None => return Vec::new(),
201+
};
202+
let raw: Vec<RawSession> = match serde_json::from_str(&body) {
203+
Ok(v) => v,
204+
Err(_) => return Vec::new(),
205+
};
206+
raw.into_iter()
207+
.filter(|r| r.archived_at.is_none())
208+
.map(|r| DaemonSession {
209+
harness: r.harness.unwrap_or_default(),
210+
title: r.title.unwrap_or_default(),
211+
status: r.status.unwrap_or_else(|| "unknown".to_string()),
212+
project_root: r.project_root.unwrap_or_default(),
213+
id: r.id,
214+
})
215+
.collect()
216+
}
217+
}
218+
219+
// ---------------------------------------------------------------------------
220+
// Tests
221+
// ---------------------------------------------------------------------------
222+
223+
#[cfg(test)]
224+
mod tests {
225+
use super::*;
226+
use crate::coven_shared::COVEN_HOME_ENV_LOCK;
227+
use std::fs;
228+
229+
/// Guard that temporarily sets `COVEN_HOME` and restores it on drop.
230+
struct EnvGuard {
231+
key: &'static str,
232+
original: Option<String>,
233+
}
234+
impl EnvGuard {
235+
fn set(key: &'static str, val: &str) -> Self {
236+
let original = std::env::var(key).ok();
237+
std::env::set_var(key, val);
238+
Self { key, original }
239+
}
240+
}
241+
impl Drop for EnvGuard {
242+
fn drop(&mut self) {
243+
match &self.original {
244+
Some(v) => std::env::set_var(self.key, v),
245+
None => std::env::remove_var(self.key),
246+
}
247+
}
248+
}
249+
250+
#[test]
251+
fn new_returns_none_when_sock_absent() {
252+
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
253+
let dir = tempfile::tempdir().unwrap();
254+
let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap());
255+
// Directory exists but no coven.sock inside → should return None.
256+
assert!(DaemonClient::new().is_none());
257+
}
258+
259+
#[test]
260+
fn new_returns_some_when_sock_present() {
261+
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
262+
let dir = tempfile::tempdir().unwrap();
263+
// Create a placeholder file (not a real socket, just needs to exist).
264+
fs::write(dir.path().join("coven.sock"), b"").unwrap();
265+
let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap());
266+
assert!(DaemonClient::new().is_some());
267+
}
268+
269+
#[test]
270+
fn familiar_status_deserializes_from_json() {
271+
let json = r#"[
272+
{
273+
"id": "sage",
274+
"display_name": "Sage",
275+
"emoji": "🌿",
276+
"role": "researcher",
277+
"description": "Deep research familiar",
278+
"status": "active",
279+
"active_sessions": 2,
280+
"memory_freshness": "fresh"
281+
},
282+
{
283+
"id": "kitty",
284+
"status": "idle",
285+
"active_sessions": 0
286+
}
287+
]"#;
288+
289+
let raw: Vec<RawFamiliar> = serde_json::from_str(json).unwrap();
290+
assert_eq!(raw.len(), 2);
291+
292+
let s0 = FamiliarStatus {
293+
display_name: raw[0].display_name.clone().unwrap_or_else(|| raw[0].id.clone()),
294+
emoji: raw[0].emoji.clone().unwrap_or_default(),
295+
status: raw[0].status.clone().unwrap_or_default(),
296+
active_sessions: raw[0].active_sessions.unwrap_or(0),
297+
memory_freshness: raw[0].memory_freshness.clone().unwrap_or_default(),
298+
id: raw[0].id.clone(),
299+
};
300+
assert_eq!(s0.id, "sage");
301+
assert_eq!(s0.display_name, "Sage");
302+
assert_eq!(s0.emoji, "🌿");
303+
assert_eq!(s0.status, "active");
304+
assert_eq!(s0.active_sessions, 2);
305+
306+
let s1 = FamiliarStatus {
307+
display_name: raw[1].display_name.clone().unwrap_or_else(|| raw[1].id.clone()),
308+
emoji: raw[1].emoji.clone().unwrap_or_default(),
309+
status: raw[1].status.clone().unwrap_or_default(),
310+
active_sessions: raw[1].active_sessions.unwrap_or(0),
311+
memory_freshness: raw[1].memory_freshness.clone().unwrap_or_default(),
312+
id: raw[1].id.clone(),
313+
};
314+
assert_eq!(s1.id, "kitty");
315+
assert_eq!(s1.display_name, "kitty"); // falls back to id
316+
assert_eq!(s1.active_sessions, 0);
317+
}
318+
319+
#[test]
320+
fn familiar_statuses_returns_empty_on_bad_json() {
321+
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
322+
let dir = tempfile::tempdir().unwrap();
323+
// Placeholder sock — not a real socket, so connect() will fail.
324+
fs::write(dir.path().join("coven.sock"), b"").unwrap();
325+
let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap());
326+
let client = DaemonClient::new().unwrap();
327+
// connect() will fail → familiar_statuses() must return empty vec, not panic.
328+
assert!(client.familiar_statuses().is_empty());
329+
}
330+
}

src-rust/crates/core/src/coven_shared.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
//! absent so coven-code keeps working standalone.
99
//!
1010
//! Tier A of the "native Coven" integration. Tier B (daemon IPC over
11-
//! `~/.coven/coven.sock`) is not implemented here.
11+
//! `~/.coven/coven.sock`) lives in [`crate::coven_daemon`].
12+
13+
// Re-export Tier B IPC types for convenience.
14+
pub use crate::coven_daemon::{DaemonClient, DaemonSession, FamiliarStatus};
1215

1316
use std::path::PathBuf;
1417
use serde::Deserialize;
@@ -29,6 +32,9 @@ pub fn coven_home() -> Option<PathBuf> {
2932
p.is_dir().then_some(p)
3033
}
3134

35+
#[cfg(test)]
36+
pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
37+
3238
// ---------------------------------------------------------------------------
3339
// Familiars (~/.coven/familiars.toml)
3440
// ---------------------------------------------------------------------------
@@ -135,14 +141,11 @@ pub fn list_daemon_skills() -> Vec<DaemonSkill> {
135141
mod tests {
136142
use super::*;
137143
use std::fs;
138-
use std::sync::Mutex;
139144
use tempfile::TempDir;
140145

141146
// coven_home() reads COVEN_HOME from process env, which is shared across
142147
// parallel tests in the same binary. Serialize the env-touching tests so
143148
// they don't clobber each other's overrides.
144-
static ENV_LOCK: Mutex<()> = Mutex::new(());
145-
146149
struct EnvGuard {
147150
_tmp: TempDir,
148151
_lock: std::sync::MutexGuard<'static, ()>,
@@ -155,7 +158,7 @@ mod tests {
155158
}
156159

157160
fn with_coven_home<F: FnOnce(&std::path::Path)>(setup: F) -> EnvGuard {
158-
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
161+
let lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
159162
let tmp = TempDir::new().unwrap();
160163
setup(tmp.path());
161164
std::env::set_var("COVEN_HOME", tmp.path());
@@ -164,7 +167,7 @@ mod tests {
164167

165168
#[test]
166169
fn coven_home_returns_none_when_dir_missing() {
167-
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
170+
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
168171
std::env::set_var("COVEN_HOME", "/nonexistent/path/cc_test_xyz");
169172
assert!(coven_home().is_none());
170173
std::env::remove_var("COVEN_HOME");

src-rust/crates/core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ pub use skill_discovery::{DiscoveredSkill, discover_skills, parse_skill_file};
9090

9191
// Coven daemon shared state — read-only bridge to ~/.coven/.
9292
pub mod coven_shared;
93+
// Tier B IPC — blocking HTTP-over-Unix-socket client for the live daemon.
94+
pub mod coven_daemon;
9395
pub use cost::CostTracker;
9496
pub use history::ConversationSession;
9597
pub use feature_flags::FeatureFlagManager;

0 commit comments

Comments
 (0)