agentstatedeveloper_core/
rebind.rs1use chrono::Utc;
14use serde::Serialize;
15use serde_json::Value;
16
17use agentstategraph::{CommitOptions, Repository};
18use agentstategraph_core::IntentCategory;
19
20use crate::error::{AsdError, Result};
21use crate::paths;
22use crate::schema::{LedgerEntry, Rebind, Symbol};
23use crate::search_fts::SearchFtsDb;
24
25#[derive(Debug, Clone, Serialize)]
27pub struct RebindOutcome {
28 pub from_symbol_id: String,
29 pub to_symbol_id: String,
30 pub to_qname: String,
31 pub entries_moved: usize,
32}
33
34pub fn rebind_ledger(
48 repo: &Repository,
49 fts: Option<&SearchFtsDb>,
50 ref_name: &str,
51 rebinds: &[(String, Symbol)],
52 agent_id: &str,
53) -> Result<Vec<RebindOutcome>> {
54 let mut seen = std::collections::HashSet::new();
55 for (from, to) in rebinds {
56 if *from == to.symbol_id {
57 return Err(AsdError::Other(format!(
58 "{from} and {} are the same symbol — nothing to rebind",
59 to.qname
60 )));
61 }
62 if !seen.insert(from.as_str()) {
63 return Err(AsdError::Other(format!("{from} is listed twice")));
64 }
65 }
66 if rebinds.is_empty() {
67 return Ok(Vec::new());
68 }
69
70 let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-rebind")?;
71 let staged = (|| -> Result<(Vec<RebindOutcome>, Vec<LedgerEntry>)> {
72 let rebinds_root = format!("{}/rebinds", paths::ASD_ROOT);
73 let mut ledger = crate::subtree::read_seed(repo, &fork, &paths::ledger_root())?;
74 let mut index = crate::subtree::read_seed(repo, &fork, &paths::ledger_index_root())?;
75 let mut records = crate::subtree::read_seed(repo, &fork, &rebinds_root)?;
76 let now = Utc::now();
77 let rebound_at = format!("rebound-at:{}", now.format("%Y-%m-%dT%H:%M:%SZ"));
78
79 let mut outcomes = Vec::with_capacity(rebinds.len());
80 let mut moved = Vec::new();
81 for (from, to) in rebinds {
82 let entries = match ledger.remove(from) {
83 Some(Value::Object(entries)) => entries,
84 _ => Default::default(),
85 };
86 let entries_moved = entries.len();
87 if entries_moved > 0 {
88 let Value::Object(dest) = ledger
89 .entry(to.symbol_id.clone())
90 .or_insert_with(|| Value::Object(Default::default()))
91 else {
92 return Err(AsdError::Other(format!(
93 "ledger node for {} is not a map",
94 to.symbol_id
95 )));
96 };
97 for (entry_id, value) in entries {
98 let mut entry: LedgerEntry = serde_json::from_value(value).map_err(|e| {
99 AsdError::Other(format!("ledger entry {entry_id} under {from}: {e}"))
100 })?;
101 entry.symbol_id = to.symbol_id.clone();
102 entry
103 .tags
104 .retain(|t| t != "orphaned" && !t.starts_with("orphaned-at:"));
105 entry.tags.push(rebound_at.clone());
106 index.insert(entry_id.clone(), Value::String(to.symbol_id.clone()));
107 dest.insert(entry_id, serde_json::to_value(&entry)?);
108 moved.push(entry);
109 }
110 }
111 let record = Rebind {
112 from_symbol_id: from.clone(),
113 to_symbol_id: to.symbol_id.clone(),
114 to_qname: to.qname.clone(),
115 at: now,
116 by: agent_id.to_string(),
117 };
118 records.insert(from.clone(), serde_json::to_value(&record)?);
119 outcomes.push(RebindOutcome {
120 from_symbol_id: from.clone(),
121 to_symbol_id: to.symbol_id.clone(),
122 to_qname: to.qname.clone(),
123 entries_moved,
124 });
125 }
126
127 let write = |path: &str, map: serde_json::Map<String, Value>| {
128 repo.spec_set_json(spec, path, &Value::Object(map))
129 .map_err(|e| AsdError::Other(e.to_string()))
130 };
131 if !moved.is_empty() {
132 write(&paths::ledger_root(), ledger)?;
133 write(&paths::ledger_index_root(), index)?;
134 }
135 write(&rebinds_root, records)?;
136 Ok((outcomes, moved))
137 })();
138 let (outcomes, moved) = match staged {
139 Ok(staged) => staged,
140 Err(e) => {
141 let _ = repo.discard_speculation(spec);
142 return Err(e);
143 }
144 };
145
146 let reasoning = outcomes
147 .iter()
148 .map(|o| {
149 format!(
150 "{} → {} ({}, {} entries)",
151 o.from_symbol_id, o.to_qname, o.to_symbol_id, o.entries_moved
152 )
153 })
154 .collect::<Vec<_>>()
155 .join("\n");
156 let opts = CommitOptions::new(
157 agent_id,
158 IntentCategory::Refine,
159 format!(
160 "rebind {} symbol(s), moving {} ledger entr{}",
161 outcomes.len(),
162 moved.len(),
163 if moved.len() == 1 { "y" } else { "ies" }
164 ),
165 )
166 .with_reasoning(reasoning);
167 repo.commit_speculation(spec, opts)
168 .map_err(|e| AsdError::Other(e.to_string()))?;
169
170 if let Some(fts) = fts {
173 for entry in &moved {
174 fts.upsert_ledger_entry(entry, ref_name)
175 .map_err(|e| AsdError::Other(format!("ledger cache: {e}")))?;
176 }
177 }
178 Ok(outcomes)
179}