1mod harness_adapters;
13pub(crate) mod history;
14mod provenance;
15pub mod tags;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::time::Instant;
22
23use anyhow::{Context, Result};
24use chrono::{DateTime, Utc};
25
26use mj_client::daemon::{
27 WikiHitBlock, WikiHitTranscript, WikiIndexState, WikiRow, WikiSessionInfo, WikiSessionStatus,
28 WikiStatus,
29};
30use mj_core::config::HarnessKind;
31use mj_core::state::{SessionRecord, State};
32use sessionwiki::adapters::{Adapter, Discovered, Store};
33use sessionwiki::model::{Message, Role, Session};
34
35use crate::controller::Controller;
36use crate::controller::checkpoint::managed_checkpoint_archive_name;
37use harness_adapters::HarnessAdapter;
38
39const TOOL: &str = "mjolnir";
43
44struct ArchiveFile {
46 path: PathBuf,
47 frontier: u64,
48 token: i64,
50}
51
52#[derive(Default)]
56struct Sessions {
57 records: BTreeMap<String, SessionRecord>,
58 subagent_ids: BTreeSet<String>,
59 live: BTreeMap<String, i64>,
61}
62
63impl Sessions {
64 fn of(state: &State) -> Self {
65 Self {
66 records: state.sessions.clone(),
67 subagent_ids: state.subagents.keys().cloned().collect(),
68 live: live_tokens(state),
69 }
70 }
71}
72
73fn live_tokens(state: &State) -> BTreeMap<String, i64> {
80 let activity = match crate::database::load_transcribed_session_activity() {
81 Ok(activity) => activity,
82 Err(error) => {
83 tracing::warn!(%error, "could not read session activity for SessionWiki");
84 return BTreeMap::new();
85 }
86 };
87 state
88 .sessions
89 .iter()
90 .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
91 .filter_map(|(session_id, _)| {
92 let watermark = activity.get(session_id)?;
93 Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
94 })
95 .collect()
96}
97
98pub struct MjolnirAdapter {
100 sessions_dir: PathBuf,
101 sessions: std::sync::Mutex<Sessions>,
102 reload: bool,
104}
105
106impl MjolnirAdapter {
107 pub fn from_state(state: &State) -> Self {
110 Self {
111 sessions_dir: mj_core::config::sessions_dir(),
112 sessions: std::sync::Mutex::new(Sessions::of(state)),
113 reload: false,
114 }
115 }
116
117 pub fn reloading(state: &State) -> Self {
126 Self {
127 reload: true,
128 ..Self::from_state(state)
129 }
130 }
131
132 pub fn indexed_tags(&self) -> BTreeMap<String, tags::MjTags> {
141 let sessions = self
142 .sessions
143 .lock()
144 .unwrap_or_else(std::sync::PoisonError::into_inner);
145 sessions
146 .records
147 .iter()
148 .map(|(session_id, record)| {
149 (
150 session_id.clone(),
151 tags::MjTags {
152 target: Some(record.target_template_id.clone()).filter(|id| !id.is_empty()),
153 profile: Some(record.last_profile.clone()).filter(|id| !id.is_empty()),
154 harness: Some(record.harness_kind.id().to_owned()),
155 },
156 )
157 })
158 .collect()
159 }
160
161 fn reload(&self) {
162 if !self.reload {
163 return;
164 }
165 match Controller::load() {
166 Ok(controller) => {
167 *self
168 .sessions
169 .lock()
170 .unwrap_or_else(std::sync::PoisonError::into_inner) =
171 Sessions::of(&controller.state)
172 }
173 Err(error) => {
174 tracing::warn!(%error, "could not refresh session records for SessionWiki")
175 }
176 }
177 }
178
179 fn checkpointed_transcript(&self, session_id: &str) -> Result<IndexedTranscript> {
182 let (newest, _) = self.newest_archives();
183 let archive = newest
184 .get(session_id)
185 .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
186 let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
187 .with_context(|| format!("read checkpoint {}", archive.path.display()))?
188 .canonical_session()
189 .with_context(|| format!("read the transcript of session {session_id}"))?;
190 let mut evidence = provenance::Evidence::default();
191 for item in &snapshot.transcript {
192 if let mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } = &item.body {
193 evidence.observe(call, item.created_at_ms);
194 }
195 }
196 let messages = summary_messages(mj_transcript::summary::TranscriptSummary::from_snapshot(
197 &snapshot,
198 ));
199 Ok(IndexedTranscript {
200 messages,
201 title: snapshot.session.session_title.clone(),
202 evidence,
203 })
204 }
205
206 fn projected_transcript(&self, session_id: &str) -> Result<IndexedTranscript> {
210 let projection = crate::database::load_materialized_session(session_id)
211 .with_context(|| format!("read the stored transcript of session {session_id}"))?
212 .with_context(|| format!("no stored transcript for session {session_id}"))?;
213 let mut evidence = provenance::Evidence::default();
214 for item in &projection.transcript {
215 if let mj_core::state::TranscriptBody::Tool { call, .. } = &item.body {
216 evidence.observe(call, item.created_at_ms);
217 }
218 }
219 Ok(IndexedTranscript {
220 messages: projected_messages(&projection),
221 title: projection.session_title.clone(),
222 evidence,
223 })
224 }
225
226 fn key_for(&self, session_id: &str) -> String {
229 format!("{}/{session_id}", self.sessions_dir.display())
230 }
231
232 fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
238 let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
239 let mut had_error = false;
240 let entries = match std::fs::read_dir(&self.sessions_dir) {
241 Ok(entries) => entries,
242 Err(error) => {
243 if self.sessions_dir.exists() {
244 tracing::debug!(
245 directory = %self.sessions_dir.display(),
246 %error,
247 "could not list the checkpoint directory for SessionWiki"
248 );
249 had_error = true;
250 }
251 return (newest, had_error);
252 }
253 };
254 for entry in entries {
255 let Ok(entry) = entry else {
256 had_error = true;
257 continue;
258 };
259 let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
260 else {
261 continue;
262 };
263 let token = entry
264 .metadata()
265 .ok()
266 .and_then(|metadata| metadata.modified().ok())
267 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
268 .map(|age| age.as_secs() as i64)
269 .unwrap_or(0);
270 let candidate = ArchiveFile {
271 path: entry.path(),
272 frontier,
273 token,
274 };
275 match newest.get(&session_id) {
276 Some(existing) if existing.frontier >= candidate.frontier => {}
277 _ => {
278 newest.insert(session_id, candidate);
279 }
280 }
281 }
282 (newest, had_error)
283 }
284}
285
286struct IndexedTranscript {
287 messages: Vec<Message>,
288 title: Option<String>,
289 evidence: provenance::Evidence,
290}
291
292fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
297 if let Some(parsed) = managed_checkpoint_archive_name(name) {
298 return Some((parsed.session_id, parsed.frontier));
299 }
300 let stem = name
301 .to_str()
302 .and_then(|name| name.strip_suffix(".hel.zip"))?;
303 mj_core::config::validate_id("session", stem)
304 .is_ok()
305 .then(|| (stem.to_owned(), 0))
306}
307
308fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
310 summary_messages(mj_transcript::summary::TranscriptSummary::from_materialized(projection))
311}
312
313fn summary_messages(summary: mj_transcript::summary::TranscriptSummary) -> Vec<Message> {
314 use mj_transcript::summary::SummaryRole;
315 summary
316 .entries
317 .into_iter()
318 .filter_map(|entry| {
319 let role = match entry.role {
320 SummaryRole::User => Role::User,
321 SummaryRole::Assistant => Role::Assistant,
322 SummaryRole::Tool => Role::Tool,
323 SummaryRole::Plan => return None,
324 };
325 message(role, entry.body(), entry.created_at_ms)
326 })
327 .collect()
328}
329
330fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
332 let text = text.trim().to_owned();
333 (!text.is_empty()).then(|| Message {
334 role,
335 text,
336 ts: DateTime::from_timestamp_millis(created_at_ms),
337 })
338}
339
340fn parse_time(value: &str) -> Option<DateTime<Utc>> {
341 DateTime::parse_from_rfc3339(value)
342 .ok()
343 .map(|time| time.with_timezone(&Utc))
344}
345
346impl Adapter for MjolnirAdapter {
347 fn name(&self) -> &'static str {
348 TOOL
349 }
350
351 fn root(&self) -> Option<PathBuf> {
352 Some(self.sessions_dir.clone())
353 }
354
355 fn discover(&self) -> Discovered {
358 Discovered {
359 files: Vec::new(),
360 had_error: false,
361 }
362 }
363
364 fn parse(&self, _path: &Path) -> Result<Session> {
365 anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
366 }
367
368 fn store(&self) -> Option<Store> {
369 self.reload();
370 let (newest, had_error) = self.newest_archives();
371 let mut files = Vec::with_capacity(newest.len());
372 let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
373 for (session_id, archive) in newest {
374 tokens.insert(session_id, archive.token);
375 files.push(archive.path);
376 }
377 let sessions = self
382 .sessions
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 let live = sessions.live.clone();
386 tokens.extend(live);
387 for (session_id, token) in tokens.iter_mut() {
392 let updated = sessions
393 .records
394 .get(session_id)
395 .and_then(|record| parse_time(&record.updated_at))
396 .map(|updated| updated.timestamp());
397 if let Some(updated) = updated {
398 *token = (*token).max(updated);
399 }
400 }
401 let keys = tokens
402 .into_iter()
403 .map(|(session_id, token)| {
404 (
405 self.key_for(&session_id),
406 token
407 .saturating_mul(1024)
408 .saturating_add(i64::from(mj_transcript::summary::SUMMARY_VERSION)),
409 )
410 })
411 .collect();
412 Some(Store {
413 keys,
414 files,
415 had_error,
416 })
417 }
418
419 fn reconcile_scope(&self) -> Option<String> {
423 Some(format!("{}/", self.sessions_dir.display()))
424 }
425
426 fn parse_key(&self, key: &str) -> Result<Session> {
427 let session_id = key.rsplit('/').next().unwrap_or_default();
428 anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
429 let sessions = self
430 .sessions
431 .lock()
432 .unwrap_or_else(std::sync::PoisonError::into_inner);
433 let IndexedTranscript {
434 messages,
435 title: snapshot_title,
436 evidence,
437 } = if sessions.live.contains_key(session_id) {
438 self.projected_transcript(session_id)?
439 } else {
440 self.checkpointed_transcript(session_id)?
441 };
442 let record = sessions.records.get(session_id);
443
444 let title = record
445 .and_then(|record| record.session_title_override.clone())
446 .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
447 .or_else(|| snapshot_title.clone())
448 .unwrap_or_else(|| {
449 messages
450 .iter()
451 .find(|message| message.role == Role::User)
452 .map(|message| message.text.chars().take(80).collect())
453 .unwrap_or_default()
454 });
455
456 Ok(Session {
457 id: session_id.to_owned(),
458 tool: TOOL,
459 path: PathBuf::from(key),
460 project: record
461 .and_then(|record| record.project_directory.as_ref())
462 .map(|directory| directory.display().to_string())
463 .unwrap_or_default(),
464 started: record.and_then(|record| parse_time(&record.created_at)),
465 ended: record.and_then(|record| parse_time(&record.updated_at)),
466 title,
467 subagent: sessions.subagent_ids.contains(session_id),
468 messages,
469 touched: evidence.paths.into_iter().collect(),
470 edits: evidence.edits,
471 })
472 }
473}
474
475struct SharedMjolnirAdapter(Arc<MjolnirAdapter>);
483
484impl Adapter for SharedMjolnirAdapter {
485 fn name(&self) -> &'static str {
486 self.0.name()
487 }
488
489 fn root(&self) -> Option<PathBuf> {
490 self.0.root()
491 }
492
493 fn discover(&self) -> Discovered {
494 self.0.discover()
495 }
496
497 fn parse(&self, path: &Path) -> Result<Session> {
498 self.0.parse(path)
499 }
500
501 fn store(&self) -> Option<Store> {
502 self.0.store()
503 }
504
505 fn parse_key(&self, key: &str) -> Result<Session> {
506 self.0.parse_key(key)
507 }
508
509 fn reconcile_scope(&self) -> Option<String> {
510 self.0.reconcile_scope()
511 }
512}
513
514pub struct WikiIndexer {
521 inner: Arc<Indexer>,
522}
523
524#[derive(Default)]
525struct Indexer {
526 running: tokio::sync::Mutex<()>,
528 notify: tokio::sync::Notify,
529 requested: AtomicBool,
531 full_requested: AtomicBool,
533 in_flight: AtomicBool,
536 last_success: std::sync::Mutex<Option<Success>>,
537}
538
539#[derive(Clone, Copy)]
540struct Success {
541 at: Instant,
542 epoch_seconds: i64,
543}
544
545impl WikiIndexer {
546 pub fn spawn() -> Self {
549 let inner = Arc::new(Indexer::default());
550 if let Ok(handle) = tokio::runtime::Handle::try_current() {
551 let worker = Arc::clone(&inner);
552 handle.spawn(async move { worker.run().await });
553 }
554 Self { inner }
555 }
556
557 pub fn request_sync(&self, full: bool) {
559 if full {
560 self.inner.full_requested.store(true, Ordering::Release);
561 }
562 self.inner.requested.store(true, Ordering::Release);
563 self.inner.notify.notify_one();
564 }
565
566 pub async fn sync_now(&self, full: bool) -> Result<()> {
568 self.inner.sync(full).await
569 }
570
571 pub fn status(&self) -> WikiStatus {
574 WikiStatus {
575 state: index_state(),
576 topping_up: self.inner.in_flight.load(Ordering::Acquire)
577 || self.inner.requested.load(Ordering::Acquire),
578 }
579 }
580
581 pub fn last_success(&self) -> Option<Instant> {
583 self.inner
584 .last_success
585 .lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner)
587 .map(|success| success.at)
588 }
589}
590
591impl Indexer {
592 async fn run(self: Arc<Self>) {
593 loop {
594 self.notify.notified().await;
595 while self.requested.swap(false, Ordering::AcqRel) {
596 let full = self.full_requested.swap(false, Ordering::AcqRel);
597 if let Err(error) = self.sync(full).await {
598 self.report(&error);
599 break;
604 }
605 }
606 }
607 }
608
609 fn report(&self, error: &anyhow::Error) {
613 if is_busy(error) {
614 self.requested.store(true, Ordering::Release);
615 tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
616 } else {
617 tracing::warn!(%error, "could not sync sessions into SessionWiki");
618 }
619 }
620
621 async fn sync(&self, full: bool) -> Result<()> {
622 let _guard = self.running.lock().await;
623 let since = if full {
624 None
625 } else {
626 self.last_success
627 .lock()
628 .unwrap_or_else(std::sync::PoisonError::into_inner)
629 .map(|success| success.epoch_seconds - 60)
632 };
633 let started = Instant::now();
634 self.in_flight.store(true, Ordering::Release);
635 let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
636 self.in_flight.store(false, Ordering::Release);
637 let ran = ran.context("run the SessionWiki sync")??;
638 if ran {
639 *self
640 .last_success
641 .lock()
642 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
643 at: started,
644 epoch_seconds: Utc::now().timestamp(),
645 });
646 }
647 Ok(())
648 }
649}
650
651fn sync_blocking(since: Option<i64>) -> Result<bool> {
654 if !index_is_writable() {
655 return Ok(false);
656 }
657 let controller =
658 Controller::load().context("load controller state for the SessionWiki sync")?;
659 let mjolnir = Arc::new(MjolnirAdapter::reloading(&controller.state));
662 let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
663 vec![Box::new(SharedMjolnirAdapter(Arc::clone(&mjolnir)))];
664 adapters.extend(native_adapters(&controller.config));
665 let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
666 sessionwiki::index::sync_with(&mut connection, &adapters, since)
667 .context("sync the SessionWiki index")?;
668 write_session_tags(&mut connection, &mjolnir.indexed_tags())
669 .context("store Mjolnir's session metadata in the SessionWiki index")?;
670 provenance::backfill(&mut connection, &mjolnir).context("backfill Mjolnir file provenance")?;
671 if since.is_none() {
672 record_first_build();
676 }
677 Ok(true)
678}
679
680fn write_session_tags(
690 connection: &mut rusqlite::Connection,
691 session_tags: &BTreeMap<String, tags::MjTags>,
692) -> Result<()> {
693 if session_tags.is_empty() {
694 return Ok(());
695 }
696 let transaction = connection
697 .transaction()
698 .context("open a transaction for the session metadata")?;
699 for (session_id, session) in session_tags {
700 if session.is_empty() {
701 continue;
702 }
703 tags::write(&transaction, session_id, session)?;
704 }
705 transaction
706 .commit()
707 .context("commit the session metadata")?;
708 Ok(())
709}
710
711fn native_adapters(config: &mj_core::config::Config) -> Vec<Box<dyn Adapter>> {
728 let mut seen: BTreeSet<(HarnessKind, &Path)> = BTreeSet::new();
731 let mut adapters: Vec<Box<dyn Adapter>> = Vec::new();
732 for (_, profile) in config.enabled_profiles() {
733 if !seen.insert((profile.kind, profile.home.as_path())) {
735 continue;
736 }
737 let adapter: Box<dyn Adapter> = match profile.kind {
738 HarnessKind::Codex => {
739 Box::new(sessionwiki::adapters::Codex::in_home(profile.home.clone()))
740 }
741 HarnessKind::Claude => Box::new(sessionwiki::adapters::ClaudeCode::in_home(
742 profile.home.clone(),
743 )),
744 kind => match HarnessAdapter::in_home(kind, profile.home.clone()) {
745 Some(adapter) => Box::new(adapter),
746 None => continue,
747 },
748 };
749 adapters.push(adapter);
750 }
751 adapters.extend(
752 sessionwiki::adapters::all()
753 .into_iter()
754 .filter(|adapter| !matches!(adapter.name(), "codex" | "claude-code")),
755 );
756 adapters
757}
758
759fn index_is_isolated() -> bool {
772 static SAID: AtomicBool = AtomicBool::new(false);
773 if mj_core::config::session_index_is_resolved()
774 || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
775 {
776 return true;
777 }
778 if !SAID.swap(true, Ordering::AcqRel) {
779 tracing::debug!(
780 "this process did not resolve a session index location; SessionWiki is not used"
781 );
782 }
783 false
784}
785
786fn index_version_mismatch() -> bool {
795 static SAID: AtomicBool = AtomicBool::new(false);
796 let Ok(path) = sessionwiki::index::db_path() else {
797 return false;
798 };
799 if !path.exists() {
800 return false;
801 }
802 let version = rusqlite::Connection::open_with_flags(
803 &path,
804 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
805 )
806 .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
807 let version: i64 = match version {
808 Ok(version) => version,
809 Err(error) => {
810 tracing::debug!(%error, "could not read the SessionWiki index schema version");
811 return false;
812 }
813 };
814 let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
817 if mismatch && !SAID.swap(true, Ordering::AcqRel) {
818 tracing::warn!(
819 found = version,
820 expected = sessionwiki::index::SCHEMA_VERSION,
821 path = %path.display(),
822 "the SessionWiki index was written by another version; Mjolnir will not open it, because opening it would rebuild it. Install the matching sessionwiki command"
823 );
824 }
825 mismatch
826}
827
828fn index_is_writable() -> bool {
829 index_is_isolated() && !index_version_mismatch()
830}
831
832fn first_build_marker() -> PathBuf {
835 mj_core::config::data_dir().join("sessionwiki-built")
836}
837
838fn record_first_build() {
839 let path = first_build_marker();
840 let version = sessionwiki::index::SCHEMA_VERSION.to_string();
841 if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
842 return;
843 }
844 if let Err(error) = std::fs::write(&path, &version) {
845 tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
846 }
847}
848
849fn first_build_is_done() -> bool {
851 std::fs::read_to_string(first_build_marker())
852 .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
853 && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
854}
855
856pub fn index_state() -> WikiIndexState {
858 if !index_is_isolated() {
859 return WikiIndexState::Indexing;
860 }
861 if index_version_mismatch() {
862 return WikiIndexState::VersionMismatch;
863 }
864 if first_build_is_done() {
865 WikiIndexState::Ready
866 } else {
867 WikiIndexState::Indexing
868 }
869}
870
871fn is_busy(error: &anyhow::Error) -> bool {
874 error.chain().any(|cause| {
875 matches!(
876 cause.downcast_ref::<rusqlite::Error>(),
877 Some(rusqlite::Error::SqliteFailure(failure, _))
878 if matches!(
879 failure.code,
880 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
881 )
882 )
883 })
884}
885
886pub const MAX_WIKI_LIMIT: usize = 200;
892pub const DEFAULT_WIKI_LIMIT: usize = 50;
894const MIN_FULLTEXT_QUERY: usize = 3;
897pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
899
900pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
902 last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
903}
904
905pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
912 let limit = limit.clamp(1, MAX_WIKI_LIMIT);
913 if !index_is_writable() {
914 return Ok(Vec::new());
918 }
919 let connection = open_readonly()?;
920 let query = query.trim();
921 if query.is_empty() {
922 let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
923 .context("list recent SessionWiki sessions")?;
924 let mut rows: Vec<WikiRow> = rows
925 .into_iter()
926 .map(|row| wiki_row(row, None, live))
927 .collect();
928 fill_session_tags(&connection, &mut rows)?;
929 return Ok(rows);
930 }
931 let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
932 sessionwiki::index::search_like(&connection, query, limit, None, None)
933 } else {
934 sessionwiki::index::search(&connection, query, limit, None, None)
935 }
936 .context("search the SessionWiki index")?;
937 let mut rows: Vec<WikiRow> = hits
938 .into_iter()
939 .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
940 .collect();
941 let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
945 for row in named_like(&connection, query)? {
946 if rows.len() >= limit {
947 break;
948 }
949 if found.contains(&row.session_id) {
950 continue;
951 }
952 rows.push(wiki_row(row, None, live));
953 }
954 fill_session_tags(&connection, &mut rows)?;
955 Ok(rows)
956}
957
958fn fill_session_tags(connection: &rusqlite::Connection, rows: &mut [WikiRow]) -> Result<()> {
964 let ids: Vec<&str> = rows
965 .iter()
966 .filter(|row| row.tool == TOOL)
967 .map(|row| row.id.as_str())
968 .collect();
969 let found = tags::read(connection, &ids).context("read the indexed session metadata")?;
970 for row in rows.iter_mut().filter(|row| row.tool == TOOL) {
971 let Some(session) = found.get(&row.id) else {
972 continue;
973 };
974 row.target = session.target.clone();
975 row.profile = session.profile.clone();
976 row.harness = session.harness.clone();
977 }
978 Ok(())
979}
980
981const NAME_SCAN_LIMIT: usize = 2_000;
985
986fn named_like(
988 connection: &rusqlite::Connection,
989 query: &str,
990) -> Result<Vec<sessionwiki::index::SessionRow>> {
991 let needle = query.to_lowercase();
992 let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
993 .context("list recent SessionWiki sessions")?;
994 Ok(rows
995 .into_iter()
996 .filter(|row| {
997 row.title.to_lowercase().contains(&needle)
998 || row.project.to_lowercase().contains(&needle)
999 })
1000 .collect())
1001}
1002
1003pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
1005 if !index_is_writable() {
1006 return Ok(None);
1007 }
1008 let connection = open_readonly()?;
1009 let Some(row) = row_by_id(&connection, id)? else {
1010 return Ok(None);
1011 };
1012 let session = sessionwiki::index::session_from_index(&connection, &row)
1013 .context("read an indexed session")?;
1014 Ok(Some(sessionwiki::commands::brief_markdown(
1015 &session, max_chars, true,
1016 )))
1017}
1018
1019pub fn transcript_hits(
1027 id: &str,
1028 query: &str,
1029 context_messages: usize,
1030 per_message_chars: usize,
1031) -> Result<Option<WikiHitTranscript>> {
1032 if !index_is_writable() {
1033 return Ok(None);
1034 }
1035 let connection = open_readonly()?;
1036 let Some(row) = row_by_id(&connection, id)? else {
1037 return Ok(None);
1038 };
1039 let session = sessionwiki::index::session_from_index(&connection, &row)
1040 .context("read an indexed session")?;
1041 Ok(Some(hit_transcript(
1042 &session,
1043 query,
1044 context_messages,
1045 per_message_chars,
1046 )))
1047}
1048
1049fn hit_transcript(
1059 session: &Session,
1060 query: &str,
1061 context_messages: usize,
1062 per_message_chars: usize,
1063) -> WikiHitTranscript {
1064 let found = sessionwiki::grep::grep_session(
1065 session,
1066 query,
1067 &sessionwiki::grep::GrepOpts {
1068 context_messages,
1069 chars: per_message_chars,
1070 max_matches: None,
1071 anchor_roles: vec![Role::User, Role::Assistant],
1072 },
1073 );
1074 WikiHitTranscript {
1075 blocks: found
1076 .hits
1077 .into_iter()
1078 .map(|hit| WikiHitBlock {
1079 role: role_name(hit.role).to_owned(),
1080 text: hit.text,
1081 hits: hit.matches,
1082 omitted_before: hit.omitted_before,
1083 truncated: hit.truncated,
1084 })
1085 .collect(),
1086 omitted_after: found.omitted_after,
1087 }
1088}
1089
1090fn role_name(role: Role) -> &'static str {
1091 match role {
1092 Role::User => "user",
1093 Role::Assistant => "assistant",
1094 Role::Tool => "tool",
1095 }
1096}
1097
1098pub struct ArchivedSession {
1102 pub title: String,
1103 pub project_directory: Option<PathBuf>,
1106 pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
1107}
1108
1109pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
1111 if !index_is_writable() {
1112 return Ok(None);
1113 }
1114 let connection = open_readonly()?;
1115 let Some(row) = row_by_id(&connection, id)? else {
1116 return Ok(None);
1117 };
1118 let session = sessionwiki::index::session_from_index(&connection, &row)
1119 .context("read an indexed session")?;
1120 let snapshot = snapshot_of(&session)?;
1121 Ok(Some(ArchivedSession {
1122 title: session.title.clone(),
1123 project_directory: project_directory_of(&session.project),
1124 snapshot,
1125 }))
1126}
1127
1128pub fn sessions_ready_to_archive(
1144 sessions: &BTreeMap<String, SessionRecord>,
1145 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1146 now: DateTime<Utc>,
1147 older_than_days: u32,
1148) -> Vec<String> {
1149 let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
1150 let aged = |session_id: &String| {
1151 sessions.get(session_id).is_some_and(|record| {
1152 record.state == mj_core::state::SessionState::Stopped
1153 && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
1154 })
1155 };
1156 let selected: BTreeSet<String> = sessions
1157 .keys()
1158 .filter(|session_id| aged(session_id))
1159 .filter(|session_id| {
1160 subagents
1161 .values()
1162 .filter(|child| &&child.parent_session_id == session_id)
1163 .filter(|child| sessions.contains_key(&child.child_session_id))
1165 .all(|child| aged(&child.child_session_id))
1166 })
1167 .cloned()
1168 .collect();
1169 let mut ordered: Vec<String> = selected.iter().cloned().collect();
1170 ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
1171 ordered
1172}
1173
1174fn ancestor_depth(
1178 session_id: &str,
1179 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1180) -> usize {
1181 let mut depth = 0;
1182 let mut current = session_id;
1183 while let Some(parent) = subagents
1185 .get(current)
1186 .map(|child| child.parent_session_id.as_str())
1187 {
1188 depth += 1;
1189 if depth > subagents.len() {
1190 break;
1191 }
1192 current = parent;
1193 }
1194 depth
1195}
1196
1197pub use mj_core::state::ArchiveSpacePreview;
1203
1204pub fn archive_space_preview(older_than_days: Option<u32>) -> Result<ArchiveSpacePreview> {
1216 let controller =
1217 Controller::load().context("load the session records to size their storage")?;
1218 Ok(archive_space_over(
1219 &mj_core::config::sessions_dir(),
1220 &controller.state.sessions,
1221 &controller.state.subagents,
1222 Utc::now(),
1223 older_than_days,
1224 ))
1225}
1226
1227fn archive_space_over(
1230 sessions_root: &Path,
1231 sessions: &BTreeMap<String, SessionRecord>,
1232 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1233 now: DateTime<Utc>,
1234 older_than_days: Option<u32>,
1235) -> ArchiveSpacePreview {
1236 let mut preview = ArchiveSpacePreview {
1237 sessions: sessions.len(),
1238 bytes: sessions
1239 .iter()
1240 .map(|(session_id, record)| session_bytes(sessions_root, session_id, record))
1241 .sum(),
1242 reclaimable_sessions: 0,
1243 reclaimable_bytes: 0,
1244 };
1245 if let Some(days) = older_than_days {
1246 let aged = sessions_ready_to_archive(sessions, subagents, now, days);
1247 preview.reclaimable_sessions = aged.len();
1248 preview.reclaimable_bytes = aged
1249 .iter()
1250 .filter_map(|session_id| {
1251 sessions
1252 .get(session_id)
1253 .map(|record| session_bytes(sessions_root, session_id, record))
1254 })
1255 .sum();
1256 }
1257 preview
1258}
1259
1260fn session_bytes(sessions_root: &Path, session_id: &str, record: &SessionRecord) -> u64 {
1263 let checkpoint = record
1264 .checkpoint
1265 .as_ref()
1266 .and_then(|checkpoint| std::fs::metadata(&checkpoint.archive_path).ok())
1267 .filter(|metadata| metadata.is_file())
1268 .map(|metadata| metadata.len())
1269 .unwrap_or(0);
1270 let attachments = sessions_root
1271 .join(session_id)
1272 .join(mj_core::attachment::ATTACHMENT_DIR);
1273 let attachments = crate::import::claude::directory_size(&attachments).unwrap_or(0);
1274 checkpoint.saturating_add(attachments)
1275}
1276
1277pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
1284 if !index_is_writable() {
1285 return Ok(BTreeSet::new());
1288 }
1289 let connection = open_readonly()?;
1290 let sessions_dir = mj_core::config::sessions_dir();
1291 let mut indexed = BTreeSet::new();
1292 for session_id in session_ids {
1293 let key = format!("{}/{session_id}", sessions_dir.display());
1294 let rows = sessionwiki::index::resolve(&connection, session_id)
1295 .context("look up a stopped session in the SessionWiki index")?;
1296 if rows
1297 .iter()
1298 .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
1299 {
1300 indexed.insert(session_id.clone());
1301 }
1302 }
1303 Ok(indexed)
1304}
1305
1306fn open_readonly() -> Result<rusqlite::Connection> {
1307 sessionwiki::index::open_readonly().context("open the SessionWiki index")
1308}
1309
1310fn row_by_id(
1313 connection: &rusqlite::Connection,
1314 id: &str,
1315) -> Result<Option<sessionwiki::index::SessionRow>> {
1316 Ok(sessionwiki::index::resolve(connection, id)
1317 .context("look up an indexed session")?
1318 .into_iter()
1319 .find(|row| row.session_id == id))
1320}
1321
1322fn wiki_row(
1323 row: sessionwiki::index::SessionRow,
1324 snippet: Option<String>,
1325 live: &BTreeSet<String>,
1326) -> WikiRow {
1327 let hel_session_id = (row.tool == TOOL)
1330 .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
1331 .filter(|session_id| live.contains(session_id));
1332 let native_id = sessionwiki::index::native_id_of(&row.path);
1333 WikiRow {
1334 id: row.session_id,
1335 tool: row.tool,
1336 project: row.project,
1337 title: row.title,
1338 started: row.started,
1339 msgs: row.msg_count,
1340 preview: row.preview,
1341 archived: row.archived,
1342 native_id,
1343 snippet,
1344 hel_session_id,
1345 target: None,
1348 profile: None,
1349 harness: None,
1350 }
1351}
1352
1353fn project_directory_of(project: &str) -> Option<PathBuf> {
1361 if project.trim().is_empty() {
1362 return None;
1363 }
1364 let path = PathBuf::from(project);
1365 let repository = path
1366 .ancestors()
1367 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1368 .and_then(std::path::Path::parent)
1369 .map(std::path::Path::to_path_buf)
1370 .unwrap_or(path);
1371 repository.is_dir().then_some(repository)
1372}
1373
1374fn snapshot_of(
1381 session: &sessionwiki::model::Session,
1382) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1383 use mj_core::archive::{
1384 CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1385 CanonicalTranscriptBody, CanonicalTranscriptItem,
1386 };
1387
1388 let started_ms = session
1389 .started
1390 .map(|time| time.timestamp_millis())
1391 .unwrap_or_default();
1392 let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1393 for message in &session.messages {
1394 let text = message.text.trim();
1395 if text.is_empty() {
1396 continue;
1397 }
1398 if transcript.is_empty() && message.role != Role::User {
1401 continue;
1402 }
1403 let position = transcript.len() as u64 + 1;
1404 let body = match message.role {
1405 Role::User => CanonicalTranscriptBody::User {
1406 content: vec![serde_json::json!({"type": "text", "text": text})],
1407 },
1408 Role::Assistant => CanonicalTranscriptBody::Agent {
1409 chunks: vec![serde_json::json!({
1410 "content": {"type": "text", "text": text}
1411 })],
1412 streaming: false,
1413 },
1414 Role::Tool => {
1416 let (call, terminal_outputs) = mj_transcript::summary::indexed_tool_call(
1417 text,
1418 &format!("wiki-tool-{position}"),
1419 );
1420 CanonicalTranscriptBody::Tool {
1421 call,
1422 terminal_outputs,
1423 terminal_refs: Vec::new(),
1424 presentation: None,
1425 }
1426 }
1427 };
1428 let created_at_ms = message
1429 .ts
1430 .map(|time| time.timestamp_millis())
1431 .unwrap_or(started_ms);
1432 transcript.push(CanonicalTranscriptItem {
1433 stable_id: format!("wiki-{position}"),
1434 position,
1435 latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1438 .then_some(position),
1439 created_at_ms,
1440 last_changed_at_ms: created_at_ms,
1441 body,
1442 });
1443 }
1444 anyhow::ensure!(
1445 !transcript.is_empty(),
1446 "the archived session has no prompt to restore from"
1447 );
1448
1449 let event_frontier = transcript.len() as u64;
1450 let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1451 Ok(CanonicalSessionSnapshot {
1452 event_frontier,
1453 event_frontier_digest: {
1457 use sha2::Digest;
1458 mj_core::hex::lower_hex(sha2::Sha256::digest(
1459 format!("sessionwiki:{}", session.id).as_bytes(),
1460 ))
1461 },
1462 session: CanonicalSessionState {
1463 execution: CanonicalExecutionState::Idle,
1464 last_activity_at_ms,
1465 session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1466 configuration: BTreeMap::new(),
1467 },
1468 transcript,
1469 queued_prompts: Vec::new(),
1470 })
1471}
1472
1473#[derive(Debug, Clone, PartialEq, Eq)]
1483pub enum WikiContinuation {
1484 Resume { session_id: String },
1486 Restore { wiki_id: String },
1489 Import {
1491 harness: HarnessKind,
1492 native_session_id: String,
1493 },
1494}
1495
1496pub fn wiki_continuation(
1502 wiki_id: &str,
1503 tool: &str,
1504 path: &Path,
1505 has_record: bool,
1506) -> Result<WikiContinuation> {
1507 if tool == TOOL {
1508 return Ok(match has_record {
1511 true => WikiContinuation::Resume {
1512 session_id: wiki_id.to_owned(),
1513 },
1514 false => WikiContinuation::Restore {
1515 wiki_id: wiki_id.to_owned(),
1516 },
1517 });
1518 }
1519 let harness = harness_adapters::harness_for_tool(tool)
1520 .with_context(|| format!("Mjolnir cannot continue a {tool} session"))?;
1521 let native_session_id = crate::import::native_session_id_from_path(harness, path)
1522 .with_context(|| {
1523 format!(
1524 "no {tool} session id in the indexed path {}",
1525 path.display()
1526 )
1527 })?;
1528 Ok(WikiContinuation::Import {
1529 harness,
1530 native_session_id,
1531 })
1532}
1533
1534pub fn wiki_session(
1539 wiki_id: &str,
1540 known_sessions: &BTreeSet<String>,
1541) -> Result<Option<WikiSessionInfo>> {
1542 if !index_is_writable() {
1543 return Ok(None);
1544 }
1545 let connection = open_readonly()?;
1546 let Some(row) = row_by_id(&connection, wiki_id)? else {
1547 return Ok(None);
1548 };
1549 let is_mjolnir = row.tool == TOOL;
1550 let mjolnir_session_id = is_mjolnir.then(|| row.session_id.clone());
1551 let has_record = mjolnir_session_id
1552 .as_deref()
1553 .is_some_and(|session_id| known_sessions.contains(session_id));
1554 let status = match (is_mjolnir, has_record) {
1555 (false, _) => WikiSessionStatus::Native,
1556 (true, true) => WikiSessionStatus::Mine,
1557 (true, false) => WikiSessionStatus::Archived,
1558 };
1559 let tags = match is_mjolnir {
1560 true => tags::read(&connection, &[row.session_id.as_str()])
1561 .context("read the indexed session metadata")?
1562 .remove(&row.session_id)
1563 .unwrap_or_default(),
1564 false => tags::MjTags::default(),
1565 };
1566 let harness = tags
1567 .harness
1568 .as_deref()
1569 .and_then(|id| id.parse::<HarnessKind>().ok())
1570 .or_else(|| {
1571 (!is_mjolnir)
1572 .then(|| harness_adapters::harness_for_tool(&row.tool))
1573 .flatten()
1574 });
1575 Ok(Some(WikiSessionInfo {
1576 wiki_id: row.session_id,
1577 tool: row.tool,
1578 path: PathBuf::from(row.path),
1579 status,
1580 mjolnir_session_id,
1581 profile_id: tags.profile,
1582 target_template_id: tags.target,
1583 harness,
1584 title: row.title,
1585 project: row.project,
1586 }))
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use std::collections::BTreeMap;
1592 use std::path::Path;
1593
1594 mod continuation {
1597 use super::super::{WikiContinuation, wiki_continuation};
1598 use mj_core::config::HarnessKind;
1599 use std::path::Path;
1600
1601 #[test]
1602 fn a_mjolnir_row_with_a_record_is_resumed_and_one_without_is_restored() {
1603 let path = Path::new("/home/user/.local/share/mj/sessions/session-7");
1604 assert_eq!(
1605 wiki_continuation("session-7", "mjolnir", path, true).unwrap(),
1606 WikiContinuation::Resume {
1607 session_id: "session-7".to_owned(),
1608 }
1609 );
1610 assert_eq!(
1611 wiki_continuation("session-7", "mjolnir", path, false).unwrap(),
1612 WikiContinuation::Restore {
1613 wiki_id: "session-7".to_owned(),
1614 }
1615 );
1616 }
1617
1618 #[test]
1619 fn a_claude_code_row_is_imported_with_the_uuid_from_its_path() {
1620 let path = Path::new(
1621 "/home/user/.claude/projects/-home-user-app/7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44.jsonl",
1622 );
1623 assert_eq!(
1624 wiki_continuation("abc123", "claude-code", path, false).unwrap(),
1625 WikiContinuation::Import {
1626 harness: HarnessKind::Claude,
1627 native_session_id: "7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44".to_owned(),
1628 }
1629 );
1630 }
1631
1632 #[test]
1635 fn a_codex_row_is_imported_with_the_uuid_from_its_rollout_name() {
1636 let path = Path::new(
1637 "/home/user/.codex/sessions/2026/09/18/rollout-2026-09-18T09-15-00-7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44.jsonl",
1638 );
1639 assert_eq!(
1640 wiki_continuation("abc123", "codex", path, false).unwrap(),
1641 WikiContinuation::Import {
1642 harness: HarnessKind::Codex,
1643 native_session_id: "7f3a1c20-0b11-4a55-9e0d-2c8a5d6f1b44".to_owned(),
1644 }
1645 );
1646 }
1647
1648 #[test]
1649 fn an_unknown_tool_is_an_error_that_names_it() {
1650 let error = wiki_continuation("abc123", "opencode", Path::new("/tmp/s.jsonl"), false)
1651 .unwrap_err();
1652 assert!(
1653 format!("{error:#}").contains("opencode"),
1654 "the error has to name the tool: {error:#}"
1655 );
1656 }
1657 }
1658
1659 use mj_checkpoint::archive::{
1660 ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1661 CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1662 TargetManifest, write_archive_atomic,
1663 };
1664
1665 use super::*;
1666
1667 fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1668 let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1671 CanonicalTranscriptItem {
1672 stable_id: format!("item-{position}"),
1673 position,
1674 latest_content_event_ordinal: streamed.then_some(position),
1675 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1676 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1677 body,
1678 }
1679 }
1680
1681 fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1684 let path = directory.join(format!(
1685 "{session_id}-{frontier}-archive-{}.hel.zip",
1686 "0".repeat(32)
1687 ));
1688 write_archive_atomic(
1689 &path,
1690 &ArchiveInput {
1691 session: SessionManifest {
1692 id: session_id.into(),
1693 title: "indexed session".into(),
1694 harness_kind: mj_core::config::HarnessKind::Codex,
1695 profile_id: "codex".into(),
1696 native_session_id: "native-session".into(),
1697 created_at: "2026-09-01T00:00:00Z".into(),
1698 checkpointed_at: "2026-09-01T01:00:00Z".into(),
1699 hel_version: "test".into(),
1700 relay_version: "test".into(),
1701 adapter_version: "test".into(),
1702 },
1703 target: TargetManifest {
1704 template_id: "local".into(),
1705 target_kind: "local-bare".into(),
1706 details: BTreeMap::new(),
1707 },
1708 bundle: BundleManifest {
1709 id: "project".into(),
1710 primary_repository: "project".into(),
1711 },
1712 canonical_session: CanonicalSessionSnapshot {
1713 event_frontier: 4,
1714 event_frontier_digest: "a".repeat(64),
1715 session: CanonicalSessionState {
1716 execution: CanonicalExecutionState::Idle,
1717 last_activity_at_ms: Some(1_700_000_000_004),
1718 session_title: Some("snapshot title".into()),
1719 configuration: BTreeMap::new(),
1720 },
1721 transcript: vec![
1722 item(
1723 1,
1724 CanonicalTranscriptBody::User {
1725 content: vec![serde_json::json!({
1726 "type": "text",
1727 "text": "index this session"
1728 })],
1729 },
1730 ),
1731 item(
1732 2,
1733 CanonicalTranscriptBody::Thought {
1734 chunks: vec![serde_json::json!({
1735 "content": {"type": "text", "text": "pondering"}
1736 })],
1737 streaming: false,
1738 },
1739 ),
1740 item(
1741 3,
1742 CanonicalTranscriptBody::Tool {
1743 call: serde_json::json!({
1744 "toolCallId": "call-1",
1745 "title": "Edit config.toml",
1746 "kind": "edit",
1747 "status": "completed",
1748 "locations": [{"path": "/old/container/config.toml"}]
1749 }),
1750 terminal_outputs: Vec::new(),
1751 terminal_refs: Vec::new(),
1752 presentation: None,
1753 },
1754 ),
1755 item(
1756 4,
1757 CanonicalTranscriptBody::Agent {
1758 chunks: vec![serde_json::json!({
1759 "content": {"type": "text", "text": "done"}
1760 })],
1761 streaming: false,
1762 },
1763 ),
1764 ],
1765 queued_prompts: Vec::new(),
1766 },
1767 native_artifacts: Vec::new(),
1768 repositories: Vec::new(),
1769 },
1770 )
1771 .unwrap();
1772 }
1773
1774 fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1775 adapter_with_live(directory, session_id, BTreeMap::new())
1776 }
1777
1778 fn adapter_with_live(
1779 directory: &Path,
1780 session_id: &str,
1781 live: BTreeMap<String, i64>,
1782 ) -> MjolnirAdapter {
1783 let record = SessionRecord {
1784 id: session_id.into(),
1785 ..record_template()
1786 };
1787 MjolnirAdapter {
1788 sessions_dir: directory.to_path_buf(),
1789 sessions: std::sync::Mutex::new(Sessions {
1790 records: BTreeMap::from([(session_id.to_owned(), record)]),
1791 subagent_ids: BTreeSet::new(),
1792 live,
1793 }),
1794 reload: false,
1795 }
1796 }
1797
1798 fn record_template() -> SessionRecord {
1799 SessionRecord {
1800 build_cache: None,
1801 container_workspace: None,
1802 mjolnir_subagents: None,
1803 create_managed_worktree: None,
1804 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1805 archived: false,
1806 container_cpus: None,
1807 container_memory: None,
1808 id: "0123456789abcdef0123456789abcdef".into(),
1809 title: "indexed session".into(),
1810 harness_kind: mj_core::config::HarnessKind::Codex,
1811 last_profile: "codex".into(),
1812 bundle_id: "project".into(),
1813 project_directory: Some(PathBuf::from("/home/dev/project")),
1814 managed_worktree: None,
1815 target_template_id: "local-bare".into(),
1816 resource_allocation: None,
1817 additional_mounts: Vec::new(),
1818 state: mj_core::state::SessionState::Stopped,
1819 target: None,
1820 native_session_id: Some("native-session".into()),
1821 acp_session_title: Some("the harness title".into()),
1822 session_title_override: None,
1823 created_at: "2026-09-01T00:00:00Z".into(),
1824 updated_at: "2026-09-01T01:00:00Z".into(),
1825 viewed_through_event_ordinal: 0,
1826 draft_input: String::new(),
1827 last_error: None,
1828 last_checkpoint_error: None,
1829 checkpoint: None,
1830 }
1831 }
1832
1833 #[test]
1834 fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1835 let directory = tempfile::tempdir().unwrap();
1836 let session_id = "0123456789abcdef0123456789abcdef";
1837 write_archive(directory.path(), session_id, 1);
1838 write_archive(directory.path(), session_id, 7);
1839 let adapter = adapter(directory.path(), session_id);
1840
1841 let store = adapter.store().expect("the adapter is a shared store");
1842 let key = format!("{}/{session_id}", directory.path().display());
1843 assert_eq!(
1844 store
1845 .keys
1846 .iter()
1847 .map(|(key, _)| key.as_str())
1848 .collect::<Vec<_>>(),
1849 vec![key.as_str()]
1850 );
1851 assert!(!store.had_error);
1852 assert_eq!(store.files.len(), 1);
1853 assert!(
1854 store.files[0]
1855 .file_name()
1856 .unwrap()
1857 .to_str()
1858 .unwrap()
1859 .contains("-7-archive-"),
1860 "the newest checkpoint is the one indexed: {:?}",
1861 store.files[0]
1862 );
1863 assert_eq!(
1864 adapter.reconcile_scope(),
1865 Some(format!("{}/", directory.path().display()))
1866 );
1867
1868 let session = adapter.parse_key(&key).unwrap();
1869 assert_eq!(session.id, session_id);
1870 assert_eq!(session.tool, "mjolnir");
1871 assert_eq!(session.path, PathBuf::from(&key));
1872 assert_eq!(session.project, "/home/dev/project");
1873 assert_eq!(session.title, "the harness title");
1874 assert!(!session.subagent);
1875 assert_eq!(
1876 session.messages.iter().map(|m| m.role).collect::<Vec<_>>(),
1877 vec![Role::User, Role::Tool, Role::Assistant]
1878 );
1879 assert_eq!(session.messages[0].text, "index this session");
1880 let tool: serde_json::Value = serde_json::from_str(&session.messages[1].text).unwrap();
1881 assert_eq!(tool["name"], "Edit");
1882 assert_eq!(tool["call"]["title"], "Edit config.toml");
1883 assert_eq!(session.messages[2].text, "done");
1884 assert_eq!(session.touched, vec!["/old/container/config.toml"]);
1885 }
1886
1887 #[test]
1888 fn provenance_backfill_repairs_an_unchanged_checkpoint_without_rebuilding_the_index() {
1889 let _held = tags::testing::lock();
1890 let (_index_dir, mut connection) = tags::testing::isolated_index();
1891 let directory = tempfile::tempdir().unwrap();
1892 write_archive(directory.path(), "old-session", 4);
1893 let source = adapter(directory.path(), "old-session");
1894 let key = source.key_for("old-session");
1895 tags::testing::index_row(&connection, "old-session", "mjolnir");
1896 connection
1897 .execute(
1898 "UPDATE files SET path = ?1 WHERE session_id = 'old-session'",
1899 [&key],
1900 )
1901 .unwrap();
1902 provenance::backfill(&mut connection, &source).unwrap();
1903 assert_eq!(
1904 sessionwiki::index::files_for(&connection, "old-session").unwrap(),
1905 vec!["/old/container/config.toml"]
1906 );
1907 provenance::backfill(&mut connection, &source).unwrap();
1908 assert_eq!(
1909 sessionwiki::index::sessions_for_file(&connection, "config.toml", 20)
1910 .unwrap()
1911 .len(),
1912 1
1913 );
1914 }
1915
1916 fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1917 use mj_core::transcript::{TranscriptBody, TranscriptItem};
1918 let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1919 let mut push = |position: u64, body: TranscriptBody| {
1920 let streamed = matches!(body, TranscriptBody::Agent { .. });
1921 projected
1922 .transcript
1923 .push(std::sync::Arc::new(TranscriptItem {
1924 stable_id: format!("item-{position}"),
1925 position,
1926 latest_content_event_ordinal: streamed.then_some(position),
1927 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1928 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1929 body,
1930 }));
1931 };
1932 push(
1933 1,
1934 TranscriptBody::User {
1935 content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1936 },
1937 );
1938 push(
1939 2,
1940 TranscriptBody::Thought {
1941 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1942 streaming: false,
1943 },
1944 );
1945 push(
1946 3,
1947 TranscriptBody::Tool {
1948 call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1949 terminal_outputs: Vec::new(),
1950 terminal_refs: Vec::new(),
1951 presentation: None,
1952 },
1953 );
1954 push(
1955 4,
1956 TranscriptBody::Agent {
1957 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1958 streaming: false,
1959 },
1960 );
1961 projected.session_title = Some("the live title".into());
1962 projected
1963 }
1964
1965 #[test]
1968 fn a_running_session_is_indexed_from_its_stored_transcript() {
1969 let session_id = "0123456789abcdef0123456789abcdef";
1970 let messages = projected_messages(&projection(session_id));
1971 assert_eq!(
1972 messages.iter().map(|m| m.role).collect::<Vec<_>>(),
1973 vec![Role::User, Role::Tool, Role::Assistant]
1974 );
1975 assert_eq!(messages[0].text, "still talking");
1976 assert_eq!(messages[2].text, "reading");
1977 let tool: serde_json::Value = serde_json::from_str(&messages[1].text).unwrap();
1978 assert_eq!(tool["name"], "Read");
1979 assert_eq!(tool["call"]["title"], "Read README.md");
1980 }
1981
1982 #[test]
1987 fn a_running_session_is_listed_with_its_own_change_token() {
1988 let directory = tempfile::tempdir().unwrap();
1989 let running = "0123456789abcdef0123456789abcdef";
1990 let never_checkpointed = "fedcba9876543210fedcba9876543210";
1991 write_archive(directory.path(), running, 3);
1992 let live = adapter_with_live(
1993 directory.path(),
1994 running,
1995 BTreeMap::from([
1996 (running.to_owned(), 1_900_000_000),
1997 (never_checkpointed.to_owned(), 1_900_000_001),
1998 ]),
1999 );
2000
2001 let store = live.store().expect("the adapter is a shared store");
2002 let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
2003 assert_eq!(
2004 store.keys,
2005 vec![
2006 (
2007 key_of(running),
2008 1_900_000_000 * 1024 + i64::from(mj_transcript::summary::SUMMARY_VERSION)
2009 ),
2010 (
2011 key_of(never_checkpointed),
2012 1_900_000_001 * 1024 + i64::from(mj_transcript::summary::SUMMARY_VERSION)
2013 ),
2014 ],
2015 "a live session's own token replaces the checkpoint's"
2016 );
2017
2018 let stopped = adapter(directory.path(), running);
2021 let keys = stopped.store().expect("a shared store").keys;
2022 assert_eq!(keys.len(), 1);
2023 assert_eq!(keys[0].0, key_of(running));
2024 assert_ne!(keys[0].1, 1_900_000_000);
2025 assert_eq!(
2026 stopped.parse_key(&key_of(running)).unwrap().title,
2027 "the harness title",
2028 "a stopped session is parsed from its checkpoint"
2029 );
2030 }
2031
2032 #[test]
2035 fn a_rename_moves_a_session_change_token() {
2036 let directory = tempfile::tempdir().unwrap();
2037 let session_id = "0123456789abcdef0123456789abcdef";
2038 write_archive(directory.path(), session_id, 1);
2039 let adapter = adapter(directory.path(), session_id);
2040 let before = adapter.store().expect("a shared store").keys[0].1;
2041
2042 {
2043 let mut sessions = adapter.sessions.lock().unwrap();
2044 let record = sessions.records.get_mut(session_id).unwrap();
2045 record.session_title_override = Some("the new name".into());
2046 record.updated_at = "2099-01-01T00:00:00Z".into();
2047 }
2048 let after = adapter.store().expect("a shared store").keys[0].1;
2049 assert!(
2050 after > before,
2051 "a renamed session is re-indexed: {before} then {after}"
2052 );
2053 assert_eq!(
2054 adapter
2055 .parse_key(&format!("{}/{session_id}", directory.path().display()))
2056 .unwrap()
2057 .title,
2058 "the new name"
2059 );
2060 }
2061
2062 fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
2063 Session {
2064 id: "0123456789abcdef0123456789abcdef".into(),
2065 tool: "mjolnir",
2066 path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
2067 project: "/home/dev/project".into(),
2068 started: DateTime::from_timestamp_millis(1_700_000_000_000),
2069 ended: None,
2070 title: "the archived session".into(),
2071 subagent: false,
2072 messages: messages
2073 .into_iter()
2074 .map(|(role, text)| Message {
2075 role,
2076 text: text.to_owned(),
2077 ts: None,
2078 })
2079 .collect(),
2080 touched: Vec::new(),
2081 edits: Vec::new(),
2082 }
2083 }
2084
2085 #[test]
2088 fn transcript_hits_locates_case_insensitive_matches() {
2089 let session = indexed(vec![
2090 (Role::User, "Make the Tests green"),
2091 (Role::Assistant, "the tests are green now"),
2092 ]);
2093
2094 let found = hit_transcript(&session, "TESTS", 0, 4_000);
2095
2096 assert_eq!(found.blocks.len(), 2, "both messages contain the query");
2097 assert_eq!(found.blocks[0].role, "user");
2098 let (start, end) = found.blocks[0].hits[0];
2099 assert_eq!(&found.blocks[0].text[start..end], "Tests");
2100 let (start, end) = found.blocks[1].hits[0];
2101 assert_eq!(&found.blocks[1].text[start..end], "tests");
2102 assert!(!found.blocks[0].truncated);
2103 assert_eq!(found.omitted_after, 0);
2104 }
2105
2106 #[test]
2109 fn transcript_hits_keeps_context_and_marks_omissions() {
2110 let session = indexed(vec![
2111 (Role::User, "zero"),
2112 (Role::Assistant, "one needle one"),
2113 (Role::Tool, "two"),
2114 (Role::User, "three"),
2115 (Role::Assistant, "four"),
2116 (Role::Tool, "five"),
2117 (Role::User, "six needle six"),
2118 (Role::Assistant, "seven"),
2119 (Role::User, "eight"),
2120 ]);
2121
2122 let found = hit_transcript(&session, "needle", 1, 4_000);
2123
2124 let shown: Vec<(&str, &str, usize)> = found
2125 .blocks
2126 .iter()
2127 .map(|block| {
2128 (
2129 block.role.as_str(),
2130 block.text.as_str(),
2131 block.omitted_before,
2132 )
2133 })
2134 .collect();
2135 assert_eq!(
2136 shown,
2137 vec![
2138 ("user", "zero", 0),
2139 ("assistant", "one needle one", 0),
2140 ("tool", "two", 0),
2141 ("tool", "five", 2),
2142 ("user", "six needle six", 0),
2143 ("assistant", "seven", 0),
2144 ]
2145 );
2146 assert_eq!(found.omitted_after, 1, "the last message is not shown");
2147 assert!(found.blocks[0].hits.is_empty(), "context has no hits");
2148 }
2149
2150 #[test]
2156 fn transcript_hits_never_anchor_on_tool_output() {
2157 let session = indexed(vec![
2158 (Role::User, "make it build"),
2159 (Role::Tool, "cargo build --needle"),
2160 (Role::Assistant, "it builds"),
2161 ]);
2162
2163 let only_in_a_tool = hit_transcript(&session, "needle", 1, 4_000);
2164 assert!(
2165 only_in_a_tool.blocks.is_empty(),
2166 "tool output must not anchor a passage, got {:?}",
2167 only_in_a_tool.blocks
2168 );
2169
2170 let beside_a_match = hit_transcript(&session, "builds", 1, 4_000);
2171 let shown: Vec<(&str, bool)> = beside_a_match
2172 .blocks
2173 .iter()
2174 .map(|block| (block.role.as_str(), !block.hits.is_empty()))
2175 .collect();
2176 assert_eq!(
2177 shown,
2178 vec![("tool", false), ("assistant", true)],
2179 "a tool message is still context around a real match"
2180 );
2181 }
2182
2183 #[test]
2186 fn transcript_hits_window_keeps_the_first_hit() {
2187 let filler = "x".repeat(4_000);
2188 let session = indexed(vec![(Role::User, &format!("{filler} needle {filler}"))]);
2189
2190 let found = hit_transcript(&session, "needle", 0, 100);
2191
2192 let block = &found.blocks[0];
2193 assert!(block.truncated);
2194 assert_eq!(block.text.chars().count(), 100);
2195 assert_eq!(block.hits.len(), 1, "the windowed text keeps its hit");
2196 let (start, end) = block.hits[0];
2197 assert_eq!(&block.text[start..end], "needle");
2198 assert!(
2199 start >= 20,
2200 "the window keeps lead-in before the hit, got {start}"
2201 );
2202 }
2203
2204 #[test]
2208 fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
2209 let snapshot = snapshot_of(&indexed(vec![
2210 (Role::User, "make the tests green"),
2211 (Role::Tool, "Read src/lib.rs"),
2212 (Role::Assistant, "they are green now"),
2213 (Role::User, " "),
2214 ]))
2215 .unwrap();
2216
2217 snapshot.validate().expect("the snapshot is well formed");
2218 assert_eq!(snapshot.event_frontier, 3);
2219 assert_eq!(
2220 snapshot.session.session_title.as_deref(),
2221 Some("the archived session")
2222 );
2223 assert!(snapshot.session.last_activity_at_ms.is_some());
2224 let bodies = snapshot
2225 .transcript
2226 .iter()
2227 .map(|item| match &item.body {
2228 mj_core::archive::CanonicalTranscriptBody::User { content } => (
2229 "user",
2230 mj_core::transcript::materialized_content_text(content),
2231 ),
2232 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
2233 "agent",
2234 mj_core::transcript::materialized_chunks_text(chunks),
2235 ),
2236 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
2237 "tool",
2238 call["title"].as_str().unwrap_or_default().to_owned(),
2239 ),
2240 _ => ("other", String::new()),
2241 })
2242 .collect::<Vec<_>>();
2243 assert_eq!(
2244 bodies,
2245 vec![
2246 ("user", "make the tests green".to_owned()),
2247 ("tool", "Read src/lib.rs".to_owned()),
2248 ("agent", "they are green now".to_owned()),
2249 ],
2250 "the blank message is dropped and every other one keeps its role"
2251 );
2252 }
2253
2254 #[test]
2258 fn messages_before_the_first_prompt_are_dropped() {
2259 let snapshot = snapshot_of(&indexed(vec![
2260 (Role::Assistant, "still working"),
2261 (Role::User, "carry on"),
2262 ]))
2263 .unwrap();
2264 assert_eq!(snapshot.transcript.len(), 1);
2265 assert_eq!(snapshot.transcript[0].position, 1);
2266 snapshot.validate().unwrap();
2267
2268 let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
2269 assert!(
2270 error.to_string().contains("no prompt"),
2271 "a session with no prompt cannot be restored: {error}"
2272 );
2273 }
2274
2275 fn record(
2276 session_id: &str,
2277 state: mj_core::state::SessionState,
2278 updated_at: &str,
2279 ) -> SessionRecord {
2280 SessionRecord {
2281 id: session_id.into(),
2282 state,
2283 updated_at: updated_at.into(),
2284 ..record_template()
2285 }
2286 }
2287
2288 fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
2289 mj_core::subagent::SubagentRecord {
2290 child_session_id: child_session_id.into(),
2291 parent_session_id: parent_session_id.into(),
2292 task_name: "task".into(),
2293 profile_id: "codex".into(),
2294 model: None,
2295 effort: None,
2296 working_directory: PathBuf::new(),
2297 initial_prompt: "do the thing".into(),
2298 request_key: "key".into(),
2299 created_at: "2026-09-01T00:00:00Z".into(),
2300 noticed_turn: None,
2301 }
2302 }
2303
2304 fn ready(
2305 sessions: Vec<SessionRecord>,
2306 children: Vec<mj_core::subagent::SubagentRecord>,
2307 ) -> Vec<String> {
2308 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2309 sessions_ready_to_archive(
2310 &sessions
2311 .into_iter()
2312 .map(|record| (record.id.clone(), record))
2313 .collect(),
2314 &children
2315 .into_iter()
2316 .map(|child| (child.child_session_id.clone(), child))
2317 .collect(),
2318 now,
2319 3,
2320 )
2321 }
2322
2323 fn sized_session(
2325 root: &Path,
2326 session_id: &str,
2327 updated_at: &str,
2328 checkpoint_bytes: usize,
2329 attachment_bytes: &[usize],
2330 ) -> SessionRecord {
2331 let archive_path = root.join(format!("{session_id}.hel.zip"));
2332 std::fs::write(&archive_path, vec![b'c'; checkpoint_bytes]).unwrap();
2333 if !attachment_bytes.is_empty() {
2334 let attachments = root
2335 .join(session_id)
2336 .join(mj_core::attachment::ATTACHMENT_DIR);
2337 std::fs::create_dir_all(&attachments).unwrap();
2338 for (index, size) in attachment_bytes.iter().enumerate() {
2339 std::fs::write(attachments.join(format!("{index}.png")), vec![b'a'; *size])
2340 .unwrap();
2341 }
2342 }
2343 SessionRecord {
2344 checkpoint: Some(mj_core::state::CheckpointMetadata {
2345 archive_path,
2346 sha256: "0".repeat(64),
2347 created_at: updated_at.into(),
2348 event_frontier: 1,
2349 }),
2350 ..record(
2351 session_id,
2352 mj_core::state::SessionState::Stopped,
2353 updated_at,
2354 )
2355 }
2356 }
2357
2358 #[test]
2359 fn the_space_preview_sizes_every_session_and_only_the_aged_ones_as_reclaimable() {
2360 let directory = tempfile::tempdir().unwrap();
2361 let root = directory.path();
2362 let sessions: BTreeMap<String, SessionRecord> = [
2363 sized_session(root, "old-stopped", "2026-09-01T00:00:00Z", 1000, &[10, 20]),
2364 sized_session(root, "just-stopped", "2026-09-09T00:00:00Z", 500, &[]),
2365 SessionRecord {
2368 checkpoint: Some(mj_core::state::CheckpointMetadata {
2369 archive_path: root.join("missing.hel.zip"),
2370 sha256: "0".repeat(64),
2371 created_at: "2026-09-01T00:00:00Z".into(),
2372 event_frontier: 1,
2373 }),
2374 ..record(
2375 "lost-checkpoint",
2376 mj_core::state::SessionState::Stopped,
2377 "2026-09-01T00:00:00Z",
2378 )
2379 },
2380 ]
2381 .into_iter()
2382 .map(|record| (record.id.clone(), record))
2383 .collect();
2384 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2385
2386 let all = archive_space_over(root, &sessions, &BTreeMap::new(), now, None);
2387 assert_eq!(all.sessions, 3);
2388 assert_eq!(all.bytes, 1530);
2389 assert_eq!(all.reclaimable_sessions, 0);
2390 assert_eq!(all.reclaimable_bytes, 0);
2391
2392 let aged = archive_space_over(root, &sessions, &BTreeMap::new(), now, Some(3));
2393 assert_eq!(aged.bytes, 1530);
2394 assert_eq!(
2395 (aged.reclaimable_sessions, aged.reclaimable_bytes),
2396 (2, 1030),
2397 "only the sessions the job would archive count, attachments included"
2398 );
2399 }
2400
2401 #[test]
2402 fn only_stopped_sessions_past_the_cut_off_are_archived() {
2403 use mj_core::state::SessionState;
2404 let selected = ready(
2405 vec![
2406 record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2407 record(
2408 "just-stopped",
2409 SessionState::Stopped,
2410 "2026-09-09T00:00:00Z",
2411 ),
2412 record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
2413 record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
2414 record("unparsable", SessionState::Stopped, "not a time"),
2415 record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
2417 ],
2418 Vec::new(),
2419 );
2420 assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
2421 }
2422
2423 #[test]
2424 fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
2425 use mj_core::state::SessionState;
2426 let selected = ready(
2427 vec![
2428 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2429 record(
2430 "running-child",
2431 SessionState::Running,
2432 "2026-09-01T00:00:00Z",
2433 ),
2434 ],
2435 vec![child("running-child", "parent")],
2436 );
2437 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2438
2439 let selected = ready(
2440 vec![
2441 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2442 record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
2443 ],
2444 vec![child("young-child", "parent")],
2445 );
2446 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2447
2448 let selected = ready(
2450 vec![record(
2451 "parent",
2452 SessionState::Stopped,
2453 "2026-09-01T00:00:00Z",
2454 )],
2455 vec![child("departed-child", "parent")],
2456 );
2457 assert_eq!(selected, vec!["parent"]);
2458 }
2459
2460 #[test]
2461 fn children_are_archived_before_their_parents() {
2462 use mj_core::state::SessionState;
2463 let selected = ready(
2464 vec![
2465 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2466 record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2467 record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2468 ],
2469 vec![child("child", "parent"), child("grandchild", "child")],
2470 );
2471 assert_eq!(selected, vec!["grandchild", "child", "parent"]);
2472 }
2473
2474 #[test]
2475 fn native_adapters_cover_every_enabled_profile_home() {
2476 use mj_core::config::{Config, HarnessKind, HarnessProfile};
2477
2478 fn profile(kind: HarnessKind, home: &str, enabled: bool) -> HarnessProfile {
2479 HarnessProfile {
2480 enabled,
2481 kind,
2482 home: PathBuf::from(home),
2483 environment: BTreeMap::new(),
2484 context_window_bytes: None,
2485 guardian_review_model: None,
2486 }
2487 }
2488
2489 let mut config = Config::default();
2490 for (id, built) in [
2491 (
2492 "codex",
2493 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2494 ),
2495 (
2496 "codex-ds",
2497 profile(HarnessKind::Codex, "/home/dev/.codex-ds", true),
2498 ),
2499 (
2501 "codex-alt",
2502 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2503 ),
2504 (
2505 "codex-off",
2506 profile(HarnessKind::Codex, "/home/dev/.codex-off", false),
2507 ),
2508 (
2509 "claude",
2510 profile(HarnessKind::Claude, "/home/dev/.claude4", true),
2511 ),
2512 ("kimi", profile(HarnessKind::Kimi, "/home/dev/.kimi", true)),
2513 ("grok", profile(HarnessKind::Grok, "/home/dev/.grok", true)),
2514 ("muse", profile(HarnessKind::Muse, "/home/dev/muse", true)),
2515 (
2516 "muse-off",
2517 profile(HarnessKind::Muse, "/home/dev/muse-off", false),
2518 ),
2519 ] {
2520 config.profiles.insert(id.into(), built);
2521 }
2522
2523 let adapters = native_adapters(&config);
2524 let roots: Vec<(&str, Option<PathBuf>)> = adapters
2525 .iter()
2526 .map(|adapter| (adapter.name(), adapter.root()))
2527 .collect();
2528
2529 let codex: Vec<&Option<PathBuf>> = roots
2530 .iter()
2531 .filter(|(name, _)| *name == "codex")
2532 .map(|(_, root)| root)
2533 .collect();
2534 assert_eq!(
2535 codex,
2536 vec![
2537 &Some(PathBuf::from("/home/dev/.codex3/sessions")),
2538 &Some(PathBuf::from("/home/dev/.codex-ds/sessions")),
2539 ],
2540 "one adapter per enabled Codex home, deduplicated: {roots:?}"
2541 );
2542
2543 let claude: Vec<&Option<PathBuf>> = roots
2544 .iter()
2545 .filter(|(name, _)| *name == "claude-code")
2546 .map(|(_, root)| root)
2547 .collect();
2548 assert_eq!(
2549 claude,
2550 vec![&Some(PathBuf::from("/home/dev/.claude4/projects"))],
2551 "one adapter for the enabled Claude home: {roots:?}"
2552 );
2553
2554 for (_, root) in &roots {
2555 let Some(root) = root else { continue };
2556 let text = root.to_string_lossy();
2557 assert!(
2558 !text.contains(".codex-off"),
2559 "a disabled profile must not be indexed: {roots:?}"
2560 );
2561 assert!(
2562 !text.ends_with("/.codex/sessions") && !text.ends_with("/.claude/projects"),
2563 "the stock homes are not indexed unless a profile names them: {roots:?}"
2564 );
2565 }
2566
2567 for (name, root) in [
2570 ("kimi-code", PathBuf::from("/home/dev/.kimi/sessions")),
2571 ("grok-build", PathBuf::from("/home/dev/.grok/sessions")),
2572 (
2573 "muse",
2574 mj_checkpoint::native::muse_sessions_root(Path::new("/home/dev/muse")).unwrap(),
2575 ),
2576 ] {
2577 let found: Vec<&Option<PathBuf>> = roots
2578 .iter()
2579 .filter(|(found, _)| *found == name)
2580 .map(|(_, root)| root)
2581 .collect();
2582 assert_eq!(found, vec![&Some(root)], "one {name} adapter: {roots:?}");
2583 }
2584
2585 for (_, root) in &roots {
2586 let Some(root) = root else { continue };
2587 assert!(
2588 !root.to_string_lossy().contains("muse-off"),
2589 "a disabled profile must not be indexed: {roots:?}"
2590 );
2591 }
2592
2593 assert!(
2594 roots.iter().any(|(name, _)| *name == "gemini"),
2595 "the other built-in adapters are kept: {roots:?}"
2596 );
2597 }
2598
2599 #[test]
2603 fn query_rows_returns_the_indexed_target_profile_and_harness() {
2604 let _held = tags::testing::lock();
2605 let (_directory, connection) = tags::testing::isolated_index();
2606 tags::testing::index_row(&connection, "mj-session", TOOL);
2607 tags::testing::index_row(&connection, "codex-session", "codex");
2608 tags::write(
2609 &connection,
2610 "mj-session",
2611 &tags::MjTags {
2612 target: Some("Prod-Box".into()),
2613 profile: Some("codex-Main".into()),
2614 harness: Some("codex".into()),
2615 },
2616 )
2617 .expect("write the session metadata");
2618
2619 let rows = query_rows("", 10, &BTreeSet::new()).expect("query the index");
2620 let mjolnir = rows
2621 .iter()
2622 .find(|row| row.id == "mj-session")
2623 .expect("the Mjolnir row is returned");
2624 assert_eq!(mjolnir.target.as_deref(), Some("Prod-Box"));
2625 assert_eq!(mjolnir.profile.as_deref(), Some("codex-Main"));
2626 assert_eq!(mjolnir.harness.as_deref(), Some("codex"));
2627
2628 let codex = rows
2629 .iter()
2630 .find(|row| row.id == "codex-session")
2631 .expect("the Codex row is returned");
2632 assert_eq!(codex.target, None);
2633 assert_eq!(codex.profile, None);
2634 assert_eq!(codex.harness, None);
2635 }
2636}