1use 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
30struct Session {
32 data: Data,
33 entries: RwLock<Vec<Entry>>,
34 entries_read: tokio::sync::Mutex<std::time::Instant>,
36 manual: RwLock<Vec<crate::conventions::Manual>>,
37 extras: OnceCell<Extras>,
39 search: OnceCell<Option<Search>>,
40 tables: Option<crate::index::Tables>,
42 past: tokio::sync::Mutex<HashMap<usize, api::OrgMap>>,
44 flow_list: OnceCell<Vec<api::FlowRow>>,
46 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 server: Option<Arc<crate::server::Server>>,
117 team: crate::mcp::Teams,
119 config: std::sync::Mutex<(Arc<crate::config::Config>, Option<std::time::Instant>)>,
122 mcp: Option<crate::mcp::Shared>,
124 mcp_generation: Arc<std::sync::atomic::AtomicU64>,
125}
126
127type Shared = Arc<AppState>;
128
129struct 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
143struct 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 async fn entries(&self) -> tokio::sync::RwLockReadGuard<'_, Vec<Entry>> {
160 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 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 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 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 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 fn settings(&self) -> crate::context::Settings {
260 crate::context::Settings::resolve(&self.team(), crate::config::env)
261 }
262
263 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 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 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 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 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
378pub enum Access {
380 Local(String),
381 Server(Arc<crate::server::Server>),
382}
383
384pub 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
590async 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 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#[derive(Clone)]
620pub(crate) struct RequestId(pub String);
621
622fn 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
630async 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
707pub(crate) fn error_body(status: StatusCode, message: impl Into<String>) -> Response {
709 failure(status, message).into_response()
710}
711
712pub(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
741pub(crate) type Reply<T> = std::result::Result<Json<T>, Failure>;
743
744pub(crate) fn bad(e: anyhow::Error) -> Failure {
746 failure(StatusCode::BAD_REQUEST, error_text(&e))
747}
748
749async fn health() -> Json<api::Health> {
751 Json(api::Health {
752 status: "ok".into(),
753 version: env!("CARGO_PKG_VERSION").into(),
754 })
755}
756
757async 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
779async 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
797pub fn new_token() -> String {
799 uuid::Uuid::new_v4().simple().to_string()
800}
801
802async 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 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
852async 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
874async 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 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 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
975pub(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
987async 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
1021fn 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 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 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 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
1109async 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
1151fn 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(¤t, &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
1296async 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
1344async 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
1374async 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
1432async 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
1465fn 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
1520fn 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
1530fn 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
1543fn 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
1604async 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
1671async 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 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}