1use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::time::Instant;
17
18use anyhow::{Context, Result};
19use chrono::{DateTime, Utc};
20
21use mj_client::daemon::{WikiIndexState, WikiRow, WikiStatus};
22use mj_core::state::{SessionRecord, State};
23use sessionwiki::adapters::{Adapter, Discovered, Store};
24use sessionwiki::model::{Message, Role, Session};
25
26use crate::controller::Controller;
27use crate::controller::checkpoint::managed_checkpoint_archive_name;
28
29const TOOL: &str = "mjolnir";
33
34struct ArchiveFile {
36 path: PathBuf,
37 frontier: u64,
38 token: i64,
40}
41
42#[derive(Default)]
46struct Sessions {
47 records: BTreeMap<String, SessionRecord>,
48 subagent_ids: BTreeSet<String>,
49 live: BTreeMap<String, i64>,
51}
52
53impl Sessions {
54 fn of(state: &State) -> Self {
55 Self {
56 records: state.sessions.clone(),
57 subagent_ids: state.subagents.keys().cloned().collect(),
58 live: live_tokens(state),
59 }
60 }
61}
62
63fn live_tokens(state: &State) -> BTreeMap<String, i64> {
70 let activity = match crate::database::load_transcribed_session_activity() {
71 Ok(activity) => activity,
72 Err(error) => {
73 tracing::warn!(%error, "could not read session activity for SessionWiki");
74 return BTreeMap::new();
75 }
76 };
77 state
78 .sessions
79 .iter()
80 .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
81 .filter_map(|(session_id, _)| {
82 let watermark = activity.get(session_id)?;
83 Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
84 })
85 .collect()
86}
87
88pub struct MjolnirAdapter {
90 sessions_dir: PathBuf,
91 sessions: std::sync::Mutex<Sessions>,
92 reload: bool,
94}
95
96impl MjolnirAdapter {
97 pub fn from_state(state: &State) -> Self {
100 Self {
101 sessions_dir: mj_core::config::sessions_dir(),
102 sessions: std::sync::Mutex::new(Sessions::of(state)),
103 reload: false,
104 }
105 }
106
107 pub fn reloading(state: &State) -> Self {
116 Self {
117 reload: true,
118 ..Self::from_state(state)
119 }
120 }
121
122 fn reload(&self) {
123 if !self.reload {
124 return;
125 }
126 match Controller::load() {
127 Ok(controller) => {
128 *self
129 .sessions
130 .lock()
131 .unwrap_or_else(std::sync::PoisonError::into_inner) =
132 Sessions::of(&controller.state)
133 }
134 Err(error) => {
135 tracing::warn!(%error, "could not refresh session records for SessionWiki")
136 }
137 }
138 }
139
140 fn checkpointed_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
143 let (newest, _) = self.newest_archives();
144 let archive = newest
145 .get(session_id)
146 .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
147 let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
148 .with_context(|| format!("read checkpoint {}", archive.path.display()))?
149 .canonical_session()
150 .with_context(|| format!("read the transcript of session {session_id}"))?;
151 let messages = snapshot
152 .transcript
153 .iter()
154 .filter_map(|item| {
155 let (role, text) = match &item.body {
156 mj_core::archive::CanonicalTranscriptBody::User { content } => (
157 Role::User,
158 mj_core::transcript::materialized_content_text(content),
159 ),
160 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
161 Role::Assistant,
162 mj_core::transcript::materialized_chunks_text(chunks),
163 ),
164 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => {
165 (Role::Tool, tool_call_title(call))
166 }
167 _ => return None,
168 };
169 message(role, text, item.created_at_ms)
170 })
171 .collect();
172 Ok((messages, snapshot.session.session_title.clone()))
173 }
174
175 fn projected_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
179 let projection = crate::database::load_materialized_session(session_id)
180 .with_context(|| format!("read the stored transcript of session {session_id}"))?
181 .with_context(|| format!("no stored transcript for session {session_id}"))?;
182 Ok((
183 projected_messages(&projection),
184 projection.session_title.clone(),
185 ))
186 }
187
188 fn key_for(&self, session_id: &str) -> String {
191 format!("{}/{session_id}", self.sessions_dir.display())
192 }
193
194 fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
200 let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
201 let mut had_error = false;
202 let entries = match std::fs::read_dir(&self.sessions_dir) {
203 Ok(entries) => entries,
204 Err(error) => {
205 if self.sessions_dir.exists() {
206 tracing::debug!(
207 directory = %self.sessions_dir.display(),
208 %error,
209 "could not list the checkpoint directory for SessionWiki"
210 );
211 had_error = true;
212 }
213 return (newest, had_error);
214 }
215 };
216 for entry in entries {
217 let Ok(entry) = entry else {
218 had_error = true;
219 continue;
220 };
221 let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
222 else {
223 continue;
224 };
225 let token = entry
226 .metadata()
227 .ok()
228 .and_then(|metadata| metadata.modified().ok())
229 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
230 .map(|age| age.as_secs() as i64)
231 .unwrap_or(0);
232 let candidate = ArchiveFile {
233 path: entry.path(),
234 frontier,
235 token,
236 };
237 match newest.get(&session_id) {
238 Some(existing) if existing.frontier >= candidate.frontier => {}
239 _ => {
240 newest.insert(session_id, candidate);
241 }
242 }
243 }
244 (newest, had_error)
245 }
246}
247
248fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
253 if let Some(parsed) = managed_checkpoint_archive_name(name) {
254 return Some((parsed.session_id, parsed.frontier));
255 }
256 let stem = name
257 .to_str()
258 .and_then(|name| name.strip_suffix(".hel.zip"))?;
259 mj_core::config::validate_id("session", stem)
260 .is_ok()
261 .then(|| (stem.to_owned(), 0))
262}
263
264fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
266 projection
267 .transcript
268 .iter()
269 .filter_map(|item| {
270 let (role, text) = match &item.body {
271 mj_core::state::TranscriptBody::User { content } => (
272 Role::User,
273 mj_core::transcript::materialized_content_text(content),
274 ),
275 mj_core::state::TranscriptBody::Agent { chunks, .. } => (
276 Role::Assistant,
277 mj_core::transcript::materialized_chunks_text(chunks),
278 ),
279 mj_core::state::TranscriptBody::Tool { call, .. } => {
280 (Role::Tool, tool_call_title(call))
281 }
282 _ => return None,
283 };
284 message(role, text, item.created_at_ms)
285 })
286 .collect()
287}
288
289fn tool_call_title(call: &serde_json::Value) -> String {
292 call.get("title")
293 .and_then(serde_json::Value::as_str)
294 .unwrap_or_default()
295 .to_owned()
296}
297
298fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
300 let text = text.trim().to_owned();
301 (!text.is_empty()).then(|| Message {
302 role,
303 text,
304 ts: DateTime::from_timestamp_millis(created_at_ms),
305 })
306}
307
308fn parse_time(value: &str) -> Option<DateTime<Utc>> {
309 DateTime::parse_from_rfc3339(value)
310 .ok()
311 .map(|time| time.with_timezone(&Utc))
312}
313
314impl Adapter for MjolnirAdapter {
315 fn name(&self) -> &'static str {
316 TOOL
317 }
318
319 fn root(&self) -> Option<PathBuf> {
320 Some(self.sessions_dir.clone())
321 }
322
323 fn discover(&self) -> Discovered {
326 Discovered {
327 files: Vec::new(),
328 had_error: false,
329 }
330 }
331
332 fn parse(&self, _path: &Path) -> Result<Session> {
333 anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
334 }
335
336 fn store(&self) -> Option<Store> {
337 self.reload();
338 let (newest, had_error) = self.newest_archives();
339 let mut files = Vec::with_capacity(newest.len());
340 let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
341 for (session_id, archive) in newest {
342 tokens.insert(session_id, archive.token);
343 files.push(archive.path);
344 }
345 let sessions = self
350 .sessions
351 .lock()
352 .unwrap_or_else(std::sync::PoisonError::into_inner);
353 let live = sessions.live.clone();
354 tokens.extend(live);
355 for (session_id, token) in tokens.iter_mut() {
360 let updated = sessions
361 .records
362 .get(session_id)
363 .and_then(|record| parse_time(&record.updated_at))
364 .map(|updated| updated.timestamp());
365 if let Some(updated) = updated {
366 *token = (*token).max(updated);
367 }
368 }
369 let keys = tokens
370 .into_iter()
371 .map(|(session_id, token)| (self.key_for(&session_id), token))
372 .collect();
373 Some(Store {
374 keys,
375 files,
376 had_error,
377 })
378 }
379
380 fn reconcile_scope(&self) -> Option<String> {
384 Some(format!("{}/", self.sessions_dir.display()))
385 }
386
387 fn parse_key(&self, key: &str) -> Result<Session> {
388 let session_id = key.rsplit('/').next().unwrap_or_default();
389 anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
390 let sessions = self
391 .sessions
392 .lock()
393 .unwrap_or_else(std::sync::PoisonError::into_inner);
394 let (messages, snapshot_title) = if sessions.live.contains_key(session_id) {
395 self.projected_transcript(session_id)?
396 } else {
397 self.checkpointed_transcript(session_id)?
398 };
399 let record = sessions.records.get(session_id);
400
401 let title = record
402 .and_then(|record| record.session_title_override.clone())
403 .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
404 .or_else(|| snapshot_title.clone())
405 .unwrap_or_else(|| {
406 messages
407 .iter()
408 .find(|message| message.role == Role::User)
409 .map(|message| message.text.chars().take(80).collect())
410 .unwrap_or_default()
411 });
412
413 Ok(Session {
414 id: session_id.to_owned(),
415 tool: TOOL,
416 path: PathBuf::from(key),
417 project: record
418 .and_then(|record| record.project_directory.as_ref())
419 .map(|directory| directory.display().to_string())
420 .unwrap_or_default(),
421 started: record.and_then(|record| parse_time(&record.created_at)),
422 ended: record.and_then(|record| parse_time(&record.updated_at)),
423 title,
424 subagent: sessions.subagent_ids.contains(session_id),
425 messages,
426 touched: Vec::new(),
427 edits: Vec::new(),
428 })
429 }
430}
431
432pub struct WikiIndexer {
439 inner: Arc<Indexer>,
440}
441
442#[derive(Default)]
443struct Indexer {
444 running: tokio::sync::Mutex<()>,
446 notify: tokio::sync::Notify,
447 requested: AtomicBool,
449 full_requested: AtomicBool,
451 in_flight: AtomicBool,
454 last_success: std::sync::Mutex<Option<Success>>,
455}
456
457#[derive(Clone, Copy)]
458struct Success {
459 at: Instant,
460 epoch_seconds: i64,
461}
462
463impl WikiIndexer {
464 pub fn spawn() -> Self {
467 let inner = Arc::new(Indexer::default());
468 if let Ok(handle) = tokio::runtime::Handle::try_current() {
469 let worker = Arc::clone(&inner);
470 handle.spawn(async move { worker.run().await });
471 }
472 Self { inner }
473 }
474
475 pub fn request_sync(&self, full: bool) {
477 if full {
478 self.inner.full_requested.store(true, Ordering::Release);
479 }
480 self.inner.requested.store(true, Ordering::Release);
481 self.inner.notify.notify_one();
482 }
483
484 pub async fn sync_now(&self, full: bool) -> Result<()> {
486 self.inner.sync(full).await
487 }
488
489 pub fn status(&self) -> WikiStatus {
492 WikiStatus {
493 state: index_state(),
494 topping_up: self.inner.in_flight.load(Ordering::Acquire)
495 || self.inner.requested.load(Ordering::Acquire),
496 }
497 }
498
499 pub fn last_success(&self) -> Option<Instant> {
501 self.inner
502 .last_success
503 .lock()
504 .unwrap_or_else(std::sync::PoisonError::into_inner)
505 .map(|success| success.at)
506 }
507}
508
509impl Indexer {
510 async fn run(self: Arc<Self>) {
511 loop {
512 self.notify.notified().await;
513 while self.requested.swap(false, Ordering::AcqRel) {
514 let full = self.full_requested.swap(false, Ordering::AcqRel);
515 if let Err(error) = self.sync(full).await {
516 self.report(&error);
517 break;
522 }
523 }
524 }
525 }
526
527 fn report(&self, error: &anyhow::Error) {
531 if is_busy(error) {
532 self.requested.store(true, Ordering::Release);
533 tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
534 } else {
535 tracing::warn!(%error, "could not sync sessions into SessionWiki");
536 }
537 }
538
539 async fn sync(&self, full: bool) -> Result<()> {
540 let _guard = self.running.lock().await;
541 let since = if full {
542 None
543 } else {
544 self.last_success
545 .lock()
546 .unwrap_or_else(std::sync::PoisonError::into_inner)
547 .map(|success| success.epoch_seconds - 60)
550 };
551 let started = Instant::now();
552 self.in_flight.store(true, Ordering::Release);
553 let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
554 self.in_flight.store(false, Ordering::Release);
555 let ran = ran.context("run the SessionWiki sync")??;
556 if ran {
557 *self
558 .last_success
559 .lock()
560 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
561 at: started,
562 epoch_seconds: Utc::now().timestamp(),
563 });
564 }
565 Ok(())
566 }
567}
568
569fn sync_blocking(since: Option<i64>) -> Result<bool> {
572 if !index_is_writable() {
573 return Ok(false);
574 }
575 let controller =
576 Controller::load().context("load controller state for the SessionWiki sync")?;
577 let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
580 vec![Box::new(MjolnirAdapter::reloading(&controller.state))];
581 adapters.extend(sessionwiki::adapters::all());
582 let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
583 sessionwiki::index::sync_with(&mut connection, &adapters, since)
584 .context("sync the SessionWiki index")?;
585 if since.is_none() {
586 record_first_build();
590 }
591 Ok(true)
592}
593
594fn index_is_isolated() -> bool {
607 static SAID: AtomicBool = AtomicBool::new(false);
608 if mj_core::config::session_index_is_resolved()
609 || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
610 {
611 return true;
612 }
613 if !SAID.swap(true, Ordering::AcqRel) {
614 tracing::debug!(
615 "this process did not resolve a session index location; SessionWiki is not used"
616 );
617 }
618 false
619}
620
621fn index_version_mismatch() -> bool {
630 static SAID: AtomicBool = AtomicBool::new(false);
631 let Ok(path) = sessionwiki::index::db_path() else {
632 return false;
633 };
634 if !path.exists() {
635 return false;
636 }
637 let version = rusqlite::Connection::open_with_flags(
638 &path,
639 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
640 )
641 .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
642 let version: i64 = match version {
643 Ok(version) => version,
644 Err(error) => {
645 tracing::debug!(%error, "could not read the SessionWiki index schema version");
646 return false;
647 }
648 };
649 let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
652 if mismatch && !SAID.swap(true, Ordering::AcqRel) {
653 tracing::warn!(
654 found = version,
655 expected = sessionwiki::index::SCHEMA_VERSION,
656 path = %path.display(),
657 "the SessionWiki index was written by another version; Mjolnir will not open it, because opening it would rebuild it. Install the matching sessionwiki command"
658 );
659 }
660 mismatch
661}
662
663fn index_is_writable() -> bool {
664 index_is_isolated() && !index_version_mismatch()
665}
666
667fn first_build_marker() -> PathBuf {
670 mj_core::config::data_dir().join("sessionwiki-built")
671}
672
673fn record_first_build() {
674 let path = first_build_marker();
675 let version = sessionwiki::index::SCHEMA_VERSION.to_string();
676 if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
677 return;
678 }
679 if let Err(error) = std::fs::write(&path, &version) {
680 tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
681 }
682}
683
684fn first_build_is_done() -> bool {
686 std::fs::read_to_string(first_build_marker())
687 .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
688 && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
689}
690
691pub fn index_state() -> WikiIndexState {
693 if !index_is_isolated() {
694 return WikiIndexState::Indexing;
695 }
696 if index_version_mismatch() {
697 return WikiIndexState::VersionMismatch;
698 }
699 if first_build_is_done() {
700 WikiIndexState::Ready
701 } else {
702 WikiIndexState::Indexing
703 }
704}
705
706fn is_busy(error: &anyhow::Error) -> bool {
709 error.chain().any(|cause| {
710 matches!(
711 cause.downcast_ref::<rusqlite::Error>(),
712 Some(rusqlite::Error::SqliteFailure(failure, _))
713 if matches!(
714 failure.code,
715 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
716 )
717 )
718 })
719}
720
721pub const MAX_WIKI_LIMIT: usize = 200;
727pub const DEFAULT_WIKI_LIMIT: usize = 50;
729const MIN_FULLTEXT_QUERY: usize = 3;
732pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
734
735pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
737 last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
738}
739
740pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
747 let limit = limit.clamp(1, MAX_WIKI_LIMIT);
748 if !index_is_writable() {
749 return Ok(Vec::new());
753 }
754 let connection = open_readonly()?;
755 let query = query.trim();
756 if query.is_empty() {
757 let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
758 .context("list recent SessionWiki sessions")?;
759 return Ok(rows
760 .into_iter()
761 .map(|row| wiki_row(row, None, live))
762 .collect());
763 }
764 let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
765 sessionwiki::index::search_like(&connection, query, limit, None, None)
766 } else {
767 sessionwiki::index::search(&connection, query, limit, None, None)
768 }
769 .context("search the SessionWiki index")?;
770 let mut rows: Vec<WikiRow> = hits
771 .into_iter()
772 .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
773 .collect();
774 let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
778 for row in named_like(&connection, query)? {
779 if rows.len() >= limit {
780 break;
781 }
782 if found.contains(&row.session_id) {
783 continue;
784 }
785 rows.push(wiki_row(row, None, live));
786 }
787 Ok(rows)
788}
789
790const NAME_SCAN_LIMIT: usize = 2_000;
794
795fn named_like(
797 connection: &rusqlite::Connection,
798 query: &str,
799) -> Result<Vec<sessionwiki::index::SessionRow>> {
800 let needle = query.to_lowercase();
801 let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
802 .context("list recent SessionWiki sessions")?;
803 Ok(rows
804 .into_iter()
805 .filter(|row| {
806 row.title.to_lowercase().contains(&needle)
807 || row.project.to_lowercase().contains(&needle)
808 })
809 .collect())
810}
811
812pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
814 if !index_is_writable() {
815 return Ok(None);
816 }
817 let connection = open_readonly()?;
818 let Some(row) = row_by_id(&connection, id)? else {
819 return Ok(None);
820 };
821 let session = sessionwiki::index::session_from_index(&connection, &row)
822 .context("read an indexed session")?;
823 Ok(Some(sessionwiki::commands::brief_markdown(
824 &session, max_chars, true,
825 )))
826}
827
828pub struct ArchivedSession {
832 pub title: String,
833 pub project_directory: Option<PathBuf>,
836 pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
837}
838
839pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
841 if !index_is_writable() {
842 return Ok(None);
843 }
844 let connection = open_readonly()?;
845 let Some(row) = row_by_id(&connection, id)? else {
846 return Ok(None);
847 };
848 let session = sessionwiki::index::session_from_index(&connection, &row)
849 .context("read an indexed session")?;
850 let snapshot = snapshot_of(&session)?;
851 Ok(Some(ArchivedSession {
852 title: session.title.clone(),
853 project_directory: project_directory_of(&session.project),
854 snapshot,
855 }))
856}
857
858pub fn sessions_ready_to_archive(
874 sessions: &BTreeMap<String, SessionRecord>,
875 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
876 now: DateTime<Utc>,
877 older_than_days: u32,
878) -> Vec<String> {
879 let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
880 let aged = |session_id: &String| {
881 sessions.get(session_id).is_some_and(|record| {
882 record.state == mj_core::state::SessionState::Stopped
883 && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
884 })
885 };
886 let selected: BTreeSet<String> = sessions
887 .keys()
888 .filter(|session_id| aged(session_id))
889 .filter(|session_id| {
890 subagents
891 .values()
892 .filter(|child| &&child.parent_session_id == session_id)
893 .filter(|child| sessions.contains_key(&child.child_session_id))
895 .all(|child| aged(&child.child_session_id))
896 })
897 .cloned()
898 .collect();
899 let mut ordered: Vec<String> = selected.iter().cloned().collect();
900 ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
901 ordered
902}
903
904fn ancestor_depth(
908 session_id: &str,
909 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
910) -> usize {
911 let mut depth = 0;
912 let mut current = session_id;
913 while let Some(parent) = subagents
915 .get(current)
916 .map(|child| child.parent_session_id.as_str())
917 {
918 depth += 1;
919 if depth > subagents.len() {
920 break;
921 }
922 current = parent;
923 }
924 depth
925}
926
927pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
934 if !index_is_writable() {
935 return Ok(BTreeSet::new());
938 }
939 let connection = open_readonly()?;
940 let sessions_dir = mj_core::config::sessions_dir();
941 let mut indexed = BTreeSet::new();
942 for session_id in session_ids {
943 let key = format!("{}/{session_id}", sessions_dir.display());
944 let rows = sessionwiki::index::resolve(&connection, session_id)
945 .context("look up a stopped session in the SessionWiki index")?;
946 if rows
947 .iter()
948 .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
949 {
950 indexed.insert(session_id.clone());
951 }
952 }
953 Ok(indexed)
954}
955
956fn open_readonly() -> Result<rusqlite::Connection> {
957 sessionwiki::index::open_readonly().context("open the SessionWiki index")
958}
959
960fn row_by_id(
963 connection: &rusqlite::Connection,
964 id: &str,
965) -> Result<Option<sessionwiki::index::SessionRow>> {
966 Ok(sessionwiki::index::resolve(connection, id)
967 .context("look up an indexed session")?
968 .into_iter()
969 .find(|row| row.session_id == id))
970}
971
972fn wiki_row(
973 row: sessionwiki::index::SessionRow,
974 snippet: Option<String>,
975 live: &BTreeSet<String>,
976) -> WikiRow {
977 let hel_session_id = (row.tool == TOOL)
980 .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
981 .filter(|session_id| live.contains(session_id));
982 let native_id = sessionwiki::index::native_id_of(&row.path);
983 WikiRow {
984 id: row.session_id,
985 tool: row.tool,
986 project: row.project,
987 title: row.title,
988 started: row.started,
989 msgs: row.msg_count,
990 preview: row.preview,
991 archived: row.archived,
992 native_id,
993 snippet,
994 hel_session_id,
995 }
996}
997
998fn project_directory_of(project: &str) -> Option<PathBuf> {
1006 if project.trim().is_empty() {
1007 return None;
1008 }
1009 let path = PathBuf::from(project);
1010 let repository = path
1011 .ancestors()
1012 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1013 .and_then(std::path::Path::parent)
1014 .map(std::path::Path::to_path_buf)
1015 .unwrap_or(path);
1016 repository.is_dir().then_some(repository)
1017}
1018
1019fn snapshot_of(
1026 session: &sessionwiki::model::Session,
1027) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1028 use mj_core::archive::{
1029 CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1030 CanonicalTranscriptBody, CanonicalTranscriptItem,
1031 };
1032
1033 let started_ms = session
1034 .started
1035 .map(|time| time.timestamp_millis())
1036 .unwrap_or_default();
1037 let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1038 for message in &session.messages {
1039 let text = message.text.trim();
1040 if text.is_empty() {
1041 continue;
1042 }
1043 if transcript.is_empty() && message.role != Role::User {
1046 continue;
1047 }
1048 let position = transcript.len() as u64 + 1;
1049 let body = match message.role {
1050 Role::User => CanonicalTranscriptBody::User {
1051 content: vec![serde_json::json!({"type": "text", "text": text})],
1052 },
1053 Role::Assistant => CanonicalTranscriptBody::Agent {
1054 chunks: vec![serde_json::json!({
1055 "content": {"type": "text", "text": text}
1056 })],
1057 streaming: false,
1058 },
1059 Role::Tool => CanonicalTranscriptBody::Tool {
1062 call: serde_json::json!({
1063 "toolCallId": format!("wiki-tool-{position}"),
1064 "title": text,
1065 "status": "completed"
1066 }),
1067 terminal_outputs: Vec::new(),
1068 terminal_refs: Vec::new(),
1069 presentation: None,
1070 },
1071 };
1072 let created_at_ms = message
1073 .ts
1074 .map(|time| time.timestamp_millis())
1075 .unwrap_or(started_ms);
1076 transcript.push(CanonicalTranscriptItem {
1077 stable_id: format!("wiki-{position}"),
1078 position,
1079 latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1082 .then_some(position),
1083 created_at_ms,
1084 last_changed_at_ms: created_at_ms,
1085 body,
1086 });
1087 }
1088 anyhow::ensure!(
1089 !transcript.is_empty(),
1090 "the archived session has no prompt to restore from"
1091 );
1092
1093 let event_frontier = transcript.len() as u64;
1094 let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1095 Ok(CanonicalSessionSnapshot {
1096 event_frontier,
1097 event_frontier_digest: {
1101 use sha2::Digest;
1102 mj_core::hex::lower_hex(sha2::Sha256::digest(
1103 format!("sessionwiki:{}", session.id).as_bytes(),
1104 ))
1105 },
1106 session: CanonicalSessionState {
1107 execution: CanonicalExecutionState::Idle,
1108 last_activity_at_ms,
1109 session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1110 configuration: BTreeMap::new(),
1111 },
1112 transcript,
1113 queued_prompts: Vec::new(),
1114 })
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use std::collections::BTreeMap;
1120 use std::path::Path;
1121
1122 use mj_checkpoint::archive::{
1123 ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1124 CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1125 TargetManifest, write_archive_atomic,
1126 };
1127
1128 use super::*;
1129
1130 fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1131 let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1134 CanonicalTranscriptItem {
1135 stable_id: format!("item-{position}"),
1136 position,
1137 latest_content_event_ordinal: streamed.then_some(position),
1138 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1139 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1140 body,
1141 }
1142 }
1143
1144 fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1147 let path = directory.join(format!(
1148 "{session_id}-{frontier}-archive-{}.hel.zip",
1149 "0".repeat(32)
1150 ));
1151 write_archive_atomic(
1152 &path,
1153 &ArchiveInput {
1154 session: SessionManifest {
1155 id: session_id.into(),
1156 title: "indexed session".into(),
1157 harness_kind: mj_core::config::HarnessKind::Codex,
1158 profile_id: "codex".into(),
1159 native_session_id: "native-session".into(),
1160 created_at: "2026-09-01T00:00:00Z".into(),
1161 checkpointed_at: "2026-09-01T01:00:00Z".into(),
1162 hel_version: "test".into(),
1163 relay_version: "test".into(),
1164 adapter_version: "test".into(),
1165 },
1166 target: TargetManifest {
1167 template_id: "local".into(),
1168 target_kind: "local-bare".into(),
1169 details: BTreeMap::new(),
1170 },
1171 bundle: BundleManifest {
1172 id: "project".into(),
1173 primary_repository: "project".into(),
1174 },
1175 canonical_session: CanonicalSessionSnapshot {
1176 event_frontier: 4,
1177 event_frontier_digest: "a".repeat(64),
1178 session: CanonicalSessionState {
1179 execution: CanonicalExecutionState::Idle,
1180 last_activity_at_ms: Some(1_700_000_000_004),
1181 session_title: Some("snapshot title".into()),
1182 configuration: BTreeMap::new(),
1183 },
1184 transcript: vec![
1185 item(
1186 1,
1187 CanonicalTranscriptBody::User {
1188 content: vec![serde_json::json!({
1189 "type": "text",
1190 "text": "index this session"
1191 })],
1192 },
1193 ),
1194 item(
1195 2,
1196 CanonicalTranscriptBody::Thought {
1197 chunks: vec![serde_json::json!({
1198 "content": {"type": "text", "text": "pondering"}
1199 })],
1200 streaming: false,
1201 },
1202 ),
1203 item(
1204 3,
1205 CanonicalTranscriptBody::Tool {
1206 call: serde_json::json!({
1207 "toolCallId": "call-1",
1208 "title": "Read config.toml",
1209 "status": "completed"
1210 }),
1211 terminal_outputs: Vec::new(),
1212 terminal_refs: Vec::new(),
1213 presentation: None,
1214 },
1215 ),
1216 item(
1217 4,
1218 CanonicalTranscriptBody::Agent {
1219 chunks: vec![serde_json::json!({
1220 "content": {"type": "text", "text": "done"}
1221 })],
1222 streaming: false,
1223 },
1224 ),
1225 ],
1226 queued_prompts: Vec::new(),
1227 },
1228 native_artifacts: Vec::new(),
1229 repositories: Vec::new(),
1230 },
1231 )
1232 .unwrap();
1233 }
1234
1235 fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1236 adapter_with_live(directory, session_id, BTreeMap::new())
1237 }
1238
1239 fn adapter_with_live(
1240 directory: &Path,
1241 session_id: &str,
1242 live: BTreeMap<String, i64>,
1243 ) -> MjolnirAdapter {
1244 let record = SessionRecord {
1245 id: session_id.into(),
1246 ..record_template()
1247 };
1248 MjolnirAdapter {
1249 sessions_dir: directory.to_path_buf(),
1250 sessions: std::sync::Mutex::new(Sessions {
1251 records: BTreeMap::from([(session_id.to_owned(), record)]),
1252 subagent_ids: BTreeSet::new(),
1253 live,
1254 }),
1255 reload: false,
1256 }
1257 }
1258
1259 fn record_template() -> SessionRecord {
1260 SessionRecord {
1261 build_cache: None,
1262 container_workspace: None,
1263 mjolnir_subagents: None,
1264 create_managed_worktree: None,
1265 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1266 archived: false,
1267 container_cpus: None,
1268 container_memory: None,
1269 id: "0123456789abcdef0123456789abcdef".into(),
1270 title: "indexed session".into(),
1271 harness_kind: mj_core::config::HarnessKind::Codex,
1272 last_profile: "codex".into(),
1273 bundle_id: "project".into(),
1274 project_directory: Some(PathBuf::from("/home/dev/project")),
1275 managed_worktree: None,
1276 target_template_id: "local-bare".into(),
1277 resource_allocation: None,
1278 additional_mounts: Vec::new(),
1279 state: mj_core::state::SessionState::Stopped,
1280 target: None,
1281 native_session_id: Some("native-session".into()),
1282 acp_session_title: Some("the harness title".into()),
1283 session_title_override: None,
1284 created_at: "2026-09-01T00:00:00Z".into(),
1285 updated_at: "2026-09-01T01:00:00Z".into(),
1286 viewed_through_event_ordinal: 0,
1287 draft_input: String::new(),
1288 last_error: None,
1289 last_checkpoint_error: None,
1290 checkpoint: None,
1291 }
1292 }
1293
1294 #[test]
1295 fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1296 let directory = tempfile::tempdir().unwrap();
1297 let session_id = "0123456789abcdef0123456789abcdef";
1298 write_archive(directory.path(), session_id, 1);
1299 write_archive(directory.path(), session_id, 7);
1300 let adapter = adapter(directory.path(), session_id);
1301
1302 let store = adapter.store().expect("the adapter is a shared store");
1303 let key = format!("{}/{session_id}", directory.path().display());
1304 assert_eq!(
1305 store
1306 .keys
1307 .iter()
1308 .map(|(key, _)| key.as_str())
1309 .collect::<Vec<_>>(),
1310 vec![key.as_str()]
1311 );
1312 assert!(!store.had_error);
1313 assert_eq!(store.files.len(), 1);
1314 assert!(
1315 store.files[0]
1316 .file_name()
1317 .unwrap()
1318 .to_str()
1319 .unwrap()
1320 .contains("-7-archive-"),
1321 "the newest checkpoint is the one indexed: {:?}",
1322 store.files[0]
1323 );
1324 assert_eq!(
1325 adapter.reconcile_scope(),
1326 Some(format!("{}/", directory.path().display()))
1327 );
1328
1329 let session = adapter.parse_key(&key).unwrap();
1330 assert_eq!(session.id, session_id);
1331 assert_eq!(session.tool, "mjolnir");
1332 assert_eq!(session.path, PathBuf::from(&key));
1333 assert_eq!(session.project, "/home/dev/project");
1334 assert_eq!(session.title, "the harness title");
1335 assert!(!session.subagent);
1336 assert_eq!(
1337 session
1338 .messages
1339 .iter()
1340 .map(|message| (message.role, message.text.as_str()))
1341 .collect::<Vec<_>>(),
1342 vec![
1343 (Role::User, "index this session"),
1344 (Role::Tool, "Read config.toml"),
1345 (Role::Assistant, "done"),
1346 ]
1347 );
1348 }
1349
1350 fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1351 use mj_core::transcript::{TranscriptBody, TranscriptItem};
1352 let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1353 let mut push = |position: u64, body: TranscriptBody| {
1354 let streamed = matches!(body, TranscriptBody::Agent { .. });
1355 projected
1356 .transcript
1357 .push(std::sync::Arc::new(TranscriptItem {
1358 stable_id: format!("item-{position}"),
1359 position,
1360 latest_content_event_ordinal: streamed.then_some(position),
1361 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1362 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1363 body,
1364 }));
1365 };
1366 push(
1367 1,
1368 TranscriptBody::User {
1369 content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1370 },
1371 );
1372 push(
1373 2,
1374 TranscriptBody::Thought {
1375 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1376 streaming: false,
1377 },
1378 );
1379 push(
1380 3,
1381 TranscriptBody::Tool {
1382 call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1383 terminal_outputs: Vec::new(),
1384 terminal_refs: Vec::new(),
1385 presentation: None,
1386 },
1387 );
1388 push(
1389 4,
1390 TranscriptBody::Agent {
1391 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1392 streaming: false,
1393 },
1394 );
1395 projected.session_title = Some("the live title".into());
1396 projected
1397 }
1398
1399 #[test]
1402 fn a_running_session_is_indexed_from_its_stored_transcript() {
1403 let session_id = "0123456789abcdef0123456789abcdef";
1404 assert_eq!(
1405 projected_messages(&projection(session_id))
1406 .iter()
1407 .map(|message| (message.role, message.text.clone()))
1408 .collect::<Vec<_>>(),
1409 vec![
1410 (Role::User, "still talking".to_owned()),
1411 (Role::Tool, "Read README.md".to_owned()),
1412 (Role::Assistant, "reading".to_owned()),
1413 ],
1414 "a thought is skipped and every other item keeps its role"
1415 );
1416 }
1417
1418 #[test]
1423 fn a_running_session_is_listed_with_its_own_change_token() {
1424 let directory = tempfile::tempdir().unwrap();
1425 let running = "0123456789abcdef0123456789abcdef";
1426 let never_checkpointed = "fedcba9876543210fedcba9876543210";
1427 write_archive(directory.path(), running, 3);
1428 let live = adapter_with_live(
1429 directory.path(),
1430 running,
1431 BTreeMap::from([
1432 (running.to_owned(), 1_900_000_000),
1433 (never_checkpointed.to_owned(), 1_900_000_001),
1434 ]),
1435 );
1436
1437 let store = live.store().expect("the adapter is a shared store");
1438 let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
1439 assert_eq!(
1440 store.keys,
1441 vec![
1442 (key_of(running), 1_900_000_000),
1443 (key_of(never_checkpointed), 1_900_000_001),
1444 ],
1445 "a live session's own token replaces the checkpoint's"
1446 );
1447
1448 let stopped = adapter(directory.path(), running);
1451 let keys = stopped.store().expect("a shared store").keys;
1452 assert_eq!(keys.len(), 1);
1453 assert_eq!(keys[0].0, key_of(running));
1454 assert_ne!(keys[0].1, 1_900_000_000);
1455 assert_eq!(
1456 stopped.parse_key(&key_of(running)).unwrap().title,
1457 "the harness title",
1458 "a stopped session is parsed from its checkpoint"
1459 );
1460 }
1461
1462 #[test]
1465 fn a_rename_moves_a_session_change_token() {
1466 let directory = tempfile::tempdir().unwrap();
1467 let session_id = "0123456789abcdef0123456789abcdef";
1468 write_archive(directory.path(), session_id, 1);
1469 let adapter = adapter(directory.path(), session_id);
1470 let before = adapter.store().expect("a shared store").keys[0].1;
1471
1472 {
1473 let mut sessions = adapter.sessions.lock().unwrap();
1474 let record = sessions.records.get_mut(session_id).unwrap();
1475 record.session_title_override = Some("the new name".into());
1476 record.updated_at = "2099-01-01T00:00:00Z".into();
1477 }
1478 let after = adapter.store().expect("a shared store").keys[0].1;
1479 assert!(
1480 after > before,
1481 "a renamed session is re-indexed: {before} then {after}"
1482 );
1483 assert_eq!(
1484 adapter
1485 .parse_key(&format!("{}/{session_id}", directory.path().display()))
1486 .unwrap()
1487 .title,
1488 "the new name"
1489 );
1490 }
1491
1492 fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
1493 Session {
1494 id: "0123456789abcdef0123456789abcdef".into(),
1495 tool: "mjolnir",
1496 path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
1497 project: "/home/dev/project".into(),
1498 started: DateTime::from_timestamp_millis(1_700_000_000_000),
1499 ended: None,
1500 title: "the archived session".into(),
1501 subagent: false,
1502 messages: messages
1503 .into_iter()
1504 .map(|(role, text)| Message {
1505 role,
1506 text: text.to_owned(),
1507 ts: None,
1508 })
1509 .collect(),
1510 touched: Vec::new(),
1511 edits: Vec::new(),
1512 }
1513 }
1514
1515 #[test]
1519 fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
1520 let snapshot = snapshot_of(&indexed(vec![
1521 (Role::User, "make the tests green"),
1522 (Role::Tool, "Read src/lib.rs"),
1523 (Role::Assistant, "they are green now"),
1524 (Role::User, " "),
1525 ]))
1526 .unwrap();
1527
1528 snapshot.validate().expect("the snapshot is well formed");
1529 assert_eq!(snapshot.event_frontier, 3);
1530 assert_eq!(
1531 snapshot.session.session_title.as_deref(),
1532 Some("the archived session")
1533 );
1534 assert!(snapshot.session.last_activity_at_ms.is_some());
1535 let bodies = snapshot
1536 .transcript
1537 .iter()
1538 .map(|item| match &item.body {
1539 mj_core::archive::CanonicalTranscriptBody::User { content } => (
1540 "user",
1541 mj_core::transcript::materialized_content_text(content),
1542 ),
1543 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
1544 "agent",
1545 mj_core::transcript::materialized_chunks_text(chunks),
1546 ),
1547 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
1548 "tool",
1549 call["title"].as_str().unwrap_or_default().to_owned(),
1550 ),
1551 _ => ("other", String::new()),
1552 })
1553 .collect::<Vec<_>>();
1554 assert_eq!(
1555 bodies,
1556 vec![
1557 ("user", "make the tests green".to_owned()),
1558 ("tool", "Read src/lib.rs".to_owned()),
1559 ("agent", "they are green now".to_owned()),
1560 ],
1561 "the blank message is dropped and every other one keeps its role"
1562 );
1563 }
1564
1565 #[test]
1569 fn messages_before_the_first_prompt_are_dropped() {
1570 let snapshot = snapshot_of(&indexed(vec![
1571 (Role::Assistant, "still working"),
1572 (Role::User, "carry on"),
1573 ]))
1574 .unwrap();
1575 assert_eq!(snapshot.transcript.len(), 1);
1576 assert_eq!(snapshot.transcript[0].position, 1);
1577 snapshot.validate().unwrap();
1578
1579 let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
1580 assert!(
1581 error.to_string().contains("no prompt"),
1582 "a session with no prompt cannot be restored: {error}"
1583 );
1584 }
1585
1586 fn record(
1587 session_id: &str,
1588 state: mj_core::state::SessionState,
1589 updated_at: &str,
1590 ) -> SessionRecord {
1591 SessionRecord {
1592 id: session_id.into(),
1593 state,
1594 updated_at: updated_at.into(),
1595 ..record_template()
1596 }
1597 }
1598
1599 fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
1600 mj_core::subagent::SubagentRecord {
1601 child_session_id: child_session_id.into(),
1602 parent_session_id: parent_session_id.into(),
1603 task_name: "task".into(),
1604 profile_id: "codex".into(),
1605 model: None,
1606 effort: None,
1607 working_directory: PathBuf::new(),
1608 initial_prompt: "do the thing".into(),
1609 request_key: "key".into(),
1610 created_at: "2026-09-01T00:00:00Z".into(),
1611 noticed_turn: None,
1612 }
1613 }
1614
1615 fn ready(
1616 sessions: Vec<SessionRecord>,
1617 children: Vec<mj_core::subagent::SubagentRecord>,
1618 ) -> Vec<String> {
1619 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
1620 sessions_ready_to_archive(
1621 &sessions
1622 .into_iter()
1623 .map(|record| (record.id.clone(), record))
1624 .collect(),
1625 &children
1626 .into_iter()
1627 .map(|child| (child.child_session_id.clone(), child))
1628 .collect(),
1629 now,
1630 3,
1631 )
1632 }
1633
1634 #[test]
1635 fn only_stopped_sessions_past_the_cut_off_are_archived() {
1636 use mj_core::state::SessionState;
1637 let selected = ready(
1638 vec![
1639 record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1640 record(
1641 "just-stopped",
1642 SessionState::Stopped,
1643 "2026-09-09T00:00:00Z",
1644 ),
1645 record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
1646 record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
1647 record("unparsable", SessionState::Stopped, "not a time"),
1648 record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
1650 ],
1651 Vec::new(),
1652 );
1653 assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
1654 }
1655
1656 #[test]
1657 fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
1658 use mj_core::state::SessionState;
1659 let selected = ready(
1660 vec![
1661 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1662 record(
1663 "running-child",
1664 SessionState::Running,
1665 "2026-09-01T00:00:00Z",
1666 ),
1667 ],
1668 vec![child("running-child", "parent")],
1669 );
1670 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
1671
1672 let selected = ready(
1673 vec![
1674 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1675 record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
1676 ],
1677 vec![child("young-child", "parent")],
1678 );
1679 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
1680
1681 let selected = ready(
1683 vec![record(
1684 "parent",
1685 SessionState::Stopped,
1686 "2026-09-01T00:00:00Z",
1687 )],
1688 vec![child("departed-child", "parent")],
1689 );
1690 assert_eq!(selected, vec!["parent"]);
1691 }
1692
1693 #[test]
1694 fn children_are_archived_before_their_parents() {
1695 use mj_core::state::SessionState;
1696 let selected = ready(
1697 vec![
1698 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1699 record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1700 record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
1701 ],
1702 vec![child("child", "parent"), child("grandchild", "child")],
1703 );
1704 assert_eq!(selected, vec!["grandchild", "child", "parent"]);
1705 }
1706}