use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
use std::time::Duration;
use agentstatedeveloper_core::{
AsgEffectStore, AsgIndexStore, EffectStore, Engine, LedgerEntry, paths, read_jsonl,
};
use agentstategraph_core::{Commit, ObjectId};
use serde_json::json;
use tokio::sync::{Mutex, OnceCell, broadcast};
pub const POLL_INTERVAL: Duration = Duration::from_secs(2);
const LOG_WINDOW: usize = 512;
const CHANNEL_CAPACITY: usize = 256;
const VERIFY_WINDOW_SECS: i64 = 10;
pub struct EventHub {
tx: broadcast::Sender<String>,
started: OnceCell<()>,
engine: Weak<Mutex<Engine>>,
audit_log_path: Option<PathBuf>,
}
impl EventHub {
pub fn new(engine: &Arc<Mutex<Engine>>, audit_log_path: Option<PathBuf>) -> Arc<Self> {
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
Arc::new(Self {
tx,
started: OnceCell::new(),
engine: Arc::downgrade(engine),
audit_log_path,
})
}
pub async fn subscribe(&self) -> broadcast::Receiver<String> {
self.started
.get_or_init(|| async {
let baseline =
snapshot_baseline(&self.engine, self.audit_log_path.as_deref()).await;
tokio::spawn(poll_loop(
self.engine.clone(),
self.tx.clone(),
self.audit_log_path.clone(),
baseline,
));
})
.await;
self.tx.subscribe()
}
}
struct Baseline {
head: Option<ObjectId>,
audit_size: u64,
audit_count: usize,
}
async fn snapshot_baseline(engine: &Weak<Mutex<Engine>>, audit: Option<&Path>) -> Baseline {
let head = match engine.upgrade() {
Some(arc) => {
let g = arc.lock().await;
g.repo.head(&g.ref_name).ok()
}
None => None,
};
let (audit_size, audit_count) = audit.map(audit_cursor).unwrap_or((0, 0));
Baseline {
head,
audit_size,
audit_count,
}
}
fn audit_cursor(path: &Path) -> (u64, usize) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let count = read_jsonl(path).map(|v| v.len()).unwrap_or(0);
(size, count)
}
async fn poll_loop(
engine: Weak<Mutex<Engine>>,
tx: broadcast::Sender<String>,
audit_path: Option<PathBuf>,
mut baseline: Baseline,
) {
let mut had_subscribers = true;
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let Some(engine_arc) = engine.upgrade() else {
return;
};
if tx.receiver_count() == 0 {
had_subscribers = false;
continue;
}
if !had_subscribers {
baseline = snapshot_baseline(&engine, audit_path.as_deref()).await;
had_subscribers = true;
continue;
}
let mut events: Vec<serde_json::Value> = Vec::new();
{
let g = engine_arc.lock().await;
if let Ok(head) = g.repo.head(&g.ref_name) {
if baseline.head.as_ref() != Some(&head) {
events.extend(commit_events(&g, baseline.head.as_ref()));
baseline.head = Some(head);
}
}
}
if let Some(path) = audit_path.as_deref() {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size != baseline.audit_size {
if let Ok(all) = read_jsonl(path) {
for e in all.iter().skip(baseline.audit_count) {
events.push(json!({
"at": e.timestamp,
"kind": "audit",
"qname": serde_json::Value::Null,
"symbol_id": serde_json::Value::Null,
"entry_id": e.subject_id,
"summary": match &e.reason {
Some(r) => format!("{} → {} ({})", e.event_type, e.outcome, r),
None => format!("{} → {}", e.event_type, e.outcome),
},
}));
}
baseline.audit_count = all.len();
}
baseline.audit_size = size;
}
}
for e in events {
if let Ok(s) = serde_json::to_string(&e) {
let _ = tx.send(s);
}
}
}
}
fn commit_events(engine: &Engine, last_head: Option<&ObjectId>) -> Vec<serde_json::Value> {
let log = match engine.repo.log(&engine.ref_name, LOG_WINDOW) {
Ok(l) => l,
Err(_) => return Vec::new(),
};
let cut = last_head.and_then(|h| log.iter().position(|c| &c.id == h));
let new_commits: Vec<&Commit> = match cut {
Some(i) => log[..i].iter().collect(),
None => {
return vec![json!({
"at": chrono::Utc::now(),
"kind": "resync",
"qname": serde_json::Value::Null,
"symbol_id": serde_json::Value::Null,
"entry_id": serde_json::Value::Null,
"summary": format!(
"more than {} commits since last poll — event log truncated, state resynced",
LOG_WINDOW
),
})];
}
};
if new_commits.is_empty() {
return Vec::new();
}
let mut id_map: Option<std::collections::HashMap<String, agentstatedeveloper_core::Symbol>> =
None;
let mut qname_of = |engine: &Engine, symbol_id: &str| -> serde_json::Value {
let map =
id_map.get_or_insert_with(|| AsgIndexStore::from_engine(engine).build_id_map(engine));
map.get(symbol_id)
.map(|s| serde_json::Value::String(s.qname.clone()))
.unwrap_or(serde_json::Value::Null)
};
let mut events: Vec<serde_json::Value> = Vec::new();
let mut index_commits: usize = 0;
let mut index_last: Option<(chrono::DateTime<chrono::Utc>, String)> = None;
for commit in new_commits.iter().rev() {
let desc = commit.intent.description.as_str();
if let Some(entry_id) = desc.strip_prefix("ledger-idx ") {
if let Some(entry) = load_ledger_entry(engine, entry_id) {
let qname = qname_of(engine, &entry.symbol_id);
events.push(json!({
"at": entry.created_at,
"kind": serde_json::to_value(entry.kind).unwrap_or(serde_json::Value::Null),
"qname": qname,
"symbol_id": entry.symbol_id,
"entry_id": entry.entry_id,
"summary": entry.summary,
}));
}
} else if desc.starts_with("ledger ") {
} else if let Some(symbol_id) = desc.strip_prefix("declare effects for ") {
let qname = qname_of(engine, symbol_id);
let decl = AsgEffectStore::from_engine(engine)
.get_effects(&engine.ref_name, symbol_id)
.ok()
.flatten();
let verified = decl
.as_ref()
.and_then(|d| d.verification.as_ref())
.filter(|v| (commit.timestamp - v.at).num_seconds().abs() <= VERIFY_WINDOW_SECS);
let (kind, summary) = match (verified, &decl) {
(Some(v), _) => (
"effect_verified",
format!(
"effects verified: {}",
serde_json::to_value(v.status)
.ok()
.and_then(|x| x.as_str().map(String::from))
.unwrap_or_else(|| "unknown".into())
),
),
(None, Some(d)) => {
let cats: Vec<&str> = d.declared.iter().map(|e| e.effect.as_str()).collect();
(
"effect_declared",
if cats.is_empty() {
"declares no effects".to_string()
} else {
format!("declares {}", cats.join(", "))
},
)
}
(None, None) => ("effect_declared", desc.to_string()),
};
events.push(json!({
"at": commit.timestamp,
"kind": kind,
"qname": qname,
"symbol_id": symbol_id,
"entry_id": serde_json::Value::Null,
"summary": summary,
}));
} else if desc.starts_with("index symbol ")
|| desc.starts_with("qname index ")
|| desc.starts_with("asd index: ")
|| desc.starts_with("asd hydrate: ")
{
index_commits += 1;
let informative = desc.starts_with("asd ");
if informative || index_last.is_none() {
index_last = Some((commit.timestamp, desc.to_string()));
}
} else {
events.push(json!({
"at": commit.timestamp,
"kind": "commit",
"qname": serde_json::Value::Null,
"symbol_id": serde_json::Value::Null,
"entry_id": serde_json::Value::Null,
"summary": desc,
}));
}
}
if let Some((at, last_desc)) = index_last {
events.push(json!({
"at": at,
"kind": "index_run",
"qname": serde_json::Value::Null,
"symbol_id": serde_json::Value::Null,
"entry_id": serde_json::Value::Null,
"summary": format!("{} ({} commits)", last_desc, index_commits),
}));
}
events
}
fn load_ledger_entry(engine: &Engine, entry_id: &str) -> Option<LedgerEntry> {
let idx = engine
.repo
.get_json(&engine.ref_name, &paths::ledger_entry_index_path(entry_id))
.ok()?;
let symbol_id = idx.as_str()?.to_string();
let val = engine
.repo
.get_json(
&engine.ref_name,
&paths::ledger_entry_path(&symbol_id, entry_id),
)
.ok()?;
serde_json::from_value(val).ok()
}