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