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    IndexEligible(bool),
14}
15
16#[derive(Debug)]
17struct CachedNativeEntry {
18    modified_at: SystemTime,
19    size_bytes: u64,
20    metadata: CachedNativeMetadata,
21}
22
23#[derive(Debug, Default)]
24struct NativeScanCacheInner {
25    entries: HashMap<PathBuf, CachedNativeEntry>,
26    parsed_files: u64,
27}
28
29/// Remembers what each native session file parsed into, keyed by its path,
30/// modified time and size. The parsers are pure functions of a file's content,
31/// so an unchanged file never has to be opened again. One cache lives for the
32/// process, so reopening the resume dialog reparses only what changed.
33#[derive(Debug, Clone, Default)]
34pub struct NativeScanCache {
35    inner: Arc<Mutex<NativeScanCacheInner>>,
36}
37
38impl NativeScanCache {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// How many files this cache has actually parsed, for tests and diagnostics.
44    pub fn parsed_files(&self) -> u64 {
45        self.lock().parsed_files
46    }
47
48    /// Index discovery excludes identified children, preserving parents even
49    /// when their entrypoint or missing working directory prevents importing.
50    pub(crate) fn index_eligible(&self, kind: HarnessKind, path: &Path) -> Result<bool> {
51        if kind == HarnessKind::Claude
52            && path
53                .components()
54                .any(|component| component.as_os_str() == "subagents")
55        {
56            return Ok(false);
57        }
58        let metadata = fs::metadata(path)?;
59        let modified_at = metadata.modified()?;
60        let size_bytes = metadata.len();
61        if let Some(CachedNativeMetadata::IndexEligible(eligible)) =
62            self.cached(path, modified_at, size_bytes)
63        {
64            return Ok(eligible);
65        }
66        let eligible = match kind {
67            HarnessKind::Codex => !codex::codex_session_summary(path)?.subagent,
68            HarnessKind::Claude => !claude::claude_native_summary(path)?.subagent,
69            other => bail!("{other:?} has its own top-level source enumeration"),
70        };
71        self.store(
72            path,
73            modified_at,
74            size_bytes,
75            CachedNativeMetadata::IndexEligible(eligible),
76        );
77        Ok(eligible)
78    }
79
80    fn lock(&self) -> std::sync::MutexGuard<'_, NativeScanCacheInner> {
81        self.inner
82            .lock()
83            .unwrap_or_else(|poisoned| poisoned.into_inner())
84    }
85
86    fn cached(
87        &self,
88        path: &Path,
89        modified_at: SystemTime,
90        size_bytes: u64,
91    ) -> Option<CachedNativeMetadata> {
92        let inner = self.lock();
93        let entry = inner.entries.get(path)?;
94        (entry.modified_at == modified_at && entry.size_bytes == size_bytes)
95            .then(|| entry.metadata.clone())
96    }
97
98    fn store(
99        &self,
100        path: &Path,
101        modified_at: SystemTime,
102        size_bytes: u64,
103        metadata: CachedNativeMetadata,
104    ) {
105        let mut inner = self.lock();
106        inner.parsed_files = inner.parsed_files.saturating_add(1);
107        inner.entries.insert(
108            path.to_owned(),
109            CachedNativeEntry {
110                modified_at,
111                size_bytes,
112                metadata,
113            },
114        );
115    }
116
117    /// Claude metadata for one transcript, parsing only on a cache miss.
118    /// Errors are returned to the caller and never cached.
119    pub(super) fn claude_metadata(
120        &self,
121        path: &Path,
122        modified_at: SystemTime,
123        size_bytes: u64,
124        parse: impl FnOnce() -> Result<Option<(String, PathBuf, String)>>,
125    ) -> Result<Option<(String, PathBuf, String)>> {
126        if let Some(CachedNativeMetadata::Claude(metadata)) =
127            self.cached(path, modified_at, size_bytes)
128        {
129            return Ok(metadata);
130        }
131        let metadata = parse()?;
132        self.store(
133            path,
134            modified_at,
135            size_bytes,
136            CachedNativeMetadata::Claude(metadata.clone()),
137        );
138        Ok(metadata)
139    }
140
141    /// Codex metadata for one rollout, parsing only on a cache miss.
142    pub(super) fn codex_metadata(
143        &self,
144        path: &Path,
145        modified_at: SystemTime,
146        size_bytes: u64,
147        parse: impl FnOnce() -> Result<Option<CodexSessionMetadata>>,
148    ) -> Result<Option<CodexSessionMetadata>> {
149        if let Some(CachedNativeMetadata::Codex(metadata)) =
150            self.cached(path, modified_at, size_bytes)
151        {
152            return Ok(metadata);
153        }
154        let metadata = parse()?;
155        self.store(
156            path,
157            modified_at,
158            size_bytes,
159            CachedNativeMetadata::Codex(metadata.clone()),
160        );
161        Ok(metadata)
162    }
163}
164
165/// One native session located on disk, normalized across harnesses: the id
166/// `session/load` takes and the file or directory its transcript is read from.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct LocatedNativeSession {
169    pub native_session_id: String,
170    pub source_path: PathBuf,
171}
172
173/// One native session as a picker lists it, normalized across harnesses.
174#[derive(Debug, Clone)]
175pub struct NativeSessionListing {
176    pub native_session_id: String,
177    pub title: String,
178    pub modified_at: SystemTime,
179    pub git_branch: String,
180    pub size_bytes: u64,
181    pub cwd: PathBuf,
182    /// Why this session cannot be imported, when it cannot be.
183    pub unavailable_reason: Option<&'static str>,
184    /// Archived inside the harness itself. Only Codex reports this today.
185    pub natively_archived: bool,
186}
187
188/// Locate one native session for any harness.
189pub fn locate_native_session(
190    harness: HarnessKind,
191    home: &Path,
192    selection: &ClaudeSessionSelection,
193) -> Result<LocatedNativeSession> {
194    let (native_session_id, source_path) = match harness {
195        HarnessKind::Muse => {
196            return muse::locate(&mj_checkpoint::native::muse_sessions_root(home)?, selection);
197        }
198        HarnessKind::Codex => {
199            let located = locate_codex_session(home, selection)?;
200            (located.native_session_id, located.jsonl_path)
201        }
202        HarnessKind::Claude => {
203            let located = locate_claude_session(home, selection)?;
204            (located.native_session_id, located.jsonl_path)
205        }
206        HarnessKind::Kimi => {
207            let located = locate_kimi_session(home, selection)?;
208            (located.native_session_id, located.session_path)
209        }
210        HarnessKind::Grok => {
211            let located = locate_grok_session(home, selection)?;
212            (located.native_session_id, located.session_path)
213        }
214        HarnessKind::OpenCode => bail!(
215            "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot import them yet"
216        ),
217    };
218    Ok(LocatedNativeSession {
219        native_session_id,
220        source_path,
221    })
222}
223
224/// The id [`locate_native_session`] takes, read back from the path that
225/// locator produced. `None` when the path has no readable name.
226///
227/// The inverse of the locators above, so every caller that has only a
228/// transcript path — the SessionWiki index stores one per row — names the
229/// session the same way `mj import` does. Each harness keeps its id in a
230/// different part of the path: Claude Code names the file after the session,
231/// Codex appends the thread UUID to a `rollout-` prefix, Kimi and Grok name
232/// the session's own directory, and Muse names the directory above
233/// `session.jsonl`.
234pub fn native_session_id_from_path(harness: HarnessKind, path: &Path) -> Option<String> {
235    match harness {
236        HarnessKind::Claude => path.file_stem()?.to_str().map(str::to_owned),
237        HarnessKind::Codex => codex_rollout_id_from_path(path).map(str::to_owned),
238        HarnessKind::Kimi | HarnessKind::Grok => path.file_name()?.to_str().map(str::to_owned),
239        HarnessKind::Muse => path.parent()?.file_name()?.to_str().map(str::to_owned),
240        // OpenCode sessions live in SQLite rows, not files, so no transcript
241        // path carries their id.
242        HarnessKind::OpenCode => None,
243    }
244}
245
246/// Where one native session's transcript lives and when it last changed.
247///
248/// Cheaper than [`NativeSessionListing`]: no git branch and no directory size,
249/// because the search index only needs a stable key and a change token.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct NativeSessionSource {
252    pub native_session_id: String,
253    pub source_path: PathBuf,
254    /// The newest modification time of the session's own transcript files, not
255    /// of the directory that holds them.
256    pub modified_at: SystemTime,
257}
258
259/// List the sessions of one harness home for indexing.
260///
261/// Only the harnesses whose sessions Mjolnir itself has to enumerate are
262/// supported; Codex and Claude Code keep one file per session, which their
263/// own readers walk directly.
264pub fn list_native_session_sources(
265    harness: HarnessKind,
266    home: &Path,
267) -> Result<Vec<NativeSessionSource>> {
268    let sources = match harness {
269        HarnessKind::Kimi => kimi_indexed_candidates(home, &home.join("sessions"))?
270            .into_iter()
271            .map(|candidate| NativeSessionSource {
272                native_session_id: candidate.native_session_id,
273                source_path: candidate.session_path,
274                modified_at: candidate.modified_at,
275            })
276            .collect(),
277        HarnessKind::Grok => grok::grok_candidates(&home.join("sessions"))?
278            .into_iter()
279            .map(|candidate| NativeSessionSource {
280                native_session_id: candidate.native_session_id,
281                source_path: candidate.session_path,
282                modified_at: candidate.modified_at,
283            })
284            .collect(),
285        HarnessKind::Muse => muse::list_sources(&mj_checkpoint::native::muse_sessions_root(home)?)?,
286        HarnessKind::OpenCode => bail!(
287            "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot enumerate them yet"
288        ),
289        other => bail!("{other:?} keeps one session per file; there is nothing to enumerate"),
290    };
291    Ok(sources)
292}
293
294/// The title the harness itself records for one session, read from that
295/// session's own metadata file. `None` when the harness records none, which
296/// leaves the caller to derive a title from the conversation.
297pub fn native_session_title(harness: HarnessKind, source_path: &Path) -> Option<String> {
298    match harness {
299        HarnessKind::Kimi => {
300            kimi_state_listing_metadata(source_path, Path::new("")).map(|(title, _, _)| title)
301        }
302        HarnessKind::Grok => grok::grok_listing_metadata(source_path).0,
303        _ => None,
304    }
305}
306
307/// Project one native session into the canonical transcript, for any harness.
308pub fn read_native_transcript(
309    harness: HarnessKind,
310    source_path: &Path,
311) -> Result<ClaudeTranscript> {
312    match harness {
313        HarnessKind::Muse => muse::read_transcript(source_path),
314        HarnessKind::Codex => read_codex_transcript(source_path),
315        HarnessKind::Claude => read_claude_transcript(source_path),
316        HarnessKind::Kimi => read_kimi_transcript(source_path),
317        HarnessKind::Grok => read_grok_transcript(source_path),
318        HarnessKind::OpenCode => bail!(
319            "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot import them yet"
320        ),
321    }
322}
323
324/// Scan a harness home newest first, reporting after every candidate.
325pub fn scan_native_sessions(
326    harness: HarnessKind,
327    home: &Path,
328    cache: &NativeScanCache,
329    mut report: impl FnMut(SessionScanProgress<NativeSessionListing>),
330) -> Result<()> {
331    let mut forward = |scanned, total, session| {
332        report(SessionScanProgress {
333            scanned,
334            total,
335            session,
336        });
337    };
338    match harness {
339        HarnessKind::Muse => muse::scan(
340            &mj_checkpoint::native::muse_sessions_root(home)?,
341            |progress| {
342                forward(progress.scanned, progress.total, progress.session);
343            },
344        ),
345        HarnessKind::Codex => scan_codex_sessions(home, cache, |progress| {
346            let session = progress.session.map(|session| NativeSessionListing {
347                unavailable_reason: session.history_mode.import_issue(),
348                native_session_id: session.native_session_id,
349                title: session.title,
350                modified_at: session.modified_at,
351                git_branch: session.git_branch,
352                size_bytes: session.size_bytes,
353                cwd: session.cwd,
354                natively_archived: session.natively_archived,
355            });
356            forward(progress.scanned, progress.total, session);
357        }),
358        HarnessKind::Claude => scan_claude_sessions(home, cache, |progress| {
359            let session = progress.session.map(|session| NativeSessionListing {
360                native_session_id: session.native_session_id,
361                title: session.title,
362                modified_at: session.modified_at,
363                git_branch: session.git_branch,
364                size_bytes: session.size_bytes,
365                cwd: session.cwd,
366                unavailable_reason: None,
367                natively_archived: false,
368            });
369            forward(progress.scanned, progress.total, session);
370        }),
371        HarnessKind::Kimi => scan_kimi_sessions(home, |progress| {
372            let session = progress.session.map(|session| NativeSessionListing {
373                native_session_id: session.native_session_id,
374                title: session.title,
375                modified_at: session.modified_at,
376                git_branch: session.git_branch,
377                size_bytes: session.size_bytes,
378                cwd: session.cwd,
379                unavailable_reason: None,
380                natively_archived: false,
381            });
382            forward(progress.scanned, progress.total, session);
383        }),
384        HarnessKind::Grok => scan_grok_sessions(home, |progress| {
385            let session = progress.session.map(|session| NativeSessionListing {
386                native_session_id: session.native_session_id,
387                title: session.title,
388                modified_at: session.modified_at,
389                git_branch: session.git_branch,
390                size_bytes: session.size_bytes,
391                cwd: session.cwd,
392                unavailable_reason: None,
393                natively_archived: false,
394            });
395            forward(progress.scanned, progress.total, session);
396        }),
397        HarnessKind::OpenCode => bail!(
398            "OpenCode keeps its sessions in a SQLite database; Mjolnir cannot enumerate them yet"
399        ),
400    }
401}