Skip to main content

agentstatedeveloper_mcp/
metrics.rs

1//! Distilled-metrics API — the record ASD keeps of itself, made searchable.
2//!
3//! Two families live here.
4//!
5//! **1. What survives a prune.** ASG Plan A distills the commit chain into
6//! `asg_history_commit_rollup` (one row per day × namespace × agent ×
7//! intent) and `asg_history_milestone` (the named spine — each row pins a
8//! `state_root` that Plan B's GC must keep reachable). Plan B's sweep then
9//! reclaims the raw objects those rows were derived from. `/api/v1/history`
10//! already charts the aggregate; the endpoints here expose the underlying
11//! *records* with search, filters and facets, and put the raw commit chain
12//! next to them so you can see what a sweep would drop against what the
13//! distilled tables preserve.
14//!
15//! **2. How healthy that record is.** The scorecard and index-freshness
16//! surfaces, until now reachable only from the CLI and MCP.
17//!
18//! All handlers are read-only. `refresh=1` — the default on every endpoint
19//! that reads the distilled tables, which includes `/commits` (its `on_spine`
20//! join and `distilled` total both come from them) — runs ASG's *incremental*
21//! extractor first, so a call made after new commits landed sees them. It
22//! no-ops once the cursor is caught up.
23
24use 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
39/// Batch size for the incremental history extractor. Matches the value
40/// `/api/v1/history` passes through `history_report(refresh = true)`.
41const HISTORY_BATCH: usize = 5_000;
42
43/// Upper bound on rows pulled out of the distilled tables before filtering.
44/// Both are aggregates — a 5.7k-commit repo distills to ~40 rollup rows and
45/// ~95 milestones — so this is a runaway guard, not a real ceiling.
46const DISTILLED_SCAN_CAP: usize = 200_000;
47
48/// Default number of raw commits walked by `/api/v1/commits` before
49/// filtering. The chain is walked parent-by-parent, so this bounds the work
50/// a single request can do on a large store. Raise per-request with `scan`.
51const 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
57// -----------------------------------------------------------------------------
58// Shared helpers
59// -----------------------------------------------------------------------------
60
61fn truthy(v: Option<&str>) -> bool {
62    matches!(v, Some("1") | Some("true") | Some("yes"))
63}
64
65/// `refresh` defaults to on: a Lens page that shows stale records is worse
66/// than one that costs an incremental extract.
67fn 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
86/// Case-insensitive exact match, used for the categorical filters so
87/// `?agent=Craig Brown` and `?agent=craig brown` behave the same.
88fn eq_ci(a: &str, b: &str) -> bool {
89    a.eq_ignore_ascii_case(b)
90}
91
92/// `day` fields are `YYYY-MM-DD`, so an inclusive range is a lexical compare.
93fn 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
97/// Rank a facet's values by count (descending), ties broken by name so the
98/// UI's filter chips don't reshuffle between identical requests.
99fn 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
111/// Run ASG's incremental history extractor. Non-fatal: a store whose engine
112/// predates Plan A still serves whatever rows already exist rather than
113/// failing the whole request.
114fn refresh_history(engine: &Engine) {
115    let _ = engine.repo.extract_history(HISTORY_BATCH);
116}
117
118// -----------------------------------------------------------------------------
119// GET /api/v1/history/milestones — the named spine, searchable
120// -----------------------------------------------------------------------------
121
122#[derive(Deserialize)]
123pub struct MilestoneQuery {
124    /// Free text over description, commit id, kind, agent and namespace.
125    q: Option<String>,
126    kind: Option<String>,
127    namespace: Option<String>,
128    agent: Option<String>,
129    /// Inclusive `YYYY-MM-DD` bounds on the milestone's day.
130    from: Option<String>,
131    to: Option<String>,
132    limit: Option<usize>,
133    offset: Option<usize>,
134    refresh: Option<String>,
135}
136
137/// GET /api/v1/history/milestones — every milestone ASG distilled out of the
138/// commit chain, filtered and paginated.
139///
140/// A milestone is the unit that *names* a state a prune must preserve:
141/// `state_root` is the retention hook Plan B's GC keeps reachable. Pinning is
142/// opt-in — a checkpoint keeps its snapshot only when its intent carries
143/// `TAG_PIN_STATE` — so `pins_state: false` is the ordinary case for routine
144/// checkpoints such as a re-index, not a defect. (Rows written before Plan A
145/// t-005 also report `false`, for the older reason that the column did not
146/// exist.) An unpinned row survives as a description and a `git_sha`: the
147/// snapshot is reclaimable, but the revision that produced it is recorded, so
148/// the state can be rebuilt from source. That distinction is exactly what an
149/// operator wants to see before running a sweep.
150///
151/// Facets are computed over the set matching `q` + the date range but *not*
152/// the categorical filters, so the chip counts stay meaningful while you
153/// toggle kind/namespace/agent.
154pub 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    // Pass 1: text + date. This is the set the facets describe.
176    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    // Pass 2: categorical filters, then page.
200    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    // Newest first — the spine reads backwards from now.
211    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// -----------------------------------------------------------------------------
252// GET /api/v1/history/rollup — the distilled commit rollup, searchable
253// -----------------------------------------------------------------------------
254
255#[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
268/// GET /api/v1/history/rollup — the rows behind the `/history` charts.
269///
270/// One row per day × namespace × agent × intent category. This is what the
271/// velocity series, intent mix and authorship breakdown are summed from, and
272/// what remains after a sweep has reclaimed the commits themselves.
273pub 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    // Totals describe the whole filtered set, not just the visible page —
339    // a page footer that only summed 100 of 4,000 rows would be a lie.
340    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// -----------------------------------------------------------------------------
382// GET /api/v1/commits — the raw chain a sweep would reclaim
383// -----------------------------------------------------------------------------
384
385#[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    /// Only commits that are (`1`) / are not (`0`) on the milestone spine.
393    milestone: Option<String>,
394    limit: Option<usize>,
395    offset: Option<usize>,
396    /// How far back along the parent chain to walk before filtering.
397    scan: Option<usize>,
398    refresh: Option<String>,
399}
400
401/// GET /api/v1/commits — the raw commit chain, searchable, each row marked
402/// with whether the distilled tables preserve it.
403///
404/// `on_spine` is the join against `asg_history_milestone`: true means a
405/// milestone names this commit and pins its `state_root`, so a sweep keeps
406/// it reachable. False means the commit's objects are candidates for
407/// reclamation once retention lets them go — its contribution to the record
408/// survives only as a `+1` in the rollup.
409///
410/// The walk covers the full parent DAG, not just first parents. This
411/// matters: `Repository::log` follows `parents.first()` only, which on this
412/// repo reaches 4,268 of 5,896 commits — and the 1,628 it skips are merge
413/// second-parents, exactly the population most likely to fall out of
414/// reachability and be reclaimed. A page about what a prune would take
415/// cannot be the one that hides them.
416///
417/// The response reports `scanned` against `distilled` (the rollup's own
418/// commit total). Read that difference carefully — an earlier version of this
419/// comment called it "already-garbage that a sweep would drop", and on this
420/// very store that was wrong in both directions:
421///
422/// - The walk starts at ONE ref head. This store holds two namespaces
423///   (`default/main` and `AgentStateDeveloper/main`) whose histories are
424///   disjoint, so 1,628 commits the walk never reaches are the `default`
425///   namespace's own live history, not garbage. GC agrees: `gc_default_roots`
426///   roots every ref in every namespace, which is why it reports ~5%
427///   reclaimable rather than the ~28% a single-ref walk implies.
428/// - `distilled` is the extractor's cursor position, not the store's size. If
429///   the extractor is behind, `distilled` UNDER-reports and the difference
430///   inverts.
431///
432/// So the honest reading is "commits this ref does not reach, plus whatever
433/// the extractor has not yet folded in" — a prompt to look, not a garbage
434/// estimate. `capped: true` means the walk hit `scan` first, so the counts
435/// describe a window rather than the whole store.
436pub 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    // This endpoint reads the distilled tables too — `on_spine` and
444    // `distilled` both come from them. Without the refresh, a store whose
445    // extractor has never run reports every commit as unpinned, which is
446    // exactly backwards from the truth it's meant to show.
447    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    // The full-parent-DAG walk now lives in the engine (ASG v1.2.0,
457    // `Repository::log_dag`). This used to be a hand-rolled BFS here because
458    // `log` follows first parents only; the engine does the same walk with
459    // the same semantics — deduped by id, a pruned parent ending that edge
460    // rather than erroring — so the workaround is gone.
461    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    // The rollup's own total. Larger than `scanned` ⇒ commits exist in the
470    // store that the ref head no longer reaches.
471    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    // The spine, keyed by full id so the join can't collide on a short hash.
478    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    // Counted over the fully-filtered set, so `on_spine` and `total` are the
558    // same population — a summary line pairing one against the other is only
559    // honest if they were filtered the same way.
560    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// -----------------------------------------------------------------------------
601// GET /api/v1/feedback — recorded search verdicts
602// -----------------------------------------------------------------------------
603
604#[derive(Deserialize)]
605pub struct FeedbackQuery {
606    /// Free text over query, symbol qname, author and note.
607    q: Option<String>,
608    verdict: Option<String>,
609    author: Option<String>,
610    /// Substring match on the symbol's qname.
611    symbol: Option<String>,
612    limit: Option<usize>,
613    offset: Option<usize>,
614}
615
616/// GET /api/v1/feedback — every recorded (query, symbol) verdict.
617///
618/// This is the corrective signal `apply_feedback_adjustments` reads when
619/// ranking search results, so being able to search it is how you find out
620/// *why* a query ranks the way it does. Expired entries (Plan J t-014) are
621/// listed with `expired: true` rather than hidden — an expired verdict still
622/// explains a past ranking.
623pub 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                // The single question a caller actually has: is this still
701                // shaping search results? Derived here so the UI does not
702                // reimplement the predicate and drift from `flat_verdicts`.
703                "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
721// -----------------------------------------------------------------------------
722// GET /api/v1/index-health — is the record current?
723// -----------------------------------------------------------------------------
724
725/// GET /api/v1/index-health — index freshness and the ASG↔FTS consistency
726/// check, i.e. whether the metrics on the other endpoints describe the
727/// codebase as it stands now.
728///
729/// Mirrors what `asd status` prints. `consistency` is `null` when the two
730/// indexes agree — `compute_index_consistency` deliberately returns nothing
731/// to say "fine", so absence is the healthy case.
732pub 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            // `format_age` takes the timestamp, not the elapsed seconds.
768            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// -----------------------------------------------------------------------------
787// GET /api/v1/scorecard — the five-dimension benchmark
788// -----------------------------------------------------------------------------
789
790#[derive(Deserialize)]
791pub struct ScorecardQuery {
792    /// Named scope alias from `.asd/scopes.toml`.
793    scope: Option<String>,
794    /// Comma-separated glob patterns restricting which files are scored.
795    paths: Option<String>,
796    /// Per-symbol gap listing for one dimension: truth / change / workflow /
797    /// uncertainty.
798    drill_down: Option<String>,
799    /// Cap on the drill-down rows returned.
800    limit: Option<usize>,
801}
802
803/// GET /api/v1/scorecard — truth / feedback / change / uncertainty /
804/// workflow, each 0-100, plus the data-quality and token-economy blocks.
805///
806/// The arithmetic lives in `core::scorecard`, shared with `asd scorecard`
807/// and the `scorecard` MCP tool. What differs here is the envelope: no
808/// snapshot-history side effect, because an HTTP read must not write the
809/// trend file the next CLI run compares against — which is why `--trend`
810/// stays CLI-only.
811pub 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        // Phrased for an API consumer: the CLI's "try broadening
832        // --scope/--paths" names flags that do not exist over HTTP.
833        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// -----------------------------------------------------------------------------
846// Feedback lifecycle actions (plan feedback-lifecycle t-006)
847// -----------------------------------------------------------------------------
848
849/// Body for the two lifecycle POSTs. Both fields optional so a caller can
850/// `POST` an empty object and get sensible defaults.
851#[derive(Deserialize, Default)]
852pub struct FeedbackActionBody {
853    /// Who performed it. Recorded on the entry for withdrawal.
854    #[serde(default)]
855    pub by: Option<String>,
856    /// Why. Stored with a withdrawal; ignored for expiry, which has no
857    /// judgement attached to it.
858    #[serde(default)]
859    pub reason: Option<String>,
860}
861
862/// POST /api/v1/feedback/{entry_id}/withdraw — retract a wrong verdict.
863///
864/// Returns the updated entry, matching the approvals endpoints' shape, so the
865/// UI can patch one row rather than refetching the list.
866///
867/// Deliberately NOT paired with a purge endpoint. Purge hard-deletes from an
868/// otherwise append-only store and exists for data that must not persist
869/// (a secret pasted into a note); that belongs behind a CLI `--yes`, not one
870/// click in a browser. Withdrawal is the reversible-in-spirit action and the
871/// one worth having where you notice the problem.
872pub 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
889/// POST /api/v1/feedback/{entry_id}/expire — lapse a verdict that was right
890/// but is no longer relevant.
891///
892/// Sets `expires_at` to now via the same re-record path the CLI uses, so both
893/// the store and the SQLite cache update together.
894pub 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    // Idempotent, same as the CLI: re-expiring must not push the timestamp
910    // forward.
911    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
922/// One entry in the shape the Feedback tab consumes. Shared by the list and
923/// both actions so a patched row cannot drift from a listed one.
924fn 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}