use std::cmp::Ordering;
use anyhow::{Context, Result};
use serde_json::Value;
pub const SETTINGS_SCHEMA: u32 = 3;
pub const PROFILES_SCHEMA: u32 = 1;
pub const CHAT_SCHEMA: u32 = 4;
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)
}
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,
},
];
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,
},
];
pub fn chat_artifact() -> JsonArtifact {
JsonArtifact {
name: "chats/<id>.json",
current: CHAT_SCHEMA,
detect: detect_chat,
steps: CHAT_STEPS,
}
}
#[cfg(test)]
mod tests {
use super::*;
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, 3);
assert_eq!(settings.steps.len(), 2);
assert_eq!(settings.steps[0].to, 2);
assert_eq!(settings.steps[1].to, 3);
let chats = chat_artifact();
assert_eq!(chats.current, 4);
assert_eq!(chats.steps.len(), 3);
assert_eq!(chats.steps[0].to, 2);
assert_eq!(chats.steps[1].to, 3);
assert_eq!(chats.steps[2].to, 4);
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!(3));
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!(3));
}
#[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!(3));
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!(3));
assert!(bare.get("default_sampling").is_none());
}
}