use meerkat_core::types::{ContentInput, SessionId};
use meerkat_core::{
AppendSystemContextRequest, AppendSystemContextStatus, Session, SessionLlmIdentity,
SessionSystemContextState,
};
use std::collections::{BTreeMap, HashMap};
#[cfg(not(target_arch = "wasm32"))]
use tokio::sync::RwLock;
#[cfg(target_arch = "wasm32")]
use tokio_with_wasm::alias::sync::RwLock;
use crate::AgentBuildConfig;
pub enum StagedPhase {
Staged { build_config: Box<AgentBuildConfig> },
Closing { build_config: Box<AgentBuildConfig> },
Promoting {
starting_system_context_state: SessionSystemContextState,
current_system_context_state: SessionSystemContextState,
},
}
pub struct StagedSlot {
pub phase: StagedPhase,
pub effective_llm_identity: SessionLlmIdentity,
pub labels: Option<BTreeMap<String, String>>,
pub deferred_prompt: Option<ContentInput>,
pub created_at_secs: u64,
pub updated_at_secs: u64,
}
#[derive(Clone)]
pub struct StagedSessionInfo {
pub labels: BTreeMap<String, String>,
pub effective_llm_identity: SessionLlmIdentity,
pub created_at_secs: u64,
pub updated_at_secs: u64,
pub is_promoting: bool,
}
pub struct PromotingSlot {
pub build_config: Box<AgentBuildConfig>,
pub effective_llm_identity: SessionLlmIdentity,
pub labels: Option<BTreeMap<String, String>>,
pub deferred_prompt: Option<ContentInput>,
pub created_at_secs: u64,
pub updated_at_secs: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum StagedLifecycleError {
#[error("session already staged: {0}")]
AlreadyStaged(SessionId),
#[error("session already being promoted: {0}")]
AlreadyPromoting(SessionId),
#[error("staged session not found: {0}")]
NotFound(SessionId),
#[error("keep_alive requires a session created with comms_name")]
KeepAliveRequiresCommsName,
}
#[derive(Default)]
pub struct StagedSessionRegistry {
slots: RwLock<HashMap<SessionId, StagedSlot>>,
}
impl StagedSessionRegistry {
pub fn new() -> Self {
Self::default()
}
pub async fn len(&self) -> usize {
self.slots.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.slots.read().await.is_empty()
}
pub async fn contains(&self, id: &SessionId) -> bool {
self.slots.read().await.contains_key(id)
}
pub async fn info(&self, id: &SessionId) -> Option<StagedSessionInfo> {
let slots = self.slots.read().await;
slots.get(id).map(Self::slot_info)
}
pub async fn effective_llm_identity(&self, id: &SessionId) -> Option<SessionLlmIdentity> {
let slots = self.slots.read().await;
slots.get(id).map(|s| s.effective_llm_identity.clone())
}
pub async fn update_keep_alive(
&self,
id: &SessionId,
keep_alive: bool,
updated_at_secs: u64,
) -> Result<bool, StagedLifecycleError> {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get_mut(id) else {
return Ok(false);
};
match &mut slot.phase {
StagedPhase::Staged { build_config } => {
if keep_alive && build_config.comms_name.is_none() {
return Err(StagedLifecycleError::KeepAliveRequiresCommsName);
}
build_config.keep_alive = keep_alive;
slot.updated_at_secs = updated_at_secs;
Ok(true)
}
StagedPhase::Promoting { .. } | StagedPhase::Closing { .. } => {
Err(StagedLifecycleError::AlreadyPromoting(id.clone()))
}
}
}
pub async fn list(
&self,
label_filter: Option<&BTreeMap<String, String>>,
) -> Vec<(SessionId, StagedSessionInfo)> {
let slots = self.slots.read().await;
slots
.iter()
.filter(|(_, slot)| Self::matches_label_filter(slot.labels.as_ref(), label_filter))
.map(|(id, slot)| (id.clone(), Self::slot_info(slot)))
.collect()
}
pub async fn stage(&self, id: SessionId, slot: StagedSlot) -> Result<(), StagedLifecycleError> {
let mut slots = self.slots.write().await;
if slots.contains_key(&id) {
return Err(StagedLifecycleError::AlreadyStaged(id));
}
slots.insert(id, slot);
Ok(())
}
pub async fn begin_promotion(
&self,
id: &SessionId,
) -> Result<Option<PromotingSlot>, StagedLifecycleError> {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get_mut(id) else {
return Ok(None);
};
let starting_state = match &slot.phase {
StagedPhase::Staged { build_config } => build_config
.resume_session
.as_ref()
.and_then(Session::system_context_state)
.unwrap_or_default(),
StagedPhase::Promoting { .. } | StagedPhase::Closing { .. } => {
return Err(StagedLifecycleError::AlreadyPromoting(id.clone()));
}
};
let phase = std::mem::replace(
&mut slot.phase,
StagedPhase::Promoting {
starting_system_context_state: starting_state.clone(),
current_system_context_state: starting_state,
},
);
let StagedPhase::Staged { build_config } = phase else {
unreachable!("phase was checked before replacement");
};
Ok(Some(PromotingSlot {
build_config,
effective_llm_identity: slot.effective_llm_identity.clone(),
labels: slot.labels.clone(),
deferred_prompt: slot.deferred_prompt.clone(),
created_at_secs: slot.created_at_secs,
updated_at_secs: slot.updated_at_secs,
}))
}
pub async fn take_promoting_system_context_state(
&self,
id: &SessionId,
) -> Option<(SessionSystemContextState, SessionSystemContextState)> {
let mut slots = self.slots.write().await;
let slot = slots.remove(id)?;
match slot.phase {
StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
} => Some((starting_system_context_state, current_system_context_state)),
StagedPhase::Staged { build_config } => {
slots.insert(
id.clone(),
StagedSlot {
phase: StagedPhase::Staged { build_config },
effective_llm_identity: slot.effective_llm_identity,
labels: slot.labels,
deferred_prompt: slot.deferred_prompt,
created_at_secs: slot.created_at_secs,
updated_at_secs: slot.updated_at_secs,
},
);
None
}
StagedPhase::Closing { build_config } => {
slots.insert(
id.clone(),
StagedSlot {
phase: StagedPhase::Closing { build_config },
effective_llm_identity: slot.effective_llm_identity,
labels: slot.labels,
deferred_prompt: slot.deferred_prompt,
created_at_secs: slot.created_at_secs,
updated_at_secs: slot.updated_at_secs,
},
);
None
}
}
}
pub async fn promoting_system_context_state(
&self,
id: &SessionId,
) -> Option<(SessionSystemContextState, SessionSystemContextState)> {
let slots = self.slots.read().await;
let slot = slots.get(id)?;
match &slot.phase {
StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
} => Some((
starting_system_context_state.clone(),
current_system_context_state.clone(),
)),
StagedPhase::Staged { .. } | StagedPhase::Closing { .. } => None,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn abandon_promotion(
&self,
id: SessionId,
build_config: AgentBuildConfig,
effective_llm_identity: SessionLlmIdentity,
labels: Option<BTreeMap<String, String>>,
deferred_prompt: Option<ContentInput>,
created_at_secs: u64,
updated_at_secs: u64,
) -> bool {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get(&id) else {
return false;
};
if !matches!(slot.phase, StagedPhase::Promoting { .. }) {
return false;
}
let updated_at_secs = updated_at_secs.max(slot.updated_at_secs);
slots.insert(
id,
StagedSlot {
phase: StagedPhase::Staged {
build_config: Box::new(build_config),
},
effective_llm_identity,
labels,
deferred_prompt,
created_at_secs,
updated_at_secs,
},
);
true
}
pub async fn begin_archive(&self, id: &SessionId) -> Result<bool, StagedLifecycleError> {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get_mut(id) else {
return Ok(false);
};
let phase = std::mem::replace(
&mut slot.phase,
StagedPhase::Promoting {
starting_system_context_state: SessionSystemContextState::default(),
current_system_context_state: SessionSystemContextState::default(),
},
);
match phase {
StagedPhase::Staged { build_config } => {
slot.phase = StagedPhase::Closing { build_config };
Ok(true)
}
StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
} => {
slot.phase = StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
};
Err(StagedLifecycleError::AlreadyPromoting(id.clone()))
}
StagedPhase::Closing { build_config } => {
slot.phase = StagedPhase::Closing { build_config };
Err(StagedLifecycleError::AlreadyPromoting(id.clone()))
}
}
}
pub async fn restore_archive(&self, id: &SessionId) -> bool {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get_mut(id) else {
return false;
};
let phase = std::mem::replace(
&mut slot.phase,
StagedPhase::Promoting {
starting_system_context_state: SessionSystemContextState::default(),
current_system_context_state: SessionSystemContextState::default(),
},
);
match phase {
StagedPhase::Closing { build_config } => {
slot.phase = StagedPhase::Staged { build_config };
true
}
StagedPhase::Staged { build_config } => {
slot.phase = StagedPhase::Staged { build_config };
false
}
StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
} => {
slot.phase = StagedPhase::Promoting {
starting_system_context_state,
current_system_context_state,
};
false
}
}
}
pub async fn finish_archive(&self, id: &SessionId) -> bool {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get(id) else {
return false;
};
if !matches!(slot.phase, StagedPhase::Closing { .. }) {
return false;
}
slots.remove(id);
true
}
pub async fn abandon(&self, id: &SessionId) -> bool {
self.slots.write().await.remove(id).is_some()
}
pub async fn take_staged(
&self,
id: &SessionId,
) -> Result<Option<StagedSlot>, StagedLifecycleError> {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get(id) else {
return Ok(None);
};
match &slot.phase {
StagedPhase::Staged { .. } => Ok(slots.remove(id)),
StagedPhase::Promoting { .. } | StagedPhase::Closing { .. } => {
Err(StagedLifecycleError::AlreadyPromoting(id.clone()))
}
}
}
pub async fn restore_taken_staged(&self, id: SessionId, slot: StagedSlot) -> bool {
let mut slots = self.slots.write().await;
if slots.contains_key(&id) {
return false;
}
slots.insert(id, slot);
true
}
pub async fn abandon_staged(&self, id: &SessionId) -> Result<bool, StagedLifecycleError> {
let mut slots = self.slots.write().await;
let Some(slot) = slots.get(id) else {
return Ok(false);
};
match &slot.phase {
StagedPhase::Staged { .. } => {
slots.remove(id);
Ok(true)
}
StagedPhase::Promoting { .. } | StagedPhase::Closing { .. } => {
Err(StagedLifecycleError::AlreadyPromoting(id.clone()))
}
}
}
pub async fn clear(&self) {
self.slots.write().await.clear();
}
pub async fn append_system_context(
&self,
id: &SessionId,
req: &AppendSystemContextRequest,
now_system_time: meerkat_core::time_compat::SystemTime,
now_secs: u64,
) -> Option<Result<AppendSystemContextStatus, meerkat_core::SystemContextStageError>> {
let mut slots = self.slots.write().await;
let slot = slots.get_mut(id)?;
let result = match &mut slot.phase {
StagedPhase::Staged { build_config } => {
let session = build_config
.resume_session
.get_or_insert_with(|| Session::with_id(id.clone()));
let mut state = session.system_context_state().unwrap_or_default();
let stage_result = state.stage_append(req, now_system_time);
match stage_result {
Ok(status) => match session.set_system_context_state(state) {
Ok(()) => Ok(status),
Err(_) => {
Err(meerkat_core::SystemContextStageError::InvalidRequest(
"failed to serialize system-context state".to_string(),
))
}
},
Err(e) => Err(e),
}
}
StagedPhase::Promoting {
current_system_context_state,
..
} => current_system_context_state.stage_append(req, now_system_time),
StagedPhase::Closing { .. } => {
Err(meerkat_core::SystemContextStageError::InvalidRequest(
"session is being archived".to_string(),
))
}
};
if result.is_ok() {
slot.updated_at_secs = now_secs;
}
Some(result)
}
fn slot_info(slot: &StagedSlot) -> StagedSessionInfo {
StagedSessionInfo {
labels: slot.labels.clone().unwrap_or_default(),
effective_llm_identity: slot.effective_llm_identity.clone(),
created_at_secs: slot.created_at_secs,
updated_at_secs: slot.updated_at_secs,
is_promoting: matches!(
slot.phase,
StagedPhase::Promoting { .. } | StagedPhase::Closing { .. }
),
}
}
fn matches_label_filter(
slot_labels: Option<&BTreeMap<String, String>>,
filter: Option<&BTreeMap<String, String>>,
) -> bool {
let Some(filter) = filter else {
return true;
};
let Some(slot_labels) = slot_labels else {
return filter.is_empty();
};
filter.iter().all(|(k, v)| slot_labels.get(k) == Some(v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use meerkat_core::Provider;
fn identity(model: &str) -> SessionLlmIdentity {
SessionLlmIdentity {
model: model.to_string(),
provider: Provider::Other,
self_hosted_server_id: None,
provider_params: None,
auth_binding: None,
}
}
fn slot() -> StagedSlot {
StagedSlot {
phase: StagedPhase::Staged {
build_config: Box::new(AgentBuildConfig::new("test-model".to_string())),
},
effective_llm_identity: identity("test-model"),
labels: None,
deferred_prompt: None,
created_at_secs: 100,
updated_at_secs: 100,
}
}
#[tokio::test]
async fn stage_then_promote_round_trip() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
assert!(reg.contains(&id).await);
let promoted = reg
.begin_promotion(&id)
.await
.ok()
.flatten()
.expect("Staged slot should promote on first call");
assert_eq!(promoted.created_at_secs, 100);
let info = reg.info(&id).await.unwrap();
assert!(info.is_promoting);
let states = reg.take_promoting_system_context_state(&id).await;
assert!(states.is_some());
assert!(!reg.contains(&id).await);
}
#[tokio::test]
async fn stage_then_abandon_without_promote() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
assert!(reg.abandon(&id).await);
assert!(!reg.contains(&id).await);
}
#[tokio::test]
async fn begin_promotion_twice_rejects_second_caller() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
let first = reg.begin_promotion(&id).await;
assert!(matches!(first, Ok(Some(_))));
let second = reg.begin_promotion(&id).await;
match second {
Err(StagedLifecycleError::AlreadyPromoting(got)) => assert_eq!(got, id),
Err(other) => panic!("expected AlreadyPromoting, got {other:?}"),
Ok(_) => panic!("expected AlreadyPromoting, got Ok"),
}
}
#[tokio::test]
async fn begin_promotion_missing_returns_none() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
let result = reg.begin_promotion(&id).await;
assert!(matches!(result, Ok(None)));
}
#[tokio::test]
async fn abandon_promotion_restores_staged() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
let promoted = reg
.begin_promotion(&id)
.await
.ok()
.flatten()
.expect("initial promotion");
assert!(reg.info(&id).await.unwrap().is_promoting);
reg.abandon_promotion(
id.clone(),
*promoted.build_config,
identity("test-model"),
promoted.labels,
promoted.deferred_prompt,
promoted.created_at_secs,
promoted.updated_at_secs,
)
.await;
assert!(!reg.info(&id).await.unwrap().is_promoting);
let promoted_again = reg
.begin_promotion(&id)
.await
.ok()
.flatten()
.expect("re-promotion after abandon");
assert_eq!(promoted_again.created_at_secs, 100);
}
#[tokio::test]
async fn abandon_promotion_does_not_resurrect_finished_slot() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
let promoted = reg
.begin_promotion(&id)
.await
.ok()
.flatten()
.expect("initial promotion");
assert!(reg.take_promoting_system_context_state(&id).await.is_some());
let restored = reg
.abandon_promotion(
id.clone(),
*promoted.build_config,
identity("test-model"),
promoted.labels,
promoted.deferred_prompt,
promoted.created_at_secs,
promoted.updated_at_secs,
)
.await;
assert!(!restored, "finished promotion must not be restored");
assert!(!reg.contains(&id).await);
}
#[tokio::test]
async fn begin_archive_blocks_promotion_until_restored_or_finished() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
assert!(reg.begin_archive(&id).await.unwrap());
assert!(reg.info(&id).await.unwrap().is_promoting);
let promoting = reg.begin_promotion(&id).await;
assert!(matches!(
promoting,
Err(StagedLifecycleError::AlreadyPromoting(_))
));
assert!(reg.restore_archive(&id).await);
assert!(!reg.info(&id).await.unwrap().is_promoting);
let promoted = reg
.begin_promotion(&id)
.await
.ok()
.flatten()
.expect("promotion should succeed after archive restore");
assert!(
reg.abandon_promotion(
id.clone(),
*promoted.build_config,
identity("test-model"),
promoted.labels,
promoted.deferred_prompt,
promoted.created_at_secs,
promoted.updated_at_secs,
)
.await
);
assert!(reg.begin_archive(&id).await.unwrap());
assert!(reg.finish_archive(&id).await);
assert!(!reg.contains(&id).await);
}
#[tokio::test]
async fn stage_is_idempotent_guard() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
let result = reg.stage(id.clone(), slot()).await;
assert!(matches!(
result,
Err(StagedLifecycleError::AlreadyStaged(_))
));
}
#[tokio::test]
async fn append_system_context_mutates_staged_slot() {
let reg = StagedSessionRegistry::new();
let id = SessionId::new();
reg.stage(id.clone(), slot()).await.unwrap();
let req = AppendSystemContextRequest {
text: "hello".to_string(),
source: Some("test".to_string()),
idempotency_key: Some("k1".to_string()),
};
let now = meerkat_core::time_compat::SystemTime::now();
let res = reg.append_system_context(&id, &req, now, 200).await;
let outcome = res.expect("slot is present");
assert!(outcome.is_ok(), "staged append should succeed");
let info = reg.info(&id).await.unwrap();
assert_eq!(info.updated_at_secs, 200);
}
#[tokio::test]
async fn list_filters_by_labels() {
let reg = StagedSessionRegistry::new();
let id_a = SessionId::new();
let id_b = SessionId::new();
let mut slot_a = slot();
slot_a.labels = Some(BTreeMap::from([("env".to_string(), "prod".to_string())]));
reg.stage(id_a.clone(), slot_a).await.unwrap();
let mut slot_b = slot();
slot_b.labels = Some(BTreeMap::from([("env".to_string(), "dev".to_string())]));
reg.stage(id_b.clone(), slot_b).await.unwrap();
let filter = BTreeMap::from([("env".to_string(), "prod".to_string())]);
let result = reg.list(Some(&filter)).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, id_a);
}
}