1use std::collections::{BTreeMap, HashSet};
25
26use agentstatedeveloper_core::{
27 AsgFeedbackStore, Engine, FeedbackStore, SearchFtsDb, compute_index_consistency, format_age,
28 scorecard as core_scorecard, stale_warning_classified,
29};
30use axum::{
31 Json,
32 extract::{Query, State},
33};
34use serde::Deserialize;
35use serde_json::{Value, json};
36
37use crate::{ApiError, AppState};
38
39const HISTORY_BATCH: usize = 5_000;
42
43const DISTILLED_SCAN_CAP: usize = 200_000;
47
48const COMMIT_SCAN_DEFAULT: usize = 5_000;
52const COMMIT_SCAN_MAX: usize = 100_000;
53
54const PAGE_DEFAULT: usize = 100;
55const PAGE_MAX: usize = 1_000;
56
57fn truthy(v: Option<&str>) -> bool {
62 matches!(v, Some("1") | Some("true") | Some("yes"))
63}
64
65fn refresh_requested(v: Option<&str>) -> bool {
68 match v {
69 None => true,
70 Some("0") | Some("false") | Some("no") => false,
71 _ => true,
72 }
73}
74
75fn page(limit: Option<usize>, offset: Option<usize>) -> (usize, usize) {
76 (
77 limit.unwrap_or(PAGE_DEFAULT).clamp(1, PAGE_MAX),
78 offset.unwrap_or(0),
79 )
80}
81
82fn contains_ci(haystack: &str, needle: &str) -> bool {
83 haystack.to_lowercase().contains(needle)
84}
85
86fn eq_ci(a: &str, b: &str) -> bool {
89 a.eq_ignore_ascii_case(b)
90}
91
92fn in_day_range(day: &str, from: Option<&str>, to: Option<&str>) -> bool {
94 from.is_none_or(|f| day >= f) && to.is_none_or(|t| day <= t)
95}
96
97fn facet(counts: BTreeMap<String, usize>) -> Vec<Value> {
100 let mut v: Vec<(String, usize)> = counts.into_iter().collect();
101 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
102 v.into_iter()
103 .map(|(value, count)| json!({ "value": value, "count": count }))
104 .collect()
105}
106
107fn bump(counts: &mut BTreeMap<String, usize>, key: &str, by: usize) {
108 *counts.entry(key.to_string()).or_default() += by;
109}
110
111fn refresh_history(engine: &Engine) {
115 let _ = engine.repo.extract_history(HISTORY_BATCH);
116}
117
118#[derive(Deserialize)]
123pub struct MilestoneQuery {
124 q: Option<String>,
126 kind: Option<String>,
127 namespace: Option<String>,
128 agent: Option<String>,
129 from: Option<String>,
131 to: Option<String>,
132 limit: Option<usize>,
133 offset: Option<usize>,
134 refresh: Option<String>,
135}
136
137pub async fn list_milestones(
155 State(state): State<AppState>,
156 Query(q): Query<MilestoneQuery>,
157) -> Result<Json<Value>, ApiError> {
158 let engine = state.engine.lock().await;
159 if refresh_requested(q.refresh.as_deref()) {
160 refresh_history(&engine);
161 }
162
163 let rows = engine
164 .repo
165 .history_milestones(DISTILLED_SCAN_CAP)
166 .map_err(|e| ApiError::Internal(e.to_string()))?;
167 let scanned = rows.len();
168
169 let needle =
170 q.q.as_deref()
171 .map(str::to_lowercase)
172 .filter(|s| !s.is_empty());
173 let (limit, offset) = page(q.limit, q.offset);
174
175 let base: Vec<_> = rows
177 .into_iter()
178 .filter(|m| in_day_range(&m.day, q.from.as_deref(), q.to.as_deref()))
179 .filter(|m| {
180 needle.as_deref().is_none_or(|n| {
181 contains_ci(&m.description, n)
182 || contains_ci(&m.commit_id.short(), n)
183 || contains_ci(&m.kind, n)
184 || contains_ci(&m.agent_id, n)
185 || contains_ci(&m.namespace, n)
186 })
187 })
188 .collect();
189
190 let mut kinds = BTreeMap::new();
191 let mut namespaces = BTreeMap::new();
192 let mut agents = BTreeMap::new();
193 for m in &base {
194 bump(&mut kinds, &m.kind, 1);
195 bump(&mut namespaces, &m.namespace, 1);
196 bump(&mut agents, &m.agent_id, 1);
197 }
198
199 let mut matched: Vec<_> = base
201 .into_iter()
202 .filter(|m| q.kind.as_deref().is_none_or(|k| eq_ci(&m.kind, k)))
203 .filter(|m| {
204 q.namespace
205 .as_deref()
206 .is_none_or(|n| eq_ci(&m.namespace, n))
207 })
208 .filter(|m| q.agent.as_deref().is_none_or(|a| eq_ci(&m.agent_id, a)))
209 .collect();
210 matched.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
212
213 let total = matched.len();
214 let unpinned = matched.iter().filter(|m| m.state_root.is_none()).count();
215 let items: Vec<Value> = matched
216 .into_iter()
217 .skip(offset)
218 .take(limit)
219 .map(|m| {
220 json!({
221 "commit": m.commit_id.short(),
222 "commit_id": m.commit_id.to_string(),
223 "kind": m.kind,
224 "timestamp": m.timestamp,
225 "day": m.day,
226 "namespace": m.namespace,
227 "agent": m.agent_id,
228 "description": m.description,
229 "state_root": m.state_root.as_ref().map(|s| s.short()),
230 "pins_state": m.state_root.is_some(),
231 "git_sha": m.git_sha,
232 })
233 })
234 .collect();
235
236 Ok(Json(json!({
237 "total": total,
238 "offset": offset,
239 "limit": limit,
240 "scanned": scanned,
241 "unpinned": unpinned,
242 "items": items,
243 "facets": {
244 "kinds": facet(kinds),
245 "namespaces": facet(namespaces),
246 "agents": facet(agents),
247 },
248 })))
249}
250
251#[derive(Deserialize)]
256pub struct RollupQuery {
257 q: Option<String>,
258 namespace: Option<String>,
259 agent: Option<String>,
260 intent: Option<String>,
261 from: Option<String>,
262 to: Option<String>,
263 limit: Option<usize>,
264 offset: Option<usize>,
265 refresh: Option<String>,
266}
267
268pub async fn list_rollup(
274 State(state): State<AppState>,
275 Query(q): Query<RollupQuery>,
276) -> Result<Json<Value>, ApiError> {
277 let engine = state.engine.lock().await;
278 if refresh_requested(q.refresh.as_deref()) {
279 refresh_history(&engine);
280 }
281
282 let rows = engine
283 .repo
284 .history_rollup()
285 .map_err(|e| ApiError::Internal(e.to_string()))?;
286 let scanned = rows.len();
287
288 let needle =
289 q.q.as_deref()
290 .map(str::to_lowercase)
291 .filter(|s| !s.is_empty());
292 let (limit, offset) = page(q.limit, q.offset);
293
294 let base: Vec<_> = rows
295 .into_iter()
296 .filter(|r| in_day_range(&r.day, q.from.as_deref(), q.to.as_deref()))
297 .filter(|r| {
298 needle.as_deref().is_none_or(|n| {
299 contains_ci(&r.day, n)
300 || contains_ci(&r.agent_id, n)
301 || contains_ci(&r.intent_category, n)
302 || contains_ci(&r.namespace, n)
303 })
304 })
305 .collect();
306
307 let mut namespaces = BTreeMap::new();
308 let mut agents = BTreeMap::new();
309 let mut intents = BTreeMap::new();
310 for r in &base {
311 let n = r.commit_count.max(0) as usize;
312 bump(&mut namespaces, &r.namespace, n);
313 bump(&mut agents, &r.agent_id, n);
314 bump(&mut intents, &r.intent_category, n);
315 }
316
317 let mut matched: Vec<_> = base
318 .into_iter()
319 .filter(|r| {
320 q.namespace
321 .as_deref()
322 .is_none_or(|n| eq_ci(&r.namespace, n))
323 })
324 .filter(|r| q.agent.as_deref().is_none_or(|a| eq_ci(&r.agent_id, a)))
325 .filter(|r| {
326 q.intent
327 .as_deref()
328 .is_none_or(|i| eq_ci(&r.intent_category, i))
329 })
330 .collect();
331 matched.sort_by(|a, b| {
332 b.day
333 .cmp(&a.day)
334 .then_with(|| b.commit_count.cmp(&a.commit_count))
335 .then_with(|| a.agent_id.cmp(&b.agent_id))
336 });
337
338 let total = matched.len();
341 let commits: i64 = matched.iter().map(|r| r.commit_count).sum();
342 let days: HashSet<&str> = matched.iter().map(|r| r.day.as_str()).collect();
343 let day_span = {
344 let mut d: Vec<&str> = days.iter().copied().collect();
345 d.sort_unstable();
346 json!({ "first": d.first(), "last": d.last(), "count": d.len() })
347 };
348
349 let items: Vec<Value> = matched
350 .iter()
351 .skip(offset)
352 .take(limit)
353 .map(|r| {
354 json!({
355 "day": r.day,
356 "namespace": r.namespace,
357 "agent": r.agent_id,
358 "intent": r.intent_category,
359 "commits": r.commit_count,
360 "first_ts": r.first_ts,
361 "last_ts": r.last_ts,
362 })
363 })
364 .collect();
365
366 Ok(Json(json!({
367 "total": total,
368 "offset": offset,
369 "limit": limit,
370 "scanned": scanned,
371 "totals": { "commits": commits, "days": day_span },
372 "items": items,
373 "facets": {
374 "namespaces": facet(namespaces),
375 "agents": facet(agents),
376 "intents": facet(intents),
377 },
378 })))
379}
380
381#[derive(Deserialize)]
386pub struct CommitQuery {
387 q: Option<String>,
388 agent: Option<String>,
389 intent: Option<String>,
390 from: Option<String>,
391 to: Option<String>,
392 milestone: Option<String>,
394 limit: Option<usize>,
395 offset: Option<usize>,
396 scan: Option<usize>,
398 refresh: Option<String>,
399}
400
401pub async fn list_commits(
437 State(state): State<AppState>,
438 Query(q): Query<CommitQuery>,
439) -> Result<Json<Value>, ApiError> {
440 let engine = state.engine.lock().await;
441 let ref_name = engine.ref_name.clone();
442
443 if refresh_requested(q.refresh.as_deref()) {
448 refresh_history(&engine);
449 }
450
451 let scan = q
452 .scan
453 .unwrap_or(COMMIT_SCAN_DEFAULT)
454 .clamp(1, COMMIT_SCAN_MAX);
455
456 let walk = engine
462 .repo
463 .log_dag(&ref_name, scan)
464 .map_err(|e| ApiError::Internal(e.to_string()))?;
465 let capped = walk.truncated;
466 let commits = &walk.commits[..];
467 let scanned = commits.len();
468
469 let distilled: i64 = engine
472 .repo
473 .history_rollup()
474 .map(|rows| rows.iter().map(|r| r.commit_count).sum())
475 .unwrap_or(0);
476
477 let spine: HashSet<String> = engine
479 .repo
480 .history_milestones(DISTILLED_SCAN_CAP)
481 .map(|rows| rows.into_iter().map(|m| m.commit_id.to_string()).collect())
482 .unwrap_or_default();
483
484 let needle =
485 q.q.as_deref()
486 .map(str::to_lowercase)
487 .filter(|s| !s.is_empty());
488 let (limit, offset) = page(q.limit, q.offset);
489 let want_milestone = q.milestone.as_deref().map(|v| truthy(Some(v)));
490
491 struct Row {
492 id: String,
493 short: String,
494 day: String,
495 timestamp: String,
496 agent: String,
497 intent: String,
498 description: String,
499 reasoning: Option<String>,
500 confidence: Option<f64>,
501 parents: usize,
502 state_root: String,
503 on_spine: bool,
504 }
505
506 let rows: Vec<Row> = commits
507 .iter()
508 .map(|c| {
509 let id = c.id.to_string();
510 let on_spine = spine.contains(&id);
511 Row {
512 short: c.id.short(),
513 day: c.timestamp.format("%Y-%m-%d").to_string(),
514 timestamp: c.timestamp.to_rfc3339(),
515 agent: c.agent_id.clone(),
516 intent: format!("{:?}", c.intent.category),
517 description: c.intent.description.clone(),
518 reasoning: c.reasoning.clone(),
519 confidence: c.confidence,
520 parents: c.parents.len(),
521 state_root: c.state_root.short(),
522 on_spine,
523 id,
524 }
525 })
526 .collect();
527
528 let base: Vec<&Row> = rows
529 .iter()
530 .filter(|r| in_day_range(&r.day, q.from.as_deref(), q.to.as_deref()))
531 .filter(|r| {
532 needle.as_deref().is_none_or(|n| {
533 contains_ci(&r.description, n)
534 || contains_ci(&r.short, n)
535 || contains_ci(&r.agent, n)
536 || contains_ci(&r.intent, n)
537 || r.reasoning.as_deref().is_some_and(|s| contains_ci(s, n))
538 })
539 })
540 .collect();
541
542 let mut agents = BTreeMap::new();
543 let mut intents = BTreeMap::new();
544 for r in &base {
545 bump(&mut agents, &r.agent, 1);
546 bump(&mut intents, &r.intent, 1);
547 }
548
549 let matched: Vec<&&Row> = base
550 .iter()
551 .filter(|r| q.agent.as_deref().is_none_or(|a| eq_ci(&r.agent, a)))
552 .filter(|r| q.intent.as_deref().is_none_or(|i| eq_ci(&r.intent, i)))
553 .filter(|r| want_milestone.is_none_or(|want| r.on_spine == want))
554 .collect();
555
556 let total = matched.len();
557 let on_spine_count = matched.iter().filter(|r| r.on_spine).count();
561 let items: Vec<Value> = matched
562 .into_iter()
563 .skip(offset)
564 .take(limit)
565 .map(|r| {
566 json!({
567 "commit": r.short,
568 "commit_id": r.id,
569 "timestamp": r.timestamp,
570 "day": r.day,
571 "agent": r.agent,
572 "intent": r.intent,
573 "description": r.description,
574 "reasoning": r.reasoning,
575 "confidence": r.confidence,
576 "parents": r.parents,
577 "state_root": r.state_root,
578 "on_spine": r.on_spine,
579 })
580 })
581 .collect();
582
583 Ok(Json(json!({
584 "total": total,
585 "offset": offset,
586 "limit": limit,
587 "scanned": scanned,
588 "capped": capped,
589 "scan": scan,
590 "distilled": distilled,
591 "on_spine": on_spine_count,
592 "items": items,
593 "facets": {
594 "agents": facet(agents),
595 "intents": facet(intents),
596 },
597 })))
598}
599
600#[derive(Deserialize)]
605pub struct FeedbackQuery {
606 q: Option<String>,
608 verdict: Option<String>,
609 author: Option<String>,
610 symbol: Option<String>,
612 limit: Option<usize>,
613 offset: Option<usize>,
614}
615
616pub async fn list_feedback(
624 State(state): State<AppState>,
625 Query(q): Query<FeedbackQuery>,
626) -> Result<Json<Value>, ApiError> {
627 let engine = state.engine.lock().await;
628 let store = AsgFeedbackStore::from_engine(&engine);
629 let entries = store
630 .list_all(&engine.ref_name)
631 .map_err(|e| ApiError::Internal(e.to_string()))?;
632 let scanned = entries.len();
633
634 let needle =
635 q.q.as_deref()
636 .map(str::to_lowercase)
637 .filter(|s| !s.is_empty());
638 let (limit, offset) = page(q.limit, q.offset);
639 let now = chrono::Utc::now();
640
641 let verdict_of = |e: &agentstatedeveloper_core::FeedbackEntry| format!("{:?}", e.verdict);
642
643 let base: Vec<_> = entries
644 .into_iter()
645 .filter(|e| {
646 needle.as_deref().is_none_or(|n| {
647 contains_ci(&e.query, n)
648 || contains_ci(&e.symbol_qname, n)
649 || contains_ci(&e.author, n)
650 || e.note.as_deref().is_some_and(|s| contains_ci(s, n))
651 })
652 })
653 .collect();
654
655 let mut verdicts = BTreeMap::new();
656 let mut authors = BTreeMap::new();
657 for e in &base {
658 bump(&mut verdicts, &verdict_of(e), 1);
659 bump(&mut authors, &e.author, 1);
660 }
661
662 let mut matched: Vec<_> = base
663 .into_iter()
664 .filter(|e| {
665 q.verdict
666 .as_deref()
667 .is_none_or(|v| eq_ci(&verdict_of(e), v))
668 })
669 .filter(|e| q.author.as_deref().is_none_or(|a| eq_ci(&e.author, a)))
670 .filter(|e| {
671 q.symbol
672 .as_deref()
673 .is_none_or(|s| contains_ci(&e.symbol_qname, &s.to_lowercase()))
674 })
675 .collect();
676 matched.sort_by(|a, b| b.created_at.cmp(&a.created_at));
677
678 let total = matched.len();
679 let items: Vec<Value> = matched
680 .into_iter()
681 .skip(offset)
682 .take(limit)
683 .map(|e| {
684 json!({
685 "entry_id": e.entry_id,
686 "symbol_id": e.symbol_id,
687 "symbol_qname": e.symbol_qname,
688 "query": e.query,
689 "verdict": verdict_of(&e),
690 "author": e.author,
691 "created_at": e.created_at,
692 "note": e.note,
693 "file_scope": e.file_scope,
694 "expires_at": e.expires_at,
695 "expired": e.expires_at.is_some_and(|t| t < now),
696 "withdrawn_at": e.withdrawn_at,
697 "withdrawn_by": e.withdrawn_by,
698 "withdrawn_reason": e.withdrawn_reason,
699 "withdrawn": e.is_withdrawn(),
700 "inert": e.is_inert(),
704 })
705 })
706 .collect();
707
708 Ok(Json(json!({
709 "total": total,
710 "offset": offset,
711 "limit": limit,
712 "scanned": scanned,
713 "items": items,
714 "facets": {
715 "verdicts": facet(verdicts),
716 "authors": facet(authors),
717 },
718 })))
719}
720
721pub async fn index_health(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
733 let engine = state.engine.lock().await;
734 let ref_name = engine.ref_name.clone();
735
736 let asg_symbols = {
737 let prefix = format!(
738 "{}/index/by-qname",
739 agentstatedeveloper_core::ASD_PATH_PREFIX
740 );
741 match engine.repo.get_tree(&ref_name, &prefix) {
742 Ok(Value::Object(map)) => map.len(),
743 _ => 0,
744 }
745 };
746
747 let fts = SearchFtsDb::open(&state.db_path).ok();
748 let fts_symbols = fts.as_ref().map(|f| f.symbol_count()).unwrap_or(0);
749 let fts_rows = fts.as_ref().map(|f| f.fts_symbol_row_count()).unwrap_or(0);
750 let feedback_entries = fts.as_ref().map(|f| f.feedback_count()).unwrap_or(0);
751 let annotated = fts
752 .as_ref()
753 .map(|f| f.annotated_symbol_count(&ref_name))
754 .unwrap_or(0);
755 let indexed_at = fts.as_ref().and_then(|f| f.last_indexed_at());
756
757 let stale = stale_warning_classified(&state.db_path, 3600);
758 let db_bytes = std::fs::metadata(&state.db_path).map(|m| m.len()).ok();
759
760 Ok(Json(json!({
761 "db_path": state.db_path.display().to_string(),
762 "db_bytes": db_bytes,
763 "ref_name": ref_name,
764 "indexed_at": indexed_at,
765 "indexed_age": indexed_at.map(|t| {
766 let age = chrono::Utc::now().timestamp().saturating_sub(t).max(0) as u64;
767 json!({ "secs": age, "human": format_age(t) })
769 }),
770 "symbols": {
771 "asg": asg_symbols,
772 "fts": fts_symbols,
773 "fts_rows": fts_rows,
774 "annotated": annotated,
775 },
776 "feedback_entries": feedback_entries,
777 "consistency": compute_index_consistency(asg_symbols, fts_symbols),
778 "stale": stale.map(|w| json!({
779 "message": w.message,
780 "severity": w.severity,
781 "age_secs": w.age_secs,
782 })),
783 })))
784}
785
786#[derive(Deserialize)]
791pub struct ScorecardQuery {
792 scope: Option<String>,
794 paths: Option<String>,
796 drill_down: Option<String>,
799 limit: Option<usize>,
801}
802
803pub async fn scorecard(
812 State(state): State<AppState>,
813 Query(q): Query<ScorecardQuery>,
814) -> Result<Json<Value>, ApiError> {
815 let engine = state.engine.lock().await;
816 let card = core_scorecard::compute(
817 &engine,
818 &state.db_path,
819 &core_scorecard::ScorecardOptions {
820 scope: q.scope.as_deref(),
821 paths: q.paths.as_deref(),
822 drill_down: q.drill_down.as_deref(),
823 drill_limit: q.limit.unwrap_or(50).clamp(1, 2_000),
824 },
825 );
826
827 let mut out = card.to_json();
828 let obj = out.as_object_mut().expect("to_json built an object");
829 obj.insert("timestamp".into(), json!(chrono::Utc::now().to_rfc3339()));
830 if card.matched_nothing {
831 obj.insert(
834 "note".into(),
835 json!(if card.scoped {
836 "no symbols matched the paths filter"
837 } else {
838 "no symbols indexed — run `asd index` first"
839 }),
840 );
841 }
842 Ok(Json(out))
843}
844
845#[derive(Deserialize, Default)]
852pub struct FeedbackActionBody {
853 #[serde(default)]
855 pub by: Option<String>,
856 #[serde(default)]
859 pub reason: Option<String>,
860}
861
862pub async fn withdraw_feedback(
873 State(state): State<AppState>,
874 axum::extract::Path(entry_id): axum::extract::Path<String>,
875 body: Option<Json<FeedbackActionBody>>,
876) -> Result<Json<Value>, ApiError> {
877 let body = body.map(|Json(b)| b).unwrap_or_default();
878 let engine = state.engine.lock().await;
879 let store = AsgFeedbackStore::from_engine(&engine);
880 let by = body.by.as_deref().unwrap_or("asd-lens");
881
882 match store.withdraw(&engine.ref_name, &entry_id, by, body.reason.as_deref()) {
883 Ok(Some(e)) => Ok(Json(feedback_json(&e))),
884 Ok(None) => Err(ApiError::NotFound(format!("no feedback entry {entry_id}"))),
885 Err(e) => Err(ApiError::Internal(e.to_string())),
886 }
887}
888
889pub async fn expire_feedback(
895 State(state): State<AppState>,
896 axum::extract::Path(entry_id): axum::extract::Path<String>,
897) -> Result<Json<Value>, ApiError> {
898 let engine = state.engine.lock().await;
899 let store = AsgFeedbackStore::from_engine(&engine);
900
901 let Some(existing) = store
902 .list_all(&engine.ref_name)
903 .map_err(|e| ApiError::Internal(e.to_string()))?
904 .into_iter()
905 .find(|e| e.entry_id == entry_id)
906 else {
907 return Err(ApiError::NotFound(format!("no feedback entry {entry_id}")));
908 };
909 if existing.is_expired() {
912 return Ok(Json(feedback_json(&existing)));
913 }
914 let mut lapsed = existing;
915 lapsed.expires_at = Some(chrono::Utc::now());
916 store
917 .record(&engine.ref_name, &lapsed, &lapsed.author.clone())
918 .map_err(|e| ApiError::Internal(e.to_string()))?;
919 Ok(Json(feedback_json(&lapsed)))
920}
921
922fn feedback_json(e: &agentstatedeveloper_core::FeedbackEntry) -> Value {
925 json!({
926 "entry_id": e.entry_id,
927 "symbol_id": e.symbol_id,
928 "symbol_qname": e.symbol_qname,
929 "query": e.query,
930 "verdict": format!("{:?}", e.verdict),
931 "author": e.author,
932 "created_at": e.created_at,
933 "note": e.note,
934 "file_scope": e.file_scope,
935 "expires_at": e.expires_at,
936 "expired": e.is_expired(),
937 "withdrawn_at": e.withdrawn_at,
938 "withdrawn_by": e.withdrawn_by,
939 "withdrawn_reason": e.withdrawn_reason,
940 "withdrawn": e.is_withdrawn(),
941 "inert": e.is_inert(),
942 })
943}