1use anyhow::Context;
18
19use crate::error::Result;
20use fs2::FileExt;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fs::{self, OpenOptions};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::sync::{
27 Arc, Mutex, OnceLock,
28 atomic::{AtomicU64, Ordering},
29};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use crate::config::Layout;
33
34static LAST_SEQ: AtomicU64 = AtomicU64::new(0);
35static LAST_EMIT_MS_BY_DIR: OnceLock<Mutex<HashMap<PathBuf, u64>>> = OnceLock::new();
36static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
37
38const LOCK_NAME: &str = ".vault-events.lock";
39
40fn with_events_lock<R, F>(dir: &Path, f: F) -> Result<R>
41where
42 F: FnOnce() -> Result<R>,
43{
44 let key = dir.to_path_buf();
45 let mutex = {
46 let mut map = PROCESS_LOCKS
47 .get_or_init(|| Mutex::new(HashMap::new()))
48 .lock()
49 .unwrap_or_else(|p| p.into_inner());
50 map.entry(key)
51 .or_insert_with(|| Arc::new(Mutex::new(())))
52 .clone()
53 };
54 let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
55 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
56 let lock_path = dir.join(LOCK_NAME);
57 let file = OpenOptions::new()
58 .create(true)
59 .read(true)
60 .append(true)
61 .open(&lock_path)
62 .with_context(|| format!("open {}", lock_path.display()))?;
63 file.lock_exclusive()
64 .with_context(|| format!("lock {}", lock_path.display()))?;
65 let result = f();
66 let _ = FileExt::unlock(&file);
67 let _ = file;
68 result
69}
70
71const DEBOUNCE_MS: u64 = 2000;
74
75const LOG_NAME: &str = ".vault-events.jsonl";
76const GEN_NAME: &str = ".vault-events.gen";
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Event {
81 pub seq: u64,
83 pub ts: u64,
85 pub kind: String,
87 pub project: Option<String>,
89 pub id: Option<String>,
91 pub path: Option<String>,
93 pub detail: Option<String>,
95}
96
97pub fn enabled() -> bool {
99 !matches!(
100 crate::process_env::var("VISSUE_EVENTS").as_deref(),
101 Ok("0") | Ok("false") | Ok("off")
102 )
103}
104
105pub fn log_path(dir: &Path) -> PathBuf {
107 dir.join(LOG_NAME)
108}
109
110pub fn gen_path(dir: &Path) -> PathBuf {
112 dir.join(GEN_NAME)
113}
114
115fn now_secs() -> u64 {
116 SystemTime::now()
117 .duration_since(UNIX_EPOCH)
118 .map(|d| d.as_secs())
119 .unwrap_or(0)
120}
121
122fn now_millis() -> u64 {
123 SystemTime::now()
124 .duration_since(UNIX_EPOCH)
125 .map(|d| d.as_millis() as u64)
126 .unwrap_or(0)
127}
128
129fn read_gen(dir: &Path) -> u64 {
130 fs::read_to_string(gen_path(dir))
131 .ok()
132 .and_then(|s| s.trim().parse().ok())
133 .unwrap_or(0)
134}
135
136fn write_gen(dir: &Path, seq: u64) -> Result<()> {
137 fs::create_dir_all(dir)?;
138 let target = gen_path(dir);
139 let tmp = dir.join(format!("{GEN_NAME}.tmp.{}-{}", std::process::id(), seq));
142 fs::write(&tmp, format!("{seq}\n"))?;
143 if let Err(e) = fs::rename(&tmp, &target) {
144 let _ = fs::remove_file(&tmp);
145 return Err(e)
146 .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))
147 .map_err(crate::error::Error::from);
148 }
149 Ok(())
150}
151
152pub fn generation_in(dir: &Path) -> u64 {
155 let g = read_gen(dir);
156 let _ = LAST_SEQ.fetch_max(g, Ordering::Relaxed);
157 g
158}
159
160pub fn emit_in(
167 dir: &Path,
168 kind: &str,
169 project: Option<&str>,
170 id: Option<&str>,
171 path: Option<&Path>,
172 detail: Option<&str>,
173) -> Result<u64> {
174 with_events_lock(dir, || {
175 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
176
177 let prev = read_gen(dir).max(LAST_SEQ.load(Ordering::Relaxed));
178 let seq = prev.saturating_add(1);
179 LAST_SEQ.store(seq, Ordering::Relaxed);
180
181 let event = Event {
182 seq,
183 ts: now_secs(),
184 kind: kind.to_string(),
185 project: project.map(|s| s.to_string()),
186 id: id.map(|s| s.to_string()),
187 path: path.map(|p| p.display().to_string()),
188 detail: detail.map(|s| s.to_string()),
189 };
190
191 if kind == "issues_write" {
192 let now_ms = now_millis();
193 let key = dir.to_path_buf();
194 let mut last_by_dir = LAST_EMIT_MS_BY_DIR
195 .get_or_init(|| Mutex::new(HashMap::new()))
196 .lock()
197 .unwrap_or_else(|poisoned| poisoned.into_inner());
198 let previous = last_by_dir.get(&key).copied().unwrap_or(0);
199 if previous > 0 && now_ms.saturating_sub(previous) < DEBOUNCE_MS {
200 write_gen(dir, seq)?;
203 LAST_SEQ.store(seq, Ordering::Relaxed);
204 last_by_dir.insert(key, now_ms);
205 return Ok(seq);
206 }
207 last_by_dir.insert(key, now_ms);
208 }
209
210 let line = serde_json::to_string(&event)?;
211 let mut file = OpenOptions::new()
212 .create(true)
213 .append(true)
214 .open(log_path(dir))?;
215 writeln!(file, "{line}")?;
216 file.flush()?;
217 write_gen(dir, seq)?;
218 Ok(seq)
219 })
220}
221
222pub fn emit_issues_write(dir: &Path, project: &str, path: &Path) -> Result<u64> {
230 emit_in(
231 dir,
232 "issues_write",
233 Some(project),
234 None,
235 Some(path),
236 Some("issues.org updated"),
237 )
238}
239
240pub fn since_in(dir: &Path, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
246 let log = log_path(dir);
247 if !log.is_file() {
248 return Ok(Vec::new());
249 }
250 let text = fs::read_to_string(&log)?;
251 let mut out = Vec::new();
252 for line in text.lines().rev() {
253 if line.trim().is_empty() {
254 continue;
255 }
256 let event: Event = match serde_json::from_str(line) {
257 Ok(e) => e,
258 Err(_) => continue,
259 };
260 if event.seq <= since_seq {
261 break;
262 }
263 out.push(event);
264 if out.len() >= limit {
265 break;
266 }
267 }
268 out.reverse();
269 Ok(out)
270}
271
272pub fn since_filtered_in(
278 dir: &Path,
279 since_seq: u64,
280 limit: usize,
281 project: Option<&str>,
282 kind: Option<&str>,
283) -> Result<Vec<Event>> {
284 let mut events = since_in(dir, since_seq, limit.saturating_mul(4).max(limit))?;
285 if let Some(p) = project {
286 events.retain(|e| e.project.as_deref() == Some(p));
287 }
288 if let Some(k) = kind {
289 events.retain(|e| e.kind == k);
290 }
291 events.truncate(limit);
292 Ok(events)
293}
294
295pub fn events_dir(layout: &Layout) -> PathBuf {
299 layout.projects_dir()
300}
301
302pub fn generation(layout: &Layout) -> u64 {
304 generation_in(&events_dir(layout))
305}
306
307pub fn since(layout: &Layout, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
313 since_in(&events_dir(layout), since_seq, limit)
314}
315
316pub fn since_report(layout: &Layout, since_seq: u64, limit: usize) -> Result<String> {
322 let dir = events_dir(layout);
323 let events = since_in(&dir, since_seq, limit)?;
324 Ok(render_events(&dir, since_seq, events))
325}
326
327fn render_events(dir: &Path, since_seq: u64, events: Vec<Event>) -> String {
328 let generation_now = generation_in(dir);
329 let mut text = format!(
330 "generation={} since={} count={}\n",
331 generation_now,
332 since_seq,
333 events.len()
334 );
335 for e in &events {
336 text.push_str(&format!(
337 "{}\t{}\t{}\t{:?}\t{:?}\t{:?}\n",
338 e.seq, e.ts, e.kind, e.project, e.id, e.path
339 ));
340 }
341 let data = serde_json::json!({
342 "generation": generation_now,
343 "since": since_seq,
344 "events": events,
345 "log": log_path(dir).display().to_string(),
346 "gen_file": gen_path(dir).display().to_string(),
347 });
348 text.push_str("\n---json---\n");
349 text.push_str(&data.to_string());
350 text.push('\n');
351 text
352}
353
354pub fn ping_report(layout: &Layout, detail: Option<&str>) -> Result<String> {
361 let dir = events_dir(layout);
362 let seq = emit_in(
363 &dir,
364 "ping",
365 None,
366 None,
367 None,
368 detail.or(Some("manual ping")),
369 )?;
370 Ok(format!(
371 "ping seq={} generation={}\nlog={}\n",
372 seq,
373 generation_in(&dir),
374 log_path(&dir).display()
375 ))
376}
377
378pub fn wait_generation(layout: &Layout, last: u64, poll_ms: u64, timeout_ms: u64) -> Result<u64> {
387 let dir = events_dir(layout);
388 let start = std::time::Instant::now();
389 loop {
390 let g = generation_in(&dir);
391 if g > last {
392 return Ok(g);
393 }
394 if start.elapsed().as_millis() as u64 >= timeout_ms {
395 return Ok(g);
396 }
397 std::thread::sleep(std::time::Duration::from_millis(poll_ms.max(50)));
398 }
399}
400
401pub fn tail_in(dir: &Path, n: usize) -> Result<Vec<Event>> {
411 let log = log_path(dir);
412 if !log.is_file() {
413 return Ok(Vec::new());
414 }
415 let text = fs::read_to_string(&log)?;
416 let mut out: Vec<Event> = text
417 .lines()
418 .rev()
419 .filter(|line| !line.trim().is_empty())
420 .filter_map(|line| serde_json::from_str(line).ok())
421 .take(n)
422 .collect();
423 out.reverse();
424 Ok(out)
425}
426
427pub fn tail_report(layout: &Layout, n: usize) -> Result<String> {
433 let dir = events_dir(layout);
434 let events = tail_in(&dir, n)?;
435 let since_seq = events.first().map(|e| e.seq.saturating_sub(1)).unwrap_or(0);
436 Ok(render_events(&dir, since_seq, events))
437}
438
439pub fn ensure_gitignore_hint(dir: &Path) -> Result<()> {
446 let gitignore = dir.join(".gitignore");
447 if !gitignore.is_file() {
448 return Ok(());
449 }
450 let current = fs::read_to_string(&gitignore)?;
451 if current.contains(".vault-events") {
452 return Ok(());
453 }
454 let mut file = OpenOptions::new().append(true).open(&gitignore)?;
455 writeln!(
456 file,
457 "\n# agent ping stream (local)\n{LOG_NAME}\n{GEN_NAME}\n"
458 )?;
459 Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::config::DEFAULT_PREFIX;
466
467 #[test]
468 fn a_sequence_advances_and_reads_back() {
469 let dir = tempfile::tempdir().unwrap();
470 let d = dir.path();
471 let first = emit_in(d, "ping", None, None, None, Some("a")).unwrap();
472 let second = emit_in(
473 d,
474 "issues_write",
475 Some("atlas"),
476 Some("atlas-1a2b"),
477 Some(Path::new("atlas/issues.org")),
478 None,
479 )
480 .unwrap();
481 assert!(second > first);
482 assert_eq!(generation_in(d), second);
483
484 let events = since_in(d, first, 10).unwrap();
485 assert_eq!(events.len(), 1);
486 assert_eq!(events[0].kind, "issues_write");
487 assert_eq!(events[0].project.as_deref(), Some("atlas"));
488 }
489
490 #[test]
491 fn filters_narrow_by_project_and_kind() {
492 let dir = tempfile::tempdir().unwrap();
493 let d = dir.path();
494 emit_in(d, "ping", None, None, None, None).unwrap();
495 emit_in(d, "manual", Some("atlas"), None, None, None).unwrap();
496 emit_in(d, "manual", Some("beacon"), None, None, None).unwrap();
497
498 let by_project = since_filtered_in(d, 0, 10, Some("atlas"), None).unwrap();
499 assert_eq!(by_project.len(), 1);
500 let by_kind = since_filtered_in(d, 0, 10, None, Some("ping")).unwrap();
501 assert_eq!(by_kind.len(), 1);
502 assert_eq!(by_kind[0].kind, "ping");
503 }
504
505 #[test]
506 fn the_report_carries_a_json_block() {
507 let dir = tempfile::tempdir().unwrap();
508 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
509 ping_report(&layout, Some("hello")).unwrap();
510 let text = since_report(&layout, 0, 10).unwrap();
511 assert!(text.starts_with("generation="), "{text}");
512 let (_, json) = text.split_once("---json---").expect("json block present");
513 let parsed: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
514 assert_eq!(parsed["events"][0]["kind"], "ping");
515 assert_eq!(parsed["events"][0]["detail"], "hello");
516 }
517
518 #[test]
519 fn the_gitignore_hint_only_touches_an_existing_file() {
520 let dir = tempfile::tempdir().unwrap();
521 ensure_gitignore_hint(dir.path()).unwrap();
522 assert!(!dir.path().join(".gitignore").exists());
523
524 fs::write(dir.path().join(".gitignore"), "target\n").unwrap();
525 ensure_gitignore_hint(dir.path()).unwrap();
526 let text = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
527 assert!(text.contains(LOG_NAME), "{text}");
528 assert!(text.contains(GEN_NAME), "{text}");
529
530 ensure_gitignore_hint(dir.path()).unwrap();
532 let again = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
533 assert_eq!(text, again);
534 }
535
536 #[test]
537 fn concurrent_emits_assign_unique_sequences() {
538 use std::sync::Arc;
539 use std::thread;
540
541 let dir = tempfile::tempdir().unwrap();
542 let d = Arc::new(dir.path().to_path_buf());
543 let handles: Vec<_> = (0..16)
544 .map(|i| {
545 let d = Arc::clone(&d);
546 thread::spawn(move || emit_in(&d, "ping", None, None, None, Some(&format!("{i}"))))
547 })
548 .collect();
549 let mut seqs = Vec::new();
550 for handle in handles {
551 seqs.push(handle.join().unwrap().unwrap());
552 }
553 seqs.sort_unstable();
554 seqs.dedup();
555 assert_eq!(seqs.len(), 16, "duplicate event sequences: {seqs:?}");
556 assert_eq!(generation_in(&d), *seqs.last().unwrap());
557 }
558
559 #[test]
560 fn a_tail_counts_lines_not_sequence_numbers() {
561 let dir = tempfile::tempdir().unwrap();
562 let d = dir.path();
563 for i in 0..5 {
564 emit_in(d, "manual", None, None, None, Some(&format!("event {i}"))).unwrap();
565 }
566 let tailed = tail_in(d, 3).unwrap();
569 assert_eq!(tailed.len(), 3, "{tailed:?}");
570 assert_eq!(tailed[2].detail.as_deref(), Some("event 4"));
571 assert_eq!(tailed[0].detail.as_deref(), Some("event 2"));
572 assert!(tail_in(d, 50).unwrap().len() == 5);
573 }
574
575 #[test]
576 fn waiting_returns_the_current_generation_on_timeout() {
577 let dir = tempfile::tempdir().unwrap();
578 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
579 let g = generation(&layout);
580 let waited = wait_generation(&layout, g + 100, 50, 120).unwrap();
581 assert!(waited <= g + 100, "timed out without advancing");
582 }
583}