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::fs;
5use std::path::{Component, Path, PathBuf};
6use std::sync::Arc;
7
8use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10
11use crate::config::{
12    Config, HarnessKind, ProjectRepository, TargetTemplate, atomic_write, data_dir, validate_id,
13};
14use crate::credentials::CredentialSyncSignal;
15use crate::relay::{
16    RELAY_EVENT_GENESIS_DIGEST, RelayOperationalState, SequencedEvent, WorkerEvent,
17};
18use crate::subagent::SubagentRecord;
19use crate::targets::{AdditionalMount, validate_additional_mounts};
20
21pub const STATE_VERSION: u32 = 1;
22
23mod session_move;
24pub use session_move::*;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum SessionState {
29    Provisioning,
30    Running,
31    Disconnected,
32    Checkpointing,
33    Closing,
34    Destroying,
35    /// Checkpointed and torn down. Persisted as `"archived"` before the verb
36    /// was renamed, so the alias keeps those records loading.
37    #[serde(alias = "archived")]
38    Stopped,
39    Lost,
40    Error,
41    DestroyedWithDataLoss,
42}
43
44/// A lifecycle transition temporarily replaces the conversation in control surfaces.
45/// Operation ownership takes precedence over intermediate durable session states.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum SessionTransitionKind {
49    Starting,
50    Resuming,
51    Moving,
52    Stopping,
53    Destroying,
54}
55
56impl SessionTransitionKind {
57    pub const fn label(self) -> &'static str {
58        match self {
59            Self::Starting => "Starting",
60            Self::Resuming => "Resuming",
61            Self::Moving => "Moving",
62            Self::Stopping => "Stopping",
63            Self::Destroying => "Destroying",
64        }
65    }
66
67    pub fn for_session(state: SessionState, operation: Option<Self>) -> Option<Self> {
68        operation.or_else(|| state.transition_kind())
69    }
70}
71
72#[cfg(test)]
73mod transition_tests {
74    use super::{SessionState, SessionTransitionKind};
75
76    #[test]
77    fn operation_ownership_hides_intermediate_move_states_but_not_ordinary_live_work() {
78        for state in [
79            SessionState::Stopped,
80            SessionState::Running,
81            SessionState::Disconnected,
82        ] {
83            assert_eq!(
84                SessionTransitionKind::for_session(state, Some(SessionTransitionKind::Moving)),
85                Some(SessionTransitionKind::Moving)
86            );
87            assert_eq!(SessionTransitionKind::for_session(state, None), None);
88        }
89        assert_eq!(SessionState::Checkpointing.transition_kind(), None);
90        assert_eq!(
91            SessionState::Closing.transition_kind(),
92            Some(SessionTransitionKind::Stopping)
93        );
94    }
95}
96
97/// Controller-owned execution state derived from the relay event stream.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(tag = "state", rename_all = "snake_case")]
100pub enum MaterializedExecutionState {
101    #[default]
102    Idle,
103    Running {
104        started_at_ms: i64,
105    },
106    Closing,
107    Closed,
108}
109
110pub use crate::transcript::{TerminalOutputRecord, TranscriptBody, TranscriptItem};
111
112/// What a durable queue entry does when its turn comes.
113///
114/// Serialized without a tag for prompts so entries written before configuration
115/// changes could be queued keep loading unchanged.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum QueuedCommandKind {
119    #[default]
120    Prompt,
121    SetConfig {
122        key: String,
123        value: String,
124    },
125}
126
127impl QueuedCommandKind {
128    pub fn is_prompt(&self) -> bool {
129        matches!(self, Self::Prompt)
130    }
131}
132
133/// The composer form of a configuration change, used both as the queue entry's
134/// display text and as the text peeled back into the composer for editing.
135pub fn config_command_text(key: &str, value: &str) -> String {
136    if key == "fast-mode" {
137        "/fast".to_owned()
138    } else {
139        format!("/{key} {value}")
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct MaterializedQueuedPrompt {
146    pub command_id: String,
147    #[serde(default, skip_serializing_if = "QueuedCommandKind::is_prompt")]
148    pub kind: QueuedCommandKind,
149    pub content: Vec<serde_json::Value>,
150    pub queued_at_ms: i64,
151    /// Relay acceptance ordinal of the `CommandQueued` event that created this
152    /// entry. It is the turn identity the API hands back to callers, so wait
153    /// can tell one queued prompt's outcome from another's.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub accepted_ordinal: Option<u64>,
156}
157
158/// The prompt currently executing, recorded when its `CommandStarted` event is
159/// projected and cleared when the command completes.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct MaterializedTurn {
163    pub command_id: String,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub accepted_ordinal: Option<u64>,
166    /// Ordinal of the `CommandStarted` event, which is also the transcript
167    /// position of the turn's first item.
168    pub turn_start_position: u64,
169    pub started_at_ms: i64,
170}
171
172/// How a prompt ended.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum TurnOutcomeKind {
176    /// The harness finished the turn and reported this stop reason.
177    Completed { stop_reason: String },
178    /// The relay refused the command before it ran.
179    Rejected { message: String },
180    /// The command was interrupted after being accepted.
181    Interrupted { message: String },
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum PromptCompletion {
186    Finished,
187    Cancelled,
188    QuotaLimit,
189    Error,
190}
191
192/// Shared interpretation for wait responses and durable completion events.
193pub fn classify_prompt_completion(stop_reason: &str) -> PromptCompletion {
194    let normalized = stop_reason
195        .chars()
196        .filter(|character| *character != '_' && *character != '-')
197        .flat_map(char::to_lowercase)
198        .collect::<String>();
199    match normalized.as_str() {
200        "endturn" => PromptCompletion::Finished,
201        "cancelled" | "canceled" => PromptCompletion::Cancelled,
202        "quotalimit" => PromptCompletion::QuotaLimit,
203        _ if crate::relay::is_capacity_stop_reason(stop_reason) => PromptCompletion::QuotaLimit,
204        _ => PromptCompletion::Error,
205    }
206}
207
208/// The most recent finished prompt on a session.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct MaterializedTurnOutcome {
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
214
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub usage: Option<crate::usage::TokenUsage>,
217    pub command_id: String,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub accepted_ordinal: Option<u64>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub turn_start_position: Option<u64>,
222    pub completed_ordinal: u64,
223    pub completed_at_ms: i64,
224    pub outcome: TurnOutcomeKind,
225}
226
227/// Canonical controller projection for one logical ACP session.
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct MaterializedSession {
231    pub session_id: String,
232    pub applied_event_ordinal: u64,
233    pub applied_event_digest: String,
234    /// Monotonic controller projection watermark derived from relay event
235    /// receipt times. It is deliberately independent of retained rows.
236    pub last_activity_at_ms: Option<i64>,
237    pub execution: MaterializedExecutionState,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub session_title: Option<String>,
240    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241    pub configuration: BTreeMap<String, serde_json::Value>,
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    /// Transcript items are shared by pointer so cloning a snapshot copies
244    /// handles rather than the whole conversation.
245    pub transcript: Vec<Arc<TranscriptItem>>,
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub queued_prompts: Vec<MaterializedQueuedPrompt>,
248    /// In-flight form requests are projected durably, but their answers are
249    /// connection-only and never enter this state.
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
252    /// The prompt running right now, if any.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub active_turn: Option<MaterializedTurn>,
255    /// The most recently finished prompt, kept after the session stops so a
256    /// caller can still read how the last turn ended.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
259}
260
261/// The small portion of a durable projection needed to populate dashboard
262/// rows before the live session delivers its full transcript snapshot.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct MaterializedSessionSummary {
265    pub session_id: String,
266    pub applied_event_ordinal: u64,
267    pub last_activity_at_ms: Option<i64>,
268    pub execution: MaterializedExecutionState,
269    pub session_title: Option<String>,
270    pub last_agent_message: Option<String>,
271    pub last_user_message: Option<String>,
272    /// Whether the last nonempty agent message appears after the last
273    /// nonempty user message in transcript order.
274    pub last_agent_message_follows_last_user: bool,
275    pub agent_message_latest_content_ordinals: Vec<u64>,
276    pub session_restart_event_ordinals: Vec<u64>,
277}
278
279impl MaterializedSession {
280    pub fn empty(session_id: impl Into<String>) -> Self {
281        Self {
282            session_id: session_id.into(),
283            applied_event_ordinal: 0,
284            applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
285            last_activity_at_ms: None,
286            execution: MaterializedExecutionState::Idle,
287            session_title: None,
288            configuration: BTreeMap::new(),
289            transcript: Vec::new(),
290            queued_prompts: Vec::new(),
291            pending_elicitations: Vec::new(),
292            active_turn: None,
293            last_turn_outcome: None,
294        }
295    }
296
297    pub fn last_activity_at_ms(&self) -> Option<i64> {
298        self.last_activity_at_ms
299    }
300
301    /// Resolve the title exposed by a live materialized session.
302    ///
303    /// Sessions created before provisional titles were projected can still
304    /// have an untitled transcript. Derive the same bounded fallback from
305    /// their first visible user prompt when reading them.
306    pub fn resolved_title(&self) -> Option<String> {
307        self.session_title
308            .as_deref()
309            .and_then(normalize_session_title)
310            .or_else(|| {
311                self.transcript.iter().find_map(|item| {
312                    let TranscriptBody::User { content } = &item.body else {
313                        return None;
314                    };
315                    provisional_session_title(&crate::transcript::materialized_content_text(
316                        content,
317                    ))
318                })
319            })
320            .or_else(|| {
321                self.queued_prompts
322                    .iter()
323                    .filter(|prompt| prompt.kind.is_prompt())
324                    .find_map(|prompt| {
325                        provisional_session_title(&crate::transcript::materialized_content_text(
326                            &prompt.content,
327                        ))
328                    })
329            })
330    }
331
332    pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
333        self.transcript
334            .iter()
335            .filter(|item| {
336                item.latest_content_event_ordinal
337                    .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
338                    && item.is_nonempty_agent_message()
339            })
340            .count() as u64
341    }
342
343    pub fn unread_session_restarts_after(&self, viewed_through_event_ordinal: u64) -> u64 {
344        self.transcript
345            .iter()
346            .filter(|item| {
347                item.position > viewed_through_event_ordinal && item.is_session_restart()
348            })
349            .count() as u64
350    }
351
352    pub fn validate(&self) -> Result<()> {
353        validate_id("session", &self.session_id)?;
354        validate_relay_event_frontier(
355            self.applied_event_ordinal,
356            &self.applied_event_digest,
357            "materialized session event frontier",
358        )?;
359        if self
360            .session_title
361            .as_ref()
362            .is_some_and(|title| title.trim().is_empty())
363        {
364            bail!("materialized session has an empty title");
365        }
366        let mut item_ids = BTreeSet::new();
367        for item in &self.transcript {
368            item.validate(self.applied_event_ordinal)?;
369            if !item_ids.insert(item.stable_id.as_str()) {
370                bail!(
371                    "materialized transcript contains duplicate item {:?}",
372                    item.stable_id
373                );
374            }
375        }
376        let mut command_ids = BTreeSet::new();
377        for prompt in &self.queued_prompts {
378            if prompt.command_id.trim().is_empty() {
379                bail!("materialized prompt queue has an empty command id");
380            }
381            if !command_ids.insert(prompt.command_id.as_str()) {
382                bail!(
383                    "materialized prompt queue contains duplicate command {:?}",
384                    prompt.command_id
385                );
386            }
387            if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
388                && (key.trim().is_empty() || value.trim().is_empty())
389            {
390                bail!(
391                    "materialized queued configuration change {:?} is incomplete",
392                    prompt.command_id
393                );
394            }
395        }
396        Ok(())
397    }
398}
399
400/// A materialized session paired with the live worker's relay state. The
401/// session manager hands this to every reader that needs both the durable
402/// projection and the connection's operational status.
403#[derive(Debug, Clone, PartialEq)]
404pub struct ManagedSessionSnapshot {
405    pub materialized: MaterializedSession,
406    /// What `materialized.transcript` leaves out, and the facts that live
407    /// there. See [`ProjectionWindow`].
408    pub window: ProjectionWindow,
409    pub operational: RelayOperationalState,
410    /// Newest relay event observed by this live actor that asks for immediate
411    /// credential reconciliation. This is intentionally ephemeral: it avoids
412    /// retaining raw replay pages or rescanning projected history.
413    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
414    /// Content address of the executable the connected worker is running, as
415    /// it reported in hello. `None` when the connection did not come from a
416    /// live worker or the worker predates the field; either way the worker is
417    /// not known to be the build this controller would install.
418    pub worker_build: Option<String>,
419    /// Pending parent-tool work fetched from the target worker.
420    pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
421    /// Recently completed tool work cached by the worker for idempotent calls.
422    pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
423}
424
425/// What a projection's transcript window leaves out.
426///
427/// A polled projection carries only the end of the transcript, because that is
428/// all any viewer shows and loading the rest is work proportional to history.
429/// Two facts a reader needs live outside that window: the provisional title
430/// comes from the *first* user message, and the newest turn start is outside
431/// it whenever a single turn is longer than the window. Both are read
432/// separately, with one indexed query each, rather than found by scanning.
433///
434/// A complete projection answers both by scanning what it already holds, which
435/// is what [`ProjectionWindow::of`] does.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct ProjectionWindow {
438    /// Transcript items before the window. Zero when the projection is whole.
439    pub omitted_items: usize,
440    /// The title derived from the first user message.
441    pub provisional_title: Option<String>,
442    /// Position of the newest turn start — a user message or the marker for a
443    /// turn the harness began on its own — whether or not it is in the
444    /// window. `None` when the session has none.
445    pub latest_turn_start_position: Option<u64>,
446}
447
448impl ProjectionWindow {
449    /// The window of a projection that omits nothing.
450    #[must_use]
451    pub fn of(session: &MaterializedSession) -> Self {
452        Self {
453            omitted_items: 0,
454            provisional_title: session.transcript.iter().find_map(|item| {
455                let TranscriptBody::User { content } = &item.body else {
456                    return None;
457                };
458                provisional_session_title(&crate::transcript::materialized_content_text(content))
459            }),
460            latest_turn_start_position: session
461                .transcript
462                .iter()
463                .rev()
464                .find(|item| item.is_turn_start())
465                .map(|item| item.position),
466        }
467    }
468}
469
470impl ManagedSessionSnapshot {
471    /// The session's title, using the same precedence as
472    /// [`MaterializedSession::resolved_title`] but taking the provisional
473    /// title from the window rather than from a transcript head that a polled
474    /// projection does not carry.
475    #[must_use]
476    pub fn resolved_title(&self) -> Option<String> {
477        self.materialized
478            .session_title
479            .as_deref()
480            .and_then(normalize_session_title)
481            .or_else(|| self.window.provisional_title.clone())
482            .or_else(|| {
483                self.materialized
484                    .queued_prompts
485                    .iter()
486                    .filter(|prompt| prompt.kind.is_prompt())
487                    .find_map(|prompt| {
488                        provisional_session_title(&crate::transcript::materialized_content_text(
489                            &prompt.content,
490                        ))
491                    })
492            })
493    }
494
495    /// The position of the turn this session most recently finished, or `None`
496    /// while it is still working. Same answer as
497    /// [`latest_completed_turn_ordinal`], from a position the window carries
498    /// rather than a scan back through the transcript.
499    #[must_use]
500    pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
501        if self.materialized.execution != MaterializedExecutionState::Idle {
502            return None;
503        }
504        self.window.latest_turn_start_position
505    }
506}
507
508/// One session's activity, reported to the recovery coordinator.
509#[derive(Debug, Clone)]
510pub struct RecoveryObservation {
511    pub session: SessionRecord,
512    pub config: Config,
513    pub latest_completed_turn_ordinal: Option<u64>,
514    pub execution: MaterializedExecutionState,
515    /// Whether live provider-owned work permits an automatic checkpoint now.
516    /// This is separate from materialized execution because Kimi detached
517    /// agents outlive the parent turn that returned the session to `Idle`.
518    pub checkpoint_safe: bool,
519}
520
521/// The position where the session's most recent finished turn began, or
522/// `None` while it is still working. A turn starts at a user message or at the
523/// marker for a turn the harness began on its own, so autonomous work is
524/// covered once it settles.
525pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
526    if session.execution != MaterializedExecutionState::Idle {
527        return None;
528    }
529    session
530        .transcript
531        .iter()
532        .rev()
533        .find(|item| item.is_turn_start())
534        .map(|item| item.position)
535}
536
537pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
538    if digest.len() != 64
539        || !digest
540            .bytes()
541            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
542    {
543        bail!("{name} must be a lowercase SHA-256 digest");
544    }
545    Ok(())
546}
547
548pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
549    validate_relay_event_digest(digest, name)?;
550    if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
551        bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
552    }
553    Ok(())
554}
555
556fn is_false(value: &bool) -> bool {
557    !*value
558}
559
560impl SessionState {
561    /// Recovery without a live operation still hides an unfinished target transition.
562    /// Ordinary checkpoints and reconnects deliberately keep their conversation visible.
563    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
564        match self {
565            Self::Provisioning => Some(SessionTransitionKind::Starting),
566            Self::Closing => Some(SessionTransitionKind::Stopping),
567            Self::Destroying => Some(SessionTransitionKind::Destroying),
568            _ => None,
569        }
570    }
571
572    /// True while the session still belongs on the dashboard. `Closing` and
573    /// `Checkpointing` stay active on purpose: a stop that has not produced a
574    /// verified checkpoint must not make its row disappear.
575    pub const fn is_active(self) -> bool {
576        matches!(
577            self,
578            Self::Provisioning
579                | Self::Running
580                | Self::Disconnected
581                | Self::Checkpointing
582                | Self::Closing
583                | Self::Destroying
584                | Self::Error
585        )
586    }
587}
588
589#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(tag = "kind", rename_all = "kebab-case")]
591pub enum PodmanWorkspaceLocator {
592    #[default]
593    ContainerLayer,
594    Volume {
595        name: String,
596    },
597    HostPath {
598        path: PathBuf,
599        helper: Vec<String>,
600        resource: String,
601    },
602}
603
604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
605#[serde(tag = "kind", rename_all = "kebab-case")]
606pub enum TargetLocator {
607    LocalBare {
608        worker_root: PathBuf,
609    },
610    LocalPodman {
611        container_id: String,
612        #[serde(default)]
613        workspace_storage: PodmanWorkspaceLocator,
614    },
615    LocalDocker {
616        container_id: String,
617    },
618    AppleContainer {
619        container_id: String,
620    },
621    AwsEc2 {
622        instance_id: String,
623        #[serde(default, skip_serializing_if = "Option::is_none")]
624        address: Option<String>,
625    },
626    SshBare {
627        host: String,
628        workspace: PathBuf,
629        #[serde(default, skip_serializing_if = "Option::is_none")]
630        worker_id: Option<String>,
631    },
632    SshPodman {
633        host: String,
634        container_id: String,
635        #[serde(default)]
636        workspace_storage: PodmanWorkspaceLocator,
637    },
638    SshDocker {
639        host: String,
640        container_id: String,
641    },
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(tag = "kind", rename_all = "kebab-case")]
646pub enum ManagedWorktreeTarget {
647    Local,
648    Ssh {
649        destination: String,
650        #[serde(default, skip_serializing_if = "Vec::is_empty")]
651        ssh_args: Vec<String>,
652    },
653}
654
655/// Whether a selected project can create a session-owned Git checkout.
656#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
657#[serde(deny_unknown_fields)]
658pub struct ManagedWorktreeOptions {
659    pub available: bool,
660    pub default_create: bool,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(deny_unknown_fields)]
665pub struct ManagedWorktree {
666    pub source_project_directory: PathBuf,
667    pub source_repository: PathBuf,
668    pub worktree_root: PathBuf,
669    pub branch: String,
670    pub target: ManagedWorktreeTarget,
671    /// The commit the session branch was created at. Recorded so an export can
672    /// diff against it in one read; sessions created before this field existed
673    /// fall back to the branch reflog, which expires.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub base_commit: Option<String>,
676}
677
678impl ManagedWorktree {
679    fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
680        for (label, path) in [
681            ("source project directory", &self.source_project_directory),
682            ("source repository", &self.source_repository),
683            ("worktree root", &self.worktree_root),
684        ] {
685            if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
686                bail!("managed worktree {label} must be an absolute safe path");
687            }
688        }
689        if !self
690            .source_project_directory
691            .starts_with(&self.source_repository)
692        {
693            bail!("managed worktree source directory is outside its repository");
694        }
695        let expected_root = self
696            .source_repository
697            .join(".mj")
698            .join("worktrees")
699            .join(session_id);
700        if self.worktree_root != expected_root {
701            bail!("managed worktree root does not match the session-owned path");
702        }
703        if self.branch != format!("mj/{session_id}") {
704            bail!("managed worktree branch does not match the session id");
705        }
706        let relative = self
707            .source_project_directory
708            .strip_prefix(&self.source_repository)
709            .expect("source relationship checked above");
710        if project_directory != Some(self.worktree_root.join(relative).as_path()) {
711            bail!("session project directory does not match its managed worktree");
712        }
713        match &self.target {
714            ManagedWorktreeTarget::Local => {}
715            ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
716                bail!("managed SSH worktree has an empty destination")
717            }
718            ManagedWorktreeTarget::Ssh { .. } => {}
719        }
720        Ok(())
721    }
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
725#[serde(tag = "kind", rename_all = "kebab-case")]
726pub enum SessionResourceAllocation {
727    Container {
728        cpus: u64,
729        memory_bytes: u64,
730    },
731    AwsEc2 {
732        instance_type: String,
733        vcpus: u64,
734        memory_bytes: u64,
735    },
736}
737
738impl SessionResourceAllocation {
739    pub fn validate(&self) -> Result<()> {
740        match self {
741            Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
742                bail!("container resource allocation must have non-zero CPU and memory")
743            }
744            Self::AwsEc2 {
745                instance_type,
746                vcpus,
747                memory_bytes,
748            } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
749                bail!("EC2 resource allocation must have an instance type, CPU, and memory")
750            }
751            _ => Ok(()),
752        }
753    }
754}
755
756/// The CPU count an allocation grants, regardless of target kind.
757pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
758    match allocation {
759        SessionResourceAllocation::Container { cpus, .. } => *cpus,
760        SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
761    }
762}
763
764/// The memory, in bytes, an allocation grants, regardless of target kind.
765pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
766    match allocation {
767        SessionResourceAllocation::Container { memory_bytes, .. }
768        | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
769    }
770}
771
772impl TargetLocator {
773    fn validate(&self, session_id: &str) -> Result<()> {
774        match self {
775            Self::LocalBare { worker_root } => {
776                if !worker_root.is_absolute()
777                    || worker_root
778                        .components()
779                        .any(|part| part == Component::ParentDir)
780                    || !worker_root.ends_with(session_id)
781                {
782                    bail!(
783                        "local bare worker root must be an absolute safe path ending in the session id"
784                    );
785                }
786            }
787            Self::LocalPodman { container_id, .. }
788            | Self::LocalDocker { container_id }
789            | Self::AppleContainer { container_id }
790            | Self::SshPodman { container_id, .. }
791            | Self::SshDocker { container_id, .. }
792                if container_id.trim().is_empty() =>
793            {
794                bail!("target locator has an empty container id")
795            }
796            Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
797                bail!("target locator has an empty AWS instance id")
798            }
799            Self::SshBare {
800                host, workspace, ..
801            } => {
802                if host.trim().is_empty() {
803                    bail!("bare SSH target locator has an empty host");
804                }
805                if workspace.as_os_str().is_empty()
806                    || workspace
807                        .components()
808                        .any(|part| part == Component::ParentDir)
809                    || !workspace.ends_with(session_id)
810                {
811                    bail!("bare SSH target locator must be a safe path ending in the session id");
812                }
813            }
814            Self::SshPodman { host, .. } if host.trim().is_empty() => {
815                bail!("SSH Podman target locator has an empty host")
816            }
817            Self::SshDocker { host, .. } if host.trim().is_empty() => {
818                bail!("SSH Docker target locator has an empty host")
819            }
820            _ => {}
821        }
822        Ok(())
823    }
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(deny_unknown_fields)]
828pub struct CheckpointMetadata {
829    pub archive_path: PathBuf,
830    /// Lowercase SHA-256 digest of the verified archive.
831    pub sha256: String,
832    pub created_at: String,
833    pub event_frontier: u64,
834}
835
836impl CheckpointMetadata {
837    fn validate(&self) -> Result<()> {
838        if self.archive_path.as_os_str().is_empty() {
839            bail!("checkpoint archive path is empty");
840        }
841        if self.sha256.len() != 64
842            || !self
843                .sha256
844                .bytes()
845                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
846        {
847            bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
848        }
849        if self.created_at.trim().is_empty() {
850            bail!("checkpoint timestamp is empty");
851        }
852        Ok(())
853    }
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(deny_unknown_fields)]
858pub struct SessionRecord {
859    pub id: String,
860    /// Owning workspace while active, or the most recent workspace while inactive.
861    ///
862    /// Inactive histories are globally resumable, so this id may refer to a
863    /// workspace that has since been deleted.
864    #[serde(default = "default_session_workspace_id")]
865    pub workspace_id: String,
866    pub title: String,
867    pub harness_kind: HarnessKind,
868    pub last_profile: String,
869    pub bundle_id: String,
870    /// Existing project directory used directly by a local or SSH bare target.
871    #[serde(default, skip_serializing_if = "Option::is_none")]
872    pub project_directory: Option<PathBuf>,
873    /// Git worktree created and owned by Hel for this raw-project session.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub managed_worktree: Option<ManagedWorktree>,
876    /// None preserves automatic selection; false uses the selected directory.
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub create_managed_worktree: Option<bool>,
879    /// None follows the global `[subagents] enabled` setting at launch time;
880    /// Some(true) and Some(false) are explicit per-session choices.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub mjolnir_subagents: Option<bool>,
883    pub target_template_id: String,
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub resource_allocation: Option<SessionResourceAllocation>,
886    #[serde(default, skip_serializing_if = "Vec::is_empty")]
887    pub additional_mounts: Vec<AdditionalMount>,
888    /// Per-session container CPU limit that overrides the target template's
889    /// value. It is applied the next time the container is created.
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub container_cpus: Option<String>,
892    /// Per-session container memory limit that overrides the target
893    /// template's value. It is applied the next time the container is created.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub container_memory: Option<String>,
896    pub state: SessionState,
897    /// Legacy visibility preference, retained for record compatibility.
898    /// Current surfaces do not hide sessions based on this flag.
899    #[serde(default, skip_serializing_if = "is_false")]
900    pub archived: bool,
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub target: Option<TargetLocator>,
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub native_session_id: Option<String>,
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub acp_session_title: Option<String>,
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub session_title_override: Option<String>,
909    pub created_at: String,
910    pub updated_at: String,
911    #[serde(default, alias = "detached_after_event_ordinal")]
912    pub viewed_through_event_ordinal: u64,
913    /// Unsent chat input carried across a detach, so returning to a session
914    /// restores what the user was typing. Empty means no draft.
915    #[serde(default, skip_serializing_if = "String::is_empty")]
916    pub draft_input: String,
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub last_error: Option<String>,
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub last_checkpoint_error: Option<String>,
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub checkpoint: Option<CheckpointMetadata>,
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926#[serde(deny_unknown_fields)]
927pub struct HostContainerSize {
928    pub cpus: u64,
929    pub memory_bytes: u64,
930}
931
932fn default_session_workspace_id() -> String {
933    crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
934}
935
936impl SessionRecord {
937    /// Configuration drift belongs to this session, not the entire controller.
938    /// The diagnostic contains only public identifiers, so both UIs can show it.
939    pub fn configuration_issue(&self, config: &Config) -> Option<String> {
940        if !self.state.is_active() {
941            return None;
942        }
943        let mut issues = Vec::new();
944        match config.profiles.get(&self.last_profile) {
945            None => issues.push(format!("missing profile {:?}", self.last_profile)),
946            Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
947                "expects {:?}, but profile {:?} is {:?}",
948                self.harness_kind, self.last_profile, profile.kind
949            )),
950            Some(_) => {}
951        }
952        if self.project_directory.is_none() && !config.bundles.contains_key(&self.bundle_id) {
953            issues.push(format!("missing bundle {:?}", self.bundle_id));
954        }
955        if !config.targets.contains_key(&self.target_template_id) {
956            issues.push(format!(
957                "missing target template {:?}",
958                self.target_template_id
959            ));
960        }
961        (!issues.is_empty()).then(|| format!(
962            "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.",
963            self.id, issues.join("; ")
964        ))
965    }
966
967    pub fn validate_configuration(&self, config: &Config) -> Result<()> {
968        if let Some(issue) = self.configuration_issue(config) {
969            bail!("{issue}");
970        }
971        Ok(())
972    }
973
974    /// User-visible session name, independent of the initial prompt stored in `title`.
975    pub fn display_title(&self) -> &str {
976        self.session_title_override
977            .as_deref()
978            .or(self.acp_session_title.as_deref())
979            .unwrap_or(&self.id)
980    }
981
982    /// Project this session works in, as the session list and the chat header
983    /// both name it: the source repository of a managed worktree, else the
984    /// project directory, else the bundle's primary repository, else the
985    /// bundle id.
986    pub fn project_name(&self, config: &Config) -> String {
987        if let Some(worktree) = &self.managed_worktree {
988            return path_leaf(&worktree.source_repository);
989        }
990        if let Some(project_directory) = &self.project_directory {
991            return path_leaf(project_directory);
992        }
993        self.bundle_source_name(config)
994    }
995
996    /// Target label used by the live session summary. Bare targets identify
997    /// the project directory they open directly; workspace targets already
998    /// identify the provisioned environment on their own.
999    pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1000        if !matches!(
1001            config.targets.get(target_id),
1002            Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1003        ) {
1004            return target_id.to_owned();
1005        }
1006        self.managed_worktree
1007            .as_ref()
1008            .map(|worktree| &worktree.source_project_directory)
1009            .or(self.project_directory.as_ref())
1010            .and_then(|path| path.file_name())
1011            .map_or_else(
1012                || target_id.to_owned(),
1013                |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1014            )
1015    }
1016
1017    /// Stable source identity used to group sessions. Managed worktrees point
1018    /// back at their source repository, raw sessions use their project
1019    /// directory until their Git origin is resolved, and bundle sessions use
1020    /// their complete canonical repository set when configured.
1021    pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1022        if let Some(worktree) = &self.managed_worktree {
1023            return ProjectSourceIdentity::path(&worktree.source_repository, None);
1024        }
1025        if let Some(project_directory) = &self.project_directory {
1026            let remote = match &self.target {
1027                Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1028                _ => None,
1029            };
1030            return ProjectSourceIdentity::path(project_directory, remote);
1031        }
1032        self.bundle_source_identity(config)
1033            .unwrap_or_else(|| ProjectSourceIdentity {
1034                key: format!("bundle:{}", self.bundle_id),
1035                short: path_leaf(Path::new(&self.bundle_id)),
1036                full: self.bundle_id.clone(),
1037            })
1038    }
1039
1040    /// Resolve the display name shared by session headings, chat headers, and
1041    /// resume details for a bundle-backed session.
1042    fn bundle_source_name(&self, config: &Config) -> String {
1043        self.bundle_source_identity(config)
1044            .map(|source| source.short)
1045            .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1046    }
1047
1048    /// Resolve the canonical identity of every repository in a bundle for
1049    /// grouping and display naming.
1050    fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1051        let bundle = config.bundles.get(&self.bundle_id)?;
1052        let sources = bundle
1053            .repositories
1054            .iter()
1055            .map(repository_source_identity)
1056            .collect::<Option<Vec<_>>>()?;
1057        ProjectSourceIdentity::bundle(sources)
1058    }
1059
1060    /// Orders two sessions the way the session list's sequence view does:
1061    /// oldest first by creation time, with the id as a stable tiebreak. A
1062    /// session whose timestamp does not parse sorts last.
1063    pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1064        self.creation_order_key().cmp(&other.creation_order_key())
1065    }
1066
1067    /// Parse once per session when used with `sort_by_cached_key`.
1068    pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1069        let timestamp = created_at_seconds(&self.created_at);
1070        (timestamp.is_none(), timestamp, &self.id)
1071    }
1072
1073    fn validate(&self, map_id: &str) -> Result<()> {
1074        validate_id("session", &self.id)?;
1075        if self.id != map_id {
1076            bail!(
1077                "session map key {map_id:?} does not match record id {:?}",
1078                self.id
1079            );
1080        }
1081        validate_id("workspace", &self.workspace_id)?;
1082        validate_id("profile", &self.last_profile)?;
1083        validate_id("bundle", &self.bundle_id)?;
1084        if let Some(project_directory) = &self.project_directory
1085            && (!project_directory.is_absolute()
1086                || project_directory
1087                    .components()
1088                    .any(|part| part == Component::ParentDir))
1089        {
1090            bail!("session {:?} has an unsafe project directory", self.id);
1091        }
1092        if let Some(managed_worktree) = &self.managed_worktree {
1093            managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1094        }
1095        validate_id("target template", &self.target_template_id)?;
1096        if let Some(allocation) = &self.resource_allocation {
1097            allocation.validate()?;
1098        }
1099        validate_additional_mounts(&self.additional_mounts)?;
1100        if self.title.trim().is_empty() {
1101            bail!("session {:?} has an empty title", self.id);
1102        }
1103        if self
1104            .acp_session_title
1105            .as_ref()
1106            .is_some_and(|title| title.trim().is_empty())
1107            || self
1108                .session_title_override
1109                .as_ref()
1110                .is_some_and(|title| title.trim().is_empty())
1111        {
1112            bail!("session {:?} has an empty display title", self.id);
1113        }
1114        if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1115            bail!("session {:?} has an empty timestamp", self.id);
1116        }
1117        if let Some(target) = &self.target {
1118            target.validate(&self.id)?;
1119        }
1120        if let Some(checkpoint) = &self.checkpoint {
1121            checkpoint.validate()?;
1122        }
1123        Ok(())
1124    }
1125}
1126
1127fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1128    repository
1129        .github
1130        .as_deref()
1131        .and_then(ProjectSourceIdentity::git_remote)
1132        .or_else(|| {
1133            repository
1134                .local
1135                .as_deref()
1136                .map(|path| ProjectSourceIdentity::path(path, None))
1137        })
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1141pub struct ProjectSourceIdentity {
1142    pub key: String,
1143    pub short: String,
1144    pub full: String,
1145}
1146
1147impl ProjectSourceIdentity {
1148    /// Combine repository identities into one stable bundle identity.
1149    pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1150        if sources.is_empty() {
1151            return None;
1152        }
1153        sources.sort_by(|left, right| {
1154            left.key
1155                .cmp(&right.key)
1156                .then_with(|| left.full.cmp(&right.full))
1157                .then_with(|| left.short.cmp(&right.short))
1158        });
1159        sources.dedup_by(|left, right| left.key == right.key);
1160        if sources.len() == 1 {
1161            return sources.pop();
1162        }
1163        let keys = sources
1164            .iter()
1165            .map(|source| source.key.clone())
1166            .collect::<Vec<_>>();
1167        let key = serde_json::to_string(&keys).ok()?;
1168        Some(Self {
1169            key: format!("bundle:{key}"),
1170            short: sources
1171                .iter()
1172                .map(|source| source.short.as_str())
1173                .collect::<Vec<_>>()
1174                .join(" + "),
1175            full: sources
1176                .iter()
1177                .map(|source| source.full.as_str())
1178                .collect::<Vec<_>>()
1179                .join(" + "),
1180        })
1181    }
1182
1183    /// Canonicalizes a Git remote so raw checkouts group as the same project
1184    /// even when their worktree paths differ.
1185    pub fn git_remote(source: &str) -> Option<Self> {
1186        if let Some(normalized) = normalize_github_source(source) {
1187            let short = normalized
1188                .rsplit_once('/')
1189                .map_or(normalized.as_str(), |(_, repository)| repository)
1190                .to_owned();
1191            return Some(Self {
1192                key: format!("github:{}", normalized.to_lowercase()),
1193                short,
1194                full: normalized,
1195            });
1196        }
1197        let normalized = source.trim().trim_end_matches('/').trim_end_matches(".git");
1198        if normalized.is_empty() {
1199            return None;
1200        }
1201        let short = normalized
1202            .rsplit(['/', ':'])
1203            .find(|part| !part.is_empty())
1204            .unwrap_or(normalized)
1205            .to_owned();
1206        Some(Self {
1207            key: format!("git:{}", normalized.to_lowercase()),
1208            short,
1209            full: normalized.to_owned(),
1210        })
1211    }
1212
1213    /// Build a local-root identity, qualified by host for remote directories.
1214    pub fn path(path: &Path, remote: Option<&str>) -> Self {
1215        let normalized = path.components().collect::<PathBuf>();
1216        let path_text = normalized.to_string_lossy().into_owned();
1217        let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
1218        let key = remote.map_or_else(
1219            || format!("path:{path_text}"),
1220            |host| format!("path:{}:{path_text}", host.to_lowercase()),
1221        );
1222        Self {
1223            key,
1224            short: path_leaf(path),
1225            full,
1226        }
1227    }
1228}
1229
1230fn normalize_github_source(source: &str) -> Option<String> {
1231    let source = source.trim();
1232    let path = source
1233        .strip_prefix("https://github.com/")
1234        .or_else(|| source.strip_prefix("http://github.com/"))
1235        .or_else(|| source.strip_prefix("git@github.com:"))
1236        .or_else(|| source.strip_prefix("ssh://git@github.com/"))
1237        .or_else(|| {
1238            (!source.contains("://") && !source.contains('@') && !source.contains(':'))
1239                .then_some(source)
1240        })?
1241        .trim_end_matches(".git");
1242    let mut parts = path.split('/');
1243    let owner = parts.next()?;
1244    let repository = parts.next()?;
1245    (!owner.is_empty() && !repository.is_empty() && parts.next().is_none())
1246        .then(|| format!("{owner}/{repository}"))
1247}
1248
1249/// Last component of a path, falling back to the whole path when it has none.
1250fn path_leaf(path: &Path) -> String {
1251    path.file_name()
1252        .unwrap_or(path.as_os_str())
1253        .to_string_lossy()
1254        .into_owned()
1255}
1256
1257fn created_at_seconds(timestamp: &str) -> Option<i64> {
1258    chrono::DateTime::parse_from_rfc3339(timestamp)
1259        .ok()
1260        .map(|timestamp| timestamp.timestamp())
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1264#[serde(deny_unknown_fields)]
1265pub struct State {
1266    pub version: u32,
1267    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1268    pub sessions: BTreeMap<String, SessionRecord>,
1269    /// Child sessions keyed by their session id. The relationship lives in
1270    /// controller state so every control surface sees the same session family.
1271    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1272    pub subagents: BTreeMap<String, SubagentRecord>,
1273    /// Recently used source directories, keyed by `local` or SSH host name.
1274    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1275    pub mount_history: BTreeMap<String, Vec<PathBuf>>,
1276    /// Most recently launched container size on each physical target host.
1277    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1278    pub container_sizes: BTreeMap<String, HostContainerSize>,
1279}
1280
1281impl Default for State {
1282    fn default() -> Self {
1283        Self {
1284            version: STATE_VERSION,
1285            sessions: BTreeMap::new(),
1286            subagents: BTreeMap::new(),
1287            mount_history: BTreeMap::new(),
1288            container_sizes: BTreeMap::new(),
1289        }
1290    }
1291}
1292
1293impl State {
1294    pub fn validate(&self) -> Result<()> {
1295        if self.version != STATE_VERSION {
1296            bail!(
1297                "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
1298                self.version
1299            );
1300        }
1301        for (id, session) in &self.sessions {
1302            session.validate(id)?;
1303        }
1304        for (child_id, subagent) in &self.subagents {
1305            if child_id != &subagent.child_session_id {
1306                bail!("sub-agent key {child_id:?} does not match its child session id");
1307            }
1308            if child_id == &subagent.parent_session_id {
1309                bail!("sub-agent {child_id:?} cannot be its own parent");
1310            }
1311            if !self.sessions.contains_key(child_id) {
1312                bail!("sub-agent {child_id:?} has no child session");
1313            }
1314            if !self.sessions.contains_key(&subagent.parent_session_id) {
1315                bail!(
1316                    "sub-agent {child_id:?} has unknown parent {:?}",
1317                    subagent.parent_session_id
1318                );
1319            }
1320            if self.subagents.contains_key(&subagent.parent_session_id) {
1321                bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
1322            }
1323            if subagent.task_name.trim().is_empty()
1324                || subagent.profile_id.trim().is_empty()
1325                || subagent.request_key.trim().is_empty()
1326            {
1327                bail!("sub-agent {child_id:?} has incomplete relationship metadata");
1328            }
1329        }
1330        for (host, sources) in &self.mount_history {
1331            if host.trim().is_empty() {
1332                bail!("mount history contains an empty host key");
1333            }
1334            if sources.iter().any(|source| !source.is_absolute()) {
1335                bail!("mount history for {host:?} contains a non-absolute source path");
1336            }
1337        }
1338        for (host, size) in &self.container_sizes {
1339            if host.trim().is_empty() {
1340                bail!("container size history contains an empty host key");
1341            }
1342            if size.cpus == 0 || size.memory_bytes == 0 {
1343                bail!("container size history for {host:?} contains a zero value");
1344            }
1345            if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
1346                bail!("container size history for {host:?} exceeds SQLite integer range");
1347            }
1348        }
1349        Ok(())
1350    }
1351
1352    pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
1353        if mounts.is_empty() {
1354            return;
1355        }
1356        let sources = self.mount_history.entry(host.to_owned()).or_default();
1357        for mount in mounts.iter().rev() {
1358            sources.retain(|source| source != &mount.source);
1359            sources.insert(0, mount.source.clone());
1360        }
1361        sources.truncate(20);
1362    }
1363
1364    pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
1365        self.container_sizes.insert(host.to_owned(), size);
1366    }
1367
1368    pub fn project_directories(&self, host: &str) -> &[PathBuf] {
1369        self.mount_history
1370            .get(&project_history_key(host))
1371            .map(Vec::as_slice)
1372            .unwrap_or_default()
1373    }
1374
1375    pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
1376        let key = project_history_key(host);
1377        let directories = self.mount_history.entry(key).or_default();
1378        directories.retain(|existing| existing != directory);
1379        directories.insert(0, directory.to_path_buf());
1380        directories.truncate(20);
1381    }
1382
1383    pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
1384        let session = self
1385            .sessions
1386            .get(session_id)
1387            .with_context(|| format!("unknown session {session_id}"))?;
1388        if session.state.is_active() {
1389            bail!("refusing to destroy active session {session_id}");
1390        }
1391        Ok(self
1392            .sessions
1393            .remove(session_id)
1394            .expect("session checked above"))
1395    }
1396
1397    /// Remove a session record from state regardless of its lifecycle state.
1398    ///
1399    /// Force destruction is the one caller: by the time it runs, every
1400    /// external artifact has been torn down or its loss accepted, so no state
1401    /// is refused here.
1402    pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
1403        self.sessions
1404            .get(session_id)
1405            .with_context(|| format!("unknown session {session_id}"))?;
1406        Ok(self
1407            .sessions
1408            .remove(session_id)
1409            .expect("session checked above"))
1410    }
1411
1412    /// Setup may add replacements under new names, but must not rewrite
1413    /// dependencies still owned by active sessions.
1414    pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
1415        for session in self
1416            .sessions
1417            .values()
1418            .filter(|session| session.state.is_active())
1419        {
1420            let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
1421                let mut comparable = profile.clone();
1422                if let Some(updated) = after.profiles.get(&session.last_profile) {
1423                    comparable.enabled = updated.enabled;
1424                }
1425                // A mismatched harness is already broken; allow repairing it.
1426                profile.kind == session.harness_kind
1427                    && after.profiles.get(&session.last_profile) != Some(&comparable)
1428            } else {
1429                false
1430            };
1431            let bundle_changed = session.project_directory.is_none()
1432                && before
1433                    .bundles
1434                    .get(&session.bundle_id)
1435                    .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
1436            let target_changed =
1437                before
1438                    .targets
1439                    .get(&session.target_template_id)
1440                    .is_some_and(|target| {
1441                        after.targets.get(&session.target_template_id) != Some(target)
1442                    });
1443            if protected || bundle_changed || target_changed {
1444                bail!(
1445                    "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.",
1446                    session.id,
1447                    session.last_profile,
1448                    session.bundle_id,
1449                    session.target_template_id
1450                );
1451            }
1452        }
1453        Ok(())
1454    }
1455
1456    /// Strict validation for callers that need all active references intact.
1457    pub fn validate_against_config(&self, config: &Config) -> Result<()> {
1458        self.validate()?;
1459        config.validate()?;
1460        for session in self.sessions.values() {
1461            session.validate_configuration(config)?;
1462        }
1463        Ok(())
1464    }
1465
1466    pub fn load_from(path: &Path) -> Result<Self> {
1467        Self::load_json_from(path)
1468    }
1469
1470    pub fn load_json_from(path: &Path) -> Result<Self> {
1471        if !path.exists() {
1472            return Ok(Self::default());
1473        }
1474        let body =
1475            fs::read(path).with_context(|| format!("read Mjolnir state {}", path.display()))?;
1476        let state: Self = serde_json::from_slice(&body)
1477            .with_context(|| format!("parse Mjolnir state {}", path.display()))?;
1478        state.validate()?;
1479        Ok(state)
1480    }
1481
1482    pub fn save_to(&self, path: &Path) -> Result<()> {
1483        self.validate()?;
1484        let body = serde_json::to_vec_pretty(self).context("serialize Mjolnir state")?;
1485        atomic_write(path, &body)
1486    }
1487}
1488
1489fn project_history_key(host: &str) -> String {
1490    format!("project:{host}")
1491}
1492
1493pub fn state_path() -> PathBuf {
1494    data_dir().join("state.json")
1495}
1496
1497/// Generate an opaque, filesystem-safe stable id for a new logical session.
1498pub fn new_session_id() -> Result<String> {
1499    let mut random = [0u8; 16];
1500    getrandom::fill(&mut random)
1501        .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
1502    let mut encoded = String::with_capacity(32);
1503    for byte in random {
1504        use std::fmt::Write as _;
1505        write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
1506    }
1507    Ok(encoded)
1508}
1509
1510/// Return the newest clean ACP session title from canonical worker events.
1511pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
1512    events.iter().rev().find_map(|event| {
1513        let WorkerEvent::Adapter { payload, .. } = &event.event else {
1514            return None;
1515        };
1516        let crate::acp::RuntimeEvent::SessionUpdate { update } =
1517            serde_json::from_value(payload.clone()).ok()?
1518        else {
1519            return None;
1520        };
1521        let kind = update
1522            .get("sessionUpdate")
1523            .and_then(serde_json::Value::as_str)?;
1524        let title = match kind {
1525            "session_info_update" | "session_title" => {
1526                update.get("title").and_then(serde_json::Value::as_str)
1527            }
1528            _ => None,
1529        }?;
1530        normalize_session_title(title)
1531    })
1532}
1533
1534pub fn normalize_session_title(title: &str) -> Option<String> {
1535    let normalized = crate::relay::strip_hidden_prompt_context(title)
1536        .split_whitespace()
1537        .collect::<Vec<_>>()
1538        .join(" ");
1539    (!normalized.is_empty()).then_some(normalized)
1540}
1541
1542/// Build the short-lived title shown before the harness supplies its own.
1543///
1544/// The first visible user prompt is immediately useful for identifying a
1545/// session, but it can be arbitrarily large. Keep this fallback bounded; a
1546/// later ACP session-info update remains authoritative and replaces it.
1547pub fn provisional_session_title(prompt: &str) -> Option<String> {
1548    const MAX_TITLE_CHARS: usize = 64;
1549
1550    let normalized = normalize_session_title(prompt)?;
1551    if normalized.chars().count() <= MAX_TITLE_CHARS {
1552        return Some(normalized);
1553    }
1554
1555    let mut truncated = normalized
1556        .chars()
1557        .take(MAX_TITLE_CHARS - 1)
1558        .collect::<String>();
1559    if let Some(boundary) = truncated.rfind(char::is_whitespace) {
1560        truncated.truncate(boundary);
1561    }
1562    truncated.push('…');
1563    Some(truncated)
1564}
1565
1566pub fn short_id(id: &str) -> &str {
1567    id.get(..8).unwrap_or(id)
1568}
1569
1570#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1571pub struct RecoveryCandidate {
1572    pub session_id: String,
1573    pub target_template_id: String,
1574    pub locator: TargetLocator,
1575    pub ownership: Option<crate::worker_launch::WorkerOwnership>,
1576}
1577
1578#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1579pub struct RecoveryScan {
1580    pub candidates: Vec<RecoveryCandidate>,
1581    pub warnings: Vec<String>,
1582}
1583
1584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1585#[serde(deny_unknown_fields)]
1586pub struct ResumeRepositorySourceReceipt {
1587    pub session_id: String,
1588    pub bundle_id: String,
1589    pub checkpoint_sha256: String,
1590    pub repositories: Vec<crate::config::ProjectRepository>,
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596    use crate::config::{
1597        CONFIG_VERSION, ContainerTemplate, HarnessProfile, ProjectBundle, ProjectRepository,
1598        TargetTemplate,
1599    };
1600
1601    fn user_item(position: u64, text: &str) -> Arc<TranscriptItem> {
1602        Arc::new(TranscriptItem {
1603            stable_id: format!("user:{position}"),
1604            position,
1605            latest_content_event_ordinal: None,
1606            created_at_ms: 1_000,
1607            last_changed_at_ms: 1_000,
1608            body: TranscriptBody::User {
1609                content: vec![serde_json::json!({"type": "text", "text": text})],
1610            },
1611        })
1612    }
1613
1614    fn agent_item(position: u64) -> Arc<TranscriptItem> {
1615        Arc::new(TranscriptItem {
1616            stable_id: format!("agent:{position}"),
1617            position,
1618            latest_content_event_ordinal: Some(position),
1619            created_at_ms: 1_000,
1620            last_changed_at_ms: 1_000,
1621            body: TranscriptBody::Agent {
1622                chunks: vec![serde_json::json!({
1623                    "content": {"type": "text", "text": "working"},
1624                    "messageId": "answer"
1625                })],
1626                streaming: false,
1627            },
1628        })
1629    }
1630
1631    fn snapshot(session: MaterializedSession, window: ProjectionWindow) -> ManagedSessionSnapshot {
1632        ManagedSessionSnapshot {
1633            materialized: session,
1634            window,
1635            worker_build: None,
1636            subagent_requests: Vec::new(),
1637            subagent_results: Vec::new(),
1638            operational: serde_json::from_value(serde_json::json!({
1639                "session_id": "session-1",
1640                "execution": "idle",
1641                "latest_ordinal": 0,
1642                "latest_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1643                "acknowledged_through": 0,
1644                "acknowledged_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1645                "recovery_floor_ordinal": 0,
1646                "recovery_floor_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1647                "native_session_id": null,
1648                "agent_capabilities": null,
1649                "agent_info": null,
1650                "config_options": [],
1651                "available_commands": [],
1652                "config": {},
1653                "active_prompt": null,
1654                "queued_prompts": [],
1655                "checkpoint_barrier": null,
1656                "checkpoint_ready": null,
1657            }))
1658            .expect("an idle operational state"),
1659            latest_credential_sync_signal: None,
1660        }
1661    }
1662
1663    /// A polled projection carries only the end of the transcript. The title
1664    /// comes from the first user message and the completed turn from the last
1665    /// one, so both have to survive the head being outside the window.
1666    #[test]
1667    fn a_windowed_projection_answers_the_same_title_and_turn_as_a_whole_one() {
1668        let mut whole = MaterializedSession::empty("session-1");
1669        whole.transcript = vec![
1670            user_item(1, "build the relay"),
1671            agent_item(2),
1672            agent_item(3),
1673            user_item(4, "now test it"),
1674            agent_item(5),
1675        ];
1676        let complete = snapshot(whole.clone(), ProjectionWindow::of(&whole));
1677
1678        // The same session, loaded as a two-item window: the head is gone and
1679        // so is the last user message.
1680        let mut windowed_session = whole.clone();
1681        windowed_session.transcript = whole.transcript[3..].to_vec();
1682        let mut windowed = snapshot(windowed_session, ProjectionWindow::of(&whole));
1683        windowed.window.omitted_items = 3;
1684
1685        assert_eq!(
1686            complete.resolved_title().as_deref(),
1687            Some("build the relay")
1688        );
1689        assert_eq!(windowed.resolved_title(), complete.resolved_title());
1690        assert_eq!(complete.latest_completed_turn_ordinal(), Some(4));
1691        assert_eq!(
1692            windowed.latest_completed_turn_ordinal(),
1693            complete.latest_completed_turn_ordinal()
1694        );
1695    }
1696
1697    /// A session still working has not completed a turn, whatever its
1698    /// transcript says.
1699    #[test]
1700    fn a_running_session_reports_no_completed_turn() {
1701        let mut session = MaterializedSession::empty("session-1");
1702        session.transcript = vec![user_item(1, "build it")];
1703        session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
1704        let window = ProjectionWindow::of(&session);
1705
1706        assert_eq!(
1707            snapshot(session, window).latest_completed_turn_ordinal(),
1708            None
1709        );
1710    }
1711
1712    #[test]
1713    fn fast_mode_configuration_uses_its_user_facing_toggle_command() {
1714        assert_eq!(config_command_text("fast-mode", "on"), "/fast");
1715        assert_eq!(config_command_text("fast-mode", "off"), "/fast");
1716        assert_eq!(config_command_text("model", "sol"), "/model sol");
1717    }
1718
1719    fn sample_state() -> State {
1720        let session = SessionRecord {
1721            mjolnir_subagents: None,
1722            create_managed_worktree: None,
1723            workspace_id: crate::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1724            archived: false,
1725            container_cpus: None,
1726            container_memory: None,
1727            id: "0123456789abcdef".into(),
1728            title: "Build Hel".into(),
1729            harness_kind: HarnessKind::Codex,
1730            last_profile: "codex-1".into(),
1731            bundle_id: "hel".into(),
1732            project_directory: None,
1733            managed_worktree: None,
1734            target_template_id: "podman".into(),
1735            resource_allocation: None,
1736            additional_mounts: vec![AdditionalMount {
1737                source: PathBuf::from("/home/test/cache"),
1738                destination: PathBuf::from("/mnt/cache"),
1739                read_only: false,
1740            }],
1741            state: SessionState::Running,
1742            target: Some(TargetLocator::LocalPodman {
1743                container_id: "afb67d".into(),
1744                workspace_storage: Default::default(),
1745            }),
1746            native_session_id: Some("native-1".into()),
1747            acp_session_title: Some("Build Hel".into()),
1748            session_title_override: None,
1749            created_at: "2026-08-09T12:00:00Z".into(),
1750            updated_at: "2026-08-09T12:01:00Z".into(),
1751            viewed_through_event_ordinal: 0,
1752            draft_input: String::new(),
1753            last_error: None,
1754            last_checkpoint_error: None,
1755            checkpoint: Some(CheckpointMetadata {
1756                archive_path: PathBuf::from("sessions/0123456789abcdef.hel.zip"),
1757                sha256: "a".repeat(64),
1758                created_at: "2026-08-09T12:01:00Z".into(),
1759                event_frontier: 42,
1760            }),
1761        };
1762        State {
1763            version: STATE_VERSION,
1764            sessions: BTreeMap::from([(session.id.clone(), session)]),
1765            subagents: BTreeMap::new(),
1766            mount_history: BTreeMap::from([(
1767                "local".into(),
1768                vec![PathBuf::from("/home/test/cache")],
1769            )]),
1770            container_sizes: BTreeMap::new(),
1771        }
1772    }
1773
1774    fn sample_config() -> Config {
1775        Config {
1776            advanced: Default::default(),
1777            version: CONFIG_VERSION,
1778            sessions_side: Default::default(),
1779            show_stopped_sessions: false,
1780            newer_config_version: None,
1781            spinner: Default::default(),
1782            theme: Default::default(),
1783            phone: Default::default(),
1784            review: Default::default(),
1785            subagents: Default::default(),
1786            legacy_startup: (),
1787            profiles: BTreeMap::from([(
1788                "codex-1".into(),
1789                HarnessProfile {
1790                    enabled: true,
1791                    context_window_bytes: None,
1792                    kind: HarnessKind::Codex,
1793                    home: PathBuf::from("/home/test/.codex"),
1794                    environment: BTreeMap::new(),
1795                    guardian_review_model: None,
1796                },
1797            )]),
1798            bundles: BTreeMap::from([(
1799                "hel".into(),
1800                ProjectBundle {
1801                    primary_repo: "hel".into(),
1802                    repositories: vec![ProjectRepository {
1803                        id: "hel".into(),
1804                        github: Some("BrokkAi/hel".into()),
1805                        local: None,
1806                        destination: PathBuf::from("hel"),
1807                        git_ref: None,
1808                    }],
1809                },
1810            )]),
1811            targets: BTreeMap::from([(
1812                "podman".into(),
1813                TargetTemplate::LocalPodman {
1814                    container: ContainerTemplate {
1815                        image: "ubuntu:24.04".into(),
1816                        pull_policy: Default::default(),
1817                        platform: None,
1818                        cpus: None,
1819                        memory: None,
1820                        environment: BTreeMap::new(),
1821                        workspace_storage: Default::default(),
1822                    },
1823                },
1824            )]),
1825        }
1826    }
1827
1828    fn sample_session() -> SessionRecord {
1829        sample_state()
1830            .sessions
1831            .remove("0123456789abcdef")
1832            .expect("sample session")
1833    }
1834
1835    #[test]
1836    fn session_records_written_before_container_overrides_still_load() {
1837        let session = sample_session();
1838        let mut json = serde_json::to_value(&session).expect("serialize session");
1839        let object = json.as_object_mut().expect("session object");
1840        assert!(object.remove("container_cpus").is_none());
1841        assert!(object.remove("container_memory").is_none());
1842
1843        let loaded: SessionRecord = serde_json::from_value(json).expect("load older session");
1844        assert_eq!(loaded.container_cpus, None);
1845        assert_eq!(loaded.container_memory, None);
1846        assert_eq!(loaded, session);
1847
1848        let mut edited = session.clone();
1849        edited.container_cpus = Some("4".into());
1850        edited.container_memory = Some("8g".into());
1851        let round_tripped: SessionRecord =
1852            serde_json::from_str(&serde_json::to_string(&edited).expect("serialize"))
1853                .expect("reload edited session");
1854        assert_eq!(round_tripped, edited);
1855    }
1856
1857    #[test]
1858    fn container_size_history_rejects_invalid_keys_and_values() {
1859        let mut state = State::default();
1860        state.container_sizes.insert(
1861            String::new(),
1862            HostContainerSize {
1863                cpus: 8,
1864                memory_bytes: 32,
1865            },
1866        );
1867        assert!(
1868            state
1869                .validate()
1870                .unwrap_err()
1871                .to_string()
1872                .contains("empty host")
1873        );
1874
1875        state.container_sizes = BTreeMap::from([(
1876            "local".into(),
1877            HostContainerSize {
1878                cpus: 0,
1879                memory_bytes: 32,
1880            },
1881        )]);
1882        assert!(state.validate().unwrap_err().to_string().contains("zero"));
1883    }
1884
1885    #[test]
1886    fn project_name_prefers_a_worktree_source_then_a_project_directory_then_the_bundle() {
1887        let mut config = sample_config();
1888        config
1889            .bundles
1890            .get_mut("hel")
1891            .expect("bundle")
1892            .repositories
1893            .push(ProjectRepository {
1894                id: "docs".into(),
1895                github: Some("BrokkAi/docs".into()),
1896                local: None,
1897                destination: PathBuf::from("documentation"),
1898                git_ref: None,
1899            });
1900        let mut session = sample_session();
1901
1902        assert_eq!(session.project_name(&config), "docs + hel");
1903
1904        session.project_directory = Some(PathBuf::from("/home/test/Projects/raw-project"));
1905        assert_eq!(session.project_name(&config), "raw-project");
1906
1907        session.project_directory = Some(PathBuf::from(
1908            "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
1909        ));
1910        session.managed_worktree = Some(ManagedWorktree {
1911            source_project_directory: PathBuf::from("/home/test/Projects/source"),
1912            source_repository: PathBuf::from("/home/test/Projects/source"),
1913            worktree_root: PathBuf::from(
1914                "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
1915            ),
1916            branch: "mj/0123456789abcdef".into(),
1917            target: ManagedWorktreeTarget::Local,
1918            base_commit: None,
1919        });
1920        assert_eq!(session.project_name(&config), "source");
1921    }
1922
1923    #[test]
1924    fn bundle_project_name_uses_the_primary_github_repository_name() {
1925        let mut config = sample_config();
1926        config.bundles.insert(
1927            "bifrost".into(),
1928            ProjectBundle {
1929                primary_repo: "bifrost".into(),
1930                repositories: vec![ProjectRepository {
1931                    id: "bifrost".into(),
1932                    github: Some("BrokkAi/bifrost-dev".into()),
1933                    local: None,
1934                    destination: PathBuf::from("bifrost"),
1935                    git_ref: None,
1936                }],
1937            },
1938        );
1939        let mut session = sample_session();
1940        session.bundle_id = "bifrost".into();
1941
1942        assert_eq!(session.project_name(&config), "bifrost-dev");
1943        assert_eq!(
1944            session.project_source(&config),
1945            ProjectSourceIdentity {
1946                key: "github:brokkai/bifrost-dev".into(),
1947                short: "bifrost-dev".into(),
1948                full: "BrokkAi/bifrost-dev".into(),
1949            }
1950        );
1951    }
1952
1953    #[test]
1954    fn bundle_project_name_uses_a_local_source_or_bundle_id_fallback() {
1955        let mut config = sample_config();
1956        config.bundles.insert(
1957            "local-bundle".into(),
1958            ProjectBundle {
1959                primary_repo: "local".into(),
1960                repositories: vec![ProjectRepository {
1961                    id: "local".into(),
1962                    github: None,
1963                    local: Some(PathBuf::from("/home/test/Projects/bifrost-dev")),
1964                    destination: PathBuf::from("bifrost"),
1965                    git_ref: None,
1966                }],
1967            },
1968        );
1969        let mut session = sample_session();
1970        session.bundle_id = "local-bundle".into();
1971
1972        assert_eq!(session.project_name(&config), "bifrost-dev");
1973        assert_eq!(
1974            session.project_source(&config),
1975            ProjectSourceIdentity {
1976                key: "path:/home/test/Projects/bifrost-dev".into(),
1977                short: "bifrost-dev".into(),
1978                full: "/home/test/Projects/bifrost-dev".into(),
1979            }
1980        );
1981
1982        session.bundle_id = "missing-bundle".into();
1983        assert_eq!(session.project_name(&config), "missing-bundle");
1984        assert_eq!(
1985            session.project_source(&config),
1986            ProjectSourceIdentity {
1987                key: "bundle:missing-bundle".into(),
1988                short: "missing-bundle".into(),
1989                full: "missing-bundle".into(),
1990            }
1991        );
1992
1993        let mut other_missing = session.clone();
1994        other_missing.bundle_id = "another-missing-bundle".into();
1995        assert_ne!(
1996            session.project_source(&config).key,
1997            other_missing.project_source(&config).key
1998        );
1999    }
2000
2001    #[test]
2002    fn project_target_adds_the_raw_project_name_only_for_bare_targets() {
2003        let mut config = sample_config();
2004        config
2005            .targets
2006            .insert("localhost".into(), TargetTemplate::LocalBare);
2007        let mut session = sample_session();
2008        session.project_directory = Some(PathBuf::from("/mnt/optane/bifrost-fird"));
2009
2010        assert_eq!(session.project_target(&config, "podman"), "podman");
2011        assert_eq!(
2012            session.project_target(&config, "localhost"),
2013            "localhost/bifrost-fird"
2014        );
2015        assert_eq!(
2016            session.project_target(&config, "retired-target"),
2017            "retired-target"
2018        );
2019    }
2020
2021    #[test]
2022    fn project_source_uses_bundle_repository_and_ignores_managed_worktree_destinations() {
2023        let config = sample_config();
2024        let mut session = sample_session();
2025        let source = session.project_source(&config);
2026        assert_eq!(source.key, "github:brokkai/hel");
2027        assert_eq!(source.short, "hel");
2028        assert_eq!(source.full, "BrokkAi/hel");
2029        assert_eq!(
2030            ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git"),
2031            ProjectSourceIdentity::git_remote("https://github.com/BrokkAi/bifrost-dev.git")
2032        );
2033        assert_ne!(
2034            ProjectSourceIdentity::git_remote("BrokkAi/bifrost-dev"),
2035            ProjectSourceIdentity::git_remote("OtherOrg/bifrost-dev")
2036        );
2037
2038        session.project_directory = Some(PathBuf::from(
2039            "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
2040        ));
2041        session.managed_worktree = Some(ManagedWorktree {
2042            source_project_directory: PathBuf::from("/home/test/Projects/source/crate"),
2043            source_repository: PathBuf::from("/home/test/Projects/source"),
2044            worktree_root: PathBuf::from(
2045                "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
2046            ),
2047            branch: "mj/0123456789abcdef".into(),
2048            target: ManagedWorktreeTarget::Local,
2049            base_commit: None,
2050        });
2051        let source = session.project_source(&config);
2052        assert_eq!(source.short, "source");
2053        assert_eq!(source.full, "/home/test/Projects/source");
2054        assert!(!source.full.contains(".mj/worktrees"));
2055    }
2056
2057    #[test]
2058    fn single_repository_bundle_uses_the_standalone_repository_identity() {
2059        let mut config = sample_config();
2060        let shared_bundle = config.bundles["hel"].clone();
2061        config.bundles.insert("other".into(), shared_bundle);
2062
2063        let first = sample_session();
2064        let mut second = first.clone();
2065        second.bundle_id = "other".into();
2066
2067        assert_eq!(
2068            config.bundles["hel"].primary_repo,
2069            config.bundles["other"].primary_repo
2070        );
2071        let first_source = first.project_source(&config);
2072        let second_source = second.project_source(&config);
2073        let standalone = ProjectSourceIdentity::git_remote("BrokkAi/hel").unwrap();
2074        assert_eq!(first_source, standalone);
2075        assert_eq!(second_source, standalone);
2076    }
2077
2078    #[test]
2079    fn multi_repository_bundles_include_all_repositories_in_sorted_identity_order() {
2080        let mut config = sample_config();
2081        let primary = config.bundles["hel"].repositories[0].clone();
2082        let secondary = ProjectRepository {
2083            id: "docs".into(),
2084            github: Some("BrokkAi/docs".into()),
2085            local: None,
2086            destination: PathBuf::from("docs"),
2087            git_ref: None,
2088        };
2089        config.bundles.insert(
2090            "with-docs".into(),
2091            ProjectBundle {
2092                primary_repo: primary.id.clone(),
2093                repositories: vec![primary.clone(), secondary.clone()],
2094            },
2095        );
2096        let mut session = sample_session();
2097        session.bundle_id = "with-docs".into();
2098
2099        assert_eq!(session.project_name(&config), "docs + hel");
2100        assert_eq!(
2101            session.project_source(&config),
2102            ProjectSourceIdentity {
2103                key: "bundle:[\"github:brokkai/docs\",\"github:brokkai/hel\"]".into(),
2104                short: "docs + hel".into(),
2105                full: "BrokkAi/docs + BrokkAi/hel".into(),
2106            }
2107        );
2108
2109        let mut other_secondary = secondary;
2110        other_secondary.github = Some("OtherOrg/docs".into());
2111        config.bundles.insert(
2112            "with-other-docs".into(),
2113            ProjectBundle {
2114                primary_repo: primary.id.clone(),
2115                repositories: vec![primary, other_secondary],
2116            },
2117        );
2118        let mut other_session = session.clone();
2119        other_session.bundle_id = "with-other-docs".into();
2120        assert_ne!(
2121            session.project_source(&config).key,
2122            other_session.project_source(&config).key
2123        );
2124    }
2125
2126    #[test]
2127    fn multi_repository_bundle_identity_ignores_repository_order_and_primary_selection() {
2128        let mut config = sample_config();
2129        let primary = config.bundles["hel"].repositories[0].clone();
2130        let secondary = ProjectRepository {
2131            id: "docs".into(),
2132            github: Some("BrokkAi/docs".into()),
2133            local: None,
2134            destination: PathBuf::from("docs"),
2135            git_ref: None,
2136        };
2137        config.bundles.insert(
2138            "first-order".into(),
2139            ProjectBundle {
2140                primary_repo: primary.id.clone(),
2141                repositories: vec![primary.clone(), secondary.clone()],
2142            },
2143        );
2144        config.bundles.insert(
2145            "second-order".into(),
2146            ProjectBundle {
2147                primary_repo: secondary.id.clone(),
2148                repositories: vec![secondary, primary],
2149            },
2150        );
2151
2152        let mut first = sample_session();
2153        first.bundle_id = "first-order".into();
2154        let mut second = first.clone();
2155        second.bundle_id = "second-order".into();
2156        assert_eq!(
2157            first.project_source(&config),
2158            second.project_source(&config)
2159        );
2160    }
2161
2162    #[test]
2163    fn duplicate_repository_sources_collapse_to_the_single_repository_identity() {
2164        let mut config = sample_config();
2165        let primary = config.bundles["hel"].repositories[0].clone();
2166        let duplicate = ProjectRepository {
2167            id: "hel-copy".into(),
2168            github: primary.github.clone(),
2169            local: None,
2170            destination: PathBuf::from("hel-copy"),
2171            git_ref: None,
2172        };
2173        config.bundles.insert(
2174            "duplicate".into(),
2175            ProjectBundle {
2176                primary_repo: primary.id.clone(),
2177                repositories: vec![primary, duplicate],
2178            },
2179        );
2180        let mut session = sample_session();
2181        session.bundle_id = "duplicate".into();
2182
2183        let source = session.project_source(&config);
2184        assert_eq!(
2185            source,
2186            ProjectSourceIdentity::git_remote("BrokkAi/hel").unwrap()
2187        );
2188    }
2189
2190    #[test]
2191    fn unresolved_bundle_repository_uses_the_bundle_fallback() {
2192        let mut config = sample_config();
2193        config.bundles.insert(
2194            "incomplete".into(),
2195            ProjectBundle {
2196                primary_repo: "broken".into(),
2197                repositories: vec![ProjectRepository {
2198                    id: "broken".into(),
2199                    github: None,
2200                    local: None,
2201                    destination: PathBuf::from("broken"),
2202                    git_ref: None,
2203                }],
2204            },
2205        );
2206        let mut session = sample_session();
2207        session.bundle_id = "incomplete".into();
2208
2209        assert_eq!(session.project_name(&config), "incomplete");
2210        assert_eq!(
2211            session.project_source(&config),
2212            ProjectSourceIdentity {
2213                key: "bundle:incomplete".into(),
2214                short: "incomplete".into(),
2215                full: "incomplete".into(),
2216            }
2217        );
2218    }
2219
2220    #[test]
2221    fn sessions_order_by_creation_time_and_fall_back_to_the_id() {
2222        let older = sample_session();
2223        let mut newer = sample_session();
2224        newer.id = "0000000000000001".into();
2225        newer.created_at = "2026-08-09T13:00:00Z".into();
2226        let mut unparsable = sample_session();
2227        unparsable.id = "0000000000000002".into();
2228        unparsable.created_at = "not a timestamp".into();
2229        let mut same_time = sample_session();
2230        same_time.id = "zzzzzzzzzzzzzzzz".into();
2231
2232        let mut sessions = [&unparsable, &newer, &same_time, &older];
2233        sessions.sort_by(|left, right| left.compare_by_creation(right));
2234
2235        assert_eq!(
2236            sessions
2237                .iter()
2238                .map(|session| &session.id)
2239                .collect::<Vec<_>>(),
2240            [&older.id, &same_time.id, &newer.id, &unparsable.id]
2241        );
2242    }
2243
2244    #[test]
2245    fn retired_checkpoint_and_detach_cursor_names_are_rejected() {
2246        let session_id = "0123456789abcdef";
2247
2248        let mut old_checkpoint = serde_json::to_value(sample_state()).unwrap();
2249        let checkpoint = old_checkpoint["sessions"][session_id]["checkpoint"]
2250            .as_object_mut()
2251            .unwrap();
2252        let frontier = checkpoint.remove("event_frontier").unwrap();
2253        checkpoint.insert("event_sequence".into(), frontier);
2254        assert!(serde_json::from_value::<State>(old_checkpoint).is_err());
2255
2256        let mut old_detach_cursor = serde_json::to_value(sample_state()).unwrap();
2257        let session = old_detach_cursor["sessions"][session_id]
2258            .as_object_mut()
2259            .unwrap();
2260        let ordinal = session.remove("viewed_through_event_ordinal").unwrap();
2261        session.insert("last_viewed_event_sequence".into(), ordinal);
2262        assert!(serde_json::from_value::<State>(old_detach_cursor).is_err());
2263    }
2264
2265    #[test]
2266    fn detached_cursor_field_loads_as_the_viewed_cursor() {
2267        let session_id = "0123456789abcdef";
2268        let mut legacy = serde_json::to_value(sample_state()).unwrap();
2269        let session = legacy["sessions"][session_id].as_object_mut().unwrap();
2270        let ordinal = session.remove("viewed_through_event_ordinal").unwrap();
2271        session.insert("detached_after_event_ordinal".into(), ordinal);
2272
2273        let loaded: State = serde_json::from_value(legacy).unwrap();
2274        assert_eq!(
2275            loaded.sessions[session_id].viewed_through_event_ordinal,
2276            sample_state().sessions[session_id].viewed_through_event_ordinal
2277        );
2278    }
2279
2280    #[test]
2281    fn state_written_before_drafts_loads_with_an_empty_draft() {
2282        let session_id = "0123456789abcdef";
2283        let mut without_draft = serde_json::to_value(sample_state()).unwrap();
2284        let session = without_draft["sessions"][session_id]
2285            .as_object_mut()
2286            .unwrap();
2287        session.remove("draft_input");
2288
2289        let state = serde_json::from_value::<State>(without_draft).unwrap();
2290        assert_eq!(state.sessions[session_id].draft_input, "");
2291    }
2292
2293    #[test]
2294    fn json_state_round_trip_is_atomic() {
2295        let directory = tempfile::tempdir().unwrap();
2296        let path = directory.path().join("nested/state.json");
2297        let state = sample_state();
2298        state.save_to(&path).unwrap();
2299        assert_eq!(State::load_from(&path).unwrap(), state);
2300        assert!(
2301            fs::read_dir(directory.path().join("nested"))
2302                .unwrap()
2303                .all(|entry| {
2304                    !entry
2305                        .unwrap()
2306                        .file_name()
2307                        .to_string_lossy()
2308                        .ends_with(".tmp")
2309                })
2310        );
2311    }
2312
2313    #[test]
2314    fn mount_history_keeps_unique_recent_sources_per_host() {
2315        let mut state = State::default();
2316        state.remember_mount_sources(
2317            "builder.example.test",
2318            &[
2319                AdditionalMount {
2320                    source: "/srv/first".into(),
2321                    destination: "/mnt/first".into(),
2322                    read_only: false,
2323                },
2324                AdditionalMount {
2325                    source: "/srv/second".into(),
2326                    destination: "/mnt/second".into(),
2327                    read_only: false,
2328                },
2329            ],
2330        );
2331        state.remember_mount_sources(
2332            "builder.example.test",
2333            &[AdditionalMount {
2334                source: "/srv/first".into(),
2335                destination: "/mnt/again".into(),
2336                read_only: false,
2337            }],
2338        );
2339
2340        assert_eq!(
2341            state.mount_history["builder.example.test"],
2342            vec![PathBuf::from("/srv/first"), PathBuf::from("/srv/second")]
2343        );
2344    }
2345
2346    #[test]
2347    fn materialized_activity_watermark_does_not_regress_when_detail_is_removed() {
2348        let mut materialized = MaterializedSession::empty("session-1");
2349        assert_eq!(materialized.last_activity_at_ms(), None);
2350
2351        materialized.execution = MaterializedExecutionState::Running { started_at_ms: 300 };
2352        materialized.transcript.push(Arc::new(TranscriptItem {
2353            stable_id: "system:1".into(),
2354            position: 1,
2355            latest_content_event_ordinal: None,
2356            created_at_ms: 350,
2357            last_changed_at_ms: 400,
2358            body: TranscriptBody::System {
2359                text: "working".into(),
2360            },
2361        }));
2362        materialized.queued_prompts.push(MaterializedQueuedPrompt {
2363            accepted_ordinal: None,
2364            command_id: "prompt-2".into(),
2365            kind: QueuedCommandKind::Prompt,
2366            content: Vec::new(),
2367            queued_at_ms: 500,
2368        });
2369        materialized.last_activity_at_ms = Some(500);
2370        assert_eq!(materialized.last_activity_at_ms(), Some(500));
2371
2372        materialized.queued_prompts.clear();
2373        assert_eq!(materialized.last_activity_at_ms(), Some(500));
2374        materialized.transcript.clear();
2375        assert_eq!(materialized.last_activity_at_ms(), Some(500));
2376        materialized.execution = MaterializedExecutionState::Idle;
2377        assert_eq!(materialized.last_activity_at_ms(), Some(500));
2378    }
2379
2380    /// Shared transcript items must stay plain JSON on the wire: sharing is a
2381    /// controller memory concern, not part of the serialized shape.
2382    #[test]
2383    fn shared_transcript_items_serialize_as_plain_items() {
2384        let mut materialized = MaterializedSession::empty("session-1");
2385        materialized.applied_event_ordinal = 1;
2386        materialized.applied_event_digest = "a".repeat(64);
2387        let item = Arc::new(TranscriptItem {
2388            stable_id: "system:1".into(),
2389            position: 1,
2390            latest_content_event_ordinal: None,
2391            created_at_ms: 10,
2392            last_changed_at_ms: 10,
2393            body: TranscriptBody::System {
2394                text: "started".into(),
2395            },
2396        });
2397        // The same item twice would be deduplicated by serde's pointer-aware
2398        // encodings; stable ids keep it a legal transcript.
2399        materialized.transcript.push(Arc::clone(&item));
2400        let mut second = TranscriptItem::clone(&item);
2401        second.stable_id = "system:2".into();
2402        materialized.transcript.push(Arc::new(second));
2403        materialized.validate().unwrap();
2404
2405        let encoded = serde_json::to_value(&materialized).unwrap();
2406        assert_eq!(encoded["transcript"][0]["stable_id"], "system:1");
2407        assert_eq!(encoded["transcript"][0]["body"]["kind"], "system");
2408        assert_eq!(encoded["transcript"][0]["body"]["text"], "started");
2409        assert_eq!(encoded["transcript"][1]["stable_id"], "system:2");
2410
2411        let restored: MaterializedSession = serde_json::from_value(encoded).unwrap();
2412        assert_eq!(restored, materialized);
2413    }
2414
2415    #[test]
2416    fn materialized_event_frontier_requires_the_matching_digest_kind() {
2417        let mut materialized = MaterializedSession::empty("session-1");
2418        materialized.validate().unwrap();
2419
2420        materialized.applied_event_ordinal = 1;
2421        assert!(
2422            materialized
2423                .validate()
2424                .unwrap_err()
2425                .to_string()
2426                .contains("inconsistent ordinal")
2427        );
2428
2429        materialized.applied_event_digest = "A".repeat(64);
2430        assert!(
2431            materialized
2432                .validate()
2433                .unwrap_err()
2434                .to_string()
2435                .contains("lowercase SHA-256")
2436        );
2437    }
2438
2439    #[test]
2440    fn project_directory_history_is_recent_and_isolated_per_remote_host() {
2441        let mut state = State::default();
2442        state.remember_project_directory("builder-a", Path::new("/srv/one"));
2443        state.remember_project_directory("builder-a", Path::new("/srv/two"));
2444        state.remember_project_directory("builder-a", Path::new("/srv/one"));
2445        state.remember_project_directory("builder-b", Path::new("/work/other"));
2446
2447        assert_eq!(
2448            state.project_directories("builder-a"),
2449            [PathBuf::from("/srv/one"), PathBuf::from("/srv/two")]
2450        );
2451        assert_eq!(
2452            state.project_directories("builder-b"),
2453            [PathBuf::from("/work/other")]
2454        );
2455    }
2456
2457    #[test]
2458    fn setup_protects_active_dependencies_but_allows_additions_repairs_and_defaults() {
2459        let state = sample_state();
2460        let before = sample_config();
2461        let session = state.sessions.values().next().unwrap();
2462        for section in ["profile", "bundle", "target"] {
2463            let mut after = before.clone();
2464            match section {
2465                "profile" => {
2466                    after.profiles.remove(&session.last_profile);
2467                }
2468                "bundle" => {
2469                    after.bundles.remove(&session.bundle_id);
2470                }
2471                _ => {
2472                    after.targets.remove(&session.target_template_id);
2473                }
2474            }
2475            assert!(
2476                state
2477                    .validate_setup_update(&before, &after)
2478                    .unwrap_err()
2479                    .to_string()
2480                    .contains("active session")
2481            );
2482            // Restoring a removed entry is always permitted.
2483            state.validate_setup_update(&after, &before).unwrap();
2484        }
2485        let mut after = before.clone();
2486        after.profiles.get_mut(&session.last_profile).unwrap().home = PathBuf::from("/new/home");
2487        assert!(state.validate_setup_update(&before, &after).is_err());
2488        let mut after = before.clone();
2489        after
2490            .profiles
2491            .get_mut(&session.last_profile)
2492            .unwrap()
2493            .enabled = false;
2494        after.advanced.show_stopped_sessions = !before.advanced.show_stopped_sessions;
2495        after.targets.insert(
2496            "alternative".into(),
2497            crate::config::TargetTemplate::LocalBare,
2498        );
2499        state.validate_setup_update(&before, &after).unwrap();
2500        let mut stopped = state.clone();
2501        stopped.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2502        stopped
2503            .validate_setup_update(&before, &Config::default())
2504            .unwrap();
2505    }
2506
2507    #[test]
2508    fn configuration_repair_reports_all_missing_entries_and_clears_after_restoration() {
2509        let state = sample_state();
2510        let session = state.sessions.values().next().unwrap();
2511        let mut config = sample_config();
2512        config.profiles.clear();
2513        config.bundles.clear();
2514        config.targets.clear();
2515        let issue = session.configuration_issue(&config).unwrap();
2516        assert!(issue.contains("missing profile"));
2517        assert!(issue.contains("missing bundle"));
2518        assert!(issue.contains("missing target template"));
2519        assert!(issue.contains("config.toml"));
2520        assert!(session.configuration_issue(&sample_config()).is_none());
2521        let mut raw = session.clone();
2522        raw.project_directory = Some(PathBuf::from("/project"));
2523        let mut config = sample_config();
2524        config.bundles.clear();
2525        assert!(raw.configuration_issue(&config).is_none());
2526        let mut stopped = session.clone();
2527        stopped.state = SessionState::Stopped;
2528        assert!(stopped.configuration_issue(&Config::default()).is_none());
2529    }
2530
2531    #[test]
2532    fn active_state_validates_references_and_harness_kind() {
2533        let state = sample_state();
2534        state.validate_against_config(&sample_config()).unwrap();
2535
2536        let mut config = sample_config();
2537        config.profiles.get_mut("codex-1").unwrap().kind = HarnessKind::Claude;
2538        assert!(
2539            state
2540                .validate_against_config(&config)
2541                .unwrap_err()
2542                .to_string()
2543                .contains("expects Codex")
2544        );
2545    }
2546
2547    /// A child's working directory is a launch choice, not a containment
2548    /// boundary, so a stored record may name any path on the parent's target.
2549    #[test]
2550    fn a_stored_subagent_may_launch_outside_the_parent_workspace() {
2551        let mut state = sample_state();
2552        let parent_id = state.sessions.keys().next().unwrap().clone();
2553        let child_id = "fedcba9876543210".to_owned();
2554        let mut child = state.sessions[&parent_id].clone();
2555        child.id = child_id.clone();
2556        state.sessions.insert(child_id.clone(), child);
2557        state.subagents.insert(
2558            child_id.clone(),
2559            SubagentRecord {
2560                child_session_id: child_id.clone(),
2561                parent_session_id: parent_id,
2562                task_name: "lane".into(),
2563                profile_id: "codex-1".into(),
2564                model: None,
2565                effort: None,
2566                working_directory: PathBuf::new(),
2567                initial_prompt: "work in the lane".into(),
2568                request_key: "request-1".into(),
2569                created_at: "2026-09-16T00:00:00Z".into(),
2570                noticed_turn: None,
2571            },
2572        );
2573        for working_directory in [
2574            PathBuf::from("/mnt/optane/bifrost-sg-c2"),
2575            PathBuf::from("../shared-checkout"),
2576        ] {
2577            state
2578                .subagents
2579                .get_mut(&child_id)
2580                .unwrap()
2581                .working_directory = working_directory;
2582            state.validate().unwrap();
2583        }
2584    }
2585
2586    /// Records written before the verb was renamed say "archived". They must
2587    /// still load, and they must be written back with the new name.
2588    #[test]
2589    fn the_stopped_state_reads_the_retired_archived_name_and_writes_the_new_one() {
2590        assert_eq!(
2591            serde_json::from_str::<SessionState>("\"archived\"").unwrap(),
2592            SessionState::Stopped
2593        );
2594        assert_eq!(
2595            serde_json::from_str::<SessionState>("\"stopped\"").unwrap(),
2596            SessionState::Stopped
2597        );
2598        assert_eq!(
2599            serde_json::to_string(&SessionState::Stopped).unwrap(),
2600            "\"stopped\""
2601        );
2602        assert!(!SessionState::Stopped.is_active());
2603    }
2604
2605    /// The archived flag is a later addition, so records written without it
2606    /// load as visible and stay out of the serialized form until it is set.
2607    #[test]
2608    fn the_archived_flag_defaults_off_and_is_omitted_when_it_is_off() {
2609        let mut state = sample_state();
2610        let session = state.sessions.values_mut().next().unwrap();
2611        assert!(!session.archived);
2612        let json = serde_json::to_string(&*session).unwrap();
2613        assert!(!json.contains("archived"), "{json}");
2614
2615        session.archived = true;
2616        let json = serde_json::to_string(&*session).unwrap();
2617        assert!(json.contains("\"archived\":true"), "{json}");
2618        assert!(
2619            serde_json::from_str::<SessionRecord>(&json)
2620                .unwrap()
2621                .archived
2622        );
2623    }
2624
2625    #[test]
2626    fn stopped_session_does_not_pin_renamed_config_entries() {
2627        let mut state = sample_state();
2628        state.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2629        state.validate_against_config(&Config::default()).unwrap();
2630    }
2631
2632    #[test]
2633    fn only_inactive_sessions_can_be_removed_from_the_archive() {
2634        let mut state = sample_state();
2635        assert!(
2636            state
2637                .destroy_stopped_session("0123456789abcdef")
2638                .unwrap_err()
2639                .to_string()
2640                .contains("active session")
2641        );
2642        assert!(state.sessions.contains_key("0123456789abcdef"));
2643
2644        state.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2645        let removed = state.destroy_stopped_session("0123456789abcdef").unwrap();
2646        assert_eq!(removed.id, "0123456789abcdef");
2647        assert!(state.sessions.is_empty());
2648    }
2649
2650    #[test]
2651    fn force_removal_permits_an_active_session() {
2652        let mut state = sample_state();
2653        let removed = state.destroy_session_force("0123456789abcdef").unwrap();
2654        assert_eq!(removed.id, "0123456789abcdef");
2655        assert!(state.sessions.is_empty());
2656        assert!(
2657            state
2658                .destroy_session_force("0123456789abcdef")
2659                .unwrap_err()
2660                .to_string()
2661                .contains("unknown session")
2662        );
2663    }
2664
2665    #[test]
2666    fn harness_title_prefers_the_newest_session_info_update() {
2667        let events = vec![
2668            SequencedEvent {
2669                seq: 1,
2670                recorded_at_ms: None,
2671                request_id: None,
2672                event: WorkerEvent::Adapter {
2673                    kind: "session_update".into(),
2674                    payload: serde_json::json!({
2675                        "type": "session_update",
2676                        "update": {
2677                            "sessionUpdate": "session_info_update",
2678                            "title": "First title"
2679                        }
2680                    }),
2681                },
2682            },
2683            SequencedEvent {
2684                seq: 2,
2685                recorded_at_ms: None,
2686                request_id: None,
2687                event: WorkerEvent::Adapter {
2688                    kind: "session_update".into(),
2689                    payload: serde_json::json!({
2690                        "type": "session_update",
2691                        "update": {
2692                            "sessionUpdate": "session_summary",
2693                            "summary": "  Build   the dashboard  "
2694                        }
2695                    }),
2696                },
2697            },
2698        ];
2699
2700        assert_eq!(
2701            harness_session_title(&events).as_deref(),
2702            Some("First title")
2703        );
2704    }
2705
2706    #[test]
2707    fn extension_session_title_is_cleaned_without_losing_available_text() {
2708        let first_prompt = format!("{}overflow", "word ".repeat(20));
2709        let expected = first_prompt.trim().to_string();
2710        let events = vec![
2711            SequencedEvent {
2712                seq: 1,
2713                recorded_at_ms: None,
2714                request_id: Some("prompt-1".into()),
2715                event: WorkerEvent::PromptAccepted {
2716                    request_id: "prompt-1".into(),
2717                    text: format!("  {first_prompt}\n"),
2718                    attachments: vec![],
2719                },
2720            },
2721            SequencedEvent {
2722                seq: 2,
2723                recorded_at_ms: None,
2724                request_id: None,
2725                event: WorkerEvent::Adapter {
2726                    kind: "session_update".into(),
2727                    payload: serde_json::json!({
2728                        "type": "session_update",
2729                        "update": {
2730                            "sessionUpdate": "session_title",
2731                            "title": first_prompt
2732                        }
2733                    }),
2734                },
2735            },
2736        ];
2737
2738        assert_eq!(
2739            harness_session_title(&events).as_deref(),
2740            Some(expected.as_str())
2741        );
2742    }
2743
2744    #[test]
2745    fn first_prompt_is_not_used_as_an_acp_session_title() {
2746        let events = vec![SequencedEvent {
2747            seq: 1,
2748            recorded_at_ms: None,
2749            request_id: Some("prompt-1".into()),
2750            event: WorkerEvent::PromptAccepted {
2751                request_id: "prompt-1".into(),
2752                text: "Do not use me as a title".into(),
2753                attachments: vec![],
2754            },
2755        }];
2756
2757        assert_eq!(harness_session_title(&events), None);
2758    }
2759
2760    #[test]
2761    fn provisional_title_is_cleaned_and_bounded() {
2762        assert_eq!(
2763            provisional_session_title(concat!(
2764                "<mj-project-memory>private</mj-project-memory> ",
2765                "  fix the flaky\nresume test  "
2766            ))
2767            .as_deref(),
2768            Some("fix the flaky resume test")
2769        );
2770
2771        let prompt = format!("{}overflow", "word ".repeat(20));
2772        assert_eq!(
2773            provisional_session_title(&prompt).as_deref(),
2774            Some(format!("{}word…", "word ".repeat(11)).as_str())
2775        );
2776    }
2777
2778    #[test]
2779    fn harness_title_elides_hidden_context_instead_of_naming_the_session_from_it() {
2780        let titled = |title: &str| SequencedEvent {
2781            seq: 1,
2782            recorded_at_ms: None,
2783            request_id: None,
2784            event: WorkerEvent::Adapter {
2785                kind: "session_update".into(),
2786                payload: serde_json::json!({
2787                    "type": "session_update",
2788                    "update": {
2789                        "sessionUpdate": "session_title",
2790                        "title": title
2791                    }
2792                }),
2793            },
2794        };
2795
2796        assert_eq!(
2797            harness_session_title(&[titled(concat!(
2798                "<mj-project-memory>private</mj-project-memory> ",
2799                "Visible session name"
2800            ))])
2801            .as_deref(),
2802            Some("Visible session name")
2803        );
2804        assert_eq!(
2805            harness_session_title(&[titled("<mj-project-memory>truncated")]),
2806            None
2807        );
2808    }
2809
2810    #[test]
2811    fn harness_titles_are_normalized_to_one_complete_line() {
2812        let events = vec![SequencedEvent {
2813            seq: 1,
2814            recorded_at_ms: None,
2815            request_id: None,
2816            event: WorkerEvent::Adapter {
2817                kind: "session_update".into(),
2818                payload: serde_json::json!({
2819                    "type": "session_update",
2820                    "update": {
2821                        "sessionUpdate": "session_title",
2822                        "title": "first\nsecond\tthird fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth"
2823                    }
2824                }),
2825            },
2826        }];
2827
2828        assert_eq!(
2829            harness_session_title(&events).as_deref(),
2830            Some(
2831                "first second third fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth"
2832            )
2833        );
2834    }
2835
2836    #[test]
2837    fn locator_rejects_parent_traversal() {
2838        let mut state = sample_state();
2839        state.sessions.values_mut().next().unwrap().target = Some(TargetLocator::SshBare {
2840            host: "builder".into(),
2841            workspace: PathBuf::from("~/hel/../other"),
2842            worker_id: None,
2843        });
2844        assert!(
2845            state
2846                .validate()
2847                .unwrap_err()
2848                .to_string()
2849                .contains("safe path ending")
2850        );
2851    }
2852
2853    #[test]
2854    fn generated_session_ids_are_valid_and_distinct() {
2855        let first = new_session_id().unwrap();
2856        let second = new_session_id().unwrap();
2857        validate_id("session", &first).unwrap();
2858        assert_eq!(first.len(), 32);
2859        assert_ne!(first, second);
2860    }
2861}