use std::collections::BTreeSet;
use std::path::Path;
use agentstategraph::{CommitOptions, Repository};
use agentstategraph_core::IntentCategory;
use serde::Serialize;
use serde_json::Value;
use crate::engine::Engine;
use crate::error::{AsdError, Result};
use crate::paths;
use crate::search_fts::SearchFtsDb;
#[derive(Debug, Clone, Serialize)]
pub struct LedgerCounts {
pub cache: Option<usize>,
pub asg: usize,
pub sidecar: Option<usize>,
pub missing_from_asg: usize,
pub missing_from_sidecar: usize,
}
impl LedgerCounts {
pub fn is_consistent(&self) -> bool {
self.missing_from_asg == 0 && self.missing_from_sidecar == 0
}
pub fn warning(&self) -> Option<String> {
if self.is_consistent() {
return None;
}
let mut parts = Vec::new();
if self.missing_from_asg > 0 {
parts.push(format!(
"{} ledger entr{} in the cache but missing from the store — \
run `asd repair` to review and `asd repair --fix` to restore",
self.missing_from_asg,
if self.missing_from_asg == 1 {
"y is"
} else {
"ies are"
}
));
}
if self.missing_from_sidecar > 0 {
parts.push(format!(
"{} ledger entr{} not in .asd/ — run `asd sync`",
self.missing_from_sidecar,
if self.missing_from_sidecar == 1 {
"y is"
} else {
"ies are"
}
));
}
Some(parts.join("; "))
}
}
pub fn asg_ledger_entry_ids(repo: &Repository, ref_name: &str) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
if let Ok(Value::Object(by_symbol)) = repo.get_json_capped(ref_name, &paths::ledger_root(), 1) {
for bucket in by_symbol.values() {
match bucket.get("_keys") {
Some(Value::Array(keys)) => {
ids.extend(keys.iter().filter_map(|k| k.as_str().map(str::to_string)));
}
_ => {
if let Value::Object(entries) = bucket {
ids.extend(entries.keys().cloned());
}
}
}
}
}
ids
}
pub fn sidecar_ledger_entry_ids(root: &Path) -> Option<BTreeSet<String>> {
let ledger = root.join(".asd").join("v1").join("ledger");
let symbols = std::fs::read_dir(&ledger).ok()?;
let mut ids = BTreeSet::new();
for sym in symbols.flatten() {
let Ok(files) = std::fs::read_dir(sym.path()) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
ids.insert(stem.to_string());
}
}
}
Some(ids)
}
pub fn ledger_counts(
repo: &Repository,
ref_name: &str,
fts: Option<&SearchFtsDb>,
sidecar_root: Option<&Path>,
) -> Result<LedgerCounts> {
let asg = asg_ledger_entry_ids(repo, ref_name);
let (cache, missing_from_asg) = match fts {
Some(fts) => {
let ids = fts
.ledger_cache_entry_ids(ref_name)
.map_err(|e| AsdError::Other(format!("read ledger cache: {e}")))?;
let missing = ids.iter().filter(|id| !asg.contains(*id)).count();
(Some(ids.len()), missing)
}
None => (None, 0),
};
let sidecar_ids = sidecar_root.and_then(sidecar_ledger_entry_ids);
let missing_from_sidecar = sidecar_ids
.as_ref()
.map(|s| asg.difference(s).count())
.unwrap_or(0);
Ok(LedgerCounts {
cache,
asg: asg.len(),
sidecar: sidecar_ids.map(|s| s.len()),
missing_from_asg,
missing_from_sidecar,
})
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct LedgerRestoreReport {
pub missing: usize,
pub restored: usize,
pub failed: Vec<(String, String)>,
pub unparseable: Vec<String>,
}
pub fn missing_ledger_entries(
engine: &Engine,
) -> Result<(Vec<crate::schema::LedgerEntry>, Vec<String>)> {
let Some(fts) = engine.fts.as_ref() else {
return Ok((Vec::new(), Vec::new()));
};
let asg = asg_ledger_entry_ids(&engine.repo, &engine.ref_name);
let (entries, unparseable) = fts
.all_ledger_entries(&engine.ref_name)
.map_err(|e| AsdError::Other(format!("read ledger cache: {e}")))?;
Ok((
entries
.into_iter()
.filter(|e| !asg.contains(&e.entry_id))
.collect(),
unparseable
.into_iter()
.filter(|id| !asg.contains(id))
.collect(),
))
}
pub fn restore_missing_ledger_entries(
engine: &Engine,
agent_id: &str,
) -> Result<LedgerRestoreReport> {
let (missing, unparseable) = missing_ledger_entries(engine)?;
let mut report = LedgerRestoreReport {
missing: missing.len() + unparseable.len(),
unparseable,
..Default::default()
};
if missing.is_empty() {
return Ok(report);
}
let repo = &engine.repo;
let (spec, fork) =
crate::subtree::speculate_at_head(repo, &engine.ref_name, "asd-repair-ledger")?;
let staged = (|| -> Result<()> {
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())?;
for entry in &missing {
let by_id = ledger
.entry(entry.symbol_id.clone())
.or_insert_with(|| Value::Object(Default::default()));
let Value::Object(by_id) = by_id else {
return Err(AsdError::Other(format!(
"ledger node for {} is not a map",
entry.symbol_id
)));
};
by_id.insert(entry.entry_id.clone(), serde_json::to_value(entry)?);
index.insert(
entry.entry_id.clone(),
Value::String(entry.symbol_id.clone()),
);
}
repo.spec_set_json(spec, &paths::ledger_root(), &Value::Object(ledger))
.map_err(|e| AsdError::Other(e.to_string()))?;
repo.spec_set_json(spec, &paths::ledger_index_root(), &Value::Object(index))
.map_err(|e| AsdError::Other(e.to_string()))?;
Ok(())
})();
let committed = staged.and_then(|()| {
let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default();
for entry in &missing {
*kinds.entry(entry.kind.as_str()).or_default() += 1;
}
let kinds = kinds
.iter()
.map(|(kind, n)| format!("{n} {kind}"))
.collect::<Vec<_>>()
.join(", ");
let ids = missing
.iter()
.map(|e| format!("{} ({})", e.entry_id, e.symbol_id))
.collect::<Vec<_>>()
.join("\n");
let opts = CommitOptions::new(
agent_id,
IntentCategory::Refine,
format!(
"restore {} ledger entries lost from the store, recovered from the ledger cache ({kinds})",
missing.len()
),
)
.with_reasoning(ids);
repo.commit_speculation(spec, opts)
.map(|_| ())
.map_err(|e| AsdError::Other(e.to_string()))
});
match committed {
Ok(()) => report.restored = missing.len(),
Err(e) => {
let _ = repo.discard_speculation(spec);
let e = e.to_string();
report.failed = missing
.iter()
.map(|entry| (entry.entry_id.clone(), e.clone()))
.collect();
}
}
Ok(report)
}