Skip to main content

coding_agent_search/ui/
trace.rs

1//! Render trace + time-travel capture for ftui TUI (bead 2noh9.4.3).
2//!
3//! Records frame snapshots, event streams, and render timing so that TUI bugs
4//! can be reproduced from a trace bundle without rerunning on the original
5//! machine.
6//!
7//! # Formats
8//!
9//! - **Render trace** (`.trace.jsonl`): one JSON object per frame with timing,
10//!   size, message that triggered the render, and optional text snapshot.
11//! - **Event stream** (`.events.jsonl`): one JSON object per `CassMsg` with
12//!   timestamp and serialized variant tag.
13//! - **Trace bundle** (directory): render trace + event stream + `tui_state.json`
14//!   + `system_info.json`.
15
16use std::io::{Error, ErrorKind, Write};
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant, SystemTime};
19
20use serde::{Deserialize, Serialize};
21
22// =========================================================================
23// Trace record types
24// =========================================================================
25
26/// One frame's render metadata.
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct FrameRecord {
29    /// Monotonic frame index (0-based).
30    pub frame_index: u64,
31    /// Wall-clock timestamp (millis since Unix epoch).
32    pub timestamp_ms: u64,
33    /// Duration of the `view()` call in microseconds.
34    pub render_us: u64,
35    /// Terminal width at render time.
36    pub width: u16,
37    /// Terminal height at render time.
38    pub height: u16,
39    /// Human-readable label of the message that triggered this render, if any.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub trigger: Option<String>,
42    /// Plain-text snapshot of the buffer (optional, can be large).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub text_snapshot: Option<String>,
45}
46
47/// One event's metadata.
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct EventRecord {
50    /// Wall-clock timestamp (millis since Unix epoch).
51    pub timestamp_ms: u64,
52    /// Monotonic event index (0-based).
53    pub event_index: u64,
54    /// CassMsg variant tag (e.g. "QueryChanged", "SearchRequested").
55    pub msg_tag: String,
56    /// Optional details (e.g. the query text for QueryChanged).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub detail: Option<String>,
59}
60
61/// System information snapshot for trace bundles.
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct SystemInfo {
64    pub os: String,
65    pub arch: String,
66    pub cass_version: String,
67    pub term: Option<String>,
68    pub colorterm: Option<String>,
69    pub terminal_size: Option<(u16, u16)>,
70    pub timestamp: String,
71}
72
73impl SystemInfo {
74    /// Capture current system info.
75    pub fn capture() -> Self {
76        Self {
77            os: std::env::consts::OS.to_string(),
78            arch: std::env::consts::ARCH.to_string(),
79            cass_version: env!("CARGO_PKG_VERSION").to_string(),
80            term: dotenvy::var("TERM").ok(),
81            colorterm: dotenvy::var("COLORTERM").ok(),
82            terminal_size: None, // filled by caller if available
83            timestamp: chrono::Utc::now().to_rfc3339(),
84        }
85    }
86}
87
88// =========================================================================
89// Trace writer
90// =========================================================================
91
92/// Appends frame and event records to JSONL files.
93pub struct TraceWriter {
94    render_file: Option<std::io::BufWriter<std::fs::File>>,
95    events_file: Option<std::io::BufWriter<std::fs::File>>,
96    frame_count: u64,
97    event_count: u64,
98    _epoch: Instant,
99}
100
101impl TraceWriter {
102    /// Open a trace writer.  Pass `None` for paths you don't want to record.
103    pub fn open(render_path: Option<&Path>, events_path: Option<&Path>) -> std::io::Result<Self> {
104        if let (Some(render_path), Some(events_path)) = (render_path, events_path)
105            && render_path == events_path
106        {
107            return Err(Error::new(
108                ErrorKind::InvalidInput,
109                format!(
110                    "render and event trace outputs must use distinct paths: {}",
111                    render_path.display()
112                ),
113            ));
114        }
115
116        if let Some(path) = render_path {
117            ensure_trace_output_available(path)?;
118        }
119        if let Some(path) = events_path {
120            ensure_trace_output_available(path)?;
121        }
122
123        let render_file = render_path.map(create_trace_output).transpose()?;
124        let events_file = events_path.map(create_trace_output).transpose()?;
125        Ok(Self {
126            render_file,
127            events_file,
128            frame_count: 0,
129            event_count: 0,
130            _epoch: Instant::now(),
131        })
132    }
133
134    /// Record a rendered frame.
135    pub fn record_frame(
136        &mut self,
137        render_duration: Duration,
138        width: u16,
139        height: u16,
140        trigger: Option<&str>,
141        text_snapshot: Option<String>,
142    ) -> std::io::Result<()> {
143        if let Some(ref mut f) = self.render_file {
144            let record = FrameRecord {
145                frame_index: self.frame_count,
146                timestamp_ms: wall_millis(),
147                render_us: render_duration.as_micros() as u64,
148                width,
149                height,
150                trigger: trigger.map(|s| s.to_string()),
151                text_snapshot,
152            };
153            serde_json::to_writer(&mut *f, &record)?;
154            f.write_all(b"\n")?;
155            self.frame_count += 1;
156        }
157        Ok(())
158    }
159
160    /// Record an event (message).
161    pub fn record_event(&mut self, msg_tag: &str, detail: Option<&str>) -> std::io::Result<()> {
162        if let Some(ref mut f) = self.events_file {
163            let record = EventRecord {
164                timestamp_ms: wall_millis(),
165                event_index: self.event_count,
166                msg_tag: msg_tag.to_string(),
167                detail: detail.map(|s| s.to_string()),
168            };
169            serde_json::to_writer(&mut *f, &record)?;
170            f.write_all(b"\n")?;
171            self.event_count += 1;
172        }
173        Ok(())
174    }
175
176    /// Flush both files.
177    pub fn flush(&mut self) -> std::io::Result<()> {
178        if let Some(ref mut f) = self.render_file {
179            f.flush()?;
180        }
181        if let Some(ref mut f) = self.events_file {
182            f.flush()?;
183        }
184        Ok(())
185    }
186
187    /// Number of frames recorded.
188    pub fn frame_count(&self) -> u64 {
189        self.frame_count
190    }
191
192    /// Number of events recorded.
193    pub fn event_count(&self) -> u64 {
194        self.event_count
195    }
196
197    /// Whether any recording is active.
198    pub fn is_active(&self) -> bool {
199        self.render_file.is_some() || self.events_file.is_some()
200    }
201}
202
203fn ensure_trace_output_available(path: &Path) -> std::io::Result<()> {
204    match std::fs::symlink_metadata(path) {
205        Ok(_) => {
206            return Err(Error::new(
207                ErrorKind::AlreadyExists,
208                format!("trace output already exists: {}", path.display()),
209            ));
210        }
211        Err(err) if err.kind() == ErrorKind::NotFound => {}
212        Err(err) => return Err(err),
213    }
214    Ok(())
215}
216
217fn create_trace_output(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
218    ensure_trace_output_available(path)?;
219    let file = std::fs::OpenOptions::new()
220        .write(true)
221        .create_new(true)
222        .open(path)?;
223    Ok(std::io::BufWriter::new(file))
224}
225
226impl Drop for TraceWriter {
227    fn drop(&mut self) {
228        let _ = self.flush();
229    }
230}
231
232// =========================================================================
233// Trace bundle
234// =========================================================================
235
236/// Write a complete trace bundle directory containing:
237/// - `render.trace.jsonl`  (if render_records is non-empty)
238/// - `events.jsonl`        (if event_records is non-empty)
239/// - `system_info.json`
240/// - `tui_state.json`      (if state bytes are provided)
241pub fn write_trace_bundle(
242    bundle_dir: &Path,
243    system_info: &SystemInfo,
244    tui_state_json: Option<&str>,
245) -> std::io::Result<()> {
246    ensure_trace_bundle_dir(bundle_dir)?;
247
248    let sys_path = bundle_dir.join("system_info.json");
249    let state_path = tui_state_json.map(|_| bundle_dir.join("tui_state.json"));
250    ensure_trace_output_available(&sys_path)?;
251    if let Some(path) = &state_path {
252        ensure_trace_output_available(path)?;
253    }
254
255    // System info
256    let mut sys_file = create_trace_output(&sys_path)?;
257    serde_json::to_writer_pretty(&mut sys_file, system_info)?;
258
259    // TUI state
260    if let (Some(state), Some(state_path)) = (tui_state_json, state_path) {
261        let mut state_file = create_trace_output(&state_path)?;
262        state_file.write_all(state.as_bytes())?;
263    }
264
265    Ok(())
266}
267
268fn ensure_trace_bundle_dir(bundle_dir: &Path) -> std::io::Result<()> {
269    match std::fs::symlink_metadata(bundle_dir) {
270        Ok(metadata) => {
271            let file_type = metadata.file_type();
272            if file_type.is_symlink() {
273                return Err(Error::new(
274                    ErrorKind::InvalidInput,
275                    format!(
276                        "trace bundle directory must not be a symlink: {}",
277                        bundle_dir.display()
278                    ),
279                ));
280            }
281            if !file_type.is_dir() {
282                return Err(Error::new(
283                    ErrorKind::InvalidInput,
284                    format!(
285                        "trace bundle path must be a directory: {}",
286                        bundle_dir.display()
287                    ),
288                ));
289            }
290            Ok(())
291        }
292        Err(err) if err.kind() == ErrorKind::NotFound => {
293            std::fs::create_dir_all(bundle_dir)?;
294            ensure_trace_bundle_dir(bundle_dir)
295        }
296        Err(err) => Err(err),
297    }
298}
299
300// =========================================================================
301// Trace reader (for replay / inspection)
302// =========================================================================
303
304/// Read a JSONL render trace file and return parsed records.
305pub fn read_render_trace(path: &Path) -> std::io::Result<Vec<FrameRecord>> {
306    let content = std::fs::read_to_string(path)?;
307    let mut records = Vec::new();
308    for line in content.lines() {
309        if line.trim().is_empty() {
310            continue;
311        }
312        let record: FrameRecord = serde_json::from_str(line).map_err(|e| {
313            std::io::Error::new(
314                std::io::ErrorKind::InvalidData,
315                format!("invalid frame record: {e}"),
316            )
317        })?;
318        records.push(record);
319    }
320    Ok(records)
321}
322
323/// Read a JSONL event stream file and return parsed records.
324pub fn read_event_stream(path: &Path) -> std::io::Result<Vec<EventRecord>> {
325    let content = std::fs::read_to_string(path)?;
326    let mut records = Vec::new();
327    for line in content.lines() {
328        if line.trim().is_empty() {
329            continue;
330        }
331        let record: EventRecord = serde_json::from_str(line).map_err(|e| {
332            std::io::Error::new(
333                std::io::ErrorKind::InvalidData,
334                format!("invalid event record: {e}"),
335            )
336        })?;
337        records.push(record);
338    }
339    Ok(records)
340}
341
342// =========================================================================
343// Trace options (parsed from CLI)
344// =========================================================================
345
346/// Options controlling trace capture, parsed from CLI flags.
347#[derive(Clone, Debug, Default)]
348pub struct TraceOptions {
349    /// Path for render trace JSONL output.
350    pub render_path: Option<PathBuf>,
351    /// Path for event stream JSONL output.
352    pub events_path: Option<PathBuf>,
353    /// Path for a full trace bundle directory.
354    pub bundle_dir: Option<PathBuf>,
355    /// Whether to include text snapshots in render trace (large output).
356    pub include_snapshots: bool,
357}
358
359impl TraceOptions {
360    /// Whether any tracing is requested.
361    pub fn is_active(&self) -> bool {
362        self.render_path.is_some() || self.events_path.is_some() || self.bundle_dir.is_some()
363    }
364
365    /// Create a TraceWriter from these options.  If bundle_dir is set,
366    /// render and event paths default to files inside the bundle dir.
367    pub fn into_writer(&self) -> std::io::Result<TraceWriter> {
368        let (render_path, events_path) = if let Some(ref dir) = self.bundle_dir {
369            ensure_trace_bundle_dir(dir)?;
370            (
371                self.render_path
372                    .clone()
373                    .unwrap_or_else(|| dir.join("render.trace.jsonl")),
374                self.events_path
375                    .clone()
376                    .unwrap_or_else(|| dir.join("events.jsonl")),
377            )
378        } else {
379            (
380                self.render_path.clone().unwrap_or_default(),
381                self.events_path.clone().unwrap_or_default(),
382            )
383        };
384
385        let render = if self.render_path.is_some() || self.bundle_dir.is_some() {
386            Some(render_path.as_path())
387        } else {
388            None
389        };
390        let events = if self.events_path.is_some() || self.bundle_dir.is_some() {
391            Some(events_path.as_path())
392        } else {
393            None
394        };
395
396        TraceWriter::open(render, events)
397    }
398}
399
400// =========================================================================
401// Helpers
402// =========================================================================
403
404fn wall_millis() -> u64 {
405    SystemTime::now()
406        .duration_since(SystemTime::UNIX_EPOCH)
407        .unwrap_or_default()
408        .as_millis() as u64
409}
410
411// =========================================================================
412// Tests
413// =========================================================================
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use tempfile::TempDir;
419
420    #[test]
421    fn trace_writer_records_frames_and_events() {
422        let tmp = TempDir::new().unwrap();
423        let render_path = tmp.path().join("render.trace.jsonl");
424        let events_path = tmp.path().join("events.jsonl");
425
426        let mut writer = TraceWriter::open(Some(&render_path), Some(&events_path)).unwrap();
427        assert!(writer.is_active());
428
429        writer
430            .record_frame(Duration::from_micros(150), 80, 24, Some("init"), None)
431            .unwrap();
432        writer
433            .record_frame(Duration::from_micros(200), 80, 24, Some("Tick"), None)
434            .unwrap();
435        writer.record_event("QueryChanged", Some("hello")).unwrap();
436        writer.record_event("SearchRequested", None).unwrap();
437        writer.flush().unwrap();
438
439        assert_eq!(writer.frame_count(), 2);
440        assert_eq!(writer.event_count(), 2);
441
442        // Verify readback
443        let frames = read_render_trace(&render_path).unwrap();
444        assert_eq!(frames.len(), 2);
445        assert_eq!(frames[0].frame_index, 0);
446        assert_eq!(frames[0].trigger.as_deref(), Some("init"));
447        assert_eq!(frames[1].frame_index, 1);
448
449        let events = read_event_stream(&events_path).unwrap();
450        assert_eq!(events.len(), 2);
451        assert_eq!(events[0].msg_tag, "QueryChanged");
452        assert_eq!(events[0].detail.as_deref(), Some("hello"));
453        assert_eq!(events[1].msg_tag, "SearchRequested");
454    }
455
456    #[test]
457    fn trace_writer_noop_when_no_paths() {
458        let mut writer = TraceWriter::open(None, None).unwrap();
459        assert!(!writer.is_active());
460        // Should silently no-op
461        writer
462            .record_frame(Duration::from_micros(100), 80, 24, None, None)
463            .unwrap();
464        writer.record_event("Tick", None).unwrap();
465        assert_eq!(writer.frame_count(), 0);
466        assert_eq!(writer.event_count(), 0);
467    }
468
469    #[test]
470    fn trace_writer_with_text_snapshot() {
471        let tmp = TempDir::new().unwrap();
472        let render_path = tmp.path().join("render.trace.jsonl");
473
474        let mut writer = TraceWriter::open(Some(&render_path), None).unwrap();
475        writer
476            .record_frame(
477                Duration::from_micros(500),
478                80,
479                24,
480                Some("SearchCompleted"),
481                Some("╭─ results ─╮\n│ hit 1     │\n╰───────────╯".to_string()),
482            )
483            .unwrap();
484        writer.flush().unwrap();
485
486        let frames = read_render_trace(&render_path).unwrap();
487        assert_eq!(frames.len(), 1);
488        assert!(frames[0].text_snapshot.is_some());
489        assert!(frames[0].text_snapshot.as_ref().unwrap().contains("hit 1"));
490    }
491
492    #[test]
493    fn trace_writer_refuses_existing_output_path() {
494        let tmp = TempDir::new().unwrap();
495        let render_path = tmp.path().join("render.trace.jsonl");
496        std::fs::write(&render_path, "existing trace").unwrap();
497
498        let err = match TraceWriter::open(Some(&render_path), None) {
499            Ok(_) => panic!("expected existing trace output to be rejected"),
500            Err(err) => err,
501        };
502
503        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
504        assert_eq!(
505            std::fs::read_to_string(&render_path).unwrap(),
506            "existing trace"
507        );
508    }
509
510    #[test]
511    fn trace_writer_refuses_existing_event_path_without_creating_render_output() {
512        let tmp = TempDir::new().unwrap();
513        let render_path = tmp.path().join("render.trace.jsonl");
514        let events_path = tmp.path().join("events.trace.jsonl");
515        std::fs::write(&events_path, "existing events").unwrap();
516
517        let err = match TraceWriter::open(Some(&render_path), Some(&events_path)) {
518            Ok(_) => panic!("expected existing event trace output to be rejected"),
519            Err(err) => err,
520        };
521
522        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
523        assert!(
524            !render_path.exists(),
525            "render trace should not be created when event trace preflight fails"
526        );
527        assert_eq!(
528            std::fs::read_to_string(&events_path).unwrap(),
529            "existing events"
530        );
531    }
532
533    #[test]
534    fn trace_writer_refuses_shared_render_and_event_path() {
535        let tmp = TempDir::new().unwrap();
536        let trace_path = tmp.path().join("trace.jsonl");
537
538        let err = match TraceWriter::open(Some(&trace_path), Some(&trace_path)) {
539            Ok(_) => panic!("expected shared trace output path to be rejected"),
540            Err(err) => err,
541        };
542
543        assert_eq!(err.kind(), ErrorKind::InvalidInput);
544        assert!(
545            !trace_path.exists(),
546            "shared-path validation should not create a partial trace file"
547        );
548    }
549
550    #[test]
551    #[cfg(unix)]
552    fn trace_writer_refuses_symlinked_output_path() {
553        use std::os::unix::fs::symlink;
554
555        let tmp = TempDir::new().unwrap();
556        let protected_path = tmp.path().join("protected.jsonl");
557        let trace_path = tmp.path().join("render.trace.jsonl");
558        std::fs::write(&protected_path, "do not overwrite").unwrap();
559        symlink(&protected_path, &trace_path).unwrap();
560
561        let err = match TraceWriter::open(Some(&trace_path), None) {
562            Ok(_) => panic!("expected symlinked trace output to be rejected"),
563            Err(err) => err,
564        };
565
566        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
567        assert_eq!(
568            std::fs::read_to_string(&protected_path).unwrap(),
569            "do not overwrite"
570        );
571        assert!(
572            std::fs::symlink_metadata(&trace_path)
573                .unwrap()
574                .file_type()
575                .is_symlink(),
576            "rejected trace symlink should remain untouched"
577        );
578    }
579
580    #[test]
581    fn write_and_read_trace_bundle() {
582        let tmp = TempDir::new().unwrap();
583        let bundle_dir = tmp.path().join("bundle");
584
585        let sys_info = SystemInfo::capture();
586        write_trace_bundle(&bundle_dir, &sys_info, Some(r#"{"query":"test"}"#)).unwrap();
587
588        assert!(bundle_dir.join("system_info.json").exists());
589        assert!(bundle_dir.join("tui_state.json").exists());
590
591        let state = std::fs::read_to_string(bundle_dir.join("tui_state.json")).unwrap();
592        assert!(state.contains("test"));
593    }
594
595    #[test]
596    #[cfg(unix)]
597    fn write_trace_bundle_rejects_symlinked_bundle_dir() {
598        use std::os::unix::fs::symlink;
599
600        let tmp = TempDir::new().unwrap();
601        let outside_dir = tmp.path().join("outside");
602        let bundle_dir = tmp.path().join("bundle");
603        std::fs::create_dir_all(&outside_dir).unwrap();
604        symlink(&outside_dir, &bundle_dir).unwrap();
605
606        let err = write_trace_bundle(&bundle_dir, &SystemInfo::capture(), Some("{}")).unwrap_err();
607
608        assert_eq!(err.kind(), ErrorKind::InvalidInput);
609        assert!(
610            !outside_dir.join("system_info.json").exists(),
611            "trace bundle writer must not follow a symlinked bundle directory"
612        );
613        assert!(
614            std::fs::symlink_metadata(&bundle_dir)
615                .unwrap()
616                .file_type()
617                .is_symlink(),
618            "rejected trace bundle symlink should remain untouched"
619        );
620    }
621
622    #[test]
623    #[cfg(unix)]
624    fn trace_options_rejects_symlinked_bundle_dir() {
625        use std::os::unix::fs::symlink;
626
627        let tmp = TempDir::new().unwrap();
628        let outside_dir = tmp.path().join("outside");
629        let bundle_dir = tmp.path().join("bundle");
630        std::fs::create_dir_all(&outside_dir).unwrap();
631        symlink(&outside_dir, &bundle_dir).unwrap();
632
633        let options = TraceOptions {
634            bundle_dir: Some(bundle_dir.clone()),
635            ..TraceOptions::default()
636        };
637
638        let err = match options.into_writer() {
639            Ok(_) => panic!("expected symlinked trace bundle option to be rejected"),
640            Err(err) => err,
641        };
642
643        assert_eq!(err.kind(), ErrorKind::InvalidInput);
644        assert!(
645            !outside_dir.join("render.trace.jsonl").exists(),
646            "trace options must not follow a symlinked bundle dir for render output"
647        );
648        assert!(
649            !outside_dir.join("events.jsonl").exists(),
650            "trace options must not follow a symlinked bundle dir for event output"
651        );
652        assert!(
653            std::fs::symlink_metadata(&bundle_dir)
654                .unwrap()
655                .file_type()
656                .is_symlink(),
657            "rejected trace options symlink should remain untouched"
658        );
659    }
660
661    #[test]
662    fn write_trace_bundle_refuses_existing_state_without_creating_system_info() {
663        let tmp = TempDir::new().unwrap();
664        let bundle_dir = tmp.path().join("bundle");
665        std::fs::create_dir_all(&bundle_dir).unwrap();
666        let state_path = bundle_dir.join("tui_state.json");
667        std::fs::write(&state_path, "existing state").unwrap();
668
669        let err = write_trace_bundle(&bundle_dir, &SystemInfo::capture(), Some("{}")).unwrap_err();
670
671        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
672        assert!(
673            !bundle_dir.join("system_info.json").exists(),
674            "system_info should not be created when state preflight fails"
675        );
676        assert_eq!(
677            std::fs::read_to_string(&state_path).unwrap(),
678            "existing state"
679        );
680    }
681
682    #[test]
683    fn system_info_captures_environment() {
684        let info = SystemInfo::capture();
685        assert!(!info.os.is_empty());
686        assert!(!info.arch.is_empty());
687        assert!(!info.cass_version.is_empty());
688        assert!(!info.timestamp.is_empty());
689    }
690
691    #[test]
692    fn trace_options_active_detection() {
693        let opts = TraceOptions::default();
694        assert!(!opts.is_active());
695
696        let opts = TraceOptions {
697            render_path: Some(PathBuf::from("/tmp/test.jsonl")),
698            ..Default::default()
699        };
700        assert!(opts.is_active());
701
702        let opts = TraceOptions {
703            bundle_dir: Some(PathBuf::from("/tmp/bundle")),
704            ..Default::default()
705        };
706        assert!(opts.is_active());
707    }
708
709    #[test]
710    fn trace_options_bundle_creates_default_paths() {
711        let tmp = TempDir::new().unwrap();
712        let bundle_dir = tmp.path().join("bundle");
713
714        let opts = TraceOptions {
715            bundle_dir: Some(bundle_dir.clone()),
716            ..Default::default()
717        };
718
719        let mut writer = opts.into_writer().unwrap();
720        assert!(writer.is_active());
721        writer
722            .record_frame(Duration::from_micros(100), 80, 24, None, None)
723            .unwrap();
724        writer.record_event("Tick", None).unwrap();
725        writer.flush().unwrap();
726
727        assert!(bundle_dir.join("render.trace.jsonl").exists());
728        assert!(bundle_dir.join("events.jsonl").exists());
729    }
730}