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 if !item.is_user_prompt() {
416 return None;
417 }
418 let TranscriptBody::User { content } = &item.body else {
419 return None;
420 };
421 provisional_session_title(&crate::transcript::materialized_content_text(
422 content,
423 ))
424 })
425 })
426 .or_else(|| {
427 self.queued_prompts
428 .iter()
429 .filter(|prompt| prompt.kind.is_prompt())
430 .find_map(|prompt| {
431 provisional_session_title(&crate::transcript::materialized_content_text(
432 &prompt.content,
433 ))
434 })
435 })
436 }
437
438 pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
439 self.transcript
440 .iter()
441 .filter(|item| {
442 item.latest_content_event_ordinal
443 .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
444 && item.is_nonempty_agent_message()
445 })
446 .count() as u64
447 }
448
449 pub fn unread_interruptions_after(&self, viewed_through_event_ordinal: u64) -> u64 {
450 self.interruption_event_ordinals()
451 .into_iter()
452 .filter(|ordinal| *ordinal > viewed_through_event_ordinal)
453 .count() as u64
454 }
455
456 pub fn interruption_event_ordinals(&self) -> Vec<u64> {
457 let mut ordinals = self
458 .transcript
459 .iter()
460 .filter(|item| item.is_work_interruption())
461 .map(|item| item.position)
462 .collect::<Vec<_>>();
463 if let Some(ordinal) = self
464 .last_turn_outcome
465 .as_ref()
466 .and_then(MaterializedTurnOutcome::interruption_ordinal)
467 {
468 ordinals.push(ordinal);
469 }
470 ordinals.sort_unstable();
471 ordinals.dedup();
472 ordinals
473 }
474
475 pub fn validate(&self) -> Result<()> {
476 validate_id("session", &self.session_id)?;
477 validate_relay_event_frontier(
478 self.applied_event_ordinal,
479 &self.applied_event_digest,
480 "materialized session event frontier",
481 )?;
482 if self
483 .session_title
484 .as_ref()
485 .is_some_and(|title| title.trim().is_empty())
486 {
487 bail!("materialized session has an empty title");
488 }
489 let mut item_ids = BTreeSet::new();
490 for item in &self.transcript {
491 item.validate(self.applied_event_ordinal)?;
492 if !item_ids.insert(item.stable_id.as_str()) {
493 bail!(
494 "materialized transcript contains duplicate item {:?}",
495 item.stable_id
496 );
497 }
498 }
499 let mut command_ids = BTreeSet::new();
500 for prompt in &self.queued_prompts {
501 if prompt.command_id.trim().is_empty() {
502 bail!("materialized prompt queue has an empty command id");
503 }
504 if !command_ids.insert(prompt.command_id.as_str()) {
505 bail!(
506 "materialized prompt queue contains duplicate command {:?}",
507 prompt.command_id
508 );
509 }
510 if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
511 && (key.trim().is_empty() || value.trim().is_empty())
512 {
513 bail!(
514 "materialized queued configuration change {:?} is incomplete",
515 prompt.command_id
516 );
517 }
518 }
519 Ok(())
520 }
521}
522
523#[derive(Debug, Clone, PartialEq)]
527pub struct ManagedSessionSnapshot {
528 pub materialized: MaterializedSession,
529 pub window: ProjectionWindow,
532 pub operational: RelayOperationalState,
533 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
537 pub worker_build: Option<String>,
542 pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
544 pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
546}
547
548#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
560pub struct ProjectionWindow {
561 pub omitted_items: usize,
563 pub provisional_title: Option<String>,
565 pub latest_turn_start_position: Option<u64>,
569}
570
571impl ProjectionWindow {
572 pub fn trim(&mut self, session: &mut MaterializedSession, target: usize) {
575 let observed = Self::of(session);
576 if self.provisional_title.is_none() {
577 self.provisional_title = observed.provisional_title;
578 }
579 self.latest_turn_start_position = observed
580 .latest_turn_start_position
581 .or(self.latest_turn_start_position);
582 let mut boundary = session.transcript.len().saturating_sub(target.max(1));
583 for (index, item) in session.transcript.iter().enumerate() {
584 let mutable = match &item.body {
585 TranscriptBody::Agent { streaming, .. }
586 | TranscriptBody::Thought { streaming, .. } => *streaming,
587 TranscriptBody::Tool { call, .. } => matches!(
588 call.get("status").and_then(serde_json::Value::as_str),
589 Some("pending" | "in_progress")
590 ),
591 _ => false,
592 };
593 if mutable || Some(item.position) == self.latest_turn_start_position {
594 boundary = boundary.min(index);
595 }
596 }
597 let cut = session
598 .transcript
599 .iter()
600 .take(boundary + 1)
601 .rposition(|item| item.is_turn_start())
602 .unwrap_or(0);
603 if cut > 0 {
604 session.transcript.drain(..cut);
605 self.omitted_items += cut;
606 }
607 }
608
609 #[must_use]
611 pub fn of(session: &MaterializedSession) -> Self {
612 Self {
613 omitted_items: 0,
614 provisional_title: session.transcript.iter().find_map(|item| {
615 if !item.is_user_prompt() {
616 return None;
617 }
618 let TranscriptBody::User { content } = &item.body else {
619 return None;
620 };
621 provisional_session_title(&crate::transcript::materialized_content_text(content))
622 }),
623 latest_turn_start_position: session
624 .transcript
625 .iter()
626 .rev()
627 .find(|item| item.is_turn_start())
628 .map(|item| item.position),
629 }
630 }
631}
632
633impl ManagedSessionSnapshot {
634 #[must_use]
639 pub fn resolved_title(&self) -> Option<String> {
640 self.materialized
641 .session_title
642 .as_deref()
643 .and_then(normalize_session_title)
644 .or_else(|| self.window.provisional_title.clone())
645 .or_else(|| {
646 self.materialized
647 .queued_prompts
648 .iter()
649 .filter(|prompt| prompt.kind.is_prompt())
650 .find_map(|prompt| {
651 provisional_session_title(&crate::transcript::materialized_content_text(
652 &prompt.content,
653 ))
654 })
655 })
656 }
657
658 #[must_use]
663 pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
664 if self.materialized.execution != MaterializedExecutionState::Idle {
665 return None;
666 }
667 self.window.latest_turn_start_position
668 }
669}
670
671#[derive(Debug, Clone)]
673pub struct RecoveryObservation {
674 pub session: SessionRecord,
675 pub config: Config,
676 pub latest_completed_turn_ordinal: Option<u64>,
677 pub checkpoint_wait: Option<crate::activity::CheckpointWait>,
681}
682
683pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
688 if session.execution != MaterializedExecutionState::Idle {
689 return None;
690 }
691 session
692 .transcript
693 .iter()
694 .rev()
695 .find(|item| item.is_turn_start())
696 .map(|item| item.position)
697}
698
699pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
700 if digest.len() != 64
701 || !digest
702 .bytes()
703 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
704 {
705 bail!("{name} must be a lowercase SHA-256 digest");
706 }
707 Ok(())
708}
709
710pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
711 validate_relay_event_digest(digest, name)?;
712 if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
713 bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
714 }
715 Ok(())
716}
717
718fn is_false(value: &bool) -> bool {
719 !*value
720}
721
722impl SessionState {
723 pub const fn as_str(self) -> &'static str {
725 match self {
726 Self::Provisioning => "provisioning",
727 Self::StartupCleanup => "startup-cleanup",
728 Self::Running => "running",
729 Self::Disconnected => "disconnected",
730 Self::Checkpointing => "checkpointing",
731 Self::Closing => "closing",
732 Self::Destroying => "destroying",
733 Self::Stopped => "stopped",
734 Self::Parked => "parked",
735 Self::Lost => "lost",
736 Self::Error => "error",
737 Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
738 }
739 }
740
741 pub fn from_stored(value: &str) -> Option<Self> {
744 Some(match value {
745 "provisioning" => Self::Provisioning,
746 "startup-cleanup" => Self::StartupCleanup,
747 "running" => Self::Running,
748 "disconnected" => Self::Disconnected,
749 "checkpointing" => Self::Checkpointing,
750 "closing" => Self::Closing,
751 "destroying" => Self::Destroying,
752 "stopped" | "archived" => Self::Stopped,
753 "parked" => Self::Parked,
754 "lost" => Self::Lost,
755 "error" => Self::Error,
756 "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
757 _ => return None,
758 })
759 }
760
761 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
764 match self {
765 Self::Provisioning => Some(SessionTransitionKind::Starting),
766 Self::StartupCleanup => Some(SessionTransitionKind::Stopping),
767 Self::Closing => Some(SessionTransitionKind::Suspending),
768 Self::Destroying => Some(SessionTransitionKind::Destroying),
769 _ => None,
770 }
771 }
772
773 pub const fn is_active(self) -> bool {
780 matches!(
781 self,
782 Self::Provisioning
783 | Self::StartupCleanup
784 | Self::Running
785 | Self::Disconnected
786 | Self::Checkpointing
787 | Self::Closing
788 | Self::Destroying
789 | Self::Parked
790 | Self::Error
791 )
792 }
793
794 pub const fn has_live_worker(self) -> bool {
798 matches!(
799 self,
800 Self::Provisioning
801 | Self::StartupCleanup
802 | Self::Running
803 | Self::Disconnected
804 | Self::Checkpointing
805 | Self::Closing
806 | Self::Destroying
807 )
808 }
809}
810
811#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(tag = "kind", rename_all = "kebab-case")]
813pub enum PodmanWorkspaceLocator {
814 #[default]
815 ContainerLayer,
816 Volume {
817 name: String,
818 },
819 HostPath {
820 path: PathBuf,
821 helper: Vec<String>,
822 resource: String,
823 },
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(tag = "kind", rename_all = "kebab-case")]
828pub enum TargetLocator {
829 LocalBare {
830 worker_root: PathBuf,
831 },
832 LocalPodman {
833 container_id: String,
834 #[serde(default)]
835 workspace_storage: PodmanWorkspaceLocator,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
840 borrowed_from: Option<String>,
841 },
842 LocalDocker {
843 container_id: String,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
848 borrowed_from: Option<String>,
849 },
850 AppleContainer {
851 container_id: String,
852 #[serde(default, skip_serializing_if = "Option::is_none")]
856 borrowed_from: Option<String>,
857 },
858 AwsEc2 {
859 instance_id: String,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 address: Option<String>,
862 },
863 SshBare {
864 host: String,
865 workspace: PathBuf,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
867 worker_id: Option<String>,
868 },
869 SshPodman {
870 host: String,
871 container_id: String,
872 #[serde(default)]
873 workspace_storage: PodmanWorkspaceLocator,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
878 borrowed_from: Option<String>,
879 },
880 SshDocker {
881 host: String,
882 container_id: String,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
887 borrowed_from: Option<String>,
888 },
889}
890
891impl ManagedWorktreeTarget {
892 pub fn same_location(&self, other: &Self) -> bool {
900 match (self, other) {
901 (Self::Local, Self::Local) => true,
902 (
903 Self::Ssh {
904 destination,
905 ssh_args,
906 },
907 Self::Ssh {
908 destination: other_destination,
909 ssh_args: other_args,
910 },
911 ) => {
912 destination == other_destination
913 && ssh_location_option(ssh_args, 'p', "port")
914 == ssh_location_option(other_args, 'p', "port")
915 && ssh_location_option(ssh_args, 'l', "user")
916 == ssh_location_option(other_args, 'l', "user")
917 }
918 _ => false,
919 }
920 }
921}
922
923fn ssh_location_option(args: &[String], flag: char, option: &str) -> Option<String> {
927 let mut args = args.iter();
928 while let Some(argument) = args.next() {
929 let Some(rest) = argument.strip_prefix('-') else {
930 continue;
931 };
932 let mut chars = rest.chars();
933 let Some(name) = chars.next() else { continue };
934 if name != flag && name != 'o' {
935 continue;
936 }
937 let inline = chars.as_str();
938 let value = if inline.is_empty() {
939 args.next().cloned()
940 } else {
941 Some(inline.to_owned())
942 };
943 if name == flag {
944 return value;
945 }
946 if let Some(setting) = value {
947 let (key, found) = setting
948 .split_once(['=', ' ', '\t'])
949 .unwrap_or((setting.as_str(), ""));
950 if key.trim().eq_ignore_ascii_case(option) {
951 return Some(found.trim().to_owned());
952 }
953 }
954 }
955 None
956}
957
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(tag = "kind", rename_all = "kebab-case")]
960pub enum ManagedWorktreeTarget {
961 Local,
962 Ssh {
963 destination: String,
964 #[serde(default, skip_serializing_if = "Vec::is_empty")]
965 ssh_args: Vec<String>,
966 },
967}
968
969#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(deny_unknown_fields)]
972pub struct ManagedWorktreeOptions {
973 pub available: bool,
974 pub default_create: bool,
975}
976
977#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
978#[serde(deny_unknown_fields)]
979pub struct ManagedWorktree {
980 #[serde(default, skip_serializing_if = "ManagedCheckoutKind::is_worktree")]
982 pub kind: ManagedCheckoutKind,
983 pub source_project_directory: PathBuf,
984 pub source_repository: PathBuf,
985 pub worktree_root: PathBuf,
986 pub branch: String,
987 pub target: ManagedWorktreeTarget,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
992 pub base_commit: Option<String>,
993}
994
995#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
996#[serde(rename_all = "snake_case")]
997pub enum ManagedCheckoutKind {
998 #[default]
999 Worktree,
1000 Clone,
1001}
1002
1003impl ManagedCheckoutKind {
1004 fn is_worktree(&self) -> bool {
1005 matches!(self, Self::Worktree)
1006 }
1007}
1008
1009impl ManagedWorktree {
1010 fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
1011 for (label, path) in [
1012 ("source project directory", &self.source_project_directory),
1013 ("source repository", &self.source_repository),
1014 ("worktree root", &self.worktree_root),
1015 ] {
1016 if !crate::target_path::is_absolute(path)
1017 || path.components().any(|part| part == Component::ParentDir)
1018 {
1019 bail!("managed worktree {label} must be an absolute safe path");
1020 }
1021 }
1022 if !self
1023 .source_project_directory
1024 .starts_with(&self.source_repository)
1025 {
1026 bail!("managed worktree source directory is outside its repository");
1027 }
1028 let expected_root = self
1029 .source_repository
1030 .join(".mj")
1031 .join(match self.kind {
1032 ManagedCheckoutKind::Worktree => "worktrees",
1033 ManagedCheckoutKind::Clone => "clones",
1034 })
1035 .join(session_id);
1036 if self.worktree_root != expected_root {
1037 bail!("managed worktree root does not match the session-owned path");
1038 }
1039 if self.kind == ManagedCheckoutKind::Worktree && self.branch != format!("mj/{session_id}") {
1040 bail!("managed worktree branch does not match the session id");
1041 }
1042 if self.kind == ManagedCheckoutKind::Clone && self.branch.trim().is_empty() {
1043 bail!("managed clone has no starting branch");
1044 }
1045 let relative = self
1046 .source_project_directory
1047 .strip_prefix(&self.source_repository)
1048 .expect("source relationship checked above");
1049 if project_directory != Some(self.worktree_root.join(relative).as_path()) {
1050 bail!("session project directory does not match its managed worktree");
1051 }
1052 match &self.target {
1053 ManagedWorktreeTarget::Local => {}
1054 ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
1055 bail!("managed SSH worktree has an empty destination")
1056 }
1057 ManagedWorktreeTarget::Ssh { .. } => {}
1058 }
1059 Ok(())
1060 }
1061}
1062
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1064#[serde(tag = "kind", rename_all = "kebab-case")]
1065pub enum SessionResourceAllocation {
1066 Container {
1067 cpus: u64,
1068 memory_bytes: u64,
1069 },
1070 AwsEc2 {
1071 instance_type: String,
1072 vcpus: u64,
1073 memory_bytes: u64,
1074 },
1075}
1076
1077#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1079#[serde(rename_all = "kebab-case")]
1080pub enum ResourceAllocationKind {
1081 #[default]
1082 Fixed,
1083 Container,
1084 AwsEc2,
1085}
1086
1087impl SessionResourceAllocation {
1088 pub fn validate(&self) -> Result<()> {
1089 match self {
1090 Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
1091 bail!("container resource allocation must have non-zero CPU and memory")
1092 }
1093 Self::AwsEc2 {
1094 instance_type,
1095 vcpus,
1096 memory_bytes,
1097 } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
1098 bail!("EC2 resource allocation must have an instance type, CPU, and memory")
1099 }
1100 _ => Ok(()),
1101 }
1102 }
1103}
1104
1105pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
1107 match allocation {
1108 SessionResourceAllocation::Container { cpus, .. } => *cpus,
1109 SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
1110 }
1111}
1112
1113pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
1115 match allocation {
1116 SessionResourceAllocation::Container { memory_bytes, .. }
1117 | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
1118 }
1119}
1120
1121impl TargetLocator {
1122 pub const fn skills_scope(&self) -> crate::skills::SkillsScope {
1124 match self {
1125 Self::LocalBare { .. } => crate::skills::SkillsScope::Localhost,
1126 _ => crate::skills::SkillsScope::Isolated,
1127 }
1128 }
1129
1130 fn validate(&self, session_id: &str) -> Result<()> {
1131 match self {
1132 Self::LocalBare { worker_root } => {
1133 if !crate::target_path::is_absolute(worker_root)
1134 || worker_root
1135 .components()
1136 .any(|part| part == Component::ParentDir)
1137 || !worker_root.ends_with(session_id)
1138 {
1139 bail!(
1140 "local bare worker root must be an absolute safe path ending in the session id"
1141 );
1142 }
1143 }
1144 Self::LocalPodman { container_id, .. }
1145 | Self::LocalDocker { container_id, .. }
1146 | Self::AppleContainer { container_id, .. }
1147 | Self::SshPodman { container_id, .. }
1148 | Self::SshDocker { container_id, .. }
1149 if container_id.trim().is_empty() =>
1150 {
1151 bail!("target locator has an empty container id")
1152 }
1153 Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
1154 bail!("target locator has an empty AWS instance id")
1155 }
1156 Self::SshBare {
1157 host,
1158 workspace,
1159 worker_id,
1160 } => {
1161 if host.trim().is_empty() {
1162 bail!("bare SSH target locator has an empty host");
1163 }
1164 let unsafe_path = workspace.as_os_str().is_empty()
1165 || workspace
1166 .components()
1167 .any(|part| part == Component::ParentDir);
1168 match worker_id {
1169 Some(worker_id) => {
1172 if worker_id != session_id {
1173 bail!(
1174 "bare SSH target locator's worker identity does not match the session id"
1175 );
1176 }
1177 if unsafe_path {
1178 bail!("bare SSH target locator must have a safe workspace path");
1179 }
1180 }
1181 None => {
1182 if unsafe_path || !workspace.ends_with(session_id) {
1183 bail!(
1184 "bare SSH target locator must be a safe path ending in the session id"
1185 );
1186 }
1187 }
1188 }
1189 }
1190 Self::SshPodman { host, .. } if host.trim().is_empty() => {
1191 bail!("SSH Podman target locator has an empty host")
1192 }
1193 Self::SshDocker { host, .. } if host.trim().is_empty() => {
1194 bail!("SSH Docker target locator has an empty host")
1195 }
1196 _ => {}
1197 }
1198 Ok(())
1199 }
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203#[serde(deny_unknown_fields)]
1204pub struct CheckpointMetadata {
1205 pub archive_path: PathBuf,
1206 pub sha256: String,
1208 pub created_at: String,
1209 pub event_frontier: u64,
1210}
1211
1212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1214#[serde(rename_all = "snake_case")]
1215pub enum PublicationState {
1216 Published,
1217 Unpublished,
1218 Unknown,
1219}
1220
1221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1223pub struct PublicationAssessment {
1224 pub checkpoint_sha256: String,
1225 pub state: PublicationState,
1226 pub dirty: bool,
1227 pub stashed: bool,
1228 pub saved_commits: Vec<String>,
1229 pub destinations: Vec<String>,
1230 pub checked_at: String,
1231 pub reason: Option<String>,
1232}
1233
1234impl CheckpointMetadata {
1235 fn validate(&self) -> Result<()> {
1236 if self.archive_path.as_os_str().is_empty() {
1237 bail!("checkpoint archive path is empty");
1238 }
1239 if self.sha256.len() != 64
1240 || !self
1241 .sha256
1242 .bytes()
1243 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1244 {
1245 bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
1246 }
1247 if self.created_at.trim().is_empty() {
1248 bail!("checkpoint timestamp is empty");
1249 }
1250 Ok(())
1251 }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1259#[serde(deny_unknown_fields)]
1260pub struct SessionBuildCache {
1261 pub host: String,
1264 pub directory: PathBuf,
1265 #[serde(default, skip_serializing_if = "Option::is_none")]
1268 pub max_size: Option<String>,
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub target_root: Option<PathBuf>,
1273}
1274
1275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1279pub struct ArchiveSpacePreview {
1280 pub sessions: usize,
1282 pub bytes: u64,
1284 pub reclaimable_sessions: usize,
1287 pub reclaimable_bytes: u64,
1288}
1289
1290#[derive(Debug, Clone, PartialEq, Eq)]
1294pub struct BuildCachePreview {
1295 pub native_mbx: Option<String>,
1297 pub mbx_profile_file: Option<String>,
1299 pub mbx_profile_warning: Option<String>,
1301 pub mbx_manual_path_line: Option<String>,
1303 pub directory: Option<PathBuf>,
1305 pub max_total_size: Option<BuildCacheLimit>,
1307 pub user_managed: bool,
1309 pub application: BuildCacheApplication,
1310 pub budget_note: Option<String>,
1312 pub stats: Option<BuildCacheStats>,
1314 pub off_reason: Option<BuildCacheOff>,
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq)]
1327pub struct BuildCacheStats {
1328 pub builds: u64,
1330 pub cached_compilations: u64,
1332 pub avoided_compiler_ns: u64,
1334 pub reflinked_bytes: u64,
1336}
1337
1338#[derive(Debug, Clone, PartialEq, Eq)]
1340pub enum BuildCacheOff {
1341 TurnedOff,
1343 Unavailable(String),
1346}
1347
1348impl std::fmt::Display for BuildCacheOff {
1349 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1350 match self {
1351 Self::TurnedOff => formatter.write_str("turned off for this machine"),
1352 Self::Unavailable(reason) => formatter.write_str(reason),
1353 }
1354 }
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1359pub enum BuildCacheLimit {
1360 Size(String),
1362 HostConfiguration(Option<String>),
1365 MjDefault(String),
1367 MbxDefault(Option<String>),
1369}
1370
1371#[derive(Debug, Clone, Default, PartialEq, Eq)]
1372pub enum BuildCacheApplication {
1373 #[default]
1374 Pending,
1375 Applied,
1376 Failed(String),
1377}
1378
1379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1380#[serde(deny_unknown_fields)]
1381pub struct SessionRecord {
1382 pub id: String,
1383 #[serde(default = "default_session_workspace_id")]
1388 pub workspace_id: String,
1389 pub title: String,
1390 pub harness_kind: HarnessKind,
1391 pub last_profile: String,
1392 pub bundle_id: String,
1393 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub project: Option<crate::repository::ProjectBundleSnapshot>,
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1398 pub project_directory: Option<PathBuf>,
1399 #[serde(default, skip_serializing_if = "Option::is_none")]
1401 pub managed_worktree: Option<ManagedWorktree>,
1402 #[serde(default, skip_serializing_if = "Option::is_none")]
1404 pub create_managed_worktree: Option<bool>,
1405 #[serde(default, skip_serializing_if = "Option::is_none")]
1412 pub launch_base: Option<String>,
1413 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub launch_branch: Option<String>,
1418 #[serde(default, skip_serializing_if = "Option::is_none")]
1422 pub checkout: Option<crate::remote_git::ExactCheckout>,
1423 #[serde(default, skip_serializing_if = "Option::is_none")]
1425 pub publication: Option<PublicationAssessment>,
1426 #[serde(
1428 default,
1429 alias = "mjolnir_subagents",
1430 deserialize_with = "crate::subagent::deserialize_optional_policy"
1431 )]
1432 pub subagents: Option<crate::subagent::SubagentPolicy>,
1433 #[serde(default, skip_serializing_if = "Option::is_none")]
1436 pub review: Option<crate::config::SessionReview>,
1437 pub target_template_id: String,
1438 #[serde(default, skip_serializing_if = "Option::is_none")]
1439 pub resource_allocation: Option<SessionResourceAllocation>,
1440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1441 pub additional_mounts: Vec<AdditionalMount>,
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1445 pub container_cpus: Option<String>,
1446 #[serde(default, skip_serializing_if = "Option::is_none")]
1449 pub container_memory: Option<String>,
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1456 pub container_workspace: Option<PathBuf>,
1457 #[serde(default, skip_serializing_if = "Option::is_none")]
1461 pub build_cache: Option<SessionBuildCache>,
1462 pub state: SessionState,
1463 #[serde(default, skip_serializing_if = "is_false")]
1466 pub archived: bool,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1468 pub target: Option<TargetLocator>,
1469 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub target_runtime: Option<TargetRuntimeSettings>,
1472 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub native_session_id: Option<String>,
1474 #[serde(default, skip_serializing_if = "Option::is_none")]
1475 pub acp_session_title: Option<String>,
1476 #[serde(default, skip_serializing_if = "Option::is_none")]
1477 pub session_title_override: Option<String>,
1478 pub created_at: String,
1479 pub updated_at: String,
1480 #[serde(default, alias = "detached_after_event_ordinal")]
1481 pub viewed_through_event_ordinal: u64,
1482 #[serde(default, skip_serializing_if = "String::is_empty")]
1485 pub draft_input: String,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1494 pub last_error: Option<String>,
1495 #[serde(default, skip_serializing_if = "Option::is_none")]
1496 pub last_checkpoint_error: Option<String>,
1497 #[serde(default, skip_serializing_if = "Option::is_none")]
1498 pub checkpoint: Option<CheckpointMetadata>,
1499}
1500
1501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1502#[serde(deny_unknown_fields)]
1503pub struct HostContainerSize {
1504 pub cpus: u64,
1505 pub memory_bytes: u64,
1506}
1507
1508pub const BASELINE_CONTAINER_CPUS: u64 = 8;
1509pub const BASELINE_CONTAINER_MEMORY_BYTES: u64 = 32 * 1024 * 1024 * 1024;
1510
1511pub fn clamp_container_size(
1514 size: HostContainerSize,
1515 limits: Option<HostContainerSize>,
1516) -> HostContainerSize {
1517 let Some(limits) = limits else {
1518 return HostContainerSize {
1519 cpus: size.cpus.max(1),
1520 memory_bytes: size.memory_bytes.max(1),
1521 };
1522 };
1523 HostContainerSize {
1524 cpus: size.cpus.min(limits.cpus.max(1)),
1525 memory_bytes: size.memory_bytes.min(limits.memory_bytes.max(1)),
1526 }
1527}
1528
1529pub fn default_container_size(
1532 remembered: Option<HostContainerSize>,
1533 limits: Option<HostContainerSize>,
1534) -> HostContainerSize {
1535 clamp_container_size(
1536 remembered.unwrap_or(HostContainerSize {
1537 cpus: BASELINE_CONTAINER_CPUS,
1538 memory_bytes: BASELINE_CONTAINER_MEMORY_BYTES,
1539 }),
1540 limits,
1541 )
1542}
1543
1544pub fn preferred_aws_allocation<'a>(
1547 options: &'a [SessionResourceAllocation],
1548 previous: Option<&SessionResourceAllocation>,
1549) -> Option<&'a SessionResourceAllocation> {
1550 if let Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) = previous
1551 && let Some(option) = options.iter().find(|option| {
1552 matches!(option, SessionResourceAllocation::AwsEc2 { instance_type: candidate, .. } if candidate == instance_type)
1553 })
1554 {
1555 return Some(option);
1556 }
1557 options
1558 .iter()
1559 .find(|option| allocation_cpus(option) == BASELINE_CONTAINER_CPUS)
1560 .or_else(|| options.first())
1561}
1562
1563fn default_session_workspace_id() -> String {
1564 crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1565}
1566
1567pub const DESTRUCTION_FAILURE_PREFIX: &str = "the destruction did not finish";
1576
1577pub const CLOSE_FAILURE_PREFIX: &str = "the suspension did not finish";
1578
1579pub const MOVE_FAILURE_PREFIX: &str = "the move did not finish";
1580
1581pub fn is_public_lifecycle_error(error: &str) -> bool {
1583 error.starts_with(CLOSE_FAILURE_PREFIX)
1584 || error.starts_with(MOVE_FAILURE_PREFIX)
1585 || error.starts_with(DESTRUCTION_FAILURE_PREFIX)
1586 || error.starts_with("the close did not finish")
1587}
1588
1589#[must_use]
1602pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1603 if !matches!(
1604 config.targets.get(target_id),
1605 Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1606 ) {
1607 return target_id.to_owned();
1608 }
1609 project.and_then(Path::file_name).map_or_else(
1610 || target_id.to_owned(),
1611 |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1612 )
1613}
1614
1615#[derive(Debug, Clone, Default, PartialEq, Eq)]
1617pub struct StartSelection {
1618 pub at: Option<String>,
1620 pub branch: Option<String>,
1622 pub base: Option<String>,
1624}
1625
1626impl SessionRecord {
1627 #[must_use]
1631 pub fn checkout(&self) -> Checkout<'_> {
1632 derive_record_checkout(self)
1633 }
1634
1635 pub fn start_selection(&self) -> StartSelection {
1639 let at = self
1640 .checkout
1641 .as_ref()
1642 .map(|checkout| checkout.commit.clone());
1643 StartSelection {
1644 branch: self
1645 .checkout
1646 .as_ref()
1647 .and_then(|checkout| checkout.branch.clone())
1648 .or_else(|| self.launch_branch.clone()),
1649 base: self.launch_base.clone().or_else(|| at.clone()),
1650 at,
1651 }
1652 }
1653
1654 pub fn publication_state(&self) -> Option<PublicationState> {
1657 let independent_clone = match self.checkout().effective() {
1658 Checkout::ManagedWorktree { worktree, .. } => {
1659 worktree.kind == ManagedCheckoutKind::Clone
1660 }
1661 Checkout::ManagedWorkspace => true,
1662 Checkout::Attached { .. } | Checkout::Borrowed { .. } => false,
1663 };
1664 if !independent_clone {
1665 return None;
1666 }
1667 if self.state.is_active() {
1668 return Some(PublicationState::Unknown);
1669 }
1670 Some(
1671 self.checkpoint
1672 .as_ref()
1673 .zip(self.publication.as_ref())
1674 .filter(|(checkpoint, assessment)| {
1675 assessment.checkpoint_sha256 == checkpoint.sha256
1676 })
1677 .map_or(PublicationState::Unknown, |(_, assessment)| {
1678 if assessment.dirty || assessment.stashed {
1679 PublicationState::Unpublished
1680 } else {
1681 assessment.state
1682 }
1683 }),
1684 )
1685 }
1686
1687 pub fn target_runtime_settings<'a>(
1692 &'a self,
1693 config: &Config,
1694 ) -> Result<std::borrow::Cow<'a, TargetRuntimeSettings>> {
1695 if let Some(runtime) = &self.target_runtime {
1696 if let Some(refreshed) =
1699 config
1700 .targets
1701 .get(&self.target_template_id)
1702 .and_then(|template| {
1703 runtime.with_current_ssh_options(&TargetRuntimeSettings::from(template))
1704 })
1705 {
1706 return Ok(std::borrow::Cow::Owned(refreshed));
1707 }
1708 return Ok(std::borrow::Cow::Borrowed(runtime));
1709 }
1710 let template = config.targets.get(&self.target_template_id).ok_or_else(|| {
1711 crate::refusal::Refusal::precondition(format!(
1712 "Session {:?} has no recorded target access settings. Restore target {:?} in config.toml once, then retry.",
1713 self.id, self.target_template_id))
1714 })?;
1715 let runtime = TargetRuntimeSettings::from(template);
1716 if let Some(locator) = &self.target {
1717 crate::targets::TargetLocator::try_from(crate::targets::RecordedTarget {
1718 locator, runtime: Some(&runtime), session_id: &self.id,
1719 }).map_err(|error| crate::refusal::Refusal::precondition(format!(
1720 "Session {:?} cannot recover target {:?}: {error}. Restore its original target settings, then retry.", self.id, self.target_template_id)))?;
1721 }
1722 Ok(std::borrow::Cow::Owned(runtime))
1723 }
1724
1725 #[must_use]
1729 pub fn public_error(&self) -> Option<&str> {
1730 self.last_error
1731 .as_deref()
1732 .filter(|error| is_public_lifecycle_error(error))
1733 }
1734
1735 pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1738 if !self.state.is_active() {
1739 return None;
1740 }
1741 let mut issues = Vec::new();
1742 match config.profiles.get(&self.last_profile) {
1743 None => issues.push(format!("missing profile {:?}", self.last_profile)),
1744 Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1745 "expects {:?}, but profile {:?} is {:?}",
1746 self.harness_kind, self.last_profile, profile.kind
1747 )),
1748 Some(_) => {}
1749 }
1750 if self.checkout().project_directory().is_none() && self.project_bundle(config).is_none() {
1751 issues.push(format!("missing bundle {:?}", self.bundle_id));
1752 }
1753 if self.target_runtime.is_none() && !config.targets.contains_key(&self.target_template_id) {
1754 issues.push(format!(
1755 "missing target template {:?}",
1756 self.target_template_id
1757 ));
1758 }
1759 (!issues.is_empty()).then(|| format!(
1760 "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.",
1761 self.id, issues.join("; ")
1762 ))
1763 }
1764
1765 pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1766 if let Some(issue) = self.configuration_issue(config) {
1767 return Err(crate::refusal::Refusal::precondition(issue).into());
1768 }
1769 Ok(())
1770 }
1771
1772 pub fn display_title(&self) -> &str {
1774 self.session_title_override
1775 .as_deref()
1776 .or(self.acp_session_title.as_deref())
1777 .unwrap_or(&self.id)
1778 }
1779
1780 pub fn listed_title(&self) -> &str {
1785 let named = self.session_title_override.is_some() || self.acp_session_title.is_some();
1786 if !named && !self.title.trim().is_empty() {
1787 return &self.title;
1788 }
1789 self.display_title()
1790 }
1791
1792 pub fn project_name(&self, config: &Config) -> String {
1797 if let Some(project) = &self.project {
1798 return project.name();
1799 }
1800 match self.checkout().effective() {
1801 Checkout::ManagedWorktree { worktree, .. } => path_leaf(&worktree.source_repository),
1802 Checkout::Attached { path } => path_leaf(path),
1803 Checkout::ManagedWorkspace => self.bundle_source_name(config),
1804 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1805 }
1806 }
1807
1808 pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1812 let checkout = self.checkout();
1813 let project = match checkout.effective() {
1814 Checkout::ManagedWorktree { worktree, .. } => {
1815 Some(worktree.source_project_directory.as_path())
1816 }
1817 Checkout::Attached { path } => Some(path),
1818 Checkout::ManagedWorkspace => None,
1819 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1820 };
1821 target_label(config, target_id, project)
1822 }
1823
1824 pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1829 if let Some(project) = &self.project {
1830 return ProjectSourceIdentity {
1831 key: project
1832 .source_key()
1833 .expect("accepted project has complete identities"),
1834 short: project.name(),
1835 full: project
1836 .identities
1837 .values()
1838 .map(crate::repository::RepositoryIdentity::key)
1839 .collect::<Vec<_>>()
1840 .join(" + "),
1841 };
1842 }
1843 match self.checkout().effective() {
1844 Checkout::ManagedWorktree { worktree, .. } => {
1845 ProjectSourceIdentity::path(&worktree.source_repository, None)
1846 }
1847 Checkout::Attached { path } => {
1848 let remote = match &self.target {
1849 Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1850 _ => None,
1851 };
1852 ProjectSourceIdentity::path(path, remote)
1853 }
1854 Checkout::ManagedWorkspace => {
1855 self.bundle_source_identity(config)
1856 .unwrap_or_else(|| ProjectSourceIdentity {
1857 key: format!("bundle:{}", self.bundle_id),
1858 short: path_leaf(Path::new(&self.bundle_id)),
1859 full: self.bundle_id.clone(),
1860 })
1861 }
1862 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1863 }
1864 }
1865
1866 fn bundle_source_name(&self, config: &Config) -> String {
1869 self.bundle_source_identity(config)
1870 .map(|source| source.short)
1871 .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1872 }
1873
1874 fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1877 let bundle = self.project_bundle(config)?;
1878 let sources = bundle
1879 .repositories
1880 .iter()
1881 .map(repository_source_identity)
1882 .collect::<Option<Vec<_>>>()?;
1883 ProjectSourceIdentity::bundle(sources)
1884 }
1885
1886 pub fn project_bundle<'a>(
1888 &'a self,
1889 config: &'a Config,
1890 ) -> Option<&'a crate::config::ProjectBundle> {
1891 self.project
1892 .as_ref()
1893 .map(|project| &project.bundle)
1894 .or_else(|| config.bundles.get(&self.bundle_id))
1895 }
1896
1897 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1901 self.creation_order_key().cmp(&other.creation_order_key())
1902 }
1903
1904 pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1906 let timestamp = created_at_seconds(&self.created_at);
1907 (timestamp.is_none(), timestamp, &self.id)
1908 }
1909
1910 fn validate(&self, map_id: &str) -> Result<()> {
1911 validate_id("session", &self.id)?;
1912 if self.id != map_id {
1913 bail!(
1914 "session map key {map_id:?} does not match record id {:?}",
1915 self.id
1916 );
1917 }
1918 if let Some(project) = &self.project {
1919 project.key()?;
1920 }
1921 validate_id("workspace", &self.workspace_id)?;
1922 validate_id("profile", &self.last_profile)?;
1923 validate_id("bundle", &self.bundle_id)?;
1924 if let Some(project_directory) = &self.project_directory
1925 && (!crate::target_path::is_absolute_on_host_or_target(project_directory)
1926 || project_directory
1927 .components()
1928 .any(|part| part == Component::ParentDir))
1929 {
1930 bail!("session {:?} has an unsafe project directory", self.id);
1931 }
1932 if let Some(managed_worktree) = &self.managed_worktree {
1933 managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1934 }
1935 validate_id("target template", &self.target_template_id)?;
1936 if let Some(allocation) = &self.resource_allocation {
1937 allocation.validate()?;
1938 }
1939 validate_additional_mounts(&self.additional_mounts)?;
1940 if self.title.trim().is_empty() {
1941 bail!("session {:?} has an empty title", self.id);
1942 }
1943 if self
1944 .acp_session_title
1945 .as_ref()
1946 .is_some_and(|title| title.trim().is_empty())
1947 || self
1948 .session_title_override
1949 .as_ref()
1950 .is_some_and(|title| title.trim().is_empty())
1951 {
1952 bail!("session {:?} has an empty display title", self.id);
1953 }
1954 if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1955 bail!("session {:?} has an empty timestamp", self.id);
1956 }
1957 if let Some(target) = &self.target {
1958 target.validate(&self.id)?;
1959 }
1960 if let Some(checkpoint) = &self.checkpoint {
1961 checkpoint.validate()?;
1962 }
1963 Ok(())
1964 }
1965}
1966
1967fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1968 repository
1969 .github
1970 .as_deref()
1971 .and_then(ProjectSourceIdentity::git_remote)
1972 .or_else(|| {
1973 repository
1974 .local
1975 .as_deref()
1976 .map(|path| ProjectSourceIdentity::path(path, None))
1977 })
1978}
1979
1980#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1981pub struct ProjectSourceIdentity {
1982 pub key: String,
1983 pub short: String,
1984 pub full: String,
1985}
1986
1987impl ProjectSourceIdentity {
1988 pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1990 if sources.is_empty() {
1991 return None;
1992 }
1993 sources.sort_by(|left, right| {
1994 left.key
1995 .cmp(&right.key)
1996 .then_with(|| left.full.cmp(&right.full))
1997 .then_with(|| left.short.cmp(&right.short))
1998 });
1999 sources.dedup_by(|left, right| left.key == right.key);
2000 if sources.len() == 1 {
2001 return sources.pop();
2002 }
2003 let keys = sources
2004 .iter()
2005 .map(|source| source.key.clone())
2006 .collect::<Vec<_>>();
2007 let key = serde_json::to_string(&keys).ok()?;
2008 Some(Self {
2009 key: format!("bundle:{key}"),
2010 short: sources
2011 .iter()
2012 .map(|source| source.short.as_str())
2013 .collect::<Vec<_>>()
2014 .join(" + "),
2015 full: sources
2016 .iter()
2017 .map(|source| source.full.as_str())
2018 .collect::<Vec<_>>()
2019 .join(" + "),
2020 })
2021 }
2022
2023 pub fn git_remote(source: &str) -> Option<Self> {
2026 let identity = crate::repository::RepositoryIdentity::from_remote(source)?;
2027 let full = crate::repository::RepositoryIdentity::remote_label(source)?;
2028 Some(Self {
2029 key: identity.key(),
2030 short: full.rsplit(['/', ':']).next()?.to_owned(),
2031 full,
2032 })
2033 }
2034
2035 pub fn path(path: &Path, remote: Option<&str>) -> Self {
2038 let normalized = path.components().collect::<PathBuf>();
2039 let path_text = crate::target_path::text(&normalized);
2040 let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
2041 let key = remote.map_or_else(
2042 || format!("path:{path_text}"),
2043 |host| format!("path:{}:{path_text}", host.to_lowercase()),
2044 );
2045 Self {
2046 key,
2047 short: path_leaf(path),
2048 full,
2049 }
2050 }
2051}
2052
2053fn path_leaf(path: &Path) -> String {
2055 path.file_name()
2056 .unwrap_or(path.as_os_str())
2057 .to_string_lossy()
2058 .into_owned()
2059}
2060
2061fn created_at_seconds(timestamp: &str) -> Option<i64> {
2062 chrono::DateTime::parse_from_rfc3339(timestamp)
2063 .ok()
2064 .map(|timestamp| timestamp.timestamp())
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2068#[serde(deny_unknown_fields)]
2069pub struct State {
2070 #[serde(default)]
2071 pub last_subagent_policy: crate::subagent::SubagentPolicy,
2072 pub version: u32,
2073 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2074 pub sessions: SnapshotMap<String, SessionRecord>,
2075 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2078 pub subagents: SnapshotMap<String, SubagentRecord>,
2079 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2081 pub mount_history: SnapshotMap<String, Vec<PathBuf>>,
2082 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2084 pub container_sizes: SnapshotMap<String, HostContainerSize>,
2085}
2086
2087impl Default for State {
2088 fn default() -> Self {
2089 Self {
2090 version: STATE_VERSION,
2091 last_subagent_policy: Default::default(),
2092 sessions: SnapshotMap::new(),
2093 subagents: SnapshotMap::new(),
2094 mount_history: SnapshotMap::new(),
2095 container_sizes: SnapshotMap::new(),
2096 }
2097 }
2098}
2099
2100#[derive(Debug, Clone, PartialEq, Eq)]
2107pub enum Checkout<'a> {
2108 Attached { path: &'a Path },
2110 ManagedWorktree {
2117 worktree: &'a ManagedWorktree,
2118 project_directory: Option<&'a Path>,
2119 },
2120 ManagedWorkspace,
2123 Borrowed {
2125 owner: &'a SessionRecord,
2126 checkout: Box<Checkout<'a>>,
2127 },
2128}
2129
2130fn derive_record_checkout(session: &SessionRecord) -> Checkout<'_> {
2132 if let Some(worktree) = &session.managed_worktree {
2133 return Checkout::ManagedWorktree {
2134 worktree,
2135 project_directory: session.project_directory.as_deref(),
2136 };
2137 }
2138 if let Some(path) = session.project_directory.as_deref() {
2139 return Checkout::Attached { path };
2140 }
2141 Checkout::ManagedWorkspace
2142}
2143
2144impl<'a> Checkout<'a> {
2145 #[must_use]
2148 pub fn managed_worktree(&self) -> Option<&'a ManagedWorktree> {
2149 match self {
2150 Self::ManagedWorktree { worktree, .. } => Some(*worktree),
2151 Self::Attached { .. } | Self::ManagedWorkspace | Self::Borrowed { .. } => None,
2152 }
2153 }
2154
2155 #[must_use]
2159 pub fn effective(&self) -> Checkout<'a> {
2160 match self {
2161 Self::Borrowed { checkout, .. } => checkout.effective(),
2162 checkout => checkout.clone(),
2163 }
2164 }
2165
2166 #[must_use]
2169 pub fn project_directory(&self) -> Option<&'a Path> {
2170 match self {
2171 Self::Attached { path } => Some(*path),
2172 Self::ManagedWorktree {
2173 project_directory, ..
2174 } => *project_directory,
2175 Self::ManagedWorkspace => None,
2176 Self::Borrowed { checkout, .. } => checkout.project_directory(),
2177 }
2178 }
2179}
2180
2181pub fn session_is_live(record: &SessionRecord, in_operation: bool, parent_live: bool) -> bool {
2189 record.state != SessionState::Stopped || in_operation || parent_live
2190}
2191
2192pub fn live_session_ids(
2196 sessions: &SnapshotMap<String, SessionRecord>,
2197 subagents: &SnapshotMap<String, SubagentRecord>,
2198 operations: &BTreeSet<String>,
2199) -> BTreeSet<String> {
2200 let mut live = sessions
2201 .iter()
2202 .filter(|(id, record)| session_is_live(record, operations.contains(*id), false))
2203 .map(|(id, _)| id.clone())
2204 .collect::<BTreeSet<_>>();
2205 loop {
2206 let joined = subagents
2207 .iter()
2208 .filter(|(child, relation)| {
2209 !live.contains(*child)
2210 && sessions.contains_key(*child)
2211 && live.contains(&relation.parent_session_id)
2212 })
2213 .map(|(child, _)| child.clone())
2214 .collect::<Vec<_>>();
2215 if joined.is_empty() {
2216 return live;
2217 }
2218 live.extend(joined);
2219 }
2220}
2221
2222impl State {
2223 pub fn checkout(&self, session_id: &str) -> Result<Checkout<'_>> {
2239 let session = self
2240 .sessions
2241 .get(session_id)
2242 .with_context(|| format!("unknown session {session_id}"))?;
2243 self.checkout_for_record(session_id, session)
2244 }
2245
2246 pub fn checkout_for_record<'a>(
2249 &'a self,
2250 session_id: &str,
2251 session: &'a SessionRecord,
2252 ) -> Result<Checkout<'a>> {
2253 self.checkout_for_record_inner(session_id, session, &mut BTreeSet::new())
2254 }
2255
2256 fn checkout_for_record_inner<'a>(
2257 &'a self,
2258 session_id: &str,
2259 session: &'a SessionRecord,
2260 visited: &mut BTreeSet<String>,
2261 ) -> Result<Checkout<'a>> {
2262 if !visited.insert(session_id.to_owned()) {
2263 bail!("sub-agent checkout ownership contains a cycle at {session_id}");
2264 }
2265 if let Some(relation) = self.subagents.get(session_id) {
2266 let Some(owner) = self.sessions.get(&relation.parent_session_id) else {
2267 return Ok(session.checkout());
2268 };
2269 return Ok(Checkout::Borrowed {
2270 owner,
2271 checkout: Box::new(self.checkout_for_record_inner(&owner.id, owner, visited)?),
2272 });
2273 }
2274 Ok(session.checkout())
2275 }
2276
2277 #[must_use]
2282 pub fn session_notice_name(&self, session_id: &str) -> String {
2283 match self.sessions.get(session_id) {
2284 Some(session) if session.listed_title() != session.id => {
2285 session.listed_title().to_owned()
2286 }
2287 _ => short_id(session_id).to_owned(),
2288 }
2289 }
2290
2291 #[must_use]
2300 pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
2301 self.subagents
2302 .get(&session.id)
2303 .and_then(|record| self.sessions.get(&record.parent_session_id))
2304 .unwrap_or(session)
2305 }
2306
2307 #[must_use]
2314 pub fn is_subagent_session(&self, id: &str) -> bool {
2315 self.subagents.contains_key(id) || crate::native_agent::is_view_id(id)
2316 }
2317
2318 pub fn validate(&self) -> Result<()> {
2319 if self.version != STATE_VERSION {
2320 bail!(
2321 "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
2322 self.version
2323 );
2324 }
2325 for (id, session) in &self.sessions {
2326 session.validate(id)?;
2327 }
2328 for child_id in self.subagents.keys() {
2329 self.validate_subagent(child_id)?;
2330 }
2331 for (host, sources) in &self.mount_history {
2332 if host.trim().is_empty() {
2333 bail!("mount history contains an empty host key");
2334 }
2335 if sources
2336 .iter()
2337 .any(|source| !crate::target_path::is_absolute_on_host_or_target(source))
2338 {
2339 bail!("mount history for {host:?} contains a non-absolute source path");
2340 }
2341 }
2342 for (host, size) in &self.container_sizes {
2343 if host.trim().is_empty() {
2344 bail!("container size history contains an empty host key");
2345 }
2346 if size.cpus == 0 || size.memory_bytes == 0 {
2347 bail!("container size history for {host:?} contains a zero value");
2348 }
2349 if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
2350 bail!("container size history for {host:?} exceeds SQLite integer range");
2351 }
2352 }
2353 Ok(())
2354 }
2355
2356 pub fn validate_subagent(&self, child_id: &str) -> Result<()> {
2358 let Some(subagent) = self.subagents.get(child_id) else {
2359 return Ok(());
2360 };
2361 if child_id != subagent.child_session_id {
2362 bail!("sub-agent key {child_id:?} does not match its child session id");
2363 }
2364 if child_id == subagent.parent_session_id {
2365 bail!("sub-agent {child_id:?} cannot be its own parent");
2366 }
2367 if !self.sessions.contains_key(child_id) {
2368 bail!("sub-agent {child_id:?} has no child session");
2369 }
2370 if !self.sessions.contains_key(&subagent.parent_session_id) {
2371 bail!(
2372 "sub-agent {child_id:?} has unknown parent {:?}",
2373 subagent.parent_session_id
2374 );
2375 }
2376 if self.subagents.contains_key(&subagent.parent_session_id) {
2377 bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
2378 }
2379 if subagent.task_name.trim().is_empty()
2380 || subagent.profile_id.trim().is_empty()
2381 || subagent.request_key.trim().is_empty()
2382 {
2383 bail!("sub-agent {child_id:?} has incomplete relationship metadata");
2384 }
2385 Ok(())
2386 }
2387
2388 pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
2389 if mounts.is_empty() {
2390 return;
2391 }
2392 let sources = self
2393 .mount_history
2394 .entry(host.to_owned())
2395 .or_insert_with(Vec::new);
2396 for mount in mounts.iter().rev() {
2397 sources.retain(|source| source != &mount.source);
2398 sources.insert(0, mount.source.clone());
2399 }
2400 sources.truncate(20);
2401 }
2402
2403 pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
2404 self.container_sizes.insert(host.to_owned(), size);
2405 }
2406
2407 pub fn project_directories(&self, host: &str) -> &[PathBuf] {
2408 self.mount_history
2409 .get(&project_history_key(host))
2410 .map(Vec::as_slice)
2411 .unwrap_or_default()
2412 }
2413
2414 pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
2415 let key = project_history_key(host);
2416 let directories = self.mount_history.entry(key).or_insert_with(Vec::new);
2417 directories.retain(|existing| existing != directory);
2418 directories.insert(0, directory.to_path_buf());
2419 directories.truncate(20);
2420 }
2421
2422 pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
2423 let session = self
2424 .sessions
2425 .get(session_id)
2426 .with_context(|| format!("unknown session {session_id}"))?;
2427 if session.state.is_active() {
2428 bail!("refusing to destroy active session {session_id}");
2429 }
2430 Ok(self
2431 .sessions
2432 .remove(session_id)
2433 .expect("session checked above"))
2434 }
2435
2436 pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
2442 self.sessions
2443 .get(session_id)
2444 .with_context(|| format!("unknown session {session_id}"))?;
2445 Ok(self
2446 .sessions
2447 .remove(session_id)
2448 .expect("session checked above"))
2449 }
2450
2451 pub fn bundle_users(&self, bundle_id: &str) -> Vec<&SessionRecord> {
2456 self.sessions
2457 .values()
2458 .filter(|session| {
2459 session.bundle_id == bundle_id
2460 && self
2461 .checkout(&session.id)
2462 .is_ok_and(|checkout| checkout.project_directory().is_none())
2463 && session.state != SessionState::DestroyedWithDataLoss
2464 })
2465 .collect()
2466 }
2467
2468 pub fn bundle_removal_refusal(&self, bundle_id: &str) -> Option<String> {
2471 let users = self.bundle_users(bundle_id);
2472 if users.is_empty() {
2473 return None;
2474 }
2475 let mut names = users
2476 .iter()
2477 .take(3)
2478 .map(|session| format!("{:?}", session.listed_title()))
2479 .collect::<Vec<_>>();
2480 if users.len() > 3 {
2481 names.push(format!("{} more", users.len() - 3));
2482 }
2483 Some(format!(
2484 "Project {bundle_id:?} is used by {}: {}. Destroy those sessions before removing it.",
2485 if users.len() == 1 {
2486 "a session"
2487 } else {
2488 "sessions"
2489 },
2490 names.join(", ")
2491 ))
2492 }
2493
2494 pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
2497 for session in self
2498 .sessions
2499 .values()
2500 .filter(|session| session.state.is_active())
2501 {
2502 let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
2503 let mut comparable = profile.clone();
2504 if let Some(updated) = after.profiles.get(&session.last_profile) {
2505 comparable.enabled = updated.enabled;
2506 comparable.subagents = updated.subagents.clone();
2507 }
2508 profile.kind == session.harness_kind
2510 && after.profiles.get(&session.last_profile) != Some(&comparable)
2511 } else {
2512 false
2513 };
2514 let bundle_changed = self
2515 .checkout(&session.id)
2516 .is_ok_and(|checkout| checkout.project_directory().is_none())
2517 && before
2518 .bundles
2519 .get(&session.bundle_id)
2520 .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
2521 let target_changed =
2525 before
2526 .targets
2527 .get(&session.target_template_id)
2528 .is_some_and(|target| {
2529 after
2530 .targets
2531 .get(&session.target_template_id)
2532 .map(TargetTemplate::without_launch_only_settings)
2533 != Some(target.without_launch_only_settings())
2534 });
2535 if protected || bundle_changed || target_changed {
2536 let mut used = Vec::new();
2540 if protected {
2541 used.push(format!("agent profile {:?}", session.last_profile));
2542 }
2543 if bundle_changed {
2544 used.push(format!("project {:?}", session.project_name(before)));
2545 }
2546 if target_changed {
2547 used.push(format!("runtime {:?}", session.target_template_id));
2548 }
2549 let used = match used.as_slice() {
2550 [only] => only.clone(),
2551 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2552 [] => unreachable!("something changed"),
2553 };
2554 let title = session.display_title();
2555 let named = if title == session.id {
2556 format!(
2557 "a running session in project {:?}",
2558 session.project_name(before)
2559 )
2560 } else {
2561 format!("the running session {title:?}")
2562 };
2563 bail!(
2564 "Setup would change the {used} that {named} uses. Save the new settings under a new name, or stop the session first."
2565 );
2566 }
2567 }
2568 Ok(())
2569 }
2570
2571 pub fn validate_against_config(&self, config: &Config) -> Result<()> {
2573 self.validate()?;
2574 config.validate()?;
2575 for session in self.sessions.values() {
2576 session.validate_configuration(config)?;
2577 }
2578 Ok(())
2579 }
2580}
2581
2582fn project_history_key(host: &str) -> String {
2583 format!("project:{host}")
2584}
2585
2586pub fn new_session_id() -> Result<String> {
2588 let mut random = [0u8; 16];
2589 getrandom::fill(&mut random)
2590 .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
2591 Ok(crate::hex::lower_hex(random))
2592}
2593
2594pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
2596 events.iter().rev().find_map(|event| {
2597 let WorkerEvent::Adapter { payload, .. } = &event.event else {
2598 return None;
2599 };
2600 let crate::acp::RuntimeEvent::SessionUpdate { update } =
2601 serde_json::from_value(payload.clone()).ok()?
2602 else {
2603 return None;
2604 };
2605 let kind = update
2606 .get("sessionUpdate")
2607 .and_then(serde_json::Value::as_str)?;
2608 let title = match kind {
2609 "session_info_update" | "session_title" => {
2610 update.get("title").and_then(serde_json::Value::as_str)
2611 }
2612 _ => None,
2613 }?;
2614 normalize_session_title(title)
2615 })
2616}
2617
2618pub fn default_session_title(
2623 project_directory: Option<&Path>,
2624 bundle_id: &str,
2625 profile_id: &str,
2626) -> String {
2627 let project = project_directory.and_then(Path::file_name).map_or_else(
2628 || bundle_id.to_owned(),
2629 |name| name.to_string_lossy().into_owned(),
2630 );
2631 format!("{project} via {profile_id}")
2632}
2633
2634pub const MAX_SESSION_TITLE_CHARS: usize = 256;
2635
2636pub fn normalize_session_title(title: &str) -> Option<String> {
2638 let normalized = crate::relay::strip_hidden_prompt_context(title)
2639 .split_whitespace()
2640 .collect::<Vec<_>>()
2641 .join(" ");
2642 (!normalized.is_empty()).then(|| truncate_session_title(normalized, MAX_SESSION_TITLE_CHARS))
2643}
2644
2645fn truncate_session_title(title: String, maximum_chars: usize) -> String {
2646 if title.chars().count() <= maximum_chars {
2647 return title;
2648 }
2649
2650 let mut truncated = title.chars().take(maximum_chars - 1).collect::<String>();
2651 if let Some(boundary) = truncated.rfind(char::is_whitespace) {
2652 truncated.truncate(boundary);
2653 }
2654 truncated.push('…');
2655 truncated
2656}
2657
2658pub fn provisional_session_title(prompt: &str) -> Option<String> {
2664 const MAX_TITLE_CHARS: usize = 64;
2665
2666 let normalized = normalize_session_title(prompt)?;
2667 Some(truncate_session_title(normalized, MAX_TITLE_CHARS))
2668}
2669
2670pub fn short_id(id: &str) -> &str {
2671 id.get(..8).unwrap_or(id)
2672}
2673
2674#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2675pub struct RecoveryCandidate {
2676 pub session_id: String,
2677 pub target_template_id: String,
2678 pub locator: TargetLocator,
2679 pub ownership: Option<crate::worker_launch::WorkerOwnership>,
2680 #[serde(default)]
2683 pub instance_id: Option<String>,
2684 #[serde(default, skip_serializing_if = "Option::is_none")]
2689 pub tracked_session: Option<SessionState>,
2690}
2691
2692#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2693pub struct RecoveryScan {
2694 pub candidates: Vec<RecoveryCandidate>,
2695 pub warnings: Vec<String>,
2696 #[serde(default)]
2698 pub instance_id: String,
2699 #[serde(default)]
2702 pub hidden_other_instances: usize,
2703}
2704
2705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2706#[serde(deny_unknown_fields)]
2707pub struct ResumeRepositorySourceReceipt {
2708 pub session_id: String,
2709 pub bundle_id: String,
2710 pub checkpoint_sha256: String,
2711 pub repositories: Vec<crate::config::ProjectRepository>,
2712}
2713
2714#[cfg(test)]
2715mod tests;