use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{delete, get, post}; use axum::Json; use serde::{Deserialize, Serialize}; use tracing::{error, warn}; use crate::actors::{ActorRegistry, ActorSessionView, RegisterRequest}; use crate::cache::NclCache; use crate::notifications::{AckRequest, NotificationStore, NotificationView}; /// Shared application state injected into handlers. #[derive(Clone)] pub struct AppState { pub cache: Arc, pub project_root: PathBuf, pub ontoref_root: Option, pub started_at: Instant, pub last_activity: Arc, pub actors: Arc, pub notifications: Arc, /// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports. pub nickel_import_path: Option, #[cfg(feature = "db")] pub db: Option>, #[cfg(feature = "nats")] pub nats: Option>, #[cfg(feature = "ui")] pub tera: Option>>, #[cfg(feature = "ui")] pub public_dir: Option, #[cfg(feature = "ui")] pub registry: Option>, #[cfg(feature = "ui")] pub sessions: Arc, /// Current project set by `set_project` MCP tool — shared across all /// connections. #[cfg(feature = "mcp")] pub mcp_current_project: Arc>>, } impl AppState { fn touch_activity(&self) { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); self.last_activity.store(now, Ordering::Relaxed); } /// Derive the project name for the default project. pub fn default_project_name(&self) -> String { self.project_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string() } } pub fn router(state: AppState) -> axum::Router { let app = axum::Router::new() // Existing endpoints .route("/health", get(health)) .route("/nickel/export", post(nickel_export)) .route("/cache/stats", get(cache_stats)) .route("/cache/invalidate", post(cache_invalidate)) // Actor endpoints .route("/actors/register", post(actor_register)) .route("/actors/{token}", delete(actor_deregister)) .route("/actors/{token}/touch", post(actor_touch)) .route("/actors/{token}/profile", post(actor_update_profile)) .route("/actors", get(actors_list)) // Notification endpoints .route("/notifications/pending", get(notifications_pending)) .route("/notifications/ack", post(notifications_ack)) // Search endpoint .route("/search", get(search)) // Describe endpoints .route("/describe/project", get(describe_project)) .route("/describe/capabilities", get(describe_capabilities)) .route("/describe/connections", get(describe_connections)) .route("/describe/actor-init", get(describe_actor_init)) // Backlog JSON endpoint .route("/backlog-json", get(backlog_json)) // Q&A read endpoint .route("/qa-json", get(qa_json)); // Gate the mutation endpoint behind the ui feature (requires crate::ui). #[cfg(feature = "ui")] let app = app .route("/qa/add", post(crate::ui::handlers::qa_add)) .route("/qa/delete", post(crate::ui::handlers::qa_delete)) .route("/qa/update", post(crate::ui::handlers::qa_update)); let app = app.with_state(state.clone()); #[cfg(feature = "ui")] let app = { use axum::response::Redirect; use tower_http::services::ServeDir; let app = app .route("/ui", get(|| async { Redirect::permanent("/ui/") })) .nest("/ui/", crate::ui::router(state.clone())); if let Some(ref public_dir) = state.public_dir { app.nest_service("/public", ServeDir::new(public_dir)) } else { app } }; // MCP streamable-HTTP endpoint — stateless per-request factory. #[cfg(feature = "mcp")] let app = { use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; let mcp_state = state.clone(); let mcp_svc = StreamableHttpService::new( move || Ok(crate::mcp::OntoreServer::new(mcp_state.clone())), std::sync::Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), ); app.nest_service("/mcp", mcp_svc) }; app } // ── Health ────────────────────────────────────────────────────────────── #[derive(Serialize)] struct HealthResponse { status: &'static str, uptime_secs: u64, cache_entries: usize, cache_hits: u64, cache_misses: u64, project_root: String, active_actors: usize, #[serde(skip_serializing_if = "Option::is_none")] db_enabled: Option, } async fn health(State(state): State) -> Json { state.touch_activity(); let db_enabled = { #[cfg(feature = "db")] { Some(state.db.is_some()) } #[cfg(not(feature = "db"))] { None } }; Json(HealthResponse { status: "ok", uptime_secs: state.started_at.elapsed().as_secs(), cache_entries: state.cache.len(), cache_hits: state.cache.hit_count(), cache_misses: state.cache.miss_count(), project_root: state.project_root.display().to_string(), active_actors: state.actors.count(), db_enabled, }) } // ── Nickel Export ─────────────────────────────────────────────────────── #[derive(Deserialize)] struct ExportRequest { path: String, import_path: Option, } #[derive(Serialize)] struct ExportResponse { data: serde_json::Value, cached: bool, elapsed_ms: u64, } async fn nickel_export( State(state): State, Json(req): Json, ) -> std::result::Result, (StatusCode, String)> { state.touch_activity(); let start = Instant::now(); // Accept absolute paths — daemon is loopback-only; OS permissions are the // boundary. Relative paths are still resolved against project_root for // backward compatibility. let file_path = resolve_any_path(&state.project_root, &req.path)?; let inherited_ip = std::env::var("NICKEL_IMPORT_PATH").unwrap_or_default(); let merged_ip: Option = match req.import_path.as_deref() { Some(caller_ip) => { if inherited_ip.is_empty() { Some(caller_ip.to_string()) } else { Some(format!("{caller_ip}:{inherited_ip}")) } } None => { if inherited_ip.is_empty() { None } else { Some(inherited_ip) } } }; let (data, was_hit) = state .cache .export(&file_path, merged_ip.as_deref()) .await .map_err(|e| { error!(path = %file_path.display(), error = %e, "nickel export failed"); (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })?; Ok(Json(ExportResponse { data, cached: was_hit, elapsed_ms: start.elapsed().as_millis() as u64, })) } // ── Cache Management ──────────────────────────────────────────────────── #[derive(Serialize)] struct CacheStatsResponse { entries: usize, hits: u64, misses: u64, hit_rate: f64, } async fn cache_stats(State(state): State) -> Json { state.touch_activity(); let hits = state.cache.hit_count(); let misses = state.cache.miss_count(); let total = hits + misses; Json(CacheStatsResponse { entries: state.cache.len(), hits, misses, hit_rate: if total > 0 { hits as f64 / total as f64 } else { 0.0 }, }) } #[derive(Deserialize)] struct InvalidateRequest { prefix: Option, file: Option, all: Option, } #[derive(Serialize)] struct InvalidateResponse { invalidated: bool, entries_remaining: usize, } async fn cache_invalidate( State(state): State, Json(req): Json, ) -> std::result::Result, (StatusCode, String)> { state.touch_activity(); if req.all.unwrap_or(false) { state.cache.invalidate_all(); } else if let Some(prefix) = &req.prefix { let path = resolve_path(&state.project_root, prefix)?; state.cache.invalidate_prefix(&path); } else if let Some(file) = &req.file { let path = resolve_path(&state.project_root, file)?; state.cache.invalidate_file(&path); } else { return Err(( StatusCode::BAD_REQUEST, "at least one of 'all', 'prefix', or 'file' must be specified".to_string(), )); } Ok(Json(InvalidateResponse { invalidated: true, entries_remaining: state.cache.len(), })) } // ── Actor Endpoints ───────────────────────────────────────────────────── #[derive(Serialize)] struct RegisterResponse { token: String, actors_connected: usize, } async fn actor_register( State(state): State, Json(req): Json, ) -> (StatusCode, Json) { state.touch_activity(); #[cfg(feature = "nats")] let actor_type = req.actor_type.clone(); #[cfg(feature = "nats")] let project = req.project.clone(); let token = state.actors.register(req); let count = state.actors.count(); #[cfg(feature = "nats")] { if let Some(ref nats) = state.nats { if let Err(e) = nats .publish_actor_registered(&token, &actor_type, &project) .await { tracing::warn!(error = %e, "failed to publish actor.registered event"); } } } ( StatusCode::CREATED, Json(RegisterResponse { token, actors_connected: count, }), ) } async fn actor_deregister(State(state): State, Path(token): Path) -> StatusCode { state.touch_activity(); if state.actors.deregister(&token) { #[cfg(feature = "nats")] { if let Some(ref nats) = state.nats { if let Err(e) = nats.publish_actor_deregistered(&token, "explicit").await { tracing::warn!(error = %e, "failed to publish actor.deregistered event"); } } } StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } async fn actor_touch(State(state): State, Path(token): Path) -> StatusCode { state.touch_activity(); if state.actors.touch(&token) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } #[derive(Deserialize)] struct ProfileRequest { #[serde(default)] role: Option, #[serde(default)] preferences: Option, } async fn actor_update_profile( State(state): State, Path(token): Path, Json(req): Json, ) -> StatusCode { state.touch_activity(); if state .actors .update_profile(&token, req.role, req.preferences) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } #[derive(Serialize)] struct ActorsListResponse { actors: Vec, total: usize, } #[derive(Serialize)] struct ActorEntry { token: String, #[serde(flatten)] session: ActorSessionView, } #[derive(Deserialize)] struct ActorsQuery { project: Option, } async fn actors_list( State(state): State, Query(query): Query, ) -> Json { state.touch_activity(); let entries = match query.project { Some(ref project) => state.actors.list_for_project(project), None => state.actors.list(), }; let total = entries.len(); let actors = entries .into_iter() .map(|(token, session)| ActorEntry { token, session }) .collect(); Json(ActorsListResponse { actors, total }) } // ── Notification Endpoints ────────────────────────────────────────────── #[derive(Deserialize)] struct PendingQuery { token: String, project: Option, #[serde(default)] check_only: bool, } #[derive(Serialize)] struct PendingResponse { pending: usize, #[serde(skip_serializing_if = "Option::is_none")] notifications: Option>, } async fn notifications_pending( State(state): State, Query(query): Query, ) -> Json { state.touch_activity(); state.actors.touch(&query.token); let project = query .project .unwrap_or_else(|| state.default_project_name()); if query.check_only { let count = state.notifications.pending_count(&project, &query.token); Json(PendingResponse { pending: count, notifications: None, }) } else { let notifications = state.notifications.pending(&project, &query.token); let count = notifications.len(); Json(PendingResponse { pending: count, notifications: Some(notifications), }) } } #[derive(Serialize)] struct AckResponse { acknowledged: usize, } async fn notifications_ack( State(state): State, Json(req): Json, ) -> std::result::Result, (StatusCode, String)> { state.touch_activity(); state.actors.touch(&req.token); let project = req.project.unwrap_or_else(|| state.default_project_name()); let count = if req.all { let acked = state.notifications.ack_all(&project, &req.token); state.actors.clear_pending(&req.token); acked } else if let Some(id) = req.notification_id { if state.notifications.ack_one(&project, &req.token, id) { 1 } else { 0 } } else { return Err(( StatusCode::BAD_REQUEST, "either 'all: true' or 'notification_id' must be specified".to_string(), )); }; Ok(Json(AckResponse { acknowledged: count, })) } // ── Search ─────────────────────────────────────────────────────────────── #[derive(Deserialize)] struct SearchQuery { q: Option, #[cfg(feature = "ui")] slug: Option, } #[derive(Serialize)] struct SearchResponse { query: String, results: Vec, } async fn search( State(state): State, Query(params): Query, ) -> impl IntoResponse { let q = params.q.as_deref().unwrap_or("").trim().to_string(); if q.is_empty() { return Json(SearchResponse { query: q, results: vec![], }); } // In multi-project mode, resolve context from slug; fall back to primary // project. #[cfg(feature = "ui")] let results = { if let (Some(slug), Some(ref registry)) = (params.slug.as_deref(), &state.registry) { if let Some(ctx) = registry.get(slug) { crate::search::search_project(&ctx.root, &ctx.cache, ctx.import_path.as_deref(), &q) .await } else { vec![] } } else { crate::search::search_project( &state.project_root, &state.cache, state.nickel_import_path.as_deref(), &q, ) .await } }; #[cfg(not(feature = "ui"))] let results = crate::search::search_project( &state.project_root, &state.cache, state.nickel_import_path.as_deref(), &q, ) .await; Json(SearchResponse { query: q, results }) } // ── Describe Endpoints ─────────────────────────────────────────────────── #[derive(Deserialize)] struct DescribeQuery { slug: Option, } #[derive(Deserialize)] struct ActorInitQuery { actor: Option, slug: Option, } /// Resolve project context from an optional slug. /// Falls back to the primary project when slug is absent or not found in /// registry. #[cfg(feature = "ui")] fn resolve_project_ctx( state: &AppState, slug: Option<&str>, ) -> (PathBuf, Arc, Option) { if let Some(s) = slug { if let Some(ref registry) = state.registry { if let Some(ctx) = registry.get(s) { return (ctx.root.clone(), ctx.cache.clone(), ctx.import_path.clone()); } } } ( state.project_root.clone(), state.cache.clone(), state.nickel_import_path.clone(), ) } #[cfg(not(feature = "ui"))] fn resolve_project_ctx( state: &AppState, _slug: Option<&str>, ) -> (PathBuf, Arc, Option) { ( state.project_root.clone(), state.cache.clone(), state.nickel_import_path.clone(), ) } async fn describe_project( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let path = root.join(".ontology").join("core.ncl"); if !path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "core.ncl not found" })), ); } match cache.export(&path, import_path.as_deref()).await { Ok((data, _)) => (StatusCode::OK, Json(data)), Err(e) => { error!(path = %path.display(), error = %e, "describe_project export failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } async fn describe_connections( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let path = root.join(".ontology").join("connections.ncl"); if !path.exists() { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "connections.ncl not found" })), ); } match cache.export(&path, import_path.as_deref()).await { Ok((data, _)) => (StatusCode::OK, Json(data)), Err(e) => { error!(path = %path.display(), error = %e, "describe_connections export failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } async fn describe_capabilities( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let modes_dir = root.join("reflection").join("modes"); let adrs_dir = root.join("adrs"); let forms_dir = root.join("reflection").join("forms"); // Modes: export each NCL for id + trigger let mut modes: 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 stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); match cache.export(&path, import_path.as_deref()).await { Ok((json, _)) => { let id = json .get("id") .and_then(|v| v.as_str()) .unwrap_or(&stem) .to_string(); let trigger = json .get("trigger") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); modes.push(serde_json::json!({ "id": id, "trigger": trigger })); } Err(e) => { warn!(path = %path.display(), error = %e, "capabilities: mode export failed"); modes.push(serde_json::json!({ "id": stem, "trigger": "" })); } } } } modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); // ADRs: count only let adr_count = std::fs::read_dir(&adrs_dir) .map(|rd| { rd.flatten() .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) .count() }) .unwrap_or(0); // Forms: list file stems let mut forms: Vec = std::fs::read_dir(&forms_dir) .map(|rd| { rd.flatten() .filter_map(|e| { let p = e.path(); if p.extension().and_then(|x| x.to_str()) != Some("ncl") { return None; } p.file_stem().and_then(|s| s.to_str()).map(str::to_string) }) .collect() }) .unwrap_or_default(); forms.sort(); let mode_count = modes.len(); let form_count = forms.len(); ( StatusCode::OK, Json(serde_json::json!({ "modes": modes, "adrs": adr_count, "forms": forms, "mode_count": mode_count, "adr_count": adr_count, "form_count": form_count, })), ) } async fn describe_actor_init( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let actor = q.actor.as_deref().unwrap_or("agent"); let config_path = root.join(".ontoref").join("config.ncl"); if !config_path.exists() { return ( StatusCode::OK, Json(serde_json::json!({ "mode": "", "auto_run": false })), ); } match cache.export(&config_path, import_path.as_deref()).await { Ok((json, _)) => { let entry = json .get("actor_init") .and_then(|v| v.as_array()) .and_then(|arr| { arr.iter() .find(|e| e.get("actor").and_then(|v| v.as_str()) == Some(actor)) }) .cloned(); let result = match entry { Some(e) => { let mode = e .get("mode") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let auto_run = e.get("auto_run").and_then(|v| v.as_bool()).unwrap_or(false); serde_json::json!({ "mode": mode, "auto_run": auto_run }) } None => serde_json::json!({ "mode": "", "auto_run": false }), }; (StatusCode::OK, Json(result)) } Err(e) => { warn!(path = %config_path.display(), error = %e, "describe_actor_init export failed"); ( StatusCode::OK, Json(serde_json::json!({ "mode": "", "auto_run": false })), ) } } } async fn backlog_json( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let backlog_path = root.join("reflection").join("backlog.ncl"); if !backlog_path.exists() { return (StatusCode::OK, Json(serde_json::json!([]))); } match cache.export(&backlog_path, import_path.as_deref()).await { Ok((json, _)) => { let items = json .get("items") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); (StatusCode::OK, Json(serde_json::Value::Array(items))) } Err(e) => { error!(path = %backlog_path.display(), error = %e, "backlog_json export failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } // ── Q&A endpoints ─────────────────────────────────────────────────────── async fn qa_json( State(state): State, Query(q): Query, ) -> impl IntoResponse { state.touch_activity(); let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); let qa_path = root.join("reflection").join("qa.ncl"); if !qa_path.exists() { return (StatusCode::OK, Json(serde_json::json!([]))); } match cache.export(&qa_path, import_path.as_deref()).await { Ok((json, _)) => { let entries = json .get("entries") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); (StatusCode::OK, Json(serde_json::Value::Array(entries))) } Err(e) => { error!(path = %qa_path.display(), error = %e, "qa_json export failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) } } } // ── Helpers ───────────────────────────────────────────────────────────── /// Resolve a path that may be absolute or relative to project_root. /// Absolute paths are accepted as-is (daemon is loopback-only; OS enforces /// access). Relative paths are resolved against project_root and must stay /// within it. fn resolve_any_path( project_root: &std::path::Path, path: &str, ) -> std::result::Result { let p = PathBuf::from(path); if p.is_absolute() { return p.canonicalize().map_err(|e| { ( StatusCode::BAD_REQUEST, format!("path does not exist or is inaccessible: {e}"), ) }); } let joined = project_root.join(p); let canonical = joined.canonicalize().map_err(|e| { ( StatusCode::BAD_REQUEST, format!("path does not exist or is inaccessible: {e}"), ) })?; if !canonical.starts_with(project_root) { return Err(( StatusCode::BAD_REQUEST, format!("path escapes project root: {path}"), )); } Ok(canonical) } /// Resolve a path relative to the project root and verify it stays within. /// Rejects absolute paths — used for cache management operations scoped to a /// project. fn resolve_path( project_root: &std::path::Path, path: &str, ) -> std::result::Result { let p = PathBuf::from(path); if p.is_absolute() { return Err(( StatusCode::BAD_REQUEST, "absolute paths are not accepted".to_string(), )); } let joined = project_root.join(p); let canonical = joined.canonicalize().map_err(|e| { ( StatusCode::BAD_REQUEST, format!("path does not exist or is inaccessible: {e}"), ) })?; if !canonical.starts_with(project_root) { return Err(( StatusCode::BAD_REQUEST, format!("path escapes project root: {path}"), )); } Ok(canonical) } impl IntoResponse for crate::error::DaemonError { fn into_response(self) -> axum::response::Response { let body = serde_json::json!({"error": self.to_string()}); (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response() } }