Skip to main content

mj_controller/import/
native.rs

1use super::*;
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6/// What one scanned native session file parsed into, keyed by the file itself.
7#[derive(Debug, Clone)]
8enum CachedNativeMetadata {
9    /// `claude_native_metadata`, including its "filtered out" verdict.
10    Claude(Option<(String, PathBuf, String)>),
11    /// `codex_session_metadata`, including its "not interactive" verdict.
12    Codex(Option<CodexSessionMetadata>),
13}
14
15#[derive(Debug)]
16struct CachedNativeEntry {
17    modified_at: SystemTime,
18    size_bytes: u64,
19    metadata: CachedNativeMetadata,
20}
21
22#[derive(Debug, Default)]
23struct NativeScanCacheInner {
24    entries: HashMap<PathBuf, CachedNativeEntry>,
25    parsed_files: u64,
26}
27
28/// Remembers what each native session file parsed into, keyed by its path,
29/// modified time and size. The parsers are pure functions of a file's content,
30/// so an unchanged file never has to be opened again. One cache lives for the
31/// process, so reopening the resume dialog reparses only what changed.
32#[derive(Debug, Clone, Default)]
33pub struct NativeScanCache {
34    inner: Arc<Mutex<NativeScanCacheInner>>,
35}
36
37impl NativeScanCache {
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// How many files this cache has actually parsed, for tests and diagnostics.
43    pub fn parsed_files(&self) -> u64 {
44        self.lock().parsed_files
45    }
46
47    fn lock(&self) -> std::sync::MutexGuard<'_, NativeScanCacheInner> {
48        self.inner
49            .lock()
50            .unwrap_or_else(|poisoned| poisoned.into_inner())
51    }
52
53    fn cached(
54        &self,
55        path: &Path,
56        modified_at: SystemTime,
57        size_bytes: u64,
58    ) -> Option<CachedNativeMetadata> {
59        let inner = self.lock();
60        let entry = inner.entries.get(path)?;
61        (entry.modified_at == modified_at && entry.size_bytes == size_bytes)
62            .then(|| entry.metadata.clone())
63    }
64
65    fn store(
66        &self,
67        path: &Path,
68        modified_at: SystemTime,
69        size_bytes: u64,
70        metadata: CachedNativeMetadata,
71    ) {
72        let mut inner = self.lock();
73        inner.parsed_files = inner.parsed_files.saturating_add(1);
74        inner.entries.insert(
75            path.to_owned(),
76            CachedNativeEntry {
77                modified_at,
78                size_bytes,
79                metadata,
80            },
81        );
82    }
83
84    /// Claude metadata for one transcript, parsing only on a cache miss.
85    /// Errors are returned to the caller and never cached.
86    pub(super) fn claude_metadata(
87        &self,
88        path: &Path,
89        modified_at: SystemTime,
90        size_bytes: u64,
91        parse: impl FnOnce() -> Result<Option<(String, PathBuf, String)>>,
92    ) -> Result<Option<(String, PathBuf, String)>> {
93        if let Some(CachedNativeMetadata::Claude(metadata)) =
94            self.cached(path, modified_at, size_bytes)
95        {
96            return Ok(metadata);
97        }
98        let metadata = parse()?;
99        self.store(
100            path,
101            modified_at,
102            size_bytes,
103            CachedNativeMetadata::Claude(metadata.clone()),
104        );
105        Ok(metadata)
106    }
107
108    /// Codex metadata for one rollout, parsing only on a cache miss.
109    pub(super) fn codex_metadata(
110        &self,
111        path: &Path,
112        modified_at: SystemTime,
113        size_bytes: u64,
114        parse: impl FnOnce() -> Result<Option<CodexSessionMetadata>>,
115    ) -> Result<Option<CodexSessionMetadata>> {
116        if let Some(CachedNativeMetadata::Codex(metadata)) =
117            self.cached(path, modified_at, size_bytes)
118        {
119            return Ok(metadata);
120        }
121        let metadata = parse()?;
122        self.store(
123            path,
124            modified_at,
125            size_bytes,
126            CachedNativeMetadata::Codex(metadata.clone()),
127        );
128        Ok(metadata)
129    }
130}
131
132/// One native session located on disk, normalized across harnesses: the id
133/// `session/load` takes and the file or directory its transcript is read from.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct LocatedNativeSession {
136    pub native_session_id: String,
137    pub source_path: PathBuf,
138}
139
140/// One native session as a picker lists it, normalized across harnesses.
141#[derive(Debug, Clone)]
142pub struct NativeSessionListing {
143    pub native_session_id: String,
144    pub title: String,
145    pub modified_at: SystemTime,
146    pub git_branch: String,
147    pub size_bytes: u64,
148    pub cwd: PathBuf,
149    /// Why this session cannot be imported, when it cannot be.
150    pub unavailable_reason: Option<&'static str>,
151    /// Archived inside the harness itself. Only Codex reports this today.
152    pub natively_archived: bool,
153}
154
155/// Locate one native session for any harness.
156pub fn locate_native_session(
157    harness: HarnessKind,
158    home: &Path,
159    selection: &ClaudeSessionSelection,
160) -> Result<LocatedNativeSession> {
161    let (native_session_id, source_path) = match harness {
162        HarnessKind::Muse => {
163            return muse::locate(&mj_checkpoint::native::muse_sessions_root(home)?, selection);
164        }
165        HarnessKind::Codex => {
166            let located = locate_codex_session(home, selection)?;
167            (located.native_session_id, located.jsonl_path)
168        }
169        HarnessKind::Claude => {
170            let located = locate_claude_session(home, selection)?;
171            (located.native_session_id, located.jsonl_path)
172        }
173        HarnessKind::Kimi => {
174            let located = locate_kimi_session(home, selection)?;
175            (located.native_session_id, located.session_path)
176        }
177        HarnessKind::Grok => {
178            let located = locate_grok_session(home, selection)?;
179            (located.native_session_id, located.session_path)
180        }
181    };
182    Ok(LocatedNativeSession {
183        native_session_id,
184        source_path,
185    })
186}
187
188/// Where one native session's transcript lives and when it last changed.
189///
190/// Cheaper than [`NativeSessionListing`]: no git branch and no directory size,
191/// because the search index only needs a stable key and a change token.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct NativeSessionSource {
194    pub native_session_id: String,
195    pub source_path: PathBuf,
196    /// The newest modification time of the session's own transcript files, not
197    /// of the directory that holds them.
198    pub modified_at: SystemTime,
199}
200
201/// List the sessions of one harness home for indexing.
202///
203/// Only the harnesses whose sessions Mjolnir itself has to enumerate are
204/// supported; Codex and Claude Code keep one file per session, which their
205/// own readers walk directly.
206pub fn list_native_session_sources(
207    harness: HarnessKind,
208    home: &Path,
209) -> Result<Vec<NativeSessionSource>> {
210    let sources = match harness {
211        HarnessKind::Kimi => kimi_indexed_candidates(home, &home.join("sessions"))?
212            .into_iter()
213            .map(|candidate| NativeSessionSource {
214                native_session_id: candidate.native_session_id,
215                source_path: candidate.session_path,
216                modified_at: candidate.modified_at,
217            })
218            .collect(),
219        HarnessKind::Grok => grok::grok_candidates(&home.join("sessions"))?
220            .into_iter()
221            .map(|candidate| NativeSessionSource {
222                native_session_id: candidate.native_session_id,
223                source_path: candidate.session_path,
224                modified_at: candidate.modified_at,
225            })
226            .collect(),
227        HarnessKind::Muse => muse::list_sources(&mj_checkpoint::native::muse_sessions_root(home)?)?,
228        other => bail!("{other:?} keeps one session per file; there is nothing to enumerate"),
229    };
230    Ok(sources)
231}
232
233/// The title the harness itself records for one session, read from that
234/// session's own metadata file. `None` when the harness records none, which
235/// leaves the caller to derive a title from the conversation.
236pub fn native_session_title(harness: HarnessKind, source_path: &Path) -> Option<String> {
237    match harness {
238        HarnessKind::Kimi => {
239            kimi_state_listing_metadata(source_path, Path::new("")).map(|(title, _, _)| title)
240        }
241        HarnessKind::Grok => grok::grok_listing_metadata(source_path).0,
242        _ => None,
243    }
244}
245
246/// Project one native session into the canonical transcript, for any harness.
247pub fn read_native_transcript(
248    harness: HarnessKind,
249    source_path: &Path,
250) -> Result<ClaudeTranscript> {
251    match harness {
252        HarnessKind::Muse => muse::read_transcript(source_path),
253        HarnessKind::Codex => read_codex_transcript(source_path),
254        HarnessKind::Claude => read_claude_transcript(source_path),
255        HarnessKind::Kimi => read_kimi_transcript(source_path),
256        HarnessKind::Grok => read_grok_transcript(source_path),
257    }
258}
259
260/// Scan a harness home newest first, reporting after every candidate.
261pub fn scan_native_sessions(
262    harness: HarnessKind,
263    home: &Path,
264    cache: &NativeScanCache,
265    mut report: impl FnMut(SessionScanProgress<NativeSessionListing>),
266) -> Result<()> {
267    let mut forward = |scanned, total, session| {
268        report(SessionScanProgress {
269            scanned,
270            total,
271            session,
272        });
273    };
274    match harness {
275        HarnessKind::Muse => muse::scan(
276            &mj_checkpoint::native::muse_sessions_root(home)?,
277            |progress| {
278                forward(progress.scanned, progress.total, progress.session);
279            },
280        ),
281        HarnessKind::Codex => scan_codex_sessions(home, cache, |progress| {
282            let session = progress.session.map(|session| NativeSessionListing {
283                unavailable_reason: session.history_mode.import_issue(),
284                native_session_id: session.native_session_id,
285                title: session.title,
286                modified_at: session.modified_at,
287                git_branch: session.git_branch,
288                size_bytes: session.size_bytes,
289                cwd: session.cwd,
290                natively_archived: session.natively_archived,
291            });
292            forward(progress.scanned, progress.total, session);
293        }),
294        HarnessKind::Claude => scan_claude_sessions(home, cache, |progress| {
295            let session = progress.session.map(|session| NativeSessionListing {
296                native_session_id: session.native_session_id,
297                title: session.title,
298                modified_at: session.modified_at,
299                git_branch: session.git_branch,
300                size_bytes: session.size_bytes,
301                cwd: session.cwd,
302                unavailable_reason: None,
303                natively_archived: false,
304            });
305            forward(progress.scanned, progress.total, session);
306        }),
307        HarnessKind::Kimi => scan_kimi_sessions(home, |progress| {
308            let session = progress.session.map(|session| NativeSessionListing {
309                native_session_id: session.native_session_id,
310                title: session.title,
311                modified_at: session.modified_at,
312                git_branch: session.git_branch,
313                size_bytes: session.size_bytes,
314                cwd: session.cwd,
315                unavailable_reason: None,
316                natively_archived: false,
317            });
318            forward(progress.scanned, progress.total, session);
319        }),
320        HarnessKind::Grok => scan_grok_sessions(home, |progress| {
321            let session = progress.session.map(|session| NativeSessionListing {
322                native_session_id: session.native_session_id,
323                title: session.title,
324                modified_at: session.modified_at,
325                git_branch: session.git_branch,
326                size_bytes: session.size_bytes,
327                cwd: session.cwd,
328                unavailable_reason: None,
329                natively_archived: false,
330            });
331            forward(progress.scanned, progress.total, session);
332        }),
333    }
334}