use chrono::Utc;
use serde::Serialize;
use serde_json::Value;
use agentstategraph::{CommitOptions, Repository};
use agentstategraph_core::IntentCategory;
use crate::error::{AsdError, Result};
use crate::paths;
use crate::schema::{LedgerEntry, Rebind, Symbol};
use crate::search_fts::SearchFtsDb;
#[derive(Debug, Clone, Serialize)]
pub struct RebindOutcome {
pub from_symbol_id: String,
pub to_symbol_id: String,
pub to_qname: String,
pub entries_moved: usize,
}
pub fn rebind_ledger(
repo: &Repository,
fts: Option<&SearchFtsDb>,
ref_name: &str,
rebinds: &[(String, Symbol)],
agent_id: &str,
) -> Result<Vec<RebindOutcome>> {
let mut seen = std::collections::HashSet::new();
for (from, to) in rebinds {
if *from == to.symbol_id {
return Err(AsdError::Other(format!(
"{from} and {} are the same symbol — nothing to rebind",
to.qname
)));
}
if !seen.insert(from.as_str()) {
return Err(AsdError::Other(format!("{from} is listed twice")));
}
}
if rebinds.is_empty() {
return Ok(Vec::new());
}
let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-rebind")?;
let staged = (|| -> Result<(Vec<RebindOutcome>, Vec<LedgerEntry>)> {
let rebinds_root = format!("{}/rebinds", paths::ASD_ROOT);
let mut ledger = crate::subtree::read_seed(repo, &fork, &paths::ledger_root())?;
let mut index = crate::subtree::read_seed(repo, &fork, &paths::ledger_index_root())?;
let mut records = crate::subtree::read_seed(repo, &fork, &rebinds_root)?;
let now = Utc::now();
let rebound_at = format!("rebound-at:{}", now.format("%Y-%m-%dT%H:%M:%SZ"));
let mut outcomes = Vec::with_capacity(rebinds.len());
let mut moved = Vec::new();
for (from, to) in rebinds {
let entries = match ledger.remove(from) {
Some(Value::Object(entries)) => entries,
_ => Default::default(),
};
let entries_moved = entries.len();
if entries_moved > 0 {
let Value::Object(dest) = ledger
.entry(to.symbol_id.clone())
.or_insert_with(|| Value::Object(Default::default()))
else {
return Err(AsdError::Other(format!(
"ledger node for {} is not a map",
to.symbol_id
)));
};
for (entry_id, value) in entries {
let mut entry: LedgerEntry = serde_json::from_value(value).map_err(|e| {
AsdError::Other(format!("ledger entry {entry_id} under {from}: {e}"))
})?;
entry.symbol_id = to.symbol_id.clone();
entry
.tags
.retain(|t| t != "orphaned" && !t.starts_with("orphaned-at:"));
entry.tags.push(rebound_at.clone());
index.insert(entry_id.clone(), Value::String(to.symbol_id.clone()));
dest.insert(entry_id, serde_json::to_value(&entry)?);
moved.push(entry);
}
}
let record = Rebind {
from_symbol_id: from.clone(),
to_symbol_id: to.symbol_id.clone(),
to_qname: to.qname.clone(),
at: now,
by: agent_id.to_string(),
};
records.insert(from.clone(), serde_json::to_value(&record)?);
outcomes.push(RebindOutcome {
from_symbol_id: from.clone(),
to_symbol_id: to.symbol_id.clone(),
to_qname: to.qname.clone(),
entries_moved,
});
}
let write = |path: &str, map: serde_json::Map<String, Value>| {
repo.spec_set_json(spec, path, &Value::Object(map))
.map_err(|e| AsdError::Other(e.to_string()))
};
if !moved.is_empty() {
write(&paths::ledger_root(), ledger)?;
write(&paths::ledger_index_root(), index)?;
}
write(&rebinds_root, records)?;
Ok((outcomes, moved))
})();
let (outcomes, moved) = match staged {
Ok(staged) => staged,
Err(e) => {
let _ = repo.discard_speculation(spec);
return Err(e);
}
};
let reasoning = outcomes
.iter()
.map(|o| {
format!(
"{} → {} ({}, {} entries)",
o.from_symbol_id, o.to_qname, o.to_symbol_id, o.entries_moved
)
})
.collect::<Vec<_>>()
.join("\n");
let opts = CommitOptions::new(
agent_id,
IntentCategory::Refine,
format!(
"rebind {} symbol(s), moving {} ledger entr{}",
outcomes.len(),
moved.len(),
if moved.len() == 1 { "y" } else { "ies" }
),
)
.with_reasoning(reasoning);
repo.commit_speculation(spec, opts)
.map_err(|e| AsdError::Other(e.to_string()))?;
if let Some(fts) = fts {
for entry in &moved {
fts.upsert_ledger_entry(entry, ref_name)
.map_err(|e| AsdError::Other(format!("ledger cache: {e}")))?;
}
}
Ok(outcomes)
}