1pub 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
42pub type SharedEngine = Arc<Mutex<Engine>>;
46
47#[derive(Clone)]
48pub struct AppState {
49 pub engine: SharedEngine,
50 pub db_path: PathBuf,
51 pub audit_log_path: Option<PathBuf>,
55 pub events: Arc<events::EventHub>,
58 id_map_memo: Arc<std::sync::Mutex<IdMapMemo>>,
66 edge_maps_memo: Arc<std::sync::Mutex<EdgeMapsMemo>>,
70 gc_memo: Arc<std::sync::Mutex<GcMemo>>,
73 gc_gate: Arc<Mutex<()>>,
76}
77
78type IdMapMemo = Option<(
79 agentstategraph_core::ObjectId,
80 Arc<std::collections::HashMap<String, agentstatedeveloper_core::Symbol>>,
81)>;
82
83type 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
91type GcMemo = Option<(
93 agentstategraph_core::ObjectId,
94 chrono::DateTime<chrono::Utc>,
95 serde_json::Value,
96)>;
97
98fn 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
128fn 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
155pub 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 .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 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 .layer(oxtel_tower::HttpServerLayer::new())
265}
266
267pub enum ApiError {
272 BadRequest(String),
273 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#[derive(Deserialize)]
306struct HistoryQuery {
307 granularity: Option<String>,
309 limit: Option<usize>,
311 store: Option<String>,
313}
314
315fn truthy(v: Option<&str>) -> bool {
316 matches!(v, Some("1") | Some("true") | Some("yes"))
317}
318
319async 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 let milestones = q.limit.unwrap_or(2000);
336 let store = truthy(q.store.as_deref());
337 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 cached_only: Option<String>,
379 refresh: Option<String>,
381}
382
383#[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
395async 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 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
474async 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 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 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 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 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 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
601fn 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#[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 #[serde(default)]
671 limit: Option<usize>,
672 #[serde(default)]
674 offset: Option<usize>,
675}
676
677#[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 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 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 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
727async 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 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 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 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
894fn 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 kind: Option<String>,
925 #[serde(default)]
927 limit: Option<usize>,
928 #[serde(default)]
930 offset: Option<usize>,
931}
932
933async 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
957fn 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
984fn 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 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#[derive(Debug, Deserialize)]
1014struct ThinkingQuery {
1015 qnames: Option<String>,
1019 min_confidence: Option<f64>,
1023}
1024
1025async 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 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
1059async 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#[derive(Debug, Deserialize)]
1084struct AuditQuery {
1085 event_type: Option<String>,
1088 since: Option<String>,
1090 actor: Option<String>,
1092 outcome: Option<String>,
1094 subject: Option<String>,
1099 #[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 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 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#[derive(Debug, Deserialize)]
1183struct ApproveBody {
1184 approver: String,
1186 #[serde(default = "default_approver_kind")]
1188 approver_kind: String,
1189 #[serde(default = "default_http_agent_id")]
1191 agent_id: String,
1192 #[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#[derive(Debug, Deserialize)]
1431struct SearchApiQuery {
1432 q: String,
1435 kind: Option<String>,
1437 lang: Option<String>,
1439 #[serde(default)]
1441 limit: Option<usize>,
1442}
1443
1444async 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 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 #[serde(default)]
1525 hops: Option<usize>,
1526 direction: Option<String>,
1528}
1529
1530const GRAPH_NODE_CAP: usize = 500;
1533
1534async 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 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 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
1646fn 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 #[serde(default)]
1659 limit: Option<usize>,
1660}
1661
1662const EFFECT_TOP_SYMBOLS: usize = 5;
1664
1665async 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 "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 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 #[serde(default)]
1727 limit: Option<usize>,
1728 kinds: Option<String>,
1731}
1732
1733async 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 "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
1770async 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 Err(_lagged) => None,
1791 });
1792 Sse::new(stream).keep_alive(
1793 KeepAlive::new()
1796 .interval(std::time::Duration::from_secs(15))
1797 .text("keep-alive"),
1798 )
1799}