941 lines
29 KiB
Rust
941 lines
29 KiB
Rust
|
|
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<NclCache>,
|
||
|
|
pub project_root: PathBuf,
|
||
|
|
pub ontoref_root: Option<PathBuf>,
|
||
|
|
pub started_at: Instant,
|
||
|
|
pub last_activity: Arc<AtomicU64>,
|
||
|
|
pub actors: Arc<ActorRegistry>,
|
||
|
|
pub notifications: Arc<NotificationStore>,
|
||
|
|
/// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports.
|
||
|
|
pub nickel_import_path: Option<String>,
|
||
|
|
#[cfg(feature = "db")]
|
||
|
|
pub db: Option<Arc<stratum_db::StratumDb>>,
|
||
|
|
#[cfg(feature = "nats")]
|
||
|
|
pub nats: Option<Arc<crate::nats::NatsPublisher>>,
|
||
|
|
#[cfg(feature = "ui")]
|
||
|
|
pub tera: Option<Arc<tokio::sync::RwLock<tera::Tera>>>,
|
||
|
|
#[cfg(feature = "ui")]
|
||
|
|
pub public_dir: Option<PathBuf>,
|
||
|
|
#[cfg(feature = "ui")]
|
||
|
|
pub registry: Option<Arc<crate::registry::ProjectRegistry>>,
|
||
|
|
#[cfg(feature = "ui")]
|
||
|
|
pub sessions: Arc<crate::session::SessionStore>,
|
||
|
|
/// Current project set by `set_project` MCP tool — shared across all
|
||
|
|
/// connections.
|
||
|
|
#[cfg(feature = "mcp")]
|
||
|
|
pub mcp_current_project: Arc<std::sync::RwLock<Option<String>>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<bool>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
||
|
|
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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct ExportResponse {
|
||
|
|
data: serde_json::Value,
|
||
|
|
cached: bool,
|
||
|
|
elapsed_ms: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn nickel_export(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Json(req): Json<ExportRequest>,
|
||
|
|
) -> std::result::Result<Json<ExportResponse>, (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<String> = 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<AppState>) -> Json<CacheStatsResponse> {
|
||
|
|
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<String>,
|
||
|
|
file: Option<String>,
|
||
|
|
all: Option<bool>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct InvalidateResponse {
|
||
|
|
invalidated: bool,
|
||
|
|
entries_remaining: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn cache_invalidate(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Json(req): Json<InvalidateRequest>,
|
||
|
|
) -> std::result::Result<Json<InvalidateResponse>, (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<AppState>,
|
||
|
|
Json(req): Json<RegisterRequest>,
|
||
|
|
) -> (StatusCode, Json<RegisterResponse>) {
|
||
|
|
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<AppState>, Path(token): Path<String>) -> 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<AppState>, Path(token): Path<String>) -> StatusCode {
|
||
|
|
state.touch_activity();
|
||
|
|
if state.actors.touch(&token) {
|
||
|
|
StatusCode::NO_CONTENT
|
||
|
|
} else {
|
||
|
|
StatusCode::NOT_FOUND
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct ProfileRequest {
|
||
|
|
#[serde(default)]
|
||
|
|
role: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
preferences: Option<serde_json::Value>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn actor_update_profile(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(token): Path<String>,
|
||
|
|
Json(req): Json<ProfileRequest>,
|
||
|
|
) -> 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<ActorEntry>,
|
||
|
|
total: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct ActorEntry {
|
||
|
|
token: String,
|
||
|
|
#[serde(flatten)]
|
||
|
|
session: ActorSessionView,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct ActorsQuery {
|
||
|
|
project: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn actors_list(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Query(query): Query<ActorsQuery>,
|
||
|
|
) -> Json<ActorsListResponse> {
|
||
|
|
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<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
check_only: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct PendingResponse {
|
||
|
|
pending: usize,
|
||
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||
|
|
notifications: Option<Vec<NotificationView>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn notifications_pending(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Query(query): Query<PendingQuery>,
|
||
|
|
) -> Json<PendingResponse> {
|
||
|
|
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<AppState>,
|
||
|
|
Json(req): Json<AckRequest>,
|
||
|
|
) -> std::result::Result<Json<AckResponse>, (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<String>,
|
||
|
|
#[cfg(feature = "ui")]
|
||
|
|
slug: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct SearchResponse {
|
||
|
|
query: String,
|
||
|
|
results: Vec<crate::search::SearchResult>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn search(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Query(params): Query<SearchQuery>,
|
||
|
|
) -> 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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct ActorInitQuery {
|
||
|
|
actor: Option<String>,
|
||
|
|
slug: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<NclCache>, Option<String>) {
|
||
|
|
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<NclCache>, Option<String>) {
|
||
|
|
(
|
||
|
|
state.project_root.clone(),
|
||
|
|
state.cache.clone(),
|
||
|
|
state.nickel_import_path.clone(),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn describe_project(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Query(q): Query<DescribeQuery>,
|
||
|
|
) -> 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<AppState>,
|
||
|
|
Query(q): Query<DescribeQuery>,
|
||
|
|
) -> 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<AppState>,
|
||
|
|
Query(q): Query<DescribeQuery>,
|
||
|
|
) -> 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<serde_json::Value> = 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<String> = 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<AppState>,
|
||
|
|
Query(q): Query<ActorInitQuery>,
|
||
|
|
) -> 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<AppState>,
|
||
|
|
Query(q): Query<DescribeQuery>,
|
||
|
|
) -> 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<AppState>,
|
||
|
|
Query(q): Query<DescribeQuery>,
|
||
|
|
) -> 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<PathBuf, (StatusCode, String)> {
|
||
|
|
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<PathBuf, (StatusCode, String)> {
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
}
|