Skip to main content

mj_core/
state.rs

1//! Durable controller-side state for Hel-managed sessions.
2
3#[cfg(test)]
4use std::collections::BTreeMap;
5use std::collections::BTreeSet;
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8
9use anyhow::{Context, Result, bail};
10use serde::{Deserialize, Serialize};
11
12use crate::config::{Config, HarnessKind, ProjectRepository, TargetTemplate, validate_id};
13use crate::credentials::CredentialSyncSignal;
14use crate::relay::{
15    RELAY_EVENT_GENESIS_DIGEST, RelayOperationalState, SequencedEvent, WorkerEvent,
16};
17use crate::snapshot_map::SnapshotMap;
18use crate::subagent::SubagentRecord;
19use crate::targets::{AdditionalMount, validate_additional_mounts};
20
21pub const STATE_VERSION: u32 = 1;
22
23mod target_runtime;
24pub use target_runtime::{TargetConnection, TargetRuntimeSettings};
25
26mod session_configuration;
27pub use session_configuration::SessionConfiguration;
28
29mod session_move;
30pub use session_move::*;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum SessionState {
35    Provisioning,
36    /// Startup failed; teardown owns the target until termination is confirmed.
37    StartupCleanup,
38    Running,
39    Disconnected,
40    Checkpointing,
41    Closing,
42    Destroying,
43    /// Checkpointed and torn down. Persisted as `"archived"` before the verb
44    /// was renamed, so the alias keeps those records loading.
45    #[serde(alias = "archived")]
46    Stopped,
47    /// A Mjolnir sub-agent whose turn ended and whose parent was told: its
48    /// worker process tree is stopped so it holds no processes in the
49    /// parent's container, while its record, relation, target locator and
50    /// worker root (relay journal, native session id) stay. Only a parent's
51    /// `send_input` starts it again. Nothing that connects to, reconnects,
52    /// recovers or upgrades live sessions acts on it.
53    Parked,
54    Lost,
55    Error,
56    DestroyedWithDataLoss,
57}
58
59/// A lifecycle transition temporarily replaces the conversation in control surfaces.
60/// Operation ownership takes precedence over intermediate durable session states.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum SessionTransitionKind {
64    Starting,
65    Resuming,
66    Moving,
67    Suspending,
68    Destroying,
69    /// A sub-agent stopped because its parent is being suspended.
70    Stopping,
71}
72
73impl SessionTransitionKind {
74    pub const fn label(self) -> &'static str {
75        match self {
76            Self::Starting => "Starting",
77            Self::Resuming => "Resuming",
78            Self::Moving => "Moving",
79            Self::Suspending => "Suspending",
80            Self::Destroying => "Destroying",
81            Self::Stopping => "Stopping",
82        }
83    }
84
85    pub fn for_session(state: SessionState, operation: Option<Self>) -> Option<Self> {
86        operation.or_else(|| state.transition_kind())
87    }
88}
89
90#[cfg(test)]
91mod transition_tests {
92    use super::{SessionState, SessionTransitionKind};
93
94    #[test]
95    fn operation_ownership_hides_intermediate_move_states_but_not_ordinary_live_work() {
96        for state in [
97            SessionState::Stopped,
98            SessionState::Running,
99            SessionState::Disconnected,
100        ] {
101            assert_eq!(
102                SessionTransitionKind::for_session(state, Some(SessionTransitionKind::Moving)),
103                Some(SessionTransitionKind::Moving)
104            );
105            assert_eq!(SessionTransitionKind::for_session(state, None), None);
106        }
107        assert_eq!(SessionState::Checkpointing.transition_kind(), None);
108        assert_eq!(
109            SessionState::Closing.transition_kind(),
110            Some(SessionTransitionKind::Suspending)
111        );
112    }
113}
114
115/// Controller-owned execution state derived from the relay event stream.
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "state", rename_all = "snake_case")]
118pub enum MaterializedExecutionState {
119    #[default]
120    Idle,
121    Running {
122        started_at_ms: i64,
123    },
124    Closing,
125    Closed,
126}
127
128pub use crate::transcript::{TerminalOutputRecord, TranscriptBody, TranscriptItem};
129
130/// What a durable queue entry does when its turn comes.
131///
132/// Serialized without a tag for prompts so entries written before configuration
133/// changes could be queued keep loading unchanged.
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum QueuedCommandKind {
137    #[default]
138    Prompt,
139    SetConfig {
140        key: String,
141        value: String,
142    },
143}
144
145impl QueuedCommandKind {
146    pub fn is_prompt(&self) -> bool {
147        matches!(self, Self::Prompt)
148    }
149}
150
151/// The composer form of a configuration change, used both as the queue entry's
152/// display text and as the text peeled back into the composer for editing.
153pub fn config_command_text(key: &str, value: &str) -> String {
154    if key == "fast-mode" {
155        "/fast".to_owned()
156    } else {
157        format!("/{key} {value}")
158    }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct MaterializedQueuedPrompt {
164    pub command_id: String,
165    #[serde(default, skip_serializing_if = "QueuedCommandKind::is_prompt")]
166    pub kind: QueuedCommandKind,
167    pub content: Vec<serde_json::Value>,
168    pub queued_at_ms: i64,
169    /// Relay acceptance ordinal of the `CommandQueued` event that created this
170    /// entry. It is the turn identity the API hands back to callers, so wait
171    /// can tell one queued prompt's outcome from another's.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub accepted_ordinal: Option<u64>,
174}
175
176/// The prompt currently executing, recorded when its `CommandStarted` event is
177/// projected and cleared when the command completes.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct MaterializedTurn {
181    pub command_id: String,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub accepted_ordinal: Option<u64>,
184    /// Ordinal of the `CommandStarted` event, which is also the transcript
185    /// position of the turn's first item.
186    pub turn_start_position: u64,
187    pub started_at_ms: i64,
188    /// The relay prompt still executing this turn after a steer moved the
189    /// turn to a queued prompt. That prompt's completion ends this turn.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub steered_into: Option<String>,
192}
193
194impl MaterializedTurn {
195    /// Whether the ending of relay prompt `command_id` ends this turn.
196    pub fn belongs_to(&self, command_id: &str) -> bool {
197        self.command_id == command_id || self.steered_into.as_deref() == Some(command_id)
198    }
199}
200
201/// How a prompt ended.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum TurnOutcomeKind {
205    /// The harness finished the turn and reported this stop reason.
206    Completed { stop_reason: String },
207    /// The relay refused the command before it ran.
208    Rejected {
209        message: String,
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        reason: Option<crate::event_outcome::OutcomeReason>,
212    },
213    /// The command was interrupted after being accepted.
214    Interrupted {
215        message: String,
216        #[serde(default, skip_serializing_if = "Option::is_none")]
217        reason: Option<crate::event_outcome::OutcomeReason>,
218    },
219}
220
221/// How a turn ended, in words a person reads: "completed, end of turn",
222/// "interrupted", or "failed: <reason>".
223impl std::fmt::Display for TurnOutcomeKind {
224    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        use crate::event_outcome::{OutcomeReason, TurnResultKind};
226        let result = self.result();
227        match result.kind {
228            TurnResultKind::Completed => formatter.write_str("completed, end of turn"),
229            TurnResultKind::InputRequired => formatter.write_str("completed, waiting for input"),
230            TurnResultKind::Cancelled | TurnResultKind::Interrupted => {
231                formatter.write_str("interrupted")
232            }
233            TurnResultKind::Failed if result.reason == Some(OutcomeReason::QuotaLimit) => {
234                formatter.write_str("failed: quota limit reached")
235            }
236            TurnResultKind::Failed | TurnResultKind::Rejected => {
237                let message = result.message.as_deref().unwrap_or("unknown failure");
238                if result.stop_reason.is_some() {
239                    write!(formatter, "failed: {}", stop_reason_words(message))
240                } else {
241                    write!(
242                        formatter,
243                        "failed: {}",
244                        message.lines().next().unwrap_or_default().trim()
245                    )
246                }
247            }
248        }
249    }
250}
251
252/// A stop reason as the harness spells it (`MaxTokens`, `max_turn_requests`)
253/// as lower-case words.
254fn stop_reason_words(stop_reason: &str) -> String {
255    let mut words = String::new();
256    let mut previous_lower = false;
257    for character in stop_reason.trim().chars() {
258        if character == '_' || character == '-' || character.is_whitespace() {
259            if !words.ends_with(' ') && !words.is_empty() {
260                words.push(' ');
261            }
262            previous_lower = false;
263            continue;
264        }
265        if character.is_uppercase() && previous_lower {
266            words.push(' ');
267        }
268        previous_lower = character.is_lowercase() || character.is_ascii_digit();
269        words.extend(character.to_lowercase());
270    }
271    match words.trim_end() {
272        "" => "no reason given".to_owned(),
273        words => words.to_owned(),
274    }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PromptCompletion {
279    InputRequired,
280    Finished,
281    Cancelled,
282    QuotaLimit,
283    Error,
284}
285
286/// Shared interpretation for wait responses and durable completion events.
287pub fn classify_prompt_completion(stop_reason: &str) -> PromptCompletion {
288    let normalized = stop_reason
289        .chars()
290        .filter(|character| *character != '_' && *character != '-')
291        .flat_map(char::to_lowercase)
292        .collect::<String>();
293    match normalized.as_str() {
294        "endturn" => PromptCompletion::Finished,
295        "awaitinginput" => PromptCompletion::InputRequired,
296        "cancelled" | "canceled" => PromptCompletion::Cancelled,
297        "quotalimit" => PromptCompletion::QuotaLimit,
298        _ => PromptCompletion::Error,
299    }
300}
301
302/// The most recent finished prompt on a session.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct MaterializedTurnOutcome {
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
308
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub usage: Option<crate::usage::TokenUsage>,
311    pub command_id: String,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub accepted_ordinal: Option<u64>,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub turn_start_position: Option<u64>,
316    pub completed_ordinal: u64,
317    pub completed_at_ms: i64,
318    pub outcome: TurnOutcomeKind,
319}
320
321impl MaterializedTurnOutcome {
322    /// Only work that actually started can have been interrupted.
323    pub fn interruption_ordinal(&self) -> Option<u64> {
324        (self.turn_start_position.is_some()
325            && matches!(self.outcome, TurnOutcomeKind::Interrupted { .. }))
326        .then_some(self.completed_ordinal)
327    }
328}
329
330/// Canonical controller projection for one logical ACP session.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(deny_unknown_fields)]
333pub struct MaterializedSession {
334    pub session_id: String,
335    pub applied_event_ordinal: u64,
336    pub applied_event_digest: String,
337    /// Monotonic controller projection watermark derived from relay event
338    /// receipt times. It is deliberately independent of retained rows.
339    pub last_activity_at_ms: Option<i64>,
340    pub execution: MaterializedExecutionState,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub session_title: Option<String>,
343    #[serde(default, skip_serializing_if = "SessionConfiguration::is_empty")]
344    pub configuration: SessionConfiguration,
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    /// Transcript items are shared by pointer so cloning a snapshot copies
347    /// handles rather than the whole conversation.
348    pub transcript: Vec<Arc<TranscriptItem>>,
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub queued_prompts: Vec<MaterializedQueuedPrompt>,
351    /// In-flight form requests are projected durably, but their answers are
352    /// connection-only and never enter this state.
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
355    /// The prompt running right now, if any.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub active_turn: Option<MaterializedTurn>,
358    /// The most recently finished prompt, kept after the session stops so a
359    /// caller can still read how the last turn ended.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
362}
363
364/// The small portion of a durable projection needed to populate dashboard
365/// rows before the live session delivers its full transcript snapshot.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct MaterializedSessionSummary {
368    pub session_id: String,
369    pub applied_event_ordinal: u64,
370    pub last_activity_at_ms: Option<i64>,
371    pub execution: MaterializedExecutionState,
372    pub session_title: Option<String>,
373    pub last_agent_message: Option<String>,
374    pub last_user_message: Option<String>,
375    /// Whether the last nonempty agent message appears after the last
376    /// nonempty user message in transcript order.
377    pub last_agent_message_follows_last_user: bool,
378    pub agent_message_latest_content_ordinals: Vec<u64>,
379    pub interruption_event_ordinals: Vec<u64>,
380}
381
382impl MaterializedSession {
383    pub fn empty(session_id: impl Into<String>) -> Self {
384        Self {
385            session_id: session_id.into(),
386            applied_event_ordinal: 0,
387            applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
388            last_activity_at_ms: None,
389            execution: MaterializedExecutionState::Idle,
390            session_title: None,
391            configuration: SessionConfiguration::default(),
392            transcript: Vec::new(),
393            queued_prompts: Vec::new(),
394            pending_elicitations: Vec::new(),
395            active_turn: None,
396            last_turn_outcome: None,
397        }
398    }
399
400    pub fn last_activity_at_ms(&self) -> Option<i64> {
401        self.last_activity_at_ms
402    }
403
404    /// Resolve the title exposed by a live materialized session.
405    ///
406    /// Sessions created before provisional titles were projected can still
407    /// have an untitled transcript. Derive the same bounded fallback from
408    /// their first visible user prompt when reading them.
409    pub fn resolved_title(&self) -> Option<String> {
410        self.session_title
411            .as_deref()
412            .and_then(normalize_session_title)
413            .or_else(|| {
414                self.transcript.iter().find_map(|item| {
415                    let TranscriptBody::User { content } = &item.body else {
416                        return None;
417                    };
418                    provisional_session_title(&crate::transcript::materialized_content_text(
419                        content,
420                    ))
421                })
422            })
423            .or_else(|| {
424                self.queued_prompts
425                    .iter()
426                    .filter(|prompt| prompt.kind.is_prompt())
427                    .find_map(|prompt| {
428                        provisional_session_title(&crate::transcript::materialized_content_text(
429                            &prompt.content,
430                        ))
431                    })
432            })
433    }
434
435    pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
436        self.transcript
437            .iter()
438            .filter(|item| {
439                item.latest_content_event_ordinal
440                    .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
441                    && item.is_nonempty_agent_message()
442            })
443            .count() as u64
444    }
445
446    pub fn unread_interruptions_after(&self, viewed_through_event_ordinal: u64) -> u64 {
447        self.interruption_event_ordinals()
448            .into_iter()
449            .filter(|ordinal| *ordinal > viewed_through_event_ordinal)
450            .count() as u64
451    }
452
453    pub fn interruption_event_ordinals(&self) -> Vec<u64> {
454        let mut ordinals = self
455            .transcript
456            .iter()
457            .filter(|item| item.is_work_interruption())
458            .map(|item| item.position)
459            .collect::<Vec<_>>();
460        if let Some(ordinal) = self
461            .last_turn_outcome
462            .as_ref()
463            .and_then(MaterializedTurnOutcome::interruption_ordinal)
464        {
465            ordinals.push(ordinal);
466        }
467        ordinals.sort_unstable();
468        ordinals.dedup();
469        ordinals
470    }
471
472    pub fn validate(&self) -> Result<()> {
473        validate_id("session", &self.session_id)?;
474        validate_relay_event_frontier(
475            self.applied_event_ordinal,
476            &self.applied_event_digest,
477            "materialized session event frontier",
478        )?;
479        if self
480            .session_title
481            .as_ref()
482            .is_some_and(|title| title.trim().is_empty())
483        {
484            bail!("materialized session has an empty title");
485        }
486        let mut item_ids = BTreeSet::new();
487        for item in &self.transcript {
488            item.validate(self.applied_event_ordinal)?;
489            if !item_ids.insert(item.stable_id.as_str()) {
490                bail!(
491                    "materialized transcript contains duplicate item {:?}",
492                    item.stable_id
493                );
494            }
495        }
496        let mut command_ids = BTreeSet::new();
497        for prompt in &self.queued_prompts {
498            if prompt.command_id.trim().is_empty() {
499                bail!("materialized prompt queue has an empty command id");
500            }
501            if !command_ids.insert(prompt.command_id.as_str()) {
502                bail!(
503                    "materialized prompt queue contains duplicate command {:?}",
504                    prompt.command_id
505                );
506            }
507            if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
508                && (key.trim().is_empty() || value.trim().is_empty())
509            {
510                bail!(
511                    "materialized queued configuration change {:?} is incomplete",
512                    prompt.command_id
513                );
514            }
515        }
516        Ok(())
517    }
518}
519
520/// A materialized session paired with the live worker's relay state. The
521/// session manager hands this to every reader that needs both the durable
522/// projection and the connection's operational status.
523#[derive(Debug, Clone, PartialEq)]
524pub struct ManagedSessionSnapshot {
525    pub materialized: MaterializedSession,
526    /// What `materialized.transcript` leaves out, and the facts that live
527    /// there. See [`ProjectionWindow`].
528    pub window: ProjectionWindow,
529    pub operational: RelayOperationalState,
530    /// Newest relay event observed by this live actor that asks for immediate
531    /// credential reconciliation. This is intentionally ephemeral: it avoids
532    /// retaining raw replay pages or rescanning projected history.
533    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
534    /// Content address of the executable the connected worker is running, as
535    /// it reported in hello. `None` when the connection did not come from a
536    /// live worker or the worker predates the field; either way the worker is
537    /// not known to be the build this controller would install.
538    pub worker_build: Option<String>,
539    /// Pending parent-tool work fetched from the target worker.
540    pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
541    /// Recently completed tool work cached by the worker for idempotent calls.
542    pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
543}
544
545/// What a projection's transcript window leaves out.
546///
547/// A polled projection carries only the end of the transcript, because that is
548/// all any viewer shows and loading the rest is work proportional to history.
549/// Two facts a reader needs live outside that window: the provisional title
550/// comes from the *first* user message, and the newest turn start is outside
551/// it whenever a single turn is longer than the window. Both are read
552/// separately, with one indexed query each, rather than found by scanning.
553///
554/// A complete projection answers both by scanning what it already holds, which
555/// is what [`ProjectionWindow::of`] does.
556#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
557pub struct ProjectionWindow {
558    /// Transcript items before the window. Zero when the projection is whole.
559    pub omitted_items: usize,
560    /// The title derived from the first user message.
561    pub provisional_title: Option<String>,
562    /// Position of the newest turn start — a user message or the marker for a
563    /// turn the harness began on its own — whether or not it is in the
564    /// window. `None` when the session has none.
565    pub latest_turn_start_position: Option<u64>,
566}
567
568impl ProjectionWindow {
569    /// Keep complete turns around the tail target. Unsettled content can still
570    /// change after a newer turn starts, so retain its turn as well.
571    pub fn trim(&mut self, session: &mut MaterializedSession, target: usize) {
572        let observed = Self::of(session);
573        if self.provisional_title.is_none() {
574            self.provisional_title = observed.provisional_title;
575        }
576        self.latest_turn_start_position = observed
577            .latest_turn_start_position
578            .or(self.latest_turn_start_position);
579        let mut boundary = session.transcript.len().saturating_sub(target.max(1));
580        for (index, item) in session.transcript.iter().enumerate() {
581            let mutable = match &item.body {
582                TranscriptBody::Agent { streaming, .. }
583                | TranscriptBody::Thought { streaming, .. } => *streaming,
584                TranscriptBody::Tool { call, .. } => matches!(
585                    call.get("status").and_then(serde_json::Value::as_str),
586                    Some("pending" | "in_progress")
587                ),
588                _ => false,
589            };
590            if mutable || Some(item.position) == self.latest_turn_start_position {
591                boundary = boundary.min(index);
592            }
593        }
594        let cut = session
595            .transcript
596            .iter()
597            .take(boundary + 1)
598            .rposition(|item| item.is_turn_start())
599            .unwrap_or(0);
600        if cut > 0 {
601            session.transcript.drain(..cut);
602            self.omitted_items += cut;
603        }
604    }
605
606    /// The window of a projection that omits nothing.
607    #[must_use]
608    pub fn of(session: &MaterializedSession) -> Self {
609        Self {
610            omitted_items: 0,
611            provisional_title: session.transcript.iter().find_map(|item| {
612                let TranscriptBody::User { content } = &item.body else {
613                    return None;
614                };
615                provisional_session_title(&crate::transcript::materialized_content_text(content))
616            }),
617            latest_turn_start_position: session
618                .transcript
619                .iter()
620                .rev()
621                .find(|item| item.is_turn_start())
622                .map(|item| item.position),
623        }
624    }
625}
626
627impl ManagedSessionSnapshot {
628    /// The session's title, using the same precedence as
629    /// [`MaterializedSession::resolved_title`] but taking the provisional
630    /// title from the window rather than from a transcript head that a polled
631    /// projection does not carry.
632    #[must_use]
633    pub fn resolved_title(&self) -> Option<String> {
634        self.materialized
635            .session_title
636            .as_deref()
637            .and_then(normalize_session_title)
638            .or_else(|| self.window.provisional_title.clone())
639            .or_else(|| {
640                self.materialized
641                    .queued_prompts
642                    .iter()
643                    .filter(|prompt| prompt.kind.is_prompt())
644                    .find_map(|prompt| {
645                        provisional_session_title(&crate::transcript::materialized_content_text(
646                            &prompt.content,
647                        ))
648                    })
649            })
650    }
651
652    /// The position of the turn this session most recently finished, or `None`
653    /// while it is still working. Same answer as
654    /// [`latest_completed_turn_ordinal`], from a position the window carries
655    /// rather than a scan back through the transcript.
656    #[must_use]
657    pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
658        if self.materialized.execution != MaterializedExecutionState::Idle {
659            return None;
660        }
661        self.window.latest_turn_start_position
662    }
663}
664
665/// One session's activity, reported to the recovery coordinator.
666#[derive(Debug, Clone)]
667pub struct RecoveryObservation {
668    pub session: SessionRecord,
669    pub config: Config,
670    pub latest_completed_turn_ordinal: Option<u64>,
671    /// Why a routine checkpoint has to wait, or `None` when one may start now:
672    /// [`crate::activity::routine_checkpoint_wait`] on the worker's
673    /// operational state, the same answer checkpoint admission gives.
674    pub checkpoint_wait: Option<crate::activity::CheckpointWait>,
675}
676
677/// The position where the session's most recent finished turn began, or
678/// `None` while it is still working. A turn starts at a user message or at the
679/// marker for a turn the harness began on its own, so autonomous work is
680/// covered once it settles.
681pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
682    if session.execution != MaterializedExecutionState::Idle {
683        return None;
684    }
685    session
686        .transcript
687        .iter()
688        .rev()
689        .find(|item| item.is_turn_start())
690        .map(|item| item.position)
691}
692
693pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
694    if digest.len() != 64
695        || !digest
696            .bytes()
697            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
698    {
699        bail!("{name} must be a lowercase SHA-256 digest");
700    }
701    Ok(())
702}
703
704pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
705    validate_relay_event_digest(digest, name)?;
706    if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
707        bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
708    }
709    Ok(())
710}
711
712fn is_false(value: &bool) -> bool {
713    !*value
714}
715
716impl SessionState {
717    /// The persisted and wire spelling, matching the serde encoding.
718    pub const fn as_str(self) -> &'static str {
719        match self {
720            Self::Provisioning => "provisioning",
721            Self::StartupCleanup => "startup-cleanup",
722            Self::Running => "running",
723            Self::Disconnected => "disconnected",
724            Self::Checkpointing => "checkpointing",
725            Self::Closing => "closing",
726            Self::Destroying => "destroying",
727            Self::Stopped => "stopped",
728            Self::Parked => "parked",
729            Self::Lost => "lost",
730            Self::Error => "error",
731            Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
732        }
733    }
734
735    /// Read a stored spelling. Rows written before the verb was renamed still
736    /// say `"archived"`.
737    pub fn from_stored(value: &str) -> Option<Self> {
738        Some(match value {
739            "provisioning" => Self::Provisioning,
740            "startup-cleanup" => Self::StartupCleanup,
741            "running" => Self::Running,
742            "disconnected" => Self::Disconnected,
743            "checkpointing" => Self::Checkpointing,
744            "closing" => Self::Closing,
745            "destroying" => Self::Destroying,
746            "stopped" | "archived" => Self::Stopped,
747            "parked" => Self::Parked,
748            "lost" => Self::Lost,
749            "error" => Self::Error,
750            "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
751            _ => return None,
752        })
753    }
754
755    /// Recovery without a live operation still hides an unfinished target transition.
756    /// Ordinary checkpoints and reconnects deliberately keep their conversation visible.
757    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
758        match self {
759            Self::Provisioning => Some(SessionTransitionKind::Starting),
760            Self::StartupCleanup => Some(SessionTransitionKind::Stopping),
761            Self::Closing => Some(SessionTransitionKind::Suspending),
762            Self::Destroying => Some(SessionTransitionKind::Destroying),
763            _ => None,
764        }
765    }
766
767    /// True while the session still belongs on the dashboard. `Closing` and
768    /// `Checkpointing` stay active on purpose: a stop that has not produced a
769    /// verified checkpoint must not make its row disappear. A `Parked`
770    /// sub-agent is active too: it is still its parent's child, still listed,
771    /// and its parent's suspend, destroy or workspace close must still end
772    /// it. Code that needs a live worker must ask [`Self::has_live_worker`].
773    pub const fn is_active(self) -> bool {
774        matches!(
775            self,
776            Self::Provisioning
777                | Self::StartupCleanup
778                | Self::Running
779                | Self::Disconnected
780                | Self::Checkpointing
781                | Self::Closing
782                | Self::Destroying
783                | Self::Parked
784                | Self::Error
785        )
786    }
787
788    /// True while the session may have a worker process tree on its target,
789    /// including one still being started or torn down: the states in which a
790    /// sub-agent counts against its parent's cap.
791    pub const fn has_live_worker(self) -> bool {
792        matches!(
793            self,
794            Self::Provisioning
795                | Self::StartupCleanup
796                | Self::Running
797                | Self::Disconnected
798                | Self::Checkpointing
799                | Self::Closing
800                | Self::Destroying
801        )
802    }
803}
804
805#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
806#[serde(tag = "kind", rename_all = "kebab-case")]
807pub enum PodmanWorkspaceLocator {
808    #[default]
809    ContainerLayer,
810    Volume {
811        name: String,
812    },
813    HostPath {
814        path: PathBuf,
815        helper: Vec<String>,
816        resource: String,
817    },
818}
819
820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821#[serde(tag = "kind", rename_all = "kebab-case")]
822pub enum TargetLocator {
823    LocalBare {
824        worker_root: PathBuf,
825    },
826    LocalPodman {
827        container_id: String,
828        #[serde(default)]
829        workspace_storage: PodmanWorkspaceLocator,
830        /// The session that owns the container when this locator is a
831        /// sub-agent child borrowing its parent's container; `None` when the
832        /// session owns the container itself.
833        #[serde(default, skip_serializing_if = "Option::is_none")]
834        borrowed_from: Option<String>,
835    },
836    LocalDocker {
837        container_id: String,
838        /// The session that owns the container when this locator is a
839        /// sub-agent child borrowing its parent's container; `None` when the
840        /// session owns the container itself.
841        #[serde(default, skip_serializing_if = "Option::is_none")]
842        borrowed_from: Option<String>,
843    },
844    AppleContainer {
845        container_id: String,
846        /// The session that owns the container when this locator is a
847        /// sub-agent child borrowing its parent's container; `None` when the
848        /// session owns the container itself.
849        #[serde(default, skip_serializing_if = "Option::is_none")]
850        borrowed_from: Option<String>,
851    },
852    AwsEc2 {
853        instance_id: String,
854        #[serde(default, skip_serializing_if = "Option::is_none")]
855        address: Option<String>,
856    },
857    SshBare {
858        host: String,
859        workspace: PathBuf,
860        #[serde(default, skip_serializing_if = "Option::is_none")]
861        worker_id: Option<String>,
862    },
863    SshPodman {
864        host: String,
865        container_id: String,
866        #[serde(default)]
867        workspace_storage: PodmanWorkspaceLocator,
868        /// The session that owns the container when this locator is a
869        /// sub-agent child borrowing its parent's container; `None` when the
870        /// session owns the container itself.
871        #[serde(default, skip_serializing_if = "Option::is_none")]
872        borrowed_from: Option<String>,
873    },
874    SshDocker {
875        host: String,
876        container_id: String,
877        /// The session that owns the container when this locator is a
878        /// sub-agent child borrowing its parent's container; `None` when the
879        /// session owns the container itself.
880        #[serde(default, skip_serializing_if = "Option::is_none")]
881        borrowed_from: Option<String>,
882    },
883}
884
885impl ManagedWorktreeTarget {
886    /// Whether `other` reaches the same checkout: the same kind and, over
887    /// SSH, the same destination, port, and login user.
888    ///
889    /// The other `ssh` options (keys, `ControlPath`, keepalives, host-key
890    /// policy) say how to connect, not where the worktree lives, so they
891    /// follow the machine's current configuration and never make a
892    /// suspended session unable to resume.
893    pub fn same_location(&self, other: &Self) -> bool {
894        match (self, other) {
895            (Self::Local, Self::Local) => true,
896            (
897                Self::Ssh {
898                    destination,
899                    ssh_args,
900                },
901                Self::Ssh {
902                    destination: other_destination,
903                    ssh_args: other_args,
904                },
905            ) => {
906                destination == other_destination
907                    && ssh_location_option(ssh_args, 'p', "port")
908                        == ssh_location_option(other_args, 'p', "port")
909                    && ssh_location_option(ssh_args, 'l', "user")
910                        == ssh_location_option(other_args, 'l', "user")
911            }
912            _ => false,
913        }
914    }
915}
916
917/// The value `ssh` would use for an option that has both a short flag
918/// (`-p 22`, `-p22`) and an `-o` spelling (`-o Port=22`, `-oPort 22`). OpenSSH
919/// keeps the first value it sees.
920fn ssh_location_option(args: &[String], flag: char, option: &str) -> Option<String> {
921    let mut args = args.iter();
922    while let Some(argument) = args.next() {
923        let Some(rest) = argument.strip_prefix('-') else {
924            continue;
925        };
926        let mut chars = rest.chars();
927        let Some(name) = chars.next() else { continue };
928        if name != flag && name != 'o' {
929            continue;
930        }
931        let inline = chars.as_str();
932        let value = if inline.is_empty() {
933            args.next().cloned()
934        } else {
935            Some(inline.to_owned())
936        };
937        if name == flag {
938            return value;
939        }
940        if let Some(setting) = value {
941            let (key, found) = setting
942                .split_once(['=', ' ', '\t'])
943                .unwrap_or((setting.as_str(), ""));
944            if key.trim().eq_ignore_ascii_case(option) {
945                return Some(found.trim().to_owned());
946            }
947        }
948    }
949    None
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(tag = "kind", rename_all = "kebab-case")]
954pub enum ManagedWorktreeTarget {
955    Local,
956    Ssh {
957        destination: String,
958        #[serde(default, skip_serializing_if = "Vec::is_empty")]
959        ssh_args: Vec<String>,
960    },
961}
962
963/// Whether a selected project can create a session-owned Git checkout.
964#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct ManagedWorktreeOptions {
967    pub available: bool,
968    pub default_create: bool,
969}
970
971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct ManagedWorktree {
974    /// Old records are linked worktrees. New isolated raw sessions own a clone.
975    #[serde(default, skip_serializing_if = "ManagedCheckoutKind::is_worktree")]
976    pub kind: ManagedCheckoutKind,
977    pub source_project_directory: PathBuf,
978    pub source_repository: PathBuf,
979    pub worktree_root: PathBuf,
980    pub branch: String,
981    pub target: ManagedWorktreeTarget,
982    /// The commit the session branch was created at. Recorded so an export can
983    /// diff against it in one read; sessions created before this field existed
984    /// fall back to the branch reflog, which expires.
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub base_commit: Option<String>,
987}
988
989#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
990#[serde(rename_all = "snake_case")]
991pub enum ManagedCheckoutKind {
992    #[default]
993    Worktree,
994    Clone,
995}
996
997impl ManagedCheckoutKind {
998    fn is_worktree(&self) -> bool {
999        matches!(self, Self::Worktree)
1000    }
1001}
1002
1003impl ManagedWorktree {
1004    fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
1005        for (label, path) in [
1006            ("source project directory", &self.source_project_directory),
1007            ("source repository", &self.source_repository),
1008            ("worktree root", &self.worktree_root),
1009        ] {
1010            if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
1011                bail!("managed worktree {label} must be an absolute safe path");
1012            }
1013        }
1014        if !self
1015            .source_project_directory
1016            .starts_with(&self.source_repository)
1017        {
1018            bail!("managed worktree source directory is outside its repository");
1019        }
1020        let expected_root = self
1021            .source_repository
1022            .join(".mj")
1023            .join(match self.kind {
1024                ManagedCheckoutKind::Worktree => "worktrees",
1025                ManagedCheckoutKind::Clone => "clones",
1026            })
1027            .join(session_id);
1028        if self.worktree_root != expected_root {
1029            bail!("managed worktree root does not match the session-owned path");
1030        }
1031        if self.kind == ManagedCheckoutKind::Worktree && self.branch != format!("mj/{session_id}") {
1032            bail!("managed worktree branch does not match the session id");
1033        }
1034        if self.kind == ManagedCheckoutKind::Clone && self.branch.trim().is_empty() {
1035            bail!("managed clone has no starting branch");
1036        }
1037        let relative = self
1038            .source_project_directory
1039            .strip_prefix(&self.source_repository)
1040            .expect("source relationship checked above");
1041        if project_directory != Some(self.worktree_root.join(relative).as_path()) {
1042            bail!("session project directory does not match its managed worktree");
1043        }
1044        match &self.target {
1045            ManagedWorktreeTarget::Local => {}
1046            ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
1047                bail!("managed SSH worktree has an empty destination")
1048            }
1049            ManagedWorktreeTarget::Ssh { .. } => {}
1050        }
1051        Ok(())
1052    }
1053}
1054
1055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1056#[serde(tag = "kind", rename_all = "kebab-case")]
1057pub enum SessionResourceAllocation {
1058    Container {
1059        cpus: u64,
1060        memory_bytes: u64,
1061    },
1062    AwsEc2 {
1063        instance_type: String,
1064        vcpus: u64,
1065        memory_bytes: u64,
1066    },
1067}
1068
1069/// Resource sizing supported by a target template.
1070#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1071#[serde(rename_all = "kebab-case")]
1072pub enum ResourceAllocationKind {
1073    #[default]
1074    Fixed,
1075    Container,
1076    AwsEc2,
1077}
1078
1079impl SessionResourceAllocation {
1080    pub fn validate(&self) -> Result<()> {
1081        match self {
1082            Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
1083                bail!("container resource allocation must have non-zero CPU and memory")
1084            }
1085            Self::AwsEc2 {
1086                instance_type,
1087                vcpus,
1088                memory_bytes,
1089            } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
1090                bail!("EC2 resource allocation must have an instance type, CPU, and memory")
1091            }
1092            _ => Ok(()),
1093        }
1094    }
1095}
1096
1097/// The CPU count an allocation grants, regardless of target kind.
1098pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
1099    match allocation {
1100        SessionResourceAllocation::Container { cpus, .. } => *cpus,
1101        SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
1102    }
1103}
1104
1105/// The memory, in bytes, an allocation grants, regardless of target kind.
1106pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
1107    match allocation {
1108        SessionResourceAllocation::Container { memory_bytes, .. }
1109        | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
1110    }
1111}
1112
1113impl TargetLocator {
1114    /// Only raw localhost sessions can use the daemon host's CLI and config.
1115    pub const fn skills_scope(&self) -> crate::skills::SkillsScope {
1116        match self {
1117            Self::LocalBare { .. } => crate::skills::SkillsScope::Localhost,
1118            _ => crate::skills::SkillsScope::Isolated,
1119        }
1120    }
1121
1122    fn validate(&self, session_id: &str) -> Result<()> {
1123        match self {
1124            Self::LocalBare { worker_root } => {
1125                if !worker_root.is_absolute()
1126                    || worker_root
1127                        .components()
1128                        .any(|part| part == Component::ParentDir)
1129                    || !worker_root.ends_with(session_id)
1130                {
1131                    bail!(
1132                        "local bare worker root must be an absolute safe path ending in the session id"
1133                    );
1134                }
1135            }
1136            Self::LocalPodman { container_id, .. }
1137            | Self::LocalDocker { container_id, .. }
1138            | Self::AppleContainer { container_id, .. }
1139            | Self::SshPodman { container_id, .. }
1140            | Self::SshDocker { container_id, .. }
1141                if container_id.trim().is_empty() =>
1142            {
1143                bail!("target locator has an empty container id")
1144            }
1145            Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
1146                bail!("target locator has an empty AWS instance id")
1147            }
1148            Self::SshBare {
1149                host,
1150                workspace,
1151                worker_id,
1152            } => {
1153                if host.trim().is_empty() {
1154                    bail!("bare SSH target locator has an empty host");
1155                }
1156                let unsafe_path = workspace.as_os_str().is_empty()
1157                    || workspace
1158                        .components()
1159                        .any(|part| part == Component::ParentDir);
1160                match worker_id {
1161                    // A sub-agent child works in its parent's workspace under
1162                    // its own worker identity, as cleanup also requires.
1163                    Some(worker_id) => {
1164                        if worker_id != session_id {
1165                            bail!(
1166                                "bare SSH target locator's worker identity does not match the session id"
1167                            );
1168                        }
1169                        if unsafe_path {
1170                            bail!("bare SSH target locator must have a safe workspace path");
1171                        }
1172                    }
1173                    None => {
1174                        if unsafe_path || !workspace.ends_with(session_id) {
1175                            bail!(
1176                                "bare SSH target locator must be a safe path ending in the session id"
1177                            );
1178                        }
1179                    }
1180                }
1181            }
1182            Self::SshPodman { host, .. } if host.trim().is_empty() => {
1183                bail!("SSH Podman target locator has an empty host")
1184            }
1185            Self::SshDocker { host, .. } if host.trim().is_empty() => {
1186                bail!("SSH Docker target locator has an empty host")
1187            }
1188            _ => {}
1189        }
1190        Ok(())
1191    }
1192}
1193
1194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1195#[serde(deny_unknown_fields)]
1196pub struct CheckpointMetadata {
1197    pub archive_path: PathBuf,
1198    /// Lowercase SHA-256 digest of the verified archive.
1199    pub sha256: String,
1200    pub created_at: String,
1201    pub event_frontier: u64,
1202}
1203
1204/// Whether every saved Git change has a verified durable copy outside mj.
1205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1206#[serde(rename_all = "snake_case")]
1207pub enum PublicationState {
1208    Published,
1209    Unpublished,
1210    Unknown,
1211}
1212
1213/// Evidence for one exact checkpoint. A newer checkpoint invalidates it.
1214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1215pub struct PublicationAssessment {
1216    pub checkpoint_sha256: String,
1217    pub state: PublicationState,
1218    pub dirty: bool,
1219    pub stashed: bool,
1220    pub saved_commits: Vec<String>,
1221    pub destinations: Vec<String>,
1222    pub checked_at: String,
1223    pub reason: Option<String>,
1224}
1225
1226impl CheckpointMetadata {
1227    fn validate(&self) -> Result<()> {
1228        if self.archive_path.as_os_str().is_empty() {
1229            bail!("checkpoint archive path is empty");
1230        }
1231        if self.sha256.len() != 64
1232            || !self
1233                .sha256
1234                .bytes()
1235                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1236        {
1237            bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
1238        }
1239        if self.created_at.trim().is_empty() {
1240            bail!("checkpoint timestamp is empty");
1241        }
1242        Ok(())
1243    }
1244}
1245
1246/// The mbx build cache a container session was provisioned with. The
1247/// directory is a host path that is mounted read-write at the same absolute
1248/// path inside the container.
1249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250#[serde(deny_unknown_fields)]
1251pub struct SessionBuildCache {
1252    /// The container host this cache was resolved on. A session moved to a
1253    /// different host cannot reuse it, so the decision is made again there.
1254    pub host: String,
1255    pub directory: PathBuf,
1256    /// Read compatibility for old records only. Launch never uses this value;
1257    /// budgets belong to the shared machine configuration.
1258    #[serde(default, skip_serializing_if = "Option::is_none")]
1259    pub max_size: Option<String>,
1260    /// A `[target] root` the host's mbx configuration relocates outside the
1261    /// cache directory, mounted read-write at the same path as well.
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub target_root: Option<PathBuf>,
1264}
1265
1266/// How much disk Mjolnir's own copies of sessions use, and how much an
1267/// `archive_after_days` value would free. Settings shows this on the
1268/// SessionWiki page so the effect of a value is visible before it is saved.
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270pub struct ArchiveSpacePreview {
1271    /// Every session record Mjolnir holds, archived or not.
1272    pub sessions: usize,
1273    /// What those sessions' checkpoints and attachments occupy.
1274    pub bytes: u64,
1275    /// The sessions an `archive_after_days` value would catch, and their
1276    /// share of `bytes`. Both are zero when no value is set.
1277    pub reclaimable_sessions: usize,
1278    pub reclaimable_bytes: u64,
1279}
1280
1281/// What a container target's host resolves for its blank build cache
1282/// settings right now. Settings shows this beside each "automatic" field so
1283/// the values a session would actually run with are visible before one starts.
1284#[derive(Debug, Clone, PartialEq, Eq)]
1285pub struct BuildCachePreview {
1286    /// The host's own mbx version, or `None` when it has none on `PATH`.
1287    pub native_mbx: Option<String>,
1288    /// The cache directory sessions would mount, once known.
1289    pub directory: Option<PathBuf>,
1290    /// The budget sessions would run with, once known.
1291    pub max_total_size: Option<BuildCacheLimit>,
1292    /// True when a general-purpose host installation owns the configuration.
1293    pub user_managed: bool,
1294    pub application: BuildCacheApplication,
1295    /// Explanation of the shared storage budget.
1296    pub budget_note: Option<String>,
1297    /// What the cache on that host has done so far, when it has a tally.
1298    pub stats: Option<BuildCacheStats>,
1299    /// Why sessions on this target run without a cache, or `None` when they
1300    /// share one.
1301    pub off_reason: Option<BuildCacheOff>,
1302}
1303
1304/// mbx's own running totals for one host's cache, read from the tally beside
1305/// the store.
1306///
1307/// These are machine-wide and cumulative: every session on that host and any
1308/// native builds the user ran themselves are counted together, since they
1309/// share one cache. They answer whether the cache is being used at all, not
1310/// what one session got out of it.
1311#[derive(Debug, Clone, PartialEq, Eq)]
1312pub struct BuildCacheStats {
1313    /// Builds that went through mbx. Zero means nothing has used the shim.
1314    pub builds: u64,
1315    /// Compilations answered from the cache instead of run.
1316    pub cached_compilations: u64,
1317    /// Compiler time those answers avoided, as mbx estimates it.
1318    pub avoided_compiler_ns: u64,
1319    /// Restored output bytes that were cloned rather than copied.
1320    pub reflinked_bytes: u64,
1321}
1322
1323/// Why a target's sessions run without the build cache.
1324#[derive(Debug, Clone, PartialEq, Eq)]
1325pub enum BuildCacheOff {
1326    /// This machine's own `enabled = false`.
1327    TurnedOff,
1328    /// Nothing on the machine's settings page can turn it on: the global
1329    /// switch, the host's mbx, or its filesystem. The text says which.
1330    Unavailable(String),
1331}
1332
1333impl std::fmt::Display for BuildCacheOff {
1334    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1335        match self {
1336            Self::TurnedOff => formatter.write_str("turned off for this machine"),
1337            Self::Unavailable(reason) => formatter.write_str(reason),
1338        }
1339    }
1340}
1341
1342/// Where a machine build cache budget comes from.
1343#[derive(Debug, Clone, PartialEq, Eq)]
1344pub enum BuildCacheLimit {
1345    /// An explicit mj machine setting written to shared mbx configuration.
1346    Size(String),
1347    /// The host's own `~/.config/mbx/config.toml` carries the budget. The
1348    /// total it sets, when it sets one.
1349    HostConfiguration(Option<String>),
1350    /// An automatic total initialized by mj, independent of later disk growth.
1351    MjDefault(String),
1352    /// The pinned mbx's disk-scaled default, or no combined limit.
1353    MbxDefault(Option<String>),
1354}
1355
1356#[derive(Debug, Clone, Default, PartialEq, Eq)]
1357pub enum BuildCacheApplication {
1358    #[default]
1359    Pending,
1360    Applied,
1361    Failed(String),
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1365#[serde(deny_unknown_fields)]
1366pub struct SessionRecord {
1367    pub id: String,
1368    /// Owning workspace while active, or the most recent workspace while inactive.
1369    ///
1370    /// Inactive histories are globally resumable, so this id may refer to a
1371    /// workspace that has since been deleted.
1372    #[serde(default = "default_session_workspace_id")]
1373    pub workspace_id: String,
1374    pub title: String,
1375    pub harness_kind: HarnessKind,
1376    pub last_profile: String,
1377    pub bundle_id: String,
1378    /// Accepted bundle for every target, including a raw directory's bundle of one.
1379    #[serde(default, skip_serializing_if = "Option::is_none")]
1380    pub project: Option<crate::repository::ProjectBundleSnapshot>,
1381    /// Existing project directory used directly by a local or SSH bare target.
1382    #[serde(default, skip_serializing_if = "Option::is_none")]
1383    pub project_directory: Option<PathBuf>,
1384    /// Git worktree created and owned by Hel for this raw-project session.
1385    #[serde(default, skip_serializing_if = "Option::is_none")]
1386    pub managed_worktree: Option<ManagedWorktree>,
1387    /// None preserves automatic selection; false uses the selected directory.
1388    #[serde(default, skip_serializing_if = "Option::is_none")]
1389    pub create_managed_worktree: Option<bool>,
1390    /// Diff baseline as supplied by the caller (the API's `base`). A raw
1391    /// managed worktree also starts here; a bundle checkout keeps its selected
1392    /// remote branch tip. With `checkout`, it applies to the checked-out
1393    /// repository only, and when absent that repository's base is the
1394    /// checkout commit. The resolved baseline lands in
1395    /// `managed_worktree.base_commit` or the clone's `mj.baseCommit`.
1396    #[serde(default, skip_serializing_if = "Option::is_none")]
1397    pub launch_base: Option<String>,
1398    /// Existing branch to check out in a new isolated workspace (the API's
1399    /// `branch` without `at`). Never set together with `checkout`, which
1400    /// carries its own branch.
1401    #[serde(default, skip_serializing_if = "Option::is_none")]
1402    pub launch_branch: Option<String>,
1403    /// Immutable exact starting selection for one bundle repository, built
1404    /// from the API's `at` and `branch`. Resume preserves checkpointed work
1405    /// rather than applying this selection again.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub checkout: Option<crate::remote_git::ExactCheckout>,
1408    /// Last verified publication verdict, tied to its checkpoint digest.
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub publication: Option<PublicationAssessment>,
1411    /// Stored delegation policy. Historical records without a choice use native delegation.
1412    #[serde(
1413        default,
1414        alias = "mjolnir_subagents",
1415        deserialize_with = "crate::subagent::deserialize_optional_policy"
1416    )]
1417    pub subagents: Option<crate::subagent::SubagentPolicy>,
1418    /// Turn review chosen for this session when it was created. `None`
1419    /// follows the global `[review]` section.
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub review: Option<crate::config::SessionReview>,
1422    pub target_template_id: String,
1423    #[serde(default, skip_serializing_if = "Option::is_none")]
1424    pub resource_allocation: Option<SessionResourceAllocation>,
1425    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1426    pub additional_mounts: Vec<AdditionalMount>,
1427    /// Per-session container CPU limit that overrides the target template's
1428    /// value. It is applied the next time the container is created.
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub container_cpus: Option<String>,
1431    /// Per-session container memory limit that overrides the target
1432    /// template's value. It is applied the next time the container is created.
1433    #[serde(default, skip_serializing_if = "Option::is_none")]
1434    pub container_memory: Option<String>,
1435    /// In-container workspace root this session's repositories live under.
1436    /// `None` is a session whose container predates per-session workspaces and
1437    /// therefore keeps the shared legacy `/workspace`; every session created
1438    /// since records `/workspace/<session id>`, so two checkouts of one project
1439    /// on a host never share an absolute path.
1440    #[serde(default, skip_serializing_if = "Option::is_none")]
1441    pub container_workspace: Option<PathBuf>,
1442    /// The mbx build cache this session's container runs with, decided once at
1443    /// provisioning. `None` means the session runs without a build cache;
1444    /// resume, move, and sub-agent children reuse the recorded value.
1445    #[serde(default, skip_serializing_if = "Option::is_none")]
1446    pub build_cache: Option<SessionBuildCache>,
1447    pub state: SessionState,
1448    /// Legacy visibility preference, retained for record compatibility.
1449    /// Current surfaces do not hide sessions based on this flag.
1450    #[serde(default, skip_serializing_if = "is_false")]
1451    pub archived: bool,
1452    #[serde(default, skip_serializing_if = "Option::is_none")]
1453    pub target: Option<TargetLocator>,
1454    /// Connection and worker settings captured when this target was selected.
1455    #[serde(default, skip_serializing_if = "Option::is_none")]
1456    pub target_runtime: Option<TargetRuntimeSettings>,
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub native_session_id: Option<String>,
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub acp_session_title: Option<String>,
1461    #[serde(default, skip_serializing_if = "Option::is_none")]
1462    pub session_title_override: Option<String>,
1463    pub created_at: String,
1464    pub updated_at: String,
1465    #[serde(default, alias = "detached_after_event_ordinal")]
1466    pub viewed_through_event_ordinal: u64,
1467    /// Unsent chat input carried across a detach, so returning to a session
1468    /// restores what the user was typing. Empty means no draft.
1469    #[serde(default, skip_serializing_if = "String::is_empty")]
1470    pub draft_input: String,
1471    /// Why the last operation on this session failed.
1472    ///
1473    /// This is usually a raw controller error chain, which names profile
1474    /// homes, project paths and SSH hosts, so a public projection publishes it
1475    /// only for a session that is stopped or failed. The one exception is a
1476    /// sentence the controller composed for the person; see
1477    /// [`CLOSE_FAILURE_PREFIX`].
1478    #[serde(default, skip_serializing_if = "Option::is_none")]
1479    pub last_error: Option<String>,
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub last_checkpoint_error: Option<String>,
1482    #[serde(default, skip_serializing_if = "Option::is_none")]
1483    pub checkpoint: Option<CheckpointMetadata>,
1484}
1485
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1487#[serde(deny_unknown_fields)]
1488pub struct HostContainerSize {
1489    pub cpus: u64,
1490    pub memory_bytes: u64,
1491}
1492
1493pub const BASELINE_CONTAINER_CPUS: u64 = 8;
1494pub const BASELINE_CONTAINER_MEMORY_BYTES: u64 = 32 * 1024 * 1024 * 1024;
1495
1496/// Clamp a container size to a host reading, preserving the TUI's minimum of
1497/// one CPU and one byte when a host reports zero totals.
1498pub fn clamp_container_size(
1499    size: HostContainerSize,
1500    limits: Option<HostContainerSize>,
1501) -> HostContainerSize {
1502    let Some(limits) = limits else {
1503        return HostContainerSize {
1504            cpus: size.cpus.max(1),
1505            memory_bytes: size.memory_bytes.max(1),
1506        };
1507    };
1508    HostContainerSize {
1509        cpus: size.cpus.min(limits.cpus.max(1)),
1510        memory_bytes: size.memory_bytes.min(limits.memory_bytes.max(1)),
1511    }
1512}
1513
1514/// The default selected for a new container target: its remembered host size,
1515/// or 8 CPUs / 32 GiB, clamped to the latest host totals when available.
1516pub fn default_container_size(
1517    remembered: Option<HostContainerSize>,
1518    limits: Option<HostContainerSize>,
1519) -> HostContainerSize {
1520    clamp_container_size(
1521        remembered.unwrap_or(HostContainerSize {
1522            cpus: BASELINE_CONTAINER_CPUS,
1523            memory_bytes: BASELINE_CONTAINER_MEMORY_BYTES,
1524        }),
1525        limits,
1526    )
1527}
1528
1529/// The TUI's preferred EC2 size: retain the same instance type if offered,
1530/// else choose 8 vCPUs, else the first offered option.
1531pub fn preferred_aws_allocation<'a>(
1532    options: &'a [SessionResourceAllocation],
1533    previous: Option<&SessionResourceAllocation>,
1534) -> Option<&'a SessionResourceAllocation> {
1535    if let Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) = previous
1536        && let Some(option) = options.iter().find(|option| {
1537            matches!(option, SessionResourceAllocation::AwsEc2 { instance_type: candidate, .. } if candidate == instance_type)
1538        })
1539    {
1540        return Some(option);
1541    }
1542    options
1543        .iter()
1544        .find(|option| allocation_cpus(option) == BASELINE_CONTAINER_CPUS)
1545        .or_else(|| options.first())
1546}
1547
1548fn default_session_workspace_id() -> String {
1549    crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1550}
1551
1552/// How a failed close's reason begins in [`SessionRecord::last_error`].
1553///
1554/// A close that fails is non-destructive: the session goes back to the state
1555/// it was running in. Its reason therefore has to be published for a live
1556/// session, which the raw error chains in the same field never are. The
1557/// controller composes a sentence for the person and tags it with this prefix,
1558/// and the projection reads the tag to tell the two apart. Written in one
1559/// place and read in one place, so the tag cannot drift.
1560pub const DESTRUCTION_FAILURE_PREFIX: &str = "the destruction did not finish";
1561
1562pub const CLOSE_FAILURE_PREFIX: &str = "the suspension did not finish";
1563
1564pub const MOVE_FAILURE_PREFIX: &str = "the move did not finish";
1565
1566/// Recognize safe lifecycle outcomes, including records saved before the rename.
1567pub fn is_public_lifecycle_error(error: &str) -> bool {
1568    error.starts_with(CLOSE_FAILURE_PREFIX)
1569        || error.starts_with(MOVE_FAILURE_PREFIX)
1570        || error.starts_with(DESTRUCTION_FAILURE_PREFIX)
1571        || error.starts_with("the close did not finish")
1572}
1573
1574/// How a target is named beside a session, wherever a surface shows one.
1575///
1576/// A bare target (`local-bare`, `ssh-bare`) opens a project directory directly,
1577/// so the target alone does not say what the session was working on and the
1578/// project's own folder name is appended. Every other kind names a provisioned
1579/// environment that already identifies itself, so the target id stands alone.
1580/// A target id the configuration no longer holds is shown verbatim, because its
1581/// kind is no longer known.
1582///
1583/// Shared so the live session summary and the Resume dialog's archived rows
1584/// cannot drift apart: [`SessionRecord::project_target`] calls this, and so
1585/// does the archived row built from the SessionWiki index.
1586#[must_use]
1587pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1588    if !matches!(
1589        config.targets.get(target_id),
1590        Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1591    ) {
1592        return target_id.to_owned();
1593    }
1594    project.and_then(Path::file_name).map_or_else(
1595        || target_id.to_owned(),
1596        |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1597    )
1598}
1599
1600/// A session's starting selection in the terms the API and CLI use.
1601#[derive(Debug, Clone, Default, PartialEq, Eq)]
1602pub struct StartSelection {
1603    /// Commit the workspace started checked out at.
1604    pub at: Option<String>,
1605    /// Branch created at `at`, or the existing branch checked out without it.
1606    pub branch: Option<String>,
1607    /// Diff base; `at` unless the caller named another.
1608    pub base: Option<String>,
1609}
1610
1611impl SessionRecord {
1612    /// The starting selection this record stores, as `at`, `branch` and
1613    /// `base`. The record keeps the older field layout, so this is the one
1614    /// place that maps it to the public names.
1615    pub fn start_selection(&self) -> StartSelection {
1616        let at = self
1617            .checkout
1618            .as_ref()
1619            .map(|checkout| checkout.commit.clone());
1620        StartSelection {
1621            branch: self
1622                .checkout
1623                .as_ref()
1624                .and_then(|checkout| checkout.branch.clone())
1625                .or_else(|| self.launch_branch.clone()),
1626            base: self.launch_base.clone().or_else(|| at.clone()),
1627            at,
1628        }
1629    }
1630
1631    /// Cached verdict for an independent clone. Active checkouts are unknown
1632    /// until a new checkpoint binds an assessment to their exact contents.
1633    pub fn publication_state(&self) -> Option<PublicationState> {
1634        let independent_clone = self
1635            .managed_worktree
1636            .as_ref()
1637            .is_some_and(|owned| owned.kind == ManagedCheckoutKind::Clone)
1638            || (self.managed_worktree.is_none() && self.project_directory.is_none());
1639        if !independent_clone {
1640            return None;
1641        }
1642        if self.state.is_active() {
1643            return Some(PublicationState::Unknown);
1644        }
1645        Some(
1646            self.checkpoint
1647                .as_ref()
1648                .zip(self.publication.as_ref())
1649                .filter(|(checkpoint, assessment)| {
1650                    assessment.checkpoint_sha256 == checkpoint.sha256
1651                })
1652                .map_or(PublicationState::Unknown, |(_, assessment)| {
1653                    if assessment.dirty || assessment.stashed {
1654                        PublicationState::Unpublished
1655                    } else {
1656                        assessment.state
1657                    }
1658                }),
1659        )
1660    }
1661
1662    /// The target access settings this session's commands use: the ones
1663    /// recorded when its target was selected, with the machine's current ssh
1664    /// options while they still reach the same host, user, and port, or the
1665    /// configured target's when nothing was recorded.
1666    pub fn target_runtime_settings<'a>(
1667        &'a self,
1668        config: &Config,
1669    ) -> Result<std::borrow::Cow<'a, TargetRuntimeSettings>> {
1670        if let Some(runtime) = &self.target_runtime {
1671            // How to reach the target follows the machine's current ssh
1672            // options; where it is stays as recorded (launch finding R3-7).
1673            if let Some(refreshed) =
1674                config
1675                    .targets
1676                    .get(&self.target_template_id)
1677                    .and_then(|template| {
1678                        runtime.with_current_ssh_options(&TargetRuntimeSettings::from(template))
1679                    })
1680            {
1681                return Ok(std::borrow::Cow::Owned(refreshed));
1682            }
1683            return Ok(std::borrow::Cow::Borrowed(runtime));
1684        }
1685        let template = config.targets.get(&self.target_template_id).ok_or_else(|| {
1686            crate::refusal::Refusal::precondition(format!(
1687                "Session {:?} has no recorded target access settings. Restore target {:?} in config.toml once, then retry.",
1688                self.id, self.target_template_id))
1689        })?;
1690        let runtime = TargetRuntimeSettings::from(template);
1691        if let Some(locator) = &self.target {
1692            crate::targets::TargetLocator::try_from(crate::targets::RecordedTarget {
1693                locator, runtime: Some(&runtime), session_id: &self.id,
1694            }).map_err(|error| crate::refusal::Refusal::precondition(format!(
1695                "Session {:?} cannot recover target {:?}: {error}. Restore its original target settings, then retry.", self.id, self.target_template_id)))?;
1696        }
1697        Ok(std::borrow::Cow::Owned(runtime))
1698    }
1699
1700    /// The recorded failure that is safe to publish whatever state this
1701    /// session is in, because the controller wrote it for the person rather
1702    /// than copying an error chain into it.
1703    #[must_use]
1704    pub fn public_error(&self) -> Option<&str> {
1705        self.last_error
1706            .as_deref()
1707            .filter(|error| is_public_lifecycle_error(error))
1708    }
1709
1710    /// Configuration drift belongs to this session, not the entire controller.
1711    /// The diagnostic contains only public identifiers, so both UIs can show it.
1712    pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1713        if !self.state.is_active() {
1714            return None;
1715        }
1716        let mut issues = Vec::new();
1717        match config.profiles.get(&self.last_profile) {
1718            None => issues.push(format!("missing profile {:?}", self.last_profile)),
1719            Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1720                "expects {:?}, but profile {:?} is {:?}",
1721                self.harness_kind, self.last_profile, profile.kind
1722            )),
1723            Some(_) => {}
1724        }
1725        if self.project_directory.is_none() && self.project_bundle(config).is_none() {
1726            issues.push(format!("missing bundle {:?}", self.bundle_id));
1727        }
1728        if self.target_runtime.is_none() && !config.targets.contains_key(&self.target_template_id) {
1729            issues.push(format!(
1730                "missing target template {:?}",
1731                self.target_template_id
1732            ));
1733        }
1734        (!issues.is_empty()).then(|| format!(
1735            "Session {:?} needs configuration repair: {}. Restore these entries in config.toml, then retry. Run mj setup to rediscover installed profiles and targets; existing sessions are preserved.",
1736            self.id, issues.join("; ")
1737        ))
1738    }
1739
1740    pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1741        if let Some(issue) = self.configuration_issue(config) {
1742            return Err(crate::refusal::Refusal::precondition(issue).into());
1743        }
1744        Ok(())
1745    }
1746
1747    /// User-visible session name, independent of the initial prompt stored in `title`.
1748    pub fn display_title(&self) -> &str {
1749        self.session_title_override
1750            .as_deref()
1751            .or(self.acp_session_title.as_deref())
1752            .unwrap_or(&self.id)
1753    }
1754
1755    /// The name a listing shows: the display title, except that a session
1756    /// the harness has not named yet and nobody renamed would otherwise be
1757    /// named by its id, which every listing already prints beside it. The
1758    /// title it was created with says more (launch findings F-12 and R2-8).
1759    pub fn listed_title(&self) -> &str {
1760        let named = self.session_title_override.is_some() || self.acp_session_title.is_some();
1761        if !named && !self.title.trim().is_empty() {
1762            return &self.title;
1763        }
1764        self.display_title()
1765    }
1766
1767    /// Project this session works in, as the session list and the chat header
1768    /// both name it: the source repository of a managed worktree, else the
1769    /// project directory, else the bundle's primary repository, else the
1770    /// bundle id.
1771    pub fn project_name(&self, config: &Config) -> String {
1772        if let Some(project) = &self.project {
1773            return project.name();
1774        }
1775        if let Some(worktree) = &self.managed_worktree {
1776            return path_leaf(&worktree.source_repository);
1777        }
1778        if let Some(project_directory) = &self.project_directory {
1779            return path_leaf(project_directory);
1780        }
1781        self.bundle_source_name(config)
1782    }
1783
1784    /// Target label used by the live session summary. Bare targets identify
1785    /// the project directory they open directly; workspace targets already
1786    /// identify the provisioned environment on their own.
1787    pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1788        let project = self
1789            .managed_worktree
1790            .as_ref()
1791            .map(|worktree| &worktree.source_project_directory)
1792            .or(self.project_directory.as_ref());
1793        target_label(config, target_id, project.map(PathBuf::as_path))
1794    }
1795
1796    /// Stable source identity used to group sessions. Managed worktrees point
1797    /// back at their source repository, raw sessions use their project
1798    /// directory until their Git origin is resolved, and bundle sessions use
1799    /// their complete canonical repository set when configured.
1800    pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1801        if let Some(project) = &self.project {
1802            return ProjectSourceIdentity {
1803                key: project
1804                    .source_key()
1805                    .expect("accepted project has complete identities"),
1806                short: project.name(),
1807                full: project
1808                    .identities
1809                    .values()
1810                    .map(crate::repository::RepositoryIdentity::key)
1811                    .collect::<Vec<_>>()
1812                    .join(" + "),
1813            };
1814        }
1815        if let Some(worktree) = &self.managed_worktree {
1816            return ProjectSourceIdentity::path(&worktree.source_repository, None);
1817        }
1818        if let Some(project_directory) = &self.project_directory {
1819            let remote = match &self.target {
1820                Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1821                _ => None,
1822            };
1823            return ProjectSourceIdentity::path(project_directory, remote);
1824        }
1825        self.bundle_source_identity(config)
1826            .unwrap_or_else(|| ProjectSourceIdentity {
1827                key: format!("bundle:{}", self.bundle_id),
1828                short: path_leaf(Path::new(&self.bundle_id)),
1829                full: self.bundle_id.clone(),
1830            })
1831    }
1832
1833    /// Resolve the display name shared by session headings, chat headers, and
1834    /// resume details for a bundle-backed session.
1835    fn bundle_source_name(&self, config: &Config) -> String {
1836        self.bundle_source_identity(config)
1837            .map(|source| source.short)
1838            .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1839    }
1840
1841    /// Resolve the canonical identity of every repository in a bundle for
1842    /// grouping and display naming.
1843    fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1844        let bundle = self.project_bundle(config)?;
1845        let sources = bundle
1846            .repositories
1847            .iter()
1848            .map(repository_source_identity)
1849            .collect::<Option<Vec<_>>>()?;
1850        ProjectSourceIdentity::bundle(sources)
1851    }
1852
1853    /// The accepted definition survives saved-project merges and config edits.
1854    pub fn project_bundle<'a>(
1855        &'a self,
1856        config: &'a Config,
1857    ) -> Option<&'a crate::config::ProjectBundle> {
1858        self.project
1859            .as_ref()
1860            .map(|project| &project.bundle)
1861            .or_else(|| config.bundles.get(&self.bundle_id))
1862    }
1863
1864    /// Orders two sessions the way the session list's sequence view does:
1865    /// oldest first by creation time, with the id as a stable tiebreak. A
1866    /// session whose timestamp does not parse sorts last.
1867    pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1868        self.creation_order_key().cmp(&other.creation_order_key())
1869    }
1870
1871    /// Parse once per session when used with `sort_by_cached_key`.
1872    pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1873        let timestamp = created_at_seconds(&self.created_at);
1874        (timestamp.is_none(), timestamp, &self.id)
1875    }
1876
1877    fn validate(&self, map_id: &str) -> Result<()> {
1878        validate_id("session", &self.id)?;
1879        if self.id != map_id {
1880            bail!(
1881                "session map key {map_id:?} does not match record id {:?}",
1882                self.id
1883            );
1884        }
1885        if let Some(project) = &self.project {
1886            project.key()?;
1887        }
1888        validate_id("workspace", &self.workspace_id)?;
1889        validate_id("profile", &self.last_profile)?;
1890        validate_id("bundle", &self.bundle_id)?;
1891        if let Some(project_directory) = &self.project_directory
1892            && (!project_directory.is_absolute()
1893                || project_directory
1894                    .components()
1895                    .any(|part| part == Component::ParentDir))
1896        {
1897            bail!("session {:?} has an unsafe project directory", self.id);
1898        }
1899        if let Some(managed_worktree) = &self.managed_worktree {
1900            managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1901        }
1902        validate_id("target template", &self.target_template_id)?;
1903        if let Some(allocation) = &self.resource_allocation {
1904            allocation.validate()?;
1905        }
1906        validate_additional_mounts(&self.additional_mounts)?;
1907        if self.title.trim().is_empty() {
1908            bail!("session {:?} has an empty title", self.id);
1909        }
1910        if self
1911            .acp_session_title
1912            .as_ref()
1913            .is_some_and(|title| title.trim().is_empty())
1914            || self
1915                .session_title_override
1916                .as_ref()
1917                .is_some_and(|title| title.trim().is_empty())
1918        {
1919            bail!("session {:?} has an empty display title", self.id);
1920        }
1921        if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1922            bail!("session {:?} has an empty timestamp", self.id);
1923        }
1924        if let Some(target) = &self.target {
1925            target.validate(&self.id)?;
1926        }
1927        if let Some(checkpoint) = &self.checkpoint {
1928            checkpoint.validate()?;
1929        }
1930        Ok(())
1931    }
1932}
1933
1934fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1935    repository
1936        .github
1937        .as_deref()
1938        .and_then(ProjectSourceIdentity::git_remote)
1939        .or_else(|| {
1940            repository
1941                .local
1942                .as_deref()
1943                .map(|path| ProjectSourceIdentity::path(path, None))
1944        })
1945}
1946
1947#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1948pub struct ProjectSourceIdentity {
1949    pub key: String,
1950    pub short: String,
1951    pub full: String,
1952}
1953
1954impl ProjectSourceIdentity {
1955    /// Combine repository identities into one stable bundle identity.
1956    pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1957        if sources.is_empty() {
1958            return None;
1959        }
1960        sources.sort_by(|left, right| {
1961            left.key
1962                .cmp(&right.key)
1963                .then_with(|| left.full.cmp(&right.full))
1964                .then_with(|| left.short.cmp(&right.short))
1965        });
1966        sources.dedup_by(|left, right| left.key == right.key);
1967        if sources.len() == 1 {
1968            return sources.pop();
1969        }
1970        let keys = sources
1971            .iter()
1972            .map(|source| source.key.clone())
1973            .collect::<Vec<_>>();
1974        let key = serde_json::to_string(&keys).ok()?;
1975        Some(Self {
1976            key: format!("bundle:{key}"),
1977            short: sources
1978                .iter()
1979                .map(|source| source.short.as_str())
1980                .collect::<Vec<_>>()
1981                .join(" + "),
1982            full: sources
1983                .iter()
1984                .map(|source| source.full.as_str())
1985                .collect::<Vec<_>>()
1986                .join(" + "),
1987        })
1988    }
1989
1990    /// Canonicalizes a Git remote so raw checkouts group as the same project
1991    /// even when their worktree paths differ.
1992    pub fn git_remote(source: &str) -> Option<Self> {
1993        let identity = crate::repository::RepositoryIdentity::from_remote(source)?;
1994        let full = crate::repository::RepositoryIdentity::remote_label(source)?;
1995        Some(Self {
1996            key: identity.key(),
1997            short: full.rsplit(['/', ':']).next()?.to_owned(),
1998            full,
1999        })
2000    }
2001
2002    /// Build a local-root identity, qualified by host for remote directories.
2003    pub fn path(path: &Path, remote: Option<&str>) -> Self {
2004        let normalized = path.components().collect::<PathBuf>();
2005        let path_text = normalized.to_string_lossy().into_owned();
2006        let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
2007        let key = remote.map_or_else(
2008            || format!("path:{path_text}"),
2009            |host| format!("path:{}:{path_text}", host.to_lowercase()),
2010        );
2011        Self {
2012            key,
2013            short: path_leaf(path),
2014            full,
2015        }
2016    }
2017}
2018
2019/// Last component of a path, falling back to the whole path when it has none.
2020fn path_leaf(path: &Path) -> String {
2021    path.file_name()
2022        .unwrap_or(path.as_os_str())
2023        .to_string_lossy()
2024        .into_owned()
2025}
2026
2027fn created_at_seconds(timestamp: &str) -> Option<i64> {
2028    chrono::DateTime::parse_from_rfc3339(timestamp)
2029        .ok()
2030        .map(|timestamp| timestamp.timestamp())
2031}
2032
2033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2034#[serde(deny_unknown_fields)]
2035pub struct State {
2036    #[serde(default)]
2037    pub last_subagent_policy: crate::subagent::SubagentPolicy,
2038    pub version: u32,
2039    #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2040    pub sessions: SnapshotMap<String, SessionRecord>,
2041    /// Child sessions keyed by their session id. The relationship lives in
2042    /// controller state so every control surface sees the same session family.
2043    #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2044    pub subagents: SnapshotMap<String, SubagentRecord>,
2045    /// Recently used source directories, keyed by `local` or SSH host name.
2046    #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2047    pub mount_history: SnapshotMap<String, Vec<PathBuf>>,
2048    /// Most recently launched container size on each physical target host.
2049    #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2050    pub container_sizes: SnapshotMap<String, HostContainerSize>,
2051}
2052
2053impl Default for State {
2054    fn default() -> Self {
2055        Self {
2056            version: STATE_VERSION,
2057            last_subagent_policy: Default::default(),
2058            sessions: SnapshotMap::new(),
2059            subagents: SnapshotMap::new(),
2060            mount_history: SnapshotMap::new(),
2061            container_sizes: SnapshotMap::new(),
2062        }
2063    }
2064}
2065
2066/// Whether a terminal dashboard follows `record` live through the runtime
2067/// feed. Stopped is the one settled state that accumulates, so a stopped
2068/// session is left to the resume dialog unless something still holds it:
2069/// a lifecycle operation or move in flight (`in_operation`), or a live
2070/// parent whose Sub-agents view shows it (`parent_live`). Lost and
2071/// data-loss sessions stay live: they are unsettled failures that need
2072/// attention.
2073pub fn session_is_live(record: &SessionRecord, in_operation: bool, parent_live: bool) -> bool {
2074    record.state != SessionState::Stopped || in_operation || parent_live
2075}
2076
2077/// Every session [`session_is_live`] keeps, given the ids that have an
2078/// operation in flight. A stopped sub-agent follows its parent, so
2079/// membership settles parent before child.
2080pub fn live_session_ids(
2081    sessions: &SnapshotMap<String, SessionRecord>,
2082    subagents: &SnapshotMap<String, SubagentRecord>,
2083    operations: &BTreeSet<String>,
2084) -> BTreeSet<String> {
2085    let mut live = sessions
2086        .iter()
2087        .filter(|(id, record)| session_is_live(record, operations.contains(*id), false))
2088        .map(|(id, _)| id.clone())
2089        .collect::<BTreeSet<_>>();
2090    loop {
2091        let joined = subagents
2092            .iter()
2093            .filter(|(child, relation)| {
2094                !live.contains(*child)
2095                    && sessions.contains_key(*child)
2096                    && live.contains(&relation.parent_session_id)
2097            })
2098            .map(|(child, _)| child.clone())
2099            .collect::<Vec<_>>();
2100        if joined.is_empty() {
2101            return live;
2102        }
2103        live.extend(joined);
2104    }
2105}
2106
2107impl State {
2108    /// How a notice names a session: the title the session list shows
2109    /// (`listed_title`, which includes the title it was created with), or its
2110    /// short id when it has no title or its record is gone (launch findings
2111    /// B-3, R5-5 and R8-3).
2112    #[must_use]
2113    pub fn session_notice_name(&self, session_id: &str) -> String {
2114        match self.sessions.get(session_id) {
2115            Some(session) if session.listed_title() != session.id => {
2116                session.listed_title().to_owned()
2117            }
2118            _ => short_id(session_id).to_owned(),
2119        }
2120    }
2121
2122    /// The session whose project identity names a row.
2123    ///
2124    /// A sub-agent child runs inside its parent's workspace and owns no
2125    /// managed worktree, so its own `project_directory` is the parent's
2126    /// worktree checkout, whose directory is named after the parent session
2127    /// id. Reading the project identity from the parent instead keeps a child
2128    /// under the same project heading and target label as the session it
2129    /// belongs to.
2130    #[must_use]
2131    pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
2132        self.subagents
2133            .get(&session.id)
2134            .and_then(|record| self.sessions.get(&record.parent_session_id))
2135            .unwrap_or(session)
2136    }
2137
2138    /// Whether `id` names a sub-agent rather than a session the user started:
2139    /// a Mjolnir-managed child, or the record a client builds to show a
2140    /// harness-owned child (see [`crate::native_agent::view_id`]).
2141    ///
2142    /// Every list of top-level sessions filters with this, so the lists
2143    /// cannot disagree about what a sub-agent is.
2144    #[must_use]
2145    pub fn is_subagent_session(&self, id: &str) -> bool {
2146        self.subagents.contains_key(id) || crate::native_agent::is_view_id(id)
2147    }
2148
2149    pub fn validate(&self) -> Result<()> {
2150        if self.version != STATE_VERSION {
2151            bail!(
2152                "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
2153                self.version
2154            );
2155        }
2156        for (id, session) in &self.sessions {
2157            session.validate(id)?;
2158        }
2159        for child_id in self.subagents.keys() {
2160            self.validate_subagent(child_id)?;
2161        }
2162        for (host, sources) in &self.mount_history {
2163            if host.trim().is_empty() {
2164                bail!("mount history contains an empty host key");
2165            }
2166            if sources.iter().any(|source| !source.is_absolute()) {
2167                bail!("mount history for {host:?} contains a non-absolute source path");
2168            }
2169        }
2170        for (host, size) in &self.container_sizes {
2171            if host.trim().is_empty() {
2172                bail!("container size history contains an empty host key");
2173            }
2174            if size.cpus == 0 || size.memory_bytes == 0 {
2175                bail!("container size history for {host:?} contains a zero value");
2176            }
2177            if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
2178                bail!("container size history for {host:?} exceeds SQLite integer range");
2179            }
2180        }
2181        Ok(())
2182    }
2183
2184    /// Validate one relationship after an incremental committed update.
2185    pub fn validate_subagent(&self, child_id: &str) -> Result<()> {
2186        let Some(subagent) = self.subagents.get(child_id) else {
2187            return Ok(());
2188        };
2189        if child_id != subagent.child_session_id {
2190            bail!("sub-agent key {child_id:?} does not match its child session id");
2191        }
2192        if child_id == subagent.parent_session_id {
2193            bail!("sub-agent {child_id:?} cannot be its own parent");
2194        }
2195        if !self.sessions.contains_key(child_id) {
2196            bail!("sub-agent {child_id:?} has no child session");
2197        }
2198        if !self.sessions.contains_key(&subagent.parent_session_id) {
2199            bail!(
2200                "sub-agent {child_id:?} has unknown parent {:?}",
2201                subagent.parent_session_id
2202            );
2203        }
2204        if self.subagents.contains_key(&subagent.parent_session_id) {
2205            bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
2206        }
2207        if subagent.task_name.trim().is_empty()
2208            || subagent.profile_id.trim().is_empty()
2209            || subagent.request_key.trim().is_empty()
2210        {
2211            bail!("sub-agent {child_id:?} has incomplete relationship metadata");
2212        }
2213        Ok(())
2214    }
2215
2216    pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
2217        if mounts.is_empty() {
2218            return;
2219        }
2220        let sources = self
2221            .mount_history
2222            .entry(host.to_owned())
2223            .or_insert_with(Vec::new);
2224        for mount in mounts.iter().rev() {
2225            sources.retain(|source| source != &mount.source);
2226            sources.insert(0, mount.source.clone());
2227        }
2228        sources.truncate(20);
2229    }
2230
2231    pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
2232        self.container_sizes.insert(host.to_owned(), size);
2233    }
2234
2235    pub fn project_directories(&self, host: &str) -> &[PathBuf] {
2236        self.mount_history
2237            .get(&project_history_key(host))
2238            .map(Vec::as_slice)
2239            .unwrap_or_default()
2240    }
2241
2242    pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
2243        let key = project_history_key(host);
2244        let directories = self.mount_history.entry(key).or_insert_with(Vec::new);
2245        directories.retain(|existing| existing != directory);
2246        directories.insert(0, directory.to_path_buf());
2247        directories.truncate(20);
2248    }
2249
2250    pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
2251        let session = self
2252            .sessions
2253            .get(session_id)
2254            .with_context(|| format!("unknown session {session_id}"))?;
2255        if session.state.is_active() {
2256            bail!("refusing to destroy active session {session_id}");
2257        }
2258        Ok(self
2259            .sessions
2260            .remove(session_id)
2261            .expect("session checked above"))
2262    }
2263
2264    /// Remove a session record from state regardless of its lifecycle state.
2265    ///
2266    /// Force destruction is the one caller: by the time it runs, every
2267    /// external artifact has been torn down or its loss accepted, so no state
2268    /// is refused here.
2269    pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
2270        self.sessions
2271            .get(session_id)
2272            .with_context(|| format!("unknown session {session_id}"))?;
2273        Ok(self
2274            .sessions
2275            .remove(session_id)
2276            .expect("session checked above"))
2277    }
2278
2279    /// Sessions that still read `bundle_id` from the config. A suspended
2280    /// session counts: resume looks its project up again. Only a session
2281    /// opened on a plain directory, or one whose data is already gone, does
2282    /// not need it.
2283    pub fn bundle_users(&self, bundle_id: &str) -> Vec<&SessionRecord> {
2284        self.sessions
2285            .values()
2286            .filter(|session| {
2287                session.bundle_id == bundle_id
2288                    && session.project_directory.is_none()
2289                    && session.state != SessionState::DestroyedWithDataLoss
2290            })
2291            .collect()
2292    }
2293
2294    /// Why `bundle_id` cannot be removed from the config, or `None` when no
2295    /// session uses it.
2296    pub fn bundle_removal_refusal(&self, bundle_id: &str) -> Option<String> {
2297        let users = self.bundle_users(bundle_id);
2298        if users.is_empty() {
2299            return None;
2300        }
2301        let mut names = users
2302            .iter()
2303            .take(3)
2304            .map(|session| format!("{:?}", session.listed_title()))
2305            .collect::<Vec<_>>();
2306        if users.len() > 3 {
2307            names.push(format!("{} more", users.len() - 3));
2308        }
2309        Some(format!(
2310            "Project {bundle_id:?} is used by {}: {}. Destroy those sessions before removing it.",
2311            if users.len() == 1 {
2312                "a session"
2313            } else {
2314                "sessions"
2315            },
2316            names.join(", ")
2317        ))
2318    }
2319
2320    /// Setup may add replacements under new names, but must not rewrite
2321    /// dependencies still owned by active sessions.
2322    pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
2323        for session in self
2324            .sessions
2325            .values()
2326            .filter(|session| session.state.is_active())
2327        {
2328            let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
2329                let mut comparable = profile.clone();
2330                if let Some(updated) = after.profiles.get(&session.last_profile) {
2331                    comparable.enabled = updated.enabled;
2332                    comparable.subagents = updated.subagents.clone();
2333                }
2334                // A mismatched harness is already broken; allow repairing it.
2335                profile.kind == session.harness_kind
2336                    && after.profiles.get(&session.last_profile) != Some(&comparable)
2337            } else {
2338                false
2339            };
2340            let bundle_changed = session.project_directory.is_none()
2341                && before
2342                    .bundles
2343                    .get(&session.bundle_id)
2344                    .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
2345            // Build cache settings are resolved at provisioning time and kept
2346            // on the session record, so editing them does not disturb a
2347            // running session.
2348            let target_changed =
2349                before
2350                    .targets
2351                    .get(&session.target_template_id)
2352                    .is_some_and(|target| {
2353                        after
2354                            .targets
2355                            .get(&session.target_template_id)
2356                            .map(TargetTemplate::without_launch_only_settings)
2357                            != Some(target.without_launch_only_settings())
2358                    });
2359            if protected || bundle_changed || target_changed {
2360                // Named as the screen names them: the session by its title,
2361                // the project by its name rather than the internal bundle
2362                // id, and only the parts this change touches.
2363                let mut used = Vec::new();
2364                if protected {
2365                    used.push(format!("agent profile {:?}", session.last_profile));
2366                }
2367                if bundle_changed {
2368                    used.push(format!("project {:?}", session.project_name(before)));
2369                }
2370                if target_changed {
2371                    used.push(format!("runtime {:?}", session.target_template_id));
2372                }
2373                let used = match used.as_slice() {
2374                    [only] => only.clone(),
2375                    [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2376                    [] => unreachable!("something changed"),
2377                };
2378                let title = session.display_title();
2379                let named = if title == session.id {
2380                    format!(
2381                        "a running session in project {:?}",
2382                        session.project_name(before)
2383                    )
2384                } else {
2385                    format!("the running session {title:?}")
2386                };
2387                bail!(
2388                    "Setup would change the {used} that {named} uses. Save the new settings under a new name, or stop the session first."
2389                );
2390            }
2391        }
2392        Ok(())
2393    }
2394
2395    /// Strict validation for callers that need all active references intact.
2396    pub fn validate_against_config(&self, config: &Config) -> Result<()> {
2397        self.validate()?;
2398        config.validate()?;
2399        for session in self.sessions.values() {
2400            session.validate_configuration(config)?;
2401        }
2402        Ok(())
2403    }
2404}
2405
2406fn project_history_key(host: &str) -> String {
2407    format!("project:{host}")
2408}
2409
2410/// Generate an opaque, filesystem-safe stable id for a new logical session.
2411pub fn new_session_id() -> Result<String> {
2412    let mut random = [0u8; 16];
2413    getrandom::fill(&mut random)
2414        .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
2415    Ok(crate::hex::lower_hex(random))
2416}
2417
2418/// Return the newest clean ACP session title from canonical worker events.
2419pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
2420    events.iter().rev().find_map(|event| {
2421        let WorkerEvent::Adapter { payload, .. } = &event.event else {
2422            return None;
2423        };
2424        let crate::acp::RuntimeEvent::SessionUpdate { update } =
2425            serde_json::from_value(payload.clone()).ok()?
2426        else {
2427            return None;
2428        };
2429        let kind = update
2430            .get("sessionUpdate")
2431            .and_then(serde_json::Value::as_str)?;
2432        let title = match kind {
2433            "session_info_update" | "session_title" => {
2434                update.get("title").and_then(serde_json::Value::as_str)
2435            }
2436            _ => None,
2437        }?;
2438        normalize_session_title(title)
2439    })
2440}
2441
2442/// The title a new session gets when whoever starts it gives none: the name
2443/// of its project directory (or its bundle id) and the profile, such as
2444/// "project via fake". The dashboard, the HTTP API, and `mj new` all use it,
2445/// so a session reads the same way whichever surface started it.
2446pub fn default_session_title(
2447    project_directory: Option<&Path>,
2448    bundle_id: &str,
2449    profile_id: &str,
2450) -> String {
2451    let project = project_directory.and_then(Path::file_name).map_or_else(
2452        || bundle_id.to_owned(),
2453        |name| name.to_string_lossy().into_owned(),
2454    );
2455    format!("{project} via {profile_id}")
2456}
2457
2458pub const MAX_SESSION_TITLE_CHARS: usize = 256;
2459
2460/// Clean a title and bound it at a word boundary, including the ellipsis.
2461pub fn normalize_session_title(title: &str) -> Option<String> {
2462    let normalized = crate::relay::strip_hidden_prompt_context(title)
2463        .split_whitespace()
2464        .collect::<Vec<_>>()
2465        .join(" ");
2466    (!normalized.is_empty()).then(|| truncate_session_title(normalized, MAX_SESSION_TITLE_CHARS))
2467}
2468
2469fn truncate_session_title(title: String, maximum_chars: usize) -> String {
2470    if title.chars().count() <= maximum_chars {
2471        return title;
2472    }
2473
2474    let mut truncated = title.chars().take(maximum_chars - 1).collect::<String>();
2475    if let Some(boundary) = truncated.rfind(char::is_whitespace) {
2476        truncated.truncate(boundary);
2477    }
2478    truncated.push('…');
2479    truncated
2480}
2481
2482/// Build the short-lived title shown before the harness supplies its own.
2483///
2484/// The first visible user prompt is immediately useful for identifying a
2485/// session, but it can be arbitrarily large. Keep this fallback bounded; a
2486/// later ACP session-info update remains authoritative and replaces it.
2487pub fn provisional_session_title(prompt: &str) -> Option<String> {
2488    const MAX_TITLE_CHARS: usize = 64;
2489
2490    let normalized = normalize_session_title(prompt)?;
2491    Some(truncate_session_title(normalized, MAX_TITLE_CHARS))
2492}
2493
2494pub fn short_id(id: &str) -> &str {
2495    id.get(..8).unwrap_or(id)
2496}
2497
2498#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2499pub struct RecoveryCandidate {
2500    pub session_id: String,
2501    pub target_template_id: String,
2502    pub locator: TargetLocator,
2503    pub ownership: Option<crate::worker_launch::WorkerOwnership>,
2504    /// Instance that created the worker, from its label or tag, else from
2505    /// the ownership marker. `None` means an older build left no stamp.
2506    #[serde(default)]
2507    pub instance_id: Option<String>,
2508    /// State of the session this resource is labelled for, when the
2509    /// controller still tracks that session. A leftover resource the session
2510    /// record no longer names can only be destroyed, never adopted, because
2511    /// the session id is already taken.
2512    #[serde(default, skip_serializing_if = "Option::is_none")]
2513    pub tracked_session: Option<SessionState>,
2514}
2515
2516#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2517pub struct RecoveryScan {
2518    pub candidates: Vec<RecoveryCandidate>,
2519    pub warnings: Vec<String>,
2520    /// Identity of the instance that ran the scan.
2521    #[serde(default)]
2522    pub instance_id: String,
2523    /// Candidates left out because another or an unknown instance created
2524    /// them and the scan was not widened to all instances.
2525    #[serde(default)]
2526    pub hidden_other_instances: usize,
2527}
2528
2529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2530#[serde(deny_unknown_fields)]
2531pub struct ResumeRepositorySourceReceipt {
2532    pub session_id: String,
2533    pub bundle_id: String,
2534    pub checkpoint_sha256: String,
2535    pub repositories: Vec<crate::config::ProjectRepository>,
2536}
2537
2538#[cfg(test)]
2539mod tests;