use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use axum::extract::{Form, Path, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::Json; use serde::{Deserialize, Serialize}; use tera::Context; use tokio::sync::RwLock; use tracing::warn; use super::auth::AuthUser; use crate::api::AppState; // ── Error ──────────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] pub enum UiError { #[error("template render error: {0}")] Render(#[from] tera::Error), #[error("ncl export failed for {path}: {reason}")] NclExport { path: String, reason: String }, #[error("ui is not configured — start the daemon with --templates-dir")] NotConfigured, #[error("bad request: {0}")] BadRequest(String), #[error("forbidden: {0}")] Forbidden(String), } impl IntoResponse for UiError { fn into_response(self) -> Response { let status = match &self { UiError::BadRequest(_) => StatusCode::BAD_REQUEST, UiError::Forbidden(_) => StatusCode::FORBIDDEN, _ => StatusCode::INTERNAL_SERVER_ERROR, }; let detail = if let UiError::Render(ref e) = self { use std::error::Error; let mut chain = format!("{e}"); let mut src = e.source(); while let Some(s) = src { chain.push_str(&format!("\nCaused by: {s}")); src = s.source(); } chain } else { format!("{self}") }; let html = format!( r#"

UI Error

{detail}
"# ); (status, Html(html)).into_response() } } // ── Helpers ────────────────────────────────────────────────────────────────── /// Acquire read lock on Tera and render a template. /// The lock is held only for the duration of `render()` — not across the /// async NCL export — so reloads are never blocked by slow nickel subprocess /// calls. pub(crate) async fn render( tera: &Arc>, template: &str, ctx: &Context, ) -> Result, UiError> { let guard = tera.read().await; guard.render(template, ctx).map(Html).map_err(|e| { use std::error::Error; let mut chain = format!("{e}"); let mut src = e.source(); while let Some(s) = src { chain.push_str(&format!(" → {s}")); src = s.source(); } tracing::error!(template, error = %chain, "tera render failed"); UiError::Render(e) }) } pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc>, UiError> { state.tera.as_ref().ok_or(UiError::NotConfigured) } async fn ncl_export(state: &AppState, relative_path: &str) -> Result { let path = state.project_root.join(relative_path); let (value, _hit) = state .cache .export(&path, state.nickel_import_path.as_deref()) .await .map_err(|e| UiError::NclExport { path: relative_path.to_string(), reason: e.to_string(), })?; Ok(value) } pub(crate) fn epoch_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } // ── Auth helpers ───────────────────────────────────────────────────────────── /// Map the HTTP auth role to a lowercase string for Tera context. /// Templates gate admin-only sections on `current_role == "admin"`. fn auth_role_str(auth: &AuthUser) -> &'static str { use crate::registry::Role; match auth.0.role { Role::Admin => "admin", Role::Viewer => "viewer", } } // ── Brand context ──────────────────────────────────────────────────────────── /// Resolve a logo config value to a URL. /// Bare paths (no `/` or scheme prefix) are served from `{base_url}/assets/`. fn resolve_logo_url(raw: &str, base_url: &str) -> String { if raw.starts_with('/') || raw.starts_with("http://") || raw.starts_with("https://") { raw.to_string() } else { format!("{base_url}/assets/{raw}") } } /// Load and export `.ontoref/config.ncl`, returning the full JSON value. /// Returns `None` if the file doesn't exist or Nickel export fails. async fn load_config_json( root: &std::path::Path, cache: &Arc, import_path: Option<&str>, ) -> Option { let config_path = root.join(".ontoref").join("config.ncl"); if !config_path.exists() { return None; } match cache.export(&config_path, import_path).await { Ok((json, _)) => { tracing::info!( path = %config_path.display(), has_card = json.get("card").is_some(), card_tagline = json.get("card").and_then(|c| c.get("tagline")).and_then(|v| v.as_str()).unwrap_or(""), "config.ncl loaded" ); Some(json) } Err(e) => { tracing::warn!( path = %config_path.display(), import_path = ?import_path, error = %e, "config.ncl export failed" ); None } } } /// Load logo URLs from `.ontoref/config.ncl` ui section. /// Returns `(logo_light_url, logo_dark_url)` — either may be `None`. async fn load_logos( root: &std::path::Path, cache: &Arc, import_path: Option<&str>, base_url: &str, ) -> (Option, Option) { let Some(json) = load_config_json(root, cache, import_path).await else { return (None, None); }; let ui = json.get("ui"); let logo = ui .and_then(|u| u.get("logo")) .and_then(|v| v.as_str()) .map(|raw| resolve_logo_url(raw, base_url)); let logo_dark = ui .and_then(|u| u.get("logo_dark")) .and_then(|v| v.as_str()) .map(|raw| resolve_logo_url(raw, base_url)); (logo, logo_dark) } /// Extract card data from a config JSON (from `.ontoref/config.ncl` `card` /// field). fn extract_card_from_config(json: &serde_json::Value) -> serde_json::Value { let Some(card) = json.get("card") else { return serde_json::Value::Null; }; serde_json::json!({ "tagline": card.get("tagline").and_then(|v| v.as_str()).unwrap_or(""), "description": card.get("description").and_then(|v| v.as_str()).unwrap_or(""), "version": card.get("version").and_then(|v| v.as_str()).unwrap_or(""), "status": card.get("status").and_then(|v| v.as_str()).unwrap_or(""), "url": card.get("url").and_then(|v| v.as_str()).unwrap_or(""), "tags": card.get("tags").and_then(|v| v.as_array()).cloned().unwrap_or_default(), "features": card.get("features").and_then(|v| v.as_array()).cloned().unwrap_or_default(), }) } /// Insert logo and MCP metadata into a Tera context. /// Logos are loaded from `.ontoref/config.ncl`; MCP availability is /// compile-time. pub(crate) async fn insert_brand_ctx( ctx: &mut Context, root: &std::path::Path, cache: &Arc, import_path: Option<&str>, base_url: &str, ) { let (logo, logo_dark) = load_logos(root, cache, import_path, base_url).await; ctx.insert("logo", &logo); ctx.insert("logo_dark", &logo_dark); insert_mcp_ctx(ctx); } /// Insert MCP metadata and daemon version into a Tera context. pub(crate) fn insert_mcp_ctx(ctx: &mut Context) { ctx.insert("daemon_version", env!("CARGO_PKG_VERSION")); #[cfg(feature = "mcp")] { ctx.insert("mcp_active", &true); ctx.insert( "mcp_tools", &[ // discovery "ontoref_help", "ontoref_list_projects", "ontoref_set_project", // search + retrieval "ontoref_search", "ontoref_get", "ontoref_get_node", "ontoref_get_adr", "ontoref_get_mode", // project state "ontoref_status", "ontoref_describe", "ontoref_guides", // ontology "ontoref_list_adrs", "ontoref_list_modes", "ontoref_list_ontology_extensions", "ontoref_get_ontology_extension", "ontoref_constraints", // backlog + actions "ontoref_backlog_list", "ontoref_backlog", "ontoref_action_list", "ontoref_action_add", // validation "ontoref_validate_adrs", "ontoref_validate", "ontoref_impact", // qa + bookmarks "ontoref_qa_list", "ontoref_qa_add", "ontoref_bookmark_list", "ontoref_bookmark_add", ], ); } #[cfg(not(feature = "mcp"))] { ctx.insert("mcp_active", &false); ctx.insert("mcp_tools", &Vec::<&str>::new()); } } // ── Dashboard ──────────────────────────────────────────────────────────────── pub async fn dashboard(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let hits = state.cache.hit_count(); let misses = state.cache.miss_count(); let total = hits + misses; let hit_rate = if total > 0 { format!("{:.1}%", hits as f64 / total as f64 * 100.0) } else { "—".to_string() }; let mut ctx = Context::new(); ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs()); ctx.insert("cache_entries", &state.cache.len()); ctx.insert("cache_hits", &hits); ctx.insert("cache_misses", &misses); ctx.insert("cache_hit_rate", &hit_rate); ctx.insert("active_actors", &state.actors.count()); ctx.insert( "notification_count", &state.notifications.all_recent().len(), ); ctx.insert("project_root", &state.project_root.display().to_string()); ctx.insert("now", &epoch_secs()); ctx.insert("base_url", "/ui"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/dashboard.html", &ctx).await } // ── Graph ──────────────────────────────────────────────────────────────────── pub async fn graph(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let core_json = match ncl_export(&state, ".ontology/core.ncl").await { Ok(v) => v, Err(e) => { warn!(error = %e, "failed to load .ontology/core.ncl for graph"); serde_json::json!({ "nodes": [], "edges": [] }) } }; let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string()); let mut ctx = Context::new(); ctx.insert("graph_json", &graph_json); ctx.insert("project_root", &state.project_root.display().to_string()); ctx.insert("base_url", "/ui"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/graph.html", &ctx).await } // ── Sessions ───────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] struct SessionRow { token: String, actor_type: String, hostname: String, pid: u32, project: String, role: String, registered_ago: u64, last_seen_ago: u64, pending_notifications: u64, has_preferences: bool, } pub async fn sessions(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let now = epoch_secs(); let rows: Vec = state .actors .list() .into_iter() .map(|(token, s)| SessionRow { token, actor_type: s.actor_type, hostname: s.hostname, pid: s.pid, project: s.project, role: s.role, registered_ago: now.saturating_sub(s.registered_at), last_seen_ago: now.saturating_sub(s.last_seen), pending_notifications: s.pending_notifications, has_preferences: !s .preferences .as_object() .map(|m| m.is_empty()) .unwrap_or(true), }) .collect(); let mut ctx = Context::new(); ctx.insert("sessions", &rows); ctx.insert("total", &rows.len()); ctx.insert("base_url", "/ui"); ctx.insert("current_role", "admin"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/sessions.html", &ctx).await } // ── Notifications // ───────────────────────────────────────────────────────────── pub async fn notifications_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let now = epoch_secs(); #[derive(Serialize)] struct NotifRow { id: u64, project: String, event: String, files: Vec, age_secs: u64, source_actor: Option, } let rows: Vec = state .notifications .all_recent() .into_iter() .map(|n| NotifRow { id: n.id, project: n.project, event: format!("{:?}", n.event), files: n.files, age_secs: now.saturating_sub(n.timestamp), source_actor: n.source_actor, }) .collect(); let mut ctx = Context::new(); ctx.insert("notifications", &rows); ctx.insert("total", &rows.len()); ctx.insert("base_url", "/ui"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/notifications.html", &ctx).await } // ── Search ──────────────────────────────────────────────────────────────────── pub async fn search_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let bookmarks = load_bookmark_entries( &state.cache, &state.project_root, state.nickel_import_path.as_deref(), ) .await; let mut ctx = Context::new(); ctx.insert("base_url", "/ui"); ctx.insert("slug", &Option::::None); ctx.insert("server_bookmarks", &bookmarks); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/search.html", &ctx).await } pub async fn search_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let base_url = format!("/ui/{slug}"); let bookmarks = load_bookmark_entries( &ctx_ref.cache, &ctx_ref.root, ctx_ref.import_path.as_deref(), ) .await; let mut ctx = Context::new(); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); ctx.insert("server_bookmarks", &bookmarks); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/search.html", &ctx).await } // ── Modes ───────────────────────────────────────────────────────────────────── pub async fn modes(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let modes_dir = state.project_root.join("reflection").join("modes"); let mut mode_entries: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&modes_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("ncl") { continue; } let rel = path .strip_prefix(&state.project_root) .unwrap_or(&path) .to_string_lossy() .to_string(); match state .cache .export(&path, state.nickel_import_path.as_deref()) .await { Ok((mut json, _)) => { json.as_object_mut() .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel))); mode_entries.push(json); } Err(e) => { warn!(path = %rel, error = %e, "failed to export reflection mode"); mode_entries.push(serde_json::json!({ "_file": rel, "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"), "_error": e.to_string(), })); } } } } mode_entries.sort_by_key(|v| { v.get("id") .and_then(|i| i.as_str()) .unwrap_or("") .to_string() }); let showcase = detect_showcase(&state.project_root, "/ui"); let generated = detect_generated(&state.project_root, "/ui"); let mut ctx = Context::new(); ctx.insert("modes", &mode_entries); ctx.insert("total", &mode_entries.len()); ctx.insert("showcase", &showcase); ctx.insert("generated", &generated); ctx.insert("base_url", "/ui"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/modes.html", &ctx).await } // ── Project Picker // ──────────────────────────────────────────────────────────── pub async fn project_picker(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let registry = &state.registry; let now = epoch_secs(); let mut projects: Vec = Vec::new(); for proj in registry.all() { // Sessions let actor_list = proj.actors.list(); let sessions: Vec = actor_list .iter() .map(|(token, s)| { serde_json::json!({ "token": &token[..8.min(token.len())], "actor_type": s.actor_type, "hostname": s.hostname, "pid": s.pid, "last_seen_ago": now.saturating_sub(s.last_seen), }) }) .collect(); // Notifications let notif_list = proj.notifications.all_recent(); let notifications: Vec = notif_list .iter() .take(8) .map(|n| { serde_json::json!({ "id": n.id, "event": format!("{:?}", n.event), "files": n.files.iter().take(3).collect::>(), "age_secs": now.saturating_sub(n.timestamp), }) }) .collect(); // Backlog — optional NCL export let backlog_path = proj.root.join("reflection").join("backlog.ncl"); let (backlog_items, backlog_open) = if backlog_path.exists() { match proj .cache .export(&backlog_path, proj.import_path.as_deref()) .await { Ok((json, _)) => { let items: Vec = json .get("items") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .map(|it| serde_json::json!({ "id": it.get("id").and_then(|v| v.as_str()).unwrap_or(""), "title": it.get("title").and_then(|v| v.as_str()).unwrap_or(""), "status": it.get("status").and_then(|v| v.as_str()).unwrap_or(""), "priority": it.get("priority").and_then(|v| v.as_str()).unwrap_or(""), "kind": it.get("kind").and_then(|v| v.as_str()).unwrap_or(""), })) .collect() }) .unwrap_or_default(); let open = items .iter() .filter(|it| it.get("status").and_then(|v| v.as_str()) == Some("Open")) .count(); (items, open) } Err(_) => (vec![], 0), } } else { (vec![], 0) }; // Manifest — layers, operational_modes, default_mode, repo_kind let manifest_path = proj.root.join(".ontology").join("manifest.ncl"); let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() { match proj .cache .export(&manifest_path, proj.import_path.as_deref()) .await { Ok((json, _)) => { let layers: Vec = json .get("layers") .and_then(|v| v.as_array()) .map(|arr| arr.iter().map(|l| serde_json::json!({ "id": l.get("id").and_then(|v| v.as_str()).unwrap_or(""), "description": l.get("description").and_then(|v| v.as_str()).unwrap_or(""), })).collect()) .unwrap_or_default(); let op_modes: Vec = json .get("operational_modes") .and_then(|v| v.as_array()) .map(|arr| arr.iter().map(|m| serde_json::json!({ "id": m.get("id").and_then(|v| v.as_str()).unwrap_or(""), "description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""), })).collect()) .unwrap_or_default(); let default_mode = json .get("default_mode") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let repo_kind = json .get("repo_kind") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); (layers, op_modes, default_mode, repo_kind) } Err(_) => (vec![], vec![], String::new(), String::new()), } } else { (vec![], vec![], String::new(), String::new()) }; // card — loaded from `.ontoref/config.ncl` `card` field (which imports // ../card.ncl) let config_json = load_config_json(&proj.root, &proj.cache, proj.import_path.as_deref()).await; let card = config_json .as_ref() .map(extract_card_from_config) .unwrap_or(serde_json::Value::Null); tracing::debug!( slug = %proj.slug, import_path = ?proj.import_path, config_loaded = config_json.is_some(), card_tagline = card.get("tagline").and_then(|v| v.as_str()).unwrap_or(""), "project card loaded" ); // Description — first meaningful text line from README.md (fallback when no // card) let description = if card .get("tagline") .and_then(|v| v.as_str()) .unwrap_or("") .is_empty() { readme_description(&proj.root) } else { String::new() }; let proj_base = format!("/ui/{}", proj.slug); let showcase = detect_showcase(&proj.root, &proj_base); let generated = detect_generated(&proj.root, &proj_base); let repos = git_remotes(&proj.root); let opmode = if proj.push_only { "push" } else { "daemon" }; projects.push(serde_json::json!({ "slug": proj.slug, "root": proj.root.display().to_string(), "auth": proj.auth_enabled(), "card": card, "description": description, "default_mode": default_mode, "repo_kind": repo_kind, "repos": repos, "session_count": sessions.len(), "sessions": sessions, "notif_count": notifications.len(), "notifications": notifications, "backlog_open": backlog_open, "backlog_items": backlog_items, "layers": layers, "op_modes": op_modes, "showcase": showcase, "generated": generated, "opmode": opmode, })); } let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("base_url", "/ui"); ctx.insert("hide_project_nav", &true); insert_mcp_ctx(&mut ctx); render(tera, "pages/project_picker.html", &ctx).await } /// Read git remote names+URLs from `{root}/.git/config` by parsing the ini file /// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." }, /// ...]`. pub(crate) fn git_remotes(root: &std::path::Path) -> Vec { let Ok(content) = std::fs::read_to_string(root.join(".git").join("config")) else { return vec![]; }; let mut remotes = Vec::new(); let mut current_name: Option = None; for line in content.lines() { let t = line.trim(); if let Some(inner) = t .strip_prefix("[remote \"") .and_then(|s| s.strip_suffix("\"]")) { current_name = Some(inner.to_string()); } else if t.starts_with('[') { current_name = None; } else if let Some(name) = ¤t_name { if let Some(url) = t.strip_prefix("url = ") { remotes.push(serde_json::json!({ "name": name, "url": url })); current_name = None; } } } remotes } /// Extract the first meaningful prose line from README.md. /// Skips HTML tags, Markdown headings, image/badge lines, blockquotes, and /// blank lines. pub(crate) fn readme_description(root: &std::path::Path) -> String { let readme = root.join("README.md"); let Ok(content) = std::fs::read_to_string(&readme) else { return String::new(); }; for raw in content.lines() { let line = raw.trim(); if line.is_empty() { continue; } // Skip: HTML tags, headings, images, badges, horizontal rules, blockquotes if line.starts_with('<') || line.starts_with('#') || line.starts_with('!') || line.starts_with("---") || line.starts_with("===") || line.contains("badge") || line.contains("img.shields") || line.contains("') // blockquote { continue; } // Strip leading Markdown bold/italic markers and quote chars let text = line.trim_start_matches(['*', '_', '`', '>', ' ']); if text.len() > 10 { // Truncate at 160 chars for the card return if text.len() > 160 { format!("{}…", &text[..text.floor_char_boundary(160)]) } else { text.to_string() }; } } String::new() } fn count_ncl_files(dir: &std::path::Path) -> usize { std::fs::read_dir(dir) .map(|rd| { rd.flatten() .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) .count() }) .unwrap_or(0) } // ── Multi-project handlers // ──────────────────────────────────────────────────── pub async fn dashboard_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let hits = ctx_ref.cache.hit_count(); let misses = ctx_ref.cache.miss_count(); let total = hits + misses; let hit_rate = if total > 0 { format!("{:.1}%", hits as f64 / total as f64 * 100.0) } else { "—".to_string() }; let base_url = format!("/ui/{slug}"); let backlog_path = ctx_ref.root.join("reflection").join("backlog.ncl"); let backlog_items = if backlog_path.exists() { load_backlog_items( &ctx_ref.cache, &backlog_path, ctx_ref.import_path.as_deref(), ) .await } else { vec![] }; let backlog = backlog_stats(&backlog_items); let adr_count = count_ncl_files(&ctx_ref.root.join("adrs")); let mode_count = count_ncl_files(&ctx_ref.root.join("reflection").join("modes")); let mut ctx = Context::new(); ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs()); ctx.insert("cache_entries", &ctx_ref.cache.len()); ctx.insert("cache_hits", &hits); ctx.insert("cache_misses", &misses); ctx.insert("cache_hit_rate", &hit_rate); ctx.insert("active_actors", &ctx_ref.actors.count()); ctx.insert( "notification_count", &ctx_ref.notifications.all_recent().len(), ); ctx.insert("project_root", &ctx_ref.root.display().to_string()); ctx.insert("now", &epoch_secs()); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("backlog", &backlog); ctx.insert("adr_count", &adr_count); ctx.insert("mode_count", &mode_count); ctx.insert("current_role", &auth_role_str(&auth)); let file_versions: std::collections::BTreeMap = ctx_ref .file_versions .iter() .map(|r| { let name = r .key() .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); (name, *r.value()) }) .collect(); ctx.insert("file_versions", &file_versions); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/dashboard.html", &ctx).await } pub async fn api_catalog_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let base_url = format!("/ui/{slug}"); // The #[onto_api] catalog is the ontoref-daemon's own HTTP surface. // Only expose it for the primary project (ontoref itself). Consumer // projects have their own API surfaces not registered in this process. let is_primary = slug == state.registry.primary_slug(); let routes: Vec = if is_primary { crate::api_catalog::catalog() .into_iter() .map(|r| { let params: Vec = r .params .iter() .map(|p| { serde_json::json!({ "name": p.name, "type": p.kind, "constraint": p.constraint, "description": p.description, }) }) .collect(); serde_json::json!({ "method": r.method, "path": r.path, "description": r.description, "auth": r.auth, "actors": r.actors, "params": params, "tags": r.tags, "feature": r.feature, }) }) .collect() } else { vec![] }; let catalog_json = serde_json::to_string(&routes).unwrap_or_else(|_| "[]".to_string()); let mut ctx = Context::new(); ctx.insert("catalog_json", &catalog_json); ctx.insert("route_count", &routes.len()); ctx.insert("is_primary", &is_primary); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/api_catalog.html", &ctx).await } pub async fn graph_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let core_path = ctx_ref.root.join(".ontology/core.ncl"); let core_json = match ctx_ref .cache .export(&core_path, ctx_ref.import_path.as_deref()) .await { Ok((v, _)) => v, Err(e) => { warn!(error = %e, slug = %slug, "failed to load .ontology/core.ncl for graph"); serde_json::json!({ "nodes": [], "edges": [] }) } }; let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string()); let base_url = format!("/ui/{slug}"); let mut ctx = Context::new(); ctx.insert("graph_json", &graph_json); ctx.insert("project_root", &ctx_ref.root.display().to_string()); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/graph.html", &ctx).await } pub async fn sessions_mp( State(state): State, Path(slug): Path, _auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let now = epoch_secs(); let rows: Vec = ctx_ref .actors .list() .into_iter() .map(|(token, s)| SessionRow { token, actor_type: s.actor_type, hostname: s.hostname, pid: s.pid, project: s.project, role: s.role, registered_ago: now.saturating_sub(s.registered_at), last_seen_ago: now.saturating_sub(s.last_seen), pending_notifications: s.pending_notifications, has_preferences: !s .preferences .as_object() .map(|m| m.is_empty()) .unwrap_or(true), }) .collect(); let base_url = format!("/ui/{slug}"); let http_role = auth_role_str(&_auth); let mut ctx = Context::new(); ctx.insert("sessions", &rows); ctx.insert("total", &rows.len()); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &http_role); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/sessions.html", &ctx).await } pub async fn notifications_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let now = epoch_secs(); #[derive(Serialize)] struct NotifRow { id: u64, project: String, event: String, is_custom: bool, files: Vec, age_secs: u64, source_actor: Option, custom_kind: Option, custom_title: Option, custom_payload: Option, source_project: Option, } let rows: Vec = ctx_ref .notifications .all_recent() .into_iter() .map(|n| { let is_custom = matches!(n.event, crate::notifications::NotificationEvent::Custom); NotifRow { id: n.id, project: n.project, event: format!("{:?}", n.event), is_custom, files: n.files, age_secs: now.saturating_sub(n.timestamp), source_actor: n.source_actor, custom_kind: n.custom_kind, custom_title: n.custom_title, custom_payload: n.custom_payload, source_project: n.source_project, } }) .collect(); // All other registered projects for the emit-to selector let other_projects: Vec = state .registry .all() .into_iter() .map(|p| p.slug.clone()) .collect(); let base_url = format!("/ui/{slug}"); let mut ctx = Context::new(); ctx.insert("notifications", &rows); ctx.insert("total", &rows.len()); let http_role = auth_role_str(&auth); // Viewers can see notifications but cannot emit them — template gates on // current_role. let empty: Vec = vec![]; let visible_projects: &Vec = if http_role == "admin" { &other_projects } else { &empty }; ctx.insert("other_projects", visible_projects); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &http_role); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/notifications.html", &ctx).await } // ── Notification emit // ───────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct EmitNotifForm { pub target_slug: String, pub kind: String, pub title: String, #[serde(default)] pub payload: String, #[serde(default)] pub source_actor: String, } // ── Notification DAG action execution ──────────────────────────────────────── #[derive(Deserialize)] pub struct NotifActionForm { pub action_id: String, } pub async fn notification_action_mp( State(state): State, Path((slug, notif_id)): Path<(String, u64)>, _auth: AuthUser, Form(form): Form, ) -> Result { let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let notif = ctx_ref .notifications .get_one(notif_id) .ok_or_else(|| UiError::BadRequest(format!("notification {notif_id} not found")))?; let actions: Vec = notif .custom_payload .as_ref() .and_then(|p| p.get("actions")) .and_then(|a| a.as_array()) .cloned() .unwrap_or_default(); let action = actions .iter() .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(form.action_id.as_str())) .ok_or_else(|| UiError::BadRequest(format!("action '{}' not found", form.action_id)))?; let mode = action .get("mode") .and_then(|v| v.as_str()) .unwrap_or("manual"); let dag_id = action.get("dag").and_then(|v| v.as_str()).unwrap_or(""); if !dag_id.is_empty() && matches!(mode, "auto" | "semi") { let ontoref_bin = ctx_ref.root.join("ontoref"); if ontoref_bin.exists() { tokio::process::Command::new(&ontoref_bin) .arg(dag_id) .current_dir(&ctx_ref.root) .spawn() .map_err(|e| UiError::BadRequest(format!("dag spawn failed: {e}")))?; } else { return Err(UiError::BadRequest(format!( "ontoref binary not found at {}", ontoref_bin.display() ))); } } Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response()) } pub async fn emit_notification_mp( State(state): State, Path(slug): Path, _auth: AuthUser, Form(form): Form, ) -> Result { let registry = &state.registry; let target = registry.get(&form.target_slug).ok_or_else(|| { UiError::BadRequest(format!("unknown target project: {}", form.target_slug)) })?; let payload = if form.payload.trim().is_empty() { None } else { serde_json::from_str::(&form.payload).ok() }; let actor = if form.source_actor.trim().is_empty() { None } else { Some(form.source_actor) }; target.notifications.push_custom( &form.target_slug, &form.kind, &form.title, payload, actor, Some(slug.clone()), ); Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response()) } // ── Asset serving ──────────────────────────────────────────────────────────── /// Serve a file from `{project_root}/assets/` by slug + relative path. /// Validates that the resolved path stays under the assets directory. pub async fn serve_asset_mp( State(state): State, Path((slug, asset_path)): Path<(String, String)>, ) -> Result { let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; serve_asset_from(&proj.root, &asset_path).await } pub async fn serve_asset_single( State(state): State, Path(asset_path): Path, ) -> Result { serve_asset_from(&state.project_root, &asset_path).await } async fn serve_asset_from(root: &std::path::Path, rel: &str) -> Result { let assets_root = root.join("assets"); let Ok(canonical_root) = assets_root.canonicalize() else { return Err(UiError::NotConfigured); }; // Resolve the requested path (tolerate leading `/`) let requested = canonical_root.join(rel.trim_start_matches('/')); let resolved = match requested.canonicalize() { Ok(p) => p, // Try appending index.html for bare directory URLs Err(_) => { let with_index = requested.join("index.html"); with_index .canonicalize() .map_err(|_| UiError::NotConfigured)? } }; // Security: must stay within assets root if !resolved.starts_with(&canonical_root) { return Ok(StatusCode::FORBIDDEN.into_response()); } // Directory → serve index.html let file_path = if resolved.is_dir() { let idx = resolved.join("index.html"); if idx.exists() { idx } else { return Ok(StatusCode::NOT_FOUND.into_response()); } } else { resolved }; let bytes = tokio::fs::read(&file_path) .await .map_err(|_| UiError::NotConfigured)?; let ct = asset_content_type(&file_path); let mut resp = bytes.into_response(); resp.headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); Ok(resp) } fn asset_content_type(path: &std::path::Path) -> &'static str { match path.extension().and_then(|e| e.to_str()) { Some("html") | Some("htm") => "text/html; charset=utf-8", Some("svg") => "image/svg+xml", Some("css") => "text/css", Some("js") => "application/javascript", Some("json") => "application/json", Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", Some("woff2") => "font/woff2", Some("woff") => "font/woff", Some("md") => "text/plain; charset=utf-8", _ => "application/octet-stream", } } /// Detect known showcase pages in `assets/` (committed artefacts). pub fn detect_showcase(root: &std::path::Path, base_url: &str) -> Vec { let candidates = [ ( "branding", "Branding", &["branding/index.html", "index.html"] as &[&str], ), ("web", "Website", &["web/index.html"]), ( "presentation", "Presentation", &[ "presentation/dist/index.html", "presentation/slides/index.html", ], ), ]; candidates .iter() .filter_map(|(id, label, paths)| { let found = paths .iter() .find(|p| root.join("assets").join(p).exists())?; Some(serde_json::json!({ "id": id, "label": label, "url": format!("{base_url}/assets/{found}"), })) }) .collect() } /// Scan `public/` for generated artefacts — any subdirectory that contains /// an `index.html` becomes a served link. /// /// Well-known subdirectory names get a display label; unknown names are /// title-cased from the directory name. pub fn detect_generated(root: &std::path::Path, base_url: &str) -> Vec { let public_dir = root.join("public"); let Ok(entries) = std::fs::read_dir(&public_dir) else { return vec![]; }; let known_labels: std::collections::HashMap<&str, &str> = [ ("cargo-doc", "API Docs (cargo doc)"), ("docs", "Documentation"), ("mdbook", "MDBook"), ("book", "Book"), ("coverage", "Coverage Report"), ("typedoc", "TypeDoc"), ("storybook", "Storybook"), ] .into_iter() .collect(); let mut items: Vec = entries .flatten() .filter_map(|e| { let path = e.path(); if !path.is_dir() { return None; } // Must have an index.html to be servable if !path.join("index.html").exists() { return None; } let dir_name = path.file_name()?.to_str()?.to_string(); let label = known_labels .get(dir_name.as_str()) .copied() .unwrap_or(&dir_name) .to_string(); Some(serde_json::json!({ "id": dir_name, "label": label, "url": format!("{base_url}/public/{dir_name}/"), })) }) .collect(); items.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); items } /// Serve a file from `{project_root}/public/` — generated, gitignored /// artefacts. pub async fn serve_public_mp( State(state): State, Path((slug, pub_path)): Path<(String, String)>, ) -> Result { let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; serve_dir_from(&proj.root.join("public"), &pub_path).await } pub async fn serve_public_single( State(state): State, Path(pub_path): Path, ) -> Result { serve_dir_from(&state.project_root.join("public"), &pub_path).await } /// Generic file server for an arbitrary base directory. async fn serve_dir_from(base: &std::path::Path, rel: &str) -> Result { let Ok(canonical_base) = base.canonicalize() else { return Ok(StatusCode::NOT_FOUND.into_response()); }; let requested = canonical_base.join(rel.trim_start_matches('/')); let resolved = match requested.canonicalize() { Ok(p) => p, Err(_) => { let with_index = requested.join("index.html"); match with_index.canonicalize() { Ok(p) => p, Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()), } } }; if !resolved.starts_with(&canonical_base) { return Ok(StatusCode::FORBIDDEN.into_response()); } let file_path = if resolved.is_dir() { let idx = resolved.join("index.html"); if idx.exists() { idx } else { return Ok(StatusCode::NOT_FOUND.into_response()); } } else { resolved }; let bytes = tokio::fs::read(&file_path) .await .map_err(|_| UiError::NotConfigured)?; let ct = asset_content_type(&file_path); let mut resp = bytes.into_response(); resp.headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); Ok(resp) } // ── Backlog // ─────────────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct BacklogStatusForm { pub id: String, pub status: String, } #[derive(Deserialize)] pub struct BacklogAddForm { pub title: String, pub kind: String, pub priority: String, pub detail: String, } fn today_iso() -> String { // No chrono dep — derive from SystemTime epoch arithmetic. let secs = epoch_secs(); let days_since_epoch = secs / 86400; // Gregorian calendar from Julian Day Number (JDN 2440588 = 1970-01-01) let jdn = days_since_epoch as i64 + 2440588; let (y, m, d) = jdn_to_ymd(jdn); format!("{y:04}-{m:02}-{d:02}") } fn jdn_to_ymd(jdn: i64) -> (i64, i64, i64) { let a = jdn + 32044; let b = (4 * a + 3) / 146097; let c = a - (146097 * b) / 4; let dd = (4 * c + 3) / 1461; let e = c - (1461 * dd) / 4; let mm = (5 * e + 2) / 153; let d = e - (153 * mm + 2) / 5 + 1; let m = mm + 3 - 12 * (mm / 10); let y = 100 * b + dd - 4800 + mm / 10; (y, m, d) } #[derive(Serialize)] struct BacklogStats { total: usize, open: usize, inprog: usize, done: usize, cancelled: usize, critical: usize, } async fn load_backlog_items( cache: &Arc, backlog_path: &std::path::Path, import_path: Option<&str>, ) -> Vec { let Ok((json, _)) = cache.export(backlog_path, import_path).await else { return vec![]; }; json.get("items") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default() } fn backlog_stats(items: &[serde_json::Value]) -> BacklogStats { let count = |key: &str, val: &str| { items .iter() .filter(|it| it.get(key).and_then(|v| v.as_str()) == Some(val)) .count() }; BacklogStats { total: items.len(), open: count("status", "Open"), inprog: count("status", "InProgress"), done: count("status", "Done"), cancelled: count("status", "Cancelled"), critical: count("priority", "Critical"), } } pub async fn backlog_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); let items = if backlog_path.exists() { load_backlog_items( &state.cache, &backlog_path, state.nickel_import_path.as_deref(), ) .await } else { vec![] }; let stats = backlog_stats(&items); let mut ctx = Context::new(); ctx.insert("items", &items); ctx.insert("stats", &stats); ctx.insert("has_backlog", &backlog_path.exists()); ctx.insert("base_url", "/ui"); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/backlog.html", &ctx).await } pub async fn backlog_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let backlog_path = proj.root.join("reflection").join("backlog.ncl"); let items = if backlog_path.exists() { load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await } else { vec![] }; proj.cache .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone())); let stats = backlog_stats(&items); let base_url = format!("/ui/{slug}"); let mut ctx = Context::new(); ctx.insert("items", &items); ctx.insert("stats", &stats); ctx.insert("has_backlog", &backlog_path.exists()); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &proj.root, &proj.cache, proj.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/backlog.html", &ctx).await } pub async fn backlog_update_status( State(state): State, Form(form): Form, ) -> Result { let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); let _guard = state.ncl_write_lock.acquire(&backlog_path).await; super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso()) .map_err(|e| UiError::NclExport { path: "backlog.ncl".into(), reason: e.to_string(), })?; state .cache .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); Ok(Redirect::to("/ui/backlog").into_response()) } pub async fn backlog_update_status_mp( State(state): State, Path(slug): Path, auth: AuthUser, Form(form): Form, ) -> Result { if auth_role_str(&auth) != "admin" { return Err(UiError::Forbidden( "admin role required for backlog writes".into(), )); } let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let backlog_path = proj.root.join("reflection").join("backlog.ncl"); let _guard = state.ncl_write_lock.acquire(&backlog_path).await; super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso()) .map_err(|e| UiError::NclExport { path: "backlog.ncl".into(), reason: e.to_string(), })?; proj.cache .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response()) } pub async fn backlog_add( State(state): State, Form(form): Form, ) -> Result { let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); let _guard = state.ncl_write_lock.acquire(&backlog_path).await; super::backlog_ncl::add_item( &backlog_path, &form.title, &form.kind, &form.priority, &form.detail, &today_iso(), ) .map_err(|e| UiError::NclExport { path: "backlog.ncl".into(), reason: e.to_string(), })?; state .cache .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); Ok(Redirect::to("/ui/backlog").into_response()) } pub async fn backlog_add_mp( State(state): State, Path(slug): Path, auth: AuthUser, Form(form): Form, ) -> Result { if auth_role_str(&auth) != "admin" { return Err(UiError::Forbidden( "admin role required for backlog writes".into(), )); } let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let backlog_path = proj.root.join("reflection").join("backlog.ncl"); let _guard = state.ncl_write_lock.acquire(&backlog_path).await; super::backlog_ncl::add_item( &backlog_path, &form.title, &form.kind, &form.priority, &form.detail, &today_iso(), ) .map_err(|e| UiError::NclExport { path: "backlog.ncl".into(), reason: e.to_string(), })?; proj.cache .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response()) } // ── Manage ──────────────────────────────────────────────────────────────────── /// Returns `~/.config/ontoref/projects.ncl` path. fn projects_ncl_path() -> std::path::PathBuf { std::env::var_os("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".config") .join("ontoref") .join("projects.ncl") } /// Read `projects.ncl` and return the set of import paths already registered. fn read_registered_paths(projects_file: &std::path::Path) -> Vec { let Ok(content) = std::fs::read_to_string(projects_file) else { return vec![]; }; content .lines() .filter_map(|l| { let l = l.trim(); if l.starts_with("import ") { let inner = l.trim_start_matches("import").trim(); let inner = inner.trim_matches('"'); Some(inner.to_string()) } else { None } }) .collect() } /// Append a project.ncl import to `projects.ncl`, creating the file if needed. fn persist_project_add( project_root: &std::path::Path, projects_file: &std::path::Path, ) -> std::io::Result<()> { let ncl_path = project_root.join(".ontoref").join("project.ncl"); let ncl_str = ncl_path.to_string_lossy().to_string(); let existing = read_registered_paths(projects_file); if existing.iter().any(|p| p == &ncl_str) { return Ok(()); } let mut all = existing; all.push(ncl_str); write_projects_ncl(projects_file, &all) } /// Remove a project.ncl import from `projects.ncl`. fn persist_project_remove( project_root: &std::path::Path, projects_file: &std::path::Path, ) -> std::io::Result<()> { let ncl_path = project_root.join(".ontoref").join("project.ncl"); let ncl_str = ncl_path.to_string_lossy().to_string(); let remaining: Vec = read_registered_paths(projects_file) .into_iter() .filter(|p| p != &ncl_str) .collect(); write_projects_ncl(projects_file, &remaining) } fn write_projects_ncl(projects_file: &std::path::Path, paths: &[String]) -> std::io::Result<()> { if let Some(parent) = projects_file.parent() { std::fs::create_dir_all(parent)?; } let content = if paths.is_empty() { "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref project-add \ /path/to/project\n\n[]\n" .to_string() } else { let imports = paths .iter() .map(|p| format!(" [import \"{p}\"],")) .collect::>() .join("\n"); format!( "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref \ project-add /path/to/project\n\nstd.array.flatten [\n{imports}\n]\n" ) }; std::fs::write(projects_file, content) } #[derive(Deserialize)] pub struct AddProjectForm { pub root: String, } #[derive(Deserialize)] pub struct RemoveProjectForm { pub slug: String, } /// Guarded variant: requires `AdminGuard` (any admin session or no auth /// configured). pub async fn manage_page_guarded( State(state): State, _guard: super::auth::AdminGuard, ) -> Result, UiError> { manage_page(State(state)).await } pub async fn manage_add_guarded( State(state): State, _guard: super::auth::AdminGuard, Form(form): Form, ) -> Result { manage_add(State(state), Form(form)).await } pub async fn manage_remove_guarded( State(state): State, _guard: super::auth::AdminGuard, Form(form): Form, ) -> Result { manage_remove(State(state), Form(form)).await } fn manage_project_json( ctx: &crate::registry::ProjectContext, registered_paths: &[String], ) -> serde_json::Value { let key_count = ctx.keys.read().map(|k| k.len()).unwrap_or(0); let roles: Vec = ctx .keys .read() .map(|keys| { keys.iter() .map(|k| format!("{:?}", k.role).to_lowercase()) .collect() }) .unwrap_or_default(); let ncl_path = ctx.root.join(".ontoref").join("project.ncl"); let in_projects_ncl = registered_paths.contains(&ncl_path.to_string_lossy().to_string()); let opmode = if ctx.push_only { "push" } else { "daemon" }; serde_json::json!({ "slug": ctx.slug, "root": ctx.root.display().to_string(), "auth": ctx.auth_enabled(), "key_count": key_count, "roles": roles, "push_only": ctx.push_only, "opmode": opmode, "in_projects_ncl": in_projects_ncl, }) } pub async fn manage_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let projects_file = projects_ncl_path(); let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("base_url", "/ui"); ctx.insert("hide_project_nav", &true); ctx.insert("error", &Option::::None); ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some()); insert_mcp_ctx(&mut ctx); render(tera, "pages/manage.html", &ctx).await } pub async fn manage_logout( State(state): State, request: axum::extract::Request, ) -> Response { use axum::http::header; use crate::session::extract_cookie; let cookie_str = request .headers() .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if let Some(token) = extract_cookie(cookie_str, crate::session::COOKIE_NAME) { state.sessions.revoke(&token); } let clear = format!( "{}=; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age=0", crate::session::COOKIE_NAME ); ( [(header::SET_COOKIE, clear)], Redirect::to("/ui/manage/login"), ) .into_response() } pub async fn manage_add( State(state): State, Form(form): Form, ) -> Result { let root = std::path::PathBuf::from(form.root.trim()); let project_ncl = root.join(".ontoref").join("project.ncl"); // Load the project.ncl via nickel to get slug + import_paths + keys. let entry: crate::registry::RegistryEntry = if project_ncl.exists() { match state .cache .export(&project_ncl, state.nickel_import_path.as_deref()) .await { Ok((json, _)) => { serde_json::from_value(json).unwrap_or_else(|_| crate::registry::RegistryEntry { slug: root .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(), root: root.clone(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: false, }) } Err(_) => crate::registry::RegistryEntry { slug: root .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(), root: root.clone(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: false, }, } } else { crate::registry::RegistryEntry { slug: root .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(), root: root.clone(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: false, } }; let projects_file = projects_ncl_path(); if let Err(e) = registry_add_and_persist(&state, entry, &projects_file).await { let tera = tera_ref(&state)?; let registered = read_registered_paths(&projects_file); let projects: Vec = state .registry .all() .into_iter() .map(|ctx| manage_project_json(&ctx, ®istered)) .collect(); let mut ctx = Context::new(); ctx.insert("projects", &projects); ctx.insert("base_url", "/ui"); ctx.insert("error", &e.to_string()); ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some()); return render(tera, "pages/manage.html", &ctx) .await .map(IntoResponse::into_response); } Ok(Redirect::to("/ui/manage").into_response()) } async fn registry_add_and_persist( state: &AppState, entry: crate::registry::RegistryEntry, projects_file: &std::path::Path, ) -> anyhow::Result<()> { let root = entry.root.clone(); state.registry.add_project(entry)?; persist_project_add(&root, projects_file) .map_err(|e| anyhow::anyhow!("added to registry but failed to persist: {e}"))?; Ok(()) } pub async fn manage_remove( State(state): State, Form(form): Form, ) -> Result { let registry = &state.registry; let root = registry.get(&form.slug).map(|ctx| ctx.root.clone()); registry.remove_project(&form.slug); if let Some(root) = root { let projects_file = projects_ncl_path(); if let Err(e) = persist_project_remove(&root, &projects_file) { warn!(slug = %form.slug, error = %e, "removed from registry but failed to persist removal"); } } Ok(Redirect::to("/ui/manage").into_response()) } pub async fn modes_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let modes_dir = ctx_ref.root.join("reflection").join("modes"); let mut mode_entries: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&modes_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("ncl") { continue; } let rel = path .strip_prefix(&ctx_ref.root) .unwrap_or(&path) .to_string_lossy() .to_string(); match ctx_ref .cache .export(&path, ctx_ref.import_path.as_deref()) .await { Ok((mut json, _)) => { json.as_object_mut() .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel))); mode_entries.push(json); } Err(e) => { warn!(path = %rel, error = %e, "failed to export reflection mode"); mode_entries.push(serde_json::json!({ "_file": rel, "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"), "_error": e.to_string(), })); } } } } mode_entries.sort_by_key(|v| { v.get("id") .and_then(|i| i.as_str()) .unwrap_or("") .to_string() }); let base_url = format!("/ui/{slug}"); let showcase = detect_showcase(&ctx_ref.root, &base_url); let generated = detect_generated(&ctx_ref.root, &base_url); let mut ctx = Context::new(); ctx.insert("modes", &mode_entries); ctx.insert("total", &mode_entries.len()); ctx.insert("showcase", &showcase); ctx.insert("generated", &generated); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/modes.html", &ctx).await } // ── Agent task composer // ─────────────────────────────────────────────────────── /// Default LLM providers used when config.ncl has no `providers` array. fn default_providers() -> Vec { vec![ serde_json::json!({ "id": "anthropic", "label": "Anthropic (Claude)", "api_url": "", "model": "claude-opus-4-6", "key_env": "ANTHROPIC_API_KEY", "kind": "anthropic" }), serde_json::json!({ "id": "openai", "label": "OpenAI (GPT-4o)", "api_url": "", "model": "gpt-4o", "key_env": "OPENAI_API_KEY", "kind": "openai" }), serde_json::json!({ "id": "local", "label": "Local (Ollama)", "api_url": "http://localhost:11434/v1/chat/completions", "model": "llama3.2", "key_env": "", "kind": "openai" }), ] } async fn read_providers( root: &std::path::Path, cache: &Arc, import_path: Option<&str>, ) -> Vec { let config_path = root.join(".ontoref").join("config.ncl"); if config_path.exists() { if let Ok((json, _)) = cache.export(&config_path, import_path).await { if let Some(arr) = json.get("providers").and_then(|v| v.as_array()) { if !arr.is_empty() { return arr.clone(); } } } } default_providers() } pub async fn compose_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let forms_dir = ctx_ref.root.join("reflection").join("forms"); let mut forms: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&forms_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("ncl") { continue; } let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); forms.push(serde_json::json!({ "id": stem, "label": stem.replace('_', " "), })); } } forms.sort_by_key(|v| { v.get("id") .and_then(|s| s.as_str()) .unwrap_or("") .to_string() }); let providers = read_providers( &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), ) .await; let base_url = format!("/ui/{slug}"); let mut ctx = Context::new(); ctx.insert("forms", &forms); ctx.insert("providers", &providers); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/compose.html", &ctx).await } pub async fn compose_form_schema_mp( State(state): State, Path((slug, form_id)): Path<(String, String)>, _auth: AuthUser, ) -> Result, UiError> { let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let form_path = ctx_ref .root .join("reflection") .join("forms") .join(format!("{form_id}.ncl")); if !form_path.exists() { return Err(UiError::BadRequest(format!("form not found: {form_id}"))); } let (json, _) = ctx_ref .cache .export(&form_path, ctx_ref.import_path.as_deref()) .await .map_err(|e| UiError::NclExport { path: form_id.clone(), reason: e.to_string(), })?; Ok(axum::Json(json)) } #[derive(serde::Deserialize)] pub struct ComposeSendBody { pub provider_id: String, pub prompt: String, #[serde(default)] pub system: String, } pub async fn compose_send_mp( State(state): State, Path(slug): Path, _auth: AuthUser, axum::Json(body): axum::Json, ) -> Result, UiError> { let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let providers = read_providers( &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), ) .await; let provider = providers .iter() .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(body.provider_id.as_str())) .ok_or_else(|| UiError::BadRequest(format!("unknown provider: {}", body.provider_id)))? .clone(); let api_url = provider .get("api_url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let model = provider .get("model") .and_then(|v| v.as_str()) .unwrap_or("claude-opus-4-6") .to_string(); let key_env = provider .get("key_env") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let kind = provider .get("kind") .and_then(|v| v.as_str()) .unwrap_or("anthropic") .to_string(); let api_key = if key_env.is_empty() { String::new() } else { std::env::var(&key_env).unwrap_or_default() }; if api_key.is_empty() && !matches!(kind.as_str(), "openai") { return Err(UiError::BadRequest(format!( "API key not configured: set {} environment variable", key_env ))); } let result = match kind.as_str() { "openai" => call_openai_api(&api_url, &api_key, &model, &body.system, &body.prompt).await, _ => call_anthropic_api(&api_url, &api_key, &model, &body.system, &body.prompt).await, }; result .map(axum::Json) .map_err(|e| UiError::BadRequest(e.to_string())) } async fn call_anthropic_api( url: &str, key: &str, model: &str, system: &str, prompt: &str, ) -> anyhow::Result { let effective_url = if url.is_empty() { "https://api.anthropic.com/v1/messages" } else { url }; let mut req = serde_json::json!({ "model": model, "max_tokens": 4096, "messages": [{"role": "user", "content": prompt}] }); if !system.is_empty() { req["system"] = serde_json::Value::String(system.to_string()); } let resp = reqwest::Client::new() .post(effective_url) .header("x-api-key", key) .header("anthropic-version", "2023-06-01") .json(&req) .send() .await? .json::() .await?; Ok(resp) } async fn call_openai_api( url: &str, key: &str, model: &str, system: &str, prompt: &str, ) -> anyhow::Result { let effective_url = if url.is_empty() { "https://api.openai.com/v1/chat/completions" } else { url }; let mut messages: Vec = Vec::new(); if !system.is_empty() { messages.push(serde_json::json!({"role": "system", "content": system})); } messages.push(serde_json::json!({"role": "user", "content": prompt})); let resp = reqwest::Client::new() .post(effective_url) .header("Authorization", format!("Bearer {key}")) .json(&serde_json::json!({ "model": model, "messages": messages })) .send() .await? .json::() .await?; Ok(resp) } // ── Actions // ─────────────────────────────────────────────────────────────────── /// Load `quick_actions` from `.ontoref/config.ncl`. async fn load_quick_actions( cache: &Arc, root: &std::path::Path, import_path: Option<&str>, ) -> Vec { let config_path = root.join(".ontoref").join("config.ncl"); if !config_path.exists() { return vec![]; } let Ok((json, _)) = cache.export(&config_path, import_path).await else { return vec![]; }; json.get("quick_actions") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default() } pub async fn actions_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let actions = load_quick_actions( &state.cache, &state.project_root, state.nickel_import_path.as_deref(), ) .await; let mut ctx = Context::new(); ctx.insert("actions", &actions); ctx.insert("base_url", "/ui"); ctx.insert("slug", &Option::::None); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/actions.html", &ctx).await } pub async fn actions_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let actions = load_quick_actions( &ctx_ref.cache, &ctx_ref.root, ctx_ref.import_path.as_deref(), ) .await; let base_url = format!("/ui/{slug}"); let mut ctx = Context::new(); ctx.insert("actions", &actions); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/actions.html", &ctx).await } // ── Q&A ─────────────────────────────────────────────────────────────────────── pub async fn qa_page(State(state): State) -> Result, UiError> { let tera = tera_ref(&state)?; let mut ctx = Context::new(); ctx.insert("base_url", "/ui"); ctx.insert("slug", &Option::::None); let entries = load_qa_entries( &state.cache, &state.project_root, state.nickel_import_path.as_deref(), ) .await; ctx.insert("entries", &entries); insert_brand_ctx( &mut ctx, &state.project_root, &state.cache, state.nickel_import_path.as_deref(), "/ui", ) .await; render(tera, "pages/qa.html", &ctx).await } pub async fn qa_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let base_url = format!("/ui/{slug}"); let entries = load_qa_entries( &ctx_ref.cache, &ctx_ref.root, ctx_ref.import_path.as_deref(), ) .await; let mut ctx = Context::new(); ctx.insert("base_url", &base_url); ctx.insert("slug", &slug); ctx.insert("current_role", &auth_role_str(&auth)); ctx.insert("entries", &entries); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/qa.html", &ctx).await } // ── Q&A mutation ───────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct QaAddRequest { pub question: String, pub answer: Option, pub actor: Option, pub tags: Option>, pub related: Option>, pub slug: Option, } pub async fn qa_add( State(state): State, Json(body): Json, ) -> impl IntoResponse { let (root, cache) = if let Some(slug) = body.slug.as_deref() { if let Some(ctx) = state.registry.get(slug) { (ctx.root.clone(), ctx.cache.clone()) } else { (state.project_root.clone(), state.cache.clone()) } } else { (state.project_root.clone(), state.cache.clone()) }; let qa_path = root.join("reflection").join("qa.ncl"); let _guard = state.ncl_write_lock.acquire(&qa_path).await; if !qa_path.exists() { return ( StatusCode::NOT_FOUND, Json( serde_json::json!({ "error": "qa.ncl not found — create reflection/qa.ncl first" }), ), ); } let answer = body.answer.as_deref().unwrap_or(""); let actor = body.actor.as_deref().unwrap_or("human"); let tags = body.tags.as_deref().unwrap_or(&[]); let related = body.related.as_deref().unwrap_or(&[]); let now = now_iso(); match super::qa_ncl::add_entry(&qa_path, &body.question, answer, actor, &now, tags, related) { Ok(id) => { cache.invalidate_file(&qa_path); ( StatusCode::OK, Json(serde_json::json!({ "ok": true, "id": id, "created_at": now, })), ) } Err(e) => { warn!(error = %e, "qa_add failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } fn now_iso() -> String { let total_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let secs_in_day = total_secs % 86400; let days = total_secs / 86400; let h = secs_in_day / 3600; let m = (secs_in_day % 3600) / 60; let s = secs_in_day % 60; let z = days + 719_468; let era = z / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y0 = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if mo <= 2 { y0 + 1 } else { y0 }; format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") } async fn load_qa_entries( cache: &Arc, root: &std::path::Path, import_path: Option<&str>, ) -> Vec { let qa_path = root.join("reflection").join("qa.ncl"); if !qa_path.exists() { return vec![]; } match cache.export(&qa_path, import_path).await { Ok((json, _)) => json .get("entries") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(), Err(_) => vec![], } } // ── Q&A delete / update // ─────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct QaDeleteRequest { pub id: String, pub slug: Option, } pub async fn qa_delete( State(state): State, Json(body): Json, ) -> impl IntoResponse { let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref()); let qa_path = root.join("reflection").join("qa.ncl"); let _guard = state.ncl_write_lock.acquire(&qa_path).await; if !qa_path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "qa.ncl not found" })), ); } match super::qa_ncl::remove_entry(&qa_path, &body.id) { Ok(()) => { cache.invalidate_file(&qa_path); ( StatusCode::OK, Json(serde_json::json!({ "ok": true, "id": body.id })), ) } Err(e) => { warn!(error = %e, id = %body.id, "qa_delete failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } #[derive(Deserialize)] pub struct QaUpdateRequest { pub id: String, pub question: String, pub answer: Option, pub slug: Option, } pub async fn qa_update( State(state): State, Json(body): Json, ) -> impl IntoResponse { let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref()); let qa_path = root.join("reflection").join("qa.ncl"); let _guard = state.ncl_write_lock.acquire(&qa_path).await; if !qa_path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "qa.ncl not found" })), ); } let answer = body.answer.as_deref().unwrap_or(""); match super::qa_ncl::update_entry(&qa_path, &body.id, &body.question, answer) { Ok(()) => { cache.invalidate_file(&qa_path); ( StatusCode::OK, Json(serde_json::json!({ "ok": true, "id": body.id })), ) } Err(e) => { warn!(error = %e, id = %body.id, "qa_update failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } fn resolve_qa_ctx( state: &crate::api::AppState, slug: Option<&str>, ) -> (std::path::PathBuf, Arc) { if let Some(s) = slug { if let Some(ctx) = state.registry.get(s) { return (ctx.root.clone(), ctx.cache.clone()); } } (state.project_root.clone(), state.cache.clone()) } // ── Actions run // ─────────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct ActionRunForm { pub action_id: String, } pub async fn actions_run( State(state): State, Form(form): Form, ) -> impl IntoResponse { let root = state.project_root.clone(); let cache = state.cache.clone(); let import_path = state.nickel_import_path.clone(); run_action_by_id(&root, &cache, import_path.as_deref(), &form.action_id).await; axum::response::Redirect::to("/ui/actions").into_response() } pub async fn actions_run_mp( State(state): State, Path(slug): Path, _auth: AuthUser, Form(form): Form, ) -> impl IntoResponse { let redirect = format!("/ui/{slug}/actions"); if let Some(ctx) = state.registry.get(&slug) { run_action_by_id( &ctx.root, &ctx.cache, ctx.import_path.as_deref(), &form.action_id, ) .await; } axum::response::Redirect::to(&redirect).into_response() } /// Resolve `action_id` from `quick_actions`, then spawn `./ontoref {mode}`. async fn run_action_by_id( root: &std::path::Path, cache: &Arc, import_path: Option<&str>, action_id: &str, ) { let config_path = root.join(".ontoref").join("config.ncl"); let Ok((json, _)) = cache.export(&config_path, import_path).await else { warn!(action_id, "actions_run: config export failed"); return; }; let mode = json .get("quick_actions") .and_then(|v| v.as_array()) .and_then(|arr| { arr.iter() .find(|a| a.get("id").and_then(|i| i.as_str()) == Some(action_id)) }) .and_then(|a| a.get("mode")) .and_then(|m| m.as_str()) .map(str::to_string); let Some(mode) = mode else { warn!(action_id, "actions_run: action not found in quick_actions"); return; }; let ontoref_bin = root.join("ontoref"); if !ontoref_bin.exists() { warn!(path = %ontoref_bin.display(), "actions_run: ontoref binary not found"); return; } match tokio::process::Command::new(&ontoref_bin) .args(["run", &mode]) .current_dir(root) .spawn() { Ok(_) => tracing::info!(action_id, mode, "action spawned"), Err(e) => warn!(action_id, mode, error = %e, "actions_run: spawn failed"), } } // ── Search bookmarks mutation // ───────────────────────────────────────────────── #[derive(Deserialize)] pub struct BookmarkAddRequest { pub node_id: String, pub kind: Option, pub title: String, pub level: Option, pub term: Option, pub actor: Option, pub tags: Option>, pub slug: Option, } #[derive(Deserialize)] pub struct BookmarkDeleteRequest { pub id: String, pub slug: Option, } pub async fn search_bookmark_add( State(state): State, Json(body): Json, ) -> impl IntoResponse { let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref()); let bm_path = root.join("reflection").join("search_bookmarks.ncl"); let _guard = state.ncl_write_lock.acquire(&bm_path).await; if !bm_path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "search_bookmarks.ncl not found — run ontoref setup first" })), ); } let kind = body.kind.as_deref().unwrap_or("node"); let level = body.level.as_deref().unwrap_or(""); let term = body.term.as_deref().unwrap_or(""); let actor = body.actor.as_deref().unwrap_or("human"); let tags = body.tags.as_deref().unwrap_or(&[]); let now = now_iso(); match super::search_bookmarks_ncl::add_entry( &bm_path, super::search_bookmarks_ncl::NewBookmark { node_id: &body.node_id, kind, title: &body.title, level, term, actor, created_at: &now, tags, }, ) { Ok(id) => { cache.invalidate_file(&bm_path); ( StatusCode::OK, Json(serde_json::json!({ "ok": true, "id": id, "created_at": now, "node_id": body.node_id, })), ) } Err(e) => { warn!(error = %e, "search_bookmark_add failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } pub async fn search_bookmark_delete( State(state): State, Json(body): Json, ) -> impl IntoResponse { let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref()); let bm_path = root.join("reflection").join("search_bookmarks.ncl"); let _guard = state.ncl_write_lock.acquire(&bm_path).await; if !bm_path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "search_bookmarks.ncl not found" })), ); } match super::search_bookmarks_ncl::remove_entry(&bm_path, &body.id) { Ok(()) => { cache.invalidate_file(&bm_path); (StatusCode::OK, Json(serde_json::json!({ "ok": true }))) } Err(e) => { warn!(error = %e, "search_bookmark_delete failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } pub(crate) async fn load_bookmark_entries( cache: &Arc, root: &std::path::Path, import_path: Option<&str>, ) -> Vec { let bm_path = root.join("reflection").join("search_bookmarks.ncl"); if !bm_path.exists() { return vec![]; } match cache.export(&bm_path, import_path).await { Ok((json, _)) => json .get("entries") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(), Err(_) => vec![], } } fn resolve_bookmark_ctx( state: &crate::api::AppState, slug: Option<&str>, ) -> (std::path::PathBuf, Arc) { if let Some(s) = slug { if let Some(ctx) = state.registry.get(s) { return (ctx.root.clone(), ctx.cache.clone()); } } (state.project_root.clone(), state.cache.clone()) } // ── Config surface page // ────────────────────────────────────────────────────── pub async fn config_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let base_url = format!("/ui/{slug}"); let surface = ctx_ref.config_surface.clone(); let has_config_surface = surface.is_some(); let mut ctx = Context::new(); ctx.insert("slug", &slug); ctx.insert("base_url", &base_url); ctx.insert("current_role", &auth_role_str(&auth)); ctx.insert("has_config_surface", &has_config_surface); if let Some(ref surface) = surface { ctx.insert("config_root", &surface.config_root.display().to_string()); ctx.insert("entry_point", &surface.entry_point); ctx.insert("kind", &format!("{:?}", surface.kind)); let quickref = crate::config_coherence::build_quickref( &slug, surface, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), None, ) .await; let overall_status = quickref .get("overall_coherence") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); ctx.insert("overall_status", overall_status); let sections = quickref .get("sections") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); ctx.insert("sections", §ions); } else { ctx.insert("config_root", ""); ctx.insert("entry_point", ""); ctx.insert("kind", ""); ctx.insert("overall_status", "Unknown"); ctx.insert("sections", &serde_json::Value::Array(vec![])); } insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/config.html", &ctx).await } pub async fn adrs_page_mp( State(state): State, Path(slug): Path, auth: AuthUser, ) -> Result, UiError> { let tera = tera_ref(&state)?; let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?; let base_url = format!("/ui/{slug}"); let adrs_dir = ctx_ref.root.join("adrs"); let import_path = ctx_ref.import_path.clone(); let cache = ctx_ref.cache.clone(); let mut adrs: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&adrs_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("ncl") { continue; } let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); // Only keep actual ADR files: adr-NNN-* where NNN starts with a digit let after_prefix = match stem.strip_prefix("adr-") { Some(s) => s, None => continue, }; if !after_prefix.starts_with(|c: char| c.is_ascii_digit()) { continue; } match cache.export(&path, import_path.as_deref()).await { Ok((v, _)) => { let hard_count = v .get("constraints") .and_then(|c| c.get("hard")) .and_then(|h| h.as_array()) .map(|a| a.len()) .unwrap_or(0); let soft_count = v .get("constraints") .and_then(|c| c.get("soft")) .and_then(|s| s.as_array()) .map(|a| a.len()) .unwrap_or(0); adrs.push(serde_json::json!({ "id": v.get("id").and_then(|i| i.as_str()).unwrap_or(&stem), "title": v.get("title").and_then(|t| t.as_str()).unwrap_or(""), "status": v.get("status").and_then(|s| s.as_str()).unwrap_or(""), "date": v.get("date").and_then(|d| d.as_str()).unwrap_or(""), "context": v.get("context").and_then(|c| c.as_str()).unwrap_or(""), "decision": v.get("decision").and_then(|d| d.as_str()).unwrap_or(""), "hard_constraints": hard_count, "soft_constraints": soft_count, "file": stem, })); } Err(e) => { tracing::warn!(path = %path.display(), error = %e, "adrs_page: export failed"); adrs.push(serde_json::json!({ "id": stem, "title": "", "status": "Error", "date": "", "context": "", "decision": "", "hard_constraints": 0, "soft_constraints": 0, "file": stem, })); } } } } adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); let adrs_json = serde_json::to_string(&adrs).unwrap_or_else(|_| "[]".to_string()); let mut ctx = Context::new(); ctx.insert("slug", &slug); ctx.insert("base_url", &base_url); ctx.insert("current_role", &auth_role_str(&auth)); ctx.insert("adrs_json", &adrs_json); ctx.insert("adr_count", &adrs.len()); insert_brand_ctx( &mut ctx, &ctx_ref.root, &ctx_ref.cache, ctx_ref.import_path.as_deref(), &base_url, ) .await; render(tera, "pages/adrs.html", &ctx).await }