2026-04-27 21:22:55 +00:00
|
|
|
//! RPC method registry for the `/crdt-sync` WebSocket multiplexer.
|
|
|
|
|
//!
|
|
|
|
|
//! Incoming [`RpcFrame::RpcRequest`] frames are dispatched through this
|
|
|
|
|
//! registry. Each method handler is a plain function that accepts a
|
|
|
|
|
//! `serde_json::Value` parameter bag and returns a `serde_json::Value` result.
|
|
|
|
|
//!
|
|
|
|
|
//! # Registering handlers
|
|
|
|
|
//!
|
|
|
|
|
//! Add a new entry to the `HANDLERS` static slice:
|
|
|
|
|
//!
|
|
|
|
|
//! ```rust,ignore
|
|
|
|
|
//! ("my.method", handle_my_method as Handler),
|
|
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! # Unknown methods
|
|
|
|
|
//!
|
|
|
|
|
//! [`dispatch`] returns `Err("NOT_FOUND")` for any method not present in the
|
|
|
|
|
//! registry. The caller should translate this into an
|
|
|
|
|
//! [`RpcFrame::RpcResponse`] with `ok: false, code: "NOT_FOUND"`.
|
|
|
|
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
|
|
|
|
use super::wire::RpcFrame;
|
|
|
|
|
|
|
|
|
|
/// Signature for a synchronous RPC method handler.
|
|
|
|
|
pub(super) type Handler = fn(Value) -> Value;
|
|
|
|
|
|
|
|
|
|
/// Static registry mapping method names to handlers.
|
|
|
|
|
///
|
|
|
|
|
/// Add new handlers here. The registry is a plain slice — linear scan is
|
|
|
|
|
/// fine for the small number of methods expected.
|
2026-04-28 15:31:29 +00:00
|
|
|
static HANDLERS: &[(&str, Handler)] = &[
|
|
|
|
|
("health.check", handle_health_check),
|
|
|
|
|
("active_agents.list", handle_active_agents_list),
|
|
|
|
|
];
|
2026-04-27 21:22:55 +00:00
|
|
|
|
|
|
|
|
/// Handler for the `health.check` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `{"status": "ok"}` unconditionally. Used as a smoke test to
|
|
|
|
|
/// verify that the RPC multiplexer is wired up correctly.
|
|
|
|
|
fn handle_health_check(_params: Value) -> Value {
|
|
|
|
|
serde_json::json!({"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:31:29 +00:00
|
|
|
/// Handler for the `active_agents.list` method.
|
|
|
|
|
///
|
|
|
|
|
/// Reads the `active_agents` collection from the CRDT and returns an array
|
|
|
|
|
/// matching the shape formerly served by `GET /api/agents`. Each entry
|
|
|
|
|
/// contains `story_id`, `agent_name`, `status`, `session_id`, and
|
|
|
|
|
/// `worktree_path`.
|
|
|
|
|
fn handle_active_agents_list(_params: Value) -> Value {
|
|
|
|
|
let entries = crate::crdt_state::read_all_active_agents().unwrap_or_default();
|
|
|
|
|
let list: Vec<Value> = entries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|view| {
|
|
|
|
|
// agent_id is the composite key "story_id:agent_name".
|
|
|
|
|
let (story_id, agent_name) = view
|
|
|
|
|
.agent_id
|
|
|
|
|
.rsplit_once(':')
|
|
|
|
|
.map(|(s, a)| (s.to_string(), a.to_string()))
|
|
|
|
|
.unwrap_or_else(|| (view.story_id.unwrap_or_default(), view.agent_id.clone()));
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"story_id": story_id,
|
|
|
|
|
"agent_name": agent_name,
|
|
|
|
|
"status": "running",
|
|
|
|
|
"session_id": null,
|
|
|
|
|
"worktree_path": null,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
Value::Array(list)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:22:55 +00:00
|
|
|
/// Dispatch an incoming RPC method call to the registered handler.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `Ok(result)` on success or `Err("NOT_FOUND")` if no handler is
|
|
|
|
|
/// registered for `method`.
|
|
|
|
|
pub(super) fn dispatch(method: &str, params: Value) -> Result<Value, &'static str> {
|
|
|
|
|
for (name, handler) in HANDLERS {
|
|
|
|
|
if *name == method {
|
|
|
|
|
return Ok(handler(params));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err("NOT_FOUND")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Try to parse `text` as an [`RpcFrame::RpcRequest`] and, if successful,
|
|
|
|
|
/// dispatch it and return the corresponding [`RpcFrame::RpcResponse`].
|
|
|
|
|
///
|
|
|
|
|
/// Returns `None` if the text is not a valid `rpc_request` frame (i.e. it
|
|
|
|
|
/// should be forwarded to the CRDT sync handler instead).
|
2026-04-28 15:31:29 +00:00
|
|
|
pub(crate) fn try_handle_rpc_text(text: &str) -> Option<RpcFrame> {
|
2026-04-27 21:22:55 +00:00
|
|
|
let frame: RpcFrame = serde_json::from_str(text).ok()?;
|
|
|
|
|
match frame {
|
|
|
|
|
RpcFrame::RpcRequest {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
method,
|
|
|
|
|
params,
|
|
|
|
|
..
|
|
|
|
|
} => {
|
|
|
|
|
let response = match dispatch(&method, params) {
|
|
|
|
|
Ok(result) => RpcFrame::RpcResponse {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
ok: true,
|
|
|
|
|
result: Some(result),
|
|
|
|
|
error: None,
|
|
|
|
|
code: None,
|
|
|
|
|
},
|
|
|
|
|
Err(code) => RpcFrame::RpcResponse {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
ok: false,
|
|
|
|
|
result: None,
|
|
|
|
|
error: Some(format!("unknown method: {method}")),
|
|
|
|
|
code: Some(code.to_string()),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
Some(response)
|
|
|
|
|
}
|
|
|
|
|
// Response frames arriving on our socket are not requests — nothing to send back.
|
|
|
|
|
RpcFrame::RpcResponse { .. } => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn health_check_returns_ok_status() {
|
|
|
|
|
let result = dispatch("health.check", serde_json::json!({}));
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert_eq!(val["status"], "ok");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_method_returns_not_found() {
|
|
|
|
|
let result = dispatch("nonexistent.method", serde_json::json!({}));
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert_eq!(result.unwrap_err(), "NOT_FOUND");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn try_handle_rpc_text_health_check() {
|
|
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "test-corr-1",
|
|
|
|
|
"ttl_ms": 5000,
|
|
|
|
|
"method": "health.check",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
|
|
|
|
let resp = try_handle_rpc_text(&text).expect("must produce a response");
|
|
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse {
|
|
|
|
|
ok,
|
|
|
|
|
correlation_id,
|
|
|
|
|
result,
|
|
|
|
|
code,
|
|
|
|
|
..
|
|
|
|
|
} => {
|
|
|
|
|
assert!(ok, "health.check must succeed");
|
|
|
|
|
assert_eq!(correlation_id, "test-corr-1");
|
|
|
|
|
assert!(result.is_some());
|
|
|
|
|
assert_eq!(result.unwrap()["status"], "ok");
|
|
|
|
|
assert!(code.is_none());
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn try_handle_rpc_text_unknown_method_returns_not_found() {
|
|
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "test-corr-2",
|
|
|
|
|
"ttl_ms": 1000,
|
|
|
|
|
"method": "no.such.method",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
|
|
|
|
let resp = try_handle_rpc_text(&text).expect("must produce a response for unknown method");
|
|
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse { ok, code, .. } => {
|
|
|
|
|
assert!(!ok, "unknown method must not succeed");
|
|
|
|
|
assert_eq!(code.unwrap(), "NOT_FOUND");
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn try_handle_rpc_text_ignores_non_rpc_frames() {
|
|
|
|
|
// A SyncMessage::Bulk frame must not be treated as an RPC request.
|
|
|
|
|
let bulk = r#"{"type":"bulk","ops":[]}"#;
|
|
|
|
|
assert!(try_handle_rpc_text(bulk).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn try_handle_rpc_text_ignores_rpc_response_frames() {
|
|
|
|
|
// An incoming rpc_response (e.g. reply to our own outbound request) must
|
|
|
|
|
// not trigger a further response.
|
|
|
|
|
let resp = serde_json::json!({
|
|
|
|
|
"kind": "rpc_response",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "x",
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": {"status": "ok"}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&resp).unwrap();
|
|
|
|
|
assert!(try_handle_rpc_text(&text).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn try_handle_rpc_text_ignores_invalid_json() {
|
|
|
|
|
assert!(try_handle_rpc_text("not json at all").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rpc_response_correlation_id_mirrors_request() {
|
|
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "mirror-me",
|
|
|
|
|
"ttl_ms": 500,
|
|
|
|
|
"method": "health.check",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
|
|
|
|
let resp = try_handle_rpc_text(&text).unwrap();
|
|
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse { correlation_id, .. } => {
|
|
|
|
|
assert_eq!(correlation_id, "mirror-me");
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|