use std::cmp::Ordering;
use anyhow::{Context, Result};
use serde_json::Value;
pub const SETTINGS_SCHEMA: u32 = 5;
pub const PROFILES_SCHEMA: u32 = 1;
pub const CHAT_SCHEMA: u32 = 5;
pub const DB_SCHEMA: u32 = 1;
pub struct Step {
pub to: u32,
pub summary: &'static str,
pub apply: fn(Value) -> Result<Value>,
}
pub struct JsonArtifact {
pub name: &'static str,
pub current: u32,
pub detect: fn(&Value) -> u32,
pub steps: &'static [Step],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assessment {
UpToDate,
Migrate { from: u32 },
Downgrade { from: u32 },
}
impl JsonArtifact {
pub fn assess(&self, v: &Value) -> Assessment {
let from = (self.detect)(v);
match from.cmp(&self.current) {
Ordering::Equal => Assessment::UpToDate,
Ordering::Less => Assessment::Migrate { from },
Ordering::Greater => Assessment::Downgrade { from },
}
}
pub fn apply_steps(&self, mut v: Value, from: u32) -> Result<Value> {
for step in self.steps.iter().filter(|s| s.to > from) {
tracing::info!(
artifact = self.name,
to = step.to,
"migration step: {}",
step.summary
);
v = (step.apply)(v)
.with_context(|| format!("migration step {} → v{}", self.name, step.to))?;
}
Ok(v)
}
}
fn detect_settings(v: &Value) -> u32 {
v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}
fn detect_profiles(v: &Value) -> u32 {
if v.is_array() {
1
} else {
v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}
}
fn detect_chat(v: &Value) -> u32 {
v.get("v").and_then(Value::as_u64).unwrap_or(1) as u32
}
const V1_SUBAGENT_TIMEOUT_SECS: u64 = 60;
const V1_SUBAGENT_MAX_TOKENS: u64 = 1024;
fn settings_to_v2(mut v: Value) -> Result<Value> {
if let Some(tools) = v.get_mut("tools").and_then(Value::as_object_mut) {
if let Some(old) = tools.remove("subagent_timeout_secs")
&& old.as_u64() != Some(V1_SUBAGENT_TIMEOUT_SECS)
{
tools.insert("subagent_run_timeout_secs".into(), old);
}
if tools.get("subagent_max_tokens").and_then(Value::as_u64) == Some(V1_SUBAGENT_MAX_TOKENS)
{
tools.remove("subagent_max_tokens");
}
}
v["schema_version"] = Value::from(2);
Ok(v)
}
const V2_MAX_TOKENS: u64 = 2048;
const V3_MAX_TOKENS: u64 = 16384;
fn settings_to_v3(mut v: Value) -> Result<Value> {
if let Some(sampling) = v.get_mut("default_sampling").and_then(Value::as_object_mut)
&& sampling.get("max_tokens").and_then(Value::as_u64) == Some(V2_MAX_TOKENS)
{
sampling.insert("max_tokens".into(), Value::from(V3_MAX_TOKENS));
}
v["schema_version"] = Value::from(3);
Ok(v)
}
fn settings_to_v4(mut v: Value) -> Result<Value> {
v["schema_version"] = Value::from(4);
Ok(v)
}
fn settings_to_v5(mut v: Value) -> Result<Value> {
v["schema_version"] = Value::from(5);
Ok(v)
}
const SETTINGS_STEPS: &[Step] = &[
Step {
to: 2,
summary: "the sub-agent's one-request timeout becomes a whole-run limit",
apply: settings_to_v2,
},
Step {
to: 3,
summary: "the default reply budget rises to cover a model's reasoning",
apply: settings_to_v3,
},
Step {
to: 4,
summary: "a slot's mode may name the OpenRouter gateway",
apply: settings_to_v4,
},
Step {
to: 5,
summary: "a reasoning effort may read max",
apply: settings_to_v5,
},
];
pub fn settings_artifact() -> JsonArtifact {
JsonArtifact {
name: "settings.json",
current: SETTINGS_SCHEMA,
detect: detect_settings,
steps: SETTINGS_STEPS,
}
}
pub fn profiles_artifact() -> JsonArtifact {
JsonArtifact {
name: "profiles.json",
current: PROFILES_SCHEMA,
detect: detect_profiles,
steps: &[],
}
}
const CHAT_STEPS: &[Step] = &[
Step {
to: 2,
summary: "a transcript is synthesized for every old call_subagent record",
apply: super::chat_steps::chat_to_v2,
},
Step {
to: 3,
summary: "chat files may carry dialogue runs (RunKind::Dialogue)",
apply: super::chat_steps::chat_to_v3,
},
Step {
to: 4,
summary: "a run title the old 100-character cap cut gets its tail back",
apply: super::chat_steps::chat_to_v4,
},
Step {
to: 5,
summary: "a reasoning effort may read max",
apply: super::chat_steps::chat_to_v5,
},
];
pub fn chat_artifact() -> JsonArtifact {
JsonArtifact {
name: "chats/<id>.json",
current: CHAT_SCHEMA,
detect: detect_chat,
steps: CHAT_STEPS,
}
}
#[cfg(test)]
pub(super) fn dropped_paths(fixture: &Value, read_back: &Value) -> Vec<String> {
fn walk(at: &str, fixture: &Value, read_back: &Value, out: &mut Vec<String>) {
match (fixture, read_back) {
(Value::Object(want), Value::Object(got)) => {
for (key, value) in want {
let path = format!("{at}/{key}");
match got.get(key) {
Some(kept) => walk(&path, value, kept, out),
None => out.push(path),
}
}
}
(Value::Array(want), Value::Array(got)) if want.len() == got.len() => {
for (i, (value, kept)) in want.iter().zip(got).enumerate() {
walk(&format!("{at}/{i}"), value, kept, out);
}
}
(want, got) if want == got => {}
_ => out.push(at.to_string()),
}
}
let mut out = Vec::new();
walk("", fixture, read_back, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::sampling::ReasoningEffort;
use serde_json::json;
#[test]
fn schema_constants_match_config_default() {
assert_eq!(SETTINGS_SCHEMA, crate::shared::config::SCHEMA_VERSION);
}
#[test]
fn detect_uses_field_or_defaults_to_one() {
assert_eq!(detect_settings(&json!({"schema_version": 3})), 3);
assert_eq!(detect_settings(&json!({})), 1);
assert_eq!(detect_profiles(&json!([])), 1); assert_eq!(detect_profiles(&json!({"schema_version": 2})), 2);
assert_eq!(detect_chat(&json!({"v": 5})), 5);
assert_eq!(detect_chat(&json!({"title": "x"})), 1);
}
fn to_v2(mut v: Value) -> Result<Value> {
v["schema_version"] = json!(2);
Ok(v)
}
const SYNTH_STEPS: &[Step] = &[Step {
to: 2,
summary: "test bump",
apply: to_v2,
}];
fn synth() -> JsonArtifact {
JsonArtifact {
name: "synthetic",
current: 2,
detect: detect_settings,
steps: SYNTH_STEPS,
}
}
#[test]
fn assess_classifies_up_to_date_migrate_downgrade() {
let a = synth();
assert_eq!(
a.assess(&json!({"schema_version": 2})),
Assessment::UpToDate
);
assert_eq!(
a.assess(&json!({"schema_version": 1})),
Assessment::Migrate { from: 1 }
);
assert_eq!(a.assess(&json!({})), Assessment::Migrate { from: 1 });
assert_eq!(
a.assess(&json!({"schema_version": 3})),
Assessment::Downgrade { from: 3 }
);
}
#[test]
fn apply_steps_runs_only_needed_steps() {
let a = synth();
let out = a
.apply_steps(json!({"schema_version": 1, "x": 7}), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(2));
assert_eq!(out["x"], json!(7), "other fields are preserved");
let noop = a.apply_steps(json!({"schema_version": 2}), 2).unwrap();
assert_eq!(noop["schema_version"], json!(2));
}
#[test]
fn apply_step_error_propagates() {
fn boom(_v: Value) -> Result<Value> {
anyhow::bail!("broken")
}
const STEPS: &[Step] = &[Step {
to: 2,
summary: "boom",
apply: boom,
}];
let a = JsonArtifact {
name: "x",
current: 2,
detect: detect_settings,
steps: STEPS,
};
assert!(a.apply_steps(json!({}), 1).is_err());
}
#[test]
fn real_registry_versions_and_steps() {
let settings = settings_artifact();
assert_eq!(settings.current, 5);
let reached: Vec<u32> = settings.steps.iter().map(|s| s.to).collect();
assert_eq!(reached, [2, 3, 4, 5]);
let chats = chat_artifact();
assert_eq!(chats.current, 5);
let reached: Vec<u32> = chats.steps.iter().map(|s| s.to).collect();
assert_eq!(reached, [2, 3, 4, 5]);
let profiles = profiles_artifact();
assert_eq!(profiles.current, 1);
assert!(profiles.steps.is_empty());
}
fn v1_settings(timeout: u64, max_tokens: u64) -> Value {
json!({
"schema_version": 1,
"max_tool_rounds": 8,
"tools": {
"web_enabled": true,
"subagent_max_tokens": max_tokens,
"subagent_timeout_secs": timeout,
"confirm_dangerous": false
}
})
}
#[test]
fn settings_step_drops_old_defaults_so_the_new_ones_apply() {
let out = settings_artifact()
.apply_steps(v1_settings(60, 1024), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
let tools = out["tools"].as_object().unwrap();
assert!(!tools.contains_key("subagent_timeout_secs"));
assert!(!tools.contains_key("subagent_run_timeout_secs"));
assert!(!tools.contains_key("subagent_max_tokens"));
assert_eq!(tools["web_enabled"], json!(true));
assert_eq!(tools["confirm_dangerous"], json!(false));
assert_eq!(out["max_tool_rounds"], json!(8));
let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
assert_eq!(
cfg.tools.subagent_run_timeout_secs,
crate::shared::config::DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS
);
assert_eq!(
cfg.tools.subagent_max_tokens,
crate::shared::config::DEFAULT_SUBAGENT_MAX_TOKENS
);
}
#[test]
fn settings_step_carries_a_changed_value_under_the_new_name() {
let out = settings_artifact()
.apply_steps(v1_settings(120, 2048), 1)
.unwrap();
let tools = out["tools"].as_object().unwrap();
assert!(!tools.contains_key("subagent_timeout_secs"));
assert_eq!(tools["subagent_run_timeout_secs"], json!(120));
assert_eq!(tools["subagent_max_tokens"], json!(2048));
let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
assert_eq!(cfg.tools.subagent_run_timeout_secs, 120);
assert_eq!(cfg.tools.subagent_max_tokens, 2048);
}
#[test]
fn settings_step_tolerates_a_file_without_the_tools_section() {
let out = settings_artifact()
.apply_steps(json!({"schema_version": 1}), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
}
#[test]
fn settings_step_lifts_the_untouched_reply_budget_and_keeps_a_chosen_one() {
let at_the_old_default = settings_artifact()
.apply_steps(
json!({"schema_version": 2, "default_sampling": {"max_tokens": 2048, "thinking": true}}),
2,
)
.unwrap();
assert_eq!(at_the_old_default["schema_version"], json!(SETTINGS_SCHEMA));
let sampling = at_the_old_default["default_sampling"].as_object().unwrap();
assert_eq!(sampling["max_tokens"], json!(16384), "{sampling:?}");
assert_eq!(sampling["thinking"], json!(true));
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(at_the_old_default).unwrap();
assert_eq!(
cfg.default_sampling.max_tokens,
crate::shared::config::AppConfig::default()
.default_sampling
.max_tokens
);
for chosen in [512u64, 4096, 65536] {
let out = settings_artifact()
.apply_steps(
json!({"schema_version": 2, "default_sampling": {"max_tokens": chosen}}),
2,
)
.unwrap();
assert_eq!(
out["default_sampling"]["max_tokens"],
json!(chosen),
"a typed {chosen} must survive"
);
}
let bare = settings_artifact()
.apply_steps(json!({"schema_version": 2}), 2)
.unwrap();
assert_eq!(bare["schema_version"], json!(SETTINGS_SCHEMA));
assert!(bare.get("default_sampling").is_none());
}
const V3_SETTINGS: &str = include_str!("fixtures/settings_v3.json");
#[test]
fn settings_step_to_v4_stamps_the_version_and_touches_nothing_else() {
let before: Value = serde_json::from_str(V3_SETTINGS).unwrap();
let after = settings_to_v4(before.clone()).unwrap();
assert_eq!(after["schema_version"], json!(4));
let mut restamped = after.clone();
restamped["schema_version"] = json!(3);
assert_eq!(restamped, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(cfg.engine.mode, crate::shared::config::ServerMode::External);
assert_eq!(
cfg.engine.external.url.as_deref(),
Some("https://openrouter.ai/api/v1")
);
assert_eq!(cfg.engine.openrouter, Default::default());
assert!(cfg.openrouter.attribution, "on unless switched off");
}
#[test]
fn a_file_naming_the_gateway_is_a_newer_version_to_the_previous_schema() {
let mut cfg = crate::shared::config::AppConfig::default();
cfg.engine.mode = crate::shared::config::ServerMode::OpenRouter;
cfg.impersonation_engine.mode = crate::shared::config::ImpersonationMode::OpenRouter;
let written = serde_json::to_value(&cfg).unwrap();
assert_eq!(written["engine"]["mode"], json!("openrouter"));
assert_eq!(written["impersonation_engine"]["mode"], json!("openrouter"));
assert_eq!(written["schema_version"], json!(SETTINGS_SCHEMA));
let previous = JsonArtifact {
name: "settings.json",
current: 3,
detect: detect_settings,
steps: &SETTINGS_STEPS[..2],
};
assert_eq!(
previous.assess(&written),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
},
"refused as newer, not parsed into an unknown variant"
);
assert_eq!(settings_artifact().assess(&written), Assessment::UpToDate);
let back: crate::shared::config::AppConfig = serde_json::from_value(written).unwrap();
assert_eq!(back, cfg);
}
const V4_SETTINGS: &str = include_str!("fixtures/settings_v4.json");
#[test]
fn the_v4_settings_fixture_holds_nothing_the_config_drops() {
let fixture: Value = serde_json::from_str(V4_SETTINGS).unwrap();
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(fixture.clone()).unwrap();
let read_back = serde_json::to_value(&cfg).unwrap();
assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
}
#[test]
fn settings_step_to_v5_stamps_the_version_and_touches_nothing_else() {
let before: Value = serde_json::from_str(V4_SETTINGS).unwrap();
let after = settings_artifact().apply_steps(before.clone(), 4).unwrap();
assert_eq!(after["schema_version"], json!(5));
let mut restamped = after.clone();
restamped["schema_version"] = json!(4);
assert_eq!(restamped, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(
cfg.default_sampling.reasoning_effort,
Some(ReasoningEffort::XHigh)
);
assert_eq!(
cfg.impersonation_sampling.reasoning_effort,
Some(ReasoningEffort::Minimal)
);
}
fn effort_arrived_in(effort: ReasoningEffort) -> u32 {
match effort {
ReasoningEffort::None
| ReasoningEffort::Minimal
| ReasoningEffort::Low
| ReasoningEffort::Medium
| ReasoningEffort::High
| ReasoningEffort::XHigh => 1,
ReasoningEffort::Max => 5,
}
}
fn reader_at(art: JsonArtifact, current: u32) -> JsonArtifact {
let known = art.steps.iter().filter(|s| s.to <= current).count();
JsonArtifact {
current,
steps: &art.steps[..known],
..art
}
}
#[test]
fn a_file_holding_an_effort_is_a_newer_version_to_a_reader_older_than_it() {
for effort in [
ReasoningEffort::None,
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
] {
let since = effort_arrived_in(effort);
assert!(
since <= SETTINGS_SCHEMA && since <= CHAT_SCHEMA,
"{effort:?}"
);
let mut cfg = crate::shared::config::AppConfig::default();
cfg.default_sampling.reasoning_effort = Some(effort);
let settings = serde_json::to_value(&cfg).unwrap();
let profile = crate::entities::profile::Profile::new("A", "s");
let mut chat = crate::entities::chat::Chat::from_profile(&profile, "t");
chat.sampling_override = Some(crate::entities::sampling::SamplingConfig {
reasoning_effort: Some(effort),
..Default::default()
});
let chat = serde_json::to_value(&chat).unwrap();
assert_eq!(settings_artifact().assess(&settings), Assessment::UpToDate);
assert_eq!(chat_artifact().assess(&chat), Assessment::UpToDate);
for older in 1..since {
assert_eq!(
reader_at(settings_artifact(), older).assess(&settings),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
},
"{effort:?}: settings.json read at schema {older}"
);
assert_eq!(
reader_at(chat_artifact(), older).assess(&chat),
Assessment::Downgrade { from: CHAT_SCHEMA },
"{effort:?}: a chat file read at schema {older}"
);
}
}
}
}