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