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