Skip to main content

comindly_engine/
web.rs

1//! Web UI (`comindly ui --web`): one embedded page over a small JSON API. Locally it answers on
2//! localhost to the link's token; in server mode members sign in. Ledger writes go to the index root.
3
4use std::collections::{BTreeMap, HashMap};
5use std::sync::Arc;
6
7use anyhow::{Context, Result};
8use axum::extract::{Query, State};
9use axum::http::{header, StatusCode};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use serde_json::Value;
14use tokio::sync::{OnceCell, RwLock};
15
16use crate::api;
17use crate::graph::{Node, Zoom};
18use crate::ledger::{Entry, Provenance, Record, RelationKind, Status};
19use crate::view::Data;
20
21mod answers;
22mod auth;
23mod flows;
24mod jobs;
25mod prompts;
26mod repos;
27mod settings;
28mod team;
29
30/// Everything tied to one loaded index; replaced as a whole when the index changes.
31struct Session {
32    data: Data,
33    entries: RwLock<Vec<Entry>>,
34    /// When `entries` was last read from the ledger.
35    entries_read: tokio::sync::Mutex<std::time::Instant>,
36    manual: RwLock<Vec<crate::conventions::Manual>>,
37    /// Loaded on first use: LLM summaries and flows, and the search model.
38    extras: OnceCell<Extras>,
39    search: OnceCell<Option<Search>>,
40    /// The index's tables, held open so LanceDB caches their metadata and indexes.
41    tables: Option<crate::index::Tables>,
42    /// Org maps of earlier index versions, by position in `/api/versions`.
43    past: tokio::sync::Mutex<HashMap<usize, api::OrgMap>>,
44    /// Where flows start, ranked once per loaded index.
45    flow_list: OnceCell<Vec<api::FlowRow>>,
46    /// The org's teams, shared with the app; they weigh notes from outside a team.
47    teams: crate::mcp::Teams,
48}
49
50#[derive(Default)]
51struct Extras {
52    enrichment: crate::search::Enrichment,
53    flows: HashMap<String, (String, Vec<String>)>,
54}
55
56struct Search {
57    embedder: Arc<crate::embed::Embedder>,
58    by_id: HashMap<String, crate::model::Symbol>,
59}
60
61impl Session {
62    async fn extras(&self) -> &Extras {
63        self.extras
64            .get_or_init(|| async {
65                let Some(uri) = graph_uri(self) else {
66                    return Extras::default();
67                };
68                let enrichment = crate::index::read_enrichment(&uri).await.ok().flatten();
69                let flows = crate::index::read_flows(&uri).await.ok().flatten();
70                Extras {
71                    enrichment: enrichment
72                        .unwrap_or_default()
73                        .into_iter()
74                        .map(|(id, s, q)| (id.render(), (s, q)))
75                        .collect(),
76                    flows: flows
77                        .unwrap_or_default()
78                        .into_iter()
79                        .map(|(id, n, q)| (id.render(), (n, q)))
80                        .collect(),
81                }
82            })
83            .await
84    }
85
86    async fn search(&self) -> Option<&Search> {
87        self.search
88            .get_or_init(|| async {
89                let model = match &self.data.root {
90                    Some(root) => crate::config::index_settings(root).await.embed_model,
91                    None => crate::embed::DEFAULT_MODEL.to_string(),
92                };
93                let embedder =
94                    tokio::task::spawn_blocking(move || crate::embed::Embedder::shared(&model))
95                        .await
96                        .ok()?
97                        .ok()?;
98                let by_id = self
99                    .data
100                    .symbols
101                    .iter()
102                    .map(|s| (s.id.render(), s.clone()))
103                    .collect();
104                Some(Search { embedder, by_id })
105            })
106            .await
107            .as_ref()
108    }
109}
110
111struct AppState {
112    current: RwLock<Arc<Session>>,
113    jobs: crate::jobs::Jobs,
114    watch: tokio::sync::Mutex<api::WatchResult>,
115    /// Members, sign-in and stored secrets, in server mode.
116    server: Option<Arc<crate::server::Server>>,
117    /// The loaded index's shared settings.
118    team: crate::mcp::Teams,
119    /// `config.toml` as last read, with the file's modification time.
120    /// Your preferences as last read, and when; `None` reads them again.
121    config: std::sync::Mutex<(Arc<crate::config::Config>, Option<std::time::Instant>)>,
122    /// What agents reach at `/mcp` on a server.
123    mcp: Option<crate::mcp::Shared>,
124    mcp_generation: Arc<std::sync::atomic::AtomicU64>,
125}
126
127type Shared = Arc<AppState>;
128
129/// The session a request works on: the index loaded when it arrived.
130struct Cur(Arc<Session>);
131
132impl axum::extract::FromRequestParts<Shared> for Cur {
133    type Rejection = std::convert::Infallible;
134
135    async fn from_request_parts(
136        _: &mut axum::http::request::Parts,
137        app: &Shared,
138    ) -> std::result::Result<Self, Self::Rejection> {
139        Ok(Cur(app.current.read().await.clone()))
140    }
141}
142
143/// The server, in server mode.
144struct Srv(Option<Arc<crate::server::Server>>);
145
146impl axum::extract::FromRequestParts<Shared> for Srv {
147    type Rejection = std::convert::Infallible;
148
149    async fn from_request_parts(
150        _: &mut axum::http::request::Parts,
151        app: &Shared,
152    ) -> std::result::Result<Self, Self::Rejection> {
153        Ok(Srv(app.server.clone()))
154    }
155}
156
157impl Session {
158    /// Ledger entries, re-read when a few seconds old: agents and colleagues write records too.
159    async fn entries(&self) -> tokio::sync::RwLockReadGuard<'_, Vec<Entry>> {
160        // One request refreshes; the others read what is there without waiting on storage.
161        let due = {
162            let mut read = self.entries_read.lock().await;
163            let due = read.elapsed() > std::time::Duration::from_secs(15);
164            if due {
165                *read = std::time::Instant::now();
166            }
167            due
168        };
169        if let (true, Some(root)) = (due, self.data.root.clone()) {
170            let opened =
171                tokio::task::spawn_blocking(move || crate::ledger::Ledger::open(&root)).await;
172            if let Ok(Ok(ledger)) = opened {
173                if let Ok(records) = ledger.all().await {
174                    *self.entries.write().await = self.derive(&records).await;
175                }
176            }
177        }
178        self.entries.read().await
179    }
180
181    /// Status and trust of every record, with notes from outside a team weighed and likely repeats marked.
182    async fn derive(&self, records: &[Record]) -> Vec<Entry> {
183        let mut entries = crate::ledger::derive(
184            records,
185            &self.data.symbols,
186            &crate::config::local_verifier(),
187        );
188        {
189            let teams = self.teams.read().unwrap_or_else(|e| e.into_inner());
190            crate::ledger::weigh_teams(&mut entries, records, &teams);
191        }
192        if let Some(search) = self.search().await {
193            crate::ledger::flag_lookalikes(&mut entries, records, &search.embedder);
194        }
195        entries
196    }
197
198    async fn open(mut data: Data, fresh: std::time::Duration, teams: crate::mcp::Teams) -> Session {
199        let entries = std::mem::take(&mut data.entries);
200        let manual = std::mem::take(&mut data.manual);
201        let tables = match data.root.as_ref() {
202            Some(r) => {
203                let uri = format!("{}/_graph", r.trim_end_matches('/'));
204                crate::index::Tables::connect(&uri, fresh).await.ok()
205            }
206            None => None,
207        };
208        Session {
209            data,
210            entries: RwLock::new(entries),
211            // Read again on first use, so the teams weigh what the index loaded.
212            entries_read: tokio::sync::Mutex::new(
213                std::time::Instant::now()
214                    .checked_sub(std::time::Duration::from_secs(60))
215                    .unwrap_or_else(std::time::Instant::now),
216            ),
217            manual: RwLock::new(manual),
218            extras: OnceCell::new(),
219            search: OnceCell::new(),
220            tables,
221            past: Default::default(),
222            teams,
223            flow_list: OnceCell::new(),
224        }
225    }
226}
227
228impl AppState {
229    /// `config.toml`, read again only when the file changed.
230    /// Your preferences, read again every few seconds: the CLI may change them too.
231    fn config(&self) -> Arc<crate::config::Config> {
232        let mut c = self.config.lock().unwrap_or_else(|e| e.into_inner());
233        if c.1
234            .is_none_or(|t| t.elapsed() > std::time::Duration::from_secs(5))
235        {
236            *c = (
237                Arc::new(crate::config::Config::load()),
238                Some(std::time::Instant::now()),
239            );
240        }
241        c.0.clone()
242    }
243
244    /// After a change here: read the preferences again on next use.
245    fn config_changed(&self) {
246        self.config.lock().unwrap_or_else(|e| e.into_inner()).1 = None;
247    }
248
249    fn team(&self) -> crate::team::TeamSettings {
250        self.team.read().unwrap_or_else(|e| e.into_inner()).clone()
251    }
252
253    fn set_team(&self, team: crate::team::TeamSettings) {
254        *self.team.write().unwrap_or_else(|e| e.into_inner()) = team;
255    }
256
257    /// The settings the loaded index runs with here.
258    fn settings(&self) -> crate::context::Settings {
259        crate::context::Settings::resolve(&self.team(), crate::config::env)
260    }
261
262    /// The repositories this app indexes: the server's, else this machine's in `config.toml`.
263    fn repos(&self) -> Vec<crate::config::RepoEntry> {
264        match &self.server {
265            Some(s) => s.settings.repos(),
266            None => self.config().repos.clone(),
267        }
268    }
269
270    fn set_repos(&self, by: Option<&str>, list: &[crate::config::RepoEntry]) -> Result<()> {
271        match &self.server {
272            Some(s) => s.settings.set_repos(by.unwrap_or("admin"), list),
273            None => crate::config::set_repos(list).map(|()| self.config_changed()),
274        }
275    }
276
277    /// Load the index at `root` and make it the one every request sees.
278    async fn reload(&self, root: &str) -> Result<()> {
279        let owned = root.to_string();
280        let data = tokio::task::spawn_blocking(move || {
281            Data::load_or_empty(&owned, &crate::config::local_verifier())
282        })
283        .await?;
284        let fresh = self.config().fresh();
285        let team = crate::team::load(root).await.unwrap_or_default();
286        let repos: Vec<String> = data.graph.repos().into_iter().map(|(r, _)| r).collect();
287        let team = self.backfill_teams(root, team, &repos).await;
288        self.set_team(team);
289        *self.current.write().await = Arc::new(Session::open(data, fresh, self.team.clone()).await);
290        self.reload_mcp(root);
291        Ok(())
292    }
293
294    /// On a server whose teams were never set up: one team named after the org, holding every
295    /// indexed repository and every member and invitation.
296    async fn backfill_teams(
297        &self,
298        root: &str,
299        stored: crate::team::Stored,
300        repos: &[String],
301    ) -> crate::team::TeamSettings {
302        let Some(server) = self.server.as_ref() else {
303            return stored.settings;
304        };
305        let mut members: Vec<String> = server
306            .accounts
307            .members()
308            .unwrap_or_default()
309            .into_iter()
310            .map(|m| m.email)
311            .collect();
312        members.extend(
313            server
314                .accounts
315                .invites()
316                .unwrap_or_default()
317                .into_iter()
318                .map(|i| i.email),
319        );
320        let Some(next) = stored.settings.backfill(repos, &members) else {
321            return stored.settings;
322        };
323        match crate::team::save_as(root, &next, stored.version, "comindly upgrade").await {
324            Ok(_) => {
325                let t = &next.team_list()[0];
326                tracing::info!(
327                    "teams backfilled: {} with {} repositories and {} members",
328                    t.name,
329                    t.repos.len(),
330                    t.members.len()
331                );
332                next
333            }
334            Err(e) => {
335                tracing::warn!("teams not backfilled: {e:#}");
336                crate::team::load(root)
337                    .await
338                    .map(|t| t.settings)
339                    .unwrap_or(stored.settings)
340            }
341        }
342    }
343
344    /// Load the index at `root` for agents in the background, keeping the previous one until then.
345    fn reload_mcp(&self, root: &str) {
346        let Some(mcp) = self.mcp.clone() else {
347            return;
348        };
349        let uri = format!("{}/_graph", root.trim_end_matches('/'));
350        let generation = self
351            .mcp_generation
352            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
353            + 1;
354        let latest = self.mcp_generation.clone();
355        let (fresh, markdown) = (self.config().fresh(), self.settings().format != "json");
356        let teams = self.team.clone();
357        tokio::spawn(async move {
358            match crate::mcp::load(&uri, markdown, fresh).await {
359                // A slower, older load never replaces a newer one.
360                Ok(mut server)
361                    if latest.load(std::sync::atomic::Ordering::SeqCst) == generation =>
362                {
363                    server.teams = teams;
364                    if let Ok(mut m) = mcp.write() {
365                        *m = Some(server);
366                    }
367                }
368                Ok(_) => {}
369                Err(e) => {
370                    crate::ui::warn(&format!("agents cannot use {uri} yet: {}", error_text(&e)))
371                }
372            }
373        });
374    }
375}
376
377/// Who the UI lets in: whoever holds the local link's token, or signed-in members of a server.
378pub enum Access {
379    Local(String),
380    Server(Arc<crate::server::Server>),
381}
382
383/// Serve the UI on `listener` until the process ends.
384pub fn serve(data: Data, listener: std::net::TcpListener, access: Access) -> Result<()> {
385    let rt = crate::rt::shared();
386    let root = data.root.clone();
387    let server = match &access {
388        Access::Server(s) => Some(s.clone()),
389        Access::Local(_) => None,
390    };
391    let cfg = Arc::new(crate::config::Config::load());
392    let team: crate::mcp::Teams = Arc::new(std::sync::RwLock::new(match &root {
393        Some(r) => rt
394            .block_on(crate::team::load(r))
395            .map(|t| t.settings)
396            .unwrap_or_default(),
397        None => Default::default(),
398    }));
399    let state = Arc::new(AppState {
400        team: team.clone(),
401        current: RwLock::new(Arc::new(rt.block_on(Session::open(
402            data,
403            cfg.fresh(),
404            team,
405        )))),
406        config: std::sync::Mutex::new((cfg, Some(std::time::Instant::now()))),
407        jobs: crate::jobs::Jobs::new(
408            match &server {
409                Some(s) => s.jobs.clone(),
410                None => Arc::new(crate::jobs::MemoryJobs::default()),
411            },
412            Arc::new(crate::ui::Terminal::new()),
413        ),
414        watch: Default::default(),
415        mcp: Some(Default::default()),
416        server,
417        mcp_generation: Default::default(),
418    });
419    if let Some(root) = root {
420        let _guard = rt.enter();
421        state.reload_mcp(&root);
422        let s = state.clone();
423        rt.block_on(async move {
424            let stored = crate::team::load(&root).await.unwrap_or_default();
425            let repos: Vec<String> = s
426                .current
427                .read()
428                .await
429                .data
430                .graph
431                .repos()
432                .into_iter()
433                .map(|(r, _)| r)
434                .collect();
435            let team = s.backfill_teams(&root, stored, &repos).await;
436            s.set_team(team);
437        });
438    }
439    rt.spawn(repos::watch(state.clone()));
440    if state.server.is_some() {
441        rt.spawn(repos::carry_on(state.clone()));
442    }
443    if let Some(s) = state.server.clone() {
444        rt.spawn(async move {
445            loop {
446                tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
447                let s = s.clone();
448                let _ = tokio::task::spawn_blocking(move || s.accounts.tidy()).await;
449            }
450        });
451    }
452    let app = Router::new()
453        .route("/api/v1/overview", get(overview))
454        .route("/api/v1/orgmap", get(orgmap))
455        .route("/api/v1/versions", get(versions))
456        .route("/api/v1/contracts", get(contracts))
457        .route("/api/v1/ledger", get(ledger))
458        .route(
459            "/api/v1/view",
460            get(view).layer(tower_http::compression::CompressionLayer::new()),
461        )
462        .route(
463            "/api/v1/conventions",
464            get(conventions).post(change_convention),
465        )
466        .route("/api/v1/find", get(find))
467        .route("/api/v1/node", get(node))
468        .route("/api/v1/search", get(search))
469        .route("/api/v1/context", get(context))
470        .route("/api/v1/agent", get(agent_view))
471        .route("/api/v1/packages", get(packages))
472        .route("/api/v1/flows", get(flows::list))
473        .route("/api/v1/flow", get(flows::one))
474        .route("/api/v1/source", get(flows::source))
475        .route("/api/v1/setup", get(setup))
476        .route("/api/v1/review", post(review))
477        .route("/api/v1/erase", post(erase))
478        .route("/api/v1/remember", post(remember))
479        .route("/api/v1/settings", get(settings::get).post(settings::save))
480        .route("/api/v1/settings/test", post(settings::test))
481        .route(
482            "/api/v1/logging",
483            get(settings::logging).post(settings::set_logging),
484        )
485        .route("/api/v1/repos", get(repos::list).post(repos::change))
486        .route("/api/v1/repos/scan", post(repos::scan))
487        .route("/api/v1/repos/remote/list", post(repos::remote_list))
488        .route("/api/v1/repos/remote/add", post(repos::remote_add))
489        .route("/api/v1/index", get(repos::job).post(repos::start))
490        .route("/api/v1/index/cancel", post(repos::cancel))
491        .route("/api/v1/index/watch", get(repos::watch_state))
492        .route("/api/v1/answer/find", get(answers::find))
493        .route("/api/v1/answer/search", get(answers::search))
494        .route("/api/v1/answer/why", get(answers::why))
495        .route("/api/v1/answer/ledger", get(answers::ledger))
496        .route("/api/v1/answer/flow", get(answers::flow))
497        .route("/api/v1/answer/contracts", get(answers::contracts))
498        .route("/api/v1/answer/explore", get(answers::explore))
499        .route("/api/v1/answer/stats", get(answers::stats))
500        .route("/api/v1/answer/hook", get(answers::hook))
501        .route("/api/v1/answer/guide", get(answers::guide))
502        .route("/api/v1/answer/packages", get(answers::packages))
503        .route("/api/v1/answer/preflight", post(answers::preflight))
504        .route("/api/v1/answer/conventions", get(answers::conventions))
505        .route("/api/v1/jobs", get(jobs::list))
506        .route("/api/v1/jobs/{id}", get(jobs::one))
507        .route("/api/v1/jobs/{id}/events", get(jobs::events))
508        .route("/api/v1/jobs/{id}/cancel", post(jobs::cancel))
509        .route("/api/v1/team", get(team::get).post(team::save))
510        .route("/api/v1/team/key", post(team::key))
511        .route("/api/v1/prompts", get(prompts::get).post(prompts::change))
512        .route("/api/v1/prompts/try", post(prompts::try_draft))
513        .route("/api/v1/join", post(team::join))
514        .route("/api/v1/team/create", post(team::create))
515        .route("/api/v1/teams", post(team::save_teams))
516        .route("/api/v1/repos/teams", post(team::label_repo))
517        .route("/api/v1/me/teams", post(team::my_teams))
518        .route("/api/v1/auth/me", get(auth::me))
519        .route("/health", get(health))
520        .route("/ready", get(ready))
521        .fallback(get(asset));
522    let local = matches!(access, Access::Local(_));
523    let app = match access {
524        Access::Local(token) => app
525            .merge({
526                let shared = state.mcp.clone().unwrap_or_default();
527                Router::new()
528                    .nest_service(
529                        "/mcp",
530                        crate::mcp::http(
531                            shared.clone(),
532                            vec!["127.0.0.1".into(), "localhost".into()],
533                        ),
534                    )
535                    .layer(axum::middleware::from_fn_with_state(shared, mcp_ready))
536            })
537            .layer(axum::middleware::from_fn_with_state(Arc::new(token), guard))
538            .layer(axum::middleware::from_fn(local_only)),
539        Access::Server(server) => app
540            .route("/api/v1/auth/link", post(auth::request_link))
541            .route("/api/v1/auth/redeem", post(auth::redeem))
542            .route("/api/v1/auth/code", post(auth::sign_in_code))
543            .route("/auth/oidc/start", get(auth::oidc_start))
544            .route(crate::oidc::CALLBACK, get(auth::oidc_callback))
545            .route("/api/v1/auth/reset-code", post(auth::reset_own_code))
546            .route("/api/v1/auth/signout", post(auth::sign_out))
547            .route(auth::SELF_REVOKE, post(auth::revoke_self))
548            .route("/api/v1/auth/device/start", post(auth::device_start))
549            .route("/api/v1/auth/device/token", post(auth::device_token))
550            .route("/api/v1/auth/device", get(auth::device_info))
551            .route("/api/v1/auth/device/approve", post(auth::device_approve))
552            .route("/api/v1/members", get(auth::members))
553            .route("/api/v1/members/invite", post(auth::invite))
554            .route("/api/v1/members/reset", post(auth::reset_code))
555            .route("/api/v1/members/role", post(auth::set_role))
556            .route("/api/v1/members/remove", post(auth::remove))
557            .route("/api/v1/members/revoke", post(auth::revoke_invite))
558            .route("/api/v1/tokens", get(auth::tokens).post(auth::create_token))
559            .route("/api/v1/tokens/revoke", post(auth::revoke_token))
560            .route("/api/v1/secrets", get(auth::secrets).post(auth::set_secret))
561            .route("/api/v1/audit", get(auth::audit))
562            .merge({
563                let shared = state.mcp.clone().unwrap_or_default();
564                Router::new()
565                    .nest_service(
566                        "/mcp",
567                        crate::mcp::http(shared.clone(), auth::hosts(&server.config)),
568                    )
569                    .layer(axum::middleware::from_fn_with_state(shared, mcp_ready))
570            })
571            .layer(axum::middleware::from_fn_with_state(server, auth::guard)),
572    }
573    .with_state(state.clone())
574    .layer(axum::middleware::from_fn_with_state(local, traced));
575    let stopping = state;
576    listener.set_nonblocking(true)?;
577    rt.block_on(async move {
578        let listener = tokio::net::TcpListener::from_std(listener)?;
579        axum::serve(
580            listener,
581            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
582        )
583        .with_graceful_shutdown(shutdown(stopping))
584        .await
585        .context("serve")
586    })
587}
588
589/// On Ctrl-C or SIGTERM: stop taking requests and let a running job stop at its next checkpoint.
590async fn shutdown(app: Shared) {
591    let term = async {
592        #[cfg(unix)]
593        if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
594        {
595            s.recv().await;
596            return;
597        }
598        std::future::pending::<()>().await
599    };
600    tokio::select! {
601        _ = tokio::signal::ctrl_c() => {}
602        _ = term => {}
603    }
604    crate::ui::note("stopping");
605    if let Some(job) = app.jobs.shutdown() {
606        job.cancel.cancel();
607        let _ = tokio::time::timeout(std::time::Duration::from_secs(20), job.wait()).await;
608    }
609    // Open event streams and agent sessions do not hold the process.
610    tokio::spawn(async {
611        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
612        crate::telemetry::flush();
613        std::process::exit(0);
614    });
615}
616
617/// The id that ties a request's log lines, the job it starts and its trace together.
618#[derive(Clone)]
619pub(crate) struct RequestId(pub String);
620
621/// A caller's `X-Request-Id` is kept when it is a short token of letters, digits, `-`, `_`, `.`.
622fn safe_request_id(id: &str) -> bool {
623    (1..=64).contains(&id.len())
624        && id
625            .bytes()
626            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
627}
628
629/// Gives each request an id and a span, and logs one access line for it: method, path without
630/// the query, status, time and the member's email. Locally, and for health checks, at debug.
631async fn traced(
632    State(local): State<bool>,
633    mut req: axum::extract::Request,
634    next: axum::middleware::Next,
635) -> axum::response::Response {
636    use tracing::Instrument;
637    let id = req
638        .headers()
639        .get("x-request-id")
640        .and_then(|v| v.to_str().ok())
641        .filter(|v| safe_request_id(v))
642        .map(str::to_string)
643        .unwrap_or_else(new_token);
644    req.extensions_mut().insert(RequestId(id.clone()));
645    let method = req.method().clone();
646    let path = req.uri().path().to_string();
647    let route = req
648        .extensions()
649        .get::<axum::extract::MatchedPath>()
650        .map_or("/*", |m| m.as_str())
651        .to_string();
652    let span = tracing::info_span!(
653        "request",
654        otel.name = %format!("{method} {route}"),
655        otel.kind = "server",
656        otel.status_code = tracing::field::Empty,
657        request_id = %id,
658        http.request.method = %method,
659        http.route = %route,
660        url.path = %path,
661        http.response.status_code = tracing::field::Empty,
662    );
663    let start = std::time::Instant::now();
664    let mut res = next.run(req).instrument(span.clone()).await;
665    let status = res.status().as_u16();
666    span.record("http.response.status_code", status);
667    if status >= 500 {
668        span.record("otel.status_code", "ERROR");
669    }
670    let ms = start.elapsed().as_millis() as u64;
671    let email = res
672        .extensions()
673        .get::<crate::server::Who>()
674        .map(|w| w.email.clone());
675    span.in_scope(|| {
676        let quiet = local || path == "/health" || path == "/ready";
677        if !crate::telemetry::logs("access") {
678            return;
679        }
680        macro_rules! access {
681            ($l:expr) => {
682                tracing::event!(
683                    $l,
684                    kind = "access",
685                    method = %method,
686                    path = %path,
687                    status,
688                    duration_ms = ms,
689                    email = email.as_deref(),
690                    "{method} {path} {status} {ms} ms"
691                )
692            };
693        }
694        if quiet {
695            access!(tracing::Level::DEBUG)
696        } else {
697            access!(tracing::Level::INFO)
698        }
699    });
700    if let Ok(v) = header::HeaderValue::from_str(&id) {
701        res.headers_mut().insert("x-request-id", v);
702    }
703    res
704}
705
706/// The one error shape, `{"error": {"code", "message"}}`, with a status that means what it says.
707pub(crate) fn error_body(status: StatusCode, message: impl Into<String>) -> Response {
708    failure(status, message).into_response()
709}
710
711/// A failed request, answered as [`api::ErrorBody`].
712pub(crate) struct Failure(StatusCode, api::ErrorDetail);
713
714impl IntoResponse for Failure {
715    fn into_response(self) -> Response {
716        (self.0, Json(api::ErrorBody { error: self.1 })).into_response()
717    }
718}
719
720pub(crate) fn failure(status: StatusCode, message: impl Into<String>) -> Failure {
721    let code = match status {
722        StatusCode::BAD_REQUEST => "bad_request",
723        StatusCode::UNAUTHORIZED => "unauthorized",
724        StatusCode::FORBIDDEN => "forbidden",
725        StatusCode::NOT_FOUND => "not_found",
726        StatusCode::CONFLICT => "conflict",
727        StatusCode::GONE => "gone",
728        StatusCode::SERVICE_UNAVAILABLE => "unavailable",
729        _ => "error",
730    };
731    let error = api::ErrorDetail {
732        code: code.into(),
733        message: message.into(),
734        host: None,
735        username: None,
736    };
737    Failure(status, error)
738}
739
740/// A handler's answer: the typed body, or the error shape.
741pub(crate) type Reply<T> = std::result::Result<Json<T>, Failure>;
742
743/// A failed request's 400 with the error explained.
744pub(crate) fn bad(e: anyhow::Error) -> Failure {
745    failure(StatusCode::BAD_REQUEST, error_text(&e))
746}
747
748/// For load balancers and orchestrators: answers while the process serves requests.
749async fn health() -> Json<api::Health> {
750    Json(api::Health {
751        status: "ok".into(),
752        version: env!("CARGO_PKG_VERSION").into(),
753    })
754}
755
756/// For orchestrators: ready once an index is loaded and, on a server, the stores answer.
757async fn ready(State(app): State<Shared>) -> Response {
758    let loaded = app.current.read().await.data.root.is_some();
759    let stores = match app.server.clone() {
760        Some(s) => tokio::task::spawn_blocking(move || s.accounts.members().is_ok())
761            .await
762            .unwrap_or(false),
763        None => true,
764    };
765    let status = if loaded && stores {
766        StatusCode::OK
767    } else {
768        StatusCode::SERVICE_UNAVAILABLE
769    };
770    let ready = api::Ready {
771        ready: status == StatusCode::OK,
772        index: loaded,
773        stores,
774    };
775    (status, Json(ready)).into_response()
776}
777
778/// Agents get 503 with a reason until there is an index to answer from.
779async fn mcp_ready(
780    State(shared): State<crate::mcp::Shared>,
781    req: axum::extract::Request,
782    next: axum::middleware::Next,
783) -> axum::response::Response {
784    if shared.read().map(|s| s.is_none()).unwrap_or(true) {
785        let mut res = error_body(
786            StatusCode::SERVICE_UNAVAILABLE,
787            "no index yet: an admin indexes repositories first",
788        );
789        res.headers_mut()
790            .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60"));
791        return res;
792    }
793    next.run(req).await
794}
795
796/// A random token for one UI session.
797pub fn new_token() -> String {
798    uuid::Uuid::new_v4().simple().to_string()
799}
800
801/// `?token=` on a page sets the session cookie and redirects to the clean URL; `/api/` requires it.
802async fn guard(
803    State(token): State<Arc<String>>,
804    req: axum::extract::Request,
805    next: axum::middleware::Next,
806) -> axum::response::Response {
807    let query = req.uri().query().unwrap_or("");
808    if query.split('&').any(|kv| kv == format!("token={token}")) {
809        let rest: Vec<&str> = query
810            .split('&')
811            .filter(|kv| !kv.starts_with("token="))
812            .collect();
813        let path = req.uri().path();
814        let to = if rest.is_empty() {
815            path.to_string()
816        } else {
817            format!("{path}?{}", rest.join("&"))
818        };
819        let cookie =
820            format!("comindly_token={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000");
821        return (
822            StatusCode::SEE_OTHER,
823            [(header::LOCATION, to), (header::SET_COOKIE, cookie)],
824        )
825            .into_response();
826    }
827    // `/api/v1/auth/me` only says "local", so the page can always tell where it is.
828    let path = req.uri().path();
829    if (path.starts_with("/api/") && path != "/api/v1/auth/me") || path.starts_with("/mcp") {
830        let want = format!("comindly_token={token}");
831        let bearer = format!("Bearer {token}");
832        let ok = req
833            .headers()
834            .get_all(header::COOKIE)
835            .iter()
836            .filter_map(|v| v.to_str().ok())
837            .flat_map(|v| v.split(';'))
838            .any(|c| c.trim() == want)
839            || req
840                .headers()
841                .get(header::AUTHORIZATION)
842                .is_some_and(|v| v.as_bytes() == bearer.as_bytes());
843        if !ok {
844            let msg = "open comindly with `comindly app`, or ask it with the `comindly` command";
845            return error_body(StatusCode::FORBIDDEN, msg).into_response();
846        }
847    }
848    next.run(req).await
849}
850
851/// Reject requests whose `Host` is not loopback, so a DNS-rebinding page cannot read the API.
852async fn local_only(
853    req: axum::extract::Request,
854    next: axum::middleware::Next,
855) -> axum::response::Response {
856    let host = req
857        .headers()
858        .get(header::HOST)
859        .and_then(|h| h.to_str().ok())
860        .unwrap_or("");
861    let name = host.rsplit_once(':').map_or(host, |(h, _)| h);
862    if matches!(name, "127.0.0.1" | "localhost" | "[::1]") {
863        next.run(req).await
864    } else {
865        (
866            StatusCode::FORBIDDEN,
867            "comindly web UI answers on localhost only",
868        )
869            .into_response()
870    }
871}
872
873/// The embedded UI: a file by path, or `index.html` for client-side routes.
874async fn asset(uri: axum::http::Uri) -> impl IntoResponse {
875    let path = uri.path().trim_start_matches('/');
876    let find = |p: &str| {
877        comindly_ui::ASSETS
878            .iter()
879            .find(|(n, _)| *n == p)
880            .map(|(_, b)| *b)
881    };
882    let (name, body) = match find(path) {
883        Some(b) if !path.is_empty() => (path, b),
884        _ if path.starts_with("api/") => {
885            return error_body(StatusCode::NOT_FOUND, format!("no such endpoint: /{path}"))
886                .into_response();
887        }
888        _ if path.starts_with("assets/") => {
889            return (
890                StatusCode::NOT_FOUND,
891                [(header::CONTENT_TYPE, "text/plain")],
892                &b"not found"[..],
893            )
894                .into_response();
895        }
896        _ => ("index.html", find("index.html").unwrap_or_default()),
897    };
898    let mime = match name.rsplit('.').next() {
899        Some("html") => "text/html; charset=utf-8",
900        Some("js") => "text/javascript",
901        Some("css") => "text/css",
902        Some("svg") => "image/svg+xml",
903        Some("png") => "image/png",
904        Some("json") => "application/json",
905        Some("woff2") => "font/woff2",
906        _ => "application/octet-stream",
907    };
908    // Bundled assets carry a content hash in their name; the page itself must stay fresh.
909    let cache = if name.starts_with("assets/") {
910        "public, max-age=31536000, immutable"
911    } else {
912        "no-cache"
913    };
914    (
915        StatusCode::OK,
916        [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
917        body,
918    )
919        .into_response()
920}
921
922async fn overview(who: Member, Cur(s): Cur) -> Json<api::Overview> {
923    let d = &s.data;
924    let entries = s.entries().await;
925    let mut status: BTreeMap<String, usize> = BTreeMap::new();
926    for e in entries.iter() {
927        *status
928            .entry(format!("{:?}", e.status).to_lowercase())
929            .or_default() += 1;
930    }
931    let linked = d
932        .contracts
933        .iter()
934        .filter(|c| !c.providers.is_empty() && !c.consumers.is_empty())
935        .count();
936    let cross = d.edges.iter().filter(|e| e.cross_repo).count();
937    let skew = d
938        .deps
939        .iter()
940        .filter(|x| x.skewed())
941        .map(|x| api::Skew {
942            package: x.name.clone(),
943            publisher: x.publisher.clone(),
944            published: x.published.clone(),
945            consumer: x.consumer.clone(),
946            locked: x.locked.clone(),
947        })
948        .collect();
949    Json(api::Overview {
950        repos: d
951            .graph
952            .repos()
953            .into_iter()
954            .map(|(repo, symbols)| api::RepoSymbols { repo, symbols })
955            .collect(),
956        symbols: d.symbols.len(),
957        edges: d.edges.len(),
958        cross_repo_edges: cross,
959        contracts: d.contracts.len(),
960        linked_contracts: linked,
961        ledger: status,
962        review: crate::ledger::inbox(&entries, true).len(),
963        skew,
964        // Viewers read only.
965        writable: d.root.is_some()
966            && who
967                .as_ref()
968                .is_none_or(|w| w.role.at_least(crate::server::Role::Member)),
969        root: d.root.clone(),
970        problem: d.problem.clone(),
971    })
972}
973
974/// An error for the UI: short, with S3 replies explained and credentials removed.
975pub(crate) fn error_text(e: &anyhow::Error) -> String {
976    crate::storage::explain(&format!("{e:#}"))
977}
978
979fn graph_uri(s: &Session) -> Option<String> {
980    s.data
981        .root
982        .as_ref()
983        .map(|r| format!("{}/_graph", r.trim_end_matches('/')))
984}
985
986/// When each index version was written, oldest first.
987async fn versions(Cur(s): Cur) -> Json<Vec<String>> {
988    let list = match &s.tables {
989        Some(t) => t.versions().await.unwrap_or_default(),
990        None => Vec::new(),
991    };
992    Json(list.into_iter().map(|(_, _, t)| t).collect())
993}
994
995async fn orgmap(Cur(s): Cur, Query(q): Query<api::AtQuery>) -> Reply<api::OrgMap> {
996    let d = &s.data;
997    let Some(i) = q.at else {
998        return Ok(Json(org(&d.graph, &d.symbols, &d.edges, &d.contracts)));
999    };
1000    let past = async {
1001        if let Some(v) = s.past.lock().await.get(&i) {
1002            return anyhow::Ok(v.clone());
1003        }
1004        let tables = s
1005            .tables
1006            .as_ref()
1007            .context("read-only UI has no index root")?;
1008        let list = tables.versions().await?;
1009        let (sv, ev, _) = *list.get(i).context("no such version")?;
1010        let (symbols, edges) = tables.graph_at((sv, ev)).await?;
1011        let graph = crate::graph::CodeGraph::build(&symbols, &edges);
1012        let contracts = graph.contracts(None, false);
1013        let v = org(&graph, &symbols, &edges, &contracts);
1014        s.past.lock().await.insert(i, v.clone());
1015        anyhow::Ok(v)
1016    };
1017    past.await.map(Json).map_err(bad)
1018}
1019
1020/// Repos as nodes; edges aggregate cross-repo code dependencies and contract links.
1021fn org(
1022    graph: &crate::graph::CodeGraph,
1023    symbols: &[crate::model::Symbol],
1024    edges: &[crate::model::Edge],
1025    contracts: &[crate::graph::ContractView],
1026) -> api::OrgMap {
1027    let repo_of: std::collections::HashMap<String, &str> = symbols
1028        .iter()
1029        .map(|x| (x.id.render(), x.repo.0.as_str()))
1030        .collect();
1031    // Per (user, provider, kind): how many links, and the contracts they go through.
1032    let mut links: BTreeMap<(String, String, &str), (usize, std::collections::BTreeSet<String>)> =
1033        BTreeMap::new();
1034    for e in edges.iter().filter(|e| e.cross_repo) {
1035        if let (Some(a), Some(b)) = (repo_of.get(&e.src.render()), repo_of.get(&e.dst.render())) {
1036            if *a != crate::resolve::CONTRACTS && *b != crate::resolve::CONTRACTS && a != b {
1037                links
1038                    .entry((a.to_string(), b.to_string(), "code"))
1039                    .or_default()
1040                    .0 += 1;
1041            }
1042        }
1043    }
1044    let real = |n: &&Node| n.repo != crate::resolve::CONTRACTS;
1045    // A queue subscribed to a topic is a contract consuming a contract: its own consumers are
1046    // the topic's consumers too.
1047    let by_id: std::collections::HashMap<&str, &crate::graph::ContractView> = contracts
1048        .iter()
1049        .map(|c| (c.contract.id.as_str(), c))
1050        .collect();
1051    for c in contracts {
1052        let mut consumers: Vec<&Node> = c.consumers.iter().filter(real).collect();
1053        for via in c.consumers.iter().filter(|n| !real(n)) {
1054            if let Some(q) = by_id.get(via.id.as_str()) {
1055                consumers.extend(q.consumers.iter().filter(real));
1056            }
1057        }
1058        // Tables and buckets are data both sides share, not a call from one to the other.
1059        let kind = if c.contract.name.starts_with("table:") || c.contract.name.starts_with("s3:") {
1060            "data"
1061        } else {
1062            "contract"
1063        };
1064        for u in consumers {
1065            for p in c.providers.iter().filter(real).filter(|p| p.repo != u.repo) {
1066                let link = links
1067                    .entry((u.repo.clone(), p.repo.clone(), kind))
1068                    .or_default();
1069                link.0 += 1;
1070                link.1.insert(c.contract.name.clone());
1071            }
1072        }
1073    }
1074    let nodes = graph
1075        .repos()
1076        .into_iter()
1077        .map(|(r, n)| api::OrgNode {
1078            id: r.clone(),
1079            name: format!("{r} ({n})"),
1080            repo: r,
1081            location: String::new(),
1082        })
1083        .collect();
1084    let edges = links
1085        .into_iter()
1086        .map(|((src, dst, kind), (weight, via))| api::OrgEdge {
1087            src,
1088            dst,
1089            kind: kind.into(),
1090            weight,
1091            via: via.into_iter().collect(),
1092        })
1093        .collect();
1094    api::OrgMap { nodes, edges }
1095}
1096
1097async fn contracts(Cur(s): Cur) -> Json<Vec<api::ContractView>> {
1098    Json(s.data.contracts.clone())
1099}
1100
1101async fn ledger(Cur(s): Cur) -> Json<Vec<Entry>> {
1102    let entries = s.entries().await;
1103    let mut v: Vec<&Entry> = entries.iter().collect();
1104    v.sort_by(|a, b| b.record.created.cmp(&a.record.created));
1105    Json(named(&s, &v))
1106}
1107
1108/// What the terminal UI shows, for `comindly ui` against this server.
1109/// The whole view a client rebuilds its data from, tagged with its hash: a client that already
1110/// holds that tag gets `304 Not Modified` instead of the graph again.
1111async fn view(Cur(s): Cur, headers: axum::http::HeaderMap) -> Response {
1112    let packages = match graph_uri(&s) {
1113        Some(u) => crate::index::read_packages(&u).await.unwrap_or_default(),
1114        None => Vec::new(),
1115    };
1116    let mut entries = s.entries().await.clone();
1117    let findings = crate::checks::run(&entries, &s.data.symbols, &s.data.edges, &packages);
1118    crate::checks::promote(&mut entries, &findings);
1119    let manual = s.manual.read().await.clone();
1120    let body = match serde_json::to_vec(&api::View {
1121        symbols: (&s.data.symbols[..]).into(),
1122        edges: (&s.data.edges[..]).into(),
1123        entries: entries.into(),
1124        conventions: (&s.data.conventions[..]).into(),
1125        manual: manual.into(),
1126        deps: (&s.data.deps[..]).into(),
1127        problem: s.data.problem.clone(),
1128    }) {
1129        Ok(b) => b,
1130        Err(e) => return failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
1131    };
1132    let tag = format!("\"{}\"", &blake3::hash(&body).to_hex()[..32]);
1133    if headers
1134        .get(header::IF_NONE_MATCH)
1135        .and_then(|v| v.to_str().ok())
1136        .is_some_and(|v| v.split(',').any(|t| t.trim() == tag))
1137    {
1138        return (StatusCode::NOT_MODIFIED, [(header::ETAG, tag)]).into_response();
1139    }
1140    (
1141        [
1142            (header::CONTENT_TYPE, "application/json".to_string()),
1143            (header::ETAG, tag),
1144        ],
1145        body,
1146    )
1147        .into_response()
1148}
1149
1150/// Entries with each anchor named as the graph shows it (`Class.method`).
1151fn named(s: &Session, entries: &[&Entry]) -> Vec<Entry> {
1152    let mut out: Vec<Entry> = entries.iter().map(|e| (*e).clone()).collect();
1153    for a in out.iter_mut().flat_map(|e| e.record.anchors.iter_mut()) {
1154        if let Some(i) = s.data.graph.lookup(&a.symbol) {
1155            a.name = s.data.graph.node(i).name;
1156        }
1157    }
1158    out
1159}
1160
1161async fn conventions(Cur(s): Cur, Query(q): Query<api::RepoQuery>) -> Json<api::Conventions> {
1162    let manual = s.manual.read().await;
1163    let mine = |r: &str| q.repo.as_deref().is_none_or(|q| r == q);
1164    let conventions = crate::conventions::apply(s.data.conventions.clone(), &manual)
1165        .into_iter()
1166        .filter(|c| mine(&c.repo))
1167        .map(|c| api::ShownConvention {
1168            id: c.id(),
1169            strength: c.strength().map(String::from),
1170            evidence: c.evidence(),
1171            few: c.few(),
1172            convention: c,
1173        })
1174        .collect();
1175    let hidden = manual
1176        .iter()
1177        .filter(|m| m.hidden && mine(&m.repo))
1178        .cloned()
1179        .collect();
1180    let repos = match graph_uri(&s) {
1181        Some(u) => crate::index::read_indexed_repos(&u)
1182            .await
1183            .unwrap_or_default(),
1184        None => Vec::new(),
1185    };
1186    let commit_urls = repos
1187        .into_iter()
1188        .filter_map(|r| {
1189            Some((
1190                r.name,
1191                crate::hosting::commit_link(r.remote.as_deref()?, "")?,
1192            ))
1193        })
1194        .collect();
1195    Json(api::Conventions {
1196        conventions,
1197        hidden,
1198        commit_urls,
1199    })
1200}
1201
1202async fn change_convention(
1203    who: Member,
1204    Cur(s): Cur,
1205    Json(b): Json<api::ConventionChange>,
1206) -> Reply<api::Done> {
1207    match apply_convention(&s, b, &identity(who.as_deref()).actor).await {
1208        Ok(msg) => Ok(Json(api::Done::new(msg))),
1209        Err(e) => Err(bad(e)),
1210    }
1211}
1212
1213async fn apply_convention(s: &Session, b: api::ConventionChange, actor: &str) -> Result<String> {
1214    use crate::conventions::{change, Change, Manual, Write, ORG};
1215    let root = s.data.root.as_deref().context("read-only UI")?;
1216    let mut manual = s.manual.write().await;
1217    let w = if b.action == "add" {
1218        let dimension = b.dimension.as_deref().unwrap_or("team");
1219        Write::Put(Manual::new(
1220            b.repo.as_deref().unwrap_or(ORG),
1221            dimension,
1222            &b.rule,
1223            actor,
1224        ))
1225    } else {
1226        let what = match b.action.as_str() {
1227            "edit" => Change::Edit(b.rule),
1228            "hide" => Change::Hide,
1229            "restore" => Change::Restore,
1230            other => anyhow::bail!("unknown action {other}"),
1231        };
1232        let current = crate::conventions::apply(s.data.conventions.clone(), &manual);
1233        change(&current, &manual, &b.id, what, actor)?
1234    };
1235    crate::conventions::Store::open(root)?.write(&w).await?;
1236    Ok(match w {
1237        Write::Put(m) => {
1238            let id = m.id.clone();
1239            manual.retain(|x| x.id != id);
1240            let verb = if m.hidden { "hid" } else { "saved" };
1241            manual.push(m);
1242            format!("{verb} {id}")
1243        }
1244        Write::Delete(id) => {
1245            manual.retain(|x| x.id != id);
1246            format!("removed {id}")
1247        }
1248    })
1249}
1250
1251async fn find(Cur(s): Cur, Query(q): Query<api::FindQuery>) -> Json<Vec<Node>> {
1252    Json(s.data.graph.find(&q.q, 40))
1253}
1254
1255async fn node(Cur(s): Cur, Query(q): Query<api::FocusQuery>) -> Reply<api::NodeDetail> {
1256    let g = &s.data.graph;
1257    let Some(idx) = g.lookup(&q.focus) else {
1258        return Err(failure(StatusCode::NOT_FOUND, "no such symbol"));
1259    };
1260    let z = g.zoom(idx);
1261    let ripple = g.ripple(idx, 4);
1262    let mut repos: BTreeMap<String, usize> = BTreeMap::new();
1263    for h in &ripple {
1264        *repos.entry(h.node.repo.clone()).or_default() += 1;
1265    }
1266    let mut ids = vec![q.focus.clone()];
1267    if let Some(f) = &z.focus {
1268        ids.push(f.id.clone());
1269        if let Some((_, scope)) = g.scope_of(&f.id) {
1270            ids.extend(scope);
1271        }
1272    }
1273    let extras = s.extras().await;
1274    let fid = z.focus.as_ref().map(|f| f.id.clone()).unwrap_or_default();
1275    let summary = extras.enrichment.get(&fid).map(|(s, _)| s.clone());
1276    let flow = extras.flows.get(&fid).map(|(n, q)| api::Narration {
1277        narration: n.clone(),
1278        questions: q.clone(),
1279    });
1280    let entries = s.entries().await;
1281    let about: Vec<&Entry> = crate::ledger::about(&entries, &ids);
1282    Ok(Json(api::NodeDetail {
1283        graph: neighbourhood(&z),
1284        zoom: z,
1285        ripple: api::Ripple {
1286            total: ripple.len(),
1287            repos,
1288        },
1289        ledger: named(&s, &about),
1290        summary,
1291        flow,
1292    }))
1293}
1294
1295/// Hybrid code search (BM25, vectors, centrality), as `comindly search` and the MCP tool.
1296async fn search(Cur(s): Cur, Query(q): Query<api::SearchQuery>) -> Reply<Vec<api::SearchHit>> {
1297    let run = async {
1298        let tables = s.tables.as_ref().context("no index root")?;
1299        let engine = s
1300            .search()
1301            .await
1302            .context("the embedding model could not be loaded")?;
1303        let extras = s.extras().await;
1304        let limit = if q.repo.is_some() { 60 } else { 20 };
1305        let mut hits = crate::search::hybrid(
1306            tables,
1307            &engine.by_id,
1308            &s.data.graph,
1309            &extras.enrichment,
1310            &engine.embedder,
1311            &q.q,
1312            limit,
1313        )
1314        .await
1315        .map_err(|e| match format!("{e:#}").contains("was not found") {
1316            true => {
1317                anyhow::anyhow!("this index has no search table: rebuild it without --no-embed")
1318            }
1319            false => e.context("hybrid search failed"),
1320        })?;
1321        if let Some(r) = &q.repo {
1322            hits.retain(|h| &h.repo == r);
1323            hits.truncate(20);
1324        }
1325        anyhow::Ok(
1326            hits.into_iter()
1327                .map(|h| api::SearchHit {
1328                    id: h.id,
1329                    name: h.name,
1330                    kind: h.kind,
1331                    repo: h.repo,
1332                    location: h.location,
1333                    signature: h.signature,
1334                    summary: h.summary,
1335                    dependents: h.deps,
1336                })
1337                .collect(),
1338        )
1339    };
1340    run.await.map(Json).map_err(bad)
1341}
1342
1343/// What a read-only MCP tool answers an agent, as markdown; plain text, not JSON.
1344async fn agent_view(
1345    State(app): State<Shared>,
1346    Cur(s): Cur,
1347    Query(q): Query<api::AgentQuery>,
1348) -> std::result::Result<String, Failure> {
1349    let args = match q.args.as_deref().filter(|a| !a.is_empty()) {
1350        Some(a) => serde_json::from_str(a).map_err(|e| bad(e.into()))?,
1351        None => serde_json::json!({}),
1352    };
1353    let slot = app.mcp.clone().unwrap_or_default();
1354    let loaded = slot.read().ok().and_then(|m| m.clone());
1355    let server = match loaded {
1356        Some(server) => server,
1357        None => {
1358            let root = s.data.root.clone().unwrap_or_default();
1359            let uri = format!("{}/_graph", root.trim_end_matches('/'));
1360            let mut server = crate::mcp::load(&uri, true, app.config().fresh())
1361                .await
1362                .map_err(|e| failure(StatusCode::SERVICE_UNAVAILABLE, error_text(&e)))?;
1363            server.teams = app.team.clone();
1364            if let Ok(mut m) = slot.write() {
1365                m.get_or_insert_with(|| server.clone());
1366            }
1367            server
1368        }
1369    };
1370    server.as_agent(&q.tool, args).await.map_err(bad)
1371}
1372
1373/// The context pack for a symbol as markdown, ready to paste into an agent; plain text, not JSON.
1374async fn context(Cur(s): Cur, Query(q): Query<api::FocusQuery>) -> impl IntoResponse {
1375    let g = &s.data.graph;
1376    let Some(idx) = g.lookup(&q.focus) else {
1377        return (StatusCode::NOT_FOUND, "no such symbol".to_string());
1378    };
1379    let extras = s.extras().await;
1380    let focus = g.node(idx);
1381    let pack = g.context_pack(idx, q.budget.unwrap_or(1500));
1382    let mut o = format!(
1383        "## Context for changing `{}` ({} · {})\n\nRead these first:\n\n",
1384        focus.name, focus.repo, focus.location
1385    );
1386    for (n, _) in pack.iter().filter(|(n, _)| n.id != focus.id) {
1387        o.push_str(&format!("- `{}` ({} · {})", n.name, n.repo, n.location));
1388        if let Some((sum, _)) = extras.enrichment.get(&n.id) {
1389            o.push_str(&format!(": {sum}"));
1390        }
1391        o.push('\n');
1392    }
1393    let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1394    ids.push(focus.id.clone());
1395    let entries = s.entries().await;
1396    let known: Vec<String> = crate::ledger::about(&entries, &ids)
1397        .into_iter()
1398        .filter(|e| e.trust >= crate::ledger::Trust::T2)
1399        .filter(|e| !matches!(e.status, Status::Superseded | Status::Retracted))
1400        .take(6)
1401        .map(crate::ledger::line)
1402        .collect();
1403    if !known.is_empty() {
1404        o.push_str("\nRecorded by the team:\n\n");
1405        for k in known {
1406            o.push_str(&format!("- {k}\n"));
1407        }
1408    }
1409    (StatusCode::OK, o)
1410}
1411
1412async fn packages(Cur(s): Cur) -> Json<Vec<api::Package>> {
1413    Json(
1414        s.data
1415            .deps
1416            .iter()
1417            .map(|d| api::Package {
1418                ecosystem: d.ecosystem.clone(),
1419                name: d.name.clone(),
1420                publisher: d.publisher.clone(),
1421                published: d.published.clone(),
1422                consumer: d.consumer.clone(),
1423                requirement: d.requirement.clone(),
1424                locked: d.locked.clone(),
1425                skewed: d.skewed(),
1426            })
1427            .collect(),
1428    )
1429}
1430
1431/// Setup status and how to connect agents: index location, checks, MCP tools.
1432async fn setup(State(app): State<Shared>, Cur(s): Cur) -> Json<api::Setup> {
1433    let (server, team) = (app.server.clone(), app.team());
1434    let root = s.data.root.clone().unwrap_or_default();
1435    let checks = {
1436        let (root, server) = (root.clone(), server.clone());
1437        tokio::task::spawn_blocking(move || crate::doctor::run(&team, server.as_deref(), &root))
1438            .await
1439            .unwrap_or_default()
1440    };
1441    let tools = crate::mcp::tools()
1442        .into_iter()
1443        .map(|(name, description)| api::Tool { name, description })
1444        .collect();
1445    let agents = match server {
1446        Some(_) => Vec::new(),
1447        None => crate::doctor::agents()
1448            .into_iter()
1449            .map(String::from)
1450            .collect(),
1451    };
1452    Json(api::Setup {
1453        remote: crate::cache::is_remote(&root),
1454        index: root,
1455        version: env!("CARGO_PKG_VERSION").into(),
1456        checks,
1457        tools,
1458        agents,
1459        agents_md: crate::agents::agents_md(),
1460        skill: crate::agents::skill(),
1461    })
1462}
1463
1464/// Nodes and labelled edges around the focus, for the canvas graph.
1465fn neighbourhood(z: &Zoom) -> api::Neighbourhood {
1466    let Some(f) = &z.focus else {
1467        return api::Neighbourhood::default();
1468    };
1469    let mut nodes: BTreeMap<&str, &Node> = BTreeMap::from([(f.id.as_str(), f)]);
1470    let mut edges: Vec<api::GraphEdge> = Vec::new();
1471    let mut add = |a: &Node, b: &Node, kind: &str| {
1472        edges.push(api::GraphEdge {
1473            src: a.id.clone(),
1474            dst: b.id.clone(),
1475            kind: kind.into(),
1476        });
1477    };
1478    const CAP: usize = 40;
1479    let groups: [(&[Node], bool, &str); 5] = [
1480        (&z.callers, false, "calls"),
1481        (&z.callees, true, "calls"),
1482        (&z.importers, false, "imports"),
1483        (&z.members, true, "contains"),
1484        (&z.consumers, false, "consumes"),
1485    ];
1486    for (list, outgoing, kind) in groups {
1487        for n in list.iter().take(CAP) {
1488            nodes.insert(&n.id, n);
1489            if outgoing {
1490                add(f, n, kind);
1491            } else {
1492                add(n, f, kind);
1493            }
1494        }
1495    }
1496    for p in z.providers.iter().take(CAP) {
1497        nodes.insert(&p.id, p);
1498        add(p, f, "provides");
1499    }
1500    if let Some(c) = &z.container {
1501        nodes.insert(&c.id, c);
1502        add(c, f, "contains");
1503    }
1504    for r in &z.contracts {
1505        nodes.insert(&r.contract.id, &r.contract);
1506        add(f, &r.contract, r.role.as_str());
1507        for c in r.counterparts.iter().take(CAP) {
1508            nodes.insert(&c.id, c);
1509            add(c, &r.contract, "counterpart");
1510        }
1511    }
1512    api::Neighbourhood {
1513        nodes: nodes.into_values().cloned().collect(),
1514        edges,
1515        focus: Some(f.id.clone()),
1516    }
1517}
1518
1519/// Provenance for a write from the UI: the signed-in member on a server, else this machine's user.
1520fn provenance(who: Option<&crate::server::Who>, s: &Session) -> Provenance {
1521    let mut p = Provenance::human("comindly-web", &identity(who));
1522    if let Some(w) = who {
1523        let teams = s.teams.read().unwrap_or_else(|e| e.into_inner());
1524        p.teams = teams.teams_of_member(&w.email);
1525    }
1526    p
1527}
1528
1529/// The signed-in member on a server, else this machine's user.
1530fn identity(who: Option<&crate::server::Who>) -> crate::ledger::Identity {
1531    match who {
1532        Some(w) => crate::ledger::Identity {
1533            actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
1534            name: Some(w.email.clone()),
1535        },
1536        None => crate::config::local_identity(),
1537    }
1538}
1539
1540type Member = Option<axum::Extension<crate::server::Who>>;
1541
1542/// Records written here are signed with this machine's key in local mode; a server does not sign.
1543fn signer(who: Option<&crate::server::Who>) -> Option<std::path::PathBuf> {
1544    who.is_none()
1545        .then(crate::config::local_signing_key)
1546        .flatten()
1547}
1548
1549async fn review(who: Member, Cur(s): Cur, Json(b): Json<api::ReviewRequest>) -> Reply<api::Done> {
1550    let signer = signer(who.as_deref());
1551    match apply_review(&s, &b, provenance(who.as_deref(), &s), signer).await {
1552        Ok(msg) => Ok(Json(api::Done::new(msg))),
1553        Err(e) => Err(bad(e)),
1554    }
1555}
1556
1557async fn apply_review(
1558    s: &Session,
1559    b: &api::ReviewRequest,
1560    prov: Provenance,
1561    signer: Option<std::path::PathBuf>,
1562) -> Result<String> {
1563    let root = s.data.root.as_deref().context("read-only UI")?;
1564    let (kind, _) = crate::ledger::review_action(&b.action).context("unknown action")?;
1565    let ledger = crate::ledger::Ledger::open(root)?.signed_by(signer);
1566    let target = {
1567        let entries = s.entries().await;
1568        let exact = entries.iter().find(|e| e.record.id == b.id);
1569        let prefixed: Vec<_> = entries
1570            .iter()
1571            .filter(|e| e.record.id.starts_with(&b.id))
1572            .collect();
1573        match (exact, prefixed.as_slice()) {
1574            (Some(e), _) | (None, &[e]) => e.record.clone(),
1575            (None, []) => anyhow::bail!("no ledger record `{}`", b.id),
1576            (None, _) => anyhow::bail!("`{}` matches several records; give more of the id", b.id),
1577        }
1578    };
1579    if matches!(kind, RelationKind::Retracts) && target.kind == crate::ledger::Kind::Relation {
1580        anyhow::bail!("relations cannot be retracted");
1581    }
1582    let commits = match graph_uri(s) {
1583        Some(u) => crate::index::read_repo_commits(&u)
1584            .await
1585            .unwrap_or_default(),
1586        None => Default::default(),
1587    };
1588    let rec = Record::review(&target, &b.action, &b.reason, prov)?.vouching(&target, |id| {
1589        let sym = s.data.symbols.iter().find(|x| x.id.render() == id)?;
1590        Some(crate::ledger::Anchor::of(sym).at(&commits))
1591    });
1592    ledger.put(&rec).await?;
1593    let records = ledger.all().await?;
1594    let fresh = s.derive(&records).await;
1595    let status = fresh
1596        .iter()
1597        .find(|e| e.record.id == target.id)
1598        .map_or(Status::Active, |e| e.status);
1599    *s.entries.write().await = fresh;
1600    Ok(format!("{} → {:?}", target.title, status))
1601}
1602
1603/// A record written by the person using the UI.
1604async fn remember(
1605    who: Member,
1606    Cur(s): Cur,
1607    Json(b): Json<api::RememberRequest>,
1608) -> Reply<api::Remembered> {
1609    let run = async {
1610        let root = s.data.root.as_deref().context("read-only UI")?;
1611        let kind = crate::ledger::Kind::parse(&b.kind).context("unknown kind")?;
1612        anyhow::ensure!(
1613            !b.body.trim().is_empty(),
1614            "say why: every record carries the reasoning other agents need"
1615        );
1616        let g = &s.data.graph;
1617        let commits = match graph_uri(&s) {
1618            Some(u) => crate::index::read_repo_commits(&u)
1619                .await
1620                .unwrap_or_default(),
1621            None => Default::default(),
1622        };
1623        let mut anchors = Vec::new();
1624        for focus in &b.on {
1625            let id = g
1626                .lookup(focus)
1627                .map(|i| g.node(i).id)
1628                .with_context(|| format!("no symbol matches `{focus}`"))?;
1629            let sym = s.data.symbols.iter().find(|x| x.id.render() == id);
1630            anchors.push(crate::ledger::Anchor::of(sym.context("symbol not loaded")?).at(&commits));
1631        }
1632        let commit = anchors.first().and_then(|a| a.commit.clone());
1633        let mut rec = Record::new(kind, &b.title, anchors, provenance(who.as_deref(), &s));
1634        let repos: Vec<&str> = rec.anchors.iter().map(|a| a.repo.as_str()).collect();
1635        s.teams
1636            .read()
1637            .unwrap_or_else(|e| e.into_inner())
1638            .accepts(&rec.provenance.teams, &repos)?;
1639        rec.body = b.body.trim().to_string();
1640        rec.trigger = b.trigger.filter(|t| !t.trim().is_empty());
1641        rec.evidence = b
1642            .evidence
1643            .into_iter()
1644            .filter(|e| !e.trim().is_empty())
1645            .collect();
1646        for (k, v) in b.fields.into_iter().filter(|(_, v)| !v.trim().is_empty()) {
1647            rec.fields.insert(k, Value::String(v));
1648        }
1649        rec.snippets = b
1650            .snippets
1651            .into_iter()
1652            .filter(|s| !s.code.trim().is_empty())
1653            .map(|mut s| {
1654                s.commit = s.commit.or_else(|| commit.clone());
1655                s
1656            })
1657            .collect();
1658        let ledger = crate::ledger::Ledger::open(root)?.signed_by(signer(who.as_deref()));
1659        ledger.put(&rec).await?;
1660        let records = ledger.all().await?;
1661        *s.entries.write().await = s.derive(&records).await;
1662        anyhow::Ok(rec.id)
1663    };
1664    match run.await {
1665        Ok(id) => Ok(Json(api::Remembered { ok: true, id })),
1666        Err(e) => Err(bad(e)),
1667    }
1668}
1669
1670/// Permanently delete a record and the reviews about it.
1671async fn erase(Cur(s): Cur, Json(b): Json<api::IdRequest>) -> Reply<api::Done> {
1672    let run = async {
1673        let root = s.data.root.as_deref().context("read-only UI")?;
1674        let ledger = crate::ledger::Ledger::open(root)?;
1675        let gone = ledger.erase(|r| r.id == b.id, true).await?;
1676        anyhow::ensure!(!gone.is_empty(), "no such record");
1677        let records = ledger.all().await?;
1678        *s.entries.write().await = s.derive(&records).await;
1679        anyhow::Ok(format!(
1680            "deleted {}",
1681            crate::doctor::plural(gone.len(), "record")
1682        ))
1683    };
1684    match run.await {
1685        Ok(msg) => Ok(Json(api::Done::new(msg))),
1686        Err(e) => Err(bad(e)),
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use super::*;
1693
1694    #[test]
1695    fn shared_tables_are_data_and_routes_are_contracts() {
1696        let node = |id: &str, repo: &str| Node {
1697            id: id.into(),
1698            name: id.into(),
1699            kind: "function".into(),
1700            repo: repo.into(),
1701            location: String::new(),
1702            signature: None,
1703        };
1704        let view = |name: &str, provider: &str, consumer: &str| crate::graph::ContractView {
1705            contract: node(name, crate::resolve::CONTRACTS),
1706            providers: vec![node("p", provider)],
1707            consumers: vec![node("c", consumer)],
1708        };
1709        let graph = crate::graph::CodeGraph::build(&[], &[]);
1710        let map = org(
1711            &graph,
1712            &[],
1713            &[],
1714            &[
1715                view("table:customer", "service", "library"),
1716                view("http:POST /chat", "agent", "service"),
1717            ],
1718        );
1719        let kind = |src: &str| {
1720            map.edges
1721                .iter()
1722                .find(|e| e.src == src)
1723                .unwrap()
1724                .kind
1725                .clone()
1726        };
1727        assert_eq!(kind("library"), "data");
1728        assert_eq!(kind("service"), "contract");
1729    }
1730
1731    /// A router behind `traced` whose log lines, as JSON, land in the returned buffer.
1732    fn traced_app() -> (Router, Arc<std::sync::Mutex<Vec<String>>>) {
1733        let lines: Arc<std::sync::Mutex<Vec<String>>> = Default::default();
1734        let app = Router::new()
1735            .route(
1736                "/api/v1/thing",
1737                get(|rid: axum::Extension<RequestId>| async move {
1738                    crate::ui::warn("something odd");
1739                    let mut res = rid.0 .0.into_response();
1740                    res.extensions_mut().insert(crate::server::Who {
1741                        email: "member@example.com".into(),
1742                        role: crate::server::Role::Member,
1743                    });
1744                    res
1745                }),
1746            )
1747            .layer(axum::middleware::from_fn_with_state(false, traced));
1748        (app, lines)
1749    }
1750
1751    async fn call(app: Router, req: axum::http::Request<axum::body::Body>) -> (String, String) {
1752        use tower::ServiceExt;
1753        let res = app.oneshot(req).await.unwrap();
1754        let id = res.headers()["x-request-id"].to_str().unwrap().to_string();
1755        let body = axum::body::to_bytes(res.into_body(), 1 << 16)
1756            .await
1757            .unwrap();
1758        (id, String::from_utf8_lossy(&body).into_owned())
1759    }
1760
1761    #[tokio::test]
1762    async fn requests_get_an_id_and_one_access_line_without_credentials() {
1763        use tracing_subscriber::layer::SubscriberExt;
1764        let (app, lines) = traced_app();
1765        let out = lines.clone();
1766        let sub = tracing_subscriber::registry()
1767            .with(crate::telemetry::Lines::to(true, move |l| {
1768                out.lock().unwrap().push(l.to_string())
1769            }));
1770        let _g = tracing::subscriber::set_default(sub);
1771
1772        let req = axum::http::Request::get("/api/v1/thing?token=query-secret")
1773            .header("x-request-id", "abc-123.x_y")
1774            .header(header::AUTHORIZATION, "Bearer bearer-secret")
1775            .header(header::COOKIE, "comindly_session=cookie-secret")
1776            .body(axum::body::Body::empty())
1777            .unwrap();
1778        let (id, seen) = call(app.clone(), req).await;
1779        assert_eq!(id, "abc-123.x_y");
1780        assert_eq!(seen, id, "the handler sees the same id");
1781
1782        let lines = lines.lock().unwrap().clone();
1783        let all = lines.join("\n");
1784        for secret in ["query-secret", "bearer-secret", "cookie-secret", "token="] {
1785            assert!(!all.contains(secret), "{secret} leaked: {all}");
1786        }
1787        let parsed: Vec<Value> = lines
1788            .iter()
1789            .map(|l| serde_json::from_str(l).unwrap())
1790            .collect();
1791        let warn = parsed.iter().find(|v| v["kind"] == "warning").unwrap();
1792        assert_eq!(warn["request_id"], "abc-123.x_y");
1793        let access: Vec<&Value> = parsed.iter().filter(|v| v["kind"] == "access").collect();
1794        assert_eq!(access.len(), 1);
1795        let a = access[0];
1796        assert_eq!(a["level"], "info");
1797        assert_eq!(a["method"], "GET");
1798        assert_eq!(a["path"], "/api/v1/thing");
1799        assert_eq!(a["status"], 200);
1800        assert_eq!(a["request_id"], "abc-123.x_y");
1801        assert_eq!(a["email"], "member@example.com");
1802        assert!(a["duration_ms"].is_u64());
1803
1804        for bad in ["has space", "semi;colon", &"x".repeat(65)] {
1805            let req = axum::http::Request::get("/api/v1/thing")
1806                .header("x-request-id", bad)
1807                .body(axum::body::Body::empty())
1808                .unwrap();
1809            let (id, _) = call(app.clone(), req).await;
1810            assert_ne!(id, bad);
1811            assert!(safe_request_id(&id));
1812        }
1813        let req = axum::http::Request::get("/api/v1/thing")
1814            .body(axum::body::Body::empty())
1815            .unwrap();
1816        let (id, _) = call(app, req).await;
1817        assert!(safe_request_id(&id));
1818    }
1819
1820    fn n(id: &str, repo: &str) -> Node {
1821        Node {
1822            id: id.into(),
1823            name: id.into(),
1824            kind: "function".into(),
1825            repo: repo.into(),
1826            location: String::new(),
1827            signature: None,
1828        }
1829    }
1830
1831    #[test]
1832    fn neighbourhood_links_focus_both_ways() {
1833        let z = Zoom {
1834            focus: Some(n("f", "a")),
1835            callers: vec![n("caller", "b")],
1836            callees: vec![n("callee", "a")],
1837            ..Default::default()
1838        };
1839        let g = neighbourhood(&z);
1840        assert_eq!(g.nodes.len(), 3);
1841        let edges: Vec<(String, String)> = g
1842            .edges
1843            .iter()
1844            .map(|e| (e.src.clone(), e.dst.clone()))
1845            .collect();
1846        assert!(edges.contains(&("caller".into(), "f".into())));
1847        assert!(edges.contains(&("f".into(), "callee".into())));
1848    }
1849}