use crate::memory_core::palace::RoomType;
use crate::memory_core::room_identity::{
DEFAULT_WING_ID, canonical_room_key, default_wing_key, mint_room_id, room_label, room_to_uuid,
room_type_from_parts, room_type_tag,
};
use crate::memory_core::store::kg::KnowledgeGraph;
use crate::memory_core::store::kg_redb::KgStoreRedb;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
pub const ROOM_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoomRecord {
pub label: String,
pub room_type: String,
pub wing_id: [u8; 16],
pub created_at_ms: i64,
pub resolved: bool,
pub description: Option<String>,
pub merged_from: Vec<[u8; 16]>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoomSchemaMarker {
pub schema_version: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoomSummary {
pub id: Uuid,
pub label: String,
pub room_type: RoomType,
pub wing_id: Uuid,
pub created_at_ms: i64,
pub resolved: bool,
pub description: Option<String>,
}
impl RoomRecord {
pub fn new(room: &RoomType, created_at_ms: i64, resolved: bool) -> Self {
Self {
label: room_label(room),
room_type: room_type_tag(room).to_string(),
wing_id: *DEFAULT_WING_ID.as_bytes(),
created_at_ms,
resolved,
description: None,
merged_from: Vec::new(),
}
}
pub fn room_type(&self) -> RoomType {
room_type_from_parts(&self.room_type, &self.label)
}
pub fn summarize(&self, id: Uuid) -> RoomSummary {
RoomSummary {
id,
label: self.label.clone(),
room_type: self.room_type(),
wing_id: Uuid::from_bytes(self.wing_id),
created_at_ms: self.created_at_ms,
resolved: self.resolved,
description: self.description.clone(),
}
}
}
pub fn resolve_room_filter_id(kg: &KnowledgeGraph, room: &RoomType) -> Uuid {
match kg.store().lookup_room_id(&default_wing_key(room)) {
Ok(Some(id)) => id,
Ok(None) => room_to_uuid(room),
Err(e) => {
tracing::warn!("room filter lookup failed, using legacy fold: {e:#}");
room_to_uuid(room)
}
}
}
pub async fn resolve_or_create_room(kg: &KnowledgeGraph, room: &RoomType) -> Uuid {
resolve_or_create_room_in_wing(kg, room, DEFAULT_WING_ID).await
}
pub async fn resolve_or_create_room_in_wing(
kg: &KnowledgeGraph,
room: &RoomType,
wing_id: Uuid,
) -> Uuid {
let store = kg.store();
let room = room.clone();
let fallback = if wing_id == DEFAULT_WING_ID {
room_to_uuid(&room)
} else {
mint_room_id(&canonical_room_key(wing_id, &room_label(&room)))
};
let joined = tokio::task::spawn_blocking(move || {
resolve_or_create_room_in_wing_sync(&store, &room, wing_id)
})
.await;
match joined {
Ok(Ok(id)) => id,
Ok(Err(e)) => {
tracing::warn!("room resolve failed, using legacy fold id: {e:#}");
fallback
}
Err(e) => {
tracing::warn!("room resolve join failed, using legacy fold id: {e:#}");
fallback
}
}
}
pub fn resolve_or_create_room_sync(store: &Arc<KgStoreRedb>, room: &RoomType) -> Result<Uuid> {
resolve_or_create_room_in_wing_sync(store, room, DEFAULT_WING_ID)
}
pub fn resolve_or_create_room_in_wing_sync(
store: &Arc<KgStoreRedb>,
room: &RoomType,
wing_id: Uuid,
) -> Result<Uuid> {
let key = canonical_room_key(wing_id, &room_label(room));
if let Some(id) = store.lookup_room_id(&key)? {
return Ok(id);
}
let id = mint_room_id(&key);
let mut record = RoomRecord::new(room, chrono::Utc::now().timestamp_millis(), true);
record.wing_id = *wing_id.as_bytes();
store
.insert_room_if_absent(id, &key, &record)
.with_context(|| format!("register room {:?}", record.label))?;
Ok(store.lookup_room_id(&key)?.unwrap_or(id))
}
pub fn list_room_summaries(store: &Arc<KgStoreRedb>) -> Result<Vec<RoomSummary>> {
Ok(store
.list_rooms()?
.into_iter()
.map(|(id, record)| record.summarize(id))
.collect())
}
pub fn create_room(
store: &Arc<KgStoreRedb>,
room: &RoomType,
description: Option<String>,
) -> Result<(RoomSummary, bool)> {
let key = default_wing_key(room);
if let Some(id) = store.lookup_room_id(&key)?
&& let Some(record) = store.get_room(id)?
{
return Ok((record.summarize(id), false));
}
let id = mint_room_id(&key);
let mut record = RoomRecord::new(room, chrono::Utc::now().timestamp_millis(), true);
record.description = description;
let inserted = store
.insert_room_if_absent(id, &key, &record)
.with_context(|| format!("create room {:?}", record.label))?;
let winner = store.lookup_room_id(&key)?.unwrap_or(id);
match store.get_room(winner)? {
Some(stored) => Ok((stored.summarize(winner), inserted && winner == id)),
None => Ok((record.summarize(winner), inserted)),
}
}
pub fn resolve_room_selector(store: &Arc<KgStoreRedb>, selector: &str) -> Result<Uuid> {
if let Ok(id) = Uuid::parse_str(selector.trim())
&& store.get_room(id)?.is_some()
{
return Ok(id);
}
store
.lookup_room_id(&canonical_room_key(DEFAULT_WING_ID, selector))?
.ok_or_else(|| anyhow::anyhow!("no room matches {selector:?} in this palace"))
}
pub fn rename_room(store: &Arc<KgStoreRedb>, id: Uuid, new_label: &str) -> Result<RoomSummary> {
let label = new_label.trim();
if label.is_empty() {
anyhow::bail!("room_rename: new_label must be non-empty");
}
let existing = store
.get_room(id)?
.ok_or_else(|| anyhow::anyhow!("no room row for {id}"))?;
let old_key = canonical_room_key(DEFAULT_WING_ID, &existing.label);
let new_key = canonical_room_key(DEFAULT_WING_ID, label);
let record = RoomRecord {
label: label.to_string(),
room_type: room_type_tag(&RoomType::parse(label)).to_string(),
resolved: true,
..existing
};
store
.rename_room(id, &old_key, &new_key, &record)
.with_context(|| format!("rename room {id} to {label:?}"))?;
Ok(record.summarize(id))
}
#[cfg(test)]
#[path = "rooms_surface_tests.rs"]
mod surface_tests;
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_core::store::kg_store::{decode_value, encode_value};
#[derive(Debug, Serialize, Deserialize)]
struct FutureRoomRecord {
label: String,
room_type: String,
wing_id: [u8; 16],
created_at_ms: i64,
resolved: bool,
description: Option<String>,
merged_from: Vec<[u8; 16]>,
owner: Option<String>,
}
fn sample() -> RoomRecord {
RoomRecord::new(
&RoomType::Custom("status".to_string()),
1_700_000_000_000,
true,
)
}
#[test]
fn room_record_round_trip() {
let r = sample();
let bytes = encode_value(&r).expect("encode");
let back: RoomRecord = decode_value(&bytes).expect("decode");
assert_eq!(r, back);
assert_eq!(back.label, "status");
assert_eq!(back.room_type, "Custom");
assert_eq!(back.room_type(), RoomType::Custom("status".to_string()));
assert_eq!(Uuid::from_bytes(back.wing_id), DEFAULT_WING_ID);
}
#[test]
fn room_record_decodes_under_a_future_field() {
let bytes = encode_value(&sample()).expect("encode");
assert!(
decode_value::<FutureRoomRecord>(&bytes).is_err(),
"postcard is positional: the naive future decode must fail"
);
let migrated: RoomRecord = decode_value(&bytes).expect("fallback to current shape");
let lifted = FutureRoomRecord {
label: migrated.label,
room_type: migrated.room_type,
wing_id: migrated.wing_id,
created_at_ms: migrated.created_at_ms,
resolved: migrated.resolved,
description: migrated.description,
merged_from: migrated.merged_from,
owner: None,
};
assert_eq!(lifted.label, "status");
assert!(lifted.owner.is_none());
}
#[test]
fn summary_projects_id_and_type() {
let id = Uuid::from_u128(7);
let s = sample().summarize(id);
assert_eq!(s.id, id);
assert_eq!(s.room_type, RoomType::Custom("status".to_string()));
assert_eq!(s.wing_id, DEFAULT_WING_ID);
assert!(s.resolved);
}
}