289 lines
8.8 KiB
Rust
Raw Normal View History

2026-03-13 00:18:14 +00:00
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)
}
}