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