chore: add src code
This commit is contained in:
parent
147576e8bb
commit
2d87d60bb5
6592
Cargo.lock
generated
Normal file
6592
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal 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"] }
|
||||||
52
crates/ontoref-daemon/Cargo.toml
Normal file
52
crates/ontoref-daemon/Cargo.toml
Normal 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 }
|
||||||
492
crates/ontoref-daemon/src/actors.rs
Normal file
492
crates/ontoref-daemon/src/actors.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
940
crates/ontoref-daemon/src/api.rs
Normal file
940
crates/ontoref-daemon/src/api.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
302
crates/ontoref-daemon/src/cache.rs
Normal file
302
crates/ontoref-daemon/src/cache.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/ontoref-daemon/src/error.rs
Normal file
25
crates/ontoref-daemon/src/error.rs
Normal 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>;
|
||||||
19
crates/ontoref-daemon/src/lib.rs
Normal file
19
crates/ontoref-daemon/src/lib.rs
Normal 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;
|
||||||
924
crates/ontoref-daemon/src/main.rs
Normal file
924
crates/ontoref-daemon/src/main.rs
Normal 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(¬ifications),
|
||||||
|
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(¬ifications),
|
||||||
|
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(¬ifications),
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
1772
crates/ontoref-daemon/src/mcp/mod.rs
Normal file
1772
crates/ontoref-daemon/src/mcp/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
433
crates/ontoref-daemon/src/nats.rs
Normal file
433
crates/ontoref-daemon/src/nats.rs
Normal 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 1970–2099)
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
470
crates/ontoref-daemon/src/notifications.rs
Normal file
470
crates/ontoref-daemon/src/notifications.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
288
crates/ontoref-daemon/src/registry.rs
Normal file
288
crates/ontoref-daemon/src/registry.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
479
crates/ontoref-daemon/src/search.rs
Normal file
479
crates/ontoref-daemon/src/search.rs
Normal 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(¶(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(§ion_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(§ion_header(label));
|
||||||
|
h.push_str(&text_block(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cons) = json.get("consequences") {
|
||||||
|
h.push_str(§ion_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(§ion_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(§ion_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(¶(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(§ion_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(§ion_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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
131
crates/ontoref-daemon/src/seed.rs
Normal file
131
crates/ontoref-daemon/src/seed.rs
Normal 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
|
||||||
|
}
|
||||||
81
crates/ontoref-daemon/src/session.rs
Normal file
81
crates/ontoref-daemon/src/session.rs
Normal 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()
|
||||||
|
}
|
||||||
122
crates/ontoref-daemon/src/ui/auth.rs
Normal file
122
crates/ontoref-daemon/src/ui/auth.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
189
crates/ontoref-daemon/src/ui/backlog_ncl.rs
Normal file
189
crates/ontoref-daemon/src/ui/backlog_ncl.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
crates/ontoref-daemon/src/ui/drift_watcher.rs
Normal file
219
crates/ontoref-daemon/src/ui/drift_watcher.rs
Normal 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, ¬ifications).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()
|
||||||
|
}
|
||||||
2467
crates/ontoref-daemon/src/ui/handlers.rs
Normal file
2467
crates/ontoref-daemon/src/ui/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
98
crates/ontoref-daemon/src/ui/login.rs
Normal file
98
crates/ontoref-daemon/src/ui/login.rs
Normal 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()
|
||||||
|
}
|
||||||
96
crates/ontoref-daemon/src/ui/mod.rs
Normal file
96
crates/ontoref-daemon/src/ui/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
288
crates/ontoref-daemon/src/ui/qa_ncl.rs
Normal file
288
crates/ontoref-daemon/src/ui/qa_ncl.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
83
crates/ontoref-daemon/src/ui/watcher.rs
Normal file
83
crates/ontoref-daemon/src/ui/watcher.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
271
crates/ontoref-daemon/src/watcher.rs
Normal file
271
crates/ontoref-daemon/src/watcher.rs
Normal 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 ¬ification_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
crates/ontoref-daemon/templates/base.html
Normal file
510
crates/ontoref-daemon/templates/base.html
Normal 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&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&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>
|
||||||
52
crates/ontoref-daemon/templates/macros/ui.html
Normal file
52
crates/ontoref-daemon/templates/macros/ui.html
Normal 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 %}
|
||||||
90
crates/ontoref-daemon/templates/pages/actions.html
Normal file
90
crates/ontoref-daemon/templates/pages/actions.html
Normal 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 %}
|
||||||
332
crates/ontoref-daemon/templates/pages/backlog.html
Normal file
332
crates/ontoref-daemon/templates/pages/backlog.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
407
crates/ontoref-daemon/templates/pages/compose.html
Normal file
407
crates/ontoref-daemon/templates/pages/compose.html
Normal 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 %}
|
||||||
91
crates/ontoref-daemon/templates/pages/dashboard.html
Normal file
91
crates/ontoref-daemon/templates/pages/dashboard.html
Normal 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&A Bookmarks</h2>
|
||||||
|
<p class="text-sm text-base-content/60">Saved questions and answers about this project</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
458
crates/ontoref-daemon/templates/pages/graph.html
Normal file
458
crates/ontoref-daemon/templates/pages/graph.html
Normal 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 %}
|
||||||
34
crates/ontoref-daemon/templates/pages/login.html
Normal file
34
crates/ontoref-daemon/templates/pages/login.html
Normal 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 %}
|
||||||
95
crates/ontoref-daemon/templates/pages/manage.html
Normal file
95
crates/ontoref-daemon/templates/pages/manage.html
Normal 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 %}
|
||||||
132
crates/ontoref-daemon/templates/pages/modes.html
Normal file
132
crates/ontoref-daemon/templates/pages/modes.html
Normal 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 %}
|
||||||
185
crates/ontoref-daemon/templates/pages/notifications.html
Normal file
185
crates/ontoref-daemon/templates/pages/notifications.html
Normal 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 %}
|
||||||
294
crates/ontoref-daemon/templates/pages/project_picker.html
Normal file
294
crates/ontoref-daemon/templates/pages/project_picker.html
Normal 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 %}
|
||||||
389
crates/ontoref-daemon/templates/pages/qa.html
Normal file
389
crates/ontoref-daemon/templates/pages/qa.html
Normal 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&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&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&A saved yet</p>
|
||||||
|
<p class="mt-1">Click "Add new Q&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&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&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&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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
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 %}
|
||||||
423
crates/ontoref-daemon/templates/pages/search.html
Normal file
423
crates/ontoref-daemon/templates/pages/search.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// ── 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 %}
|
||||||
63
crates/ontoref-daemon/templates/pages/sessions.html
Normal file
63
crates/ontoref-daemon/templates/pages/sessions.html
Normal 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 %}
|
||||||
16
crates/ontoref-ontology/Cargo.toml
Normal file
16
crates/ontoref-ontology/Cargo.toml
Normal 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" }
|
||||||
17
crates/ontoref-ontology/src/error.rs
Normal file
17
crates/ontoref-ontology/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
11
crates/ontoref-ontology/src/lib.rs
Normal file
11
crates/ontoref-ontology/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
503
crates/ontoref-ontology/src/ontology.rs
Normal file
503
crates/ontoref-ontology/src/ontology.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
226
crates/ontoref-ontology/src/types.rs
Normal file
226
crates/ontoref-ontology/src/types.rs
Normal 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>,
|
||||||
|
}
|
||||||
32
crates/ontoref-reflection/Cargo.toml
Normal file
32
crates/ontoref-reflection/Cargo.toml
Normal 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" }
|
||||||
200
crates/ontoref-reflection/src/dag.rs
Normal file
200
crates/ontoref-reflection/src/dag.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/ontoref-reflection/src/error.rs
Normal file
25
crates/ontoref-reflection/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
441
crates/ontoref-reflection/src/executor.rs
Normal file
441
crates/ontoref-reflection/src/executor.rs
Normal 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, ¶ms)
|
||||||
|
.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}", ¶ms).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}", ¶ms).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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/ontoref-reflection/src/lib.rs
Normal file
10
crates/ontoref-reflection/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
167
crates/ontoref-reflection/src/mode.rs
Normal file
167
crates/ontoref-reflection/src/mode.rs
Normal 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,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user