agentstatedeveloper_cli/commands/
conclusions.rs1use anyhow::Result;
10use clap::{Args, Subcommand, ValueEnum};
11use serde_json::json;
12
13use agentstatedeveloper_core::{
14 AsgIndexStore, ConclusionClass, Engine, IndexStore, LedgerKind,
15 conclusions_export::{self},
16};
17
18use crate::config::Config;
19
20#[derive(Debug, Subcommand)]
21pub enum ConclusionsCmd {
22 List(ListArgs),
24 Export(ExportArgs),
28 Import(ImportArgs),
32 Merge(MergeArgs),
38}
39
40#[derive(Debug, Args)]
41pub struct ListArgs {
42 #[arg(long, value_enum)]
44 pub class: Option<CliConclusionClass>,
45
46 #[arg(long)]
48 pub symbol: Option<String>,
49}
50
51#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
52pub enum CliConclusionClass {
53 Decisions,
54 Classifications,
55 Mappings,
56 Hazards,
57 Recipes,
58 #[value(name = "followups")]
59 FollowUps,
60}
61
62impl From<CliConclusionClass> for ConclusionClass {
63 fn from(c: CliConclusionClass) -> Self {
64 match c {
65 CliConclusionClass::Decisions => ConclusionClass::Decisions,
66 CliConclusionClass::Classifications => ConclusionClass::Classifications,
67 CliConclusionClass::Mappings => ConclusionClass::Mappings,
68 CliConclusionClass::Hazards => ConclusionClass::Hazards,
69 CliConclusionClass::Recipes => ConclusionClass::Recipes,
70 CliConclusionClass::FollowUps => ConclusionClass::FollowUps,
71 }
72 }
73}
74
75#[derive(Debug, Args)]
76pub struct ExportArgs {
77 #[arg(long)]
80 pub out: Option<std::path::PathBuf>,
81
82 #[arg(long)]
84 pub quiet: bool,
85
86 #[arg(long)]
90 pub check_budget: bool,
91
92 #[arg(long, requires = "check_budget")]
96 pub soft: bool,
97}
98
99#[derive(Debug, Args)]
100pub struct ImportArgs {
101 #[arg(long, name = "in")]
104 pub in_dir: Option<std::path::PathBuf>,
105}
106
107#[derive(Debug, Args)]
108pub struct MergeArgs {
109 pub base: std::path::PathBuf,
112 pub ours: std::path::PathBuf,
115 pub theirs: std::path::PathBuf,
117}
118
119pub fn run(cfg: &Config, cmd: ConclusionsCmd) -> Result<()> {
120 match cmd {
121 ConclusionsCmd::List(args) => list(cfg, args),
122 ConclusionsCmd::Export(args) => export(cfg, args),
123 ConclusionsCmd::Import(args) => import(cfg, args),
124 ConclusionsCmd::Merge(args) => merge(args),
125 }
126}
127
128fn merge(args: MergeArgs) -> Result<()> {
131 let n = conclusions_export::merge_jsonl(&args.ours, &args.theirs)?;
132 eprintln!(
133 "asd conclusions merge: unioned {} into {} ({} records)",
134 args.theirs.display(),
135 args.ours.display(),
136 n
137 );
138 Ok(())
139}
140
141fn import(cfg: &Config, args: ImportArgs) -> Result<()> {
142 let engine = Engine::open_sqlite(&cfg.db_path)?;
143 let in_dir = args.in_dir.unwrap_or_else(|| {
144 cfg.db_path
145 .parent()
146 .unwrap_or(std::path::Path::new("."))
147 .join(".asd")
148 .join("conclusions")
149 });
150 let results = conclusions_export::import_all(&engine, &in_dir, &cfg.agent_id)?;
151 let payload = json!({
152 "in_dir": in_dir.display().to_string(),
153 "files": results.iter().map(|r| json!({
154 "class": r.class,
155 "file": r.file,
156 "read": r.read,
157 "imported": r.imported,
158 "skipped_up_to_date": r.skipped_up_to_date,
159 "skipped_unknown_qname": r.skipped_unknown_qname,
160 "skipped_parse_error": r.skipped_parse_error,
161 })).collect::<Vec<_>>(),
162 "total_read": results.iter().map(|r| r.read).sum::<usize>(),
163 "total_imported": results.iter().map(|r| r.imported).sum::<usize>(),
164 "total_skipped_up_to_date": results.iter().map(|r| r.skipped_up_to_date).sum::<usize>(),
165 "total_skipped_unknown_qname": results.iter().map(|r| r.skipped_unknown_qname).sum::<usize>(),
166 "total_skipped_parse_error": results.iter().map(|r| r.skipped_parse_error).sum::<usize>(),
167 });
168 println!("{}", serde_json::to_string_pretty(&payload)?);
169 let dropped: Vec<String> = results
172 .iter()
173 .filter(|r| r.skipped_unknown_qname + r.skipped_parse_error > 0)
174 .map(|r| {
175 format!(
176 "{}: read {}, imported {}, skipped {} (unknown qname {}, parse error {})",
177 r.class,
178 r.read,
179 r.imported,
180 r.skipped_unknown_qname + r.skipped_parse_error,
181 r.skipped_unknown_qname,
182 r.skipped_parse_error
183 )
184 })
185 .collect();
186 if !dropped.is_empty() {
187 eprintln!(
188 "warning: conclusions import skipped entries:\n {}",
189 dropped.join("\n ")
190 );
191 }
192 Ok(())
193}
194
195fn list(cfg: &Config, args: ListArgs) -> Result<()> {
196 use std::collections::{BTreeMap, HashSet};
197 let engine = Engine::open_sqlite(&cfg.db_path)?;
198 let index = AsgIndexStore::from_engine(&engine);
199 let ref_name = engine.ref_name.clone();
200
201 let target_class = args.class.map(ConclusionClass::from);
202
203 let target_symbol_id: Option<String> = if let Some(qname) = args.symbol.as_deref() {
206 match index.get_symbol_by_qname(&ref_name, qname)? {
207 Some(sym) => Some(sym.symbol_id),
208 None => {
209 println!(
210 "{}",
211 serde_json::to_string_pretty(&json!({
212 "class": target_class.map(|c| c.filename_stem()),
213 "symbol": qname,
214 "buckets": {},
215 "warning": "symbol not found",
216 }))?
217 );
218 return Ok(());
219 }
220 }
221 } else {
222 None
223 };
224
225 let mut buckets: BTreeMap<&'static str, Vec<serde_json::Value>> = BTreeMap::new();
226 for class in ConclusionClass::all() {
227 if target_class.is_none() || target_class == Some(*class) {
228 buckets.insert(class.filename_stem(), Vec::new());
229 }
230 }
231
232 let id_map = index.build_id_map(&engine);
242 let ledger_tree = engine
243 .repo
244 .get_tree(&ref_name, "/asd/v1/ledger")
245 .unwrap_or(serde_json::Value::Null);
246 let by_symbol = match ledger_tree {
247 serde_json::Value::Object(map) => map,
248 _ => serde_json::Map::new(),
249 };
250
251 for (sym_id, per_symbol) in &by_symbol {
252 if let Some(ref target) = target_symbol_id {
253 if sym_id != target {
254 continue;
255 }
256 }
257 let qname = match id_map.get(sym_id) {
261 Some(s) => s.qname.clone(),
262 None => continue,
263 };
264 let entries_map = match per_symbol {
265 serde_json::Value::Object(m) => m,
266 _ => continue,
267 };
268 let mut entries: Vec<agentstatedeveloper_core::LedgerEntry> = entries_map
271 .values()
272 .filter_map(|v| serde_json::from_value(v.clone()).ok())
273 .collect();
274 let superseded: HashSet<String> = entries
275 .iter()
276 .flat_map(|e| e.supersedes.iter().cloned())
277 .collect();
278 entries.retain(|e| !superseded.contains(&e.entry_id));
279 entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
280 for entry in entries {
281 let class = entry.kind.conclusion_class();
282 if let Some(filter) = target_class {
283 if class != filter {
284 continue;
285 }
286 }
287 let stem = class.filename_stem();
288 if let Some(bucket) = buckets.get_mut(stem) {
289 bucket.push(json!({
290 "entry_id": entry.entry_id,
291 "kind": kind_str(entry.kind),
292 "qname": qname,
293 "symbol_id": entry.symbol_id,
294 "summary": entry.summary,
295 "role": entry.role,
296 "command": entry.command,
297 "tags": entry.tags,
298 "created_at": entry.created_at,
299 }));
300 }
301 }
302 }
303
304 let total: usize = buckets.values().map(|v| v.len()).sum();
305 let payload = json!({
306 "class": target_class.map(|c| c.filename_stem()),
307 "symbol": args.symbol,
308 "total": total,
309 "buckets": buckets,
310 });
311 println!("{}", serde_json::to_string_pretty(&payload)?);
312 Ok(())
313}
314
315fn kind_str(k: LedgerKind) -> &'static str {
316 k.as_str()
317}
318
319fn export(cfg: &Config, args: ExportArgs) -> Result<()> {
326 let engine = Engine::open_sqlite(&cfg.db_path)?;
327 let out_dir = args.out.unwrap_or_else(|| {
328 cfg.db_path
329 .parent()
330 .unwrap_or(std::path::Path::new("."))
331 .join(".asd")
332 .join("conclusions")
333 });
334
335 let counts = conclusions_export::export_all(&engine, &out_dir)?;
336
337 let budget_report = if args.check_budget {
342 let project_root = out_dir
343 .parent()
344 .and_then(|p| p.parent())
345 .unwrap_or_else(|| std::path::Path::new("."));
346 let cfg_sidecar =
347 agentstatedeveloper_core::sidecar_config::SidecarConfig::load_from_project(
348 project_root,
349 );
350 let report = conclusions_export::check_budget(&out_dir, &cfg_sidecar.budget)?;
351 Some(report)
352 } else {
353 None
354 };
355
356 if args.quiet {
357 let total_entries: usize = counts.iter().map(|(_, n, _)| n).sum();
358 let total_bytes: u64 = counts.iter().map(|(_, _, b)| b).sum();
359 println!(
360 "exported {} entries ({} bytes) to {}",
361 total_entries,
362 total_bytes,
363 out_dir.display()
364 );
365 } else {
366 let mut payload = json!({
367 "out_dir": out_dir.display().to_string(),
368 "files": counts.iter().map(|(stem, n, b)| json!({
369 "class": stem,
370 "file": format!("{stem}.jsonl"),
371 "entries": n,
372 "bytes": b,
373 })).collect::<Vec<_>>(),
374 "total_entries": counts.iter().map(|(_, n, _)| n).sum::<usize>(),
375 "total_bytes": counts.iter().map(|(_, _, b)| b).sum::<u64>(),
376 });
377 if let Some(ref r) = budget_report {
378 payload["budget"] = json!({
379 "ok": r.ok,
380 "total_bytes": r.total_bytes,
381 "total_budget": r.total_budget,
382 "over_total": r.over_total,
383 "per_shard_budget": r.per_shard_budget,
384 "shards": r.shards.iter().map(|s| json!({
385 "path": s.path,
386 "bytes": s.bytes,
387 "over_per_shard": s.over_per_shard,
388 })).collect::<Vec<_>>(),
389 });
390 }
391 println!("{}", serde_json::to_string_pretty(&payload)?);
392 }
393
394 if let Some(r) = budget_report {
398 if !r.ok {
399 let mut violations: Vec<String> = Vec::new();
400 if r.over_total {
401 violations.push(format!(
402 "total {} bytes > budget {} bytes",
403 r.total_bytes, r.total_budget
404 ));
405 }
406 for s in &r.shards {
407 if s.over_per_shard {
408 violations.push(format!(
409 "shard `{}` {} bytes > per-shard budget {} bytes",
410 s.path, s.bytes, r.per_shard_budget
411 ));
412 }
413 }
414 let msg = format!("sidecar budget exceeded:\n {}", violations.join("\n "));
415 if args.soft {
416 eprintln!("warning: {msg}");
417 } else {
418 anyhow::bail!(msg);
419 }
420 }
421 }
422 Ok(())
423}
424
425