use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
use crate::Session;
use crate::session::{SESSION_METADATA_SCHEMA_VERSION, SESSION_VERSION};
#[derive(Debug)]
pub struct PartialSessionMigration {
pub session: Session,
pub legacy: BTreeMap<String, Value>,
}
#[derive(Debug)]
pub enum SessionMigrationError {
Malformed(String),
Deserialize(serde_json::Error),
Partial(Box<PartialSessionMigration>),
}
impl std::fmt::Display for SessionMigrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed(reason) => write!(f, "malformed persisted session: {reason}"),
Self::Deserialize(err) => {
write!(
f,
"persisted session deserialize failed after migration: {err}"
)
}
Self::Partial(inner) => write!(
f,
"persisted session migrated with salvaged legacy payload ({} keys retained)",
inner.legacy.len()
),
}
}
}
impl std::error::Error for SessionMigrationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Deserialize(err) => Some(err),
_ => None,
}
}
}
pub fn deserialize_session_migrating(bytes: &[u8]) -> Result<Session, SessionMigrationError> {
let value: Value = serde_json::from_slice(bytes).map_err(SessionMigrationError::Deserialize)?;
match migrate_session_value(value) {
Ok(session) => Ok(session),
Err(SessionMigrationError::Partial(partial)) => {
tracing::warn!(
legacy_keys = ?partial.legacy.keys().collect::<Vec<_>>(),
"session migration salvaged a legacy payload during load"
);
Ok(partial.session)
}
Err(other) => Err(other),
}
}
pub fn migrate_session_value(mut value: Value) -> Result<Session, SessionMigrationError> {
let Some(root) = value.as_object_mut() else {
return Err(SessionMigrationError::Malformed(
"top-level session blob is not a JSON object".to_string(),
));
};
let mut legacy: BTreeMap<String, Value> = BTreeMap::new();
if let Some(sess_meta) = root
.get_mut("metadata")
.and_then(Value::as_object_mut)
.and_then(|map| map.get_mut("session_metadata"))
.and_then(Value::as_object_mut)
{
migrate_metadata_object(sess_meta, &mut legacy);
}
if let Some(sess_meta) = root
.get_mut("session_metadata")
.and_then(Value::as_object_mut)
{
migrate_metadata_object(sess_meta, &mut legacy);
}
if let Some(ident) = root
.get_mut("session_llm_identity")
.and_then(Value::as_object_mut)
{
migrate_identity_object(ident, &mut legacy);
}
root.entry("version").or_insert_with(|| json!(1));
root.insert("version".to_string(), json!(SESSION_VERSION));
match serde_json::from_value::<Session>(value) {
Ok(session) if legacy.is_empty() => Ok(session),
Ok(session) => Err(SessionMigrationError::Partial(Box::new(
PartialSessionMigration { session, legacy },
))),
Err(err) => Err(SessionMigrationError::Deserialize(err)),
}
}
pub fn migrate_input_state_value(mut value: Value) -> Result<Value, SessionMigrationError> {
let Some(root) = value.as_object_mut() else {
return Err(SessionMigrationError::Malformed(
"input state blob is not a JSON object".to_string(),
));
};
if let Some(persisted) = root
.get_mut("persisted_input")
.and_then(Value::as_object_mut)
{
for (_variant, body) in persisted.iter_mut() {
let Some(body_obj) = body.as_object_mut() else {
continue;
};
if let Some(tm) = body_obj
.get_mut("turn_metadata")
.and_then(Value::as_object_mut)
{
migrate_turn_metadata_object(tm);
}
}
}
root.entry("stored_input_state_version")
.or_insert_with(|| json!(1));
root.insert(
"stored_input_state_version".to_string(),
json!(STORED_INPUT_STATE_VERSION),
);
Ok(value)
}
pub const STORED_INPUT_STATE_VERSION: u32 = 2;
fn migrate_metadata_object(meta: &mut Map<String, Value>, legacy: &mut BTreeMap<String, Value>) {
migrate_auth_binding_field(meta, legacy, "legacy_auth_binding_session_metadata");
meta.entry("schema_version").or_insert_with(|| json!(1));
meta.insert(
"schema_version".to_string(),
json!(SESSION_METADATA_SCHEMA_VERSION),
);
}
fn migrate_identity_object(ident: &mut Map<String, Value>, legacy: &mut BTreeMap<String, Value>) {
migrate_auth_binding_field(ident, legacy, "legacy_auth_binding_session_llm_identity");
}
fn migrate_turn_metadata_object(tm: &mut Map<String, Value>) {
if let Some(old) = tm.remove("connection_ref")
&& !tm.contains_key("auth_binding")
{
tm.insert("auth_binding".to_string(), old);
}
if let Some(cref) = tm.get_mut("auth_binding") {
let mut throwaway = BTreeMap::new();
rewrite_auth_binding(cref, &mut throwaway, "__discard__");
}
}
fn migrate_auth_binding_field(
map: &mut Map<String, Value>,
legacy: &mut BTreeMap<String, Value>,
legacy_key: &str,
) {
if let Some(old) = map.remove("connection_ref") {
if map.contains_key("auth_binding") {
legacy.insert(format!("{legacy_key}_compat_alias"), old);
} else {
map.insert("auth_binding".to_string(), old);
}
}
if let Some(cref) = map.get_mut("auth_binding") {
rewrite_auth_binding(cref, legacy, legacy_key);
}
}
fn rewrite_auth_binding(cref: &mut Value, legacy: &mut BTreeMap<String, Value>, legacy_key: &str) {
let Some(obj) = cref.as_object_mut() else {
return;
};
let has_legacy_keys = obj.contains_key("realm_id") || obj.contains_key("binding_id");
let realm_raw = obj.remove("realm_id").or_else(|| obj.remove("realm"));
let binding_raw = obj.remove("binding_id").or_else(|| obj.remove("binding"));
let mut preserved = serde_json::Map::new();
let mut slugified = false;
if let Some(raw) = realm_raw {
if let Some(s) = raw.as_str() {
let (coerced, was_slugified) = slugify_if_needed(s);
if was_slugified {
preserved.insert("realm_id".to_string(), Value::String(s.to_string()));
slugified = true;
}
obj.insert("realm".to_string(), Value::String(coerced));
} else {
obj.insert("realm".to_string(), raw);
}
}
if let Some(raw) = binding_raw {
if let Some(s) = raw.as_str() {
let (coerced, was_slugified) = slugify_if_needed(s);
if was_slugified {
preserved.insert("binding_id".to_string(), Value::String(s.to_string()));
slugified = true;
}
obj.insert("binding".to_string(), Value::String(coerced));
} else {
obj.insert("binding".to_string(), raw);
}
}
if let Some(profile_raw) = obj.remove("profile") {
if let Some(s) = profile_raw.as_str() {
let (coerced, was_slugified) = slugify_if_needed(s);
if was_slugified {
preserved.insert("profile".to_string(), Value::String(s.to_string()));
slugified = true;
}
obj.insert("profile".to_string(), Value::String(coerced));
} else {
obj.insert("profile".to_string(), profile_raw);
}
}
if slugified && legacy_key != "__discard__" {
legacy.insert(legacy_key.to_string(), Value::Object(preserved));
} else if has_legacy_keys && legacy_key != "__discard__" {
}
}
fn slugify_if_needed(raw: &str) -> (String, bool) {
if !raw.is_empty()
&& raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return (raw.to_string(), false);
}
let lower = raw.to_ascii_lowercase();
let coerced: String = lower
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
(coerced, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_passes_clean_slugs() {
assert_eq!(slugify_if_needed("dev"), ("dev".to_string(), false));
assert_eq!(
slugify_if_needed("default_openai"),
("default_openai".to_string(), false)
);
assert_eq!(
slugify_if_needed("realm-1.2"),
("realm-1.2".to_string(), false)
);
}
#[test]
fn slugify_coerces_invalid_chars() {
assert_eq!(
slugify_if_needed("dev mode"),
("dev_mode".to_string(), true)
);
assert_eq!(
slugify_if_needed("Prod/Thing"),
("prod_thing".to_string(), true)
);
}
}