Start Phase 4 typed daemon client
This commit is contained in:
parent
0e35c1bc27
commit
20809cda1d
7 changed files with 250 additions and 5 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -129,6 +129,19 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"colibri-daemon",
|
||||
"colibri-glasspane",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-contracts"
|
||||
version = "0.0.1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon"]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client"]
|
||||
|
||||
[package]
|
||||
name = "colibri"
|
||||
|
|
|
|||
18
crates/colibri-client/Cargo.toml
Normal file
18
crates/colibri-client/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "colibri-client"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Typed Unix-socket client for colibri-daemon/Glasspane API"
|
||||
|
||||
[dependencies]
|
||||
colibri-daemon = { path = "../colibri-daemon" }
|
||||
colibri-glasspane = { path = "../colibri-glasspane" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["io-util", "net"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
211
crates/colibri-client/src/lib.rs
Normal file
211
crates/colibri-client/src/lib.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! colibri-client — typed client for the colibri-daemon Unix socket API.
|
||||
//!
|
||||
//! Phase 4 starts here: display clients (Herdr on Linux, web boards, editor
|
||||
//! integrations) should not need to know daemon internals. They connect to the
|
||||
//! daemon socket, send newline-delimited JSON commands, and deserialize typed
|
||||
//! responses such as `GlasspaneSnapshot`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use colibri_daemon::{HerdrCommand, HerdrResponse};
|
||||
use colibri_glasspane::GlasspaneSnapshot;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
net::UnixStream,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("daemon returned error: {0}")]
|
||||
Daemon(String),
|
||||
#[error("daemon response missing data")]
|
||||
MissingData,
|
||||
#[error("daemon closed socket without response")]
|
||||
EmptyResponse,
|
||||
}
|
||||
|
||||
/// Small typed client for the daemon's newline-delimited Unix socket protocol.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DaemonClient {
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl DaemonClient {
|
||||
pub fn new(socket_path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
socket_path: socket_path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
/// Send one command and parse the daemon's generic response envelope.
|
||||
pub async fn send(&self, command: &HerdrCommand) -> Result<HerdrResponse, ClientError> {
|
||||
let stream = UnixStream::connect(&self.socket_path).await?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
let mut line = serde_json::to_string(command)?;
|
||||
line.push('\n');
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
|
||||
let mut response = String::new();
|
||||
let read = reader.read_line(&mut response).await?;
|
||||
if read == 0 {
|
||||
return Err(ClientError::EmptyResponse);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str(response.trim_end())?)
|
||||
}
|
||||
|
||||
/// Send one command and deserialize its `data` payload to a typed value.
|
||||
pub async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
command: &HerdrCommand,
|
||||
) -> Result<T, ClientError> {
|
||||
let response = self.send(command).await?;
|
||||
if !response.ok {
|
||||
return Err(ClientError::Daemon(
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "unknown daemon error".to_string()),
|
||||
));
|
||||
}
|
||||
let data = response.data.ok_or(ClientError::MissingData)?;
|
||||
Ok(serde_json::from_value(data)?)
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::Status).await
|
||||
}
|
||||
|
||||
pub async fn glasspane_snapshot(&self) -> Result<GlasspaneSnapshot, ClientError> {
|
||||
self.request(&HerdrCommand::GlasspaneSnapshot).await
|
||||
}
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::ListSessions).await
|
||||
}
|
||||
|
||||
pub async fn spawn_agent(
|
||||
&self,
|
||||
provider: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
session_id: Option<String>,
|
||||
system_prompt: Option<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::SpawnAgent {
|
||||
provider: provider.into(),
|
||||
model: model.into(),
|
||||
session_id,
|
||||
system_prompt,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn kill_agent(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::KillAgent {
|
||||
agent_id: agent_id.into(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
&self,
|
||||
session_id: impl Into<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::GetSession {
|
||||
session_id: session_id.into(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compact_session(
|
||||
&self,
|
||||
session_id: impl Into<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::CompactSession {
|
||||
session_id: session_id.into(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use colibri_glasspane::{AgentState, GLASSPANE_SNAPSHOT_SCHEMA};
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
async fn one_shot_server(response: serde_json::Value) -> PathBuf {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("colibri-client-test-{}.sock", uuid::Uuid::new_v4()));
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let server_path = path.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
assert!(request.contains("\"cmd\""));
|
||||
writer
|
||||
.write_all(format!("{}\n", serde_json::to_string(&response).unwrap()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = tokio::fs::remove_file(server_path).await;
|
||||
});
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_reads_glasspane_snapshot() {
|
||||
let response = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"schema": GLASSPANE_SNAPSHOT_SCHEMA,
|
||||
"host": "test-host",
|
||||
"observed_at": "2026-05-27T12:00:00.000Z",
|
||||
"panes": [{
|
||||
"id": "pane-1",
|
||||
"agent": "pi",
|
||||
"state": "working",
|
||||
"pi_session_id": "pi-session-1",
|
||||
"last_event_at": "2026-05-27T11:59:59.000Z",
|
||||
"cwd": "/repo"
|
||||
}]
|
||||
}
|
||||
});
|
||||
let path = one_shot_server(response).await;
|
||||
let client = DaemonClient::new(path);
|
||||
let snapshot = client.glasspane_snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.schema, GLASSPANE_SNAPSHOT_SCHEMA);
|
||||
assert_eq!(snapshot.panes[0].id, "pane-1");
|
||||
assert_eq!(snapshot.panes[0].state, AgentState::Working);
|
||||
assert!(!snapshot.panes[0].stalled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_surfaces_daemon_error() {
|
||||
let response = serde_json::json!({"ok": false, "error": "boom"});
|
||||
let path = one_shot_server(response).await;
|
||||
let client = DaemonClient::new(path);
|
||||
let err = client.status().await.unwrap_err();
|
||||
assert!(matches!(err, ClientError::Daemon(message) if message == "boom"));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize};
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inbound command from Herdr over the Unix socket.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd")]
|
||||
pub enum HerdrCommand {
|
||||
#[serde(rename = "status")]
|
||||
|
|
@ -51,7 +51,7 @@ pub enum HerdrCommand {
|
|||
}
|
||||
|
||||
/// Outbound response to Herdr.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct HerdrResponse {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -523,6 +523,7 @@ Unix socket path (`DaemonConfig.socket_path`).
|
|||
| Document | Relationship |
|
||||
|-------------------------------------------------------|------------------------------------------------|
|
||||
| `docs/COLIBRI-GLASSPANE-DESIGN.md` | Glasspane capability design, phase plan, non-goals |
|
||||
| `crates/colibri-client/src/lib.rs` | Phase-4 typed Unix-socket client for display/UI consumers |
|
||||
| `docs/HERDR-VS-COLIBRI-GRAPH.md` | Hybrid boundary: Herdr as Linux display client |
|
||||
| `docs/CALLER-INVENTORY.md` | Caller-side inventory of Pi/agent invocations |
|
||||
| `crates/colibri-daemon/src/socket.rs` | Socket server implementation |
|
||||
|
|
|
|||
|
|
@ -106,8 +106,10 @@ control plane. Glasspane owns supervision only.
|
|||
a `portable-pty` launch seam. `run_pane_reader` now drains fake or PTY-backed
|
||||
JSONL readers into the same streaming ingestion path. Tests use fake JSONL
|
||||
readers first; live FreeBSD PTY validation is a later osa lane.
|
||||
4. **Client API** — socket / SSE / HTTP serving `GlasspaneSnapshot`; Herdr-Linux
|
||||
and Zed/web as display clients.
|
||||
4. 🟡 **Client API** — started. `colibri-client` provides a typed Unix-socket
|
||||
client for daemon commands, including `glasspane_snapshot()` returning a
|
||||
`GlasspaneSnapshot`. Herdr-Linux, Zed, and web boards can consume this API;
|
||||
HTTP/SSE remains a later transport.
|
||||
5. **Orchestrator** — route/dispatch work across panes (separate `colibri-orchestrator`).
|
||||
|
||||
## Non-goals
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue