Skip to main content

mj_core/
state.rs

1//! Durable controller-side state for Hel-managed sessions.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10use crate::config::{Config, HarnessKind, ProjectRepository, TargetTemplate, validate_id};
11use crate::credentials::CredentialSyncSignal;
12use crate::relay::{
13    RELAY_EVENT_GENESIS_DIGEST, RelayOperationalState, SequencedEvent, WorkerEvent,
14};
15use crate::subagent::SubagentRecord;
16use crate::targets::{AdditionalMount, validate_additional_mounts};
17
18pub const STATE_VERSION: u32 = 1;
19
20mod session_move;
21pub use session_move::*;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum SessionState {
26    Provisioning,
27    Running,
28    Disconnected,
29    Checkpointing,
30    Closing,
31    Destroying,
32    /// Checkpointed and torn down. Persisted as `"archived"` before the verb
33    /// was renamed, so the alias keeps those records loading.
34    #[serde(alias = "archived")]
35    Stopped,
36    Lost,
37    Error,
38    DestroyedWithDataLoss,
39}
40
41/// A lifecycle transition temporarily replaces the conversation in control surfaces.
42/// Operation ownership takes precedence over intermediate durable session states.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum SessionTransitionKind {
46    Starting,
47    Resuming,
48    Moving,
49    Stopping,
50    Destroying,
51}
52
53impl SessionTransitionKind {
54    pub const fn label(self) -> &'static str {
55        match self {
56            Self::Starting => "Starting",
57            Self::Resuming => "Resuming",
58            Self::Moving => "Moving",
59            Self::Stopping => "Stopping",
60            Self::Destroying => "Destroying",
61        }
62    }
63
64    pub fn for_session(state: SessionState, operation: Option<Self>) -> Option<Self> {
65        operation.or_else(|| state.transition_kind())
66    }
67}
68
69#[cfg(test)]
70mod transition_tests {
71    use super::{SessionState, SessionTransitionKind};
72
73    #[test]
74    fn operation_ownership_hides_intermediate_move_states_but_not_ordinary_live_work() {
75        for state in [
76            SessionState::Stopped,
77            SessionState::Running,
78            SessionState::Disconnected,
79        ] {
80            assert_eq!(
81                SessionTransitionKind::for_session(state, Some(SessionTransitionKind::Moving)),
82                Some(SessionTransitionKind::Moving)
83            );
84            assert_eq!(SessionTransitionKind::for_session(state, None), None);
85        }
86        assert_eq!(SessionState::Checkpointing.transition_kind(), None);
87        assert_eq!(
88            SessionState::Closing.transition_kind(),
89            Some(SessionTransitionKind::Stopping)
90        );
91    }
92}
93
94/// Controller-owned execution state derived from the relay event stream.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(tag = "state", rename_all = "snake_case")]
97pub enum MaterializedExecutionState {
98    #[default]
99    Idle,
100    Running {
101        started_at_ms: i64,
102    },
103    Closing,
104    Closed,
105}
106
107pub use crate::transcript::{TerminalOutputRecord, TranscriptBody, TranscriptItem};
108
109/// What a durable queue entry does when its turn comes.
110///
111/// Serialized without a tag for prompts so entries written before configuration
112/// changes could be queued keep loading unchanged.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum QueuedCommandKind {
116    #[default]
117    Prompt,
118    SetConfig {
119        key: String,
120        value: String,
121    },
122}
123
124impl QueuedCommandKind {
125    pub fn is_prompt(&self) -> bool {
126        matches!(self, Self::Prompt)
127    }
128}
129
130/// The composer form of a configuration change, used both as the queue entry's
131/// display text and as the text peeled back into the composer for editing.
132pub fn config_command_text(key: &str, value: &str) -> String {
133    if key == "fast-mode" {
134        "/fast".to_owned()
135    } else {
136        format!("/{key} {value}")
137    }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct MaterializedQueuedPrompt {
143    pub command_id: String,
144    #[serde(default, skip_serializing_if = "QueuedCommandKind::is_prompt")]
145    pub kind: QueuedCommandKind,
146    pub content: Vec<serde_json::Value>,
147    pub queued_at_ms: i64,
148    /// Relay acceptance ordinal of the `CommandQueued` event that created this
149    /// entry. It is the turn identity the API hands back to callers, so wait
150    /// can tell one queued prompt's outcome from another's.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub accepted_ordinal: Option<u64>,
153}
154
155/// The prompt currently executing, recorded when its `CommandStarted` event is
156/// projected and cleared when the command completes.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct MaterializedTurn {
160    pub command_id: String,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub accepted_ordinal: Option<u64>,
163    /// Ordinal of the `CommandStarted` event, which is also the transcript
164    /// position of the turn's first item.
165    pub turn_start_position: u64,
166    pub started_at_ms: i64,
167}
168
169/// How a prompt ended.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "kind", rename_all = "snake_case")]
172pub enum TurnOutcomeKind {
173    /// The harness finished the turn and reported this stop reason.
174    Completed { stop_reason: String },
175    /// The relay refused the command before it ran.
176    Rejected { message: String },
177    /// The command was interrupted after being accepted.
178    Interrupted { message: String },
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum PromptCompletion {
183    Finished,
184    Cancelled,
185    QuotaLimit,
186    Error,
187}
188
189/// Shared interpretation for wait responses and durable completion events.
190pub fn classify_prompt_completion(stop_reason: &str) -> PromptCompletion {
191    let normalized = stop_reason
192        .chars()
193        .filter(|character| *character != '_' && *character != '-')
194        .flat_map(char::to_lowercase)
195        .collect::<String>();
196    match normalized.as_str() {
197        "endturn" => PromptCompletion::Finished,
198        "cancelled" | "canceled" => PromptCompletion::Cancelled,
199        "quotalimit" => PromptCompletion::QuotaLimit,
200        _ if crate::relay::is_capacity_stop_reason(stop_reason) => PromptCompletion::QuotaLimit,
201        _ => PromptCompletion::Error,
202    }
203}
204
205/// The most recent finished prompt on a session.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct MaterializedTurnOutcome {
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
211
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub usage: Option<crate::usage::TokenUsage>,
214    pub command_id: String,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub accepted_ordinal: Option<u64>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub turn_start_position: Option<u64>,
219    pub completed_ordinal: u64,
220    pub completed_at_ms: i64,
221    pub outcome: TurnOutcomeKind,
222}
223
224/// Canonical controller projection for one logical ACP session.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct MaterializedSession {
228    pub session_id: String,
229    pub applied_event_ordinal: u64,
230    pub applied_event_digest: String,
231    /// Monotonic controller projection watermark derived from relay event
232    /// receipt times. It is deliberately independent of retained rows.
233    pub last_activity_at_ms: Option<i64>,
234    pub execution: MaterializedExecutionState,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub session_title: Option<String>,
237    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
238    pub configuration: BTreeMap<String, serde_json::Value>,
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    /// Transcript items are shared by pointer so cloning a snapshot copies
241    /// handles rather than the whole conversation.
242    pub transcript: Vec<Arc<TranscriptItem>>,
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub queued_prompts: Vec<MaterializedQueuedPrompt>,
245    /// In-flight form requests are projected durably, but their answers are
246    /// connection-only and never enter this state.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
249    /// The prompt running right now, if any.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub active_turn: Option<MaterializedTurn>,
252    /// The most recently finished prompt, kept after the session stops so a
253    /// caller can still read how the last turn ended.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
256}
257
258/// The small portion of a durable projection needed to populate dashboard
259/// rows before the live session delivers its full transcript snapshot.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct MaterializedSessionSummary {
262    pub session_id: String,
263    pub applied_event_ordinal: u64,
264    pub last_activity_at_ms: Option<i64>,
265    pub execution: MaterializedExecutionState,
266    pub session_title: Option<String>,
267    pub last_agent_message: Option<String>,
268    pub last_user_message: Option<String>,
269    /// Whether the last nonempty agent message appears after the last
270    /// nonempty user message in transcript order.
271    pub last_agent_message_follows_last_user: bool,
272    pub agent_message_latest_content_ordinals: Vec<u64>,
273    pub session_restart_event_ordinals: Vec<u64>,
274}
275
276impl MaterializedSession {
277    pub fn empty(session_id: impl Into<String>) -> Self {
278        Self {
279            session_id: session_id.into(),
280            applied_event_ordinal: 0,
281            applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
282            last_activity_at_ms: None,
283            execution: MaterializedExecutionState::Idle,
284            session_title: None,
285            configuration: BTreeMap::new(),
286            transcript: Vec::new(),
287            queued_prompts: Vec::new(),
288            pending_elicitations: Vec::new(),
289            active_turn: None,
290            last_turn_outcome: None,
291        }
292    }
293
294    pub fn last_activity_at_ms(&self) -> Option<i64> {
295        self.last_activity_at_ms
296    }
297
298    /// Resolve the title exposed by a live materialized session.
299    ///
300    /// Sessions created before provisional titles were projected can still
301    /// have an untitled transcript. Derive the same bounded fallback from
302    /// their first visible user prompt when reading them.
303    pub fn resolved_title(&self) -> Option<String> {
304        self.session_title
305            .as_deref()
306            .and_then(normalize_session_title)
307            .or_else(|| {
308                self.transcript.iter().find_map(|item| {
309                    let TranscriptBody::User { content } = &item.body else {
310                        return None;
311                    };
312                    provisional_session_title(&crate::transcript::materialized_content_text(
313                        content,
314                    ))
315                })
316            })
317            .or_else(|| {
318                self.queued_prompts
319                    .iter()
320                    .filter(|prompt| prompt.kind.is_prompt())
321                    .find_map(|prompt| {
322                        provisional_session_title(&crate::transcript::materialized_content_text(
323                            &prompt.content,
324                        ))
325                    })
326            })
327    }
328
329    pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
330        self.transcript
331            .iter()
332            .filter(|item| {
333                item.latest_content_event_ordinal
334                    .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
335                    && item.is_nonempty_agent_message()
336            })
337            .count() as u64
338    }
339
340    pub fn unread_session_restarts_after(&self, viewed_through_event_ordinal: u64) -> u64 {
341        self.transcript
342            .iter()
343            .filter(|item| {
344                item.position > viewed_through_event_ordinal && item.is_session_restart()
345            })
346            .count() as u64
347    }
348
349    pub fn validate(&self) -> Result<()> {
350        validate_id("session", &self.session_id)?;
351        validate_relay_event_frontier(
352            self.applied_event_ordinal,
353            &self.applied_event_digest,
354            "materialized session event frontier",
355        )?;
356        if self
357            .session_title
358            .as_ref()
359            .is_some_and(|title| title.trim().is_empty())
360        {
361            bail!("materialized session has an empty title");
362        }
363        let mut item_ids = BTreeSet::new();
364        for item in &self.transcript {
365            item.validate(self.applied_event_ordinal)?;
366            if !item_ids.insert(item.stable_id.as_str()) {
367                bail!(
368                    "materialized transcript contains duplicate item {:?}",
369                    item.stable_id
370                );
371            }
372        }
373        let mut command_ids = BTreeSet::new();
374        for prompt in &self.queued_prompts {
375            if prompt.command_id.trim().is_empty() {
376                bail!("materialized prompt queue has an empty command id");
377            }
378            if !command_ids.insert(prompt.command_id.as_str()) {
379                bail!(
380                    "materialized prompt queue contains duplicate command {:?}",
381                    prompt.command_id
382                );
383            }
384            if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
385                && (key.trim().is_empty() || value.trim().is_empty())
386            {
387                bail!(
388                    "materialized queued configuration change {:?} is incomplete",
389                    prompt.command_id
390                );
391            }
392        }
393        Ok(())
394    }
395}
396
397/// A materialized session paired with the live worker's relay state. The
398/// session manager hands this to every reader that needs both the durable
399/// projection and the connection's operational status.
400#[derive(Debug, Clone, PartialEq)]
401pub struct ManagedSessionSnapshot {
402    pub materialized: MaterializedSession,
403    /// What `materialized.transcript` leaves out, and the facts that live
404    /// there. See [`ProjectionWindow`].
405    pub window: ProjectionWindow,
406    pub operational: RelayOperationalState,
407    /// Newest relay event observed by this live actor that asks for immediate
408    /// credential reconciliation. This is intentionally ephemeral: it avoids
409    /// retaining raw replay pages or rescanning projected history.
410    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
411    /// Content address of the executable the connected worker is running, as
412    /// it reported in hello. `None` when the connection did not come from a
413    /// live worker or the worker predates the field; either way the worker is
414    /// not known to be the build this controller would install.
415    pub worker_build: Option<String>,
416    /// Pending parent-tool work fetched from the target worker.
417    pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
418    /// Recently completed tool work cached by the worker for idempotent calls.
419    pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
420}
421
422/// What a projection's transcript window leaves out.
423///
424/// A polled projection carries only the end of the transcript, because that is
425/// all any viewer shows and loading the rest is work proportional to history.
426/// Two facts a reader needs live outside that window: the provisional title
427/// comes from the *first* user message, and the newest turn start is outside
428/// it whenever a single turn is longer than the window. Both are read
429/// separately, with one indexed query each, rather than found by scanning.
430///
431/// A complete projection answers both by scanning what it already holds, which
432/// is what [`ProjectionWindow::of`] does.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct ProjectionWindow {
435    /// Transcript items before the window. Zero when the projection is whole.
436    pub omitted_items: usize,
437    /// The title derived from the first user message.
438    pub provisional_title: Option<String>,
439    /// Position of the newest turn start — a user message or the marker for a
440    /// turn the harness began on its own — whether or not it is in the
441    /// window. `None` when the session has none.
442    pub latest_turn_start_position: Option<u64>,
443}
444
445impl ProjectionWindow {
446    /// The window of a projection that omits nothing.
447    #[must_use]
448    pub fn of(session: &MaterializedSession) -> Self {
449        Self {
450            omitted_items: 0,
451            provisional_title: session.transcript.iter().find_map(|item| {
452                let TranscriptBody::User { content } = &item.body else {
453                    return None;
454                };
455                provisional_session_title(&crate::transcript::materialized_content_text(content))
456            }),
457            latest_turn_start_position: session
458                .transcript
459                .iter()
460                .rev()
461                .find(|item| item.is_turn_start())
462                .map(|item| item.position),
463        }
464    }
465}
466
467impl ManagedSessionSnapshot {
468    /// The session's title, using the same precedence as
469    /// [`MaterializedSession::resolved_title`] but taking the provisional
470    /// title from the window rather than from a transcript head that a polled
471    /// projection does not carry.
472    #[must_use]
473    pub fn resolved_title(&self) -> Option<String> {
474        self.materialized
475            .session_title
476            .as_deref()
477            .and_then(normalize_session_title)
478            .or_else(|| self.window.provisional_title.clone())
479            .or_else(|| {
480                self.materialized
481                    .queued_prompts
482                    .iter()
483                    .filter(|prompt| prompt.kind.is_prompt())
484                    .find_map(|prompt| {
485                        provisional_session_title(&crate::transcript::materialized_content_text(
486                            &prompt.content,
487                        ))
488                    })
489            })
490    }
491
492    /// The position of the turn this session most recently finished, or `None`
493    /// while it is still working. Same answer as
494    /// [`latest_completed_turn_ordinal`], from a position the window carries
495    /// rather than a scan back through the transcript.
496    #[must_use]
497    pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
498        if self.materialized.execution != MaterializedExecutionState::Idle {
499            return None;
500        }
501        self.window.latest_turn_start_position
502    }
503}
504
505/// One session's activity, reported to the recovery coordinator.
506#[derive(Debug, Clone)]
507pub struct RecoveryObservation {
508    pub session: SessionRecord,
509    pub config: Config,
510    pub latest_completed_turn_ordinal: Option<u64>,
511    pub execution: MaterializedExecutionState,
512    /// Whether live provider-owned work permits an automatic checkpoint now.
513    /// This is separate from materialized execution because Kimi detached
514    /// agents outlive the parent turn that returned the session to `Idle`.
515    pub checkpoint_safe: bool,
516}
517
518/// The position where the session's most recent finished turn began, or
519/// `None` while it is still working. A turn starts at a user message or at the
520/// marker for a turn the harness began on its own, so autonomous work is
521/// covered once it settles.
522pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
523    if session.execution != MaterializedExecutionState::Idle {
524        return None;
525    }
526    session
527        .transcript
528        .iter()
529        .rev()
530        .find(|item| item.is_turn_start())
531        .map(|item| item.position)
532}
533
534pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
535    if digest.len() != 64
536        || !digest
537            .bytes()
538            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
539    {
540        bail!("{name} must be a lowercase SHA-256 digest");
541    }
542    Ok(())
543}
544
545pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
546    validate_relay_event_digest(digest, name)?;
547    if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
548        bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
549    }
550    Ok(())
551}
552
553fn is_false(value: &bool) -> bool {
554    !*value
555}
556
557impl SessionState {
558    /// The persisted and wire spelling, matching the serde encoding.
559    pub const fn as_str(self) -> &'static str {
560        match self {
561            Self::Provisioning => "provisioning",
562            Self::Running => "running",
563            Self::Disconnected => "disconnected",
564            Self::Checkpointing => "checkpointing",
565            Self::Closing => "closing",
566            Self::Destroying => "destroying",
567            Self::Stopped => "stopped",
568            Self::Lost => "lost",
569            Self::Error => "error",
570            Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
571        }
572    }
573
574    /// Read a stored spelling. Rows written before the verb was renamed still
575    /// say `"archived"`.
576    pub fn from_stored(value: &str) -> Option<Self> {
577        Some(match value {
578            "provisioning" => Self::Provisioning,
579            "running" => Self::Running,
580            "disconnected" => Self::Disconnected,
581            "checkpointing" => Self::Checkpointing,
582            "closing" => Self::Closing,
583            "destroying" => Self::Destroying,
584            "stopped" | "archived" => Self::Stopped,
585            "lost" => Self::Lost,
586            "error" => Self::Error,
587            "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
588            _ => return None,
589        })
590    }
591
592    /// Recovery without a live operation still hides an unfinished target transition.
593    /// Ordinary checkpoints and reconnects deliberately keep their conversation visible.
594    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
595        match self {
596            Self::Provisioning => Some(SessionTransitionKind::Starting),
597            Self::Closing => Some(SessionTransitionKind::Stopping),
598            Self::Destroying => Some(SessionTransitionKind::Destroying),
599            _ => None,
600        }
601    }
602
603    /// True while the session still belongs on the dashboard. `Closing` and
604    /// `Checkpointing` stay active on purpose: a stop that has not produced a
605    /// verified checkpoint must not make its row disappear.
606    pub const fn is_active(self) -> bool {
607        matches!(
608            self,
609            Self::Provisioning
610                | Self::Running
611                | Self::Disconnected
612                | Self::Checkpointing
613                | Self::Closing
614                | Self::Destroying
615                | Self::Error
616        )
617    }
618}
619
620#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(tag = "kind", rename_all = "kebab-case")]
622pub enum PodmanWorkspaceLocator {
623    #[default]
624    ContainerLayer,
625    Volume {
626        name: String,
627    },
628    HostPath {
629        path: PathBuf,
630        helper: Vec<String>,
631        resource: String,
632    },
633}
634
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(tag = "kind", rename_all = "kebab-case")]
637pub enum TargetLocator {
638    LocalBare {
639        worker_root: PathBuf,
640    },
641    LocalPodman {
642        container_id: String,
643        #[serde(default)]
644        workspace_storage: PodmanWorkspaceLocator,
645        /// The session that owns the container when this locator is a
646        /// sub-agent child borrowing its parent's container; `None` when the
647        /// session owns the container itself.
648        #[serde(default, skip_serializing_if = "Option::is_none")]
649        borrowed_from: Option<String>,
650    },
651    LocalDocker {
652        container_id: String,
653        /// The session that owns the container when this locator is a
654        /// sub-agent child borrowing its parent's container; `None` when the
655        /// session owns the container itself.
656        #[serde(default, skip_serializing_if = "Option::is_none")]
657        borrowed_from: Option<String>,
658    },
659    AppleContainer {
660        container_id: String,
661        /// The session that owns the container when this locator is a
662        /// sub-agent child borrowing its parent's container; `None` when the
663        /// session owns the container itself.
664        #[serde(default, skip_serializing_if = "Option::is_none")]
665        borrowed_from: Option<String>,
666    },
667    AwsEc2 {
668        instance_id: String,
669        #[serde(default, skip_serializing_if = "Option::is_none")]
670        address: Option<String>,
671    },
672    SshBare {
673        host: String,
674        workspace: PathBuf,
675        #[serde(default, skip_serializing_if = "Option::is_none")]
676        worker_id: Option<String>,
677    },
678    SshPodman {
679        host: String,
680        container_id: String,
681        #[serde(default)]
682        workspace_storage: PodmanWorkspaceLocator,
683        /// The session that owns the container when this locator is a
684        /// sub-agent child borrowing its parent's container; `None` when the
685        /// session owns the container itself.
686        #[serde(default, skip_serializing_if = "Option::is_none")]
687        borrowed_from: Option<String>,
688    },
689    SshDocker {
690        host: String,
691        container_id: String,
692        /// The session that owns the container when this locator is a
693        /// sub-agent child borrowing its parent's container; `None` when the
694        /// session owns the container itself.
695        #[serde(default, skip_serializing_if = "Option::is_none")]
696        borrowed_from: Option<String>,
697    },
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(tag = "kind", rename_all = "kebab-case")]
702pub enum ManagedWorktreeTarget {
703    Local,
704    Ssh {
705        destination: String,
706        #[serde(default, skip_serializing_if = "Vec::is_empty")]
707        ssh_args: Vec<String>,
708    },
709}
710
711/// Whether a selected project can create a session-owned Git checkout.
712#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct ManagedWorktreeOptions {
715    pub available: bool,
716    pub default_create: bool,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(deny_unknown_fields)]
721pub struct ManagedWorktree {
722    pub source_project_directory: PathBuf,
723    pub source_repository: PathBuf,
724    pub worktree_root: PathBuf,
725    pub branch: String,
726    pub target: ManagedWorktreeTarget,
727    /// The commit the session branch was created at. Recorded so an export can
728    /// diff against it in one read; sessions created before this field existed
729    /// fall back to the branch reflog, which expires.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub base_commit: Option<String>,
732}
733
734impl ManagedWorktree {
735    fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
736        for (label, path) in [
737            ("source project directory", &self.source_project_directory),
738            ("source repository", &self.source_repository),
739            ("worktree root", &self.worktree_root),
740        ] {
741            if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
742                bail!("managed worktree {label} must be an absolute safe path");
743            }
744        }
745        if !self
746            .source_project_directory
747            .starts_with(&self.source_repository)
748        {
749            bail!("managed worktree source directory is outside its repository");
750        }
751        let expected_root = self
752            .source_repository
753            .join(".mj")
754            .join("worktrees")
755            .join(session_id);
756        if self.worktree_root != expected_root {
757            bail!("managed worktree root does not match the session-owned path");
758        }
759        if self.branch != format!("mj/{session_id}") {
760            bail!("managed worktree branch does not match the session id");
761        }
762        let relative = self
763            .source_project_directory
764            .strip_prefix(&self.source_repository)
765            .expect("source relationship checked above");
766        if project_directory != Some(self.worktree_root.join(relative).as_path()) {
767            bail!("session project directory does not match its managed worktree");
768        }
769        match &self.target {
770            ManagedWorktreeTarget::Local => {}
771            ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
772                bail!("managed SSH worktree has an empty destination")
773            }
774            ManagedWorktreeTarget::Ssh { .. } => {}
775        }
776        Ok(())
777    }
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(tag = "kind", rename_all = "kebab-case")]
782pub enum SessionResourceAllocation {
783    Container {
784        cpus: u64,
785        memory_bytes: u64,
786    },
787    AwsEc2 {
788        instance_type: String,
789        vcpus: u64,
790        memory_bytes: u64,
791    },
792}
793
794impl SessionResourceAllocation {
795    pub fn validate(&self) -> Result<()> {
796        match self {
797            Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
798                bail!("container resource allocation must have non-zero CPU and memory")
799            }
800            Self::AwsEc2 {
801                instance_type,
802                vcpus,
803                memory_bytes,
804            } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
805                bail!("EC2 resource allocation must have an instance type, CPU, and memory")
806            }
807            _ => Ok(()),
808        }
809    }
810}
811
812/// The CPU count an allocation grants, regardless of target kind.
813pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
814    match allocation {
815        SessionResourceAllocation::Container { cpus, .. } => *cpus,
816        SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
817    }
818}
819
820/// The memory, in bytes, an allocation grants, regardless of target kind.
821pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
822    match allocation {
823        SessionResourceAllocation::Container { memory_bytes, .. }
824        | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
825    }
826}
827
828impl TargetLocator {
829    fn validate(&self, session_id: &str) -> Result<()> {
830        match self {
831            Self::LocalBare { worker_root } => {
832                if !worker_root.is_absolute()
833                    || worker_root
834                        .components()
835                        .any(|part| part == Component::ParentDir)
836                    || !worker_root.ends_with(session_id)
837                {
838                    bail!(
839                        "local bare worker root must be an absolute safe path ending in the session id"
840                    );
841                }
842            }
843            Self::LocalPodman { container_id, .. }
844            | Self::LocalDocker { container_id, .. }
845            | Self::AppleContainer { container_id, .. }
846            | Self::SshPodman { container_id, .. }
847            | Self::SshDocker { container_id, .. }
848                if container_id.trim().is_empty() =>
849            {
850                bail!("target locator has an empty container id")
851            }
852            Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
853                bail!("target locator has an empty AWS instance id")
854            }
855            Self::SshBare {
856                host, workspace, ..
857            } => {
858                if host.trim().is_empty() {
859                    bail!("bare SSH target locator has an empty host");
860                }
861                if workspace.as_os_str().is_empty()
862                    || workspace
863                        .components()
864                        .any(|part| part == Component::ParentDir)
865                    || !workspace.ends_with(session_id)
866                {
867                    bail!("bare SSH target locator must be a safe path ending in the session id");
868                }
869            }
870            Self::SshPodman { host, .. } if host.trim().is_empty() => {
871                bail!("SSH Podman target locator has an empty host")
872            }
873            Self::SshDocker { host, .. } if host.trim().is_empty() => {
874                bail!("SSH Docker target locator has an empty host")
875            }
876            _ => {}
877        }
878        Ok(())
879    }
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883#[serde(deny_unknown_fields)]
884pub struct CheckpointMetadata {
885    pub archive_path: PathBuf,
886    /// Lowercase SHA-256 digest of the verified archive.
887    pub sha256: String,
888    pub created_at: String,
889    pub event_frontier: u64,
890}
891
892impl CheckpointMetadata {
893    fn validate(&self) -> Result<()> {
894        if self.archive_path.as_os_str().is_empty() {
895            bail!("checkpoint archive path is empty");
896        }
897        if self.sha256.len() != 64
898            || !self
899                .sha256
900                .bytes()
901                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
902        {
903            bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
904        }
905        if self.created_at.trim().is_empty() {
906            bail!("checkpoint timestamp is empty");
907        }
908        Ok(())
909    }
910}
911
912/// The mbx build cache a container session was provisioned with. The
913/// directory is a host path that is mounted read-write at the same absolute
914/// path inside the container.
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
916#[serde(deny_unknown_fields)]
917pub struct SessionBuildCache {
918    /// The container host this cache was resolved on. A session moved to a
919    /// different host cannot reuse it, so the decision is made again there.
920    pub host: String,
921    pub directory: PathBuf,
922    /// An mbx size string passed as `MBX_GC_MAX_SIZE`, or `None` when the
923    /// host's own mbx configuration file already carries the budget.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub max_size: Option<String>,
926    /// A `[target] root` the host's mbx configuration relocates outside the
927    /// cache directory, mounted read-write at the same path as well.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub target_root: Option<PathBuf>,
930}
931
932/// How much disk Mjolnir's own copies of sessions use, and how much an
933/// `archive_after_days` value would free. Settings shows this on the
934/// SessionWiki page so the effect of a value is visible before it is saved.
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936pub struct ArchiveSpacePreview {
937    /// Every session record Mjolnir holds, archived or not.
938    pub sessions: usize,
939    /// What those sessions' checkpoints and attachments occupy.
940    pub bytes: u64,
941    /// The sessions an `archive_after_days` value would catch, and their
942    /// share of `bytes`. Both are zero when no value is set.
943    pub reclaimable_sessions: usize,
944    pub reclaimable_bytes: u64,
945}
946
947/// What a container target's host resolves for its blank build cache
948/// settings right now. Settings shows this beside each "automatic" field so
949/// the values a session would actually run with are visible before one starts.
950#[derive(Debug, Clone, PartialEq, Eq)]
951pub struct BuildCachePreview {
952    /// The host's own mbx version, or `None` when it has none on `PATH`.
953    pub native_mbx: Option<String>,
954    /// The cache directory sessions would mount, once known.
955    pub directory: Option<PathBuf>,
956    /// The budget sessions would run with, once known.
957    pub max_size: Option<BuildCacheLimit>,
958    /// Why sessions on this target run without a cache, or `None` when they
959    /// share one.
960    pub off_reason: Option<String>,
961}
962
963/// Where a build cache session's size budget comes from.
964#[derive(Debug, Clone, PartialEq, Eq)]
965pub enum BuildCacheLimit {
966    /// An mbx size string passed as `MBX_GC_MAX_SIZE`.
967    Size(String),
968    /// The host's own `~/.config/mbx/config.toml` carries the budget. The
969    /// `gc.max_size` it sets, when it sets one.
970    HostConfiguration(Option<String>),
971}
972
973#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
974#[serde(deny_unknown_fields)]
975pub struct SessionRecord {
976    pub id: String,
977    /// Owning workspace while active, or the most recent workspace while inactive.
978    ///
979    /// Inactive histories are globally resumable, so this id may refer to a
980    /// workspace that has since been deleted.
981    #[serde(default = "default_session_workspace_id")]
982    pub workspace_id: String,
983    pub title: String,
984    pub harness_kind: HarnessKind,
985    pub last_profile: String,
986    pub bundle_id: String,
987    /// Existing project directory used directly by a local or SSH bare target.
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub project_directory: Option<PathBuf>,
990    /// Git worktree created and owned by Hel for this raw-project session.
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub managed_worktree: Option<ManagedWorktree>,
993    /// None preserves automatic selection; false uses the selected directory.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub create_managed_worktree: Option<bool>,
996    /// None follows the global `[subagents] enabled` setting at launch time;
997    /// Some(true) and Some(false) are explicit per-session choices.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub mjolnir_subagents: Option<bool>,
1000    pub target_template_id: String,
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub resource_allocation: Option<SessionResourceAllocation>,
1003    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004    pub additional_mounts: Vec<AdditionalMount>,
1005    /// Per-session container CPU limit that overrides the target template's
1006    /// value. It is applied the next time the container is created.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub container_cpus: Option<String>,
1009    /// Per-session container memory limit that overrides the target
1010    /// template's value. It is applied the next time the container is created.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub container_memory: Option<String>,
1013    /// In-container workspace root this session's repositories live under.
1014    /// `None` is a session whose container predates per-session workspaces and
1015    /// therefore keeps the shared legacy `/workspace`; every session created
1016    /// since records `/workspace/<session id>`, so two checkouts of one project
1017    /// on a host never share an absolute path.
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub container_workspace: Option<PathBuf>,
1020    /// The mbx build cache this session's container runs with, decided once at
1021    /// provisioning. `None` means the session runs without a build cache;
1022    /// resume, move, and sub-agent children reuse the recorded value.
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub build_cache: Option<SessionBuildCache>,
1025    pub state: SessionState,
1026    /// Legacy visibility preference, retained for record compatibility.
1027    /// Current surfaces do not hide sessions based on this flag.
1028    #[serde(default, skip_serializing_if = "is_false")]
1029    pub archived: bool,
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub target: Option<TargetLocator>,
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub native_session_id: Option<String>,
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub acp_session_title: Option<String>,
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub session_title_override: Option<String>,
1038    pub created_at: String,
1039    pub updated_at: String,
1040    #[serde(default, alias = "detached_after_event_ordinal")]
1041    pub viewed_through_event_ordinal: u64,
1042    /// Unsent chat input carried across a detach, so returning to a session
1043    /// restores what the user was typing. Empty means no draft.
1044    #[serde(default, skip_serializing_if = "String::is_empty")]
1045    pub draft_input: String,
1046    /// Why the last operation on this session failed.
1047    ///
1048    /// This is usually a raw controller error chain, which names profile
1049    /// homes, project paths and SSH hosts, so a public projection publishes it
1050    /// only for a session that is stopped or failed. The one exception is a
1051    /// sentence the controller composed for the person; see
1052    /// [`CLOSE_FAILURE_PREFIX`].
1053    #[serde(default, skip_serializing_if = "Option::is_none")]
1054    pub last_error: Option<String>,
1055    #[serde(default, skip_serializing_if = "Option::is_none")]
1056    pub last_checkpoint_error: Option<String>,
1057    #[serde(default, skip_serializing_if = "Option::is_none")]
1058    pub checkpoint: Option<CheckpointMetadata>,
1059}
1060
1061#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(deny_unknown_fields)]
1063pub struct HostContainerSize {
1064    pub cpus: u64,
1065    pub memory_bytes: u64,
1066}
1067
1068fn default_session_workspace_id() -> String {
1069    crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1070}
1071
1072/// How a failed close's reason begins in [`SessionRecord::last_error`].
1073///
1074/// A close that fails is non-destructive: the session goes back to the state
1075/// it was running in. Its reason therefore has to be published for a live
1076/// session, which the raw error chains in the same field never are. The
1077/// controller composes a sentence for the person and tags it with this prefix,
1078/// and the projection reads the tag to tell the two apart. Written in one
1079/// place and read in one place, so the tag cannot drift.
1080pub const CLOSE_FAILURE_PREFIX: &str = "the close did not finish";
1081
1082/// How a target is named beside a session, wherever a surface shows one.
1083///
1084/// A bare target (`local-bare`, `ssh-bare`) opens a project directory directly,
1085/// so the target alone does not say what the session was working on and the
1086/// project's own folder name is appended. Every other kind names a provisioned
1087/// environment that already identifies itself, so the target id stands alone.
1088/// A target id the configuration no longer holds is shown verbatim, because its
1089/// kind is no longer known.
1090///
1091/// Shared so the live session summary and the Resume dialog's archived rows
1092/// cannot drift apart: [`SessionRecord::project_target`] calls this, and so
1093/// does the archived row built from the SessionWiki index.
1094#[must_use]
1095pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1096    if !matches!(
1097        config.targets.get(target_id),
1098        Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1099    ) {
1100        return target_id.to_owned();
1101    }
1102    project.and_then(Path::file_name).map_or_else(
1103        || target_id.to_owned(),
1104        |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1105    )
1106}
1107
1108impl SessionRecord {
1109    /// The recorded failure that is safe to publish whatever state this
1110    /// session is in, because the controller wrote it for the person rather
1111    /// than copying an error chain into it.
1112    #[must_use]
1113    pub fn public_error(&self) -> Option<&str> {
1114        self.last_error
1115            .as_deref()
1116            .filter(|error| error.starts_with(CLOSE_FAILURE_PREFIX))
1117    }
1118
1119    /// Configuration drift belongs to this session, not the entire controller.
1120    /// The diagnostic contains only public identifiers, so both UIs can show it.
1121    pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1122        if !self.state.is_active() {
1123            return None;
1124        }
1125        let mut issues = Vec::new();
1126        match config.profiles.get(&self.last_profile) {
1127            None => issues.push(format!("missing profile {:?}", self.last_profile)),
1128            Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1129                "expects {:?}, but profile {:?} is {:?}",
1130                self.harness_kind, self.last_profile, profile.kind
1131            )),
1132            Some(_) => {}
1133        }
1134        if self.project_directory.is_none() && !config.bundles.contains_key(&self.bundle_id) {
1135            issues.push(format!("missing bundle {:?}", self.bundle_id));
1136        }
1137        if !config.targets.contains_key(&self.target_template_id) {
1138            issues.push(format!(
1139                "missing target template {:?}",
1140                self.target_template_id
1141            ));
1142        }
1143        (!issues.is_empty()).then(|| format!(
1144            "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.",
1145            self.id, issues.join("; ")
1146        ))
1147    }
1148
1149    pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1150        if let Some(issue) = self.configuration_issue(config) {
1151            bail!("{issue}");
1152        }
1153        Ok(())
1154    }
1155
1156    /// User-visible session name, independent of the initial prompt stored in `title`.
1157    pub fn display_title(&self) -> &str {
1158        self.session_title_override
1159            .as_deref()
1160            .or(self.acp_session_title.as_deref())
1161            .unwrap_or(&self.id)
1162    }
1163
1164    /// Project this session works in, as the session list and the chat header
1165    /// both name it: the source repository of a managed worktree, else the
1166    /// project directory, else the bundle's primary repository, else the
1167    /// bundle id.
1168    pub fn project_name(&self, config: &Config) -> String {
1169        if let Some(worktree) = &self.managed_worktree {
1170            return path_leaf(&worktree.source_repository);
1171        }
1172        if let Some(project_directory) = &self.project_directory {
1173            return path_leaf(project_directory);
1174        }
1175        self.bundle_source_name(config)
1176    }
1177
1178    /// Target label used by the live session summary. Bare targets identify
1179    /// the project directory they open directly; workspace targets already
1180    /// identify the provisioned environment on their own.
1181    pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1182        let project = self
1183            .managed_worktree
1184            .as_ref()
1185            .map(|worktree| &worktree.source_project_directory)
1186            .or(self.project_directory.as_ref());
1187        target_label(config, target_id, project.map(PathBuf::as_path))
1188    }
1189
1190    /// Stable source identity used to group sessions. Managed worktrees point
1191    /// back at their source repository, raw sessions use their project
1192    /// directory until their Git origin is resolved, and bundle sessions use
1193    /// their complete canonical repository set when configured.
1194    pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1195        if let Some(worktree) = &self.managed_worktree {
1196            return ProjectSourceIdentity::path(&worktree.source_repository, None);
1197        }
1198        if let Some(project_directory) = &self.project_directory {
1199            let remote = match &self.target {
1200                Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1201                _ => None,
1202            };
1203            return ProjectSourceIdentity::path(project_directory, remote);
1204        }
1205        self.bundle_source_identity(config)
1206            .unwrap_or_else(|| ProjectSourceIdentity {
1207                key: format!("bundle:{}", self.bundle_id),
1208                short: path_leaf(Path::new(&self.bundle_id)),
1209                full: self.bundle_id.clone(),
1210            })
1211    }
1212
1213    /// Resolve the display name shared by session headings, chat headers, and
1214    /// resume details for a bundle-backed session.
1215    fn bundle_source_name(&self, config: &Config) -> String {
1216        self.bundle_source_identity(config)
1217            .map(|source| source.short)
1218            .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1219    }
1220
1221    /// Resolve the canonical identity of every repository in a bundle for
1222    /// grouping and display naming.
1223    fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1224        let bundle = config.bundles.get(&self.bundle_id)?;
1225        let sources = bundle
1226            .repositories
1227            .iter()
1228            .map(repository_source_identity)
1229            .collect::<Option<Vec<_>>>()?;
1230        ProjectSourceIdentity::bundle(sources)
1231    }
1232
1233    /// Orders two sessions the way the session list's sequence view does:
1234    /// oldest first by creation time, with the id as a stable tiebreak. A
1235    /// session whose timestamp does not parse sorts last.
1236    pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1237        self.creation_order_key().cmp(&other.creation_order_key())
1238    }
1239
1240    /// Parse once per session when used with `sort_by_cached_key`.
1241    pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1242        let timestamp = created_at_seconds(&self.created_at);
1243        (timestamp.is_none(), timestamp, &self.id)
1244    }
1245
1246    fn validate(&self, map_id: &str) -> Result<()> {
1247        validate_id("session", &self.id)?;
1248        if self.id != map_id {
1249            bail!(
1250                "session map key {map_id:?} does not match record id {:?}",
1251                self.id
1252            );
1253        }
1254        validate_id("workspace", &self.workspace_id)?;
1255        validate_id("profile", &self.last_profile)?;
1256        validate_id("bundle", &self.bundle_id)?;
1257        if let Some(project_directory) = &self.project_directory
1258            && (!project_directory.is_absolute()
1259                || project_directory
1260                    .components()
1261                    .any(|part| part == Component::ParentDir))
1262        {
1263            bail!("session {:?} has an unsafe project directory", self.id);
1264        }
1265        if let Some(managed_worktree) = &self.managed_worktree {
1266            managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1267        }
1268        validate_id("target template", &self.target_template_id)?;
1269        if let Some(allocation) = &self.resource_allocation {
1270            allocation.validate()?;
1271        }
1272        validate_additional_mounts(&self.additional_mounts)?;
1273        if self.title.trim().is_empty() {
1274            bail!("session {:?} has an empty title", self.id);
1275        }
1276        if self
1277            .acp_session_title
1278            .as_ref()
1279            .is_some_and(|title| title.trim().is_empty())
1280            || self
1281                .session_title_override
1282                .as_ref()
1283                .is_some_and(|title| title.trim().is_empty())
1284        {
1285            bail!("session {:?} has an empty display title", self.id);
1286        }
1287        if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1288            bail!("session {:?} has an empty timestamp", self.id);
1289        }
1290        if let Some(target) = &self.target {
1291            target.validate(&self.id)?;
1292        }
1293        if let Some(checkpoint) = &self.checkpoint {
1294            checkpoint.validate()?;
1295        }
1296        Ok(())
1297    }
1298}
1299
1300fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1301    repository
1302        .github
1303        .as_deref()
1304        .and_then(ProjectSourceIdentity::git_remote)
1305        .or_else(|| {
1306            repository
1307                .local
1308                .as_deref()
1309                .map(|path| ProjectSourceIdentity::path(path, None))
1310        })
1311}
1312
1313#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1314pub struct ProjectSourceIdentity {
1315    pub key: String,
1316    pub short: String,
1317    pub full: String,
1318}
1319
1320impl ProjectSourceIdentity {
1321    /// Combine repository identities into one stable bundle identity.
1322    pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1323        if sources.is_empty() {
1324            return None;
1325        }
1326        sources.sort_by(|left, right| {
1327            left.key
1328                .cmp(&right.key)
1329                .then_with(|| left.full.cmp(&right.full))
1330                .then_with(|| left.short.cmp(&right.short))
1331        });
1332        sources.dedup_by(|left, right| left.key == right.key);
1333        if sources.len() == 1 {
1334            return sources.pop();
1335        }
1336        let keys = sources
1337            .iter()
1338            .map(|source| source.key.clone())
1339            .collect::<Vec<_>>();
1340        let key = serde_json::to_string(&keys).ok()?;
1341        Some(Self {
1342            key: format!("bundle:{key}"),
1343            short: sources
1344                .iter()
1345                .map(|source| source.short.as_str())
1346                .collect::<Vec<_>>()
1347                .join(" + "),
1348            full: sources
1349                .iter()
1350                .map(|source| source.full.as_str())
1351                .collect::<Vec<_>>()
1352                .join(" + "),
1353        })
1354    }
1355
1356    /// Canonicalizes a Git remote so raw checkouts group as the same project
1357    /// even when their worktree paths differ.
1358    pub fn git_remote(source: &str) -> Option<Self> {
1359        if let Some(normalized) = normalize_github_source(source) {
1360            let short = normalized
1361                .rsplit_once('/')
1362                .map_or(normalized.as_str(), |(_, repository)| repository)
1363                .to_owned();
1364            return Some(Self {
1365                key: format!("github:{}", normalized.to_lowercase()),
1366                short,
1367                full: normalized,
1368            });
1369        }
1370        let normalized = source.trim().trim_end_matches('/').trim_end_matches(".git");
1371        if normalized.is_empty() {
1372            return None;
1373        }
1374        let short = normalized
1375            .rsplit(['/', ':'])
1376            .find(|part| !part.is_empty())
1377            .unwrap_or(normalized)
1378            .to_owned();
1379        Some(Self {
1380            key: format!("git:{}", normalized.to_lowercase()),
1381            short,
1382            full: normalized.to_owned(),
1383        })
1384    }
1385
1386    /// Build a local-root identity, qualified by host for remote directories.
1387    pub fn path(path: &Path, remote: Option<&str>) -> Self {
1388        let normalized = path.components().collect::<PathBuf>();
1389        let path_text = normalized.to_string_lossy().into_owned();
1390        let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
1391        let key = remote.map_or_else(
1392            || format!("path:{path_text}"),
1393            |host| format!("path:{}:{path_text}", host.to_lowercase()),
1394        );
1395        Self {
1396            key,
1397            short: path_leaf(path),
1398            full,
1399        }
1400    }
1401}
1402
1403fn normalize_github_source(source: &str) -> Option<String> {
1404    let source = source.trim();
1405    let path = source
1406        .strip_prefix("https://github.com/")
1407        .or_else(|| source.strip_prefix("http://github.com/"))
1408        .or_else(|| source.strip_prefix("git@github.com:"))
1409        .or_else(|| source.strip_prefix("ssh://git@github.com/"))
1410        .or_else(|| {
1411            (!source.contains("://") && !source.contains('@') && !source.contains(':'))
1412                .then_some(source)
1413        })?
1414        .trim_end_matches(".git");
1415    let mut parts = path.split('/');
1416    let owner = parts.next()?;
1417    let repository = parts.next()?;
1418    (!owner.is_empty() && !repository.is_empty() && parts.next().is_none())
1419        .then(|| format!("{owner}/{repository}"))
1420}
1421
1422/// Last component of a path, falling back to the whole path when it has none.
1423fn path_leaf(path: &Path) -> String {
1424    path.file_name()
1425        .unwrap_or(path.as_os_str())
1426        .to_string_lossy()
1427        .into_owned()
1428}
1429
1430fn created_at_seconds(timestamp: &str) -> Option<i64> {
1431    chrono::DateTime::parse_from_rfc3339(timestamp)
1432        .ok()
1433        .map(|timestamp| timestamp.timestamp())
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1437#[serde(deny_unknown_fields)]
1438pub struct State {
1439    pub version: u32,
1440    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1441    pub sessions: BTreeMap<String, SessionRecord>,
1442    /// Child sessions keyed by their session id. The relationship lives in
1443    /// controller state so every control surface sees the same session family.
1444    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1445    pub subagents: BTreeMap<String, SubagentRecord>,
1446    /// Recently used source directories, keyed by `local` or SSH host name.
1447    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1448    pub mount_history: BTreeMap<String, Vec<PathBuf>>,
1449    /// Most recently launched container size on each physical target host.
1450    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1451    pub container_sizes: BTreeMap<String, HostContainerSize>,
1452}
1453
1454impl Default for State {
1455    fn default() -> Self {
1456        Self {
1457            version: STATE_VERSION,
1458            sessions: BTreeMap::new(),
1459            subagents: BTreeMap::new(),
1460            mount_history: BTreeMap::new(),
1461            container_sizes: BTreeMap::new(),
1462        }
1463    }
1464}
1465
1466impl State {
1467    /// The session whose project identity names a row.
1468    ///
1469    /// A sub-agent child runs inside its parent's workspace and owns no
1470    /// managed worktree, so its own `project_directory` is the parent's
1471    /// worktree checkout, whose directory is named after the parent session
1472    /// id. Reading the project identity from the parent instead keeps a child
1473    /// under the same project heading and target label as the session it
1474    /// belongs to.
1475    #[must_use]
1476    pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
1477        self.subagents
1478            .get(&session.id)
1479            .and_then(|record| self.sessions.get(&record.parent_session_id))
1480            .unwrap_or(session)
1481    }
1482
1483    pub fn validate(&self) -> Result<()> {
1484        if self.version != STATE_VERSION {
1485            bail!(
1486                "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
1487                self.version
1488            );
1489        }
1490        for (id, session) in &self.sessions {
1491            session.validate(id)?;
1492        }
1493        for (child_id, subagent) in &self.subagents {
1494            if child_id != &subagent.child_session_id {
1495                bail!("sub-agent key {child_id:?} does not match its child session id");
1496            }
1497            if child_id == &subagent.parent_session_id {
1498                bail!("sub-agent {child_id:?} cannot be its own parent");
1499            }
1500            if !self.sessions.contains_key(child_id) {
1501                bail!("sub-agent {child_id:?} has no child session");
1502            }
1503            if !self.sessions.contains_key(&subagent.parent_session_id) {
1504                bail!(
1505                    "sub-agent {child_id:?} has unknown parent {:?}",
1506                    subagent.parent_session_id
1507                );
1508            }
1509            if self.subagents.contains_key(&subagent.parent_session_id) {
1510                bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
1511            }
1512            if subagent.task_name.trim().is_empty()
1513                || subagent.profile_id.trim().is_empty()
1514                || subagent.request_key.trim().is_empty()
1515            {
1516                bail!("sub-agent {child_id:?} has incomplete relationship metadata");
1517            }
1518        }
1519        for (host, sources) in &self.mount_history {
1520            if host.trim().is_empty() {
1521                bail!("mount history contains an empty host key");
1522            }
1523            if sources.iter().any(|source| !source.is_absolute()) {
1524                bail!("mount history for {host:?} contains a non-absolute source path");
1525            }
1526        }
1527        for (host, size) in &self.container_sizes {
1528            if host.trim().is_empty() {
1529                bail!("container size history contains an empty host key");
1530            }
1531            if size.cpus == 0 || size.memory_bytes == 0 {
1532                bail!("container size history for {host:?} contains a zero value");
1533            }
1534            if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
1535                bail!("container size history for {host:?} exceeds SQLite integer range");
1536            }
1537        }
1538        Ok(())
1539    }
1540
1541    pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
1542        if mounts.is_empty() {
1543            return;
1544        }
1545        let sources = self.mount_history.entry(host.to_owned()).or_default();
1546        for mount in mounts.iter().rev() {
1547            sources.retain(|source| source != &mount.source);
1548            sources.insert(0, mount.source.clone());
1549        }
1550        sources.truncate(20);
1551    }
1552
1553    pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
1554        self.container_sizes.insert(host.to_owned(), size);
1555    }
1556
1557    pub fn project_directories(&self, host: &str) -> &[PathBuf] {
1558        self.mount_history
1559            .get(&project_history_key(host))
1560            .map(Vec::as_slice)
1561            .unwrap_or_default()
1562    }
1563
1564    pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
1565        let key = project_history_key(host);
1566        let directories = self.mount_history.entry(key).or_default();
1567        directories.retain(|existing| existing != directory);
1568        directories.insert(0, directory.to_path_buf());
1569        directories.truncate(20);
1570    }
1571
1572    pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
1573        let session = self
1574            .sessions
1575            .get(session_id)
1576            .with_context(|| format!("unknown session {session_id}"))?;
1577        if session.state.is_active() {
1578            bail!("refusing to destroy active session {session_id}");
1579        }
1580        Ok(self
1581            .sessions
1582            .remove(session_id)
1583            .expect("session checked above"))
1584    }
1585
1586    /// Remove a session record from state regardless of its lifecycle state.
1587    ///
1588    /// Force destruction is the one caller: by the time it runs, every
1589    /// external artifact has been torn down or its loss accepted, so no state
1590    /// is refused here.
1591    pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
1592        self.sessions
1593            .get(session_id)
1594            .with_context(|| format!("unknown session {session_id}"))?;
1595        Ok(self
1596            .sessions
1597            .remove(session_id)
1598            .expect("session checked above"))
1599    }
1600
1601    /// Setup may add replacements under new names, but must not rewrite
1602    /// dependencies still owned by active sessions.
1603    pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
1604        for session in self
1605            .sessions
1606            .values()
1607            .filter(|session| session.state.is_active())
1608        {
1609            let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
1610                let mut comparable = profile.clone();
1611                if let Some(updated) = after.profiles.get(&session.last_profile) {
1612                    comparable.enabled = updated.enabled;
1613                }
1614                // A mismatched harness is already broken; allow repairing it.
1615                profile.kind == session.harness_kind
1616                    && after.profiles.get(&session.last_profile) != Some(&comparable)
1617            } else {
1618                false
1619            };
1620            let bundle_changed = session.project_directory.is_none()
1621                && before
1622                    .bundles
1623                    .get(&session.bundle_id)
1624                    .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
1625            let target_changed =
1626                before
1627                    .targets
1628                    .get(&session.target_template_id)
1629                    .is_some_and(|target| {
1630                        after.targets.get(&session.target_template_id) != Some(target)
1631                    });
1632            if protected || bundle_changed || target_changed {
1633                bail!(
1634                    "Setup would change configuration used by active session {:?}. Keep its profile {:?}, bundle {:?}, and target {:?}; add a separate entry for new settings, or stop the session before editing its configuration.",
1635                    session.id,
1636                    session.last_profile,
1637                    session.bundle_id,
1638                    session.target_template_id
1639                );
1640            }
1641        }
1642        Ok(())
1643    }
1644
1645    /// Strict validation for callers that need all active references intact.
1646    pub fn validate_against_config(&self, config: &Config) -> Result<()> {
1647        self.validate()?;
1648        config.validate()?;
1649        for session in self.sessions.values() {
1650            session.validate_configuration(config)?;
1651        }
1652        Ok(())
1653    }
1654}
1655
1656fn project_history_key(host: &str) -> String {
1657    format!("project:{host}")
1658}
1659
1660/// Generate an opaque, filesystem-safe stable id for a new logical session.
1661pub fn new_session_id() -> Result<String> {
1662    let mut random = [0u8; 16];
1663    getrandom::fill(&mut random)
1664        .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
1665    Ok(crate::hex::lower_hex(random))
1666}
1667
1668/// Return the newest clean ACP session title from canonical worker events.
1669pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
1670    events.iter().rev().find_map(|event| {
1671        let WorkerEvent::Adapter { payload, .. } = &event.event else {
1672            return None;
1673        };
1674        let crate::acp::RuntimeEvent::SessionUpdate { update } =
1675            serde_json::from_value(payload.clone()).ok()?
1676        else {
1677            return None;
1678        };
1679        let kind = update
1680            .get("sessionUpdate")
1681            .and_then(serde_json::Value::as_str)?;
1682        let title = match kind {
1683            "session_info_update" | "session_title" => {
1684                update.get("title").and_then(serde_json::Value::as_str)
1685            }
1686            _ => None,
1687        }?;
1688        normalize_session_title(title)
1689    })
1690}
1691
1692pub fn normalize_session_title(title: &str) -> Option<String> {
1693    let normalized = crate::relay::strip_hidden_prompt_context(title)
1694        .split_whitespace()
1695        .collect::<Vec<_>>()
1696        .join(" ");
1697    (!normalized.is_empty()).then_some(normalized)
1698}
1699
1700/// Build the short-lived title shown before the harness supplies its own.
1701///
1702/// The first visible user prompt is immediately useful for identifying a
1703/// session, but it can be arbitrarily large. Keep this fallback bounded; a
1704/// later ACP session-info update remains authoritative and replaces it.
1705pub fn provisional_session_title(prompt: &str) -> Option<String> {
1706    const MAX_TITLE_CHARS: usize = 64;
1707
1708    let normalized = normalize_session_title(prompt)?;
1709    if normalized.chars().count() <= MAX_TITLE_CHARS {
1710        return Some(normalized);
1711    }
1712
1713    let mut truncated = normalized
1714        .chars()
1715        .take(MAX_TITLE_CHARS - 1)
1716        .collect::<String>();
1717    if let Some(boundary) = truncated.rfind(char::is_whitespace) {
1718        truncated.truncate(boundary);
1719    }
1720    truncated.push('…');
1721    Some(truncated)
1722}
1723
1724pub fn short_id(id: &str) -> &str {
1725    id.get(..8).unwrap_or(id)
1726}
1727
1728#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1729pub struct RecoveryCandidate {
1730    pub session_id: String,
1731    pub target_template_id: String,
1732    pub locator: TargetLocator,
1733    pub ownership: Option<crate::worker_launch::WorkerOwnership>,
1734    /// Instance that created the worker, from its label or tag, else from
1735    /// the ownership marker. `None` means an older build left no stamp.
1736    #[serde(default)]
1737    pub instance_id: Option<String>,
1738    /// State of the session this resource is labelled for, when the
1739    /// controller still tracks that session. A leftover resource the session
1740    /// record no longer names can only be destroyed, never adopted, because
1741    /// the session id is already taken.
1742    #[serde(default, skip_serializing_if = "Option::is_none")]
1743    pub tracked_session: Option<SessionState>,
1744}
1745
1746#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1747pub struct RecoveryScan {
1748    pub candidates: Vec<RecoveryCandidate>,
1749    pub warnings: Vec<String>,
1750    /// Identity of the instance that ran the scan.
1751    #[serde(default)]
1752    pub instance_id: String,
1753    /// Candidates left out because another or an unknown instance created
1754    /// them and the scan was not widened to all instances.
1755    #[serde(default)]
1756    pub hidden_other_instances: usize,
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1760#[serde(deny_unknown_fields)]
1761pub struct ResumeRepositorySourceReceipt {
1762    pub session_id: String,
1763    pub bundle_id: String,
1764    pub checkpoint_sha256: String,
1765    pub repositories: Vec<crate::config::ProjectRepository>,
1766}
1767
1768#[cfg(test)]
1769mod tests;