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
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::time::Instant;
17
18use anyhow::{Context, Result};
19use chrono::{DateTime, Utc};
20
21use mj_client::daemon::{WikiIndexState, WikiRow, WikiStatus};
22use mj_core::state::{SessionRecord, State};
23use sessionwiki::adapters::{Adapter, Discovered, Store};
24use sessionwiki::model::{Message, Role, Session};
25
26use crate::controller::Controller;
27use crate::controller::checkpoint::managed_checkpoint_archive_name;
28
29/// The tool name every Mjolnir instance publishes under. One name means one
30/// search partition; reconciliation is scoped per instance instead (see
31/// [`Adapter::reconcile_scope`]).
32const TOOL: &str = "mjolnir";
33
34/// One checkpoint archive on disk, reduced to what indexing needs.
35struct ArchiveFile {
36    path: PathBuf,
37    frontier: u64,
38    /// Modification time in epoch seconds, SessionWiki's change token.
39    token: i64,
40}
41
42/// What indexing needs from controller state: which sessions exist, which of
43/// them are sub-agent children, and which are still running with their
44/// conversation in the daemon's own database rather than in a checkpoint.
45#[derive(Default)]
46struct Sessions {
47    records: BTreeMap<String, SessionRecord>,
48    subagent_ids: BTreeSet<String>,
49    /// Session id to change token, for sessions indexed from the projection.
50    live: BTreeMap<String, i64>,
51}
52
53impl Sessions {
54    fn of(state: &State) -> Self {
55        Self {
56            records: state.sessions.clone(),
57            subagent_ids: state.subagents.keys().cloned().collect(),
58            live: live_tokens(state),
59        }
60    }
61}
62
63/// The change token of every session whose transcript is still only in the
64/// daemon's database: its activity watermark in whole seconds.
65///
66/// A stopped session keeps being indexed from its checkpoint, which never
67/// changes again. Everything else is indexed from the projection, so a running
68/// session is findable before it has ever been closed.
69fn live_tokens(state: &State) -> BTreeMap<String, i64> {
70    let activity = match crate::database::load_transcribed_session_activity() {
71        Ok(activity) => activity,
72        Err(error) => {
73            tracing::warn!(%error, "could not read session activity for SessionWiki");
74            return BTreeMap::new();
75        }
76    };
77    state
78        .sessions
79        .iter()
80        .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
81        .filter_map(|(session_id, _)| {
82            let watermark = activity.get(session_id)?;
83            Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
84        })
85        .collect()
86}
87
88/// Mjolnir's sessions, as SessionWiki sees them.
89pub struct MjolnirAdapter {
90    sessions_dir: PathBuf,
91    sessions: std::sync::Mutex<Sessions>,
92    /// Re-read controller state when the indexer reaches this adapter.
93    reload: bool,
94}
95
96impl MjolnirAdapter {
97    /// A fixed view of the given state, which is what a caller with a state in
98    /// hand wants.
99    pub fn from_state(state: &State) -> Self {
100        Self {
101            sessions_dir: mj_core::config::sessions_dir(),
102            sessions: std::sync::Mutex::new(Sessions::of(state)),
103            reload: false,
104        }
105    }
106
107    /// The same, but re-reading controller state when the indexer reaches this
108    /// adapter.
109    ///
110    /// One sync pass walks every other tool's store first, which can take
111    /// minutes on a large corpus. Without the reload, sessions that closed
112    /// during that walk would be indexed with no record: no project, no start
113    /// time, and the title guessed from the first prompt. Their checkpoints do
114    /// not change afterwards, so nothing would ever correct them.
115    pub fn reloading(state: &State) -> Self {
116        Self {
117            reload: true,
118            ..Self::from_state(state)
119        }
120    }
121
122    fn reload(&self) {
123        if !self.reload {
124            return;
125        }
126        match Controller::load() {
127            Ok(controller) => {
128                *self
129                    .sessions
130                    .lock()
131                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
132                    Sessions::of(&controller.state)
133            }
134            Err(error) => {
135                tracing::warn!(%error, "could not refresh session records for SessionWiki")
136            }
137        }
138    }
139
140    /// The conversation of a stopped session, read from its newest checkpoint,
141    /// with the title the checkpoint recorded.
142    fn checkpointed_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
143        let (newest, _) = self.newest_archives();
144        let archive = newest
145            .get(session_id)
146            .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
147        let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
148            .with_context(|| format!("read checkpoint {}", archive.path.display()))?
149            .canonical_session()
150            .with_context(|| format!("read the transcript of session {session_id}"))?;
151        let messages = snapshot
152            .transcript
153            .iter()
154            .filter_map(|item| {
155                let (role, text) = match &item.body {
156                    mj_core::archive::CanonicalTranscriptBody::User { content } => (
157                        Role::User,
158                        mj_core::transcript::materialized_content_text(content),
159                    ),
160                    mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
161                        Role::Assistant,
162                        mj_core::transcript::materialized_chunks_text(chunks),
163                    ),
164                    mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => {
165                        (Role::Tool, tool_call_title(call))
166                    }
167                    _ => return None,
168                };
169                message(role, text, item.created_at_ms)
170            })
171            .collect();
172        Ok((messages, snapshot.session.session_title.clone()))
173    }
174
175    /// The conversation of a session that has not stopped, read from the
176    /// daemon's own projection. It is the same conversation the checkpoint
177    /// would hold, minus whatever has not happened yet.
178    fn projected_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
179        let projection = crate::database::load_materialized_session(session_id)
180            .with_context(|| format!("read the stored transcript of session {session_id}"))?
181            .with_context(|| format!("no stored transcript for session {session_id}"))?;
182        Ok((
183            projected_messages(&projection),
184            projection.session_title.clone(),
185        ))
186    }
187
188    /// The stable key for one session: its checkpoint directory and id. The
189    /// directory is per instance, which is what scopes reconciliation.
190    fn key_for(&self, session_id: &str) -> String {
191        format!("{}/{session_id}", self.sessions_dir.display())
192    }
193
194    /// The newest checkpoint of every session in the directory, by session id.
195    ///
196    /// `had_error` is true when the directory exists but could not be read in
197    /// full; the indexer then skips deletion reconciliation rather than
198    /// archiving every Mjolnir session off a partial listing.
199    fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
200        let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
201        let mut had_error = false;
202        let entries = match std::fs::read_dir(&self.sessions_dir) {
203            Ok(entries) => entries,
204            Err(error) => {
205                if self.sessions_dir.exists() {
206                    tracing::debug!(
207                        directory = %self.sessions_dir.display(),
208                        %error,
209                        "could not list the checkpoint directory for SessionWiki"
210                    );
211                    had_error = true;
212                }
213                return (newest, had_error);
214            }
215        };
216        for entry in entries {
217            let Ok(entry) = entry else {
218                had_error = true;
219                continue;
220            };
221            let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
222            else {
223                continue;
224            };
225            let token = entry
226                .metadata()
227                .ok()
228                .and_then(|metadata| metadata.modified().ok())
229                .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
230                .map(|age| age.as_secs() as i64)
231                .unwrap_or(0);
232            let candidate = ArchiveFile {
233                path: entry.path(),
234                frontier,
235                token,
236            };
237            match newest.get(&session_id) {
238                Some(existing) if existing.frontier >= candidate.frontier => {}
239                _ => {
240                    newest.insert(session_id, candidate);
241                }
242            }
243        }
244        (newest, had_error)
245    }
246}
247
248/// The session a checkpoint file name belongs to, with its generation.
249///
250/// Managed checkpoints carry a frontier and a nonce; an imported archive is
251/// named for its session alone and counts as generation zero.
252fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
253    if let Some(parsed) = managed_checkpoint_archive_name(name) {
254        return Some((parsed.session_id, parsed.frontier));
255    }
256    let stem = name
257        .to_str()
258        .and_then(|name| name.strip_suffix(".hel.zip"))?;
259    mj_core::config::validate_id("session", stem)
260        .is_ok()
261        .then(|| (stem.to_owned(), 0))
262}
263
264/// A running session's conversation, as SessionWiki stores it.
265fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
266    projection
267        .transcript
268        .iter()
269        .filter_map(|item| {
270            let (role, text) = match &item.body {
271                mj_core::state::TranscriptBody::User { content } => (
272                    Role::User,
273                    mj_core::transcript::materialized_content_text(content),
274                ),
275                mj_core::state::TranscriptBody::Agent { chunks, .. } => (
276                    Role::Assistant,
277                    mj_core::transcript::materialized_chunks_text(chunks),
278                ),
279                mj_core::state::TranscriptBody::Tool { call, .. } => {
280                    (Role::Tool, tool_call_title(call))
281                }
282                _ => return None,
283            };
284            message(role, text, item.created_at_ms)
285        })
286        .collect()
287}
288
289/// The tool's own title, which is what the transcript showed the user.
290/// Arguments and output are not worth indexing.
291fn tool_call_title(call: &serde_json::Value) -> String {
292    call.get("title")
293        .and_then(serde_json::Value::as_str)
294        .unwrap_or_default()
295        .to_owned()
296}
297
298/// One indexed message, or nothing when the item carried no text.
299fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
300    let text = text.trim().to_owned();
301    (!text.is_empty()).then(|| Message {
302        role,
303        text,
304        ts: DateTime::from_timestamp_millis(created_at_ms),
305    })
306}
307
308fn parse_time(value: &str) -> Option<DateTime<Utc>> {
309    DateTime::parse_from_rfc3339(value)
310        .ok()
311        .map(|time| time.with_timezone(&Utc))
312}
313
314impl Adapter for MjolnirAdapter {
315    fn name(&self) -> &'static str {
316        TOOL
317    }
318
319    fn root(&self) -> Option<PathBuf> {
320        Some(self.sessions_dir.clone())
321    }
322
323    /// Unused: this is a shared-store adapter, so the indexer enumerates
324    /// sessions through [`Adapter::store`] instead of walking files.
325    fn discover(&self) -> Discovered {
326        Discovered {
327            files: Vec::new(),
328            had_error: false,
329        }
330    }
331
332    fn parse(&self, _path: &Path) -> Result<Session> {
333        anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
334    }
335
336    fn store(&self) -> Option<Store> {
337        self.reload();
338        let (newest, had_error) = self.newest_archives();
339        let mut files = Vec::with_capacity(newest.len());
340        let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
341        for (session_id, archive) in newest {
342            tokens.insert(session_id, archive.token);
343            files.push(archive.path);
344        }
345        // A session that is still running is indexed from the projection, and
346        // its own token replaces any checkpoint token it has: the conversation
347        // has moved on since that checkpoint was written. Listing it also
348        // keeps reconciliation from archiving a running session.
349        let sessions = self
350            .sessions
351            .lock()
352            .unwrap_or_else(std::sync::PoisonError::into_inner);
353        let live = sessions.live.clone();
354        tokens.extend(live);
355        // A rename changes the record and not the conversation, so the
356        // record's own last update is part of the change token. Without it a
357        // renamed session would keep its old title in the index for as long as
358        // its transcript stood still.
359        for (session_id, token) in tokens.iter_mut() {
360            let updated = sessions
361                .records
362                .get(session_id)
363                .and_then(|record| parse_time(&record.updated_at))
364                .map(|updated| updated.timestamp());
365            if let Some(updated) = updated {
366                *token = (*token).max(updated);
367            }
368        }
369        let keys = tokens
370            .into_iter()
371            .map(|(session_id, token)| (self.key_for(&session_id), token))
372            .collect();
373        Some(Store {
374            keys,
375            files,
376            had_error,
377        })
378    }
379
380    /// Every Mjolnir instance publishes under one tool name, so this instance
381    /// speaks only for keys under its own checkpoint directory. Without the
382    /// scope, two instances would archive each other's rows on every sync.
383    fn reconcile_scope(&self) -> Option<String> {
384        Some(format!("{}/", self.sessions_dir.display()))
385    }
386
387    fn parse_key(&self, key: &str) -> Result<Session> {
388        let session_id = key.rsplit('/').next().unwrap_or_default();
389        anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
390        let sessions = self
391            .sessions
392            .lock()
393            .unwrap_or_else(std::sync::PoisonError::into_inner);
394        let (messages, snapshot_title) = if sessions.live.contains_key(session_id) {
395            self.projected_transcript(session_id)?
396        } else {
397            self.checkpointed_transcript(session_id)?
398        };
399        let record = sessions.records.get(session_id);
400
401        let title = record
402            .and_then(|record| record.session_title_override.clone())
403            .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
404            .or_else(|| snapshot_title.clone())
405            .unwrap_or_else(|| {
406                messages
407                    .iter()
408                    .find(|message| message.role == Role::User)
409                    .map(|message| message.text.chars().take(80).collect())
410                    .unwrap_or_default()
411            });
412
413        Ok(Session {
414            id: session_id.to_owned(),
415            tool: TOOL,
416            path: PathBuf::from(key),
417            project: record
418                .and_then(|record| record.project_directory.as_ref())
419                .map(|directory| directory.display().to_string())
420                .unwrap_or_default(),
421            started: record.and_then(|record| parse_time(&record.created_at)),
422            ended: record.and_then(|record| parse_time(&record.updated_at)),
423            title,
424            subagent: sessions.subagent_ids.contains(session_id),
425            messages,
426            touched: Vec::new(),
427            edits: Vec::new(),
428        })
429    }
430}
431
432/// The daemon's SessionWiki sync job.
433///
434/// Triggers coalesce: a request while a sync is running marks a rerun instead
435/// of queueing a second one, so a burst of closing sessions costs one extra
436/// pass. Syncs are single-flight because SessionWiki holds a write transaction
437/// per adapter batch, and two writers only produce a busy error.
438pub struct WikiIndexer {
439    inner: Arc<Indexer>,
440}
441
442#[derive(Default)]
443struct Indexer {
444    /// Held for the whole of one run: this is what makes syncs single-flight.
445    running: tokio::sync::Mutex<()>,
446    notify: tokio::sync::Notify,
447    /// A trigger arrived; the worker has not consumed it yet.
448    requested: AtomicBool,
449    /// At least one waiting trigger asked for a full sync.
450    full_requested: AtomicBool,
451    /// A sync pass is running now. A surface shows this as "topping up", so a
452    /// user knows more results may arrive.
453    in_flight: AtomicBool,
454    last_success: std::sync::Mutex<Option<Success>>,
455}
456
457#[derive(Clone, Copy)]
458struct Success {
459    at: Instant,
460    epoch_seconds: i64,
461}
462
463impl WikiIndexer {
464    /// Start the background sync worker. Without a Tokio runtime (some tests
465    /// build a runtime state without one) the indexer stays inert.
466    pub fn spawn() -> Self {
467        let inner = Arc::new(Indexer::default());
468        if let Ok(handle) = tokio::runtime::Handle::try_current() {
469            let worker = Arc::clone(&inner);
470            handle.spawn(async move { worker.run().await });
471        }
472        Self { inner }
473    }
474
475    /// Ask for a sync. Returns immediately; the work happens in the background.
476    pub fn request_sync(&self, full: bool) {
477        if full {
478            self.inner.full_requested.store(true, Ordering::Release);
479        }
480        self.inner.requested.store(true, Ordering::Release);
481        self.inner.notify.notify_one();
482    }
483
484    /// Run a sync and wait for it, joining a sync already in flight.
485    pub async fn sync_now(&self, full: bool) -> Result<()> {
486        self.inner.sync(full).await
487    }
488
489    /// The state of the index and whether a sync is running, for the surfaces
490    /// that say so while the first build is under way.
491    pub fn status(&self) -> WikiStatus {
492        WikiStatus {
493            state: index_state(),
494            topping_up: self.inner.in_flight.load(Ordering::Acquire)
495                || self.inner.requested.load(Ordering::Acquire),
496        }
497    }
498
499    /// When the last sync succeeded, for callers that trigger on staleness.
500    pub fn last_success(&self) -> Option<Instant> {
501        self.inner
502            .last_success
503            .lock()
504            .unwrap_or_else(std::sync::PoisonError::into_inner)
505            .map(|success| success.at)
506    }
507}
508
509impl Indexer {
510    async fn run(self: Arc<Self>) {
511        loop {
512            self.notify.notified().await;
513            while self.requested.swap(false, Ordering::AcqRel) {
514                let full = self.full_requested.swap(false, Ordering::AcqRel);
515                if let Err(error) = self.sync(full).await {
516                    self.report(&error);
517                    // A failure waits for the next trigger rather than
518                    // retrying straight away: a busy index stays busy for as
519                    // long as the other writer holds it, and a spin would only
520                    // add to the contention.
521                    break;
522                }
523            }
524        }
525    }
526
527    /// Log a failed sync at the level its cause deserves. A busy index is an
528    /// expected collision with another writer, not a fault: mark a rerun and
529    /// say so only in debug output.
530    fn report(&self, error: &anyhow::Error) {
531        if is_busy(error) {
532            self.requested.store(true, Ordering::Release);
533            tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
534        } else {
535            tracing::warn!(%error, "could not sync sessions into SessionWiki");
536        }
537    }
538
539    async fn sync(&self, full: bool) -> Result<()> {
540        let _guard = self.running.lock().await;
541        let since = if full {
542            None
543        } else {
544            self.last_success
545                .lock()
546                .unwrap_or_else(std::sync::PoisonError::into_inner)
547                // A minute of overlap covers checkpoints written while the
548                // previous run was reading the directory.
549                .map(|success| success.epoch_seconds - 60)
550        };
551        let started = Instant::now();
552        self.in_flight.store(true, Ordering::Release);
553        let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
554        self.in_flight.store(false, Ordering::Release);
555        let ran = ran.context("run the SessionWiki sync")??;
556        if ran {
557            *self
558                .last_success
559                .lock()
560                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
561                at: started,
562                epoch_seconds: Utc::now().timestamp(),
563            });
564        }
565        Ok(())
566    }
567}
568
569/// One synchronous sync pass. Returns false when this process must not touch
570/// the index, so a refused run never records a success it did not have.
571fn sync_blocking(since: Option<i64>) -> Result<bool> {
572    if !index_is_writable() {
573        return Ok(false);
574    }
575    let controller =
576        Controller::load().context("load controller state for the SessionWiki sync")?;
577    // Mjolnir's own sessions go first: a cold index walks every other tool's
578    // store for many minutes, and a just-closed session should not wait on it.
579    let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
580        vec![Box::new(MjolnirAdapter::reloading(&controller.state))];
581    adapters.extend(sessionwiki::adapters::all());
582    let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
583    sessionwiki::index::sync_with(&mut connection, &adapters, since)
584        .context("sync the SessionWiki index")?;
585    if since.is_none() {
586        // A full pass has walked every store, so the index is complete enough
587        // for a search to be trusted. The marker is what a later daemon reads
588        // instead of walking the corpus again to find out.
589        record_first_build();
590    }
591    Ok(true)
592}
593
594// ---------------------------------------------------------------------------
595// Which index, and whether it may be touched
596// ---------------------------------------------------------------------------
597
598/// Whether this process may open the index at all.
599///
600/// Indexing is always on, so a process that never resolved where its index
601/// belongs must not reach for one: it would walk the user's real session
602/// stores and write the user's real index. Only Mjolnir's own startup resolves
603/// it (see `mj_core::config::apply_instance_flag`), so this refuses every unit
604/// test that builds a daemon runtime directly and every other embedder, unless
605/// it names an index of its own with `SESSIONWIKI_DATA`.
606fn index_is_isolated() -> bool {
607    static SAID: AtomicBool = AtomicBool::new(false);
608    if mj_core::config::session_index_is_resolved()
609        || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
610    {
611        return true;
612    }
613    if !SAID.swap(true, Ordering::AcqRel) {
614        tracing::debug!(
615            "this process did not resolve a session index location; SessionWiki is not used"
616        );
617    }
618    false
619}
620
621/// Whether the index on disk was written by a SessionWiki at another schema
622/// version.
623///
624/// SessionWiki's own `open` drops and rebuilds its whole cache when the file's
625/// `user_version` differs from the version it was built with, which on a large
626/// corpus costs tens of minutes. Mjolnir will not do that to a user who also
627/// runs the `sessionwiki` command: it reads the version without SessionWiki and
628/// stands aside.
629fn index_version_mismatch() -> bool {
630    static SAID: AtomicBool = AtomicBool::new(false);
631    let Ok(path) = sessionwiki::index::db_path() else {
632        return false;
633    };
634    if !path.exists() {
635        return false;
636    }
637    let version = rusqlite::Connection::open_with_flags(
638        &path,
639        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
640    )
641    .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
642    let version: i64 = match version {
643        Ok(version) => version,
644        Err(error) => {
645            tracing::debug!(%error, "could not read the SessionWiki index schema version");
646            return false;
647        }
648    };
649    // Zero is an index SessionWiki has not finished creating; it is not a
650    // different version.
651    let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
652    if mismatch && !SAID.swap(true, Ordering::AcqRel) {
653        tracing::warn!(
654            found = version,
655            expected = sessionwiki::index::SCHEMA_VERSION,
656            path = %path.display(),
657            "the SessionWiki index was written by another version;              Mjolnir will not open it, because opening it would rebuild it.              Install the matching sessionwiki command"
658        );
659    }
660    mismatch
661}
662
663fn index_is_writable() -> bool {
664    index_is_isolated() && !index_version_mismatch()
665}
666
667/// The file recording that one full sync has completed, holding the schema
668/// version it completed at.
669fn first_build_marker() -> PathBuf {
670    mj_core::config::data_dir().join("sessionwiki-built")
671}
672
673fn record_first_build() {
674    let path = first_build_marker();
675    let version = sessionwiki::index::SCHEMA_VERSION.to_string();
676    if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
677        return;
678    }
679    if let Err(error) = std::fs::write(&path, &version) {
680        tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
681    }
682}
683
684/// Whether this index has completed a full build at this schema version.
685fn first_build_is_done() -> bool {
686    std::fs::read_to_string(first_build_marker())
687        .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
688        && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
689}
690
691/// What a surface should say about this index right now.
692pub fn index_state() -> WikiIndexState {
693    if !index_is_isolated() {
694        return WikiIndexState::Indexing;
695    }
696    if index_version_mismatch() {
697        return WikiIndexState::VersionMismatch;
698    }
699    if first_build_is_done() {
700        WikiIndexState::Ready
701    } else {
702        WikiIndexState::Indexing
703    }
704}
705
706/// Whether a failure is SQLite reporting another writer, which a later trigger
707/// simply retries.
708fn is_busy(error: &anyhow::Error) -> bool {
709    error.chain().any(|cause| {
710        matches!(
711            cause.downcast_ref::<rusqlite::Error>(),
712            Some(rusqlite::Error::SqliteFailure(failure, _))
713                if matches!(
714                    failure.code,
715                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
716                )
717        )
718    })
719}
720
721// ---------------------------------------------------------------------------
722// Queries and restore
723// ---------------------------------------------------------------------------
724
725/// The largest page a caller may ask a wiki query for.
726pub const MAX_WIKI_LIMIT: usize = 200;
727/// The page size a caller that names none gets.
728pub const DEFAULT_WIKI_LIMIT: usize = 50;
729/// SessionWiki's full-text index needs three characters; shorter queries fall
730/// back to a substring scan.
731const MIN_FULLTEXT_QUERY: usize = 3;
732/// How stale the index may be before a query triggers a background sync.
733pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
734
735/// Whether a query should trigger a bounded background sync before it answers.
736pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
737    last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
738}
739
740/// One page of the index, newest first or best match first.
741///
742/// `live` is the set of session ids this daemon still holds, which is what
743/// decides whether a Mjolnir row names a session the user can simply resume.
744/// Runs SQLite work, so callers on the async runtime wrap it in
745/// `spawn_blocking`.
746pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
747    let limit = limit.clamp(1, MAX_WIKI_LIMIT);
748    if !index_is_writable() {
749        // Nothing to answer from: either this process has no index of its own
750        // or the one on disk is at another version. The status beside the rows
751        // says which.
752        return Ok(Vec::new());
753    }
754    let connection = open_readonly()?;
755    let query = query.trim();
756    if query.is_empty() {
757        let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
758            .context("list recent SessionWiki sessions")?;
759        return Ok(rows
760            .into_iter()
761            .map(|row| wiki_row(row, None, live))
762            .collect());
763    }
764    let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
765        sessionwiki::index::search_like(&connection, query, limit, None, None)
766    } else {
767        sessionwiki::index::search(&connection, query, limit, None, None)
768    }
769    .context("search the SessionWiki index")?;
770    let mut rows: Vec<WikiRow> = hits
771        .into_iter()
772        .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
773        .collect();
774    // SessionWiki searches message text alone, so a session known by a title
775    // or a project that is never said out loud would be unfindable. Those
776    // matches follow the full-text ones rather than displacing them.
777    let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
778    for row in named_like(&connection, query)? {
779        if rows.len() >= limit {
780            break;
781        }
782        if found.contains(&row.session_id) {
783            continue;
784        }
785        rows.push(wiki_row(row, None, live));
786    }
787    Ok(rows)
788}
789
790/// How far back a title or project match looks. Those columns have no index of
791/// their own, so this is a scan of the most recent sessions rather than of the
792/// whole corpus.
793const NAME_SCAN_LIMIT: usize = 2_000;
794
795/// Indexed sessions whose title or project contains the query, ignoring case.
796fn named_like(
797    connection: &rusqlite::Connection,
798    query: &str,
799) -> Result<Vec<sessionwiki::index::SessionRow>> {
800    let needle = query.to_lowercase();
801    let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
802        .context("list recent SessionWiki sessions")?;
803    Ok(rows
804        .into_iter()
805        .filter(|row| {
806            row.title.to_lowercase().contains(&needle)
807                || row.project.to_lowercase().contains(&needle)
808        })
809        .collect())
810}
811
812/// The briefing for one indexed session, or `None` when the id names none.
813pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
814    if !index_is_writable() {
815        return Ok(None);
816    }
817    let connection = open_readonly()?;
818    let Some(row) = row_by_id(&connection, id)? else {
819        return Ok(None);
820    };
821    let session = sessionwiki::index::session_from_index(&connection, &row)
822        .context("read an indexed session")?;
823    Ok(Some(sessionwiki::commands::brief_markdown(
824        &session, max_chars, true,
825    )))
826}
827
828/// What a restore needs from the index: the transcript as a snapshot the
829/// compaction pipeline accepts, plus the title and project of the session it
830/// came from.
831pub struct ArchivedSession {
832    pub title: String,
833    /// The project directory the session ran in, when the row names one that
834    /// still exists.
835    pub project_directory: Option<PathBuf>,
836    pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
837}
838
839/// Load one indexed session for restore, or `None` when the id names none.
840pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
841    if !index_is_writable() {
842        return Ok(None);
843    }
844    let connection = open_readonly()?;
845    let Some(row) = row_by_id(&connection, id)? else {
846        return Ok(None);
847    };
848    let session = sessionwiki::index::session_from_index(&connection, &row)
849        .context("read an indexed session")?;
850    let snapshot = snapshot_of(&session)?;
851    Ok(Some(ArchivedSession {
852        title: session.title.clone(),
853        project_directory: project_directory_of(&session.project),
854        snapshot,
855    }))
856}
857
858// ---------------------------------------------------------------------------
859// The archive job
860// ---------------------------------------------------------------------------
861
862/// The stopped sessions that `archive_after_days = older_than_days` has caught,
863/// children before their parents.
864///
865/// A session qualifies when its record is `Stopped`, its last update is at
866/// least that many days old, and every sub-agent child it still has is being
867/// archived in the same pass. The child rule is what keeps the pass from
868/// destroying a session it did not choose: archiving a parent tears its
869/// children down with it, so a child that is still running, or stopped but not
870/// yet old enough, holds its parent back until the next pass.
871///
872/// Pure over controller state, so the rule can be tested without a daemon.
873pub fn sessions_ready_to_archive(
874    sessions: &BTreeMap<String, SessionRecord>,
875    subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
876    now: DateTime<Utc>,
877    older_than_days: u32,
878) -> Vec<String> {
879    let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
880    let aged = |session_id: &String| {
881        sessions.get(session_id).is_some_and(|record| {
882            record.state == mj_core::state::SessionState::Stopped
883                && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
884        })
885    };
886    let selected: BTreeSet<String> = sessions
887        .keys()
888        .filter(|session_id| aged(session_id))
889        .filter(|session_id| {
890            subagents
891                .values()
892                .filter(|child| &&child.parent_session_id == session_id)
893                // A child whose record is already gone holds nothing open.
894                .filter(|child| sessions.contains_key(&child.child_session_id))
895                .all(|child| aged(&child.child_session_id))
896        })
897        .cloned()
898        .collect();
899    let mut ordered: Vec<String> = selected.iter().cloned().collect();
900    ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
901    ordered
902}
903
904/// How many sub-agent parents a session has above it. Deeper sessions are
905/// archived first so a parent never tears down a child the pass still has to
906/// visit.
907fn ancestor_depth(
908    session_id: &str,
909    subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
910) -> usize {
911    let mut depth = 0;
912    let mut current = session_id;
913    // Bounded by the map: a cycle cannot outlive one pass over every entry.
914    while let Some(parent) = subagents
915        .get(current)
916        .map(|child| child.parent_session_id.as_str())
917    {
918        depth += 1;
919        if depth > subagents.len() {
920            break;
921        }
922        current = parent;
923    }
924    depth
925}
926
927/// Which of `session_ids` the index holds under this instance's own key, with
928/// at least one message and not already archived.
929///
930/// This is the gate the archive job will not cross: Mjolnir only deletes its
931/// own copy of a conversation SessionWiki has actually stored. Runs SQLite
932/// work, so callers on the async runtime wrap it in `spawn_blocking`.
933pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
934    if !index_is_writable() {
935        // An index this daemon will not open holds nothing it may act on, and
936        // the archive job deletes data, so it must find nothing here.
937        return Ok(BTreeSet::new());
938    }
939    let connection = open_readonly()?;
940    let sessions_dir = mj_core::config::sessions_dir();
941    let mut indexed = BTreeSet::new();
942    for session_id in session_ids {
943        let key = format!("{}/{session_id}", sessions_dir.display());
944        let rows = sessionwiki::index::resolve(&connection, session_id)
945            .context("look up a stopped session in the SessionWiki index")?;
946        if rows
947            .iter()
948            .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
949        {
950            indexed.insert(session_id.clone());
951        }
952    }
953    Ok(indexed)
954}
955
956fn open_readonly() -> Result<rusqlite::Connection> {
957    sessionwiki::index::open_readonly().context("open the SessionWiki index")
958}
959
960/// The one row an id names exactly. `resolve` matches prefixes, which is right
961/// for a person typing and wrong for a client passing an id back.
962fn row_by_id(
963    connection: &rusqlite::Connection,
964    id: &str,
965) -> Result<Option<sessionwiki::index::SessionRow>> {
966    Ok(sessionwiki::index::resolve(connection, id)
967        .context("look up an indexed session")?
968        .into_iter()
969        .find(|row| row.session_id == id))
970}
971
972fn wiki_row(
973    row: sessionwiki::index::SessionRow,
974    snippet: Option<String>,
975    live: &BTreeSet<String>,
976) -> WikiRow {
977    // Only this daemon's own sessions can be live here, and only under the key
978    // shape the adapter writes: the checkpoint directory and the session id.
979    let hel_session_id = (row.tool == TOOL)
980        .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
981        .filter(|session_id| live.contains(session_id));
982    let native_id = sessionwiki::index::native_id_of(&row.path);
983    WikiRow {
984        id: row.session_id,
985        tool: row.tool,
986        project: row.project,
987        title: row.title,
988        started: row.started,
989        msgs: row.msg_count,
990        preview: row.preview,
991        archived: row.archived,
992        native_id,
993        snippet,
994        hel_session_id,
995    }
996}
997
998/// The project a restored session should open.
999///
1000/// A Mjolnir session runs in a managed worktree under the repository it was
1001/// started from, and that worktree is gone once the session is archived. The
1002/// repository above it is what the user still has, so a worktree path is
1003/// reduced to it. Any other path is used as it stands, and a path that no
1004/// longer exists is left for the caller to replace.
1005fn project_directory_of(project: &str) -> Option<PathBuf> {
1006    if project.trim().is_empty() {
1007        return None;
1008    }
1009    let path = PathBuf::from(project);
1010    let repository = path
1011        .ancestors()
1012        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1013        .and_then(std::path::Path::parent)
1014        .map(std::path::Path::to_path_buf)
1015        .unwrap_or(path);
1016    repository.is_dir().then_some(repository)
1017}
1018
1019/// Rebuild an indexed transcript as a canonical snapshot.
1020///
1021/// The snapshot is only ever read by the compaction pipeline, which wants
1022/// turns: a user message opens a turn and assistant and tool items attach to
1023/// it. Messages before the first user message therefore have nowhere to go and
1024/// are dropped, and a session with no user message at all cannot be restored.
1025fn snapshot_of(
1026    session: &sessionwiki::model::Session,
1027) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1028    use mj_core::archive::{
1029        CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1030        CanonicalTranscriptBody, CanonicalTranscriptItem,
1031    };
1032
1033    let started_ms = session
1034        .started
1035        .map(|time| time.timestamp_millis())
1036        .unwrap_or_default();
1037    let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1038    for message in &session.messages {
1039        let text = message.text.trim();
1040        if text.is_empty() {
1041            continue;
1042        }
1043        // Compaction attaches assistant and tool items to the open turn, so an
1044        // item before the first user message would be dropped anyway.
1045        if transcript.is_empty() && message.role != Role::User {
1046            continue;
1047        }
1048        let position = transcript.len() as u64 + 1;
1049        let body = match message.role {
1050            Role::User => CanonicalTranscriptBody::User {
1051                content: vec![serde_json::json!({"type": "text", "text": text})],
1052            },
1053            Role::Assistant => CanonicalTranscriptBody::Agent {
1054                chunks: vec![serde_json::json!({
1055                    "content": {"type": "text", "text": text}
1056                })],
1057                streaming: false,
1058            },
1059            // The index keeps a tool call's title and nothing else, which is
1060            // what the transcript showed the user.
1061            Role::Tool => CanonicalTranscriptBody::Tool {
1062                call: serde_json::json!({
1063                    "toolCallId": format!("wiki-tool-{position}"),
1064                    "title": text,
1065                    "status": "completed"
1066                }),
1067                terminal_outputs: Vec::new(),
1068                terminal_refs: Vec::new(),
1069                presentation: None,
1070            },
1071        };
1072        let created_at_ms = message
1073            .ts
1074            .map(|time| time.timestamp_millis())
1075            .unwrap_or(started_ms);
1076        transcript.push(CanonicalTranscriptItem {
1077            stable_id: format!("wiki-{position}"),
1078            position,
1079            // The validator wants an ordinal on agent messages and on nothing
1080            // else; one event per item makes the item's own position right.
1081            latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1082                .then_some(position),
1083            created_at_ms,
1084            last_changed_at_ms: created_at_ms,
1085            body,
1086        });
1087    }
1088    anyhow::ensure!(
1089        !transcript.is_empty(),
1090        "the archived session has no prompt to restore from"
1091    );
1092
1093    let event_frontier = transcript.len() as u64;
1094    let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1095    Ok(CanonicalSessionSnapshot {
1096        event_frontier,
1097        // Not a relay frontier, so there is no recorded digest to carry. It has
1098        // to be a well-formed non-genesis digest, and deriving it from the
1099        // session makes two restores of one session agree.
1100        event_frontier_digest: {
1101            use sha2::Digest;
1102            mj_core::hex::lower_hex(sha2::Sha256::digest(
1103                format!("sessionwiki:{}", session.id).as_bytes(),
1104            ))
1105        },
1106        session: CanonicalSessionState {
1107            execution: CanonicalExecutionState::Idle,
1108            last_activity_at_ms,
1109            session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1110            configuration: BTreeMap::new(),
1111        },
1112        transcript,
1113        queued_prompts: Vec::new(),
1114    })
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use std::collections::BTreeMap;
1120    use std::path::Path;
1121
1122    use mj_checkpoint::archive::{
1123        ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1124        CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1125        TargetManifest, write_archive_atomic,
1126    };
1127
1128    use super::*;
1129
1130    fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1131        // Only an agent message carries a content ordinal; the snapshot
1132        // validator rejects one on any other item and demands one here.
1133        let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1134        CanonicalTranscriptItem {
1135            stable_id: format!("item-{position}"),
1136            position,
1137            latest_content_event_ordinal: streamed.then_some(position),
1138            created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1139            last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1140            body,
1141        }
1142    }
1143
1144    /// A managed checkpoint with one prompt, one reply, one tool call, and one
1145    /// thought, which is every transcript shape the adapter decides about.
1146    fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1147        let path = directory.join(format!(
1148            "{session_id}-{frontier}-archive-{}.hel.zip",
1149            "0".repeat(32)
1150        ));
1151        write_archive_atomic(
1152            &path,
1153            &ArchiveInput {
1154                session: SessionManifest {
1155                    id: session_id.into(),
1156                    title: "indexed session".into(),
1157                    harness_kind: mj_core::config::HarnessKind::Codex,
1158                    profile_id: "codex".into(),
1159                    native_session_id: "native-session".into(),
1160                    created_at: "2026-09-01T00:00:00Z".into(),
1161                    checkpointed_at: "2026-09-01T01:00:00Z".into(),
1162                    hel_version: "test".into(),
1163                    relay_version: "test".into(),
1164                    adapter_version: "test".into(),
1165                },
1166                target: TargetManifest {
1167                    template_id: "local".into(),
1168                    target_kind: "local-bare".into(),
1169                    details: BTreeMap::new(),
1170                },
1171                bundle: BundleManifest {
1172                    id: "project".into(),
1173                    primary_repository: "project".into(),
1174                },
1175                canonical_session: CanonicalSessionSnapshot {
1176                    event_frontier: 4,
1177                    event_frontier_digest: "a".repeat(64),
1178                    session: CanonicalSessionState {
1179                        execution: CanonicalExecutionState::Idle,
1180                        last_activity_at_ms: Some(1_700_000_000_004),
1181                        session_title: Some("snapshot title".into()),
1182                        configuration: BTreeMap::new(),
1183                    },
1184                    transcript: vec![
1185                        item(
1186                            1,
1187                            CanonicalTranscriptBody::User {
1188                                content: vec![serde_json::json!({
1189                                    "type": "text",
1190                                    "text": "index this session"
1191                                })],
1192                            },
1193                        ),
1194                        item(
1195                            2,
1196                            CanonicalTranscriptBody::Thought {
1197                                chunks: vec![serde_json::json!({
1198                                    "content": {"type": "text", "text": "pondering"}
1199                                })],
1200                                streaming: false,
1201                            },
1202                        ),
1203                        item(
1204                            3,
1205                            CanonicalTranscriptBody::Tool {
1206                                call: serde_json::json!({
1207                                    "toolCallId": "call-1",
1208                                    "title": "Read config.toml",
1209                                    "status": "completed"
1210                                }),
1211                                terminal_outputs: Vec::new(),
1212                                terminal_refs: Vec::new(),
1213                                presentation: None,
1214                            },
1215                        ),
1216                        item(
1217                            4,
1218                            CanonicalTranscriptBody::Agent {
1219                                chunks: vec![serde_json::json!({
1220                                    "content": {"type": "text", "text": "done"}
1221                                })],
1222                                streaming: false,
1223                            },
1224                        ),
1225                    ],
1226                    queued_prompts: Vec::new(),
1227                },
1228                native_artifacts: Vec::new(),
1229                repositories: Vec::new(),
1230            },
1231        )
1232        .unwrap();
1233    }
1234
1235    fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1236        adapter_with_live(directory, session_id, BTreeMap::new())
1237    }
1238
1239    fn adapter_with_live(
1240        directory: &Path,
1241        session_id: &str,
1242        live: BTreeMap<String, i64>,
1243    ) -> MjolnirAdapter {
1244        let record = SessionRecord {
1245            id: session_id.into(),
1246            ..record_template()
1247        };
1248        MjolnirAdapter {
1249            sessions_dir: directory.to_path_buf(),
1250            sessions: std::sync::Mutex::new(Sessions {
1251                records: BTreeMap::from([(session_id.to_owned(), record)]),
1252                subagent_ids: BTreeSet::new(),
1253                live,
1254            }),
1255            reload: false,
1256        }
1257    }
1258
1259    fn record_template() -> SessionRecord {
1260        SessionRecord {
1261            build_cache: None,
1262            container_workspace: None,
1263            mjolnir_subagents: None,
1264            create_managed_worktree: None,
1265            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1266            archived: false,
1267            container_cpus: None,
1268            container_memory: None,
1269            id: "0123456789abcdef0123456789abcdef".into(),
1270            title: "indexed session".into(),
1271            harness_kind: mj_core::config::HarnessKind::Codex,
1272            last_profile: "codex".into(),
1273            bundle_id: "project".into(),
1274            project_directory: Some(PathBuf::from("/home/dev/project")),
1275            managed_worktree: None,
1276            target_template_id: "local-bare".into(),
1277            resource_allocation: None,
1278            additional_mounts: Vec::new(),
1279            state: mj_core::state::SessionState::Stopped,
1280            target: None,
1281            native_session_id: Some("native-session".into()),
1282            acp_session_title: Some("the harness title".into()),
1283            session_title_override: None,
1284            created_at: "2026-09-01T00:00:00Z".into(),
1285            updated_at: "2026-09-01T01:00:00Z".into(),
1286            viewed_through_event_ordinal: 0,
1287            draft_input: String::new(),
1288            last_error: None,
1289            last_checkpoint_error: None,
1290            checkpoint: None,
1291        }
1292    }
1293
1294    #[test]
1295    fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1296        let directory = tempfile::tempdir().unwrap();
1297        let session_id = "0123456789abcdef0123456789abcdef";
1298        write_archive(directory.path(), session_id, 1);
1299        write_archive(directory.path(), session_id, 7);
1300        let adapter = adapter(directory.path(), session_id);
1301
1302        let store = adapter.store().expect("the adapter is a shared store");
1303        let key = format!("{}/{session_id}", directory.path().display());
1304        assert_eq!(
1305            store
1306                .keys
1307                .iter()
1308                .map(|(key, _)| key.as_str())
1309                .collect::<Vec<_>>(),
1310            vec![key.as_str()]
1311        );
1312        assert!(!store.had_error);
1313        assert_eq!(store.files.len(), 1);
1314        assert!(
1315            store.files[0]
1316                .file_name()
1317                .unwrap()
1318                .to_str()
1319                .unwrap()
1320                .contains("-7-archive-"),
1321            "the newest checkpoint is the one indexed: {:?}",
1322            store.files[0]
1323        );
1324        assert_eq!(
1325            adapter.reconcile_scope(),
1326            Some(format!("{}/", directory.path().display()))
1327        );
1328
1329        let session = adapter.parse_key(&key).unwrap();
1330        assert_eq!(session.id, session_id);
1331        assert_eq!(session.tool, "mjolnir");
1332        assert_eq!(session.path, PathBuf::from(&key));
1333        assert_eq!(session.project, "/home/dev/project");
1334        assert_eq!(session.title, "the harness title");
1335        assert!(!session.subagent);
1336        assert_eq!(
1337            session
1338                .messages
1339                .iter()
1340                .map(|message| (message.role, message.text.as_str()))
1341                .collect::<Vec<_>>(),
1342            vec![
1343                (Role::User, "index this session"),
1344                (Role::Tool, "Read config.toml"),
1345                (Role::Assistant, "done"),
1346            ]
1347        );
1348    }
1349
1350    fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1351        use mj_core::transcript::{TranscriptBody, TranscriptItem};
1352        let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1353        let mut push = |position: u64, body: TranscriptBody| {
1354            let streamed = matches!(body, TranscriptBody::Agent { .. });
1355            projected
1356                .transcript
1357                .push(std::sync::Arc::new(TranscriptItem {
1358                    stable_id: format!("item-{position}"),
1359                    position,
1360                    latest_content_event_ordinal: streamed.then_some(position),
1361                    created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1362                    last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1363                    body,
1364                }));
1365        };
1366        push(
1367            1,
1368            TranscriptBody::User {
1369                content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1370            },
1371        );
1372        push(
1373            2,
1374            TranscriptBody::Thought {
1375                chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1376                streaming: false,
1377            },
1378        );
1379        push(
1380            3,
1381            TranscriptBody::Tool {
1382                call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1383                terminal_outputs: Vec::new(),
1384                terminal_refs: Vec::new(),
1385                presentation: None,
1386            },
1387        );
1388        push(
1389            4,
1390            TranscriptBody::Agent {
1391                chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1392                streaming: false,
1393            },
1394        );
1395        projected.session_title = Some("the live title".into());
1396        projected
1397    }
1398
1399    /// A session that has never been checkpointed is indexed from the
1400    /// daemon's own projection, with the same roles a checkpoint would give.
1401    #[test]
1402    fn a_running_session_is_indexed_from_its_stored_transcript() {
1403        let session_id = "0123456789abcdef0123456789abcdef";
1404        assert_eq!(
1405            projected_messages(&projection(session_id))
1406                .iter()
1407                .map(|message| (message.role, message.text.clone()))
1408                .collect::<Vec<_>>(),
1409            vec![
1410                (Role::User, "still talking".to_owned()),
1411                (Role::Tool, "Read README.md".to_owned()),
1412                (Role::Assistant, "reading".to_owned()),
1413            ],
1414            "a thought is skipped and every other item keeps its role"
1415        );
1416    }
1417
1418    /// A running session is listed under the same key as a stopped one, with
1419    /// its own change token, so it is searchable before it is ever closed and
1420    /// reconciliation never archives it. When it stops, the key stays and the
1421    /// checkpoint becomes its source.
1422    #[test]
1423    fn a_running_session_is_listed_with_its_own_change_token() {
1424        let directory = tempfile::tempdir().unwrap();
1425        let running = "0123456789abcdef0123456789abcdef";
1426        let never_checkpointed = "fedcba9876543210fedcba9876543210";
1427        write_archive(directory.path(), running, 3);
1428        let live = adapter_with_live(
1429            directory.path(),
1430            running,
1431            BTreeMap::from([
1432                (running.to_owned(), 1_900_000_000),
1433                (never_checkpointed.to_owned(), 1_900_000_001),
1434            ]),
1435        );
1436
1437        let store = live.store().expect("the adapter is a shared store");
1438        let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
1439        assert_eq!(
1440            store.keys,
1441            vec![
1442                (key_of(running), 1_900_000_000),
1443                (key_of(never_checkpointed), 1_900_000_001),
1444            ],
1445            "a live session's own token replaces the checkpoint's"
1446        );
1447
1448        // Once it stops it leaves the live set, and the checkpoint's own
1449        // modification time is the token again.
1450        let stopped = adapter(directory.path(), running);
1451        let keys = stopped.store().expect("a shared store").keys;
1452        assert_eq!(keys.len(), 1);
1453        assert_eq!(keys[0].0, key_of(running));
1454        assert_ne!(keys[0].1, 1_900_000_000);
1455        assert_eq!(
1456            stopped.parse_key(&key_of(running)).unwrap().title,
1457            "the harness title",
1458            "a stopped session is parsed from its checkpoint"
1459        );
1460    }
1461
1462    /// Renaming a session leaves its conversation untouched, so only the
1463    /// record's own last update can tell the index the title moved.
1464    #[test]
1465    fn a_rename_moves_a_session_change_token() {
1466        let directory = tempfile::tempdir().unwrap();
1467        let session_id = "0123456789abcdef0123456789abcdef";
1468        write_archive(directory.path(), session_id, 1);
1469        let adapter = adapter(directory.path(), session_id);
1470        let before = adapter.store().expect("a shared store").keys[0].1;
1471
1472        {
1473            let mut sessions = adapter.sessions.lock().unwrap();
1474            let record = sessions.records.get_mut(session_id).unwrap();
1475            record.session_title_override = Some("the new name".into());
1476            record.updated_at = "2099-01-01T00:00:00Z".into();
1477        }
1478        let after = adapter.store().expect("a shared store").keys[0].1;
1479        assert!(
1480            after > before,
1481            "a renamed session is re-indexed: {before} then {after}"
1482        );
1483        assert_eq!(
1484            adapter
1485                .parse_key(&format!("{}/{session_id}", directory.path().display()))
1486                .unwrap()
1487                .title,
1488            "the new name"
1489        );
1490    }
1491
1492    fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
1493        Session {
1494            id: "0123456789abcdef0123456789abcdef".into(),
1495            tool: "mjolnir",
1496            path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
1497            project: "/home/dev/project".into(),
1498            started: DateTime::from_timestamp_millis(1_700_000_000_000),
1499            ended: None,
1500            title: "the archived session".into(),
1501            subagent: false,
1502            messages: messages
1503                .into_iter()
1504                .map(|(role, text)| Message {
1505                    role,
1506                    text: text.to_owned(),
1507                    ts: None,
1508                })
1509                .collect(),
1510            touched: Vec::new(),
1511            edits: Vec::new(),
1512        }
1513    }
1514
1515    /// The snapshot a restore hands to compaction has to satisfy the same
1516    /// validator a real checkpoint does, and has to carry every message in
1517    /// order.
1518    #[test]
1519    fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
1520        let snapshot = snapshot_of(&indexed(vec![
1521            (Role::User, "make the tests green"),
1522            (Role::Tool, "Read src/lib.rs"),
1523            (Role::Assistant, "they are green now"),
1524            (Role::User, "  "),
1525        ]))
1526        .unwrap();
1527
1528        snapshot.validate().expect("the snapshot is well formed");
1529        assert_eq!(snapshot.event_frontier, 3);
1530        assert_eq!(
1531            snapshot.session.session_title.as_deref(),
1532            Some("the archived session")
1533        );
1534        assert!(snapshot.session.last_activity_at_ms.is_some());
1535        let bodies = snapshot
1536            .transcript
1537            .iter()
1538            .map(|item| match &item.body {
1539                mj_core::archive::CanonicalTranscriptBody::User { content } => (
1540                    "user",
1541                    mj_core::transcript::materialized_content_text(content),
1542                ),
1543                mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
1544                    "agent",
1545                    mj_core::transcript::materialized_chunks_text(chunks),
1546                ),
1547                mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
1548                    "tool",
1549                    call["title"].as_str().unwrap_or_default().to_owned(),
1550                ),
1551                _ => ("other", String::new()),
1552            })
1553            .collect::<Vec<_>>();
1554        assert_eq!(
1555            bodies,
1556            vec![
1557                ("user", "make the tests green".to_owned()),
1558                ("tool", "Read src/lib.rs".to_owned()),
1559                ("agent", "they are green now".to_owned()),
1560            ],
1561            "the blank message is dropped and every other one keeps its role"
1562        );
1563    }
1564
1565    /// Compaction attaches assistant and tool items to the open turn, so an
1566    /// index that starts mid-conversation must not produce a snapshot whose
1567    /// first item has no turn to join.
1568    #[test]
1569    fn messages_before_the_first_prompt_are_dropped() {
1570        let snapshot = snapshot_of(&indexed(vec![
1571            (Role::Assistant, "still working"),
1572            (Role::User, "carry on"),
1573        ]))
1574        .unwrap();
1575        assert_eq!(snapshot.transcript.len(), 1);
1576        assert_eq!(snapshot.transcript[0].position, 1);
1577        snapshot.validate().unwrap();
1578
1579        let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
1580        assert!(
1581            error.to_string().contains("no prompt"),
1582            "a session with no prompt cannot be restored: {error}"
1583        );
1584    }
1585
1586    fn record(
1587        session_id: &str,
1588        state: mj_core::state::SessionState,
1589        updated_at: &str,
1590    ) -> SessionRecord {
1591        SessionRecord {
1592            id: session_id.into(),
1593            state,
1594            updated_at: updated_at.into(),
1595            ..record_template()
1596        }
1597    }
1598
1599    fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
1600        mj_core::subagent::SubagentRecord {
1601            child_session_id: child_session_id.into(),
1602            parent_session_id: parent_session_id.into(),
1603            task_name: "task".into(),
1604            profile_id: "codex".into(),
1605            model: None,
1606            effort: None,
1607            working_directory: PathBuf::new(),
1608            initial_prompt: "do the thing".into(),
1609            request_key: "key".into(),
1610            created_at: "2026-09-01T00:00:00Z".into(),
1611            noticed_turn: None,
1612        }
1613    }
1614
1615    fn ready(
1616        sessions: Vec<SessionRecord>,
1617        children: Vec<mj_core::subagent::SubagentRecord>,
1618    ) -> Vec<String> {
1619        let now = parse_time("2026-09-10T00:00:00Z").unwrap();
1620        sessions_ready_to_archive(
1621            &sessions
1622                .into_iter()
1623                .map(|record| (record.id.clone(), record))
1624                .collect(),
1625            &children
1626                .into_iter()
1627                .map(|child| (child.child_session_id.clone(), child))
1628                .collect(),
1629            now,
1630            3,
1631        )
1632    }
1633
1634    #[test]
1635    fn only_stopped_sessions_past_the_cut_off_are_archived() {
1636        use mj_core::state::SessionState;
1637        let selected = ready(
1638            vec![
1639                record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1640                record(
1641                    "just-stopped",
1642                    SessionState::Stopped,
1643                    "2026-09-09T00:00:00Z",
1644                ),
1645                record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
1646                record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
1647                record("unparsable", SessionState::Stopped, "not a time"),
1648                // Exactly the cut-off counts as old enough.
1649                record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
1650            ],
1651            Vec::new(),
1652        );
1653        assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
1654    }
1655
1656    #[test]
1657    fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
1658        use mj_core::state::SessionState;
1659        let selected = ready(
1660            vec![
1661                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1662                record(
1663                    "running-child",
1664                    SessionState::Running,
1665                    "2026-09-01T00:00:00Z",
1666                ),
1667            ],
1668            vec![child("running-child", "parent")],
1669        );
1670        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
1671
1672        let selected = ready(
1673            vec![
1674                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1675                record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
1676            ],
1677            vec![child("young-child", "parent")],
1678        );
1679        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
1680
1681        // A child whose record is already gone holds nothing open.
1682        let selected = ready(
1683            vec![record(
1684                "parent",
1685                SessionState::Stopped,
1686                "2026-09-01T00:00:00Z",
1687            )],
1688            vec![child("departed-child", "parent")],
1689        );
1690        assert_eq!(selected, vec!["parent"]);
1691    }
1692
1693    #[test]
1694    fn children_are_archived_before_their_parents() {
1695        use mj_core::state::SessionState;
1696        let selected = ready(
1697            vec![
1698                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1699                record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1700                record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1701            ],
1702            vec![child("child", "parent"), child("grandchild", "child")],
1703        );
1704        assert_eq!(selected, vec!["grandchild", "child", "parent"]);
1705    }
1706}