feat(embed): Medulla sub-facade and the RPC surface to drive a session (#5238)

This commit is contained in:
Steven Enamakel
2026-07-28 11:18:51 +03:00
committed by GitHub
parent fe33726702
commit 7620945aa7
6 changed files with 563 additions and 6 deletions
+199
View File
@@ -0,0 +1,199 @@
//! Medulla sub-facade — typed access to the Medulla orchestration backend.
//!
//! Follows the shape [`super::config`] established: a borrowed newtype over the
//! runtime, and two-line methods delegating to [`call`](super::call::call).
//!
//! # Types are re-exported, not redefined
//!
//! [`MedullaStatus`], [`SessionSummary`] and [`RosterWorker`] come straight from
//! the domain rather than being mirrored here. A parallel set of facade structs
//! would be one more thing to keep in step with the wire contract, and the whole
//! point of the domain owning them is that there is a single definition.
//!
//! # Gating
//!
//! Compiled only with the `medulla` feature, like the domain it wraps. With the
//! feature off `Core::medulla()` does not exist, so a host that cannot use it
//! fails to compile against it rather than discovering an error at runtime.
use std::sync::Arc;
use super::call::call;
use super::error::CoreError;
use crate::core::runtime::CoreRuntime;
pub use crate::openhuman::medulla::client::{
AbortResult, EventEnvelope, Message, RosterWorker, SendResult, SessionCreated, SessionDetail,
SessionSummary,
};
pub use crate::openhuman::medulla::ops::MedullaStatus;
/// Typed access to the Medulla backend.
///
/// Obtained from [`Core::medulla`](super::Core::medulla); never constructed
/// directly.
pub struct Medulla<'a>(pub(super) &'a Arc<CoreRuntime>);
impl Medulla<'_> {
/// Whether the integration is configured and signed in.
///
/// Makes no network call and does not fail on an unconfigured host — the
/// result carries a `configured` flag and a stable reason instead. A host
/// polls this to decide whether to show the Medulla surface at all, so
/// "not set up" has to be a value it can render, not an error it must
/// special-case.
pub async fn status(&self) -> Result<MedullaStatus, CoreError> {
call(self.0, "openhuman.medulla_status", serde_json::json!({})).await
}
/// List the operator's durable sessions.
///
/// # Errors
///
/// [`CoreError::Domain`] with `kind = "MedullaNoBaseUrl"` or
/// `"MedullaNoSessionToken"` when the integration is not usable, both
/// flagged `expected_user_state` so a host renders a notice rather than a
/// failure. Backend rejections carry the backend's own `errorCode` as
/// `kind`, and HTTP 401/403 are likewise `expected_user_state`.
pub async fn list_sessions(&self) -> Result<Vec<SessionSummary>, CoreError> {
call(
self.0,
"openhuman.medulla_list_sessions",
serde_json::json!({}),
)
.await
}
/// Create a durable session.
///
/// `title` is optional — the backend names an untitled session itself
/// rather than the host inventing one.
pub async fn create_session(&self, title: Option<&str>) -> Result<SessionCreated, CoreError> {
call(
self.0,
"openhuman.medulla_create_session",
serde_json::json!({ "title": title }),
)
.await
}
/// Fetch one session's state.
pub async fn get_session(&self, session_id: &str) -> Result<SessionDetail, CoreError> {
call(
self.0,
"openhuman.medulla_get_session",
serde_json::json!({ "sessionId": session_id }),
)
.await
}
/// Send a message to a session.
///
/// `sync = false` returns as soon as the backend accepts the turn, leaving
/// the reply to arrive over the event stream; `true` blocks until it
/// replies. A UI wants the former so it can render progress, a scripted
/// caller usually wants the latter.
pub async fn send_message(
&self,
session_id: &str,
body: &str,
sync: bool,
) -> Result<SendResult, CoreError> {
call(
self.0,
"openhuman.medulla_send_message",
serde_json::json!({ "sessionId": session_id, "body": body, "sync": sync }),
)
.await
}
/// Abort a session's running cycle.
pub async fn abort(&self, session_id: &str) -> Result<AbortResult, CoreError> {
call(
self.0,
"openhuman.medulla_abort",
serde_json::json!({ "sessionId": session_id }),
)
.await
}
/// Replay a session's messages after `after`.
///
/// `after` is a cursor, not a page offset: passing the last seq already
/// seen returns only what is new, which is what makes a reconnect cheap.
pub async fn list_messages(
&self,
session_id: &str,
after: Option<i64>,
) -> Result<Vec<Message>, CoreError> {
call(
self.0,
"openhuman.medulla_list_messages",
serde_json::json!({ "sessionId": session_id, "after": after }),
)
.await
}
/// Replay a session's events after `after`.
///
/// Same cursor semantics as [`list_messages`](Self::list_messages).
pub async fn list_events(
&self,
session_id: &str,
after: Option<i64>,
) -> Result<Vec<EventEnvelope>, CoreError> {
call(
self.0,
"openhuman.medulla_list_events",
serde_json::json!({ "sessionId": session_id, "after": after }),
)
.await
}
/// Read the roster of workers currently connected to the backend.
///
/// # Errors
///
/// Same shape as [`list_sessions`](Self::list_sessions).
pub async fn roster(&self) -> Result<Vec<RosterWorker>, CoreError> {
call(self.0, "openhuman.medulla_roster", serde_json::json!({})).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::medulla::all_medulla_registered_controllers;
/// Every method this facade dispatches must name a registered controller.
///
/// The facade's method names are strings; a typo or a renamed controller
/// would otherwise surface as `CoreError::Unavailable` at runtime, which is
/// indistinguishable from a domain the host gated off on purpose. Pinning
/// them here turns that into a test failure.
#[test]
fn every_dispatched_method_is_registered() {
let registered: Vec<String> = all_medulla_registered_controllers()
.iter()
.map(|c| c.rpc_method_name())
.collect();
for method in [
"openhuman.medulla_status",
"openhuman.medulla_list_sessions",
"openhuman.medulla_create_session",
"openhuman.medulla_get_session",
"openhuman.medulla_send_message",
"openhuman.medulla_abort",
"openhuman.medulla_list_messages",
"openhuman.medulla_list_events",
"openhuman.medulla_roster",
] {
assert!(
registered.iter().any(|m| m == method),
"facade dispatches `{method}`, which no controller registers. \
Registered: {registered:?}"
);
}
}
}
+16
View File
@@ -53,9 +53,16 @@
mod call;
mod config;
mod error;
#[cfg(feature = "medulla")]
mod medulla;
pub use config::{Config, RuntimeFlags};
pub use error::CoreError;
#[cfg(feature = "medulla")]
pub use medulla::{
AbortResult, EventEnvelope, Medulla, MedullaStatus, Message, RosterWorker, SendResult,
SessionCreated, SessionDetail, SessionSummary,
};
use std::sync::Arc;
@@ -86,6 +93,15 @@ impl Core {
Config(&self.rt)
}
/// Typed access to the Medulla orchestration backend.
///
/// Absent unless the `medulla` feature is on, so a host built without it
/// fails to compile against this rather than meeting a runtime error.
#[cfg(feature = "medulla")]
pub fn medulla(&self) -> Medulla<'_> {
Medulla(&self.rt)
}
/// The underlying runtime, for anything this facade does not yet model.
///
/// An escape hatch, not the intended path — every use is a candidate for a
@@ -25,7 +25,7 @@ pub struct RosterBudget {
}
/// Connected worker returned by `GET /medulla/v1/roster`.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RosterWorker {
/// Stable worker identifier in the manager registry.
@@ -7,13 +7,17 @@
use serde::{Deserialize, Serialize};
/// Session lifecycle status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
Active,
Idle,
Archived,
/// Any status not yet modelled by this client.
///
/// Also the `Default`: a value constructed rather than decoded has not
/// declared a status, and defaulting to `Active` would assert one.
#[default]
#[serde(other)]
Other,
}
@@ -36,7 +40,7 @@ pub struct SessionCreated {
}
/// Item in the session list (`GET /medulla/v1/sessions`).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub session_id: String,
+112 -3
View File
@@ -5,16 +5,24 @@
//! failures become [`StructuredRpcError`]s so a host can branch on a stable
//! `data.kind` instead of matching on message text.
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::openhuman::config::Config;
use crate::rpc::{RpcOutcome, StructuredRpcError};
use super::client::{ClientError, MedullaClient, RosterWorker, SessionSummary};
use super::client::{
AbortResult, ClientError, EventEnvelope, MedullaClient, Message, RosterWorker, SendResult,
SessionCreated, SessionDetail, SessionSummary,
};
use super::resolve::{self, NotConfigured};
/// Whether the Medulla integration is usable, and why not when it isn't.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
///
/// `Deserialize` as well as `Serialize` because this type round-trips: ops
/// serializes it onto the RPC boundary and the embed facade deserializes it
/// back on the other side. An output-only derive compiles until the facade
/// tries to read it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MedullaStatus {
/// True when a base URL and a session token are both available.
@@ -67,6 +75,107 @@ pub async fn list_sessions(config: &Config) -> Result<RpcOutcome<Vec<SessionSumm
Ok(RpcOutcome::new(sessions, Vec::new()))
}
/// Create a durable session.
///
/// `title` is optional; the backend names an untitled session itself rather
/// than this host inventing one.
pub async fn create_session(
config: &Config,
title: Option<&str>,
) -> Result<RpcOutcome<SessionCreated>, String> {
let client = resolved(config)?;
let created = call(client.create_session(title).await, "medulla_create_session")?;
log::debug!("[medulla] create_session id={}", created.session_id);
Ok(RpcOutcome::new(created, Vec::new()))
}
/// Fetch one session's state.
pub async fn get_session(
config: &Config,
session_id: &str,
) -> Result<RpcOutcome<SessionDetail>, String> {
let client = resolved(config)?;
let detail = call(client.get_session(session_id).await, "medulla_get_session")?;
Ok(RpcOutcome::new(detail, Vec::new()))
}
/// Send a message to a session.
///
/// `sync = false` returns as soon as the backend accepts the turn; `true`
/// blocks until it replies. The caller chooses, because a TUI wants the former
/// (so it can render streaming progress) while a scripted client wants the
/// latter.
pub async fn send_message(
config: &Config,
session_id: &str,
body: &str,
sync: bool,
) -> Result<RpcOutcome<SendResult>, String> {
let client = resolved(config)?;
let result = call(
client.send_message(session_id, body, sync).await,
"medulla_send_message",
)?;
log::debug!(
"[medulla] send_message session={session_id} sync={sync} cycle={} seq={}",
result.cycle_id,
result.seq
);
Ok(RpcOutcome::new(result, Vec::new()))
}
/// Abort a session's running cycle.
pub async fn abort(config: &Config, session_id: &str) -> Result<RpcOutcome<AbortResult>, String> {
let client = resolved(config)?;
let result = call(client.abort(session_id).await, "medulla_abort")?;
log::debug!(
"[medulla] abort session={session_id} aborted={}",
result.aborted
);
Ok(RpcOutcome::new(result, Vec::new()))
}
/// Replay a session's messages after `after`.
///
/// `after` is a cursor, not a page offset: passing the last seq already seen
/// returns only what is new, which is what makes a reconnect cheap.
pub async fn list_messages(
config: &Config,
session_id: &str,
after: Option<i64>,
) -> Result<RpcOutcome<Vec<Message>>, String> {
let client = resolved(config)?;
let messages = call(
client.list_messages(session_id, after).await,
"medulla_list_messages",
)?;
log::debug!(
"[medulla] list_messages session={session_id} after={after:?} count={}",
messages.len()
);
Ok(RpcOutcome::new(messages, Vec::new()))
}
/// Replay a session's events after `after`.
///
/// Same cursor semantics as [`list_messages`].
pub async fn list_events(
config: &Config,
session_id: &str,
after: Option<i64>,
) -> Result<RpcOutcome<Vec<EventEnvelope>>, String> {
let client = resolved(config)?;
let events = call(
client.list_events(session_id, after).await,
"medulla_list_events",
)?;
log::debug!(
"[medulla] list_events session={session_id} after={after:?} count={}",
events.len()
);
Ok(RpcOutcome::new(events, Vec::new()))
}
/// Read the connected worker roster.
pub async fn roster(config: &Config) -> Result<RpcOutcome<Vec<RosterWorker>>, String> {
let client = resolved(config)?;
+229
View File
@@ -5,6 +5,8 @@
//! the single site in `src/core/all.rs`, so a host that switches the family off
//! sees these methods as unknown rather than as failing.
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
@@ -12,11 +14,50 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::ops;
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CreateSessionParams {
#[serde(default)]
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionIdParams {
session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendMessageParams {
session_id: String,
body: String,
/// Block until the backend replies. Defaults to false so a caller that
/// omits it gets the non-blocking behaviour a UI wants.
#[serde(default)]
sync: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReplayParams {
session_id: String,
/// Replay cursor: the last seq already seen. Absent replays from the start.
#[serde(default)]
after: Option<i64>,
}
/// Every schema in the namespace, for `/schema` introspection.
pub fn all_medulla_controller_schemas() -> Vec<ControllerSchema> {
vec![
medulla_schemas("medulla_status"),
medulla_schemas("medulla_list_sessions"),
medulla_schemas("medulla_create_session"),
medulla_schemas("medulla_get_session"),
medulla_schemas("medulla_send_message"),
medulla_schemas("medulla_abort"),
medulla_schemas("medulla_list_messages"),
medulla_schemas("medulla_list_events"),
medulla_schemas("medulla_roster"),
]
}
@@ -32,6 +73,30 @@ pub fn all_medulla_registered_controllers() -> Vec<RegisteredController> {
schema: medulla_schemas("medulla_list_sessions"),
handler: handle_list_sessions,
},
RegisteredController {
schema: medulla_schemas("medulla_create_session"),
handler: handle_create_session,
},
RegisteredController {
schema: medulla_schemas("medulla_get_session"),
handler: handle_get_session,
},
RegisteredController {
schema: medulla_schemas("medulla_send_message"),
handler: handle_send_message,
},
RegisteredController {
schema: medulla_schemas("medulla_abort"),
handler: handle_abort,
},
RegisteredController {
schema: medulla_schemas("medulla_list_messages"),
handler: handle_list_messages,
},
RegisteredController {
schema: medulla_schemas("medulla_list_events"),
handler: handle_list_events,
},
RegisteredController {
schema: medulla_schemas("medulla_roster"),
handler: handle_roster,
@@ -66,6 +131,97 @@ pub fn medulla_schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"medulla_create_session" => ControllerSchema {
namespace: "medulla",
function: "create_session",
description: "Create a durable Medulla session.",
inputs: vec![FieldSchema {
name: "title",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional title. Omitted lets the backend name the session.",
required: false,
}],
outputs: vec![FieldSchema {
name: "session",
ty: TypeSchema::Json,
comment: "The created session's identifier.",
required: true,
}],
},
"medulla_get_session" => ControllerSchema {
namespace: "medulla",
function: "get_session",
description: "Fetch one session's current state.",
inputs: vec![session_id_input()],
outputs: vec![FieldSchema {
name: "session",
ty: TypeSchema::Json,
comment: "Full session detail.",
required: true,
}],
},
"medulla_send_message" => ControllerSchema {
namespace: "medulla",
function: "send_message",
description: "Send a message to a session, optionally blocking until it replies.",
inputs: vec![
session_id_input(),
FieldSchema {
name: "body",
ty: TypeSchema::String,
comment: "Message text.",
required: true,
},
FieldSchema {
name: "sync",
ty: TypeSchema::Bool,
comment: "Block until the backend replies. Defaults to false.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Cycle id and sequence, plus the reply when sync was set.",
required: true,
}],
},
"medulla_abort" => ControllerSchema {
namespace: "medulla",
function: "abort",
description: "Abort a session's running cycle.",
inputs: vec![session_id_input()],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Whether a cycle was actually aborted.",
required: true,
}],
},
"medulla_list_messages" => ControllerSchema {
namespace: "medulla",
function: "list_messages",
description: "Replay a session's messages after a sequence cursor.",
inputs: vec![session_id_input(), after_input()],
outputs: vec![FieldSchema {
name: "messages",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Messages newer than the cursor.",
required: true,
}],
},
"medulla_list_events" => ControllerSchema {
namespace: "medulla",
function: "list_events",
description: "Replay a session's events after a sequence cursor.",
inputs: vec![session_id_input(), after_input()],
outputs: vec![FieldSchema {
name: "events",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Sequenced events newer than the cursor.",
required: true,
}],
},
"medulla_roster" => ControllerSchema {
namespace: "medulla",
function: "roster",
@@ -82,6 +238,26 @@ pub fn medulla_schemas(function: &str) -> ControllerSchema {
}
}
/// The session identifier every per-session method takes.
fn session_id_input() -> FieldSchema {
FieldSchema {
name: "sessionId",
ty: TypeSchema::String,
comment: "Target session identifier.",
required: true,
}
}
/// The replay cursor shared by the two list methods.
fn after_input() -> FieldSchema {
FieldSchema {
name: "after",
ty: TypeSchema::Option(Box::new(TypeSchema::I64)),
comment: "Last sequence already seen. Absent replays from the start.",
required: false,
}
}
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(ops::status(&load_config().await?).await?) })
}
@@ -90,6 +266,48 @@ fn handle_list_sessions(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(ops::list_sessions(&load_config().await?).await?) })
}
fn handle_create_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: CreateSessionParams = deserialize_params(params)?;
to_json(ops::create_session(&load_config().await?, p.title.as_deref()).await?)
})
}
fn handle_get_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SessionIdParams = deserialize_params(params)?;
to_json(ops::get_session(&load_config().await?, &p.session_id).await?)
})
}
fn handle_send_message(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SendMessageParams = deserialize_params(params)?;
to_json(ops::send_message(&load_config().await?, &p.session_id, &p.body, p.sync).await?)
})
}
fn handle_abort(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: SessionIdParams = deserialize_params(params)?;
to_json(ops::abort(&load_config().await?, &p.session_id).await?)
})
}
fn handle_list_messages(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: ReplayParams = deserialize_params(params)?;
to_json(ops::list_messages(&load_config().await?, &p.session_id, p.after).await?)
})
}
fn handle_list_events(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p: ReplayParams = deserialize_params(params)?;
to_json(ops::list_events(&load_config().await?, &p.session_id, p.after).await?)
})
}
fn handle_roster(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(ops::roster(&load_config().await?).await?) })
}
@@ -99,6 +317,11 @@ async fn load_config() -> Result<crate::openhuman::config::Config, String> {
crate::openhuman::config::ops::load_config_with_timeout().await
}
/// Decode a controller's params into its typed shape.
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
/// Serialize an outcome through the shared CLI-compatible envelope.
fn to_json<T: serde::Serialize>(outcome: crate::rpc::RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
@@ -142,6 +365,12 @@ mod tests {
vec![
"openhuman.medulla_status",
"openhuman.medulla_list_sessions",
"openhuman.medulla_create_session",
"openhuman.medulla_get_session",
"openhuman.medulla_send_message",
"openhuman.medulla_abort",
"openhuman.medulla_list_messages",
"openhuman.medulla_list_events",
"openhuman.medulla_roster",
]
);