1#[cfg(test)]
4use std::collections::BTreeMap;
5use std::collections::BTreeSet;
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8
9use anyhow::{Context, Result, bail};
10use serde::{Deserialize, Serialize};
11
12use crate::config::{Config, HarnessKind, ProjectRepository, TargetTemplate, validate_id};
13use crate::credentials::CredentialSyncSignal;
14use crate::relay::{
15 RELAY_EVENT_GENESIS_DIGEST, RelayOperationalState, SequencedEvent, WorkerEvent,
16};
17use crate::snapshot_map::SnapshotMap;
18use crate::subagent::SubagentRecord;
19use crate::targets::{AdditionalMount, validate_additional_mounts};
20
21pub const STATE_VERSION: u32 = 1;
22
23mod target_runtime;
24pub use target_runtime::{TargetConnection, TargetRuntimeSettings};
25
26mod session_configuration;
27pub use session_configuration::SessionConfiguration;
28
29mod session_move;
30pub use session_move::*;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum SessionState {
35 Provisioning,
36 StartupCleanup,
38 Running,
39 Disconnected,
40 Checkpointing,
41 Closing,
42 Destroying,
43 #[serde(alias = "archived")]
46 Stopped,
47 Parked,
54 Lost,
55 Error,
56 DestroyedWithDataLoss,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum SessionTransitionKind {
64 Starting,
65 Resuming,
66 Moving,
67 Suspending,
68 Destroying,
69 Stopping,
71}
72
73impl SessionTransitionKind {
74 pub const fn label(self) -> &'static str {
75 match self {
76 Self::Starting => "Starting",
77 Self::Resuming => "Resuming",
78 Self::Moving => "Moving",
79 Self::Suspending => "Suspending",
80 Self::Destroying => "Destroying",
81 Self::Stopping => "Stopping",
82 }
83 }
84
85 pub fn for_session(state: SessionState, operation: Option<Self>) -> Option<Self> {
86 operation.or_else(|| state.transition_kind())
87 }
88}
89
90#[cfg(test)]
91mod transition_tests {
92 use super::{SessionState, SessionTransitionKind};
93
94 #[test]
95 fn operation_ownership_hides_intermediate_move_states_but_not_ordinary_live_work() {
96 for state in [
97 SessionState::Stopped,
98 SessionState::Running,
99 SessionState::Disconnected,
100 ] {
101 assert_eq!(
102 SessionTransitionKind::for_session(state, Some(SessionTransitionKind::Moving)),
103 Some(SessionTransitionKind::Moving)
104 );
105 assert_eq!(SessionTransitionKind::for_session(state, None), None);
106 }
107 assert_eq!(SessionState::Checkpointing.transition_kind(), None);
108 assert_eq!(
109 SessionState::Closing.transition_kind(),
110 Some(SessionTransitionKind::Suspending)
111 );
112 }
113}
114
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "state", rename_all = "snake_case")]
118pub enum MaterializedExecutionState {
119 #[default]
120 Idle,
121 Running {
122 started_at_ms: i64,
123 },
124 Closing,
125 Closed,
126}
127
128pub use crate::transcript::{TerminalOutputRecord, TranscriptBody, TranscriptItem};
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum QueuedCommandKind {
137 #[default]
138 Prompt,
139 SetConfig {
140 key: String,
141 value: String,
142 },
143}
144
145impl QueuedCommandKind {
146 pub fn is_prompt(&self) -> bool {
147 matches!(self, Self::Prompt)
148 }
149}
150
151pub fn config_command_text(key: &str, value: &str) -> String {
154 if key == "fast-mode" {
155 "/fast".to_owned()
156 } else {
157 format!("/{key} {value}")
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct MaterializedQueuedPrompt {
164 pub command_id: String,
165 #[serde(default, skip_serializing_if = "QueuedCommandKind::is_prompt")]
166 pub kind: QueuedCommandKind,
167 pub content: Vec<serde_json::Value>,
168 pub queued_at_ms: i64,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub accepted_ordinal: Option<u64>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct MaterializedTurn {
181 pub command_id: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub accepted_ordinal: Option<u64>,
184 pub turn_start_position: u64,
187 pub started_at_ms: i64,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub steered_into: Option<String>,
192}
193
194impl MaterializedTurn {
195 pub fn belongs_to(&self, command_id: &str) -> bool {
197 self.command_id == command_id || self.steered_into.as_deref() == Some(command_id)
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum TurnOutcomeKind {
205 Completed { stop_reason: String },
207 Rejected {
209 message: String,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 reason: Option<crate::event_outcome::OutcomeReason>,
212 },
213 Interrupted {
215 message: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 reason: Option<crate::event_outcome::OutcomeReason>,
218 },
219}
220
221impl std::fmt::Display for TurnOutcomeKind {
224 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 use crate::event_outcome::{OutcomeReason, TurnResultKind};
226 let result = self.result();
227 match result.kind {
228 TurnResultKind::Completed => formatter.write_str("completed, end of turn"),
229 TurnResultKind::InputRequired => formatter.write_str("completed, waiting for input"),
230 TurnResultKind::Cancelled | TurnResultKind::Interrupted => {
231 formatter.write_str("interrupted")
232 }
233 TurnResultKind::Failed if result.reason == Some(OutcomeReason::QuotaLimit) => {
234 formatter.write_str("failed: quota limit reached")
235 }
236 TurnResultKind::Failed | TurnResultKind::Rejected => {
237 let message = result.message.as_deref().unwrap_or("unknown failure");
238 if result.stop_reason.is_some() {
239 write!(formatter, "failed: {}", stop_reason_words(message))
240 } else {
241 write!(
242 formatter,
243 "failed: {}",
244 message.lines().next().unwrap_or_default().trim()
245 )
246 }
247 }
248 }
249 }
250}
251
252fn stop_reason_words(stop_reason: &str) -> String {
255 let mut words = String::new();
256 let mut previous_lower = false;
257 for character in stop_reason.trim().chars() {
258 if character == '_' || character == '-' || character.is_whitespace() {
259 if !words.ends_with(' ') && !words.is_empty() {
260 words.push(' ');
261 }
262 previous_lower = false;
263 continue;
264 }
265 if character.is_uppercase() && previous_lower {
266 words.push(' ');
267 }
268 previous_lower = character.is_lowercase() || character.is_ascii_digit();
269 words.extend(character.to_lowercase());
270 }
271 match words.trim_end() {
272 "" => "no reason given".to_owned(),
273 words => words.to_owned(),
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PromptCompletion {
279 InputRequired,
280 Finished,
281 Cancelled,
282 QuotaLimit,
283 Error,
284}
285
286pub fn classify_prompt_completion(stop_reason: &str) -> PromptCompletion {
288 let normalized = stop_reason
289 .chars()
290 .filter(|character| *character != '_' && *character != '-')
291 .flat_map(char::to_lowercase)
292 .collect::<String>();
293 match normalized.as_str() {
294 "endturn" => PromptCompletion::Finished,
295 "awaitinginput" => PromptCompletion::InputRequired,
296 "cancelled" | "canceled" => PromptCompletion::Cancelled,
297 "quotalimit" => PromptCompletion::QuotaLimit,
298 _ => PromptCompletion::Error,
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct MaterializedTurnOutcome {
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub usage: Option<crate::usage::TokenUsage>,
311 pub command_id: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub accepted_ordinal: Option<u64>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub turn_start_position: Option<u64>,
316 pub completed_ordinal: u64,
317 pub completed_at_ms: i64,
318 pub outcome: TurnOutcomeKind,
319}
320
321impl MaterializedTurnOutcome {
322 pub fn interruption_ordinal(&self) -> Option<u64> {
324 (self.turn_start_position.is_some()
325 && matches!(self.outcome, TurnOutcomeKind::Interrupted { .. }))
326 .then_some(self.completed_ordinal)
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(deny_unknown_fields)]
333pub struct MaterializedSession {
334 pub session_id: String,
335 pub applied_event_ordinal: u64,
336 pub applied_event_digest: String,
337 pub last_activity_at_ms: Option<i64>,
340 pub execution: MaterializedExecutionState,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub session_title: Option<String>,
343 #[serde(default, skip_serializing_if = "SessionConfiguration::is_empty")]
344 pub configuration: SessionConfiguration,
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub transcript: Vec<Arc<TranscriptItem>>,
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub queued_prompts: Vec<MaterializedQueuedPrompt>,
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub active_turn: Option<MaterializedTurn>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct MaterializedSessionSummary {
368 pub session_id: String,
369 pub applied_event_ordinal: u64,
370 pub last_activity_at_ms: Option<i64>,
371 pub execution: MaterializedExecutionState,
372 pub session_title: Option<String>,
373 pub last_agent_message: Option<String>,
374 pub last_user_message: Option<String>,
375 pub last_agent_message_follows_last_user: bool,
378 pub agent_message_latest_content_ordinals: Vec<u64>,
379 pub interruption_event_ordinals: Vec<u64>,
380}
381
382impl MaterializedSession {
383 pub fn empty(session_id: impl Into<String>) -> Self {
384 Self {
385 session_id: session_id.into(),
386 applied_event_ordinal: 0,
387 applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
388 last_activity_at_ms: None,
389 execution: MaterializedExecutionState::Idle,
390 session_title: None,
391 configuration: SessionConfiguration::default(),
392 transcript: Vec::new(),
393 queued_prompts: Vec::new(),
394 pending_elicitations: Vec::new(),
395 active_turn: None,
396 last_turn_outcome: None,
397 }
398 }
399
400 pub fn last_activity_at_ms(&self) -> Option<i64> {
401 self.last_activity_at_ms
402 }
403
404 pub fn resolved_title(&self) -> Option<String> {
410 self.session_title
411 .as_deref()
412 .and_then(normalize_session_title)
413 .or_else(|| {
414 self.transcript.iter().find_map(|item| {
415 let TranscriptBody::User { content } = &item.body else {
416 return None;
417 };
418 provisional_session_title(&crate::transcript::materialized_content_text(
419 content,
420 ))
421 })
422 })
423 .or_else(|| {
424 self.queued_prompts
425 .iter()
426 .filter(|prompt| prompt.kind.is_prompt())
427 .find_map(|prompt| {
428 provisional_session_title(&crate::transcript::materialized_content_text(
429 &prompt.content,
430 ))
431 })
432 })
433 }
434
435 pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
436 self.transcript
437 .iter()
438 .filter(|item| {
439 item.latest_content_event_ordinal
440 .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
441 && item.is_nonempty_agent_message()
442 })
443 .count() as u64
444 }
445
446 pub fn unread_interruptions_after(&self, viewed_through_event_ordinal: u64) -> u64 {
447 self.interruption_event_ordinals()
448 .into_iter()
449 .filter(|ordinal| *ordinal > viewed_through_event_ordinal)
450 .count() as u64
451 }
452
453 pub fn interruption_event_ordinals(&self) -> Vec<u64> {
454 let mut ordinals = self
455 .transcript
456 .iter()
457 .filter(|item| item.is_work_interruption())
458 .map(|item| item.position)
459 .collect::<Vec<_>>();
460 if let Some(ordinal) = self
461 .last_turn_outcome
462 .as_ref()
463 .and_then(MaterializedTurnOutcome::interruption_ordinal)
464 {
465 ordinals.push(ordinal);
466 }
467 ordinals.sort_unstable();
468 ordinals.dedup();
469 ordinals
470 }
471
472 pub fn validate(&self) -> Result<()> {
473 validate_id("session", &self.session_id)?;
474 validate_relay_event_frontier(
475 self.applied_event_ordinal,
476 &self.applied_event_digest,
477 "materialized session event frontier",
478 )?;
479 if self
480 .session_title
481 .as_ref()
482 .is_some_and(|title| title.trim().is_empty())
483 {
484 bail!("materialized session has an empty title");
485 }
486 let mut item_ids = BTreeSet::new();
487 for item in &self.transcript {
488 item.validate(self.applied_event_ordinal)?;
489 if !item_ids.insert(item.stable_id.as_str()) {
490 bail!(
491 "materialized transcript contains duplicate item {:?}",
492 item.stable_id
493 );
494 }
495 }
496 let mut command_ids = BTreeSet::new();
497 for prompt in &self.queued_prompts {
498 if prompt.command_id.trim().is_empty() {
499 bail!("materialized prompt queue has an empty command id");
500 }
501 if !command_ids.insert(prompt.command_id.as_str()) {
502 bail!(
503 "materialized prompt queue contains duplicate command {:?}",
504 prompt.command_id
505 );
506 }
507 if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
508 && (key.trim().is_empty() || value.trim().is_empty())
509 {
510 bail!(
511 "materialized queued configuration change {:?} is incomplete",
512 prompt.command_id
513 );
514 }
515 }
516 Ok(())
517 }
518}
519
520#[derive(Debug, Clone, PartialEq)]
524pub struct ManagedSessionSnapshot {
525 pub materialized: MaterializedSession,
526 pub window: ProjectionWindow,
529 pub operational: RelayOperationalState,
530 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
534 pub worker_build: Option<String>,
539 pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
541 pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
543}
544
545#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
557pub struct ProjectionWindow {
558 pub omitted_items: usize,
560 pub provisional_title: Option<String>,
562 pub latest_turn_start_position: Option<u64>,
566}
567
568impl ProjectionWindow {
569 pub fn trim(&mut self, session: &mut MaterializedSession, target: usize) {
572 let observed = Self::of(session);
573 if self.provisional_title.is_none() {
574 self.provisional_title = observed.provisional_title;
575 }
576 self.latest_turn_start_position = observed
577 .latest_turn_start_position
578 .or(self.latest_turn_start_position);
579 let mut boundary = session.transcript.len().saturating_sub(target.max(1));
580 for (index, item) in session.transcript.iter().enumerate() {
581 let mutable = match &item.body {
582 TranscriptBody::Agent { streaming, .. }
583 | TranscriptBody::Thought { streaming, .. } => *streaming,
584 TranscriptBody::Tool { call, .. } => matches!(
585 call.get("status").and_then(serde_json::Value::as_str),
586 Some("pending" | "in_progress")
587 ),
588 _ => false,
589 };
590 if mutable || Some(item.position) == self.latest_turn_start_position {
591 boundary = boundary.min(index);
592 }
593 }
594 let cut = session
595 .transcript
596 .iter()
597 .take(boundary + 1)
598 .rposition(|item| item.is_turn_start())
599 .unwrap_or(0);
600 if cut > 0 {
601 session.transcript.drain(..cut);
602 self.omitted_items += cut;
603 }
604 }
605
606 #[must_use]
608 pub fn of(session: &MaterializedSession) -> Self {
609 Self {
610 omitted_items: 0,
611 provisional_title: session.transcript.iter().find_map(|item| {
612 let TranscriptBody::User { content } = &item.body else {
613 return None;
614 };
615 provisional_session_title(&crate::transcript::materialized_content_text(content))
616 }),
617 latest_turn_start_position: session
618 .transcript
619 .iter()
620 .rev()
621 .find(|item| item.is_turn_start())
622 .map(|item| item.position),
623 }
624 }
625}
626
627impl ManagedSessionSnapshot {
628 #[must_use]
633 pub fn resolved_title(&self) -> Option<String> {
634 self.materialized
635 .session_title
636 .as_deref()
637 .and_then(normalize_session_title)
638 .or_else(|| self.window.provisional_title.clone())
639 .or_else(|| {
640 self.materialized
641 .queued_prompts
642 .iter()
643 .filter(|prompt| prompt.kind.is_prompt())
644 .find_map(|prompt| {
645 provisional_session_title(&crate::transcript::materialized_content_text(
646 &prompt.content,
647 ))
648 })
649 })
650 }
651
652 #[must_use]
657 pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
658 if self.materialized.execution != MaterializedExecutionState::Idle {
659 return None;
660 }
661 self.window.latest_turn_start_position
662 }
663}
664
665#[derive(Debug, Clone)]
667pub struct RecoveryObservation {
668 pub session: SessionRecord,
669 pub config: Config,
670 pub latest_completed_turn_ordinal: Option<u64>,
671 pub checkpoint_wait: Option<crate::activity::CheckpointWait>,
675}
676
677pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
682 if session.execution != MaterializedExecutionState::Idle {
683 return None;
684 }
685 session
686 .transcript
687 .iter()
688 .rev()
689 .find(|item| item.is_turn_start())
690 .map(|item| item.position)
691}
692
693pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
694 if digest.len() != 64
695 || !digest
696 .bytes()
697 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
698 {
699 bail!("{name} must be a lowercase SHA-256 digest");
700 }
701 Ok(())
702}
703
704pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
705 validate_relay_event_digest(digest, name)?;
706 if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
707 bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
708 }
709 Ok(())
710}
711
712fn is_false(value: &bool) -> bool {
713 !*value
714}
715
716impl SessionState {
717 pub const fn as_str(self) -> &'static str {
719 match self {
720 Self::Provisioning => "provisioning",
721 Self::StartupCleanup => "startup-cleanup",
722 Self::Running => "running",
723 Self::Disconnected => "disconnected",
724 Self::Checkpointing => "checkpointing",
725 Self::Closing => "closing",
726 Self::Destroying => "destroying",
727 Self::Stopped => "stopped",
728 Self::Parked => "parked",
729 Self::Lost => "lost",
730 Self::Error => "error",
731 Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
732 }
733 }
734
735 pub fn from_stored(value: &str) -> Option<Self> {
738 Some(match value {
739 "provisioning" => Self::Provisioning,
740 "startup-cleanup" => Self::StartupCleanup,
741 "running" => Self::Running,
742 "disconnected" => Self::Disconnected,
743 "checkpointing" => Self::Checkpointing,
744 "closing" => Self::Closing,
745 "destroying" => Self::Destroying,
746 "stopped" | "archived" => Self::Stopped,
747 "parked" => Self::Parked,
748 "lost" => Self::Lost,
749 "error" => Self::Error,
750 "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
751 _ => return None,
752 })
753 }
754
755 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
758 match self {
759 Self::Provisioning => Some(SessionTransitionKind::Starting),
760 Self::StartupCleanup => Some(SessionTransitionKind::Stopping),
761 Self::Closing => Some(SessionTransitionKind::Suspending),
762 Self::Destroying => Some(SessionTransitionKind::Destroying),
763 _ => None,
764 }
765 }
766
767 pub const fn is_active(self) -> bool {
774 matches!(
775 self,
776 Self::Provisioning
777 | Self::StartupCleanup
778 | Self::Running
779 | Self::Disconnected
780 | Self::Checkpointing
781 | Self::Closing
782 | Self::Destroying
783 | Self::Parked
784 | Self::Error
785 )
786 }
787
788 pub const fn has_live_worker(self) -> bool {
792 matches!(
793 self,
794 Self::Provisioning
795 | Self::StartupCleanup
796 | Self::Running
797 | Self::Disconnected
798 | Self::Checkpointing
799 | Self::Closing
800 | Self::Destroying
801 )
802 }
803}
804
805#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
806#[serde(tag = "kind", rename_all = "kebab-case")]
807pub enum PodmanWorkspaceLocator {
808 #[default]
809 ContainerLayer,
810 Volume {
811 name: String,
812 },
813 HostPath {
814 path: PathBuf,
815 helper: Vec<String>,
816 resource: String,
817 },
818}
819
820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821#[serde(tag = "kind", rename_all = "kebab-case")]
822pub enum TargetLocator {
823 LocalBare {
824 worker_root: PathBuf,
825 },
826 LocalPodman {
827 container_id: String,
828 #[serde(default)]
829 workspace_storage: PodmanWorkspaceLocator,
830 #[serde(default, skip_serializing_if = "Option::is_none")]
834 borrowed_from: Option<String>,
835 },
836 LocalDocker {
837 container_id: String,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
842 borrowed_from: Option<String>,
843 },
844 AppleContainer {
845 container_id: String,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
850 borrowed_from: Option<String>,
851 },
852 AwsEc2 {
853 instance_id: String,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
855 address: Option<String>,
856 },
857 SshBare {
858 host: String,
859 workspace: PathBuf,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 worker_id: Option<String>,
862 },
863 SshPodman {
864 host: String,
865 container_id: String,
866 #[serde(default)]
867 workspace_storage: PodmanWorkspaceLocator,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
872 borrowed_from: Option<String>,
873 },
874 SshDocker {
875 host: String,
876 container_id: String,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
881 borrowed_from: Option<String>,
882 },
883}
884
885impl ManagedWorktreeTarget {
886 pub fn same_location(&self, other: &Self) -> bool {
894 match (self, other) {
895 (Self::Local, Self::Local) => true,
896 (
897 Self::Ssh {
898 destination,
899 ssh_args,
900 },
901 Self::Ssh {
902 destination: other_destination,
903 ssh_args: other_args,
904 },
905 ) => {
906 destination == other_destination
907 && ssh_location_option(ssh_args, 'p', "port")
908 == ssh_location_option(other_args, 'p', "port")
909 && ssh_location_option(ssh_args, 'l', "user")
910 == ssh_location_option(other_args, 'l', "user")
911 }
912 _ => false,
913 }
914 }
915}
916
917fn ssh_location_option(args: &[String], flag: char, option: &str) -> Option<String> {
921 let mut args = args.iter();
922 while let Some(argument) = args.next() {
923 let Some(rest) = argument.strip_prefix('-') else {
924 continue;
925 };
926 let mut chars = rest.chars();
927 let Some(name) = chars.next() else { continue };
928 if name != flag && name != 'o' {
929 continue;
930 }
931 let inline = chars.as_str();
932 let value = if inline.is_empty() {
933 args.next().cloned()
934 } else {
935 Some(inline.to_owned())
936 };
937 if name == flag {
938 return value;
939 }
940 if let Some(setting) = value {
941 let (key, found) = setting
942 .split_once(['=', ' ', '\t'])
943 .unwrap_or((setting.as_str(), ""));
944 if key.trim().eq_ignore_ascii_case(option) {
945 return Some(found.trim().to_owned());
946 }
947 }
948 }
949 None
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(tag = "kind", rename_all = "kebab-case")]
954pub enum ManagedWorktreeTarget {
955 Local,
956 Ssh {
957 destination: String,
958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
959 ssh_args: Vec<String>,
960 },
961}
962
963#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct ManagedWorktreeOptions {
967 pub available: bool,
968 pub default_create: bool,
969}
970
971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct ManagedWorktree {
974 #[serde(default, skip_serializing_if = "ManagedCheckoutKind::is_worktree")]
976 pub kind: ManagedCheckoutKind,
977 pub source_project_directory: PathBuf,
978 pub source_repository: PathBuf,
979 pub worktree_root: PathBuf,
980 pub branch: String,
981 pub target: ManagedWorktreeTarget,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub base_commit: Option<String>,
987}
988
989#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
990#[serde(rename_all = "snake_case")]
991pub enum ManagedCheckoutKind {
992 #[default]
993 Worktree,
994 Clone,
995}
996
997impl ManagedCheckoutKind {
998 fn is_worktree(&self) -> bool {
999 matches!(self, Self::Worktree)
1000 }
1001}
1002
1003impl ManagedWorktree {
1004 fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
1005 for (label, path) in [
1006 ("source project directory", &self.source_project_directory),
1007 ("source repository", &self.source_repository),
1008 ("worktree root", &self.worktree_root),
1009 ] {
1010 if !crate::target_path::is_absolute(path)
1011 || path.components().any(|part| part == Component::ParentDir)
1012 {
1013 bail!("managed worktree {label} must be an absolute safe path");
1014 }
1015 }
1016 if !self
1017 .source_project_directory
1018 .starts_with(&self.source_repository)
1019 {
1020 bail!("managed worktree source directory is outside its repository");
1021 }
1022 let expected_root = self
1023 .source_repository
1024 .join(".mj")
1025 .join(match self.kind {
1026 ManagedCheckoutKind::Worktree => "worktrees",
1027 ManagedCheckoutKind::Clone => "clones",
1028 })
1029 .join(session_id);
1030 if self.worktree_root != expected_root {
1031 bail!("managed worktree root does not match the session-owned path");
1032 }
1033 if self.kind == ManagedCheckoutKind::Worktree && self.branch != format!("mj/{session_id}") {
1034 bail!("managed worktree branch does not match the session id");
1035 }
1036 if self.kind == ManagedCheckoutKind::Clone && self.branch.trim().is_empty() {
1037 bail!("managed clone has no starting branch");
1038 }
1039 let relative = self
1040 .source_project_directory
1041 .strip_prefix(&self.source_repository)
1042 .expect("source relationship checked above");
1043 if project_directory != Some(self.worktree_root.join(relative).as_path()) {
1044 bail!("session project directory does not match its managed worktree");
1045 }
1046 match &self.target {
1047 ManagedWorktreeTarget::Local => {}
1048 ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
1049 bail!("managed SSH worktree has an empty destination")
1050 }
1051 ManagedWorktreeTarget::Ssh { .. } => {}
1052 }
1053 Ok(())
1054 }
1055}
1056
1057#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058#[serde(tag = "kind", rename_all = "kebab-case")]
1059pub enum SessionResourceAllocation {
1060 Container {
1061 cpus: u64,
1062 memory_bytes: u64,
1063 },
1064 AwsEc2 {
1065 instance_type: String,
1066 vcpus: u64,
1067 memory_bytes: u64,
1068 },
1069}
1070
1071#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1073#[serde(rename_all = "kebab-case")]
1074pub enum ResourceAllocationKind {
1075 #[default]
1076 Fixed,
1077 Container,
1078 AwsEc2,
1079}
1080
1081impl SessionResourceAllocation {
1082 pub fn validate(&self) -> Result<()> {
1083 match self {
1084 Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
1085 bail!("container resource allocation must have non-zero CPU and memory")
1086 }
1087 Self::AwsEc2 {
1088 instance_type,
1089 vcpus,
1090 memory_bytes,
1091 } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
1092 bail!("EC2 resource allocation must have an instance type, CPU, and memory")
1093 }
1094 _ => Ok(()),
1095 }
1096 }
1097}
1098
1099pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
1101 match allocation {
1102 SessionResourceAllocation::Container { cpus, .. } => *cpus,
1103 SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
1104 }
1105}
1106
1107pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
1109 match allocation {
1110 SessionResourceAllocation::Container { memory_bytes, .. }
1111 | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
1112 }
1113}
1114
1115impl TargetLocator {
1116 pub const fn skills_scope(&self) -> crate::skills::SkillsScope {
1118 match self {
1119 Self::LocalBare { .. } => crate::skills::SkillsScope::Localhost,
1120 _ => crate::skills::SkillsScope::Isolated,
1121 }
1122 }
1123
1124 fn validate(&self, session_id: &str) -> Result<()> {
1125 match self {
1126 Self::LocalBare { worker_root } => {
1127 if !crate::target_path::is_absolute(worker_root)
1128 || worker_root
1129 .components()
1130 .any(|part| part == Component::ParentDir)
1131 || !worker_root.ends_with(session_id)
1132 {
1133 bail!(
1134 "local bare worker root must be an absolute safe path ending in the session id"
1135 );
1136 }
1137 }
1138 Self::LocalPodman { container_id, .. }
1139 | Self::LocalDocker { container_id, .. }
1140 | Self::AppleContainer { container_id, .. }
1141 | Self::SshPodman { container_id, .. }
1142 | Self::SshDocker { container_id, .. }
1143 if container_id.trim().is_empty() =>
1144 {
1145 bail!("target locator has an empty container id")
1146 }
1147 Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
1148 bail!("target locator has an empty AWS instance id")
1149 }
1150 Self::SshBare {
1151 host,
1152 workspace,
1153 worker_id,
1154 } => {
1155 if host.trim().is_empty() {
1156 bail!("bare SSH target locator has an empty host");
1157 }
1158 let unsafe_path = workspace.as_os_str().is_empty()
1159 || workspace
1160 .components()
1161 .any(|part| part == Component::ParentDir);
1162 match worker_id {
1163 Some(worker_id) => {
1166 if worker_id != session_id {
1167 bail!(
1168 "bare SSH target locator's worker identity does not match the session id"
1169 );
1170 }
1171 if unsafe_path {
1172 bail!("bare SSH target locator must have a safe workspace path");
1173 }
1174 }
1175 None => {
1176 if unsafe_path || !workspace.ends_with(session_id) {
1177 bail!(
1178 "bare SSH target locator must be a safe path ending in the session id"
1179 );
1180 }
1181 }
1182 }
1183 }
1184 Self::SshPodman { host, .. } if host.trim().is_empty() => {
1185 bail!("SSH Podman target locator has an empty host")
1186 }
1187 Self::SshDocker { host, .. } if host.trim().is_empty() => {
1188 bail!("SSH Docker target locator has an empty host")
1189 }
1190 _ => {}
1191 }
1192 Ok(())
1193 }
1194}
1195
1196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1197#[serde(deny_unknown_fields)]
1198pub struct CheckpointMetadata {
1199 pub archive_path: PathBuf,
1200 pub sha256: String,
1202 pub created_at: String,
1203 pub event_frontier: u64,
1204}
1205
1206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1208#[serde(rename_all = "snake_case")]
1209pub enum PublicationState {
1210 Published,
1211 Unpublished,
1212 Unknown,
1213}
1214
1215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1217pub struct PublicationAssessment {
1218 pub checkpoint_sha256: String,
1219 pub state: PublicationState,
1220 pub dirty: bool,
1221 pub stashed: bool,
1222 pub saved_commits: Vec<String>,
1223 pub destinations: Vec<String>,
1224 pub checked_at: String,
1225 pub reason: Option<String>,
1226}
1227
1228impl CheckpointMetadata {
1229 fn validate(&self) -> Result<()> {
1230 if self.archive_path.as_os_str().is_empty() {
1231 bail!("checkpoint archive path is empty");
1232 }
1233 if self.sha256.len() != 64
1234 || !self
1235 .sha256
1236 .bytes()
1237 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1238 {
1239 bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
1240 }
1241 if self.created_at.trim().is_empty() {
1242 bail!("checkpoint timestamp is empty");
1243 }
1244 Ok(())
1245 }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1253#[serde(deny_unknown_fields)]
1254pub struct SessionBuildCache {
1255 pub host: String,
1258 pub directory: PathBuf,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1262 pub max_size: Option<String>,
1263 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub target_root: Option<PathBuf>,
1267}
1268
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1273pub struct ArchiveSpacePreview {
1274 pub sessions: usize,
1276 pub bytes: u64,
1278 pub reclaimable_sessions: usize,
1281 pub reclaimable_bytes: u64,
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Eq)]
1288pub struct BuildCachePreview {
1289 pub native_mbx: Option<String>,
1291 pub mbx_profile_file: Option<String>,
1293 pub mbx_profile_warning: Option<String>,
1295 pub mbx_manual_path_line: Option<String>,
1297 pub directory: Option<PathBuf>,
1299 pub max_total_size: Option<BuildCacheLimit>,
1301 pub user_managed: bool,
1303 pub application: BuildCacheApplication,
1304 pub budget_note: Option<String>,
1306 pub stats: Option<BuildCacheStats>,
1308 pub off_reason: Option<BuildCacheOff>,
1311}
1312
1313#[derive(Debug, Clone, PartialEq, Eq)]
1321pub struct BuildCacheStats {
1322 pub builds: u64,
1324 pub cached_compilations: u64,
1326 pub avoided_compiler_ns: u64,
1328 pub reflinked_bytes: u64,
1330}
1331
1332#[derive(Debug, Clone, PartialEq, Eq)]
1334pub enum BuildCacheOff {
1335 TurnedOff,
1337 Unavailable(String),
1340}
1341
1342impl std::fmt::Display for BuildCacheOff {
1343 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1344 match self {
1345 Self::TurnedOff => formatter.write_str("turned off for this machine"),
1346 Self::Unavailable(reason) => formatter.write_str(reason),
1347 }
1348 }
1349}
1350
1351#[derive(Debug, Clone, PartialEq, Eq)]
1353pub enum BuildCacheLimit {
1354 Size(String),
1356 HostConfiguration(Option<String>),
1359 MjDefault(String),
1361 MbxDefault(Option<String>),
1363}
1364
1365#[derive(Debug, Clone, Default, PartialEq, Eq)]
1366pub enum BuildCacheApplication {
1367 #[default]
1368 Pending,
1369 Applied,
1370 Failed(String),
1371}
1372
1373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1374#[serde(deny_unknown_fields)]
1375pub struct SessionRecord {
1376 pub id: String,
1377 #[serde(default = "default_session_workspace_id")]
1382 pub workspace_id: String,
1383 pub title: String,
1384 pub harness_kind: HarnessKind,
1385 pub last_profile: String,
1386 pub bundle_id: String,
1387 #[serde(default, skip_serializing_if = "Option::is_none")]
1389 pub project: Option<crate::repository::ProjectBundleSnapshot>,
1390 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub project_directory: Option<PathBuf>,
1393 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub managed_worktree: Option<ManagedWorktree>,
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1398 pub create_managed_worktree: Option<bool>,
1399 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 pub launch_base: Option<String>,
1407 #[serde(default, skip_serializing_if = "Option::is_none")]
1411 pub launch_branch: Option<String>,
1412 #[serde(default, skip_serializing_if = "Option::is_none")]
1416 pub checkout: Option<crate::remote_git::ExactCheckout>,
1417 #[serde(default, skip_serializing_if = "Option::is_none")]
1419 pub publication: Option<PublicationAssessment>,
1420 #[serde(
1422 default,
1423 alias = "mjolnir_subagents",
1424 deserialize_with = "crate::subagent::deserialize_optional_policy"
1425 )]
1426 pub subagents: Option<crate::subagent::SubagentPolicy>,
1427 #[serde(default, skip_serializing_if = "Option::is_none")]
1430 pub review: Option<crate::config::SessionReview>,
1431 pub target_template_id: String,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1433 pub resource_allocation: Option<SessionResourceAllocation>,
1434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1435 pub additional_mounts: Vec<AdditionalMount>,
1436 #[serde(default, skip_serializing_if = "Option::is_none")]
1439 pub container_cpus: Option<String>,
1440 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub container_memory: Option<String>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1450 pub container_workspace: Option<PathBuf>,
1451 #[serde(default, skip_serializing_if = "Option::is_none")]
1455 pub build_cache: Option<SessionBuildCache>,
1456 pub state: SessionState,
1457 #[serde(default, skip_serializing_if = "is_false")]
1460 pub archived: bool,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1462 pub target: Option<TargetLocator>,
1463 #[serde(default, skip_serializing_if = "Option::is_none")]
1465 pub target_runtime: Option<TargetRuntimeSettings>,
1466 #[serde(default, skip_serializing_if = "Option::is_none")]
1467 pub native_session_id: Option<String>,
1468 #[serde(default, skip_serializing_if = "Option::is_none")]
1469 pub acp_session_title: Option<String>,
1470 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub session_title_override: Option<String>,
1472 pub created_at: String,
1473 pub updated_at: String,
1474 #[serde(default, alias = "detached_after_event_ordinal")]
1475 pub viewed_through_event_ordinal: u64,
1476 #[serde(default, skip_serializing_if = "String::is_empty")]
1479 pub draft_input: String,
1480 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub last_error: Option<String>,
1489 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub last_checkpoint_error: Option<String>,
1491 #[serde(default, skip_serializing_if = "Option::is_none")]
1492 pub checkpoint: Option<CheckpointMetadata>,
1493}
1494
1495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1496#[serde(deny_unknown_fields)]
1497pub struct HostContainerSize {
1498 pub cpus: u64,
1499 pub memory_bytes: u64,
1500}
1501
1502pub const BASELINE_CONTAINER_CPUS: u64 = 8;
1503pub const BASELINE_CONTAINER_MEMORY_BYTES: u64 = 32 * 1024 * 1024 * 1024;
1504
1505pub fn clamp_container_size(
1508 size: HostContainerSize,
1509 limits: Option<HostContainerSize>,
1510) -> HostContainerSize {
1511 let Some(limits) = limits else {
1512 return HostContainerSize {
1513 cpus: size.cpus.max(1),
1514 memory_bytes: size.memory_bytes.max(1),
1515 };
1516 };
1517 HostContainerSize {
1518 cpus: size.cpus.min(limits.cpus.max(1)),
1519 memory_bytes: size.memory_bytes.min(limits.memory_bytes.max(1)),
1520 }
1521}
1522
1523pub fn default_container_size(
1526 remembered: Option<HostContainerSize>,
1527 limits: Option<HostContainerSize>,
1528) -> HostContainerSize {
1529 clamp_container_size(
1530 remembered.unwrap_or(HostContainerSize {
1531 cpus: BASELINE_CONTAINER_CPUS,
1532 memory_bytes: BASELINE_CONTAINER_MEMORY_BYTES,
1533 }),
1534 limits,
1535 )
1536}
1537
1538pub fn preferred_aws_allocation<'a>(
1541 options: &'a [SessionResourceAllocation],
1542 previous: Option<&SessionResourceAllocation>,
1543) -> Option<&'a SessionResourceAllocation> {
1544 if let Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) = previous
1545 && let Some(option) = options.iter().find(|option| {
1546 matches!(option, SessionResourceAllocation::AwsEc2 { instance_type: candidate, .. } if candidate == instance_type)
1547 })
1548 {
1549 return Some(option);
1550 }
1551 options
1552 .iter()
1553 .find(|option| allocation_cpus(option) == BASELINE_CONTAINER_CPUS)
1554 .or_else(|| options.first())
1555}
1556
1557fn default_session_workspace_id() -> String {
1558 crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1559}
1560
1561pub const DESTRUCTION_FAILURE_PREFIX: &str = "the destruction did not finish";
1570
1571pub const CLOSE_FAILURE_PREFIX: &str = "the suspension did not finish";
1572
1573pub const MOVE_FAILURE_PREFIX: &str = "the move did not finish";
1574
1575pub fn is_public_lifecycle_error(error: &str) -> bool {
1577 error.starts_with(CLOSE_FAILURE_PREFIX)
1578 || error.starts_with(MOVE_FAILURE_PREFIX)
1579 || error.starts_with(DESTRUCTION_FAILURE_PREFIX)
1580 || error.starts_with("the close did not finish")
1581}
1582
1583#[must_use]
1596pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1597 if !matches!(
1598 config.targets.get(target_id),
1599 Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1600 ) {
1601 return target_id.to_owned();
1602 }
1603 project.and_then(Path::file_name).map_or_else(
1604 || target_id.to_owned(),
1605 |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1606 )
1607}
1608
1609#[derive(Debug, Clone, Default, PartialEq, Eq)]
1611pub struct StartSelection {
1612 pub at: Option<String>,
1614 pub branch: Option<String>,
1616 pub base: Option<String>,
1618}
1619
1620impl SessionRecord {
1621 #[must_use]
1625 pub fn checkout(&self) -> Checkout<'_> {
1626 derive_record_checkout(self)
1627 }
1628
1629 pub fn start_selection(&self) -> StartSelection {
1633 let at = self
1634 .checkout
1635 .as_ref()
1636 .map(|checkout| checkout.commit.clone());
1637 StartSelection {
1638 branch: self
1639 .checkout
1640 .as_ref()
1641 .and_then(|checkout| checkout.branch.clone())
1642 .or_else(|| self.launch_branch.clone()),
1643 base: self.launch_base.clone().or_else(|| at.clone()),
1644 at,
1645 }
1646 }
1647
1648 pub fn publication_state(&self) -> Option<PublicationState> {
1651 let independent_clone = match self.checkout().effective() {
1652 Checkout::ManagedWorktree { worktree, .. } => {
1653 worktree.kind == ManagedCheckoutKind::Clone
1654 }
1655 Checkout::ManagedWorkspace => true,
1656 Checkout::Attached { .. } | Checkout::Borrowed { .. } => false,
1657 };
1658 if !independent_clone {
1659 return None;
1660 }
1661 if self.state.is_active() {
1662 return Some(PublicationState::Unknown);
1663 }
1664 Some(
1665 self.checkpoint
1666 .as_ref()
1667 .zip(self.publication.as_ref())
1668 .filter(|(checkpoint, assessment)| {
1669 assessment.checkpoint_sha256 == checkpoint.sha256
1670 })
1671 .map_or(PublicationState::Unknown, |(_, assessment)| {
1672 if assessment.dirty || assessment.stashed {
1673 PublicationState::Unpublished
1674 } else {
1675 assessment.state
1676 }
1677 }),
1678 )
1679 }
1680
1681 pub fn target_runtime_settings<'a>(
1686 &'a self,
1687 config: &Config,
1688 ) -> Result<std::borrow::Cow<'a, TargetRuntimeSettings>> {
1689 if let Some(runtime) = &self.target_runtime {
1690 if let Some(refreshed) =
1693 config
1694 .targets
1695 .get(&self.target_template_id)
1696 .and_then(|template| {
1697 runtime.with_current_ssh_options(&TargetRuntimeSettings::from(template))
1698 })
1699 {
1700 return Ok(std::borrow::Cow::Owned(refreshed));
1701 }
1702 return Ok(std::borrow::Cow::Borrowed(runtime));
1703 }
1704 let template = config.targets.get(&self.target_template_id).ok_or_else(|| {
1705 crate::refusal::Refusal::precondition(format!(
1706 "Session {:?} has no recorded target access settings. Restore target {:?} in config.toml once, then retry.",
1707 self.id, self.target_template_id))
1708 })?;
1709 let runtime = TargetRuntimeSettings::from(template);
1710 if let Some(locator) = &self.target {
1711 crate::targets::TargetLocator::try_from(crate::targets::RecordedTarget {
1712 locator, runtime: Some(&runtime), session_id: &self.id,
1713 }).map_err(|error| crate::refusal::Refusal::precondition(format!(
1714 "Session {:?} cannot recover target {:?}: {error}. Restore its original target settings, then retry.", self.id, self.target_template_id)))?;
1715 }
1716 Ok(std::borrow::Cow::Owned(runtime))
1717 }
1718
1719 #[must_use]
1723 pub fn public_error(&self) -> Option<&str> {
1724 self.last_error
1725 .as_deref()
1726 .filter(|error| is_public_lifecycle_error(error))
1727 }
1728
1729 pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1732 if !self.state.is_active() {
1733 return None;
1734 }
1735 let mut issues = Vec::new();
1736 match config.profiles.get(&self.last_profile) {
1737 None => issues.push(format!("missing profile {:?}", self.last_profile)),
1738 Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1739 "expects {:?}, but profile {:?} is {:?}",
1740 self.harness_kind, self.last_profile, profile.kind
1741 )),
1742 Some(_) => {}
1743 }
1744 if self.checkout().project_directory().is_none() && self.project_bundle(config).is_none() {
1745 issues.push(format!("missing bundle {:?}", self.bundle_id));
1746 }
1747 if self.target_runtime.is_none() && !config.targets.contains_key(&self.target_template_id) {
1748 issues.push(format!(
1749 "missing target template {:?}",
1750 self.target_template_id
1751 ));
1752 }
1753 (!issues.is_empty()).then(|| format!(
1754 "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.",
1755 self.id, issues.join("; ")
1756 ))
1757 }
1758
1759 pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1760 if let Some(issue) = self.configuration_issue(config) {
1761 return Err(crate::refusal::Refusal::precondition(issue).into());
1762 }
1763 Ok(())
1764 }
1765
1766 pub fn display_title(&self) -> &str {
1768 self.session_title_override
1769 .as_deref()
1770 .or(self.acp_session_title.as_deref())
1771 .unwrap_or(&self.id)
1772 }
1773
1774 pub fn listed_title(&self) -> &str {
1779 let named = self.session_title_override.is_some() || self.acp_session_title.is_some();
1780 if !named && !self.title.trim().is_empty() {
1781 return &self.title;
1782 }
1783 self.display_title()
1784 }
1785
1786 pub fn project_name(&self, config: &Config) -> String {
1791 if let Some(project) = &self.project {
1792 return project.name();
1793 }
1794 match self.checkout().effective() {
1795 Checkout::ManagedWorktree { worktree, .. } => path_leaf(&worktree.source_repository),
1796 Checkout::Attached { path } => path_leaf(path),
1797 Checkout::ManagedWorkspace => self.bundle_source_name(config),
1798 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1799 }
1800 }
1801
1802 pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1806 let checkout = self.checkout();
1807 let project = match checkout.effective() {
1808 Checkout::ManagedWorktree { worktree, .. } => {
1809 Some(worktree.source_project_directory.as_path())
1810 }
1811 Checkout::Attached { path } => Some(path),
1812 Checkout::ManagedWorkspace => None,
1813 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1814 };
1815 target_label(config, target_id, project)
1816 }
1817
1818 pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1823 if let Some(project) = &self.project {
1824 return ProjectSourceIdentity {
1825 key: project
1826 .source_key()
1827 .expect("accepted project has complete identities"),
1828 short: project.name(),
1829 full: project
1830 .identities
1831 .values()
1832 .map(crate::repository::RepositoryIdentity::key)
1833 .collect::<Vec<_>>()
1834 .join(" + "),
1835 };
1836 }
1837 match self.checkout().effective() {
1838 Checkout::ManagedWorktree { worktree, .. } => {
1839 ProjectSourceIdentity::path(&worktree.source_repository, None)
1840 }
1841 Checkout::Attached { path } => {
1842 let remote = match &self.target {
1843 Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1844 _ => None,
1845 };
1846 ProjectSourceIdentity::path(path, remote)
1847 }
1848 Checkout::ManagedWorkspace => {
1849 self.bundle_source_identity(config)
1850 .unwrap_or_else(|| ProjectSourceIdentity {
1851 key: format!("bundle:{}", self.bundle_id),
1852 short: path_leaf(Path::new(&self.bundle_id)),
1853 full: self.bundle_id.clone(),
1854 })
1855 }
1856 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1857 }
1858 }
1859
1860 fn bundle_source_name(&self, config: &Config) -> String {
1863 self.bundle_source_identity(config)
1864 .map(|source| source.short)
1865 .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1866 }
1867
1868 fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1871 let bundle = self.project_bundle(config)?;
1872 let sources = bundle
1873 .repositories
1874 .iter()
1875 .map(repository_source_identity)
1876 .collect::<Option<Vec<_>>>()?;
1877 ProjectSourceIdentity::bundle(sources)
1878 }
1879
1880 pub fn project_bundle<'a>(
1882 &'a self,
1883 config: &'a Config,
1884 ) -> Option<&'a crate::config::ProjectBundle> {
1885 self.project
1886 .as_ref()
1887 .map(|project| &project.bundle)
1888 .or_else(|| config.bundles.get(&self.bundle_id))
1889 }
1890
1891 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1895 self.creation_order_key().cmp(&other.creation_order_key())
1896 }
1897
1898 pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1900 let timestamp = created_at_seconds(&self.created_at);
1901 (timestamp.is_none(), timestamp, &self.id)
1902 }
1903
1904 fn validate(&self, map_id: &str) -> Result<()> {
1905 validate_id("session", &self.id)?;
1906 if self.id != map_id {
1907 bail!(
1908 "session map key {map_id:?} does not match record id {:?}",
1909 self.id
1910 );
1911 }
1912 if let Some(project) = &self.project {
1913 project.key()?;
1914 }
1915 validate_id("workspace", &self.workspace_id)?;
1916 validate_id("profile", &self.last_profile)?;
1917 validate_id("bundle", &self.bundle_id)?;
1918 if let Some(project_directory) = &self.project_directory
1919 && (!crate::target_path::is_absolute_on_host_or_target(project_directory)
1920 || project_directory
1921 .components()
1922 .any(|part| part == Component::ParentDir))
1923 {
1924 bail!("session {:?} has an unsafe project directory", self.id);
1925 }
1926 if let Some(managed_worktree) = &self.managed_worktree {
1927 managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1928 }
1929 validate_id("target template", &self.target_template_id)?;
1930 if let Some(allocation) = &self.resource_allocation {
1931 allocation.validate()?;
1932 }
1933 validate_additional_mounts(&self.additional_mounts)?;
1934 if self.title.trim().is_empty() {
1935 bail!("session {:?} has an empty title", self.id);
1936 }
1937 if self
1938 .acp_session_title
1939 .as_ref()
1940 .is_some_and(|title| title.trim().is_empty())
1941 || self
1942 .session_title_override
1943 .as_ref()
1944 .is_some_and(|title| title.trim().is_empty())
1945 {
1946 bail!("session {:?} has an empty display title", self.id);
1947 }
1948 if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1949 bail!("session {:?} has an empty timestamp", self.id);
1950 }
1951 if let Some(target) = &self.target {
1952 target.validate(&self.id)?;
1953 }
1954 if let Some(checkpoint) = &self.checkpoint {
1955 checkpoint.validate()?;
1956 }
1957 Ok(())
1958 }
1959}
1960
1961fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1962 repository
1963 .github
1964 .as_deref()
1965 .and_then(ProjectSourceIdentity::git_remote)
1966 .or_else(|| {
1967 repository
1968 .local
1969 .as_deref()
1970 .map(|path| ProjectSourceIdentity::path(path, None))
1971 })
1972}
1973
1974#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1975pub struct ProjectSourceIdentity {
1976 pub key: String,
1977 pub short: String,
1978 pub full: String,
1979}
1980
1981impl ProjectSourceIdentity {
1982 pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1984 if sources.is_empty() {
1985 return None;
1986 }
1987 sources.sort_by(|left, right| {
1988 left.key
1989 .cmp(&right.key)
1990 .then_with(|| left.full.cmp(&right.full))
1991 .then_with(|| left.short.cmp(&right.short))
1992 });
1993 sources.dedup_by(|left, right| left.key == right.key);
1994 if sources.len() == 1 {
1995 return sources.pop();
1996 }
1997 let keys = sources
1998 .iter()
1999 .map(|source| source.key.clone())
2000 .collect::<Vec<_>>();
2001 let key = serde_json::to_string(&keys).ok()?;
2002 Some(Self {
2003 key: format!("bundle:{key}"),
2004 short: sources
2005 .iter()
2006 .map(|source| source.short.as_str())
2007 .collect::<Vec<_>>()
2008 .join(" + "),
2009 full: sources
2010 .iter()
2011 .map(|source| source.full.as_str())
2012 .collect::<Vec<_>>()
2013 .join(" + "),
2014 })
2015 }
2016
2017 pub fn git_remote(source: &str) -> Option<Self> {
2020 let identity = crate::repository::RepositoryIdentity::from_remote(source)?;
2021 let full = crate::repository::RepositoryIdentity::remote_label(source)?;
2022 Some(Self {
2023 key: identity.key(),
2024 short: full.rsplit(['/', ':']).next()?.to_owned(),
2025 full,
2026 })
2027 }
2028
2029 pub fn path(path: &Path, remote: Option<&str>) -> Self {
2032 let normalized = path.components().collect::<PathBuf>();
2033 let path_text = crate::target_path::text(&normalized);
2034 let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
2035 let key = remote.map_or_else(
2036 || format!("path:{path_text}"),
2037 |host| format!("path:{}:{path_text}", host.to_lowercase()),
2038 );
2039 Self {
2040 key,
2041 short: path_leaf(path),
2042 full,
2043 }
2044 }
2045}
2046
2047fn path_leaf(path: &Path) -> String {
2049 path.file_name()
2050 .unwrap_or(path.as_os_str())
2051 .to_string_lossy()
2052 .into_owned()
2053}
2054
2055fn created_at_seconds(timestamp: &str) -> Option<i64> {
2056 chrono::DateTime::parse_from_rfc3339(timestamp)
2057 .ok()
2058 .map(|timestamp| timestamp.timestamp())
2059}
2060
2061#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2062#[serde(deny_unknown_fields)]
2063pub struct State {
2064 #[serde(default)]
2065 pub last_subagent_policy: crate::subagent::SubagentPolicy,
2066 pub version: u32,
2067 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2068 pub sessions: SnapshotMap<String, SessionRecord>,
2069 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2072 pub subagents: SnapshotMap<String, SubagentRecord>,
2073 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2075 pub mount_history: SnapshotMap<String, Vec<PathBuf>>,
2076 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2078 pub container_sizes: SnapshotMap<String, HostContainerSize>,
2079}
2080
2081impl Default for State {
2082 fn default() -> Self {
2083 Self {
2084 version: STATE_VERSION,
2085 last_subagent_policy: Default::default(),
2086 sessions: SnapshotMap::new(),
2087 subagents: SnapshotMap::new(),
2088 mount_history: SnapshotMap::new(),
2089 container_sizes: SnapshotMap::new(),
2090 }
2091 }
2092}
2093
2094#[derive(Debug, Clone, PartialEq, Eq)]
2101pub enum Checkout<'a> {
2102 Attached { path: &'a Path },
2104 ManagedWorktree {
2111 worktree: &'a ManagedWorktree,
2112 project_directory: Option<&'a Path>,
2113 },
2114 ManagedWorkspace,
2117 Borrowed {
2119 owner: &'a SessionRecord,
2120 checkout: Box<Checkout<'a>>,
2121 },
2122}
2123
2124fn derive_record_checkout(session: &SessionRecord) -> Checkout<'_> {
2126 if let Some(worktree) = &session.managed_worktree {
2127 return Checkout::ManagedWorktree {
2128 worktree,
2129 project_directory: session.project_directory.as_deref(),
2130 };
2131 }
2132 if let Some(path) = session.project_directory.as_deref() {
2133 return Checkout::Attached { path };
2134 }
2135 Checkout::ManagedWorkspace
2136}
2137
2138impl<'a> Checkout<'a> {
2139 #[must_use]
2142 pub fn managed_worktree(&self) -> Option<&'a ManagedWorktree> {
2143 match self {
2144 Self::ManagedWorktree { worktree, .. } => Some(*worktree),
2145 Self::Attached { .. } | Self::ManagedWorkspace | Self::Borrowed { .. } => None,
2146 }
2147 }
2148
2149 #[must_use]
2153 pub fn effective(&self) -> Checkout<'a> {
2154 match self {
2155 Self::Borrowed { checkout, .. } => checkout.effective(),
2156 checkout => checkout.clone(),
2157 }
2158 }
2159
2160 #[must_use]
2163 pub fn project_directory(&self) -> Option<&'a Path> {
2164 match self {
2165 Self::Attached { path } => Some(*path),
2166 Self::ManagedWorktree {
2167 project_directory, ..
2168 } => *project_directory,
2169 Self::ManagedWorkspace => None,
2170 Self::Borrowed { checkout, .. } => checkout.project_directory(),
2171 }
2172 }
2173}
2174
2175pub fn session_is_live(record: &SessionRecord, in_operation: bool, parent_live: bool) -> bool {
2183 record.state != SessionState::Stopped || in_operation || parent_live
2184}
2185
2186pub fn live_session_ids(
2190 sessions: &SnapshotMap<String, SessionRecord>,
2191 subagents: &SnapshotMap<String, SubagentRecord>,
2192 operations: &BTreeSet<String>,
2193) -> BTreeSet<String> {
2194 let mut live = sessions
2195 .iter()
2196 .filter(|(id, record)| session_is_live(record, operations.contains(*id), false))
2197 .map(|(id, _)| id.clone())
2198 .collect::<BTreeSet<_>>();
2199 loop {
2200 let joined = subagents
2201 .iter()
2202 .filter(|(child, relation)| {
2203 !live.contains(*child)
2204 && sessions.contains_key(*child)
2205 && live.contains(&relation.parent_session_id)
2206 })
2207 .map(|(child, _)| child.clone())
2208 .collect::<Vec<_>>();
2209 if joined.is_empty() {
2210 return live;
2211 }
2212 live.extend(joined);
2213 }
2214}
2215
2216impl State {
2217 pub fn checkout(&self, session_id: &str) -> Result<Checkout<'_>> {
2233 let session = self
2234 .sessions
2235 .get(session_id)
2236 .with_context(|| format!("unknown session {session_id}"))?;
2237 self.checkout_for_record(session_id, session)
2238 }
2239
2240 pub fn checkout_for_record<'a>(
2243 &'a self,
2244 session_id: &str,
2245 session: &'a SessionRecord,
2246 ) -> Result<Checkout<'a>> {
2247 self.checkout_for_record_inner(session_id, session, &mut BTreeSet::new())
2248 }
2249
2250 fn checkout_for_record_inner<'a>(
2251 &'a self,
2252 session_id: &str,
2253 session: &'a SessionRecord,
2254 visited: &mut BTreeSet<String>,
2255 ) -> Result<Checkout<'a>> {
2256 if !visited.insert(session_id.to_owned()) {
2257 bail!("sub-agent checkout ownership contains a cycle at {session_id}");
2258 }
2259 if let Some(relation) = self.subagents.get(session_id) {
2260 let Some(owner) = self.sessions.get(&relation.parent_session_id) else {
2261 return Ok(session.checkout());
2262 };
2263 return Ok(Checkout::Borrowed {
2264 owner,
2265 checkout: Box::new(self.checkout_for_record_inner(&owner.id, owner, visited)?),
2266 });
2267 }
2268 Ok(session.checkout())
2269 }
2270
2271 #[must_use]
2276 pub fn session_notice_name(&self, session_id: &str) -> String {
2277 match self.sessions.get(session_id) {
2278 Some(session) if session.listed_title() != session.id => {
2279 session.listed_title().to_owned()
2280 }
2281 _ => short_id(session_id).to_owned(),
2282 }
2283 }
2284
2285 #[must_use]
2294 pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
2295 self.subagents
2296 .get(&session.id)
2297 .and_then(|record| self.sessions.get(&record.parent_session_id))
2298 .unwrap_or(session)
2299 }
2300
2301 #[must_use]
2308 pub fn is_subagent_session(&self, id: &str) -> bool {
2309 self.subagents.contains_key(id) || crate::native_agent::is_view_id(id)
2310 }
2311
2312 pub fn validate(&self) -> Result<()> {
2313 if self.version != STATE_VERSION {
2314 bail!(
2315 "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
2316 self.version
2317 );
2318 }
2319 for (id, session) in &self.sessions {
2320 session.validate(id)?;
2321 }
2322 for child_id in self.subagents.keys() {
2323 self.validate_subagent(child_id)?;
2324 }
2325 for (host, sources) in &self.mount_history {
2326 if host.trim().is_empty() {
2327 bail!("mount history contains an empty host key");
2328 }
2329 if sources
2330 .iter()
2331 .any(|source| !crate::target_path::is_absolute_on_host_or_target(source))
2332 {
2333 bail!("mount history for {host:?} contains a non-absolute source path");
2334 }
2335 }
2336 for (host, size) in &self.container_sizes {
2337 if host.trim().is_empty() {
2338 bail!("container size history contains an empty host key");
2339 }
2340 if size.cpus == 0 || size.memory_bytes == 0 {
2341 bail!("container size history for {host:?} contains a zero value");
2342 }
2343 if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
2344 bail!("container size history for {host:?} exceeds SQLite integer range");
2345 }
2346 }
2347 Ok(())
2348 }
2349
2350 pub fn validate_subagent(&self, child_id: &str) -> Result<()> {
2352 let Some(subagent) = self.subagents.get(child_id) else {
2353 return Ok(());
2354 };
2355 if child_id != subagent.child_session_id {
2356 bail!("sub-agent key {child_id:?} does not match its child session id");
2357 }
2358 if child_id == subagent.parent_session_id {
2359 bail!("sub-agent {child_id:?} cannot be its own parent");
2360 }
2361 if !self.sessions.contains_key(child_id) {
2362 bail!("sub-agent {child_id:?} has no child session");
2363 }
2364 if !self.sessions.contains_key(&subagent.parent_session_id) {
2365 bail!(
2366 "sub-agent {child_id:?} has unknown parent {:?}",
2367 subagent.parent_session_id
2368 );
2369 }
2370 if self.subagents.contains_key(&subagent.parent_session_id) {
2371 bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
2372 }
2373 if subagent.task_name.trim().is_empty()
2374 || subagent.profile_id.trim().is_empty()
2375 || subagent.request_key.trim().is_empty()
2376 {
2377 bail!("sub-agent {child_id:?} has incomplete relationship metadata");
2378 }
2379 Ok(())
2380 }
2381
2382 pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
2383 if mounts.is_empty() {
2384 return;
2385 }
2386 let sources = self
2387 .mount_history
2388 .entry(host.to_owned())
2389 .or_insert_with(Vec::new);
2390 for mount in mounts.iter().rev() {
2391 sources.retain(|source| source != &mount.source);
2392 sources.insert(0, mount.source.clone());
2393 }
2394 sources.truncate(20);
2395 }
2396
2397 pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
2398 self.container_sizes.insert(host.to_owned(), size);
2399 }
2400
2401 pub fn project_directories(&self, host: &str) -> &[PathBuf] {
2402 self.mount_history
2403 .get(&project_history_key(host))
2404 .map(Vec::as_slice)
2405 .unwrap_or_default()
2406 }
2407
2408 pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
2409 let key = project_history_key(host);
2410 let directories = self.mount_history.entry(key).or_insert_with(Vec::new);
2411 directories.retain(|existing| existing != directory);
2412 directories.insert(0, directory.to_path_buf());
2413 directories.truncate(20);
2414 }
2415
2416 pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
2417 let session = self
2418 .sessions
2419 .get(session_id)
2420 .with_context(|| format!("unknown session {session_id}"))?;
2421 if session.state.is_active() {
2422 bail!("refusing to destroy active session {session_id}");
2423 }
2424 Ok(self
2425 .sessions
2426 .remove(session_id)
2427 .expect("session checked above"))
2428 }
2429
2430 pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
2436 self.sessions
2437 .get(session_id)
2438 .with_context(|| format!("unknown session {session_id}"))?;
2439 Ok(self
2440 .sessions
2441 .remove(session_id)
2442 .expect("session checked above"))
2443 }
2444
2445 pub fn bundle_users(&self, bundle_id: &str) -> Vec<&SessionRecord> {
2450 self.sessions
2451 .values()
2452 .filter(|session| {
2453 session.bundle_id == bundle_id
2454 && self
2455 .checkout(&session.id)
2456 .is_ok_and(|checkout| checkout.project_directory().is_none())
2457 && session.state != SessionState::DestroyedWithDataLoss
2458 })
2459 .collect()
2460 }
2461
2462 pub fn bundle_removal_refusal(&self, bundle_id: &str) -> Option<String> {
2465 let users = self.bundle_users(bundle_id);
2466 if users.is_empty() {
2467 return None;
2468 }
2469 let mut names = users
2470 .iter()
2471 .take(3)
2472 .map(|session| format!("{:?}", session.listed_title()))
2473 .collect::<Vec<_>>();
2474 if users.len() > 3 {
2475 names.push(format!("{} more", users.len() - 3));
2476 }
2477 Some(format!(
2478 "Project {bundle_id:?} is used by {}: {}. Destroy those sessions before removing it.",
2479 if users.len() == 1 {
2480 "a session"
2481 } else {
2482 "sessions"
2483 },
2484 names.join(", ")
2485 ))
2486 }
2487
2488 pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
2491 for session in self
2492 .sessions
2493 .values()
2494 .filter(|session| session.state.is_active())
2495 {
2496 let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
2497 let mut comparable = profile.clone();
2498 if let Some(updated) = after.profiles.get(&session.last_profile) {
2499 comparable.enabled = updated.enabled;
2500 comparable.subagents = updated.subagents.clone();
2501 }
2502 profile.kind == session.harness_kind
2504 && after.profiles.get(&session.last_profile) != Some(&comparable)
2505 } else {
2506 false
2507 };
2508 let bundle_changed = self
2509 .checkout(&session.id)
2510 .is_ok_and(|checkout| checkout.project_directory().is_none())
2511 && before
2512 .bundles
2513 .get(&session.bundle_id)
2514 .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
2515 let target_changed =
2519 before
2520 .targets
2521 .get(&session.target_template_id)
2522 .is_some_and(|target| {
2523 after
2524 .targets
2525 .get(&session.target_template_id)
2526 .map(TargetTemplate::without_launch_only_settings)
2527 != Some(target.without_launch_only_settings())
2528 });
2529 if protected || bundle_changed || target_changed {
2530 let mut used = Vec::new();
2534 if protected {
2535 used.push(format!("agent profile {:?}", session.last_profile));
2536 }
2537 if bundle_changed {
2538 used.push(format!("project {:?}", session.project_name(before)));
2539 }
2540 if target_changed {
2541 used.push(format!("runtime {:?}", session.target_template_id));
2542 }
2543 let used = match used.as_slice() {
2544 [only] => only.clone(),
2545 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2546 [] => unreachable!("something changed"),
2547 };
2548 let title = session.display_title();
2549 let named = if title == session.id {
2550 format!(
2551 "a running session in project {:?}",
2552 session.project_name(before)
2553 )
2554 } else {
2555 format!("the running session {title:?}")
2556 };
2557 bail!(
2558 "Setup would change the {used} that {named} uses. Save the new settings under a new name, or stop the session first."
2559 );
2560 }
2561 }
2562 Ok(())
2563 }
2564
2565 pub fn validate_against_config(&self, config: &Config) -> Result<()> {
2567 self.validate()?;
2568 config.validate()?;
2569 for session in self.sessions.values() {
2570 session.validate_configuration(config)?;
2571 }
2572 Ok(())
2573 }
2574}
2575
2576fn project_history_key(host: &str) -> String {
2577 format!("project:{host}")
2578}
2579
2580pub fn new_session_id() -> Result<String> {
2582 let mut random = [0u8; 16];
2583 getrandom::fill(&mut random)
2584 .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
2585 Ok(crate::hex::lower_hex(random))
2586}
2587
2588pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
2590 events.iter().rev().find_map(|event| {
2591 let WorkerEvent::Adapter { payload, .. } = &event.event else {
2592 return None;
2593 };
2594 let crate::acp::RuntimeEvent::SessionUpdate { update } =
2595 serde_json::from_value(payload.clone()).ok()?
2596 else {
2597 return None;
2598 };
2599 let kind = update
2600 .get("sessionUpdate")
2601 .and_then(serde_json::Value::as_str)?;
2602 let title = match kind {
2603 "session_info_update" | "session_title" => {
2604 update.get("title").and_then(serde_json::Value::as_str)
2605 }
2606 _ => None,
2607 }?;
2608 normalize_session_title(title)
2609 })
2610}
2611
2612pub fn default_session_title(
2617 project_directory: Option<&Path>,
2618 bundle_id: &str,
2619 profile_id: &str,
2620) -> String {
2621 let project = project_directory.and_then(Path::file_name).map_or_else(
2622 || bundle_id.to_owned(),
2623 |name| name.to_string_lossy().into_owned(),
2624 );
2625 format!("{project} via {profile_id}")
2626}
2627
2628pub const MAX_SESSION_TITLE_CHARS: usize = 256;
2629
2630pub fn normalize_session_title(title: &str) -> Option<String> {
2632 let normalized = crate::relay::strip_hidden_prompt_context(title)
2633 .split_whitespace()
2634 .collect::<Vec<_>>()
2635 .join(" ");
2636 (!normalized.is_empty()).then(|| truncate_session_title(normalized, MAX_SESSION_TITLE_CHARS))
2637}
2638
2639fn truncate_session_title(title: String, maximum_chars: usize) -> String {
2640 if title.chars().count() <= maximum_chars {
2641 return title;
2642 }
2643
2644 let mut truncated = title.chars().take(maximum_chars - 1).collect::<String>();
2645 if let Some(boundary) = truncated.rfind(char::is_whitespace) {
2646 truncated.truncate(boundary);
2647 }
2648 truncated.push('…');
2649 truncated
2650}
2651
2652pub fn provisional_session_title(prompt: &str) -> Option<String> {
2658 const MAX_TITLE_CHARS: usize = 64;
2659
2660 let normalized = normalize_session_title(prompt)?;
2661 Some(truncate_session_title(normalized, MAX_TITLE_CHARS))
2662}
2663
2664pub fn short_id(id: &str) -> &str {
2665 id.get(..8).unwrap_or(id)
2666}
2667
2668#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2669pub struct RecoveryCandidate {
2670 pub session_id: String,
2671 pub target_template_id: String,
2672 pub locator: TargetLocator,
2673 pub ownership: Option<crate::worker_launch::WorkerOwnership>,
2674 #[serde(default)]
2677 pub instance_id: Option<String>,
2678 #[serde(default, skip_serializing_if = "Option::is_none")]
2683 pub tracked_session: Option<SessionState>,
2684}
2685
2686#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2687pub struct RecoveryScan {
2688 pub candidates: Vec<RecoveryCandidate>,
2689 pub warnings: Vec<String>,
2690 #[serde(default)]
2692 pub instance_id: String,
2693 #[serde(default)]
2696 pub hidden_other_instances: usize,
2697}
2698
2699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2700#[serde(deny_unknown_fields)]
2701pub struct ResumeRepositorySourceReceipt {
2702 pub session_id: String,
2703 pub bundle_id: String,
2704 pub checkpoint_sha256: String,
2705 pub repositories: Vec<crate::config::ProjectRepository>,
2706}
2707
2708#[cfg(test)]
2709mod tests;