1use 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 #[serde(alias = "archived")]
38 Stopped,
39 Lost,
40 Error,
41 DestroyedWithDataLoss,
42}
43
44#[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#[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#[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
133pub 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 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub accepted_ordinal: Option<u64>,
156}
157
158#[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 pub turn_start_position: u64,
169 pub started_at_ms: i64,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum TurnOutcomeKind {
176 Completed { stop_reason: String },
178 Rejected { message: String },
180 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
192pub 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#[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#[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 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 pub transcript: Vec<Arc<TranscriptItem>>,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub queued_prompts: Vec<MaterializedQueuedPrompt>,
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub active_turn: Option<MaterializedTurn>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
259}
260
261#[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 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 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#[derive(Debug, Clone, PartialEq)]
404pub struct ManagedSessionSnapshot {
405 pub materialized: MaterializedSession,
406 pub window: ProjectionWindow,
409 pub operational: RelayOperationalState,
410 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
414 pub worker_build: Option<String>,
419 pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
421 pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct ProjectionWindow {
438 pub omitted_items: usize,
440 pub provisional_title: Option<String>,
442 pub latest_turn_start_position: Option<u64>,
446}
447
448impl ProjectionWindow {
449 #[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 #[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 #[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#[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 pub checkpoint_safe: bool,
519}
520
521pub 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 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 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#[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 #[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
756pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
758 match allocation {
759 SessionResourceAllocation::Container { cpus, .. } => *cpus,
760 SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
761 }
762}
763
764pub 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 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub project_directory: Option<PathBuf>,
873 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub managed_worktree: Option<ManagedWorktree>,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub create_managed_worktree: Option<bool>,
879 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub container_cpus: Option<String>,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub container_memory: Option<String>,
896 pub state: SessionState,
897 #[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 #[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 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 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 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 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 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 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 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 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1064 self.creation_order_key().cmp(&other.creation_order_key())
1065 }
1066
1067 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 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 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 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
1249fn 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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1272 pub subagents: BTreeMap<String, SubagentRecord>,
1273 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1275 pub mount_history: BTreeMap<String, Vec<PathBuf>>,
1276 #[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 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 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 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 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
1497pub 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
1510pub 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
1542pub 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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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}