use std::collections::{BTreeMap, HashSet};
use agentstatedeveloper_core::{
AsgFeedbackStore, Engine, FeedbackStore, SearchFtsDb, compute_index_consistency, format_age,
scorecard as core_scorecard, stale_warning_classified,
};
use axum::{
Json,
extract::{Query, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{ApiError, AppState};
const HISTORY_BATCH: usize = 5_000;
const DISTILLED_SCAN_CAP: usize = 200_000;
const COMMIT_SCAN_DEFAULT: usize = 5_000;
const COMMIT_SCAN_MAX: usize = 100_000;
const PAGE_DEFAULT: usize = 100;
const PAGE_MAX: usize = 1_000;
fn truthy(v: Option<&str>) -> bool {
matches!(v, Some("1") | Some("true") | Some("yes"))
}
fn refresh_requested(v: Option<&str>) -> bool {
match v {
None => true,
Some("0") | Some("false") | Some("no") => false,
_ => true,
}
}
fn page(limit: Option<usize>, offset: Option<usize>) -> (usize, usize) {
(
limit.unwrap_or(PAGE_DEFAULT).clamp(1, PAGE_MAX),
offset.unwrap_or(0),
)
}
fn contains_ci(haystack: &str, needle: &str) -> bool {
haystack.to_lowercase().contains(needle)
}
fn eq_ci(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
fn in_day_range(day: &str, from: Option<&str>, to: Option<&str>) -> bool {
from.is_none_or(|f| day >= f) && to.is_none_or(|t| day <= t)
}
fn facet(counts: BTreeMap<String, usize>) -> Vec<Value> {
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v.into_iter()
.map(|(value, count)| json!({ "value": value, "count": count }))
.collect()
}
fn bump(counts: &mut BTreeMap<String, usize>, key: &str, by: usize) {
*counts.entry(key.to_string()).or_default() += by;
}
fn refresh_history(engine: &Engine) {
let _ = engine.repo.extract_history(HISTORY_BATCH);
}
#[derive(Deserialize)]
pub struct MilestoneQuery {
q: Option<String>,
kind: Option<String>,
namespace: Option<String>,
agent: Option<String>,
from: Option<String>,
to: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
refresh: Option<String>,
}
pub async fn list_milestones(
State(state): State<AppState>,
Query(q): Query<MilestoneQuery>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
if refresh_requested(q.refresh.as_deref()) {
refresh_history(&engine);
}
let rows = engine
.repo
.history_milestones(DISTILLED_SCAN_CAP)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let scanned = rows.len();
let needle =
q.q.as_deref()
.map(str::to_lowercase)
.filter(|s| !s.is_empty());
let (limit, offset) = page(q.limit, q.offset);
let base: Vec<_> = rows
.into_iter()
.filter(|m| in_day_range(&m.day, q.from.as_deref(), q.to.as_deref()))
.filter(|m| {
needle.as_deref().is_none_or(|n| {
contains_ci(&m.description, n)
|| contains_ci(&m.commit_id.short(), n)
|| contains_ci(&m.kind, n)
|| contains_ci(&m.agent_id, n)
|| contains_ci(&m.namespace, n)
})
})
.collect();
let mut kinds = BTreeMap::new();
let mut namespaces = BTreeMap::new();
let mut agents = BTreeMap::new();
for m in &base {
bump(&mut kinds, &m.kind, 1);
bump(&mut namespaces, &m.namespace, 1);
bump(&mut agents, &m.agent_id, 1);
}
let mut matched: Vec<_> = base
.into_iter()
.filter(|m| q.kind.as_deref().is_none_or(|k| eq_ci(&m.kind, k)))
.filter(|m| {
q.namespace
.as_deref()
.is_none_or(|n| eq_ci(&m.namespace, n))
})
.filter(|m| q.agent.as_deref().is_none_or(|a| eq_ci(&m.agent_id, a)))
.collect();
matched.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
let total = matched.len();
let unpinned = matched.iter().filter(|m| m.state_root.is_none()).count();
let items: Vec<Value> = matched
.into_iter()
.skip(offset)
.take(limit)
.map(|m| {
json!({
"commit": m.commit_id.short(),
"commit_id": m.commit_id.to_string(),
"kind": m.kind,
"timestamp": m.timestamp,
"day": m.day,
"namespace": m.namespace,
"agent": m.agent_id,
"description": m.description,
"state_root": m.state_root.as_ref().map(|s| s.short()),
"pins_state": m.state_root.is_some(),
"git_sha": m.git_sha,
})
})
.collect();
Ok(Json(json!({
"total": total,
"offset": offset,
"limit": limit,
"scanned": scanned,
"unpinned": unpinned,
"items": items,
"facets": {
"kinds": facet(kinds),
"namespaces": facet(namespaces),
"agents": facet(agents),
},
})))
}
#[derive(Deserialize)]
pub struct RollupQuery {
q: Option<String>,
namespace: Option<String>,
agent: Option<String>,
intent: Option<String>,
from: Option<String>,
to: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
refresh: Option<String>,
}
pub async fn list_rollup(
State(state): State<AppState>,
Query(q): Query<RollupQuery>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
if refresh_requested(q.refresh.as_deref()) {
refresh_history(&engine);
}
let rows = engine
.repo
.history_rollup()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let scanned = rows.len();
let needle =
q.q.as_deref()
.map(str::to_lowercase)
.filter(|s| !s.is_empty());
let (limit, offset) = page(q.limit, q.offset);
let base: Vec<_> = rows
.into_iter()
.filter(|r| in_day_range(&r.day, q.from.as_deref(), q.to.as_deref()))
.filter(|r| {
needle.as_deref().is_none_or(|n| {
contains_ci(&r.day, n)
|| contains_ci(&r.agent_id, n)
|| contains_ci(&r.intent_category, n)
|| contains_ci(&r.namespace, n)
})
})
.collect();
let mut namespaces = BTreeMap::new();
let mut agents = BTreeMap::new();
let mut intents = BTreeMap::new();
for r in &base {
let n = r.commit_count.max(0) as usize;
bump(&mut namespaces, &r.namespace, n);
bump(&mut agents, &r.agent_id, n);
bump(&mut intents, &r.intent_category, n);
}
let mut matched: Vec<_> = base
.into_iter()
.filter(|r| {
q.namespace
.as_deref()
.is_none_or(|n| eq_ci(&r.namespace, n))
})
.filter(|r| q.agent.as_deref().is_none_or(|a| eq_ci(&r.agent_id, a)))
.filter(|r| {
q.intent
.as_deref()
.is_none_or(|i| eq_ci(&r.intent_category, i))
})
.collect();
matched.sort_by(|a, b| {
b.day
.cmp(&a.day)
.then_with(|| b.commit_count.cmp(&a.commit_count))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
let total = matched.len();
let commits: i64 = matched.iter().map(|r| r.commit_count).sum();
let days: HashSet<&str> = matched.iter().map(|r| r.day.as_str()).collect();
let day_span = {
let mut d: Vec<&str> = days.iter().copied().collect();
d.sort_unstable();
json!({ "first": d.first(), "last": d.last(), "count": d.len() })
};
let items: Vec<Value> = matched
.iter()
.skip(offset)
.take(limit)
.map(|r| {
json!({
"day": r.day,
"namespace": r.namespace,
"agent": r.agent_id,
"intent": r.intent_category,
"commits": r.commit_count,
"first_ts": r.first_ts,
"last_ts": r.last_ts,
})
})
.collect();
Ok(Json(json!({
"total": total,
"offset": offset,
"limit": limit,
"scanned": scanned,
"totals": { "commits": commits, "days": day_span },
"items": items,
"facets": {
"namespaces": facet(namespaces),
"agents": facet(agents),
"intents": facet(intents),
},
})))
}
#[derive(Deserialize)]
pub struct CommitQuery {
q: Option<String>,
agent: Option<String>,
intent: Option<String>,
from: Option<String>,
to: Option<String>,
milestone: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
scan: Option<usize>,
refresh: Option<String>,
}
pub async fn list_commits(
State(state): State<AppState>,
Query(q): Query<CommitQuery>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
let ref_name = engine.ref_name.clone();
if refresh_requested(q.refresh.as_deref()) {
refresh_history(&engine);
}
let scan = q
.scan
.unwrap_or(COMMIT_SCAN_DEFAULT)
.clamp(1, COMMIT_SCAN_MAX);
let walk = engine
.repo
.log_dag(&ref_name, scan)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let capped = walk.truncated;
let commits = &walk.commits[..];
let scanned = commits.len();
let distilled: i64 = engine
.repo
.history_rollup()
.map(|rows| rows.iter().map(|r| r.commit_count).sum())
.unwrap_or(0);
let spine: HashSet<String> = engine
.repo
.history_milestones(DISTILLED_SCAN_CAP)
.map(|rows| rows.into_iter().map(|m| m.commit_id.to_string()).collect())
.unwrap_or_default();
let needle =
q.q.as_deref()
.map(str::to_lowercase)
.filter(|s| !s.is_empty());
let (limit, offset) = page(q.limit, q.offset);
let want_milestone = q.milestone.as_deref().map(|v| truthy(Some(v)));
struct Row {
id: String,
short: String,
day: String,
timestamp: String,
agent: String,
intent: String,
description: String,
reasoning: Option<String>,
confidence: Option<f64>,
parents: usize,
state_root: String,
on_spine: bool,
}
let rows: Vec<Row> = commits
.iter()
.map(|c| {
let id = c.id.to_string();
let on_spine = spine.contains(&id);
Row {
short: c.id.short(),
day: c.timestamp.format("%Y-%m-%d").to_string(),
timestamp: c.timestamp.to_rfc3339(),
agent: c.agent_id.clone(),
intent: format!("{:?}", c.intent.category),
description: c.intent.description.clone(),
reasoning: c.reasoning.clone(),
confidence: c.confidence,
parents: c.parents.len(),
state_root: c.state_root.short(),
on_spine,
id,
}
})
.collect();
let base: Vec<&Row> = rows
.iter()
.filter(|r| in_day_range(&r.day, q.from.as_deref(), q.to.as_deref()))
.filter(|r| {
needle.as_deref().is_none_or(|n| {
contains_ci(&r.description, n)
|| contains_ci(&r.short, n)
|| contains_ci(&r.agent, n)
|| contains_ci(&r.intent, n)
|| r.reasoning.as_deref().is_some_and(|s| contains_ci(s, n))
})
})
.collect();
let mut agents = BTreeMap::new();
let mut intents = BTreeMap::new();
for r in &base {
bump(&mut agents, &r.agent, 1);
bump(&mut intents, &r.intent, 1);
}
let matched: Vec<&&Row> = base
.iter()
.filter(|r| q.agent.as_deref().is_none_or(|a| eq_ci(&r.agent, a)))
.filter(|r| q.intent.as_deref().is_none_or(|i| eq_ci(&r.intent, i)))
.filter(|r| want_milestone.is_none_or(|want| r.on_spine == want))
.collect();
let total = matched.len();
let on_spine_count = matched.iter().filter(|r| r.on_spine).count();
let items: Vec<Value> = matched
.into_iter()
.skip(offset)
.take(limit)
.map(|r| {
json!({
"commit": r.short,
"commit_id": r.id,
"timestamp": r.timestamp,
"day": r.day,
"agent": r.agent,
"intent": r.intent,
"description": r.description,
"reasoning": r.reasoning,
"confidence": r.confidence,
"parents": r.parents,
"state_root": r.state_root,
"on_spine": r.on_spine,
})
})
.collect();
Ok(Json(json!({
"total": total,
"offset": offset,
"limit": limit,
"scanned": scanned,
"capped": capped,
"scan": scan,
"distilled": distilled,
"on_spine": on_spine_count,
"items": items,
"facets": {
"agents": facet(agents),
"intents": facet(intents),
},
})))
}
#[derive(Deserialize)]
pub struct FeedbackQuery {
q: Option<String>,
verdict: Option<String>,
author: Option<String>,
symbol: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
}
pub async fn list_feedback(
State(state): State<AppState>,
Query(q): Query<FeedbackQuery>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
let store = AsgFeedbackStore::from_engine(&engine);
let entries = store
.list_all(&engine.ref_name)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let scanned = entries.len();
let needle =
q.q.as_deref()
.map(str::to_lowercase)
.filter(|s| !s.is_empty());
let (limit, offset) = page(q.limit, q.offset);
let now = chrono::Utc::now();
let verdict_of = |e: &agentstatedeveloper_core::FeedbackEntry| format!("{:?}", e.verdict);
let base: Vec<_> = entries
.into_iter()
.filter(|e| {
needle.as_deref().is_none_or(|n| {
contains_ci(&e.query, n)
|| contains_ci(&e.symbol_qname, n)
|| contains_ci(&e.author, n)
|| e.note.as_deref().is_some_and(|s| contains_ci(s, n))
})
})
.collect();
let mut verdicts = BTreeMap::new();
let mut authors = BTreeMap::new();
for e in &base {
bump(&mut verdicts, &verdict_of(e), 1);
bump(&mut authors, &e.author, 1);
}
let mut matched: Vec<_> = base
.into_iter()
.filter(|e| {
q.verdict
.as_deref()
.is_none_or(|v| eq_ci(&verdict_of(e), v))
})
.filter(|e| q.author.as_deref().is_none_or(|a| eq_ci(&e.author, a)))
.filter(|e| {
q.symbol
.as_deref()
.is_none_or(|s| contains_ci(&e.symbol_qname, &s.to_lowercase()))
})
.collect();
matched.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total = matched.len();
let items: Vec<Value> = matched
.into_iter()
.skip(offset)
.take(limit)
.map(|e| {
json!({
"entry_id": e.entry_id,
"symbol_id": e.symbol_id,
"symbol_qname": e.symbol_qname,
"query": e.query,
"verdict": verdict_of(&e),
"author": e.author,
"created_at": e.created_at,
"note": e.note,
"file_scope": e.file_scope,
"expires_at": e.expires_at,
"expired": e.expires_at.is_some_and(|t| t < now),
"withdrawn_at": e.withdrawn_at,
"withdrawn_by": e.withdrawn_by,
"withdrawn_reason": e.withdrawn_reason,
"withdrawn": e.is_withdrawn(),
"inert": e.is_inert(),
})
})
.collect();
Ok(Json(json!({
"total": total,
"offset": offset,
"limit": limit,
"scanned": scanned,
"items": items,
"facets": {
"verdicts": facet(verdicts),
"authors": facet(authors),
},
})))
}
pub async fn index_health(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
let ref_name = engine.ref_name.clone();
let asg_symbols = {
let prefix = format!(
"{}/index/by-qname",
agentstatedeveloper_core::ASD_PATH_PREFIX
);
match engine.repo.get_tree(&ref_name, &prefix) {
Ok(Value::Object(map)) => map.len(),
_ => 0,
}
};
let fts = SearchFtsDb::open(&state.db_path).ok();
let fts_symbols = fts.as_ref().map(|f| f.symbol_count()).unwrap_or(0);
let fts_rows = fts.as_ref().map(|f| f.fts_symbol_row_count()).unwrap_or(0);
let feedback_entries = fts.as_ref().map(|f| f.feedback_count()).unwrap_or(0);
let annotated = fts
.as_ref()
.map(|f| f.annotated_symbol_count(&ref_name))
.unwrap_or(0);
let indexed_at = fts.as_ref().and_then(|f| f.last_indexed_at());
let stale = stale_warning_classified(&state.db_path, 3600);
let db_bytes = std::fs::metadata(&state.db_path).map(|m| m.len()).ok();
Ok(Json(json!({
"db_path": state.db_path.display().to_string(),
"db_bytes": db_bytes,
"ref_name": ref_name,
"indexed_at": indexed_at,
"indexed_age": indexed_at.map(|t| {
let age = chrono::Utc::now().timestamp().saturating_sub(t).max(0) as u64;
json!({ "secs": age, "human": format_age(t) })
}),
"symbols": {
"asg": asg_symbols,
"fts": fts_symbols,
"fts_rows": fts_rows,
"annotated": annotated,
},
"feedback_entries": feedback_entries,
"consistency": compute_index_consistency(asg_symbols, fts_symbols),
"stale": stale.map(|w| json!({
"message": w.message,
"severity": w.severity,
"age_secs": w.age_secs,
})),
})))
}
#[derive(Deserialize)]
pub struct ScorecardQuery {
scope: Option<String>,
paths: Option<String>,
drill_down: Option<String>,
limit: Option<usize>,
}
pub async fn scorecard(
State(state): State<AppState>,
Query(q): Query<ScorecardQuery>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
let card = core_scorecard::compute(
&engine,
&state.db_path,
&core_scorecard::ScorecardOptions {
scope: q.scope.as_deref(),
paths: q.paths.as_deref(),
drill_down: q.drill_down.as_deref(),
drill_limit: q.limit.unwrap_or(50).clamp(1, 2_000),
},
);
let mut out = card.to_json();
let obj = out.as_object_mut().expect("to_json built an object");
obj.insert("timestamp".into(), json!(chrono::Utc::now().to_rfc3339()));
if card.matched_nothing {
obj.insert(
"note".into(),
json!(if card.scoped {
"no symbols matched the paths filter"
} else {
"no symbols indexed — run `asd index` first"
}),
);
}
Ok(Json(out))
}
#[derive(Deserialize, Default)]
pub struct FeedbackActionBody {
#[serde(default)]
pub by: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
pub async fn withdraw_feedback(
State(state): State<AppState>,
axum::extract::Path(entry_id): axum::extract::Path<String>,
body: Option<Json<FeedbackActionBody>>,
) -> Result<Json<Value>, ApiError> {
let body = body.map(|Json(b)| b).unwrap_or_default();
let engine = state.engine.lock().await;
let store = AsgFeedbackStore::from_engine(&engine);
let by = body.by.as_deref().unwrap_or("asd-lens");
match store.withdraw(&engine.ref_name, &entry_id, by, body.reason.as_deref()) {
Ok(Some(e)) => Ok(Json(feedback_json(&e))),
Ok(None) => Err(ApiError::NotFound(format!("no feedback entry {entry_id}"))),
Err(e) => Err(ApiError::Internal(e.to_string())),
}
}
pub async fn expire_feedback(
State(state): State<AppState>,
axum::extract::Path(entry_id): axum::extract::Path<String>,
) -> Result<Json<Value>, ApiError> {
let engine = state.engine.lock().await;
let store = AsgFeedbackStore::from_engine(&engine);
let Some(existing) = store
.list_all(&engine.ref_name)
.map_err(|e| ApiError::Internal(e.to_string()))?
.into_iter()
.find(|e| e.entry_id == entry_id)
else {
return Err(ApiError::NotFound(format!("no feedback entry {entry_id}")));
};
if existing.is_expired() {
return Ok(Json(feedback_json(&existing)));
}
let mut lapsed = existing;
lapsed.expires_at = Some(chrono::Utc::now());
store
.record(&engine.ref_name, &lapsed, &lapsed.author.clone())
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(feedback_json(&lapsed)))
}
fn feedback_json(e: &agentstatedeveloper_core::FeedbackEntry) -> Value {
json!({
"entry_id": e.entry_id,
"symbol_id": e.symbol_id,
"symbol_qname": e.symbol_qname,
"query": e.query,
"verdict": format!("{:?}", e.verdict),
"author": e.author,
"created_at": e.created_at,
"note": e.note,
"file_scope": e.file_scope,
"expires_at": e.expires_at,
"expired": e.is_expired(),
"withdrawn_at": e.withdrawn_at,
"withdrawn_by": e.withdrawn_by,
"withdrawn_reason": e.withdrawn_reason,
"withdrawn": e.is_withdrawn(),
"inert": e.is_inert(),
})
}