Skip to main content

mj_controller/
sessionwiki.rs

1//! Publishing Mjolnir's own checkpointed sessions into the user's SessionWiki
2//! index, and the daemon-side job that keeps that index current.
3//!
4//! SessionWiki keeps one searchable index of AI coding sessions across every
5//! tool a user runs. Mjolnir links it as a library and registers
6//! [`MjolnirAdapter`] beside SessionWiki's built-in adapters, so a Mjolnir
7//! session is searchable next to a Claude Code or Codex one. The adapter is a
8//! "shared store" adapter: checkpoints are not one-file-per-session in a shape
9//! SessionWiki can parse, so the indexer enumerates sessions by key and asks
10//! this adapter to parse the ones whose checkpoint changed.
11
12mod harness_adapters;
13pub(crate) mod history;
14mod provenance;
15pub mod tags;
16mod top_level;
17mod transcript_grep;
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context, Result};
26use chrono::{DateTime, Utc};
27
28use mj_client::daemon::{
29    SessionTextMatch, SessionTextMatchKind, WikiHitBlock, WikiHitTranscript, WikiIndexState,
30    WikiRow, WikiSessionInfo, WikiSessionStatus, WikiStatus,
31};
32use mj_core::config::{Config, HarnessKind};
33use mj_core::state::{SessionRecord, State};
34use sessionwiki::adapters::{Adapter, Discovered, Store};
35use sessionwiki::model::{Message, Role, Session};
36
37use crate::controller::Controller;
38use crate::controller::checkpoint::managed_checkpoint_archive_name;
39use harness_adapters::HarnessAdapter;
40
41/// The tool name every Mjolnir instance publishes under. One name means one
42/// search partition; reconciliation is scoped per instance instead (see
43/// [`Adapter::reconcile_scope`]).
44const TOOL: &str = "mjolnir";
45const CONVERSATION_ROLES: &[Role] = &[Role::User, Role::Assistant];
46
47/// The low token slot identifies the `Session` projection format. Raising it
48/// makes rows indexed by older parse rules stale exactly once.
49const SESSION_PARSE_FORMAT_VERSION: i64 = 1;
50const CHANGE_TOKEN_SLOT_BITS: u32 = 10;
51
52fn session_change_token(token: i64) -> i64 {
53    token
54        .saturating_mul(1_i64 << (CHANGE_TOKEN_SLOT_BITS * 2))
55        .saturating_add(
56            i64::from(mj_transcript::summary::SUMMARY_VERSION)
57                .saturating_mul(1_i64 << CHANGE_TOKEN_SLOT_BITS),
58        )
59        .saturating_add(SESSION_PARSE_FORMAT_VERSION)
60}
61
62/// One checkpoint archive on disk, reduced to what indexing needs.
63struct ArchiveFile {
64    path: PathBuf,
65    frontier: u64,
66    /// Modification time in epoch seconds, SessionWiki's change token.
67    token: i64,
68}
69
70/// What indexing needs from controller state: which sessions exist, which of
71/// them are sub-agent children, and which are still running with their
72/// conversation in the daemon's own database rather than in a checkpoint.
73#[derive(Default)]
74struct Sessions {
75    records: mj_core::snapshot_map::SnapshotMap<String, SessionRecord>,
76    ownership: top_level::Snapshot,
77    /// Effective agent working directory derived through `State::checkout`.
78    /// Errors are retained so a malformed bundle cannot silently index with
79    /// an empty project.
80    project_directories: BTreeMap<String, std::result::Result<Option<PathBuf>, String>>,
81    /// Session id to change token, for sessions indexed from the projection.
82    live: BTreeMap<String, i64>,
83}
84
85impl Sessions {
86    fn of(state: &State, config: Option<&Config>) -> Self {
87        Self {
88            records: state.sessions.clone(),
89            ownership: top_level::Snapshot::from_state(state),
90            project_directories: project_directories_of(state, config),
91            live: live_tokens(state),
92        }
93    }
94}
95
96fn project_directories_of(
97    state: &State,
98    config: Option<&Config>,
99) -> BTreeMap<String, std::result::Result<Option<PathBuf>, String>> {
100    state
101        .sessions
102        .iter()
103        .map(|(session_id, record)| {
104            (
105                session_id.clone(),
106                indexed_project_directory(state, config, session_id, record)
107                    .map_err(|error| format!("{error:#}")),
108            )
109        })
110        .collect()
111}
112
113/// The project path SessionWiki should search for this session: the attached
114/// or managed raw checkout, or the primary repository in a managed workspace.
115fn indexed_project_directory(
116    state: &State,
117    config: Option<&Config>,
118    session_id: &str,
119    record: &SessionRecord,
120) -> Result<Option<PathBuf>> {
121    match state.checkout(session_id)?.effective() {
122        mj_core::state::Checkout::Attached { path } => Ok(Some(path.to_path_buf())),
123        mj_core::state::Checkout::ManagedWorktree {
124            project_directory, ..
125        } => Ok(project_directory.map(Path::to_path_buf)),
126        mj_core::state::Checkout::ManagedWorkspace => {
127            let Some(config) = config else {
128                return Ok(None);
129            };
130            let Some(bundle) = record.project_bundle(config) else {
131                return Ok(None);
132            };
133            let primary = bundle
134                .repositories
135                .iter()
136                .find(|repository| repository.id == bundle.primary_repo)
137                .with_context(|| {
138                    format!(
139                        "session bundle has no primary repository {:?}",
140                        bundle.primary_repo
141                    )
142                })?;
143            let workspace_root = match record.target.as_ref() {
144                Some(locator) => {
145                    let backend = crate::controller::backend_locator(locator, record, config)
146                        .context("resolve the session target for SessionWiki")?;
147                    crate::controller::workspace_root(
148                        &backend,
149                        record.container_workspace.as_deref(),
150                    )
151                }
152                None => mj_core::targets::container_workspace_root(
153                    record.container_workspace.as_deref(),
154                ),
155            };
156            Ok(Some(PathBuf::from(
157                crate::server_runtime::api::agent_working_directory_at(
158                    &workspace_root,
159                    &primary.destination,
160                ),
161            )))
162        }
163        mj_core::state::Checkout::Borrowed { .. } => {
164            unreachable!("State::checkout resolves borrowed sessions")
165        }
166    }
167}
168
169/// The change token of every session whose transcript is still only in the
170/// daemon's database: its activity watermark in whole seconds.
171///
172/// A stopped session keeps being indexed from its checkpoint, which never
173/// changes again. Everything else is indexed from the projection, so a running
174/// session is findable before it has ever been closed.
175fn live_tokens(state: &State) -> BTreeMap<String, i64> {
176    let activity = match crate::database::load_transcribed_session_activity() {
177        Ok(activity) => activity,
178        Err(error) => {
179            tracing::warn!(%error, "could not read session activity for SessionWiki");
180            return BTreeMap::new();
181        }
182    };
183    state
184        .sessions
185        .iter()
186        .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
187        .filter_map(|(session_id, _)| {
188            let watermark = activity.get(session_id)?;
189            Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
190        })
191        .collect()
192}
193
194/// Mjolnir's sessions, as SessionWiki sees them.
195pub struct MjolnirAdapter {
196    sessions_dir: PathBuf,
197    sessions: std::sync::Mutex<Sessions>,
198    /// Re-read controller state when the indexer reaches this adapter.
199    reload: bool,
200}
201
202impl MjolnirAdapter {
203    /// A fixed view of the given state, which is what a caller with a state in
204    /// hand wants.
205    pub fn from_state(state: &State) -> Self {
206        Self {
207            sessions_dir: mj_core::config::sessions_dir(),
208            sessions: std::sync::Mutex::new(Sessions::of(state, None)),
209            reload: false,
210        }
211    }
212
213    /// A fixed view that can resolve bundle-backed session directories from
214    /// the configuration snapshot paired with the state.
215    pub(crate) fn from_state_with_config(state: &State, config: &Config) -> Self {
216        Self {
217            sessions_dir: mj_core::config::sessions_dir(),
218            sessions: std::sync::Mutex::new(Sessions::of(state, Some(config))),
219            reload: false,
220        }
221    }
222
223    /// The same, but re-reading controller state when the indexer reaches this
224    /// adapter.
225    ///
226    /// One sync pass walks every other tool's store first, which can take
227    /// minutes on a large corpus. Without the reload, sessions that closed
228    /// during that walk would be indexed with no record: no project, no start
229    /// time, and the title guessed from the first prompt. Their checkpoints do
230    /// not change afterwards, so nothing would ever correct them.
231    pub fn reloading(state: &State) -> Self {
232        Self {
233            reload: true,
234            ..Self::from_state(state)
235        }
236    }
237
238    /// Mjolnir's own metadata for every session this adapter knows about, to
239    /// be stored in the index beside the transcripts.
240    ///
241    /// Read from the adapter's own snapshot rather than from the controller
242    /// state the sync loaded, because [`MjolnirAdapter::reloading`] replaces
243    /// that snapshot when the indexer reaches this adapter. A session that
244    /// closed during a long first pass is indexed from the reloaded state, so
245    /// its metadata has to come from the same state that produced its row.
246    pub fn indexed_tags(&self) -> BTreeMap<String, tags::MjTags> {
247        let sessions = self
248            .sessions
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner);
251        sessions
252            .records
253            .iter()
254            .filter(|(id, _)| !sessions.ownership.owns_session(id))
255            .map(|(session_id, record)| {
256                (
257                    session_id.clone(),
258                    tags::MjTags {
259                        target: Some(record.target_template_id.clone()).filter(|id| !id.is_empty()),
260                        profile: Some(record.last_profile.clone()).filter(|id| !id.is_empty()),
261                        harness: Some(record.harness_kind.id().to_owned()),
262                    },
263                )
264            })
265            .collect()
266    }
267
268    fn reload(&self) {
269        if !self.reload {
270            return;
271        }
272        match Controller::load() {
273            Ok(controller) => {
274                *self
275                    .sessions
276                    .lock()
277                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
278                    Sessions::of(&controller.state, Some(&controller.config))
279            }
280            Err(error) => {
281                tracing::warn!(%error, "could not refresh session records for SessionWiki")
282            }
283        }
284    }
285
286    /// The conversation of a stopped session, read from its newest checkpoint,
287    /// with the title the checkpoint recorded.
288    fn checkpointed_transcript(&self, session_id: &str) -> Result<IndexedTranscript> {
289        let (newest, _) = self.newest_archives();
290        let archive = newest
291            .get(session_id)
292            .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
293        let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
294            .with_context(|| format!("read checkpoint {}", archive.path.display()))?
295            .canonical_session()
296            .with_context(|| format!("read the transcript of session {session_id}"))?;
297        let mut evidence = provenance::Evidence::default();
298        for item in &snapshot.transcript {
299            if let mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } = &item.body {
300                evidence.observe(call, item.created_at_ms);
301            }
302        }
303        let messages = summary_messages(
304            mj_transcript::summary::TranscriptSummary::from_snapshot_keeping(&snapshot, usize::MAX),
305        );
306        Ok(IndexedTranscript {
307            messages,
308            title: snapshot.session.session_title.clone(),
309            evidence,
310        })
311    }
312
313    /// The conversation of a session that has not stopped, read from the
314    /// daemon's own projection. It is the same conversation the checkpoint
315    /// would hold, minus whatever has not happened yet.
316    fn projected_transcript(&self, session_id: &str) -> Result<IndexedTranscript> {
317        let projection = crate::database::load_materialized_session(session_id)
318            .with_context(|| format!("read the stored transcript of session {session_id}"))?
319            .with_context(|| format!("no stored transcript for session {session_id}"))?;
320        let mut evidence = provenance::Evidence::default();
321        for item in &projection.transcript {
322            if let mj_core::state::TranscriptBody::Tool { call, .. } = &item.body {
323                evidence.observe(call, item.created_at_ms);
324            }
325        }
326        Ok(IndexedTranscript {
327            messages: projected_messages(&projection),
328            title: projection.session_title.clone(),
329            evidence,
330        })
331    }
332
333    /// The stable key for one session: its checkpoint directory and id. The
334    /// directory is per instance, which is what scopes reconciliation.
335    fn key_for(&self, session_id: &str) -> String {
336        format!("{}/{session_id}", self.sessions_dir.display())
337    }
338
339    /// The newest checkpoint of every session in the directory, by session id.
340    ///
341    /// `had_error` is true when the directory exists but could not be read in
342    /// full; the indexer then skips deletion reconciliation rather than
343    /// archiving every Mjolnir session off a partial listing.
344    fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
345        let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
346        let mut had_error = false;
347        let entries = match std::fs::read_dir(&self.sessions_dir) {
348            Ok(entries) => entries,
349            Err(error) => {
350                if self.sessions_dir.exists() {
351                    tracing::debug!(
352                        directory = %self.sessions_dir.display(),
353                        %error,
354                        "could not list the checkpoint directory for SessionWiki"
355                    );
356                    had_error = true;
357                }
358                return (newest, had_error);
359            }
360        };
361        for entry in entries {
362            let Ok(entry) = entry else {
363                had_error = true;
364                continue;
365            };
366            let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
367            else {
368                continue;
369            };
370            let token = entry
371                .metadata()
372                .ok()
373                .and_then(|metadata| metadata.modified().ok())
374                .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
375                .map(|age| age.as_secs() as i64)
376                .unwrap_or(0);
377            let candidate = ArchiveFile {
378                path: entry.path(),
379                frontier,
380                token,
381            };
382            match newest.get(&session_id) {
383                Some(existing) if existing.frontier >= candidate.frontier => {}
384                _ => {
385                    newest.insert(session_id, candidate);
386                }
387            }
388        }
389        (newest, had_error)
390    }
391}
392
393struct IndexedTranscript {
394    messages: Vec<Message>,
395    title: Option<String>,
396    evidence: provenance::Evidence,
397}
398
399/// The session a checkpoint file name belongs to, with its generation.
400///
401/// Managed checkpoints carry a frontier and a nonce; an imported archive is
402/// named for its session alone and counts as generation zero.
403fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
404    if let Some(parsed) = managed_checkpoint_archive_name(name) {
405        return Some((parsed.session_id, parsed.frontier));
406    }
407    let stem = name
408        .to_str()
409        .and_then(|name| name.strip_suffix(".hel.zip"))?;
410    mj_core::config::validate_id("session", stem)
411        .is_ok()
412        .then(|| (stem.to_owned(), 0))
413}
414
415/// A running session's conversation, as SessionWiki stores it.
416fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
417    summary_messages(
418        mj_transcript::summary::TranscriptSummary::from_materialized_keeping(
419            projection,
420            usize::MAX,
421        ),
422    )
423}
424
425/// A transcript summary as SessionWiki messages.
426///
427/// The summary must keep every tool call in full: a call's output line count
428/// and first error line come from its full record.
429fn summary_messages(summary: mj_transcript::summary::TranscriptSummary) -> Vec<Message> {
430    use mj_transcript::summary::SummaryRole;
431    let mut messages = Vec::with_capacity(summary.entries.len());
432    for entry in summary.entries {
433        let role = match entry.role {
434            SummaryRole::User => Role::User,
435            SummaryRole::Assistant => Role::Assistant,
436            SummaryRole::Tool => {
437                if let Some(tool) = &entry.tool {
438                    messages.extend(tool_messages(tool, entry.created_at_ms));
439                    continue;
440                }
441                Role::Tool
442            }
443            // SessionWiki has no external-message role. Preserve sender
444            // provenance as received context in its tool channel.
445            SummaryRole::Message => Role::Tool,
446            SummaryRole::Plan => continue,
447        };
448        messages.extend(message(role, entry.body(), entry.created_at_ms));
449    }
450    messages
451}
452
453/// One tool call as SessionWiki's call and result parts, which its sync folds
454/// into a single summary line such as `→ execute(cargo test) ⇒ ok · 12 lines`.
455/// The result carries the output only so SessionWiki can count its lines and
456/// quote the first line of a failure; the index keeps neither in full.
457fn tool_messages(tool: &serde_json::Value, created_at_ms: i64) -> Vec<Message> {
458    use sessionwiki::model::{ToolCall, ToolResult};
459    let ts = DateTime::from_timestamp_millis(created_at_ms);
460    let text = |key: &str| tool.get(key).and_then(serde_json::Value::as_str);
461    let id = text("toolCallId").map(str::to_owned);
462    // The title names the file or command; the presentation summary is often
463    // a single word such as "Edit".
464    let description = tool
465        .pointer("/call/title")
466        .and_then(serde_json::Value::as_str)
467        .filter(|title| !title.trim().is_empty())
468        .or_else(|| text("name"))
469        .unwrap_or("tool");
470    let mut messages = vec![Message::tool_call(
471        ts,
472        ToolCall {
473            id: id.clone(),
474            name: text("kind").unwrap_or("other").to_owned(),
475            args: serde_json::json!({ "description": description }),
476        },
477    )];
478    let failed = match text("status") {
479        Some("completed") => false,
480        Some("failed") => true,
481        _ => return messages,
482    };
483    let is_error = failed
484        || tool.get("signal").is_some()
485        || tool
486            .get("exit_code")
487            .and_then(serde_json::Value::as_i64)
488            .is_some_and(|code| code != 0);
489    messages.push(Message::tool_result(
490        ts,
491        ToolResult {
492            call_id: id,
493            text: tool_output(tool),
494            is_error,
495        },
496    ));
497    messages
498}
499
500/// The text a finished tool call produced, as Mjolnir's transcript shows it.
501fn tool_output(tool: &serde_json::Value) -> String {
502    let Some(call) = tool.get("call").and_then(|call| {
503        serde_json::from_value::<agent_client_protocol::schema::v1::ToolCall>(call.clone()).ok()
504    }) else {
505        return String::new();
506    };
507    let terminals: Vec<mj_core::transcript::TerminalOutputRecord> = tool
508        .get("terminals")
509        .and_then(serde_json::Value::as_object)
510        .into_iter()
511        .flatten()
512        .filter_map(|(id, record)| {
513            let mut record = record.clone();
514            record
515                .as_object_mut()?
516                .insert("terminal_id".into(), id.clone().into());
517            serde_json::from_value(record).ok()
518        })
519        .collect();
520    mj_transcript::transcript::tool_content_details(
521        &call.content,
522        &terminals,
523        call.raw_output.as_ref(),
524    )
525    .join("\n")
526}
527
528/// One indexed message, or nothing when the item carried no text.
529fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
530    let text = text.trim().to_owned();
531    (!text.is_empty()).then(|| Message {
532        role,
533        text,
534        ts: DateTime::from_timestamp_millis(created_at_ms),
535        tool: None,
536    })
537}
538
539fn parse_time(value: &str) -> Option<DateTime<Utc>> {
540    DateTime::parse_from_rfc3339(value)
541        .ok()
542        .map(|time| time.with_timezone(&Utc))
543}
544
545impl Adapter for MjolnirAdapter {
546    fn name(&self) -> &'static str {
547        TOOL
548    }
549
550    fn root(&self) -> Option<PathBuf> {
551        Some(self.sessions_dir.clone())
552    }
553
554    /// Unused: this is a shared-store adapter, so the indexer enumerates
555    /// sessions through [`Adapter::store`] instead of walking files.
556    fn discover(&self) -> Discovered {
557        Discovered {
558            files: Vec::new(),
559            had_error: false,
560        }
561    }
562
563    fn parse(&self, _path: &Path) -> Result<Session> {
564        anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
565    }
566
567    fn store(&self) -> Option<Store> {
568        self.reload();
569        let (newest, had_error) = self.newest_archives();
570        let mut files = Vec::with_capacity(newest.len());
571        let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
572        let sessions = self
573            .sessions
574            .lock()
575            .unwrap_or_else(std::sync::PoisonError::into_inner);
576        for (session_id, archive) in newest {
577            if sessions.ownership.owns_session(&session_id) {
578                continue;
579            }
580            tokens.insert(session_id, archive.token);
581            files.push(archive.path);
582        }
583        // A session that is still running is indexed from the projection, and
584        // its own token replaces any checkpoint token it has: the conversation
585        // has moved on since that checkpoint was written. Listing it also
586        // keeps reconciliation from archiving a running session.
587        tokens.extend(
588            sessions
589                .live
590                .iter()
591                .filter(|(id, _)| !sessions.ownership.owns_session(id))
592                .map(|(id, token)| (id.clone(), *token)),
593        );
594        // A rename changes the record and not the conversation, so the
595        // record's own last update is part of the change token. Without it a
596        // renamed session would keep its old title in the index for as long as
597        // its transcript stood still.
598        for (session_id, token) in tokens.iter_mut() {
599            let updated = sessions
600                .records
601                .get(session_id)
602                .and_then(|record| parse_time(&record.updated_at))
603                .map(|updated| updated.timestamp());
604            if let Some(updated) = updated {
605                *token = (*token).max(updated);
606            }
607        }
608        let keys = tokens
609            .into_iter()
610            .map(|(session_id, token)| (self.key_for(&session_id), session_change_token(token)))
611            .collect();
612        Some(Store {
613            keys,
614            files,
615            had_error,
616        })
617    }
618
619    /// Every Mjolnir instance publishes under one tool name, so this instance
620    /// speaks only for keys under its own checkpoint directory. Without the
621    /// scope, two instances would archive each other's rows on every sync.
622    fn reconcile_scope(&self) -> Option<String> {
623        Some(format!("{}/", self.sessions_dir.display()))
624    }
625
626    fn parse_key(&self, key: &str) -> Result<Session> {
627        let session_id = key.rsplit('/').next().unwrap_or_default();
628        anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
629        let sessions = self
630            .sessions
631            .lock()
632            .unwrap_or_else(std::sync::PoisonError::into_inner);
633        anyhow::ensure!(
634            !sessions.ownership.owns_session(session_id),
635            "sub-agent sessions are not indexed"
636        );
637        let IndexedTranscript {
638            messages,
639            title: snapshot_title,
640            evidence,
641        } = if sessions.live.contains_key(session_id) {
642            self.projected_transcript(session_id)?
643        } else {
644            self.checkpointed_transcript(session_id)?
645        };
646        let record = sessions.records.get(session_id);
647        let project = match sessions.project_directories.get(session_id) {
648            Some(Ok(Some(directory))) => directory.display().to_string(),
649            Some(Ok(None)) => String::new(),
650            Some(Err(error)) => {
651                anyhow::bail!("resolve the session project directory: {error}")
652            }
653            None => record
654                .and_then(|record| record.checkout().project_directory())
655                .map(|directory| directory.display().to_string())
656                .unwrap_or_default(),
657        };
658
659        let title = record
660            .and_then(|record| record.session_title_override.clone())
661            .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
662            .or_else(|| snapshot_title.clone())
663            .unwrap_or_else(|| {
664                messages
665                    .iter()
666                    .find(|message| message.role == Role::User)
667                    .map(|message| message.text.chars().take(80).collect())
668                    .unwrap_or_default()
669            });
670
671        Ok(Session {
672            id: session_id.to_owned(),
673            tool: TOOL,
674            path: PathBuf::from(key),
675            project,
676            started: record.and_then(|record| parse_time(&record.created_at)),
677            ended: record.and_then(|record| parse_time(&record.updated_at)),
678            title,
679            subagent: sessions.ownership.owns_session(session_id),
680            messages,
681            touched: evidence.paths.into_iter().collect(),
682            edits: evidence.edits,
683        })
684    }
685}
686
687/// The Mjolnir adapter handed to the indexer while the sync keeps its own
688/// handle on it.
689///
690/// The indexer takes `Box<dyn Adapter>` and consumes the list, but the sync has
691/// to ask the same adapter for its final session snapshot once the walk is over
692/// (see [`MjolnirAdapter::indexed_tags`]). Sharing the adapter is the only way
693/// both can hold it.
694struct SharedMjolnirAdapter(Arc<MjolnirAdapter>);
695
696impl Adapter for SharedMjolnirAdapter {
697    fn name(&self) -> &'static str {
698        self.0.name()
699    }
700
701    fn root(&self) -> Option<PathBuf> {
702        self.0.root()
703    }
704
705    fn discover(&self) -> Discovered {
706        self.0.discover()
707    }
708
709    fn parse(&self, path: &Path) -> Result<Session> {
710        self.0.parse(path)
711    }
712
713    fn store(&self) -> Option<Store> {
714        self.0.store()
715    }
716
717    fn parse_key(&self, key: &str) -> Result<Session> {
718        self.0.parse_key(key)
719    }
720
721    fn reconcile_scope(&self) -> Option<String> {
722        self.0.reconcile_scope()
723    }
724}
725
726/// The daemon's SessionWiki sync job.
727///
728/// Triggers coalesce: a request while a sync is running marks a rerun instead
729/// of queueing a second one, so a burst of closing sessions costs one extra
730/// pass. Syncs are single-flight because SessionWiki holds a write transaction
731/// per adapter batch, and two writers only produce a busy error.
732pub struct WikiIndexer {
733    inner: Arc<Indexer>,
734}
735
736#[derive(Default)]
737struct Indexer {
738    /// Held for the whole of one run: this is what makes syncs single-flight.
739    running: tokio::sync::Mutex<()>,
740    notify: tokio::sync::Notify,
741    /// A trigger arrived; the worker has not consumed it yet.
742    requested: AtomicBool,
743    /// At least one waiting trigger asked for a full sync.
744    full_requested: AtomicBool,
745    /// A sync pass is running now. A surface shows this as "topping up", so a
746    /// user knows more results may arrive.
747    in_flight: AtomicBool,
748    last_success: std::sync::Mutex<Option<Success>>,
749    native_scan_cache: crate::import::NativeScanCache,
750}
751
752#[derive(Clone, Copy)]
753struct Success {
754    at: Instant,
755    epoch_seconds: i64,
756}
757
758impl WikiIndexer {
759    /// Start the background sync worker. Without a Tokio runtime (some tests
760    /// build a runtime state without one) the indexer stays inert.
761    pub fn spawn() -> Self {
762        let inner = Arc::new(Indexer {
763            native_scan_cache: crate::import::NativeScanCache::shared(),
764            ..Indexer::default()
765        });
766        if let Ok(handle) = tokio::runtime::Handle::try_current() {
767            let worker = Arc::clone(&inner);
768            handle.spawn(async move { worker.run().await });
769        }
770        Self { inner }
771    }
772
773    /// Ask for a sync. Returns immediately; the work happens in the background.
774    pub fn request_sync(&self, full: bool) {
775        if full {
776            self.inner.full_requested.store(true, Ordering::Release);
777        }
778        self.inner.requested.store(true, Ordering::Release);
779        self.inner.notify.notify_one();
780    }
781
782    /// An indexer with no worker, so a test can see a request that nothing
783    /// takes and no test runs a sync against the real index.
784    #[cfg(test)]
785    pub(crate) fn inert() -> Self {
786        Self {
787            inner: Arc::new(Indexer::default()),
788        }
789    }
790
791    /// Whether a sync has been requested and not yet taken by the worker.
792    #[cfg(test)]
793    pub(crate) fn sync_requested(&self) -> bool {
794        self.inner.requested.load(Ordering::Acquire)
795    }
796
797    /// Run a sync and wait for it, joining a sync already in flight.
798    pub async fn sync_now(&self, full: bool) -> Result<()> {
799        self.inner.sync(full).await
800    }
801
802    /// The state of the index and whether a sync is running, for the surfaces
803    /// that say so while the first build is under way.
804    pub fn status(&self) -> WikiStatus {
805        WikiStatus {
806            state: index_state(),
807            topping_up: self.inner.in_flight.load(Ordering::Acquire)
808                || self.inner.requested.load(Ordering::Acquire),
809        }
810    }
811
812    /// When the last sync succeeded, for callers that trigger on staleness.
813    pub fn last_success(&self) -> Option<Instant> {
814        self.inner
815            .last_success
816            .lock()
817            .unwrap_or_else(std::sync::PoisonError::into_inner)
818            .map(|success| success.at)
819    }
820}
821
822impl Indexer {
823    async fn run(self: Arc<Self>) {
824        loop {
825            self.notify.notified().await;
826            while self.requested.swap(false, Ordering::AcqRel) {
827                let full = self.full_requested.swap(false, Ordering::AcqRel);
828                if let Err(error) = self.sync(full).await {
829                    self.report(&error);
830                    // A failure waits for the next trigger rather than
831                    // retrying straight away: a busy index stays busy for as
832                    // long as the other writer holds it, and a spin would only
833                    // add to the contention.
834                    break;
835                }
836            }
837        }
838    }
839
840    /// Log a failed sync at the level its cause deserves. A busy index is an
841    /// expected collision with another writer, not a fault: mark a rerun and
842    /// say so only in debug output.
843    fn report(&self, error: &anyhow::Error) {
844        if crate::database::is_busy_error(error) {
845            self.requested.store(true, Ordering::Release);
846            tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
847        } else {
848            tracing::warn!(%error, "could not sync sessions into SessionWiki");
849        }
850    }
851
852    async fn sync(&self, full: bool) -> Result<()> {
853        let _guard = self.running.lock().await;
854        let since = if full {
855            None
856        } else {
857            self.last_success
858                .lock()
859                .unwrap_or_else(std::sync::PoisonError::into_inner)
860                // A minute of overlap covers checkpoints written while the
861                // previous run was reading the directory.
862                .map(|success| success.epoch_seconds - 60)
863        };
864        let started = Instant::now();
865        self.in_flight.store(true, Ordering::Release);
866        let cache = self.native_scan_cache.clone();
867        let ran = run_abandonable(move || sync_blocking(since, &cache)).await;
868        self.in_flight.store(false, Ordering::Release);
869        let ran = ran?;
870        if ran {
871            *self
872                .last_success
873                .lock()
874                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
875                at: started,
876                epoch_seconds: Utc::now().timestamp(),
877            });
878        }
879        Ok(())
880    }
881}
882
883/// Run one sync pass on a thread of its own, outside daemon upgrade admission
884/// and outside the runtime's blocking pool.
885///
886/// A pass walks every native session store and can take minutes. It is safe to
887/// stop at any point: SessionWiki writes its index in SQLite transactions,
888/// which roll back when the process exits, and every daemon syncs again when
889/// it starts. So a daemon handoff must not wait for it. Holding admission made
890/// the handoff wait for the whole pass, and the daemon process waits for its
891/// blocking pool when it exits, so a pass there would hold up the exit
892/// instead. On this thread the exiting process abandons the pass.
893async fn run_abandonable<T: Send + 'static>(
894    pass: impl FnOnce() -> Result<T> + Send + 'static,
895) -> Result<T> {
896    let (sender, receiver) = tokio::sync::oneshot::channel();
897    std::thread::Builder::new()
898        .name("sessionwiki-sync".to_owned())
899        .spawn(move || {
900            // The receiver is gone only when the caller was dropped; the
901            // pass has nobody left to report to.
902            let _ = sender.send(pass());
903        })
904        .context("start the SessionWiki sync thread")?;
905    receiver
906        .await
907        .context("the SessionWiki sync thread stopped without an answer")?
908}
909
910/// Open the index for writing, with tool output kept as summaries.
911///
912/// Mjolnir keeps only SessionWiki's one-line tool summaries: full output
913/// would greatly enlarge an index that holds every session on the machine and
914/// is shared with the `sessionwiki` command. The mode is set
915/// on every open rather than once, so an index whose setting is absent stays
916/// on summaries if SessionWiki's default changes. Setting the mode starts its
917/// own transaction, so it must run before any sync does.
918fn open_for_sync() -> Result<rusqlite::Connection> {
919    let connection = sessionwiki::index::open().context("open the SessionWiki index")?;
920    sessionwiki::index::set_tool_output_mode(
921        &connection,
922        sessionwiki::index::ToolOutputMode::Summary,
923    )
924    .context("keep tool output as summaries in the SessionWiki index")?;
925    Ok(connection)
926}
927
928/// One synchronous sync pass. Returns false when this process must not touch
929/// the index, so a refused run never records a success it did not have.
930fn sync_blocking(since: Option<i64>, cache: &crate::import::NativeScanCache) -> Result<bool> {
931    if !index_is_writable() {
932        return Ok(false);
933    }
934    // Isolated tests park a pass here to stand for one that takes minutes.
935    mj_core::test_hooks::reach_test_hook("sessionwiki_sync_pass")?;
936    let controller =
937        Controller::load().context("load controller state for the SessionWiki sync")?;
938    // Mjolnir's own sessions go first: a cold index walks every other tool's
939    // store for many minutes, and a just-closed session should not wait on it.
940    // Cleanup and enumeration use one ownership snapshot. Native discovery
941    // happens afterwards, so it cannot make this snapshot stale before use.
942    let mjolnir = Arc::new(MjolnirAdapter::from_state_with_config(
943        &controller.state,
944        &controller.config,
945    ));
946    let owned: Vec<Box<dyn Adapter>> = vec![Box::new(SharedMjolnirAdapter(Arc::clone(&mjolnir)))];
947    let ownership = mjolnir
948        .sessions
949        .lock()
950        .unwrap_or_else(std::sync::PoisonError::into_inner)
951        .ownership
952        .clone();
953    let mut connection = open_for_sync()?;
954    top_level::prune(&mut connection, &BTreeSet::new(), &ownership)?;
955    sessionwiki::index::sync_with(&mut connection, &owned, since)
956        .context("sync Mjolnir sessions into SessionWiki")?;
957    let (native, excluded) = top_level::prepare(native_adapters(&controller.config), cache);
958    top_level::prune(&mut connection, &excluded, &ownership)?;
959    sessionwiki::index::sync_with(&mut connection, &native, since)
960        .context("sync native sessions into SessionWiki")?;
961    top_level::prune(&mut connection, &excluded, &ownership)?;
962    write_session_tags(&mut connection, &mjolnir.indexed_tags())
963        .context("store Mjolnir's session metadata in the SessionWiki index")?;
964    provenance::backfill(&mut connection, &mjolnir).context("backfill Mjolnir file provenance")?;
965    if since.is_none() {
966        // A full pass has walked every store, so the index is complete enough
967        // for a search to be trusted. The marker is what a later daemon reads
968        // instead of walking the corpus again to find out.
969        record_first_build();
970    }
971    Ok(true)
972}
973
974/// Store each session's target, profile and harness in the index, in one
975/// transaction.
976///
977/// Every session is written on every sync rather than only the changed ones:
978/// the write is a delete and three inserts, which is nothing beside the
979/// transcript indexing in the same pass, and it is what makes a Move or a
980/// profile switch show up without tracking which records changed. It is also
981/// what gives sessions indexed before this existed their metadata, with no
982/// migration and no re-index.
983fn write_session_tags(
984    connection: &mut rusqlite::Connection,
985    session_tags: &BTreeMap<String, tags::MjTags>,
986) -> Result<()> {
987    if session_tags.is_empty() {
988        return Ok(());
989    }
990    let transaction = connection
991        .transaction()
992        .context("open a transaction for the session metadata")?;
993    for (session_id, session) in session_tags {
994        if session.is_empty() {
995            continue;
996        }
997        tags::write(&transaction, session_id, session)?;
998    }
999    transaction
1000        .commit()
1001        .context("commit the session metadata")?;
1002    Ok(())
1003}
1004
1005/// The non-Mjolnir adapters this install indexes.
1006///
1007/// Mjolnir's configured harness profiles decide which harness homes are
1008/// indexed, not the stock `~/.codex` and `~/.claude` locations. A user who
1009/// runs several profile homes expects every session Mjolnir can start to be
1010/// searchable, and a home no profile names is not Mjolnir's to walk. So the
1011/// stock Codex and Claude adapters are dropped and one adapter per enabled
1012/// profile home takes their place; of the rest, only OpenCode is kept, the
1013/// only other harness Mjolnir can start. Every other built-in adapter (aider,
1014/// gemini, cline, ...) is dropped, so a tool Mjolnir cannot run can neither
1015/// trigger a home-directory walk nor add rows Resume cannot act on.
1016///
1017/// Kimi Code, Grok Build and Muse have no SessionWiki adapter at all, so
1018/// Mjolnir supplies one per enabled profile home of its own (see
1019/// [`harness_adapters`]). Without them those sessions would never appear in
1020/// the Resume dialog's search.
1021///
1022/// Each per-home adapter reports a reconcile scope covering only its own root,
1023/// so a sync of one install never archives the rows of another.
1024fn native_adapters(config: &mj_core::config::Config) -> Vec<Box<dyn Adapter>> {
1025    // Two profiles may share one home, and two harnesses may share one home
1026    // path without sharing sessions, so the kind is part of the identity.
1027    let mut seen: BTreeSet<(HarnessKind, &Path)> = BTreeSet::new();
1028    let mut adapters: Vec<Box<dyn Adapter>> = Vec::new();
1029    for (_, profile) in config.enabled_profiles() {
1030        // A second adapter for the same home would only walk it twice.
1031        if !seen.insert((profile.kind, profile.home.as_path())) {
1032            continue;
1033        }
1034        let adapter: Box<dyn Adapter> = match profile.kind {
1035            HarnessKind::Codex => {
1036                Box::new(sessionwiki::adapters::Codex::in_home(profile.home.clone()))
1037            }
1038            HarnessKind::Claude => Box::new(sessionwiki::adapters::ClaudeCode::in_home(
1039                profile.home.clone(),
1040            )),
1041            kind => match HarnessAdapter::in_home(kind, profile.home.clone()) {
1042                Some(adapter) => Box::new(adapter),
1043                None => continue,
1044            },
1045        };
1046        adapters.push(adapter);
1047    }
1048    adapters.extend(sessionwiki::adapters::all().into_iter().filter(|adapter| {
1049        harness_adapters::harness_for_tool(adapter.name()) == Some(HarnessKind::OpenCode)
1050    }));
1051    adapters
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Which index, and whether it may be touched
1056// ---------------------------------------------------------------------------
1057
1058/// Whether this process may open the index at all.
1059///
1060/// Indexing is always on, so a process that never resolved where its index
1061/// belongs must not reach for one: it would walk the user's real session
1062/// stores and write the user's real index. Only Mjolnir's own startup resolves
1063/// it (see `mj_core::config::apply_instance_flag`), so this refuses every unit
1064/// test that builds a daemon runtime directly and every other embedder, unless
1065/// it names an index of its own with `SESSIONWIKI_DATA`.
1066fn index_is_isolated() -> bool {
1067    static SAID: AtomicBool = AtomicBool::new(false);
1068    if mj_core::config::session_index_is_resolved()
1069        || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
1070    {
1071        return true;
1072    }
1073    if !SAID.swap(true, Ordering::AcqRel) {
1074        tracing::debug!(
1075            "this process did not resolve a session index location; SessionWiki is not used"
1076        );
1077    }
1078    false
1079}
1080
1081/// Whether the index on disk was written by a SessionWiki at another schema
1082/// version.
1083///
1084/// SessionWiki's own `open` drops and rebuilds its whole cache when the file's
1085/// `user_version` differs from the version it was built with, which on a large
1086/// corpus costs tens of minutes. Mjolnir will not do that to a user who also
1087/// runs the `sessionwiki` command: it reads the version without SessionWiki and
1088/// stands aside.
1089fn index_version_mismatch() -> bool {
1090    static SAID: AtomicBool = AtomicBool::new(false);
1091    let Ok(path) = sessionwiki::index::db_path() else {
1092        return false;
1093    };
1094    if !path.exists() {
1095        return false;
1096    }
1097    let version = rusqlite::Connection::open_with_flags(
1098        &path,
1099        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
1100    )
1101    .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
1102    let version: i64 = match version {
1103        Ok(version) => version,
1104        Err(error) => {
1105            tracing::debug!(%error, "could not read the SessionWiki index schema version");
1106            return false;
1107        }
1108    };
1109    // Zero is an index SessionWiki has not finished creating; it is not a
1110    // different version.
1111    let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
1112    if mismatch && !SAID.swap(true, Ordering::AcqRel) {
1113        tracing::warn!(
1114            found = version,
1115            expected = sessionwiki::index::SCHEMA_VERSION,
1116            path = %path.display(),
1117            "the SessionWiki index was written by another version;              Mjolnir will not open it, because opening it would rebuild it.              Install the matching sessionwiki command"
1118        );
1119    }
1120    mismatch
1121}
1122
1123fn index_is_writable() -> bool {
1124    index_is_isolated() && !index_version_mismatch()
1125}
1126
1127/// The file recording that one full sync has completed, holding the schema
1128/// version it completed at.
1129fn first_build_marker() -> PathBuf {
1130    mj_core::config::data_dir().join("sessionwiki-built")
1131}
1132
1133fn record_first_build() {
1134    let path = first_build_marker();
1135    let version = sessionwiki::index::SCHEMA_VERSION.to_string();
1136    if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
1137        return;
1138    }
1139    if let Err(error) = std::fs::write(&path, &version) {
1140        tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
1141    }
1142}
1143
1144/// Whether this index has completed a full build at this schema version.
1145fn first_build_is_done() -> bool {
1146    std::fs::read_to_string(first_build_marker())
1147        .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
1148        && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
1149}
1150
1151/// What a surface should say about this index right now.
1152pub fn index_state() -> WikiIndexState {
1153    if !index_is_isolated() {
1154        return WikiIndexState::Indexing;
1155    }
1156    if index_version_mismatch() {
1157        return WikiIndexState::VersionMismatch;
1158    }
1159    if first_build_is_done() {
1160        WikiIndexState::Ready
1161    } else {
1162        WikiIndexState::Indexing
1163    }
1164}
1165
1166// ---------------------------------------------------------------------------
1167// Indexing a session before it is destroyed
1168// ---------------------------------------------------------------------------
1169
1170/// How long a destroy waits for a sync pass before it indexes the session on
1171/// its own. A first build can run for many minutes, and a destroy must not
1172/// wait for it.
1173pub const DESTROY_SYNC_WAIT: Duration = Duration::from_secs(20);
1174
1175/// How often rows the index was too busy to take are offered again, and for
1176/// how long. A first build holds the index while it parses one tool's
1177/// sessions and frees it between tools.
1178const DEFERRED_WRITE_RETRY: Duration = Duration::from_secs(5);
1179const DEFERRED_WRITE_LIMIT: Duration = Duration::from_secs(60 * 60);
1180
1181/// How a session about to be destroyed, with its sub-agents, reached the
1182/// index.
1183#[derive(Debug, Clone, PartialEq, Eq)]
1184pub enum IndexedBeforeDestroy {
1185    /// The index already held every one of them as they are now, or none of
1186    /// them had a conversation to index.
1187    Current,
1188    /// A sync pass that began after the request finished in time.
1189    Synced,
1190    /// The sync pass did not finish in time, so their rows were written on
1191    /// their own.
1192    WrittenDirectly,
1193    /// The index was busy. Their rows were read before the destroy and are
1194    /// written in the background once the index is free.
1195    Deferred,
1196    /// This process may not write the index, and why.
1197    Unavailable(&'static str),
1198    /// Indexing failed, and why. The destroy goes ahead.
1199    Failed(String),
1200}
1201
1202impl WikiIndexer {
1203    /// Put a top-level session into the index while its record and stored
1204    /// conversation still exist. Child cleanup never requires an index copy.
1205    ///
1206    /// Sessions enter the index only on a sync pass, and destroy deletes the
1207    /// record and the conversation, so a session created and destroyed
1208    /// between two passes was never findable (R2-11). This asks for an
1209    /// incremental pass and waits `wait` for it. A pass that does not finish
1210    /// in time, such as a first build, is left running, and the sessions are
1211    /// indexed on their own from the same rows the pass would write.
1212    pub async fn index_before_destroy(
1213        &self,
1214        session_id: &str,
1215        wait: Duration,
1216    ) -> IndexedBeforeDestroy {
1217        if let Some(reason) = unwritable_reason() {
1218            return IndexedBeforeDestroy::Unavailable(reason);
1219        }
1220        let root = session_id.to_owned();
1221        let pending = match tokio::task::spawn_blocking(move || unindexed_session(&root)).await {
1222            Ok(Ok(pending)) => pending,
1223            Ok(Err(error)) => {
1224                return IndexedBeforeDestroy::Failed(format!(
1225                    "could not tell whether the index holds the session: {error:#}"
1226                ));
1227            }
1228            Err(error) => {
1229                return IndexedBeforeDestroy::Failed(format!(
1230                    "checking the index for the session stopped: {error}"
1231                ));
1232            }
1233        };
1234        if pending.is_empty() {
1235            return IndexedBeforeDestroy::Current;
1236        }
1237        let inner = Arc::clone(&self.inner);
1238        index_before_destroy_with(
1239            async move { inner.sync(false).await },
1240            wait,
1241            move || capture_sessions(&pending),
1242            DEFERRED_WRITE_RETRY,
1243        )
1244        .await
1245    }
1246}
1247
1248/// Why this process may not write the index, if it may not.
1249fn unwritable_reason() -> Option<&'static str> {
1250    if !index_is_isolated() {
1251        return Some("this process did not resolve a SessionWiki index of its own");
1252    }
1253    if index_version_mismatch() {
1254        return Some("the SessionWiki index was written by another SessionWiki version");
1255    }
1256    None
1257}
1258
1259/// The bounded wait and its fallback, with the sync pass and the reading of
1260/// the sessions passed in so a test can stand in for either.
1261async fn index_before_destroy_with<S, C>(
1262    sync: S,
1263    wait: Duration,
1264    capture: C,
1265    retry: Duration,
1266) -> IndexedBeforeDestroy
1267where
1268    S: std::future::Future<Output = Result<()>> + Send + 'static,
1269    C: FnOnce() -> Result<Vec<CapturedSession>> + Send + 'static,
1270{
1271    // Spawned rather than awaited here, so a wait that runs out drops only
1272    // the handle and the pass still finishes. Dropping `Indexer::sync`
1273    // part-way would release the single-flight lock while its blocking half
1274    // was still writing.
1275    match tokio::time::timeout(wait, tokio::spawn(sync)).await {
1276        Ok(Ok(Ok(()))) => return IndexedBeforeDestroy::Synced,
1277        Ok(Ok(Err(error))) => tracing::warn!(
1278            error = %format!("{error:#}"),
1279            "the SessionWiki sync before a destroy failed; indexing the session on its own"
1280        ),
1281        Ok(Err(error)) => tracing::warn!(
1282            %error,
1283            "the SessionWiki sync before a destroy stopped; indexing the session on its own"
1284        ),
1285        Err(_) => tracing::info!(
1286            wait_seconds = wait.as_secs_f64(),
1287            "the SessionWiki sync did not finish in time; indexing the session on its own"
1288        ),
1289    }
1290    let captured = match tokio::task::spawn_blocking(capture).await {
1291        Ok(Ok(captured)) => Arc::new(captured),
1292        Ok(Err(error)) => {
1293            return IndexedBeforeDestroy::Failed(format!(
1294                "could not read the session to index it: {error:#}"
1295            ));
1296        }
1297        Err(error) => {
1298            return IndexedBeforeDestroy::Failed(format!(
1299                "reading the session to index it stopped: {error}"
1300            ));
1301        }
1302    };
1303    if captured.is_empty() {
1304        return IndexedBeforeDestroy::Current;
1305    }
1306    let attempt = Arc::clone(&captured);
1307    match tokio::task::spawn_blocking(move || write_captured(&attempt)).await {
1308        Ok(Ok(())) => IndexedBeforeDestroy::WrittenDirectly,
1309        Ok(Err(error)) if crate::database::is_busy_error(&error) => {
1310            write_captured_later(captured, retry);
1311            IndexedBeforeDestroy::Deferred
1312        }
1313        Ok(Err(error)) => IndexedBeforeDestroy::Failed(format!(
1314            "could not write the session into the index: {error:#}"
1315        )),
1316        Err(error) => IndexedBeforeDestroy::Failed(format!(
1317            "writing the session into the index stopped: {error}"
1318        )),
1319    }
1320}
1321
1322/// A top-level session whose current conversation the index does not hold.
1323fn unindexed_session(root: &str) -> Result<Vec<String>> {
1324    let controller =
1325        Controller::load().context("load controller state to index a destroyed session")?;
1326    if !controller.state.sessions.contains_key(root) {
1327        return Ok(Vec::new());
1328    }
1329    unindexed(
1330        &MjolnirAdapter::from_state_with_config(&controller.state, &controller.config),
1331        &[root.to_owned()],
1332    )
1333}
1334
1335/// Those of `session_ids` that have a conversation the index does not hold
1336/// as it is now: no row, an archived row, or a row with an older change
1337/// token than the adapter lists.
1338fn unindexed(adapter: &MjolnirAdapter, session_ids: &[String]) -> Result<Vec<String>> {
1339    let tokens: BTreeMap<String, i64> = adapter
1340        .store()
1341        .map(|store| store.keys.into_iter().collect())
1342        .unwrap_or_default();
1343    // No index yet holds nothing.
1344    let connection = open_readonly().ok();
1345    let mut pending = Vec::new();
1346    for session_id in session_ids {
1347        let key = adapter.key_for(session_id);
1348        // Only a session with a stored or checkpointed conversation is listed.
1349        let Some(&token) = tokens.get(&key) else {
1350            continue;
1351        };
1352        let current = match &connection {
1353            Some(connection) => indexed_token(connection, &key)? == Some(token),
1354            None => false,
1355        };
1356        if !current {
1357            pending.push(session_id.clone());
1358        }
1359    }
1360    Ok(pending)
1361}
1362
1363/// The change token the index holds for a live row. SessionWiki stores a
1364/// shared-store token in the `mtime` column.
1365fn indexed_token(connection: &rusqlite::Connection, key: &str) -> Result<Option<i64>> {
1366    use rusqlite::OptionalExtension;
1367    connection
1368        .query_row(
1369            "SELECT mtime FROM files WHERE path = ?1 AND archived_at IS NULL",
1370            [key],
1371            |row| row.get(0),
1372        )
1373        .optional()
1374        .context("read a session's change token from the SessionWiki index")
1375}
1376
1377/// One session's index row and metadata, read while its record and
1378/// conversation still exist, so they can be written after both are gone.
1379struct CapturedSession {
1380    key: String,
1381    token: i64,
1382    session: Session,
1383    tags: tags::MjTags,
1384}
1385
1386fn capture_sessions(session_ids: &[String]) -> Result<Vec<CapturedSession>> {
1387    let controller =
1388        Controller::load().context("load controller state to index a destroyed session")?;
1389    capture_sessions_from(
1390        &MjolnirAdapter::from_state_with_config(&controller.state, &controller.config),
1391        session_ids,
1392    )
1393}
1394
1395/// The rows a sync pass would write for these sessions, built by the same
1396/// adapter. A session with no conversation to index is left out.
1397fn capture_sessions_from(
1398    adapter: &MjolnirAdapter,
1399    session_ids: &[String],
1400) -> Result<Vec<CapturedSession>> {
1401    let tokens: BTreeMap<String, i64> = adapter
1402        .store()
1403        .map(|store| store.keys.into_iter().collect())
1404        .unwrap_or_default();
1405    let mut session_tags = adapter.indexed_tags();
1406    let mut captured = Vec::new();
1407    for session_id in session_ids {
1408        let key = adapter.key_for(session_id);
1409        let Some(&token) = tokens.get(&key) else {
1410            continue;
1411        };
1412        captured.push(CapturedSession {
1413            session: adapter.parse_key(&key)?,
1414            tags: session_tags.remove(session_id).unwrap_or_default(),
1415            key,
1416            token,
1417        });
1418    }
1419    Ok(captured)
1420}
1421
1422/// Write captured rows through SessionWiki's own indexing, one session at a
1423/// time, and their metadata beside them.
1424fn write_captured(captured: &Arc<Vec<CapturedSession>>) -> Result<()> {
1425    anyhow::ensure!(
1426        index_is_writable(),
1427        "this process may not write the SessionWiki index"
1428    );
1429    let mut connection = open_for_sync()?;
1430    for index in 0..captured.len() {
1431        let adapter: Box<dyn Adapter> = Box::new(CapturedAdapter {
1432            captured: Arc::clone(captured),
1433            index,
1434        });
1435        sessionwiki::index::sync_with(&mut connection, &[adapter], None)
1436            .context("index a session before it is destroyed")?;
1437    }
1438    let session_tags = captured
1439        .iter()
1440        .map(|captured| (captured.session.id.clone(), captured.tags.clone()))
1441        .collect();
1442    write_session_tags(&mut connection, &session_tags)
1443        .context("store Mjolnir's session metadata in the SessionWiki index")
1444}
1445
1446/// Offer rows the index was too busy to take until it takes them, or until
1447/// [`DEFERRED_WRITE_LIMIT`] passes. The rows live only in this task: a
1448/// daemon that stops before the index is free loses them, and the sessions
1449/// logged here are then not found by id.
1450fn write_captured_later(captured: Arc<Vec<CapturedSession>>, retry: Duration) {
1451    let sessions = captured
1452        .iter()
1453        .map(|captured| captured.session.id.clone())
1454        .collect::<Vec<_>>();
1455    tracing::info!(
1456        ?sessions,
1457        "the SessionWiki index is busy; indexing the destroyed sessions once it is free"
1458    );
1459    tokio::spawn(async move {
1460        let started = Instant::now();
1461        loop {
1462            tokio::time::sleep(retry).await;
1463            let attempt = Arc::clone(&captured);
1464            let error = match tokio::task::spawn_blocking(move || write_captured(&attempt)).await {
1465                Ok(Ok(())) => {
1466                    tracing::info!(?sessions, "indexed the destroyed sessions");
1467                    return;
1468                }
1469                Ok(Err(error)) => error,
1470                Err(error) => anyhow::Error::new(error),
1471            };
1472            if !crate::database::is_busy_error(&error) || started.elapsed() >= DEFERRED_WRITE_LIMIT
1473            {
1474                tracing::warn!(
1475                    ?sessions,
1476                    error = %format!("{error:#}"),
1477                    "gave up indexing destroyed sessions in SessionWiki"
1478                );
1479                return;
1480            }
1481        }
1482    });
1483}
1484
1485/// One captured session, offered to SessionWiki as a shared store that
1486/// lists only it.
1487struct CapturedAdapter {
1488    captured: Arc<Vec<CapturedSession>>,
1489    index: usize,
1490}
1491
1492impl CapturedAdapter {
1493    fn captured(&self) -> &CapturedSession {
1494        &self.captured[self.index]
1495    }
1496}
1497
1498impl Adapter for CapturedAdapter {
1499    fn name(&self) -> &'static str {
1500        TOOL
1501    }
1502
1503    fn root(&self) -> Option<PathBuf> {
1504        Path::new(&self.captured().key)
1505            .parent()
1506            .map(Path::to_path_buf)
1507    }
1508
1509    fn discover(&self) -> Discovered {
1510        Discovered {
1511            files: Vec::new(),
1512            had_error: false,
1513        }
1514    }
1515
1516    fn parse(&self, _path: &Path) -> Result<Session> {
1517        anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
1518    }
1519
1520    fn store(&self) -> Option<Store> {
1521        let captured = self.captured();
1522        Some(Store {
1523            keys: vec![(captured.key.clone(), captured.token)],
1524            files: Vec::new(),
1525            had_error: false,
1526        })
1527    }
1528
1529    fn parse_key(&self, key: &str) -> Result<Session> {
1530        let captured = self.captured();
1531        anyhow::ensure!(key == captured.key, "no captured session for key {key:?}");
1532        Ok(copy_session(&captured.session))
1533    }
1534
1535    /// A prefix no key starts with, since keys hold no NUL. This store lists
1536    /// one session, not every session of this instance, so reconciliation
1537    /// must not archive the rows it does not list.
1538    fn reconcile_scope(&self) -> Option<String> {
1539        Some(format!("{}\0", self.captured().key))
1540    }
1541}
1542
1543/// A copy of an indexed session, for a write that may be retried.
1544/// SessionWiki's model does not implement `Clone`.
1545fn copy_session(session: &Session) -> Session {
1546    Session {
1547        id: session.id.clone(),
1548        tool: session.tool,
1549        path: session.path.clone(),
1550        project: session.project.clone(),
1551        started: session.started,
1552        ended: session.ended,
1553        title: session.title.clone(),
1554        subagent: session.subagent,
1555        messages: session
1556            .messages
1557            .iter()
1558            .map(|message| Message {
1559                role: message.role,
1560                text: message.text.clone(),
1561                ts: message.ts,
1562                tool: message.tool.clone(),
1563            })
1564            .collect(),
1565        touched: session.touched.clone(),
1566        edits: session
1567            .edits
1568            .iter()
1569            .map(|edit| sessionwiki::model::EditEvent {
1570                path: edit.path.clone(),
1571                kind: edit.kind,
1572                snippet: edit.snippet.clone(),
1573                ts: edit.ts,
1574            })
1575            .collect(),
1576    }
1577}
1578
1579// ---------------------------------------------------------------------------
1580// Queries and restore
1581// ---------------------------------------------------------------------------
1582
1583/// The largest page a caller may ask a wiki query for.
1584pub const MAX_WIKI_LIMIT: usize = 200;
1585/// The page size a caller that names none gets.
1586pub const DEFAULT_WIKI_LIMIT: usize = 50;
1587/// SessionWiki's full-text index needs three characters; shorter queries fall
1588/// back to a substring scan.
1589const MIN_FULLTEXT_QUERY: usize = 3;
1590/// How stale the index may be before a query triggers a background sync.
1591pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
1592
1593/// Whether a query should trigger a bounded background sync before it answers.
1594pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
1595    last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
1596}
1597
1598/// One page of the index, newest first or best match first.
1599///
1600/// `live` is the set of session ids this daemon still holds, which is what
1601/// decides whether a Mjolnir row names a session the user can simply resume.
1602/// Only top-level sessions are answered. `include_tool_matches` keeps tool-only
1603/// hits for agent history; the resume list requires conversational matches.
1604/// Runs SQLite work, so callers on the async runtime wrap it in
1605/// `spawn_blocking`.
1606pub fn query_rows(
1607    query: &str,
1608    limit: usize,
1609    live: &BTreeSet<String>,
1610    include_tool_matches: bool,
1611) -> Result<Vec<WikiRow>> {
1612    let limit = limit.clamp(1, MAX_WIKI_LIMIT);
1613    if !index_is_writable() {
1614        // Nothing to answer from: either this process has no index of its own
1615        // or the one on disk is at another version. The status beside the rows
1616        // says which.
1617        return Ok(Vec::new());
1618    }
1619    let connection = open_readonly()?;
1620    let ownership = top_level::current_snapshot()?;
1621    let cache = crate::import::NativeScanCache::shared();
1622    query_rows_from(
1623        &connection,
1624        query,
1625        limit,
1626        live,
1627        include_tool_matches,
1628        &ownership,
1629        &cache,
1630    )
1631}
1632
1633fn query_rows_from(
1634    connection: &rusqlite::Connection,
1635    query: &str,
1636    limit: usize,
1637    live: &BTreeSet<String>,
1638    include_tool_matches: bool,
1639    ownership: &top_level::Snapshot,
1640    cache: &crate::import::NativeScanCache,
1641) -> Result<Vec<WikiRow>> {
1642    let limit = limit.clamp(1, MAX_WIKI_LIMIT);
1643    let query = query.trim();
1644    if query.is_empty() {
1645        let rows = sessionwiki::index::recent(connection, MAX_WIKI_LIMIT, None, None, None, true)
1646            .context("list recent SessionWiki sessions")?;
1647        let mut visible = Vec::with_capacity(limit);
1648        for row in rows {
1649            if visible.len() >= limit {
1650                break;
1651            }
1652            if top_level::is_child(&row, ownership, cache)? {
1653                continue;
1654            }
1655            visible.push(wiki_row(row, None, live));
1656        }
1657        let mut rows = visible;
1658        fill_session_tags(connection, &mut rows)?;
1659        return Ok(rows);
1660    }
1661    // SessionWiki filters conversational roles before its candidate cap and
1662    // session grouping. Native-child filtering still happens here, so
1663    // over-fetch to keep children ranked first from shortening a page.
1664    let search_limit = MAX_WIKI_LIMIT;
1665    let roles = if include_tool_matches {
1666        None
1667    } else {
1668        Some(CONVERSATION_ROLES)
1669    };
1670    let hits =
1671        sessionwiki::index::search_with_roles(connection, query, search_limit, None, None, roles)
1672            .context("search the SessionWiki index")?;
1673    let mut rows = Vec::with_capacity(hits.len().min(limit));
1674    for hit in hits {
1675        if rows.len() >= limit {
1676            break;
1677        }
1678        if top_level::is_child(&hit.row, ownership, cache)? {
1679            continue;
1680        }
1681        rows.push(wiki_row(hit.row, Some(hit.snippet), live));
1682    }
1683    // SessionWiki searches message text alone, so a session known by a title
1684    // or a project that is never said out loud would be unfindable. Those
1685    // matches follow the full-text ones rather than displacing them.
1686    if rows.len() < limit {
1687        let mut found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
1688        for row in named_like(connection, query)? {
1689            if rows.len() >= limit {
1690                break;
1691            }
1692            if found.contains(&row.session_id) || top_level::is_child(&row, ownership, cache)? {
1693                continue;
1694            }
1695            found.insert(row.session_id.clone());
1696            rows.push(wiki_row(row, None, live));
1697        }
1698    }
1699    fill_session_tags(connection, &mut rows)?;
1700    Ok(rows)
1701}
1702
1703/// How many message rows one text search reads before it stops. A term common
1704/// enough to pass this can miss sessions whose only match ranks past it; the
1705/// person narrows the query, as with the resume dialog's own search.
1706const TEXT_SEARCH_MESSAGE_LIMIT: i64 = 20_000;
1707
1708/// The live Mjolnir sessions whose user or agent messages contain `query`,
1709/// ignoring case, and where: a session with a user match reports the user
1710/// match. Tool messages never match. Runs SQLite work, so callers on the async
1711/// runtime wrap it in `spawn_blocking`.
1712pub fn session_text_matches(query: &str, live: &BTreeSet<String>) -> Result<Vec<SessionTextMatch>> {
1713    let query = query.trim();
1714    if query.is_empty() || !index_is_writable() {
1715        return Ok(Vec::new());
1716    }
1717    let connection = open_readonly()?;
1718    let ownership = top_level::current_snapshot()?;
1719    text_matches_in(&connection, query, live, &ownership)
1720}
1721
1722fn text_matches_in(
1723    connection: &rusqlite::Connection,
1724    query: &str,
1725    live: &BTreeSet<String>,
1726    ownership: &top_level::Snapshot,
1727) -> Result<Vec<SessionTextMatch>> {
1728    let mut statement;
1729    let rows = if query.chars().count() < MIN_FULLTEXT_QUERY {
1730        // Too short for the trigram index; scan the newest messages instead.
1731        let pattern = format!(
1732            "%{}%",
1733            sessionwiki::util::nfc(query)
1734                .replace('\\', "\\\\")
1735                .replace('%', "\\%")
1736                .replace('_', "\\_")
1737        );
1738        statement = connection.prepare(
1739            "SELECT f.session_id, f.path, m.role, f.kind
1740             FROM messages m JOIN files f ON f.session_id = m.session_id
1741             WHERE f.tool = ?1 AND m.role IN ('user', 'assistant')
1742               AND m.text LIKE ?2 ESCAPE '\\'
1743             ORDER BY m.id DESC LIMIT ?3",
1744        )?;
1745        statement
1746            .query_map(
1747                rusqlite::params![TOOL, pattern, TEXT_SEARCH_MESSAGE_LIMIT],
1748                |row| {
1749                    Ok((
1750                        row.get::<_, String>(0)?,
1751                        row.get::<_, String>(1)?,
1752                        row.get::<_, String>(2)?,
1753                        row.get::<_, String>(3)?,
1754                    ))
1755                },
1756            )?
1757            .collect::<rusqlite::Result<Vec<_>>>()
1758    } else {
1759        let phrase = format!("\"{}\"", sessionwiki::util::nfc(query).replace('"', "\"\""));
1760        statement = connection.prepare(
1761            "SELECT f.session_id, f.path, m.role, f.kind
1762             FROM (SELECT rowid AS mid FROM msgs WHERE msgs MATCH ?2 LIMIT ?3) x
1763             JOIN messages m ON m.id = x.mid
1764             JOIN files f ON f.session_id = m.session_id
1765             WHERE f.tool = ?1 AND m.role IN ('user', 'assistant')",
1766        )?;
1767        statement
1768            .query_map(
1769                rusqlite::params![TOOL, phrase, TEXT_SEARCH_MESSAGE_LIMIT],
1770                |row| {
1771                    Ok((
1772                        row.get::<_, String>(0)?,
1773                        row.get::<_, String>(1)?,
1774                        row.get::<_, String>(2)?,
1775                        row.get::<_, String>(3)?,
1776                    ))
1777                },
1778            )?
1779            .collect::<rusqlite::Result<Vec<_>>>()
1780    }
1781    .context("search the indexed messages")?;
1782    let mut found = BTreeMap::<String, SessionTextMatchKind>::new();
1783    for (session_id, path, role, kind) in rows {
1784        if !live.contains(&session_id)
1785            || ownership.owns_indexed_child(&session_id, &path, TOOL, &kind)
1786        {
1787            continue;
1788        }
1789        let kind = if role == "user" {
1790            SessionTextMatchKind::User
1791        } else {
1792            SessionTextMatchKind::Agent
1793        };
1794        let entry = found.entry(session_id.clone()).or_insert(kind);
1795        *entry = (*entry).min(kind);
1796    }
1797    Ok(found
1798        .into_iter()
1799        .map(|(session_id, kind)| SessionTextMatch { session_id, kind })
1800        .collect())
1801}
1802
1803/// Fill in the target, profile and harness of every Mjolnir row on this page
1804/// from the index's own tags, in one query.
1805///
1806/// Only Mjolnir writes those tags, so a row from another tool keeps `None` and
1807/// is not even asked about.
1808fn fill_session_tags(connection: &rusqlite::Connection, rows: &mut [WikiRow]) -> Result<()> {
1809    let ids: Vec<&str> = rows
1810        .iter()
1811        .filter(|row| row.tool == TOOL)
1812        .map(|row| row.id.as_str())
1813        .collect();
1814    let found = tags::read(connection, &ids).context("read the indexed session metadata")?;
1815    for row in rows.iter_mut().filter(|row| row.tool == TOOL) {
1816        let Some(session) = found.get(&row.id) else {
1817            continue;
1818        };
1819        row.target = session.target.clone();
1820        row.profile = session.profile.clone();
1821        row.harness = session.harness.clone();
1822    }
1823    Ok(())
1824}
1825
1826/// How far back a title or project match looks. Those columns have no index of
1827/// their own, so this is a scan of the most recent sessions rather than of the
1828/// whole corpus. Fetch only metadata here: the regular recent-session query
1829/// also loads a preview, summary and tags for every row, none of which title
1830/// matching needs.
1831const NAME_SCAN_LIMIT: usize = 2_000;
1832
1833/// Indexed sessions whose title or project contains the query, ignoring case.
1834fn named_like(
1835    connection: &rusqlite::Connection,
1836    query: &str,
1837) -> Result<Vec<sessionwiki::index::SessionRow>> {
1838    let needle = query.to_lowercase();
1839    let sql = format!(
1840        "SELECT session_id, tool, path, project, title, started, msg_count, kind,
1841                archived_at IS NOT NULL
1842         FROM files ORDER BY started DESC LIMIT {NAME_SCAN_LIMIT}"
1843    );
1844    let mut statement = connection
1845        .prepare(&sql)
1846        .context("prepare recent SessionWiki metadata scan")?;
1847    let rows = statement
1848        .query_map([], |row| {
1849            Ok(sessionwiki::index::SessionRow {
1850                last_active: None,
1851                session_id: row.get(0)?,
1852                tool: row.get(1)?,
1853                path: row.get(2)?,
1854                project: row.get(3)?,
1855                title: row.get(4)?,
1856                started: row.get(5)?,
1857                msg_count: row.get(6)?,
1858                kind: row.get(7)?,
1859                preview: None,
1860                summary: None,
1861                tags: None,
1862                archived: row.get(8)?,
1863                account: None,
1864            })
1865        })
1866        .context("list recent SessionWiki metadata")?
1867        .collect::<rusqlite::Result<Vec<_>>>()
1868        .context("read recent SessionWiki metadata")?;
1869    Ok(rows
1870        .into_iter()
1871        .filter(|row| {
1872            row.title.to_lowercase().contains(&needle)
1873                || row.project.to_lowercase().contains(&needle)
1874        })
1875        .collect())
1876}
1877
1878/// The briefing for one indexed session, or `None` when the id names none.
1879pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
1880    if !index_is_writable() {
1881        return Ok(None);
1882    }
1883    let connection = open_readonly()?;
1884    let ownership = top_level::current_snapshot()?;
1885    let cache = crate::import::NativeScanCache::shared();
1886    let Some(row) = visible_row_by_id(&connection, id, &ownership, &cache)? else {
1887        return Ok(None);
1888    };
1889    let session = sessionwiki::index::session_from_index(&connection, &row)
1890        .context("read an indexed session")?;
1891    Ok(Some(sessionwiki::commands::brief_markdown(
1892        &session, max_chars, true,
1893    )))
1894}
1895
1896/// The passages of one indexed session that match `query`, or `None` when the
1897/// id names no indexed session.
1898///
1899/// Every matching message is returned with `context_messages` neighbours on
1900/// each side; overlapping groups are merged and each group's first block says
1901/// how many messages were skipped before it. Each block's text is capped at
1902/// `per_message_chars` characters, keeping the window around its first match.
1903pub fn transcript_hits(
1904    id: &str,
1905    query: &str,
1906    context_messages: usize,
1907    per_message_chars: usize,
1908) -> Result<Option<WikiHitTranscript>> {
1909    if !index_is_writable() {
1910        return Ok(None);
1911    }
1912    let connection = open_readonly()?;
1913    let ownership = top_level::current_snapshot()?;
1914    let cache = crate::import::NativeScanCache::shared();
1915    let Some(row) = visible_row_by_id(&connection, id, &ownership, &cache)? else {
1916        return Ok(None);
1917    };
1918    let session = sessionwiki::index::session_from_index(&connection, &row)
1919        .context("read an indexed session")?;
1920    Ok(Some(hit_transcript(
1921        &session,
1922        query,
1923        context_messages,
1924        per_message_chars,
1925    )))
1926}
1927
1928/// The matching passages of one loaded session, converted to the daemon
1929/// response shape. Pure, so the conversion can be tested without an index.
1930///
1931/// Tool output never anchors a passage: it is machine chatter the reader did
1932/// not write, a hit buried in it would open the preview on a wall of command
1933/// output, and the preview collapses tool runs anyway. Tool messages still
1934/// appear as context around a real match.
1935fn hit_transcript(
1936    session: &Session,
1937    query: &str,
1938    context_messages: usize,
1939    per_message_chars: usize,
1940) -> WikiHitTranscript {
1941    let found = transcript_grep::grep_session(
1942        session,
1943        query,
1944        &transcript_grep::GrepOpts {
1945            context_messages,
1946            chars: per_message_chars,
1947            max_matches: None,
1948            anchor_roles: vec![Role::User, Role::Assistant],
1949        },
1950    );
1951    WikiHitTranscript {
1952        blocks: found
1953            .hits
1954            .into_iter()
1955            .map(|hit| WikiHitBlock {
1956                role: role_name(hit.role).to_owned(),
1957                text: hit.text,
1958                hits: hit.matches,
1959                omitted_before: hit.omitted_before,
1960                truncated: hit.truncated,
1961            })
1962            .collect(),
1963        omitted_after: found.omitted_after,
1964    }
1965}
1966
1967fn role_name(role: Role) -> &'static str {
1968    match role {
1969        Role::User => "user",
1970        Role::Assistant => "assistant",
1971        Role::Tool => "tool",
1972    }
1973}
1974
1975/// What a restore needs from the index: the transcript as a snapshot the
1976/// compaction pipeline accepts, plus the title and project of the session it
1977/// came from.
1978pub struct ArchivedSession {
1979    pub title: String,
1980    /// The project directory the session ran in, when the row names one that
1981    /// still exists.
1982    pub project_directory: Option<PathBuf>,
1983    pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
1984}
1985
1986/// Load one indexed session for restore, or `None` when the id names none.
1987pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
1988    if !index_is_writable() {
1989        return Ok(None);
1990    }
1991    let connection = open_readonly()?;
1992    let ownership = top_level::current_snapshot()?;
1993    let cache = crate::import::NativeScanCache::shared();
1994    let Some(row) = visible_row_by_id(&connection, id, &ownership, &cache)? else {
1995        return Ok(None);
1996    };
1997    let session = sessionwiki::index::session_from_index(&connection, &row)
1998        .context("read an indexed session")?;
1999    let snapshot = snapshot_of(&session)?;
2000    Ok(Some(ArchivedSession {
2001        title: session.title.clone(),
2002        project_directory: project_directory_of(&session.project),
2003        snapshot,
2004    }))
2005}
2006
2007// ---------------------------------------------------------------------------
2008// The archive job
2009// ---------------------------------------------------------------------------
2010
2011/// The stopped sessions that `archive_after_days = older_than_days` has caught,
2012/// children before their parents.
2013///
2014/// A session qualifies when its record is `Stopped`, its last update is at
2015/// least that many days old, and every sub-agent child it still has is being
2016/// archived in the same pass. The child rule is what keeps the pass from
2017/// destroying a session it did not choose: archiving a parent tears its
2018/// children down with it, so a child that is still running, or stopped but not
2019/// yet old enough, holds its parent back until the next pass.
2020///
2021/// Pure over controller state, so the rule can be tested without a daemon.
2022pub fn sessions_ready_to_archive(
2023    sessions: &mj_core::snapshot_map::SnapshotMap<String, SessionRecord>,
2024    subagents: &mj_core::snapshot_map::SnapshotMap<String, mj_core::subagent::SubagentRecord>,
2025    now: DateTime<Utc>,
2026    older_than_days: u32,
2027) -> Vec<String> {
2028    let state = mj_core::state::State {
2029        sessions: sessions.clone(),
2030        subagents: subagents.clone(),
2031        ..mj_core::state::State::default()
2032    };
2033    sessions_ready_to_archive_from_state(&state, now, older_than_days)
2034}
2035
2036pub(crate) fn sessions_ready_to_archive_from_state(
2037    state: &mj_core::state::State,
2038    now: DateTime<Utc>,
2039    older_than_days: u32,
2040) -> Vec<String> {
2041    let sessions = &state.sessions;
2042    let subagents = &state.subagents;
2043    let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
2044    let aged = |session_id: &String| {
2045        sessions.get(session_id).is_some_and(|record| {
2046            let checkout = state.checkout(session_id).ok();
2047            let is_managed_worktree = checkout.as_ref().is_some_and(|checkout| {
2048                matches!(
2049                    checkout.effective(),
2050                    mj_core::state::Checkout::ManagedWorktree { worktree, .. }
2051                        if worktree.kind == mj_core::state::ManagedCheckoutKind::Worktree
2052                )
2053            });
2054            // Preserve the historical child rule: its copied raw path counted
2055            // as a raw checkout, even when the parent owns a managed clone.
2056            let has_raw_path = checkout.as_ref().is_some_and(|checkout| {
2057                checkout.project_directory().is_some()
2058                    && matches!(
2059                        checkout,
2060                        mj_core::state::Checkout::Attached { .. }
2061                            | mj_core::state::Checkout::Borrowed { .. }
2062                    )
2063            });
2064            record.state == mj_core::state::SessionState::Stopped
2065                && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
2066                && (is_managed_worktree
2067                    || has_raw_path
2068                    || record
2069                        .checkpoint
2070                        .as_ref()
2071                        .zip(record.publication.as_ref())
2072                        .is_some_and(|(checkpoint, publication)| {
2073                            publication.checkpoint_sha256 == checkpoint.sha256
2074                                && publication.state == mj_core::state::PublicationState::Published
2075                                && !publication.dirty
2076                                && !publication.stashed
2077                        }))
2078        })
2079    };
2080    let selected: BTreeSet<String> = sessions
2081        .keys()
2082        .filter(|session_id| aged(session_id))
2083        .filter(|session_id| {
2084            subagents
2085                .values()
2086                .filter(|child| &&child.parent_session_id == session_id)
2087                // A child whose record is already gone holds nothing open.
2088                .filter(|child| sessions.contains_key(&child.child_session_id))
2089                .all(|child| aged(&child.child_session_id))
2090        })
2091        .cloned()
2092        .collect();
2093    let mut ordered: Vec<String> = selected.iter().cloned().collect();
2094    ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
2095    ordered
2096}
2097
2098/// How many sub-agent parents a session has above it. Deeper sessions are
2099/// archived first so a parent never tears down a child the pass still has to
2100/// visit.
2101fn ancestor_depth(
2102    session_id: &str,
2103    subagents: &mj_core::snapshot_map::SnapshotMap<String, mj_core::subagent::SubagentRecord>,
2104) -> usize {
2105    let mut depth = 0;
2106    let mut current = session_id;
2107    // Bounded by the map: a cycle cannot outlive one pass over every entry.
2108    while let Some(parent) = subagents
2109        .get(current)
2110        .map(|child| child.parent_session_id.as_str())
2111    {
2112        depth += 1;
2113        if depth > subagents.len() {
2114            break;
2115        }
2116        current = parent;
2117    }
2118    depth
2119}
2120
2121/// How much disk Mjolnir's own copies of sessions use, and how much an
2122/// `archive_after_days` value would free. "Mjolnir's own copy" is the
2123/// checkpoint archive plus the session's image attachments; the conversation
2124/// itself lives in the SessionWiki index and is not counted, because archiving
2125/// keeps it. The type lives in `mj-core` so the terminal UI can name it too.
2126pub use mj_core::state::ArchiveSpacePreview;
2127
2128/// The space every session uses now and, when `older_than_days` is set, the
2129/// space archiving after that many days would reclaim.
2130///
2131/// The reclaim figure uses the archive job's own selection rule but not its
2132/// "is it indexed yet" gate: that gate depends on how far the hourly index
2133/// sync has got, so applying it would make the estimate swing between zero and
2134/// the true value while the first index builds. This answers what the policy
2135/// would reclaim, not what the next tick happens to reclaim.
2136///
2137/// Walks the filesystem, so callers on the async runtime must run it in a
2138/// blocking task.
2139pub fn archive_space_preview(older_than_days: Option<u32>) -> Result<ArchiveSpacePreview> {
2140    let controller =
2141        Controller::load().context("load the session records to size their storage")?;
2142    Ok(archive_space_over_state(
2143        &mj_core::config::sessions_dir(),
2144        &controller.state,
2145        Utc::now(),
2146        older_than_days,
2147    ))
2148}
2149
2150/// Size archives using a State view so borrowed checkout owners remain visible.
2151fn archive_space_over_state(
2152    sessions_root: &Path,
2153    state: &mj_core::state::State,
2154    now: DateTime<Utc>,
2155    older_than_days: Option<u32>,
2156) -> ArchiveSpacePreview {
2157    let sessions = &state.sessions;
2158    let mut preview = ArchiveSpacePreview {
2159        sessions: sessions.len(),
2160        bytes: sessions
2161            .iter()
2162            .map(|(session_id, record)| session_bytes(sessions_root, session_id, record))
2163            .sum(),
2164        reclaimable_sessions: 0,
2165        reclaimable_bytes: 0,
2166    };
2167    if let Some(days) = older_than_days {
2168        let aged = sessions_ready_to_archive_from_state(state, now, days);
2169        preview.reclaimable_sessions = aged.len();
2170        preview.reclaimable_bytes = aged
2171            .iter()
2172            .filter_map(|session_id| {
2173                sessions
2174                    .get(session_id)
2175                    .map(|record| session_bytes(sessions_root, session_id, record))
2176            })
2177            .sum();
2178    }
2179    preview
2180}
2181
2182/// The sizing itself, over given records and a sessions directory.
2183#[cfg(test)]
2184fn archive_space_over(
2185    sessions_root: &Path,
2186    sessions: &mj_core::snapshot_map::SnapshotMap<String, SessionRecord>,
2187    subagents: &mj_core::snapshot_map::SnapshotMap<String, mj_core::subagent::SubagentRecord>,
2188    now: DateTime<Utc>,
2189    older_than_days: Option<u32>,
2190) -> ArchiveSpacePreview {
2191    let state = mj_core::state::State {
2192        sessions: sessions.clone(),
2193        subagents: subagents.clone(),
2194        ..mj_core::state::State::default()
2195    };
2196    archive_space_over_state(sessions_root, &state, now, older_than_days)
2197}
2198
2199/// What archiving one session would free: its checkpoint archive and its
2200/// attachments. Anything already missing counts as zero.
2201fn session_bytes(sessions_root: &Path, session_id: &str, record: &SessionRecord) -> u64 {
2202    let checkpoint = record
2203        .checkpoint
2204        .as_ref()
2205        .and_then(|checkpoint| std::fs::metadata(&checkpoint.archive_path).ok())
2206        .filter(|metadata| metadata.is_file())
2207        .map(|metadata| metadata.len())
2208        .unwrap_or(0);
2209    let attachments = sessions_root
2210        .join(session_id)
2211        .join(mj_core::attachment::ATTACHMENT_DIR);
2212    let attachments = crate::import::claude::directory_size(&attachments).unwrap_or(0);
2213    checkpoint.saturating_add(attachments)
2214}
2215
2216/// Which of `session_ids` the index holds under this instance's own key, with
2217/// at least one message and not already archived.
2218///
2219/// This is the gate the archive job will not cross: Mjolnir only deletes its
2220/// own copy of a conversation SessionWiki has actually stored. Runs SQLite
2221/// work, so callers on the async runtime wrap it in `spawn_blocking`.
2222pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
2223    if !index_is_writable() {
2224        // An index this daemon will not open holds nothing it may act on, and
2225        // the archive job deletes data, so it must find nothing here.
2226        return Ok(BTreeSet::new());
2227    }
2228    let connection = open_readonly()?;
2229    let sessions_dir = mj_core::config::sessions_dir();
2230    let mut indexed = BTreeSet::new();
2231    for session_id in session_ids {
2232        let key = format!("{}/{session_id}", sessions_dir.display());
2233        let rows = sessionwiki::index::resolve(&connection, session_id)
2234            .context("look up a stopped session in the SessionWiki index")?;
2235        if rows
2236            .iter()
2237            .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
2238        {
2239            indexed.insert(session_id.clone());
2240        }
2241    }
2242    Ok(indexed)
2243}
2244
2245fn open_readonly() -> Result<rusqlite::Connection> {
2246    sessionwiki::index::open_readonly().context("open the SessionWiki index")
2247}
2248
2249/// The one row an id names exactly. `resolve` matches prefixes, which is right
2250/// for a person typing and wrong for a client passing an id back.
2251fn row_by_id(
2252    connection: &rusqlite::Connection,
2253    id: &str,
2254) -> Result<Option<sessionwiki::index::SessionRow>> {
2255    Ok(sessionwiki::index::resolve(connection, id)
2256        .context("look up an indexed session")?
2257        .into_iter()
2258        .find(|row| row.session_id == id))
2259}
2260
2261/// Explicit IDs obey the same visibility rule as search results: a child
2262/// found by standalone SessionWiki sync is hidden as though it were absent.
2263fn visible_row_by_id(
2264    connection: &rusqlite::Connection,
2265    id: &str,
2266    ownership: &top_level::Snapshot,
2267    cache: &crate::import::NativeScanCache,
2268) -> Result<Option<sessionwiki::index::SessionRow>> {
2269    let Some(row) = row_by_id(connection, id)? else {
2270        return Ok(None);
2271    };
2272    if top_level::is_child(&row, ownership, cache)? {
2273        Ok(None)
2274    } else {
2275        Ok(Some(row))
2276    }
2277}
2278
2279fn wiki_row(
2280    row: sessionwiki::index::SessionRow,
2281    snippet: Option<String>,
2282    live: &BTreeSet<String>,
2283) -> WikiRow {
2284    // Only this daemon's own sessions can be live here, and only under the key
2285    // shape the adapter writes: the checkpoint directory and the session id.
2286    let hel_session_id = (row.tool == TOOL)
2287        .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
2288        .filter(|session_id| live.contains(session_id));
2289    let native_id = sessionwiki::index::native_id_of(&row.path);
2290    WikiRow {
2291        id: row.session_id,
2292        tool: row.tool,
2293        project: row.project,
2294        title: row.title,
2295        started: row.started,
2296        msgs: row.msg_count,
2297        preview: row.preview,
2298        archived: row.archived,
2299        native_id,
2300        snippet,
2301        hel_session_id,
2302        // Filled in by `fill_session_tags` from the index's own tags; the row
2303        // itself does not carry them.
2304        target: None,
2305        profile: None,
2306        harness: None,
2307    }
2308}
2309
2310/// The project a restored session should open.
2311///
2312/// A Mjolnir session runs in a managed worktree under the repository it was
2313/// started from, and that worktree is gone once the session is archived. The
2314/// repository above it is what the user still has, so a worktree path is
2315/// reduced to it. Any other path is used as it stands, and a path that no
2316/// longer exists is left for the caller to replace.
2317fn project_directory_of(project: &str) -> Option<PathBuf> {
2318    if project.trim().is_empty() {
2319        return None;
2320    }
2321    let path = PathBuf::from(project);
2322    let repository = path
2323        .ancestors()
2324        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
2325        .and_then(std::path::Path::parent)
2326        .map(std::path::Path::to_path_buf)
2327        .unwrap_or(path);
2328    repository.is_dir().then_some(repository)
2329}
2330
2331/// Rebuild an indexed transcript as a canonical snapshot.
2332///
2333/// The snapshot is only ever read by the compaction pipeline, which wants
2334/// turns: a user message opens a turn and assistant and tool items attach to
2335/// it. Messages before the first user message therefore have nowhere to go and
2336/// are dropped, and a session with no user message at all cannot be restored.
2337fn snapshot_of(
2338    session: &sessionwiki::model::Session,
2339) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
2340    use mj_core::archive::{
2341        CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
2342        CanonicalTranscriptBody, CanonicalTranscriptItem,
2343    };
2344
2345    let started_ms = session
2346        .started
2347        .map(|time| time.timestamp_millis())
2348        .unwrap_or_default();
2349    let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
2350    for message in &session.messages {
2351        let text = message.text.trim();
2352        if text.is_empty() {
2353            continue;
2354        }
2355        // Compaction attaches assistant and tool items to the open turn, so an
2356        // item before the first user message would be dropped anyway.
2357        if transcript.is_empty() && message.role != Role::User {
2358            continue;
2359        }
2360        let position = transcript.len() as u64 + 1;
2361        let body = match message.role {
2362            Role::User => CanonicalTranscriptBody::User {
2363                content: vec![serde_json::json!({"type": "text", "text": text})],
2364            },
2365            Role::Assistant => CanonicalTranscriptBody::Agent {
2366                chunks: vec![serde_json::json!({
2367                    "content": {"type": "text", "text": text}
2368                })],
2369                streaming: false,
2370            },
2371            // New indexes retain the shared projection; legacy rows contain only a title.
2372            Role::Tool => {
2373                let (call, terminal_outputs) = mj_transcript::summary::indexed_tool_call(
2374                    text,
2375                    &format!("wiki-tool-{position}"),
2376                );
2377                CanonicalTranscriptBody::Tool {
2378                    call,
2379                    terminal_outputs,
2380                    terminal_refs: Vec::new(),
2381                    presentation: None,
2382                }
2383            }
2384        };
2385        let created_at_ms = message
2386            .ts
2387            .map(|time| time.timestamp_millis())
2388            .unwrap_or(started_ms);
2389        transcript.push(CanonicalTranscriptItem {
2390            stable_id: format!("wiki-{position}"),
2391            position,
2392            // The validator wants an ordinal on agent messages and on nothing
2393            // else; one event per item makes the item's own position right.
2394            latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
2395                .then_some(position),
2396            created_at_ms,
2397            last_changed_at_ms: created_at_ms,
2398            body,
2399        });
2400    }
2401    anyhow::ensure!(
2402        !transcript.is_empty(),
2403        "the archived session has no prompt to restore from"
2404    );
2405
2406    let event_frontier = transcript.len() as u64;
2407    let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
2408    Ok(CanonicalSessionSnapshot {
2409        command_ledger: None,
2410        assessment_state: None,
2411        event_frontier,
2412        // Not a relay frontier, so there is no recorded digest to carry. It has
2413        // to be a well-formed non-genesis digest, and deriving it from the
2414        // session makes two restores of one session agree.
2415        event_frontier_digest: {
2416            use sha2::Digest;
2417            mj_core::hex::lower_hex(sha2::Sha256::digest(
2418                format!("sessionwiki:{}", session.id).as_bytes(),
2419            ))
2420        },
2421        session: CanonicalSessionState {
2422            execution: CanonicalExecutionState::Idle,
2423            last_activity_at_ms,
2424            session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
2425            configuration: Default::default(),
2426        },
2427        transcript,
2428        queued_prompts: Vec::new(),
2429    })
2430}
2431
2432// ---------------------------------------------------------------------------
2433// Continuing an indexed session
2434// ---------------------------------------------------------------------------
2435
2436/// What continuing one indexed session means.
2437///
2438/// An agent that found a session with SessionWiki should not have to know
2439/// whose session it was, so the branch lives here and `mj resume --wiki` takes
2440/// it on the agent's behalf.
2441#[derive(Debug, Clone, PartialEq, Eq)]
2442pub enum WikiContinuation {
2443    /// A Mjolnir session this daemon still has a record of: resume it.
2444    Resume { session_id: String },
2445    /// A Mjolnir session whose record the archive job destroyed: start a new
2446    /// session seeded with a compacted hand-off.
2447    Restore { wiki_id: String },
2448    /// Another tool's session: import it, then resume what the import made.
2449    Import {
2450        harness: HarnessKind,
2451        native_session_id: String,
2452    },
2453}
2454
2455/// How to continue the indexed session a row describes.
2456///
2457/// Pure over the row so the branch can be tested without an index:
2458/// `path` is the row's stored path and `has_record` says whether controller
2459/// state still holds a session with this id.
2460pub fn wiki_continuation(
2461    wiki_id: &str,
2462    tool: &str,
2463    path: &Path,
2464    has_record: bool,
2465) -> Result<WikiContinuation> {
2466    if tool == TOOL {
2467        // `MjolnirAdapter::parse_key` names the session by its own Mjolnir id,
2468        // so a Mjolnir row's SessionWiki id is the session id.
2469        return Ok(match has_record {
2470            true => WikiContinuation::Resume {
2471                session_id: wiki_id.to_owned(),
2472            },
2473            false => WikiContinuation::Restore {
2474                wiki_id: wiki_id.to_owned(),
2475            },
2476        });
2477    }
2478    let harness = harness_adapters::harness_for_tool(tool)
2479        .with_context(|| format!("Mjolnir cannot continue a {tool} session"))?;
2480    let native_session_id = crate::import::native_session_id_from_path(harness, path)
2481        .with_context(|| {
2482            format!(
2483                "no {tool} session id in the indexed path {}",
2484                path.display()
2485            )
2486        })?;
2487    Ok(WikiContinuation::Import {
2488        harness,
2489        native_session_id,
2490    })
2491}
2492
2493/// What one indexed session is, as far as continuing it is concerned.
2494///
2495/// Read through [`wiki_session`]; the daemon serves it for `mj resume --wiki`
2496/// and for `mj sessions --session` when the id names no Mjolnir session.
2497pub fn wiki_session(
2498    wiki_id: &str,
2499    known_sessions: &BTreeSet<String>,
2500) -> Result<Option<WikiSessionInfo>> {
2501    if !index_is_writable() {
2502        return Ok(None);
2503    }
2504    let connection = open_readonly()?;
2505    let ownership = top_level::current_snapshot()?;
2506    let cache = crate::import::NativeScanCache::shared();
2507    wiki_session_from(&connection, wiki_id, known_sessions, &ownership, &cache)
2508}
2509
2510fn wiki_session_from(
2511    connection: &rusqlite::Connection,
2512    wiki_id: &str,
2513    known_sessions: &BTreeSet<String>,
2514    ownership: &top_level::Snapshot,
2515    cache: &crate::import::NativeScanCache,
2516) -> Result<Option<WikiSessionInfo>> {
2517    let Some(row) = visible_row_by_id(connection, wiki_id, ownership, cache)? else {
2518        return Ok(None);
2519    };
2520    let is_mjolnir = row.tool == TOOL;
2521    let mjolnir_session_id = is_mjolnir.then(|| row.session_id.clone());
2522    let has_record = mjolnir_session_id
2523        .as_deref()
2524        .is_some_and(|session_id| known_sessions.contains(session_id));
2525    let status = match (is_mjolnir, has_record) {
2526        (false, _) => WikiSessionStatus::Native,
2527        (true, true) => WikiSessionStatus::Mine,
2528        (true, false) => WikiSessionStatus::Archived,
2529    };
2530    let tags = match is_mjolnir {
2531        true => tags::read(connection, &[row.session_id.as_str()])
2532            .context("read the indexed session metadata")?
2533            .remove(&row.session_id)
2534            .unwrap_or_default(),
2535        false => tags::MjTags::default(),
2536    };
2537    // Only an archived row is continued by restoring its transcript, and a
2538    // restore needs a prompt to open the first turn.
2539    let nothing_to_restore = status == WikiSessionStatus::Archived
2540        && !has_prompt(
2541            &sessionwiki::index::session_from_index(connection, &row)
2542                .context("read an indexed session")?,
2543        );
2544    let harness = tags
2545        .harness
2546        .as_deref()
2547        .and_then(|id| id.parse::<HarnessKind>().ok())
2548        .or_else(|| {
2549            (!is_mjolnir)
2550                .then(|| harness_adapters::harness_for_tool(&row.tool))
2551                .flatten()
2552        });
2553    Ok(Some(WikiSessionInfo {
2554        wiki_id: row.session_id,
2555        tool: row.tool,
2556        path: PathBuf::from(row.path),
2557        status,
2558        mjolnir_session_id,
2559        profile_id: tags.profile,
2560        target_template_id: tags.target,
2561        harness,
2562        title: row.title,
2563        project: row.project,
2564        nothing_to_restore,
2565    }))
2566}
2567
2568/// Whether an indexed transcript holds a prompt, which is what
2569/// [`snapshot_of`] needs to open a turn.
2570fn has_prompt(session: &sessionwiki::model::Session) -> bool {
2571    session
2572        .messages
2573        .iter()
2574        .any(|message| message.role == Role::User && !message.text.trim().is_empty())
2575}
2576
2577#[cfg(test)]
2578mod tests {
2579    use std::collections::BTreeMap;
2580    use std::path::Path;
2581
2582    /// The dispatch `mj resume --wiki` takes, over rows built by hand: the
2583    /// branch has to be right without an index behind it.
2584    mod continuation {
2585        use super::super::{WikiContinuation, wiki_continuation};
2586        use mj_core::config::HarnessKind;
2587        use std::path::Path;
2588
2589        #[test]
2590        fn a_claude_code_row_is_imported_with_the_uuid_from_its_path() {
2591            let path = Path::new(
2592                "/home/user/.claude/projects/-home-user-app/7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44.jsonl",
2593            );
2594            assert_eq!(
2595                wiki_continuation("abc123", "claude-code", path, false).unwrap(),
2596                WikiContinuation::Import {
2597                    harness: HarnessKind::Claude,
2598                    native_session_id: "7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44".to_owned(),
2599                }
2600            );
2601        }
2602
2603        /// A Codex rollout's file name is a timestamp and the thread UUID, so
2604        /// the stem alone is not the id `mj import codex --session` takes.
2605        #[test]
2606        fn a_codex_row_is_imported_with_the_uuid_from_its_rollout_name() {
2607            let path = Path::new(
2608                "/home/user/.codex/sessions/2026/09/18/rollout-2026-09-18T09-15-00-7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44.jsonl",
2609            );
2610            assert_eq!(
2611                wiki_continuation("abc123", "codex", path, false).unwrap(),
2612                WikiContinuation::Import {
2613                    harness: HarnessKind::Codex,
2614                    native_session_id: "7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44".to_owned(),
2615                }
2616            );
2617        }
2618    }
2619
2620    use mj_checkpoint::archive::{
2621        ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
2622        CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
2623        TargetManifest, write_archive_atomic,
2624    };
2625
2626    use super::*;
2627
2628    fn wiki_session_for_test(
2629        wiki_id: &str,
2630        known_sessions: &BTreeSet<String>,
2631    ) -> Result<Option<WikiSessionInfo>> {
2632        let connection = open_readonly()?;
2633        wiki_session_from(
2634            &connection,
2635            wiki_id,
2636            known_sessions,
2637            &top_level::Snapshot::default(),
2638            &crate::import::NativeScanCache::new(),
2639        )
2640    }
2641
2642    fn query_rows_for_test(
2643        query: &str,
2644        limit: usize,
2645        include_tool_matches: bool,
2646    ) -> Result<Vec<WikiRow>> {
2647        query_rows_with_snapshot_for_test(
2648            query,
2649            limit,
2650            include_tool_matches,
2651            &top_level::Snapshot::default(),
2652        )
2653    }
2654
2655    fn query_rows_with_snapshot_for_test(
2656        query: &str,
2657        limit: usize,
2658        include_tool_matches: bool,
2659        ownership: &top_level::Snapshot,
2660    ) -> Result<Vec<WikiRow>> {
2661        let connection = open_readonly()?;
2662        query_rows_from(
2663            &connection,
2664            query,
2665            limit,
2666            &BTreeSet::new(),
2667            include_tool_matches,
2668            ownership,
2669            &crate::import::NativeScanCache::new(),
2670        )
2671    }
2672
2673    fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
2674        // Only an agent message carries a content ordinal; the snapshot
2675        // validator rejects one on any other item and demands one here.
2676        let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
2677        CanonicalTranscriptItem {
2678            stable_id: format!("item-{position}"),
2679            position,
2680            latest_content_event_ordinal: streamed.then_some(position),
2681            created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
2682            last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
2683            body,
2684        }
2685    }
2686
2687    /// A managed checkpoint with one prompt, one reply, one tool call, and one
2688    /// thought, which is every transcript shape the adapter decides about.
2689    fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
2690        let path = directory.join(format!(
2691            "{session_id}-{frontier}-archive-{}.hel.zip",
2692            "0".repeat(32)
2693        ));
2694        write_archive_atomic(
2695            &path,
2696            &ArchiveInput {
2697                session: SessionManifest {
2698                    id: session_id.into(),
2699                    title: "indexed session".into(),
2700                    harness_kind: mj_core::config::HarnessKind::Codex,
2701                    profile_id: "codex".into(),
2702                    native_session_id: "native-session".into(),
2703                    created_at: "2026-09-01T00:00:00Z".into(),
2704                    checkpointed_at: "2026-09-01T01:00:00Z".into(),
2705                    hel_version: "test".into(),
2706                    relay_version: "test".into(),
2707                    adapter_version: "test".into(),
2708                },
2709                target: TargetManifest {
2710                    template_id: "local".into(),
2711                    target_kind: "local-bare".into(),
2712                    details: BTreeMap::new(),
2713                },
2714                bundle: BundleManifest {
2715                    id: "project".into(),
2716                    primary_repository: "project".into(),
2717                },
2718                canonical_session: CanonicalSessionSnapshot {
2719                    command_ledger: None,
2720                    assessment_state: None,
2721                    event_frontier: 4,
2722                    event_frontier_digest: "a".repeat(64),
2723                    session: CanonicalSessionState {
2724                        execution: CanonicalExecutionState::Idle,
2725                        last_activity_at_ms: Some(1_700_000_000_004),
2726                        session_title: Some("snapshot title".into()),
2727                        configuration: Default::default(),
2728                    },
2729                    transcript: vec![
2730                        item(
2731                            1,
2732                            CanonicalTranscriptBody::User {
2733                                content: vec![serde_json::json!({
2734                                    "type": "text",
2735                                    "text": "index this session"
2736                                })],
2737                            },
2738                        ),
2739                        item(
2740                            2,
2741                            CanonicalTranscriptBody::Thought {
2742                                chunks: vec![serde_json::json!({
2743                                    "content": {"type": "text", "text": "pondering"}
2744                                })],
2745                                streaming: false,
2746                            },
2747                        ),
2748                        item(
2749                            3,
2750                            CanonicalTranscriptBody::Tool {
2751                                call: serde_json::json!({
2752                                    "toolCallId": "call-1",
2753                                    "title": "Edit config.toml",
2754                                    "kind": "edit",
2755                                    "status": "completed",
2756                                    "locations": [{"path": "/old/container/config.toml"}]
2757                                }),
2758                                terminal_outputs: Vec::new(),
2759                                terminal_refs: Vec::new(),
2760                                presentation: None,
2761                            },
2762                        ),
2763                        item(
2764                            4,
2765                            CanonicalTranscriptBody::Agent {
2766                                chunks: vec![serde_json::json!({
2767                                    "content": {"type": "text", "text": "done"}
2768                                })],
2769                                streaming: false,
2770                            },
2771                        ),
2772                    ],
2773                    queued_prompts: Vec::new(),
2774                },
2775                native_artifacts: Vec::new(),
2776                repositories: Vec::new(),
2777            },
2778        )
2779        .unwrap();
2780    }
2781
2782    fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
2783        adapter_with_live(directory, session_id, BTreeMap::new())
2784    }
2785
2786    fn adapter_with_live(
2787        directory: &Path,
2788        session_id: &str,
2789        live: BTreeMap<String, i64>,
2790    ) -> MjolnirAdapter {
2791        let record = SessionRecord {
2792            project: None,
2793            id: session_id.into(),
2794            ..record_template()
2795        };
2796        MjolnirAdapter {
2797            sessions_dir: directory.to_path_buf(),
2798            sessions: std::sync::Mutex::new(Sessions {
2799                records: [(session_id.to_owned(), record)].into_iter().collect(),
2800                ownership: top_level::Snapshot::default(),
2801                project_directories: [(
2802                    session_id.to_owned(),
2803                    Ok(Some(PathBuf::from("/home/dev/project"))),
2804                )]
2805                .into_iter()
2806                .collect(),
2807                live,
2808            }),
2809            reload: false,
2810        }
2811    }
2812
2813    fn adapter_for_state(directory: &Path, state: &State, config: &Config) -> MjolnirAdapter {
2814        let sessions = Sessions {
2815            records: state.sessions.clone(),
2816            ownership: top_level::Snapshot::from_state(state),
2817            project_directories: project_directories_of(state, Some(config)),
2818            live: BTreeMap::new(),
2819        };
2820        MjolnirAdapter {
2821            sessions_dir: directory.to_path_buf(),
2822            sessions: std::sync::Mutex::new(sessions),
2823            reload: false,
2824        }
2825    }
2826
2827    fn sync_adapter(connection: &mut rusqlite::Connection, source: &Arc<MjolnirAdapter>) {
2828        let adapter: Box<dyn Adapter> = Box::new(SharedMjolnirAdapter(Arc::clone(source)));
2829        sessionwiki::index::sync_with(connection, &[adapter], None).unwrap();
2830    }
2831
2832    fn indexed_project(connection: &rusqlite::Connection, session_id: &str) -> String {
2833        connection
2834            .query_row(
2835                "SELECT project FROM files WHERE session_id = ?1",
2836                [session_id],
2837                |row| row.get(0),
2838            )
2839            .unwrap()
2840    }
2841
2842    fn indexed_mtime(connection: &rusqlite::Connection, session_id: &str) -> i64 {
2843        connection
2844            .query_row(
2845                "SELECT mtime FROM files WHERE session_id = ?1",
2846                [session_id],
2847                |row| row.get(0),
2848            )
2849            .unwrap()
2850    }
2851
2852    fn bundle_config() -> Config {
2853        let mut config = Config::default();
2854        config.bundles.insert(
2855            "project".into(),
2856            mj_core::config::ProjectBundle {
2857                primary_repo: "bifrost".into(),
2858                repositories: vec![mj_core::config::ProjectRepository {
2859                    id: "bifrost".into(),
2860                    github: Some("BrokkAi/bifrost".into()),
2861                    local: None,
2862                    destination: PathBuf::from("bifrost"),
2863                    git_ref: None,
2864                }],
2865            },
2866        );
2867        config
2868    }
2869
2870    fn bundle_record(session_id: &str) -> SessionRecord {
2871        SessionRecord {
2872            id: session_id.into(),
2873            project_directory: None,
2874            container_workspace: Some(PathBuf::from(format!("/workspace/{session_id}"))),
2875            target: Some(mj_core::state::TargetLocator::LocalPodman {
2876                container_id: "test-container".into(),
2877                workspace_storage: Default::default(),
2878                borrowed_from: None,
2879            }),
2880            ..record_template()
2881        }
2882    }
2883
2884    fn state_with_record(session_id: &str, record: SessionRecord) -> State {
2885        let mut state = State::default();
2886        state.sessions.insert(session_id.to_owned(), record);
2887        state
2888    }
2889
2890    fn record_template() -> SessionRecord {
2891        SessionRecord {
2892            project: None,
2893            target_runtime: None,
2894            launch_base: None,
2895            launch_branch: None,
2896            checkout: None,
2897            publication: None,
2898            build_cache: None,
2899            container_workspace: None,
2900            subagents: None,
2901            create_managed_worktree: None,
2902            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2903            archived: false,
2904            container_cpus: None,
2905            container_memory: None,
2906            id: "0123456789abcdef0123456789abcdef".into(),
2907            title: "indexed session".into(),
2908            harness_kind: mj_core::config::HarnessKind::Codex,
2909            last_profile: "codex".into(),
2910            bundle_id: "project".into(),
2911            project_directory: Some(PathBuf::from("/home/dev/project")),
2912            managed_worktree: None,
2913            review: None,
2914            no_project_memory: false,
2915            no_mailbox: false,
2916            target_template_id: "local-bare".into(),
2917            resource_allocation: None,
2918            additional_mounts: Vec::new(),
2919            state: mj_core::state::SessionState::Stopped,
2920            target: None,
2921            native_session_id: Some("native-session".into()),
2922            acp_session_title: Some("the harness title".into()),
2923            session_title_override: None,
2924            created_at: "2026-09-01T00:00:00Z".into(),
2925            updated_at: "2026-09-01T01:00:00Z".into(),
2926            viewed_through_event_ordinal: 0,
2927            draft_input: String::new(),
2928            last_error: None,
2929            last_checkpoint_error: None,
2930            checkpoint: None,
2931        }
2932    }
2933
2934    #[test]
2935    fn children_have_no_store_keys_metadata_or_pre_destroy_work() {
2936        let _held = tags::testing::lock();
2937        let (_index, _connection) = tags::testing::isolated_index();
2938        let directory = tempfile::tempdir().unwrap();
2939        let parent = "0123456789abcdef0123456789abcdef";
2940        let child = "fedcba9876543210fedcba9876543210";
2941        write_archive(directory.path(), parent, 1);
2942        write_archive(directory.path(), child, 1);
2943        let source = adapter_with_live(
2944            directory.path(),
2945            parent,
2946            BTreeMap::from([
2947                (parent.to_owned(), 1_900_000_000),
2948                (child.to_owned(), 1_900_000_001),
2949            ]),
2950        );
2951        {
2952            let mut sessions = source.sessions.lock().unwrap();
2953            sessions.ownership = top_level::Snapshot::for_test(BTreeSet::from([child.to_owned()]));
2954            sessions.records.insert(
2955                child.to_owned(),
2956                SessionRecord {
2957                    id: child.into(),
2958                    ..record_template()
2959                },
2960            );
2961        }
2962        let store = source.store().unwrap();
2963        assert_eq!(store.keys.len(), 1);
2964        assert_eq!(store.keys[0].0, source.key_for(parent));
2965        assert_eq!(store.files.len(), 1);
2966        assert_eq!(
2967            source.indexed_tags().keys().cloned().collect::<Vec<_>>(),
2968            [parent]
2969        );
2970        assert_eq!(
2971            unindexed(&source, &[parent.to_owned(), child.to_owned()]).unwrap(),
2972            [parent]
2973        );
2974        assert!(
2975            source
2976                .parse_key(&source.key_for(child))
2977                .unwrap_err()
2978                .to_string()
2979                .contains("sub-agent")
2980        );
2981        // Stopped children are excluded as well, even when their checkpoint remains.
2982        source.sessions.lock().unwrap().live.clear();
2983        assert_eq!(source.store().unwrap().keys.len(), 1);
2984    }
2985
2986    #[test]
2987    fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
2988        let directory = tempfile::tempdir().unwrap();
2989        let session_id = "0123456789abcdef0123456789abcdef";
2990        write_archive(directory.path(), session_id, 1);
2991        write_archive(directory.path(), session_id, 7);
2992        let adapter = adapter(directory.path(), session_id);
2993
2994        let store = adapter.store().expect("the adapter is a shared store");
2995        let key = format!("{}/{session_id}", directory.path().display());
2996        assert_eq!(
2997            store
2998                .keys
2999                .iter()
3000                .map(|(key, _)| key.as_str())
3001                .collect::<Vec<_>>(),
3002            vec![key.as_str()]
3003        );
3004        assert!(!store.had_error);
3005        assert_eq!(store.files.len(), 1);
3006        assert!(
3007            store.files[0]
3008                .file_name()
3009                .unwrap()
3010                .to_str()
3011                .unwrap()
3012                .contains("-7-archive-"),
3013            "the newest checkpoint is the one indexed: {:?}",
3014            store.files[0]
3015        );
3016        assert_eq!(
3017            adapter.reconcile_scope(),
3018            Some(format!("{}/", directory.path().display()))
3019        );
3020
3021        let session = sessionwiki::adapters::parse_session_key(&adapter, &key).unwrap();
3022        assert_eq!(session.id, session_id);
3023        assert_eq!(session.tool, "mjolnir");
3024        assert_eq!(session.path, PathBuf::from(&key));
3025        assert_eq!(session.project, "/home/dev/project");
3026        assert_eq!(session.title, "the harness title");
3027        assert!(!session.subagent);
3028        assert_eq!(
3029            session.messages.iter().map(|m| m.role).collect::<Vec<_>>(),
3030            vec![Role::User, Role::Tool, Role::Assistant]
3031        );
3032        assert_eq!(session.messages[0].text, "index this session");
3033        assert_eq!(
3034            session.messages[1].text,
3035            "→ edit(Edit config.toml) ⇒ ok · 0 lines"
3036        );
3037        assert_eq!(session.messages[2].text, "done");
3038        assert_eq!(session.touched, vec!["/old/container/config.toml"]);
3039    }
3040
3041    #[test]
3042    fn a_failed_command_is_indexed_as_one_line_without_its_output() {
3043        let tool = serde_json::json!({
3044            "toolCallId": "call-9",
3045            "name": "cargo",
3046            "kind": "execute",
3047            "status": "failed",
3048            "call": {
3049                "toolCallId": "call-9",
3050                "title": "cargo test",
3051                "kind": "execute",
3052                "status": "failed",
3053                "content": [{"type": "terminal", "terminalId": "term-1"}]
3054            },
3055            "terminals": {
3056                "term-1": {"output": "error[E0063]: missing field\nmore detail", "exit_code": 101}
3057            }
3058        });
3059        let mut session = Session {
3060            id: "s".into(),
3061            tool: "mjolnir",
3062            path: PathBuf::new(),
3063            project: String::new(),
3064            started: None,
3065            ended: None,
3066            title: String::new(),
3067            subagent: false,
3068            messages: tool_messages(&tool, 0),
3069            touched: Vec::new(),
3070            edits: Vec::new(),
3071        };
3072
3073        sessionwiki::tool_summary::fold_tool_parts(&mut session);
3074
3075        assert_eq!(session.messages.len(), 1);
3076        assert_eq!(
3077            session.messages[0].text,
3078            "→ execute(cargo test) ⇒ error · 3 lines — error[E0063]: missing field"
3079        );
3080    }
3081
3082    #[test]
3083    fn opening_for_sync_keeps_tool_output_as_summaries() {
3084        use sessionwiki::index::{ToolOutputMode, set_tool_output_mode, tool_output_mode};
3085        let _held = tags::testing::lock();
3086        let (_index_dir, connection) = tags::testing::isolated_index();
3087        set_tool_output_mode(&connection, ToolOutputMode::Full).unwrap();
3088        drop(connection);
3089
3090        let connection = open_for_sync().unwrap();
3091
3092        assert_eq!(
3093            tool_output_mode(&connection).unwrap(),
3094            ToolOutputMode::Summary
3095        );
3096    }
3097
3098    #[test]
3099    fn a_bundle_session_is_indexed_and_searchable_by_its_primary_repository() {
3100        let _held = tags::testing::lock();
3101        let (_index_dir, mut connection) = tags::testing::isolated_index();
3102        let directory = tempfile::tempdir().unwrap();
3103        let session_id = "0123456789abcdef0123456789abcdef";
3104        write_archive(directory.path(), session_id, 1);
3105        let state = state_with_record(session_id, bundle_record(session_id));
3106        let source = Arc::new(adapter_for_state(
3107            directory.path(),
3108            &state,
3109            &bundle_config(),
3110        ));
3111
3112        sync_adapter(&mut connection, &source);
3113
3114        assert_eq!(
3115            indexed_project(&connection, session_id),
3116            format!("/workspace/{session_id}/bifrost")
3117        );
3118        assert!(
3119            query_rows_for_test("bifrost", 10, false)
3120                .unwrap()
3121                .iter()
3122                .any(|row| row.id == session_id),
3123            "the indexed bundle session is found by its repository name"
3124        );
3125    }
3126
3127    #[test]
3128    fn a_raw_local_session_keeps_its_directory_when_indexed() {
3129        let _held = tags::testing::lock();
3130        let (_index_dir, mut connection) = tags::testing::isolated_index();
3131        let directory = tempfile::tempdir().unwrap();
3132        let session_id = "fedcba9876543210fedcba9876543210";
3133        let project_directory = PathBuf::from("/home/jonathan/Projects/bifrost");
3134        write_archive(directory.path(), session_id, 1);
3135        let record = SessionRecord {
3136            id: session_id.into(),
3137            project_directory: Some(project_directory.clone()),
3138            ..record_template()
3139        };
3140        let state = state_with_record(session_id, record);
3141        let source = Arc::new(adapter_for_state(
3142            directory.path(),
3143            &state,
3144            &Config::default(),
3145        ));
3146
3147        sync_adapter(&mut connection, &source);
3148
3149        assert_eq!(
3150            indexed_project(&connection, session_id),
3151            project_directory.display().to_string()
3152        );
3153    }
3154
3155    #[test]
3156    fn a_row_from_the_previous_parse_format_is_reparsed_once() {
3157        let _held = tags::testing::lock();
3158        let (_index_dir, mut connection) = tags::testing::isolated_index();
3159        let directory = tempfile::tempdir().unwrap();
3160        let session_id = "0123456789abcdef0123456789abcdef";
3161        write_archive(directory.path(), session_id, 1);
3162        let mut record = bundle_record(session_id);
3163        record.updated_at = "2099-01-01T00:00:00Z".into();
3164        let previous_token = parse_time(&record.updated_at)
3165            .unwrap()
3166            .timestamp()
3167            .saturating_mul(1024)
3168            .saturating_add(i64::from(mj_transcript::summary::SUMMARY_VERSION));
3169        let state = state_with_record(session_id, record);
3170        let source = Arc::new(adapter_for_state(
3171            directory.path(),
3172            &state,
3173            &bundle_config(),
3174        ));
3175        let key = source.key_for(session_id);
3176        tags::testing::index_row(&connection, session_id, TOOL);
3177        connection
3178            .execute(
3179                "UPDATE files SET path = ?1, project = '', mtime = ?2 WHERE session_id = ?3",
3180                rusqlite::params![key, previous_token, session_id],
3181            )
3182            .unwrap();
3183
3184        sync_adapter(&mut connection, &source);
3185
3186        let parsed_token = indexed_mtime(&connection, session_id);
3187        assert_eq!(
3188            indexed_project(&connection, session_id),
3189            format!("/workspace/{session_id}/bifrost")
3190        );
3191        assert_eq!(
3192            parsed_token,
3193            source
3194                .store()
3195                .unwrap()
3196                .keys
3197                .into_iter()
3198                .find(|(path, _)| path == &key)
3199                .unwrap()
3200                .1
3201        );
3202
3203        // If the unchanged second sync calls parse_key again, it will fail.
3204        source
3205            .sessions
3206            .lock()
3207            .unwrap()
3208            .project_directories
3209            .insert(session_id.into(), Err("unexpected second parse".into()));
3210        sync_adapter(&mut connection, &source);
3211
3212        assert_eq!(indexed_mtime(&connection, session_id), parsed_token);
3213        assert_eq!(
3214            indexed_project(&connection, session_id),
3215            format!("/workspace/{session_id}/bifrost")
3216        );
3217    }
3218
3219    #[test]
3220    fn provenance_backfill_repairs_an_unchanged_checkpoint_without_rebuilding_the_index() {
3221        let _held = tags::testing::lock();
3222        let (_index_dir, mut connection) = tags::testing::isolated_index();
3223        let directory = tempfile::tempdir().unwrap();
3224        write_archive(directory.path(), "old-session", 4);
3225        let source = adapter(directory.path(), "old-session");
3226        let key = source.key_for("old-session");
3227        tags::testing::index_row(&connection, "old-session", "mjolnir");
3228        connection
3229            .execute(
3230                "UPDATE files SET path = ?1 WHERE session_id = 'old-session'",
3231                [&key],
3232            )
3233            .unwrap();
3234        provenance::backfill(&mut connection, &source).unwrap();
3235        assert_eq!(
3236            sessionwiki::index::files_for(&connection, "old-session").unwrap(),
3237            vec!["/old/container/config.toml"]
3238        );
3239        provenance::backfill(&mut connection, &source).unwrap();
3240        assert_eq!(
3241            sessionwiki::index::sessions_for_file(&connection, "config.toml", 20)
3242                .unwrap()
3243                .len(),
3244            1
3245        );
3246    }
3247
3248    /// A running session is listed under the same key as a stopped one, with
3249    /// its own change token, so it is searchable before it is ever closed and
3250    /// reconciliation never archives it. When it stops, the key stays and the
3251    /// checkpoint becomes its source.
3252    #[test]
3253    fn a_running_session_is_listed_with_its_own_change_token() {
3254        let directory = tempfile::tempdir().unwrap();
3255        let running = "0123456789abcdef0123456789abcdef";
3256        let never_checkpointed = "fedcba9876543210fedcba9876543210";
3257        write_archive(directory.path(), running, 3);
3258        let live = adapter_with_live(
3259            directory.path(),
3260            running,
3261            BTreeMap::from([
3262                (running.to_owned(), 1_900_000_000),
3263                (never_checkpointed.to_owned(), 1_900_000_001),
3264            ]),
3265        );
3266
3267        let store = live.store().expect("the adapter is a shared store");
3268        let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
3269        assert_eq!(
3270            store.keys,
3271            vec![
3272                (key_of(running), session_change_token(1_900_000_000)),
3273                (
3274                    key_of(never_checkpointed),
3275                    session_change_token(1_900_000_001)
3276                ),
3277            ],
3278            "a live session's own token replaces the checkpoint's"
3279        );
3280
3281        // Once it stops it leaves the live set, and the checkpoint's own
3282        // modification time is the token again.
3283        let stopped = adapter(directory.path(), running);
3284        let keys = stopped.store().expect("a shared store").keys;
3285        assert_eq!(keys.len(), 1);
3286        assert_eq!(keys[0].0, key_of(running));
3287        assert_ne!(keys[0].1, 1_900_000_000);
3288        assert_eq!(
3289            stopped.parse_key(&key_of(running)).unwrap().title,
3290            "the harness title",
3291            "a stopped session is parsed from its checkpoint"
3292        );
3293    }
3294
3295    /// Renaming a session leaves its conversation untouched, so only the
3296    /// record's own last update can tell the index the title moved.
3297    #[test]
3298    fn a_rename_moves_a_session_change_token() {
3299        let directory = tempfile::tempdir().unwrap();
3300        let session_id = "0123456789abcdef0123456789abcdef";
3301        write_archive(directory.path(), session_id, 1);
3302        let adapter = adapter(directory.path(), session_id);
3303        let before = adapter.store().expect("a shared store").keys[0].1;
3304
3305        {
3306            let mut sessions = adapter.sessions.lock().unwrap();
3307            let record = sessions.records.get_mut(session_id).unwrap();
3308            record.session_title_override = Some("the new name".into());
3309            record.updated_at = "2099-01-01T00:00:00Z".into();
3310        }
3311        let after = adapter.store().expect("a shared store").keys[0].1;
3312        assert!(
3313            after > before,
3314            "a renamed session is re-indexed: {before} then {after}"
3315        );
3316        assert_eq!(
3317            adapter
3318                .parse_key(&format!("{}/{session_id}", directory.path().display()))
3319                .unwrap()
3320                .title,
3321            "the new name"
3322        );
3323    }
3324
3325    fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
3326        Session {
3327            id: "0123456789abcdef0123456789abcdef".into(),
3328            tool: "mjolnir",
3329            path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
3330            project: "/home/dev/project".into(),
3331            started: DateTime::from_timestamp_millis(1_700_000_000_000),
3332            ended: None,
3333            title: "the archived session".into(),
3334            subagent: false,
3335            messages: messages
3336                .into_iter()
3337                .map(|(role, text)| Message {
3338                    role,
3339                    text: text.to_owned(),
3340                    ts: None,
3341                    tool: None,
3342                })
3343                .collect(),
3344            touched: Vec::new(),
3345            edits: Vec::new(),
3346        }
3347    }
3348
3349    #[test]
3350    fn golden_wiki_resume_preview_search() {
3351        use std::fmt::Write as _;
3352
3353        fn response(out: &mut String, label: &str, transcript: &WikiHitTranscript) {
3354            writeln!(
3355                out,
3356                "=== {label} ({} preview blocks) ===",
3357                transcript.blocks.len()
3358            )
3359            .unwrap();
3360            writeln!(out, "{}", serde_json::to_string_pretty(transcript).unwrap()).unwrap();
3361        }
3362
3363        let mut out = String::new();
3364        let case_insensitive = indexed(vec![
3365            (Role::User, "Make the Tests green"),
3366            (Role::Assistant, "the tests are green now"),
3367        ]);
3368        response(
3369            &mut out,
3370            "case-insensitive query with user and assistant hits",
3371            &hit_transcript(&case_insensitive, "TESTS", 0, 4_000),
3372        );
3373
3374        let tool_only_and_assistant = indexed(vec![
3375            (Role::User, "make it build"),
3376            (Role::Tool, "cargo build --needle"),
3377            (Role::Assistant, "it builds"),
3378        ]);
3379        response(
3380            &mut out,
3381            "query found only in tool output",
3382            &hit_transcript(&tool_only_and_assistant, "needle", 1, 4_000),
3383        );
3384        response(
3385            &mut out,
3386            "tool context beside an assistant hit",
3387            &hit_transcript(&tool_only_and_assistant, "builds", 1, 4_000),
3388        );
3389
3390        mj_core::golden::assert_golden(
3391            env!("CARGO_MANIFEST_DIR"),
3392            "wiki-resume-preview-search",
3393            &out,
3394        );
3395    }
3396
3397    /// Context messages come back around each hit, with the gap between two
3398    /// groups counted rather than silently closed.
3399    #[test]
3400    fn transcript_hits_keeps_context_and_marks_omissions() {
3401        let session = indexed(vec![
3402            (Role::User, "zero"),
3403            (Role::Assistant, "one needle one"),
3404            (Role::Tool, "two"),
3405            (Role::User, "three"),
3406            (Role::Assistant, "four"),
3407            (Role::Tool, "five"),
3408            (Role::User, "six needle six"),
3409            (Role::Assistant, "seven"),
3410            (Role::User, "eight"),
3411        ]);
3412
3413        let found = hit_transcript(&session, "needle", 1, 4_000);
3414
3415        let shown: Vec<(&str, &str, usize)> = found
3416            .blocks
3417            .iter()
3418            .map(|block| {
3419                (
3420                    block.role.as_str(),
3421                    block.text.as_str(),
3422                    block.omitted_before,
3423                )
3424            })
3425            .collect();
3426        assert_eq!(
3427            shown,
3428            vec![
3429                ("user", "zero", 0),
3430                ("assistant", "one needle one", 0),
3431                ("tool", "two", 0),
3432                ("tool", "five", 2),
3433                ("user", "six needle six", 0),
3434                ("assistant", "seven", 0),
3435            ]
3436        );
3437        assert_eq!(found.omitted_after, 1, "the last message is not shown");
3438        assert!(found.blocks[0].hits.is_empty(), "context has no hits");
3439    }
3440
3441    /// A long message is cut down to the caller's budget around its first hit,
3442    /// not from the start, so the match is always in what comes back.
3443    #[test]
3444    fn transcript_hits_window_keeps_the_first_hit() {
3445        let filler = "x".repeat(4_000);
3446        let session = indexed(vec![(Role::User, &format!("{filler} needle {filler}"))]);
3447
3448        let found = hit_transcript(&session, "needle", 0, 100);
3449
3450        let block = &found.blocks[0];
3451        assert!(block.truncated);
3452        assert_eq!(block.text.chars().count(), 100);
3453        assert_eq!(block.hits.len(), 1, "the windowed text keeps its hit");
3454        let (start, end) = block.hits[0];
3455        assert_eq!(&block.text[start..end], "needle");
3456        assert!(
3457            start >= 20,
3458            "the window keeps lead-in before the hit, got {start}"
3459        );
3460    }
3461
3462    /// Compaction attaches assistant and tool items to the open turn, so an
3463    /// index that starts mid-conversation must not produce a snapshot whose
3464    /// first item has no turn to join.
3465    #[test]
3466    fn messages_before_the_first_prompt_are_dropped() {
3467        let snapshot = snapshot_of(&indexed(vec![
3468            (Role::Assistant, "still working"),
3469            (Role::User, "carry on"),
3470        ]))
3471        .unwrap();
3472        assert_eq!(snapshot.transcript.len(), 1);
3473        assert_eq!(snapshot.transcript[0].position, 1);
3474        snapshot.validate().unwrap();
3475
3476        let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
3477        assert!(
3478            error.to_string().contains("no prompt"),
3479            "a session with no prompt cannot be restored: {error}"
3480        );
3481        // `mj sessions --session` offers a restore by the same rule.
3482        assert!(!has_prompt(&indexed(vec![(
3483            Role::Assistant,
3484            "nobody asked"
3485        )])));
3486        assert!(has_prompt(&indexed(vec![(Role::User, "carry on")])));
3487    }
3488
3489    fn record(
3490        session_id: &str,
3491        state: mj_core::state::SessionState,
3492        updated_at: &str,
3493    ) -> SessionRecord {
3494        SessionRecord {
3495            project: None,
3496            id: session_id.into(),
3497            state,
3498            updated_at: updated_at.into(),
3499            ..record_template()
3500        }
3501    }
3502
3503    fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
3504        mj_core::subagent::SubagentRecord {
3505            child_session_id: child_session_id.into(),
3506            parent_session_id: parent_session_id.into(),
3507            task_name: "task".into(),
3508            profile_id: "codex".into(),
3509            model: None,
3510            effort: None,
3511            working_directory: PathBuf::new(),
3512            initial_prompt: "do the thing".into(),
3513            request_key: "key".into(),
3514            created_at: "2026-09-01T00:00:00Z".into(),
3515            noticed_turn: None,
3516            reported_finish: None,
3517            handback_tool: false,
3518        }
3519    }
3520
3521    fn ready(
3522        sessions: Vec<SessionRecord>,
3523        children: Vec<mj_core::subagent::SubagentRecord>,
3524    ) -> Vec<String> {
3525        let now = parse_time("2026-09-10T00:00:00Z").unwrap();
3526        sessions_ready_to_archive(
3527            &sessions
3528                .into_iter()
3529                .map(|record| (record.id.clone(), record))
3530                .collect(),
3531            &children
3532                .into_iter()
3533                .map(|child| (child.child_session_id.clone(), child))
3534                .collect(),
3535            now,
3536            3,
3537        )
3538    }
3539
3540    #[test]
3541    fn aged_clone_requires_clean_published_evidence_for_its_current_checkpoint() {
3542        let id = "0123456789abcdef0123456789abcdef";
3543        let root = PathBuf::from(format!("/srv/project/.mj/clones/{id}"));
3544        let mut session = record(
3545            id,
3546            mj_core::state::SessionState::Stopped,
3547            "2026-09-01T00:00:00Z",
3548        );
3549        session.project_directory = Some(root.clone());
3550        session.managed_worktree = Some(mj_core::state::ManagedWorktree {
3551            kind: mj_core::state::ManagedCheckoutKind::Clone,
3552            source_project_directory: "/srv/project".into(),
3553            source_repository: "/srv/project".into(),
3554            worktree_root: root,
3555            branch: "feature".into(),
3556            target: mj_core::state::ManagedWorktreeTarget::Local,
3557            base_commit: Some("1".repeat(40)),
3558        });
3559        session.checkpoint = Some(mj_core::state::CheckpointMetadata {
3560            archive_path: "sessions/checkpoint.hel.zip".into(),
3561            sha256: "a".repeat(64),
3562            created_at: "2026-09-01T00:00:00Z".into(),
3563            event_frontier: 0,
3564        });
3565        assert!(ready(vec![session.clone()], vec![]).is_empty());
3566        session.publication = Some(mj_core::state::PublicationAssessment {
3567            checkpoint_sha256: "a".repeat(64),
3568            state: mj_core::state::PublicationState::Published,
3569            dirty: false,
3570            stashed: false,
3571            saved_commits: vec!["2".repeat(40)],
3572            destinations: vec!["https://example.test/repository.git".into()],
3573            checked_at: "2026-09-01T01:00:00Z".into(),
3574            reason: Some("feature branch was pushed but not merged".into()),
3575        });
3576        assert_eq!(ready(vec![session.clone()], vec![]), vec![id]);
3577        session.publication.as_mut().unwrap().stashed = true;
3578        assert!(ready(vec![session.clone()], vec![]).is_empty());
3579        session.publication.as_mut().unwrap().stashed = false;
3580        session.publication.as_mut().unwrap().checkpoint_sha256 = "b".repeat(64);
3581        assert!(ready(vec![session], vec![]).is_empty());
3582    }
3583
3584    /// A session whose checkpoint archive and attachments sit under `root`.
3585    fn sized_session(
3586        root: &Path,
3587        session_id: &str,
3588        updated_at: &str,
3589        checkpoint_bytes: usize,
3590        attachment_bytes: &[usize],
3591    ) -> SessionRecord {
3592        let archive_path = root.join(format!("{session_id}.hel.zip"));
3593        std::fs::write(&archive_path, vec![b'c'; checkpoint_bytes]).unwrap();
3594        if !attachment_bytes.is_empty() {
3595            let attachments = root
3596                .join(session_id)
3597                .join(mj_core::attachment::ATTACHMENT_DIR);
3598            std::fs::create_dir_all(&attachments).unwrap();
3599            for (index, size) in attachment_bytes.iter().enumerate() {
3600                std::fs::write(attachments.join(format!("{index}.png")), vec![b'a'; *size])
3601                    .unwrap();
3602            }
3603        }
3604        SessionRecord {
3605            project: None,
3606            checkpoint: Some(mj_core::state::CheckpointMetadata {
3607                archive_path,
3608                sha256: "0".repeat(64),
3609                created_at: updated_at.into(),
3610                event_frontier: 1,
3611            }),
3612            ..record(
3613                session_id,
3614                mj_core::state::SessionState::Stopped,
3615                updated_at,
3616            )
3617        }
3618    }
3619
3620    #[test]
3621    fn the_space_preview_sizes_every_session_and_only_the_aged_ones_as_reclaimable() {
3622        let directory = tempfile::tempdir().unwrap();
3623        let root = directory.path();
3624        let sessions: mj_core::snapshot_map::SnapshotMap<String, SessionRecord> = [
3625            sized_session(root, "old-stopped", "2026-09-01T00:00:00Z", 1000, &[10, 20]),
3626            sized_session(root, "just-stopped", "2026-09-09T00:00:00Z", 500, &[]),
3627            // A record whose checkpoint file is already gone counts as zero
3628            // rather than failing the whole estimate.
3629            SessionRecord {
3630                project: None,
3631                checkpoint: Some(mj_core::state::CheckpointMetadata {
3632                    archive_path: root.join("missing.hel.zip"),
3633                    sha256: "0".repeat(64),
3634                    created_at: "2026-09-01T00:00:00Z".into(),
3635                    event_frontier: 1,
3636                }),
3637                ..record(
3638                    "lost-checkpoint",
3639                    mj_core::state::SessionState::Stopped,
3640                    "2026-09-01T00:00:00Z",
3641                )
3642            },
3643        ]
3644        .into_iter()
3645        .map(|record| (record.id.clone(), record))
3646        .collect();
3647        let now = parse_time("2026-09-10T00:00:00Z").unwrap();
3648
3649        let all = archive_space_over(root, &sessions, &Default::default(), now, None);
3650        assert_eq!(all.sessions, 3);
3651        assert_eq!(all.bytes, 1530);
3652        assert_eq!(all.reclaimable_sessions, 0);
3653        assert_eq!(all.reclaimable_bytes, 0);
3654
3655        let aged = archive_space_over(root, &sessions, &Default::default(), now, Some(3));
3656        assert_eq!(aged.bytes, 1530);
3657        assert_eq!(
3658            (aged.reclaimable_sessions, aged.reclaimable_bytes),
3659            (2, 1030),
3660            "only the sessions the job would archive count, attachments included"
3661        );
3662    }
3663
3664    #[test]
3665    fn only_stopped_sessions_past_the_cut_off_are_archived() {
3666        use mj_core::state::SessionState;
3667        let selected = ready(
3668            vec![
3669                record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3670                record(
3671                    "just-stopped",
3672                    SessionState::Stopped,
3673                    "2026-09-09T00:00:00Z",
3674                ),
3675                record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
3676                record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
3677                record("unparsable", SessionState::Stopped, "not a time"),
3678                // Exactly the cut-off counts as old enough.
3679                record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
3680            ],
3681            Vec::new(),
3682        );
3683        assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
3684    }
3685
3686    #[test]
3687    fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
3688        use mj_core::state::SessionState;
3689        let selected = ready(
3690            vec![
3691                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3692                record(
3693                    "running-child",
3694                    SessionState::Running,
3695                    "2026-09-01T00:00:00Z",
3696                ),
3697            ],
3698            vec![child("running-child", "parent")],
3699        );
3700        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
3701
3702        let selected = ready(
3703            vec![
3704                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3705                record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
3706            ],
3707            vec![child("young-child", "parent")],
3708        );
3709        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
3710
3711        // A child whose record is already gone holds nothing open.
3712        let selected = ready(
3713            vec![record(
3714                "parent",
3715                SessionState::Stopped,
3716                "2026-09-01T00:00:00Z",
3717            )],
3718            vec![child("departed-child", "parent")],
3719        );
3720        assert_eq!(selected, vec!["parent"]);
3721    }
3722
3723    #[test]
3724    fn children_are_archived_before_their_parents() {
3725        use mj_core::state::SessionState;
3726        let selected = ready(
3727            vec![
3728                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3729                record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3730                record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
3731            ],
3732            vec![child("child", "parent"), child("grandchild", "child")],
3733        );
3734        assert_eq!(selected, vec!["grandchild", "child", "parent"]);
3735    }
3736
3737    /// A Mjolnir row carries the target, profile and harness the sync stored
3738    /// in the index; a row from another tool carries none, because only
3739    /// Mjolnir writes those tags.
3740    #[test]
3741    fn query_rows_returns_the_indexed_target_profile_and_harness() {
3742        let _held = tags::testing::lock();
3743        let (_directory, connection) = tags::testing::isolated_index();
3744        tags::testing::index_row(&connection, "mj-session", TOOL);
3745        tags::testing::index_row(&connection, "codex-session", "codex");
3746        tags::write(
3747            &connection,
3748            "mj-session",
3749            &tags::MjTags {
3750                target: Some("Prod-Box".into()),
3751                profile: Some("codex-Main".into()),
3752                harness: Some("codex".into()),
3753            },
3754        )
3755        .expect("write the session metadata");
3756
3757        let rows = query_rows_for_test("", 10, false).expect("query the index");
3758        let mjolnir = rows
3759            .iter()
3760            .find(|row| row.id == "mj-session")
3761            .expect("the Mjolnir row is returned");
3762        assert_eq!(mjolnir.target.as_deref(), Some("Prod-Box"));
3763        assert_eq!(mjolnir.profile.as_deref(), Some("codex-Main"));
3764        assert_eq!(mjolnir.harness.as_deref(), Some("codex"));
3765
3766        let codex = rows
3767            .iter()
3768            .find(|row| row.id == "codex-session")
3769            .expect("the Codex row is returned");
3770        assert_eq!(codex.target, None);
3771        assert_eq!(codex.profile, None);
3772        assert_eq!(codex.harness, None);
3773    }
3774
3775    #[test]
3776    fn standalone_indexed_children_are_hidden_from_every_history_listing() {
3777        let _held = tags::testing::lock();
3778        let (_directory, mut connection) = tags::testing::isolated_index();
3779        let home = tempfile::tempdir().unwrap();
3780        let project = home.path().join("projects/project");
3781        std::fs::create_dir_all(&project).unwrap();
3782        let paths = [
3783            project.join("00000000-0000-4000-8000-000000000001.jsonl"),
3784            project.join("00000000-0000-4000-8000-000000000002.jsonl"),
3785            project.join("00000000-0000-4000-8000-000000000003.jsonl"),
3786            project.join("00000000-0000-4000-8000-000000000004.jsonl"),
3787        ];
3788        let transcript = |timestamp: &str, content: &str, sidechain: bool| {
3789            format!(
3790                "{{\"type\":\"user\",\"cwd\":\"/src/project\",\"entrypoint\":\"cli\",\"timestamp\":\"{timestamp}\",\"isSidechain\":{sidechain},\"message\":{{\"role\":\"user\",\"content\":\"{content}\"}}}}\n"
3791            )
3792        };
3793        for (index, path) in paths.iter().enumerate() {
3794            let is_child = index >= 2;
3795            let content = if is_child {
3796                "quokka quokka quokka quokka quokka quokka"
3797            } else {
3798                "quokka parent conversation"
3799            };
3800            let timestamp = match index {
3801                0 => "2026-10-04T00:00:00Z",
3802                1 => "2026-10-03T00:00:00Z",
3803                _ => "2026-10-05T00:00:00Z",
3804            };
3805            std::fs::write(path, transcript(timestamp, content, index == 3)).unwrap();
3806        }
3807
3808        // This is a standalone SessionWiki sync: it sees no Mjolnir ownership
3809        // relation and stores both child transcripts as ordinary main rows.
3810        let adapter = Box::new(sessionwiki::adapters::ClaudeCode::in_home(
3811            home.path().to_owned(),
3812        ));
3813        sessionwiki::index::sync_with(&mut connection, &[adapter], None).unwrap();
3814        for path in &paths[2..] {
3815            let kind: String = connection
3816                .query_row(
3817                    "SELECT kind FROM files WHERE path = ?1",
3818                    [path.to_string_lossy()],
3819                    |row| row.get(0),
3820                )
3821                .unwrap();
3822            assert_eq!(kind, "main", "the standalone sync misclassified {path:?}");
3823        }
3824
3825        let mj_child_id = "mj-child-session";
3826        let native_mj_child_id = "00000000-0000-4000-8000-000000000003";
3827        let mut state = mj_core::state::State::default();
3828        let mut child_record = crate::database::test_session(mj_child_id, "test-project");
3829        child_record.native_session_id = Some(native_mj_child_id.to_owned());
3830        state.sessions.insert(mj_child_id.to_owned(), child_record);
3831        state.subagents.insert(
3832            mj_child_id.to_owned(),
3833            mj_core::subagent::SubagentRecord {
3834                child_session_id: mj_child_id.to_owned(),
3835                parent_session_id: "mj-parent-session".to_owned(),
3836                task_name: "child".to_owned(),
3837                profile_id: "codex".to_owned(),
3838                model: None,
3839                effort: None,
3840                working_directory: PathBuf::from("/src/project"),
3841                initial_prompt: "child task".to_owned(),
3842                request_key: "test-child".to_owned(),
3843                created_at: "2026-10-05T00:00:00Z".to_owned(),
3844                noticed_turn: None,
3845                reported_finish: None,
3846                handback_tool: false,
3847            },
3848        );
3849        let ownership = top_level::Snapshot::from_state(&state);
3850        let ids_for_paths: Vec<String> = paths
3851            .iter()
3852            .map(|path| {
3853                connection
3854                    .query_row(
3855                        "SELECT session_id FROM files WHERE path = ?1",
3856                        [path.to_string_lossy()],
3857                        |row| row.get(0),
3858                    )
3859                    .unwrap()
3860            })
3861            .collect();
3862        for id in &ids_for_paths {
3863            connection
3864                .execute(
3865                    "INSERT INTO touched(session_id, path) VALUES (?1, '/src/project/src/a.rs')",
3866                    [id],
3867                )
3868                .unwrap();
3869        }
3870        connection
3871            .execute(
3872                "UPDATE files SET title = 'standalone-only-name' WHERE session_id = ?1",
3873                [&ids_for_paths[2]],
3874            )
3875            .unwrap();
3876
3877        let child_native_ids = BTreeSet::from([
3878            "00000000-0000-4000-8000-000000000003".to_owned(),
3879            "00000000-0000-4000-8000-000000000004".to_owned(),
3880        ]);
3881        let raw_hits = sessionwiki::index::search(&connection, "quokka", 10, None, None).unwrap();
3882        let first_search_ids: BTreeSet<_> = raw_hits
3883            .iter()
3884            .take(2)
3885            .filter_map(|hit| sessionwiki::index::native_id_of(&hit.row.path))
3886            .collect();
3887        assert_eq!(first_search_ids, child_native_ids);
3888        let raw_recent =
3889            sessionwiki::index::recent(&connection, 2, None, None, None, true).unwrap();
3890        let first_recent_ids: BTreeSet<_> = raw_recent
3891            .iter()
3892            .filter_map(|row| sessionwiki::index::native_id_of(&row.path))
3893            .collect();
3894        assert_eq!(first_recent_ids, child_native_ids);
3895        let resume = query_rows_with_snapshot_for_test("quokka", 2, false, &ownership).unwrap();
3896        let mut resume_ids: Vec<_> = resume.into_iter().map(|row| row.id).collect();
3897        resume_ids.sort();
3898        let mut parent_ids = ids_for_paths[..2].to_vec();
3899        parent_ids.sort();
3900        assert_eq!(resume_ids, parent_ids);
3901
3902        let recent = query_rows_with_snapshot_for_test("", 2, false, &ownership).unwrap();
3903        let mut recent_ids: Vec<_> = recent.into_iter().map(|row| row.id).collect();
3904        recent_ids.sort();
3905        assert_eq!(recent_ids, parent_ids);
3906        assert!(
3907            query_rows_with_snapshot_for_test("standalone-only-name", 10, false, &ownership,)
3908                .unwrap()
3909                .is_empty()
3910        );
3911
3912        let search_sessions = mj_core::history::HistoryRequest {
3913            request_id: "test-search".into(),
3914            query: mj_core::history::HistoryQuery::SearchSessions {
3915                query: "quokka".into(),
3916                limit: 2,
3917            },
3918            blame: None,
3919        };
3920        let history = history::query_in_with(
3921            &connection,
3922            &search_sessions,
3923            &ownership,
3924            &crate::import::NativeScanCache::new(),
3925        )
3926        .unwrap();
3927        let mut history_ids: Vec<String> = history["sessions"]
3928            .as_array()
3929            .unwrap()
3930            .iter()
3931            .map(|row| row["id"].as_str().unwrap().to_owned())
3932            .collect();
3933        history_ids.sort();
3934        assert_eq!(history_ids, parent_ids);
3935
3936        let trace = history::query_in_with(
3937            &connection,
3938            &mj_core::history::HistoryRequest {
3939                request_id: "test-trace".into(),
3940                query: mj_core::history::HistoryQuery::TraceFile {
3941                    path: "src/a.rs".into(),
3942                    limit: 2,
3943                },
3944                blame: None,
3945            },
3946            &ownership,
3947            &crate::import::NativeScanCache::new(),
3948        )
3949        .unwrap();
3950        let mut trace_ids: Vec<String> = trace["sessions"]
3951            .as_array()
3952            .unwrap()
3953            .iter()
3954            .map(|item| item["session"]["id"].as_str().unwrap().to_owned())
3955            .collect();
3956        trace_ids.sort();
3957        assert_eq!(trace_ids, parent_ids);
3958
3959        let blame_time = chrono::DateTime::parse_from_rfc3339("2026-10-05T00:00:00Z")
3960            .unwrap()
3961            .timestamp();
3962        let blame = history::query_in_with(
3963            &connection,
3964            &mj_core::history::HistoryRequest {
3965                request_id: "test-blame".into(),
3966                query: mj_core::history::HistoryQuery::BlameFile {
3967                    path: PathBuf::from("src/a.rs"),
3968                    start_line: 1,
3969                    end_line: 1,
3970                },
3971                blame: Some(mj_core::history::BlameEvidence {
3972                    repository: PathBuf::from("/src/project"),
3973                    relative_path: PathBuf::from("src/a.rs"),
3974                    porcelain: format!(
3975                        "{} 1 1 1\nauthor-time {blame_time}\n\tline\n",
3976                        "a".repeat(40),
3977                    ),
3978                }),
3979            },
3980            &ownership,
3981            &crate::import::NativeScanCache::new(),
3982        )
3983        .unwrap();
3984        assert_eq!(blame["runs"][0]["status"], "confident");
3985        assert_eq!(
3986            blame["runs"][0]["sessions"][0]["session_id"],
3987            ids_for_paths[0]
3988        );
3989
3990        let child_brief = history::query_in_with(
3991            &connection,
3992            &mj_core::history::HistoryRequest {
3993                request_id: "test-child-brief".into(),
3994                query: mj_core::history::HistoryQuery::GetSessionBrief {
3995                    session_id: ids_for_paths[2].clone(),
3996                    max_chars: 200,
3997                },
3998                blame: None,
3999            },
4000            &ownership,
4001            &crate::import::NativeScanCache::new(),
4002        );
4003        assert!(child_brief.unwrap_err().to_string().contains("not found"));
4004    }
4005
4006    #[test]
4007    fn every_query_path_excludes_sub_agents_including_agent_history() {
4008        let _held = tags::testing::lock();
4009        let (_directory, connection) = tags::testing::isolated_index();
4010        for (session_id, kind) in [("main-session", "main"), ("sub-session", "sub")] {
4011            tags::testing::index_row(&connection, session_id, "claude");
4012            connection
4013                .execute(
4014                    "UPDATE files SET kind = ?2 WHERE session_id = ?1",
4015                    rusqlite::params![session_id, kind],
4016                )
4017                .expect("set the session kind");
4018            connection
4019                .execute(
4020                    "INSERT INTO messages(session_id, role, text)
4021                     VALUES (?1, 'user', 'fix the bridge derivation zq')",
4022                    [session_id],
4023                )
4024                .expect("insert a message");
4025            connection
4026                .execute(
4027                    "INSERT INTO msgs(rowid, text) VALUES (?1, 'fix the bridge derivation zq')",
4028                    [connection.last_insert_rowid()],
4029                )
4030                .expect("index the message");
4031        }
4032        let ids = |query: &str, include_tool_matches: bool| {
4033            let mut ids: Vec<String> = query_rows_for_test(query, 10, include_tool_matches)
4034                .expect("query the index")
4035                .into_iter()
4036                .map(|row| row.id)
4037                .collect();
4038            ids.sort();
4039            ids
4040        };
4041
4042        // The recent list, full-text search, short-query scan, and title match.
4043        for query in ["", "bridge derivation", "zq", "an indexed session"] {
4044            assert_eq!(ids(query, false), ["main-session"], "query {query:?}");
4045            assert_eq!(ids(query, true), ["main-session"], "query {query:?}");
4046        }
4047    }
4048
4049    /// I1-5: a phrase only a sub-agent wrote reaches its parent's index as
4050    /// tool text (Claude Code records the Task prompt and the sub-agent's
4051    /// answer as the parent's tool call and tool result). The resume search
4052    /// matched the parent on it while the preview, which never anchors on
4053    /// tool output, said "no hits". A match counts only where the preview can
4054    /// show it.
4055    // Hard-won: e53bc221: A child-only transcript phrase must not make the parent match when its preview has no hits.
4056    #[test]
4057    fn a_phrase_only_in_a_sub_agents_transcript_does_not_match_its_parent() {
4058        let _held = tags::testing::lock();
4059        let (_directory, connection) = tags::testing::isolated_index();
4060        let message = |session_id: &str, role: &str, text: &str| {
4061            connection
4062                .execute(
4063                    "INSERT INTO messages(session_id, role, text) VALUES (?1, ?2, ?3)",
4064                    rusqlite::params![session_id, role, text],
4065                )
4066                .expect("insert a message");
4067            connection
4068                .execute(
4069                    "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4070                    rusqlite::params![connection.last_insert_rowid(), text],
4071                )
4072                .expect("index the message");
4073        };
4074        for (session_id, kind) in [("parent", "main"), ("child", "sub")] {
4075            tags::testing::index_row(&connection, session_id, "claude");
4076            connection
4077                .execute(
4078                    "UPDATE files SET kind = ?2 WHERE session_id = ?1",
4079                    rusqlite::params![session_id, kind],
4080                )
4081                .expect("set the session kind");
4082        }
4083        message("parent", "user", "look into the relay journal");
4084        message("parent", "tool", "Task {\"prompt\":\"read the journal\"}");
4085        message("parent", "tool", "the journal uses a quokka checksum");
4086        message(
4087            "parent",
4088            "assistant",
4089            "The journal is fine; the parent zebra ends here.",
4090        );
4091        message("child", "user", "read the journal");
4092        message("child", "assistant", "the journal uses a quokka checksum");
4093
4094        let ids = |query: &str, include_tool_matches: bool| {
4095            let mut ids: Vec<String> = query_rows_for_test(query, 10, include_tool_matches)
4096                .expect("query the index")
4097                .into_iter()
4098                .map(|row| row.id)
4099                .collect();
4100            ids.sort();
4101            ids
4102        };
4103        assert!(
4104            ids("quokka", false).is_empty(),
4105            "{:?}",
4106            ids("quokka", false)
4107        );
4108        // Agent history keeps the parent's tool text, but never child rows.
4109        assert_eq!(ids("quokka", true), ["parent"]);
4110        assert_eq!(ids("parent zebra", false), ["parent"]);
4111
4112        let history = history::query_in_with(
4113            &connection,
4114            &mj_core::history::HistoryRequest {
4115                request_id: "test-tool-search".into(),
4116                query: mj_core::history::HistoryQuery::SearchSessions {
4117                    query: "quokka".into(),
4118                    limit: 10,
4119                },
4120                blame: None,
4121            },
4122            &top_level::Snapshot::default(),
4123            &crate::import::NativeScanCache::new(),
4124        )
4125        .expect("search indexed history");
4126        assert_eq!(history["sessions"][0]["id"], "parent");
4127    }
4128
4129    // Hard-won: e53bc221: Short-query fallback must not restore the tool-only false match fixed in trigram search.
4130    #[test]
4131    fn short_query_scan_also_ignores_tool_only_matches() {
4132        let _held = tags::testing::lock();
4133        let (_directory, connection) = tags::testing::isolated_index();
4134        tags::testing::index_row(&connection, "parent", "claude");
4135        connection
4136            .execute(
4137                "INSERT INTO messages(session_id, role, text) VALUES ('parent', 'tool', 'qx')",
4138                [],
4139            )
4140            .expect("insert a message");
4141        assert!(
4142            query_rows_for_test("qx", 10, false)
4143                .expect("query the index")
4144                .is_empty()
4145        );
4146    }
4147
4148    #[test]
4149    fn resume_search_uses_conversational_hits_and_ands_terms_in_one_message() {
4150        let _held = tags::testing::lock();
4151        let (_directory, connection) = tags::testing::isolated_index();
4152        let insert_message = |session_id: &str, role: &str, text: &str| {
4153            connection
4154                .execute(
4155                    "INSERT INTO messages(session_id, role, text) VALUES (?1, ?2, ?3)",
4156                    rusqlite::params![session_id, role, text],
4157                )
4158                .expect("insert a message");
4159            connection
4160                .execute(
4161                    "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4162                    rusqlite::params![connection.last_insert_rowid(), text],
4163                )
4164                .expect("index a message");
4165        };
4166
4167        tags::testing::index_row(&connection, "mixed", "claude");
4168        insert_message("mixed", "tool", &"amber manta tool output ".repeat(20));
4169        insert_message(
4170            "mixed",
4171            "user",
4172            "amber and manta appear in this conversation",
4173        );
4174        tags::testing::index_row(&connection, "same-message", "claude");
4175        insert_message("same-message", "assistant", "amber appears before manta");
4176        tags::testing::index_row(&connection, "split-messages", "claude");
4177        insert_message("split-messages", "user", "amber appears here");
4178        insert_message("split-messages", "assistant", "manta appears there");
4179
4180        let rows =
4181            query_rows_for_test("amber manta", 10, false).expect("search conversational messages");
4182        let ids: BTreeSet<_> = rows.iter().map(|row| row.id.as_str()).collect();
4183        assert_eq!(ids, BTreeSet::from(["mixed", "same-message"]));
4184        let mixed = rows.iter().find(|row| row.id == "mixed").unwrap();
4185        let snippet = mixed.snippet.as_deref().expect("role-filtered snippet");
4186        assert!(
4187            snippet.contains(" and ") && snippet.contains("appe"),
4188            "{snippet:?}"
4189        );
4190        assert!(!snippet.contains("tool output"), "{snippet:?}");
4191    }
4192
4193    #[test]
4194    fn resume_search_preserves_short_nfc_and_cjk_queries() {
4195        let _held = tags::testing::lock();
4196        let (_directory, connection) = tags::testing::isolated_index();
4197        let insert_message = |session_id: &str, role: &str, text: &str| {
4198            connection
4199                .execute(
4200                    "INSERT INTO messages(session_id, role, text) VALUES (?1, ?2, ?3)",
4201                    rusqlite::params![session_id, role, text],
4202                )
4203                .expect("insert a message");
4204            connection
4205                .execute(
4206                    "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4207                    rusqlite::params![connection.last_insert_rowid(), text],
4208                )
4209                .expect("index a message");
4210        };
4211        for (id, text) in [
4212            ("short-query", "The short token qx appears here"),
4213            ("nfc-query", "The café is open"),
4214            ("cjk-query", "東京の会話を検索する"),
4215        ] {
4216            tags::testing::index_row(&connection, id, "codex");
4217            insert_message(id, "assistant", text);
4218        }
4219
4220        for (query, expected) in [
4221            ("qx", "short-query"),
4222            ("cafe\u{301}", "nfc-query"),
4223            ("東京", "cjk-query"),
4224        ] {
4225            let rows = query_rows_for_test(query, 10, false)
4226                .unwrap_or_else(|error| panic!("search {query:?}: {error:#}"));
4227            assert_eq!(
4228                rows.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(),
4229                [expected],
4230                "query {query:?}"
4231            );
4232        }
4233    }
4234
4235    #[test]
4236    fn role_filter_keeps_a_full_page_after_more_than_200_tool_only_hits() {
4237        let _held = tags::testing::lock();
4238        let (_directory, connection) = tags::testing::isolated_index();
4239        let insert_message = |session_id: &str, role: &str, text: &str| {
4240            connection
4241                .execute(
4242                    "INSERT INTO messages(session_id, role, text) VALUES (?1, ?2, ?3)",
4243                    rusqlite::params![session_id, role, text],
4244                )
4245                .expect("insert a message");
4246            connection
4247                .execute(
4248                    "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4249                    rusqlite::params![connection.last_insert_rowid(), text],
4250                )
4251                .expect("index a message");
4252        };
4253
4254        let query = "quokka kalimba";
4255        let tool_text = format!("{} tool output", "quokka kalimba ".repeat(16));
4256        for index in 0..250 {
4257            let id = format!("tool-only-{index:03}");
4258            tags::testing::index_row(&connection, &id, "claude");
4259            insert_message(&id, "tool", &tool_text);
4260        }
4261        for index in 0..200 {
4262            let id = format!("conversation-{index:03}");
4263            tags::testing::index_row(&connection, &id, "claude");
4264            insert_message(
4265                &id,
4266                "user",
4267                "quokka kalimba appears once in this conversation",
4268            );
4269        }
4270
4271        let unfiltered = sessionwiki::index::search(&connection, query, 200, None, None)
4272            .expect("search all indexed roles");
4273        assert_eq!(unfiltered.len(), 200);
4274        assert!(
4275            unfiltered
4276                .iter()
4277                .all(|hit| hit.row.session_id.starts_with("tool-only-")),
4278            "tool-only hits must outrank conversational hits in the fixture"
4279        );
4280
4281        let rows =
4282            query_rows_for_test(query, 200, false).expect("search a full conversational page");
4283        assert_eq!(rows.len(), 200);
4284        assert!(rows.iter().all(|row| row.id.starts_with("conversation-")));
4285    }
4286
4287    #[test]
4288    fn full_text_search_fills_its_result_limit_after_skipping_sub_agents() {
4289        let _held = tags::testing::lock();
4290        let (_directory, connection) = tags::testing::isolated_index();
4291        for (id, kind, text) in [
4292            ("sub", "sub", "restic restic restic restic"),
4293            ("main", "main", "restic cleanup"),
4294        ] {
4295            tags::testing::index_row(&connection, id, "codex");
4296            connection
4297                .execute(
4298                    "UPDATE files SET kind = ?2 WHERE session_id = ?1",
4299                    rusqlite::params![id, kind],
4300                )
4301                .unwrap();
4302            connection
4303                .execute(
4304                    "INSERT INTO messages(session_id, role, text) VALUES (?1, 'user', ?2)",
4305                    rusqlite::params![id, text],
4306                )
4307                .unwrap();
4308            connection
4309                .execute(
4310                    "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4311                    rusqlite::params![connection.last_insert_rowid(), text],
4312                )
4313                .unwrap();
4314        }
4315
4316        let rows = query_rows_for_test("restic", 1, false).unwrap();
4317        assert_eq!(
4318            rows.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(),
4319            ["main"]
4320        );
4321    }
4322
4323    /// A runtime of the test's own, so a test can hold the index lock without
4324    /// holding it across an await.
4325    fn block_on<F: std::future::Future>(future: F) -> F::Output {
4326        tokio::runtime::Builder::new_current_thread()
4327            .enable_all()
4328            .build()
4329            .unwrap()
4330            .block_on(future)
4331    }
4332
4333    /// R2-11's fallback. A destroy that outwaits a running sync pass, such as
4334    /// a first build, indexes the session on its own from the rows the pass
4335    /// would write, and does not wait for the pass. The session is then found
4336    /// by its id with no record left, as after the destroy.
4337    // Hard-won: ab7d7f49: A session destroyed between sync passes must remain searchable by its ID.
4338    #[test]
4339    fn a_destroy_indexes_the_session_itself_when_the_sync_outlasts_the_wait() {
4340        let _held = tags::testing::lock();
4341        let (_index_dir, _connection) = tags::testing::isolated_index();
4342        let directory = tempfile::tempdir().unwrap();
4343        let session_id = "0123456789abcdef0123456789abcdef";
4344        write_archive(directory.path(), session_id, 1);
4345        let source = adapter(directory.path(), session_id);
4346
4347        let started = Instant::now();
4348        let outcome = block_on(index_before_destroy_with(
4349            std::future::pending::<Result<()>>(),
4350            Duration::from_millis(200),
4351            move || capture_sessions_from(&source, &[session_id.to_owned()]),
4352            Duration::from_millis(50),
4353        ));
4354
4355        assert_eq!(outcome, IndexedBeforeDestroy::WrittenDirectly);
4356        assert!(
4357            started.elapsed() < Duration::from_secs(10),
4358            "the destroy must not wait for the pass: {:?}",
4359            started.elapsed()
4360        );
4361        let found = wiki_session_for_test(session_id, &BTreeSet::new())
4362            .unwrap()
4363            .expect("the session is found by its id");
4364        assert_eq!(found.status, WikiSessionStatus::Archived);
4365        assert_eq!(found.tool, TOOL);
4366        assert_eq!(
4367            found.path,
4368            PathBuf::from(format!("{}/{session_id}", directory.path().display()))
4369        );
4370        assert_eq!(found.title, "the harness title");
4371        assert_eq!(
4372            found.harness,
4373            Some(HarnessKind::Codex),
4374            "the session's metadata is written beside its row"
4375        );
4376        assert!(!found.nothing_to_restore);
4377    }
4378
4379    /// While another writer holds the index, as a first build does while it
4380    /// parses one tool's sessions, the destroy goes ahead and the rows it read
4381    /// are written once the index is free.
4382    // Hard-won: ab7d7f49: A busy SessionWiki writer must not make a destroyed session disappear from search.
4383    #[test]
4384    fn a_busy_index_takes_the_destroyed_session_once_it_is_free() {
4385        let _held = tags::testing::lock();
4386        let (_index_dir, writer) = tags::testing::isolated_index();
4387        let directory = tempfile::tempdir().unwrap();
4388        let session_id = "0123456789abcdef0123456789abcdef";
4389        write_archive(directory.path(), session_id, 1);
4390        let source = adapter(directory.path(), session_id);
4391
4392        block_on(async {
4393            writer.execute_batch("BEGIN IMMEDIATE").unwrap();
4394            let outcome = index_before_destroy_with(
4395                std::future::pending::<Result<()>>(),
4396                Duration::from_millis(50),
4397                move || capture_sessions_from(&source, &[session_id.to_owned()]),
4398                Duration::from_millis(50),
4399            )
4400            .await;
4401            assert_eq!(outcome, IndexedBeforeDestroy::Deferred);
4402            assert!(
4403                wiki_session_for_test(session_id, &BTreeSet::new())
4404                    .unwrap()
4405                    .is_none(),
4406                "nothing is written while the other writer holds the index"
4407            );
4408
4409            writer.execute_batch("COMMIT").unwrap();
4410            let deadline = Instant::now() + Duration::from_secs(30);
4411            while wiki_session_for_test(session_id, &BTreeSet::new())
4412                .unwrap()
4413                .is_none()
4414            {
4415                assert!(
4416                    Instant::now() < deadline,
4417                    "the deferred row never reached the index"
4418                );
4419                tokio::time::sleep(Duration::from_millis(50)).await;
4420            }
4421        });
4422    }
4423
4424    /// A destroy of a session the index already holds as it is now runs no
4425    /// sync pass, so destroying a workspace or a parent with sub-agents costs
4426    /// one pass at most. The token compared is the one SessionWiki stored.
4427    #[test]
4428    fn a_session_the_index_holds_as_it_is_now_needs_no_indexing() {
4429        let _held = tags::testing::lock();
4430        let (_index_dir, _connection) = tags::testing::isolated_index();
4431        let directory = tempfile::tempdir().unwrap();
4432        let session_id = "0123456789abcdef0123456789abcdef";
4433        let never_prompted = "fedcba9876543210fedcba9876543210";
4434        write_archive(directory.path(), session_id, 1);
4435        let source = adapter(directory.path(), session_id);
4436        let ids = [session_id.to_owned(), never_prompted.to_owned()];
4437
4438        assert_eq!(
4439            unindexed(&source, &ids).unwrap(),
4440            [session_id],
4441            "a session with no conversation has nothing to index"
4442        );
4443        let captured = Arc::new(capture_sessions_from(&source, &ids).unwrap());
4444        write_captured(&captured).unwrap();
4445        assert!(unindexed(&source, &ids).unwrap().is_empty());
4446
4447        // A rename moves the change token, so the row is stale again.
4448        source
4449            .sessions
4450            .lock()
4451            .unwrap()
4452            .records
4453            .get_mut(session_id)
4454            .unwrap()
4455            .updated_at = "2099-01-01T00:00:00Z".into();
4456        assert_eq!(unindexed(&source, &ids).unwrap(), [session_id]);
4457    }
4458
4459    // Hard-won: #1259: the stock Aider adapter walks $HOME at startup looking for history files.
4460    #[test]
4461    fn adapters_cover_every_supported_harness_and_no_unsupported_tool() {
4462        use mj_core::config::HarnessProfile;
4463        let directory = tempfile::tempdir().unwrap();
4464        let mut config = Config::default();
4465        for (index, kind) in HarnessKind::ALL.into_iter().enumerate() {
4466            let home = directory.path().join(format!("home-{index}"));
4467            std::fs::create_dir_all(&home).unwrap();
4468            config.profiles.insert(
4469                format!("profile-{index}"),
4470                HarnessProfile {
4471                    enabled: true,
4472                    kind,
4473                    home,
4474                    environment: Default::default(),
4475                    context_window_bytes: None,
4476                    legacy_subagents: Default::default(),
4477                    guardian_review_model: None,
4478                },
4479            );
4480        }
4481        let mut names: Vec<&str> = native_adapters(&config)
4482            .iter()
4483            .map(|adapter| adapter.name())
4484            .collect();
4485        names.sort_unstable();
4486        assert_eq!(
4487            names,
4488            [
4489                "claude-code",
4490                "codex",
4491                "grok-build",
4492                "kimi-code",
4493                "muse",
4494                "opencode"
4495            ],
4496            "one adapter per supported harness, none for tools Mjolnir cannot run"
4497        );
4498    }
4499
4500    /// The text search over an in-memory index shaped like SessionWiki's.
4501    mod text_search {
4502        use super::super::{SessionTextMatch, SessionTextMatchKind, text_matches_in};
4503        use std::collections::BTreeSet;
4504
4505        fn index(sessions: &[(&str, &[(&str, &str)])]) -> rusqlite::Connection {
4506            let connection = rusqlite::Connection::open_in_memory().unwrap();
4507            connection
4508                .execute_batch(
4509                    "CREATE TABLE files(path TEXT PRIMARY KEY, session_id TEXT NOT NULL,
4510                                        tool TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'main');
4511                     CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT NOT NULL,
4512                                           role TEXT NOT NULL, text TEXT NOT NULL);
4513                     CREATE VIRTUAL TABLE msgs USING fts5(
4514                         text, content='messages', content_rowid='id', tokenize='trigram');",
4515                )
4516                .unwrap();
4517            for (id, messages) in sessions {
4518                connection
4519                    .execute(
4520                        "INSERT INTO files(path, session_id, tool) VALUES (?1, ?2, 'mjolnir')",
4521                        rusqlite::params![format!("/checkpoints/{id}"), id],
4522                    )
4523                    .unwrap();
4524                for (role, text) in *messages {
4525                    connection
4526                        .execute(
4527                            "INSERT INTO messages(session_id, role, text) VALUES (?1, ?2, ?3)",
4528                            rusqlite::params![id, role, text],
4529                        )
4530                        .unwrap();
4531                    let rowid = connection.last_insert_rowid();
4532                    connection
4533                        .execute(
4534                            "INSERT INTO msgs(rowid, text) VALUES (?1, ?2)",
4535                            rusqlite::params![rowid, text],
4536                        )
4537                        .unwrap();
4538                }
4539            }
4540            connection
4541        }
4542
4543        fn live(ids: &[&str]) -> BTreeSet<String> {
4544            ids.iter().map(|id| (*id).to_owned()).collect()
4545        }
4546
4547        fn matches(kinds: &[(&str, SessionTextMatchKind)]) -> Vec<SessionTextMatch> {
4548            kinds
4549                .iter()
4550                .map(|(id, kind)| SessionTextMatch {
4551                    session_id: (*id).to_owned(),
4552                    kind: *kind,
4553                })
4554                .collect()
4555        }
4556
4557        #[test]
4558        fn golden_sessions_filter_search() {
4559            let connection = index(&[
4560                ("said-by-user", &[("user", "please fix the Zebra crossing")]),
4561                ("said-by-agent", &[("assistant", "the zebra is fixed")]),
4562                (
4563                    "only-in-tool",
4564                    &[("tool", "zebra stack trace"), ("user", "hello")],
4565                ),
4566                (
4567                    "both",
4568                    &[("assistant", "a ZEBRA appears"), ("user", "a zebra please")],
4569                ),
4570                ("gone", &[("user", "zebra")]),
4571            ]);
4572            let live = live(&["said-by-user", "said-by-agent", "only-in-tool", "both"]);
4573            let found = text_matches_in(
4574                &connection,
4575                "zebra",
4576                &live,
4577                &super::super::top_level::Snapshot::default(),
4578            )
4579            .unwrap();
4580            assert_eq!(
4581                found,
4582                matches(&[
4583                    ("both", SessionTextMatchKind::User),
4584                    ("said-by-agent", SessionTextMatchKind::Agent),
4585                    ("said-by-user", SessionTextMatchKind::User),
4586                ])
4587            );
4588            let rendered = format!(
4589                "=== controller text search response ({} matches) ===\n{}\n",
4590                found.len(),
4591                serde_json::to_string_pretty(&found).unwrap()
4592            );
4593            mj_core::golden::assert_golden(
4594                env!("CARGO_MANIFEST_DIR"),
4595                "sessions-filter-search",
4596                &rendered,
4597            );
4598        }
4599
4600        #[test]
4601        fn a_query_too_short_for_the_trigram_index_still_matches() {
4602            let connection = index(&[
4603                ("a", &[("user", "go to the zoo")]),
4604                ("b", &[("assistant", "zoo")]),
4605                ("c", &[("tool", "zoo")]),
4606            ]);
4607            assert_eq!(
4608                text_matches_in(
4609                    &connection,
4610                    "zo",
4611                    &live(&["a", "b", "c"]),
4612                    &super::super::top_level::Snapshot::default(),
4613                )
4614                .unwrap(),
4615                matches(&[
4616                    ("a", SessionTextMatchKind::User),
4617                    ("b", SessionTextMatchKind::Agent),
4618                ])
4619            );
4620            // A percent sign is text, not a wildcard.
4621            assert_eq!(
4622                text_matches_in(
4623                    &connection,
4624                    "%z",
4625                    &live(&["a"]),
4626                    &super::super::top_level::Snapshot::default(),
4627                )
4628                .unwrap(),
4629                Vec::new()
4630            );
4631        }
4632    }
4633}