Skip to content

Commit eb3b2dc

Browse files
committed
feat(remote): phase α.5 — RemoteClient outbound implementation
Closes Phase α. RemoteClient is the crab-proto outbound side, symmetric to the RemoteServer from α.4.b. Same wire, same lifecycle, different direction: client calls connect(), runs initialize, then issues typed session.create / session.attach / session.sendInput / session.cancel round-trips and subscribes to server -> client session.event notifications via a broadcast channel. Module layout under crates/remote/src/client/: - mod.rs RemoteClient + dispatcher task. Cheap-to-clone (internally Arc<Inner>) so a TUI and a logger can both subscribe without fighting over the connection. Background task owns the WebSocket and routes: * outbound Request -> serialise + send + stash reply_tx in a pending-map keyed by MessageId * inbound Response -> pull reply_tx from map, fulfil oneshot so the waiting caller unblocks * inbound SESSION_EVENT notification -> broadcast::send close() signals the dispatcher to emit a WS close frame and exit; idempotent — second call returns AlreadyClosed. - config.rs ClientConfig { url, auth_token, client_info, request_timeout, event_buffer }. Durations serde as integer seconds so web / mobile clients reading the same config file get a predictable wire shape. - error.rs ClientError enum: InvalidUrl / InvalidAuthToken / Handshake / Transport / IncompatibleProtocol / ServerError / ConnectionClosed / Serde / AlreadyClosed. Wire-level and protocol-level failures split so callers can retry the former but not the latter. lib.rs re-exports RemoteClient / ClientConfig / ClientError at the crate root. Integration tests in tests/client_roundtrip.rs (4 cases, all green) exercise a real RemoteClient against a real RemoteServer: * handshake -> create -> backend event reaches client broadcast -> client send_input observed on backend inbound_rx -> cancel same * attach on a nonexistent session surfaces ServerError with the SessionNotFound code (-32003) * close is idempotent; second call returns AlreadyClosed * wrong JWT secret rejected at handshake (tungstenite bubbles the 401 as Error::Http -> ClientError::Handshake) Local totals for crab-remote: 33 unit + 4 server e2e + 4 client e2e = 41 green. Phase α closes here. crab-proto now has both a working server and a working client, validated against each other.
1 parent 06ecea1 commit eb3b2dc

5 files changed

Lines changed: 670 additions & 0 deletions

File tree

crates/remote/src/client/config.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Configuration for [`super::RemoteClient`].
2+
//!
3+
//! Split out so `cli` / `daemon` can load + validate config independently
4+
//! of actually opening a connection (e.g. `crab config check` can verify
5+
//! the shape without touching the network).
6+
7+
use serde::{Deserialize, Serialize};
8+
use std::time::Duration;
9+
10+
use crate::protocol::ClientInfo;
11+
12+
/// Where and how to connect to a crab-proto server.
13+
#[derive(Debug, Clone, Serialize, Deserialize)]
14+
#[serde(rename_all = "camelCase")]
15+
pub struct ClientConfig {
16+
/// `ws://host:port/` or `wss://host:port/`. Path is ignored — the
17+
/// server exposes the upgrade at `/` by convention.
18+
pub url: String,
19+
20+
/// JWT issued by the target server's `jwt_secret`. Sent in the
21+
/// `Authorization: Bearer <token>` header during the WebSocket
22+
/// handshake.
23+
pub auth_token: String,
24+
25+
/// How we identify ourselves in the `initialize` handshake. The
26+
/// server logs this and can surface it in the TUI ("connected:
27+
/// vscode-extension 1.2.3").
28+
pub client_info: ClientInfo,
29+
30+
/// Round-trip timeout for request/response calls. Long enough for a
31+
/// heavily-loaded server on a slow link; not so long that a dead
32+
/// connection hangs indefinitely.
33+
#[serde(with = "duration_secs")]
34+
pub request_timeout: Duration,
35+
36+
/// Capacity of the `subscribe_events` broadcast channel. Overflows
37+
/// are dropped on the slowest subscriber per tokio broadcast
38+
/// semantics; 256 gives comfortable headroom for TUI rendering
39+
/// while an agent streams tool-call output.
40+
pub event_buffer: usize,
41+
}
42+
43+
impl ClientConfig {
44+
/// Build a minimal config from endpoint + token. Sets `client_info`
45+
/// to `(crab, CARGO_PKG_VERSION)` and `request_timeout` to 30s.
46+
pub fn new(url: impl Into<String>, auth_token: impl Into<String>) -> Self {
47+
Self {
48+
url: url.into(),
49+
auth_token: auth_token.into(),
50+
client_info: ClientInfo {
51+
name: "crab".into(),
52+
version: env!("CARGO_PKG_VERSION").into(),
53+
},
54+
request_timeout: Duration::from_secs(30),
55+
event_buffer: 256,
56+
}
57+
}
58+
}
59+
60+
/// Serde helper mirroring the one in `server::config` — `Duration` on
61+
/// the wire is integer seconds.
62+
mod duration_secs {
63+
use serde::{Deserialize, Deserializer, Serializer};
64+
use std::time::Duration;
65+
66+
pub fn serialize<S: Serializer>(d: &Duration, ser: S) -> Result<S::Ok, S::Error> {
67+
ser.serialize_u64(d.as_secs())
68+
}
69+
70+
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
71+
let secs = u64::deserialize(de)?;
72+
Ok(Duration::from_secs(secs))
73+
}
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::*;
79+
80+
#[test]
81+
fn new_populates_client_info_and_defaults() {
82+
let c = ClientConfig::new("ws://127.0.0.1:4180/", "tok");
83+
assert_eq!(c.client_info.name, "crab");
84+
assert!(!c.client_info.version.is_empty());
85+
assert_eq!(c.request_timeout, Duration::from_secs(30));
86+
assert_eq!(c.event_buffer, 256);
87+
}
88+
89+
#[test]
90+
fn serde_roundtrip_uses_integer_seconds() {
91+
let c = ClientConfig::new("ws://x/", "t");
92+
let json = serde_json::to_string(&c).unwrap();
93+
assert!(json.contains("\"requestTimeout\":30"));
94+
let back: ClientConfig = serde_json::from_str(&json).unwrap();
95+
assert_eq!(back.request_timeout, c.request_timeout);
96+
}
97+
}

crates/remote/src/client/error.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//! Errors surfaced by [`super::RemoteClient`].
2+
//!
3+
//! Wire-level (envelope / JSON-RPC) failures are kept separate from
4+
//! protocol-level (server returned an error reply) failures so consumers
5+
//! can retry the former but not the latter.
6+
7+
use crate::protocol::JsonRpcError;
8+
9+
#[derive(Debug, thiserror::Error)]
10+
pub enum ClientError {
11+
#[error("invalid server URL: {0}")]
12+
InvalidUrl(String),
13+
14+
#[error("invalid auth token (not HTTP-header-safe): {0}")]
15+
InvalidAuthToken(String),
16+
17+
#[error("WebSocket handshake failed: {0}")]
18+
Handshake(#[source] tokio_tungstenite::tungstenite::Error),
19+
20+
#[error("WebSocket read/write error: {0}")]
21+
Transport(#[source] tokio_tungstenite::tungstenite::Error),
22+
23+
#[error("server speaks protocol {server}, client speaks {client}")]
24+
IncompatibleProtocol { server: String, client: String },
25+
26+
#[error("server replied with JSON-RPC error {}: {}", .0.code, .0.message)]
27+
ServerError(JsonRpcError),
28+
29+
#[error("connection closed before the response arrived (pending id {0})")]
30+
ConnectionClosed(u64),
31+
32+
#[error("failed to (de)serialise {what}: {source}")]
33+
Serde {
34+
what: &'static str,
35+
#[source]
36+
source: serde_json::Error,
37+
},
38+
39+
#[error("client is already closed")]
40+
AlreadyClosed,
41+
}

0 commit comments

Comments
 (0)