The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup --gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
497 lines
16 KiB
Rust
497 lines
16 KiB
Rust
use std::collections::HashSet;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use dashmap::DashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::broadcast;
|
|
use tracing::debug;
|
|
|
|
/// Broadcast capacity per `NotificationStore`.
|
|
/// Old events are overwritten when the buffer is full — this is intentional:
|
|
/// SSE clients that lag behind lose events but reconnect and resume via
|
|
/// polling.
|
|
const BROADCAST_CAPACITY: usize = 256;
|
|
|
|
/// 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 with SSE broadcast support.
|
|
///
|
|
/// Stores the last `capacity` notifications. Thread-safe via DashMap
|
|
/// for per-project isolation + AtomicU64 for the global sequence counter.
|
|
/// `event_tx` broadcasts every new notification to SSE subscribers.
|
|
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>,
|
|
/// Broadcast channel — SSE handlers subscribe via `subscribe()`.
|
|
event_tx: broadcast::Sender<NotificationView>,
|
|
}
|
|
|
|
impl NotificationStore {
|
|
pub fn new(capacity: usize, ack_required: Vec<String>) -> Self {
|
|
let (event_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
|
Self {
|
|
projects: DashMap::new(),
|
|
sequence: AtomicU64::new(1),
|
|
capacity,
|
|
ack_required,
|
|
event_tx,
|
|
}
|
|
}
|
|
|
|
/// Subscribe to live notification events for SSE delivery.
|
|
/// Returns a `broadcast::Receiver` that receives every new
|
|
/// `NotificationView` pushed via `push()` or `push_custom()`.
|
|
pub fn subscribe(&self) -> broadcast::Receiver<NotificationView> {
|
|
self.event_tx.subscribe()
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
// Broadcast to SSE subscribers — lagging receivers get a `Lagged` error.
|
|
if let Some(view) = ring.last().map(NotificationView::from) {
|
|
let _ = self.event_tx.send(view);
|
|
}
|
|
|
|
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);
|
|
}
|
|
if let Some(view) = ring.last().map(NotificationView::from) {
|
|
let _ = self.event_tx.send(view);
|
|
}
|
|
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());
|
|
}
|
|
}
|