Skip to main content

magi_code/sessions/
metadata.rs

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` means the indexed JSONL snapshot was stable; it does not mean the turn succeeded.
87    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
481/// Frontend lists do not rebuild potentially large histories merely to show one page.
482/// Missing or stale sidecars omit the title and use the validated primary's modification time.
483pub(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    // Crash semantics: JSONL is durable before this cache/sidecar update; marker mismatch forces a bounded rebuild after restart.
545    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}