use meerkat_contracts::wire::supervisor_bridge::{
BridgeLiveControlOutcome, BridgeLiveControlVerb, BridgeRejectionCause,
};
use meerkat_contracts::{
LiveCloseStatus, LiveOpenResult, LiveOpenTransport, RealtimeTurningMode, WireLiveAdapterStatus,
};
use meerkat_core::time_compat::Duration;
use meerkat_core::types::SessionId;
pub struct MemberLiveLifecycleLease {
#[cfg(not(feature = "live"))]
_uninhabited: std::convert::Infallible,
#[cfg(feature = "live")]
session_id: SessionId,
#[cfg(feature = "live")]
gate: std::sync::Arc<crate::tokio::sync::Mutex<()>>,
#[cfg(feature = "live")]
_guard: crate::tokio::sync::OwnedMutexGuard<()>,
}
#[cfg(feature = "live")]
impl MemberLiveLifecycleLease {
pub(crate) fn new(
session_id: SessionId,
gate: std::sync::Arc<crate::tokio::sync::Mutex<()>>,
guard: crate::tokio::sync::OwnedMutexGuard<()>,
) -> Self {
Self {
session_id,
gate,
_guard: guard,
}
}
pub(crate) fn session_id(&self) -> &SessionId {
&self.session_id
}
pub(crate) fn matches_gate(
&self,
gate: &std::sync::Arc<crate::tokio::sync::Mutex<()>>,
) -> bool {
std::sync::Arc::ptr_eq(&self.gate, gate)
}
}
pub const MEMBER_LIVE_OPEN_CEILING: Duration = Duration::from_secs(25);
pub const MEMBER_LIVE_DISPOSAL_CEILING: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MemberLiveError {
#[error("model {model} (provider {provider}) does not support realtime")]
ModelNotRealtime { model: String, provider: String },
#[error("provider {provider} has no live adapter wired on this host")]
AdapterUnavailable { provider: String },
#[error("member host has no live transport configured")]
TransportUnavailable,
#[error("member session already has an active live channel")]
ChannelAlreadyBound,
#[error("live channel not found")]
ChannelNotFound,
#[error("live transport {requested} is not supported by this host")]
TransportUnsupported { requested: String },
#[error("member live substrate unavailable: {reason}")]
Unavailable { reason: String },
#[error("member live internal fault: {reason}")]
Internal { reason: String },
}
impl MemberLiveError {
#[must_use]
pub fn to_bridge_rejection(&self) -> BridgeRejectionCause {
match self {
Self::ModelNotRealtime { model, provider } => BridgeRejectionCause::ModelNotRealtime {
model: model.clone(),
provider: provider.clone(),
},
Self::AdapterUnavailable { provider } => BridgeRejectionCause::LiveAdapterUnavailable {
provider: provider.clone(),
},
Self::TransportUnavailable => BridgeRejectionCause::LiveTransportUnavailable,
Self::ChannelAlreadyBound => BridgeRejectionCause::LiveChannelAlreadyBound,
Self::ChannelNotFound => BridgeRejectionCause::LiveChannelNotFound,
Self::TransportUnsupported { requested } => {
BridgeRejectionCause::LiveTransportUnsupported {
requested: requested.clone(),
}
}
Self::Unavailable { .. } => BridgeRejectionCause::Unavailable,
Self::Internal { .. } => BridgeRejectionCause::Internal,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemberLiveStatus {
pub channel_id: String,
pub status: WireLiveAdapterStatus,
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait MemberLiveHost: Send + Sync {
async fn open(
&self,
session: &SessionId,
turning_mode: Option<RealtimeTurningMode>,
transport: Option<LiveOpenTransport>,
) -> Result<LiveOpenResult, MemberLiveError>;
async fn close(
&self,
session: &SessionId,
channel_id: &str,
) -> Result<LiveCloseStatus, MemberLiveError>;
async fn status(
&self,
session: &SessionId,
channel_id: Option<String>,
) -> Result<MemberLiveStatus, MemberLiveError>;
async fn control(
&self,
session: &SessionId,
channel_id: &str,
verb: BridgeLiveControlVerb,
) -> Result<BridgeLiveControlOutcome, MemberLiveError>;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn every_variant_maps_to_its_landed_cause() {
let rows: Vec<(MemberLiveError, BridgeRejectionCause)> = vec![
(
MemberLiveError::ModelNotRealtime {
model: "gpt-5.4".to_string(),
provider: "openai".to_string(),
},
BridgeRejectionCause::ModelNotRealtime {
model: "gpt-5.4".to_string(),
provider: "openai".to_string(),
},
),
(
MemberLiveError::AdapterUnavailable {
provider: "anthropic".to_string(),
},
BridgeRejectionCause::LiveAdapterUnavailable {
provider: "anthropic".to_string(),
},
),
(
MemberLiveError::TransportUnavailable,
BridgeRejectionCause::LiveTransportUnavailable,
),
(
MemberLiveError::ChannelAlreadyBound,
BridgeRejectionCause::LiveChannelAlreadyBound,
),
(
MemberLiveError::ChannelNotFound,
BridgeRejectionCause::LiveChannelNotFound,
),
(
MemberLiveError::TransportUnsupported {
requested: "webrtc".to_string(),
},
BridgeRejectionCause::LiveTransportUnsupported {
requested: "webrtc".to_string(),
},
),
(
MemberLiveError::Unavailable {
reason: "session not resident".to_string(),
},
BridgeRejectionCause::Unavailable,
),
(
MemberLiveError::Internal {
reason: "invariant".to_string(),
},
BridgeRejectionCause::Internal,
),
];
for (error, expected) in rows {
assert_eq!(
error.to_bridge_rejection(),
expected,
"cause mapping drifted for {error:?}"
);
}
}
#[test]
fn display_carries_the_reason_material() {
assert_eq!(
MemberLiveError::ModelNotRealtime {
model: "m".to_string(),
provider: "p".to_string(),
}
.to_string(),
"model m (provider p) does not support realtime"
);
assert_eq!(
MemberLiveError::TransportUnavailable.to_string(),
"member host has no live transport configured"
);
assert_eq!(
MemberLiveError::Unavailable {
reason: "why".to_string()
}
.to_string(),
"member live substrate unavailable: why"
);
}
#[test]
fn open_ceiling_nests_inside_bridge_open_timeout() {
assert!(MEMBER_LIVE_OPEN_CEILING < Duration::from_secs(30));
}
}