289 lines
8.8 KiB
Rust
289 lines
8.8 KiB
Rust
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)
|
|
}
|
|
}
|