1use std::collections::HashSet;
2
3use agentstategraph::{CommitOptions, Repository};
4use agentstategraph_core::IntentCategory;
5
6use crate::engine::Engine;
7use crate::error::{AsdError, Result};
8use crate::paths;
9use crate::schema::LedgerEntry;
10use crate::search_fts::SearchFtsDb;
11
12pub trait RatifyOps: Send + Sync {
23 fn approve_entry(
24 &self,
25 repo: &Repository,
26 ref_name: &str,
27 entry_id: &str,
28 approver_id: &str,
29 approver_kind: &str,
30 message: Option<&str>,
31 agent_id: &str,
32 ) -> Result<ApprovalOutcome>;
33
34 fn reject_entry(
35 &self,
36 repo: &Repository,
37 ref_name: &str,
38 entry_id: &str,
39 reviewer_id: &str,
40 reviewer_kind: &str,
41 reason: &str,
42 agent_id: &str,
43 ) -> Result<ReviewOutcome>;
44
45 fn withdraw_entry(
46 &self,
47 repo: &Repository,
48 ref_name: &str,
49 entry_id: &str,
50 author_id: &str,
51 agent_id: &str,
52 ) -> Result<ReviewOutcome>;
53}
54
55#[derive(Debug, Clone)]
58pub struct ApprovalOutcome {
59 pub entry: LedgerEntry,
60 pub already_approved: bool,
61}
62
63#[derive(Debug, Clone)]
66pub struct ReviewOutcome {
67 pub entry: LedgerEntry,
68 pub already_resolved: bool,
69}
70
71pub trait LedgerStore {
72 fn append_entry(&self, ref_name: &str, entry: &LedgerEntry, agent_id: &str) -> Result<()>;
73
74 fn list_entries(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<LedgerEntry>> {
77 let all = self.list_entries_with_superseded(ref_name, symbol_id)?;
78 let superseded: HashSet<String> = all
79 .iter()
80 .flat_map(|e| e.supersedes.iter().cloned())
81 .collect();
82 Ok(all
83 .into_iter()
84 .filter(|e| !superseded.contains(&e.entry_id))
85 .collect())
86 }
87
88 fn list_entries_with_superseded(
90 &self,
91 ref_name: &str,
92 symbol_id: &str,
93 ) -> Result<Vec<LedgerEntry>>;
94
95 fn approve_entry(
99 &self,
100 _ref_name: &str,
101 _entry_id: &str,
102 _approver_id: &str,
103 _approver_kind: &str,
104 _message: Option<&str>,
105 _agent_id: &str,
106 ) -> Result<ApprovalOutcome> {
107 Err(AsdError::Other(
108 "ledger approve is a commercial feature (Team tier) — \
109 install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
110 .into(),
111 ))
112 }
113
114 fn reject_entry(
117 &self,
118 _ref_name: &str,
119 _entry_id: &str,
120 _reviewer_id: &str,
121 _reviewer_kind: &str,
122 _reason: &str,
123 _agent_id: &str,
124 ) -> Result<ReviewOutcome> {
125 Err(AsdError::Other(
126 "ledger reject is a commercial feature (Team tier) — \
127 install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
128 .into(),
129 ))
130 }
131
132 fn withdraw_entry(
135 &self,
136 _ref_name: &str,
137 _entry_id: &str,
138 _author_id: &str,
139 _agent_id: &str,
140 ) -> Result<ReviewOutcome> {
141 Err(AsdError::Other(
142 "ledger withdraw is a commercial feature (Team tier) — \
143 install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
144 .into(),
145 ))
146 }
147}
148
149pub struct AsgLedgerStore<'a> {
150 pub repo: &'a Repository,
151 pub fts: Option<&'a SearchFtsDb>,
156}
157
158impl<'a> AsgLedgerStore<'a> {
159 pub fn new(repo: &'a Repository) -> Self {
161 Self { repo, fts: None }
162 }
163 pub fn from_engine(engine: &'a Engine) -> Self {
165 Self {
166 repo: &engine.repo,
167 fts: engine.fts.as_ref(),
168 }
169 }
170}
171
172impl<'a> LedgerStore for AsgLedgerStore<'a> {
173 fn append_entry(&self, ref_name: &str, entry: &LedgerEntry, agent_id: &str) -> Result<()> {
174 let path = paths::ledger_entry_path(&entry.symbol_id, &entry.entry_id);
175 let value = serde_json::to_value(entry)?;
176 let mut opts = CommitOptions::new(
177 agent_id,
178 IntentCategory::Refine,
179 format!("ledger {} for {}", entry.kind.as_str(), entry.symbol_id),
180 );
181 let reasoning = entry.body.clone().unwrap_or_else(|| entry.summary.clone());
185 if !reasoning.is_empty() {
186 opts = opts.with_reasoning(reasoning);
187 }
188 if let Some(confidence) = entry.confidence {
189 opts = opts.with_confidence(confidence);
190 }
191 self.repo.set_json(ref_name, &path, &value, opts)?;
192
193 let idx_path = paths::ledger_entry_index_path(&entry.entry_id);
195 let idx_val = serde_json::Value::String(entry.symbol_id.clone());
196 let idx_opts = CommitOptions::new(
197 agent_id,
198 IntentCategory::Refine,
199 format!("ledger-idx {}", entry.entry_id),
200 );
201 self.repo
202 .set_json(ref_name, &idx_path, &idx_val, idx_opts)?;
203
204 if let Some(fts) = self.fts {
206 let _ = fts.upsert_ledger_entry(entry, ref_name);
207 }
208 Ok(())
209 }
210
211 fn list_entries_with_superseded(
212 &self,
213 ref_name: &str,
214 symbol_id: &str,
215 ) -> Result<Vec<LedgerEntry>> {
216 if let Some(fts) = self.fts {
218 if fts.ledger_entry_count_for(symbol_id, ref_name) > 0 {
219 if let Ok(entries) = fts.list_ledger_entries_for(symbol_id, ref_name) {
220 return Ok(entries);
221 }
222 }
223 }
224
225 let parent = paths::ledger_symbol_path(symbol_id);
228 let json = match self.repo.get_json(ref_name, &parent) {
229 Ok(v) => v,
230 Err(_) => return Ok(Vec::new()),
231 };
232 let mut entries = Vec::new();
233 if let serde_json::Value::Object(map) = json {
234 for (k, v) in map {
235 match serde_json::from_value::<LedgerEntry>(v) {
236 Ok(e) => entries.push(e),
237 Err(err) => eprintln!(
238 "warning: skipping malformed ledger entry {}/{}: {}",
239 symbol_id, k, err
240 ),
241 }
242 }
243 }
244 entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
245
246 if !entries.is_empty() {
248 if let Some(fts) = self.fts {
249 for entry in &entries {
250 let _ = fts.upsert_ledger_entry(entry, ref_name);
251 }
252 }
253 }
254
255 Ok(entries)
256 }
257}
258
259pub fn detect_orphaned_entries(repo: &Repository, ref_name: &str, agent_id: &str) -> Result<usize> {
271 tag_orphaned_entries(repo, ref_name, agent_id).map(|changed| changed.tagged.len())
272}
273
274#[derive(Default)]
276pub(crate) struct OrphanTags {
277 pub tagged: Vec<LedgerEntry>,
279 pub untagged: Vec<LedgerEntry>,
282}
283
284fn is_orphan_tag(tag: &str) -> bool {
285 tag == "orphaned" || tag.starts_with("orphaned-at:")
286}
287
288pub(crate) fn tag_orphaned_entries(
300 repo: &Repository,
301 ref_name: &str,
302 agent_id: &str,
303) -> Result<OrphanTags> {
304 use chrono::Utc;
305
306 let (spec, fork) = crate::subtree::speculate_at_head(repo, ref_name, "asd-tag-orphans")?;
307 let staged = (|| -> Result<OrphanTags> {
308 let indexed: HashSet<String> =
310 crate::subtree::read_seed(repo, &fork, &format!("{}/index/by-qname", paths::ASD_ROOT))?
311 .values()
312 .filter_map(|v| v.get("symbol_id")?.as_str().map(|s| s.to_string()))
313 .collect();
314 let mut ledger = crate::subtree::read_seed(repo, &fork, &paths::ledger_root())?;
315
316 let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
317 let orphaned_at = format!("orphaned-at:{now}");
318 let reattached_at = format!("reattached-at:{now}");
319 let mut changed = OrphanTags::default();
320 for (sym_id, per_symbol) in ledger.iter_mut() {
321 let live = indexed.contains(sym_id);
322 let serde_json::Value::Object(entries_map) = per_symbol else {
323 continue;
324 };
325 for value in entries_map.values_mut() {
326 let tagged = value
329 .get("tags")
330 .and_then(serde_json::Value::as_array)
331 .is_some_and(|tags| {
332 tags.iter()
333 .filter_map(serde_json::Value::as_str)
334 .any(is_orphan_tag)
335 });
336 if live != tagged {
337 continue;
338 }
339 let Ok(mut entry) = serde_json::from_value::<LedgerEntry>(value.clone()) else {
340 continue;
341 };
342 if live {
343 entry
344 .tags
345 .retain(|t| !is_orphan_tag(t) && !t.starts_with("reattached-at:"));
346 entry.tags.push(reattached_at.clone());
347 } else {
348 entry.tags.push("orphaned".to_string());
349 entry.tags.push(orphaned_at.clone());
350 }
351 *value = serde_json::to_value(&entry)?;
352 if live {
353 changed.untagged.push(entry);
354 } else {
355 changed.tagged.push(entry);
356 }
357 }
358 }
359 if !changed.tagged.is_empty() || !changed.untagged.is_empty() {
360 repo.spec_set_json(
361 spec,
362 &paths::ledger_root(),
363 &serde_json::Value::Object(ledger),
364 )
365 .map_err(|e| AsdError::Other(e.to_string()))?;
366 }
367 Ok(changed)
368 })();
369 match staged {
370 Ok(changed) if changed.tagged.is_empty() && changed.untagged.is_empty() => {
371 let _ = repo.discard_speculation(spec);
372 Ok(changed)
373 }
374 Ok(changed) => {
375 let plural = |n: usize| if n == 1 { "y" } else { "ies" };
376 let (t, u) = (changed.tagged.len(), changed.untagged.len());
377 let opts = CommitOptions::new(
378 agent_id,
379 IntentCategory::Refine,
380 match (t, u) {
381 (_, 0) => format!("tag {t} orphaned ledger entr{}", plural(t)),
382 (0, _) => format!("clear orphan tags from {u} ledger entr{}", plural(u)),
383 _ => format!(
384 "tag {t} orphaned ledger entr{}, clear orphan tags from {u}",
385 plural(t)
386 ),
387 },
388 )
389 .with_reasoning(
390 changed
391 .tagged
392 .iter()
393 .map(|e| format!("tagged {}", e.entry_id))
394 .chain(
395 changed
396 .untagged
397 .iter()
398 .map(|e| format!("cleared {}", e.entry_id)),
399 )
400 .collect::<Vec<_>>()
401 .join("\n"),
402 );
403 repo.commit_speculation(spec, opts)
404 .map_err(|e| AsdError::Other(e.to_string()))?;
405 Ok(changed)
406 }
407 Err(e) => {
408 let _ = repo.discard_speculation(spec);
409 Err(e)
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 struct NopStore;
419
420 impl LedgerStore for NopStore {
421 fn append_entry(&self, _: &str, _: &LedgerEntry, _: &str) -> crate::error::Result<()> {
422 Ok(())
423 }
424 fn list_entries_with_superseded(
425 &self,
426 _: &str,
427 _: &str,
428 ) -> crate::error::Result<Vec<LedgerEntry>> {
429 Ok(vec![])
430 }
431 }
432
433 #[test]
434 fn oss_approve_returns_commercial_error_with_url() {
435 let store = NopStore;
436 let err = store
437 .approve_entry("main", "e1", "alice", "human", None, "agent")
438 .unwrap_err();
439 let msg = err.to_string();
440 assert!(msg.contains("commercial feature"), "msg: {}", msg);
441 assert!(
442 msg.contains("agentstatedeveloper.dev/pricing"),
443 "msg: {}",
444 msg
445 );
446 }
447
448 #[test]
449 fn oss_reject_returns_commercial_error_with_url() {
450 let store = NopStore;
451 let err = store
452 .reject_entry("main", "e1", "alice", "human", "reason", "agent")
453 .unwrap_err();
454 let msg = err.to_string();
455 assert!(msg.contains("commercial feature"), "msg: {}", msg);
456 assert!(
457 msg.contains("agentstatedeveloper.dev/pricing"),
458 "msg: {}",
459 msg
460 );
461 }
462
463 #[test]
464 fn oss_withdraw_returns_commercial_error_with_url() {
465 let store = NopStore;
466 let err = store
467 .withdraw_entry("main", "e1", "alice", "agent")
468 .unwrap_err();
469 let msg = err.to_string();
470 assert!(msg.contains("commercial feature"), "msg: {}", msg);
471 assert!(
472 msg.contains("agentstatedeveloper.dev/pricing"),
473 "msg: {}",
474 msg
475 );
476 }
477}