use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use uuid::Uuid;
fn registry() -> &'static std::sync::Mutex<HashMap<(Uuid, String), u64>> {
static REGISTRY: OnceLock<std::sync::Mutex<HashMap<(Uuid, String), u64>>> = OnceLock::new();
REGISTRY.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
fn epochs() -> &'static std::sync::Mutex<HashMap<Uuid, u64>> {
static EPOCHS: OnceLock<std::sync::Mutex<HashMap<Uuid, u64>>> = OnceLock::new();
EPOCHS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
pub fn skill_slug_from_path(path: &Path) -> Option<String> {
let file_name = path.file_name()?.to_str()?;
if file_name != "SKILL.md" {
return None;
}
let mut comps = path.components().rev();
comps.next()?; let slug = comps.next()?;
if comps.next()?.as_os_str() != "skills" {
return None;
}
slug.as_os_str().to_str().map(|s| s.to_string())
}
pub fn mark_seen(session_id: Uuid, slug: &str) {
let epoch = current_epoch(session_id);
registry()
.lock()
.expect("seen_skills registry poisoned")
.insert((session_id, slug.to_string()), epoch);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let slug = slug.to_string();
handle.spawn(async move {
match persist_seen(session_id, &slug, epoch).await {
Ok(()) => {}
Err(e) => tracing::warn!(
"seen_skills: DB persist of ({session_id}, {slug}) failed (in-memory \
registry unaffected): {e:#}"
),
}
});
}
}
async fn persist_seen(session_id: Uuid, slug: &str, epoch: u64) -> anyhow::Result<()> {
let Some(pool) = crate::db::global_pool() else {
return Ok(());
};
crate::db::repository::SessionSkillsRepository::new(pool.clone())
.record(session_id, slug, epoch)
.await
}
fn current_epoch(session_id: Uuid) -> u64 {
*epochs()
.lock()
.expect("seen_skills epochs poisoned")
.get(&session_id)
.unwrap_or(&0)
}
pub fn note_compaction(session_id: Uuid) {
let mut epochs = epochs().lock().expect("seen_skills epochs poisoned");
let next = epochs.get(&session_id).copied().unwrap_or(0) + 1;
epochs.insert(session_id, next);
}
pub fn seen_since_compaction(session_id: Uuid, slug: &str) -> bool {
let stored = registry()
.lock()
.expect("seen_skills registry poisoned")
.get(&(session_id, slug.to_string()))
.copied();
if let Some(epoch) = stored {
epoch >= current_epoch(session_id)
} else {
false
}
}
pub fn was_seen(session_id: Uuid, slug: &str) -> bool {
registry()
.lock()
.expect("seen_skills registry poisoned")
.contains_key(&(session_id, slug.to_string()))
}
pub fn seen_for_session(session_id: Uuid) -> Vec<String> {
let all: BTreeSet<String> = registry()
.lock()
.expect("seen_skills registry poisoned")
.iter()
.filter(|((sid, _), _)| *sid == session_id)
.map(|((_, slug), _)| slug.clone())
.collect();
all.into_iter().collect()
}
pub struct HydrationSeeds {
pub seen: HashMap<(Uuid, String), u64>,
pub epochs: HashMap<Uuid, u64>,
}
pub fn hydrate_from_rows(rows: Vec<(Uuid, String, Option<i64>)>) -> HydrationSeeds {
let mut seeds = HydrationSeeds {
seen: HashMap::new(),
epochs: HashMap::new(),
};
for (session_id, slug, epoch) in rows {
let epoch = epoch.unwrap_or(0).max(0) as u64;
seeds
.seen
.entry((session_id, slug))
.and_modify(|e| *e = (*e).max(epoch))
.or_insert(epoch);
seeds
.epochs
.entry(session_id)
.and_modify(|e| *e = (*e).max(epoch))
.or_insert(epoch);
}
seeds
}
pub fn apply_seeds(seeds: HydrationSeeds) -> usize {
let n = seeds.seen.len();
{
let mut registry = registry().lock().expect("seen_skills registry poisoned");
for (key, epoch) in seeds.seen {
registry
.entry(key)
.and_modify(|e| *e = (*e).max(epoch))
.or_insert(epoch);
}
}
{
let mut epochs = epochs().lock().expect("seen_skills epochs poisoned");
for (session_id, epoch) in seeds.epochs {
epochs
.entry(session_id)
.and_modify(|e| *e = (*e).max(epoch))
.or_insert(epoch);
}
}
n
}
pub fn hydrate_from_db() {
static HYDRATED: AtomicBool = AtomicBool::new(false);
let Some(pool) = crate::db::global_pool().cloned() else {
tracing::debug!(
"seen_skills: no DB pool at boot — registry stays in-memory only (restart \
durability unavailable this run)"
);
return;
};
let Ok(handle) = tokio::runtime::Handle::try_current() else {
tracing::debug!("seen_skills: no tokio runtime at boot — skipping DB hydration");
return;
};
if HYDRATED.swap(true, Ordering::SeqCst) {
return;
}
handle.spawn(async move {
let repo = crate::db::repository::SessionSkillsRepository::new(pool);
match repo.all().await {
Ok(rows) => {
let n = apply_seeds(hydrate_from_rows(rows));
tracing::debug!("seen_skills: hydrated registry from DB ({n} seen rows)");
}
Err(e) => tracing::warn!(
"seen_skills: DB hydration failed (registry starts empty, restart durability \
lost this run): {e:#}"
),
}
match repo.prune_missing_sessions().await {
Ok(0) => {}
Ok(n) => tracing::debug!("seen_skills: pruned {n} rows for deleted sessions"),
Err(e) => tracing::warn!("seen_skills: prune of orphaned rows failed: {e:#}"),
}
});
}