1use anyhow::Context;
18
19use crate::error::{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 emit_state_change(
248 layout: &Layout,
249 project: &str,
250 id: &str,
251 from: &str,
252 to: &str,
253) -> Result<u64> {
254 emit_in(
255 &events_dir(layout),
256 "state_change",
257 Some(project),
258 Some(id),
259 None,
260 Some(&format!("{from}->{to}")),
261 )
262}
263
264pub fn since_in(dir: &Path, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
270 let log = log_path(dir);
271 if !log.is_file() {
272 return Ok(Vec::new());
273 }
274 let text = fs::read_to_string(&log)?;
275 let mut out = Vec::new();
276 for line in text.lines().rev() {
277 if line.trim().is_empty() {
278 continue;
279 }
280 let event: Event = match serde_json::from_str(line) {
281 Ok(e) => e,
282 Err(_) => continue,
283 };
284 if event.seq <= since_seq {
285 break;
286 }
287 out.push(event);
288 if out.len() >= limit {
289 break;
290 }
291 }
292 out.reverse();
293 Ok(out)
294}
295
296pub fn since_filtered_in(
302 dir: &Path,
303 since_seq: u64,
304 limit: usize,
305 project: Option<&str>,
306 kind: Option<&str>,
307) -> Result<Vec<Event>> {
308 let mut events = since_in(dir, since_seq, limit.saturating_mul(4).max(limit))?;
309 if let Some(p) = project {
310 events.retain(|e| e.project.as_deref() == Some(p));
311 }
312 if let Some(k) = kind {
313 events.retain(|e| e.kind == k);
314 }
315 events.truncate(limit);
316 Ok(events)
317}
318
319pub fn events_dir(layout: &Layout) -> PathBuf {
323 layout.projects_dir()
324}
325
326pub fn generation(layout: &Layout) -> u64 {
328 generation_in(&events_dir(layout))
329}
330
331pub fn since(layout: &Layout, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
337 since_in(&events_dir(layout), since_seq, limit)
338}
339
340pub fn since_report(layout: &Layout, since_seq: u64, limit: usize) -> Result<String> {
346 let dir = events_dir(layout);
347 let events = since_in(&dir, since_seq, limit)?;
348 Ok(render_events(&dir, since_seq, events))
349}
350
351fn render_events(dir: &Path, since_seq: u64, events: Vec<Event>) -> String {
352 let generation_now = generation_in(dir);
353 let mut text = format!(
354 "generation={} since={} count={}\n",
355 generation_now,
356 since_seq,
357 events.len()
358 );
359 for e in &events {
360 text.push_str(&format!(
361 "{}\t{}\t{}\t{:?}\t{:?}\t{:?}\n",
362 e.seq, e.ts, e.kind, e.project, e.id, e.path
363 ));
364 }
365 let data = serde_json::json!({
366 "generation": generation_now,
367 "since": since_seq,
368 "events": events,
369 "log": log_path(dir).display().to_string(),
370 "gen_file": gen_path(dir).display().to_string(),
371 });
372 text.push_str("\n---json---\n");
373 text.push_str(&data.to_string());
374 text.push('\n');
375 text
376}
377
378pub fn ping_report(layout: &Layout, detail: Option<&str>) -> Result<String> {
385 let dir = events_dir(layout);
386 let seq = emit_in(
387 &dir,
388 "ping",
389 None,
390 None,
391 None,
392 detail.or(Some("manual ping")),
393 )?;
394 Ok(format!(
395 "ping seq={} generation={}\nlog={}\n",
396 seq,
397 generation_in(&dir),
398 log_path(&dir).display()
399 ))
400}
401
402pub fn wait_generation(layout: &Layout, last: u64, poll_ms: u64, timeout_ms: u64) -> Result<u64> {
411 let dir = events_dir(layout);
412 let start = std::time::Instant::now();
413 loop {
414 let g = generation_in(&dir);
415 if g > last {
416 return Ok(g);
417 }
418 if start.elapsed().as_millis() as u64 >= timeout_ms {
419 return Ok(g);
420 }
421 std::thread::sleep(std::time::Duration::from_millis(poll_ms.max(50)));
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum TerminalWait {
428 Done {
430 generation: u64,
432 },
433 Cancelled {
435 generation: u64,
437 },
438 Timeout {
440 generation: u64,
442 state: String,
444 },
445}
446
447pub fn wait_until_terminal(
455 layout: &Layout,
456 id: &str,
457 poll_ms: u64,
458 timeout_ms: u64,
459) -> Result<TerminalWait> {
460 let dir = events_dir(layout);
461 let start = std::time::Instant::now();
462 let mut last_gen = generation_in(&dir);
463 loop {
464 let heading = crate::store::find_by_id(layout, id)?
465 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?
466 .0;
467 let generation = generation_in(&dir);
468 match heading.state.as_str() {
469 "DONE" => return Ok(TerminalWait::Done { generation }),
470 "CANCELLED" => return Ok(TerminalWait::Cancelled { generation }),
471 _ => {}
472 }
473 if start.elapsed().as_millis() as u64 >= timeout_ms {
474 return Ok(TerminalWait::Timeout {
475 generation,
476 state: heading.state,
477 });
478 }
479 let poll = poll_ms.max(50);
481 let slice = 50_u64.min(poll);
482 let wake = std::time::Instant::now();
483 loop {
484 let now_gen = generation_in(&dir);
485 if now_gen != last_gen {
486 last_gen = now_gen;
487 break;
488 }
489 if wake.elapsed().as_millis() as u64 >= poll {
490 break;
491 }
492 if start.elapsed().as_millis() as u64 >= timeout_ms {
493 break;
494 }
495 std::thread::sleep(std::time::Duration::from_millis(slice));
496 }
497 }
498}
499
500pub fn tail_in(dir: &Path, n: usize) -> Result<Vec<Event>> {
510 let log = log_path(dir);
511 if !log.is_file() {
512 return Ok(Vec::new());
513 }
514 let text = fs::read_to_string(&log)?;
515 let mut out: Vec<Event> = text
516 .lines()
517 .rev()
518 .filter(|line| !line.trim().is_empty())
519 .filter_map(|line| serde_json::from_str(line).ok())
520 .take(n)
521 .collect();
522 out.reverse();
523 Ok(out)
524}
525
526pub fn tail_report(layout: &Layout, n: usize) -> Result<String> {
532 let dir = events_dir(layout);
533 let events = tail_in(&dir, n)?;
534 let since_seq = events.first().map(|e| e.seq.saturating_sub(1)).unwrap_or(0);
535 Ok(render_events(&dir, since_seq, events))
536}
537
538pub fn ensure_gitignore_hint(dir: &Path) -> Result<()> {
545 let gitignore = dir.join(".gitignore");
546 if !gitignore.is_file() {
547 return Ok(());
548 }
549 let current = fs::read_to_string(&gitignore)?;
550 if current.contains(".vault-events") {
551 return Ok(());
552 }
553 let mut file = OpenOptions::new().append(true).open(&gitignore)?;
554 writeln!(
555 file,
556 "\n# agent ping stream (local)\n{LOG_NAME}\n{GEN_NAME}\n"
557 )?;
558 Ok(())
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::config::DEFAULT_PREFIX;
565 use crate::model::TODO_HEADER;
566
567 #[test]
568 fn a_sequence_advances_and_reads_back() {
569 let dir = tempfile::tempdir().unwrap();
570 let d = dir.path();
571 let first = emit_in(d, "ping", None, None, None, Some("a")).unwrap();
572 let second = emit_in(
573 d,
574 "issues_write",
575 Some("atlas"),
576 Some("atlas-1a2b"),
577 Some(Path::new("atlas/issues.org")),
578 None,
579 )
580 .unwrap();
581 assert!(second > first);
582 assert_eq!(generation_in(d), second);
583
584 let events = since_in(d, first, 10).unwrap();
585 assert_eq!(events.len(), 1);
586 assert_eq!(events[0].kind, "issues_write");
587 assert_eq!(events[0].project.as_deref(), Some("atlas"));
588 }
589
590 #[test]
591 fn filters_narrow_by_project_and_kind() {
592 let dir = tempfile::tempdir().unwrap();
593 let d = dir.path();
594 emit_in(d, "ping", None, None, None, None).unwrap();
595 emit_in(d, "manual", Some("atlas"), None, None, None).unwrap();
596 emit_in(d, "manual", Some("beacon"), None, None, None).unwrap();
597
598 let by_project = since_filtered_in(d, 0, 10, Some("atlas"), None).unwrap();
599 assert_eq!(by_project.len(), 1);
600 let by_kind = since_filtered_in(d, 0, 10, None, Some("ping")).unwrap();
601 assert_eq!(by_kind.len(), 1);
602 assert_eq!(by_kind[0].kind, "ping");
603 }
604
605 #[test]
606 fn the_report_carries_a_json_block() {
607 let dir = tempfile::tempdir().unwrap();
608 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
609 ping_report(&layout, Some("hello")).unwrap();
610 let text = since_report(&layout, 0, 10).unwrap();
611 assert!(text.starts_with("generation="), "{text}");
612 let (_, json) = text.split_once("---json---").expect("json block present");
613 let parsed: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
614 assert_eq!(parsed["events"][0]["kind"], "ping");
615 assert_eq!(parsed["events"][0]["detail"], "hello");
616 }
617
618 #[test]
619 fn the_gitignore_hint_only_touches_an_existing_file() {
620 let dir = tempfile::tempdir().unwrap();
621 ensure_gitignore_hint(dir.path()).unwrap();
622 assert!(!dir.path().join(".gitignore").exists());
623
624 fs::write(dir.path().join(".gitignore"), "target\n").unwrap();
625 ensure_gitignore_hint(dir.path()).unwrap();
626 let text = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
627 assert!(text.contains(LOG_NAME), "{text}");
628 assert!(text.contains(GEN_NAME), "{text}");
629
630 ensure_gitignore_hint(dir.path()).unwrap();
632 let again = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
633 assert_eq!(text, again);
634 }
635
636 #[test]
637 fn concurrent_emits_assign_unique_sequences() {
638 use std::sync::Arc;
639 use std::thread;
640
641 let dir = tempfile::tempdir().unwrap();
642 let d = Arc::new(dir.path().to_path_buf());
643 let handles: Vec<_> = (0..16)
644 .map(|i| {
645 let d = Arc::clone(&d);
646 thread::spawn(move || emit_in(&d, "ping", None, None, None, Some(&format!("{i}"))))
647 })
648 .collect();
649 let mut seqs = Vec::new();
650 for handle in handles {
651 seqs.push(handle.join().unwrap().unwrap());
652 }
653 seqs.sort_unstable();
654 seqs.dedup();
655 assert_eq!(seqs.len(), 16, "duplicate event sequences: {seqs:?}");
656 assert_eq!(generation_in(&d), *seqs.last().unwrap());
657 }
658
659 #[test]
660 fn a_tail_counts_lines_not_sequence_numbers() {
661 let dir = tempfile::tempdir().unwrap();
662 let d = dir.path();
663 for i in 0..5 {
664 emit_in(d, "manual", None, None, None, Some(&format!("event {i}"))).unwrap();
665 }
666 let tailed = tail_in(d, 3).unwrap();
669 assert_eq!(tailed.len(), 3, "{tailed:?}");
670 assert_eq!(tailed[2].detail.as_deref(), Some("event 4"));
671 assert_eq!(tailed[0].detail.as_deref(), Some("event 2"));
672 assert!(tail_in(d, 50).unwrap().len() == 5);
673 }
674
675 #[test]
676 fn waiting_returns_the_current_generation_on_timeout() {
677 let dir = tempfile::tempdir().unwrap();
678 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
679 let g = generation(&layout);
680 let waited = wait_generation(&layout, g + 100, 50, 120).unwrap();
681 assert!(waited <= g + 100, "timed out without advancing");
682 }
683
684 fn layout_with_issue(state: &str, id: &str) -> (tempfile::TempDir, Layout) {
685 let dir = tempfile::tempdir().unwrap();
686 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
687 let path = layout.project_issues_path("sample");
688 fs::create_dir_all(path.parent().unwrap()).unwrap();
689 fs::write(
690 &path,
691 format!(
692 "#+TITLE: sample issues\n{TODO_HEADER}\n\n* {state} [#B] wait target\n:PROPERTIES:\n:ID: {id}\n:END:\n"
693 ),
694 )
695 .unwrap();
696 (dir, layout)
697 }
698
699 #[test]
700 fn emit_state_change_writes_kind_id_and_detail() {
701 let dir = tempfile::tempdir().unwrap();
702 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
703 let seq = emit_state_change(&layout, "sample", "sample-aaaa", "TODO", "CANCELLED").unwrap();
704 let events = since(&layout, 0, 10).unwrap();
705 assert_eq!(events.len(), 1);
706 assert_eq!(events[0].seq, seq);
707 assert_eq!(events[0].kind, "state_change");
708 assert_eq!(events[0].id.as_deref(), Some("sample-aaaa"));
709 assert_eq!(events[0].project.as_deref(), Some("sample"));
710 assert_eq!(events[0].detail.as_deref(), Some("TODO->CANCELLED"));
711 }
712
713 #[test]
714 fn two_rapid_state_changes_both_appear_in_the_log() {
715 let dir = tempfile::tempdir().unwrap();
716 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
717 emit_state_change(&layout, "sample", "sample-aaaa", "TODO", "STARTED").unwrap();
718 emit_state_change(&layout, "sample", "sample-aaaa", "STARTED", "DONE").unwrap();
719 let events = since(&layout, 0, 10).unwrap();
720 assert_eq!(events.len(), 2, "{events:?}");
721 assert_eq!(events[0].kind, "state_change");
722 assert_eq!(events[1].kind, "state_change");
723 assert_eq!(events[0].detail.as_deref(), Some("TODO->STARTED"));
724 assert_eq!(events[1].detail.as_deref(), Some("STARTED->DONE"));
725 assert_eq!(events[0].id.as_deref(), Some("sample-aaaa"));
726 assert_eq!(events[1].id.as_deref(), Some("sample-aaaa"));
727 }
728
729 #[test]
730 fn wait_until_terminal_returns_done_immediately() {
731 let (_dir, layout) = layout_with_issue("DONE", "sample-done");
732 let waited = wait_until_terminal(&layout, "sample-done", 50, 120).unwrap();
733 match waited {
734 TerminalWait::Done { .. } => {}
735 other => panic!("expected Done, got {other:?}"),
736 }
737 }
738
739 #[test]
740 fn wait_until_terminal_returns_cancelled() {
741 let (_dir, layout) = layout_with_issue("CANCELLED", "sample-canc");
742 let waited = wait_until_terminal(&layout, "sample-canc", 50, 120).unwrap();
743 match waited {
744 TerminalWait::Cancelled { .. } => {}
745 other => panic!("expected Cancelled, got {other:?}"),
746 }
747 }
748
749 #[test]
750 fn wait_until_terminal_times_out_on_started() {
751 let (_dir, layout) = layout_with_issue("STARTED", "sample-work");
752 let waited = wait_until_terminal(&layout, "sample-work", 50, 120).unwrap();
753 match waited {
754 TerminalWait::Timeout { state, .. } => assert_eq!(state, "STARTED"),
755 other => panic!("expected Timeout, got {other:?}"),
756 }
757 }
758}