1mod harness_adapters;
13pub mod tags;
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::time::Instant;
20
21use anyhow::{Context, Result};
22use chrono::{DateTime, Utc};
23
24use mj_client::daemon::{WikiHitBlock, WikiHitTranscript, WikiIndexState, WikiRow, WikiStatus};
25use mj_core::state::{SessionRecord, State};
26use sessionwiki::adapters::{Adapter, Discovered, Store};
27use sessionwiki::model::{Message, Role, Session};
28
29use crate::controller::Controller;
30use crate::controller::checkpoint::managed_checkpoint_archive_name;
31use harness_adapters::HarnessAdapter;
32
33const TOOL: &str = "mjolnir";
37
38struct ArchiveFile {
40 path: PathBuf,
41 frontier: u64,
42 token: i64,
44}
45
46#[derive(Default)]
50struct Sessions {
51 records: BTreeMap<String, SessionRecord>,
52 subagent_ids: BTreeSet<String>,
53 live: BTreeMap<String, i64>,
55}
56
57impl Sessions {
58 fn of(state: &State) -> Self {
59 Self {
60 records: state.sessions.clone(),
61 subagent_ids: state.subagents.keys().cloned().collect(),
62 live: live_tokens(state),
63 }
64 }
65}
66
67fn live_tokens(state: &State) -> BTreeMap<String, i64> {
74 let activity = match crate::database::load_transcribed_session_activity() {
75 Ok(activity) => activity,
76 Err(error) => {
77 tracing::warn!(%error, "could not read session activity for SessionWiki");
78 return BTreeMap::new();
79 }
80 };
81 state
82 .sessions
83 .iter()
84 .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
85 .filter_map(|(session_id, _)| {
86 let watermark = activity.get(session_id)?;
87 Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
88 })
89 .collect()
90}
91
92pub struct MjolnirAdapter {
94 sessions_dir: PathBuf,
95 sessions: std::sync::Mutex<Sessions>,
96 reload: bool,
98}
99
100impl MjolnirAdapter {
101 pub fn from_state(state: &State) -> Self {
104 Self {
105 sessions_dir: mj_core::config::sessions_dir(),
106 sessions: std::sync::Mutex::new(Sessions::of(state)),
107 reload: false,
108 }
109 }
110
111 pub fn reloading(state: &State) -> Self {
120 Self {
121 reload: true,
122 ..Self::from_state(state)
123 }
124 }
125
126 pub fn indexed_tags(&self) -> BTreeMap<String, tags::MjTags> {
135 let sessions = self
136 .sessions
137 .lock()
138 .unwrap_or_else(std::sync::PoisonError::into_inner);
139 sessions
140 .records
141 .iter()
142 .map(|(session_id, record)| {
143 (
144 session_id.clone(),
145 tags::MjTags {
146 target: Some(record.target_template_id.clone()).filter(|id| !id.is_empty()),
147 profile: Some(record.last_profile.clone()).filter(|id| !id.is_empty()),
148 harness: Some(record.harness_kind.id().to_owned()),
149 },
150 )
151 })
152 .collect()
153 }
154
155 fn reload(&self) {
156 if !self.reload {
157 return;
158 }
159 match Controller::load() {
160 Ok(controller) => {
161 *self
162 .sessions
163 .lock()
164 .unwrap_or_else(std::sync::PoisonError::into_inner) =
165 Sessions::of(&controller.state)
166 }
167 Err(error) => {
168 tracing::warn!(%error, "could not refresh session records for SessionWiki")
169 }
170 }
171 }
172
173 fn checkpointed_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
176 let (newest, _) = self.newest_archives();
177 let archive = newest
178 .get(session_id)
179 .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
180 let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
181 .with_context(|| format!("read checkpoint {}", archive.path.display()))?
182 .canonical_session()
183 .with_context(|| format!("read the transcript of session {session_id}"))?;
184 let messages = snapshot
185 .transcript
186 .iter()
187 .filter_map(|item| {
188 let (role, text) = match &item.body {
189 mj_core::archive::CanonicalTranscriptBody::User { content } => (
190 Role::User,
191 mj_core::transcript::materialized_content_text(content),
192 ),
193 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
194 Role::Assistant,
195 mj_core::transcript::materialized_chunks_text(chunks),
196 ),
197 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => {
198 (Role::Tool, tool_call_title(call))
199 }
200 _ => return None,
201 };
202 message(role, text, item.created_at_ms)
203 })
204 .collect();
205 Ok((messages, snapshot.session.session_title.clone()))
206 }
207
208 fn projected_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
212 let projection = crate::database::load_materialized_session(session_id)
213 .with_context(|| format!("read the stored transcript of session {session_id}"))?
214 .with_context(|| format!("no stored transcript for session {session_id}"))?;
215 Ok((
216 projected_messages(&projection),
217 projection.session_title.clone(),
218 ))
219 }
220
221 fn key_for(&self, session_id: &str) -> String {
224 format!("{}/{session_id}", self.sessions_dir.display())
225 }
226
227 fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
233 let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
234 let mut had_error = false;
235 let entries = match std::fs::read_dir(&self.sessions_dir) {
236 Ok(entries) => entries,
237 Err(error) => {
238 if self.sessions_dir.exists() {
239 tracing::debug!(
240 directory = %self.sessions_dir.display(),
241 %error,
242 "could not list the checkpoint directory for SessionWiki"
243 );
244 had_error = true;
245 }
246 return (newest, had_error);
247 }
248 };
249 for entry in entries {
250 let Ok(entry) = entry else {
251 had_error = true;
252 continue;
253 };
254 let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
255 else {
256 continue;
257 };
258 let token = entry
259 .metadata()
260 .ok()
261 .and_then(|metadata| metadata.modified().ok())
262 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
263 .map(|age| age.as_secs() as i64)
264 .unwrap_or(0);
265 let candidate = ArchiveFile {
266 path: entry.path(),
267 frontier,
268 token,
269 };
270 match newest.get(&session_id) {
271 Some(existing) if existing.frontier >= candidate.frontier => {}
272 _ => {
273 newest.insert(session_id, candidate);
274 }
275 }
276 }
277 (newest, had_error)
278 }
279}
280
281fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
286 if let Some(parsed) = managed_checkpoint_archive_name(name) {
287 return Some((parsed.session_id, parsed.frontier));
288 }
289 let stem = name
290 .to_str()
291 .and_then(|name| name.strip_suffix(".hel.zip"))?;
292 mj_core::config::validate_id("session", stem)
293 .is_ok()
294 .then(|| (stem.to_owned(), 0))
295}
296
297fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
299 projection
300 .transcript
301 .iter()
302 .filter_map(|item| {
303 let (role, text) = match &item.body {
304 mj_core::state::TranscriptBody::User { content } => (
305 Role::User,
306 mj_core::transcript::materialized_content_text(content),
307 ),
308 mj_core::state::TranscriptBody::Agent { chunks, .. } => (
309 Role::Assistant,
310 mj_core::transcript::materialized_chunks_text(chunks),
311 ),
312 mj_core::state::TranscriptBody::Tool { call, .. } => {
313 (Role::Tool, tool_call_title(call))
314 }
315 _ => return None,
316 };
317 message(role, text, item.created_at_ms)
318 })
319 .collect()
320}
321
322fn tool_call_title(call: &serde_json::Value) -> String {
325 call.get("title")
326 .and_then(serde_json::Value::as_str)
327 .unwrap_or_default()
328 .to_owned()
329}
330
331fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
333 let text = text.trim().to_owned();
334 (!text.is_empty()).then(|| Message {
335 role,
336 text,
337 ts: DateTime::from_timestamp_millis(created_at_ms),
338 })
339}
340
341fn parse_time(value: &str) -> Option<DateTime<Utc>> {
342 DateTime::parse_from_rfc3339(value)
343 .ok()
344 .map(|time| time.with_timezone(&Utc))
345}
346
347impl Adapter for MjolnirAdapter {
348 fn name(&self) -> &'static str {
349 TOOL
350 }
351
352 fn root(&self) -> Option<PathBuf> {
353 Some(self.sessions_dir.clone())
354 }
355
356 fn discover(&self) -> Discovered {
359 Discovered {
360 files: Vec::new(),
361 had_error: false,
362 }
363 }
364
365 fn parse(&self, _path: &Path) -> Result<Session> {
366 anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
367 }
368
369 fn store(&self) -> Option<Store> {
370 self.reload();
371 let (newest, had_error) = self.newest_archives();
372 let mut files = Vec::with_capacity(newest.len());
373 let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
374 for (session_id, archive) in newest {
375 tokens.insert(session_id, archive.token);
376 files.push(archive.path);
377 }
378 let sessions = self
383 .sessions
384 .lock()
385 .unwrap_or_else(std::sync::PoisonError::into_inner);
386 let live = sessions.live.clone();
387 tokens.extend(live);
388 for (session_id, token) in tokens.iter_mut() {
393 let updated = sessions
394 .records
395 .get(session_id)
396 .and_then(|record| parse_time(&record.updated_at))
397 .map(|updated| updated.timestamp());
398 if let Some(updated) = updated {
399 *token = (*token).max(updated);
400 }
401 }
402 let keys = tokens
403 .into_iter()
404 .map(|(session_id, token)| (self.key_for(&session_id), token))
405 .collect();
406 Some(Store {
407 keys,
408 files,
409 had_error,
410 })
411 }
412
413 fn reconcile_scope(&self) -> Option<String> {
417 Some(format!("{}/", self.sessions_dir.display()))
418 }
419
420 fn parse_key(&self, key: &str) -> Result<Session> {
421 let session_id = key.rsplit('/').next().unwrap_or_default();
422 anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
423 let sessions = self
424 .sessions
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 let (messages, snapshot_title) = if sessions.live.contains_key(session_id) {
428 self.projected_transcript(session_id)?
429 } else {
430 self.checkpointed_transcript(session_id)?
431 };
432 let record = sessions.records.get(session_id);
433
434 let title = record
435 .and_then(|record| record.session_title_override.clone())
436 .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
437 .or_else(|| snapshot_title.clone())
438 .unwrap_or_else(|| {
439 messages
440 .iter()
441 .find(|message| message.role == Role::User)
442 .map(|message| message.text.chars().take(80).collect())
443 .unwrap_or_default()
444 });
445
446 Ok(Session {
447 id: session_id.to_owned(),
448 tool: TOOL,
449 path: PathBuf::from(key),
450 project: record
451 .and_then(|record| record.project_directory.as_ref())
452 .map(|directory| directory.display().to_string())
453 .unwrap_or_default(),
454 started: record.and_then(|record| parse_time(&record.created_at)),
455 ended: record.and_then(|record| parse_time(&record.updated_at)),
456 title,
457 subagent: sessions.subagent_ids.contains(session_id),
458 messages,
459 touched: Vec::new(),
460 edits: Vec::new(),
461 })
462 }
463}
464
465struct SharedMjolnirAdapter(Arc<MjolnirAdapter>);
473
474impl Adapter for SharedMjolnirAdapter {
475 fn name(&self) -> &'static str {
476 self.0.name()
477 }
478
479 fn root(&self) -> Option<PathBuf> {
480 self.0.root()
481 }
482
483 fn discover(&self) -> Discovered {
484 self.0.discover()
485 }
486
487 fn parse(&self, path: &Path) -> Result<Session> {
488 self.0.parse(path)
489 }
490
491 fn store(&self) -> Option<Store> {
492 self.0.store()
493 }
494
495 fn parse_key(&self, key: &str) -> Result<Session> {
496 self.0.parse_key(key)
497 }
498
499 fn reconcile_scope(&self) -> Option<String> {
500 self.0.reconcile_scope()
501 }
502}
503
504pub struct WikiIndexer {
511 inner: Arc<Indexer>,
512}
513
514#[derive(Default)]
515struct Indexer {
516 running: tokio::sync::Mutex<()>,
518 notify: tokio::sync::Notify,
519 requested: AtomicBool,
521 full_requested: AtomicBool,
523 in_flight: AtomicBool,
526 last_success: std::sync::Mutex<Option<Success>>,
527}
528
529#[derive(Clone, Copy)]
530struct Success {
531 at: Instant,
532 epoch_seconds: i64,
533}
534
535impl WikiIndexer {
536 pub fn spawn() -> Self {
539 let inner = Arc::new(Indexer::default());
540 if let Ok(handle) = tokio::runtime::Handle::try_current() {
541 let worker = Arc::clone(&inner);
542 handle.spawn(async move { worker.run().await });
543 }
544 Self { inner }
545 }
546
547 pub fn request_sync(&self, full: bool) {
549 if full {
550 self.inner.full_requested.store(true, Ordering::Release);
551 }
552 self.inner.requested.store(true, Ordering::Release);
553 self.inner.notify.notify_one();
554 }
555
556 pub async fn sync_now(&self, full: bool) -> Result<()> {
558 self.inner.sync(full).await
559 }
560
561 pub fn status(&self) -> WikiStatus {
564 WikiStatus {
565 state: index_state(),
566 topping_up: self.inner.in_flight.load(Ordering::Acquire)
567 || self.inner.requested.load(Ordering::Acquire),
568 }
569 }
570
571 pub fn last_success(&self) -> Option<Instant> {
573 self.inner
574 .last_success
575 .lock()
576 .unwrap_or_else(std::sync::PoisonError::into_inner)
577 .map(|success| success.at)
578 }
579}
580
581impl Indexer {
582 async fn run(self: Arc<Self>) {
583 loop {
584 self.notify.notified().await;
585 while self.requested.swap(false, Ordering::AcqRel) {
586 let full = self.full_requested.swap(false, Ordering::AcqRel);
587 if let Err(error) = self.sync(full).await {
588 self.report(&error);
589 break;
594 }
595 }
596 }
597 }
598
599 fn report(&self, error: &anyhow::Error) {
603 if is_busy(error) {
604 self.requested.store(true, Ordering::Release);
605 tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
606 } else {
607 tracing::warn!(%error, "could not sync sessions into SessionWiki");
608 }
609 }
610
611 async fn sync(&self, full: bool) -> Result<()> {
612 let _guard = self.running.lock().await;
613 let since = if full {
614 None
615 } else {
616 self.last_success
617 .lock()
618 .unwrap_or_else(std::sync::PoisonError::into_inner)
619 .map(|success| success.epoch_seconds - 60)
622 };
623 let started = Instant::now();
624 self.in_flight.store(true, Ordering::Release);
625 let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
626 self.in_flight.store(false, Ordering::Release);
627 let ran = ran.context("run the SessionWiki sync")??;
628 if ran {
629 *self
630 .last_success
631 .lock()
632 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
633 at: started,
634 epoch_seconds: Utc::now().timestamp(),
635 });
636 }
637 Ok(())
638 }
639}
640
641fn sync_blocking(since: Option<i64>) -> Result<bool> {
644 if !index_is_writable() {
645 return Ok(false);
646 }
647 let controller =
648 Controller::load().context("load controller state for the SessionWiki sync")?;
649 let mjolnir = Arc::new(MjolnirAdapter::reloading(&controller.state));
652 let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
653 vec![Box::new(SharedMjolnirAdapter(Arc::clone(&mjolnir)))];
654 adapters.extend(native_adapters(&controller.config));
655 let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
656 sessionwiki::index::sync_with(&mut connection, &adapters, since)
657 .context("sync the SessionWiki index")?;
658 write_session_tags(&mut connection, &mjolnir.indexed_tags())
659 .context("store Mjolnir's session metadata in the SessionWiki index")?;
660 if since.is_none() {
661 record_first_build();
665 }
666 Ok(true)
667}
668
669fn write_session_tags(
679 connection: &mut rusqlite::Connection,
680 session_tags: &BTreeMap<String, tags::MjTags>,
681) -> Result<()> {
682 if session_tags.is_empty() {
683 return Ok(());
684 }
685 let transaction = connection
686 .transaction()
687 .context("open a transaction for the session metadata")?;
688 for (session_id, session) in session_tags {
689 if session.is_empty() {
690 continue;
691 }
692 tags::write(&transaction, session_id, session)?;
693 }
694 transaction
695 .commit()
696 .context("commit the session metadata")?;
697 Ok(())
698}
699
700fn native_adapters(config: &mj_core::config::Config) -> Vec<Box<dyn Adapter>> {
717 use mj_core::config::HarnessKind;
718
719 let mut seen: BTreeSet<(HarnessKind, &Path)> = BTreeSet::new();
722 let mut adapters: Vec<Box<dyn Adapter>> = Vec::new();
723 for (_, profile) in config.enabled_profiles() {
724 if !seen.insert((profile.kind, profile.home.as_path())) {
726 continue;
727 }
728 let adapter: Box<dyn Adapter> = match profile.kind {
729 HarnessKind::Codex => {
730 Box::new(sessionwiki::adapters::Codex::in_home(profile.home.clone()))
731 }
732 HarnessKind::Claude => Box::new(sessionwiki::adapters::ClaudeCode::in_home(
733 profile.home.clone(),
734 )),
735 kind => match HarnessAdapter::in_home(kind, profile.home.clone()) {
736 Some(adapter) => Box::new(adapter),
737 None => continue,
738 },
739 };
740 adapters.push(adapter);
741 }
742 adapters.extend(
743 sessionwiki::adapters::all()
744 .into_iter()
745 .filter(|adapter| !matches!(adapter.name(), "codex" | "claude-code")),
746 );
747 adapters
748}
749
750fn index_is_isolated() -> bool {
763 static SAID: AtomicBool = AtomicBool::new(false);
764 if mj_core::config::session_index_is_resolved()
765 || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
766 {
767 return true;
768 }
769 if !SAID.swap(true, Ordering::AcqRel) {
770 tracing::debug!(
771 "this process did not resolve a session index location; SessionWiki is not used"
772 );
773 }
774 false
775}
776
777fn index_version_mismatch() -> bool {
786 static SAID: AtomicBool = AtomicBool::new(false);
787 let Ok(path) = sessionwiki::index::db_path() else {
788 return false;
789 };
790 if !path.exists() {
791 return false;
792 }
793 let version = rusqlite::Connection::open_with_flags(
794 &path,
795 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
796 )
797 .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
798 let version: i64 = match version {
799 Ok(version) => version,
800 Err(error) => {
801 tracing::debug!(%error, "could not read the SessionWiki index schema version");
802 return false;
803 }
804 };
805 let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
808 if mismatch && !SAID.swap(true, Ordering::AcqRel) {
809 tracing::warn!(
810 found = version,
811 expected = sessionwiki::index::SCHEMA_VERSION,
812 path = %path.display(),
813 "the SessionWiki index was written by another version; Mjolnir will not open it, because opening it would rebuild it. Install the matching sessionwiki command"
814 );
815 }
816 mismatch
817}
818
819fn index_is_writable() -> bool {
820 index_is_isolated() && !index_version_mismatch()
821}
822
823fn first_build_marker() -> PathBuf {
826 mj_core::config::data_dir().join("sessionwiki-built")
827}
828
829fn record_first_build() {
830 let path = first_build_marker();
831 let version = sessionwiki::index::SCHEMA_VERSION.to_string();
832 if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
833 return;
834 }
835 if let Err(error) = std::fs::write(&path, &version) {
836 tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
837 }
838}
839
840fn first_build_is_done() -> bool {
842 std::fs::read_to_string(first_build_marker())
843 .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
844 && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
845}
846
847pub fn index_state() -> WikiIndexState {
849 if !index_is_isolated() {
850 return WikiIndexState::Indexing;
851 }
852 if index_version_mismatch() {
853 return WikiIndexState::VersionMismatch;
854 }
855 if first_build_is_done() {
856 WikiIndexState::Ready
857 } else {
858 WikiIndexState::Indexing
859 }
860}
861
862fn is_busy(error: &anyhow::Error) -> bool {
865 error.chain().any(|cause| {
866 matches!(
867 cause.downcast_ref::<rusqlite::Error>(),
868 Some(rusqlite::Error::SqliteFailure(failure, _))
869 if matches!(
870 failure.code,
871 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
872 )
873 )
874 })
875}
876
877pub const MAX_WIKI_LIMIT: usize = 200;
883pub const DEFAULT_WIKI_LIMIT: usize = 50;
885const MIN_FULLTEXT_QUERY: usize = 3;
888pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
890
891pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
893 last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
894}
895
896pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
903 let limit = limit.clamp(1, MAX_WIKI_LIMIT);
904 if !index_is_writable() {
905 return Ok(Vec::new());
909 }
910 let connection = open_readonly()?;
911 let query = query.trim();
912 if query.is_empty() {
913 let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
914 .context("list recent SessionWiki sessions")?;
915 let mut rows: Vec<WikiRow> = rows
916 .into_iter()
917 .map(|row| wiki_row(row, None, live))
918 .collect();
919 fill_session_tags(&connection, &mut rows)?;
920 return Ok(rows);
921 }
922 let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
923 sessionwiki::index::search_like(&connection, query, limit, None, None)
924 } else {
925 sessionwiki::index::search(&connection, query, limit, None, None)
926 }
927 .context("search the SessionWiki index")?;
928 let mut rows: Vec<WikiRow> = hits
929 .into_iter()
930 .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
931 .collect();
932 let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
936 for row in named_like(&connection, query)? {
937 if rows.len() >= limit {
938 break;
939 }
940 if found.contains(&row.session_id) {
941 continue;
942 }
943 rows.push(wiki_row(row, None, live));
944 }
945 fill_session_tags(&connection, &mut rows)?;
946 Ok(rows)
947}
948
949fn fill_session_tags(connection: &rusqlite::Connection, rows: &mut [WikiRow]) -> Result<()> {
955 let ids: Vec<&str> = rows
956 .iter()
957 .filter(|row| row.tool == TOOL)
958 .map(|row| row.id.as_str())
959 .collect();
960 let found = tags::read(connection, &ids).context("read the indexed session metadata")?;
961 for row in rows.iter_mut().filter(|row| row.tool == TOOL) {
962 let Some(session) = found.get(&row.id) else {
963 continue;
964 };
965 row.target = session.target.clone();
966 row.profile = session.profile.clone();
967 row.harness = session.harness.clone();
968 }
969 Ok(())
970}
971
972const NAME_SCAN_LIMIT: usize = 2_000;
976
977fn named_like(
979 connection: &rusqlite::Connection,
980 query: &str,
981) -> Result<Vec<sessionwiki::index::SessionRow>> {
982 let needle = query.to_lowercase();
983 let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
984 .context("list recent SessionWiki sessions")?;
985 Ok(rows
986 .into_iter()
987 .filter(|row| {
988 row.title.to_lowercase().contains(&needle)
989 || row.project.to_lowercase().contains(&needle)
990 })
991 .collect())
992}
993
994pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
996 if !index_is_writable() {
997 return Ok(None);
998 }
999 let connection = open_readonly()?;
1000 let Some(row) = row_by_id(&connection, id)? else {
1001 return Ok(None);
1002 };
1003 let session = sessionwiki::index::session_from_index(&connection, &row)
1004 .context("read an indexed session")?;
1005 Ok(Some(sessionwiki::commands::brief_markdown(
1006 &session, max_chars, true,
1007 )))
1008}
1009
1010pub fn transcript_hits(
1018 id: &str,
1019 query: &str,
1020 context_messages: usize,
1021 per_message_chars: usize,
1022) -> Result<Option<WikiHitTranscript>> {
1023 if !index_is_writable() {
1024 return Ok(None);
1025 }
1026 let connection = open_readonly()?;
1027 let Some(row) = row_by_id(&connection, id)? else {
1028 return Ok(None);
1029 };
1030 let session = sessionwiki::index::session_from_index(&connection, &row)
1031 .context("read an indexed session")?;
1032 Ok(Some(hit_transcript(
1033 &session,
1034 query,
1035 context_messages,
1036 per_message_chars,
1037 )))
1038}
1039
1040fn hit_transcript(
1050 session: &Session,
1051 query: &str,
1052 context_messages: usize,
1053 per_message_chars: usize,
1054) -> WikiHitTranscript {
1055 let needle = sessionwiki::util::nfc(query.trim()).to_lowercase();
1056 if needle.is_empty() || session.messages.is_empty() {
1057 return WikiHitTranscript::default();
1058 }
1059 let texts: Vec<String> = session
1060 .messages
1061 .iter()
1062 .map(|message| {
1063 sessionwiki::redact::redact(&sessionwiki::util::nfc(message.text.trim())).into_owned()
1064 })
1065 .collect();
1066 let found: Vec<Vec<(usize, usize)>> = texts
1072 .iter()
1073 .zip(&session.messages)
1074 .map(|(text, message)| match message.role {
1075 Role::Tool => Vec::new(),
1076 _ => matches_in(text, &needle),
1077 })
1078 .collect();
1079
1080 let last = texts.len() - 1;
1083 let mut groups: Vec<(usize, usize)> = Vec::new();
1084 for index in (0..texts.len()).filter(|index| !found[*index].is_empty()) {
1085 let start = index.saturating_sub(context_messages);
1086 let end = (index + context_messages).min(last);
1087 match groups.last_mut() {
1088 Some(previous) if start <= previous.1 + 1 => previous.1 = previous.1.max(end),
1089 _ => groups.push((start, end)),
1090 }
1091 }
1092 if groups.is_empty() {
1093 return WikiHitTranscript::default();
1094 }
1095
1096 let mut blocks: Vec<WikiHitBlock> = Vec::new();
1097 let mut previous_end: Option<usize> = None;
1098 for (start, end) in &groups {
1099 let omitted = match previous_end {
1100 Some(previous) => start - previous - 1,
1101 None => *start,
1102 };
1103 for index in *start..=*end {
1104 let (text, hits, truncated) = excerpt(&texts[index], &found[index], per_message_chars);
1105 blocks.push(WikiHitBlock {
1106 role: role_name(session.messages[index].role).to_owned(),
1107 text,
1108 hits,
1109 omitted_before: if index == *start { omitted } else { 0 },
1110 truncated,
1111 });
1112 }
1113 previous_end = Some(*end);
1114 }
1115 WikiHitTranscript {
1116 blocks,
1117 omitted_after: last - previous_end.unwrap_or(last),
1118 }
1119}
1120
1121fn role_name(role: Role) -> &'static str {
1122 match role {
1123 Role::User => "user",
1124 Role::Assistant => "assistant",
1125 Role::Tool => "tool",
1126 }
1127}
1128
1129fn matches_in(text: &str, needle: &str) -> Vec<(usize, usize)> {
1137 let mut lowered = String::with_capacity(text.len());
1138 let mut origin: Vec<usize> = Vec::with_capacity(text.len() + 1);
1139 for (index, character) in text.char_indices() {
1140 let before = lowered.len();
1141 lowered.extend(character.to_lowercase());
1142 origin.resize(origin.len() + (lowered.len() - before), index);
1143 }
1144 origin.push(text.len());
1145
1146 let mut hits: Vec<(usize, usize)> = Vec::new();
1147 let mut from = 0;
1148 while let Some(offset) = lowered[from..].find(needle) {
1149 let start = from + offset;
1150 from = start + needle.len();
1151 let begin = origin[start];
1152 let mut end = origin[from];
1153 if end <= begin {
1154 end = text[begin..]
1156 .chars()
1157 .next()
1158 .map_or(begin, |character| begin + character.len_utf8());
1159 }
1160 hits.push((begin, end));
1161 }
1162 hits
1163}
1164
1165fn excerpt(
1168 text: &str,
1169 hits: &[(usize, usize)],
1170 per_message_chars: usize,
1171) -> (String, Vec<(usize, usize)>, bool) {
1172 let total = text.chars().count();
1173 if per_message_chars == 0 || total <= per_message_chars {
1174 return (text.to_owned(), hits.to_vec(), false);
1175 }
1176 let first = hits
1179 .first()
1180 .map_or(0, |(start, _)| text[..*start].chars().count());
1181 let mut window_start = first.saturating_sub(per_message_chars / 4);
1182 window_start = window_start.min(total - per_message_chars);
1183 let begin = byte_of_char(text, window_start);
1184 let end = byte_of_char(text, window_start + per_message_chars);
1185 let kept = hits
1186 .iter()
1187 .filter_map(|(start, stop)| {
1188 let start = (*start).max(begin);
1189 let stop = (*stop).min(end);
1190 if start < stop {
1191 Some((start - begin, stop - begin))
1192 } else {
1193 None
1194 }
1195 })
1196 .collect();
1197 (text[begin..end].to_owned(), kept, true)
1198}
1199
1200fn byte_of_char(text: &str, char_index: usize) -> usize {
1201 text.char_indices()
1202 .nth(char_index)
1203 .map_or(text.len(), |(offset, _)| offset)
1204}
1205
1206pub struct ArchivedSession {
1210 pub title: String,
1211 pub project_directory: Option<PathBuf>,
1214 pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
1215}
1216
1217pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
1219 if !index_is_writable() {
1220 return Ok(None);
1221 }
1222 let connection = open_readonly()?;
1223 let Some(row) = row_by_id(&connection, id)? else {
1224 return Ok(None);
1225 };
1226 let session = sessionwiki::index::session_from_index(&connection, &row)
1227 .context("read an indexed session")?;
1228 let snapshot = snapshot_of(&session)?;
1229 Ok(Some(ArchivedSession {
1230 title: session.title.clone(),
1231 project_directory: project_directory_of(&session.project),
1232 snapshot,
1233 }))
1234}
1235
1236pub fn sessions_ready_to_archive(
1252 sessions: &BTreeMap<String, SessionRecord>,
1253 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1254 now: DateTime<Utc>,
1255 older_than_days: u32,
1256) -> Vec<String> {
1257 let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
1258 let aged = |session_id: &String| {
1259 sessions.get(session_id).is_some_and(|record| {
1260 record.state == mj_core::state::SessionState::Stopped
1261 && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
1262 })
1263 };
1264 let selected: BTreeSet<String> = sessions
1265 .keys()
1266 .filter(|session_id| aged(session_id))
1267 .filter(|session_id| {
1268 subagents
1269 .values()
1270 .filter(|child| &&child.parent_session_id == session_id)
1271 .filter(|child| sessions.contains_key(&child.child_session_id))
1273 .all(|child| aged(&child.child_session_id))
1274 })
1275 .cloned()
1276 .collect();
1277 let mut ordered: Vec<String> = selected.iter().cloned().collect();
1278 ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
1279 ordered
1280}
1281
1282fn ancestor_depth(
1286 session_id: &str,
1287 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1288) -> usize {
1289 let mut depth = 0;
1290 let mut current = session_id;
1291 while let Some(parent) = subagents
1293 .get(current)
1294 .map(|child| child.parent_session_id.as_str())
1295 {
1296 depth += 1;
1297 if depth > subagents.len() {
1298 break;
1299 }
1300 current = parent;
1301 }
1302 depth
1303}
1304
1305pub use mj_core::state::ArchiveSpacePreview;
1311
1312pub fn archive_space_preview(older_than_days: Option<u32>) -> Result<ArchiveSpacePreview> {
1324 let controller =
1325 Controller::load().context("load the session records to size their storage")?;
1326 Ok(archive_space_over(
1327 &mj_core::config::sessions_dir(),
1328 &controller.state.sessions,
1329 &controller.state.subagents,
1330 Utc::now(),
1331 older_than_days,
1332 ))
1333}
1334
1335fn archive_space_over(
1338 sessions_root: &Path,
1339 sessions: &BTreeMap<String, SessionRecord>,
1340 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1341 now: DateTime<Utc>,
1342 older_than_days: Option<u32>,
1343) -> ArchiveSpacePreview {
1344 let mut preview = ArchiveSpacePreview {
1345 sessions: sessions.len(),
1346 bytes: sessions
1347 .iter()
1348 .map(|(session_id, record)| session_bytes(sessions_root, session_id, record))
1349 .sum(),
1350 reclaimable_sessions: 0,
1351 reclaimable_bytes: 0,
1352 };
1353 if let Some(days) = older_than_days {
1354 let aged = sessions_ready_to_archive(sessions, subagents, now, days);
1355 preview.reclaimable_sessions = aged.len();
1356 preview.reclaimable_bytes = aged
1357 .iter()
1358 .filter_map(|session_id| {
1359 sessions
1360 .get(session_id)
1361 .map(|record| session_bytes(sessions_root, session_id, record))
1362 })
1363 .sum();
1364 }
1365 preview
1366}
1367
1368fn session_bytes(sessions_root: &Path, session_id: &str, record: &SessionRecord) -> u64 {
1371 let checkpoint = record
1372 .checkpoint
1373 .as_ref()
1374 .and_then(|checkpoint| std::fs::metadata(&checkpoint.archive_path).ok())
1375 .filter(|metadata| metadata.is_file())
1376 .map(|metadata| metadata.len())
1377 .unwrap_or(0);
1378 let attachments = sessions_root
1379 .join(session_id)
1380 .join(mj_core::attachment::ATTACHMENT_DIR);
1381 let attachments = crate::import::claude::directory_size(&attachments).unwrap_or(0);
1382 checkpoint.saturating_add(attachments)
1383}
1384
1385pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
1392 if !index_is_writable() {
1393 return Ok(BTreeSet::new());
1396 }
1397 let connection = open_readonly()?;
1398 let sessions_dir = mj_core::config::sessions_dir();
1399 let mut indexed = BTreeSet::new();
1400 for session_id in session_ids {
1401 let key = format!("{}/{session_id}", sessions_dir.display());
1402 let rows = sessionwiki::index::resolve(&connection, session_id)
1403 .context("look up a stopped session in the SessionWiki index")?;
1404 if rows
1405 .iter()
1406 .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
1407 {
1408 indexed.insert(session_id.clone());
1409 }
1410 }
1411 Ok(indexed)
1412}
1413
1414fn open_readonly() -> Result<rusqlite::Connection> {
1415 sessionwiki::index::open_readonly().context("open the SessionWiki index")
1416}
1417
1418fn row_by_id(
1421 connection: &rusqlite::Connection,
1422 id: &str,
1423) -> Result<Option<sessionwiki::index::SessionRow>> {
1424 Ok(sessionwiki::index::resolve(connection, id)
1425 .context("look up an indexed session")?
1426 .into_iter()
1427 .find(|row| row.session_id == id))
1428}
1429
1430fn wiki_row(
1431 row: sessionwiki::index::SessionRow,
1432 snippet: Option<String>,
1433 live: &BTreeSet<String>,
1434) -> WikiRow {
1435 let hel_session_id = (row.tool == TOOL)
1438 .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
1439 .filter(|session_id| live.contains(session_id));
1440 let native_id = sessionwiki::index::native_id_of(&row.path);
1441 WikiRow {
1442 id: row.session_id,
1443 tool: row.tool,
1444 project: row.project,
1445 title: row.title,
1446 started: row.started,
1447 msgs: row.msg_count,
1448 preview: row.preview,
1449 archived: row.archived,
1450 native_id,
1451 snippet,
1452 hel_session_id,
1453 target: None,
1456 profile: None,
1457 harness: None,
1458 }
1459}
1460
1461fn project_directory_of(project: &str) -> Option<PathBuf> {
1469 if project.trim().is_empty() {
1470 return None;
1471 }
1472 let path = PathBuf::from(project);
1473 let repository = path
1474 .ancestors()
1475 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1476 .and_then(std::path::Path::parent)
1477 .map(std::path::Path::to_path_buf)
1478 .unwrap_or(path);
1479 repository.is_dir().then_some(repository)
1480}
1481
1482fn snapshot_of(
1489 session: &sessionwiki::model::Session,
1490) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1491 use mj_core::archive::{
1492 CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1493 CanonicalTranscriptBody, CanonicalTranscriptItem,
1494 };
1495
1496 let started_ms = session
1497 .started
1498 .map(|time| time.timestamp_millis())
1499 .unwrap_or_default();
1500 let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1501 for message in &session.messages {
1502 let text = message.text.trim();
1503 if text.is_empty() {
1504 continue;
1505 }
1506 if transcript.is_empty() && message.role != Role::User {
1509 continue;
1510 }
1511 let position = transcript.len() as u64 + 1;
1512 let body = match message.role {
1513 Role::User => CanonicalTranscriptBody::User {
1514 content: vec![serde_json::json!({"type": "text", "text": text})],
1515 },
1516 Role::Assistant => CanonicalTranscriptBody::Agent {
1517 chunks: vec![serde_json::json!({
1518 "content": {"type": "text", "text": text}
1519 })],
1520 streaming: false,
1521 },
1522 Role::Tool => CanonicalTranscriptBody::Tool {
1525 call: serde_json::json!({
1526 "toolCallId": format!("wiki-tool-{position}"),
1527 "title": text,
1528 "status": "completed"
1529 }),
1530 terminal_outputs: Vec::new(),
1531 terminal_refs: Vec::new(),
1532 presentation: None,
1533 },
1534 };
1535 let created_at_ms = message
1536 .ts
1537 .map(|time| time.timestamp_millis())
1538 .unwrap_or(started_ms);
1539 transcript.push(CanonicalTranscriptItem {
1540 stable_id: format!("wiki-{position}"),
1541 position,
1542 latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1545 .then_some(position),
1546 created_at_ms,
1547 last_changed_at_ms: created_at_ms,
1548 body,
1549 });
1550 }
1551 anyhow::ensure!(
1552 !transcript.is_empty(),
1553 "the archived session has no prompt to restore from"
1554 );
1555
1556 let event_frontier = transcript.len() as u64;
1557 let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1558 Ok(CanonicalSessionSnapshot {
1559 event_frontier,
1560 event_frontier_digest: {
1564 use sha2::Digest;
1565 mj_core::hex::lower_hex(sha2::Sha256::digest(
1566 format!("sessionwiki:{}", session.id).as_bytes(),
1567 ))
1568 },
1569 session: CanonicalSessionState {
1570 execution: CanonicalExecutionState::Idle,
1571 last_activity_at_ms,
1572 session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1573 configuration: BTreeMap::new(),
1574 },
1575 transcript,
1576 queued_prompts: Vec::new(),
1577 })
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582 use std::collections::BTreeMap;
1583 use std::path::Path;
1584
1585 use mj_checkpoint::archive::{
1586 ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1587 CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1588 TargetManifest, write_archive_atomic,
1589 };
1590
1591 use super::*;
1592
1593 fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1594 let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1597 CanonicalTranscriptItem {
1598 stable_id: format!("item-{position}"),
1599 position,
1600 latest_content_event_ordinal: streamed.then_some(position),
1601 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1602 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1603 body,
1604 }
1605 }
1606
1607 fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1610 let path = directory.join(format!(
1611 "{session_id}-{frontier}-archive-{}.hel.zip",
1612 "0".repeat(32)
1613 ));
1614 write_archive_atomic(
1615 &path,
1616 &ArchiveInput {
1617 session: SessionManifest {
1618 id: session_id.into(),
1619 title: "indexed session".into(),
1620 harness_kind: mj_core::config::HarnessKind::Codex,
1621 profile_id: "codex".into(),
1622 native_session_id: "native-session".into(),
1623 created_at: "2026-09-01T00:00:00Z".into(),
1624 checkpointed_at: "2026-09-01T01:00:00Z".into(),
1625 hel_version: "test".into(),
1626 relay_version: "test".into(),
1627 adapter_version: "test".into(),
1628 },
1629 target: TargetManifest {
1630 template_id: "local".into(),
1631 target_kind: "local-bare".into(),
1632 details: BTreeMap::new(),
1633 },
1634 bundle: BundleManifest {
1635 id: "project".into(),
1636 primary_repository: "project".into(),
1637 },
1638 canonical_session: CanonicalSessionSnapshot {
1639 event_frontier: 4,
1640 event_frontier_digest: "a".repeat(64),
1641 session: CanonicalSessionState {
1642 execution: CanonicalExecutionState::Idle,
1643 last_activity_at_ms: Some(1_700_000_000_004),
1644 session_title: Some("snapshot title".into()),
1645 configuration: BTreeMap::new(),
1646 },
1647 transcript: vec![
1648 item(
1649 1,
1650 CanonicalTranscriptBody::User {
1651 content: vec![serde_json::json!({
1652 "type": "text",
1653 "text": "index this session"
1654 })],
1655 },
1656 ),
1657 item(
1658 2,
1659 CanonicalTranscriptBody::Thought {
1660 chunks: vec![serde_json::json!({
1661 "content": {"type": "text", "text": "pondering"}
1662 })],
1663 streaming: false,
1664 },
1665 ),
1666 item(
1667 3,
1668 CanonicalTranscriptBody::Tool {
1669 call: serde_json::json!({
1670 "toolCallId": "call-1",
1671 "title": "Read config.toml",
1672 "status": "completed"
1673 }),
1674 terminal_outputs: Vec::new(),
1675 terminal_refs: Vec::new(),
1676 presentation: None,
1677 },
1678 ),
1679 item(
1680 4,
1681 CanonicalTranscriptBody::Agent {
1682 chunks: vec![serde_json::json!({
1683 "content": {"type": "text", "text": "done"}
1684 })],
1685 streaming: false,
1686 },
1687 ),
1688 ],
1689 queued_prompts: Vec::new(),
1690 },
1691 native_artifacts: Vec::new(),
1692 repositories: Vec::new(),
1693 },
1694 )
1695 .unwrap();
1696 }
1697
1698 fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1699 adapter_with_live(directory, session_id, BTreeMap::new())
1700 }
1701
1702 fn adapter_with_live(
1703 directory: &Path,
1704 session_id: &str,
1705 live: BTreeMap<String, i64>,
1706 ) -> MjolnirAdapter {
1707 let record = SessionRecord {
1708 id: session_id.into(),
1709 ..record_template()
1710 };
1711 MjolnirAdapter {
1712 sessions_dir: directory.to_path_buf(),
1713 sessions: std::sync::Mutex::new(Sessions {
1714 records: BTreeMap::from([(session_id.to_owned(), record)]),
1715 subagent_ids: BTreeSet::new(),
1716 live,
1717 }),
1718 reload: false,
1719 }
1720 }
1721
1722 fn record_template() -> SessionRecord {
1723 SessionRecord {
1724 build_cache: None,
1725 container_workspace: None,
1726 mjolnir_subagents: None,
1727 create_managed_worktree: None,
1728 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1729 archived: false,
1730 container_cpus: None,
1731 container_memory: None,
1732 id: "0123456789abcdef0123456789abcdef".into(),
1733 title: "indexed session".into(),
1734 harness_kind: mj_core::config::HarnessKind::Codex,
1735 last_profile: "codex".into(),
1736 bundle_id: "project".into(),
1737 project_directory: Some(PathBuf::from("/home/dev/project")),
1738 managed_worktree: None,
1739 target_template_id: "local-bare".into(),
1740 resource_allocation: None,
1741 additional_mounts: Vec::new(),
1742 state: mj_core::state::SessionState::Stopped,
1743 target: None,
1744 native_session_id: Some("native-session".into()),
1745 acp_session_title: Some("the harness title".into()),
1746 session_title_override: None,
1747 created_at: "2026-09-01T00:00:00Z".into(),
1748 updated_at: "2026-09-01T01:00:00Z".into(),
1749 viewed_through_event_ordinal: 0,
1750 draft_input: String::new(),
1751 last_error: None,
1752 last_checkpoint_error: None,
1753 checkpoint: None,
1754 }
1755 }
1756
1757 #[test]
1758 fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1759 let directory = tempfile::tempdir().unwrap();
1760 let session_id = "0123456789abcdef0123456789abcdef";
1761 write_archive(directory.path(), session_id, 1);
1762 write_archive(directory.path(), session_id, 7);
1763 let adapter = adapter(directory.path(), session_id);
1764
1765 let store = adapter.store().expect("the adapter is a shared store");
1766 let key = format!("{}/{session_id}", directory.path().display());
1767 assert_eq!(
1768 store
1769 .keys
1770 .iter()
1771 .map(|(key, _)| key.as_str())
1772 .collect::<Vec<_>>(),
1773 vec![key.as_str()]
1774 );
1775 assert!(!store.had_error);
1776 assert_eq!(store.files.len(), 1);
1777 assert!(
1778 store.files[0]
1779 .file_name()
1780 .unwrap()
1781 .to_str()
1782 .unwrap()
1783 .contains("-7-archive-"),
1784 "the newest checkpoint is the one indexed: {:?}",
1785 store.files[0]
1786 );
1787 assert_eq!(
1788 adapter.reconcile_scope(),
1789 Some(format!("{}/", directory.path().display()))
1790 );
1791
1792 let session = adapter.parse_key(&key).unwrap();
1793 assert_eq!(session.id, session_id);
1794 assert_eq!(session.tool, "mjolnir");
1795 assert_eq!(session.path, PathBuf::from(&key));
1796 assert_eq!(session.project, "/home/dev/project");
1797 assert_eq!(session.title, "the harness title");
1798 assert!(!session.subagent);
1799 assert_eq!(
1800 session
1801 .messages
1802 .iter()
1803 .map(|message| (message.role, message.text.as_str()))
1804 .collect::<Vec<_>>(),
1805 vec![
1806 (Role::User, "index this session"),
1807 (Role::Tool, "Read config.toml"),
1808 (Role::Assistant, "done"),
1809 ]
1810 );
1811 }
1812
1813 fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1814 use mj_core::transcript::{TranscriptBody, TranscriptItem};
1815 let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1816 let mut push = |position: u64, body: TranscriptBody| {
1817 let streamed = matches!(body, TranscriptBody::Agent { .. });
1818 projected
1819 .transcript
1820 .push(std::sync::Arc::new(TranscriptItem {
1821 stable_id: format!("item-{position}"),
1822 position,
1823 latest_content_event_ordinal: streamed.then_some(position),
1824 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1825 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1826 body,
1827 }));
1828 };
1829 push(
1830 1,
1831 TranscriptBody::User {
1832 content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1833 },
1834 );
1835 push(
1836 2,
1837 TranscriptBody::Thought {
1838 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1839 streaming: false,
1840 },
1841 );
1842 push(
1843 3,
1844 TranscriptBody::Tool {
1845 call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1846 terminal_outputs: Vec::new(),
1847 terminal_refs: Vec::new(),
1848 presentation: None,
1849 },
1850 );
1851 push(
1852 4,
1853 TranscriptBody::Agent {
1854 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1855 streaming: false,
1856 },
1857 );
1858 projected.session_title = Some("the live title".into());
1859 projected
1860 }
1861
1862 #[test]
1865 fn a_running_session_is_indexed_from_its_stored_transcript() {
1866 let session_id = "0123456789abcdef0123456789abcdef";
1867 assert_eq!(
1868 projected_messages(&projection(session_id))
1869 .iter()
1870 .map(|message| (message.role, message.text.clone()))
1871 .collect::<Vec<_>>(),
1872 vec![
1873 (Role::User, "still talking".to_owned()),
1874 (Role::Tool, "Read README.md".to_owned()),
1875 (Role::Assistant, "reading".to_owned()),
1876 ],
1877 "a thought is skipped and every other item keeps its role"
1878 );
1879 }
1880
1881 #[test]
1886 fn a_running_session_is_listed_with_its_own_change_token() {
1887 let directory = tempfile::tempdir().unwrap();
1888 let running = "0123456789abcdef0123456789abcdef";
1889 let never_checkpointed = "fedcba9876543210fedcba9876543210";
1890 write_archive(directory.path(), running, 3);
1891 let live = adapter_with_live(
1892 directory.path(),
1893 running,
1894 BTreeMap::from([
1895 (running.to_owned(), 1_900_000_000),
1896 (never_checkpointed.to_owned(), 1_900_000_001),
1897 ]),
1898 );
1899
1900 let store = live.store().expect("the adapter is a shared store");
1901 let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
1902 assert_eq!(
1903 store.keys,
1904 vec![
1905 (key_of(running), 1_900_000_000),
1906 (key_of(never_checkpointed), 1_900_000_001),
1907 ],
1908 "a live session's own token replaces the checkpoint's"
1909 );
1910
1911 let stopped = adapter(directory.path(), running);
1914 let keys = stopped.store().expect("a shared store").keys;
1915 assert_eq!(keys.len(), 1);
1916 assert_eq!(keys[0].0, key_of(running));
1917 assert_ne!(keys[0].1, 1_900_000_000);
1918 assert_eq!(
1919 stopped.parse_key(&key_of(running)).unwrap().title,
1920 "the harness title",
1921 "a stopped session is parsed from its checkpoint"
1922 );
1923 }
1924
1925 #[test]
1928 fn a_rename_moves_a_session_change_token() {
1929 let directory = tempfile::tempdir().unwrap();
1930 let session_id = "0123456789abcdef0123456789abcdef";
1931 write_archive(directory.path(), session_id, 1);
1932 let adapter = adapter(directory.path(), session_id);
1933 let before = adapter.store().expect("a shared store").keys[0].1;
1934
1935 {
1936 let mut sessions = adapter.sessions.lock().unwrap();
1937 let record = sessions.records.get_mut(session_id).unwrap();
1938 record.session_title_override = Some("the new name".into());
1939 record.updated_at = "2099-01-01T00:00:00Z".into();
1940 }
1941 let after = adapter.store().expect("a shared store").keys[0].1;
1942 assert!(
1943 after > before,
1944 "a renamed session is re-indexed: {before} then {after}"
1945 );
1946 assert_eq!(
1947 adapter
1948 .parse_key(&format!("{}/{session_id}", directory.path().display()))
1949 .unwrap()
1950 .title,
1951 "the new name"
1952 );
1953 }
1954
1955 fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
1956 Session {
1957 id: "0123456789abcdef0123456789abcdef".into(),
1958 tool: "mjolnir",
1959 path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
1960 project: "/home/dev/project".into(),
1961 started: DateTime::from_timestamp_millis(1_700_000_000_000),
1962 ended: None,
1963 title: "the archived session".into(),
1964 subagent: false,
1965 messages: messages
1966 .into_iter()
1967 .map(|(role, text)| Message {
1968 role,
1969 text: text.to_owned(),
1970 ts: None,
1971 })
1972 .collect(),
1973 touched: Vec::new(),
1974 edits: Vec::new(),
1975 }
1976 }
1977
1978 #[test]
1981 fn transcript_hits_locates_case_insensitive_matches() {
1982 let session = indexed(vec![
1983 (Role::User, "Make the Tests green"),
1984 (Role::Assistant, "the tests are green now"),
1985 ]);
1986
1987 let found = hit_transcript(&session, "TESTS", 0, 4_000);
1988
1989 assert_eq!(found.blocks.len(), 2, "both messages contain the query");
1990 assert_eq!(found.blocks[0].role, "user");
1991 let (start, end) = found.blocks[0].hits[0];
1992 assert_eq!(&found.blocks[0].text[start..end], "Tests");
1993 let (start, end) = found.blocks[1].hits[0];
1994 assert_eq!(&found.blocks[1].text[start..end], "tests");
1995 assert!(!found.blocks[0].truncated);
1996 assert_eq!(found.omitted_after, 0);
1997 }
1998
1999 #[test]
2002 fn transcript_hits_keeps_context_and_marks_omissions() {
2003 let session = indexed(vec![
2004 (Role::User, "zero"),
2005 (Role::Assistant, "one needle one"),
2006 (Role::Tool, "two"),
2007 (Role::User, "three"),
2008 (Role::Assistant, "four"),
2009 (Role::Tool, "five"),
2010 (Role::User, "six needle six"),
2011 (Role::Assistant, "seven"),
2012 (Role::User, "eight"),
2013 ]);
2014
2015 let found = hit_transcript(&session, "needle", 1, 4_000);
2016
2017 let shown: Vec<(&str, &str, usize)> = found
2018 .blocks
2019 .iter()
2020 .map(|block| {
2021 (
2022 block.role.as_str(),
2023 block.text.as_str(),
2024 block.omitted_before,
2025 )
2026 })
2027 .collect();
2028 assert_eq!(
2029 shown,
2030 vec![
2031 ("user", "zero", 0),
2032 ("assistant", "one needle one", 0),
2033 ("tool", "two", 0),
2034 ("tool", "five", 2),
2035 ("user", "six needle six", 0),
2036 ("assistant", "seven", 0),
2037 ]
2038 );
2039 assert_eq!(found.omitted_after, 1, "the last message is not shown");
2040 assert!(found.blocks[0].hits.is_empty(), "context has no hits");
2041 }
2042
2043 #[test]
2049 fn transcript_hits_never_anchor_on_tool_output() {
2050 let session = indexed(vec![
2051 (Role::User, "make it build"),
2052 (Role::Tool, "cargo build --needle"),
2053 (Role::Assistant, "it builds"),
2054 ]);
2055
2056 let only_in_a_tool = hit_transcript(&session, "needle", 1, 4_000);
2057 assert!(
2058 only_in_a_tool.blocks.is_empty(),
2059 "tool output must not anchor a passage, got {:?}",
2060 only_in_a_tool.blocks
2061 );
2062
2063 let beside_a_match = hit_transcript(&session, "builds", 1, 4_000);
2064 let shown: Vec<(&str, bool)> = beside_a_match
2065 .blocks
2066 .iter()
2067 .map(|block| (block.role.as_str(), !block.hits.is_empty()))
2068 .collect();
2069 assert_eq!(
2070 shown,
2071 vec![("tool", false), ("assistant", true)],
2072 "a tool message is still context around a real match"
2073 );
2074 }
2075
2076 #[test]
2079 fn transcript_hits_window_keeps_the_first_hit() {
2080 let filler = "x".repeat(4_000);
2081 let session = indexed(vec![(Role::User, &format!("{filler} needle {filler}"))]);
2082
2083 let found = hit_transcript(&session, "needle", 0, 100);
2084
2085 let block = &found.blocks[0];
2086 assert!(block.truncated);
2087 assert_eq!(block.text.chars().count(), 100);
2088 assert_eq!(block.hits.len(), 1, "the windowed text keeps its hit");
2089 let (start, end) = block.hits[0];
2090 assert_eq!(&block.text[start..end], "needle");
2091 assert!(
2092 start >= 20,
2093 "the window keeps lead-in before the hit, got {start}"
2094 );
2095 }
2096
2097 #[test]
2101 fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
2102 let snapshot = snapshot_of(&indexed(vec![
2103 (Role::User, "make the tests green"),
2104 (Role::Tool, "Read src/lib.rs"),
2105 (Role::Assistant, "they are green now"),
2106 (Role::User, " "),
2107 ]))
2108 .unwrap();
2109
2110 snapshot.validate().expect("the snapshot is well formed");
2111 assert_eq!(snapshot.event_frontier, 3);
2112 assert_eq!(
2113 snapshot.session.session_title.as_deref(),
2114 Some("the archived session")
2115 );
2116 assert!(snapshot.session.last_activity_at_ms.is_some());
2117 let bodies = snapshot
2118 .transcript
2119 .iter()
2120 .map(|item| match &item.body {
2121 mj_core::archive::CanonicalTranscriptBody::User { content } => (
2122 "user",
2123 mj_core::transcript::materialized_content_text(content),
2124 ),
2125 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
2126 "agent",
2127 mj_core::transcript::materialized_chunks_text(chunks),
2128 ),
2129 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
2130 "tool",
2131 call["title"].as_str().unwrap_or_default().to_owned(),
2132 ),
2133 _ => ("other", String::new()),
2134 })
2135 .collect::<Vec<_>>();
2136 assert_eq!(
2137 bodies,
2138 vec![
2139 ("user", "make the tests green".to_owned()),
2140 ("tool", "Read src/lib.rs".to_owned()),
2141 ("agent", "they are green now".to_owned()),
2142 ],
2143 "the blank message is dropped and every other one keeps its role"
2144 );
2145 }
2146
2147 #[test]
2151 fn messages_before_the_first_prompt_are_dropped() {
2152 let snapshot = snapshot_of(&indexed(vec![
2153 (Role::Assistant, "still working"),
2154 (Role::User, "carry on"),
2155 ]))
2156 .unwrap();
2157 assert_eq!(snapshot.transcript.len(), 1);
2158 assert_eq!(snapshot.transcript[0].position, 1);
2159 snapshot.validate().unwrap();
2160
2161 let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
2162 assert!(
2163 error.to_string().contains("no prompt"),
2164 "a session with no prompt cannot be restored: {error}"
2165 );
2166 }
2167
2168 fn record(
2169 session_id: &str,
2170 state: mj_core::state::SessionState,
2171 updated_at: &str,
2172 ) -> SessionRecord {
2173 SessionRecord {
2174 id: session_id.into(),
2175 state,
2176 updated_at: updated_at.into(),
2177 ..record_template()
2178 }
2179 }
2180
2181 fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
2182 mj_core::subagent::SubagentRecord {
2183 child_session_id: child_session_id.into(),
2184 parent_session_id: parent_session_id.into(),
2185 task_name: "task".into(),
2186 profile_id: "codex".into(),
2187 model: None,
2188 effort: None,
2189 working_directory: PathBuf::new(),
2190 initial_prompt: "do the thing".into(),
2191 request_key: "key".into(),
2192 created_at: "2026-09-01T00:00:00Z".into(),
2193 noticed_turn: None,
2194 }
2195 }
2196
2197 fn ready(
2198 sessions: Vec<SessionRecord>,
2199 children: Vec<mj_core::subagent::SubagentRecord>,
2200 ) -> Vec<String> {
2201 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2202 sessions_ready_to_archive(
2203 &sessions
2204 .into_iter()
2205 .map(|record| (record.id.clone(), record))
2206 .collect(),
2207 &children
2208 .into_iter()
2209 .map(|child| (child.child_session_id.clone(), child))
2210 .collect(),
2211 now,
2212 3,
2213 )
2214 }
2215
2216 fn sized_session(
2218 root: &Path,
2219 session_id: &str,
2220 updated_at: &str,
2221 checkpoint_bytes: usize,
2222 attachment_bytes: &[usize],
2223 ) -> SessionRecord {
2224 let archive_path = root.join(format!("{session_id}.hel.zip"));
2225 std::fs::write(&archive_path, vec![b'c'; checkpoint_bytes]).unwrap();
2226 if !attachment_bytes.is_empty() {
2227 let attachments = root
2228 .join(session_id)
2229 .join(mj_core::attachment::ATTACHMENT_DIR);
2230 std::fs::create_dir_all(&attachments).unwrap();
2231 for (index, size) in attachment_bytes.iter().enumerate() {
2232 std::fs::write(attachments.join(format!("{index}.png")), vec![b'a'; *size])
2233 .unwrap();
2234 }
2235 }
2236 SessionRecord {
2237 checkpoint: Some(mj_core::state::CheckpointMetadata {
2238 archive_path,
2239 sha256: "0".repeat(64),
2240 created_at: updated_at.into(),
2241 event_frontier: 1,
2242 }),
2243 ..record(
2244 session_id,
2245 mj_core::state::SessionState::Stopped,
2246 updated_at,
2247 )
2248 }
2249 }
2250
2251 #[test]
2252 fn the_space_preview_sizes_every_session_and_only_the_aged_ones_as_reclaimable() {
2253 let directory = tempfile::tempdir().unwrap();
2254 let root = directory.path();
2255 let sessions: BTreeMap<String, SessionRecord> = [
2256 sized_session(root, "old-stopped", "2026-09-01T00:00:00Z", 1000, &[10, 20]),
2257 sized_session(root, "just-stopped", "2026-09-09T00:00:00Z", 500, &[]),
2258 SessionRecord {
2261 checkpoint: Some(mj_core::state::CheckpointMetadata {
2262 archive_path: root.join("missing.hel.zip"),
2263 sha256: "0".repeat(64),
2264 created_at: "2026-09-01T00:00:00Z".into(),
2265 event_frontier: 1,
2266 }),
2267 ..record(
2268 "lost-checkpoint",
2269 mj_core::state::SessionState::Stopped,
2270 "2026-09-01T00:00:00Z",
2271 )
2272 },
2273 ]
2274 .into_iter()
2275 .map(|record| (record.id.clone(), record))
2276 .collect();
2277 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2278
2279 let all = archive_space_over(root, &sessions, &BTreeMap::new(), now, None);
2280 assert_eq!(all.sessions, 3);
2281 assert_eq!(all.bytes, 1530);
2282 assert_eq!(all.reclaimable_sessions, 0);
2283 assert_eq!(all.reclaimable_bytes, 0);
2284
2285 let aged = archive_space_over(root, &sessions, &BTreeMap::new(), now, Some(3));
2286 assert_eq!(aged.bytes, 1530);
2287 assert_eq!(
2288 (aged.reclaimable_sessions, aged.reclaimable_bytes),
2289 (2, 1030),
2290 "only the sessions the job would archive count, attachments included"
2291 );
2292 }
2293
2294 #[test]
2295 fn only_stopped_sessions_past_the_cut_off_are_archived() {
2296 use mj_core::state::SessionState;
2297 let selected = ready(
2298 vec![
2299 record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2300 record(
2301 "just-stopped",
2302 SessionState::Stopped,
2303 "2026-09-09T00:00:00Z",
2304 ),
2305 record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
2306 record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
2307 record("unparsable", SessionState::Stopped, "not a time"),
2308 record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
2310 ],
2311 Vec::new(),
2312 );
2313 assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
2314 }
2315
2316 #[test]
2317 fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
2318 use mj_core::state::SessionState;
2319 let selected = ready(
2320 vec![
2321 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2322 record(
2323 "running-child",
2324 SessionState::Running,
2325 "2026-09-01T00:00:00Z",
2326 ),
2327 ],
2328 vec![child("running-child", "parent")],
2329 );
2330 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2331
2332 let selected = ready(
2333 vec![
2334 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2335 record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
2336 ],
2337 vec![child("young-child", "parent")],
2338 );
2339 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2340
2341 let selected = ready(
2343 vec![record(
2344 "parent",
2345 SessionState::Stopped,
2346 "2026-09-01T00:00:00Z",
2347 )],
2348 vec![child("departed-child", "parent")],
2349 );
2350 assert_eq!(selected, vec!["parent"]);
2351 }
2352
2353 #[test]
2354 fn children_are_archived_before_their_parents() {
2355 use mj_core::state::SessionState;
2356 let selected = ready(
2357 vec![
2358 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2359 record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2360 record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2361 ],
2362 vec![child("child", "parent"), child("grandchild", "child")],
2363 );
2364 assert_eq!(selected, vec!["grandchild", "child", "parent"]);
2365 }
2366
2367 #[test]
2368 fn native_adapters_cover_every_enabled_profile_home() {
2369 use mj_core::config::{Config, HarnessKind, HarnessProfile};
2370
2371 fn profile(kind: HarnessKind, home: &str, enabled: bool) -> HarnessProfile {
2372 HarnessProfile {
2373 enabled,
2374 kind,
2375 home: PathBuf::from(home),
2376 environment: BTreeMap::new(),
2377 context_window_bytes: None,
2378 guardian_review_model: None,
2379 }
2380 }
2381
2382 let mut config = Config::default();
2383 for (id, built) in [
2384 (
2385 "codex",
2386 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2387 ),
2388 (
2389 "codex-ds",
2390 profile(HarnessKind::Codex, "/home/dev/.codex-ds", true),
2391 ),
2392 (
2394 "codex-alt",
2395 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2396 ),
2397 (
2398 "codex-off",
2399 profile(HarnessKind::Codex, "/home/dev/.codex-off", false),
2400 ),
2401 (
2402 "claude",
2403 profile(HarnessKind::Claude, "/home/dev/.claude4", true),
2404 ),
2405 ("kimi", profile(HarnessKind::Kimi, "/home/dev/.kimi", true)),
2406 ("grok", profile(HarnessKind::Grok, "/home/dev/.grok", true)),
2407 ("muse", profile(HarnessKind::Muse, "/home/dev/muse", true)),
2408 (
2409 "muse-off",
2410 profile(HarnessKind::Muse, "/home/dev/muse-off", false),
2411 ),
2412 ] {
2413 config.profiles.insert(id.into(), built);
2414 }
2415
2416 let adapters = native_adapters(&config);
2417 let roots: Vec<(&str, Option<PathBuf>)> = adapters
2418 .iter()
2419 .map(|adapter| (adapter.name(), adapter.root()))
2420 .collect();
2421
2422 let codex: Vec<&Option<PathBuf>> = roots
2423 .iter()
2424 .filter(|(name, _)| *name == "codex")
2425 .map(|(_, root)| root)
2426 .collect();
2427 assert_eq!(
2428 codex,
2429 vec![
2430 &Some(PathBuf::from("/home/dev/.codex3/sessions")),
2431 &Some(PathBuf::from("/home/dev/.codex-ds/sessions")),
2432 ],
2433 "one adapter per enabled Codex home, deduplicated: {roots:?}"
2434 );
2435
2436 let claude: Vec<&Option<PathBuf>> = roots
2437 .iter()
2438 .filter(|(name, _)| *name == "claude-code")
2439 .map(|(_, root)| root)
2440 .collect();
2441 assert_eq!(
2442 claude,
2443 vec![&Some(PathBuf::from("/home/dev/.claude4/projects"))],
2444 "one adapter for the enabled Claude home: {roots:?}"
2445 );
2446
2447 for (_, root) in &roots {
2448 let Some(root) = root else { continue };
2449 let text = root.to_string_lossy();
2450 assert!(
2451 !text.contains(".codex-off"),
2452 "a disabled profile must not be indexed: {roots:?}"
2453 );
2454 assert!(
2455 !text.ends_with("/.codex/sessions") && !text.ends_with("/.claude/projects"),
2456 "the stock homes are not indexed unless a profile names them: {roots:?}"
2457 );
2458 }
2459
2460 for (name, root) in [
2463 ("kimi-code", PathBuf::from("/home/dev/.kimi/sessions")),
2464 ("grok-build", PathBuf::from("/home/dev/.grok/sessions")),
2465 (
2466 "muse",
2467 mj_checkpoint::native::muse_sessions_root(Path::new("/home/dev/muse")).unwrap(),
2468 ),
2469 ] {
2470 let found: Vec<&Option<PathBuf>> = roots
2471 .iter()
2472 .filter(|(found, _)| *found == name)
2473 .map(|(_, root)| root)
2474 .collect();
2475 assert_eq!(found, vec![&Some(root)], "one {name} adapter: {roots:?}");
2476 }
2477
2478 for (_, root) in &roots {
2479 let Some(root) = root else { continue };
2480 assert!(
2481 !root.to_string_lossy().contains("muse-off"),
2482 "a disabled profile must not be indexed: {roots:?}"
2483 );
2484 }
2485
2486 assert!(
2487 roots.iter().any(|(name, _)| *name == "gemini"),
2488 "the other built-in adapters are kept: {roots:?}"
2489 );
2490 }
2491
2492 #[test]
2496 fn query_rows_returns_the_indexed_target_profile_and_harness() {
2497 let _held = tags::testing::lock();
2498 let (_directory, connection) = tags::testing::isolated_index();
2499 tags::testing::index_row(&connection, "mj-session", TOOL);
2500 tags::testing::index_row(&connection, "codex-session", "codex");
2501 tags::write(
2502 &connection,
2503 "mj-session",
2504 &tags::MjTags {
2505 target: Some("Prod-Box".into()),
2506 profile: Some("codex-Main".into()),
2507 harness: Some("codex".into()),
2508 },
2509 )
2510 .expect("write the session metadata");
2511
2512 let rows = query_rows("", 10, &BTreeSet::new()).expect("query the index");
2513 let mjolnir = rows
2514 .iter()
2515 .find(|row| row.id == "mj-session")
2516 .expect("the Mjolnir row is returned");
2517 assert_eq!(mjolnir.target.as_deref(), Some("Prod-Box"));
2518 assert_eq!(mjolnir.profile.as_deref(), Some("codex-Main"));
2519 assert_eq!(mjolnir.harness.as_deref(), Some("codex"));
2520
2521 let codex = rows
2522 .iter()
2523 .find(|row| row.id == "codex-session")
2524 .expect("the Codex row is returned");
2525 assert_eq!(codex.target, None);
2526 assert_eq!(codex.profile, None);
2527 assert_eq!(codex.harness, None);
2528 }
2529}