chore: add src code
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled

This commit is contained in:
Jesús Pérez 2026-03-13 00:18:14 +00:00
parent 147576e8bb
commit 2d87d60bb5
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
51 changed files with 22067 additions and 0 deletions

6592
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View File

@ -0,0 +1,31 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
edition = "2021"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
tokio = { version = "1.50", features = ["full"] }
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
bytes = "1"
tempfile = "3"
tokio-test = "0.4"
axum = { version = "0.8", features = ["json"] }
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
notify = { version = "8.2", default-features = false, features = ["macos_fsevent"] }
dashmap = "6.1"
clap = { version = "4", features = ["derive"] }
hostname = "0.4"
libc = "0.2"
reqwest = { version = "0.13", features = ["json"] }

View File

@ -0,0 +1,52 @@
[package]
name = "ontoref-daemon"
version = "0.1.0"
edition.workspace = true
description = "Ontoref runtime daemon: NCL export cache, file watcher, actor registry, HTTP API"
license.workspace = true
[[bin]]
name = "ontoref-daemon"
path = "src/main.rs"
[dependencies]
stratum-db = { path = "../../../stratumiops/crates/stratum-db", default-features = false, optional = true }
platform-nats = { path = "../../../stratumiops/crates/platform-nats", optional = true }
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
notify = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tower-http = { workspace = true }
tera = { version = "1", default-features = false, features = ["builtins"], optional = true }
argon2 = { version = "0.5", features = ["std"], optional = true }
toml = { version = "0.8", optional = true }
uuid = { workspace = true, optional = true }
axum-server = { version = "0.7", features = ["tls-rustls"], optional = true }
rmcp = { version = "1", features = ["server", "transport-io", "transport-streamable-http-server"], optional = true }
schemars = { version = "1", optional = true }
thiserror = { workspace = true }
dashmap = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
bytes = { workspace = true }
hostname = { workspace = true }
reqwest = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[features]
default = ["db", "nats", "ui", "mcp"]
db = ["stratum-db/remote"]
nats = ["dep:platform-nats"]
ui = ["dep:tera", "dep:argon2", "dep:toml", "dep:uuid"]
tls = ["ui", "dep:axum-server"]
mcp = ["ui", "dep:rmcp", "dep:schemars"]
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@ -0,0 +1,492 @@
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
/// Deterministic actor token: `{actor_type}:{hostname}:{pid}`.
/// No UUIDs — the tuple is unique per machine and disambiguated by
/// `registered_at` against PID recycling.
pub fn make_token(actor_type: &str, hostname: &str, pid: u32) -> String {
format!("{actor_type}:{hostname}:{pid}")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ActorSession {
pub actor_type: String,
pub hostname: String,
pub pid: u32,
pub project: String,
/// Role identifier — validated against `.ontoref/roles.ncl` if present;
/// defaults to "developer".
pub role: String,
pub registered_at: u64,
pub last_seen: u64,
pub pending_notifications: AtomicU64,
/// UI and behavioral preferences (theme, nav_mode, etc.).
pub preferences: serde_json::Value,
}
/// Serialisable snapshot of an `ActorSession` for disk persistence.
/// Written to `{persist_dir}/{token_safe}.json` on profile update.
#[derive(Debug, Serialize, Deserialize)]
struct PersistedSession {
token: String,
actor_type: String,
hostname: String,
pid: u32,
project: String,
role: String,
registered_at: u64,
last_seen: u64,
preferences: serde_json::Value,
}
/// Lock-free concurrent actor registry.
///
/// Key = token (`actor_type:hostname:pid`), value = session metadata.
/// Actors register via HTTP POST and deregister via HTTP DELETE or
/// are reaped by the periodic sweep.
///
/// When `persist_dir` is set (e.g. `{project_root}/.ontoref/sessions`), actor
/// profiles are written as JSON files on update and loaded on construction.
pub struct ActorRegistry {
sessions: DashMap<String, ActorSession>,
stale_timeout_secs: u64,
/// Cached at construction to avoid syscall on every sweep iteration.
local_hostname: String,
/// Directory for session profile persistence (`{root}/.ontoref/sessions`).
persist_dir: Option<PathBuf>,
}
impl ActorRegistry {
pub fn new(stale_timeout_secs: u64) -> Self {
let local_hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
Self {
sessions: DashMap::new(),
stale_timeout_secs,
local_hostname,
persist_dir: None,
}
}
pub fn with_persist_dir(mut self, dir: PathBuf) -> Self {
self.persist_dir = Some(dir);
self
}
/// Load persisted session profiles from `persist_dir`. Tokens that do not
/// correspond to a live actor are kept in the registry with their last-seen
/// timestamp so the daemon can restore preferences on the next registration
/// of the same token within the stale window.
pub fn load_persisted(&self) {
let Some(dir) = &self.persist_dir else { return };
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(session) = serde_json::from_slice::<PersistedSession>(&bytes) else {
warn!(path = %path.display(), "failed to deserialise persisted session");
continue;
};
// Do not re-insert if already registered (live session takes priority).
if self.sessions.contains_key(&session.token) {
continue;
}
let token = session.token.clone();
self.sessions.insert(
token.clone(),
ActorSession {
actor_type: session.actor_type,
hostname: session.hostname,
pid: session.pid,
project: session.project,
role: session.role,
registered_at: session.registered_at,
last_seen: session.last_seen,
pending_notifications: AtomicU64::new(0),
preferences: session.preferences,
},
);
debug!(token = %token, "loaded persisted actor session");
}
}
pub fn register(&self, req: RegisterRequest) -> String {
let token = make_token(&req.actor_type, &req.hostname, req.pid);
let now = epoch_secs();
let session = ActorSession {
actor_type: req.actor_type,
hostname: req.hostname,
pid: req.pid,
project: req.project,
role: req.role.unwrap_or_else(|| "developer".to_string()),
registered_at: now,
last_seen: now,
pending_notifications: AtomicU64::new(0),
preferences: req
.preferences
.unwrap_or(serde_json::Value::Object(Default::default())),
};
self.sessions.insert(token.clone(), session);
info!(token = %token, "actor registered");
token
}
pub fn deregister(&self, token: &str) -> bool {
let removed = self.sessions.remove(token).is_some();
if removed {
info!(token = %token, "actor deregistered");
}
removed
}
/// Update role and/or preferences for a registered session. Returns `true`
/// if found. Persists the updated profile to disk if `persist_dir` is
/// configured.
pub fn update_profile(
&self,
token: &str,
role: Option<String>,
preferences: Option<serde_json::Value>,
) -> bool {
let Some(mut session) = self.sessions.get_mut(token) else {
return false;
};
if let Some(r) = role {
session.role = r;
}
if let Some(p) = preferences {
session.preferences = p;
}
session.last_seen = epoch_secs();
if let Some(dir) = &self.persist_dir {
let persisted = PersistedSession {
token: token.to_string(),
actor_type: session.actor_type.clone(),
hostname: session.hostname.clone(),
pid: session.pid,
project: session.project.clone(),
role: session.role.clone(),
registered_at: session.registered_at,
last_seen: session.last_seen,
preferences: session.preferences.clone(),
};
drop(session); // release DashMap write guard before I/O
self.write_persisted_session(dir, token, &persisted);
}
true
}
fn write_persisted_session(&self, dir: &PathBuf, token: &str, session: &PersistedSession) {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!(dir = %dir.display(), error = %e, "failed to create sessions persist dir");
return;
}
let file = dir.join(format!("{}.json", token_to_filename(token)));
match serde_json::to_vec_pretty(session) {
Ok(bytes) => {
if let Err(e) = std::fs::write(&file, bytes) {
warn!(path = %file.display(), error = %e, "failed to persist actor session");
}
}
Err(e) => warn!(error = %e, "failed to serialise actor session"),
}
}
/// Update `last_seen` timestamp. Returns `true` if the token was found.
pub fn touch(&self, token: &str) -> bool {
if let Some(mut session) = self.sessions.get_mut(token) {
session.last_seen = epoch_secs();
true
} else {
false
}
}
pub fn increment_pending(&self, token: &str) {
if let Some(session) = self.sessions.get(token) {
session
.pending_notifications
.fetch_add(1, Ordering::Relaxed);
}
}
pub fn clear_pending(&self, token: &str) {
if let Some(session) = self.sessions.get(token) {
session.pending_notifications.store(0, Ordering::Relaxed);
}
}
pub fn get(&self, token: &str) -> Option<ActorSessionView> {
self.sessions
.get(token)
.map(|s| ActorSessionView::from(&*s))
}
pub fn list(&self) -> Vec<(String, ActorSessionView)> {
self.sessions
.iter()
.map(|entry| (entry.key().clone(), ActorSessionView::from(entry.value())))
.collect()
}
pub fn list_for_project(&self, project: &str) -> Vec<(String, ActorSessionView)> {
self.sessions
.iter()
.filter(|entry| entry.value().project == project)
.map(|entry| (entry.key().clone(), ActorSessionView::from(entry.value())))
.collect()
}
/// Tokens of all actors registered to a given project.
pub fn tokens_for_project(&self, project: &str) -> Vec<String> {
self.sessions
.iter()
.filter(|entry| entry.value().project == project)
.map(|entry| entry.key().clone())
.collect()
}
/// Sweep stale sessions. Local actors: `kill -0` liveness check.
/// Remote actors (CI): `last_seen` timeout.
/// Returns tokens of reaped sessions.
pub fn sweep_stale(&self) -> Vec<String> {
let now = epoch_secs();
let mut reaped = Vec::new();
// Collect tokens to remove (can't remove during iteration with DashMap).
let stale_tokens: Vec<String> = self
.sessions
.iter()
.filter(|entry| {
let session = entry.value();
if session.hostname == self.local_hostname {
!process_alive(session.pid)
} else {
now.saturating_sub(session.last_seen) > self.stale_timeout_secs
}
})
.map(|entry| entry.key().clone())
.collect();
for token in stale_tokens {
if self.sessions.remove(&token).is_some() {
debug!(token = %token, "reaped stale actor session");
if let Some(dir) = &self.persist_dir {
let _ = std::fs::remove_file(
dir.join(format!("{}.json", token_to_filename(&token))),
);
}
reaped.push(token);
}
}
if !reaped.is_empty() {
info!(count = reaped.len(), "sweep completed");
}
reaped
}
pub fn count(&self) -> usize {
self.sessions.len()
}
/// Collect all unique project names from registered actors.
pub fn active_projects(&self) -> HashSet<String> {
self.sessions
.iter()
.map(|entry| entry.value().project.clone())
.collect()
}
}
/// Serializable view of an `ActorSession` (AtomicU64 → u64).
#[derive(Debug, Clone, Serialize)]
pub struct ActorSessionView {
pub actor_type: String,
pub hostname: String,
pub pid: u32,
pub project: String,
pub role: String,
pub registered_at: u64,
pub last_seen: u64,
pub pending_notifications: u64,
pub preferences: serde_json::Value,
}
impl From<&ActorSession> for ActorSessionView {
fn from(s: &ActorSession) -> Self {
Self {
actor_type: s.actor_type.clone(),
hostname: s.hostname.clone(),
pid: s.pid,
project: s.project.clone(),
role: s.role.clone(),
registered_at: s.registered_at,
last_seen: s.last_seen,
pending_notifications: s.pending_notifications.load(Ordering::Relaxed),
preferences: s.preferences.clone(),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct RegisterRequest {
pub actor_type: String,
pub hostname: String,
pub pid: u32,
pub project: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub preferences: Option<serde_json::Value>,
}
/// Sanitise a token string into a safe filename component.
/// Replaces `:` and path-separator characters with `_`.
fn token_to_filename(token: &str) -> String {
token
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect()
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Check if a local process is alive via `kill -0`.
#[cfg(unix)]
fn process_alive(pid: u32) -> bool {
// SAFETY: kill(pid, 0) is a POSIX-standard liveness check.
// Signal 0 is explicitly defined as "no signal sent, but error checking
// is still performed" — it cannot affect the target process.
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
fn process_alive(_pid: u32) -> bool {
// Non-Unix: assume alive, rely on last_seen timeout.
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_format() {
assert_eq!(
make_token("developer", "macbook", 1234),
"developer:macbook:1234"
);
}
#[test]
fn register_and_deregister() {
let registry = ActorRegistry::new(120);
let token = registry.register(RegisterRequest {
actor_type: "developer".into(),
hostname: "testhost".into(),
pid: 9999,
project: "ontoref".into(),
..Default::default()
});
assert_eq!(registry.count(), 1);
assert!(registry.get(&token).is_some());
assert!(registry.deregister(&token));
assert_eq!(registry.count(), 0);
}
#[test]
fn list_for_project_filters() {
let registry = ActorRegistry::new(120);
registry.register(RegisterRequest {
actor_type: "developer".into(),
hostname: "h1".into(),
pid: 1,
project: "alpha".into(),
..Default::default()
});
registry.register(RegisterRequest {
actor_type: "agent".into(),
hostname: "h2".into(),
pid: 2,
project: "beta".into(),
..Default::default()
});
let alpha_actors = registry.list_for_project("alpha");
assert_eq!(alpha_actors.len(), 1);
assert_eq!(alpha_actors[0].1.actor_type, "developer");
}
#[test]
fn pending_notifications_lifecycle() {
let registry = ActorRegistry::new(120);
let token = registry.register(RegisterRequest {
actor_type: "agent".into(),
hostname: "h".into(),
pid: 42,
project: "test".into(),
..Default::default()
});
registry.increment_pending(&token);
registry.increment_pending(&token);
let view = registry.get(&token).expect("session exists");
assert_eq!(view.pending_notifications, 2);
registry.clear_pending(&token);
let view = registry.get(&token).expect("session exists");
assert_eq!(view.pending_notifications, 0);
}
#[cfg(unix)]
#[test]
fn sweep_reaps_dead_processes() {
let registry = ActorRegistry::new(120);
let local_host = registry.local_hostname.clone();
// PID 999999 almost certainly does not exist
registry.register(RegisterRequest {
actor_type: "agent".into(),
hostname: local_host,
pid: 999_999,
project: "test".into(),
..Default::default()
});
let reaped = registry.sweep_stale();
assert_eq!(reaped.len(), 1);
assert_eq!(registry.count(), 0);
}
}

View File

@ -0,0 +1,940 @@
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()
}
}

View File

@ -0,0 +1,302 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use dashmap::DashMap;
use serde_json::Value;
use tokio::sync::Mutex;
use tracing::debug;
use crate::error::{DaemonError, Result};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
path: PathBuf,
mtime: SystemTime,
import_path: Option<String>,
}
impl CacheKey {
fn new(path: &Path, mtime: SystemTime, import_path: Option<&str>) -> Self {
Self {
path: path.to_path_buf(),
mtime,
import_path: import_path.map(String::from),
}
}
}
struct CachedExport {
json: Value,
exported_at: Instant,
}
/// Caches `nickel export` subprocess results keyed by file path + mtime.
///
/// First call to a file invokes `nickel export` (~100ms). Subsequent calls
/// with unchanged mtime return the cached JSON (<1ms).
pub struct NclCache {
cache: DashMap<CacheKey, CachedExport>,
inflight: DashMap<PathBuf, Arc<Mutex<()>>>,
stats: CacheStats,
}
struct CacheStats {
hits: std::sync::atomic::AtomicU64,
misses: std::sync::atomic::AtomicU64,
}
impl CacheStats {
fn new() -> Self {
Self {
hits: std::sync::atomic::AtomicU64::new(0),
misses: std::sync::atomic::AtomicU64::new(0),
}
}
fn hit(&self) {
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn miss(&self) {
self.misses
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn hits(&self) -> u64 {
self.hits.load(std::sync::atomic::Ordering::Relaxed)
}
fn misses(&self) -> u64 {
self.misses.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl Default for NclCache {
fn default() -> Self {
Self::new()
}
}
impl NclCache {
pub fn new() -> Self {
Self {
cache: DashMap::new(),
inflight: DashMap::new(),
stats: CacheStats::new(),
}
}
/// Export a Nickel file to JSON. Returns cached result if mtime unchanged.
///
/// On cache miss, spawns the `nickel export` subprocess on a blocking
/// thread to avoid stalling the Tokio runtime.
/// Returns `(json, was_cache_hit)`.
pub async fn export(&self, path: &Path, import_path: Option<&str>) -> Result<(Value, bool)> {
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mtime = std::fs::metadata(&abs_path)
.map_err(|e| DaemonError::NclExport {
path: abs_path.display().to_string(),
reason: format!("stat failed: {e}"),
})?
.modified()
.map_err(|e| DaemonError::NclExport {
path: abs_path.display().to_string(),
reason: format!("mtime unavailable: {e}"),
})?;
let key = CacheKey::new(&abs_path, mtime, import_path);
if let Some(cached) = self.cache.get(&key) {
self.stats.hit();
debug!(
path = %abs_path.display(),
age_ms = cached.exported_at.elapsed().as_millis(),
"cache hit"
);
return Ok((cached.json.clone(), true));
}
debug!(path = %abs_path.display(), "cache miss — acquiring inflight lock");
// Acquire per-path lock to coalesce concurrent misses for the same file.
let lock = self
.inflight
.entry(abs_path.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let _guard = lock.lock().await;
// Re-check cache after acquiring the lock — another task may have filled it.
// Return as a hit: the subprocess ran, but not on our behalf.
if let Some(cached) = self.cache.get(&key) {
self.stats.hit();
return Ok((cached.json.clone(), true));
}
// Confirmed miss — no cached result exists, we will invoke the subprocess.
self.stats.miss();
let export_path = abs_path.clone();
let export_ip = import_path.map(String::from);
let result = tokio::task::spawn_blocking(move || {
run_nickel_export(&export_path, export_ip.as_deref())
})
.await
.map_err(|e| DaemonError::NclExport {
path: abs_path.display().to_string(),
reason: format!("spawn_blocking join failed: {e}"),
});
// On error: release inflight slot so future attempts can retry.
// On success: insert into cache BEFORE releasing inflight, so concurrent
// waiters find the result on their re-check instead of re-running the export.
match result {
Err(e) => {
drop(_guard);
self.inflight.remove(&abs_path);
Err(e)
}
Ok(Err(e)) => {
drop(_guard);
self.inflight.remove(&abs_path);
Err(e)
}
Ok(Ok(json)) => {
self.cache.insert(
key,
CachedExport {
json: json.clone(),
exported_at: Instant::now(),
},
);
// Release inflight AFTER cache is populated — concurrent waiters
// will hit the cache on their re-check.
drop(_guard);
self.inflight.remove(&abs_path);
Ok((json, false))
}
}
}
/// Invalidate all cache entries whose path starts with the given prefix.
pub fn invalidate_prefix(&self, prefix: &Path) {
let before = self.cache.len();
self.cache.retain(|k, _| !k.path.starts_with(prefix));
let evicted = before - self.cache.len();
if evicted > 0 {
debug!(prefix = %prefix.display(), evicted, "cache invalidation");
}
}
/// Invalidate a specific file path (all mtimes).
///
/// Paths from the watcher and API are always resolved to absolute before
/// calling this. The `debug_assert` catches programming errors in tests.
pub fn invalidate_file(&self, path: &Path) {
debug_assert!(path.is_absolute(), "invalidate_file expects absolute path");
self.cache.retain(|k, _| k.path != path);
}
/// Drop all cached entries.
pub fn invalidate_all(&self) {
let count = self.cache.len();
self.cache.clear();
debug!(count, "full cache invalidation");
}
pub fn len(&self) -> usize {
self.cache.len()
}
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
pub fn hit_count(&self) -> u64 {
self.stats.hits()
}
pub fn miss_count(&self) -> u64 {
self.stats.misses()
}
}
/// Invoke `nickel export --format json` as a subprocess.
fn run_nickel_export(path: &Path, import_path: Option<&str>) -> Result<Value> {
let mut cmd = Command::new("nickel");
cmd.args(["export", "--format", "json"]).arg(path);
if let Some(ip) = import_path {
cmd.env("NICKEL_IMPORT_PATH", ip);
} else {
cmd.env_remove("NICKEL_IMPORT_PATH");
}
let output = cmd.output().map_err(|e| DaemonError::NclExport {
path: path.display().to_string(),
reason: format!("spawn failed: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DaemonError::NclExport {
path: path.display().to_string(),
reason: stderr.trim().to_string(),
});
}
let json: Value =
serde_json::from_slice(&output.stdout).map_err(|e| DaemonError::NclExport {
path: path.display().to_string(),
reason: format!("JSON parse failed: {e}"),
})?;
Ok(json)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_equality() {
let t = SystemTime::now();
let k1 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1"));
let k2 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1"));
let k3 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path2"));
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[test]
fn invalidation_by_prefix() {
let cache = NclCache::new();
let t = SystemTime::now();
// Insert fake entries directly
let key1 = CacheKey::new(Path::new("/project/.ontology/core.ncl"), t, None);
let key2 = CacheKey::new(Path::new("/project/.ontology/state.ncl"), t, None);
let key3 = CacheKey::new(Path::new("/project/adrs/adr-001.ncl"), t, None);
for key in [key1, key2, key3] {
cache.cache.insert(
key,
CachedExport {
json: Value::Null,
exported_at: Instant::now(),
},
);
}
assert_eq!(cache.len(), 3);
cache.invalidate_prefix(Path::new("/project/.ontology"));
assert_eq!(cache.len(), 1);
}
}

View File

@ -0,0 +1,25 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DaemonError {
#[error("NCL export failed for {path}: {reason}")]
NclExport { path: String, reason: String },
#[error("File watcher error: {0}")]
Watcher(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Configuration error: {0}")]
Config(String),
#[cfg(feature = "db")]
#[error("Database error: {0}")]
Db(#[from] stratum_db::DbError),
}
pub type Result<T> = std::result::Result<T, DaemonError>;

View File

@ -0,0 +1,19 @@
pub mod actors;
pub mod api;
pub mod cache;
pub mod error;
#[cfg(feature = "mcp")]
pub mod mcp;
#[cfg(feature = "nats")]
pub mod nats;
pub mod notifications;
#[cfg(feature = "ui")]
pub mod registry;
pub mod search;
#[cfg(feature = "db")]
pub mod seed;
#[cfg(feature = "ui")]
pub mod session;
#[cfg(feature = "ui")]
pub mod ui;
pub mod watcher;

View File

@ -0,0 +1,924 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use clap::Parser;
use ontoref_daemon::actors::ActorRegistry;
use ontoref_daemon::api::{self, AppState};
use ontoref_daemon::cache::NclCache;
use ontoref_daemon::notifications::NotificationStore;
use ontoref_daemon::watcher::{FileWatcher, WatcherDeps};
use tokio::net::TcpListener;
use tokio::sync::watch;
use tower_http::trace::TraceLayer;
use tracing::{error, info, warn};
/// Load daemon config from .ontoref/config.ncl and override CLI defaults.
/// Returns the resolved NICKEL_IMPORT_PATH from config (colon-separated).
fn load_config_overrides(cli: &mut Cli) -> Option<String> {
let config_path = cli.project_root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return None;
}
let output = match Command::new("nickel")
.arg("export")
.arg(&config_path)
.output()
{
Ok(o) => o,
Err(e) => {
warn!(error = %e, path = %config_path.display(), "failed to read config");
return None;
}
};
if !output.status.success() {
warn!("nickel export failed for config");
return None;
}
let config_json: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "failed to parse config JSON");
return None;
}
};
// Extract daemon config
if let Some(daemon) = config_json.get("daemon").and_then(|d| d.as_object()) {
if let Some(port) = daemon.get("port").and_then(|p| p.as_u64()) {
cli.port = port as u16;
}
if let Some(timeout) = daemon.get("idle_timeout").and_then(|t| t.as_u64()) {
cli.idle_timeout = timeout;
}
if let Some(interval) = daemon.get("invalidation_interval").and_then(|i| i.as_u64()) {
cli.invalidation_interval = interval;
}
if let Some(sweep) = daemon.get("actor_sweep_interval").and_then(|s| s.as_u64()) {
cli.actor_sweep_interval = sweep;
}
if let Some(stale) = daemon.get("actor_stale_timeout").and_then(|s| s.as_u64()) {
cli.actor_stale_timeout = stale;
}
if let Some(max) = daemon.get("max_notifications").and_then(|m| m.as_u64()) {
cli.max_notifications = max as usize;
}
if let Some(ack_dirs) = daemon
.get("notification_ack_required")
.and_then(|a| a.as_array())
{
cli.notification_ack_required = ack_dirs
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
// Extract db config (if db feature enabled)
#[cfg(feature = "db")]
{
if let Some(db) = config_json.get("db").and_then(|d| d.as_object()) {
if let Some(url) = db.get("url").and_then(|u| u.as_str()) {
if !url.is_empty() {
cli.db_url = Some(url.to_string());
}
}
if let Some(ns) = db.get("namespace").and_then(|n| n.as_str()) {
if !ns.is_empty() {
cli.db_namespace = Some(ns.to_string());
}
}
if let Some(user) = db.get("username").and_then(|u| u.as_str()) {
if !user.is_empty() {
cli.db_username = user.to_string();
}
}
if let Some(pass) = db.get("password").and_then(|p| p.as_str()) {
if !pass.is_empty() {
cli.db_password = pass.to_string();
}
}
}
}
// Env var overrides for DB credentials — not persisted to disk.
#[cfg(feature = "db")]
{
if let Ok(user) = std::env::var("ONTOREF_DB_USERNAME") {
if !user.is_empty() {
cli.db_username = user;
}
}
if let Ok(pass) = std::env::var("ONTOREF_DB_PASSWORD") {
if !pass.is_empty() {
cli.db_password = pass;
}
}
}
// UI config section — only populates fields not already set via CLI.
#[cfg(feature = "ui")]
apply_ui_config(cli, &config_json);
info!("config loaded from {}", config_path.display());
let import_path = config_json
.get("nickel_import_paths")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(":")
})
.filter(|s| !s.is_empty());
import_path
}
#[derive(Parser)]
#[command(name = "ontoref-daemon", about = "Ontoref cache daemon")]
struct Cli {
/// Project root directory (where .ontoref/config.ncl lives)
#[arg(long, default_value = ".")]
project_root: PathBuf,
/// Stratumiops root directory (for shared schemas/modules)
#[arg(long)]
ontoref_root: Option<PathBuf>,
/// HTTP listen port (overridden by config if present)
#[arg(long, default_value_t = 7891)]
port: u16,
/// Seconds of inactivity before auto-shutdown (overridden by config)
#[arg(long, default_value_t = 1800)]
idle_timeout: u64,
/// Full cache invalidation interval in seconds (overridden by config)
#[arg(long, default_value_t = 60)]
invalidation_interval: u64,
/// PID file path
#[arg(long)]
pid_file: Option<PathBuf>,
/// Actor sweep interval in seconds (reap stale sessions)
#[arg(long, default_value_t = 30)]
actor_sweep_interval: u64,
/// Seconds before a remote actor (no `kill -0` check) is considered stale
#[arg(long, default_value_t = 120)]
actor_stale_timeout: u64,
/// Maximum notifications to retain per project (ring buffer)
#[arg(long, default_value_t = 1000)]
max_notifications: usize,
/// Directories requiring notification acknowledgment before commit
#[arg(long, value_delimiter = ',')]
notification_ack_required: Vec<String>,
/// Directory containing Tera HTML templates for the web UI
#[cfg(feature = "ui")]
#[arg(long)]
templates_dir: Option<PathBuf>,
/// Directory to serve as /public (CSS, JS assets)
#[cfg(feature = "ui")]
#[arg(long)]
public_dir: Option<PathBuf>,
/// Path to registry.toml for multi-project mode
#[cfg(feature = "ui")]
#[arg(long)]
registry: Option<PathBuf>,
/// Hash a password with argon2id and print the PHC string, then exit
#[cfg(feature = "ui")]
#[arg(long, value_name = "PASSWORD")]
hash_password: Option<String>,
/// Run as an MCP server over stdin/stdout (for Claude Desktop, Cursor,
/// etc.). No HTTP server is started in this mode.
#[cfg(feature = "mcp")]
#[arg(long)]
mcp_stdio: bool,
/// TLS certificate file (PEM). Enables HTTPS when combined with --tls-key
#[cfg(feature = "tls")]
#[arg(long)]
tls_cert: Option<PathBuf>,
/// TLS private key file (PEM). Enables HTTPS when combined with --tls-cert
#[cfg(feature = "tls")]
#[arg(long)]
tls_key: Option<PathBuf>,
/// SurrealDB remote WebSocket URL (e.g., ws://127.0.0.1:8000)
#[cfg(feature = "db")]
#[arg(long)]
db_url: Option<String>,
/// SurrealDB namespace for this daemon instance
#[cfg(feature = "db")]
#[arg(long)]
db_namespace: Option<String>,
/// SurrealDB username
#[cfg(feature = "db")]
#[arg(long, default_value = "root")]
db_username: String,
/// SurrealDB password
#[cfg(feature = "db")]
#[arg(long, default_value = "root")]
db_password: String,
}
#[tokio::main]
async fn main() {
// Parse CLI first so we can redirect logs to stderr in stdio MCP mode.
// In stdio mode stdout is the MCP JSON-RPC transport; any log line there
// corrupts the framing and the client silently drops or errors.
let mut cli = Cli::parse();
#[cfg(feature = "mcp")]
let use_stderr = cli.mcp_stdio;
#[cfg(not(feature = "mcp"))]
let use_stderr = false;
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "ontoref_daemon=info,tower_http=debug".into());
if use_stderr {
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.init();
} else {
tracing_subscriber::fmt().with_env_filter(env_filter).init();
}
#[cfg(feature = "ui")]
if let Some(ref password) = cli.hash_password {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash failed")
.to_string();
println!("{hash}");
return;
}
// Read config from project's .ontoref/config.ncl and override CLI defaults
let nickel_import_path = load_config_overrides(&mut cli);
let project_root = match cli.project_root.canonicalize() {
Ok(p) if p.is_dir() => p,
Ok(p) => {
error!(
path = %p.display(),
"project_root is not a directory — aborting"
);
std::process::exit(1);
}
Err(e) => {
error!(
path = %cli.project_root.display(),
error = %e,
"project_root does not exist or is inaccessible — aborting"
);
std::process::exit(1);
}
};
info!(
project_root = %project_root.display(),
port = cli.port,
idle_timeout = cli.idle_timeout,
"starting ontoref-daemon"
);
let cache = Arc::new(NclCache::new());
#[cfg(feature = "ui")]
let registry: Option<Arc<ontoref_daemon::registry::ProjectRegistry>> = {
let reg_path: Option<std::path::PathBuf> = cli.registry.clone().or_else(|| {
let candidate = project_root.join(".ontoref").join("registry.toml");
if candidate.exists() {
info!(path = %candidate.display(), "auto-discovered registry.toml");
Some(candidate)
} else {
None
}
});
if let Some(ref reg_path) = reg_path {
match ontoref_daemon::registry::ProjectRegistry::load(
reg_path,
cli.actor_stale_timeout,
cli.max_notifications,
) {
Ok(r) => {
info!(path = %reg_path.display(), projects = r.count(), "registry loaded");
Some(Arc::new(r))
}
Err(e) => {
error!(error = %e, path = %reg_path.display(), "failed to load registry — aborting");
std::process::exit(1);
}
}
} else {
None
}
};
#[cfg(feature = "ui")]
let sessions = Arc::new(ontoref_daemon::session::SessionStore::new());
let actors = Arc::new(ActorRegistry::new(cli.actor_stale_timeout));
// Initialize Tera template engine from the configured templates directory.
#[cfg(feature = "ui")]
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> = {
if let Some(ref tdir) = cli.templates_dir {
let glob = format!("{}/**/*.html", tdir.display());
match tera::Tera::new(&glob) {
Ok(t) => {
info!(templates_dir = %tdir.display(), "Tera templates loaded");
Some(Arc::new(tokio::sync::RwLock::new(t)))
}
Err(e) => {
warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled");
None
}
}
} else {
info!("--templates-dir not set — web UI disabled");
None
}
};
// Notification store with configurable capacity and ack requirements
let ack_required = if cli.notification_ack_required.is_empty() {
vec![".ontology".to_string(), "adrs".to_string()]
} else {
cli.notification_ack_required.clone()
};
let notifications = Arc::new(NotificationStore::new(cli.max_notifications, ack_required));
// Optional DB connection with health check
#[cfg(feature = "db")]
let db = {
if cli.db_url.is_some() {
info!(url = %cli.db_url.as_deref().unwrap_or(""), "connecting to SurrealDB...");
connect_db(&cli).await
} else {
info!("SurrealDB not configured — running cache-only");
None
}
};
// Seed ontology tables from local NCL files → DB projection.
#[cfg(feature = "db")]
{
if let Some(ref db) = db {
info!("seeding ontology tables from local files...");
ontoref_daemon::seed::seed_ontology(
db,
&project_root,
&cache,
nickel_import_path.as_deref(),
)
.await;
}
}
// Initialize NATS publisher
#[cfg(feature = "nats")]
info!("connecting to NATS...");
#[cfg(feature = "nats")]
let nats_publisher = {
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
match ontoref_daemon::nats::NatsPublisher::connect(
&project_root.join(".ontoref").join("config.ncl"),
project_name,
cli.port,
)
.await
{
Ok(Some(pub_)) => {
info!("NATS publisher initialized");
Some(Arc::new(pub_))
}
Ok(None) => {
info!("NATS disabled or unavailable");
None
}
Err(e) => {
warn!(error = %e, "NATS initialization failed");
None
}
}
};
// Start file watcher — after DB so it can re-seed on changes
let watcher_deps = WatcherDeps {
#[cfg(feature = "db")]
db: db.clone(),
import_path: nickel_import_path.clone(),
notifications: Arc::clone(&notifications),
actors: Arc::clone(&actors),
#[cfg(feature = "nats")]
nats: nats_publisher.clone(),
};
let _watcher = match FileWatcher::start(
&project_root,
Arc::clone(&cache),
cli.invalidation_interval,
watcher_deps,
) {
Ok(w) => Some(w),
Err(e) => {
error!(error = %e, "file watcher failed to start — running without auto-invalidation");
None
}
};
let epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let last_activity = Arc::new(AtomicU64::new(epoch_secs));
// Capture display values before they are moved into AppState.
#[cfg(feature = "ui")]
let project_root_str = project_root.display().to_string();
#[cfg(feature = "ui")]
let ui_startup: Option<(String, String)> = cli.templates_dir.as_ref().map(|tdir| {
let public = cli
.public_dir
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "".to_string());
(tdir.display().to_string(), public)
});
let state = {
#[cfg(feature = "nats")]
{
AppState {
cache,
project_root,
ontoref_root: cli.ontoref_root,
started_at: Instant::now(),
last_activity: Arc::clone(&last_activity),
actors: Arc::clone(&actors),
notifications: Arc::clone(&notifications),
nickel_import_path: nickel_import_path.clone(),
#[cfg(feature = "db")]
db,
nats: nats_publisher.clone(),
#[cfg(feature = "ui")]
tera: tera_instance,
#[cfg(feature = "ui")]
public_dir: cli.public_dir,
#[cfg(feature = "ui")]
registry: registry.clone(),
#[cfg(feature = "ui")]
sessions: Arc::clone(&sessions),
#[cfg(feature = "mcp")]
mcp_current_project: Arc::new(std::sync::RwLock::new(None)),
}
}
#[cfg(not(feature = "nats"))]
{
AppState {
cache,
project_root,
ontoref_root: cli.ontoref_root,
started_at: Instant::now(),
last_activity: Arc::clone(&last_activity),
actors: Arc::clone(&actors),
notifications: Arc::clone(&notifications),
nickel_import_path: nickel_import_path.clone(),
#[cfg(feature = "db")]
db,
#[cfg(feature = "ui")]
tera: tera_instance,
#[cfg(feature = "ui")]
public_dir: cli.public_dir,
#[cfg(feature = "ui")]
registry: registry.clone(),
#[cfg(feature = "ui")]
sessions: Arc::clone(&sessions),
#[cfg(feature = "mcp")]
mcp_current_project: Arc::new(std::sync::RwLock::new(None)),
}
}
};
// Start template hot-reload watcher if templates dir is configured.
#[cfg(feature = "ui")]
let _template_watcher = {
if let (Some(ref tdir), Some(ref tera)) = (&cli.templates_dir, &state.tera) {
match ontoref_daemon::ui::TemplateWatcher::start(tdir, Arc::clone(tera)) {
Ok(w) => Some(w),
Err(e) => {
warn!(error = %e, "template watcher failed to start — hot-reload disabled");
None
}
}
} else {
None
}
};
// Start passive drift observer (scan+diff, no apply).
#[cfg(feature = "ui")]
let _drift_watcher = {
let project_name = state.default_project_name();
let notif_store = Arc::clone(&state.notifications);
match ontoref_daemon::ui::DriftWatcher::start(
&state.project_root,
project_name,
notif_store,
) {
Ok(w) => {
info!("drift watcher started");
Some(w)
}
Err(e) => {
warn!(error = %e, "drift watcher failed to start");
None
}
}
};
// MCP stdio mode — skips HTTP entirely; serves stdin/stdout to AI client.
#[cfg(feature = "mcp")]
if cli.mcp_stdio {
if let Err(e) = ontoref_daemon::mcp::serve_stdio(state).await {
error!(error = %e, "MCP stdio server error");
std::process::exit(1);
}
return;
}
let app = api::router(state).layer(TraceLayer::new_for_http());
let addr = SocketAddr::from(([127, 0, 0, 1], cli.port));
let listener = TcpListener::bind(addr).await.unwrap_or_else(|e| {
error!(addr = %addr, error = %e, "failed to bind");
std::process::exit(1);
});
// Write PID file only after successful bind
if let Some(ref pid_path) = cli.pid_file {
if let Err(e) = write_pid_file(pid_path) {
error!(path = %pid_path.display(), error = %e, "failed to write PID file");
}
}
info!(addr = %addr, "listening");
#[cfg(feature = "ui")]
if let Some((ref tdir, ref public)) = ui_startup {
#[cfg(feature = "tls")]
let scheme = if cli.tls_cert.is_some() && cli.tls_key.is_some() {
"https"
} else {
"http"
};
#[cfg(not(feature = "tls"))]
let scheme = "http";
info!(
url = %format!("{scheme}://{addr}/ui/"),
project_root = %project_root_str,
templates_dir = %tdir,
public_dir = %public,
"web UI available"
);
}
// Publish daemon.started event
#[cfg(feature = "nats")]
{
if let Some(ref nats) = nats_publisher {
if let Err(e) = nats.publish_started().await {
warn!(error = %e, "failed to publish daemon.started event");
}
}
}
// Spawn NATS event polling handler if enabled
#[cfg(feature = "nats")]
let _nats_handler = if let Some(ref nats) = nats_publisher {
let nats_clone = Arc::clone(nats);
let handle = tokio::spawn(async move {
handle_nats_events(nats_clone).await;
});
Some(handle)
} else {
None
};
// Spawn actor sweep task — reaps stale sessions periodically
let sweep_actors = Arc::clone(&actors);
#[cfg(feature = "nats")]
let sweep_nats = nats_publisher.clone();
let sweep_interval = cli.actor_sweep_interval;
let _sweep_task = tokio::spawn(async move {
actor_sweep_loop(
sweep_actors,
sweep_interval,
#[cfg(feature = "nats")]
sweep_nats,
)
.await;
});
// Idle timeout: spawn a watchdog that signals shutdown via watch channel.
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
if cli.idle_timeout > 0 {
let idle_secs = cli.idle_timeout;
let activity = Arc::clone(&last_activity);
tokio::spawn(idle_watchdog(activity, idle_secs, shutdown_tx));
}
// TLS serve path — takes priority when cert + key are both configured.
#[cfg(feature = "tls")]
if let (Some(cert), Some(key)) = (&cli.tls_cert, &cli.tls_key) {
let tls_config = match axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key).await
{
Ok(c) => c,
Err(e) => {
error!(error = %e, cert = %cert.display(), key = %key.display(),
"TLS config failed — aborting");
std::process::exit(1);
}
};
let handle = axum_server::Handle::new();
let shutdown_handle = handle.clone();
let mut tls_rx = shutdown_rx.clone();
tokio::spawn(async move {
let _ = tls_rx.wait_for(|&v| v).await;
shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
});
let std_listener = listener.into_std().unwrap_or_else(|e| {
error!(error = %e, "listener conversion failed");
std::process::exit(1);
});
#[cfg(feature = "nats")]
let tls_start = Instant::now();
if let Err(e) = axum_server::from_tcp_rustls(std_listener, tls_config)
.handle(handle)
.serve(app.into_make_service())
.await
{
error!(error = %e, "TLS server error");
}
#[cfg(feature = "nats")]
if let Some(ref nats) = nats_publisher {
let _ = nats.publish_stopped(tls_start.elapsed().as_secs()).await;
}
if let Some(ref pid_path) = cli.pid_file {
let _ = std::fs::remove_file(pid_path);
}
return;
}
// Plain HTTP serve path.
#[cfg(feature = "nats")]
let startup_instant = Instant::now();
let graceful = async move {
let _ = shutdown_rx.wait_for(|&v| v).await;
};
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(graceful)
.await
{
error!(error = %e, "server error");
}
// Publish daemon.stopped event on graceful shutdown
#[cfg(feature = "nats")]
{
if let Some(ref nats) = nats_publisher {
let uptime_secs = startup_instant.elapsed().as_secs();
if let Err(e) = nats.publish_stopped(uptime_secs).await {
warn!(error = %e, "failed to publish daemon.stopped event");
}
}
}
// Cleanup PID file
if let Some(ref pid_path) = cli.pid_file {
let _ = std::fs::remove_file(pid_path);
}
}
async fn idle_watchdog(activity: Arc<AtomicU64>, idle_secs: u64, shutdown: watch::Sender<bool>) {
let check_interval = std::time::Duration::from_secs(30);
loop {
tokio::time::sleep(check_interval).await;
let last = activity.load(Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let idle = now.saturating_sub(last);
if idle >= idle_secs {
info!(idle, idle_secs, "idle timeout reached — shutting down");
let _ = shutdown.send(true);
return;
}
}
}
/// Periodic sweep of stale actor sessions.
/// Local actors: checked via `kill -0 <pid>`. Remote actors: `last_seen`
/// timeout. Publishes `actor.deregistered` events via NATS for reaped sessions.
async fn actor_sweep_loop(
actors: Arc<ActorRegistry>,
interval_secs: u64,
#[cfg(feature = "nats")] nats: Option<Arc<ontoref_daemon::nats::NatsPublisher>>,
) {
let interval = std::time::Duration::from_secs(interval_secs);
loop {
tokio::time::sleep(interval).await;
let reaped = actors.sweep_stale();
#[cfg(feature = "nats")]
publish_reaped_actors(&nats, &reaped).await;
#[cfg(not(feature = "nats"))]
let _ = reaped;
}
}
#[cfg(feature = "nats")]
async fn publish_reaped_actors(
nats: &Option<Arc<ontoref_daemon::nats::NatsPublisher>>,
reaped: &[String],
) {
let Some(ref nats) = nats else { return };
for token in reaped {
if let Err(e) = nats.publish_actor_deregistered(token, "stale_sweep").await {
warn!(error = %e, token = %token, "failed to publish actor.deregistered");
}
}
}
#[cfg(feature = "db")]
async fn connect_db(cli: &Cli) -> Option<Arc<stratum_db::StratumDb>> {
let db_url = cli.db_url.as_ref()?;
let namespace = cli.db_namespace.as_deref().unwrap_or("ontoref");
let connect_timeout = std::time::Duration::from_secs(5);
let db = match tokio::time::timeout(
connect_timeout,
stratum_db::StratumDb::connect_remote(
db_url,
namespace,
"daemon",
&cli.db_username,
&cli.db_password,
),
)
.await
{
Ok(Ok(db)) => db,
Ok(Err(e)) => {
error!(error = %e, "SurrealDB connection failed — running without persistence");
return None;
}
Err(_) => {
error!(url = %db_url, "SurrealDB connection timed out (5s) — running without persistence");
return None;
}
};
let health_timeout = std::time::Duration::from_secs(5);
match tokio::time::timeout(health_timeout, db.health_check()).await {
Ok(Ok(())) => {
info!(url = %db_url, namespace = %namespace, "SurrealDB connected and healthy");
}
Ok(Err(e)) => {
error!(error = %e, "SurrealDB health check failed — running without persistence");
return None;
}
Err(_) => {
error!("SurrealDB health check timed out — running without persistence");
return None;
}
}
if let Err(e) = db.initialize_tables().await {
warn!(error = %e, "table initialization failed — proceeding with cache only");
return None;
}
info!("Level 1 ontology tables initialized");
Some(Arc::new(db))
}
#[cfg(feature = "ui")]
fn apply_ui_config(cli: &mut Cli, config: &serde_json::Value) {
let Some(ui) = config.get("ui").and_then(|u| u.as_object()) else {
return;
};
if cli.templates_dir.is_none() {
let dir = ui
.get("templates_dir")
.and_then(|d| d.as_str())
.unwrap_or("");
if !dir.is_empty() {
cli.templates_dir = Some(cli.project_root.join(dir));
}
}
if cli.public_dir.is_none() {
let dir = ui.get("public_dir").and_then(|d| d.as_str()).unwrap_or("");
if !dir.is_empty() {
cli.public_dir = Some(cli.project_root.join(dir));
}
}
#[cfg(feature = "tls")]
{
if cli.tls_cert.is_none() {
let p = ui.get("tls_cert").and_then(|d| d.as_str()).unwrap_or("");
if !p.is_empty() {
cli.tls_cert = Some(cli.project_root.join(p));
}
}
if cli.tls_key.is_none() {
let p = ui.get("tls_key").and_then(|d| d.as_str()).unwrap_or("");
if !p.is_empty() {
cli.tls_key = Some(cli.project_root.join(p));
}
}
}
}
fn write_pid_file(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, std::process::id().to_string())
}
/// Poll NATS JetStream for incoming events.
#[cfg(feature = "nats")]
async fn handle_nats_events(nats: Arc<ontoref_daemon::nats::NatsPublisher>) {
loop {
let events = match nats.pull_events(10).await {
Ok(ev) => ev,
Err(e) => {
warn!("NATS poll error: {e}");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
};
for (subject, payload) in events {
dispatch_nats_event(&subject, &payload);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
#[cfg(feature = "nats")]
fn dispatch_nats_event(subject: &str, payload: &serde_json::Value) {
use ontoref_daemon::nats::NatsPublisher;
if subject != "ecosystem.reflection.request" {
return;
}
if let Some((mode_id, _params)) = NatsPublisher::parse_reflection_request(payload) {
info!(mode_id = %mode_id, "received reflection.request via JetStream");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,433 @@
#[cfg(feature = "nats")]
use std::path::PathBuf;
#[cfg(feature = "nats")]
use std::process::Command;
#[cfg(feature = "nats")]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "nats")]
use anyhow::{anyhow, Result};
#[cfg(feature = "nats")]
use bytes::Bytes;
#[cfg(feature = "nats")]
use platform_nats::{EventStream, NatsConnectionConfig, TopologyConfig};
#[cfg(feature = "nats")]
use serde_json::{json, Value};
#[cfg(feature = "nats")]
use tracing::{info, warn};
/// NATS JetStream publisher for daemon lifecycle events.
///
/// Uses platform-nats `connect_client()` — connection + auth only.
/// Stream/consumer topology comes entirely from `nats/streams.json` (or
/// equivalent), referenced by `nats_events.streams_config` in
/// `.ontoref/config.ncl`.
///
/// Gracefully degrades if NATS is unavailable or disabled in config.
#[cfg(feature = "nats")]
pub struct NatsPublisher {
stream: EventStream,
project: String,
port: u16,
}
#[cfg(feature = "nats")]
impl NatsPublisher {
/// Connect to NATS JetStream, apply topology from config, bind consumer.
/// Reads `nats_events` section from `.ontoref/config.ncl`.
/// Returns `Ok(None)` if disabled or unavailable (graceful degradation).
pub async fn connect(
config_path: &PathBuf,
project: String,
port: u16,
) -> Result<Option<Self>> {
let config = load_nats_config(config_path)?;
let nats_section = match config.get("nats_events") {
Some(section) => section,
None => return Ok(None),
};
let enabled = nats_section
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
if !enabled {
return Ok(None);
}
let url = nats_section
.get("url")
.and_then(|u| u.as_str())
.unwrap_or("nats://localhost:4222")
.to_string();
let nkey_seed = nats_section
.get("nkey_seed")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let require_signed = nats_section
.get("require_signed_messages")
.and_then(|r| r.as_bool())
.unwrap_or(false);
let trusted_nkeys = nats_section
.get("trusted_nkeys")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let conn_cfg = NatsConnectionConfig {
url: url.clone(),
nkey_seed,
require_signed_messages: require_signed,
trusted_nkeys,
};
let mut stream = match tokio::time::timeout(
std::time::Duration::from_secs(3),
EventStream::connect_client(&conn_cfg),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
warn!(error = %e, url = %url, "NATS connection failed — running without events");
return Ok(None);
}
Err(_) => {
warn!(url = %url, "NATS connection timed out — running without events");
return Ok(None);
}
};
info!(url = %url, "NATS connected");
// Apply topology from streams_config file declared in project config.
// Fallback: NATS_STREAMS_CONFIG env var.
let topology_path = nats_section
.get("streams_config")
.and_then(|s| s.as_str())
.map(std::path::PathBuf::from);
let topology = match TopologyConfig::load(topology_path.as_deref()) {
Ok(Some(t)) => Some(t),
Ok(None) => {
warn!("no topology config found — publish-only mode (no consumer bound)");
None
}
Err(e) => {
warn!(error = %e, "topology config load failed — publish-only mode");
None
}
};
if let Some(ref topo) = topology {
match stream.apply_topology(topo).await {
Ok(report) => info!(
streams = report.streams_applied,
consumers = report.consumers_applied,
"topology applied"
),
Err(e) => warn!(error = %e, "topology apply failed — publish-only mode"),
}
}
// Bind to daemon consumer on the first stream (convention: "daemon-{project}").
if let Some(ref topo) = topology {
if let Some(first_stream) = topo.streams.first() {
let consumer_name = format!("daemon-{project}");
if let Err(e) = stream
.bind_consumer(&first_stream.name, &consumer_name)
.await
{
warn!(error = %e, "failed to bind daemon consumer — pull_events disabled");
}
}
}
Ok(Some(Self {
stream,
project,
port,
}))
}
pub async fn publish_started(&self) -> Result<()> {
let payload = json!({
"project": self.project,
"port": self.port,
"timestamp": iso8601_now(),
});
self.stream
.publish("ecosystem.daemon.started", Bytes::from(payload.to_string()))
.await?;
info!(port = self.port, "published daemon.started");
Ok(())
}
pub async fn publish_stopped(&self, uptime_secs: u64) -> Result<()> {
let payload = json!({
"project": self.project,
"uptime_seconds": uptime_secs,
"timestamp": iso8601_now(),
});
self.stream
.publish("ecosystem.daemon.stopped", Bytes::from(payload.to_string()))
.await?;
info!(uptime = uptime_secs, "published daemon.stopped");
Ok(())
}
/// Publish a file change notification for a specific project and event
/// type.
pub async fn publish_notification(
&self,
project: &str,
event: &crate::notifications::NotificationEvent,
files: &[String],
) -> Result<()> {
let subject = format!("ecosystem.{project}.{}", event.nats_suffix());
let payload = json!({
"project": project,
"event": format!("{event:?}"),
"files": files,
"timestamp": iso8601_now(),
});
self.stream
.publish(&subject, Bytes::from(payload.to_string()))
.await?;
info!(subject = %subject, files = files.len(), "published notification");
Ok(())
}
/// Publish an actor registration event.
pub async fn publish_actor_registered(
&self,
token: &str,
actor_type: &str,
project: &str,
) -> Result<()> {
let payload = json!({
"token": token,
"actor_type": actor_type,
"project": project,
"timestamp": iso8601_now(),
});
self.stream
.publish(
"ecosystem.actor.registered",
Bytes::from(payload.to_string()),
)
.await?;
info!(token = %token, "published actor.registered");
Ok(())
}
/// Publish an actor deregistration event.
pub async fn publish_actor_deregistered(&self, token: &str, reason: &str) -> Result<()> {
let payload = json!({
"token": token,
"reason": reason,
"timestamp": iso8601_now(),
});
self.stream
.publish(
"ecosystem.actor.deregistered",
Bytes::from(payload.to_string()),
)
.await?;
info!(token = %token, reason = %reason, "published actor.deregistered");
Ok(())
}
/// Publish a file change event for a project (general, not requiring ack).
pub async fn publish_file_changed(&self, project: &str, files: &[String]) -> Result<()> {
let subject = format!("ecosystem.{project}.file.changed");
let payload = json!({
"project": project,
"files": files,
"timestamp": iso8601_now(),
});
self.stream
.publish(&subject, Bytes::from(payload.to_string()))
.await?;
Ok(())
}
pub async fn publish_cache_invalidated(&self, reason: &str) -> Result<()> {
let payload = json!({
"project": self.project,
"reason": reason,
"affected_keys": [],
"timestamp": iso8601_now(),
});
self.stream
.publish(
"ecosystem.daemon.cache.invalidated",
Bytes::from(payload.to_string()),
)
.await?;
info!(reason = %reason, "published daemon.cache.invalidated");
Ok(())
}
/// Pull pending messages from the bound JetStream consumer.
/// Returns (subject, parsed_json) for each valid message.
/// Returns empty vec if no consumer is bound (publish-only mode).
pub async fn pull_events(&self, max_msgs: usize) -> Result<Vec<(String, Value)>> {
let batch = match self.stream.pull_batch(max_msgs).await {
Ok(b) => b,
Err(e) => {
let msg = e.to_string();
if msg.contains("no consumer bound") {
return Ok(Vec::new());
}
return Err(e);
}
};
let mut events = Vec::with_capacity(batch.len());
for (subject, payload_bytes, msg) in batch {
match serde_json::from_slice::<Value>(&payload_bytes) {
Ok(json) => events.push((subject, json)),
Err(e) => {
warn!(error = %e, subject = %subject, "invalid JSON in NATS message — skipping");
}
}
let _ = msg.ack().await;
}
Ok(events)
}
/// Extract (mode_id, params) from a reflection.request payload.
pub fn parse_reflection_request(payload: &Value) -> Option<(String, Value)> {
let mode_id = payload.get("mode_id")?.as_str()?.to_string();
let params = payload
.get("params")
.cloned()
.unwrap_or(Value::Object(Default::default()));
Some((mode_id, params))
}
}
/// Load project NATS config from .ontoref/config.ncl via nickel export.
#[cfg(feature = "nats")]
fn load_nats_config(config_path: &PathBuf) -> Result<Value> {
if !config_path.exists() {
return Ok(json!({}));
}
let output = Command::new("nickel")
.arg("export")
.arg(config_path)
.output()
.map_err(|e| anyhow!("running nickel export: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("nickel export failed: {stderr}"));
}
serde_json::from_slice(&output.stdout).map_err(|e| anyhow!("parsing nickel export output: {e}"))
}
/// ISO 8601 timestamp (UTC) without external dependency.
#[cfg(feature = "nats")]
fn iso8601_now() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let micros = now.subsec_micros();
let days = secs / 86400;
let day_secs = secs % 86400;
// Gregorian approximation (valid for 19702099)
let year = 1970 + (days / 365);
let month = ((days % 365) / 30) + 1;
let day = ((days % 365) % 30) + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
year,
month,
day,
day_secs / 3600,
(day_secs % 3600) / 60,
day_secs % 60,
micros,
)
}
// ── No-op implementation when nats feature is disabled ────────────────
#[cfg(not(feature = "nats"))]
pub struct NatsPublisher;
#[cfg(not(feature = "nats"))]
impl NatsPublisher {
pub async fn connect(
_config_path: &std::path::PathBuf,
_project: String,
_port: u16,
) -> anyhow::Result<Option<Self>> {
Ok(None)
}
pub async fn publish_started(&self) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_stopped(&self, _uptime_secs: u64) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_notification(
&self,
_project: &str,
_event: &crate::notifications::NotificationEvent,
_files: &[String],
) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_actor_registered(
&self,
_token: &str,
_actor_type: &str,
_project: &str,
) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_actor_deregistered(
&self,
_token: &str,
_reason: &str,
) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_file_changed(
&self,
_project: &str,
_files: &[String],
) -> anyhow::Result<()> {
Ok(())
}
pub async fn publish_cache_invalidated(&self, _reason: &str) -> anyhow::Result<()> {
Ok(())
}
}

View File

@ -0,0 +1,470 @@
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tracing::debug;
/// Notification events that require acknowledgment before git commit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationEvent {
OntologyChanged,
AdrChanged,
ReflectionChanged,
/// User-emitted notification with a free-form kind and payload.
Custom,
}
impl NotificationEvent {
/// Map a relative file path to its notification event type.
/// Returns `None` for paths outside watched directories.
pub fn from_path(relative_path: &str) -> Option<Self> {
if relative_path.starts_with(".ontology/") || relative_path.starts_with(".ontology\\") {
Some(Self::OntologyChanged)
} else if relative_path.starts_with("adrs/") || relative_path.starts_with("adrs\\") {
Some(Self::AdrChanged)
} else if relative_path.starts_with("reflection/")
|| relative_path.starts_with("reflection\\")
{
Some(Self::ReflectionChanged)
} else {
None
}
}
/// NATS subject suffix for this event type.
pub fn nats_suffix(&self) -> &'static str {
match self {
Self::OntologyChanged => "notification.ontology",
Self::AdrChanged => "notification.adr",
Self::ReflectionChanged => "notification.reflection",
Self::Custom => "notification.custom",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Notification {
pub id: u64,
pub project: String,
pub event: NotificationEvent,
pub files: Vec<String>,
pub timestamp: u64,
pub source_actor: Option<String>,
pub acked_by: HashSet<String>,
/// Populated for `NotificationEvent::Custom` notifications only.
pub custom_kind: Option<String>,
pub custom_title: Option<String>,
pub custom_payload: Option<serde_json::Value>,
/// Source project slug for cross-project notifications.
pub source_project: Option<String>,
}
/// Request body for user-emitted notifications (REST + UI form).
#[derive(Debug, Deserialize)]
pub struct EmitRequest {
/// Target project slug. Required in multi-project mode.
#[serde(default)]
pub target_project: Option<String>,
/// Free-form kind label, e.g. "backlog_delegation", "alert", "cross_ref".
pub kind: String,
pub title: String,
#[serde(default)]
pub payload: Option<serde_json::Value>,
#[serde(default)]
pub source_actor: Option<String>,
}
/// Per-project notification ring buffer.
///
/// Stores the last `capacity` notifications. Thread-safe via DashMap
/// for per-project isolation + AtomicU64 for the global sequence counter.
pub struct NotificationStore {
/// project → ordered notifications (newest last)
projects: DashMap<String, Vec<Notification>>,
sequence: AtomicU64,
capacity: usize,
/// Directories that require acknowledgment (e.g., [".ontology", "adrs"])
ack_required: Vec<String>,
}
impl NotificationStore {
pub fn new(capacity: usize, ack_required: Vec<String>) -> Self {
Self {
projects: DashMap::new(),
sequence: AtomicU64::new(1),
capacity,
ack_required,
}
}
/// Push notifications for changed files in a project, grouped by event
/// type.
///
/// A single file batch may contain files from multiple watched directories
/// (e.g., `.ontology/` and `adrs/`). This method creates one notification
/// per distinct event type, so no changes are silently swallowed.
///
/// Returns the IDs of created notifications (empty if none required ack).
pub fn push(
&self,
project: &str,
files: Vec<String>,
source_actor: Option<String>,
) -> Vec<u64> {
// Group files by event type — each group becomes a separate notification
let mut by_event: std::collections::HashMap<NotificationEvent, Vec<String>> =
std::collections::HashMap::new();
for file in files {
if let Some(event) = NotificationEvent::from_path(&file) {
by_event.entry(event).or_default().push(file);
}
}
let mut ids = Vec::new();
let now = epoch_secs();
for (event, event_files) in by_event {
if !self.requires_ack(&event) {
continue;
}
let id = self.sequence.fetch_add(1, Ordering::Relaxed);
let notification = Notification {
id,
project: project.to_string(),
event,
files: event_files,
timestamp: now,
source_actor: source_actor.clone(),
acked_by: HashSet::new(),
custom_kind: None,
custom_title: None,
custom_payload: None,
source_project: None,
};
debug!(
id,
project,
event = ?notification.event,
file_count = notification.files.len(),
"notification created"
);
let mut ring = self.projects.entry(project.to_string()).or_default();
ring.push(notification);
// Trim to capacity
if ring.len() > self.capacity {
let excess = ring.len() - self.capacity;
ring.drain(..excess);
}
ids.push(id);
}
ids
}
/// Emit a user-authored notification directly into this project's ring
/// buffer.
///
/// Unlike `push()`, this bypasses file-path classification and is always
/// stored. Returns the new notification ID.
pub fn push_custom(
&self,
project: &str,
kind: impl Into<String>,
title: impl Into<String>,
payload: Option<serde_json::Value>,
source_actor: Option<String>,
source_project: Option<String>,
) -> u64 {
let id = self.sequence.fetch_add(1, Ordering::Relaxed);
let notification = Notification {
id,
project: project.to_string(),
event: NotificationEvent::Custom,
files: vec![],
timestamp: epoch_secs(),
source_actor,
acked_by: HashSet::new(),
custom_kind: Some(kind.into()),
custom_title: Some(title.into()),
custom_payload: payload,
source_project,
};
let mut ring = self.projects.entry(project.to_string()).or_default();
ring.push(notification);
if ring.len() > self.capacity {
let excess = ring.len() - self.capacity;
ring.drain(..excess);
}
id
}
/// Get pending (unacknowledged) notifications for a specific actor token.
pub fn pending(&self, project: &str, token: &str) -> Vec<NotificationView> {
let ring = match self.projects.get(project) {
Some(r) => r,
None => return Vec::new(),
};
ring.iter()
.filter(|n| !n.acked_by.contains(token))
.map(NotificationView::from)
.collect()
}
/// Count of pending notifications for a token.
pub fn pending_count(&self, project: &str, token: &str) -> usize {
let ring = match self.projects.get(project) {
Some(r) => r,
None => return 0,
};
ring.iter().filter(|n| !n.acked_by.contains(token)).count()
}
/// Acknowledge all pending notifications for a token.
pub fn ack_all(&self, project: &str, token: &str) -> usize {
let mut ring = match self.projects.get_mut(project) {
Some(r) => r,
None => return 0,
};
let mut count = 0;
for n in ring.iter_mut() {
if n.acked_by.insert(token.to_string()) {
count += 1;
}
}
debug!(token, project, acked = count, "notifications acknowledged");
count
}
/// Acknowledge a specific notification by ID.
pub fn ack_one(&self, project: &str, token: &str, notification_id: u64) -> bool {
let mut ring = match self.projects.get_mut(project) {
Some(r) => r,
None => return false,
};
ring.iter_mut()
.find(|n| n.id == notification_id)
.map(|n| n.acked_by.insert(token.to_string()))
.unwrap_or(false)
}
/// Check whether a notification event requires acknowledgment.
fn requires_ack(&self, event: &NotificationEvent) -> bool {
// Custom (user-emitted) notifications are always stored.
if matches!(event, NotificationEvent::Custom) {
return true;
}
if self.ack_required.is_empty() {
// Default: ontology and ADR changes require ack; Custom already handled above.
matches!(
event,
NotificationEvent::OntologyChanged
| NotificationEvent::AdrChanged
| NotificationEvent::Custom
)
} else {
let dir = match event {
NotificationEvent::OntologyChanged => ".ontology",
NotificationEvent::AdrChanged => "adrs",
NotificationEvent::ReflectionChanged => "reflection",
NotificationEvent::Custom => return true,
};
self.ack_required.iter().any(|r| r == dir)
}
}
/// Get a single notification by ID across all projects.
pub fn get_one(&self, id: u64) -> Option<NotificationView> {
for entry in self.projects.iter() {
if let Some(n) = entry.value().iter().find(|n| n.id == id) {
return Some(NotificationView::from(n));
}
}
None
}
/// All projects with stored notifications.
pub fn projects(&self) -> Vec<String> {
self.projects.iter().map(|e| e.key().clone()).collect()
}
/// All notifications across all projects, newest-last order per project.
/// Used by the web UI to display a global feed without a per-actor token.
pub fn all_recent(&self) -> Vec<NotificationView> {
self.projects
.iter()
.flat_map(|entry| {
entry
.value()
.iter()
.map(NotificationView::from)
.collect::<Vec<_>>()
})
.collect()
}
}
/// Serializable view without the mutable `acked_by` set.
#[derive(Debug, Clone, Serialize)]
pub struct NotificationView {
pub id: u64,
pub project: String,
pub event: NotificationEvent,
pub files: Vec<String>,
pub timestamp: u64,
pub source_actor: Option<String>,
pub custom_kind: Option<String>,
pub custom_title: Option<String>,
pub custom_payload: Option<serde_json::Value>,
pub source_project: Option<String>,
}
impl From<&Notification> for NotificationView {
fn from(n: &Notification) -> Self {
Self {
id: n.id,
project: n.project.clone(),
event: n.event,
files: n.files.clone(),
timestamp: n.timestamp,
source_actor: n.source_actor.clone(),
custom_kind: n.custom_kind.clone(),
custom_title: n.custom_title.clone(),
custom_payload: n.custom_payload.clone(),
source_project: n.source_project.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct AckRequest {
pub token: String,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub all: bool,
#[serde(default)]
pub notification_id: Option<u64>,
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> NotificationStore {
NotificationStore::new(100, vec![".ontology".into(), "adrs".into()])
}
#[test]
fn event_from_path() {
assert_eq!(
NotificationEvent::from_path(".ontology/core.ncl"),
Some(NotificationEvent::OntologyChanged)
);
assert_eq!(
NotificationEvent::from_path("adrs/adr-001.ncl"),
Some(NotificationEvent::AdrChanged)
);
assert_eq!(
NotificationEvent::from_path("reflection/modes/sync.ncl"),
Some(NotificationEvent::ReflectionChanged)
);
assert_eq!(NotificationEvent::from_path("src/main.rs"), None);
}
#[test]
fn push_and_pending() {
let store = store();
let files = vec![".ontology/core.ncl".into()];
let ids = store.push("proj", files, None);
assert_eq!(ids.len(), 1);
let pending = store.pending("proj", "dev:host:1");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].event, NotificationEvent::OntologyChanged);
}
#[test]
fn push_groups_by_event_type() {
let store = store();
let files = vec![
".ontology/core.ncl".into(),
"adrs/adr-001.ncl".into(),
".ontology/state.ncl".into(),
];
let ids = store.push("proj", files, None);
// Should create 2 notifications: OntologyChanged + AdrChanged
assert_eq!(ids.len(), 2);
let pending = store.pending("proj", "dev:host:1");
assert_eq!(pending.len(), 2);
let events: HashSet<NotificationEvent> = pending.iter().map(|n| n.event).collect();
assert!(events.contains(&NotificationEvent::OntologyChanged));
assert!(events.contains(&NotificationEvent::AdrChanged));
}
#[test]
fn ack_clears_pending() {
let store = store();
store.push("proj", vec![".ontology/state.ncl".into()], None);
store.push("proj", vec!["adrs/adr-002.ncl".into()], None);
let token = "dev:host:1";
assert_eq!(store.pending_count("proj", token), 2);
let acked = store.ack_all("proj", token);
assert_eq!(acked, 2);
assert_eq!(store.pending_count("proj", token), 0);
}
#[test]
fn ack_one_specific() {
let store = store();
let ids = store.push("proj", vec![".ontology/core.ncl".into()], None);
let id1 = ids[0];
store.push("proj", vec!["adrs/adr-001.ncl".into()], None);
let token = "dev:host:1";
assert!(store.ack_one("proj", token, id1));
assert_eq!(store.pending_count("proj", token), 1);
}
#[test]
fn ring_buffer_eviction() {
let store = NotificationStore::new(3, vec![".ontology".into()]);
for i in 0..5 {
store.push("proj", vec![format!(".ontology/file{i}.ncl")], None);
}
// Only last 3 retained
let pending = store.pending("proj", "token");
assert_eq!(pending.len(), 3);
}
#[test]
fn reflection_not_ack_required_by_default_config() {
let store = store(); // ack_required = [".ontology", "adrs"]
let ids = store.push("proj", vec!["reflection/modes/sync.ncl".into()], None);
// reflection not in ack_required → push returns empty
assert!(ids.is_empty());
}
}

View File

@ -0,0 +1,288 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::actors::ActorRegistry;
use crate::cache::NclCache;
use crate::notifications::NotificationStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Viewer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyEntry {
pub role: Role,
/// Argon2id PHC string — produced by `ontoref-daemon --hash-password <pw>`
pub hash: String,
}
/// Serialisable entry used for TOML read/write.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryEntry {
pub slug: String,
pub root: PathBuf,
#[serde(default)]
pub keys: Vec<KeyEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct RegistryFile {
projects: Vec<RegistryEntry>,
}
/// Per-project runtime state owned by the registry.
pub struct ProjectContext {
pub slug: String,
pub root: PathBuf,
pub import_path: Option<String>,
pub cache: Arc<NclCache>,
pub actors: Arc<ActorRegistry>,
pub notifications: Arc<NotificationStore>,
/// Stored for TOML serialisation round-trips.
pub keys: Vec<KeyEntry>,
}
impl ProjectContext {
pub fn auth_enabled(&self) -> bool {
!self.keys.is_empty()
}
/// Returns the role of the first key whose argon2id hash matches
/// `password`.
pub fn verify_key(&self, password: &str) -> Option<Role> {
use argon2::{Argon2, PasswordHash, PasswordVerifier};
for key in &self.keys {
let Ok(parsed) = PasswordHash::new(&key.hash) else {
continue;
};
if Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
{
return Some(key.role);
}
}
None
}
}
pub struct ProjectRegistry {
contexts: DashMap<String, Arc<ProjectContext>>,
pub path: PathBuf,
stale_actor_timeout: u64,
max_notifications: usize,
}
impl ProjectRegistry {
/// Load and parse a TOML registry file, creating per-project runtime state.
pub fn load(
path: &Path,
stale_actor_timeout: u64,
max_notifications: usize,
) -> anyhow::Result<Self> {
let registry = Self {
contexts: DashMap::new(),
path: path.to_path_buf(),
stale_actor_timeout,
max_notifications,
};
registry.reload_from_file(path)?;
Ok(registry)
}
/// Hot-reload from the registry file; preserves existing caches for
/// unchanged slugs.
pub fn reload(&self) -> anyhow::Result<()> {
self.reload_from_file(&self.path.clone())
}
fn reload_from_file(&self, path: &Path) -> anyhow::Result<()> {
let contents = std::fs::read_to_string(path)?;
let file: RegistryFile = toml::from_str(&contents)?;
let new_slugs: std::collections::HashSet<String> =
file.projects.iter().map(|p| p.slug.clone()).collect();
// Remove projects no longer in the file.
self.contexts
.retain(|slug, _| new_slugs.contains(slug.as_str()));
for entry in file.projects {
let root = entry
.root
.canonicalize()
.map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?;
if let Some(existing) = self.contexts.get(&entry.slug) {
// Project already loaded — update keys only, reuse warm cache/actors.
if existing.root == root {
// Re-insert a new Arc with updated keys but same internals.
let ctx = ProjectContext {
slug: existing.slug.clone(),
root: existing.root.clone(),
import_path: existing.import_path.clone(),
cache: Arc::clone(&existing.cache),
actors: Arc::clone(&existing.actors),
notifications: Arc::clone(&existing.notifications),
keys: entry.keys,
};
drop(existing);
self.contexts.insert(entry.slug, Arc::new(ctx));
continue;
}
drop(existing);
}
// New project — create fresh context.
let import_path = load_import_path(&root);
let ctx = make_context(
entry.slug.clone(),
root,
import_path,
entry.keys,
self.stale_actor_timeout,
self.max_notifications,
);
self.contexts.insert(entry.slug, Arc::new(ctx));
}
Ok(())
}
/// Add a project at runtime and persist to the TOML file.
pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> {
if self.contexts.contains_key(&entry.slug) {
anyhow::bail!("project '{}' already exists", entry.slug);
}
let root = entry
.root
.canonicalize()
.map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?;
let import_path = load_import_path(&root);
let ctx = make_context(
entry.slug.clone(),
root,
import_path,
entry.keys,
self.stale_actor_timeout,
self.max_notifications,
);
self.contexts.insert(entry.slug, Arc::new(ctx));
self.write_toml()
}
/// Remove a project by slug and persist to the TOML file.
pub fn remove_project(&self, slug: &str) -> anyhow::Result<()> {
self.contexts.remove(slug);
self.write_toml()
}
/// Serialise current in-memory state back to the registry TOML file.
pub fn write_toml(&self) -> anyhow::Result<()> {
let mut projects: Vec<RegistryEntry> = self
.contexts
.iter()
.map(|r| RegistryEntry {
slug: r.slug.clone(),
root: r.root.clone(),
keys: r.keys.clone(),
})
.collect();
projects.sort_by(|a, b| a.slug.cmp(&b.slug));
let file = RegistryFile { projects };
let toml = toml::to_string_pretty(&file)?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, toml)?;
Ok(())
}
pub fn get(&self, slug: &str) -> Option<Arc<ProjectContext>> {
self.contexts.get(slug).map(|r| Arc::clone(&*r))
}
pub fn all(&self) -> Vec<Arc<ProjectContext>> {
let mut list: Vec<_> = self.contexts.iter().map(|r| Arc::clone(&*r)).collect();
list.sort_by(|a, b| a.slug.cmp(&b.slug));
list
}
pub fn count(&self) -> usize {
self.contexts.len()
}
}
fn make_context(
slug: String,
root: PathBuf,
import_path: Option<String>,
keys: Vec<KeyEntry>,
stale_actor_timeout: u64,
max_notifications: usize,
) -> ProjectContext {
let sessions_dir = root.join(".ontoref").join("sessions");
let actors = Arc::new(ActorRegistry::new(stale_actor_timeout).with_persist_dir(sessions_dir));
actors.load_persisted();
ProjectContext {
slug,
root,
import_path,
cache: Arc::new(NclCache::new()),
actors,
notifications: Arc::new(NotificationStore::new(
max_notifications,
vec![".ontology".into(), "adrs".into()],
)),
keys,
}
}
fn load_import_path(root: &Path) -> Option<String> {
use std::process::Command;
let config_path = root.join(".ontoref").join("config.ncl");
if !config_path.exists() {
return None;
}
let output = Command::new("nickel")
.arg("export")
.arg(&config_path)
.output()
.ok()?;
if !output.status.success() {
warn!(path = %config_path.display(), "nickel export failed for registry project config");
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let paths = json.get("nickel_import_paths")?.as_array()?;
// Resolve each path against the project root so that relative entries like
// "." or "reflection/schemas" work correctly in multi-project mode where the
// daemon CWD is not the project root.
let joined = paths
.iter()
.filter_map(|v| v.as_str())
.map(|p| {
let candidate = std::path::Path::new(p);
if candidate.is_absolute() {
p.to_string()
} else {
root.join(candidate).display().to_string()
}
})
.collect::<Vec<_>>()
.join(":");
if joined.is_empty() {
None
} else {
Some(joined)
}
}

View File

@ -0,0 +1,479 @@
use std::path::Path;
use std::sync::Arc;
use serde::Serialize;
use tracing::warn;
use crate::cache::NclCache;
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ResultKind {
Node,
Adr,
Mode,
}
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub kind: ResultKind,
pub id: String,
pub title: String,
pub description: String,
pub detail_html: String,
pub path: String,
pub pole: Option<String>,
pub level: Option<String>,
}
/// Case-insensitive full-text search across ontology nodes, ADRs, and
/// reflection modes.
pub async fn search_project(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
query: &str,
) -> Vec<SearchResult> {
let q = query.to_lowercase();
let mut results = Vec::new();
// Ensure root is always in NICKEL_IMPORT_PATH so that cross-directory imports
// like `import "reflection/defaults.ncl"` resolve from the project root,
// regardless of whether the project has an explicit nickel_import_paths config.
let root_str = root.display().to_string();
let effective_ip = match import_path {
Some(ip) if !ip.is_empty() => format!("{root_str}:{ip}"),
_ => root_str,
};
let ip = Some(effective_ip.as_str());
search_nodes(root, cache, ip, &q, &mut results).await;
search_adrs(root, cache, ip, &q, &mut results).await;
search_modes(root, cache, ip, &q, &mut results).await;
results
}
async fn search_nodes(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let core_path = root.join(".ontology").join("core.ncl");
if !core_path.exists() {
return;
}
let Ok((json, _)) = cache.export(&core_path, import_path).await else {
return;
};
let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else {
return;
};
for node in nodes {
let id = str_field(node, "id");
let name = str_field(node, "name");
let desc = str_field(node, "description");
if matches(q, &[id, name, desc]) {
out.push(SearchResult {
kind: ResultKind::Node,
id: str_field(node, "id").to_string(),
title: str_field(node, "name").to_string(),
description: truncate(str_field(node, "description"), 180),
detail_html: node_html(node),
path: ".ontology/core.ncl".to_string(),
pole: node.get("pole").and_then(|v| v.as_str()).map(String::from),
level: node.get("level").and_then(|v| v.as_str()).map(String::from),
});
}
}
}
async fn search_adrs(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let adrs_dir = root.join("adrs");
let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Only process actual ADR records: adr-NNN-*.ncl
if !is_adr_record(fname) {
continue;
}
match cache.export(&path, import_path).await {
Ok((json, _)) => {
let id = str_field(&json, "id");
let title = str_field(&json, "title");
let context = str_field(&json, "context");
let decision = str_field(&json, "decision");
// Also search constraint claims
let constraint_text = json
.get("constraints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| c.get("claim").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
if matches(q, &[id, title, context, decision, &constraint_text]) {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.push(SearchResult {
kind: ResultKind::Adr,
id: str_field(&json, "id").to_string(),
title: str_field(&json, "title").to_string(),
description: truncate(str_field(&json, "context"), 180),
detail_html: adr_html(&json),
path: rel,
pole: None,
level: None,
});
}
}
Err(e) => warn!(path = %path.display(), error = %e, "search: adr export failed"),
}
}
}
async fn search_modes(
root: &Path,
cache: &Arc<NclCache>,
import_path: Option<&str>,
q: &str,
out: &mut Vec<SearchResult>,
) {
let modes_dir = root.join("reflection").join("modes");
let Ok(entries) = std::fs::read_dir(&modes_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
match cache.export(&path, import_path).await {
Ok((json, _)) => {
let id = str_field(&json, "id");
let desc = str_field(&json, "description");
if matches(q, &[id, desc]) {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.push(SearchResult {
kind: ResultKind::Mode,
id: str_field(&json, "id").to_string(),
title: str_field(&json, "id").to_string(),
description: truncate(str_field(&json, "description"), 180),
detail_html: mode_html(&json),
path: rel,
pole: None,
level: None,
});
}
}
Err(e) => warn!(path = %path.display(), error = %e, "search: mode export failed"),
}
}
}
// ── Detail HTML builders ─────────────────────────────────────────────────────
fn node_html(n: &serde_json::Value) -> String {
let desc = str_field(n, "description");
let level = str_field(n, "level");
let pole = str_field(n, "pole");
let invariant = n
.get("invariant")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let artifacts = n
.get("artifact_paths")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let mut h = String::new();
h.push_str(&para(desc));
h.push_str("<div class=\"flex flex-wrap gap-1 my-2\">");
h.push_str(&badge(level, "badge-ghost"));
h.push_str(&badge(pole, "badge-ghost"));
if invariant {
h.push_str(&badge("invariant", "badge-warning"));
}
h.push_str("</div>");
if !artifacts.is_empty() {
h.push_str(&section_header("Artifacts"));
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
for a in artifacts {
h.push_str(&format!(
"<li class=\"text-base-content/60\">{}</li>",
esc(a)
));
}
h.push_str("</ul>");
}
h
}
fn adr_html(json: &serde_json::Value) -> String {
let mut h = String::new();
if let Some(status) = json.get("status").and_then(|v| v.as_str()) {
h.push_str(&format!(
"<div class=\"mb-2\"><span class=\"badge badge-sm badge-outline\">{}</span></div>",
esc(status)
));
}
for field in &["context", "decision"] {
let label = if *field == "context" {
"Context"
} else {
"Decision"
};
let text = str_field(json, field);
if !text.is_empty() {
h.push_str(&section_header(label));
h.push_str(&text_block(text));
}
}
if let Some(cons) = json.get("consequences") {
h.push_str(&section_header("Consequences"));
for (label, key) in &[("Positive", "positive"), ("Negative", "negative")] {
let items = cons
.get(key)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
if !items.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs font-medium text-base-content/60 mt-1 mb-0.5\">{label}</p>"
));
h.push_str(&bullet_list(items));
}
}
}
if let Some(constraints) = json.get("constraints").and_then(|v| v.as_array()) {
if !constraints.is_empty() {
h.push_str(&section_header("Constraints"));
for c in constraints {
let claim = c.get("claim").and_then(|v| v.as_str()).unwrap_or("");
let rationale = c.get("rationale").and_then(|v| v.as_str()).unwrap_or("");
let severity = c.get("severity").and_then(|v| v.as_str()).unwrap_or("");
let badge_cls = if severity == "Hard" {
"badge-error"
} else {
"badge-warning"
};
h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
h.push_str(&format!(
"<div class=\"flex items-start gap-1.5 mb-1\"><span class=\"badge badge-xs \
{badge_cls} flex-shrink-0\">{severity}</span><p class=\"text-xs \
font-medium\">{}</p></div>",
esc(claim)
));
if !rationale.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/60\">{}</p>",
esc(rationale)
));
}
h.push_str("</div>");
}
}
}
if let Some(alts) = json
.get("alternatives_considered")
.and_then(|v| v.as_array())
{
if !alts.is_empty() {
h.push_str(&section_header("Alternatives Considered"));
for alt in alts {
let opt = alt.get("option").and_then(|v| v.as_str()).unwrap_or("");
let why = alt
.get("why_rejected")
.and_then(|v| v.as_str())
.unwrap_or("");
h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
h.push_str(&format!(
"<p class=\"text-xs font-medium mb-0.5\">{}</p>",
esc(opt)
));
if !why.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/50\">{}</p>",
esc(why)
));
}
h.push_str("</div>");
}
}
}
h
}
fn mode_html(json: &serde_json::Value) -> String {
let mut h = String::new();
let desc = str_field(json, "description");
if !desc.is_empty() {
h.push_str(&para(desc));
}
for (label, key) in &[
("Preconditions", "preconditions"),
("Postconditions", "postconditions"),
] {
let items = json
.get(key)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
if !items.is_empty() {
h.push_str(&section_header(label));
h.push_str(&bullet_list(items));
}
}
if let Some(steps) = json.get("steps").and_then(|v| v.as_array()) {
if !steps.is_empty() {
h.push_str(&section_header("Steps"));
h.push_str("<ol class=\"space-y-1.5\">");
for step in steps {
let step_id = step.get("id").and_then(|v| v.as_str()).unwrap_or("");
let actor = step.get("actor").and_then(|v| v.as_str()).unwrap_or("");
let note = step.get("note").and_then(|v| v.as_str()).unwrap_or("");
let cmd = step.get("cmd").and_then(|v| v.as_str()).unwrap_or("");
h.push_str("<li class=\"border border-base-content/10 rounded p-2\">");
h.push_str(&format!(
"<div class=\"flex items-center gap-1.5 mb-0.5\"><span class=\"badge badge-xs \
badge-ghost font-mono\">{}</span><span class=\"badge badge-xs \
badge-outline\">{}</span></div>",
esc(step_id),
esc(actor)
));
if !note.is_empty() {
h.push_str(&format!(
"<p class=\"text-xs text-base-content/70\">{}</p>",
esc(note)
));
}
if !cmd.is_empty() {
h.push_str(&format!(
"<pre class=\"text-xs font-mono bg-base-300 rounded px-2 py-1 mt-1 \
overflow-x-auto\">{}</pre>",
esc(cmd)
));
}
h.push_str("</li>");
}
h.push_str("</ol>");
}
}
h
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Returns true only for actual ADR record files: `adr-NNN-*.ncl`
/// where NNN is one or more digits immediately after the second hyphen.
fn is_adr_record(fname: &str) -> bool {
let Some(rest) = fname.strip_prefix("adr-") else {
return false;
};
// next char must be a digit (adr-001-..., adr-1-..., etc.)
rest.starts_with(|c: char| c.is_ascii_digit()) && fname.ends_with(".ncl")
}
fn str_field<'a>(v: &'a serde_json::Value, key: &str) -> &'a str {
v.get(key).and_then(|v| v.as_str()).unwrap_or("")
}
fn matches(q: &str, fields: &[&str]) -> bool {
fields.iter().any(|f| f.to_lowercase().contains(q))
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}", &s[..s.floor_char_boundary(n)])
}
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn section_header(label: &str) -> String {
format!(
"<p class=\"text-xs font-semibold text-base-content/40 uppercase tracking-wider mt-3 \
mb-1\">{}</p>",
esc(label)
)
}
fn para(text: &str) -> String {
let escaped = esc(text);
// double-newline → paragraph break, single newline → <br>
let paras: Vec<_> = escaped.split("\n\n").collect();
paras
.iter()
.map(|p| {
format!(
"<p class=\"text-sm leading-relaxed\">{}</p>",
p.replace('\n', "<br>")
)
})
.collect::<Vec<_>>()
.join("")
}
fn text_block(text: &str) -> String {
format!(
"<div class=\"text-sm leading-relaxed text-base-content/80 mb-1\">{}</div>",
para(text)
)
}
fn badge(text: &str, cls: &str) -> String {
format!("<span class=\"badge badge-xs {cls}\">{}</span>", esc(text))
}
fn bullet_list(items: &[serde_json::Value]) -> String {
let mut h = String::from(
"<ul class=\"list-disc list-inside text-xs space-y-0.5 text-base-content/80\">",
);
for s in items.iter().filter_map(|v| v.as_str()) {
h.push_str(&format!("<li>{}</li>", esc(s)));
}
h.push_str("</ul>");
h
}

View File

@ -0,0 +1,131 @@
use std::path::Path;
use serde_json::Value;
use tracing::{info, warn};
use crate::cache::NclCache;
/// Counts of records upserted per table during a seed pass.
pub struct SeedReport {
pub nodes: usize,
pub edges: usize,
pub dimensions: usize,
pub membranes: usize,
}
/// Seed ontology tables from local NCL files into SurrealDB.
///
/// Local files are the source of truth. The DB is a queryable projection
/// rebuilt from files on daemon startup and on watcher-detected changes.
/// Uses UPSERT semantics — idempotent, safe to call on every change.
#[cfg(feature = "db")]
pub async fn seed_ontology(
db: &stratum_db::StratumDb,
project_root: &Path,
cache: &NclCache,
import_path: Option<&str>,
) -> SeedReport {
let mut report = SeedReport {
nodes: 0,
edges: 0,
dimensions: 0,
membranes: 0,
};
let core_path = project_root.join(".ontology").join("core.ncl");
if core_path.exists() {
match cache.export(&core_path, import_path).await {
Ok((json, _)) => {
report.nodes = seed_table_by_id(db, "node", &json, "nodes").await;
report.edges = seed_edges(db, &json).await;
}
Err(e) => warn!(error = %e, "seed: core.ncl export failed"),
}
}
let state_path = project_root.join(".ontology").join("state.ncl");
if state_path.exists() {
match cache.export(&state_path, import_path).await {
Ok((json, _)) => {
report.dimensions = seed_table_by_id(db, "dimension", &json, "dimensions").await;
}
Err(e) => warn!(error = %e, "seed: state.ncl export failed"),
}
}
let gate_path = project_root.join(".ontology").join("gate.ncl");
if gate_path.exists() {
match cache.export(&gate_path, import_path).await {
Ok((json, _)) => {
report.membranes = seed_table_by_id(db, "membrane", &json, "membranes").await;
}
Err(e) => warn!(error = %e, "seed: gate.ncl export failed"),
}
}
info!(
nodes = report.nodes,
edges = report.edges,
dimensions = report.dimensions,
membranes = report.membranes,
"ontology seeded from local files"
);
report
}
/// Generic upsert: extract `json[array_key]`, iterate, use each item's `id`
/// field as record key.
#[cfg(feature = "db")]
async fn seed_table_by_id(
db: &stratum_db::StratumDb,
table: &str,
json: &Value,
array_key: &str,
) -> usize {
let items = match json.get(array_key).and_then(|a| a.as_array()) {
Some(arr) => arr,
None => return 0,
};
let mut count = 0;
for item in items {
let id = match item.get("id").and_then(|i| i.as_str()) {
Some(id) => id,
None => continue,
};
if let Err(e) = db.upsert(table, id, item.clone()).await {
warn!(table, id, error = %e, "seed: upsert failed");
continue;
}
count += 1;
}
count
}
/// Edges use a deterministic compound key: `{from}--{kind}--{to}`.
#[cfg(feature = "db")]
async fn seed_edges(db: &stratum_db::StratumDb, core_json: &Value) -> usize {
let edges = match core_json.get("edges").and_then(|e| e.as_array()) {
Some(arr) => arr,
None => return 0,
};
let mut count = 0;
for edge in edges {
let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or("");
let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or("");
let kind = edge
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("unknown");
let edge_id = format!("{from}--{kind}--{to}");
if let Err(e) = db.upsert("edge", &edge_id, edge.clone()).await {
warn!(edge_id, error = %e, "seed: edge upsert failed");
continue;
}
count += 1;
}
count
}

View File

@ -0,0 +1,81 @@
use std::time::{SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use uuid::Uuid;
use crate::registry::Role;
pub const COOKIE_NAME: &str = "ontoref-session";
const SESSION_SECS: u64 = 30 * 24 * 3600;
#[derive(Clone)]
pub struct SessionEntry {
pub slug: String,
pub role: Role,
pub expires: u64,
}
pub struct SessionStore {
sessions: DashMap<String, SessionEntry>,
}
impl Default for SessionStore {
fn default() -> Self {
Self::new()
}
}
impl SessionStore {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
}
}
pub fn create(&self, slug: String, role: Role) -> String {
let token = Uuid::new_v4().to_string();
let expires = now_secs() + SESSION_SECS;
self.sessions.insert(
token.clone(),
SessionEntry {
slug,
role,
expires,
},
);
token
}
pub fn get(&self, token: &str) -> Option<SessionEntry> {
let entry = self.sessions.get(token)?;
if entry.expires < now_secs() {
drop(entry);
self.sessions.remove(token);
return None;
}
Some(entry.clone())
}
pub fn revoke(&self, token: &str) {
self.sessions.remove(token);
}
}
/// Extract the value of a named cookie from a raw `Cookie:` header string.
pub fn extract_cookie(header: &str, name: &str) -> Option<String> {
for part in header.split(';') {
if let Some((k, v)) = part.trim().split_once('=') {
if k.trim() == name {
return Some(v.trim().to_string());
}
}
}
None
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}

View File

@ -0,0 +1,122 @@
use axum::{
extract::{FromRequestParts, Path},
http::{header, request::Parts, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use crate::api::AppState;
use crate::registry::Role;
use crate::session::{self, SessionEntry, COOKIE_NAME};
/// Injected by the `AuthUser` extractor; available as a handler parameter.
#[derive(Clone)]
pub struct AuthUser(pub SessionEntry);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let Path(slug) = Path::<String>::from_request_parts(parts, state)
.await
.map_err(|_| axum::http::StatusCode::BAD_REQUEST.into_response())?;
let Some(ref registry) = state.registry else {
return Err(axum::http::StatusCode::NOT_FOUND.into_response());
};
let Some(ctx) = registry.get(&slug) else {
return Err(axum::http::StatusCode::NOT_FOUND.into_response());
};
if !ctx.auth_enabled() {
// Auth not required for this project — synthesise a pass-through entry.
use std::time::{SystemTime, UNIX_EPOCH};
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
+ 86400;
return Ok(AuthUser(SessionEntry {
slug,
role: Role::Admin,
expires,
}));
}
let cookie_str = parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let Some(token) = session::extract_cookie(cookie_str, COOKIE_NAME) else {
return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response());
};
let Some(entry) = state.sessions.get(&token) else {
return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response());
};
if entry.slug != slug {
return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response());
}
Ok(AuthUser(entry))
}
}
/// Global manage-page guard — no slug in path.
///
/// Allows access when:
/// - The registry has no projects with auth enabled (open/loopback deployment).
/// - The request carries a valid admin-role session cookie for any project.
///
/// On failure redirects to the login page of the first alphabetically sorted
/// project that has auth enabled; returns 403 if there is no such project to
/// redirect to.
pub struct AdminGuard;
impl FromRequestParts<AppState> for AdminGuard {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let Some(ref registry) = state.registry else {
// Single-project mode — no registry, manage is not meaningful but not blocked.
return Ok(AdminGuard);
};
let auth_projects: Vec<String> = registry
.all()
.into_iter()
.filter(|ctx| ctx.auth_enabled())
.map(|ctx| ctx.slug.clone())
.collect();
if auth_projects.is_empty() {
// No project requires auth — open deployment, pass through.
return Ok(AdminGuard);
}
let cookie_str = parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if let Some(token) = session::extract_cookie(cookie_str, COOKIE_NAME) {
if let Some(entry) = state.sessions.get(&token) {
if matches!(entry.role, Role::Admin) {
return Ok(AdminGuard);
}
}
}
// Find first auth-enabled project as redirect target (stable: sorted above by
// slug).
let mut sorted = auth_projects;
sorted.sort();
if let Some(slug) = sorted.first() {
return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response());
}
Err(StatusCode::FORBIDDEN.into_response())
}
}

View File

@ -0,0 +1,189 @@
//! In-place mutations of reflection/backlog.ncl.
//!
//! The Nickel file format is predictable enough for line-level surgery.
//! We never parse the full AST — we only do targeted replacements inside
//! item blocks identified by their unique `id` field.
use std::path::Path;
/// Update `status` and `updated` fields inside the item block with `id`.
pub fn update_status(path: &Path, id: &str, new_status: &str, today: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let updated = mutate_item_fields(
&content,
id,
&[
("status", &format!("'{new_status}")),
("updated", &format!("\"{today}\"")),
],
);
std::fs::write(path, updated)?;
Ok(())
}
/// Append a new item to the items array.
///
/// Generates the next `bl-NNN` id, formats a Nickel record, and inserts it
/// before the closing ` ],` of the items array.
pub fn add_item(
path: &Path,
title: &str,
kind: &str,
priority: &str,
detail: &str,
today: &str,
) -> anyhow::Result<String> {
let content = std::fs::read_to_string(path)?;
let next_id = next_item_id(&content);
let block = format!(
r#" {{
id = "{id}",
title = "{title}",
kind = '{kind},
priority = '{priority},
status = 'Open,
detail = "{detail}",
related_adrs = [],
related_modes = [],
graduates_to = 'StateTransition,
created = "{today}",
updated = "{today}",
}},
"#,
id = next_id,
title = escape_ncl(title),
detail = escape_ncl(detail),
);
// Insert before the closing ` ],` of the items array.
let updated = insert_before_array_close(&content, &block)?;
std::fs::write(path, updated)?;
Ok(next_id)
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// Replace the value of one or more fields within the item block identified by
/// `id`.
///
/// For each `(field_name, new_value)` pair, finds the line
/// `<ws>field_name<ws>=<ws>...,` inside the target block and replaces the value
/// in-place.
fn mutate_item_fields(content: &str, id: &str, fields: &[(&str, &str)]) -> String {
let id_needle = format!("\"{}\"", id);
let mut in_block = false;
let mut result: Vec<String> = Vec::with_capacity(content.lines().count() + 1);
for line in content.lines() {
if !in_block {
if line.contains(&id_needle) && line.contains('=') {
in_block = true;
}
result.push(line.to_string());
continue;
}
// Inside the target block — attempt field replacement.
let trimmed = line.trim_start();
let mut replaced = false;
for (field, new_val) in fields {
if trimmed.starts_with(field) {
let eq_pos = trimmed.find('=').unwrap_or(usize::MAX);
// Guard: must have `=` and next non-whitespace char after `=` is the value.
if eq_pos < trimmed.len() {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
// Preserve original field alignment by keeping spacing up to `=`.
let before_eq = &trimmed[..eq_pos];
result.push(format!(
"{}{} {} {},",
indent,
before_eq.trim_end(),
"=",
new_val
));
replaced = true;
break;
}
}
}
if !replaced {
result.push(line.to_string());
}
// Detect end of block: a line whose trimmed form is `},`
if trimmed == "}," {
in_block = false;
}
}
result.join("\n")
}
/// Find the highest `bl-NNN` id and return `bl-(NNN+1)` zero-padded to 3
/// digits.
fn next_item_id(content: &str) -> String {
let max = content
.lines()
.filter_map(|line| {
let t = line.trim();
let rest = t.strip_prefix("id")?;
let val = rest.split('"').nth(1)?;
let num_str = val.strip_prefix("bl-")?;
num_str.parse::<u32>().ok()
})
.max()
.unwrap_or(0);
format!("bl-{:03}", max + 1)
}
/// Insert `block` before the first occurrence of ` ],` (items array close).
fn insert_before_array_close(content: &str, block: &str) -> anyhow::Result<String> {
// We look for a line whose trimmed content is `],` — this is the array closing.
// To avoid matching other arrays, we require exactly two leading spaces.
let needle = " ],";
let pos = content.find(needle).ok_or_else(|| {
anyhow::anyhow!("could not locate items array closing ` ],` in backlog.ncl")
})?;
let mut result = String::with_capacity(content.len() + block.len());
result.push_str(&content[..pos]);
result.push_str(block);
result.push_str(&content[pos..]);
Ok(result)
}
/// Minimal escaping for string values embedded in Nickel double-quoted strings.
fn escape_ncl(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_id_empty() {
assert_eq!(next_item_id(""), "bl-001");
}
#[test]
fn next_id_increments() {
let content = r#"id = "bl-003","#;
assert_eq!(next_item_id(content), "bl-004");
}
#[test]
fn mutate_status() {
let content = "id = \"bl-001\",\nstatus = 'Open,\nupdated = \"2026-01-01\",\n},\n";
let result = mutate_item_fields(
content,
"bl-001",
&[("status", "'Done"), ("updated", "\"2026-03-12\"")],
);
assert!(result.contains("'Done"), "status not updated: {result}");
assert!(
result.contains("\"2026-03-12\""),
"updated not changed: {result}"
);
}
}

View File

@ -0,0 +1,219 @@
//! Passive drift observer.
//!
//! Watches `crates/`, `.ontology/`, and `adrs/` for changes. After a debounce
//! window, spawns `./ontoref sync scan` followed by `./ontoref sync diff`. If
//! the diff output contains MISSING, STALE, DRIFT, or BROKEN items it pushes a
//! custom notification into every registered project's notification ring
//! buffer.
//!
//! Intentionally read-only: no `apply` step is ever triggered automatically.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::notifications::NotificationStore;
const DEBOUNCE: Duration = Duration::from_secs(15);
pub struct DriftWatcher {
_watcher: RecommendedWatcher,
_task: tokio::task::JoinHandle<()>,
}
impl DriftWatcher {
/// Start watching `project_root` for source and ontology changes.
///
/// Watches `crates/`, `.ontology/`, `adrs/` (and `reflection/modes/`).
/// When drift is detected after `./ontoref sync scan && sync diff`, emits
/// a custom notification via `notifications`.
pub fn start(
project_root: &Path,
project_name: String,
notifications: Arc<NotificationStore>,
) -> Result<Self, crate::error::DaemonError> {
let root = project_root.to_path_buf();
let (tx, rx) = mpsc::channel::<PathBuf>(64);
let tx2 = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| match res {
Ok(event) => {
for path in event.paths {
let _ = tx2.try_send(path);
}
}
Err(e) => warn!(error = %e, "drift watcher notify error"),
},
Config::default(),
)
.map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?;
// Watch relevant subtrees; ignore missing dirs gracefully.
for subdir in &["crates", ".ontology", "adrs", "reflection/modes"] {
let path = root.join(subdir);
if path.exists() {
watcher
.watch(&path, RecursiveMode::Recursive)
.map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?;
debug!(path = %path.display(), "drift watcher: watching");
}
}
info!(root = %root.display(), "drift watcher started");
let task = tokio::spawn(drift_loop(rx, root, project_name, notifications));
Ok(Self {
_watcher: watcher,
_task: task,
})
}
}
async fn drift_loop(
mut rx: mpsc::Receiver<PathBuf>,
root: PathBuf,
project: String,
notifications: Arc<NotificationStore>,
) {
loop {
// Block until first change arrives.
let Some(_first) = rx.recv().await else {
return;
};
// Drain events within debounce window.
tokio::time::sleep(DEBOUNCE).await;
while rx.try_recv().is_ok() {}
info!(
project,
"drift watcher: change detected — running scan+diff"
);
run_scan_diff(&root, &project, &notifications).await;
}
}
async fn run_scan_diff(root: &Path, project: &str, notifications: &Arc<NotificationStore>) {
let ontoref_bin = root.join("ontoref");
if !ontoref_bin.exists() {
debug!("drift watcher: ontoref binary not found, skipping scan+diff");
return;
}
// Phase 1: scan.
let scan_ok = tokio::process::Command::new(&ontoref_bin)
.args(["sync", "scan"])
.current_dir(root)
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !scan_ok {
warn!(project, "drift watcher: sync scan failed");
return;
}
// Phase 2: diff — capture stdout to detect drift.
let diff_out = match tokio::process::Command::new(&ontoref_bin)
.args(["sync", "diff"])
.current_dir(root)
.output()
.await
{
Ok(o) => o,
Err(e) => {
warn!(project, error = %e, "drift watcher: sync diff spawn failed");
return;
}
};
let stdout = String::from_utf8_lossy(&diff_out.stdout);
let stderr = String::from_utf8_lossy(&diff_out.stderr);
let combined = format!("{stdout}{stderr}");
let counts = DriftCounts::parse(&combined);
if counts.is_clean() {
debug!(project, "drift watcher: no drift detected");
return;
}
let summary = counts.summary();
info!(project, %summary, "drift watcher: ontology drift detected");
notifications.push_custom(
project,
"ontology_drift",
format!("Ontology drift detected — {summary}"),
Some(serde_json::json!({
"kind": "drift",
"counts": {
"missing": counts.missing,
"stale": counts.stale,
"drift": counts.drift,
"broken": counts.broken,
},
"hint": "Run `./ontoref sync-ontology` to review and apply patches.",
})),
Some("drift-watcher".to_string()),
None,
);
}
struct DriftCounts {
missing: usize,
stale: usize,
drift: usize,
broken: usize,
}
impl DriftCounts {
/// Parse diff output looking for MISSING / STALE / DRIFT / BROKEN markers.
///
/// The format from `./ontoref sync diff` uses these keywords as line
/// prefixes or inline labels. We count distinct occurrences.
fn parse(output: &str) -> Self {
let missing = count_marker(output, "MISSING");
let stale = count_marker(output, "STALE");
let drift = count_marker(output, "DRIFT");
let broken = count_marker(output, "BROKEN");
Self {
missing,
stale,
drift,
broken,
}
}
fn is_clean(&self) -> bool {
self.missing == 0 && self.stale == 0 && self.drift == 0 && self.broken == 0
}
fn summary(&self) -> String {
let mut parts = Vec::new();
if self.missing > 0 {
parts.push(format!("{} MISSING", self.missing));
}
if self.stale > 0 {
parts.push(format!("{} STALE", self.stale));
}
if self.drift > 0 {
parts.push(format!("{} DRIFT", self.drift));
}
if self.broken > 0 {
parts.push(format!("{} BROKEN", self.broken));
}
parts.join(", ")
}
}
fn count_marker(output: &str, marker: &str) -> usize {
output.lines().filter(|l| l.contains(marker)).count()
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
use axum::{
extract::{Form, Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use serde::Deserialize;
use tera::Context;
use super::handlers::{render, UiError};
use crate::api::AppState;
use crate::session::{extract_cookie, COOKIE_NAME};
pub async fn login_page(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Html<String>, UiError> {
let tera = state.tera.as_ref().ok_or(UiError::NotConfigured)?;
let mut ctx = Context::new();
ctx.insert("slug", &slug);
ctx.insert("error", &false);
ctx.insert("base_url", &format!("/ui/{slug}"));
render(tera, "pages/login.html", &ctx).await
}
#[derive(Deserialize)]
pub struct LoginForm {
pub key: String,
}
pub async fn login_submit(
State(state): State<AppState>,
Path(slug): Path<String>,
Form(form): Form<LoginForm>,
) -> Response {
let Some(ref registry) = state.registry else {
return Redirect::to("/ui/").into_response();
};
let Some(ctx) = registry.get(&slug) else {
return StatusCode::NOT_FOUND.into_response();
};
match ctx.verify_key(&form.key) {
Some(role) => {
let token = state.sessions.create(slug.clone(), role);
let cookie = format!(
"{}={}; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age={}",
COOKIE_NAME,
token,
30 * 24 * 3600,
);
(
[(header::SET_COOKIE, cookie)],
Redirect::to(&format!("/ui/{slug}/")),
)
.into_response()
}
None => {
let tera = match state.tera.as_ref() {
Some(t) => t,
None => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let mut tctx = Context::new();
tctx.insert("slug", &slug);
tctx.insert("error", &true);
tctx.insert("base_url", &format!("/ui/{slug}"));
match render(tera, "pages/login.html", &tctx).await {
Ok(html) => html.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
}
pub async fn logout(
State(state): State<AppState>,
Path(slug): Path<String>,
request: axum::extract::Request,
) -> Response {
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, COOKIE_NAME) {
state.sessions.revoke(&token);
}
let clear = format!(
"{}=; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age=0",
COOKIE_NAME
);
(
[(header::SET_COOKIE, clear)],
Redirect::to(&format!("/ui/{slug}/login")),
)
.into_response()
}

View File

@ -0,0 +1,96 @@
pub mod auth;
pub mod backlog_ncl;
pub mod drift_watcher;
pub mod handlers;
pub mod login;
pub mod qa_ncl;
pub mod watcher;
pub use drift_watcher::DriftWatcher;
pub use watcher::TemplateWatcher;
use crate::api::AppState;
pub fn router(state: AppState) -> axum::Router {
if state.registry.is_some() {
multi_router(state)
} else {
single_router(state)
}
}
fn single_router(state: AppState) -> axum::Router {
use axum::routing::{get, post};
axum::Router::new()
.route("/", get(handlers::dashboard))
.route("/graph", get(handlers::graph))
.route("/sessions", get(handlers::sessions))
.route("/notifications", get(handlers::notifications_page))
.route("/modes", get(handlers::modes))
.route("/search", get(handlers::search_page))
.route("/assets/{*path}", get(handlers::serve_asset_single))
.route("/public/{*path}", get(handlers::serve_public_single))
.route("/backlog", get(handlers::backlog_page))
.route("/backlog/status", post(handlers::backlog_update_status))
.route("/backlog/add", post(handlers::backlog_add))
.route("/manage", get(handlers::manage_page))
.route("/manage/add", post(handlers::manage_add))
.route("/manage/remove", post(handlers::manage_remove))
.route("/actions", get(handlers::actions_page))
.route("/actions/run", post(handlers::actions_run))
.route("/qa", get(handlers::qa_page))
.route("/qa/delete", post(handlers::qa_delete))
.route("/qa/update", post(handlers::qa_update))
.with_state(state)
}
fn multi_router(state: AppState) -> axum::Router {
use axum::routing::{get, post};
axum::Router::new()
// Project picker and management at root
.route("/", get(handlers::project_picker))
.route("/manage", get(handlers::manage_page_guarded))
.route("/manage/add", post(handlers::manage_add_guarded))
.route("/manage/remove", post(handlers::manage_remove_guarded))
// Per-project routes — AuthUser extractor enforces auth per project
.route("/{slug}/", get(handlers::dashboard_mp))
.route("/{slug}/graph", get(handlers::graph_mp))
.route("/{slug}/sessions", get(handlers::sessions_mp))
.route("/{slug}/notifications", get(handlers::notifications_mp))
.route("/{slug}/modes", get(handlers::modes_mp))
.route("/{slug}/logout", get(login::logout))
.route("/{slug}/search", get(handlers::search_page_mp))
.route("/{slug}/assets/{*path}", get(handlers::serve_asset_mp))
.route("/{slug}/public/{*path}", get(handlers::serve_public_mp))
.route("/{slug}/backlog", get(handlers::backlog_page_mp))
.route(
"/{slug}/backlog/status",
post(handlers::backlog_update_status_mp),
)
.route("/{slug}/backlog/add", post(handlers::backlog_add_mp))
.route(
"/{slug}/notifications/{id}/action",
post(handlers::notification_action_mp),
)
.route(
"/{slug}/notifications/emit",
post(handlers::emit_notification_mp),
)
.route("/{slug}/compose", get(handlers::compose_page_mp))
.route(
"/{slug}/compose/form/{form_id}",
get(handlers::compose_form_schema_mp),
)
.route("/{slug}/compose/send", post(handlers::compose_send_mp))
.route("/{slug}/actions", get(handlers::actions_page_mp))
.route("/{slug}/actions/run", post(handlers::actions_run_mp))
.route("/{slug}/qa", get(handlers::qa_page_mp))
.route("/{slug}/qa/delete", post(handlers::qa_delete))
.route("/{slug}/qa/update", post(handlers::qa_update))
// Login is public — no AuthUser extractor
.route(
"/{slug}/login",
get(login::login_page).post(login::login_submit),
)
.with_state(state)
}

View File

@ -0,0 +1,288 @@
//! In-place mutations of reflection/qa.ncl.
//!
//! Mirrors backlog_ncl.rs — line-level surgery on a predictable Nickel
//! structure. The QA store has a single `entries` array of `QaEntry` records.
use std::path::Path;
/// Append a new Q&A entry to reflection/qa.ncl.
///
/// Returns the generated id (`qa-NNN`).
pub fn add_entry(
path: &Path,
question: &str,
answer: &str,
actor: &str,
created_at: &str,
tags: &[String],
related: &[String],
) -> anyhow::Result<String> {
let content = std::fs::read_to_string(path)?;
let next_id = next_entry_id(&content);
let block = format!(
r#" {{
id = "{id}",
question = "{question}",
answer = "{answer}",
actor = "{actor}",
created_at = "{created_at}",
tags = {tags},
related = {related},
verified = false,
}},
"#,
id = next_id,
question = escape_ncl(question),
answer = escape_ncl(answer),
actor = escape_ncl(actor),
created_at = escape_ncl(created_at),
tags = ncl_string_array(tags),
related = ncl_string_array(related),
);
let updated = insert_before_entries_close(&content, &block)?;
std::fs::write(path, updated)?;
Ok(next_id)
}
/// Update `question` and `answer` fields for the entry with `id`.
pub fn update_entry(path: &Path, id: &str, question: &str, answer: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let updated = mutate_entry_fields(
&content,
id,
&[
("question", &format!("\"{}\"", escape_ncl(question))),
("answer", &format!("\"{}\"", escape_ncl(answer))),
],
);
std::fs::write(path, updated)?;
Ok(())
}
/// Remove the entry block with `id` from the entries array.
pub fn remove_entry(path: &Path, id: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let updated = delete_entry_block(&content, id)?;
std::fs::write(path, updated)?;
Ok(())
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// Replace field values inside the entry block identified by `id`.
///
/// For each `(field, new_value)`, finds the `field = ...,` line inside the
/// block and substitutes the value in-place.
fn mutate_entry_fields(content: &str, id: &str, fields: &[(&str, &str)]) -> String {
let id_needle = format!("\"{}\"", id);
let mut in_block = false;
let mut result: Vec<String> = Vec::with_capacity(content.lines().count() + 1);
for line in content.lines() {
if !in_block {
if line.contains(&id_needle) && line.contains('=') {
in_block = true;
}
result.push(line.to_string());
continue;
}
let trimmed = line.trim_start();
let replacement = fields.iter().find_map(|(field, new_val)| {
if !trimmed.starts_with(field) {
return None;
}
let eq_pos = trimmed.find('=')?;
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let before_eq = trimmed[..eq_pos].trim_end();
Some(format!("{}{} = {},", indent, before_eq, new_val))
});
result.push(replacement.unwrap_or_else(|| line.to_string()));
if trimmed == "}," {
in_block = false;
}
}
result.join("\n")
}
/// Remove the block containing `id = "qa-NNN"`.
///
/// Scans line by line, tracking ` {` opens and ` },` closes. Removes the
/// entire block (from the ` {` through ` },` inclusive) that contains the
/// id needle.
fn delete_entry_block(content: &str, id: &str) -> anyhow::Result<String> {
let id_needle = format!("\"{}\"", id);
let lines: Vec<&str> = content.lines().collect();
let n = lines.len();
// Find the line index containing the id field.
let id_line = lines
.iter()
.position(|l| l.contains(&id_needle) && l.contains('='))
.ok_or_else(|| anyhow::anyhow!("entry id {} not found in qa.ncl", id))?;
// Scan backward from id_line to find ` {` (block open — exactly 4 spaces +
// `{`).
let block_start = (0..=id_line)
.rev()
.find(|&i| lines[i].trim() == "{")
.ok_or_else(|| anyhow::anyhow!("could not find block open for entry {}", id))?;
// Scan forward from id_line to find ` },` (block close — trim == `},`).
let block_end = (id_line..n)
.find(|&i| lines[i].trim() == "},")
.ok_or_else(|| anyhow::anyhow!("could not find block close for entry {}", id))?;
// Reconstruct without [block_start..=block_end].
let mut result = Vec::with_capacity(n - (block_end - block_start + 1));
for (i, line) in lines.iter().enumerate() {
if i < block_start || i > block_end {
result.push(*line);
}
}
Ok(result.join("\n"))
}
/// Find the highest `qa-NNN` id and return `qa-(NNN+1)` zero-padded to 3
/// digits.
fn next_entry_id(content: &str) -> String {
let max = content
.lines()
.filter_map(|line| {
let t = line.trim();
let rest = t.strip_prefix("id")?;
let val = rest.split('"').nth(1)?;
let num_str = val.strip_prefix("qa-")?;
num_str.parse::<u32>().ok()
})
.max()
.unwrap_or(0);
format!("qa-{:03}", max + 1)
}
/// Insert `block` before the closing ` ],` of the entries array.
fn insert_before_entries_close(content: &str, block: &str) -> anyhow::Result<String> {
let needle = " ],";
let pos = content.find(needle).ok_or_else(|| {
anyhow::anyhow!("could not locate entries array closing ` ],` in qa.ncl")
})?;
let mut result = String::with_capacity(content.len() + block.len());
result.push_str(&content[..pos]);
result.push_str(block);
result.push_str(&content[pos..]);
Ok(result)
}
/// Format a `&[String]` as a Nickel array literal: `["a", "b"]`.
fn ncl_string_array(items: &[String]) -> String {
if items.is_empty() {
return "[]".to_string();
}
let inner: Vec<String> = items
.iter()
.map(|s| format!("\"{}\"", escape_ncl(s)))
.collect();
format!("[{}]", inner.join(", "))
}
/// Minimal escaping for string values embedded in Nickel double-quoted strings.
fn escape_ncl(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = concat!(
"let s = import \"qa\" in\n",
"{\n",
" entries = [\n",
" {\n",
" id = \"qa-001\",\n",
" question = \"What is X?\",\n",
" answer = \"It is Y.\",\n",
" actor = \"human\",\n",
" created_at = \"2026-03-12\",\n",
" tags = [],\n",
" related = [],\n",
" verified = false,\n",
" },\n",
" {\n",
" id = \"qa-002\",\n",
" question = \"Second?\",\n",
" answer = \"Yes.\",\n",
" actor = \"agent\",\n",
" created_at = \"2026-03-12\",\n",
" tags = [],\n",
" related = [],\n",
" verified = false,\n",
" },\n",
" ],\n",
"} | s.QaStore\n",
);
#[test]
fn next_id_empty() {
assert_eq!(next_entry_id(""), "qa-001");
}
#[test]
fn next_id_increments() {
let content = r#"id = "qa-005","#;
assert_eq!(next_entry_id(content), "qa-006");
}
#[test]
fn array_empty() {
assert_eq!(ncl_string_array(&[]), "[]");
}
#[test]
fn array_values() {
let v = vec!["a".to_string(), "b".to_string()];
assert_eq!(ncl_string_array(&v), r#"["a", "b"]"#);
}
#[test]
fn insert_before_close() {
let content = "let s = import \"qa\" in\n{\n entries = [\n ],\n} | s.QaStore\n";
let block = " { id = \"qa-001\" },\n";
let result = insert_before_entries_close(content, block).unwrap();
assert!(result.contains("{ id = \"qa-001\" }"));
assert!(result.contains(" ],"));
}
#[test]
fn update_answer() {
let updated = mutate_entry_fields(SAMPLE, "qa-001", &[("answer", "\"New answer.\"")]);
assert!(updated.contains("\"New answer.\""), "answer not updated");
assert!(
updated.contains("\"Second?\""),
"qa-002 should be untouched"
);
}
#[test]
fn delete_first_entry() {
let updated = delete_entry_block(SAMPLE, "qa-001").unwrap();
assert!(!updated.contains("qa-001"), "qa-001 should be removed");
assert!(updated.contains("qa-002"), "qa-002 should remain");
}
#[test]
fn delete_second_entry() {
let updated = delete_entry_block(SAMPLE, "qa-002").unwrap();
assert!(updated.contains("qa-001"), "qa-001 should remain");
assert!(!updated.contains("qa-002"), "qa-002 should be removed");
}
#[test]
fn delete_missing_id_errors() {
assert!(delete_entry_block(SAMPLE, "qa-999").is_err());
}
}

View File

@ -0,0 +1,83 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::{mpsc, RwLock};
use tracing::{info, warn};
use crate::error::DaemonError;
/// Watches a templates directory for HTML changes and calls
/// `Tera::full_reload()` after a debounce window. Runs independently of the NCL
/// file watcher.
pub struct TemplateWatcher {
_watcher: RecommendedWatcher,
_task: tokio::task::JoinHandle<()>,
}
impl TemplateWatcher {
/// Start watching `templates_dir` recursively for `*.html` changes.
/// On any change, reloads all Tera templates after a 150ms debounce.
pub fn start(templates_dir: &Path, tera: Arc<RwLock<tera::Tera>>) -> Result<Self, DaemonError> {
let (tx, rx) = mpsc::channel::<PathBuf>(64);
let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| match res {
Ok(event) => {
for path in event.paths {
if path.extension().is_some_and(|ext| ext == "html") {
let _ = tx.try_send(path);
}
}
}
Err(e) => warn!(error = %e, "template watcher error"),
},
Config::default(),
)
.map_err(|e| DaemonError::Watcher(e.to_string()))?;
watcher
.watch(templates_dir, RecursiveMode::Recursive)
.map_err(|e| DaemonError::Watcher(e.to_string()))?;
info!(dir = %templates_dir.display(), "template watcher started");
let task = tokio::spawn(reload_loop(rx, tera));
Ok(Self {
_watcher: watcher,
_task: task,
})
}
}
async fn reload_loop(mut rx: mpsc::Receiver<PathBuf>, tera: Arc<RwLock<tera::Tera>>) {
let debounce = Duration::from_millis(150);
loop {
// Block until at least one change arrives
let Some(first) = rx.recv().await else {
return;
};
// Drain additional events within the debounce window
let mut changed = vec![first];
tokio::time::sleep(debounce).await;
while let Ok(path) = rx.try_recv() {
changed.push(path);
}
let names: Vec<String> = changed
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.collect();
let mut guard = tera.write().await;
match guard.full_reload() {
Ok(()) => info!(files = %names.join(", "), "templates reloaded"),
Err(e) => warn!(error = %e, "template reload failed — keeping previous state"),
}
}
}

View File

@ -0,0 +1,271 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::actors::ActorRegistry;
use crate::cache::NclCache;
use crate::notifications::NotificationStore;
/// Directories to watch for NCL changes relative to a project root.
const WATCH_DIRS: &[&str] = &[".ontology", "adrs", "reflection", "ontology"];
/// File watcher that invalidates the NCL cache on filesystem changes
/// and pushes notifications to the notification store.
pub struct FileWatcher {
_watcher: RecommendedWatcher,
_debounce_task: tokio::task::JoinHandle<()>,
}
/// Optional dependencies injected into the file watcher.
pub struct WatcherDeps {
#[cfg(feature = "db")]
pub db: Option<Arc<stratum_db::StratumDb>>,
pub import_path: Option<String>,
pub notifications: Arc<NotificationStore>,
pub actors: Arc<ActorRegistry>,
#[cfg(feature = "nats")]
pub nats: Option<Arc<crate::nats::NatsPublisher>>,
}
impl FileWatcher {
/// Start watching NCL-relevant directories under `project_root`.
///
/// Changes are debounced (200ms) before invalidating the cache.
/// A periodic full invalidation runs every `full_invalidation_secs` as
/// safety net.
pub fn start(
project_root: &Path,
cache: Arc<NclCache>,
full_invalidation_secs: u64,
deps: WatcherDeps,
) -> std::result::Result<Self, crate::error::DaemonError> {
let (tx, rx) = mpsc::channel::<Vec<PathBuf>>(256);
let project_root_owned = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let tx_notify = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| match res {
Ok(event) => {
let ncl_paths: Vec<PathBuf> = event
.paths
.into_iter()
.filter(|p| {
p.extension()
.is_some_and(|ext| ext == "ncl" || ext == "jsonl")
})
.collect();
if !ncl_paths.is_empty() {
let _ = tx_notify.try_send(ncl_paths);
}
}
Err(e) => warn!(error = %e, "file watcher error"),
},
Config::default(),
)
.map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?;
let mut watched_count = 0;
for dir_name in WATCH_DIRS {
let dir = project_root.join(dir_name);
if dir.is_dir() {
if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) {
warn!(dir = %dir.display(), error = %e, "failed to watch directory");
} else {
info!(dir = %dir.display(), "watching directory");
watched_count += 1;
}
}
}
info!(watched_count, "file watcher started");
let debounce_task = tokio::spawn(debounce_loop(
rx,
cache,
project_root_owned,
full_invalidation_secs,
deps,
));
Ok(Self {
_watcher: watcher,
_debounce_task: debounce_task,
})
}
}
/// Debounce filesystem events: collect paths over 200ms windows, then
/// invalidate once. Also runs periodic full invalidation as safety net.
/// Pushes notifications to the store and optionally publishes via NATS.
async fn debounce_loop(
mut rx: mpsc::Receiver<Vec<PathBuf>>,
cache: Arc<NclCache>,
project_root: PathBuf,
full_invalidation_secs: u64,
deps: WatcherDeps,
) {
let debounce = Duration::from_millis(200);
let effective_secs = if full_invalidation_secs == 0 {
60
} else {
full_invalidation_secs
};
let mut full_tick = tokio::time::interval(Duration::from_secs(effective_secs));
full_tick.tick().await; // consume immediate first tick
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
loop {
tokio::select! {
recv = rx.recv() => match recv {
None => {
debug!("watcher channel closed — debounce task exiting");
return;
}
Some(paths) => {
// Collect all events within debounce window
let mut all_paths = paths;
tokio::time::sleep(debounce).await;
while let Ok(more) = rx.try_recv() {
all_paths.extend(more);
}
// Canonicalize, deduplicate, and invalidate.
let mut canonical: Vec<PathBuf> = all_paths
.into_iter()
.filter_map(|p| p.canonicalize().ok())
.collect();
canonical.sort();
canonical.dedup();
let file_names: Vec<String> = canonical
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.collect();
for path in &canonical {
cache.invalidate_file(path);
}
info!(
files = canonical.len(),
names = %file_names.join(", "),
"cache invalidated — files changed"
);
// Convert to relative paths for notification matching
let relative_paths: Vec<String> = canonical
.iter()
.filter_map(|p| {
p.strip_prefix(&project_root)
.ok()
.map(|rel| rel.to_string_lossy().to_string())
})
.collect();
// Publish general file.changed event via NATS (all files, not just ack-required)
#[cfg(feature = "nats")]
{
if !relative_paths.is_empty() {
if let Some(ref nats) = deps.nats {
if let Err(e) = nats.publish_file_changed(&project_name, &relative_paths).await {
warn!(error = %e, "NATS file.changed publish failed");
}
}
}
}
// Push notifications — one per event type, actors need to ack
if !relative_paths.is_empty() {
let notification_ids = deps.notifications.push(
&project_name,
relative_paths.clone(),
None, // source_actor unknown from fs event
);
if !notification_ids.is_empty() {
let actor_tokens = deps.actors.tokens_for_project(&project_name);
// Increment pending count on each actor for each notification
for token in &actor_tokens {
for _ in &notification_ids {
deps.actors.increment_pending(token);
}
}
info!(
notifications = notification_ids.len(),
project = %project_name,
actors = actor_tokens.len(),
"notifications pushed"
);
// Publish via NATS — derive events from the file paths directly
#[cfg(feature = "nats")]
{
if let Some(ref nats) = deps.nats {
let mut published_events = std::collections::HashSet::new();
for file in &relative_paths {
if let Some(event) = crate::notifications::NotificationEvent::from_path(file) {
if published_events.insert(event) {
let event_files: Vec<String> = relative_paths
.iter()
.filter(|f| crate::notifications::NotificationEvent::from_path(f) == Some(event))
.cloned()
.collect();
if let Err(e) = nats.publish_notification(
&project_name,
&event,
&event_files,
).await {
warn!(error = %e, "NATS notification publish failed");
}
}
}
}
}
}
}
}
// Re-seed DB if ontology files changed
#[cfg(feature = "db")]
{
let ontology_changed = canonical.iter().any(|p| {
p.to_string_lossy().contains(".ontology")
});
if ontology_changed {
if let Some(ref db) = deps.db {
info!("re-seeding ontology tables from changed files");
crate::seed::seed_ontology(
db,
&project_root,
&cache,
deps.import_path.as_deref(),
).await;
}
}
}
}
},
_ = full_tick.tick() => {
// Periodic full invalidation as safety net against missed events.
let before = cache.len();
cache.invalidate_all();
if before > 0 {
info!(evicted = before, "periodic full cache invalidation");
}
}
}
}
}

View File

@ -0,0 +1,510 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Ontoref{% endblock title %}</title>
<!-- Apply saved theme + nav-mode before paint to avoid flash -->
<script>(function(){
var t=localStorage.getItem("ontoref-theme");
if(t)document.documentElement.setAttribute("data-theme",t);
var m=localStorage.getItem("ontoref-nav-mode");
if(m==="icons")document.documentElement.classList.add("nav-icons");
else if(m==="names")document.documentElement.classList.add("nav-names");
})()</script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
html.nav-icons .nav-label { display: none !important; }
html.nav-names .nav-icon { display: none !important; }
</style>
{% block head %}{% endblock head %}
</head>
<body class="min-h-screen bg-base-100 text-base-content">
<div class="navbar bg-base-200 shadow-lg px-4">
<!-- Mobile: burger -->
<div class="navbar-start md:hidden">
<div class="dropdown">
<button tabindex="0" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<ul tabindex="0"
class="dropdown-content menu menu-sm bg-base-200 shadow-lg rounded-box z-50 w-52 mt-2 p-2 gap-0.5">
{% if slug %}
<li><a href="/ui/">← Projects</a></li>
<li class="divider my-0.5"></li>
{% endif %}
{% if not hide_project_nav %}
<li><a href="{{ base_url }}/" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
<span class="nav-label">Dashboard</span>
</a></li>
<li><a href="{{ base_url }}/graph" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
</a></li>
<li><a href="{{ base_url }}/sessions" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Sessions</span>
</a></li>
<li><a href="{{ base_url }}/notifications" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
<span class="nav-label">Notifications</span>
</a></li>
<li><a href="{{ base_url }}/modes" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</a></li>
<li><a href="{{ base_url }}/search" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Search</span>
</a></li>
<li><a href="{{ base_url }}/actions" class="gap-1.5 {% block mob_nav_actions %}{% endblock mob_nav_actions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-1.5 {% block mob_nav_qa %}{% endblock mob_nav_qa %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/backlog" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/compose" class="gap-1.5">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<span class="nav-label">Compose</span>
</a></li>
<li class="divider my-0.5"></li>
{% endif %}
{% if not slug or current_role == "admin" %}
<li><a href="/ui/manage">Manage Projects</a></li>
{% endif %}
{% if slug %}
<li><a href="/ui/{{ slug }}/logout">Logout</a></li>
{% endif %}
</ul>
</div>
<!-- Mobile brand -->
<a href="/ui/" class="btn btn-ghost p-1 ml-1">
{% if logo or logo_dark %}
{% if logo %}<img id="proj-logo-light-m" src="{{ logo }}" alt="{{ slug }}" class="h-11 max-w-[8rem] object-contain object-left">{% endif %}
{% if logo_dark %}<img id="proj-logo-dark-m" src="{{ logo_dark }}" alt="{{ slug }}" class="h-11 max-w-[8rem] object-contain object-left">{% endif %}
{% else %}
<span class="text-base font-bold tracking-tight"><span style="color:#C0CCD8;">onto</span><span style="color:#E8A838;">ref</span></span>
{% endif %}
</a>
</div>
<!-- Desktop: brand + back link -->
<div class="navbar-start hidden md:flex items-center gap-2">
<a href="/ui/" class="btn btn-ghost p-1">
{% if logo or logo_dark %}
{% if logo %}<img id="proj-logo-light" src="{{ logo }}" alt="{{ slug }}" class="h-11 max-w-[9rem] object-contain object-left">{% endif %}
{% if logo_dark %}<img id="proj-logo-dark" src="{{ logo_dark }}" alt="{{ slug }}" class="h-11 max-w-[9rem] object-contain object-left">{% endif %}
{% else %}
<span class="text-lg font-bold tracking-tight"><span style="color:#C0CCD8;">onto</span><span style="color:#E8A838;">ref</span></span>
{% endif %}
</a>
{% if slug %}
<a href="/ui/" class="btn btn-xs btn-ghost text-base-content/50 gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Projects
</a>
{% endif %}
</div>
<!-- Desktop: nav links -->
<div class="navbar-center hidden md:flex">
{% if not hide_project_nav %}
<ul class="menu menu-horizontal px-1 gap-0.5 text-sm">
<li><a href="{{ base_url }}/" class="gap-1.5 {% block nav_dashboard %}{% endblock nav_dashboard %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
<span class="nav-label">Dashboard</span>
</a></li>
<li><a href="{{ base_url }}/graph" class="gap-1.5 {% block nav_graph %}{% endblock nav_graph %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-label">Graph</span>
</a></li>
<li><a href="{{ base_url }}/sessions" class="gap-1.5 {% block nav_sessions %}{% endblock nav_sessions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-label">Sessions</span>
</a></li>
<li><a href="{{ base_url }}/notifications" class="gap-1.5 {% block nav_notifications %}{% endblock nav_notifications %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
<span class="nav-label">Notifications</span>
</a></li>
<li><a href="{{ base_url }}/modes" class="gap-1.5 {% block nav_modes %}{% endblock nav_modes %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="nav-label">Modes</span>
</a></li>
<li><a href="{{ base_url }}/search" class="gap-1.5 {% block nav_search %}{% endblock nav_search %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<span class="nav-label">Search</span>
</a></li>
<li><a href="{{ base_url }}/actions" class="gap-2 {% block nav_actions %}{% endblock nav_actions %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="nav-label">Actions</span>
</a></li>
<li><a href="{{ base_url }}/qa" class="gap-2 {% block nav_qa %}{% endblock nav_qa %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="nav-label">Q&amp;A</span>
</a></li>
<li><a href="{{ base_url }}/backlog" class="gap-1.5 {% block nav_backlog %}{% endblock nav_backlog %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span class="nav-label">Backlog</span>
</a></li>
<li><a href="{{ base_url }}/compose" class="gap-1.5 {% block nav_compose %}{% endblock nav_compose %}">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
<span class="nav-label">Compose</span>
</a></li>
</ul>
{% endif %}
</div>
<!-- End: slug, user/session, MCP, manage (main page only), theme -->
<div class="navbar-end gap-1">
{% if slug %}
<span class="text-xs font-mono text-base-content/40 hidden sm:inline">{{ slug }}</span>
{% if current_role == "viewer" %}
<span class="badge badge-xs badge-ghost font-mono hidden sm:inline-flex">viewer</span>
{% endif %}
<!-- User icon → logout -->
<a href="/ui/{{ slug }}/logout" class="btn btn-xs btn-ghost hidden sm:inline-flex" title="Logout">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</a>
{% endif %}
{% if mcp_active %}
<div class="dropdown dropdown-end hidden sm:block">
<button tabindex="0" class="btn btn-xs btn-ghost gap-1" title="MCP Server active">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"/>
</svg>
<span class="text-xs font-mono">MCP</span>
<span class="inline-block w-1.5 h-1.5 rounded-full bg-success"></span>
</button>
<div tabindex="0"
class="dropdown-content bg-base-200 border border-base-content/10 rounded-box z-50 shadow-lg w-64 p-3 mt-1">
<p class="text-xs font-semibold text-base-content/60 mb-2 uppercase tracking-wide">MCP Tools</p>
<ul class="space-y-0.5">
{% for tool in mcp_tools %}
<li class="text-xs font-mono text-base-content/80 py-0.5 px-1 rounded hover:bg-base-300">{{ tool }}</li>
{% endfor %}
</ul>
<div class="mt-2 pt-2 border-t border-base-content/10 text-[11px] text-base-content/40 space-y-0.5">
<div>HTTP: <code class="font-mono">/mcp</code></div>
<div>stdio: <code class="font-mono">--mcp-stdio</code></div>
</div>
</div>
</div>
{% endif %}
<!-- Manage gear: only on main picker page (no project) -->
{% if not slug %}
<a href="/ui/manage" class="btn btn-xs btn-ghost hidden sm:inline-flex" title="Manage projects">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</a>
{% endif %}
<!-- Loopback status badge → dropdown with live health -->
<div class="dropdown dropdown-end hidden sm:block" id="loopback-wrap">
<button tabindex="0" id="loopback-btn"
class="btn btn-xs btn-ghost font-mono gap-1 text-base-content/50"
title="Daemon status">
<span id="loopback-dot" class="inline-block w-1.5 h-1.5 rounded-full bg-base-content/20"></span>
<span id="loopback-host">loopback</span>
</button>
<div tabindex="0"
class="dropdown-content bg-base-200 border border-base-content/10 rounded-box z-50 shadow-lg w-64 p-3 mt-1 text-xs">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-base-content/60 uppercase tracking-wide">Daemon</span>
<button onclick="daemonPing()" class="btn btn-xs btn-ghost py-0 h-5 min-h-0 text-base-content/40">↺ ping</button>
</div>
<div id="loopback-details" class="space-y-1 text-base-content/70">
<div class="text-base-content/30 italic">loading…</div>
</div>
<div class="mt-2 pt-2 border-t border-base-content/10 flex justify-between items-center">
<code class="font-mono text-[11px] text-base-content/40" id="loopback-addr"></code>
<button onclick="navigator.clipboard.writeText(document.getElementById('loopback-addr').textContent)"
class="btn btn-xs btn-ghost py-0 h-5 min-h-0 text-base-content/30" title="Copy address">⎘</button>
</div>
</div>
</div>
<!-- Nav display mode toggle -->
<button id="nav-mode-btn" class="btn btn-ghost btn-sm btn-circle hidden sm:inline-flex" title="Toggle nav display (icons+labels / icons / labels)" aria-label="Toggle nav display">
<svg id="nav-mode-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
</button>
<button id="theme-toggle" class="btn btn-ghost btn-sm btn-circle" title="Toggle theme" aria-label="Toggle theme">
<svg id="icon-sun" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
</svg>
<svg id="icon-moon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/>
</svg>
</button>
</div>
</div>
<main class="container mx-auto px-4 py-6 max-w-7xl">
{% block content %}{% endblock content %}
</main>
{% block scripts %}{% endblock scripts %}
<script>
(function () {
var html = document.documentElement;
// ── Theme ──────────────────────────────────────────────────────────────
var DARK = "dark", LIGHT = "light", THEME_KEY = "ontoref-theme";
var btn = document.getElementById("theme-toggle");
var iconSun = document.getElementById("icon-sun");
var iconMoon = document.getElementById("icon-moon");
function currentTheme() { return html.getAttribute("data-theme") === DARK ? DARK : LIGHT; }
var logoLight = document.getElementById("proj-logo-light");
var logoDark = document.getElementById("proj-logo-dark");
var logoLightM = document.getElementById("proj-logo-light-m");
var logoDarkM = document.getElementById("proj-logo-dark-m");
function applyLogos(theme) {
if (logoLight && !logoDark) {
logoLight.style.display = "";
} else {
if (logoLight) logoLight.style.display = (theme === DARK) ? "none" : "";
if (logoDark) logoDark.style.display = (theme === DARK) ? "" : "none";
}
if (logoLightM && !logoDarkM) {
logoLightM.style.display = "";
} else {
if (logoLightM) logoLightM.style.display = (theme === DARK) ? "none" : "";
if (logoDarkM) logoDarkM.style.display = (theme === DARK) ? "" : "none";
}
}
function applyTheme(theme) {
html.setAttribute("data-theme", theme);
localStorage.setItem(THEME_KEY, theme);
if (theme === DARK) { iconSun.classList.remove("hidden"); iconMoon.classList.add("hidden"); }
else { iconMoon.classList.remove("hidden"); iconSun.classList.add("hidden"); }
applyLogos(theme);
}
applyTheme(currentTheme());
btn.addEventListener("click", function () {
applyTheme(currentTheme() === DARK ? LIGHT : DARK);
});
// ── Nav display mode ───────────────────────────────────────────────────
var NAV_KEY = "ontoref-nav-mode";
var NAV_MODES = ["both", "icons", "names"];
var navBtn = document.getElementById("nav-mode-btn");
var navIcon = document.getElementById("nav-mode-icon");
// Icon paths per mode: both=list, icons=grid, names=text
var NAV_ICONS = {
both: "M4 6h16M4 12h16M4 18h7",
icons: "M4 6h4M4 12h4M4 18h4M10 6h10M10 12h10M10 18h10",
names: "M4 6h16M4 12h16M4 18h16"
};
function currentNavMode() { return localStorage.getItem(NAV_KEY) || "both"; }
function applyNavMode(mode) {
html.classList.remove("nav-icons", "nav-names");
if (mode === "icons") html.classList.add("nav-icons");
if (mode === "names") html.classList.add("nav-names");
localStorage.setItem(NAV_KEY, mode);
if (navIcon) navIcon.setAttribute("d", NAV_ICONS[mode] || NAV_ICONS.both);
if (navBtn) navBtn.title = "Nav: " + mode + " — click to change";
}
applyNavMode(currentNavMode());
if (navBtn) {
navBtn.addEventListener("click", function () {
var cur = currentNavMode();
var next = NAV_MODES[(NAV_MODES.indexOf(cur) + 1) % NAV_MODES.length];
applyNavMode(next);
});
}
// ── Last project ───────────────────────────────────────────────────────
{% if slug %}
try { localStorage.setItem("ontoref-last-project", "{{ slug }}"); } catch (_) {}
{% endif %}
})();
// ── Browser session auto-registration ─────────────────────────────────
{% if slug %}
(function () {
var SLUG = "{{ slug }}";
var SK = "ontoref-session:" + SLUG;
var TTL = 29 * 60 * 1000; // 29 min — shorter than server stale timeout
var HB_SECS = 60 * 1000; // heartbeat every 60s
function stored() {
try { return JSON.parse(sessionStorage.getItem(SK)); } catch(_) { return null; }
}
function currentPrefs() {
return {
theme: localStorage.getItem("ontoref-theme") || "dark",
nav_mode: localStorage.getItem("ontoref-nav-mode") || "both",
};
}
function syncProfile(token) {
fetch("/actors/" + encodeURIComponent(token) + "/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ preferences: currentPrefs() }),
}).catch(function(){});
}
function register() {
var pid = Math.floor(Math.random() * 90000) + 10000;
fetch("/actors/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
actor_type: "browser",
hostname: window.location.hostname || "browser",
pid: pid,
project: SLUG,
role: "viewer",
preferences: currentPrefs(),
}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
try {
sessionStorage.setItem(SK, JSON.stringify({
token: data.token,
expires_at: Date.now() + TTL,
}));
} catch(_) {}
})
.catch(function(){});
}
function heartbeat() {
var s = stored();
if (!s) { register(); return; }
if (Date.now() > s.expires_at) { register(); return; }
fetch("/actors/" + encodeURIComponent(s.token) + "/touch", { method: "POST" })
.then(function(r) {
if (r.status === 404) register();
}).catch(function(){});
}
var s = stored();
if (!s || Date.now() > s.expires_at) {
register();
}
setInterval(heartbeat, HB_SECS);
})();
{% endif %}
// ── Daemon loopback status ─────────────────────────────────────────────
(function () {
var dot = document.getElementById("loopback-dot");
var host = document.getElementById("loopback-host");
var details = document.getElementById("loopback-details");
var addr = document.getElementById("loopback-addr");
if (!dot) return; // loopback widget not rendered (single-project without flag, etc.)
if (host) host.textContent = window.location.host;
if (addr) addr.textContent = window.location.origin;
function fmtUptime(secs) {
if (secs < 60) return secs + "s";
if (secs < 3600) return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
var h = Math.floor(secs / 3600);
var m = Math.floor((secs % 3600) / 60);
return h + "h " + m + "m";
}
function row(label, value) {
return '<div class="flex justify-between"><span class="text-base-content/40">' +
label + '</span><span class="font-mono">' + value + '</span></div>';
}
function ping() {
fetch("/health")
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (d) {
var total = (d.cache_hits || 0) + (d.cache_misses || 0);
var rate = total > 0 ? Math.round((d.cache_hits / total) * 100) + "%" : "—";
if (dot) {
dot.className = "inline-block w-1.5 h-1.5 rounded-full " +
(d.status === "ok" ? "bg-success" : "bg-warning");
}
if (details) {
details.innerHTML =
row("uptime", fmtUptime(d.uptime_secs || 0)) +
row("actors", d.active_actors || 0) +
row("cache", (d.cache_entries || 0) + " entries") +
row("hit rate", rate);
}
})
.catch(function () {
if (dot) dot.className = "inline-block w-1.5 h-1.5 rounded-full bg-error";
if (details) details.innerHTML = '<div class="text-error/70 italic">unreachable</div>';
});
}
window.daemonPing = ping;
ping();
setInterval(ping, 30000);
})();
</script>
<!-- Version footer -->
<div style="
position: fixed;
bottom: 0.75rem;
right: 1rem;
font-size: 0.7rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
user-select: none;
opacity: 0.55;
letter-spacing: 0.03em;
">
<a href="https://ontoref.dev" target="_blank" rel="noopener" style="text-decoration: none;">
<span style="color: #C0CCD8;">onto</span><span style="color: #E8A838; font-weight: 600;">ref</span>
</a>
<span style="color: #E8A838; font-weight: 600;">v{{ daemon_version | default(value="") }}</span>
<span style="color: #475569; margin: 0 0.25rem;">|</span>
<span style="color: #64748b;">2026</span>
</div>
</body>
</html>

View File

@ -0,0 +1,52 @@
{% macro stat(title, value, desc="", accent="") %}
<div class="stat">
<div class="stat-title">{{ title }}</div>
<div class="stat-value {% if accent %}text-{{ accent }}{% endif %}">{{ value }}</div>
{% if desc %}<div class="stat-desc">{{ desc }}</div>{% endif %}
</div>
{% endmacro stat %}
{% macro badge(text, kind="neutral") %}
<span class="badge badge-{{ kind }} badge-sm font-mono">{{ text }}</span>
{% endmacro badge %}
{% macro event_badge(event) %}
{% if event == "OntologyChanged" %}
<span class="badge badge-warning badge-sm">ontology</span>
{% elif event == "AdrChanged" %}
<span class="badge badge-info badge-sm">adr</span>
{% elif event == "ReflectionChanged" %}
<span class="badge badge-success badge-sm">reflection</span>
{% else %}
<span class="badge badge-neutral badge-sm">{{ event }}</span>
{% endif %}
{% endmacro event_badge %}
{% macro actor_badge(actor_type) %}
{% if actor_type == "developer" %}
<span class="badge badge-primary badge-sm">developer</span>
{% elif actor_type == "agent" %}
<span class="badge badge-secondary badge-sm">agent</span>
{% elif actor_type == "ci" %}
<span class="badge badge-accent badge-sm">ci</span>
{% else %}
<span class="badge badge-neutral badge-sm">{{ actor_type }}</span>
{% endif %}
{% endmacro actor_badge %}
{% macro age(secs) %}
{% if secs < 60 %}{{ secs }}s ago
{% elif secs < 3600 %}{{ secs / 60 | round }}m ago
{% else %}{{ secs / 3600 | round }}h ago
{% endif %}
{% endmacro age %}
{% macro empty_state(message) %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40">
<svg class="w-12 h-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
</svg>
<p class="text-sm">{{ message }}</p>
</div>
{% endmacro empty_state %}

View File

@ -0,0 +1,90 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}Actions — Ontoref{% endblock title %}
{% block nav_actions %}active{% endblock nav_actions %}
{% block mob_nav_actions %}active{% endblock mob_nav_actions %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-xl font-bold">Quick Actions</h1>
<span class="text-xs text-base-content/40 font-mono">Runnable tasks and workflows</span>
</div>
{% if not actions or actions | length == 0 %}
{{ m::empty_state(message="No quick actions configured — add quick_actions to .ontoref/config.ncl") }}
{% else %}
{% set grouped = actions | group_by(attribute="category") %}
{% for cat, cat_actions in grouped %}
<section class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-base-content/50 mb-3">
{% if cat == "docs" %}Documentation
{% elif cat == "sync" %}Synchronization
{% elif cat == "analysis" %}Analysis
{% elif cat == "test" %}Testing
{% else %}{{ cat | title }}{% endif %}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for action in cat_actions %}
<div class="card bg-base-200 border border-base-content/10 hover:border-base-content/20 transition-colors">
<div class="card-body p-4 gap-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-base-300 flex items-center justify-center text-primary">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{% if action.icon == "book-open" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
{% elif action.icon == "refresh" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
{% elif action.icon == "code" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
{% else %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
{% endif %}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-sm leading-tight">{{ action.label }}</h3>
<p class="text-xs text-base-content/50 font-mono mt-0.5">mode: {{ action.mode }}</p>
</div>
</div>
<div class="flex flex-wrap gap-1">
{% for actor in action.actors %}
<span class="badge badge-xs
{% if actor == "developer" %}badge-primary
{% elif actor == "agent" %}badge-secondary
{% elif actor == "ci" %}badge-accent
{% else %}badge-ghost{% endif %}">{{ actor }}</span>
{% endfor %}
</div>
{% if not current_role or current_role == "admin" %}
<div class="card-actions justify-end pt-1">
<form method="post" action="{{ base_url }}/actions/run">
<input type="hidden" name="action_id" value="{{ action.id }}">
<button type="submit" class="btn btn-xs btn-primary gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Run
</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,332 @@
{% extends "base.html" %}
{% block title %}Backlog — Ontoref{% endblock title %}
{% block nav_backlog %}active{% endblock nav_backlog %}
{% block content %}
<div class="mb-5 flex items-center justify-between">
<h1 class="text-xl font-bold">Backlog</h1>
<button onclick="document.getElementById('add-modal').showModal()"
class="btn btn-sm btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New item
</button>
</div>
{% if not has_backlog %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
<p>No <code class="font-mono">reflection/backlog.ncl</code> found in this project.</p>
</div>
{% else %}
<!-- Dashboard summary -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Open</div>
<div class="stat-value text-2xl text-info">{{ stats.open }}</div>
</div>
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">In Progress</div>
<div class="stat-value text-2xl text-warning">{{ stats.inprog }}</div>
</div>
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Done</div>
<div class="stat-value text-2xl text-success">{{ stats.done }}</div>
</div>
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Critical</div>
<div class="stat-value text-2xl text-error">{{ stats.critical }}</div>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2 mb-4" id="filter-bar">
<button class="btn btn-xs filter-btn active-filter" data-filter="all">All ({{ stats.total }})</button>
<button class="btn btn-xs filter-btn" data-filter="Open">Open ({{ stats.open }})</button>
<button class="btn btn-xs filter-btn" data-filter="InProgress">In Progress ({{ stats.inprog }})</button>
<button class="btn btn-xs filter-btn" data-filter="Done">Done ({{ stats.done }})</button>
<div class="ml-auto flex gap-1.5">
<button class="btn btn-xs priority-btn active-priority" data-priority="all">All priorities</button>
<button class="btn btn-xs priority-btn" data-priority="Critical">Critical</button>
<button class="btn btn-xs priority-btn" data-priority="High">High</button>
</div>
</div>
<!-- Items table -->
{% if items %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
<th class="w-20">ID</th>
<th class="w-24">Status</th>
<th class="w-20">Priority</th>
<th class="w-16">Kind</th>
<th>Title</th>
<th class="w-24 text-right">Actions</th>
</tr>
</thead>
<tbody id="backlog-tbody">
{% for it in items %}
<tr class="backlog-row hover:bg-base-200/50"
data-status="{{ it.status }}"
data-priority="{{ it.priority }}">
<td class="font-mono text-xs text-base-content/50">{{ it.id }}</td>
<td>
<span class="badge badge-xs
{% if it.status == "Open" %}badge-info
{% elif it.status == "InProgress" %}badge-warning
{% elif it.status == "Done" %}badge-success
{% else %}badge-ghost{% endif %}">{{ it.status }}</span>
</td>
<td>
<span class="badge badge-xs
{% if it.priority == "Critical" %}badge-error
{% elif it.priority == "High" %}badge-warning
{% else %}badge-ghost{% endif %}">{{ it.priority }}</span>
</td>
<td>
<span class="badge badge-xs badge-ghost">{{ it.kind }}</span>
</td>
<td>
<div class="font-medium text-sm leading-tight">{{ it.title }}</div>
{% if it.detail %}
<div class="text-xs text-base-content/50 leading-tight mt-0.5 line-clamp-1">{{ it.detail }}</div>
{% endif %}
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-xs btn-ghost"></button>
<ul tabindex="0"
class="dropdown-content menu menu-xs bg-base-200 shadow rounded-box z-50 w-40 p-1">
{% if it.status != "InProgress" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="InProgress">
<button type="submit" class="w-full text-left">→ In Progress</button>
</form>
</li>
{% endif %}
{% if it.status != "Done" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Done">
<button type="submit" class="w-full text-left">✓ Done</button>
</form>
</li>
{% endif %}
{% if it.status != "Open" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Open">
<button type="submit" class="w-full text-left">↩ Reopen</button>
</form>
</li>
{% endif %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Cancelled">
<button type="submit" class="w-full text-left text-error">✕ Cancel</button>
</form>
</li>
<li class="border-t border-base-content/10 mt-1 pt-1">
<a href="{{ base_url }}/notifications?kind=backlog_delegation&title={{ it.id | urlencode }}%3A%20{{ it.title | urlencode }}&payload=%7B%22item_id%22%3A%22{{ it.id | urlencode }}%22%2C%22status%22%3A%22{{ it.status | urlencode }}%22%2C%22priority%22%3A%22{{ it.priority | urlencode }}%22%7D"
class="w-full text-left">↗ Send to project</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
No backlog items yet.
</div>
{% endif %}
{% endif %}
{% if slug %}
<!-- Cross-project backlog panel (multi-project mode only) -->
<div class="mt-8 border border-base-content/10 rounded-lg p-4">
<h2 class="text-sm font-semibold text-base-content/60 uppercase tracking-wide mb-3">Cross-project backlog</h2>
<div class="flex gap-2 mb-4">
<input id="peer-slug-input" type="text" placeholder="peer-project-slug"
class="input input-bordered input-sm flex-1 font-mono max-w-xs">
<button id="btn-load-peer" class="btn btn-sm btn-ghost gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Load
</button>
</div>
<div id="peer-backlog">
<p class="text-xs text-base-content/30 italic">Enter a project slug and click Load to view its backlog.</p>
</div>
</div>
{% endif %}
<!-- Add item modal -->
<dialog id="add-modal" class="modal">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-4">New Backlog Item</h3>
<form method="post" action="{{ base_url }}/backlog/add" class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
<input type="text" name="title" required placeholder="Short description of the item"
class="input input-bordered input-sm w-full">
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Kind</span></label>
<select name="kind" class="select select-bordered select-sm">
<option value="Todo">Todo</option>
<option value="Wish">Wish</option>
<option value="Idea">Idea</option>
<option value="Bug">Bug</option>
<option value="Debt">Debt</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Priority</span></label>
<select name="priority" class="select select-bordered select-sm">
<option value="Medium" selected>Medium</option>
<option value="High">High</option>
<option value="Critical">Critical</option>
<option value="Low">Low</option>
</select>
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Detail</span></label>
<textarea name="detail" rows="3" placeholder="Optional detail or acceptance criteria"
class="textarea textarea-bordered textarea-sm w-full"></textarea>
</div>
<div class="modal-action mt-2">
<button type="button" onclick="document.getElementById('add-modal').close()"
class="btn btn-sm btn-ghost">Cancel</button>
<button type="submit" class="btn btn-sm btn-primary">Add item</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endblock content %}
{% block scripts %}
<script>
// ── Filter logic ──────────────────────────────────────────────────────────────
let activeStatus = 'all';
let activePriority = 'all';
function applyFilters() {
document.querySelectorAll('.backlog-row').forEach(row => {
const s = row.dataset.status;
const p = row.dataset.priority;
const showStatus = activeStatus === 'all' || s === activeStatus;
const showPriority = activePriority === 'all' || p === activePriority;
row.classList.toggle('hidden', !(showStatus && showPriority));
});
}
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active-filter', 'btn-primary'));
btn.classList.add('active-filter', 'btn-primary');
activeStatus = btn.dataset.filter;
applyFilters();
});
});
document.querySelectorAll('.priority-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.priority-btn').forEach(b => b.classList.remove('active-priority', 'btn-accent'));
btn.classList.add('active-priority', 'btn-accent');
activePriority = btn.dataset.priority;
applyFilters();
});
});
// Initialise active button styles
document.querySelector('[data-filter="all"]').classList.add('btn-primary');
document.querySelector('[data-priority="all"]').classList.add('btn-accent');
// ── Cross-project backlog ──────────────────────────────────────────────────
(function () {
var peerInput = document.getElementById('peer-slug-input');
var peerBtn = document.getElementById('btn-load-peer');
var peerDiv = document.getElementById('peer-backlog');
if (!peerBtn) return;
function statusBadge(s) {
var cls = s === 'Open' ? 'badge-info' : s === 'InProgress' ? 'badge-warning' : s === 'Done' ? 'badge-success' : 'badge-ghost';
return '<span class="badge badge-xs ' + cls + '">' + esc(s) + '</span>';
}
function priorityBadge(p) {
var cls = p === 'Critical' ? 'badge-error' : p === 'High' ? 'badge-warning' : 'badge-ghost';
return '<span class="badge badge-xs ' + cls + '">' + esc(p) + '</span>';
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function loadPeer() {
var slug = peerInput.value.trim();
if (!slug) { peerInput.focus(); return; }
peerDiv.innerHTML = '<div class="flex items-center gap-2 text-xs text-base-content/40 py-2">'
+ '<span class="loading loading-spinner loading-xs"></span> Loading…</div>';
var url = '/backlog-json?slug=' + encodeURIComponent(slug);
var data;
try {
var res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
data = await res.json();
} catch (err) {
peerDiv.innerHTML = '<p class="text-xs text-error">Failed to load backlog for <code>' + esc(slug) + '</code>: ' + esc(String(err)) + '</p>';
return;
}
var items = data.items || [];
if (items.length === 0) {
peerDiv.innerHTML = '<p class="text-xs text-base-content/40 italic">No backlog items for <code>' + esc(slug) + '</code>.</p>';
return;
}
var rows = items.map(function (it) {
return '<tr>'
+ '<td class="font-mono text-xs text-base-content/50">' + esc(it.id) + '</td>'
+ '<td>' + statusBadge(it.status) + '</td>'
+ '<td>' + priorityBadge(it.priority) + '</td>'
+ '<td class="text-sm">' + esc(it.title) + '</td>'
+ '</tr>';
}).join('');
peerDiv.innerHTML = '<div class="overflow-x-auto rounded-lg border border-base-content/10">'
+ '<table class="table table-xs w-full">'
+ '<thead><tr class="text-xs text-base-content/40 uppercase tracking-wider">'
+ '<th class="w-20">ID</th><th class="w-24">Status</th><th class="w-20">Priority</th><th>Title</th>'
+ '</tr></thead>'
+ '<tbody>' + rows + '</tbody>'
+ '</table></div>'
+ '<p class="text-xs text-base-content/30 mt-2 font-mono">' + items.length + ' items from ' + esc(slug) + '</p>';
}
peerBtn.addEventListener('click', loadPeer);
peerInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') loadPeer();
});
})();
</script>
{% endblock scripts %}

View File

@ -0,0 +1,407 @@
{% extends "base.html" %}
{% block title %}Compose — Ontoref{% endblock title %}
{% block content %}
<div class="mb-5 flex items-center justify-between">
<div>
<h1 class="text-xl font-bold">Agent Task Composer</h1>
<p class="text-sm text-base-content/50 mt-0.5">Select a form template, fill fields, send to an AI provider or export.</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Left: template selector + form fields -->
<div class="space-y-4">
<!-- Template selector -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-2">Template</h2>
{% if forms %}
<select id="form-select" class="select select-bordered select-sm w-full">
<option value="">— choose a form template —</option>
{% for f in forms %}
<option value="{{ f.id }}">{{ f.label }}</option>
{% endfor %}
</select>
{% else %}
<p class="text-xs text-base-content/40">No forms found in <code>reflection/forms/</code>.</p>
{% endif %}
</div>
</div>
<!-- Dynamic form fields -->
<div id="form-fields" class="card bg-base-200 border border-base-content/10 hidden">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-1" id="form-name">Fields</h2>
<p class="text-xs text-base-content/40 mb-3" id="form-description"></p>
<div id="fields-container" class="space-y-3"></div>
</div>
</div>
<!-- System prompt override -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<h2 class="text-sm font-semibold mb-2">System prompt <span class="text-xs font-normal text-base-content/40">(optional)</span></h2>
<textarea id="system-prompt" rows="3"
placeholder="Override default system instructions for the agent..."
class="textarea textarea-bordered textarea-sm w-full font-mono text-xs"></textarea>
</div>
</div>
</div>
<!-- Right: prompt preview + send -->
<div class="space-y-4">
<!-- Assembled prompt preview -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold">Assembled prompt</h2>
<button id="copy-btn" class="btn btn-xs btn-ghost gap-1" onclick="copyPrompt()">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
Copy
</button>
</div>
<textarea id="prompt-preview" rows="12" readonly
placeholder="Fill form fields to see assembled prompt..."
class="textarea textarea-bordered textarea-sm w-full font-mono text-xs bg-base-300 resize-none"></textarea>
</div>
</div>
<!-- Provider + actions -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body py-4 px-5 space-y-3">
<h2 class="text-sm font-semibold">Send / Export</h2>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Provider</span></label>
<select id="provider-select" class="select select-bordered select-sm">
{% for p in providers %}
<option value="{{ p.id }}">{{ p.label }}</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2 flex-wrap">
<button onclick="sendToAPI()" class="btn btn-sm btn-primary gap-1.5 flex-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Send to API
</button>
<button onclick="downloadPlan()" class="btn btn-sm btn-outline gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Save as plan
</button>
</div>
<!-- API response -->
<div id="api-response" class="hidden">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-base-content/60">Response</span>
<button onclick="copyResponse()" class="btn btn-xs btn-ghost">Copy</button>
</div>
<div id="response-text"
class="bg-base-300 rounded p-3 text-xs font-mono whitespace-pre-wrap max-h-64 overflow-auto"></div>
</div>
<div id="api-error" class="hidden alert alert-error text-xs py-2 px-3"></div>
<div id="api-loading" class="hidden flex items-center gap-2 text-xs text-base-content/40">
<span class="loading loading-spinner loading-xs"></span> Sending...
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
const BASE_URL = "{{ base_url }}";
let formSchema = null;
// ── Template selection ────────────────────────────────────────────────────────
document.getElementById('form-select')?.addEventListener('change', async function() {
const id = this.value;
if (!id) {
document.getElementById('form-fields').classList.add('hidden');
formSchema = null;
updatePreview();
return;
}
try {
const resp = await fetch(`${BASE_URL}/compose/form/${encodeURIComponent(id)}`);
if (!resp.ok) throw new Error(await resp.text());
formSchema = await resp.json();
renderFields(formSchema);
updatePreview();
} catch(e) {
console.error('failed to load form schema', e);
}
});
// ── Dynamic field rendering ───────────────────────────────────────────────────
function renderFields(schema) {
document.getElementById('form-name').textContent = schema.name || '';
document.getElementById('form-description').textContent = schema.description || '';
const container = document.getElementById('fields-container');
container.innerHTML = '';
const elements = Array.isArray(schema.elements) ? schema.elements : [];
for (const el of elements) {
if (['section_header', 'section', 'confirm'].includes(el.type)) continue;
const wrap = document.createElement('div');
wrap.className = 'form-control';
const label = document.createElement('label');
label.className = 'label py-0.5';
label.innerHTML = `<span class="label-text text-xs font-medium">${el.prompt || el.name}${el.required ? ' <span class="text-error">*</span>' : ''}</span>`;
wrap.appendChild(label);
if (el.help) {
const help = document.createElement('p');
help.className = 'text-xs text-base-content/40 mb-1';
help.textContent = el.help;
wrap.appendChild(help);
}
let input;
if (el.type === 'select') {
input = document.createElement('select');
input.className = 'select select-bordered select-sm';
for (const opt of (el.options || [])) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label || opt.value;
if (opt.value === el.default) o.selected = true;
input.appendChild(o);
}
} else if (el.type === 'multiselect') {
input = document.createElement('div');
input.className = 'flex flex-wrap gap-2';
for (const opt of (el.options || [])) {
const lbl = document.createElement('label');
lbl.className = 'flex items-center gap-1 text-xs cursor-pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'checkbox checkbox-xs';
cb.value = opt.value;
cb.dataset.name = el.name;
cb.addEventListener('change', updatePreview);
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(opt.label || opt.value));
input.appendChild(lbl);
}
} else if (el.type === 'editor') {
input = document.createElement('textarea');
input.className = 'textarea textarea-bordered textarea-sm w-full font-mono text-xs';
input.rows = 4;
input.placeholder = el.prefix_text ? el.prefix_text.replace(/^#.*\n/gm, '').trim() : '';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'input input-bordered input-sm w-full';
input.placeholder = el.placeholder || '';
if (el.default) input.value = el.default;
}
if (input.tagName !== 'DIV') {
input.dataset.name = el.name;
input.addEventListener('input', updatePreview);
input.addEventListener('change', updatePreview);
}
wrap.appendChild(input);
container.appendChild(wrap);
}
document.getElementById('form-fields').classList.remove('hidden');
}
// ── Prompt assembly ───────────────────────────────────────────────────────────
function collectFields() {
const fields = {};
const container = document.getElementById('fields-container');
container.querySelectorAll('[data-name]').forEach(el => {
const name = el.dataset.name;
if (el.type === 'checkbox') {
if (!fields[name]) fields[name] = [];
if (el.checked) fields[name].push(el.value);
} else {
fields[name] = el.value;
}
});
return fields;
}
function updatePreview() {
if (!formSchema) {
document.getElementById('prompt-preview').value = '';
return;
}
const fields = collectFields();
const lines = [];
lines.push(`# Task: ${formSchema.name || 'Agent Task'}`);
if (formSchema.description) lines.push(`\n${formSchema.description}`);
lines.push('\n## Parameters\n');
const elements = Array.isArray(formSchema.elements) ? formSchema.elements : [];
for (const el of elements) {
if (['section_header', 'section', 'confirm'].includes(el.type)) continue;
const val = fields[el.name];
if (!val || (Array.isArray(val) && val.length === 0)) continue;
const display = Array.isArray(val) ? val.join(', ') : val;
lines.push(`**${el.prompt || el.name}**: ${display}\n`);
}
lines.push('\n## Instructions');
lines.push('Execute this task according to the above parameters. Return results in structured format.');
document.getElementById('prompt-preview').value = lines.join('\n');
}
// ── Send to API ───────────────────────────────────────────────────────────────
async function sendToAPI() {
const prompt = document.getElementById('prompt-preview').value.trim();
if (!prompt) { alert('Fill in form fields first.'); return; }
const providerId = document.getElementById('provider-select').value;
const system = document.getElementById('system-prompt').value.trim();
document.getElementById('api-loading').classList.remove('hidden');
document.getElementById('api-response').classList.add('hidden');
document.getElementById('api-error').classList.add('hidden');
try {
const resp = await fetch(`${BASE_URL}/compose/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId, prompt, system }),
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || JSON.stringify(data));
}
// Extract text from Anthropic or OpenAI response shape
let text = '';
if (data.content && Array.isArray(data.content)) {
text = data.content.map(c => c.text || '').join('\n');
} else if (data.choices && Array.isArray(data.choices)) {
text = data.choices.map(c => c.message?.content || '').join('\n');
} else {
text = JSON.stringify(data, null, 2);
}
document.getElementById('response-text').textContent = text;
document.getElementById('api-response').classList.remove('hidden');
} catch(e) {
document.getElementById('api-error').textContent = e.message || String(e);
document.getElementById('api-error').classList.remove('hidden');
} finally {
document.getElementById('api-loading').classList.add('hidden');
}
}
// ── Download as plan file ─────────────────────────────────────────────────────
function buildNclMeta(formId, fields, provider) {
const date = new Date().toISOString().slice(0, 10);
const fieldLines = Object.entries(fields)
.filter(([, v]) => v !== '' && !(Array.isArray(v) && v.length === 0))
.map(([k, v]) => {
const val = Array.isArray(v) ? `[${v.map(s => `"${s}"`).join(', ')}]` : `"${v.replace(/"/g, '\\"')}"`;
return ` ${k} = ${val},`;
}).join('\n');
return `let S = import "reflection/schemas/plan.ncl" in S.Plan & {
template = "${formId}",
date = "${date}",
provider = "${provider}",
status = 'Draft,
linked_backlog = [],
linked_adrs = [],
fields = {
${fieldLines}
},
dag = [],
}
`;
}
function downloadPlan() {
const prompt = document.getElementById('prompt-preview').value.trim();
if (!prompt) { alert('Fill in form fields first.'); return; }
const formId = document.getElementById('form-select').value || 'task';
const provider = document.getElementById('provider-select').value || '';
const date = new Date().toISOString().slice(0, 10);
const base = `${date}-${formId}`;
// Download .plan.md
const mdBlob = new Blob([prompt], { type: 'text/markdown' });
triggerDownload(mdBlob, `${base}.plan.md`);
// Download .plan.ncl companion
const fields = collectFields();
const nclText = buildNclMeta(formId, fields, provider);
const nclBlob = new Blob([nclText], { type: 'text/plain' });
// Small delay so browsers don't block the second download
setTimeout(() => triggerDownload(nclBlob, `${base}.plan.ncl`), 100);
}
function triggerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// ── Copy helpers ──────────────────────────────────────────────────────────────
function copyPrompt() {
const text = document.getElementById('prompt-preview').value;
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Copied';
setTimeout(() => { btn.innerHTML = orig; }, 1500);
});
}
function copyResponse() {
const text = document.getElementById('response-text').textContent;
navigator.clipboard.writeText(text);
}
// Pre-fill from URL params (?form=...) — useful when launched from backlog "Send to agent"
(function() {
const p = new URLSearchParams(location.search);
const f = p.get('form');
const sel = document.getElementById('form-select');
if (f && sel) {
sel.value = f;
sel.dispatchEvent(new Event('change'));
}
})();
</script>
{% endblock scripts %}

View File

@ -0,0 +1,91 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}Dashboard — Ontoref{% endblock title %}
{% block nav_dashboard %}active{% endblock nav_dashboard %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="text-base-content/60 text-sm font-mono mt-1">{{ project_root }}</p>
</div>
<!-- Daemon stats -->
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto">
{{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }}
{{ m::stat(title="Cache entries", value=cache_entries) }}
{{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }}
{{ m::stat(title="Sessions", value=active_actors, accent="primary") }}
{{ m::stat(title="Notifications", value=notification_count, accent="warning") }}
</div>
<!-- Project status -->
{% if backlog %}
<div class="stats stats-horizontal shadow w-full mb-8 bg-base-200 overflow-x-auto">
{{ m::stat(title="Backlog total", value=backlog.total) }}
{{ m::stat(title="Open", value=backlog.open, accent="warning") }}
{{ m::stat(title="In progress", value=backlog.inprog, accent="info") }}
{{ m::stat(title="Done", value=backlog.done, accent="success") }}
{{ m::stat(title="Critical", value=backlog.critical, accent="error") }}
{{ m::stat(title="ADRs", value=adr_count) }}
{{ m::stat(title="Modes", value=mode_count) }}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="{{ base_url }}/graph" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-primary">Ontology Graph</h2>
<p class="text-sm text-base-content/60">Nodes, edges, and relationships from <code>.ontology/core.ncl</code></p>
</div>
</a>
<a href="{{ base_url }}/sessions" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-secondary">Active Sessions</h2>
<p class="text-sm text-base-content/60">{{ active_actors }} actor(s) currently registered</p>
</div>
</a>
<a href="{{ base_url }}/notifications" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-warning">Notifications</h2>
<p class="text-sm text-base-content/60">{{ notification_count }} notification(s) in store</p>
</div>
</a>
<a href="{{ base_url }}/modes" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-accent">Reflection Modes</h2>
<p class="text-sm text-base-content/60">NCL DAG workflows in <code>reflection/modes/</code></p>
{% if mode_count %}<p class="text-xs text-base-content/40 mt-1">{{ mode_count }} modes</p>{% endif %}
</div>
</a>
<a href="{{ base_url }}/backlog" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-info">Backlog</h2>
{% if backlog %}
<p class="text-sm text-base-content/60">{{ backlog.open }} open · {{ backlog.inprog }} in progress · {{ backlog.critical }} critical</p>
{% else %}
<p class="text-sm text-base-content/60">Work items in <code>reflection/backlog.ncl</code></p>
{% endif %}
</div>
</a>
<a href="{{ base_url }}/search" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title">Search</h2>
<p class="text-sm text-base-content/60">Full-text across nodes, ADRs, and modes</p>
{% if adr_count %}<p class="text-xs text-base-content/40 mt-1">{{ adr_count }} ADRs</p>{% endif %}
</div>
</a>
<a href="{{ base_url }}/actions" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title text-primary">Quick Actions</h2>
<p class="text-sm text-base-content/60">Catalog of runnable tasks and workflows</p>
</div>
</a>
<a href="{{ base_url }}/qa" class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div class="card-body">
<h2 class="card-title">Q&amp;A Bookmarks</h2>
<p class="text-sm text-base-content/60">Saved questions and answers about this project</p>
</div>
</a>
</div>
{% endblock content %}

View File

@ -0,0 +1,458 @@
{% extends "base.html" %}
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<style>
#graph-root {
display: flex;
height: calc(100vh - 148px);
min-height: 400px;
gap: 0;
user-select: none;
}
#cy-wrapper {
flex: 1 1 auto;
min-width: 220px;
overflow: hidden;
border-radius: 0.5rem;
}
#cy { width: 100%; height: 100%; }
#resize-handle {
flex: 0 0 6px;
cursor: col-resize;
background: transparent;
position: relative;
z-index: 20;
transition: background 0.15s;
}
#resize-handle:hover,
#resize-handle.dragging { background: oklch(var(--p) / 0.5); }
#resize-handle::after {
content: "";
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
border-radius: 99px;
background: oklch(var(--bc) / 0.25);
}
#detail-panel {
flex: 0 0 300px;
min-width: 160px;
max-width: 60%;
overflow-y: auto;
overflow-x: hidden;
border-radius: 0.5rem;
}
</style>
{% endblock head %}
{% block content %}
<!-- Toolbar -->
<div class="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
<h1 class="text-xl font-bold">Ontology Graph</h1>
<div class="flex flex-wrap gap-1 items-center">
<!-- Level filters (toggle = hide that level) -->
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
<!-- Pole filters -->
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
<!-- Layout -->
<div class="join">
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
</div>
<button id="btn-reset" class="btn btn-xs btn-ghost">Reset</button>
</div>
</div>
<!-- Split: graph | drag handle | detail -->
<div id="graph-root">
<div id="cy-wrapper" class="bg-base-200">
<div id="cy"></div>
</div>
<div id="resize-handle"></div>
<div id="detail-panel" class="bg-base-200 p-4 hidden">
<div class="flex justify-between items-start mb-2">
<h3 class="font-bold text-base leading-tight" id="d-name"></h3>
<button id="btn-close-panel" class="btn btn-xs btn-ghost ml-2 flex-shrink-0"></button>
</div>
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
<div id="d-artifacts" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Artifacts</p>
<ul id="d-artifact-list" class="text-xs font-mono text-base-content/60 space-y-1 break-all"></ul>
</div>
<div id="d-edges" class="hidden">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
const GRAPH = {{ graph_json | safe }};
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6" };
const LEVEL_SHAPE = {
Axiom: "diamond",
Tension: "ellipse",
Practice: "round-rectangle",
Project: "hexagon",
Moment: "triangle",
};
const EDGE_STYLE = {
ManifestsIn: { color: "#6b7280" },
DependsOn: { color: "#ef4444" },
Resolves: { color: "#22c55e" },
Implies: { color: "#f59e0b" },
Complements: { color: "#3b82f6" },
Contradicts: { color: "#ec4899", dashed: true },
TensionWith: { color: "#f97316", dashed: true },
Contains: { color: "#a3a3a3" },
FlowsTo: { color: "#06b6d4" },
SpiralsWith: { color: "#8b5cf6" },
LimitedBy: { color: "#f43f5e" },
ValidatedBy: { color: "#84cc16" },
};
const nodes = (GRAPH.nodes || []).map(n => ({
data: {
id: n.id,
label: n.name || n.id,
pole: n.pole || "",
level: n.level || "",
description: n.description || "",
invariant: !!n.invariant,
artifact_paths: n.artifact_paths || [],
color: POLE_COLOR[n.pole] || "#6b7280",
shape: LEVEL_SHAPE[n.level] || "ellipse",
}
}));
const edges = (GRAPH.edges || []).map(e => {
const s = EDGE_STYLE[e.kind] || {};
return {
data: {
id: `${e.from}__${e.to}__${e.kind}`,
source: e.from,
target: e.to,
kind: e.kind || "",
note: e.note || "",
color: s.color || "#6b7280",
dashed: !!s.dashed,
}
};
});
// ── Cytoscape ────────────────────────────────────────────────
const cy = cytoscape({
container: document.getElementById("cy"),
elements: { nodes, edges },
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
"shape": "data(shape)",
"width": 32,
"height": 32,
"border-width": 2,
"border-color": "#111827",
"label": "data(label)",
"color": "#f9fafb",
"font-size": "10px",
"font-family": "ui-sans-serif, system-ui, sans-serif",
"font-weight": 500,
"text-valign": "bottom",
"text-halign": "center",
"text-margin-y": 6,
"text-wrap": "wrap",
"text-max-width": "100px",
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-shape": "round-rectangle",
"text-background-padding": "3px",
"text-outline-width": 0,
}
},
{
selector: "node[?invariant]",
style: { "border-color": "#f59e0b", "border-width": 3 }
},
{
selector: "node:selected",
style: { "border-color": "#ffffff", "border-width": 3 }
},
{
selector: "node.faded",
style: { "opacity": 0.2, "text-opacity": 0.2 }
},
{
selector: "edge",
style: {
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"width": 1.5,
"arrow-scale": 0.8,
"opacity": 0.6,
}
},
{
selector: "edge[?dashed]",
style: { "line-style": "dashed", "line-dash-pattern": [6, 4] }
},
{
selector: "edge.faded",
style: { "opacity": 0.05 }
},
{
selector: "edge.highlighted",
style: {
"opacity": 1,
"width": 2.5,
"label": "data(kind)",
"font-size": "9px",
"color": "#e5e7eb",
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-padding": "2px",
"text-background-shape": "round-rectangle",
}
},
],
layout: buildBfsLayout(),
wheelSensitivity: 0.3,
boxSelectionEnabled: false,
minZoom: 0.1,
maxZoom: 4,
});
function buildBfsLayout() {
return {
name: "breadthfirst",
directed: true,
animate: false,
spacingFactor: 2.2,
padding: 48,
fit: true,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
};
}
function buildCoseLayout() {
return {
name: "cose",
animate: false,
randomize: true,
componentSpacing: 120,
nodeRepulsion: 800000,
nodeOverlap: 80,
idealEdgeLength: 220,
edgeElasticity: 20,
nestingFactor: 1.0,
gravity: 2,
numIter: 3000,
initialTemp: 2000,
coolingFactor: 0.99,
minTemp: 1.0,
fit: true,
padding: 60,
};
}
// ── Filters ──────────────────────────────────────────────────
// Track which levels/poles are hidden
const hiddenLevels = new Set();
const hiddenPoles = new Set();
function applyFilters() {
cy.nodes().forEach(n => {
const hide = hiddenLevels.has(n.data("level")) || hiddenPoles.has(n.data("pole"));
if (hide) n.hide(); else n.show();
});
cy.edges().forEach(e => {
if (e.source().hidden() || e.target().hidden()) e.hide();
else e.show();
});
}
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
btn.addEventListener("click", () => {
const level = btn.dataset.level;
if (hiddenLevels.has(level)) {
hiddenLevels.delete(level);
btn.style.opacity = "1";
btn.style.textDecoration = "";
} else {
hiddenLevels.add(level);
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
applyFilters();
});
});
document.querySelectorAll(".filter-btn[data-pole]").forEach(btn => {
btn.addEventListener("click", () => {
const pole = btn.dataset.pole;
if (hiddenPoles.has(pole)) {
hiddenPoles.delete(pole);
btn.style.opacity = "1";
btn.style.textDecoration = "";
} else {
hiddenPoles.add(pole);
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
applyFilters();
});
});
// ── Layout buttons ───────────────────────────────────────────
const btnBfs = document.getElementById("btn-bfs");
const btnCose = document.getElementById("btn-cose");
btnBfs.addEventListener("click", () => {
cy.layout(buildBfsLayout()).run();
btnBfs.classList.add("btn-primary"); btnBfs.classList.remove("btn-ghost");
btnCose.classList.add("btn-ghost"); btnCose.classList.remove("btn-primary");
});
btnCose.addEventListener("click", () => {
cy.layout(buildCoseLayout()).run();
btnCose.classList.add("btn-primary"); btnCose.classList.remove("btn-ghost");
btnBfs.classList.add("btn-ghost"); btnBfs.classList.remove("btn-primary");
});
document.getElementById("btn-reset").addEventListener("click", () => {
cy.elements().removeClass("faded highlighted");
cy.fit(undefined, 48);
closePanel();
});
// ── Node detail panel ────────────────────────────────────────
const panel = document.getElementById("detail-panel");
const dName = document.getElementById("d-name");
const dBadges = document.getElementById("d-badges");
const dDesc = document.getElementById("d-description");
const dArtifacts = document.getElementById("d-artifacts");
const dList = document.getElementById("d-artifact-list");
const dEdges = document.getElementById("d-edges");
const dEdgeList = document.getElementById("d-edge-list");
function closePanel() {
panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted");
}
cy.on("tap", "node", evt => {
const d = evt.target.data();
panel.classList.remove("hidden");
dName.textContent = d.label;
dBadges.innerHTML =
`<span class="badge badge-xs badge-outline">${d.level}</span>` +
`<span class="badge badge-xs" style="background:${d.color};color:#111;border:none">${d.pole}</span>` +
(d.invariant ? `<span class="badge badge-xs badge-warning">invariant</span>` : "");
dDesc.textContent = d.description;
if (d.artifact_paths.length) {
dArtifacts.classList.remove("hidden");
dList.innerHTML = d.artifact_paths.map(p =>
`<li class="break-all"><code>${p}</code></li>`
).join("");
} else {
dArtifacts.classList.add("hidden");
}
const conn = evt.target.connectedEdges();
if (conn.length) {
dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => {
const isSrc = e.data("source") === d.id;
const other = isSrc
? cy.getElementById(e.data("target")).data("label")
: cy.getElementById(e.data("source")).data("label");
const arrow = isSrc ? "→" : "←";
return `<li class="flex gap-1"><span class="opacity-40 flex-shrink-0">${arrow}</span>` +
`<span class="text-base-content/80 flex-shrink-0">${e.data("kind")}</span>` +
`<span class="opacity-60 break-all">${other}</span></li>`;
}).join("");
} else {
dEdges.classList.add("hidden");
}
// Dim non-neighbours
cy.elements().addClass("faded").removeClass("highlighted");
evt.target.closedNeighborhood().removeClass("faded").addClass("highlighted");
});
cy.on("tap", evt => {
if (evt.target === cy) closePanel();
});
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
// ── Resizable split ───────────────────────────────────────────
const handle = document.getElementById("resize-handle");
const cyWrapper = document.getElementById("cy-wrapper");
const graphRoot = document.getElementById("graph-root");
let resizing = false;
handle.addEventListener("mousedown", e => {
resizing = true;
handle.classList.add("dragging");
document.body.style.cursor = "col-resize";
e.preventDefault();
});
document.addEventListener("mousemove", e => {
if (!resizing) return;
const rect = graphRoot.getBoundingClientRect();
const hW = handle.offsetWidth;
const cyW = Math.max(220, e.clientX - rect.left);
const panW = Math.max(160, rect.width - cyW - hW);
if (cyW + panW + hW <= rect.width + 2) {
cyWrapper.style.flex = `0 0 ${cyW}px`;
if (!panel.classList.contains("hidden")) {
panel.style.flex = `0 0 ${panW}px`;
}
cy.resize();
}
});
document.addEventListener("mouseup", () => {
if (!resizing) return;
resizing = false;
handle.classList.remove("dragging");
document.body.style.cursor = "";
});
</script>
{% endblock scripts %}

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Login — {{ slug }} — Ontoref{% endblock title %}
{% block content %}
<div class="flex justify-center pt-16">
<div class="card bg-base-200 shadow-xl w-full max-w-sm">
<div class="card-body gap-4">
<div class="text-center">
<h1 class="text-2xl font-bold"><span style="color:#C0CCD8;">onto</span><span style="color:#E8A838;">ref</span></h1>
<p class="text-base-content/60 text-sm mt-1 font-mono">{{ slug }}</p>
</div>
{% if error %}
<div class="alert alert-error text-sm py-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Invalid key.
</div>
{% endif %}
<form method="POST" action="/ui/{{ slug }}/login" class="flex flex-col gap-3">
<div class="form-control">
<label class="label pb-1">
<span class="label-text text-xs uppercase tracking-wider text-base-content/50">Access key</span>
</label>
<input type="password" name="key" autofocus
class="input input-bordered input-sm font-mono"
placeholder="••••••••••••">
</div>
<button type="submit" class="btn btn-primary btn-sm mt-1">Enter</button>
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Manage Projects — Ontoref{% endblock title %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-xl font-bold">Manage Projects</h1>
{% if registry_path %}
<span class="text-xs font-mono text-base-content/40 truncate max-w-xs" title="{{ registry_path }}">{{ registry_path }}</span>
{% endif %}
</div>
{% if error %}
<div class="alert alert-error mb-4 text-sm">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ error }}</span>
</div>
{% endif %}
<!-- Registered Projects -->
<div class="mb-6">
<h2 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">Registered Projects</h2>
{% if projects %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
<th>Slug</th>
<th>Root</th>
<th>Auth</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr class="hover:bg-base-200/50">
<td class="font-mono font-medium">
<a href="/ui/{{ p.slug }}/" class="link link-hover text-primary">{{ p.slug }}</a>
</td>
<td class="font-mono text-xs text-base-content/60 max-w-xs truncate" title="{{ p.root }}">{{ p.root }}</td>
<td>
{% if p.auth %}
<span class="badge badge-xs badge-warning">protected</span>
{% else %}
<span class="badge badge-xs badge-ghost">open</span>
{% endif %}
</td>
<td class="text-right">
<form method="post" action="/ui/manage/remove" class="inline"
onsubmit="return confirm('Remove project {{ p.slug }}?')">
<input type="hidden" name="slug" value="{{ p.slug }}">
<button type="submit" class="btn btn-xs btn-error btn-outline">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
No projects registered. Add one below.
</div>
{% endif %}
</div>
<!-- Add Project Form -->
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body p-5">
<h2 class="card-title text-sm font-semibold">Add Project</h2>
<form method="post" action="/ui/manage/add" class="flex flex-col sm:flex-row gap-3 mt-2">
<div class="form-control flex-1">
<label class="label py-1">
<span class="label-text text-xs text-base-content/60">Slug</span>
</label>
<input type="text" name="slug" required placeholder="my-project"
class="input input-bordered input-sm font-mono"
pattern="[a-z0-9][a-z0-9\-]*" title="Lowercase letters, digits, hyphens">
</div>
<div class="form-control flex-[3]">
<label class="label py-1">
<span class="label-text text-xs text-base-content/60">Absolute root path</span>
</label>
<input type="text" name="root" required placeholder="/path/to/project"
class="input input-bordered input-sm font-mono">
</div>
<div class="flex items-end">
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}Reflection Modes — Ontoref{% endblock title %}
{% block nav_modes %}active{% endblock nav_modes %}
{% block content %}
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Reflection Modes</h1>
<span class="badge badge-lg badge-neutral">{{ total }}</span>
</div>
{% if showcase %}
<div class="mb-6">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">Project Showcase</p>
<div class="flex flex-wrap gap-2">
{% for s in showcase %}
<a href="{{ s.url }}" target="_blank" rel="noopener"
class="btn btn-sm btn-outline gap-2">
{% if s.id == "branding" %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
{% elif s.id == "web" %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
{% elif s.id == "presentation" %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
</svg>
{% endif %}
{{ s.label }}
<svg class="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if generated %}
<div class="mb-6">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">Generated Artifacts</p>
<div class="flex flex-wrap gap-2">
{% for g in generated %}
<a href="{{ g.url }}" target="_blank" rel="noopener"
class="btn btn-sm btn-ghost gap-2 border border-base-content/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ g.label }}
<svg class="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if modes | length == 0 %}
{{ m::empty_state(message="No reflection modes found in reflection/modes/") }}
{% else %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% for mode in modes %}
<div class="card bg-base-200 shadow">
<div class="card-body p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<h2 class="card-title text-base font-bold font-mono text-primary">
{{ mode.id | default(value="unknown") }}
</h2>
{% if mode._error %}
<span class="badge badge-error badge-sm shrink-0">error</span>
{% else %}
{% set step_count = mode.steps | default(value=[]) | length %}
<span class="badge badge-neutral badge-sm shrink-0">{{ step_count }} step(s)</span>
{% endif %}
</div>
{% if mode._error %}
<p class="text-xs text-error font-mono bg-base-300 p-2 rounded">{{ mode._error }}</p>
{% else %}
<p class="text-sm text-base-content/70 mb-3">{{ mode.trigger | default(value="") }}</p>
{% set steps = mode.steps | default(value=[]) %}
{% if steps | length > 0 %}
<div class="collapse collapse-arrow bg-base-300 rounded">
<input type="checkbox" class="peer">
<div class="collapse-title text-xs font-semibold py-2 min-h-0">
Steps ({{ steps | length }})
</div>
<div class="collapse-content px-3 pb-3">
<ol class="space-y-2 mt-1">
{% for step in steps %}
<li class="flex gap-2">
<span class="badge badge-outline badge-xs font-mono mt-0.5 shrink-0">{{ step.id | default(value=loop.index) }}</span>
<div class="min-w-0">
<p class="text-xs text-base-content/80">{{ step.action | default(value="") }}</p>
{% if step.cmd %}
<code class="text-xs text-accent block mt-0.5 truncate">{{ step.cmd }}</code>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
</div>
{% endif %}
{% if mode.preconditions %}
<div class="mt-2 text-xs text-base-content/40 font-mono">
{{ mode.preconditions | length }} precondition(s)
</div>
{% endif %}
{% endif %}
<div class="mt-3 pt-2 border-t border-base-300">
<code class="text-xs text-base-content/30">{{ mode._file | default(value="") }}</code>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,185 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}Notifications — Ontoref{% endblock title %}
{% block nav_notifications %}active{% endblock nav_notifications %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Notifications</h1>
<p class="text-base-content/50 text-sm mt-0.5">{{ total }} total</p>
</div>
{% if other_projects %}
<button onclick="document.getElementById('emit-modal').showModal()"
class="btn btn-sm btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Emit notification
</button>
{% endif %}
</div>
{% if notifications | length == 0 %}
{{ m::empty_state(message="No notifications in store") }}
{% else %}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full bg-base-200 rounded-lg">
<thead>
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
<th>#</th>
<th>Type</th>
<th>Project</th>
<th>Content</th>
<th>Source</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{% for n in notifications %}
<tr>
<td class="font-mono text-xs text-base-content/40">{{ n.id }}</td>
<td>
{% if n.is_custom %}
<span class="badge badge-xs badge-primary font-mono">{{ n.custom_kind | default(value="custom") }}</span>
{% else %}
{{ m::event_badge(event=n.event) }}
{% endif %}
</td>
<td class="font-mono text-sm">
{{ n.project }}
{% if n.source_project and n.source_project != n.project %}
<span class="text-xs text-base-content/40 block">← {{ n.source_project }}</span>
{% endif %}
</td>
<td>
{% if n.is_custom %}
<div class="font-medium text-sm">{{ n.custom_title | default(value="") }}</div>
{% if n.custom_payload %}
{% set p = n.custom_payload %}
{% if p.actions %}
<!-- DAG action buttons -->
<div class="flex flex-wrap gap-1 mt-1.5">
{% for act in p.actions %}
<form method="post" action="{{ base_url }}/notifications/{{ n.id }}/action" class="inline">
<input type="hidden" name="action_id" value="{{ act.id }}">
<button type="submit" class="btn btn-xs
{% if act.mode == 'auto' %}btn-error
{% elif act.mode == 'semi' %}btn-warning
{% else %}btn-ghost{% endif %} gap-1">
{% if act.mode == 'auto' %}▶{% elif act.mode == 'semi' %}◑{% else %}→{% endif %}
{{ act.label }}
</button>
</form>
{% endfor %}
</div>
{% else %}
<details class="mt-1">
<summary class="text-xs text-base-content/40 cursor-pointer">payload</summary>
<pre class="text-xs text-base-content/60 mt-1 bg-base-300 rounded p-2 max-w-xs overflow-auto">{{ p }}</pre>
</details>
{% endif %}
{% endif %}
{% else %}
<div class="flex flex-col gap-0.5">
{% for f in n.files %}
<code class="text-xs text-base-content/60">{{ f }}</code>
{% endfor %}
</div>
{% endif %}
</td>
<td class="text-xs font-mono text-base-content/50">
{{ n.source_actor | default(value="—") }}
</td>
<td class="text-xs text-base-content/60 whitespace-nowrap">{{ n.age_secs }}s ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if other_projects %}
<!-- Emit notification modal -->
<dialog id="emit-modal" class="modal">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-4">Emit Notification</h3>
<form method="post" action="{{ base_url }}/notifications/emit" class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Target project</span></label>
<select name="target_slug" class="select select-bordered select-sm" required>
{% for p in other_projects %}
<option value="{{ p }}" {% if p == slug %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Kind</span></label>
<input type="text" name="kind" id="emit-kind" required
placeholder="e.g. backlog_delegation"
list="kind-suggestions"
class="input input-bordered input-sm w-full">
<datalist id="kind-suggestions">
<option value="backlog_delegation">
<option value="cross_ref">
<option value="alert">
<option value="review_request">
<option value="status_update">
</datalist>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Actor (optional)</span></label>
<input type="text" name="source_actor" placeholder="your name or role"
class="input input-bordered input-sm w-full">
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
<input type="text" name="title" id="emit-title" required
placeholder="Short description"
class="input input-bordered input-sm w-full">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Payload (JSON, optional)</span></label>
<textarea name="payload" id="emit-payload" rows="4"
placeholder='{ "item_id": "TSK-001", "url": "..." }'
class="textarea textarea-bordered textarea-sm w-full font-mono text-xs"></textarea>
</div>
<div class="modal-action mt-2">
<button type="button" onclick="document.getElementById('emit-modal').close()"
class="btn btn-sm btn-ghost">Cancel</button>
<button type="submit" class="btn btn-sm btn-primary">Send</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endif %}
{% endblock content %}
{% block scripts %}
<script>
// Pre-fill emit modal from URL params (?kind=...&title=...&payload=...&target=...)
(function() {
const p = new URLSearchParams(location.search);
if (!p.has('kind') && !p.has('title')) return;
const modal = document.getElementById('emit-modal');
if (!modal) return;
['kind','title','payload'].forEach(k => {
const v = p.get(k);
const el = document.getElementById('emit-' + k);
if (v && el) el.value = v;
});
const target = p.get('target');
if (target) {
const sel = modal.querySelector('select[name=target_slug]');
if (sel) sel.value = target;
}
modal.showModal();
history.replaceState(null, '', location.pathname);
})();
</script>
{% endblock scripts %}

View File

@ -0,0 +1,294 @@
{% extends "base.html" %}
{% block title %}Projects — Ontoref{% endblock title %}
{% block head %}
<script>
(function(){
var last = "";
try { last = localStorage.getItem("ontoref-last-project") || ""; } catch(_) {}
if (!last) return;
document.addEventListener("DOMContentLoaded", function() {
var banner = document.getElementById("resume-banner");
var lbl = document.getElementById("resume-label");
if (!banner || !lbl) return;
lbl.textContent = last;
banner.href = "/ui/" + encodeURIComponent(last) + "/";
banner.classList.remove("hidden");
});
})();
</script>
{% endblock head %}
{% block content %}
<!-- Resume last project -->
<a id="resume-banner" href="#" class="hidden mb-4 flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary/10 border border-primary/20 hover:bg-primary/20 transition-colors text-sm font-medium">
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Resume <span id="resume-label" class="font-mono text-primary"></span>
<span class="ml-auto text-xs text-base-content/40">last project</span>
</a>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Projects</h1>
<p class="text-base-content/50 text-sm mt-1">{{ projects | length }} project{% if projects | length != 1 %}s{% endif %} registered</p>
</div>
<a href="/ui/manage" class="btn btn-sm btn-ghost gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Manage
</a>
</div>
{% if projects %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% for p in projects %}
<div class="card bg-base-200 border border-base-content/10">
<!-- Card header: always visible -->
<div class="card-body gap-0 py-4 px-5">
<!-- Title row -->
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0">
<a href="/ui/{{ p.slug }}/"
class="card-title text-base font-mono link link-hover text-primary">{{ p.slug }}</a>
{% if p.auth %}
<span class="badge badge-warning badge-xs flex-shrink-0">protected</span>
{% else %}
<span class="badge badge-neutral badge-xs flex-shrink-0">open</span>
{% endif %}
{% if p.default_mode %}
<span class="badge badge-ghost badge-xs flex-shrink-0 font-mono">{{ p.default_mode }}</span>
{% endif %}
{% if p.repo_kind %}
<span class="badge badge-outline badge-xs flex-shrink-0 text-base-content/40">{{ p.repo_kind }}</span>
{% endif %}
</div>
<!-- Quick-access shortcut icons -->
<div class="flex items-center gap-0.5 flex-shrink-0">
<a href="/ui/{{ p.slug }}/search" title="Search"
class="btn btn-ghost btn-xs btn-circle">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</a>
<a href="/ui/{{ p.slug }}/backlog" title="Backlog"
class="btn btn-ghost btn-xs btn-circle">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</a>
<a href="/ui/{{ p.slug }}/" title="Open dashboard"
class="btn btn-ghost btn-xs btn-circle">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
</div>
{% if p.description %}
<p class="text-sm text-base-content/70 leading-snug mb-1.5">{{ p.description }}</p>
{% endif %}
<p class="text-xs font-mono text-base-content/35 truncate mb-2" title="{{ p.root }}">{{ p.root }}</p>
<!-- Git remotes -->
{% if p.repos %}
<div class="flex flex-wrap gap-1 mb-2">
{% for r in p.repos %}
<a href="{{ r.url }}" target="_blank" rel="noopener"
class="badge badge-xs badge-ghost font-mono gap-1 border border-base-content/10 hover:border-primary/40 hover:text-primary transition-colors"
title="{{ r.url }}">
<svg class="w-2.5 h-2.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
{{ r.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Showcase + Generated links -->
{% if p.showcase or p.generated %}
<div class="flex flex-wrap gap-1.5 mb-2">
{% for s in p.showcase %}
<a href="{{ s.url }}" target="_blank" rel="noopener"
class="btn btn-xs btn-ghost gap-1 border border-base-content/10">
{% if s.id == "branding" %}🎨{% elif s.id == "web" %}🌐{% elif s.id == "presentation" %}📊{% endif %}
{{ s.label }}
</a>
{% endfor %}
{% for g in p.generated %}
<a href="{{ g.url }}" target="_blank" rel="noopener"
class="btn btn-xs btn-ghost gap-1 border border-base-content/10 opacity-70">
📄 {{ g.label }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Summary badges -->
<div class="flex flex-wrap gap-1.5 mb-3">
{% if p.session_count > 0 %}
<span class="badge badge-sm badge-success gap-1">
<span class="w-1.5 h-1.5 rounded-full bg-current inline-block"></span>
{{ p.session_count }} session{% if p.session_count != 1 %}s{% endif %}
</span>
{% else %}
<span class="badge badge-sm badge-ghost">no sessions</span>
{% endif %}
{% if p.notif_count > 0 %}
<span class="badge badge-sm badge-warning">{{ p.notif_count }} notif</span>
{% endif %}
{% if p.backlog_open > 0 %}
<span class="badge badge-sm badge-info">{{ p.backlog_open }} open</span>
{% endif %}
{% if p.layers %}
<span class="badge badge-sm badge-ghost">{{ p.layers | length }} layers</span>
{% endif %}
{% if p.op_modes %}
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
{% endif %}
</div>
<!-- Accordion panels -->
<div class="space-y-1">
{% if p.layers or p.op_modes %}
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
Features & Layers
</summary>
<div class="collapse-content px-3 pb-3">
{% if p.layers %}
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1.5">Layers</p>
<div class="space-y-1 mb-3">
{% for l in p.layers %}
<div class="flex gap-2">
<span class="badge badge-xs badge-ghost font-mono flex-shrink-0 mt-0.5">{{ l.id }}</span>
<span class="text-xs text-base-content/70">{{ l.description }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if p.op_modes %}
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1.5">Operational Modes</p>
<div class="space-y-1">
{% for m in p.op_modes %}
<div class="flex gap-2">
<span class="badge badge-xs badge-primary font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
<span class="text-xs text-base-content/70">{{ m.description }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
{% if p.backlog_items %}
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
Backlog
<span class="badge badge-xs badge-info ml-1">{{ p.backlog_open }} open</span>
</summary>
<div class="collapse-content px-3 pb-3">
<div class="space-y-1.5">
{% for it in p.backlog_items %}
<div class="flex items-start gap-1.5">
<span class="badge badge-xs font-mono flex-shrink-0 mt-0.5
{% if it.status == 'Open' %}badge-info
{% elif it.status == 'Done' %}badge-success
{% else %}badge-ghost{% endif %}">
{{ it.status }}
</span>
<span class="badge badge-xs flex-shrink-0 mt-0.5
{% if it.priority == 'Critical' %}badge-error
{% elif it.priority == 'High' %}badge-warning
{% else %}badge-ghost{% endif %}">
{{ it.priority }}
</span>
<span class="text-xs text-base-content/80 leading-tight">{{ it.title }}</span>
</div>
{% endfor %}
</div>
<a href="/ui/{{ p.slug }}/backlog" class="btn btn-xs btn-ghost mt-2 w-full">
Manage backlog →
</a>
</div>
</details>
{% endif %}
{% if p.sessions %}
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
Sessions
<span class="badge badge-xs badge-success ml-1">{{ p.session_count }}</span>
</summary>
<div class="collapse-content px-3 pb-3">
<div class="space-y-1">
{% for s in p.sessions %}
<div class="flex items-center gap-1.5 text-xs">
<span class="badge badge-xs badge-ghost font-mono">{{ s.actor_type }}</span>
<span class="text-base-content/60">{{ s.hostname }}</span>
<span class="text-base-content/30 ml-auto">{{ s.last_seen_ago }}s ago</span>
</div>
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% if p.notifications %}
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
Notifications
<span class="badge badge-xs badge-warning ml-1">{{ p.notif_count }}</span>
</summary>
<div class="collapse-content px-3 pb-3">
<div class="space-y-1">
{% for n in p.notifications %}
<div class="flex items-start gap-1.5 text-xs">
<span class="badge badge-xs badge-ghost flex-shrink-0">{{ n.event }}</span>
<span class="text-base-content/60 truncate">
{% if n.files %}
{{ n.files | first }}
{% set fc = n.files | length %}
{% if fc > 1 %} +{{ fc - 1 }}{% endif %}
{% endif %}
</span>
<span class="text-base-content/30 ml-auto flex-shrink-0">{{ n.age_secs }}s</span>
</div>
{% endfor %}
</div>
</div>
</details>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40">
<svg class="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<p class="text-sm">No projects registered.</p>
<a href="/ui/manage" class="btn btn-sm btn-ghost mt-3">Add a project</a>
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,389 @@
{% extends "base.html" %}
{% block title %}Q&A — Ontoref{% endblock title %}
{% block nav_qa %}active{% endblock nav_qa %}
{% block mob_nav_qa %}active{% endblock mob_nav_qa %}
{% block content %}
<!-- Hidden context for JS -->
<input type="hidden" id="qa-project" value="{% if slug %}{{ slug }}{% else %}__single__{% endif %}">
<!-- Page header -->
<div class="mb-4 flex items-center gap-3">
<h1 class="text-xl font-bold flex items-center gap-2">
Q&amp;A Bookmarks
<span id="qa-count" class="badge badge-ghost badge-sm font-mono hidden">0</span>
</h1>
<div class="flex-1"></div>
<button id="btn-add-new" class="btn btn-sm btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add new Q&amp;A
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[calc(100vh-200px)] min-h-[480px]">
<!-- Left column: list -->
<div class="flex flex-col bg-base-200 rounded-lg overflow-hidden border border-base-content/10">
<!-- Search filter -->
<div class="p-3 border-b border-base-content/10 flex-shrink-0">
<div class="relative">
<input id="qa-filter" type="search" placeholder="Filter by question…"
class="input input-bordered input-sm w-full pr-8 font-mono"
autocomplete="off" spellcheck="false">
<svg class="absolute right-2.5 top-2 w-4 h-4 text-base-content/30 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</div>
</div>
<!-- List -->
<ul id="qa-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
<!-- Empty state for list -->
<div id="qa-list-empty" class="flex-1 flex flex-col items-center justify-center text-base-content/30 text-xs p-6 hidden">
<svg class="w-10 h-10 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p>No Q&amp;A saved yet</p>
<p class="mt-1">Click "Add new Q&amp;A" to get started</p>
</div>
</div>
<!-- Right column: detail / form -->
<div class="flex flex-col bg-base-200 rounded-lg overflow-hidden border border-base-content/10">
<!-- Empty state (default) -->
<div id="qa-detail-empty" class="flex-1 flex flex-col items-center justify-center text-base-content/30 text-sm">
<svg class="w-10 h-10 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Select a Q&amp;A or add a new one
</div>
<!-- View panel -->
<div id="qa-view" class="flex-1 flex flex-col overflow-hidden hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-base-content/10 flex-shrink-0">
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Q&amp;A</span>
<div class="flex gap-1">
<button id="btn-edit-qa" class="btn btn-xs btn-ghost">Edit</button>
<button id="btn-delete-qa" class="btn btn-xs btn-ghost text-error">Delete</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<div class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">Question</div>
<p id="qa-view-question" class="text-sm leading-relaxed whitespace-pre-wrap"></p>
</div>
<div>
<div class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">Answer</div>
<p id="qa-view-answer" class="text-sm leading-relaxed whitespace-pre-wrap text-base-content/80"></p>
</div>
<div class="text-xs text-base-content/30 font-mono" id="qa-view-meta"></div>
</div>
</div>
<!-- Edit / Add form -->
<div id="qa-form" class="flex-1 flex flex-col overflow-hidden hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-base-content/10 flex-shrink-0">
<span id="qa-form-title" class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">New Q&amp;A</span>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Question</span></label>
<textarea id="qa-input-question" rows="4"
placeholder="What is this project's primary architecture constraint?"
class="textarea textarea-bordered textarea-sm w-full font-mono resize-y"></textarea>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Answer <span class="text-base-content/40">(optional — fill later)</span></span></label>
<textarea id="qa-input-answer" rows="6"
placeholder="Leave blank to fill in later…"
class="textarea textarea-bordered textarea-sm w-full resize-y"></textarea>
</div>
</div>
</div>
<div class="flex items-center gap-2 justify-end px-4 py-3 border-t border-base-content/10 flex-shrink-0">
<span id="qa-save-status" class="text-xs text-error flex-1"></span>
<button id="btn-form-cancel" class="btn btn-sm btn-ghost">Cancel</button>
<button id="btn-form-save" class="btn btn-sm btn-primary">Save to DAG</button>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
(function () {
const projectEl = document.getElementById('qa-project');
const PROJECT = projectEl ? projectEl.value : '__single__';
// Server-side entries injected at render time (NCL-backed, canonical).
const SERVER_ENTRIES = {{ entries | default(value=[]) | json_encode() }};
let items = SERVER_ENTRIES;
let selectedId = null;
let editingId = null;
let filterText = '';
// ── DOM refs ─────────────────────────────────────────────────────────────
const qaList = document.getElementById('qa-list');
const qaListEmpty = document.getElementById('qa-list-empty');
const qaCount = document.getElementById('qa-count');
const qaFilter = document.getElementById('qa-filter');
const detailEmpty = document.getElementById('qa-detail-empty');
const viewPanel = document.getElementById('qa-view');
const formPanel = document.getElementById('qa-form');
const viewQuestion = document.getElementById('qa-view-question');
const viewAnswer = document.getElementById('qa-view-answer');
const viewMeta = document.getElementById('qa-view-meta');
const formTitle = document.getElementById('qa-form-title');
const inputQuestion = document.getElementById('qa-input-question');
const inputAnswer = document.getElementById('qa-input-answer');
const btnAddNew = document.getElementById('btn-add-new');
const btnEditQa = document.getElementById('btn-edit-qa');
const btnDeleteQa = document.getElementById('btn-delete-qa');
const btnFormCancel = document.getElementById('btn-form-cancel');
const btnFormSave = document.getElementById('btn-form-save');
const saveStatus = document.getElementById('qa-save-status');
// ── Helpers ───────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString(); } catch (_) { return iso; }
}
// ── Render list ──────────────────────────────────────────────────────────
function renderList() {
const filtered = filterText
? items.filter(it => it.question.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (items.length === 0) {
qaListEmpty.classList.remove('hidden');
qaList.innerHTML = '';
} else {
qaListEmpty.classList.add('hidden');
}
qaCount.textContent = items.length;
if (items.length > 0) {
qaCount.classList.remove('hidden');
} else {
qaCount.classList.add('hidden');
}
if (filtered.length === 0 && items.length > 0) {
qaList.innerHTML = '<li class="px-3 py-8 text-center text-xs text-base-content/40">No matches</li>';
return;
}
qaList.innerHTML = filtered.map(it => {
const isSelected = it.id === selectedId;
const preview = it.answer
? it.answer.replace(/\n/g, ' ').slice(0, 80) + (it.answer.length > 80 ? '…' : '')
: '';
return `
<li class="qa-item cursor-pointer hover:bg-base-300 transition-colors ${isSelected ? 'bg-base-300' : ''}"
data-id="${esc(it.id)}">
<div class="px-3 py-2.5 flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-tight truncate">${esc(it.question)}</div>
${preview ? `<div class="text-xs text-base-content/40 leading-tight truncate mt-0.5">${esc(preview)}</div>` : ''}
</div>
<button class="btn-delete-item btn btn-ghost btn-xs btn-circle flex-shrink-0 opacity-30 hover:opacity-100 hover:text-error"
data-id="${esc(it.id)}" title="Delete">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</li>`;
}).join('');
qaList.querySelectorAll('.qa-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.btn-delete-item')) return;
selectItem(el.dataset.id);
});
});
qaList.querySelectorAll('.btn-delete-item').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
deleteItem(el.dataset.id);
});
});
}
// ── Select / view ────────────────────────────────────────────────────────
function selectItem(id) {
selectedId = id;
editingId = null;
const it = items.find(x => x.id === id);
if (!it) return;
showPanel('view');
viewQuestion.textContent = it.question;
viewAnswer.textContent = it.answer || '—';
const ts = it.created_at || it.saved_at || '';
viewMeta.textContent = (ts ? 'saved: ' + fmtDate(ts) + ' · ' : '') + 'id: ' + it.id;
renderList();
}
async function deleteItem(id) {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
try {
await fetch('/qa/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, slug }),
});
} catch (_) { /* best-effort */ }
items = items.filter(x => x.id !== id);
if (selectedId === id) { selectedId = null; showPanel('empty'); }
renderList();
}
// ── Panels ───────────────────────────────────────────────────────────────
function showPanel(which) {
detailEmpty.classList.add('hidden');
viewPanel.classList.add('hidden');
formPanel.classList.add('hidden');
if (which === 'view') viewPanel.classList.remove('hidden');
if (which === 'form') formPanel.classList.remove('hidden');
if (which === 'empty') detailEmpty.classList.remove('hidden');
}
// ── Add / Edit form ──────────────────────────────────────────────────────
function openAddForm() {
editingId = null;
selectedId = null;
formTitle.textContent = 'New Q&A';
inputQuestion.value = '';
inputAnswer.value = '';
showPanel('form');
renderList();
inputQuestion.focus();
}
function openEditForm(id) {
const it = items.find(x => x.id === id);
if (!it) return;
editingId = id;
formTitle.textContent = 'Edit Q&A';
inputQuestion.value = it.question;
inputAnswer.value = it.answer || '';
showPanel('form');
inputQuestion.focus();
}
async function saveForm() {
const q = inputQuestion.value.trim();
if (!q) { inputQuestion.focus(); return; }
const a = inputAnswer.value.trim();
if (editingId) {
if (saveStatus) saveStatus.textContent = 'Saving…';
btnFormSave.disabled = true;
try {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
const res = await fetch('/qa/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingId, question: q, answer: a, slug }),
});
const data = await res.json();
if (data.ok) {
const idx = items.findIndex(x => x.id === editingId);
if (idx >= 0) items[idx] = { ...items[idx], question: q, answer: a };
selectedId = editingId;
editingId = null;
selectItem(selectedId);
if (saveStatus) saveStatus.textContent = '';
} else {
if (saveStatus) saveStatus.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (_) {
if (saveStatus) saveStatus.textContent = 'Network error';
} finally {
btnFormSave.disabled = false;
}
renderList();
return;
}
// New entry — POST to server for NCL persistence.
if (saveStatus) saveStatus.textContent = 'Saving…';
btnFormSave.disabled = true;
try {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
const res = await fetch('/qa/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q, answer: a, actor: 'human', slug }),
});
const data = await res.json();
if (data.ok) {
const entry = { id: data.id, question: q, answer: a, created_at: data.created_at };
items = [entry, ...items];
selectedId = entry.id;
editingId = null;
selectItem(entry.id);
if (saveStatus) saveStatus.textContent = '';
} else {
if (saveStatus) saveStatus.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (err) {
if (saveStatus) saveStatus.textContent = 'Network error';
} finally {
btnFormSave.disabled = false;
}
renderList();
}
// ── Events ───────────────────────────────────────────────────────────────
btnAddNew.addEventListener('click', openAddForm);
btnEditQa.addEventListener('click', () => { if (selectedId) openEditForm(selectedId); });
btnDeleteQa.addEventListener('click', () => { if (selectedId) deleteItem(selectedId); });
btnFormCancel.addEventListener('click', () => {
editingId = null;
if (selectedId) {
selectItem(selectedId);
} else {
showPanel('empty');
}
});
btnFormSave.addEventListener('click', saveForm);
qaFilter.addEventListener('input', () => {
filterText = qaFilter.value;
renderList();
});
// ── Init ─────────────────────────────────────────────────────────────────
renderList();
if (items.length === 0) {
showPanel('empty');
}
})();
</script>
{% endblock scripts %}

View File

@ -0,0 +1,423 @@
{% extends "base.html" %}
{% block title %}Search — Ontoref{% endblock title %}
{% block nav_search %}active{% endblock nav_search %}
{% block content %}
<!-- Hidden context for JS -->
<input type="hidden" id="search-slug" value="{% if slug %}{{ slug }}{% endif %}">
<!-- Page header: tab switcher -->
<div class="mb-4 flex items-center gap-1.5">
<button id="tab-search"
class="btn btn-sm btn-primary gap-1.5"
title="Search ontology nodes, ADRs and modes">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<span class="nav-label">Search</span>
</button>
<button id="tab-bookmarks"
class="btn btn-sm btn-ghost gap-1.5"
title="Saved bookmarks">
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg>
<span class="nav-label">Bookmarks</span>
<span id="bm-count" class="badge badge-xs badge-ghost hidden"></span>
</button>
<div class="flex-1"></div>
<button id="btn-reset-search" class="btn btn-xs btn-ghost">Clear</button>
</div>
<div class="flex gap-0 h-[calc(100vh-196px)] min-h-[400px]">
<!-- Left: tab content (search or bookmarks) -->
<div class="flex flex-col bg-base-200 rounded-l-lg" style="flex:0 0 320px;min-width:200px">
<!-- ── Search pane ── -->
<div id="search-pane" class="flex flex-col flex-1 overflow-hidden">
<div class="p-3 border-b border-base-content/10">
<div class="relative">
<input id="search-input" type="search" placeholder="Search nodes, ADRs, modes…"
class="input input-bordered input-sm w-full pr-8 font-mono"
autocomplete="off" autocorrect="off" spellcheck="false">
<svg class="absolute right-2.5 top-2 w-4 h-4 text-base-content/30 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</div>
</div>
<div id="results-count" class="px-3 py-1 text-xs text-base-content/40 border-b border-base-content/10 hidden"></div>
<ul id="results-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
</div>
<!-- ── Bookmarks pane ── -->
<div id="bookmarks-pane" class="flex flex-col flex-1 overflow-hidden hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-base-content/10">
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Saved bookmarks
</span>
<button id="btn-clear-bookmarks"
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error">
clear all
</button>
</div>
<ul id="bookmarks-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
<div id="bookmarks-empty" class="flex-1 flex items-center justify-center text-base-content/25 text-xs hidden">
No bookmarks yet — star a result to save it
</div>
</div>
</div>
<!-- Resize handle -->
<div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div>
<!-- Right: detail panel -->
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-y-auto p-5 hidden min-w-[200px]">
<!-- filled by JS -->
</div>
<!-- Empty right side when nothing selected -->
<div id="detail-empty" class="flex-1 bg-base-200 rounded-r-lg flex items-center justify-center text-base-content/25 text-sm">
<div class="text-center">
<svg class="w-10 h-10 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
Type to search, click a result to view
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
const slugInput = document.getElementById('search-slug');
const input = document.getElementById('search-input');
const resultsList = document.getElementById('results-list');
const resultsCount = document.getElementById('results-count');
const detail = document.getElementById('detail-panel');
const detailEmpty = document.getElementById('detail-empty');
const resetBtn = document.getElementById('btn-reset-search');
const resizeHandle = document.getElementById('search-resize');
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
const CONTAINER = LEFT_PANEL.parentElement;
let results = [];
let searchTimer = null;
let selectedItem = null;
// ── Tab switching ──────────────────────────────────────────────────────────
const tabSearch = document.getElementById('tab-search');
const tabBm = document.getElementById('tab-bookmarks');
const searchPane = document.getElementById('search-pane');
const bookmarksPane = document.getElementById('bookmarks-pane');
const TAB_KEY = 'ontoref-search-tab';
function setTab(tab) {
const isBm = tab === 'bookmarks';
searchPane.classList.toggle('hidden', isBm);
bookmarksPane.classList.toggle('hidden', !isBm);
tabSearch.className = isBm ? 'btn btn-sm btn-ghost gap-1.5' : 'btn btn-sm btn-primary gap-1.5';
tabBm.className = isBm ? 'btn btn-sm btn-primary gap-1.5' : 'btn btn-sm btn-ghost gap-1.5';
resetBtn.classList.toggle('hidden', isBm);
try { localStorage.setItem(TAB_KEY, tab); } catch (_) {}
if (isBm) renderBookmarks();
else { input.focus(); }
}
tabSearch.addEventListener('click', () => setTab('search'));
tabBm.addEventListener('click', () => setTab('bookmarks'));
// ── Bookmarks ──────────────────────────────────────────────────────────────
const BM_KEY = 'ontoref-bookmarks';
const bmList = document.getElementById('bookmarks-list');
const bmCount = document.getElementById('bm-count');
const bmEmpty = document.getElementById('bookmarks-empty');
const bmClearBtn = document.getElementById('btn-clear-bookmarks');
const PROJECT = slugInput.value || '__single__';
function loadBookmarks() {
try { return JSON.parse(localStorage.getItem(BM_KEY) || '[]'); } catch(_) { return []; }
}
function saveBookmarks(bms) {
try { localStorage.setItem(BM_KEY, JSON.stringify(bms)); } catch(_) {}
}
function bmKey(r) { return `${r.kind}:${r.id}:${PROJECT}`; }
function isBookmarked(r) { return loadBookmarks().some(b => b.key === bmKey(r)); }
function toggleBookmark(r) {
let bms = loadBookmarks();
const key = bmKey(r);
const idx = bms.findIndex(b => b.key === key);
if (idx >= 0) {
bms.splice(idx, 1);
} else {
bms.unshift({ key, kind: r.kind, id: r.id, title: r.title,
description: r.description, project: PROJECT,
pole: r.pole || null, level: r.level || null,
saved: Date.now() });
}
saveBookmarks(bms);
renderBookmarks();
renderResults();
}
function renderBookmarks() {
const bms = loadBookmarks().filter(b => b.project === PROJECT);
if (bms.length > 0) {
bmCount.textContent = bms.length;
bmCount.classList.remove('hidden');
} else {
bmCount.classList.add('hidden');
}
if (bms.length === 0) {
bmList.innerHTML = '';
if (bmEmpty) bmEmpty.classList.remove('hidden');
return;
}
if (bmEmpty) bmEmpty.classList.add('hidden');
bmList.innerHTML = bms.map(b => `
<li class="bm-item cursor-pointer hover:bg-base-300 transition-colors" data-key="${esc(b.key)}">
<div class="px-3 py-2 flex items-center gap-2">
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${b.kind}</span>
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
<button class="btn-unbm btn btn-ghost btn-xs btn-circle flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error" data-key="${esc(b.key)}" title="Remove">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</li>
`).join('');
bmList.querySelectorAll('.bm-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.btn-unbm')) return;
const key = el.dataset.key;
const bm = loadBookmarks().find(b => b.key === key);
if (!bm) return;
if (selectedItem) selectedItem.classList.remove('bg-base-200');
selectedItem = null;
showDetailBm(bm);
});
});
bmList.querySelectorAll('.btn-unbm').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const key = el.dataset.key;
saveBookmarks(loadBookmarks().filter(b => b.key !== key));
renderBookmarks();
renderResults();
});
});
}
function showDetailBm(bm) {
detail.classList.remove('hidden');
detailEmpty.classList.add('hidden');
detail.innerHTML = `
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2>
<div class="flex flex-wrap gap-1 mt-1.5">
<span class="badge badge-xs ${kindCls(bm.kind)}">${bm.kind}</span>
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
${bm.pole ? `<span class="badge badge-xs" style="background:${poleColor(bm.pole)};color:#111;border:none">${esc(bm.pole)}</span>` : ''}
</div>
</div>
<button class="btn btn-ghost btn-xs btn-circle text-warning" title="Remove bookmark"
onclick="toggleBookmark(${JSON.stringify(bm).replace(/</g,'\\u003c')})">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg>
</button>
</div>
<p class="text-xs text-base-content/40 mb-3">${esc(bm.description)}</p>
<p class="text-xs font-mono text-base-content/30">id: ${esc(bm.id)}</p>
`;
}
bmClearBtn.addEventListener('click', () => {
saveBookmarks(loadBookmarks().filter(b => b.project !== PROJECT));
renderBookmarks();
renderResults();
});
// ── Persistence ────────────────────────────────────────────────────────────
const STORAGE_KEY = 'ontoref-search:' + PROJECT;
function saveQuery(q) { try { sessionStorage.setItem(STORAGE_KEY, q); } catch (_) {} }
function loadQuery() { try { return sessionStorage.getItem(STORAGE_KEY) || ''; } catch (_) { return ''; } }
// ── Search ─────────────────────────────────────────────────────────────────
input.addEventListener('input', () => {
saveQuery(input.value);
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 280);
});
input.addEventListener('keydown', e => { if (e.key === 'Escape') { reset(); e.preventDefault(); } });
async function doSearch() {
const q = input.value.trim();
if (!q) { clearResults(); return; }
const slug = slugInput.value;
const url = `/search?q=${encodeURIComponent(q)}${slug ? `&slug=${encodeURIComponent(slug)}` : ''}`;
let data;
try {
const res = await fetch(url);
data = await res.json();
} catch (err) {
resultsCount.textContent = 'Search error';
resultsCount.classList.remove('hidden');
return;
}
results = data.results || [];
renderResults();
}
function renderResults() {
if (results.length === 0) {
resultsList.innerHTML = '<li class="px-3 py-8 text-center text-xs text-base-content/40">No results</li>';
resultsCount.textContent = '0 results';
resultsCount.classList.remove('hidden');
return;
}
resultsCount.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`;
resultsCount.classList.remove('hidden');
resultsList.innerHTML = results.map((r, i) => {
const starred = isBookmarked(r);
return `
<li class="result-item cursor-pointer hover:bg-base-300 transition-colors" data-idx="${i}">
<div class="px-3 py-2.5 flex items-start gap-1">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1 mb-1">
<span class="badge badge-xs ${kindCls(r.kind)}">${r.kind}</span>
${r.level ? `<span class="badge badge-xs badge-ghost">${esc(r.level)}</span>` : ''}
${r.pole ? `<span class="badge badge-xs" style="background:${poleColor(r.pole)};color:#111;border:none">${esc(r.pole)}</span>` : ''}
</div>
<div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div>
<div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div>
</div>
<button class="btn-star btn btn-ghost btn-xs btn-circle flex-shrink-0 mt-0.5 ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}"
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}">
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg>
</button>
</div>
</li>`;
}).join('');
document.querySelectorAll('.result-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.btn-star')) return;
if (selectedItem) selectedItem.classList.remove('bg-base-200');
el.classList.add('bg-base-200');
selectedItem = el;
showDetail(parseInt(el.dataset.idx));
});
});
document.querySelectorAll('.btn-star').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
toggleBookmark(results[parseInt(el.dataset.idx)]);
});
});
}
function showDetail(idx) {
const r = results[idx];
const starred = isBookmarked(r);
detail.classList.remove('hidden');
detailEmpty.classList.add('hidden');
detail.innerHTML = `
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
<div class="flex flex-wrap gap-1 mt-1.5">
<span class="badge badge-xs ${kindCls(r.kind)}">${r.kind}</span>
${r.level ? `<span class="badge badge-xs badge-ghost">${esc(r.level)}</span>` : ''}
${r.pole ? `<span class="badge badge-xs" style="background:${poleColor(r.pole)};color:#111;border:none">${esc(r.pole)}</span>` : ''}
</div>
</div>
<button id="detail-star" class="btn btn-ghost btn-xs btn-circle ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}" title="${starred ? 'Remove bookmark' : 'Bookmark this'}">
<svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg>
</button>
</div>
<p class="text-xs font-mono text-base-content/30 mb-4 truncate">${esc(r.path)}</p>
<div class="space-y-1 text-sm">${r.detail_html}</div>
`;
document.getElementById('detail-star').addEventListener('click', () => {
toggleBookmark(r);
showDetail(idx);
});
}
function clearResults() {
results = [];
resultsList.innerHTML = '';
resultsCount.classList.add('hidden');
detail.classList.add('hidden');
detail.innerHTML = '';
detailEmpty.classList.remove('hidden');
selectedItem = null;
}
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
resetBtn.addEventListener('click', reset);
// ── Resize handle ──────────────────────────────────────────────────────────
let resizing = false;
resizeHandle.addEventListener('mousedown', e => {
resizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!resizing) return;
const rect = CONTAINER.getBoundingClientRect();
const newW = Math.max(160, Math.min(e.clientX - rect.left, rect.width * 0.6));
LEFT_PANEL.style.flex = `0 0 ${newW}px`;
});
document.addEventListener('mouseup', () => {
if (!resizing) return;
resizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
// ── Helpers ────────────────────────────────────────────────────────────────
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6' }[p] || '#6b7280'; }
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── Init ───────────────────────────────────────────────────────────────────
renderBookmarks();
const savedTab = (() => { try { return localStorage.getItem(TAB_KEY); } catch (_) { return null; } })();
setTab(savedTab === 'bookmarks' ? 'bookmarks' : 'search');
const savedQuery = loadQuery();
if (savedQuery) { input.value = savedQuery; doSearch(); }
</script>
{% endblock scripts %}

View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}Sessions — Ontoref{% endblock title %}
{% block nav_sessions %}active{% endblock nav_sessions %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Active Sessions</h1>
<span class="badge badge-lg badge-neutral">{{ total }} actor(s)</span>
</div>
{% if sessions | length == 0 %}
{{ m::empty_state(message="No active actor sessions") }}
{% else %}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full bg-base-200 rounded-lg">
<thead>
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
<th>Token</th>
<th>Type</th>
<th>Role</th>
<th>Project</th>
<th>Host / PID</th>
<th>Registered</th>
<th>Last seen</th>
<th class="text-right">Pending</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td class="font-mono text-xs text-base-content/50">{{ s.token }}</td>
<td>{{ m::actor_badge(actor_type=s.actor_type) }}</td>
<td>
<span class="badge badge-xs font-mono
{% if s.role == 'admin' %}badge-error
{% elif s.role == 'developer' %}badge-primary
{% elif s.role == 'agent' %}badge-secondary
{% elif s.role == 'ci' %}badge-accent
{% else %}badge-ghost{% endif %}">{{ s.role }}</span>
{% if s.has_preferences %}
<span class="ml-1 text-base-content/30 text-xs" title="has saved preferences"></span>
{% endif %}
</td>
<td class="font-mono text-sm">{{ s.project }}</td>
<td class="font-mono text-xs">{{ s.hostname }}:{{ s.pid }}</td>
<td class="text-xs text-base-content/60">{{ s.registered_ago }}s ago</td>
<td class="text-xs text-base-content/60">{{ s.last_seen_ago }}s ago</td>
<td class="text-right">
{% if s.pending_notifications > 0 %}
<span class="badge badge-warning badge-sm">{{ s.pending_notifications }}</span>
{% else %}
<span class="text-base-content/30 text-xs"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,16 @@
[package]
name = "ontoref-ontology"
version = "0.1.0"
edition = "2021"
description = "Load and query project ontology (.ontology/ NCL files) as typed Rust structs"
license = "MIT OR Apache-2.0"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
anyhow = { version = "1" }
thiserror = { version = "2" }
tracing = { version = "0.1" }
[dev-dependencies]
tempfile = { version = "3" }

View File

@ -0,0 +1,17 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OntologyError {
#[error("nickel export failed on '{path}': {stderr}")]
NickelExport { path: String, stderr: String },
#[error("failed to parse '{section}' from nickel output: {source}")]
Parse {
section: &'static str,
#[source]
source: serde_json::Error,
},
#[error("ontology directory '{0}' is missing expected file '{1}'")]
MissingFile(String, String),
}

View File

@ -0,0 +1,11 @@
pub mod error;
pub mod ontology;
pub mod types;
pub use error::OntologyError;
pub use ontology::{Core, Gate, Ontology, State};
pub use types::{
AbstractionLevel, CoreConfig, Coupling, Dimension, DimensionState, Duration, Edge, EdgeType,
GateConfig, Horizon, Membrane, Node, OpeningCondition, Permeability, Pole, Protocol,
SignalType, StateConfig, TensionLevel, Transition,
};

View File

@ -0,0 +1,503 @@
use std::{collections::HashMap, path::Path};
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use tracing::debug;
use crate::{
error::OntologyError,
types::{
AbstractionLevel, CoreConfig, Dimension, Edge, GateConfig, Membrane, Node, Permeability,
StateConfig, TensionLevel,
},
};
/// Full project ontology: core DAG + state FSM + gate membranes.
#[derive(Debug)]
pub struct Ontology {
pub core: Core,
pub state: State,
pub gate: Gate,
}
impl Ontology {
/// Load all three sections from `ontology_dir/` (core.ncl, state.ncl,
/// gate.ncl). Each file is exported via `nickel export --format json`.
///
/// Prefer constructing from pre-fetched JSON via [`Core::from_value`],
/// [`State::from_value`], [`Gate::from_value`] when a daemon or cache
/// is available.
#[deprecated(note = "use from_value() constructors with daemon-provided JSON instead")]
pub fn load(ontology_dir: &Path) -> Result<Self> {
#[allow(deprecated)]
{
let core = Core::load(&ontology_dir.join("core.ncl"))?;
let state = State::load(&ontology_dir.join("state.ncl"))?;
let gate = Gate::load(&ontology_dir.join("gate.ncl"))?;
Ok(Self { core, state, gate })
}
}
/// Construct from pre-fetched JSON values (from stratum-daemon, stratum-db,
/// or any other source that provides the NCL export output).
pub fn from_values(core_json: &Value, state_json: &Value, gate_json: &Value) -> Result<Self> {
Ok(Self {
core: Core::from_value(core_json)?,
state: State::from_value(state_json)?,
gate: Gate::from_value(gate_json)?,
})
}
/// Reload all sections from disk (re-runs nickel export).
#[deprecated(note = "use from_values() with daemon-provided JSON instead")]
pub fn reload(&mut self, ontology_dir: &Path) -> Result<()> {
#[allow(deprecated)]
{
self.core = Core::load(&ontology_dir.join("core.ncl"))?;
self.state = State::load(&ontology_dir.join("state.ncl"))?;
self.gate = Gate::load(&ontology_dir.join("gate.ncl"))?;
Ok(())
}
}
}
// ── Core ──────────────────────────────────────────────────────────────────────
/// The core ontology DAG: nodes (axioms, tensions, practices) and edges.
#[derive(Debug)]
pub struct Core {
nodes: Vec<Node>,
edges: Vec<Edge>,
by_id: HashMap<String, usize>,
}
impl Core {
/// Construct from a pre-fetched JSON value (the output of `nickel export
/// core.ncl`).
pub fn from_value(value: &Value) -> Result<Self> {
let cfg: CoreConfig =
serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
section: "core",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.clone(), i))
.collect();
Ok(Self {
nodes: cfg.nodes,
edges: cfg.edges,
by_id,
})
}
#[deprecated(note = "use Core::from_value() with daemon-provided JSON instead")]
fn load(path: &Path) -> Result<Self> {
let raw = nickel_export(path, "core")?;
let cfg: CoreConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
section: "core",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.clone(), i))
.collect();
Ok(Self {
nodes: cfg.nodes,
edges: cfg.edges,
by_id,
})
}
pub fn nodes(&self) -> &[Node] {
&self.nodes
}
pub fn edges(&self) -> &[Edge] {
&self.edges
}
pub fn node_by_id(&self, id: &str) -> Option<&Node> {
self.by_id.get(id).map(|&i| &self.nodes[i])
}
pub fn axioms(&self) -> impl Iterator<Item = &Node> {
self.nodes
.iter()
.filter(|n| n.level == AbstractionLevel::Axiom)
}
pub fn tensions(&self) -> impl Iterator<Item = &Node> {
self.nodes
.iter()
.filter(|n| n.level == AbstractionLevel::Tension)
}
pub fn practices(&self) -> impl Iterator<Item = &Node> {
self.nodes
.iter()
.filter(|n| n.level == AbstractionLevel::Practice)
}
/// Nodes with `invariant = true` — must never be violated.
pub fn invariants(&self) -> impl Iterator<Item = &Node> {
self.nodes.iter().filter(|n| n.invariant)
}
/// All edges originating from `node_id`.
pub fn edges_from(&self, node_id: &str) -> impl Iterator<Item = &Edge> {
let id = node_id.to_owned();
self.edges.iter().filter(move |e| e.from == id)
}
/// All edges pointing to `node_id`.
pub fn edges_to(&self, node_id: &str) -> impl Iterator<Item = &Edge> {
let id = node_id.to_owned();
self.edges.iter().filter(move |e| e.to == id)
}
}
// ── State ─────────────────────────────────────────────────────────────────────
/// The state FSM: tracked dimensions and their transition graphs.
#[derive(Debug)]
pub struct State {
dimensions: Vec<Dimension>,
by_id: HashMap<String, usize>,
}
impl State {
/// Construct from a pre-fetched JSON value (the output of `nickel export
/// state.ncl`).
pub fn from_value(value: &Value) -> Result<Self> {
let cfg: StateConfig =
serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
section: "state",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.dimensions
.iter()
.enumerate()
.map(|(i, d)| (d.id.clone(), i))
.collect();
Ok(Self {
dimensions: cfg.dimensions,
by_id,
})
}
#[deprecated(note = "use State::from_value() with daemon-provided JSON instead")]
fn load(path: &Path) -> Result<Self> {
let raw = nickel_export(path, "state")?;
let cfg: StateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
section: "state",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.dimensions
.iter()
.enumerate()
.map(|(i, d)| (d.id.clone(), i))
.collect();
Ok(Self {
dimensions: cfg.dimensions,
by_id,
})
}
pub fn dimensions(&self) -> &[Dimension] {
&self.dimensions
}
pub fn dimension_by_id(&self, id: &str) -> Option<&Dimension> {
self.by_id.get(id).map(|&i| &self.dimensions[i])
}
/// Dimensions with high tension in their current state.
pub fn high_tension_dimensions(&self) -> impl Iterator<Item = &Dimension> {
self.dimensions.iter().filter(|d| {
d.states
.iter()
.find(|e| e.id == d.current_state)
.is_some_and(|e| e.tension == TensionLevel::High)
})
}
/// Check if a transition from `current` to `target` is declared for
/// dimension `dim_id`. Returns `Ok(())` if valid, `Err` with the
/// declared blocker if not.
pub fn can_transition(&self, dim_id: &str, to: &str) -> Result<(), String> {
let dim = self
.dimension_by_id(dim_id)
.ok_or_else(|| format!("dimension '{dim_id}' not found"))?;
let transition = dim
.transitions
.iter()
.find(|t| t.from == dim.current_state && t.to == to);
match transition {
Some(t) if t.blocker.is_empty() => Ok(()),
Some(t) => Err(format!("transition blocked: {}", t.blocker)),
None => Err(format!(
"no declared transition from '{}' to '{to}' in dimension '{dim_id}'",
dim.current_state
)),
}
}
}
// ── Gate ──────────────────────────────────────────────────────────────────────
/// The gate: membranes that filter incoming signals.
#[derive(Debug)]
pub struct Gate {
membranes: Vec<Membrane>,
by_id: HashMap<String, usize>,
}
impl Gate {
/// Construct from a pre-fetched JSON value (the output of `nickel export
/// gate.ncl`).
pub fn from_value(value: &Value) -> Result<Self> {
let cfg: GateConfig =
serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse {
section: "gate",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.membranes
.iter()
.enumerate()
.map(|(i, m)| (m.id.clone(), i))
.collect();
Ok(Self {
membranes: cfg.membranes,
by_id,
})
}
#[deprecated(note = "use Gate::from_value() with daemon-provided JSON instead")]
fn load(path: &Path) -> Result<Self> {
let raw = nickel_export(path, "gate")?;
let cfg: GateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
section: "gate",
source: e,
})?;
let by_id: HashMap<String, usize> = cfg
.membranes
.iter()
.enumerate()
.map(|(i, m)| (m.id.clone(), i))
.collect();
Ok(Self {
membranes: cfg.membranes,
by_id,
})
}
pub fn membranes(&self) -> &[Membrane] {
&self.membranes
}
pub fn membrane_by_id(&self, id: &str) -> Option<&Membrane> {
self.by_id.get(id).map(|&i| &self.membranes[i])
}
/// Active membranes that are currently open.
pub fn active_membranes(&self) -> impl Iterator<Item = &Membrane> {
self.membranes.iter().filter(|m| m.active)
}
/// Membranes with `Closed` permeability — signals cannot enter.
pub fn closed_membranes(&self) -> impl Iterator<Item = &Membrane> {
self.membranes
.iter()
.filter(|m| m.permeability == Permeability::Closed)
}
/// Membranes that protect the node with the given id.
pub fn protecting(&self, node_id: &str) -> impl Iterator<Item = &Membrane> {
let id = node_id.to_owned();
self.membranes
.iter()
.filter(move |m| m.protects.iter().any(|p| p == &id))
}
}
// ── Shared ────────────────────────────────────────────────────────────────────
fn nickel_export(path: &Path, section: &'static str) -> Result<Vec<u8>> {
if !path.exists() {
return Err(OntologyError::MissingFile(
path.parent().unwrap_or(path).display().to_string(),
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
)
.into());
}
debug!(section, path = %path.display(), "running nickel export");
let output = std::process::Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(path)
.output()
.with_context(|| format!("running nickel export on '{}'", path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return Err(anyhow!(OntologyError::NickelExport {
path: path.display().to_string(),
stderr,
}));
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn core_from_value_parses_valid_json() {
let json = serde_json::json!({
"nodes": [
{
"id": "test-axiom",
"name": "Test Axiom",
"pole": "Yang",
"level": "Axiom",
"description": "A test axiom",
"invariant": true
}
],
"edges": [
{
"from": "test-axiom",
"to": "test-axiom",
"kind": "Contains",
"weight": 1.0,
"note": ""
}
]
});
let core = Core::from_value(&json).unwrap();
assert_eq!(core.nodes().len(), 1);
assert_eq!(core.edges().len(), 1);
assert!(core.node_by_id("test-axiom").is_some());
assert_eq!(core.axioms().count(), 1);
assert_eq!(core.invariants().count(), 1);
}
#[test]
fn state_from_value_parses_and_transitions() {
let json = serde_json::json!({
"dimensions": [
{
"id": "test-dim",
"name": "Test",
"description": "",
"current_state": "a",
"desired_state": "b",
"horizon": "Weeks",
"states": [],
"transitions": [
{
"from": "a",
"to": "b",
"condition": "ready",
"catalyst": "",
"blocker": "",
"horizon": "Weeks"
}
],
"coupled_with": []
}
]
});
let state = State::from_value(&json).unwrap();
assert_eq!(state.dimensions().len(), 1);
assert!(state.can_transition("test-dim", "b").is_ok());
assert!(state.can_transition("test-dim", "c").is_err());
}
#[test]
fn gate_from_value_parses_membranes() {
let json = serde_json::json!({
"membranes": [
{
"id": "test-gate",
"name": "Test Gate",
"description": "A test membrane",
"permeability": "High",
"accepts": ["HardBug"],
"protects": ["test-axiom"],
"opening_condition": {
"max_tension_dimensions": 2,
"pending_transitions": 1,
"core_stable": true,
"description": "test"
},
"closing_condition": "done",
"protocol": "Observe",
"max_duration": "Weeks",
"active": true
}
]
});
let gate = Gate::from_value(&json).unwrap();
assert_eq!(gate.membranes().len(), 1);
assert_eq!(gate.active_membranes().count(), 1);
assert_eq!(gate.protecting("test-axiom").count(), 1);
}
#[test]
fn ontology_from_values_composes_all_three() {
let core_json = serde_json::json!({
"nodes": [{
"id": "ax", "name": "Ax", "pole": "Yang",
"level": "Axiom", "description": "d", "invariant": false
}],
"edges": []
});
let state_json = serde_json::json!({ "dimensions": [] });
let gate_json = serde_json::json!({ "membranes": [] });
let ont = Ontology::from_values(&core_json, &state_json, &gate_json).unwrap();
assert_eq!(ont.core.nodes().len(), 1);
assert!(ont.state.dimensions().is_empty());
assert!(ont.gate.membranes().is_empty());
}
#[test]
fn from_value_rejects_invalid_json() {
let bad = serde_json::json!({"nodes": "not_an_array"});
assert!(Core::from_value(&bad).is_err());
}
}

View File

@ -0,0 +1,226 @@
use serde::{Deserialize, Serialize};
// ── Core (DAG)
// ────────────────────────────────────────────────────────────────
/// Node polarity in the ontology DAG.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Pole {
Yang,
Yin,
Spiral,
}
/// Abstraction level of a node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AbstractionLevel {
Axiom,
Tension,
Practice,
Project,
Moment,
}
/// Edge type between nodes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeType {
Contains,
TensionWith,
ManifestsIn,
ValidatedBy,
FlowsTo,
CyclesIn,
SpiralsWith,
LimitedBy,
Complements,
}
/// An ontology node (axiom, tension, practice, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub name: String,
pub pole: Pole,
pub level: AbstractionLevel,
pub description: String,
#[serde(default)]
pub invariant: bool,
}
/// A directed edge between two nodes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
pub kind: EdgeType,
#[serde(default = "Edge::default_weight")]
pub weight: f64,
#[serde(default)]
pub note: String,
}
impl Edge {
fn default_weight() -> f64 {
1.0
}
}
/// Deserialization root for core.ncl exports.
#[derive(Debug, Deserialize)]
pub struct CoreConfig {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
// ── State (FSM)
// ───────────────────────────────────────────────────────────────
/// Tension level of a state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TensionLevel {
High,
Medium,
Low,
Ignored,
}
/// Time horizon for a dimension or transition.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Horizon {
Weeks,
Months,
Years,
Continuous,
}
/// A discrete state within a dimension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DimensionState {
pub id: String,
pub name: String,
pub description: String,
pub tension: TensionLevel,
}
/// A valid transition between two states.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transition {
pub from: String,
pub to: String,
pub condition: String,
pub catalyst: String,
pub blocker: String,
pub horizon: Horizon,
}
/// Coupling relationship between dimensions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Coupling {
pub origin: String,
pub destination: String,
pub kind: String,
pub note: String,
}
/// A tracked dimension of the project's state (FSM).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dimension {
pub id: String,
pub name: String,
pub description: String,
pub current_state: String,
pub desired_state: String,
pub horizon: Horizon,
pub states: Vec<DimensionState>,
pub transitions: Vec<Transition>,
#[serde(default)]
pub coupled_with: Vec<String>,
}
/// Deserialization root for state.ncl exports.
#[derive(Debug, Deserialize)]
pub struct StateConfig {
pub dimensions: Vec<Dimension>,
}
// ── Gate (membranes)
// ──────────────────────────────────────────────────────────
/// Membrane permeability level.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Permeability {
High,
Medium,
Low,
Closed,
}
/// Signal processing protocol.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Protocol {
Observe,
Absorb,
Challenge,
Reject,
}
/// Maximum duration a membrane can remain open.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Duration {
Moment,
Days,
Weeks,
Indefinite,
}
/// Known signal types that membranes can accept.
/// Uses `#[serde(other)]` so project-specific extensions deserialize as
/// `Unknown`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignalType {
FrameBreakingQuestion,
UsageFriction,
ProductiveMisunderstanding,
HardBug,
ContextlessInsight,
SignificantSilence,
ConnectsToPractice,
EcosystemRelevance,
DepthDemonstrated,
IdentityReinforcement,
OpportunityAlignment,
#[serde(other)]
Unknown,
}
/// Conditions under which a membrane opens.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpeningCondition {
pub max_tension_dimensions: u32,
pub pending_transitions: u32,
pub core_stable: bool,
pub description: String,
}
/// A gate membrane — filters incoming signals to protect ontology invariants.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Membrane {
pub id: String,
pub name: String,
pub description: String,
pub permeability: Permeability,
pub accepts: Vec<SignalType>,
pub protects: Vec<String>,
pub opening_condition: OpeningCondition,
pub closing_condition: String,
pub protocol: Protocol,
pub max_duration: Duration,
#[serde(default)]
pub active: bool,
}
/// Deserialization root for gate.ncl exports.
#[derive(Debug, Deserialize)]
pub struct GateConfig {
pub membranes: Vec<Membrane>,
}

View File

@ -0,0 +1,32 @@
[package]
name = "ontoref-reflection"
version = "0.1.0"
edition = "2021"
description = "Load, validate, and execute Reflection modes (NCL DAG contracts) against project state"
license = "MIT OR Apache-2.0"
[features]
default = []
nats = ["dep:platform-nats", "dep:bytes"]
[dependencies]
stratum-graph = { path = "../../../stratumiops/crates/stratum-graph" }
stratum-state = { path = "../../../stratumiops/crates/stratum-state" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
anyhow = { version = "1" }
thiserror = { version = "2" }
async-trait = { version = "0.1" }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = { version = "0.1" }
regex = { version = "1" }
platform-nats = { path = "../../../stratumiops/crates/platform-nats", optional = true }
bytes = { version = "1", optional = true }
[dev-dependencies]
tokio-test = { version = "0.4" }
tempfile = { version = "3" }

View File

@ -0,0 +1,200 @@
use std::collections::{HashMap, HashSet, VecDeque};
use crate::{error::ReflectionError, mode::ActionStep};
/// Full DAG validation: uniqueness + referential integrity + cycle detection.
pub fn validate(steps: &[ActionStep]) -> Result<(), ReflectionError> {
check_uniqueness(steps)?;
check_referential_integrity(steps)?;
detect_cycles(steps)?;
Ok(())
}
/// Kahn's algorithm: returns steps grouped into parallel execution layers.
/// Layer 0 has no dependencies; layer N depends only on layers < N.
/// Returns `Err(CycleDetected)` if the graph is not a DAG.
pub fn topological_layers(steps: &[ActionStep]) -> Result<Vec<Vec<String>>, ReflectionError> {
// in_degree[id] = number of steps id depends on
let mut in_degree: HashMap<&str, usize> = steps
.iter()
.map(|s| (s.id.as_str(), s.depends_on.len()))
.collect();
// dependents[id] = steps that depend on id (reverse edges)
let mut dependents: HashMap<&str, Vec<&str>> =
steps.iter().map(|s| (s.id.as_str(), vec![])).collect();
for step in steps {
for dep in &step.depends_on {
dependents
.entry(dep.step.as_str())
.or_default()
.push(step.id.as_str());
}
}
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter_map(|(&id, &d)| (d == 0).then_some(id))
.collect();
let mut layers: Vec<Vec<String>> = Vec::new();
let mut visited = 0usize;
while !queue.is_empty() {
let layer: Vec<&str> = queue.drain(..).collect();
visited += layer.len();
let mut next: Vec<&str> = Vec::new();
for &node in &layer {
for &dep in dependents.get(node).map(Vec::as_slice).unwrap_or(&[]) {
let d = in_degree
.get_mut(dep)
.expect("all step ids must be in in_degree — check uniqueness first");
*d -= 1;
if *d == 0 {
next.push(dep);
}
}
}
layers.push(layer.iter().map(|&s| s.to_string()).collect());
queue.extend(next);
}
if visited != steps.len() {
Err(ReflectionError::CycleDetected)
} else {
Ok(layers)
}
}
fn check_uniqueness(steps: &[ActionStep]) -> Result<(), ReflectionError> {
let mut seen: HashSet<&str> = HashSet::with_capacity(steps.len());
for step in steps {
if !seen.insert(step.id.as_str()) {
return Err(ReflectionError::DuplicateStepId(step.id.clone()));
}
}
Ok(())
}
fn check_referential_integrity(steps: &[ActionStep]) -> Result<(), ReflectionError> {
let ids: HashSet<&str> = steps.iter().map(|s| s.id.as_str()).collect();
let bad: Vec<String> = steps
.iter()
.flat_map(|step| {
step.depends_on.iter().filter_map(|dep| {
if ids.contains(dep.step.as_str()) {
None
} else {
Some(format!(
"step '{}' depends_on unknown '{}'",
step.id, dep.step
))
}
})
})
.collect();
if bad.is_empty() {
Ok(())
} else {
Err(ReflectionError::BadDependencyRefs(bad))
}
}
fn detect_cycles(steps: &[ActionStep]) -> Result<(), ReflectionError> {
topological_layers(steps).map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mode::{Dependency, DependencyKind, OnError};
fn step(id: &str, deps: &[&str]) -> ActionStep {
ActionStep {
id: id.to_string(),
action: id.to_string(),
actor: crate::mode::Actor::Both,
cmd: None,
depends_on: deps
.iter()
.map(|d| Dependency {
step: d.to_string(),
kind: DependencyKind::Always,
condition: None,
})
.collect(),
on_error: OnError::default(),
verify: None,
note: None,
}
}
#[test]
fn linear_chain_produces_single_layers() {
let steps = vec![step("a", &[]), step("b", &["a"]), step("c", &["b"])];
let layers = topological_layers(&steps).unwrap();
assert_eq!(layers.len(), 3);
assert_eq!(layers[0], vec!["a"]);
assert_eq!(layers[1], vec!["b"]);
assert_eq!(layers[2], vec!["c"]);
}
#[test]
fn parallel_deps_form_single_layer() {
// a → {b, c} → d
let steps = vec![
step("a", &[]),
step("b", &["a"]),
step("c", &["a"]),
step("d", &["b", "c"]),
];
let layers = topological_layers(&steps).unwrap();
assert_eq!(layers.len(), 3);
assert_eq!(layers[0], vec!["a"]);
assert!(layers[1].contains(&"b".to_string()));
assert!(layers[1].contains(&"c".to_string()));
assert_eq!(layers[2], vec!["d"]);
}
#[test]
fn cycle_detected() {
let steps = vec![step("a", &["b"]), step("b", &["a"])];
assert!(matches!(
topological_layers(&steps),
Err(ReflectionError::CycleDetected)
));
}
#[test]
fn duplicate_id_rejected() {
let steps = vec![step("a", &[]), step("a", &[])];
assert!(matches!(
check_uniqueness(&steps),
Err(ReflectionError::DuplicateStepId(_))
));
}
#[test]
fn bad_ref_rejected() {
let steps = vec![step("a", &["nonexistent"])];
assert!(matches!(
check_referential_integrity(&steps),
Err(ReflectionError::BadDependencyRefs(_))
));
}
#[test]
fn validate_passes_for_valid_dag() {
let steps = vec![
step("init_repo", &[]),
step("copy_ontology", &["init_repo"]),
step("init_kogral", &["init_repo"]),
step("publish", &["copy_ontology", "init_kogral"]),
];
assert!(validate(&steps).is_ok());
}
}

View File

@ -0,0 +1,25 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ReflectionError {
#[error("duplicate step id: '{0}'")]
DuplicateStepId(String),
#[error("invalid depends_on references: {}", .0.join(", "))]
BadDependencyRefs(Vec<String>),
#[error("cycle detected in step dependency graph")]
CycleDetected,
#[error("unknown parameter placeholders in cmd (not in RunContext.params): {}", .0.join(", "))]
UnknownParams(Vec<String>),
#[error("nickel export failed on '{path}': {stderr}")]
NickelExport { path: String, stderr: String },
#[error("failed to parse ReflectionMode from nickel output: {0}")]
ParseMode(#[from] serde_json::Error),
#[error("step task panicked: {0}")]
TaskPanic(String),
}

View File

@ -0,0 +1,441 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::{anyhow, Context, Result};
use regex::Regex;
use stratum_graph::types::NodeId;
use stratum_state::{PipelineRun, PipelineRunId, PipelineStatus, StateTracker, StepRecord};
use tokio::task::JoinSet;
use tracing::{info, warn};
use crate::{
dag,
error::ReflectionError,
mode::{ActionStep, Actor, ErrorStrategy, ReflectionMode},
};
/// Context provided to a mode execution run.
pub struct RunContext {
/// Project identifier — used in NATS trigger subject.
pub project: String,
/// Parameter values substituted into step `cmd` fields via `{key}`
/// placeholders.
pub params: HashMap<String, String>,
/// State tracker for recording pipeline run and step records.
pub state: Arc<dyn StateTracker>,
/// Optional NATS stream for publishing step completion events.
#[cfg(feature = "nats")]
pub nats: Option<Arc<platform_nats::EventStream>>,
}
/// Result of executing a reflection mode.
#[derive(Debug)]
pub struct ModeRun {
pub mode_id: String,
pub run_id: PipelineRunId,
pub final_status: PipelineStatus,
}
impl ReflectionMode {
/// Validate and execute this mode against the provided context.
/// Steps within each topological layer run concurrently via `JoinSet`.
/// Step failure behaviour is governed by `on_error.strategy`.
pub async fn execute(&self, ctx: &RunContext) -> Result<ModeRun> {
self.validate()
.with_context(|| format!("mode '{}' failed pre-execution DAG validation", self.id))?;
let trigger_subject = format!("ecosystem.reflection.{}.{}", self.id, ctx.project);
let trigger_payload = serde_json::to_value(&ctx.params)
.context("serializing RunContext.params as trigger payload")?;
let run = PipelineRun::new(trigger_subject, trigger_payload);
let run_id = run.id.clone();
ctx.state
.create_run(&run)
.await
.context("creating PipelineRun in state tracker")?;
let layers = dag::topological_layers(&self.steps)
.with_context(|| format!("computing execution layers for mode '{}'", self.id))?;
let step_index: HashMap<&str, &ActionStep> =
self.steps.iter().map(|s| (s.id.as_str(), s)).collect();
for layer in &layers {
let failure = run_layer(layer, &step_index, ctx, &run_id, &self.id).await?;
if let Some(e) = failure {
ctx.state
.update_status(&run_id, PipelineStatus::Failed)
.await
.context("updating pipeline status to Failed")?;
return Err(e);
}
}
ctx.state
.update_status(&run_id, PipelineStatus::Success)
.await
.context("updating pipeline status to Success")?;
info!(mode = %self.id, run = %run_id, "mode completed successfully");
Ok(ModeRun {
mode_id: self.id.clone(),
run_id,
final_status: PipelineStatus::Success,
})
}
}
/// Execute all steps in a layer concurrently. Returns the first fatal error, if
/// any.
async fn run_layer(
layer: &[String],
step_index: &HashMap<&str, &ActionStep>,
ctx: &RunContext,
run_id: &PipelineRunId,
mode_id: &str,
) -> Result<Option<anyhow::Error>> {
let mut set: JoinSet<(String, Result<()>)> = JoinSet::new();
for step_id in layer {
let step = (*step_index
.get(step_id.as_str())
.expect("layer ids are derived from the validated step list"))
.clone();
let owned_step_id = step.id.clone();
let params = ctx.params.clone();
let state = Arc::clone(&ctx.state);
let owned_run_id = run_id.clone();
let owned_mode_id = mode_id.to_owned();
let owned_project = ctx.project.clone();
#[cfg(feature = "nats")]
let nats = ctx.nats.clone();
set.spawn(async move {
#[cfg(feature = "nats")]
let result = execute_step(
step,
params,
state,
owned_run_id,
owned_mode_id,
owned_project,
nats,
)
.await;
#[cfg(not(feature = "nats"))]
let result = execute_step(
step,
params,
state,
owned_run_id,
owned_mode_id,
owned_project,
)
.await;
(owned_step_id, result)
});
}
let mut fatal: Option<anyhow::Error> = None;
while let Some(join_result) = set.join_next().await {
let (step_id, result) =
join_result.map_err(|e| anyhow!(ReflectionError::TaskPanic(e.to_string())))?;
if let Err(e) = result {
let step = step_index[step_id.as_str()];
match step.on_error.strategy {
ErrorStrategy::Stop | ErrorStrategy::Retry => {
// Retry is exhausted inside execute_step; treat the final failure as Stop.
set.abort_all();
fatal = Some(e);
break;
}
ErrorStrategy::Continue | ErrorStrategy::Fallback | ErrorStrategy::Branch => {
warn!(
step = %step_id,
strategy = ?step.on_error.strategy,
"step failed, continuing per on_error strategy: {e}"
);
}
}
}
}
Ok(fatal)
}
/// Execute a single step: record start → run cmd (with retry) → record outcome.
/// All parameters are owned so this future is `'static` and can be spawned.
#[cfg_attr(not(feature = "nats"), allow(unused_variables))]
async fn execute_step(
step: ActionStep,
params: HashMap<String, String>,
state: Arc<dyn StateTracker>,
run_id: PipelineRunId,
mode_id: String,
project: String,
#[cfg(feature = "nats")] nats: Option<Arc<platform_nats::EventStream>>,
) -> Result<()> {
let start_record = StepRecord::start(NodeId(step.id.clone()));
state
.record_step(&run_id, &start_record)
.await
.with_context(|| format!("recording step '{}' start", step.id))?;
info!(step = %step.id, action = %step.action, actor = ?step.actor, "executing step");
let outcome = match step.actor {
Actor::Human => {
info!(
step = %step.id,
"step requires human action — skipping automated execution: {}",
step.note.as_deref().unwrap_or(&step.action)
);
Ok(())
}
Actor::Agent | Actor::Both => match &step.cmd {
None => {
info!(step = %step.id, "no cmd — step is documentation-only");
Ok(())
}
Some(cmd) => {
let resolved = substitute_params(cmd, &params)
.with_context(|| format!("substituting params in step '{}' cmd", step.id))?;
run_with_retry(&resolved, &step.on_error).await
}
},
};
match outcome {
Ok(()) => {
state
.record_step(&run_id, &start_record.succeed(vec![]))
.await
.with_context(|| format!("recording step '{}' success", step.id))?;
#[cfg(feature = "nats")]
if let Some(ref nats) = nats {
publish_step_event(nats, &run_id, &step.id, true).await;
publish_kogral_capture(nats, &run_id, &mode_id, &project, &step.id, "success")
.await;
}
Ok(())
}
Err(e) => {
let err_str = e.to_string();
state
.record_step(&run_id, &start_record.fail(err_str.clone()))
.await
.with_context(|| format!("recording step '{}' failure", step.id))?;
#[cfg(feature = "nats")]
if let Some(ref nats) = nats {
publish_step_event(nats, &run_id, &step.id, false).await;
publish_kogral_capture(nats, &run_id, &mode_id, &project, &step.id, "failed").await;
}
Err(anyhow!("step '{}' failed: {}", step.id, err_str))
}
}
}
/// Replace `{key}` placeholders in `cmd` with values from `params`.
/// Returns `Err(UnknownParams)` if any placeholder key is absent from `params`.
fn substitute_params(
cmd: &str,
params: &HashMap<String, String>,
) -> Result<String, ReflectionError> {
let re = Regex::new(r"\{([^}]+)\}").expect("static regex is valid");
let unknown: Vec<String> = re
.captures_iter(cmd)
.filter_map(|cap| {
let key = cap[1].to_string();
if params.contains_key(&key) {
None
} else {
Some(key)
}
})
.collect();
if !unknown.is_empty() {
return Err(ReflectionError::UnknownParams(unknown));
}
let mut result = cmd.to_owned();
for (key, value) in params {
result = result.replace(&format!("{{{key}}}"), value);
}
Ok(result)
}
/// Execute `cmd` via `sh -c`, retrying on failure when `on_error.strategy ==
/// Retry`.
async fn run_with_retry(cmd: &str, on_error: &crate::mode::OnError) -> Result<()> {
let max_attempts = if on_error.strategy == ErrorStrategy::Retry {
on_error.max.max(1)
} else {
1
};
for attempt in 0..max_attempts {
match run_cmd(cmd).await {
Ok(()) => return Ok(()),
Err(e) => {
if attempt + 1 < max_attempts {
let delay = Duration::from_secs(on_error.backoff_s);
warn!(
attempt = attempt + 1,
max = max_attempts,
"cmd failed, retrying in {delay:?}: {e}"
);
tokio::time::sleep(delay).await;
} else {
return Err(e);
}
}
}
}
// Defensive: reached only when max_attempts == 0, which is prevented by .max(1)
// above.
Err(anyhow!("retry loop exited without result"))
}
async fn run_cmd(cmd: &str) -> Result<()> {
let output = tokio::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.await
.with_context(|| format!("spawning sh -c: {cmd}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(anyhow!(
"command exited {}: stderr='{}' stdout='{}'",
output.status,
stderr.trim(),
stdout.trim()
))
}
}
#[cfg(feature = "nats")]
async fn publish_step_event(
nats: &platform_nats::EventStream,
run_id: &PipelineRunId,
step_id: &str,
success: bool,
) {
let subject = if success {
"ecosystem.reflection.step.completed"
} else {
"ecosystem.reflection.step.failed"
};
let payload = serde_json::json!({
"run_id": run_id.0.to_string(),
"step_id": step_id,
"success": success,
});
match serde_json::to_vec(&payload) {
Ok(bytes) => {
if let Err(e) = nats.publish(subject, bytes::Bytes::from(bytes)).await {
warn!(step = %step_id, "failed to publish step event to '{subject}': {e}");
}
}
Err(e) => {
warn!(step = %step_id, "failed to serialize step event: {e}");
}
}
}
/// Publish to `ecosystem.kogral.capture` so Kogral can record this step as an
/// Execution node in the shared knowledge graph. Matches the KogralCapture
/// payload contract defined in `nats/subjects.ncl`.
#[cfg(feature = "nats")]
async fn publish_kogral_capture(
nats: &platform_nats::EventStream,
run_id: &PipelineRunId,
mode_id: &str,
project: &str,
step_id: &str,
status: &str,
) {
const SUBJECT: &str = "ecosystem.kogral.capture";
let payload = serde_json::json!({
"project": project,
"mode_id": mode_id,
"step_id": step_id,
"run_id": run_id.0.to_string(),
"action": step_id,
"status": status,
"context": {},
});
match serde_json::to_vec(&payload) {
Ok(bytes) => {
if let Err(e) = nats.publish(SUBJECT, bytes::Bytes::from(bytes)).await {
warn!(step = %step_id, "failed to publish kogral capture to '{SUBJECT}': {e}");
}
}
Err(e) => {
warn!(step = %step_id, "failed to serialize kogral capture payload: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitute_replaces_known_params() {
let mut params = HashMap::new();
params.insert("project_name".to_string(), "my-service".to_string());
params.insert("project_dir".to_string(), "/tmp/my-service".to_string());
let result =
substitute_params("git -C {project_dir} init && echo {project_name}", &params).unwrap();
assert_eq!(result, "git -C /tmp/my-service init && echo my-service");
}
#[test]
fn substitute_rejects_unknown_params() {
let params = HashMap::new();
let err = substitute_params("echo {unknown_key}", &params).unwrap_err();
assert!(matches!(
err,
ReflectionError::UnknownParams(keys) if keys.contains(&"unknown_key".to_string())
));
}
#[test]
fn substitute_no_placeholders_is_identity() {
let result = substitute_params("ls -la /tmp", &HashMap::new()).unwrap();
assert_eq!(result, "ls -la /tmp");
}
#[tokio::test]
async fn run_cmd_success() {
run_cmd("true").await.unwrap();
}
#[tokio::test]
async fn run_cmd_failure_returns_err() {
let err = run_cmd("false").await.unwrap_err();
assert!(err.to_string().contains("command exited"));
}
}

View File

@ -0,0 +1,10 @@
pub mod dag;
pub mod error;
pub mod executor;
pub mod mode;
pub use error::ReflectionError;
pub use executor::{ModeRun, RunContext};
pub use mode::{
ActionStep, Actor, Dependency, DependencyKind, ErrorStrategy, OnError, ReflectionMode,
};

View File

@ -0,0 +1,167 @@
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::dag;
use crate::error::ReflectionError;
/// A reflection mode loaded from a Nickel `.ncl` file.
/// Corresponds to the `Mode String` contract in `reflection/schema.ncl`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReflectionMode {
pub id: String,
pub trigger: String,
#[serde(default)]
pub preconditions: Vec<String>,
pub steps: Vec<ActionStep>,
#[serde(default)]
pub postconditions: Vec<String>,
}
impl ReflectionMode {
/// Load a `ReflectionMode` from a Nickel file by invoking `nickel export
/// --format json`. Mirrors the pattern in
/// `stratum-orchestrator::graph::loader::load_node_from_ncl`.
pub fn load(mode_file: &Path) -> Result<Self> {
let output = std::process::Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(mode_file)
.output()
.with_context(|| format!("running nickel export on '{}'", mode_file.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return Err(ReflectionError::NickelExport {
path: mode_file.display().to_string(),
stderr,
}
.into());
}
serde_json::from_slice::<Self>(&output.stdout)
.map_err(ReflectionError::ParseMode)
.with_context(|| {
format!(
"parsing ReflectionMode from '{}' — confirm the .ncl exports `| (s.Mode \
String)`",
mode_file.display()
)
})
}
/// Validate the step DAG in Rust (uniqueness + referential integrity +
/// cycle detection). Complements the Nickel-side structural +
/// referential checks; adds cycle detection that Nickel cannot express.
pub fn validate(&self) -> Result<(), ReflectionError> {
dag::validate(&self.steps)
}
/// Return steps in a flat topological order (one valid sequencing, no
/// parallelism). Useful for dry-run display. For parallel execution,
/// use `dag::topological_layers`.
pub fn execution_order(&self) -> Result<Vec<&ActionStep>, ReflectionError> {
let layers = dag::topological_layers(&self.steps)?;
Ok(layers
.into_iter()
.flat_map(|layer| {
layer
.into_iter()
.filter_map(|id| self.steps.iter().find(|s| s.id == id))
})
.collect())
}
}
/// A single executable step within a mode.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStep {
pub id: String,
/// Semantic action name (string identifier in cross-project modes).
pub action: String,
#[serde(default)]
pub actor: Actor,
/// Shell command with optional `{param}` placeholders substituted from
/// `RunContext.params`.
pub cmd: Option<String>,
#[serde(default)]
pub depends_on: Vec<Dependency>,
#[serde(default)]
pub on_error: OnError,
pub verify: Option<String>,
pub note: Option<String>,
}
/// Who executes this step.
/// Nickel enum tags serialize with their exact case: `'Human` → `"Human"`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Actor {
Human,
Agent,
#[default]
Both,
}
/// Dependency edge: this step waits for another step based on `kind`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
/// ID of the step this step depends on.
pub step: String,
#[serde(default)]
pub kind: DependencyKind,
pub condition: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DependencyKind {
#[default]
Always,
OnSuccess,
OnFailure,
}
/// Error handling strategy for a step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnError {
pub strategy: ErrorStrategy,
pub target: Option<String>,
pub on_success: Option<String>,
#[serde(default = "OnError::default_max")]
pub max: u32,
/// Backoff in seconds between retry attempts.
#[serde(default = "OnError::default_backoff_s")]
pub backoff_s: u64,
}
impl OnError {
fn default_max() -> u32 {
3
}
fn default_backoff_s() -> u64 {
5
}
}
impl Default for OnError {
fn default() -> Self {
Self {
strategy: ErrorStrategy::Stop,
target: None,
on_success: None,
max: Self::default_max(),
backoff_s: Self::default_backoff_s(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorStrategy {
Stop,
Continue,
Retry,
Fallback,
Branch,
}