1use crate::bash_background::process::is_process_alive;
7use crate::executor::Executor;
8use crate::run_tool_call::{ToolCallPhaseDurations, WaitingOn};
9use std::cell::RefCell;
10use std::collections::{BTreeMap, HashMap, VecDeque};
11use std::fs::{self, File, OpenOptions};
12use std::io::{self, BufWriter, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::mpsc::{self, SyncSender, TrySendError};
16use std::sync::{LazyLock, Mutex, OnceLock};
17use std::thread;
18use std::time::{Duration, Instant, SystemTime};
19
20const LOG_FILE_BYTES: u64 = 32 * 1024 * 1024;
22const LOG_GENERATIONS: usize = 1;
24const ROTATION_CHECK_EVERY: u64 = 1;
26const LOG_CHANNEL_CAPACITY: usize = 4096;
27const DEAD_PROCESS_LOG_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
29const LOG_DIRECTORY_BUDGET_BYTES: u64 = 200 * 1024 * 1024;
31const LOG_SWEEP_INTERVAL: Duration = Duration::from_secs(60 * 60);
33const DEFAULT_PERF_TICK_INTERVAL: Duration = Duration::from_secs(60);
34const PERF_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
35const SLOW_TOOL_CALL_THRESHOLD: Duration = Duration::from_millis(50);
36const TOOL_CALL_SAMPLE_CAPACITY: usize = 256;
37const INDEX_EVENT_MAX_BYTES: usize = 300;
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
42pub(crate) enum IndexPlane {
43 Callgraph,
44 Search,
45 Semantic,
46 Tier2,
47}
48
49impl IndexPlane {
50 pub(crate) fn as_str(self) -> &'static str {
51 match self {
52 Self::Callgraph => "callgraph",
53 Self::Search => "search",
54 Self::Semantic => "semantic",
55 Self::Tier2 => "tier2",
56 }
57 }
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub(crate) enum IndexEventKind {
63 BuildStarted,
64 BuildProgress,
65 BuildReady,
66 BuildSuperseded,
67 #[allow(dead_code)]
68 BuildCancelled,
69 BuildFailed,
70 BuildSuspended,
71 BreakerAdmitted,
72 BreakerReset,
73 ArtifactLoaded,
74 FirstQuery,
75}
76
77impl IndexEventKind {
78 pub(crate) fn as_str(self) -> &'static str {
79 match self {
80 Self::BuildStarted => "build_started",
81 Self::BuildProgress => "build_progress",
82 Self::BuildReady => "build_ready",
83 Self::BuildSuperseded => "build_superseded",
84 Self::BuildCancelled => "build_cancelled",
85 Self::BuildFailed => "build_failed",
86 Self::BuildSuspended => "build_suspended",
87 Self::BreakerAdmitted => "breaker_admitted",
88 Self::BreakerReset => "breaker_reset",
89 Self::ArtifactLoaded => "artifact_loaded",
90 Self::FirstQuery => "first_query",
91 }
92 }
93
94 fn is_terminal(self) -> bool {
95 matches!(
96 self,
97 Self::BuildReady
98 | Self::BuildSuperseded
99 | Self::BuildCancelled
100 | Self::BuildFailed
101 | Self::BuildSuspended
102 )
103 }
104}
105
106#[derive(Clone, Debug)]
108pub(crate) struct IndexEvent {
109 pub kind: IndexEventKind,
110 pub plane: IndexPlane,
111 pub build_id: String,
112 pub root: PathBuf,
113 pub key: String,
114 extra: Vec<(&'static str, String)>,
115}
116
117impl IndexEvent {
118 pub(crate) fn new(
119 kind: IndexEventKind,
120 plane: IndexPlane,
121 build_id: impl Into<String>,
122 root: impl AsRef<Path>,
123 key: impl Into<String>,
124 ) -> Self {
125 Self {
126 kind,
127 plane,
128 build_id: build_id.into(),
129 root: root.as_ref().to_path_buf(),
130 key: key.into(),
131 extra: Vec::new(),
132 }
133 }
134
135 pub(crate) fn from_scope(kind: IndexEventKind, scope: &IndexBuildScope) -> Self {
136 Self::new(
137 kind,
138 scope.plane,
139 scope.build_id.clone(),
140 &scope.root,
141 scope.key.clone(),
142 )
143 }
144
145 pub(crate) fn field(mut self, key: &'static str, value: impl ToString) -> Self {
146 self.extra.push((key, value.to_string()));
147 self
148 }
149}
150
151#[derive(Clone, Debug)]
154pub(crate) struct IndexBuildScope {
155 pub plane: IndexPlane,
156 pub build_id: String,
157 pub root: PathBuf,
158 pub key: String,
159 pub started_at: Instant,
160}
161
162impl IndexBuildScope {
163 pub(crate) fn new(plane: IndexPlane, root: impl AsRef<Path>, key: impl Into<String>) -> Self {
164 Self {
165 plane,
166 build_id: mint_index_build_id(),
167 root: root.as_ref().to_path_buf(),
168 key: key.into(),
169 started_at: Instant::now(),
170 }
171 }
172
173 pub(crate) fn elapsed_ms(&self) -> u64 {
174 self.started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
175 }
176}
177
178struct InFlightBuild {
179 build_id: String,
180}
181
182struct UnclaimedReady {
183 build_id: String,
184 key: String,
185 ready_at: Instant,
186}
187
188#[derive(Default)]
189struct IndexLifecycle {
190 in_flight: Mutex<HashMap<(String, IndexPlane), InFlightBuild>>,
191 unclaimed: Mutex<HashMap<(String, IndexPlane), UnclaimedReady>>,
192}
193
194#[derive(Default)]
195struct IndexBuildStartSignals {
196 sequence: u64,
197 last_by_root_plane: HashMap<(String, IndexPlane), u64>,
198 in_flight_by_root_plane: HashMap<(String, IndexPlane), u64>,
199 waiters: HashMap<(String, IndexPlane), Vec<(u64, crossbeam_channel::Sender<()>)>>,
200}
201
202struct ToolCallWaitState {
203 waiting_on: WaitingOn,
204 waiting_on_build_id: Option<String>,
205 wait_ms: u64,
206 queue_ms: u64,
207}
208
209impl Default for ToolCallWaitState {
210 fn default() -> Self {
211 Self {
212 waiting_on: WaitingOn::None,
213 waiting_on_build_id: None,
214 wait_ms: 0,
215 queue_ms: 0,
216 }
217 }
218}
219
220static INDEX_BUILD_COUNTER: AtomicU64 = AtomicU64::new(1);
221static INDEX_LIFECYCLE: LazyLock<IndexLifecycle> = LazyLock::new(IndexLifecycle::default);
222static INDEX_BUILD_START_SIGNALS: LazyLock<Mutex<IndexBuildStartSignals>> =
223 LazyLock::new(|| Mutex::new(IndexBuildStartSignals::default()));
224
225thread_local! {
226 static CURRENT_INDEX_BUILD: RefCell<Option<IndexBuildScope>> = const { RefCell::new(None) };
227 static TOOL_CALL_WAIT: RefCell<ToolCallWaitState> = const {
228 RefCell::new(ToolCallWaitState {
229 waiting_on: WaitingOn::None,
230 waiting_on_build_id: None,
231 wait_ms: 0,
232 queue_ms: 0,
233 })
234 };
235}
236
237#[cfg(test)]
238static INDEX_EVENT_CAPTURE: LazyLock<Mutex<Option<Vec<String>>>> =
239 LazyLock::new(|| Mutex::new(None));
240#[cfg(test)]
241static INDEX_EVENT_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
242
243pub(crate) fn mint_index_build_id() -> String {
245 let n = INDEX_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed);
246 format!("b-{}-{n}", std::process::id())
247}
248
249pub(crate) struct IndexBuildGuard {
251 previous: Option<IndexBuildScope>,
252}
253
254impl Drop for IndexBuildGuard {
255 fn drop(&mut self) {
256 CURRENT_INDEX_BUILD.with(|slot| {
257 *slot.borrow_mut() = self.previous.take();
258 });
259 }
260}
261
262pub(crate) fn install_index_build(scope: IndexBuildScope) -> IndexBuildGuard {
264 let previous = CURRENT_INDEX_BUILD.with(|slot| slot.replace(Some(scope)));
265 IndexBuildGuard { previous }
266}
267
268#[allow(dead_code)]
270pub(crate) fn with_index_build<R>(scope: IndexBuildScope, f: impl FnOnce() -> R) -> R {
271 let _guard = install_index_build(scope);
272 f()
273}
274
275pub(crate) struct IndexBuildFailureGuard {
277 armed: bool,
278}
279
280impl IndexBuildFailureGuard {
281 pub(crate) fn new() -> Self {
282 Self { armed: true }
283 }
284
285 pub(crate) fn disarm(&mut self) {
286 self.armed = false;
287 }
288}
289
290impl Drop for IndexBuildFailureGuard {
291 fn drop(&mut self) {
292 if !self.armed {
293 return;
294 }
295 let Some(scope) = current_index_build() else {
296 return;
297 };
298 if in_flight_build_id(scope.plane, &scope.root).as_deref() != Some(scope.build_id.as_str())
300 {
301 return;
302 }
303 log_current_index_event(
304 IndexEventKind::BuildFailed,
305 &[("reason", "error".to_string())],
306 );
307 }
308}
309
310pub(crate) fn current_index_build() -> Option<IndexBuildScope> {
311 CURRENT_INDEX_BUILD.with(|slot| slot.borrow().clone())
312}
313
314pub(crate) fn normalize_index_root(root: &Path) -> String {
316 root.to_string_lossy().replace('\\', "/")
317}
318
319fn sanitize_index_value(value: &str) -> String {
320 let mut out = String::with_capacity(value.len().max(1));
321 for ch in value.chars() {
322 match ch {
323 ' ' | '\t' | '\n' | '\r' | '=' => out.push('_'),
324 '\\' => out.push('/'),
325 c if c.is_ascii_graphic() || c == '/' => out.push(c),
326 _ => out.push('_'),
327 }
328 }
329 if out.is_empty() {
330 out.push('-');
331 }
332 out
333}
334
335fn push_index_field(line: &mut String, key: &str, value: &str) {
336 let sanitized = sanitize_index_value(value);
337 let addition = format!(" {key}={sanitized}");
338 if line.len() + addition.len() > INDEX_EVENT_MAX_BYTES {
339 return;
340 }
341 line.push_str(&addition);
342}
343
344fn left_truncate_root(root: &str, max_bytes: usize) -> String {
346 const MARKER: &str = "...";
347 if root.len() <= max_bytes {
348 return root.to_string();
349 }
350 let max_bytes = max_bytes.max(MARKER.len());
351 let keep = max_bytes.saturating_sub(MARKER.len());
352 let mut start = root.len().saturating_sub(keep);
353 while start < root.len() && !root.is_char_boundary(start) {
354 start += 1;
355 }
356 format!("{MARKER}{}", &root[start..])
357}
358
359fn format_index_event_line(event: &IndexEvent) -> String {
360 let kind = sanitize_index_value(event.kind.as_str());
361 let plane = sanitize_index_value(event.plane.as_str());
362 let build_id = sanitize_index_value(&event.build_id);
363 let key = sanitize_index_value(&event.key);
364 let mut root = sanitize_index_value(&normalize_index_root(&event.root));
365 let prefix = format!("index_event kind={kind} plane={plane} build_id={build_id} root=");
366 let key_field = format!(" key={key}");
367 let root_budget = INDEX_EVENT_MAX_BYTES
368 .saturating_sub(prefix.len())
369 .saturating_sub(key_field.len());
370 if root.len() > root_budget {
371 root = left_truncate_root(&root, root_budget);
372 }
373 let mut line = format!("{prefix}{root}{key_field}");
374 for (key, value) in &event.extra {
375 push_index_field(&mut line, key, value);
376 }
377 line
378}
379
380fn root_plane_key(root: &Path, plane: IndexPlane) -> (String, IndexPlane) {
381 (normalize_index_root(root), plane)
382}
383
384fn remember_in_flight(event: &IndexEvent) {
385 let key = root_plane_key(&event.root, event.plane);
386 if event.kind == IndexEventKind::BuildStarted {
387 if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
388 signals.sequence = signals.sequence.wrapping_add(1);
389 let sequence = signals.sequence;
390 signals.last_by_root_plane.insert(key.clone(), sequence);
391 signals
392 .in_flight_by_root_plane
393 .insert(key.clone(), sequence);
394 if let Some(waiters) = signals.waiters.remove(&key) {
395 for (baseline, sender) in waiters {
396 if sequence > baseline {
397 let _ = sender.send(());
398 }
399 }
400 }
401 }
402 if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
403 in_flight.insert(
404 key,
405 InFlightBuild {
406 build_id: event.build_id.clone(),
407 },
408 );
409 }
410 return;
411 }
412 if event.kind.is_terminal() {
413 if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
414 in_flight.remove(&key);
415 }
416 if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
417 signals.in_flight_by_root_plane.remove(&key);
418 }
419 release_index_build_start_waiters(event.plane, &event.root);
420 }
421 if event.kind == IndexEventKind::BuildReady {
422 if let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() {
423 unclaimed.insert(
424 key,
425 UnclaimedReady {
426 build_id: event.build_id.clone(),
427 key: event.key.clone(),
428 ready_at: Instant::now(),
429 },
430 );
431 }
432 }
433}
434
435fn emit_index_event_line(line: String) {
436 #[cfg(test)]
437 if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
438 if let Some(events) = slot.as_mut() {
439 events.push(line.clone());
440 }
441 }
442 crate::slog_info!("{}", line);
443}
444
445pub(crate) fn log_index_event(event: IndexEvent) {
447 remember_in_flight(&event);
448 emit_index_event_line(format_index_event_line(&event));
449}
450
451pub(crate) fn log_watcher_rescan(
452 root: &Path,
453 reason: crate::watcher_filter::RescanReason,
454 cost_ms: u64,
455 rss_delta_bytes: Option<i64>,
456 raw_events_since_last: u64,
457) {
458 let reason = sanitize_index_value(reason.as_str());
459 let rss_delta = rss_delta_bytes
460 .map(|delta| delta.to_string())
461 .unwrap_or_else(|| "unavailable".to_string());
462 let prefix = "index_event kind=watcher_rescan plane=watcher root=";
463 let suffix = format!(
464 " reason={reason} cost_ms={cost_ms} rss_delta_bytes={rss_delta} raw_events_since_last={raw_events_since_last}"
465 );
466 let mut root = sanitize_index_value(&normalize_index_root(root));
467 let root_budget = INDEX_EVENT_MAX_BYTES
468 .saturating_sub(prefix.len())
469 .saturating_sub(suffix.len());
470 if root.len() > root_budget {
471 root = left_truncate_root(&root, root_budget);
472 }
473 emit_index_event_line(format!("{prefix}{root}{suffix}"));
474}
475
476pub(crate) fn log_current_index_event(kind: IndexEventKind, extra: &[(&'static str, String)]) {
478 let Some(scope) = current_index_build() else {
479 return;
480 };
481 let mut event = IndexEvent::from_scope(kind, &scope);
482 for (key, value) in extra {
483 event.extra.push((key, value.clone()));
484 }
485 log_index_event(event);
486}
487
488pub(crate) fn index_build_start_sequence(plane: IndexPlane, root: &Path) -> u64 {
490 INDEX_BUILD_START_SIGNALS
491 .lock()
492 .ok()
493 .and_then(|signals| {
494 signals
495 .last_by_root_plane
496 .get(&root_plane_key(root, plane))
497 .copied()
498 })
499 .unwrap_or(0)
500}
501
502pub(crate) fn signal_after_index_build_start(
504 plane: IndexPlane,
505 root: &Path,
506 baseline: u64,
507 sender: crossbeam_channel::Sender<()>,
508) {
509 let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() else {
510 let _ = sender.send(());
511 return;
512 };
513 let key = root_plane_key(root, plane);
514 if signals.in_flight_by_root_plane.contains_key(&key)
515 || signals
516 .last_by_root_plane
517 .get(&key)
518 .is_some_and(|sequence| *sequence > baseline)
519 {
520 let _ = sender.send(());
521 } else {
522 signals
523 .waiters
524 .entry(key)
525 .or_default()
526 .push((baseline, sender));
527 }
528}
529
530pub(crate) fn release_index_build_start_waiters(plane: IndexPlane, root: &Path) {
532 let waiters = INDEX_BUILD_START_SIGNALS
533 .lock()
534 .ok()
535 .and_then(|mut signals| signals.waiters.remove(&root_plane_key(root, plane)))
536 .unwrap_or_default();
537 for (_, sender) in waiters {
538 let _ = sender.send(());
539 }
540}
541
542pub(crate) fn in_flight_build_id(plane: IndexPlane, root: &Path) -> Option<String> {
544 INDEX_LIFECYCLE
545 .in_flight
546 .lock()
547 .ok()?
548 .get(&root_plane_key(root, plane))
549 .map(|build| build.build_id.clone())
550}
551
552pub(crate) fn claim_first_query(
554 plane: IndexPlane,
555 root: &Path,
556 tool: &str,
557 queue_ms: u64,
558 service_ms: u64,
559 status: &str,
560) -> bool {
561 let slot = {
562 let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() else {
563 return false;
564 };
565 unclaimed.remove(&root_plane_key(root, plane))
566 };
567 let Some(slot) = slot else {
568 return false;
569 };
570 let ready_to_first_query_ms = slot.ready_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
571 log_index_event(
572 IndexEvent::new(
573 IndexEventKind::FirstQuery,
574 plane,
575 slot.build_id,
576 root,
577 slot.key,
578 )
579 .field("tool", tool)
580 .field("queue_ms", queue_ms)
581 .field("service_ms", service_ms)
582 .field("status", status)
583 .field("ready_to_first_query_ms", ready_to_first_query_ms),
584 );
585 true
586}
587
588pub(crate) fn note_index_query(
590 plane: IndexPlane,
591 root: &Path,
592 tool: &str,
593 service_ms: u64,
594 status: &str,
595) {
596 match status {
597 "building" | "rebuilding" => {
598 let build_id = in_flight_build_id(plane, root);
599 note_tool_call_wait(WaitingOn::Build, build_id.as_deref(), 0);
600 }
601 _ => {
602 let queue_ms = TOOL_CALL_WAIT.with(|slot| slot.borrow().queue_ms);
603 claim_first_query(plane, root, tool, queue_ms, service_ms, status);
604 }
605 }
606}
607
608pub(crate) fn reset_tool_call_wait() {
610 TOOL_CALL_WAIT.with(|slot| *slot.borrow_mut() = ToolCallWaitState::default());
611}
612
613pub(crate) fn note_tool_call_wait(waiting_on: WaitingOn, build_id: Option<&str>, wait_ms: u64) {
615 TOOL_CALL_WAIT.with(|slot| {
616 let mut state = slot.borrow_mut();
617 state.waiting_on = waiting_on;
618 state.waiting_on_build_id = build_id.map(str::to_string);
619 state.wait_ms = state.wait_ms.saturating_add(wait_ms);
620 });
621}
622
623pub(crate) fn note_tool_call_queue_ms(queue_ms: u64) {
624 TOOL_CALL_WAIT.with(|slot| slot.borrow_mut().queue_ms = queue_ms);
625}
626
627pub(crate) fn take_tool_call_wait() -> (WaitingOn, Option<String>, u64) {
628 TOOL_CALL_WAIT.with(|slot| {
629 let state = std::mem::take(&mut *slot.borrow_mut());
630 (state.waiting_on, state.waiting_on_build_id, state.wait_ms)
631 })
632}
633
634#[cfg(test)]
635pub(crate) fn capture_index_events<R>(f: impl FnOnce() -> R) -> (R, Vec<String>) {
636 let _serial = INDEX_EVENT_CAPTURE_LOCK
637 .lock()
638 .unwrap_or_else(std::sync::PoisonError::into_inner);
639 if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
640 *slot = Some(Vec::new());
641 }
642 let result = f();
643 let events = INDEX_EVENT_CAPTURE
644 .lock()
645 .ok()
646 .and_then(|mut slot| slot.take())
647 .unwrap_or_default();
648 (result, events)
649}
650
651pub fn init() {
653 let storage_root = crate::bash_background::storage_dir(None);
654 let logs_dir = storage_root.join("logs");
655 let file_name = format!("aft-{}.log", std::process::id());
656 let file_path = logs_dir.join(file_name);
657 let mut startup_sweep = None;
658
659 let file_tx = match prepare_file_sink(&logs_dir, &file_path) {
660 Ok((sink, summary)) => {
661 startup_sweep = Some(summary);
662 let (tx, rx) = mpsc::sync_channel(LOG_CHANNEL_CAPACITY);
663 thread::Builder::new()
664 .name("aft-log-writer".to_string())
665 .spawn(move || run_file_writer(sink, rx))
666 .map(|_| {
667 if let Ok(mut control) = FILE_CONTROL.lock() {
668 control.tx = Some(tx.clone());
669 control.storage_root = Some(storage_root.clone());
670 }
671 Some(tx)
672 })
673 .unwrap_or_else(|error| {
674 write_stderr_once(&format!(
675 "[aft] durable log disabled: cannot start writer thread: {error}\n"
676 ));
677 None
678 })
679 }
680 Err(error) => {
681 write_stderr_once(&format!(
682 "[aft] durable log disabled for {}: {error}\n",
683 file_path.display()
684 ));
685 None
686 }
687 };
688
689 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
690 .target(env_logger::Target::Pipe(Box::new(TeeWriter { file_tx })))
691 .format(|buf, record| {
692 let prefix = if record.target().starts_with("aft::lsp")
693 || record.target().starts_with("aft_lsp")
694 {
695 "[aft-lsp]"
696 } else {
697 "[aft]"
698 };
699 writeln!(
704 buf,
705 "{} {} {}",
706 format_utc_timestamp(),
707 prefix,
708 record.args()
709 )
710 })
711 .init();
712
713 if let Some(summary) = startup_sweep {
714 log_sweep_summary(summary);
715 }
716}
717
718fn format_utc_timestamp() -> String {
723 let secs = SystemTime::now()
724 .duration_since(SystemTime::UNIX_EPOCH)
725 .map(|d| d.as_secs())
726 .unwrap_or(0);
727 format_epoch_secs(secs)
728}
729
730fn format_epoch_secs(secs: u64) -> String {
731 let (days, rem) = (secs / 86_400, secs % 86_400);
732 let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
733 let z = days as i64 + 719_468;
737 let era = z.div_euclid(146_097);
738 let doe = z.rem_euclid(146_097);
739 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
740 let y = yoe + era * 400;
741 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
742 let mp = (5 * doy + 2) / 153;
743 let d = doy - (153 * mp + 2) / 5 + 1;
744 let m = if mp < 10 { mp + 3 } else { mp - 9 };
745 let y = if m <= 2 { y + 1 } else { y };
746 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
747}
748
749fn prepare_file_sink(
750 logs_dir: &Path,
751 file_path: &Path,
752) -> io::Result<(RotatingFile, SweepSummary)> {
753 fs::create_dir_all(logs_dir)?;
754 let summary = sweep_logs(
755 logs_dir,
756 SystemTime::now(),
757 DEAD_PROCESS_LOG_MAX_AGE,
758 LOG_DIRECTORY_BUDGET_BYTES,
759 )?;
760 mark_log_sweep_ran();
761 let sink = RotatingFile::open(
762 file_path.to_path_buf(),
763 LOG_FILE_BYTES,
764 LOG_GENERATIONS,
765 ROTATION_CHECK_EVERY,
766 )?;
767 Ok((sink, summary))
768}
769
770enum LogMessage {
771 Write(Vec<u8>),
772 Reconfigure(PathBuf),
773}
774
775#[derive(Default)]
776struct FileControl {
777 tx: Option<SyncSender<LogMessage>>,
778 storage_root: Option<PathBuf>,
779}
780
781static FILE_CONTROL: LazyLock<Mutex<FileControl>> =
782 LazyLock::new(|| Mutex::new(FileControl::default()));
783
784struct TeeWriter {
785 file_tx: Option<SyncSender<LogMessage>>,
786}
787
788impl Write for TeeWriter {
789 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
790 io::stderr().write_all(buf)?;
791 if let Some(tx) = self.file_tx.as_ref() {
792 match tx.try_send(LogMessage::Write(buf.to_vec())) {
793 Ok(()) => {}
794 Err(TrySendError::Full(_)) => {
795 PERF.file_lines_dropped.fetch_add(1, Ordering::Relaxed);
796 }
797 Err(TrySendError::Disconnected(_)) => self.file_tx = None,
798 }
799 }
800 Ok(buf.len())
801 }
802
803 fn flush(&mut self) -> io::Result<()> {
804 io::stderr().flush()
805 }
806}
807
808fn run_file_writer(mut sink: RotatingFile, rx: mpsc::Receiver<LogMessage>) {
809 while let Ok(message) = rx.recv() {
810 let mut lines = Vec::new();
811 let mut reconfigure = None;
812 match message {
813 LogMessage::Write(line) => {
814 lines.push(line);
815 while lines.len() < 256 {
816 match rx.try_recv() {
817 Ok(LogMessage::Write(line)) => lines.push(line),
818 Ok(LogMessage::Reconfigure(storage_root)) => {
819 reconfigure = Some(storage_root);
820 break;
821 }
822 Err(_) => break,
823 }
824 }
825 }
826 LogMessage::Reconfigure(storage_root) => reconfigure = Some(storage_root),
827 }
828 if !lines.is_empty() {
829 if let Err(error) = sink.write_batch(&lines) {
830 write_stderr_once(&format!(
831 "[aft] durable log disabled after write failure for {}: {error}\n",
832 sink.path.display()
833 ));
834 break;
835 }
836 }
837 if let Some(storage_root) = reconfigure {
838 let logs_dir = storage_root.join("logs");
839 let path = logs_dir.join(format!("aft-{}.log", std::process::id()));
840 match prepare_file_sink(&logs_dir, &path) {
841 Ok((new_sink, summary)) => {
842 sink = new_sink;
843 log_sweep_summary(summary);
844 }
845 Err(error) => write_stderr_once(&format!(
846 "[aft] durable log could not switch to {}: {error}\n",
847 path.display()
848 )),
849 }
850 }
851 }
852}
853
854fn write_stderr_once(message: &str) {
855 let _ = io::stderr().write_all(message.as_bytes());
856}
857
858struct RotatingFile {
859 path: PathBuf,
860 writer: Option<BufWriter<File>>,
861 size: u64,
862 threshold: u64,
863 generations: usize,
864 check_every: u64,
865 writes_since_check: u64,
866}
867
868impl RotatingFile {
869 fn open(
870 path: PathBuf,
871 threshold: u64,
872 generations: usize,
873 check_every: u64,
874 ) -> io::Result<Self> {
875 let file = OpenOptions::new().create(true).append(true).open(&path)?;
876 let size = file.metadata()?.len();
877 let mut sink = Self {
878 path,
879 writer: Some(BufWriter::new(file)),
880 size,
881 threshold,
882 generations,
883 check_every: check_every.max(1),
884 writes_since_check: 0,
885 };
886 if size > threshold {
887 sink.rotate()?;
888 }
889 Ok(sink)
890 }
891
892 fn write_batch(&mut self, lines: &[Vec<u8>]) -> io::Result<()> {
893 let batch_bytes = lines.iter().map(Vec::len).sum::<usize>() as u64;
894 self.writes_since_check = self.writes_since_check.saturating_add(lines.len() as u64);
895 if self.writes_since_check >= self.check_every
896 && self.size > 0
897 && self.size.saturating_add(batch_bytes) > self.threshold
898 {
899 self.rotate()?;
900 }
901 let writer = self
902 .writer
903 .as_mut()
904 .ok_or_else(|| io::Error::other("log writer unavailable"))?;
905 for line in lines {
906 writer.write_all(line)?;
907 }
908 writer.flush()?;
911 self.size = self.size.saturating_add(batch_bytes);
912 if self.writes_since_check >= self.check_every {
913 self.writes_since_check = 0;
914 }
915 Ok(())
916 }
917
918 fn rotate(&mut self) -> io::Result<()> {
919 if let Some(mut writer) = self.writer.take() {
920 writer.flush()?;
921 }
922 if self.generations > 0 {
923 let oldest = rotated_path(&self.path, self.generations);
924 remove_file_if_present(&oldest)?;
925 for generation in (1..self.generations).rev() {
926 let from = rotated_path(&self.path, generation);
927 let to = rotated_path(&self.path, generation + 1);
928 rename_if_present(&from, &to)?;
929 }
930 rename_if_present(&self.path, &rotated_path(&self.path, 1))?;
931 } else {
932 remove_file_if_present(&self.path)?;
933 }
934 let file = OpenOptions::new()
935 .create(true)
936 .write(true)
937 .truncate(true)
938 .open(&self.path)?;
939 self.writer = Some(BufWriter::new(file));
940 self.size = 0;
941 self.writes_since_check = 0;
942 Ok(())
943 }
944}
945
946fn rotated_path(base: &Path, generation: usize) -> PathBuf {
947 let mut path = base.as_os_str().to_os_string();
948 path.push(format!(".{generation}"));
949 PathBuf::from(path)
950}
951
952fn remove_file_if_present(path: &Path) -> io::Result<()> {
953 match fs::remove_file(path) {
954 Ok(()) => Ok(()),
955 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
956 Err(error) => Err(error),
957 }
958}
959
960fn rename_if_present(from: &Path, to: &Path) -> io::Result<()> {
961 match fs::rename(from, to) {
962 Ok(()) => Ok(()),
963 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
964 Err(error) => Err(error),
965 }
966}
967
968#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
969struct SweepSummary {
970 removed_files: usize,
971 bytes_freed: u64,
972}
973
974struct ProcessLogFile {
975 path: PathBuf,
976 modified: Option<SystemTime>,
977 bytes: u64,
978 dead: bool,
979 old_enough: bool,
980 removed: bool,
981}
982
983fn log_sweep_summary(summary: SweepSummary) {
984 crate::slog_info!(
985 "log retention sweep: removed_files={} bytes_freed={}",
986 summary.removed_files,
987 summary.bytes_freed
988 );
989}
990
991fn sweep_logs(
994 dir: &Path,
995 now: SystemTime,
996 max_age: Duration,
997 budget_bytes: u64,
998) -> io::Result<SweepSummary> {
999 let mut total_bytes = 0_u64;
1000 let mut process_logs = Vec::new();
1001 let mut live_pids = BTreeMap::new();
1002 let own_pid = std::process::id();
1003
1004 for entry in fs::read_dir(dir)? {
1005 let entry = match entry {
1006 Ok(entry) => entry,
1007 Err(_) => continue,
1008 };
1009 let metadata = match entry.metadata() {
1010 Ok(metadata) if metadata.is_file() => metadata,
1011 Ok(_) | Err(_) => continue,
1012 };
1013 let bytes = metadata.len();
1014 total_bytes = total_bytes.saturating_add(bytes);
1015 let name = entry.file_name();
1016 let name = name.to_string_lossy();
1017 let pid = match name.as_ref() {
1018 "aft-plugin.log" => continue,
1021 _ => process_log_pid(&name),
1022 };
1023 let Some(pid) = pid else {
1024 continue;
1025 };
1026 let modified = metadata.modified().ok();
1027 let old_enough = modified
1028 .and_then(|modified| now.duration_since(modified).ok())
1029 .is_some_and(|age| age >= max_age);
1030 let alive = *live_pids
1031 .entry(pid)
1032 .or_insert_with(|| is_process_alive(pid));
1033 process_logs.push(ProcessLogFile {
1034 path: entry.path(),
1035 modified,
1036 bytes,
1037 dead: pid != own_pid && !alive,
1038 old_enough,
1039 removed: false,
1040 });
1041 }
1042
1043 let mut summary = SweepSummary::default();
1044 for file in &mut process_logs {
1045 if file.dead && file.old_enough && remove_sweep_candidate(&file.path) {
1046 file.removed = true;
1047 total_bytes = total_bytes.saturating_sub(file.bytes);
1048 summary.removed_files += 1;
1049 summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1050 }
1051 }
1052
1053 process_logs.sort_by_key(|file| file.modified);
1058 for file in process_logs
1059 .iter_mut()
1060 .filter(|file| file.dead && !file.removed)
1061 {
1062 if total_bytes <= budget_bytes {
1063 break;
1064 }
1065 if remove_sweep_candidate(&file.path) {
1066 file.removed = true;
1067 total_bytes = total_bytes.saturating_sub(file.bytes);
1068 summary.removed_files += 1;
1069 summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1070 }
1071 }
1072
1073 Ok(summary)
1074}
1075
1076fn remove_sweep_candidate(path: &Path) -> bool {
1077 fs::remove_file(path).is_ok()
1080}
1081
1082fn process_log_pid(name: &str) -> Option<u32> {
1083 let rest = name.strip_prefix("aft-")?;
1084 let (pid, suffix) = rest.split_once(".log")?;
1085 if !suffix.is_empty()
1086 && !(suffix.starts_with('.') && suffix[1..].chars().all(|ch| ch.is_ascii_digit()))
1087 {
1088 return None;
1089 }
1090 pid.parse().ok()
1091}
1092
1093static LAST_LOG_SWEEP: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
1094
1095fn mark_log_sweep_ran() {
1096 if let Ok(mut last_run) = LAST_LOG_SWEEP.lock() {
1097 *last_run = Some(Instant::now());
1098 }
1099}
1100
1101pub fn maybe_sweep_logs() {
1103 let now = Instant::now();
1104 let should_run = LAST_LOG_SWEEP
1105 .lock()
1106 .map(|mut last_run| {
1107 if last_run.is_some_and(|last| now.duration_since(last) < LOG_SWEEP_INTERVAL) {
1108 false
1109 } else {
1110 *last_run = Some(now);
1111 true
1112 }
1113 })
1114 .unwrap_or(false);
1115 if !should_run {
1116 return;
1117 }
1118
1119 let storage_root = FILE_CONTROL
1120 .lock()
1121 .ok()
1122 .and_then(|control| control.storage_root.clone())
1123 .unwrap_or_else(|| crate::bash_background::storage_dir(None));
1124 let logs_dir = storage_root.join("logs");
1125 match sweep_logs(
1126 &logs_dir,
1127 SystemTime::now(),
1128 DEAD_PROCESS_LOG_MAX_AGE,
1129 LOG_DIRECTORY_BUDGET_BYTES,
1130 ) {
1131 Ok(summary) => log_sweep_summary(summary),
1132 Err(error) => crate::slog_warn!(
1133 "log retention sweep failed for {}: {}",
1134 logs_dir.display(),
1135 error
1136 ),
1137 }
1138}
1139
1140#[derive(Default)]
1141struct PerfMetrics {
1142 watcher_ingested: AtomicU64,
1143 watcher_paths: AtomicU64,
1144 watcher_dropped: AtomicU64,
1145 drain_slices: AtomicU64,
1146 semantic_collects: AtomicU64,
1147 semantic_files: AtomicU64,
1148 semantic_chunks: AtomicU64,
1149 semantic_ms: AtomicU64,
1150 callgraph_invalidations: AtomicU64,
1151 file_lines_dropped: AtomicU64,
1152 tool_call_count: AtomicU64,
1153 tool_calls: Mutex<VecDeque<ToolCallPerfSample>>,
1154 tier2: Mutex<BTreeMap<String, (u64, u64)>>,
1155 next_sample_ns: AtomicU64,
1156 reporter: Mutex<PerfReporter>,
1157}
1158
1159struct PerfReporter {
1160 last_report: Instant,
1161 last_completed_interactive: u64,
1162 last_completed_maintenance: u64,
1163 last_tool_call_count: u64,
1164}
1165
1166impl Default for PerfReporter {
1167 fn default() -> Self {
1168 Self {
1169 last_report: Instant::now(),
1170 last_completed_interactive: 0,
1171 last_completed_maintenance: 0,
1172 last_tool_call_count: 0,
1173 }
1174 }
1175}
1176
1177#[derive(Clone, Copy)]
1178struct ToolCallPerfSample {
1179 total_ms: u64,
1180 queue_ms: u64,
1181}
1182
1183#[derive(Clone, Copy, Default)]
1184struct ToolCallPerfSummary {
1185 window: usize,
1186 p50_total_ms: u64,
1187 max_total_ms: u64,
1188 p50_queue_ms: u64,
1189 max_queue_ms: u64,
1190}
1191
1192#[derive(Clone, Copy, Default)]
1193struct ExecutorSample {
1194 interactive_running: usize,
1195 maintenance_running: usize,
1196 interactive_queued: usize,
1197 maintenance_queued: usize,
1198 interactive_oldest_ms: Option<u64>,
1199 maintenance_oldest_ms: Option<u64>,
1200}
1201
1202static PERF: LazyLock<PerfMetrics> = LazyLock::new(PerfMetrics::default);
1203
1204pub fn sync_storage_root(storage_root: PathBuf) {
1210 let Ok(mut control) = FILE_CONTROL.lock() else {
1211 return;
1212 };
1213 if control.storage_root.as_ref() == Some(&storage_root) {
1214 return;
1215 }
1216 let Some(tx) = control.tx.as_ref() else {
1217 return;
1218 };
1219 if tx
1220 .try_send(LogMessage::Reconfigure(storage_root.clone()))
1221 .is_ok()
1222 {
1223 control.storage_root = Some(storage_root);
1224 }
1225}
1226
1227pub fn note_watcher_events(count: usize) {
1229 PERF.watcher_ingested
1230 .fetch_add(count as u64, Ordering::Relaxed);
1231}
1232
1233pub fn note_drain_paths(count: usize) {
1235 PERF.watcher_paths
1236 .fetch_add(count as u64, Ordering::Relaxed);
1237}
1238
1239pub fn note_watcher_overflow() {
1241 PERF.watcher_dropped.fetch_add(1, Ordering::Relaxed);
1242}
1243
1244pub fn note_drain_slice() {
1246 PERF.drain_slices.fetch_add(1, Ordering::Relaxed);
1247}
1248
1249pub fn note_semantic_collect(chunks: usize, files: usize, elapsed_ms: u64) {
1251 PERF.semantic_collects.fetch_add(1, Ordering::Relaxed);
1252 PERF.semantic_chunks
1253 .fetch_add(chunks as u64, Ordering::Relaxed);
1254 PERF.semantic_files
1255 .fetch_add(files as u64, Ordering::Relaxed);
1256 PERF.semantic_ms.fetch_add(elapsed_ms, Ordering::Relaxed);
1257}
1258
1259pub fn note_tier2_scan(category: String, elapsed_ms: u64) {
1261 if let Ok(mut tier2) = PERF.tier2.lock() {
1262 let entry = tier2.entry(category).or_default();
1263 entry.0 = entry.0.saturating_add(1);
1264 entry.1 = entry.1.saturating_add(elapsed_ms);
1265 }
1266}
1267
1268pub fn note_callgraph_invalidations(files: usize) {
1270 PERF.callgraph_invalidations
1271 .fetch_add(files as u64, Ordering::Relaxed);
1272}
1273
1274pub fn note_tool_call_trace(
1278 name: &str,
1279 root: &Path,
1280 channel: u16,
1281 corr: u64,
1282 phases: ToolCallPhaseDurations,
1283) {
1284 let sample = ToolCallPerfSample {
1285 total_ms: duration_millis_u64(phases.total),
1286 queue_ms: duration_millis_u64(phases.queue),
1287 };
1288 if let Ok(mut samples) = PERF.tool_calls.lock() {
1289 if samples.len() == TOOL_CALL_SAMPLE_CAPACITY {
1290 samples.pop_front();
1291 }
1292 samples.push_back(sample);
1293 PERF.tool_call_count.fetch_add(1, Ordering::Relaxed);
1294 }
1295
1296 let waiting_on_build_id = phases.waiting_on_build_id.as_deref().unwrap_or("-");
1297 crate::slog_debug!(
1298 "tool_call phase name={} channel={} corr={} total_ms={:.3} queue_ms={:.3} translate_ms={:.3} exec_ms={:.3} format_ms={:.3} finalize_ms={:.3} egress_ms={:.3} egress_enqueue_ms={:.3} egress_queue_ms={:.3} egress_prepare_ms={:.3} egress_write_ms={:.3} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1299 name,
1300 channel,
1301 corr,
1302 duration_millis_f64(phases.total),
1303 duration_millis_f64(phases.queue),
1304 duration_millis_f64(phases.translate),
1305 duration_millis_f64(phases.execute),
1306 duration_millis_f64(phases.format),
1307 duration_millis_f64(phases.finalize),
1308 duration_millis_f64(phases.egress),
1309 duration_millis_f64(phases.egress_enqueue),
1310 duration_millis_f64(phases.egress_queue),
1311 duration_millis_f64(phases.egress_prepare),
1312 duration_millis_f64(phases.egress_write),
1313 phases.frame_bytes,
1314 phases.writer_queue_depth,
1315 phases.writer_active_at_enqueue,
1316 phases.writer_queue_was_full,
1317 phases.writer_reserve_timeouts,
1318 phases.waiting_on.as_str(),
1319 waiting_on_build_id,
1320 phases.wait_ms,
1321 root.display(),
1322 );
1323
1324 if phases.total > SLOW_TOOL_CALL_THRESHOLD {
1325 crate::slog_warn!(
1326 "slow tool_call name={} channel={} corr={} total={}ms queue={} translate={} exec={} format={} finalize={} egress={} egress_enqueue={} egress_queue={} egress_prepare={} egress_write={} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1327 name,
1328 channel,
1329 corr,
1330 duration_millis_u64(phases.total),
1331 duration_millis_u64(phases.queue),
1332 duration_millis_u64(phases.translate),
1333 duration_millis_u64(phases.execute),
1334 duration_millis_u64(phases.format),
1335 duration_millis_u64(phases.finalize),
1336 duration_millis_u64(phases.egress),
1337 duration_millis_u64(phases.egress_enqueue),
1338 duration_millis_u64(phases.egress_queue),
1339 duration_millis_u64(phases.egress_prepare),
1340 duration_millis_u64(phases.egress_write),
1341 phases.frame_bytes,
1342 phases.writer_queue_depth,
1343 phases.writer_active_at_enqueue,
1344 phases.writer_queue_was_full,
1345 phases.writer_reserve_timeouts,
1346 phases.waiting_on.as_str(),
1347 waiting_on_build_id,
1348 phases.wait_ms,
1349 root.display(),
1350 );
1351 }
1352}
1353
1354pub fn perf_tick(executor: Option<&Executor>) {
1359 if !perf_sample_due() {
1360 return;
1361 }
1362
1363 let sample = executor.and_then(|executor| {
1364 executor
1365 .try_dispatch_liveness_snapshot()
1366 .map(|snapshot| ExecutorSample {
1367 interactive_running: snapshot.running.interactive,
1368 maintenance_running: snapshot.running.maintenance,
1369 interactive_queued: snapshot.interactive.queued,
1370 maintenance_queued: snapshot.maintenance.queued,
1371 interactive_oldest_ms: snapshot.interactive.oldest_age_ms,
1372 maintenance_oldest_ms: snapshot.maintenance.oldest_age_ms,
1373 })
1374 });
1375
1376 let completion_counts = executor.map_or((0, 0), Executor::completion_counts);
1377 let tool_call_count = PERF.tool_call_count.load(Ordering::Relaxed);
1378 let (completed_interactive, completed_maintenance, new_tool_calls) = {
1379 let Ok(mut reporter) = PERF.reporter.lock() else {
1380 return;
1381 };
1382 if reporter.last_report.elapsed() < perf_tick_interval() {
1383 return;
1384 }
1385 reporter.last_report = Instant::now();
1386 let completed = (
1387 completion_counts
1388 .0
1389 .saturating_sub(reporter.last_completed_interactive),
1390 completion_counts
1391 .1
1392 .saturating_sub(reporter.last_completed_maintenance),
1393 tool_call_count.saturating_sub(reporter.last_tool_call_count),
1394 );
1395 reporter.last_completed_interactive = completion_counts.0;
1396 reporter.last_completed_maintenance = completion_counts.1;
1397 reporter.last_tool_call_count = tool_call_count;
1398 completed
1399 };
1400
1401 let watcher_ingested = PERF.watcher_ingested.swap(0, Ordering::Relaxed);
1402 let watcher_paths = PERF.watcher_paths.swap(0, Ordering::Relaxed);
1403 let watcher_dropped = PERF.watcher_dropped.swap(0, Ordering::Relaxed);
1404 let drain_slices = PERF.drain_slices.swap(0, Ordering::Relaxed);
1405 let semantic_collects = PERF.semantic_collects.swap(0, Ordering::Relaxed);
1406 let semantic_files = PERF.semantic_files.swap(0, Ordering::Relaxed);
1407 let semantic_chunks = PERF.semantic_chunks.swap(0, Ordering::Relaxed);
1408 let semantic_ms = PERF.semantic_ms.swap(0, Ordering::Relaxed);
1409 let callgraph_invalidations = PERF.callgraph_invalidations.swap(0, Ordering::Relaxed);
1410 let file_lines_dropped = PERF.file_lines_dropped.swap(0, Ordering::Relaxed);
1411 let tier2 = PERF
1412 .tier2
1413 .lock()
1414 .map(|mut tier2| std::mem::take(&mut *tier2))
1415 .unwrap_or_default();
1416 let tool_calls = PERF
1417 .tool_calls
1418 .lock()
1419 .map(|samples| summarize_tool_calls(&samples))
1420 .unwrap_or_default();
1421
1422 let executor_busy = sample.is_some_and(|sample| {
1423 sample.interactive_running > 0
1424 || sample.maintenance_running > 0
1425 || sample.interactive_queued > 0
1426 || sample.maintenance_queued > 0
1427 });
1428 let active = watcher_ingested > 0
1429 || watcher_paths > 0
1430 || watcher_dropped > 0
1431 || drain_slices > 0
1432 || semantic_collects > 0
1433 || callgraph_invalidations > 0
1434 || completed_interactive > 0
1435 || completed_maintenance > 0
1436 || new_tool_calls > 0
1437 || file_lines_dropped > 0
1438 || !tier2.is_empty()
1439 || executor_busy;
1440 if !active {
1441 return;
1442 }
1443
1444 let tier2_summary = if tier2.is_empty() {
1445 "none".to_string()
1446 } else {
1447 tier2
1448 .into_iter()
1449 .map(|(category, (count, ms))| format!("{category}:{count}/{ms}ms"))
1450 .collect::<Vec<_>>()
1451 .join(",")
1452 };
1453 let sample = sample.unwrap_or_default();
1454 crate::slog_info!(
1455 "perf tick: watcher={{ingested:{},paths:{},dropped:{}}} drains={} tier2=[{}] semantic={{collects:{},files:{},chunks:{},ms:{}}} callgraph_invalidations={} executor_completed={{interactive:{},maintenance:{}}} oldest_queued_ms={{interactive:{},maintenance:{}}} {} file_log_dropped={}",
1456 watcher_ingested,
1457 watcher_paths,
1458 watcher_dropped,
1459 drain_slices,
1460 tier2_summary,
1461 semantic_collects,
1462 semantic_files,
1463 semantic_chunks,
1464 semantic_ms,
1465 callgraph_invalidations,
1466 completed_interactive,
1467 completed_maintenance,
1468 format_optional_ms(sample.interactive_oldest_ms),
1469 format_optional_ms(sample.maintenance_oldest_ms),
1470 format_tool_call_summary(new_tool_calls, tool_calls),
1471 file_lines_dropped,
1472 );
1473}
1474
1475fn duration_millis_f64(duration: Duration) -> f64 {
1476 duration.as_secs_f64() * 1_000.0
1477}
1478
1479fn duration_millis_u64(duration: Duration) -> u64 {
1480 duration.as_millis().min(u64::MAX as u128) as u64
1481}
1482
1483fn summarize_tool_calls(samples: &VecDeque<ToolCallPerfSample>) -> ToolCallPerfSummary {
1484 if samples.is_empty() {
1485 return ToolCallPerfSummary::default();
1486 }
1487 let mut totals = samples
1488 .iter()
1489 .map(|sample| sample.total_ms)
1490 .collect::<Vec<_>>();
1491 let mut queues = samples
1492 .iter()
1493 .map(|sample| sample.queue_ms)
1494 .collect::<Vec<_>>();
1495 totals.sort_unstable();
1496 queues.sort_unstable();
1497 let median_index = (samples.len() - 1) / 2;
1498 ToolCallPerfSummary {
1499 window: samples.len(),
1500 p50_total_ms: totals[median_index],
1501 max_total_ms: totals[totals.len() - 1],
1502 p50_queue_ms: queues[median_index],
1503 max_queue_ms: queues[queues.len() - 1],
1504 }
1505}
1506
1507fn format_tool_call_summary(new_tool_calls: u64, summary: ToolCallPerfSummary) -> String {
1508 format!(
1509 "toolcall={{new:{new_tool_calls},window:{},p50_total_ms:{},max_total_ms:{},p50_queue_ms:{},max_queue_ms:{}}}",
1510 summary.window,
1511 summary.p50_total_ms,
1512 summary.max_total_ms,
1513 summary.p50_queue_ms,
1514 summary.max_queue_ms,
1515 )
1516}
1517
1518fn format_optional_ms(value: Option<u64>) -> String {
1519 value
1520 .map(|value| value.to_string())
1521 .unwrap_or_else(|| "none".to_string())
1522}
1523
1524fn perf_sample_due() -> bool {
1525 static ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
1526 let now_ns = ORIGIN.elapsed().as_nanos().min(u64::MAX as u128) as u64;
1527 let mut deadline = PERF.next_sample_ns.load(Ordering::Relaxed);
1528 loop {
1529 if now_ns < deadline {
1530 return false;
1531 }
1532 let next = now_ns.saturating_add(PERF_SAMPLE_INTERVAL.as_nanos() as u64);
1533 match PERF.next_sample_ns.compare_exchange_weak(
1534 deadline,
1535 next,
1536 Ordering::Relaxed,
1537 Ordering::Relaxed,
1538 ) {
1539 Ok(_) => return true,
1540 Err(observed) => deadline = observed,
1541 }
1542 }
1543}
1544
1545fn perf_tick_interval() -> Duration {
1546 static INTERVAL: OnceLock<Duration> = OnceLock::new();
1547 *INTERVAL.get_or_init(|| {
1548 std::env::var("AFT_PERF_TICK_INTERVAL_MS")
1549 .ok()
1550 .and_then(|value| value.parse::<u64>().ok())
1551 .filter(|value| *value > 0)
1552 .map(Duration::from_millis)
1553 .unwrap_or(DEFAULT_PERF_TICK_INTERVAL)
1554 })
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559 use super::*;
1560 use filetime::{set_file_mtime, FileTime};
1561 use tempfile::TempDir;
1562
1563 fn line(value: &str) -> Vec<Vec<u8>> {
1564 vec![format!("{value}\n").into_bytes()]
1565 }
1566
1567 #[test]
1568 fn epoch_timestamp_renders_known_dates() {
1569 assert_eq!(format_epoch_secs(0), "1970-01-01T00:00:00Z");
1572 assert_eq!(format_epoch_secs(1_704_067_200), "2024-01-01T00:00:00Z");
1573 assert_eq!(format_epoch_secs(1_709_251_199), "2024-02-29T23:59:59Z");
1574 assert_eq!(format_epoch_secs(4_102_444_800), "2100-01-01T00:00:00Z");
1575 assert_eq!(format_epoch_secs(4_107_542_399), "2100-02-28T23:59:59Z");
1576 }
1577
1578 #[test]
1579 fn rotation_rolls_once_and_replaces_the_single_backup_generation() {
1580 let temp = TempDir::new().unwrap();
1581 let path = temp.path().join("aft-123.log");
1582 fs::write(rotated_path(&path, 1), "stale backup\n").unwrap();
1583 let mut sink = RotatingFile::open(path.clone(), 10, 1, 1).unwrap();
1584 sink.write_batch(&line("aaaa")).unwrap();
1585 sink.write_batch(&line("bbbb")).unwrap();
1586 sink.write_batch(&line("cccc")).unwrap();
1587 sink.write_batch(&line("dddd")).unwrap();
1588 sink.write_batch(&line("eeee")).unwrap();
1589
1590 assert_eq!(fs::read_to_string(&path).unwrap(), "eeee\n");
1591 assert_eq!(
1592 fs::read_to_string(rotated_path(&path, 1)).unwrap(),
1593 "cccc\ndddd\n"
1594 );
1595 assert!(!rotated_path(&path, 2).exists());
1596 }
1597
1598 #[test]
1599 fn dead_pid_sweep_respects_age_liveness_and_explicit_plugin_exclusion() {
1600 let temp = TempDir::new().unwrap();
1601 let dead = temp.path().join("aft-4294967294.log");
1602 let dead_rotated = temp.path().join("aft-4294967294.log.1");
1603 let fresh_dead = temp.path().join("aft-4294967293.log");
1604 let own = temp.path().join(format!("aft-{}.log", std::process::id()));
1605 let live_rotated = rotated_path(&own, 1);
1606 let plugin = temp.path().join("aft-plugin.log");
1607 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1608 for path in [&dead, &dead_rotated, &own, &live_rotated, &plugin] {
1609 fs::write(path, "log").unwrap();
1610 set_file_mtime(path, FileTime::from_unix_time(1, 0)).unwrap();
1611 }
1612 fs::write(&fresh_dead, "fresh").unwrap();
1613 set_file_mtime(
1614 &fresh_dead,
1615 FileTime::from_unix_time(
1616 (now - DEAD_PROCESS_LOG_MAX_AGE + Duration::from_secs(1))
1617 .duration_since(SystemTime::UNIX_EPOCH)
1618 .unwrap()
1619 .as_secs() as i64,
1620 0,
1621 ),
1622 )
1623 .unwrap();
1624
1625 let summary = sweep_logs(temp.path(), now, DEAD_PROCESS_LOG_MAX_AGE, u64::MAX).unwrap();
1626
1627 assert_eq!(summary.removed_files, 2);
1628 assert!(!dead.exists());
1629 assert!(!dead_rotated.exists());
1630 assert!(fresh_dead.exists());
1631 assert!(own.exists());
1632 assert!(live_rotated.exists());
1633 assert!(plugin.exists());
1634 }
1635
1636 #[test]
1637 fn budget_backstop_deletes_oldest_dead_files_but_not_live_files() {
1638 let temp = TempDir::new().unwrap();
1639 let oldest = temp.path().join("aft-4294967294.log");
1640 let newest = temp.path().join("aft-4294967293.log");
1641 let live = temp.path().join(format!("aft-{}.log", std::process::id()));
1642 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1643 fs::write(&oldest, "oldest").unwrap();
1644 fs::write(&newest, "newest").unwrap();
1645 fs::write(&live, "live-live").unwrap();
1646 set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
1647 set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
1648 set_file_mtime(&live, FileTime::from_unix_time(1, 0)).unwrap();
1649
1650 let summary = sweep_logs(
1651 temp.path(),
1652 now,
1653 Duration::from_secs(365 * 24 * 60 * 60),
1654 15,
1655 )
1656 .unwrap();
1657
1658 assert_eq!(summary.removed_files, 1);
1659 assert!(!oldest.exists());
1660 assert!(newest.exists());
1661 assert!(live.exists());
1662 }
1663
1664 #[test]
1665 fn tool_call_summary_uses_bounded_window_median_and_maxima() {
1666 let samples = VecDeque::from([
1667 ToolCallPerfSample {
1668 total_ms: 9,
1669 queue_ms: 5,
1670 },
1671 ToolCallPerfSample {
1672 total_ms: 3,
1673 queue_ms: 1,
1674 },
1675 ToolCallPerfSample {
1676 total_ms: 7,
1677 queue_ms: 2,
1678 },
1679 ToolCallPerfSample {
1680 total_ms: 5,
1681 queue_ms: 4,
1682 },
1683 ]);
1684
1685 let summary = summarize_tool_calls(&samples);
1686
1687 assert_eq!(summary.window, 4);
1688 assert_eq!(summary.p50_total_ms, 5);
1689 assert_eq!(summary.max_total_ms, 9);
1690 assert_eq!(summary.p50_queue_ms, 2);
1691 assert_eq!(summary.max_queue_ms, 5);
1692 }
1693
1694 #[test]
1695 fn tool_call_tick_labels_interval_count_and_rolling_window() {
1696 let samples = VecDeque::from([ToolCallPerfSample {
1697 total_ms: 3_000,
1698 queue_ms: 2_900,
1699 }]);
1700 let summary = summarize_tool_calls(&samples);
1701
1702 assert_eq!(
1703 format_tool_call_summary(1, summary),
1704 "toolcall={new:1,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1705 );
1706 assert_eq!(
1707 format_tool_call_summary(0, summary),
1708 "toolcall={new:0,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1709 );
1710 }
1711
1712 fn index_event_matches_grammar(line: &str) -> bool {
1713 let Some(rest) = line.strip_prefix("index_event ") else {
1714 return false;
1715 };
1716 if rest.is_empty() {
1717 return false;
1718 }
1719 rest.split(' ').all(|token| {
1720 let Some((key, value)) = token.split_once('=') else {
1721 return false;
1722 };
1723 !key.is_empty()
1724 && key.chars().all(|c| c.is_ascii_lowercase() || c == '_')
1725 && !value.is_empty()
1726 && !value.contains(' ')
1727 && !value.contains('=')
1728 })
1729 }
1730
1731 fn index_event_fields(line: &str) -> BTreeMap<String, String> {
1732 let mut fields = BTreeMap::new();
1733 for token in line.split_whitespace().skip(1) {
1734 let Some((key, value)) = token.split_once('=') else {
1735 continue;
1736 };
1737 fields.insert(key.to_string(), value.to_string());
1738 }
1739 fields
1740 }
1741
1742 fn assert_index_event_grammar(lines: &[String]) {
1743 for line in lines {
1744 assert!(
1745 index_event_matches_grammar(line),
1746 "index_event line failed grammar: {line}"
1747 );
1748 }
1749 }
1750
1751 fn event_matches(line: &str, plane: &str, root: &str, key: &str) -> bool {
1752 let fields = index_event_fields(line);
1753 fields.get("plane").map(String::as_str) == Some(plane)
1754 && fields.get("root").map(String::as_str) == Some(root)
1755 && fields.get("key").map(String::as_str) == Some(key)
1756 }
1757
1758 fn assert_lifecycle_sequence(
1759 lines: &[String],
1760 plane: &str,
1761 root: &str,
1762 key: &str,
1763 require_progress: bool,
1764 ) {
1765 let events: Vec<_> = lines
1766 .iter()
1767 .filter(|line| event_matches(line, plane, root, key))
1768 .cloned()
1769 .collect();
1770 assert_index_event_grammar(&events);
1771 let kinds: Vec<_> = events
1772 .iter()
1773 .filter_map(|line| index_event_fields(line).get("kind").cloned())
1774 .filter(|kind| {
1775 matches!(
1776 kind.as_str(),
1777 "build_started" | "build_progress" | "build_ready"
1778 )
1779 })
1780 .collect();
1781 assert!(
1782 kinds.first().is_some_and(|kind| kind == "build_started"),
1783 "expected build_started first, got {kinds:?} from {events:?}"
1784 );
1785 if require_progress {
1786 assert!(
1787 kinds.iter().any(|kind| kind == "build_progress"),
1788 "expected build_progress in {kinds:?} from {events:?}"
1789 );
1790 }
1791 assert!(
1792 kinds.last().is_some_and(|kind| kind == "build_ready"),
1793 "expected build_ready last, got {kinds:?} from {events:?}"
1794 );
1795 let build_ids: Vec<_> = events
1796 .iter()
1797 .filter_map(|line| index_event_fields(line).get("build_id").cloned())
1798 .collect();
1799 assert!(!build_ids.is_empty());
1800 assert!(
1801 build_ids.iter().all(|id| id == &build_ids[0]),
1802 "build_id drifted across events: {build_ids:?}"
1803 );
1804 for line in &events {
1805 let fields = index_event_fields(line);
1806 assert_eq!(fields.get("root").map(String::as_str), Some(root), "{line}");
1807 assert_eq!(fields.get("key").map(String::as_str), Some(key), "{line}");
1808 }
1809 }
1810
1811 fn tiny_project(name: &str, file_name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
1812 let temp = tempfile::tempdir().expect("tempdir");
1813 let root = temp.path().join(name);
1814 std::fs::create_dir_all(&root).expect("create project");
1815 std::fs::write(root.join(file_name), contents).expect("write fixture");
1816 let root = std::fs::canonicalize(&root).unwrap_or(root);
1817 (temp, root)
1818 }
1819
1820 #[test]
1821 fn watcher_rescan_event_reports_reason_cost_rss_and_interval_count() {
1822 let (_, lines) = capture_index_events(|| {
1823 log_watcher_rescan(
1824 Path::new("/tmp/watcher root"),
1825 crate::watcher_filter::RescanReason::KernelDropped,
1826 42,
1827 Some(-4096),
1828 137,
1829 );
1830 });
1831 assert_index_event_grammar(&lines);
1832 assert_eq!(lines.len(), 1);
1833 let fields = index_event_fields(&lines[0]);
1834 assert_eq!(
1835 fields.get("kind").map(String::as_str),
1836 Some("watcher_rescan")
1837 );
1838 assert_eq!(fields.get("plane").map(String::as_str), Some("watcher"));
1839 assert_eq!(
1840 fields.get("root").map(String::as_str),
1841 Some("/tmp/watcher_root")
1842 );
1843 assert_eq!(
1844 fields.get("reason").map(String::as_str),
1845 Some("kernel_dropped")
1846 );
1847 assert_eq!(fields.get("cost_ms").map(String::as_str), Some("42"));
1848 assert_eq!(
1849 fields.get("rss_delta_bytes").map(String::as_str),
1850 Some("-4096")
1851 );
1852 assert_eq!(
1853 fields.get("raw_events_since_last").map(String::as_str),
1854 Some("137")
1855 );
1856 }
1857
1858 #[test]
1859 fn index_event_grammar_rejects_spaces_and_equals_in_values() {
1860 let (result, lines) = capture_index_events(|| {
1861 log_index_event(
1862 IndexEvent::new(
1863 IndexEventKind::BuildStarted,
1864 IndexPlane::Search,
1865 "b-1-1",
1866 "/tmp/root",
1867 "abc123",
1868 )
1869 .field("stage", "streaming"),
1870 );
1871 });
1872 let _ = result;
1873 assert_index_event_grammar(&lines);
1874 let own: Vec<&String> = lines
1878 .iter()
1879 .filter(|line| {
1880 index_event_fields(line).get("build_id").map(String::as_str) == Some("b-1-1")
1881 })
1882 .collect();
1883 assert_eq!(own.len(), 1, "{lines:?}");
1884 }
1885
1886 #[test]
1887 fn index_event_keeps_required_fields_for_long_root() {
1888 let root = format!("/{}", "a".repeat(399));
1889 let (_, lines) = capture_index_events(|| {
1890 log_index_event(IndexEvent::new(
1891 IndexEventKind::BuildStarted,
1892 IndexPlane::Search,
1893 "b-9-9",
1894 &root,
1895 "keepkey",
1896 ));
1897 });
1898 assert_eq!(lines.len(), 1, "{lines:?}");
1899 let fields = index_event_fields(&lines[0]);
1900 assert_eq!(fields.get("build_id").map(String::as_str), Some("b-9-9"));
1901 assert_eq!(fields.get("key").map(String::as_str), Some("keepkey"));
1902 assert!(fields.get("root").is_some(), "{lines:?}");
1903 assert_index_event_grammar(&lines);
1904 }
1905
1906 #[test]
1907 fn first_query_fires_once_per_build_id() {
1908 let root = PathBuf::from("/tmp/first-query-root");
1909 let key = "firstquerykey";
1910 let build_id = "b-1-99";
1911 let (_, lines) = capture_index_events(|| {
1912 log_index_event(IndexEvent::new(
1913 IndexEventKind::BuildReady,
1914 IndexPlane::Search,
1915 build_id,
1916 &root,
1917 key,
1918 ));
1919 assert!(claim_first_query(
1920 IndexPlane::Search,
1921 &root,
1922 "grep",
1923 1,
1924 2,
1925 "ok"
1926 ));
1927 assert!(!claim_first_query(
1928 IndexPlane::Search,
1929 &root,
1930 "grep",
1931 1,
1932 2,
1933 "ok"
1934 ));
1935 });
1936 let first_query: Vec<_> = lines
1937 .iter()
1938 .filter(|line| line.contains("kind=first_query"))
1939 .collect();
1940 assert_eq!(first_query.len(), 1, "{lines:?}");
1941 let fields = index_event_fields(first_query[0]);
1942 assert_eq!(fields.get("build_id").map(String::as_str), Some(build_id));
1943 assert_eq!(fields.get("tool").map(String::as_str), Some("grep"));
1944 }
1945
1946 #[test]
1947 fn callgraph_cold_build_emits_started_progress_ready() {
1948 let (_temp, root) = tiny_project("cg", "lib.rs", "pub fn marker() {}\n");
1949 let key = crate::search_index::artifact_cache_key(&root);
1950 let callgraph_dir = _temp.path().join("callgraph").join(&key);
1951 crate::root_cache::configure_artifact_access(&root, &key, false);
1952 let source = root.join("lib.rs");
1953 let (built, lines) = capture_index_events(|| {
1954 crate::callgraph_store::CallGraphStore::cold_build_with_lease(
1955 callgraph_dir,
1956 root.clone(),
1957 std::slice::from_ref(&source),
1958 )
1959 });
1960 built.expect("callgraph cold build");
1961 assert_lifecycle_sequence(
1962 &lines,
1963 "callgraph",
1964 &normalize_index_root(&root),
1965 &key,
1966 true,
1967 );
1968 }
1969
1970 #[test]
1971 fn search_cold_build_emits_started_progress_ready() {
1972 let (_temp, root) = tiny_project("search", "file.txt", "alpha token\n");
1973 let key = crate::search_index::artifact_cache_key(&root);
1974 let (index, lines) = capture_index_events(|| {
1975 crate::search_index::SearchIndex::build_with_limit(&root, 1_000_000)
1976 });
1977 assert!(index.ready);
1978 assert_lifecycle_sequence(&lines, "search", &normalize_index_root(&root), &key, true);
1979 }
1980
1981 #[test]
1982 fn semantic_cold_build_emits_started_progress_ready() {
1983 let (_temp, root) = tiny_project("sem", "lib.rs", "pub fn hello() {}\n");
1984 let key = crate::search_index::artifact_cache_key(&root);
1985 let files = vec![root.join("lib.rs")];
1986 let mut embed = |texts: Vec<String>| {
1987 Ok(texts
1988 .into_iter()
1989 .map(|_| vec![0.01_f32; 384])
1990 .collect::<Vec<_>>())
1991 };
1992 let (built, lines) = capture_index_events(|| {
1993 crate::semantic_index::SemanticIndex::build(&root, &files, &mut embed, 8)
1994 });
1995 built.expect("semantic build");
1996 assert_lifecycle_sequence(&lines, "semantic", &normalize_index_root(&root), &key, true);
1997 }
1998
1999 #[test]
2000 fn tier2_category_emits_started_ready() {
2001 let (_temp, root) = tiny_project("t2", "mod0.ts", "export function f0() { return 0; }\n");
2002 let key = crate::search_index::artifact_cache_key(&root);
2003 crate::root_cache::configure_artifact_access(&root, &key, false);
2004 let inspect_dir = _temp.path().join("inspect");
2005 let manager = std::sync::Arc::new(crate::inspect::InspectManager::new());
2006 let snapshot = crate::inspect::InspectSnapshot::new(
2007 root.clone(),
2008 inspect_dir,
2009 std::sync::Arc::new(crate::config::Config {
2010 project_root: Some(root.clone()),
2011 ..crate::config::Config::default()
2012 }),
2013 std::sync::Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new())),
2014 );
2015 let (outcome, lines) = capture_index_events(|| {
2016 manager.tier2_run_with_reuse_blocking_fresh(
2017 snapshot,
2018 crate::inspect::InspectCategory::Complexity,
2019 crate::inspect::JobScope::for_project(root.clone()),
2020 )
2021 });
2022 assert!(outcome.payload().is_some(), "{outcome:?}");
2023 assert_lifecycle_sequence(&lines, "tier2", &normalize_index_root(&root), &key, false);
2024 assert!(
2025 !lines.iter().any(|line| {
2026 event_matches(line, "tier2", &normalize_index_root(&root), &key)
2027 && line.contains("kind=build_progress")
2028 }),
2029 "tier2 must not emit synthetic build_progress: {lines:?}"
2030 );
2031 }
2032
2033 #[test]
2034 fn superseded_cold_build_keeps_build_id_and_skips_ready() {
2035 let (_temp, root) = tiny_project("sup", "lib.rs", "pub fn marker() {}\n");
2036 let key = crate::search_index::artifact_cache_key(&root);
2037 let callgraph_dir = _temp.path().join("callgraph").join(&key);
2038 crate::root_cache::configure_artifact_access(&root, &key, false);
2039 let source = root.join("lib.rs");
2040 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
2041 let stale_epoch = epoch.current();
2042 epoch.next();
2043 let (failed, lines) = capture_index_events(|| {
2044 crate::callgraph_store::with_publish_epoch(epoch, stale_epoch, || {
2045 crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2046 callgraph_dir,
2047 root.clone(),
2048 std::slice::from_ref(&source),
2049 )
2050 })
2051 });
2052 assert!(matches!(
2053 failed,
2054 Err(crate::callgraph_store::CallGraphStoreError::Superseded)
2055 ));
2056 let root_s = normalize_index_root(&root);
2057 let events: Vec<_> = lines
2058 .iter()
2059 .filter(|line| event_matches(line, "callgraph", &root_s, &key))
2060 .cloned()
2061 .collect();
2062 assert_index_event_grammar(&events);
2063 let kinds: Vec<_> = events
2064 .iter()
2065 .filter_map(|line| index_event_fields(line).get("kind").cloned())
2066 .collect();
2067 assert!(
2068 kinds.contains(&"build_started".to_string()),
2069 "{kinds:?} {events:?}"
2070 );
2071 assert!(
2072 kinds.contains(&"build_superseded".to_string()),
2073 "{kinds:?} {events:?}"
2074 );
2075 assert!(
2076 !kinds.contains(&"build_ready".to_string()),
2077 "superseded build must not emit build_ready: {kinds:?}"
2078 );
2079 let started_id = events
2080 .iter()
2081 .find(|line| line.contains("kind=build_started"))
2082 .and_then(|line| index_event_fields(line).get("build_id").cloned());
2083 let superseded_id = events
2084 .iter()
2085 .find(|line| line.contains("kind=build_superseded"))
2086 .and_then(|line| index_event_fields(line).get("build_id").cloned());
2087 assert_eq!(started_id, superseded_id);
2088 for line in &events {
2089 let fields = index_event_fields(line);
2090 assert_eq!(
2091 fields.get("root").map(String::as_str),
2092 Some(root_s.as_str())
2093 );
2094 assert_eq!(fields.get("key").map(String::as_str), Some(key.as_str()));
2095 }
2096 }
2097}