1use super::event::{SessionEvent, SessionEventKind};
2use super::manager::{Session, SessionInternalDiagnostic, SessionListReport, SessionManager};
3use super::read::{MAX_METADATA_VISIT_BYTES, MAX_METADATA_VISIT_LINES, validate_session_id};
4use super::store::{open_existing_named, validate_existing_file, validate_path_file};
5use super::write::{
6 COMPACTION_SCHEMA_VERSION, sanitize_compaction_summary, sanitize_session_title,
7};
8use crate::{output::redact_sensitive_text, persistence::atomic_write_with_permissions};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::{
13 fs,
14 io::Read,
15 path::{Path, PathBuf},
16 time::{SystemTime, UNIX_EPOCH},
17};
18
19const MAX_SESSION_METADATA_BYTES: u64 = 64 * 1024;
20const MAX_SESSION_INTERNAL_DIAGNOSTICS: usize = 64;
21pub(crate) fn push_session_diagnostic(
22 diagnostics: &mut Vec<SessionInternalDiagnostic>,
23 session_id: Option<String>,
24 message: String,
25) {
26 if diagnostics.len() < MAX_SESSION_INTERNAL_DIAGNOSTICS {
27 let redacted = redact_sensitive_text(&message);
28 let mut chars = redacted.chars();
29 let mut bounded = chars.by_ref().take(500).collect::<String>();
30 if chars.next().is_some() {
31 bounded.push('…');
32 }
33 diagnostics.push(SessionInternalDiagnostic {
34 session_id,
35 message: bounded,
36 });
37 }
38}
39
40#[derive(Debug, Clone, Copy)]
41pub(crate) enum SessionDiagnosticOperation {
42 Replay,
43 Metadata,
44 Listing,
45 Title,
46 RecentContext,
47}
48
49impl SessionDiagnosticOperation {
50 fn as_str(self) -> &'static str {
51 match self {
52 Self::Replay => "replay",
53 Self::Metadata => "metadata",
54 Self::Listing => "listing",
55 Self::Title => "title",
56 Self::RecentContext => "recent_context",
57 }
58 }
59}
60
61pub(crate) fn report_session_diagnostic(
62 operation: SessionDiagnosticOperation,
63 _path: &Path,
64 _error: impl std::fmt::Display,
65) {
66 eprintln!(
67 "warning: session operation={} category=session_jsonl failed",
68 operation.as_str()
69 );
70}
71const SESSION_METADATA_SCHEMA_VERSION: u64 = 3;
72const SESSION_METADATA_EXTENSION: &str = "metadata.json";
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub(in crate::sessions) struct SessionMetadataRecord {
76 schema_version: u64,
77 session_id: String,
78 latest_activity_timestamp: Option<DateTime<Utc>>,
79 latest_title: Option<String>,
80 session_path: Option<PathBuf>,
81 #[serde(default)]
82 first_user_input_text: Option<String>,
83 user_input_count: usize,
84 jsonl_len: u64,
85 jsonl_modified_ns: Option<u128>,
86 complete: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub(crate) struct SessionMetadataSummary {
91 pub(crate) session: Session,
92 pub(crate) latest_activity_time: SystemTime,
93 pub(crate) latest_title: Option<String>,
94 pub(crate) session_path: Option<PathBuf>,
95}
96
97impl SessionMetadataRecord {
98 fn empty(session: &Session, marker: JsonlMarker, complete: bool) -> Self {
99 Self {
100 schema_version: SESSION_METADATA_SCHEMA_VERSION,
101 session_id: session.id.clone(),
102 latest_activity_timestamp: None,
103 latest_title: None,
104 session_path: None,
105 first_user_input_text: None,
106 user_input_count: 0,
107 jsonl_len: marker.len,
108 jsonl_modified_ns: marker.modified_ns,
109 complete,
110 }
111 }
112
113 fn apply_event(&mut self, event: &SessionEvent) {
114 if self.session_path.is_none() {
115 self.session_path = event.session_path.clone();
116 }
117 self.latest_activity_timestamp = Some(
118 self.latest_activity_timestamp
119 .map_or(event.timestamp, |current| current.max(event.timestamp)),
120 );
121 match event.kind() {
122 Some(SessionEventKind::SessionTitle) => {
123 if let Some(title) = event.payload.get("title").and_then(Value::as_str)
124 && let Some(title) = sanitize_session_title(title)
125 {
126 self.latest_title = Some(title);
127 }
128 self.first_user_input_text = None;
129 }
130 Some(SessionEventKind::UserInput) => {
131 self.user_input_count = self.user_input_count.saturating_add(1);
132 if self.first_user_input_text.is_none()
133 && let Some(text) = event.payload.get("text").and_then(Value::as_str)
134 && !text.trim().is_empty()
135 {
136 self.first_user_input_text = Some(text.to_string());
137 }
138 }
139 Some(SessionEventKind::Compaction) => {
140 if let Some(aggregate) = event.payload.get("aggregate") {
141 if let Some(title) = aggregate.get("latest_title").and_then(Value::as_str) {
142 self.latest_title = sanitize_session_title(title);
143 }
144 if let Some(count) = aggregate.get("user_input_count").and_then(Value::as_u64)
145 && let Ok(count) = usize::try_from(count)
146 {
147 self.user_input_count = count;
148 }
149 self.first_user_input_text = aggregate
150 .get("first_user_input_text")
151 .and_then(Value::as_str)
152 .map(|text| redact_sensitive_text(text).chars().take(4_000).collect());
153 if let Some(path) = aggregate.get("session_path").and_then(Value::as_str) {
154 self.session_path = Some(PathBuf::from(path));
155 }
156 if let Some(timestamp) = aggregate.get("latest_activity_timestamp")
157 && let Ok(timestamp) =
158 serde_json::from_value::<chrono::DateTime<Utc>>(timestamp.clone())
159 {
160 self.latest_activity_timestamp = Some(
161 self.latest_activity_timestamp
162 .map_or(timestamp, |current| current.max(timestamp)),
163 );
164 }
165 }
166 }
167 _ => {}
168 }
169 }
170
171 pub(crate) fn checkpoint_aggregate(&self) -> Value {
172 serde_json::json!({
173 "latest_title": self.latest_title,
174 "user_input_count": self.user_input_count,
175 "first_user_input_text": self.first_user_input_text.as_deref().map(|text| redact_sensitive_text(text).chars().take(4_000).collect::<String>()),
176 "session_path": self.session_path.as_ref().map(|path| path.to_string_lossy().to_string()),
177 "latest_activity_timestamp": self.latest_activity_timestamp,
178 })
179 }
180
181 pub(crate) fn activity_time(&self, session: &Session) -> SystemTime {
182 self.latest_activity_timestamp
183 .map(SystemTime::from)
184 .unwrap_or_else(|| jsonl_modified_time(session).unwrap_or(SystemTime::UNIX_EPOCH))
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189struct JsonlMarker {
190 len: u64,
191 modified_ns: Option<u128>,
192}
193
194impl SessionManager {
195 pub(crate) fn list_metadata_summaries(&self) -> anyhow::Result<Vec<SessionMetadataSummary>> {
196 Ok(self.list_metadata_report()?.summaries)
197 }
198
199 pub(crate) fn list_metadata_report(&self) -> anyhow::Result<SessionListReport> {
200 if !self.root.exists() {
201 return Ok(SessionListReport {
202 summaries: Vec::new(),
203 diagnostics: Vec::new(),
204 });
205 }
206 super::store::validate_session_root(&self.root)?;
207 let mut summaries = Vec::new();
208 let mut diagnostics = Vec::new();
209 for entry in fs::read_dir(&self.root)? {
210 let entry = match entry {
211 Ok(entry) => entry,
212 Err(error) => {
213 push_session_diagnostic(
214 &mut diagnostics,
215 None,
216 format!("failed to read session directory entry: {error}"),
217 );
218 continue;
219 }
220 };
221 let path = entry.path();
222 let Some((id, path)) = session_from_entry(path) else {
223 continue;
224 };
225 let id = match validate_session_id(id) {
226 Ok(id) => id,
227 Err(error) => {
228 push_session_diagnostic(
229 &mut diagnostics,
230 None,
231 format!("ignored invalid session file {}: {error}", path.display()),
232 );
233 continue;
234 }
235 };
236 if validate_path_file(&self.root, &id, &path).is_err() {
237 push_session_diagnostic(
238 &mut diagnostics,
239 Some(id),
240 "ignored unsafe session file".to_string(),
241 );
242 continue;
243 }
244 let session = Session { id, path };
245 match session_metadata_summary(session) {
246 Ok(summary) => summaries.push(summary),
247 Err(error) => push_session_diagnostic(
248 &mut diagnostics,
249 None,
250 format!("failed to load session metadata: {error}"),
251 ),
252 }
253 }
254 summaries.sort_by(|left, right| {
255 left.latest_activity_time
256 .cmp(&right.latest_activity_time)
257 .then_with(|| left.session.id.cmp(&right.session.id))
258 });
259 Ok(SessionListReport {
260 summaries,
261 diagnostics,
262 })
263 }
264}
265
266pub(crate) fn session_from_entry(path: PathBuf) -> Option<(String, PathBuf)> {
267 if path
268 .extension()
269 .is_none_or(|extension| extension != "jsonl")
270 {
271 return None;
272 }
273 let id = path.file_stem()?.to_string_lossy().to_string();
274 Some((id, path))
275}
276
277pub(crate) fn metadata_path_for_session(session: &Session) -> PathBuf {
278 session.path.with_extension(SESSION_METADATA_EXTENSION)
279}
280
281fn jsonl_marker(session: &Session) -> anyhow::Result<JsonlMarker> {
282 let root = session
283 .path
284 .parent()
285 .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
286 let Some(file) = super::store::open_existing_primary(root, &session.id)? else {
287 anyhow::bail!("session JSONL is missing");
288 };
289 let metadata = file.metadata()?;
290 Ok(JsonlMarker {
291 len: metadata.len(),
292 modified_ns: metadata.modified().ok().map(system_time_ns),
293 })
294}
295
296fn jsonl_modified_time(session: &Session) -> Option<SystemTime> {
297 let root = session.path.parent()?;
298 super::store::open_existing_primary(root, &session.id)
299 .ok()
300 .flatten()
301 .and_then(|file| file.metadata().ok())
302 .and_then(|metadata| metadata.modified().ok())
303}
304
305fn system_time_ns(time: SystemTime) -> u128 {
306 time.duration_since(UNIX_EPOCH)
307 .unwrap_or_default()
308 .as_nanos()
309}
310
311fn read_session_metadata_with_policy(
312 session: &Session,
313 require_complete: bool,
314) -> anyhow::Result<Option<SessionMetadataRecord>> {
315 let path = metadata_path_for_session(session);
316 let root = session
317 .path
318 .parent()
319 .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
320 let name = path
321 .file_name()
322 .ok_or_else(|| anyhow::anyhow!("metadata path has no filename"))?
323 .to_string_lossy();
324 if !session.path.exists() {
325 return Ok(None);
326 }
327 let Some(file) = open_existing_named(root, &name)? else {
328 return Ok(None);
329 };
330 let mut bytes = Vec::new();
331 file.take(MAX_SESSION_METADATA_BYTES.saturating_add(1))
332 .read_to_end(&mut bytes)?;
333 if bytes.len() as u64 > MAX_SESSION_METADATA_BYTES {
334 return Ok(None);
335 }
336 let Ok(record) = serde_json::from_slice::<SessionMetadataRecord>(&bytes) else {
337 return Ok(None);
338 };
339 if record.schema_version != SESSION_METADATA_SCHEMA_VERSION
340 || record.session_id != session.id
341 || (require_complete && !record.complete)
342 {
343 return Ok(None);
344 }
345 let marker = jsonl_marker(session)?;
346 if record.jsonl_len != marker.len || record.jsonl_modified_ns != marker.modified_ns {
347 return Ok(None);
348 }
349 Ok(Some(record))
350}
351
352pub(crate) fn read_complete_session_metadata(
353 session: &Session,
354) -> anyhow::Result<Option<SessionMetadataRecord>> {
355 read_session_metadata(session)
356}
357
358fn read_session_metadata(session: &Session) -> anyhow::Result<Option<SessionMetadataRecord>> {
359 read_session_metadata_with_policy(session, true)
360}
361
362pub(crate) fn read_session_metadata_for_append(
363 session: &Session,
364) -> anyhow::Result<Option<SessionMetadataRecord>> {
365 read_session_metadata_with_policy(session, false)
366}
367fn write_session_metadata(session: &Session, record: &SessionMetadataRecord) -> anyhow::Result<()> {
368 let path = metadata_path_for_session(session);
369 validate_existing_file(&path)?;
370 let bytes = serde_json::to_vec_pretty(record)?;
371 atomic_write_with_permissions(&path, &bytes, Some(super::store::SESSION_FILE_MODE))
372}
373
374pub(crate) fn rebuild_session_metadata_from_jsonl(
375 session: &Session,
376) -> anyhow::Result<SessionMetadataRecord> {
377 validate_session_id(session.id.clone())?;
378 let initial_marker = jsonl_marker(session)?;
379 let mut record = SessionMetadataRecord::empty(session, initial_marker, true);
380 let (_, _) = session.visit_events_tolerant_bounded(
381 MAX_METADATA_VISIT_LINES,
382 MAX_METADATA_VISIT_BYTES,
383 |event| {
384 if event.session_id == session.id {
385 record.apply_event(&event);
386 }
387 },
388 )?;
389 let final_marker = jsonl_marker(session)?;
390 record.jsonl_len = final_marker.len;
391 record.jsonl_modified_ns = final_marker.modified_ns;
392 record.complete = initial_marker == final_marker;
393 if let Err(error) = write_session_metadata(session, &record) {
394 report_session_diagnostic(
395 SessionDiagnosticOperation::Metadata,
396 &metadata_path_for_session(session),
397 &error,
398 );
399 }
400 Ok(record)
401}
402
403pub(crate) fn write_rotated_session_metadata(
404 session: &Session,
405 mut record: SessionMetadataRecord,
406 checkpoint: &SessionEvent,
407) -> anyhow::Result<()> {
408 record.apply_event(checkpoint);
409 let final_marker = jsonl_marker(session)?;
410 record.jsonl_len = final_marker.len;
411 record.jsonl_modified_ns = final_marker.modified_ns;
412 record.complete = true;
413 write_session_metadata(session, &record)
414}
415
416pub(crate) fn session_metadata_for_listing(
417 session: &Session,
418) -> anyhow::Result<SessionMetadataRecord> {
419 if let Some(record) = read_session_metadata(session)? {
420 return Ok(record);
421 }
422 rebuild_session_metadata_from_jsonl(session)
423}
424
425fn session_metadata_summary(session: Session) -> anyhow::Result<SessionMetadataSummary> {
426 let record = session_metadata_for_listing(&session)?;
427 let latest_activity_time = record.activity_time(&session);
428 Ok(SessionMetadataSummary {
429 session,
430 latest_activity_time,
431 latest_title: record.latest_title,
432 session_path: record.session_path,
433 })
434}
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub(crate) struct CompactionCheckpoint {
437 pub(crate) summary: String,
438 pub(crate) provider: String,
439 pub(crate) model: String,
440 pub(crate) cutoff_event_count: usize,
441 pub(crate) event_index: usize,
442}
443pub(crate) fn update_session_metadata_after_append_batch(
444 session: &Session,
445 previous: Option<SessionMetadataRecord>,
446 events: &[SessionEvent],
447) -> anyhow::Result<()> {
448 let mut record = match previous {
449 Some(record) => record,
450 None => SessionMetadataRecord::empty(session, jsonl_marker(session)?, false),
451 };
452 for event in events {
453 record.apply_event(event);
454 }
455 let marker = jsonl_marker(session)?;
456 record.jsonl_len = marker.len;
457 record.jsonl_modified_ns = marker.modified_ns;
458 write_session_metadata(session, &record)
459}
460
461pub(crate) fn latest_valid_compaction_checkpoint(
462 session_id: &str,
463 events: &[SessionEvent],
464) -> (Option<CompactionCheckpoint>, Vec<String>) {
465 let mut diagnostics = Vec::new();
466 for (event_index, event) in events.iter().enumerate().rev() {
467 if event.kind() != Some(SessionEventKind::Compaction) {
468 continue;
469 }
470 match validate_compaction_checkpoint(session_id, event, event_index, events.len()) {
471 Ok(checkpoint) => return (Some(checkpoint), diagnostics),
472 Err(message) => diagnostics.push(message),
473 }
474 }
475 (None, diagnostics)
476}
477
478fn validate_compaction_checkpoint(
479 session_id: &str,
480 event: &SessionEvent,
481 event_index: usize,
482 total_event_count: usize,
483) -> Result<CompactionCheckpoint, String> {
484 if event.session_id != session_id {
485 return Err(format!(
486 "ignored malformed compaction checkpoint at event_index={event_index}: session_id_mismatch"
487 ));
488 }
489 let schema_version = event
490 .payload
491 .get("schema_version")
492 .and_then(Value::as_u64)
493 .ok_or_else(|| {
494 format!(
495 "ignored malformed compaction checkpoint at event_index={event_index}: missing_schema_version"
496 )
497 })?;
498 if schema_version != COMPACTION_SCHEMA_VERSION {
499 return Err(format!(
500 "ignored malformed compaction checkpoint at event_index={event_index}: unsupported_schema_version"
501 ));
502 }
503 let summary = event
504 .payload
505 .get("summary")
506 .and_then(Value::as_str)
507 .and_then(sanitize_compaction_summary)
508 .ok_or_else(|| {
509 format!(
510 "ignored malformed compaction checkpoint at event_index={event_index}: empty_summary"
511 )
512 })?;
513 let provider = required_non_blank_payload_string(event, "provider", event_index)?;
514 let model = required_non_blank_payload_string(event, "model", event_index)?;
515 let cutoff_event_count = event
516 .payload
517 .get("cutoff_event_count")
518 .and_then(Value::as_u64)
519 .and_then(|value| usize::try_from(value).ok())
520 .ok_or_else(|| {
521 format!(
522 "ignored malformed compaction checkpoint at event_index={event_index}: missing_cutoff_event_count"
523 )
524 })?;
525 if cutoff_event_count > event_index || cutoff_event_count > total_event_count {
526 return Err(format!(
527 "ignored malformed compaction checkpoint at event_index={event_index}: cutoff_event_count_out_of_bounds"
528 ));
529 }
530 Ok(CompactionCheckpoint {
531 summary,
532 provider,
533 model,
534 cutoff_event_count,
535 event_index,
536 })
537}
538
539fn required_non_blank_payload_string(
540 event: &SessionEvent,
541 key: &str,
542 event_index: usize,
543) -> Result<String, String> {
544 event.payload.get(key).and_then(Value::as_str).map(str::trim)
545 .filter(|value| !value.is_empty())
546 .map(ToString::to_string)
547 .ok_or_else(|| format!("ignored malformed compaction checkpoint at event_index={event_index}: missing_{key}"))
548}
549
550#[derive(Debug, Clone, Default, PartialEq, Eq)]
551pub(crate) struct SessionTitleMetadata {
552 pub(crate) latest_title: Option<String>,
553 pub(crate) user_input_count: usize,
554 pub(crate) first_user_input_text: Option<String>,
555}
556
557pub(crate) fn session_title_metadata(session: &Session) -> anyhow::Result<SessionTitleMetadata> {
558 if let Some(record) = read_session_metadata(session)? {
559 return Ok(SessionTitleMetadata {
560 latest_title: record.latest_title,
561 user_input_count: record.user_input_count,
562 first_user_input_text: record.first_user_input_text,
563 });
564 }
565 let mut metadata = SessionTitleMetadata::default();
566 session
567 .visit_events_tolerant_bounded(
568 MAX_METADATA_VISIT_LINES,
569 MAX_METADATA_VISIT_BYTES,
570 |event| match event.kind() {
571 Some(SessionEventKind::SessionTitle) => {
572 if let Some(title) = event
573 .payload
574 .get("title")
575 .and_then(Value::as_str)
576 .and_then(sanitize_session_title)
577 {
578 metadata.latest_title = Some(title);
579 }
580 }
581 Some(SessionEventKind::UserInput) => {
582 metadata.user_input_count = metadata.user_input_count.saturating_add(1);
583 if metadata.first_user_input_text.is_none()
584 && let Some(text) = event.payload.get("text").and_then(Value::as_str)
585 && !text.trim().is_empty()
586 {
587 metadata.first_user_input_text = Some(text.to_string());
588 }
589 }
590 _ => {}
591 },
592 )
593 .inspect_err(|error| {
594 report_session_diagnostic(SessionDiagnosticOperation::Title, session.path(), error);
595 })?;
596 Ok(metadata)
597}
598
599pub fn latest_session_title(session: &Session) -> anyhow::Result<Option<String>> {
600 Ok(session_title_metadata(session)?.latest_title)
601}
602
603pub fn session_user_input_count(session: &Session) -> anyhow::Result<usize> {
604 Ok(session_title_metadata(session)?.user_input_count)
605}
606
607#[cfg(test)]
608mod tests {
609 use super::super::read::latest_valid_event_timestamp_streaming;
610 use super::super::write::{
611 SESSION_TITLE_EVENT, record_session_compaction, record_session_event, record_session_title,
612 };
613 use super::*;
614 use chrono::{TimeZone, Timelike};
615 use serde_json::json;
616 use tempfile::TempDir;
617
618 #[test]
619 fn compaction_checkpoint_appends_round_trip_and_redacts_summary() {
620 let temp = TempDir::new().unwrap();
621 let manager = SessionManager::new(temp.path().join("sessions"));
622 let session = manager.create().unwrap();
623 record_session_compaction(
624 &session,
625 temp.path(),
626 "summary with sk-testSecret123456",
627 "provider-a",
628 "model-a",
629 0,
630 )
631 .unwrap();
632
633 let events = session.read_events().unwrap();
634 assert_eq!(events.len(), 1);
635 assert_eq!(events[0].event_type, "compaction");
636 assert_eq!(
637 events[0].payload["schema_version"],
638 COMPACTION_SCHEMA_VERSION
639 );
640 assert_eq!(events[0].payload["provider"], "provider-a");
641 assert_eq!(events[0].payload["model"], "model-a");
642 assert_eq!(events[0].payload["cutoff_event_count"], 0);
643 assert!(
644 !events[0].payload["summary"]
645 .to_string()
646 .contains("sk-testSecret")
647 );
648
649 let (checkpoint, diagnostics) = latest_valid_compaction_checkpoint(session.id(), &events);
650 assert!(diagnostics.is_empty(), "{diagnostics:?}");
651 let checkpoint = checkpoint.unwrap();
652 assert_eq!(checkpoint.provider, "provider-a");
653 assert_eq!(checkpoint.model, "model-a");
654 assert_eq!(checkpoint.cutoff_event_count, 0);
655 assert_eq!(checkpoint.event_index, 0);
656 }
657
658 #[test]
659 fn compaction_checkpoint_rejects_empty_summary() {
660 let temp = TempDir::new().unwrap();
661 let manager = SessionManager::new(temp.path().join("sessions"));
662 let session = manager.create().unwrap();
663
664 let error = record_session_compaction(&session, temp.path(), " \n\t ", "p", "m", 0)
665 .unwrap_err()
666 .to_string();
667
668 assert!(error.contains("empty"), "{error}");
669 assert!(session.read_events().unwrap().is_empty());
670 }
671
672 #[test]
673 fn latest_valid_compaction_falls_back_after_malformed_or_mismatched_latest() {
674 let temp = TempDir::new().unwrap();
675 let manager = SessionManager::new(temp.path().join("sessions"));
676 let session = manager.open("safe").unwrap();
677 let valid = SessionEvent::new_kind(
678 SessionEventKind::Compaction,
679 session.id().to_string(),
680 temp.path().to_path_buf(),
681 json!({
682 "schema_version": COMPACTION_SCHEMA_VERSION,
683 "summary":"valid summary",
684 "provider":"p",
685 "model":"m",
686 "cutoff_event_count": 0,
687 }),
688 );
689 let malformed = SessionEvent::new_kind(
690 SessionEventKind::Compaction,
691 session.id().to_string(),
692 temp.path().to_path_buf(),
693 json!({"schema_version": COMPACTION_SCHEMA_VERSION, "summary":" "}),
694 );
695 let mismatched = SessionEvent::new_kind(
696 SessionEventKind::Compaction,
697 "other".to_string(),
698 temp.path().to_path_buf(),
699 json!({
700 "schema_version": COMPACTION_SCHEMA_VERSION,
701 "summary":"wrong session",
702 "provider":"p",
703 "model":"m",
704 "cutoff_event_count": 2,
705 }),
706 );
707
708 let (checkpoint, diagnostics) = latest_valid_compaction_checkpoint(
709 session.id(),
710 &[valid.clone(), malformed, mismatched],
711 );
712
713 assert_eq!(checkpoint.unwrap().summary, "valid summary");
714 assert_eq!(diagnostics.len(), 2);
715 assert!(
716 diagnostics
717 .iter()
718 .any(|message| message.contains("session_id_mismatch"))
719 );
720 assert!(
721 diagnostics
722 .iter()
723 .any(|message| message.contains("empty_summary"))
724 );
725 assert!(!diagnostics.join("\n").contains("wrong session"));
726 }
727
728 #[test]
729 fn session_titles_sanitize_round_trip_and_latest_wins() {
730 let temp = TempDir::new().unwrap();
731 let manager = SessionManager::new(temp.path().join("sessions"));
732 let session = manager.create().unwrap();
733
734 assert_eq!(session_title_metadata(&session).unwrap().latest_title, None);
735 assert_eq!(
736 sanitize_session_title(" \"Fix parser\"\nnow "),
737 Some("Fix parser now".to_string())
738 );
739 assert_eq!(sanitize_session_title("\n\t\r"), None);
740 assert_eq!(
741 sanitize_session_title(&"é".repeat(60))
742 .unwrap()
743 .chars()
744 .count(),
745 50
746 );
747
748 record_session_title(
749 &session,
750 temp.path(),
751 "First title",
752 "provider-a",
753 "model-a",
754 )
755 .unwrap();
756 record_session_title(
757 &session,
758 temp.path(),
759 "'Second title'",
760 "provider-b",
761 "model-b",
762 )
763 .unwrap();
764
765 let events = session.read_events().unwrap();
766 assert_eq!(events.len(), 2);
767 assert_eq!(events[0].event_type, SESSION_TITLE_EVENT);
768 assert_eq!(events[0].payload["title"], "First title");
769 assert_eq!(events[0].payload["provider"], "provider-a");
770 assert_eq!(events[0].payload["model"], "model-a");
771 assert!(events[0].payload.get("access_token").is_none());
772 assert_eq!(
773 session_title_metadata(&session)
774 .unwrap()
775 .latest_title
776 .as_deref(),
777 Some("Second title")
778 );
779 }
780 #[test]
781 fn session_user_input_count_supports_first_message_trigger() {
782 let temp = TempDir::new().unwrap();
783 let manager = SessionManager::new(temp.path().join("sessions"));
784 let session = manager.create().unwrap();
785 assert_eq!(session_user_input_count(&session).unwrap(), 0);
786 record_session_event(
787 Some(&session),
788 temp.path(),
789 "user_input",
790 json!({"text":"one"}),
791 )
792 .unwrap();
793 assert_eq!(session_user_input_count(&session).unwrap(), 1);
794 record_session_event(
795 Some(&session),
796 temp.path(),
797 "user_input",
798 json!({"text":"two"}),
799 )
800 .unwrap();
801 assert_eq!(session_user_input_count(&session).unwrap(), 2);
802 }
803
804 #[test]
805 fn session_title_metadata_uses_first_persisted_non_empty_text() {
806 let temp = TempDir::new().unwrap();
807 let manager = SessionManager::new(temp.path().join("sessions"));
808 let session = manager.create().unwrap();
809 record_session_event(
810 Some(&session),
811 temp.path(),
812 "diagnostic",
813 json!({"text":"ignore"}),
814 )
815 .unwrap();
816 record_session_event(
817 Some(&session),
818 temp.path(),
819 "user_input",
820 json!({"text":" "}),
821 )
822 .unwrap();
823 record_session_event(
824 Some(&session),
825 temp.path(),
826 "user_input",
827 json!({"text":"first durable prompt"}),
828 )
829 .unwrap();
830 record_session_event(
831 Some(&session),
832 temp.path(),
833 "user_input",
834 json!({"text":"second prompt"}),
835 )
836 .unwrap();
837
838 assert_eq!(
839 session_title_metadata(&session)
840 .unwrap()
841 .first_user_input_text
842 .as_deref(),
843 Some("first durable prompt")
844 );
845 }
846
847 #[test]
848 fn session_title_metadata_rejects_oversized_jsonl() {
849 let temp = TempDir::new().unwrap();
850 let manager = SessionManager::new(temp.path().join("sessions"));
851 let session = manager.open("oversized-title").unwrap();
852 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
853 fs::OpenOptions::new()
854 .create(true)
855 .write(true)
856 .truncate(true)
857 .open(session.path())
858 .unwrap()
859 .set_len((MAX_METADATA_VISIT_BYTES as u64) + 1)
860 .unwrap();
861 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
862
863 let error = session_title_metadata(&session).unwrap_err().to_string();
864
865 assert!(error.contains("tolerant read limit exceeded"), "{error}");
866 assert!(error.contains("bytes"), "{error}");
867 }
868
869 #[test]
870 fn metadata_rebuild_rejects_oversized_jsonl() {
871 let temp = TempDir::new().unwrap();
872 let manager = SessionManager::new(temp.path().join("sessions"));
873 let session = manager.open("oversized-metadata").unwrap();
874 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
875 fs::OpenOptions::new()
876 .create(true)
877 .write(true)
878 .truncate(true)
879 .open(session.path())
880 .unwrap()
881 .set_len((MAX_METADATA_VISIT_BYTES as u64) + 1)
882 .unwrap();
883 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
884
885 let error = session_metadata_for_listing(&session)
886 .unwrap_err()
887 .to_string();
888
889 assert!(error.contains("tolerant read limit exceeded"), "{error}");
890 assert!(error.contains("bytes"), "{error}");
891 }
892
893 #[test]
894 fn session_title_and_user_count_tolerate_malformed_lines() {
895 let temp = TempDir::new().unwrap();
896 let manager = SessionManager::new(temp.path().join("sessions"));
897 let session = manager.open("safe").unwrap();
898 let first_title = SessionEvent::new_kind(
899 SessionEventKind::SessionTitle,
900 session.id().to_string(),
901 temp.path().to_path_buf(),
902 json!({"title":"First title", "provider":"p", "model":"m"}),
903 );
904 let user_input = SessionEvent::new_kind(
905 SessionEventKind::UserInput,
906 session.id().to_string(),
907 temp.path().to_path_buf(),
908 json!({"text":"one"}),
909 );
910 let latest_title = SessionEvent::new_kind(
911 SessionEventKind::SessionTitle,
912 session.id().to_string(),
913 temp.path().to_path_buf(),
914 json!({"title":"Latest title", "provider":"p", "model":"m"}),
915 );
916 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
917 fs::write(
918 session.path(),
919 format!(
920 "{}\nnot json\n{}\n{}\n",
921 serde_json::to_string(&first_title).unwrap(),
922 serde_json::to_string(&user_input).unwrap(),
923 serde_json::to_string(&latest_title).unwrap()
924 ),
925 )
926 .unwrap();
927 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
928
929 assert!(session.read_events().is_err());
930 let metadata = session_title_metadata(&session).unwrap();
931 assert_eq!(metadata.latest_title.as_deref(), Some("Latest title"));
932 assert_eq!(metadata.user_input_count, 1);
933 assert_eq!(metadata.first_user_input_text.as_deref(), Some("one"));
934 assert_eq!(
935 session_title_metadata(&session)
936 .unwrap()
937 .latest_title
938 .as_deref(),
939 Some("Latest title")
940 );
941 assert_eq!(session_user_input_count(&session).unwrap(), 1);
942 }
943
944 #[test]
945 fn continue_uses_most_recent_session_by_latest_event_timestamp() {
946 let temp = TempDir::new().unwrap();
947 let manager = SessionManager::new(temp.path().join("sessions"));
948 fs::create_dir_all(temp.path().join("sessions")).unwrap();
949
950 let z_old = manager.open("zzzz").unwrap();
951 let a_new = manager.open("aaaa").unwrap();
952
953 let z_old_event = event_at(&z_old, temp.path(), 2024, 1, 3);
954 fs::write(
955 z_old.path(),
956 format!("{}\n", serde_json::to_string(&z_old_event).unwrap()),
957 )
958 .unwrap();
959
960 let a_latest_outside_tail = event_at(&a_new, temp.path(), 2024, 1, 4);
961 let a_tail_older = event_at(&a_new, temp.path(), 2024, 1, 2);
962 fs::write(
963 a_new.path(),
964 format!(
965 "{}\n{}\n{}\n",
966 serde_json::to_string(&a_latest_outside_tail).unwrap(),
967 "not-json".repeat(300 * 1024),
968 serde_json::to_string(&a_tail_older).unwrap()
969 ),
970 )
971 .unwrap();
972 crate::sessions::store::secure_test_session_root(temp.path().join("sessions").as_path());
973
974 let sessions = manager.list().unwrap();
975 assert_eq!(sessions.last().unwrap().id(), "aaaa");
976 assert_eq!(manager.most_recent().unwrap().unwrap().id(), "aaaa");
977 }
978
979 #[test]
980 fn session_list_activity_streams_latest_valid_timestamp_without_event_vec() {
981 let temp = TempDir::new().unwrap();
982 let manager = SessionManager::new(temp.path().join("sessions"));
983 let older = manager.open("older").unwrap();
984 let newer = manager.open("newer").unwrap();
985 let older_event = event_at(&older, temp.path(), 2024, 1, 1);
986 let newer_event = event_at(&newer, temp.path(), 2024, 1, 2);
987 fs::create_dir_all(newer.path().parent().unwrap()).unwrap();
988 fs::write(
989 older.path(),
990 format!("{}\n", serde_json::to_string(&older_event).unwrap()),
991 )
992 .unwrap();
993 fs::write(
994 newer.path(),
995 format!(
996 "not json\n{}\n",
997 serde_json::to_string(&newer_event).unwrap()
998 ),
999 )
1000 .unwrap();
1001 crate::sessions::store::secure_test_session_root(newer.path().parent().unwrap());
1002
1003 let sessions = manager.list().unwrap();
1004
1005 assert_eq!(sessions.last().unwrap().id(), "newer");
1006 assert_eq!(
1007 latest_valid_event_timestamp_streaming(&newer),
1008 Some(SystemTime::from(newer_event.timestamp))
1009 );
1010 }
1011
1012 #[test]
1013 fn session_activity_streaming_uses_latest_valid_event_across_full_file() {
1014 let temp = TempDir::new().unwrap();
1015 let manager = SessionManager::new(temp.path().join("sessions"));
1016 let session = manager.open("tail-session").unwrap();
1017 let old = event_at(&session, temp.path(), 2024, 1, 1);
1018 let recent = event_at(&session, temp.path(), 2024, 1, 2);
1019 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
1020 fs::write(
1021 session.path(),
1022 format!(
1023 "{}\n{}\nnot json\n",
1024 serde_json::to_string(&old).unwrap(),
1025 serde_json::to_string(&recent).unwrap()
1026 ),
1027 )
1028 .unwrap();
1029 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
1030
1031 assert_eq!(
1032 latest_valid_event_timestamp_streaming(&session),
1033 Some(SystemTime::from(recent.timestamp))
1034 );
1035 }
1036
1037 #[test]
1038 fn record_session_event_propagates_append_failure() {
1039 let temp = TempDir::new().unwrap();
1040 let invalid_session =
1041 Session::unchecked_for_test("../unsafe".to_string(), temp.path().join("unsafe.jsonl"));
1042
1043 let error = record_session_event(
1044 Some(&invalid_session),
1045 temp.path(),
1046 "diagnostic",
1047 json!({"message":"persist me"}),
1048 )
1049 .unwrap_err();
1050
1051 assert!(error.to_string().contains("must not contain '..'"));
1052 assert!(!invalid_session.path().exists());
1053 }
1054
1055 #[test]
1056 fn session_metadata_sidecar_excludes_raw_transcript_and_secrets() {
1057 let temp = TempDir::new().unwrap();
1058 let manager = SessionManager::new(temp.path().join("sessions"));
1059 let session = manager.create().unwrap();
1060 session
1061 .append(&SessionEvent::new(
1062 "user_input",
1063 session.id().to_string(),
1064 temp.path().to_path_buf(),
1065 json!({"text":"raw prompt secret sk-testSecret123456", "api_key":"abc123"}),
1066 ))
1067 .unwrap();
1068 session
1069 .append(&SessionEvent::new(
1070 "tool_result",
1071 session.id().to_string(),
1072 temp.path().to_path_buf(),
1073 json!({"result":{"content":"tool output bearer secret-token"}}),
1074 ))
1075 .unwrap();
1076 record_session_title(
1077 &session,
1078 temp.path(),
1079 "Safe Indexed Title",
1080 "provider-a",
1081 "model-a",
1082 )
1083 .unwrap();
1084
1085 let sidecar = fs::read_to_string(metadata_path_for_session(&session)).unwrap();
1086
1087 assert!(sidecar.contains("Safe Indexed Title"));
1088 assert!(sidecar.contains("session_path"));
1089 for forbidden in [
1090 "raw prompt secret",
1091 "sk-testSecret123456",
1092 "api_key",
1093 "abc123",
1094 "tool output",
1095 "bearer secret-token",
1096 "provider-a",
1097 "model-a",
1098 "access_token",
1099 "account_id",
1100 ] {
1101 assert!(!sidecar.contains(forbidden), "sidecar leaked {forbidden}");
1102 }
1103 }
1104
1105 #[test]
1106 fn metadata_indexes_first_session_path_and_keeps_legacy_missing_path_none() {
1107 let temp = TempDir::new().unwrap();
1108 let manager = SessionManager::new(temp.path().join("sessions"));
1109 let scoped = temp.path().join("scoped");
1110 fs::create_dir(&scoped).unwrap();
1111 let session = manager.open("scoped-session").unwrap();
1112 session
1113 .append(&SessionEvent::new(
1114 "diagnostic",
1115 session.id().to_string(),
1116 scoped.clone(),
1117 json!({}),
1118 ))
1119 .unwrap();
1120
1121 let record: SessionMetadataRecord =
1122 serde_json::from_slice(&fs::read(metadata_path_for_session(&session)).unwrap())
1123 .unwrap();
1124 assert_eq!(record.schema_version, SESSION_METADATA_SCHEMA_VERSION);
1125 assert_eq!(record.session_path.as_deref(), Some(scoped.as_path()));
1126 let summary = manager
1127 .list_metadata_summaries()
1128 .unwrap()
1129 .into_iter()
1130 .find(|summary| summary.session.id() == session.id())
1131 .unwrap();
1132 assert_eq!(summary.session_path.as_deref(), Some(scoped.as_path()));
1133
1134 let legacy = manager.open("legacy-session").unwrap();
1135 let mut legacy_event = SessionEvent::new(
1136 "diagnostic",
1137 legacy.id().to_string(),
1138 temp.path().to_path_buf(),
1139 json!({}),
1140 );
1141 legacy_event.session_path = None;
1142 fs::create_dir_all(legacy.path().parent().unwrap()).unwrap();
1143 fs::write(
1144 legacy.path(),
1145 format!("{}\n", serde_json::to_string(&legacy_event).unwrap()),
1146 )
1147 .unwrap();
1148 crate::sessions::store::secure_test_session_root(legacy.path().parent().unwrap());
1149
1150 let legacy_record = session_metadata_for_listing(&legacy).unwrap();
1151 assert_eq!(legacy_record.session_path, None);
1152 }
1153
1154 #[test]
1155 fn most_recent_uses_valid_sidecar_without_parsing_jsonl() {
1156 let temp = TempDir::new().unwrap();
1157 let manager = SessionManager::new(temp.path().join("sessions"));
1158 let session = manager.open("indexed-session").unwrap();
1159 let event = event_at(&session, temp.path(), 2024, 1, 5);
1160 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
1161 fs::write(
1162 session.path(),
1163 format!("{}\n", serde_json::to_string(&event).unwrap()),
1164 )
1165 .unwrap();
1166 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
1167 let mut record = rebuild_session_metadata_from_jsonl(&session).unwrap();
1168 fs::write(
1169 session.path(),
1170 "not valid jsonl but marker matches sidecar\n",
1171 )
1172 .unwrap();
1173 let marker = jsonl_marker(&session).unwrap();
1174 record.jsonl_len = marker.len;
1175 record.jsonl_modified_ns = marker.modified_ns;
1176 write_session_metadata(&session, &record).unwrap();
1177
1178 let latest = manager.most_recent().unwrap().unwrap();
1179
1180 assert_eq!(latest.id(), session.id());
1181 }
1182
1183 #[test]
1184 fn corrupt_sidecar_falls_back_to_jsonl_and_rebuilds() {
1185 let temp = TempDir::new().unwrap();
1186 let manager = SessionManager::new(temp.path().join("sessions"));
1187 let session = manager.open("fallback-session").unwrap();
1188 let event = event_at(&session, temp.path(), 2024, 2, 1);
1189 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
1190 fs::write(
1191 session.path(),
1192 format!("{}\n", serde_json::to_string(&event).unwrap()),
1193 )
1194 .unwrap();
1195 fs::write(metadata_path_for_session(&session), "{not json").unwrap();
1196 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
1197
1198 let sessions = manager.list().unwrap();
1199 let rebuilt: SessionMetadataRecord =
1200 serde_json::from_slice(&fs::read(metadata_path_for_session(&session)).unwrap())
1201 .unwrap();
1202
1203 assert_eq!(sessions.len(), 1);
1204 assert_eq!(sessions[0].id(), session.id());
1205 assert_eq!(rebuilt.session_id, session.id());
1206 assert_eq!(rebuilt.latest_activity_timestamp, Some(event.timestamp));
1207 }
1208
1209 #[test]
1210 fn list_metadata_report_surfaces_invalid_session_entries_without_failing() {
1211 let temp = TempDir::new().unwrap();
1212 let manager = SessionManager::new(temp.path().join("sessions"));
1213 fs::create_dir_all(temp.path().join("sessions")).unwrap();
1214 crate::sessions::store::secure_test_session_root(temp.path().join("sessions").as_path());
1215 let valid = manager.open("valid-session").unwrap();
1216 append_event_at(&valid, temp.path(), 2024, 2, 1);
1217 fs::write(temp.path().join("sessions/bad.name.jsonl"), "{}").unwrap();
1218
1219 let report = manager.list_metadata_report().unwrap();
1220
1221 assert_eq!(report.summaries.len(), 1);
1222 assert_eq!(report.summaries[0].session.id(), "valid-session");
1223 assert_eq!(report.diagnostics.len(), 1);
1224 assert!(report.diagnostics[0].message.contains("invalid session"));
1225 }
1226
1227 #[test]
1228 fn append_without_metadata_writes_incomplete_sidecar_without_jsonl_rebuild() {
1229 let temp = TempDir::new().unwrap();
1230 let manager = SessionManager::new(temp.path().join("sessions"));
1231 let session = manager.open("missing-sidecar").unwrap();
1232 let old_title = SessionEvent::new_kind(
1233 SessionEventKind::SessionTitle,
1234 session.id().to_string(),
1235 temp.path().to_path_buf(),
1236 json!({"title":"Old Title", "provider":"p", "model":"m"}),
1237 );
1238 fs::create_dir_all(session.path().parent().unwrap()).unwrap();
1239 fs::write(
1240 session.path(),
1241 format!("{}\n", serde_json::to_string(&old_title).unwrap()),
1242 )
1243 .unwrap();
1244 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
1245
1246 session
1247 .append(&SessionEvent::new(
1248 "diagnostic",
1249 session.id().to_string(),
1250 temp.path().to_path_buf(),
1251 json!({"message":"new append"}),
1252 ))
1253 .unwrap();
1254
1255 let append_record: SessionMetadataRecord =
1256 serde_json::from_slice(&fs::read(metadata_path_for_session(&session)).unwrap())
1257 .unwrap();
1258 assert!(!append_record.complete);
1259 assert_eq!(append_record.latest_title, None);
1260
1261 let summaries = manager.list_metadata_summaries().unwrap();
1262 let rebuilt: SessionMetadataRecord =
1263 serde_json::from_slice(&fs::read(metadata_path_for_session(&session)).unwrap())
1264 .unwrap();
1265 assert_eq!(summaries[0].latest_title.as_deref(), Some("Old Title"));
1266 assert!(rebuilt.complete);
1267 }
1268
1269 #[test]
1270 fn metadata_rebuild_does_not_publish_stale_complete_under_append() {
1271 let temp = TempDir::new().unwrap();
1272 let manager = SessionManager::new(temp.path().join("sessions"));
1273 for iteration in 0..32 {
1274 let session = manager.open(format!("rebuild-race-{iteration}")).unwrap();
1275 record_session_event(
1276 Some(&session),
1277 temp.path(),
1278 "user_input",
1279 json!({"text":"seed"}),
1280 )
1281 .unwrap();
1282 fs::remove_file(metadata_path_for_session(&session)).unwrap();
1283
1284 let append_session = session.clone();
1285 let cwd = temp.path().to_path_buf();
1286 let handle = std::thread::spawn(move || {
1287 std::thread::sleep(std::time::Duration::from_micros(50 * iteration));
1288 append_session
1289 .append(&SessionEvent::new(
1290 "user_input",
1291 append_session.id().to_string(),
1292 cwd,
1293 json!({"text":"concurrent"}),
1294 ))
1295 .unwrap();
1296 });
1297
1298 let record = session_metadata_for_listing(&session).unwrap();
1299 handle.join().unwrap();
1300
1301 let final_marker = jsonl_marker(&session).unwrap();
1302 let actual_user_inputs = session
1303 .read_events_tolerant()
1304 .unwrap()
1305 .events
1306 .iter()
1307 .filter(|event| event.kind() == Some(SessionEventKind::UserInput))
1308 .count();
1309 if record.complete
1310 && record.jsonl_len == final_marker.len
1311 && record.jsonl_modified_ns == final_marker.modified_ns
1312 {
1313 assert_eq!(record.user_input_count, actual_user_inputs);
1314 }
1315 }
1316 }
1317
1318 #[test]
1319 fn metadata_sidecar_write_failure_warns_but_append_succeeds() {
1320 let temp = TempDir::new().unwrap();
1321 let manager = SessionManager::new(temp.path().join("sessions"));
1322 let session = manager.open("metadata-fails").unwrap();
1323 fs::create_dir_all(metadata_path_for_session(&session)).unwrap();
1324 crate::sessions::store::secure_test_session_root(session.path().parent().unwrap());
1325
1326 session
1327 .append(&SessionEvent::new(
1328 "user_input",
1329 session.id().to_string(),
1330 temp.path().to_path_buf(),
1331 json!({"text":"durable"}),
1332 ))
1333 .unwrap();
1334
1335 let events = session.read_events().unwrap();
1336 assert_eq!(events.len(), 1);
1337 assert_eq!(events[0].payload["text"], "durable");
1338 assert!(metadata_path_for_session(&session).is_dir());
1339 }
1340
1341 #[test]
1342 fn concurrent_session_appends_update_metadata_without_losing_latest() {
1343 let temp = TempDir::new().unwrap();
1344 let manager = SessionManager::new(temp.path().join("sessions"));
1345 let session = manager.create().unwrap();
1346 let mut handles = Vec::new();
1347 for index in 0..16 {
1348 let session = session.clone();
1349 let cwd = temp.path().to_path_buf();
1350 handles.push(std::thread::spawn(move || {
1351 let mut event = SessionEvent::new(
1352 "diagnostic",
1353 session.id().to_string(),
1354 cwd,
1355 json!({"index": index}),
1356 );
1357 event.timestamp = Utc.with_ymd_and_hms(2024, 3, 1, 0, 0, index).unwrap();
1358 session.append(&event).unwrap();
1359 }));
1360 }
1361 for handle in handles {
1362 handle.join().unwrap();
1363 }
1364
1365 let record: SessionMetadataRecord =
1366 serde_json::from_slice(&fs::read(metadata_path_for_session(&session)).unwrap())
1367 .unwrap();
1368 let tolerant = session.read_events_tolerant().unwrap();
1369
1370 assert!(tolerant.diagnostics.is_empty());
1371 assert_eq!(tolerant.events.len(), 16);
1372 assert_eq!(record.latest_activity_timestamp.unwrap().second(), 15);
1373 }
1374
1375 fn append_event_at(session: &Session, cwd: &Path, year: i32, month: u32, day: u32) {
1376 session
1377 .append(&event_at(session, cwd, year, month, day))
1378 .unwrap();
1379 }
1380
1381 fn event_at(session: &Session, cwd: &Path, year: i32, month: u32, day: u32) -> SessionEvent {
1382 let mut event = SessionEvent::new(
1383 "diagnostic",
1384 session.id().to_string(),
1385 cwd.to_path_buf(),
1386 json!({}),
1387 );
1388 event.timestamp = Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap();
1389 event
1390 }
1391}