Skip to main content

agentstatedeveloper_mcp/
lib.rs

1//! HTTP server for AgentStateDeveloper (ASD) — powers the M2 Lens consumer.
2//!
3//! Exposes a read-only JSON API over the ASG-backed ASD state. See
4//! [`build_router`] for the full route surface. Binary entrypoint lives in
5//! `src/bin/asd-serve.rs`.
6//!
7//! Also hosts the MCP stdio server module [`mcp_server`] — wired up by the
8//! `asd-mcp` binary.
9
10pub mod events;
11pub mod mcp_params;
12pub mod mcp_server;
13pub mod metrics;
14
15pub use mcp_server::AsdMcpServer;
16
17use std::path::PathBuf;
18use std::sync::Arc;
19
20use agentstatedeveloper_core::{
21    AsdError, AsgEffectStore, AsgIndexStore, AsgLedgerStore, AuditEvent, EffectStore, Engine,
22    FtsFilters, IndexStore, LedgerStore, declared_effect_blast_radius, emit_audit, event_types,
23    explain_match, find_candidates, kind_str, list_all_effect_decls, parse_query,
24};
25use axum::http::HeaderValue;
26use axum::{
27    Json, Router,
28    extract::{Path, Query, State},
29    http::StatusCode,
30    response::{
31        IntoResponse, Response,
32        sse::{Event as SseEvent, KeepAlive, Sse},
33    },
34    routing::get,
35};
36use serde::{Deserialize, Serialize};
37use serde_json::json;
38use tokio::sync::Mutex;
39use tower_http::cors::{AllowOrigin, CorsLayer};
40use tower_http::services::{ServeDir, ServeFile};
41
42/// Engine is shared across handlers. The underlying `Repository` holds a
43/// `Box<dyn Storage>` trait object that is not guaranteed Send+Sync at the
44/// trait-object level, so we serialize access through an async Mutex.
45pub type SharedEngine = Arc<Mutex<Engine>>;
46
47#[derive(Clone)]
48pub struct AppState {
49    pub engine: SharedEngine,
50    pub db_path: PathBuf,
51    /// Optional audit log path. When Some, `/api/v1/audit` reads events
52    /// from this JSONL file; otherwise the endpoint returns an empty
53    /// list with a `configured: false` flag.
54    pub audit_log_path: Option<PathBuf>,
55    /// Live-events hub backing `/api/v1/events` (SSE). Spawns its poller
56    /// lazily on the first subscriber — see [`events::EventHub`].
57    pub events: Arc<events::EventHub>,
58    /// Memoized `symbol_id → Symbol` map keyed by the ASG ref head, shared by
59    /// the graph endpoints (`/callers`, `/callees`, `/graph`). On a cold FTS
60    /// cache `build_id_map` falls back to a full by-qname tree walk (~2s at
61    /// 10k symbols); this memo makes that a once-per-head cost instead of a
62    /// once-per-request cost. `repo.head` is the same cheap change cursor the
63    /// events poller uses, so writes landed by other processes invalidate the
64    /// memo on the next request.
65    id_map_memo: Arc<std::sync::Mutex<IdMapMemo>>,
66    /// Same memo scheme for the bulk call-edge maps `(callers_of,
67    /// callees_of)` used by the `/graph` BFS — one bulk read per head change
68    /// instead of one git read per visited node.
69    edge_maps_memo: Arc<std::sync::Mutex<EdgeMapsMemo>>,
70    /// Memoized `/gc/dry-run`, keyed the same way. Far and away the most
71    /// expensive read in the API — see [`gc_dry_run`].
72    gc_memo: Arc<std::sync::Mutex<GcMemo>>,
73    /// Admits one dry-run walk at a time so concurrent callers share a single
74    /// computation instead of queueing a full walk each.
75    gc_gate: Arc<Mutex<()>>,
76}
77
78type IdMapMemo = Option<(
79    agentstategraph_core::ObjectId,
80    Arc<std::collections::HashMap<String, agentstatedeveloper_core::Symbol>>,
81)>;
82
83/// `(callers_of, callees_of)`, each `symbol_id → [neighbor_id, …]`.
84type EdgeMaps = (
85    std::collections::HashMap<String, Vec<String>>,
86    std::collections::HashMap<String, Vec<String>>,
87);
88
89type EdgeMapsMemo = Option<(agentstategraph_core::ObjectId, Arc<EdgeMaps>)>;
90
91/// `(head, computed_at, adapted payload)` for the memoized GC dry run.
92type GcMemo = Option<(
93    agentstategraph_core::ObjectId,
94    chrono::DateTime<chrono::Utc>,
95    serde_json::Value,
96)>;
97
98/// Fetch the shared `symbol_id → Symbol` map, rebuilding only when the ref
99/// head has moved since the last build. Falls back to an unmemoized build
100/// when the head can't be resolved — never worse than one bulk read per
101/// request. Callers hold the engine mutex, so the brief std-mutex locks here
102/// never contend across an `.await`.
103fn shared_id_map(
104    state: &AppState,
105    engine: &Engine,
106) -> Arc<std::collections::HashMap<String, agentstatedeveloper_core::Symbol>> {
107    let head = engine.repo.head(&engine.ref_name).ok();
108    if let Some(h) = &head {
109        if let Some((cached_head, map)) = state
110            .id_map_memo
111            .lock()
112            .expect("id_map_memo poisoned")
113            .as_ref()
114        {
115            if cached_head == h {
116                return Arc::clone(map);
117            }
118        }
119    }
120    let index = AsgIndexStore::from_engine(engine);
121    let map = Arc::new(index.build_id_map(engine));
122    if let Some(h) = head {
123        *state.id_map_memo.lock().expect("id_map_memo poisoned") = Some((h, Arc::clone(&map)));
124    }
125    map
126}
127
128/// Fetch the shared bulk call-edge maps, rebuilding only when the ref head
129/// has moved. Same contract as [`shared_id_map`].
130fn shared_edge_maps(state: &AppState, engine: &Engine) -> Arc<EdgeMaps> {
131    let head = engine.repo.head(&engine.ref_name).ok();
132    if let Some(h) = &head {
133        if let Some((cached_head, maps)) = state
134            .edge_maps_memo
135            .lock()
136            .expect("edge_maps_memo poisoned")
137            .as_ref()
138        {
139            if cached_head == h {
140                return Arc::clone(maps);
141            }
142        }
143    }
144    let index = AsgIndexStore::from_engine(engine);
145    let maps = Arc::new(index.build_edge_maps(engine));
146    if let Some(h) = head {
147        *state
148            .edge_maps_memo
149            .lock()
150            .expect("edge_maps_memo poisoned") = Some((h, Arc::clone(&maps)));
151    }
152    maps
153}
154
155/// Build the axum router wired to the given engine + db path.
156///
157/// If `lens_dir` is Some and the directory exists, it's mounted as a static
158/// fallback so the Lens SPA can be served alongside the API. Otherwise
159/// unmatched non-API requests 404 gracefully.
160///
161/// `cors_permissive`: when true, allows any origin (dev convenience). When
162/// false (the default), only `http://localhost:*` and `http://127.0.0.1:*`
163/// are permitted. Set via `ASD_CORS_PERMISSIVE=1` or `--cors-permissive`.
164pub fn build_router(
165    engine: SharedEngine,
166    db_path: PathBuf,
167    lens_dir: Option<PathBuf>,
168    audit_log_path: Option<PathBuf>,
169    cors_permissive: bool,
170) -> Router {
171    let events = events::EventHub::new(&engine, audit_log_path.clone());
172    let state = AppState {
173        engine,
174        db_path,
175        audit_log_path,
176        events,
177        id_map_memo: Arc::new(std::sync::Mutex::new(None)),
178        edge_maps_memo: Arc::new(std::sync::Mutex::new(None)),
179        gc_memo: Arc::new(std::sync::Mutex::new(None)),
180        gc_gate: Arc::new(Mutex::new(())),
181    };
182
183    let api = Router::new()
184        .route("/health", get(health))
185        .route("/events", get(events_stream))
186        .route("/search", get(search_symbols))
187        .route("/effects/overview", get(effects_overview))
188        .route("/timeline", get(list_timeline))
189        .route("/history", get(list_history))
190        .route("/gc/dry-run", get(gc_dry_run))
191        .route("/gc/sweep", axum::routing::post(gc_sweep_preview))
192        // Searchable views over the same distilled record the charts
193        // summarize, plus the health surfaces — see [`metrics`].
194        .route("/history/milestones", get(metrics::list_milestones))
195        .route("/history/rollup", get(metrics::list_rollup))
196        .route("/commits", get(metrics::list_commits))
197        .route("/feedback", get(metrics::list_feedback))
198        .route(
199            "/feedback/{entry_id}/withdraw",
200            axum::routing::post(metrics::withdraw_feedback),
201        )
202        .route(
203            "/feedback/{entry_id}/expire",
204            axum::routing::post(metrics::expire_feedback),
205        )
206        .route("/index-health", get(metrics::index_health))
207        .route("/scorecard", get(metrics::scorecard))
208        .route("/files", get(list_files))
209        .route("/files/{*path}", get(read_file))
210        .route("/symbols", get(list_symbols))
211        .route("/symbols/{qname}", get(get_symbol))
212        .route("/symbols/{qname}/graph", get(get_symbol_graph))
213        .route("/symbols/{qname}/ledger", get(get_symbol_ledger))
214        .route("/symbols/{qname}/effects", get(get_symbol_effects))
215        .route("/symbols/{qname}/callers", get(get_symbol_callers))
216        .route("/symbols/{qname}/callees", get(get_symbol_callees))
217        .route("/ledger", get(list_ledger))
218        .route("/thinking", get(list_thinking))
219        .route("/symbols/{qname}/thinking", get(get_symbol_thinking))
220        .route("/audit", get(list_audit))
221        .route("/audit/verify", get(verify_audit))
222        .route(
223            "/approvals/{entry_id}/approve",
224            axum::routing::post(approve_entry),
225        )
226        .route(
227            "/approvals/{entry_id}/reject",
228            axum::routing::post(reject_entry),
229        )
230        .route(
231            "/approvals/{entry_id}/withdraw",
232            axum::routing::post(withdraw_entry),
233        );
234
235    let mut router = Router::new().nest("/api/v1", api);
236
237    if let Some(dir) = lens_dir {
238        if dir.exists() {
239            // SPA fallback: ServeDir handles static assets; its own
240            // `.fallback()` takes a service, and `ServeFile` serves one
241            // specific file regardless of the request URL. So any path
242            // that doesn't resolve to a static asset falls through to
243            // index.html and client-side routing (e.g. /audit,
244            // /approvals) takes over.
245            let index = dir.join("index.html");
246            router = router.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)));
247        }
248    }
249
250    let cors = if cors_permissive {
251        CorsLayer::permissive()
252    } else {
253        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _| {
254            let b = origin.as_bytes();
255            b.starts_with(b"http://localhost") || b.starts_with(b"http://127.0.0.1")
256        }))
257    };
258
259    router
260        .with_state(state)
261        .layer(cors)
262        // A span, a request log line and duration metrics per request, named
263        // by the OpenTelemetry HTTP conventions.
264        .layer(oxtel_tower::HttpServerLayer::new())
265}
266
267// -----------------------------------------------------------------------------
268// Error type
269// -----------------------------------------------------------------------------
270
271pub enum ApiError {
272    BadRequest(String),
273    /// The request is well-formed but this server will not carry it out — e.g.
274    /// a GC sweep asked to delete, which is CLI-only.
275    Forbidden(String),
276    NotFound(String),
277    Internal(String),
278}
279
280impl IntoResponse for ApiError {
281    fn into_response(self) -> Response {
282        let (status, msg) = match self {
283            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
284            ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
285            ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
286            ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
287        };
288        (status, Json(json!({ "error": msg }))).into_response()
289    }
290}
291
292impl From<agentstatedeveloper_core::AsdError> for ApiError {
293    fn from(e: agentstatedeveloper_core::AsdError) -> Self {
294        ApiError::Internal(e.to_string())
295    }
296}
297
298// -----------------------------------------------------------------------------
299// Project history + store health / GC (ASG Plan A / Plan B) — powers the Lens
300// `/history` page. These proxy the ASG `Repository` history/GC surface exposed
301// in agentstategraph ≥ 0.9.22; the JSON shapes match the lens-core
302// `HistoryReport` / `GcDryRun` types.
303// -----------------------------------------------------------------------------
304
305#[derive(Deserialize)]
306struct HistoryQuery {
307    /// Velocity bucket granularity: `day` (default) or `week`.
308    granularity: Option<String>,
309    /// Milestone cap (default 50).
310    limit: Option<usize>,
311    /// Include the physical `store_shape` (a `dbstat` scan). Accepts `1`/`true`.
312    store: Option<String>,
313}
314
315fn truthy(v: Option<&str>) -> bool {
316    matches!(v, Some("1") | Some("true") | Some("yes"))
317}
318
319/// GET /api/v1/history — distilled project history (velocity, intent mix,
320/// authorship, milestones) and, with `store`, the physical store shape.
321///
322/// Adapts the ASG engine's `history_report` JSON to the lens-core
323/// `HistoryReport` contract: adds `totals.milestones` (a count), mirrors
324/// `velocity.by` to `granularity`, and flattens
325/// `store_shape.path_copy_amplification.objects_per_commit` to
326/// `store_shape.objects_per_commit`. Everything else passes through unchanged.
327async fn list_history(
328    State(state): State<AppState>,
329    Query(q): Query<HistoryQuery>,
330) -> Result<Json<serde_json::Value>, ApiError> {
331    let engine = state.engine.lock().await;
332    let by = q.granularity.as_deref().unwrap_or("day");
333    // Default high enough that `totals.milestones` reflects the true count for
334    // real repos (the array doubles as both the count source and the timeline).
335    let milestones = q.limit.unwrap_or(2000);
336    let store = truthy(q.store.as_deref());
337    // `refresh = true` lazily materializes the `asg_history_*` tables (cheap
338    // after the first, incremental call).
339    let mut report = engine
340        .repo
341        .history_report(None, by, milestones, true, store)
342        .map_err(|e| ApiError::Internal(e.to_string()))?;
343
344    let milestone_count = report
345        .get("milestones")
346        .and_then(|m| m.as_array())
347        .map(|a| a.len())
348        .unwrap_or(0);
349    if let Some(totals) = report.get_mut("totals").and_then(|t| t.as_object_mut()) {
350        totals.insert("milestones".into(), json!(milestone_count));
351    }
352    if let Some(by_val) = report.get("velocity").and_then(|v| v.get("by")).cloned() {
353        if let Some(vel) = report.get_mut("velocity").and_then(|v| v.as_object_mut()) {
354            vel.insert("granularity".into(), by_val);
355        }
356    }
357    if let Some(opc) = report
358        .get("store_shape")
359        .and_then(|s| s.get("path_copy_amplification"))
360        .and_then(|p| p.get("objects_per_commit"))
361        .cloned()
362    {
363        if let Some(ss) = report
364            .get_mut("store_shape")
365            .and_then(|s| s.as_object_mut())
366        {
367            ss.insert("objects_per_commit".into(), opc);
368        }
369    }
370    Ok(Json(report))
371}
372
373#[derive(Deserialize)]
374struct GcQuery {
375    /// Serve the memo if it's warm, but never start a walk. Lets a page show
376    /// the panel instantly when the answer is known and offer a button when
377    /// it isn't, instead of hanging for half a minute on load.
378    cached_only: Option<String>,
379    /// Recompute even when the memo matches the current head.
380    refresh: Option<String>,
381}
382
383/// Body of `POST /api/v1/gc/sweep`: the same shape as AgentStateGraph's own
384/// `/gc/sweep`, so a client can target either server. Every field is optional;
385/// send `{}` for ASD's defaults.
386#[derive(Debug, Default, Deserialize)]
387pub struct GcSweepRequest {
388    keep_recent: Option<usize>,
389    checkpoint_every: Option<usize>,
390    keep_milestones: Option<bool>,
391    mutate: Option<bool>,
392    vacuum: Option<bool>,
393}
394
395/// POST /api/v1/gc/sweep — preview a GC sweep under a retention policy.
396///
397/// **Preview only, by design.** This server binds every interface by default
398/// and has no authentication, so it will not delete or vacuum: a request with
399/// `mutate` or `vacuum` set is refused with 403 and pointed at `asd gc`, which
400/// runs on the machine that owns the database. Nothing here can change the
401/// store.
402///
403/// Unlike [`gc_dry_run`], which measures against the default keep-set, this is
404/// policy-aware: it answers what `asd gc --sweep` would delete under the same
405/// policy, and shares that command's defaults (`agentstatedeveloper_core::gc`).
406/// `keep_milestones: false` is rejected rather than honoured — ASD never
407/// drops a pin someone asked for; `asd gc --unpin-legacy` clears the pins that
408/// nobody did.
409///
410/// The walk costs what the dry-run's does, so it gets the same treatment: a
411/// private connection off the async pool, one walk at a time behind `gc_gate`.
412/// It is not memoized, since the answer depends on the policy.
413async fn gc_sweep_preview(
414    State(state): State<AppState>,
415    Json(req): Json<GcSweepRequest>,
416) -> Result<Json<serde_json::Value>, ApiError> {
417    if req.mutate == Some(true) || req.vacuum == Some(true) {
418        return Err(ApiError::Forbidden(
419            "asd-serve only previews GC; deleting and vacuuming are CLI-only. \
420             Run `asd gc --sweep` (add `--vacuum` to shrink the file) on the \
421             machine that owns the database."
422                .to_string(),
423        ));
424    }
425    if req.keep_milestones == Some(false) {
426        return Err(ApiError::BadRequest(
427            "keep_milestones=false is not supported: ASD never drops a milestone \
428             pin someone asked for. Clear pins from before AgentStateGraph v1.2.2 \
429             with `asd gc --unpin-legacy`."
430                .to_string(),
431        ));
432    }
433    let policy = agentstatedeveloper_core::gc::gc_policy(
434        req.keep_recent
435            .unwrap_or(agentstatedeveloper_core::gc::GC_KEEP_RECENT),
436        req.checkpoint_every
437            .unwrap_or(agentstatedeveloper_core::gc::GC_CHECKPOINT_EVERY),
438    );
439
440    let _gate = state.gc_gate.lock().await;
441    let db_path = state.db_path.clone();
442    let shared = Arc::clone(&state.engine);
443    let preview = tokio::task::spawn_blocking(move || -> Result<serde_json::Value, String> {
444        // Same reasoning as the dry-run: a private reader keeps a long walk
445        // from holding the shared engine's lock against every other handler.
446        if db_path.is_file() {
447            match Engine::open_sqlite(&db_path) {
448                Ok(engine) => {
449                    return engine
450                        .repo
451                        .gc_sweep(policy, false, false)
452                        .map_err(|e| e.to_string());
453                }
454                Err(e) => tracing::warn!(
455                    error = %e,
456                    "gc sweep preview could not open a private connection; \
457                     falling back to the shared engine"
458                ),
459            }
460        }
461        let engine = shared.blocking_lock();
462        engine
463            .repo
464            .gc_sweep(policy, false, false)
465            .map_err(|e| e.to_string())
466    })
467    .await
468    .map_err(|e| ApiError::Internal(format!("gc sweep preview task failed: {e}")))?
469    .map_err(ApiError::Internal)?;
470
471    Ok(Json(preview))
472}
473
474/// GET /api/v1/gc/dry-run — what a GC sweep would reclaim under the default
475/// retention policy, computed without mutating anything.
476///
477/// Adapts the ASG engine's nested `gc_dry_run` JSON (`objects`/`bytes`/`safety`
478/// sub-objects) to the lens-core `GcDryRun` contract's flat fields.
479///
480/// **This is the expensive endpoint.** ASG's reachability marker walks the
481/// entire object DAG — measured at 27s over an 866k-object store — and it
482/// needs the engine, which every other handler also needs. Three things keep
483/// that from wedging the server:
484///
485/// 1. **Memoized on the ref head**, the same cheap cursor [`shared_id_map`]
486///    and the events poller use. The walk is paid once per write, not once
487///    per request.
488/// 2. **Single-flight** behind `gc_gate`: N concurrent callers cost one walk,
489///    not N. A page that fires two of these on mount used to queue 54s of
490///    work.
491/// 3. **`spawn_blocking`**, so a half-minute of synchronous CPU doesn't sit
492///    on an async worker thread starving unrelated connections.
493///
494/// The response carries `cached` / `computed_at` so a caller can tell a fresh
495/// answer from a remembered one, and `?cached_only=1` returns
496/// `{"status": "uncomputed"}` rather than starting a walk.
497async fn gc_dry_run(
498    State(state): State<AppState>,
499    Query(q): Query<GcQuery>,
500) -> Result<Json<serde_json::Value>, ApiError> {
501    let head = {
502        let engine = state.engine.lock().await;
503        engine.repo.head(&engine.ref_name).ok()
504    };
505    let force = truthy(q.refresh.as_deref());
506
507    if !force {
508        if let Some(hit) = gc_memo_hit(&state, head.as_ref()) {
509            return Ok(Json(hit));
510        }
511    }
512    if truthy(q.cached_only.as_deref()) {
513        return Ok(Json(json!({
514            "status": "uncomputed",
515            "reason": "no cached estimate for the current head; \
516                       re-request without cached_only to compute one",
517        })));
518    }
519
520    // One walk at a time. Whoever gets here second finds the memo filled by
521    // whoever got here first.
522    let _gate = state.gc_gate.lock().await;
523    if !force {
524        if let Some(hit) = gc_memo_hit(&state, head.as_ref()) {
525            return Ok(Json(hit));
526        }
527    }
528
529    let db_path = state.db_path.clone();
530    let shared = Arc::clone(&state.engine);
531    let raw = tokio::task::spawn_blocking(move || -> Result<serde_json::Value, String> {
532        // The walk is read-only and takes half a minute, so give it its own
533        // SQLite connection rather than the shared engine's. Holding that
534        // mutex for 30s stalls every other handler — measured: an unrelated
535        // `/history/milestones` issued one second into a dry run waited 25s
536        // for it. A private reader drops that to milliseconds, and it is the
537        // same concurrency the `asd` CLI already relies on when it runs
538        // against a database `asd-serve` has open.
539        if db_path.is_file() {
540            match Engine::open_sqlite(&db_path) {
541                Ok(engine) => return engine.repo.gc_dry_run().map_err(|e| e.to_string()),
542                // Fall through to the shared engine — a private connection is
543                // an optimization, not a requirement.
544                Err(e) => tracing::warn!(
545                    error = %e,
546                    "gc dry-run could not open a private connection; \
547                     falling back to the shared engine"
548                ),
549            }
550        }
551        // In-memory engines (tests, `:memory:`) have no file to reopen: a
552        // second connection would be a different, empty database.
553        // `blocking_lock` is correct here and only here — we are on a
554        // blocking-pool thread and the async guard was dropped above.
555        let engine = shared.blocking_lock();
556        engine.repo.gc_dry_run().map_err(|e| e.to_string())
557    })
558    .await
559    .map_err(|e| ApiError::Internal(format!("gc dry-run task failed: {e}")))?
560    .map_err(ApiError::Internal)?;
561
562    // Deep-get a nested field by path, returning Null if any hop is missing.
563    let at = |path: &[&str]| -> serde_json::Value {
564        let mut cur = &raw;
565        for key in path {
566            match cur.get(key) {
567                Some(v) => cur = v,
568                None => return serde_json::Value::Null,
569            }
570        }
571        cur.clone()
572    };
573    let pct = at(&["objects", "reclaimable_pct"]).as_f64().unwrap_or(0.0);
574
575    let out = json!({
576        "objects_before": at(&["objects", "total"]),
577        "objects_after": at(&["objects", "live"]),
578        "reclaimable_objects": at(&["objects", "reclaimable"]),
579        "bytes_before": at(&["bytes", "current_total"]),
580        "bytes_after": at(&["bytes", "estimated_post_vacuum"]),
581        "estimated_reclaimable_bytes": at(&["bytes", "estimated_reclaimable"]),
582        "reclaimable_fraction": pct / 100.0,
583        "safe": raw.get("safe").cloned().unwrap_or(json!(false)),
584        "undistilled_commits": at(&["safety", "undistilled_commits"]),
585        "policy": {},
586    });
587
588    let computed_at = chrono::Utc::now();
589    if let Some(h) = head {
590        *state.gc_memo.lock().expect("gc_memo poisoned") = Some((h, computed_at, out.clone()));
591    }
592
593    let mut fresh = out;
594    if let Some(obj) = fresh.as_object_mut() {
595        obj.insert("cached".into(), json!(false));
596        obj.insert("computed_at".into(), json!(computed_at.to_rfc3339()));
597    }
598    Ok(Json(fresh))
599}
600
601/// Return the memoized dry-run when it was computed at `head`, stamped
602/// `cached: true` plus the age a caller needs to judge it. A `None` head
603/// (unresolvable ref) never hits — better to recompute than to serve an
604/// estimate we can't tie to a revision.
605fn gc_memo_hit(
606    state: &AppState,
607    head: Option<&agentstategraph_core::ObjectId>,
608) -> Option<serde_json::Value> {
609    let head = head?;
610    let guard = state.gc_memo.lock().expect("gc_memo poisoned");
611    let (cached_head, computed_at, value) = guard.as_ref()?;
612    if cached_head != head {
613        return None;
614    }
615    let mut out = value.clone();
616    if let Some(obj) = out.as_object_mut() {
617        obj.insert("cached".into(), json!(true));
618        obj.insert("computed_at".into(), json!(computed_at.to_rfc3339()));
619        obj.insert(
620            "age_secs".into(),
621            json!(
622                chrono::Utc::now()
623                    .signed_duration_since(*computed_at)
624                    .num_seconds()
625                    .max(0)
626            ),
627        );
628    }
629    Some(out)
630}
631
632// -----------------------------------------------------------------------------
633// Handlers
634// -----------------------------------------------------------------------------
635
636#[derive(Serialize)]
637struct HealthResponse {
638    status: &'static str,
639    db_path: String,
640    symbol_count: usize,
641}
642
643async fn health(State(state): State<AppState>) -> Result<Json<HealthResponse>, ApiError> {
644    let engine = state.engine.lock().await;
645    let ref_name = engine.ref_name.clone();
646    let prefix = format!(
647        "{}/index/by-qname",
648        agentstatedeveloper_core::ASD_PATH_PREFIX
649    );
650    let symbol_count = match engine.repo.get_tree(&ref_name, &prefix) {
651        Ok(serde_json::Value::Object(map)) => map.len(),
652        _ => 0,
653    };
654    let abs = state
655        .db_path
656        .canonicalize()
657        .unwrap_or(state.db_path.clone())
658        .to_string_lossy()
659        .to_string();
660    Ok(Json(HealthResponse {
661        status: "ok",
662        db_path: abs,
663        symbol_count,
664    }))
665}
666
667#[derive(Debug, Deserialize)]
668struct SymbolQuery {
669    /// Maximum symbols to return (default 500, max 2000).
670    #[serde(default)]
671    limit: Option<usize>,
672    /// Zero-based offset for pagination (default 0).
673    #[serde(default)]
674    offset: Option<usize>,
675}
676
677/// One entry per file that contains at least one indexed symbol.
678///
679/// The Lens Code Overview derives its file count, language breakdown and
680/// "largest files" list from this. It was calling GET /files against a route
681/// that never existed, so every repo 404'd and the page's Promise.all
682/// rejected — health and symbols both succeeded, but the view rendered as
683/// "ASD server unreachable".
684#[derive(Serialize)]
685struct FileEntry {
686    path: String,
687    language: String,
688    symbol_count: usize,
689}
690
691async fn list_files(State(state): State<AppState>) -> Result<Json<Vec<FileEntry>>, ApiError> {
692    let engine = state.engine.lock().await;
693    // Same bulk read as list_symbols — a per-file query would reintroduce the
694    // per-qname blowup measured in Plan T t-007.
695    let index = AsgIndexStore::from_engine(&engine);
696
697    let mut by_path: std::collections::HashMap<String, (String, usize)> =
698        std::collections::HashMap::new();
699    for sym in index.build_id_map(&engine).into_values() {
700        let entry = by_path
701            .entry(sym.file)
702            .or_insert_with(|| (sym.language.clone(), 0));
703        // First language wins; a file is one language in practice, and
704        // disagreement is not worth a second pass to resolve.
705        entry.1 += 1;
706    }
707
708    let mut files: Vec<FileEntry> = by_path
709        .into_iter()
710        .map(|(path, (language, symbol_count))| FileEntry {
711            path,
712            language,
713            symbol_count,
714        })
715        .collect();
716
717    // Largest first — the overview slices the top 8 straight off the front.
718    files.sort_by(|a, b| {
719        b.symbol_count
720            .cmp(&a.symbol_count)
721            .then_with(|| a.path.cmp(&b.path))
722    });
723
724    Ok(Json(files))
725}
726
727/// Raw source of one indexed file, as text/plain.
728///
729/// lens-core's `readFile()` calls GET /files/{path} through `fetchText`, and
730/// the Lens file view links to it from every entry in /files. Without it the
731/// list rendered but every link 404'd.
732///
733/// Only files the index already knows about are readable. That is the whole
734/// access-control story here: the handler serves contents off disk over an
735/// HTTP port, so it refuses anything that is not a path already present in the
736/// symbol index, and re-checks the resolved path against the repo root so a
737/// traversal segment cannot escape even if one somehow reached the index.
738async fn read_file(
739    State(state): State<AppState>,
740    Path(path): Path<String>,
741) -> Result<String, ApiError> {
742    let engine = state.engine.lock().await;
743    let index = AsgIndexStore::from_engine(&engine);
744    let known = index
745        .build_id_map(&engine)
746        .into_values()
747        .any(|sym| sym.file == path);
748    if !known {
749        return Err(ApiError::NotFound(format!("{path} is not an indexed file")));
750    }
751
752    // The db lives at <repo>/.asd-state.db, so its parent is the repo root
753    // that indexed `file` paths are relative to.
754    let root = state
755        .db_path
756        .parent()
757        .ok_or_else(|| ApiError::Internal("db path has no parent directory".into()))?
758        .to_path_buf();
759    let target = root.join(&path);
760
761    // canonicalize resolves .. and symlinks; both sides must canonicalize so
762    // the prefix check compares real paths, not lexical ones.
763    let real_root = root
764        .canonicalize()
765        .map_err(|e| ApiError::Internal(format!("cannot resolve repo root: {e}")))?;
766    let real_target = target
767        .canonicalize()
768        .map_err(|_| ApiError::NotFound(format!("{path} is indexed but missing on disk")))?;
769    if !real_target.starts_with(&real_root) {
770        return Err(ApiError::BadRequest(format!(
771            "{path} resolves outside the repo"
772        )));
773    }
774
775    std::fs::read_to_string(&real_target)
776        .map_err(|e| ApiError::Internal(format!("cannot read {path}: {e}")))
777}
778
779async fn list_symbols(
780    State(state): State<AppState>,
781    Query(q): Query<SymbolQuery>,
782) -> Result<Json<Vec<agentstatedeveloper_core::Symbol>>, ApiError> {
783    let engine = state.engine.lock().await;
784    // One bulk read (FTS cache when warm, one tree walk otherwise) instead
785    // of a per-qname lookup — the per-qname form measured 8m45s per page on
786    // a 9.6k-symbol repo with a cold cache, holding the engine mutex the
787    // whole time (Plan T t-007 finding).
788    let index = AsgIndexStore::from_engine(&engine);
789    let mut symbols: Vec<agentstatedeveloper_core::Symbol> =
790        index.build_id_map(&engine).into_values().collect();
791
792    symbols.sort_by(|a, b| a.qname.cmp(&b.qname));
793
794    let offset = q.offset.unwrap_or(0);
795    let limit = q.limit.unwrap_or(500).min(2000);
796    let page: Vec<_> = symbols.into_iter().skip(offset).take(limit).collect();
797    Ok(Json(page))
798}
799
800#[derive(Serialize)]
801struct SymbolDetail {
802    symbol: agentstatedeveloper_core::Symbol,
803    effects: Option<agentstatedeveloper_core::EffectDecl>,
804    ledger: Vec<agentstatedeveloper_core::LedgerEntry>,
805}
806
807async fn get_symbol(
808    State(state): State<AppState>,
809    Path(qname): Path<String>,
810) -> Result<Json<SymbolDetail>, ApiError> {
811    let engine = state.engine.lock().await;
812    let ref_name = engine.ref_name.clone();
813
814    let index = AsgIndexStore::from_engine(&engine);
815    let symbol = index
816        .get_symbol_by_qname(&ref_name, &qname)
817        .map_err(ApiError::from)?
818        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
819
820    let effects_store = AsgEffectStore::from_engine(&engine);
821    let effects = effects_store
822        .get_effects(&ref_name, &symbol.symbol_id)
823        .map_err(ApiError::from)?;
824
825    let ledger_store = AsgLedgerStore::from_engine(&engine);
826    let mut ledger = ledger_store
827        .list_entries(&ref_name, &symbol.symbol_id)
828        .map_err(ApiError::from)?;
829    ledger.truncate(20);
830
831    Ok(Json(SymbolDetail {
832        symbol,
833        effects,
834        ledger,
835    }))
836}
837
838async fn get_symbol_ledger(
839    State(state): State<AppState>,
840    Path(qname): Path<String>,
841) -> Result<Json<Vec<agentstatedeveloper_core::LedgerEntry>>, ApiError> {
842    let engine = state.engine.lock().await;
843    let ref_name = engine.ref_name.clone();
844
845    let index = AsgIndexStore::from_engine(&engine);
846    let symbol = index
847        .get_symbol_by_qname(&ref_name, &qname)
848        .map_err(ApiError::from)?
849        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
850
851    let ledger_store = AsgLedgerStore::from_engine(&engine);
852    let entries = ledger_store
853        .list_entries(&ref_name, &symbol.symbol_id)
854        .map_err(ApiError::from)?;
855    Ok(Json(entries))
856}
857
858async fn get_symbol_callers(
859    State(state): State<AppState>,
860    Path(qname): Path<String>,
861) -> Result<Json<Vec<agentstatedeveloper_core::Symbol>>, ApiError> {
862    let engine = state.engine.lock().await;
863    let ref_name = engine.ref_name.clone();
864    let index = AsgIndexStore::from_engine(&engine);
865    let target = index
866        .get_symbol_by_qname(&ref_name, &qname)
867        .map_err(ApiError::from)?
868        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
869    let ids = index
870        .get_callers(&ref_name, &target.symbol_id)
871        .map_err(ApiError::from)?;
872    let syms = resolve_symbols_by_ids(&state, &engine, &ids);
873    Ok(Json(syms))
874}
875
876async fn get_symbol_callees(
877    State(state): State<AppState>,
878    Path(qname): Path<String>,
879) -> Result<Json<Vec<agentstatedeveloper_core::Symbol>>, ApiError> {
880    let engine = state.engine.lock().await;
881    let ref_name = engine.ref_name.clone();
882    let index = AsgIndexStore::from_engine(&engine);
883    let target = index
884        .get_symbol_by_qname(&ref_name, &qname)
885        .map_err(ApiError::from)?
886        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
887    let ids = index
888        .get_callees(&ref_name, &target.symbol_id)
889        .map_err(ApiError::from)?;
890    let syms = resolve_symbols_by_ids(&state, &engine, &ids);
891    Ok(Json(syms))
892}
893
894/// Resolve a list of `symbol_id`s to full `Symbol` records via the shared
895/// (head-memoized) id map — one bulk read per head change, O(ids) per call.
896/// The previous implementation listed every qname and then re-fetched each
897/// symbol with a per-qname `get_symbol_by_qname` — ~10k git reads per request
898/// on a cold cache (measured in minutes on the AcmeProj db); same disease
899/// 91b2aaf cured in `list_symbols`.
900fn resolve_symbols_by_ids(
901    state: &AppState,
902    engine: &Engine,
903    ids: &[String],
904) -> Vec<agentstatedeveloper_core::Symbol> {
905    let id_map = shared_id_map(state, engine);
906    let mut out: Vec<agentstatedeveloper_core::Symbol> = {
907        let mut seen = std::collections::HashSet::new();
908        ids.iter()
909            .filter(|id| seen.insert(id.as_str()))
910            .filter_map(|id| id_map.get(id).cloned())
911            .collect()
912    };
913    out.sort_by(|a, b| a.qname.cmp(&b.qname));
914    out
915}
916
917#[derive(Debug, Deserialize)]
918struct LedgerQuery {
919    tag: Option<String>,
920    /// Comma-separated `LedgerKind` names in snake_case (e.g.
921    /// `hypothesis,mental_model`). Unknown tokens are ignored — they can't
922    /// match any entry anyway, and we don't want a typo to 400 the whole
923    /// listing.
924    kind: Option<String>,
925    /// Maximum entries to return (default 100, max 1000).
926    #[serde(default)]
927    limit: Option<usize>,
928    /// Zero-based offset for pagination (default 0).
929    #[serde(default)]
930    offset: Option<usize>,
931}
932
933/// Flat cross-symbol ledger listing. Optional `?tag=<name>` filters to entries
934/// carrying that tag (e.g. `awaiting-approval`). Walks the `/asd/v1/ledger`
935/// subtree directly so we don't have to re-resolve every symbol_id.
936async fn list_ledger(
937    State(state): State<AppState>,
938    Query(q): Query<LedgerQuery>,
939) -> Result<Json<Vec<agentstatedeveloper_core::LedgerEntry>>, ApiError> {
940    let engine = state.engine.lock().await;
941    let mut entries = all_ledger_entries(&engine);
942
943    if let Some(tag) = q.tag.as_deref() {
944        entries.retain(|e| e.tags.iter().any(|t| t == tag));
945    }
946
947    apply_kind_filter(&mut entries, q.kind.as_deref());
948
949    entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
950
951    let offset = q.offset.unwrap_or(0);
952    let limit = q.limit.unwrap_or(100).min(1000);
953    let page: Vec<_> = entries.into_iter().skip(offset).take(limit).collect();
954    Ok(Json(page))
955}
956
957/// Walk the `/asd/v1/ledger` subtree and return every entry across all
958/// symbols. Shared by `list_ledger` and `list_timeline` so both stay in
959/// lockstep on how the flat cross-symbol listing is produced.
960fn all_ledger_entries(engine: &Engine) -> Vec<agentstatedeveloper_core::LedgerEntry> {
961    let prefix = format!("{}/ledger", agentstatedeveloper_core::ASD_PATH_PREFIX);
962    let tree = match engine.repo.get_tree(&engine.ref_name, &prefix) {
963        Ok(t) => t,
964        Err(_) => return Vec::new(),
965    };
966
967    let mut entries: Vec<agentstatedeveloper_core::LedgerEntry> = Vec::new();
968    if let serde_json::Value::Object(by_symbol) = tree {
969        for (_symbol_id, symbol_bucket) in by_symbol {
970            if let serde_json::Value::Object(entry_map) = symbol_bucket {
971                for (_entry_id, entry_val) in entry_map {
972                    if let Ok(e) =
973                        serde_json::from_value::<agentstatedeveloper_core::LedgerEntry>(entry_val)
974                    {
975                        entries.push(e);
976                    }
977                }
978            }
979        }
980    }
981    entries
982}
983
984/// Apply a comma-separated `LedgerKind` filter (snake_case names, e.g.
985/// `hypothesis,mental_model`) to an entry list. Unknown tokens are ignored —
986/// they can't match any entry anyway, and we don't want a typo to 400 the
987/// whole listing. If ONLY unknown tokens were passed, the list is cleared
988/// rather than returned unfiltered, otherwise the filter is silent.
989fn apply_kind_filter(entries: &mut Vec<agentstatedeveloper_core::LedgerEntry>, raw: Option<&str>) {
990    let Some(raw) = raw else { return };
991    let allowed: Vec<agentstatedeveloper_core::LedgerKind> = raw
992        .split(',')
993        .map(|s| s.trim())
994        .filter(|s| !s.is_empty())
995        .filter_map(|s| {
996            // LedgerKind serializes snake_case, so round-trip via JSON
997            // to parse the user's token without writing a hand-rolled
998            // match against every variant.
999            serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
1000        })
1001        .collect();
1002    if !allowed.is_empty() {
1003        entries.retain(|e| allowed.contains(&e.kind));
1004    } else {
1005        entries.clear();
1006    }
1007}
1008
1009// -----------------------------------------------------------------------------
1010// Plan G / K — captured "thinking" projection
1011// -----------------------------------------------------------------------------
1012
1013#[derive(Debug, Deserialize)]
1014struct ThinkingQuery {
1015    /// Comma-separated qnames to scan. When omitted, scans every qname in
1016    /// the by-qname index — fine on real-world workspaces (low thousands of
1017    /// symbols), but explicit qnames are always cheaper.
1018    qnames: Option<String>,
1019    /// Drop Hypothesis entries below this confidence. Defaults to
1020    /// [`agentstatedeveloper_core::thinking::DEFAULT_CONFIDENCE_FLOOR`]
1021    /// (0.3) so the floor stays consistent with the CLI / MCP.
1022    min_confidence: Option<f64>,
1023}
1024
1025/// `GET /api/v1/thinking?qnames=a,b,c&min_confidence=0.3` — projects Plan G
1026/// thinking entries (hypotheses / mental models / open questions / failed
1027/// attempts) for the given qnames. With no `qnames`, scans every indexed
1028/// symbol. Response body is the `PriorThinking { entries, summary }` shape;
1029/// callers use `summary.surfaced > 0` to decide whether to render.
1030async fn list_thinking(
1031    State(state): State<AppState>,
1032    Query(q): Query<ThinkingQuery>,
1033) -> Result<Json<agentstatedeveloper_core::thinking::PriorThinking>, ApiError> {
1034    let engine = state.engine.lock().await;
1035    let floor = q
1036        .min_confidence
1037        .unwrap_or(agentstatedeveloper_core::thinking::DEFAULT_CONFIDENCE_FLOOR);
1038    let projection = match q.qnames.as_deref() {
1039        Some(raw) => {
1040            let qnames = raw
1041                .split(',')
1042                .map(|s| s.trim().to_string())
1043                .filter(|s| !s.is_empty())
1044                .collect::<Vec<_>>();
1045            agentstatedeveloper_core::thinking::gather_prior_thinking(&engine, &qnames, floor)
1046        }
1047        // Workspace-wide: the bulk single-walk variant, not a synthetic
1048        // "every qname" list (that form is quadratic — Plan T t-007).
1049        None => agentstatedeveloper_core::thinking::gather_prior_thinking_all(&engine, floor),
1050    };
1051    Ok(Json(projection))
1052}
1053
1054#[derive(Debug, Deserialize)]
1055struct SymbolThinkingQuery {
1056    min_confidence: Option<f64>,
1057}
1058
1059/// `GET /api/v1/symbols/{qname}/thinking?min_confidence=…` — same projection
1060/// scoped to one symbol. Lets the symbol detail page show an "inherited
1061/// thinking" panel without pulling the whole workspace.
1062async fn get_symbol_thinking(
1063    State(state): State<AppState>,
1064    Path(qname): Path<String>,
1065    Query(q): Query<SymbolThinkingQuery>,
1066) -> Result<Json<agentstatedeveloper_core::thinking::PriorThinking>, ApiError> {
1067    let engine = state.engine.lock().await;
1068    let floor = q
1069        .min_confidence
1070        .unwrap_or(agentstatedeveloper_core::thinking::DEFAULT_CONFIDENCE_FLOOR);
1071    let projection = agentstatedeveloper_core::thinking::gather_prior_thinking(
1072        &engine,
1073        std::slice::from_ref(&qname),
1074        floor,
1075    );
1076    Ok(Json(projection))
1077}
1078
1079// -----------------------------------------------------------------------------
1080// Audit log
1081// -----------------------------------------------------------------------------
1082
1083#[derive(Debug, Deserialize)]
1084struct AuditQuery {
1085    /// Substring match on event_type (e.g., `ledger.approve`, `ledger.`
1086    /// for all ledger events).
1087    event_type: Option<String>,
1088    /// Return only events AFTER this `event_id` (exclusive).
1089    since: Option<String>,
1090    /// Exact match on actor_id.
1091    actor: Option<String>,
1092    /// Exact match on outcome.
1093    outcome: Option<String>,
1094    /// Exact match on `subject_id` OR `secondary_id`. Lets consumers pull
1095    /// "every audit record naming this entry/symbol" server-side instead
1096    /// of scanning the last N events client-side (Plan I t-034; the
1097    /// lens-core AccountabilityCard is the primary consumer).
1098    subject: Option<String>,
1099    /// Max events to return (default 200, max 1000).
1100    #[serde(default)]
1101    limit: Option<usize>,
1102}
1103
1104async fn list_audit(
1105    State(state): State<AppState>,
1106    Query(q): Query<AuditQuery>,
1107) -> Result<Json<serde_json::Value>, ApiError> {
1108    let Some(path) = state.audit_log_path.as_ref() else {
1109        return Ok(Json(json!({
1110            "configured": false,
1111            "count": 0,
1112            "events": [],
1113        })));
1114    };
1115
1116    let events = agentstatedeveloper_core::read_jsonl(path)
1117        .map_err(|e| ApiError::Internal(format!("read audit log: {}", e)))?;
1118
1119    // Apply `since` cursor (drop up to and including the matching id).
1120    let start_idx = match q.since {
1121        Some(ref id) => events
1122            .iter()
1123            .position(|e| &e.event_id == id)
1124            .map(|i| i + 1)
1125            .unwrap_or(0),
1126        None => 0,
1127    };
1128
1129    let limit = q.limit.unwrap_or(200).min(1000);
1130    let filtered: Vec<&agentstatedeveloper_core::AuditEvent> = events[start_idx..]
1131        .iter()
1132        .filter(|e| {
1133            if let Some(ref t) = q.event_type {
1134                if !e.event_type.contains(t) {
1135                    return false;
1136                }
1137            }
1138            if let Some(ref a) = q.actor {
1139                if &e.actor_id != a {
1140                    return false;
1141                }
1142            }
1143            if let Some(ref o) = q.outcome {
1144                if &e.outcome != o {
1145                    return false;
1146                }
1147            }
1148            if let Some(ref s) = q.subject {
1149                if e.subject_id.as_deref() != Some(s.as_str())
1150                    && e.secondary_id.as_deref() != Some(s.as_str())
1151                {
1152                    return false;
1153                }
1154            }
1155            true
1156        })
1157        .take(limit)
1158        .collect();
1159
1160    Ok(Json(json!({
1161        "configured": true,
1162        "path": path.display().to_string(),
1163        "count": filtered.len(),
1164        "events": filtered,
1165    })))
1166}
1167
1168async fn verify_audit(State(state): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
1169    // Chain verification is a commercial feature (Enterprise tier).
1170    // OSS asd-serve returns a consistent shape so the Lens can detect
1171    // edition and render an upgrade CTA without 500-ing.
1172    Ok(Json(json!({
1173        "configured": state.audit_log_path.is_some(),
1174        "edition": "oss",
1175        "verified": false,
1176        "error": "audit verify is a commercial feature (Enterprise tier) — install asd-pro",
1177        "upgrade_url": "https://agentstatedeveloper.dev/pricing",
1178    })))
1179}
1180
1181/// Body for POST /approvals/:entry_id/approve.
1182#[derive(Debug, Deserialize)]
1183struct ApproveBody {
1184    /// Approver id — recorded as `approved-by:<id>`.
1185    approver: String,
1186    /// Approver kind — must match one of the original `approver:*` tags.
1187    #[serde(default = "default_approver_kind")]
1188    approver_kind: String,
1189    /// Optional commit agent id. Defaults to "asd-http".
1190    #[serde(default = "default_http_agent_id")]
1191    agent_id: String,
1192    /// Optional rationale appended to the entry body.
1193    #[serde(default)]
1194    message: Option<String>,
1195}
1196
1197#[derive(Debug, Deserialize)]
1198struct RejectBody {
1199    reviewer: String,
1200    #[serde(default = "default_approver_kind")]
1201    reviewer_kind: String,
1202    reason: String,
1203    #[serde(default = "default_http_agent_id")]
1204    agent_id: String,
1205}
1206
1207#[derive(Debug, Deserialize)]
1208struct WithdrawBody {
1209    author_id: String,
1210    #[serde(default = "default_http_agent_id")]
1211    agent_id: String,
1212}
1213
1214fn default_approver_kind() -> String {
1215    "human".into()
1216}
1217
1218fn default_http_agent_id() -> String {
1219    "asd-http".into()
1220}
1221
1222async fn approve_entry(
1223    State(state): State<AppState>,
1224    Path(entry_id): Path<String>,
1225    Json(body): Json<ApproveBody>,
1226) -> Result<Json<serde_json::Value>, ApiError> {
1227    let engine = state.engine.lock().await;
1228    let ref_name = engine.ref_name.clone();
1229    let result = match engine.ratify.as_ref() {
1230        Some(ratify) => ratify.approve_entry(
1231            &engine.repo,
1232            &ref_name,
1233            &entry_id,
1234            &body.approver,
1235            &body.approver_kind,
1236            body.message.as_deref(),
1237            &body.agent_id,
1238        ),
1239        None => Err(AsdError::Other(
1240            "ledger approve is a commercial feature (Team tier) — \
1241             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
1242                .into(),
1243        )),
1244    };
1245    match result {
1246        Ok(outcome) => {
1247            let status = if outcome.already_approved {
1248                "already-approved"
1249            } else {
1250                "approved"
1251            };
1252            let event = AuditEvent::new(
1253                event_types::LEDGER_APPROVE,
1254                &body.approver,
1255                &body.approver_kind,
1256                status,
1257            )
1258            .with_subject(&outcome.entry.entry_id)
1259            .with_secondary(&outcome.entry.symbol_id)
1260            .with_matched_policy(outcome.entry.matched_policy.clone())
1261            .with_payload(json!({ "tags": outcome.entry.tags }));
1262            emit_audit(engine.audit.as_ref(), event);
1263
1264            Ok(Json(json!({
1265                "status": status,
1266                "entry": outcome.entry,
1267            })))
1268        }
1269        Err(e) => {
1270            let event = AuditEvent::new(
1271                event_types::LEDGER_APPROVE,
1272                &body.approver,
1273                &body.approver_kind,
1274                "error",
1275            )
1276            .with_subject(&entry_id)
1277            .with_reason(e.to_string());
1278            emit_audit(engine.audit.as_ref(), event);
1279            Err(ApiError::from(e))
1280        }
1281    }
1282}
1283
1284async fn reject_entry(
1285    State(state): State<AppState>,
1286    Path(entry_id): Path<String>,
1287    Json(body): Json<RejectBody>,
1288) -> Result<Json<serde_json::Value>, ApiError> {
1289    let engine = state.engine.lock().await;
1290    let ref_name = engine.ref_name.clone();
1291    let result = match engine.ratify.as_ref() {
1292        Some(ratify) => ratify.reject_entry(
1293            &engine.repo,
1294            &ref_name,
1295            &entry_id,
1296            &body.reviewer,
1297            &body.reviewer_kind,
1298            &body.reason,
1299            &body.agent_id,
1300        ),
1301        None => Err(AsdError::Other(
1302            "ledger reject is a commercial feature (Team tier) — \
1303             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
1304                .into(),
1305        )),
1306    };
1307    match result {
1308        Ok(outcome) => {
1309            let status = if outcome.already_resolved {
1310                "already-rejected"
1311            } else {
1312                "rejected"
1313            };
1314            let event = AuditEvent::new(
1315                event_types::LEDGER_REJECT,
1316                &body.reviewer,
1317                &body.reviewer_kind,
1318                status,
1319            )
1320            .with_subject(&outcome.entry.entry_id)
1321            .with_secondary(&outcome.entry.symbol_id)
1322            .with_matched_policy(outcome.entry.matched_policy.clone())
1323            .with_reason(&body.reason)
1324            .with_payload(json!({ "tags": outcome.entry.tags }));
1325            emit_audit(engine.audit.as_ref(), event);
1326
1327            Ok(Json(json!({
1328                "status": status,
1329                "entry": outcome.entry,
1330            })))
1331        }
1332        Err(e) => {
1333            let event = AuditEvent::new(
1334                event_types::LEDGER_REJECT,
1335                &body.reviewer,
1336                &body.reviewer_kind,
1337                "error",
1338            )
1339            .with_subject(&entry_id)
1340            .with_reason(e.to_string());
1341            emit_audit(engine.audit.as_ref(), event);
1342            Err(ApiError::from(e))
1343        }
1344    }
1345}
1346
1347async fn withdraw_entry(
1348    State(state): State<AppState>,
1349    Path(entry_id): Path<String>,
1350    Json(body): Json<WithdrawBody>,
1351) -> Result<Json<serde_json::Value>, ApiError> {
1352    let engine = state.engine.lock().await;
1353    let ref_name = engine.ref_name.clone();
1354    let result = match engine.ratify.as_ref() {
1355        Some(ratify) => ratify.withdraw_entry(
1356            &engine.repo,
1357            &ref_name,
1358            &entry_id,
1359            &body.author_id,
1360            &body.agent_id,
1361        ),
1362        None => Err(AsdError::Other(
1363            "ledger withdraw is a commercial feature (Team tier) — \
1364             install asd-pro to enable. See https://agentstatedeveloper.dev/pricing"
1365                .into(),
1366        )),
1367    };
1368    match result {
1369        Ok(outcome) => {
1370            let status = if outcome.already_resolved {
1371                "already-withdrawn"
1372            } else {
1373                "withdrawn"
1374            };
1375            let event = AuditEvent::new(
1376                event_types::LEDGER_WITHDRAW,
1377                &body.author_id,
1378                "agent",
1379                status,
1380            )
1381            .with_subject(&outcome.entry.entry_id)
1382            .with_secondary(&outcome.entry.symbol_id)
1383            .with_payload(json!({ "tags": outcome.entry.tags }));
1384            emit_audit(engine.audit.as_ref(), event);
1385
1386            Ok(Json(json!({
1387                "status": status,
1388                "entry": outcome.entry,
1389            })))
1390        }
1391        Err(e) => {
1392            let event = AuditEvent::new(
1393                event_types::LEDGER_WITHDRAW,
1394                &body.author_id,
1395                "agent",
1396                "error",
1397            )
1398            .with_subject(&entry_id)
1399            .with_reason(e.to_string());
1400            emit_audit(engine.audit.as_ref(), event);
1401            Err(ApiError::from(e))
1402        }
1403    }
1404}
1405
1406async fn get_symbol_effects(
1407    State(state): State<AppState>,
1408    Path(qname): Path<String>,
1409) -> Result<Json<Option<agentstatedeveloper_core::EffectDecl>>, ApiError> {
1410    let engine = state.engine.lock().await;
1411    let ref_name = engine.ref_name.clone();
1412
1413    let index = AsgIndexStore::from_engine(&engine);
1414    let symbol = index
1415        .get_symbol_by_qname(&ref_name, &qname)
1416        .map_err(ApiError::from)?
1417        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
1418
1419    let effects_store = AsgEffectStore::from_engine(&engine);
1420    let effects = effects_store
1421        .get_effects(&ref_name, &symbol.symbol_id)
1422        .map_err(ApiError::from)?;
1423    Ok(Json(effects))
1424}
1425
1426// -----------------------------------------------------------------------------
1427// Plan T t-003 — Lens web-UI endpoints (search / graph / effects / timeline)
1428// -----------------------------------------------------------------------------
1429
1430#[derive(Debug, Deserialize)]
1431struct SearchApiQuery {
1432    /// Concept or keyword(s) to search for. Same query syntax as
1433    /// `asd search` (inline `-term` exclusions supported).
1434    q: String,
1435    /// Filter by symbol kind: module, function, method, class, variable.
1436    kind: Option<String>,
1437    /// Filter by language (e.g. "swift", "python", "rust").
1438    lang: Option<String>,
1439    /// Maximum results (default 20, max 100).
1440    #[serde(default)]
1441    limit: Option<usize>,
1442}
1443
1444/// `GET /api/v1/search?q=…&kind=…&lang=…&limit=…` — ranked symbol search
1445/// backed by the same `find_candidates` pipeline the CLI (`asd search`
1446/// via prepare_change/investigate) and MCP tools use, so Lens results and
1447/// scores match agent-side results. `why` carries the core
1448/// `explain_match` reasons (`name:token`, `file:token`,
1449/// `invariant-attached:N`, …) — no ranking is invented here.
1450async fn search_symbols(
1451    State(state): State<AppState>,
1452    Query(q): Query<SearchApiQuery>,
1453) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
1454    let query = q.q.trim().to_string();
1455    if query.is_empty() {
1456        return Err(ApiError::BadRequest(
1457            "query parameter `q` must not be empty".into(),
1458        ));
1459    }
1460    let limit = q.limit.unwrap_or(20).clamp(1, 100);
1461
1462    let engine = state.engine.lock().await;
1463    let ref_name = engine.ref_name.clone();
1464    let (tokens, exclusions) = parse_query(&query);
1465    let filters = FtsFilters {
1466        kind: q.kind.as_deref().map(|k| k.to_lowercase()),
1467        language: q.lang.as_deref().map(|l| l.to_lowercase()),
1468        include_tests: false,
1469        tests_only: false,
1470        exclude_terms: exclusions,
1471        paths_filter: Vec::new(),
1472        exclude_paths: Vec::new(),
1473        exclude_languages: Vec::new(),
1474    };
1475
1476    let index = AsgIndexStore::from_engine(&engine);
1477    let ledger_store = AsgLedgerStore::from_engine(&engine);
1478    let candidates = find_candidates(
1479        &engine,
1480        &query,
1481        &tokens,
1482        &filters,
1483        &ledger_store,
1484        &index,
1485        limit,
1486    );
1487
1488    let mut results: Vec<serde_json::Value> = Vec::with_capacity(candidates.len());
1489    for (score, qname) in candidates {
1490        let sym = match index.get_symbol_by_qname(&ref_name, &qname) {
1491            Ok(Some(s)) => s,
1492            _ => continue,
1493        };
1494        let entries = ledger_store
1495            .list_entries(&ref_name, &sym.symbol_id)
1496            .unwrap_or_default();
1497        // is_hot=false: recency needs `git log` relative to the indexed
1498        // checkout's CWD, which the server can't assume. The match reasons
1499        // and scores are unaffected.
1500        let why = explain_match(&sym, &tokens, &entries, false);
1501        let name = sym
1502            .qname
1503            .rsplit(|c| c == '.' || c == ':')
1504            .next()
1505            .unwrap_or(sym.qname.as_str())
1506            .to_string();
1507        results.push(json!({
1508            "qname": sym.qname,
1509            "name": name,
1510            "kind": kind_str(&sym.kind),
1511            "language": sym.language,
1512            "file": sym.file,
1513            "line": sym.start.line,
1514            "score": score,
1515            "why": why,
1516        }));
1517    }
1518    Ok(Json(results))
1519}
1520
1521#[derive(Debug, Deserialize)]
1522struct GraphQuery {
1523    /// BFS depth from the root symbol (1..=3, default 1).
1524    #[serde(default)]
1525    hops: Option<usize>,
1526    /// Which edges to walk: `callers`, `callees`, or `both` (default).
1527    direction: Option<String>,
1528}
1529
1530/// Hard cap on nodes returned by `/symbols/{qname}/graph`. When hit, the
1531/// response carries `"truncated": true` — never a silent cut.
1532const GRAPH_NODE_CAP: usize = 500;
1533
1534/// `GET /api/v1/symbols/{qname}/graph?hops=…&direction=…` — BFS over the
1535/// call graph from the symbol, in a render-ready nodes/links shape. Node
1536/// `id`s are the stable `symbol_id`s; links always point caller → callee
1537/// (`source` calls `target`) regardless of traversal direction, and are
1538/// deduped.
1539async fn get_symbol_graph(
1540    State(state): State<AppState>,
1541    Path(qname): Path<String>,
1542    Query(q): Query<GraphQuery>,
1543) -> Result<Json<serde_json::Value>, ApiError> {
1544    let hops = q.hops.unwrap_or(1);
1545    if !(1..=3).contains(&hops) {
1546        return Err(ApiError::BadRequest(format!(
1547            "hops must be between 1 and 3, got {}",
1548            hops
1549        )));
1550    }
1551    let direction = q.direction.as_deref().unwrap_or("both");
1552    let (want_callers, want_callees) = match direction {
1553        "callers" => (true, false),
1554        "callees" => (false, true),
1555        "both" => (true, true),
1556        other => {
1557            return Err(ApiError::BadRequest(format!(
1558                "direction must be one of callers|callees|both, got {}",
1559                other
1560            )));
1561        }
1562    };
1563
1564    let engine = state.engine.lock().await;
1565    let ref_name = engine.ref_name.clone();
1566    let index = AsgIndexStore::from_engine(&engine);
1567    let root = index
1568        .get_symbol_by_qname(&ref_name, &qname)
1569        .map_err(ApiError::from)?
1570        .ok_or_else(|| ApiError::NotFound(format!("symbol not found: {}", qname)))?;
1571    let id_map = shared_id_map(&state, &engine);
1572    let edge_maps = shared_edge_maps(&state, &engine);
1573    let (callers_of, callees_of) = edge_maps.as_ref();
1574
1575    let node_json = |sym: &agentstatedeveloper_core::Symbol| {
1576        json!({
1577            "id": sym.symbol_id,
1578            "qname": sym.qname,
1579            "kind": kind_str(&sym.kind),
1580            "file": sym.file,
1581            "module": qname_module(&sym.qname),
1582        })
1583    };
1584
1585    let mut nodes: Vec<serde_json::Value> = vec![node_json(&root)];
1586    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
1587    visited.insert(root.symbol_id.clone());
1588    let mut links: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
1589    let mut queue: std::collections::VecDeque<(String, usize)> = std::collections::VecDeque::new();
1590    queue.push_back((root.symbol_id.clone(), 0));
1591    let mut truncated = false;
1592
1593    while let Some((sym_id, depth)) = queue.pop_front() {
1594        if depth >= hops {
1595            continue;
1596        }
1597        // (neighbor_id, link caller→callee)
1598        let mut neighbors: Vec<(String, (String, String))> = Vec::new();
1599        if want_callers {
1600            for caller in callers_of.get(&sym_id).into_iter().flatten() {
1601                let link = (caller.clone(), sym_id.clone());
1602                neighbors.push((caller.clone(), link));
1603            }
1604        }
1605        if want_callees {
1606            for callee in callees_of.get(&sym_id).into_iter().flatten() {
1607                let link = (sym_id.clone(), callee.clone());
1608                neighbors.push((callee.clone(), link));
1609            }
1610        }
1611        for (nbr_id, link) in neighbors {
1612            // Skip edges whose peer no longer resolves to an indexed symbol
1613            // (stale edge after a partial reindex) — a dangling link would
1614            // break the renderer.
1615            let Some(nbr_sym) = id_map.get(&nbr_id) else {
1616                continue;
1617            };
1618            if !visited.contains(&nbr_id) {
1619                if visited.len() >= GRAPH_NODE_CAP {
1620                    truncated = true;
1621                    continue;
1622                }
1623                visited.insert(nbr_id.clone());
1624                nodes.push(node_json(nbr_sym));
1625                queue.push_back((nbr_id, depth + 1));
1626            }
1627            links.insert(link);
1628        }
1629    }
1630
1631    let links: Vec<serde_json::Value> = links
1632        .into_iter()
1633        .map(|(source, target)| json!({ "source": source, "target": target }))
1634        .collect();
1635
1636    Ok(Json(json!({
1637        "root": root.qname,
1638        "hops": hops,
1639        "direction": direction,
1640        "nodes": nodes,
1641        "links": links,
1642        "truncated": truncated,
1643    })))
1644}
1645
1646/// Parent path of a qname (`payments.charge_card` → `payments`,
1647/// `crate::mod::f` → `crate::mod`). `None` for top-level names.
1648fn qname_module(qname: &str) -> Option<String> {
1649    if let Some((module, _)) = qname.rsplit_once("::") {
1650        return Some(module.to_string());
1651    }
1652    qname.rsplit_once('.').map(|(m, _)| m.to_string())
1653}
1654
1655#[derive(Debug, Deserialize)]
1656struct EffectsOverviewQuery {
1657    /// Maximum effect rows to return (default 50, max 500).
1658    #[serde(default)]
1659    limit: Option<usize>,
1660}
1661
1662/// Top declaring symbols listed per effect row in `/effects/overview`.
1663const EFFECT_TOP_SYMBOLS: usize = 5;
1664
1665/// `GET /api/v1/effects/overview?limit=…` — per effect category, the count
1666/// of symbols declaring it plus the top declarers ranked by transitive
1667/// blast radius. Radius comes from the stored transitive data written by
1668/// `propagate_transitive` at index time (walked via
1669/// `core::declared_effect_blast_radius`) — reachability is not recomputed
1670/// here. Backs DESIGN.md Plan I t-031.
1671async fn effects_overview(
1672    State(state): State<AppState>,
1673    Query(q): Query<EffectsOverviewQuery>,
1674) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
1675    let limit = q.limit.unwrap_or(50).min(500);
1676    let engine = state.engine.lock().await;
1677    let ref_name = engine.ref_name.clone();
1678
1679    let decls = list_all_effect_decls(&engine.repo, &ref_name).map_err(ApiError::from)?;
1680    let radius_by_effect = declared_effect_blast_radius(&decls);
1681    let index = AsgIndexStore::from_engine(&engine);
1682    let id_map = index.build_id_map(&engine);
1683
1684    let mut rows: Vec<(String, usize, Vec<serde_json::Value>)> = radius_by_effect
1685        .into_iter()
1686        .map(|(cat, declarers)| {
1687            let symbol_count = declarers.len();
1688            let top_symbols: Vec<serde_json::Value> = declarers
1689                .iter()
1690                .take(EFFECT_TOP_SYMBOLS)
1691                .map(|(symbol_id, blast_radius)| {
1692                    json!({
1693                        // Fall back to the raw symbol_id if the declarer is
1694                        // no longer in the index (stale decl).
1695                        "qname": id_map
1696                            .get(symbol_id)
1697                            .map(|s| s.qname.clone())
1698                            .unwrap_or_else(|| symbol_id.clone()),
1699                        "blast_radius": blast_radius,
1700                    })
1701                })
1702                .collect();
1703            (cat.as_str().to_string(), symbol_count, top_symbols)
1704        })
1705        .collect();
1706    // Busiest effects first; alphabetical tie-break keeps output stable.
1707    rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1708    rows.truncate(limit);
1709
1710    Ok(Json(
1711        rows.into_iter()
1712            .map(|(effect, symbol_count, top_symbols)| {
1713                json!({
1714                    "effect": effect,
1715                    "symbol_count": symbol_count,
1716                    "top_symbols": top_symbols,
1717                })
1718            })
1719            .collect(),
1720    ))
1721}
1722
1723#[derive(Debug, Deserialize)]
1724struct TimelineQuery {
1725    /// Maximum entries to return (default 100, max 1000).
1726    #[serde(default)]
1727    limit: Option<usize>,
1728    /// Comma-separated `LedgerKind` names in snake_case (e.g.
1729    /// `hypothesis,decision`). Same semantics as `/ledger`'s `kind` filter.
1730    kinds: Option<String>,
1731}
1732
1733/// `GET /api/v1/timeline?limit=…&kinds=…` — chronological (newest first)
1734/// merged feed of ledger entries across all symbols. Plan G thinking
1735/// entries (hypothesis / mental_model / open_question / failed_attempt)
1736/// ARE ledger kinds, so one walk covers both; use `kinds=` to slice
1737/// either family.
1738async fn list_timeline(
1739    State(state): State<AppState>,
1740    Query(q): Query<TimelineQuery>,
1741) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
1742    let engine = state.engine.lock().await;
1743
1744    let mut entries = all_ledger_entries(&engine);
1745    apply_kind_filter(&mut entries, q.kinds.as_deref());
1746    entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
1747
1748    let limit = q.limit.unwrap_or(100).min(1000);
1749    entries.truncate(limit);
1750
1751    let index = AsgIndexStore::from_engine(&engine);
1752    let id_map = index.build_id_map(&engine);
1753    let feed: Vec<serde_json::Value> = entries
1754        .into_iter()
1755        .map(|e| {
1756            json!({
1757                "at": e.created_at,
1758                // LedgerKind serializes snake_case — reuse that for the wire.
1759                "kind": serde_json::to_value(e.kind).unwrap_or(serde_json::Value::Null),
1760                "symbol_id": e.symbol_id,
1761                "qname": id_map.get(&e.symbol_id).map(|s| s.qname.clone()),
1762                "summary": e.summary,
1763                "entry_id": e.entry_id,
1764            })
1765        })
1766        .collect();
1767    Ok(Json(feed))
1768}
1769
1770// -----------------------------------------------------------------------------
1771// Live activity stream (SSE) — see the `events` module for the poller.
1772// -----------------------------------------------------------------------------
1773
1774/// `GET /api/v1/events` — Server-Sent Events stream of live repo activity:
1775/// ledger entries (all kinds, incl. Plan G thinking), effect declarations /
1776/// verifications, index runs, and audit events. Each `data:` payload is a
1777/// JSON object whose field names match `/api/v1/timeline`
1778/// (`{at, kind, qname, symbol_id, entry_id, summary}`) so the Lens "now"
1779/// feed can merge both sources. Events are detected by polling — worst-case
1780/// latency is one [`events::POLL_INTERVAL`].
1781async fn events_stream(
1782    State(state): State<AppState>,
1783) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
1784    use tokio_stream::StreamExt;
1785    let rx = state.events.subscribe().await;
1786    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|item| match item {
1787        Ok(data) => Some(Ok(SseEvent::default().data(data))),
1788        // Slow consumer fell > CHANNEL_CAPACITY events behind: skip the
1789        // gap and keep streaming rather than killing the connection.
1790        Err(_lagged) => None,
1791    });
1792    Sse::new(stream).keep_alive(
1793        // Comment frames so idle streams survive proxies/load-balancers
1794        // that reap quiet connections.
1795        KeepAlive::new()
1796            .interval(std::time::Duration::from_secs(15))
1797            .text("keep-alive"),
1798    )
1799}