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 pub target_template_id: String,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub resource_allocation: Option<SessionResourceAllocation>,
882 #[serde(default, skip_serializing_if = "Vec::is_empty")]
883 pub additional_mounts: Vec<AdditionalMount>,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub container_cpus: Option<String>,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub container_memory: Option<String>,
892 pub state: SessionState,
893 #[serde(default, skip_serializing_if = "is_false")]
896 pub archived: bool,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub target: Option<TargetLocator>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub native_session_id: Option<String>,
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub acp_session_title: Option<String>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub session_title_override: Option<String>,
905 pub created_at: String,
906 pub updated_at: String,
907 #[serde(default, alias = "detached_after_event_ordinal")]
908 pub viewed_through_event_ordinal: u64,
909 #[serde(default, skip_serializing_if = "String::is_empty")]
912 pub draft_input: String,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub last_error: Option<String>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub last_checkpoint_error: Option<String>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub checkpoint: Option<CheckpointMetadata>,
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
922#[serde(deny_unknown_fields)]
923pub struct HostContainerSize {
924 pub cpus: u64,
925 pub memory_bytes: u64,
926}
927
928fn default_session_workspace_id() -> String {
929 crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
930}
931
932impl SessionRecord {
933 pub fn configuration_issue(&self, config: &Config) -> Option<String> {
936 if !self.state.is_active() {
937 return None;
938 }
939 let mut issues = Vec::new();
940 match config.profiles.get(&self.last_profile) {
941 None => issues.push(format!("missing profile {:?}", self.last_profile)),
942 Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
943 "expects {:?}, but profile {:?} is {:?}",
944 self.harness_kind, self.last_profile, profile.kind
945 )),
946 Some(_) => {}
947 }
948 if self.project_directory.is_none() && !config.bundles.contains_key(&self.bundle_id) {
949 issues.push(format!("missing bundle {:?}", self.bundle_id));
950 }
951 if !config.targets.contains_key(&self.target_template_id) {
952 issues.push(format!(
953 "missing target template {:?}",
954 self.target_template_id
955 ));
956 }
957 (!issues.is_empty()).then(|| format!(
958 "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.",
959 self.id, issues.join("; ")
960 ))
961 }
962
963 pub fn validate_configuration(&self, config: &Config) -> Result<()> {
964 if let Some(issue) = self.configuration_issue(config) {
965 bail!("{issue}");
966 }
967 Ok(())
968 }
969
970 pub fn display_title(&self) -> &str {
972 self.session_title_override
973 .as_deref()
974 .or(self.acp_session_title.as_deref())
975 .unwrap_or(&self.id)
976 }
977
978 pub fn project_name(&self, config: &Config) -> String {
983 if let Some(worktree) = &self.managed_worktree {
984 return path_leaf(&worktree.source_repository);
985 }
986 if let Some(project_directory) = &self.project_directory {
987 return path_leaf(project_directory);
988 }
989 self.bundle_source_name(config)
990 }
991
992 pub fn project_target(&self, config: &Config, target_id: &str) -> String {
996 if !matches!(
997 config.targets.get(target_id),
998 Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
999 ) {
1000 return target_id.to_owned();
1001 }
1002 self.managed_worktree
1003 .as_ref()
1004 .map(|worktree| &worktree.source_project_directory)
1005 .or(self.project_directory.as_ref())
1006 .and_then(|path| path.file_name())
1007 .map_or_else(
1008 || target_id.to_owned(),
1009 |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1010 )
1011 }
1012
1013 pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1018 if let Some(worktree) = &self.managed_worktree {
1019 return ProjectSourceIdentity::path(&worktree.source_repository, None);
1020 }
1021 if let Some(project_directory) = &self.project_directory {
1022 let remote = match &self.target {
1023 Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1024 _ => None,
1025 };
1026 return ProjectSourceIdentity::path(project_directory, remote);
1027 }
1028 self.bundle_source_identity(config)
1029 .unwrap_or_else(|| ProjectSourceIdentity {
1030 key: format!("bundle:{}", self.bundle_id),
1031 short: path_leaf(Path::new(&self.bundle_id)),
1032 full: self.bundle_id.clone(),
1033 })
1034 }
1035
1036 fn bundle_source_name(&self, config: &Config) -> String {
1039 self.bundle_source_identity(config)
1040 .map(|source| source.short)
1041 .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1042 }
1043
1044 fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1047 let bundle = config.bundles.get(&self.bundle_id)?;
1048 let sources = bundle
1049 .repositories
1050 .iter()
1051 .map(repository_source_identity)
1052 .collect::<Option<Vec<_>>>()?;
1053 ProjectSourceIdentity::bundle(sources)
1054 }
1055
1056 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1060 self.creation_order_key().cmp(&other.creation_order_key())
1061 }
1062
1063 pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1065 let timestamp = created_at_seconds(&self.created_at);
1066 (timestamp.is_none(), timestamp, &self.id)
1067 }
1068
1069 fn validate(&self, map_id: &str) -> Result<()> {
1070 validate_id("session", &self.id)?;
1071 if self.id != map_id {
1072 bail!(
1073 "session map key {map_id:?} does not match record id {:?}",
1074 self.id
1075 );
1076 }
1077 validate_id("workspace", &self.workspace_id)?;
1078 validate_id("profile", &self.last_profile)?;
1079 validate_id("bundle", &self.bundle_id)?;
1080 if let Some(project_directory) = &self.project_directory
1081 && (!project_directory.is_absolute()
1082 || project_directory
1083 .components()
1084 .any(|part| part == Component::ParentDir))
1085 {
1086 bail!("session {:?} has an unsafe project directory", self.id);
1087 }
1088 if let Some(managed_worktree) = &self.managed_worktree {
1089 managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1090 }
1091 validate_id("target template", &self.target_template_id)?;
1092 if let Some(allocation) = &self.resource_allocation {
1093 allocation.validate()?;
1094 }
1095 validate_additional_mounts(&self.additional_mounts)?;
1096 if self.title.trim().is_empty() {
1097 bail!("session {:?} has an empty title", self.id);
1098 }
1099 if self
1100 .acp_session_title
1101 .as_ref()
1102 .is_some_and(|title| title.trim().is_empty())
1103 || self
1104 .session_title_override
1105 .as_ref()
1106 .is_some_and(|title| title.trim().is_empty())
1107 {
1108 bail!("session {:?} has an empty display title", self.id);
1109 }
1110 if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1111 bail!("session {:?} has an empty timestamp", self.id);
1112 }
1113 if let Some(target) = &self.target {
1114 target.validate(&self.id)?;
1115 }
1116 if let Some(checkpoint) = &self.checkpoint {
1117 checkpoint.validate()?;
1118 }
1119 Ok(())
1120 }
1121}
1122
1123fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1124 repository
1125 .github
1126 .as_deref()
1127 .and_then(ProjectSourceIdentity::git_remote)
1128 .or_else(|| {
1129 repository
1130 .local
1131 .as_deref()
1132 .map(|path| ProjectSourceIdentity::path(path, None))
1133 })
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1137pub struct ProjectSourceIdentity {
1138 pub key: String,
1139 pub short: String,
1140 pub full: String,
1141}
1142
1143impl ProjectSourceIdentity {
1144 pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1146 if sources.is_empty() {
1147 return None;
1148 }
1149 sources.sort_by(|left, right| {
1150 left.key
1151 .cmp(&right.key)
1152 .then_with(|| left.full.cmp(&right.full))
1153 .then_with(|| left.short.cmp(&right.short))
1154 });
1155 sources.dedup_by(|left, right| left.key == right.key);
1156 if sources.len() == 1 {
1157 return sources.pop();
1158 }
1159 let keys = sources
1160 .iter()
1161 .map(|source| source.key.clone())
1162 .collect::<Vec<_>>();
1163 let key = serde_json::to_string(&keys).ok()?;
1164 Some(Self {
1165 key: format!("bundle:{key}"),
1166 short: sources
1167 .iter()
1168 .map(|source| source.short.as_str())
1169 .collect::<Vec<_>>()
1170 .join(" + "),
1171 full: sources
1172 .iter()
1173 .map(|source| source.full.as_str())
1174 .collect::<Vec<_>>()
1175 .join(" + "),
1176 })
1177 }
1178
1179 pub fn git_remote(source: &str) -> Option<Self> {
1182 if let Some(normalized) = normalize_github_source(source) {
1183 let short = normalized
1184 .rsplit_once('/')
1185 .map_or(normalized.as_str(), |(_, repository)| repository)
1186 .to_owned();
1187 return Some(Self {
1188 key: format!("github:{}", normalized.to_lowercase()),
1189 short,
1190 full: normalized,
1191 });
1192 }
1193 let normalized = source.trim().trim_end_matches('/').trim_end_matches(".git");
1194 if normalized.is_empty() {
1195 return None;
1196 }
1197 let short = normalized
1198 .rsplit(['/', ':'])
1199 .find(|part| !part.is_empty())
1200 .unwrap_or(normalized)
1201 .to_owned();
1202 Some(Self {
1203 key: format!("git:{}", normalized.to_lowercase()),
1204 short,
1205 full: normalized.to_owned(),
1206 })
1207 }
1208
1209 pub fn path(path: &Path, remote: Option<&str>) -> Self {
1211 let normalized = path.components().collect::<PathBuf>();
1212 let path_text = normalized.to_string_lossy().into_owned();
1213 let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
1214 let key = remote.map_or_else(
1215 || format!("path:{path_text}"),
1216 |host| format!("path:{}:{path_text}", host.to_lowercase()),
1217 );
1218 Self {
1219 key,
1220 short: path_leaf(path),
1221 full,
1222 }
1223 }
1224}
1225
1226fn normalize_github_source(source: &str) -> Option<String> {
1227 let source = source.trim();
1228 let path = source
1229 .strip_prefix("https://github.com/")
1230 .or_else(|| source.strip_prefix("http://github.com/"))
1231 .or_else(|| source.strip_prefix("git@github.com:"))
1232 .or_else(|| source.strip_prefix("ssh://git@github.com/"))
1233 .or_else(|| {
1234 (!source.contains("://") && !source.contains('@') && !source.contains(':'))
1235 .then_some(source)
1236 })?
1237 .trim_end_matches(".git");
1238 let mut parts = path.split('/');
1239 let owner = parts.next()?;
1240 let repository = parts.next()?;
1241 (!owner.is_empty() && !repository.is_empty() && parts.next().is_none())
1242 .then(|| format!("{owner}/{repository}"))
1243}
1244
1245fn path_leaf(path: &Path) -> String {
1247 path.file_name()
1248 .unwrap_or(path.as_os_str())
1249 .to_string_lossy()
1250 .into_owned()
1251}
1252
1253fn created_at_seconds(timestamp: &str) -> Option<i64> {
1254 chrono::DateTime::parse_from_rfc3339(timestamp)
1255 .ok()
1256 .map(|timestamp| timestamp.timestamp())
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1260#[serde(deny_unknown_fields)]
1261pub struct State {
1262 pub version: u32,
1263 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1264 pub sessions: BTreeMap<String, SessionRecord>,
1265 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1268 pub subagents: BTreeMap<String, SubagentRecord>,
1269 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1271 pub mount_history: BTreeMap<String, Vec<PathBuf>>,
1272 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1274 pub container_sizes: BTreeMap<String, HostContainerSize>,
1275}
1276
1277impl Default for State {
1278 fn default() -> Self {
1279 Self {
1280 version: STATE_VERSION,
1281 sessions: BTreeMap::new(),
1282 subagents: BTreeMap::new(),
1283 mount_history: BTreeMap::new(),
1284 container_sizes: BTreeMap::new(),
1285 }
1286 }
1287}
1288
1289impl State {
1290 pub fn validate(&self) -> Result<()> {
1291 if self.version != STATE_VERSION {
1292 bail!(
1293 "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
1294 self.version
1295 );
1296 }
1297 for (id, session) in &self.sessions {
1298 session.validate(id)?;
1299 }
1300 for (child_id, subagent) in &self.subagents {
1301 if child_id != &subagent.child_session_id {
1302 bail!("sub-agent key {child_id:?} does not match its child session id");
1303 }
1304 if child_id == &subagent.parent_session_id {
1305 bail!("sub-agent {child_id:?} cannot be its own parent");
1306 }
1307 if !self.sessions.contains_key(child_id) {
1308 bail!("sub-agent {child_id:?} has no child session");
1309 }
1310 if !self.sessions.contains_key(&subagent.parent_session_id) {
1311 bail!(
1312 "sub-agent {child_id:?} has unknown parent {:?}",
1313 subagent.parent_session_id
1314 );
1315 }
1316 if self.subagents.contains_key(&subagent.parent_session_id) {
1317 bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
1318 }
1319 if subagent.task_name.trim().is_empty()
1320 || subagent.profile_id.trim().is_empty()
1321 || subagent.request_key.trim().is_empty()
1322 {
1323 bail!("sub-agent {child_id:?} has incomplete relationship metadata");
1324 }
1325 if subagent.working_directory.is_absolute()
1326 || subagent
1327 .working_directory
1328 .components()
1329 .any(|component| component == Component::ParentDir)
1330 {
1331 bail!("sub-agent {child_id:?} has an unsafe working directory");
1332 }
1333 }
1334 for (host, sources) in &self.mount_history {
1335 if host.trim().is_empty() {
1336 bail!("mount history contains an empty host key");
1337 }
1338 if sources.iter().any(|source| !source.is_absolute()) {
1339 bail!("mount history for {host:?} contains a non-absolute source path");
1340 }
1341 }
1342 for (host, size) in &self.container_sizes {
1343 if host.trim().is_empty() {
1344 bail!("container size history contains an empty host key");
1345 }
1346 if size.cpus == 0 || size.memory_bytes == 0 {
1347 bail!("container size history for {host:?} contains a zero value");
1348 }
1349 if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
1350 bail!("container size history for {host:?} exceeds SQLite integer range");
1351 }
1352 }
1353 Ok(())
1354 }
1355
1356 pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
1357 if mounts.is_empty() {
1358 return;
1359 }
1360 let sources = self.mount_history.entry(host.to_owned()).or_default();
1361 for mount in mounts.iter().rev() {
1362 sources.retain(|source| source != &mount.source);
1363 sources.insert(0, mount.source.clone());
1364 }
1365 sources.truncate(20);
1366 }
1367
1368 pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
1369 self.container_sizes.insert(host.to_owned(), size);
1370 }
1371
1372 pub fn project_directories(&self, host: &str) -> &[PathBuf] {
1373 self.mount_history
1374 .get(&project_history_key(host))
1375 .map(Vec::as_slice)
1376 .unwrap_or_default()
1377 }
1378
1379 pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
1380 let key = project_history_key(host);
1381 let directories = self.mount_history.entry(key).or_default();
1382 directories.retain(|existing| existing != directory);
1383 directories.insert(0, directory.to_path_buf());
1384 directories.truncate(20);
1385 }
1386
1387 pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
1388 let session = self
1389 .sessions
1390 .get(session_id)
1391 .with_context(|| format!("unknown session {session_id}"))?;
1392 if session.state.is_active() {
1393 bail!("refusing to destroy active session {session_id}");
1394 }
1395 Ok(self
1396 .sessions
1397 .remove(session_id)
1398 .expect("session checked above"))
1399 }
1400
1401 pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
1407 self.sessions
1408 .get(session_id)
1409 .with_context(|| format!("unknown session {session_id}"))?;
1410 Ok(self
1411 .sessions
1412 .remove(session_id)
1413 .expect("session checked above"))
1414 }
1415
1416 pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
1419 for session in self
1420 .sessions
1421 .values()
1422 .filter(|session| session.state.is_active())
1423 {
1424 let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
1425 let mut comparable = profile.clone();
1426 if let Some(updated) = after.profiles.get(&session.last_profile) {
1427 comparable.enabled = updated.enabled;
1428 }
1429 profile.kind == session.harness_kind
1431 && after.profiles.get(&session.last_profile) != Some(&comparable)
1432 } else {
1433 false
1434 };
1435 let bundle_changed = session.project_directory.is_none()
1436 && before
1437 .bundles
1438 .get(&session.bundle_id)
1439 .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
1440 let target_changed =
1441 before
1442 .targets
1443 .get(&session.target_template_id)
1444 .is_some_and(|target| {
1445 after.targets.get(&session.target_template_id) != Some(target)
1446 });
1447 if protected || bundle_changed || target_changed {
1448 bail!(
1449 "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.",
1450 session.id,
1451 session.last_profile,
1452 session.bundle_id,
1453 session.target_template_id
1454 );
1455 }
1456 }
1457 Ok(())
1458 }
1459
1460 pub fn validate_against_config(&self, config: &Config) -> Result<()> {
1462 self.validate()?;
1463 config.validate()?;
1464 for session in self.sessions.values() {
1465 session.validate_configuration(config)?;
1466 }
1467 Ok(())
1468 }
1469
1470 pub fn load_from(path: &Path) -> Result<Self> {
1471 Self::load_json_from(path)
1472 }
1473
1474 pub fn load_json_from(path: &Path) -> Result<Self> {
1475 if !path.exists() {
1476 return Ok(Self::default());
1477 }
1478 let body =
1479 fs::read(path).with_context(|| format!("read Mjolnir state {}", path.display()))?;
1480 let state: Self = serde_json::from_slice(&body)
1481 .with_context(|| format!("parse Mjolnir state {}", path.display()))?;
1482 state.validate()?;
1483 Ok(state)
1484 }
1485
1486 pub fn save_to(&self, path: &Path) -> Result<()> {
1487 self.validate()?;
1488 let body = serde_json::to_vec_pretty(self).context("serialize Mjolnir state")?;
1489 atomic_write(path, &body)
1490 }
1491}
1492
1493fn project_history_key(host: &str) -> String {
1494 format!("project:{host}")
1495}
1496
1497pub fn state_path() -> PathBuf {
1498 data_dir().join("state.json")
1499}
1500
1501pub fn new_session_id() -> Result<String> {
1503 let mut random = [0u8; 16];
1504 getrandom::fill(&mut random)
1505 .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
1506 let mut encoded = String::with_capacity(32);
1507 for byte in random {
1508 use std::fmt::Write as _;
1509 write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
1510 }
1511 Ok(encoded)
1512}
1513
1514pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
1516 events.iter().rev().find_map(|event| {
1517 let WorkerEvent::Adapter { payload, .. } = &event.event else {
1518 return None;
1519 };
1520 let crate::acp::RuntimeEvent::SessionUpdate { update } =
1521 serde_json::from_value(payload.clone()).ok()?
1522 else {
1523 return None;
1524 };
1525 let kind = update
1526 .get("sessionUpdate")
1527 .and_then(serde_json::Value::as_str)?;
1528 let title = match kind {
1529 "session_info_update" | "session_title" => {
1530 update.get("title").and_then(serde_json::Value::as_str)
1531 }
1532 _ => None,
1533 }?;
1534 normalize_session_title(title)
1535 })
1536}
1537
1538pub fn normalize_session_title(title: &str) -> Option<String> {
1539 let normalized = crate::relay::strip_hidden_prompt_context(title)
1540 .split_whitespace()
1541 .collect::<Vec<_>>()
1542 .join(" ");
1543 (!normalized.is_empty()).then_some(normalized)
1544}
1545
1546pub fn provisional_session_title(prompt: &str) -> Option<String> {
1552 const MAX_TITLE_CHARS: usize = 64;
1553
1554 let normalized = normalize_session_title(prompt)?;
1555 if normalized.chars().count() <= MAX_TITLE_CHARS {
1556 return Some(normalized);
1557 }
1558
1559 let mut truncated = normalized
1560 .chars()
1561 .take(MAX_TITLE_CHARS - 1)
1562 .collect::<String>();
1563 if let Some(boundary) = truncated.rfind(char::is_whitespace) {
1564 truncated.truncate(boundary);
1565 }
1566 truncated.push('…');
1567 Some(truncated)
1568}
1569
1570pub fn short_id(id: &str) -> &str {
1571 id.get(..8).unwrap_or(id)
1572}
1573
1574#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1575pub struct RecoveryCandidate {
1576 pub session_id: String,
1577 pub target_template_id: String,
1578 pub locator: TargetLocator,
1579 pub ownership: Option<crate::worker_launch::WorkerOwnership>,
1580}
1581
1582#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1583pub struct RecoveryScan {
1584 pub candidates: Vec<RecoveryCandidate>,
1585 pub warnings: Vec<String>,
1586}
1587
1588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1589#[serde(deny_unknown_fields)]
1590pub struct ResumeRepositorySourceReceipt {
1591 pub session_id: String,
1592 pub bundle_id: String,
1593 pub checkpoint_sha256: String,
1594 pub repositories: Vec<crate::config::ProjectRepository>,
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599 use super::*;
1600 use crate::config::{
1601 CONFIG_VERSION, ContainerTemplate, HarnessProfile, ProjectBundle, ProjectRepository,
1602 TargetTemplate,
1603 };
1604
1605 fn user_item(position: u64, text: &str) -> Arc<TranscriptItem> {
1606 Arc::new(TranscriptItem {
1607 stable_id: format!("user:{position}"),
1608 position,
1609 latest_content_event_ordinal: None,
1610 created_at_ms: 1_000,
1611 last_changed_at_ms: 1_000,
1612 body: TranscriptBody::User {
1613 content: vec![serde_json::json!({"type": "text", "text": text})],
1614 },
1615 })
1616 }
1617
1618 fn agent_item(position: u64) -> Arc<TranscriptItem> {
1619 Arc::new(TranscriptItem {
1620 stable_id: format!("agent:{position}"),
1621 position,
1622 latest_content_event_ordinal: Some(position),
1623 created_at_ms: 1_000,
1624 last_changed_at_ms: 1_000,
1625 body: TranscriptBody::Agent {
1626 chunks: vec![serde_json::json!({
1627 "content": {"type": "text", "text": "working"},
1628 "messageId": "answer"
1629 })],
1630 streaming: false,
1631 },
1632 })
1633 }
1634
1635 fn snapshot(session: MaterializedSession, window: ProjectionWindow) -> ManagedSessionSnapshot {
1636 ManagedSessionSnapshot {
1637 materialized: session,
1638 window,
1639 worker_build: None,
1640 subagent_requests: Vec::new(),
1641 subagent_results: Vec::new(),
1642 operational: serde_json::from_value(serde_json::json!({
1643 "session_id": "session-1",
1644 "execution": "idle",
1645 "latest_ordinal": 0,
1646 "latest_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1647 "acknowledged_through": 0,
1648 "acknowledged_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1649 "recovery_floor_ordinal": 0,
1650 "recovery_floor_digest": crate::relay::RELAY_EVENT_GENESIS_DIGEST,
1651 "native_session_id": null,
1652 "agent_capabilities": null,
1653 "agent_info": null,
1654 "config_options": [],
1655 "available_commands": [],
1656 "config": {},
1657 "active_prompt": null,
1658 "queued_prompts": [],
1659 "checkpoint_barrier": null,
1660 "checkpoint_ready": null,
1661 }))
1662 .expect("an idle operational state"),
1663 latest_credential_sync_signal: None,
1664 }
1665 }
1666
1667 #[test]
1671 fn a_windowed_projection_answers_the_same_title_and_turn_as_a_whole_one() {
1672 let mut whole = MaterializedSession::empty("session-1");
1673 whole.transcript = vec![
1674 user_item(1, "build the relay"),
1675 agent_item(2),
1676 agent_item(3),
1677 user_item(4, "now test it"),
1678 agent_item(5),
1679 ];
1680 let complete = snapshot(whole.clone(), ProjectionWindow::of(&whole));
1681
1682 let mut windowed_session = whole.clone();
1685 windowed_session.transcript = whole.transcript[3..].to_vec();
1686 let mut windowed = snapshot(windowed_session, ProjectionWindow::of(&whole));
1687 windowed.window.omitted_items = 3;
1688
1689 assert_eq!(
1690 complete.resolved_title().as_deref(),
1691 Some("build the relay")
1692 );
1693 assert_eq!(windowed.resolved_title(), complete.resolved_title());
1694 assert_eq!(complete.latest_completed_turn_ordinal(), Some(4));
1695 assert_eq!(
1696 windowed.latest_completed_turn_ordinal(),
1697 complete.latest_completed_turn_ordinal()
1698 );
1699 }
1700
1701 #[test]
1704 fn a_running_session_reports_no_completed_turn() {
1705 let mut session = MaterializedSession::empty("session-1");
1706 session.transcript = vec![user_item(1, "build it")];
1707 session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
1708 let window = ProjectionWindow::of(&session);
1709
1710 assert_eq!(
1711 snapshot(session, window).latest_completed_turn_ordinal(),
1712 None
1713 );
1714 }
1715
1716 #[test]
1717 fn fast_mode_configuration_uses_its_user_facing_toggle_command() {
1718 assert_eq!(config_command_text("fast-mode", "on"), "/fast");
1719 assert_eq!(config_command_text("fast-mode", "off"), "/fast");
1720 assert_eq!(config_command_text("model", "sol"), "/model sol");
1721 }
1722
1723 fn sample_state() -> State {
1724 let session = SessionRecord {
1725 create_managed_worktree: None,
1726 workspace_id: crate::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1727 archived: false,
1728 container_cpus: None,
1729 container_memory: None,
1730 id: "0123456789abcdef".into(),
1731 title: "Build Hel".into(),
1732 harness_kind: HarnessKind::Codex,
1733 last_profile: "codex-1".into(),
1734 bundle_id: "hel".into(),
1735 project_directory: None,
1736 managed_worktree: None,
1737 target_template_id: "podman".into(),
1738 resource_allocation: None,
1739 additional_mounts: vec![AdditionalMount {
1740 source: PathBuf::from("/home/test/cache"),
1741 destination: PathBuf::from("/mnt/cache"),
1742 read_only: false,
1743 }],
1744 state: SessionState::Running,
1745 target: Some(TargetLocator::LocalPodman {
1746 container_id: "afb67d".into(),
1747 workspace_storage: Default::default(),
1748 }),
1749 native_session_id: Some("native-1".into()),
1750 acp_session_title: Some("Build Hel".into()),
1751 session_title_override: None,
1752 created_at: "2026-08-09T12:00:00Z".into(),
1753 updated_at: "2026-08-09T12:01:00Z".into(),
1754 viewed_through_event_ordinal: 0,
1755 draft_input: String::new(),
1756 last_error: None,
1757 last_checkpoint_error: None,
1758 checkpoint: Some(CheckpointMetadata {
1759 archive_path: PathBuf::from("sessions/0123456789abcdef.hel.zip"),
1760 sha256: "a".repeat(64),
1761 created_at: "2026-08-09T12:01:00Z".into(),
1762 event_frontier: 42,
1763 }),
1764 };
1765 State {
1766 version: STATE_VERSION,
1767 sessions: BTreeMap::from([(session.id.clone(), session)]),
1768 subagents: BTreeMap::new(),
1769 mount_history: BTreeMap::from([(
1770 "local".into(),
1771 vec![PathBuf::from("/home/test/cache")],
1772 )]),
1773 container_sizes: BTreeMap::new(),
1774 }
1775 }
1776
1777 fn sample_config() -> Config {
1778 Config {
1779 advanced: Default::default(),
1780 version: CONFIG_VERSION,
1781 sessions_side: Default::default(),
1782 show_stopped_sessions: false,
1783 newer_config_version: None,
1784 spinner: Default::default(),
1785 theme: Default::default(),
1786 phone: Default::default(),
1787 review: Default::default(),
1788 subagents: Default::default(),
1789 legacy_startup: (),
1790 profiles: BTreeMap::from([(
1791 "codex-1".into(),
1792 HarnessProfile {
1793 enabled: true,
1794 context_window_bytes: None,
1795 kind: HarnessKind::Codex,
1796 home: PathBuf::from("/home/test/.codex"),
1797 environment: BTreeMap::new(),
1798 },
1799 )]),
1800 bundles: BTreeMap::from([(
1801 "hel".into(),
1802 ProjectBundle {
1803 primary_repo: "hel".into(),
1804 repositories: vec![ProjectRepository {
1805 id: "hel".into(),
1806 github: Some("BrokkAi/hel".into()),
1807 local: None,
1808 destination: PathBuf::from("hel"),
1809 git_ref: None,
1810 }],
1811 },
1812 )]),
1813 targets: BTreeMap::from([(
1814 "podman".into(),
1815 TargetTemplate::LocalPodman {
1816 container: ContainerTemplate {
1817 image: "ubuntu:24.04".into(),
1818 pull_policy: Default::default(),
1819 platform: None,
1820 cpus: None,
1821 memory: None,
1822 environment: BTreeMap::new(),
1823 workspace_storage: Default::default(),
1824 },
1825 },
1826 )]),
1827 }
1828 }
1829
1830 fn sample_session() -> SessionRecord {
1831 sample_state()
1832 .sessions
1833 .remove("0123456789abcdef")
1834 .expect("sample session")
1835 }
1836
1837 #[test]
1838 fn session_records_written_before_container_overrides_still_load() {
1839 let session = sample_session();
1840 let mut json = serde_json::to_value(&session).expect("serialize session");
1841 let object = json.as_object_mut().expect("session object");
1842 assert!(object.remove("container_cpus").is_none());
1843 assert!(object.remove("container_memory").is_none());
1844
1845 let loaded: SessionRecord = serde_json::from_value(json).expect("load older session");
1846 assert_eq!(loaded.container_cpus, None);
1847 assert_eq!(loaded.container_memory, None);
1848 assert_eq!(loaded, session);
1849
1850 let mut edited = session.clone();
1851 edited.container_cpus = Some("4".into());
1852 edited.container_memory = Some("8g".into());
1853 let round_tripped: SessionRecord =
1854 serde_json::from_str(&serde_json::to_string(&edited).expect("serialize"))
1855 .expect("reload edited session");
1856 assert_eq!(round_tripped, edited);
1857 }
1858
1859 #[test]
1860 fn container_size_history_rejects_invalid_keys_and_values() {
1861 let mut state = State::default();
1862 state.container_sizes.insert(
1863 String::new(),
1864 HostContainerSize {
1865 cpus: 8,
1866 memory_bytes: 32,
1867 },
1868 );
1869 assert!(
1870 state
1871 .validate()
1872 .unwrap_err()
1873 .to_string()
1874 .contains("empty host")
1875 );
1876
1877 state.container_sizes = BTreeMap::from([(
1878 "local".into(),
1879 HostContainerSize {
1880 cpus: 0,
1881 memory_bytes: 32,
1882 },
1883 )]);
1884 assert!(state.validate().unwrap_err().to_string().contains("zero"));
1885 }
1886
1887 #[test]
1888 fn project_name_prefers_a_worktree_source_then_a_project_directory_then_the_bundle() {
1889 let mut config = sample_config();
1890 config
1891 .bundles
1892 .get_mut("hel")
1893 .expect("bundle")
1894 .repositories
1895 .push(ProjectRepository {
1896 id: "docs".into(),
1897 github: Some("BrokkAi/docs".into()),
1898 local: None,
1899 destination: PathBuf::from("documentation"),
1900 git_ref: None,
1901 });
1902 let mut session = sample_session();
1903
1904 assert_eq!(session.project_name(&config), "docs + hel");
1905
1906 session.project_directory = Some(PathBuf::from("/home/test/Projects/raw-project"));
1907 assert_eq!(session.project_name(&config), "raw-project");
1908
1909 session.project_directory = Some(PathBuf::from(
1910 "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
1911 ));
1912 session.managed_worktree = Some(ManagedWorktree {
1913 source_project_directory: PathBuf::from("/home/test/Projects/source"),
1914 source_repository: PathBuf::from("/home/test/Projects/source"),
1915 worktree_root: PathBuf::from(
1916 "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
1917 ),
1918 branch: "mj/0123456789abcdef".into(),
1919 target: ManagedWorktreeTarget::Local,
1920 base_commit: None,
1921 });
1922 assert_eq!(session.project_name(&config), "source");
1923 }
1924
1925 #[test]
1926 fn bundle_project_name_uses_the_primary_github_repository_name() {
1927 let mut config = sample_config();
1928 config.bundles.insert(
1929 "bifrost".into(),
1930 ProjectBundle {
1931 primary_repo: "bifrost".into(),
1932 repositories: vec![ProjectRepository {
1933 id: "bifrost".into(),
1934 github: Some("BrokkAi/bifrost-dev".into()),
1935 local: None,
1936 destination: PathBuf::from("bifrost"),
1937 git_ref: None,
1938 }],
1939 },
1940 );
1941 let mut session = sample_session();
1942 session.bundle_id = "bifrost".into();
1943
1944 assert_eq!(session.project_name(&config), "bifrost-dev");
1945 assert_eq!(
1946 session.project_source(&config),
1947 ProjectSourceIdentity {
1948 key: "github:brokkai/bifrost-dev".into(),
1949 short: "bifrost-dev".into(),
1950 full: "BrokkAi/bifrost-dev".into(),
1951 }
1952 );
1953 }
1954
1955 #[test]
1956 fn bundle_project_name_uses_a_local_source_or_bundle_id_fallback() {
1957 let mut config = sample_config();
1958 config.bundles.insert(
1959 "local-bundle".into(),
1960 ProjectBundle {
1961 primary_repo: "local".into(),
1962 repositories: vec![ProjectRepository {
1963 id: "local".into(),
1964 github: None,
1965 local: Some(PathBuf::from("/home/test/Projects/bifrost-dev")),
1966 destination: PathBuf::from("bifrost"),
1967 git_ref: None,
1968 }],
1969 },
1970 );
1971 let mut session = sample_session();
1972 session.bundle_id = "local-bundle".into();
1973
1974 assert_eq!(session.project_name(&config), "bifrost-dev");
1975 assert_eq!(
1976 session.project_source(&config),
1977 ProjectSourceIdentity {
1978 key: "path:/home/test/Projects/bifrost-dev".into(),
1979 short: "bifrost-dev".into(),
1980 full: "/home/test/Projects/bifrost-dev".into(),
1981 }
1982 );
1983
1984 session.bundle_id = "missing-bundle".into();
1985 assert_eq!(session.project_name(&config), "missing-bundle");
1986 assert_eq!(
1987 session.project_source(&config),
1988 ProjectSourceIdentity {
1989 key: "bundle:missing-bundle".into(),
1990 short: "missing-bundle".into(),
1991 full: "missing-bundle".into(),
1992 }
1993 );
1994
1995 let mut other_missing = session.clone();
1996 other_missing.bundle_id = "another-missing-bundle".into();
1997 assert_ne!(
1998 session.project_source(&config).key,
1999 other_missing.project_source(&config).key
2000 );
2001 }
2002
2003 #[test]
2004 fn project_target_adds_the_raw_project_name_only_for_bare_targets() {
2005 let mut config = sample_config();
2006 config
2007 .targets
2008 .insert("localhost".into(), TargetTemplate::LocalBare);
2009 let mut session = sample_session();
2010 session.project_directory = Some(PathBuf::from("/mnt/optane/bifrost-fird"));
2011
2012 assert_eq!(session.project_target(&config, "podman"), "podman");
2013 assert_eq!(
2014 session.project_target(&config, "localhost"),
2015 "localhost/bifrost-fird"
2016 );
2017 assert_eq!(
2018 session.project_target(&config, "retired-target"),
2019 "retired-target"
2020 );
2021 }
2022
2023 #[test]
2024 fn project_source_uses_bundle_repository_and_ignores_managed_worktree_destinations() {
2025 let config = sample_config();
2026 let mut session = sample_session();
2027 let source = session.project_source(&config);
2028 assert_eq!(source.key, "github:brokkai/hel");
2029 assert_eq!(source.short, "hel");
2030 assert_eq!(source.full, "BrokkAi/hel");
2031 assert_eq!(
2032 ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git"),
2033 ProjectSourceIdentity::git_remote("https://github.com/BrokkAi/bifrost-dev.git")
2034 );
2035 assert_ne!(
2036 ProjectSourceIdentity::git_remote("BrokkAi/bifrost-dev"),
2037 ProjectSourceIdentity::git_remote("OtherOrg/bifrost-dev")
2038 );
2039
2040 session.project_directory = Some(PathBuf::from(
2041 "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
2042 ));
2043 session.managed_worktree = Some(ManagedWorktree {
2044 source_project_directory: PathBuf::from("/home/test/Projects/source/crate"),
2045 source_repository: PathBuf::from("/home/test/Projects/source"),
2046 worktree_root: PathBuf::from(
2047 "/home/test/Projects/source/.mj/worktrees/0123456789abcdef",
2048 ),
2049 branch: "mj/0123456789abcdef".into(),
2050 target: ManagedWorktreeTarget::Local,
2051 base_commit: None,
2052 });
2053 let source = session.project_source(&config);
2054 assert_eq!(source.short, "source");
2055 assert_eq!(source.full, "/home/test/Projects/source");
2056 assert!(!source.full.contains(".mj/worktrees"));
2057 }
2058
2059 #[test]
2060 fn single_repository_bundle_uses_the_standalone_repository_identity() {
2061 let mut config = sample_config();
2062 let shared_bundle = config.bundles["hel"].clone();
2063 config.bundles.insert("other".into(), shared_bundle);
2064
2065 let first = sample_session();
2066 let mut second = first.clone();
2067 second.bundle_id = "other".into();
2068
2069 assert_eq!(
2070 config.bundles["hel"].primary_repo,
2071 config.bundles["other"].primary_repo
2072 );
2073 let first_source = first.project_source(&config);
2074 let second_source = second.project_source(&config);
2075 let standalone = ProjectSourceIdentity::git_remote("BrokkAi/hel").unwrap();
2076 assert_eq!(first_source, standalone);
2077 assert_eq!(second_source, standalone);
2078 }
2079
2080 #[test]
2081 fn multi_repository_bundles_include_all_repositories_in_sorted_identity_order() {
2082 let mut config = sample_config();
2083 let primary = config.bundles["hel"].repositories[0].clone();
2084 let secondary = ProjectRepository {
2085 id: "docs".into(),
2086 github: Some("BrokkAi/docs".into()),
2087 local: None,
2088 destination: PathBuf::from("docs"),
2089 git_ref: None,
2090 };
2091 config.bundles.insert(
2092 "with-docs".into(),
2093 ProjectBundle {
2094 primary_repo: primary.id.clone(),
2095 repositories: vec![primary.clone(), secondary.clone()],
2096 },
2097 );
2098 let mut session = sample_session();
2099 session.bundle_id = "with-docs".into();
2100
2101 assert_eq!(session.project_name(&config), "docs + hel");
2102 assert_eq!(
2103 session.project_source(&config),
2104 ProjectSourceIdentity {
2105 key: "bundle:[\"github:brokkai/docs\",\"github:brokkai/hel\"]".into(),
2106 short: "docs + hel".into(),
2107 full: "BrokkAi/docs + BrokkAi/hel".into(),
2108 }
2109 );
2110
2111 let mut other_secondary = secondary;
2112 other_secondary.github = Some("OtherOrg/docs".into());
2113 config.bundles.insert(
2114 "with-other-docs".into(),
2115 ProjectBundle {
2116 primary_repo: primary.id.clone(),
2117 repositories: vec![primary, other_secondary],
2118 },
2119 );
2120 let mut other_session = session.clone();
2121 other_session.bundle_id = "with-other-docs".into();
2122 assert_ne!(
2123 session.project_source(&config).key,
2124 other_session.project_source(&config).key
2125 );
2126 }
2127
2128 #[test]
2129 fn multi_repository_bundle_identity_ignores_repository_order_and_primary_selection() {
2130 let mut config = sample_config();
2131 let primary = config.bundles["hel"].repositories[0].clone();
2132 let secondary = ProjectRepository {
2133 id: "docs".into(),
2134 github: Some("BrokkAi/docs".into()),
2135 local: None,
2136 destination: PathBuf::from("docs"),
2137 git_ref: None,
2138 };
2139 config.bundles.insert(
2140 "first-order".into(),
2141 ProjectBundle {
2142 primary_repo: primary.id.clone(),
2143 repositories: vec![primary.clone(), secondary.clone()],
2144 },
2145 );
2146 config.bundles.insert(
2147 "second-order".into(),
2148 ProjectBundle {
2149 primary_repo: secondary.id.clone(),
2150 repositories: vec![secondary, primary],
2151 },
2152 );
2153
2154 let mut first = sample_session();
2155 first.bundle_id = "first-order".into();
2156 let mut second = first.clone();
2157 second.bundle_id = "second-order".into();
2158 assert_eq!(
2159 first.project_source(&config),
2160 second.project_source(&config)
2161 );
2162 }
2163
2164 #[test]
2165 fn duplicate_repository_sources_collapse_to_the_single_repository_identity() {
2166 let mut config = sample_config();
2167 let primary = config.bundles["hel"].repositories[0].clone();
2168 let duplicate = ProjectRepository {
2169 id: "hel-copy".into(),
2170 github: primary.github.clone(),
2171 local: None,
2172 destination: PathBuf::from("hel-copy"),
2173 git_ref: None,
2174 };
2175 config.bundles.insert(
2176 "duplicate".into(),
2177 ProjectBundle {
2178 primary_repo: primary.id.clone(),
2179 repositories: vec![primary, duplicate],
2180 },
2181 );
2182 let mut session = sample_session();
2183 session.bundle_id = "duplicate".into();
2184
2185 let source = session.project_source(&config);
2186 assert_eq!(
2187 source,
2188 ProjectSourceIdentity::git_remote("BrokkAi/hel").unwrap()
2189 );
2190 }
2191
2192 #[test]
2193 fn unresolved_bundle_repository_uses_the_bundle_fallback() {
2194 let mut config = sample_config();
2195 config.bundles.insert(
2196 "incomplete".into(),
2197 ProjectBundle {
2198 primary_repo: "broken".into(),
2199 repositories: vec![ProjectRepository {
2200 id: "broken".into(),
2201 github: None,
2202 local: None,
2203 destination: PathBuf::from("broken"),
2204 git_ref: None,
2205 }],
2206 },
2207 );
2208 let mut session = sample_session();
2209 session.bundle_id = "incomplete".into();
2210
2211 assert_eq!(session.project_name(&config), "incomplete");
2212 assert_eq!(
2213 session.project_source(&config),
2214 ProjectSourceIdentity {
2215 key: "bundle:incomplete".into(),
2216 short: "incomplete".into(),
2217 full: "incomplete".into(),
2218 }
2219 );
2220 }
2221
2222 #[test]
2223 fn sessions_order_by_creation_time_and_fall_back_to_the_id() {
2224 let older = sample_session();
2225 let mut newer = sample_session();
2226 newer.id = "0000000000000001".into();
2227 newer.created_at = "2026-08-09T13:00:00Z".into();
2228 let mut unparsable = sample_session();
2229 unparsable.id = "0000000000000002".into();
2230 unparsable.created_at = "not a timestamp".into();
2231 let mut same_time = sample_session();
2232 same_time.id = "zzzzzzzzzzzzzzzz".into();
2233
2234 let mut sessions = [&unparsable, &newer, &same_time, &older];
2235 sessions.sort_by(|left, right| left.compare_by_creation(right));
2236
2237 assert_eq!(
2238 sessions
2239 .iter()
2240 .map(|session| &session.id)
2241 .collect::<Vec<_>>(),
2242 [&older.id, &same_time.id, &newer.id, &unparsable.id]
2243 );
2244 }
2245
2246 #[test]
2247 fn retired_checkpoint_and_detach_cursor_names_are_rejected() {
2248 let session_id = "0123456789abcdef";
2249
2250 let mut old_checkpoint = serde_json::to_value(sample_state()).unwrap();
2251 let checkpoint = old_checkpoint["sessions"][session_id]["checkpoint"]
2252 .as_object_mut()
2253 .unwrap();
2254 let frontier = checkpoint.remove("event_frontier").unwrap();
2255 checkpoint.insert("event_sequence".into(), frontier);
2256 assert!(serde_json::from_value::<State>(old_checkpoint).is_err());
2257
2258 let mut old_detach_cursor = serde_json::to_value(sample_state()).unwrap();
2259 let session = old_detach_cursor["sessions"][session_id]
2260 .as_object_mut()
2261 .unwrap();
2262 let ordinal = session.remove("viewed_through_event_ordinal").unwrap();
2263 session.insert("last_viewed_event_sequence".into(), ordinal);
2264 assert!(serde_json::from_value::<State>(old_detach_cursor).is_err());
2265 }
2266
2267 #[test]
2268 fn detached_cursor_field_loads_as_the_viewed_cursor() {
2269 let session_id = "0123456789abcdef";
2270 let mut legacy = serde_json::to_value(sample_state()).unwrap();
2271 let session = legacy["sessions"][session_id].as_object_mut().unwrap();
2272 let ordinal = session.remove("viewed_through_event_ordinal").unwrap();
2273 session.insert("detached_after_event_ordinal".into(), ordinal);
2274
2275 let loaded: State = serde_json::from_value(legacy).unwrap();
2276 assert_eq!(
2277 loaded.sessions[session_id].viewed_through_event_ordinal,
2278 sample_state().sessions[session_id].viewed_through_event_ordinal
2279 );
2280 }
2281
2282 #[test]
2283 fn state_written_before_drafts_loads_with_an_empty_draft() {
2284 let session_id = "0123456789abcdef";
2285 let mut without_draft = serde_json::to_value(sample_state()).unwrap();
2286 let session = without_draft["sessions"][session_id]
2287 .as_object_mut()
2288 .unwrap();
2289 session.remove("draft_input");
2290
2291 let state = serde_json::from_value::<State>(without_draft).unwrap();
2292 assert_eq!(state.sessions[session_id].draft_input, "");
2293 }
2294
2295 #[test]
2296 fn json_state_round_trip_is_atomic() {
2297 let directory = tempfile::tempdir().unwrap();
2298 let path = directory.path().join("nested/state.json");
2299 let state = sample_state();
2300 state.save_to(&path).unwrap();
2301 assert_eq!(State::load_from(&path).unwrap(), state);
2302 assert!(
2303 fs::read_dir(directory.path().join("nested"))
2304 .unwrap()
2305 .all(|entry| {
2306 !entry
2307 .unwrap()
2308 .file_name()
2309 .to_string_lossy()
2310 .ends_with(".tmp")
2311 })
2312 );
2313 }
2314
2315 #[test]
2316 fn mount_history_keeps_unique_recent_sources_per_host() {
2317 let mut state = State::default();
2318 state.remember_mount_sources(
2319 "builder.example.test",
2320 &[
2321 AdditionalMount {
2322 source: "/srv/first".into(),
2323 destination: "/mnt/first".into(),
2324 read_only: false,
2325 },
2326 AdditionalMount {
2327 source: "/srv/second".into(),
2328 destination: "/mnt/second".into(),
2329 read_only: false,
2330 },
2331 ],
2332 );
2333 state.remember_mount_sources(
2334 "builder.example.test",
2335 &[AdditionalMount {
2336 source: "/srv/first".into(),
2337 destination: "/mnt/again".into(),
2338 read_only: false,
2339 }],
2340 );
2341
2342 assert_eq!(
2343 state.mount_history["builder.example.test"],
2344 vec![PathBuf::from("/srv/first"), PathBuf::from("/srv/second")]
2345 );
2346 }
2347
2348 #[test]
2349 fn materialized_activity_watermark_does_not_regress_when_detail_is_removed() {
2350 let mut materialized = MaterializedSession::empty("session-1");
2351 assert_eq!(materialized.last_activity_at_ms(), None);
2352
2353 materialized.execution = MaterializedExecutionState::Running { started_at_ms: 300 };
2354 materialized.transcript.push(Arc::new(TranscriptItem {
2355 stable_id: "system:1".into(),
2356 position: 1,
2357 latest_content_event_ordinal: None,
2358 created_at_ms: 350,
2359 last_changed_at_ms: 400,
2360 body: TranscriptBody::System {
2361 text: "working".into(),
2362 },
2363 }));
2364 materialized.queued_prompts.push(MaterializedQueuedPrompt {
2365 accepted_ordinal: None,
2366 command_id: "prompt-2".into(),
2367 kind: QueuedCommandKind::Prompt,
2368 content: Vec::new(),
2369 queued_at_ms: 500,
2370 });
2371 materialized.last_activity_at_ms = Some(500);
2372 assert_eq!(materialized.last_activity_at_ms(), Some(500));
2373
2374 materialized.queued_prompts.clear();
2375 assert_eq!(materialized.last_activity_at_ms(), Some(500));
2376 materialized.transcript.clear();
2377 assert_eq!(materialized.last_activity_at_ms(), Some(500));
2378 materialized.execution = MaterializedExecutionState::Idle;
2379 assert_eq!(materialized.last_activity_at_ms(), Some(500));
2380 }
2381
2382 #[test]
2385 fn shared_transcript_items_serialize_as_plain_items() {
2386 let mut materialized = MaterializedSession::empty("session-1");
2387 materialized.applied_event_ordinal = 1;
2388 materialized.applied_event_digest = "a".repeat(64);
2389 let item = Arc::new(TranscriptItem {
2390 stable_id: "system:1".into(),
2391 position: 1,
2392 latest_content_event_ordinal: None,
2393 created_at_ms: 10,
2394 last_changed_at_ms: 10,
2395 body: TranscriptBody::System {
2396 text: "started".into(),
2397 },
2398 });
2399 materialized.transcript.push(Arc::clone(&item));
2402 let mut second = TranscriptItem::clone(&item);
2403 second.stable_id = "system:2".into();
2404 materialized.transcript.push(Arc::new(second));
2405 materialized.validate().unwrap();
2406
2407 let encoded = serde_json::to_value(&materialized).unwrap();
2408 assert_eq!(encoded["transcript"][0]["stable_id"], "system:1");
2409 assert_eq!(encoded["transcript"][0]["body"]["kind"], "system");
2410 assert_eq!(encoded["transcript"][0]["body"]["text"], "started");
2411 assert_eq!(encoded["transcript"][1]["stable_id"], "system:2");
2412
2413 let restored: MaterializedSession = serde_json::from_value(encoded).unwrap();
2414 assert_eq!(restored, materialized);
2415 }
2416
2417 #[test]
2418 fn materialized_event_frontier_requires_the_matching_digest_kind() {
2419 let mut materialized = MaterializedSession::empty("session-1");
2420 materialized.validate().unwrap();
2421
2422 materialized.applied_event_ordinal = 1;
2423 assert!(
2424 materialized
2425 .validate()
2426 .unwrap_err()
2427 .to_string()
2428 .contains("inconsistent ordinal")
2429 );
2430
2431 materialized.applied_event_digest = "A".repeat(64);
2432 assert!(
2433 materialized
2434 .validate()
2435 .unwrap_err()
2436 .to_string()
2437 .contains("lowercase SHA-256")
2438 );
2439 }
2440
2441 #[test]
2442 fn project_directory_history_is_recent_and_isolated_per_remote_host() {
2443 let mut state = State::default();
2444 state.remember_project_directory("builder-a", Path::new("/srv/one"));
2445 state.remember_project_directory("builder-a", Path::new("/srv/two"));
2446 state.remember_project_directory("builder-a", Path::new("/srv/one"));
2447 state.remember_project_directory("builder-b", Path::new("/work/other"));
2448
2449 assert_eq!(
2450 state.project_directories("builder-a"),
2451 [PathBuf::from("/srv/one"), PathBuf::from("/srv/two")]
2452 );
2453 assert_eq!(
2454 state.project_directories("builder-b"),
2455 [PathBuf::from("/work/other")]
2456 );
2457 }
2458
2459 #[test]
2460 fn setup_protects_active_dependencies_but_allows_additions_repairs_and_defaults() {
2461 let state = sample_state();
2462 let before = sample_config();
2463 let session = state.sessions.values().next().unwrap();
2464 for section in ["profile", "bundle", "target"] {
2465 let mut after = before.clone();
2466 match section {
2467 "profile" => {
2468 after.profiles.remove(&session.last_profile);
2469 }
2470 "bundle" => {
2471 after.bundles.remove(&session.bundle_id);
2472 }
2473 _ => {
2474 after.targets.remove(&session.target_template_id);
2475 }
2476 }
2477 assert!(
2478 state
2479 .validate_setup_update(&before, &after)
2480 .unwrap_err()
2481 .to_string()
2482 .contains("active session")
2483 );
2484 state.validate_setup_update(&after, &before).unwrap();
2486 }
2487 let mut after = before.clone();
2488 after.profiles.get_mut(&session.last_profile).unwrap().home = PathBuf::from("/new/home");
2489 assert!(state.validate_setup_update(&before, &after).is_err());
2490 let mut after = before.clone();
2491 after
2492 .profiles
2493 .get_mut(&session.last_profile)
2494 .unwrap()
2495 .enabled = false;
2496 after.advanced.show_stopped_sessions = !before.advanced.show_stopped_sessions;
2497 after.targets.insert(
2498 "alternative".into(),
2499 crate::config::TargetTemplate::LocalBare,
2500 );
2501 state.validate_setup_update(&before, &after).unwrap();
2502 let mut stopped = state.clone();
2503 stopped.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2504 stopped
2505 .validate_setup_update(&before, &Config::default())
2506 .unwrap();
2507 }
2508
2509 #[test]
2510 fn configuration_repair_reports_all_missing_entries_and_clears_after_restoration() {
2511 let state = sample_state();
2512 let session = state.sessions.values().next().unwrap();
2513 let mut config = sample_config();
2514 config.profiles.clear();
2515 config.bundles.clear();
2516 config.targets.clear();
2517 let issue = session.configuration_issue(&config).unwrap();
2518 assert!(issue.contains("missing profile"));
2519 assert!(issue.contains("missing bundle"));
2520 assert!(issue.contains("missing target template"));
2521 assert!(issue.contains("config.toml"));
2522 assert!(session.configuration_issue(&sample_config()).is_none());
2523 let mut raw = session.clone();
2524 raw.project_directory = Some(PathBuf::from("/project"));
2525 let mut config = sample_config();
2526 config.bundles.clear();
2527 assert!(raw.configuration_issue(&config).is_none());
2528 let mut stopped = session.clone();
2529 stopped.state = SessionState::Stopped;
2530 assert!(stopped.configuration_issue(&Config::default()).is_none());
2531 }
2532
2533 #[test]
2534 fn active_state_validates_references_and_harness_kind() {
2535 let state = sample_state();
2536 state.validate_against_config(&sample_config()).unwrap();
2537
2538 let mut config = sample_config();
2539 config.profiles.get_mut("codex-1").unwrap().kind = HarnessKind::Claude;
2540 assert!(
2541 state
2542 .validate_against_config(&config)
2543 .unwrap_err()
2544 .to_string()
2545 .contains("expects Codex")
2546 );
2547 }
2548
2549 #[test]
2552 fn the_stopped_state_reads_the_retired_archived_name_and_writes_the_new_one() {
2553 assert_eq!(
2554 serde_json::from_str::<SessionState>("\"archived\"").unwrap(),
2555 SessionState::Stopped
2556 );
2557 assert_eq!(
2558 serde_json::from_str::<SessionState>("\"stopped\"").unwrap(),
2559 SessionState::Stopped
2560 );
2561 assert_eq!(
2562 serde_json::to_string(&SessionState::Stopped).unwrap(),
2563 "\"stopped\""
2564 );
2565 assert!(!SessionState::Stopped.is_active());
2566 }
2567
2568 #[test]
2571 fn the_archived_flag_defaults_off_and_is_omitted_when_it_is_off() {
2572 let mut state = sample_state();
2573 let session = state.sessions.values_mut().next().unwrap();
2574 assert!(!session.archived);
2575 let json = serde_json::to_string(&*session).unwrap();
2576 assert!(!json.contains("archived"), "{json}");
2577
2578 session.archived = true;
2579 let json = serde_json::to_string(&*session).unwrap();
2580 assert!(json.contains("\"archived\":true"), "{json}");
2581 assert!(
2582 serde_json::from_str::<SessionRecord>(&json)
2583 .unwrap()
2584 .archived
2585 );
2586 }
2587
2588 #[test]
2589 fn stopped_session_does_not_pin_renamed_config_entries() {
2590 let mut state = sample_state();
2591 state.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2592 state.validate_against_config(&Config::default()).unwrap();
2593 }
2594
2595 #[test]
2596 fn only_inactive_sessions_can_be_removed_from_the_archive() {
2597 let mut state = sample_state();
2598 assert!(
2599 state
2600 .destroy_stopped_session("0123456789abcdef")
2601 .unwrap_err()
2602 .to_string()
2603 .contains("active session")
2604 );
2605 assert!(state.sessions.contains_key("0123456789abcdef"));
2606
2607 state.sessions.values_mut().next().unwrap().state = SessionState::Stopped;
2608 let removed = state.destroy_stopped_session("0123456789abcdef").unwrap();
2609 assert_eq!(removed.id, "0123456789abcdef");
2610 assert!(state.sessions.is_empty());
2611 }
2612
2613 #[test]
2614 fn force_removal_permits_an_active_session() {
2615 let mut state = sample_state();
2616 let removed = state.destroy_session_force("0123456789abcdef").unwrap();
2617 assert_eq!(removed.id, "0123456789abcdef");
2618 assert!(state.sessions.is_empty());
2619 assert!(
2620 state
2621 .destroy_session_force("0123456789abcdef")
2622 .unwrap_err()
2623 .to_string()
2624 .contains("unknown session")
2625 );
2626 }
2627
2628 #[test]
2629 fn harness_title_prefers_the_newest_session_info_update() {
2630 let events = vec![
2631 SequencedEvent {
2632 seq: 1,
2633 recorded_at_ms: None,
2634 request_id: None,
2635 event: WorkerEvent::Adapter {
2636 kind: "session_update".into(),
2637 payload: serde_json::json!({
2638 "type": "session_update",
2639 "update": {
2640 "sessionUpdate": "session_info_update",
2641 "title": "First title"
2642 }
2643 }),
2644 },
2645 },
2646 SequencedEvent {
2647 seq: 2,
2648 recorded_at_ms: None,
2649 request_id: None,
2650 event: WorkerEvent::Adapter {
2651 kind: "session_update".into(),
2652 payload: serde_json::json!({
2653 "type": "session_update",
2654 "update": {
2655 "sessionUpdate": "session_summary",
2656 "summary": " Build the dashboard "
2657 }
2658 }),
2659 },
2660 },
2661 ];
2662
2663 assert_eq!(
2664 harness_session_title(&events).as_deref(),
2665 Some("First title")
2666 );
2667 }
2668
2669 #[test]
2670 fn extension_session_title_is_cleaned_without_losing_available_text() {
2671 let first_prompt = format!("{}overflow", "word ".repeat(20));
2672 let expected = first_prompt.trim().to_string();
2673 let events = vec![
2674 SequencedEvent {
2675 seq: 1,
2676 recorded_at_ms: None,
2677 request_id: Some("prompt-1".into()),
2678 event: WorkerEvent::PromptAccepted {
2679 request_id: "prompt-1".into(),
2680 text: format!(" {first_prompt}\n"),
2681 attachments: vec![],
2682 },
2683 },
2684 SequencedEvent {
2685 seq: 2,
2686 recorded_at_ms: None,
2687 request_id: None,
2688 event: WorkerEvent::Adapter {
2689 kind: "session_update".into(),
2690 payload: serde_json::json!({
2691 "type": "session_update",
2692 "update": {
2693 "sessionUpdate": "session_title",
2694 "title": first_prompt
2695 }
2696 }),
2697 },
2698 },
2699 ];
2700
2701 assert_eq!(
2702 harness_session_title(&events).as_deref(),
2703 Some(expected.as_str())
2704 );
2705 }
2706
2707 #[test]
2708 fn first_prompt_is_not_used_as_an_acp_session_title() {
2709 let events = vec![SequencedEvent {
2710 seq: 1,
2711 recorded_at_ms: None,
2712 request_id: Some("prompt-1".into()),
2713 event: WorkerEvent::PromptAccepted {
2714 request_id: "prompt-1".into(),
2715 text: "Do not use me as a title".into(),
2716 attachments: vec![],
2717 },
2718 }];
2719
2720 assert_eq!(harness_session_title(&events), None);
2721 }
2722
2723 #[test]
2724 fn provisional_title_is_cleaned_and_bounded() {
2725 assert_eq!(
2726 provisional_session_title(concat!(
2727 "<mj-project-memory>private</mj-project-memory> ",
2728 " fix the flaky\nresume test "
2729 ))
2730 .as_deref(),
2731 Some("fix the flaky resume test")
2732 );
2733
2734 let prompt = format!("{}overflow", "word ".repeat(20));
2735 assert_eq!(
2736 provisional_session_title(&prompt).as_deref(),
2737 Some(format!("{}word…", "word ".repeat(11)).as_str())
2738 );
2739 }
2740
2741 #[test]
2742 fn harness_title_elides_hidden_context_instead_of_naming_the_session_from_it() {
2743 let titled = |title: &str| SequencedEvent {
2744 seq: 1,
2745 recorded_at_ms: None,
2746 request_id: None,
2747 event: WorkerEvent::Adapter {
2748 kind: "session_update".into(),
2749 payload: serde_json::json!({
2750 "type": "session_update",
2751 "update": {
2752 "sessionUpdate": "session_title",
2753 "title": title
2754 }
2755 }),
2756 },
2757 };
2758
2759 assert_eq!(
2760 harness_session_title(&[titled(concat!(
2761 "<mj-project-memory>private</mj-project-memory> ",
2762 "Visible session name"
2763 ))])
2764 .as_deref(),
2765 Some("Visible session name")
2766 );
2767 assert_eq!(
2768 harness_session_title(&[titled("<mj-project-memory>truncated")]),
2769 None
2770 );
2771 }
2772
2773 #[test]
2774 fn harness_titles_are_normalized_to_one_complete_line() {
2775 let events = vec![SequencedEvent {
2776 seq: 1,
2777 recorded_at_ms: None,
2778 request_id: None,
2779 event: WorkerEvent::Adapter {
2780 kind: "session_update".into(),
2781 payload: serde_json::json!({
2782 "type": "session_update",
2783 "update": {
2784 "sessionUpdate": "session_title",
2785 "title": "first\nsecond\tthird fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth"
2786 }
2787 }),
2788 },
2789 }];
2790
2791 assert_eq!(
2792 harness_session_title(&events).as_deref(),
2793 Some(
2794 "first second third fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth"
2795 )
2796 );
2797 }
2798
2799 #[test]
2800 fn locator_rejects_parent_traversal() {
2801 let mut state = sample_state();
2802 state.sessions.values_mut().next().unwrap().target = Some(TargetLocator::SshBare {
2803 host: "builder".into(),
2804 workspace: PathBuf::from("~/hel/../other"),
2805 worker_id: None,
2806 });
2807 assert!(
2808 state
2809 .validate()
2810 .unwrap_err()
2811 .to_string()
2812 .contains("safe path ending")
2813 );
2814 }
2815
2816 #[test]
2817 fn generated_session_ids_are_valid_and_distinct() {
2818 let first = new_session_id().unwrap();
2819 let second = new_session_id().unwrap();
2820 validate_id("session", &first).unwrap();
2821 assert_eq!(first.len(), 32);
2822 assert_ne!(first, second);
2823 }
2824}