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, ToolCachePlacement};
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 "inferredfinished" => PromptCompletion::Finished,
299 "cancelled" | "canceled" => PromptCompletion::Cancelled,
300 "quotalimit" => PromptCompletion::QuotaLimit,
301 _ => PromptCompletion::Error,
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct MaterializedTurnOutcome {
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
311
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub usage: Option<crate::usage::TokenUsage>,
314 pub command_id: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub accepted_ordinal: Option<u64>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub turn_start_position: Option<u64>,
319 pub completed_ordinal: u64,
320 pub completed_at_ms: i64,
321 pub outcome: TurnOutcomeKind,
322}
323
324impl MaterializedTurnOutcome {
325 pub fn interruption_ordinal(&self) -> Option<u64> {
327 (self.turn_start_position.is_some()
328 && matches!(self.outcome, TurnOutcomeKind::Interrupted { .. }))
329 .then_some(self.completed_ordinal)
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct MaterializedSession {
337 pub session_id: String,
338 pub applied_event_ordinal: u64,
339 pub applied_event_digest: String,
340 pub last_activity_at_ms: Option<i64>,
343 pub execution: MaterializedExecutionState,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub session_title: Option<String>,
346 #[serde(default, skip_serializing_if = "SessionConfiguration::is_empty")]
347 pub configuration: SessionConfiguration,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub transcript: Vec<Arc<TranscriptItem>>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub queued_prompts: Vec<MaterializedQueuedPrompt>,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
357 pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub active_turn: Option<MaterializedTurn>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct MaterializedSessionSummary {
371 pub session_id: String,
372 pub applied_event_ordinal: u64,
373 pub last_activity_at_ms: Option<i64>,
374 pub execution: MaterializedExecutionState,
375 pub session_title: Option<String>,
376 pub last_agent_message: Option<String>,
377 pub last_user_message: Option<String>,
378 pub last_agent_message_follows_last_user: bool,
381 pub agent_message_latest_content_ordinals: Vec<u64>,
382 pub interruption_event_ordinals: Vec<u64>,
383}
384
385impl MaterializedSession {
386 pub fn empty(session_id: impl Into<String>) -> Self {
387 Self {
388 session_id: session_id.into(),
389 applied_event_ordinal: 0,
390 applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
391 last_activity_at_ms: None,
392 execution: MaterializedExecutionState::Idle,
393 session_title: None,
394 configuration: SessionConfiguration::default(),
395 transcript: Vec::new(),
396 queued_prompts: Vec::new(),
397 pending_elicitations: Vec::new(),
398 active_turn: None,
399 last_turn_outcome: None,
400 }
401 }
402
403 pub fn last_activity_at_ms(&self) -> Option<i64> {
404 self.last_activity_at_ms
405 }
406
407 pub fn resolved_title(&self) -> Option<String> {
413 self.session_title
414 .as_deref()
415 .and_then(normalize_session_title)
416 .or_else(|| {
417 self.transcript.iter().find_map(|item| {
418 if !item.is_user_prompt() {
419 return None;
420 }
421 let TranscriptBody::User { content } = &item.body else {
422 return None;
423 };
424 provisional_session_title(&crate::transcript::materialized_content_text(
425 content,
426 ))
427 })
428 })
429 .or_else(|| {
430 self.queued_prompts
431 .iter()
432 .filter(|prompt| prompt.kind.is_prompt())
433 .find_map(|prompt| {
434 provisional_session_title(&crate::transcript::materialized_content_text(
435 &prompt.content,
436 ))
437 })
438 })
439 }
440
441 pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
442 self.transcript
443 .iter()
444 .filter(|item| {
445 item.latest_content_event_ordinal
446 .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
447 && item.is_nonempty_agent_message()
448 })
449 .count() as u64
450 }
451
452 pub fn unread_interruptions_after(&self, viewed_through_event_ordinal: u64) -> u64 {
453 self.interruption_event_ordinals()
454 .into_iter()
455 .filter(|ordinal| *ordinal > viewed_through_event_ordinal)
456 .count() as u64
457 }
458
459 pub fn interruption_event_ordinals(&self) -> Vec<u64> {
460 let mut ordinals = self
461 .transcript
462 .iter()
463 .filter(|item| item.is_work_interruption())
464 .map(|item| item.position)
465 .collect::<Vec<_>>();
466 if let Some(ordinal) = self
467 .last_turn_outcome
468 .as_ref()
469 .and_then(MaterializedTurnOutcome::interruption_ordinal)
470 {
471 ordinals.push(ordinal);
472 }
473 ordinals.sort_unstable();
474 ordinals.dedup();
475 ordinals
476 }
477
478 pub fn validate(&self) -> Result<()> {
479 validate_id("session", &self.session_id)?;
480 validate_relay_event_frontier(
481 self.applied_event_ordinal,
482 &self.applied_event_digest,
483 "materialized session event frontier",
484 )?;
485 if self
486 .session_title
487 .as_ref()
488 .is_some_and(|title| title.trim().is_empty())
489 {
490 bail!("materialized session has an empty title");
491 }
492 let mut item_ids = BTreeSet::new();
493 for item in &self.transcript {
494 item.validate(self.applied_event_ordinal)?;
495 if !item_ids.insert(item.stable_id.as_str()) {
496 bail!(
497 "materialized transcript contains duplicate item {:?}",
498 item.stable_id
499 );
500 }
501 }
502 let mut command_ids = BTreeSet::new();
503 for prompt in &self.queued_prompts {
504 if prompt.command_id.trim().is_empty() {
505 bail!("materialized prompt queue has an empty command id");
506 }
507 if !command_ids.insert(prompt.command_id.as_str()) {
508 bail!(
509 "materialized prompt queue contains duplicate command {:?}",
510 prompt.command_id
511 );
512 }
513 if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
514 && (key.trim().is_empty() || value.trim().is_empty())
515 {
516 bail!(
517 "materialized queued configuration change {:?} is incomplete",
518 prompt.command_id
519 );
520 }
521 }
522 Ok(())
523 }
524}
525
526#[derive(Debug, Clone, PartialEq)]
530pub struct ManagedSessionSnapshot {
531 pub materialized: MaterializedSession,
532 pub window: ProjectionWindow,
535 pub operational: RelayOperationalState,
536 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
540 pub worker_build: Option<String>,
544 pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
546 pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
548}
549
550#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
562pub struct ProjectionWindow {
563 pub omitted_items: usize,
565 pub provisional_title: Option<String>,
567 pub latest_turn_start_position: Option<u64>,
571}
572
573impl ProjectionWindow {
574 pub fn trim(&mut self, session: &mut MaterializedSession, target: usize) {
577 let observed = Self::of(session);
578 if self.provisional_title.is_none() {
579 self.provisional_title = observed.provisional_title;
580 }
581 self.latest_turn_start_position = observed
582 .latest_turn_start_position
583 .or(self.latest_turn_start_position);
584 let mut boundary = session.transcript.len().saturating_sub(target.max(1));
585 for (index, item) in session.transcript.iter().enumerate() {
586 let mutable = match &item.body {
587 TranscriptBody::Agent { streaming, .. }
588 | TranscriptBody::Thought { streaming, .. } => *streaming,
589 TranscriptBody::Tool { call, .. } => matches!(
590 call.get("status").and_then(serde_json::Value::as_str),
591 Some("pending" | "in_progress")
592 ),
593 _ => false,
594 };
595 if mutable || Some(item.position) == self.latest_turn_start_position {
596 boundary = boundary.min(index);
597 }
598 }
599 let cut = session
600 .transcript
601 .iter()
602 .take(boundary + 1)
603 .rposition(|item| item.is_turn_start())
604 .unwrap_or(0);
605 if cut > 0 {
606 session.transcript.drain(..cut);
607 self.omitted_items += cut;
608 }
609 }
610
611 #[must_use]
613 pub fn of(session: &MaterializedSession) -> Self {
614 Self {
615 omitted_items: 0,
616 provisional_title: session.transcript.iter().find_map(|item| {
617 if !item.is_user_prompt() {
618 return None;
619 }
620 let TranscriptBody::User { content } = &item.body else {
621 return None;
622 };
623 provisional_session_title(&crate::transcript::materialized_content_text(content))
624 }),
625 latest_turn_start_position: session
626 .transcript
627 .iter()
628 .rev()
629 .find(|item| item.is_turn_start())
630 .map(|item| item.position),
631 }
632 }
633}
634
635impl ManagedSessionSnapshot {
636 #[must_use]
641 pub fn resolved_title(&self) -> Option<String> {
642 self.materialized
643 .session_title
644 .as_deref()
645 .and_then(normalize_session_title)
646 .or_else(|| self.window.provisional_title.clone())
647 .or_else(|| {
648 self.materialized
649 .queued_prompts
650 .iter()
651 .filter(|prompt| prompt.kind.is_prompt())
652 .find_map(|prompt| {
653 provisional_session_title(&crate::transcript::materialized_content_text(
654 &prompt.content,
655 ))
656 })
657 })
658 }
659
660 #[must_use]
665 pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
666 if self.materialized.execution != MaterializedExecutionState::Idle {
667 return None;
668 }
669 self.window.latest_turn_start_position
670 }
671}
672
673#[derive(Debug, Clone)]
675pub struct RecoveryObservation {
676 pub session: SessionRecord,
677 pub config: Config,
678 pub latest_completed_turn_ordinal: Option<u64>,
679 pub checkpoint_wait: Option<crate::activity::CheckpointWait>,
683}
684
685pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
690 if session.execution != MaterializedExecutionState::Idle {
691 return None;
692 }
693 session
694 .transcript
695 .iter()
696 .rev()
697 .find(|item| item.is_turn_start())
698 .map(|item| item.position)
699}
700
701pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
702 if digest.len() != 64
703 || !digest
704 .bytes()
705 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
706 {
707 bail!("{name} must be a lowercase SHA-256 digest");
708 }
709 Ok(())
710}
711
712pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
713 validate_relay_event_digest(digest, name)?;
714 if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
715 bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
716 }
717 Ok(())
718}
719
720fn is_false(value: &bool) -> bool {
721 !*value
722}
723
724impl SessionState {
725 pub const fn as_str(self) -> &'static str {
727 match self {
728 Self::Provisioning => "provisioning",
729 Self::StartupCleanup => "startup-cleanup",
730 Self::Running => "running",
731 Self::Disconnected => "disconnected",
732 Self::Checkpointing => "checkpointing",
733 Self::Closing => "closing",
734 Self::Destroying => "destroying",
735 Self::Stopped => "stopped",
736 Self::Parked => "parked",
737 Self::Lost => "lost",
738 Self::Error => "error",
739 Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
740 }
741 }
742
743 pub fn from_stored(value: &str) -> Option<Self> {
746 Some(match value {
747 "provisioning" => Self::Provisioning,
748 "startup-cleanup" => Self::StartupCleanup,
749 "running" => Self::Running,
750 "disconnected" => Self::Disconnected,
751 "checkpointing" => Self::Checkpointing,
752 "closing" => Self::Closing,
753 "destroying" => Self::Destroying,
754 "stopped" | "archived" => Self::Stopped,
755 "parked" => Self::Parked,
756 "lost" => Self::Lost,
757 "error" => Self::Error,
758 "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
759 _ => return None,
760 })
761 }
762
763 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
766 match self {
767 Self::Provisioning => Some(SessionTransitionKind::Starting),
768 Self::StartupCleanup => Some(SessionTransitionKind::Stopping),
769 Self::Closing => Some(SessionTransitionKind::Suspending),
770 Self::Destroying => Some(SessionTransitionKind::Destroying),
771 _ => None,
772 }
773 }
774
775 pub const fn is_active(self) -> bool {
782 matches!(
783 self,
784 Self::Provisioning
785 | Self::StartupCleanup
786 | Self::Running
787 | Self::Disconnected
788 | Self::Checkpointing
789 | Self::Closing
790 | Self::Destroying
791 | Self::Parked
792 | Self::Error
793 )
794 }
795
796 pub const fn has_live_worker(self) -> bool {
800 matches!(
801 self,
802 Self::Provisioning
803 | Self::StartupCleanup
804 | Self::Running
805 | Self::Disconnected
806 | Self::Checkpointing
807 | Self::Closing
808 | Self::Destroying
809 )
810 }
811}
812
813#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
814#[serde(tag = "kind", rename_all = "kebab-case")]
815pub enum PodmanWorkspaceLocator {
816 #[default]
817 ContainerLayer,
818 Volume {
819 name: String,
820 },
821 HostPath {
822 path: PathBuf,
823 helper: Vec<String>,
824 resource: String,
825 },
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(tag = "kind", rename_all = "kebab-case")]
830pub enum TargetLocator {
831 LocalBare {
832 worker_root: PathBuf,
833 },
834 LocalPodman {
835 container_id: String,
836 #[serde(default)]
837 workspace_storage: PodmanWorkspaceLocator,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
842 borrowed_from: Option<String>,
843 },
844 LocalDocker {
845 container_id: String,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
850 borrowed_from: Option<String>,
851 },
852 AppleContainer {
853 container_id: String,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
858 borrowed_from: Option<String>,
859 },
860 AwsEc2 {
861 instance_id: String,
862 #[serde(default, skip_serializing_if = "Option::is_none")]
863 address: Option<String>,
864 },
865 SshBare {
866 host: String,
867 workspace: PathBuf,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 worker_id: Option<String>,
870 },
871 SshPodman {
872 host: String,
873 container_id: String,
874 #[serde(default)]
875 workspace_storage: PodmanWorkspaceLocator,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
880 borrowed_from: Option<String>,
881 },
882 SshDocker {
883 host: String,
884 container_id: String,
885 #[serde(default, skip_serializing_if = "Option::is_none")]
889 borrowed_from: Option<String>,
890 },
891}
892
893impl ManagedWorktreeTarget {
894 pub fn same_location(&self, other: &Self) -> bool {
902 match (self, other) {
903 (Self::Local, Self::Local) => true,
904 (
905 Self::Ssh {
906 destination,
907 ssh_args,
908 },
909 Self::Ssh {
910 destination: other_destination,
911 ssh_args: other_args,
912 },
913 ) => {
914 destination == other_destination
915 && ssh_location_option(ssh_args, 'p', "port")
916 == ssh_location_option(other_args, 'p', "port")
917 && ssh_location_option(ssh_args, 'l', "user")
918 == ssh_location_option(other_args, 'l', "user")
919 }
920 _ => false,
921 }
922 }
923}
924
925fn ssh_location_option(args: &[String], flag: char, option: &str) -> Option<String> {
929 let mut args = args.iter();
930 while let Some(argument) = args.next() {
931 let Some(rest) = argument.strip_prefix('-') else {
932 continue;
933 };
934 let mut chars = rest.chars();
935 let Some(name) = chars.next() else { continue };
936 if name != flag && name != 'o' {
937 continue;
938 }
939 let inline = chars.as_str();
940 let value = if inline.is_empty() {
941 args.next().cloned()
942 } else {
943 Some(inline.to_owned())
944 };
945 if name == flag {
946 return value;
947 }
948 if let Some(setting) = value {
949 let (key, found) = setting
950 .split_once(['=', ' ', '\t'])
951 .unwrap_or((setting.as_str(), ""));
952 if key.trim().eq_ignore_ascii_case(option) {
953 return Some(found.trim().to_owned());
954 }
955 }
956 }
957 None
958}
959
960#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(tag = "kind", rename_all = "kebab-case")]
962pub enum ManagedWorktreeTarget {
963 Local,
964 Ssh {
965 destination: String,
966 #[serde(default, skip_serializing_if = "Vec::is_empty")]
967 ssh_args: Vec<String>,
968 },
969}
970
971#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
973#[serde(deny_unknown_fields)]
974pub struct ManagedWorktreeOptions {
975 pub available: bool,
976 pub default_create: bool,
977}
978
979#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980#[serde(deny_unknown_fields)]
981pub struct ManagedWorktree {
982 #[serde(default, skip_serializing_if = "ManagedCheckoutKind::is_worktree")]
984 pub kind: ManagedCheckoutKind,
985 pub source_project_directory: PathBuf,
986 pub source_repository: PathBuf,
987 pub worktree_root: PathBuf,
988 pub branch: String,
989 pub target: ManagedWorktreeTarget,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub base_commit: Option<String>,
995}
996
997#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
998#[serde(rename_all = "snake_case")]
999pub enum ManagedCheckoutKind {
1000 #[default]
1001 Worktree,
1002 Clone,
1003}
1004
1005impl ManagedCheckoutKind {
1006 fn is_worktree(&self) -> bool {
1007 matches!(self, Self::Worktree)
1008 }
1009}
1010
1011impl ManagedWorktree {
1012 fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
1013 for (label, path) in [
1014 ("source project directory", &self.source_project_directory),
1015 ("source repository", &self.source_repository),
1016 ("worktree root", &self.worktree_root),
1017 ] {
1018 if !crate::target_path::is_absolute(path)
1019 || path.components().any(|part| part == Component::ParentDir)
1020 {
1021 bail!("managed worktree {label} must be an absolute safe path");
1022 }
1023 }
1024 if !self
1025 .source_project_directory
1026 .starts_with(&self.source_repository)
1027 {
1028 bail!("managed worktree source directory is outside its repository");
1029 }
1030 let expected_root = self
1031 .source_repository
1032 .join(".mj")
1033 .join(match self.kind {
1034 ManagedCheckoutKind::Worktree => "worktrees",
1035 ManagedCheckoutKind::Clone => "clones",
1036 })
1037 .join(session_id);
1038 if self.worktree_root != expected_root {
1039 bail!("managed worktree root does not match the session-owned path");
1040 }
1041 if self.kind == ManagedCheckoutKind::Worktree && self.branch != format!("mj/{session_id}") {
1042 bail!("managed worktree branch does not match the session id");
1043 }
1044 if self.kind == ManagedCheckoutKind::Clone && self.branch.trim().is_empty() {
1045 bail!("managed clone has no starting branch");
1046 }
1047 let relative = self
1048 .source_project_directory
1049 .strip_prefix(&self.source_repository)
1050 .expect("source relationship checked above");
1051 if project_directory != Some(self.worktree_root.join(relative).as_path()) {
1052 bail!("session project directory does not match its managed worktree");
1053 }
1054 match &self.target {
1055 ManagedWorktreeTarget::Local => {}
1056 ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
1057 bail!("managed SSH worktree has an empty destination")
1058 }
1059 ManagedWorktreeTarget::Ssh { .. } => {}
1060 }
1061 Ok(())
1062 }
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066#[serde(tag = "kind", rename_all = "kebab-case")]
1067pub enum SessionResourceAllocation {
1068 Container {
1069 cpus: u64,
1070 memory_bytes: u64,
1071 },
1072 AwsEc2 {
1073 instance_type: String,
1074 vcpus: u64,
1075 memory_bytes: u64,
1076 },
1077}
1078
1079#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1081#[serde(rename_all = "kebab-case")]
1082pub enum ResourceAllocationKind {
1083 #[default]
1084 Fixed,
1085 Container,
1086 AwsEc2,
1087}
1088
1089impl SessionResourceAllocation {
1090 pub fn validate(&self) -> Result<()> {
1091 match self {
1092 Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
1093 bail!("container resource allocation must have non-zero CPU and memory")
1094 }
1095 Self::AwsEc2 {
1096 instance_type,
1097 vcpus,
1098 memory_bytes,
1099 } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
1100 bail!("EC2 resource allocation must have an instance type, CPU, and memory")
1101 }
1102 _ => Ok(()),
1103 }
1104 }
1105}
1106
1107pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
1109 match allocation {
1110 SessionResourceAllocation::Container { cpus, .. } => *cpus,
1111 SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
1112 }
1113}
1114
1115pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
1117 match allocation {
1118 SessionResourceAllocation::Container { memory_bytes, .. }
1119 | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
1120 }
1121}
1122
1123impl TargetLocator {
1124 pub const fn skills_scope(&self) -> crate::skills::SkillsScope {
1126 match self {
1127 Self::LocalBare { .. } => crate::skills::SkillsScope::Localhost,
1128 _ => crate::skills::SkillsScope::Isolated,
1129 }
1130 }
1131
1132 fn validate(&self, session_id: &str) -> Result<()> {
1133 match self {
1134 Self::LocalBare { worker_root } => {
1135 if !crate::target_path::is_absolute(worker_root)
1136 || worker_root
1137 .components()
1138 .any(|part| part == Component::ParentDir)
1139 || !worker_root.ends_with(session_id)
1140 {
1141 bail!(
1142 "local bare worker root must be an absolute safe path ending in the session id"
1143 );
1144 }
1145 }
1146 Self::LocalPodman { container_id, .. }
1147 | Self::LocalDocker { container_id, .. }
1148 | Self::AppleContainer { container_id, .. }
1149 | Self::SshPodman { container_id, .. }
1150 | Self::SshDocker { container_id, .. }
1151 if container_id.trim().is_empty() =>
1152 {
1153 bail!("target locator has an empty container id")
1154 }
1155 Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
1156 bail!("target locator has an empty AWS instance id")
1157 }
1158 Self::SshBare {
1159 host,
1160 workspace,
1161 worker_id,
1162 } => {
1163 if host.trim().is_empty() {
1164 bail!("bare SSH target locator has an empty host");
1165 }
1166 let unsafe_path = workspace.as_os_str().is_empty()
1167 || workspace
1168 .components()
1169 .any(|part| part == Component::ParentDir);
1170 match worker_id {
1171 Some(worker_id) => {
1174 if worker_id != session_id {
1175 bail!(
1176 "bare SSH target locator's worker identity does not match the session id"
1177 );
1178 }
1179 if unsafe_path {
1180 bail!("bare SSH target locator must have a safe workspace path");
1181 }
1182 }
1183 None => {
1184 if unsafe_path || !workspace.ends_with(session_id) {
1185 bail!(
1186 "bare SSH target locator must be a safe path ending in the session id"
1187 );
1188 }
1189 }
1190 }
1191 }
1192 Self::SshPodman { host, .. } if host.trim().is_empty() => {
1193 bail!("SSH Podman target locator has an empty host")
1194 }
1195 Self::SshDocker { host, .. } if host.trim().is_empty() => {
1196 bail!("SSH Docker target locator has an empty host")
1197 }
1198 _ => {}
1199 }
1200 Ok(())
1201 }
1202}
1203
1204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1205#[serde(deny_unknown_fields)]
1206pub struct CheckpointMetadata {
1207 pub archive_path: PathBuf,
1208 pub sha256: String,
1210 pub created_at: String,
1211 pub event_frontier: u64,
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1216#[serde(rename_all = "snake_case")]
1217pub enum PublicationState {
1218 Published,
1219 Unpublished,
1220 Unknown,
1221}
1222
1223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1225pub struct PublicationAssessment {
1226 pub checkpoint_sha256: String,
1227 pub state: PublicationState,
1228 pub dirty: bool,
1229 pub stashed: bool,
1230 pub saved_commits: Vec<String>,
1231 pub destinations: Vec<String>,
1232 pub checked_at: String,
1233 pub reason: Option<String>,
1234}
1235
1236impl CheckpointMetadata {
1237 fn validate(&self) -> Result<()> {
1238 if self.archive_path.as_os_str().is_empty() {
1239 bail!("checkpoint archive path is empty");
1240 }
1241 if self.sha256.len() != 64
1242 || !self
1243 .sha256
1244 .bytes()
1245 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1246 {
1247 bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
1248 }
1249 if self.created_at.trim().is_empty() {
1250 bail!("checkpoint timestamp is empty");
1251 }
1252 Ok(())
1253 }
1254}
1255
1256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1261#[serde(deny_unknown_fields)]
1262pub struct SessionBuildCache {
1263 pub host: String,
1266 pub directory: PathBuf,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub max_size: Option<String>,
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1274 pub target_root: Option<PathBuf>,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281pub struct ArchiveSpacePreview {
1282 pub sessions: usize,
1284 pub bytes: u64,
1286 pub reclaimable_sessions: usize,
1289 pub reclaimable_bytes: u64,
1290}
1291
1292#[derive(Debug, Clone, PartialEq, Eq)]
1296pub struct BuildCachePreview {
1297 pub native_mbx: Option<String>,
1299 pub mbx_profile_file: Option<String>,
1301 pub mbx_profile_warning: Option<String>,
1303 pub mbx_manual_path_line: Option<String>,
1305 pub directory: Option<PathBuf>,
1307 pub max_total_size: Option<BuildCacheLimit>,
1309 pub user_managed: bool,
1311 pub application: BuildCacheApplication,
1312 pub budget_note: Option<String>,
1314 pub stats: Option<BuildCacheStats>,
1316 pub off_reason: Option<BuildCacheOff>,
1319}
1320
1321#[derive(Debug, Clone, PartialEq, Eq)]
1329pub struct BuildCacheStats {
1330 pub builds: u64,
1332 pub cached_compilations: u64,
1334 pub avoided_compiler_ns: u64,
1336 pub reflinked_bytes: u64,
1338}
1339
1340#[derive(Debug, Clone, PartialEq, Eq)]
1342pub enum BuildCacheOff {
1343 TurnedOff,
1345 Unavailable(String),
1348}
1349
1350impl std::fmt::Display for BuildCacheOff {
1351 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1352 match self {
1353 Self::TurnedOff => formatter.write_str("turned off for this machine"),
1354 Self::Unavailable(reason) => formatter.write_str(reason),
1355 }
1356 }
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Eq)]
1361pub enum BuildCacheLimit {
1362 Size(String),
1364 HostConfiguration(Option<String>),
1367 MjDefault(String),
1369 MbxDefault(Option<String>),
1371}
1372
1373#[derive(Debug, Clone, Default, PartialEq, Eq)]
1374pub enum BuildCacheApplication {
1375 #[default]
1376 Pending,
1377 Applied,
1378 Failed(String),
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1382#[serde(deny_unknown_fields)]
1383pub struct SessionRecord {
1384 pub id: String,
1385 #[serde(default = "default_session_workspace_id")]
1390 pub workspace_id: String,
1391 pub title: String,
1392 pub harness_kind: HarnessKind,
1393 pub last_profile: String,
1394 pub bundle_id: String,
1395 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub project: Option<crate::repository::ProjectBundleSnapshot>,
1398 #[serde(default, skip_serializing_if = "Option::is_none")]
1400 pub project_directory: Option<PathBuf>,
1401 #[serde(default, skip_serializing_if = "Option::is_none")]
1403 pub managed_worktree: Option<ManagedWorktree>,
1404 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 pub create_managed_worktree: Option<bool>,
1407 #[serde(default, skip_serializing_if = "Option::is_none")]
1414 pub launch_base: Option<String>,
1415 #[serde(default, skip_serializing_if = "Option::is_none")]
1419 pub launch_branch: Option<String>,
1420 #[serde(default, skip_serializing_if = "Option::is_none")]
1424 pub checkout: Option<crate::remote_git::ExactCheckout>,
1425 #[serde(default, skip_serializing_if = "Option::is_none")]
1427 pub publication: Option<PublicationAssessment>,
1428 #[serde(
1430 default,
1431 alias = "mjolnir_subagents",
1432 deserialize_with = "crate::subagent::deserialize_optional_policy"
1433 )]
1434 pub subagents: Option<crate::subagent::SubagentPolicy>,
1435 #[serde(default, skip_serializing_if = "Option::is_none")]
1438 pub review: Option<crate::config::SessionReview>,
1439 #[serde(default, skip_serializing_if = "is_false")]
1445 pub no_project_memory: bool,
1446 #[serde(default, skip_serializing_if = "is_false")]
1454 pub no_mailbox: bool,
1455 pub target_template_id: String,
1456 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub resource_allocation: Option<SessionResourceAllocation>,
1458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1459 pub additional_mounts: Vec<AdditionalMount>,
1460 #[serde(default, skip_serializing_if = "Option::is_none")]
1463 pub container_cpus: Option<String>,
1464 #[serde(default, skip_serializing_if = "Option::is_none")]
1467 pub container_memory: Option<String>,
1468 #[serde(default, skip_serializing_if = "Option::is_none")]
1474 pub container_workspace: Option<PathBuf>,
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1479 pub build_cache: Option<SessionBuildCache>,
1480 pub state: SessionState,
1481 #[serde(default, skip_serializing_if = "is_false")]
1484 pub archived: bool,
1485 #[serde(default, skip_serializing_if = "Option::is_none")]
1486 pub target: Option<TargetLocator>,
1487 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub target_runtime: Option<TargetRuntimeSettings>,
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1491 pub native_session_id: Option<String>,
1492 #[serde(default, skip_serializing_if = "Option::is_none")]
1493 pub acp_session_title: Option<String>,
1494 #[serde(default, skip_serializing_if = "Option::is_none")]
1495 pub session_title_override: Option<String>,
1496 pub created_at: String,
1497 pub updated_at: String,
1498 #[serde(default, alias = "detached_after_event_ordinal")]
1499 pub viewed_through_event_ordinal: u64,
1500 #[serde(default, skip_serializing_if = "String::is_empty")]
1503 pub draft_input: String,
1504 #[serde(default, skip_serializing_if = "Option::is_none")]
1512 pub last_error: Option<String>,
1513 #[serde(default, skip_serializing_if = "Option::is_none")]
1514 pub last_checkpoint_error: Option<String>,
1515 #[serde(default, skip_serializing_if = "Option::is_none")]
1516 pub checkpoint: Option<CheckpointMetadata>,
1517}
1518
1519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1520#[serde(deny_unknown_fields)]
1521pub struct HostContainerSize {
1522 pub cpus: u64,
1523 pub memory_bytes: u64,
1524}
1525
1526pub const BASELINE_CONTAINER_CPUS: u64 = 8;
1527pub const BASELINE_CONTAINER_MEMORY_BYTES: u64 = 32 * 1024 * 1024 * 1024;
1528
1529pub fn clamp_container_size(
1532 size: HostContainerSize,
1533 limits: Option<HostContainerSize>,
1534) -> HostContainerSize {
1535 let Some(limits) = limits else {
1536 return HostContainerSize {
1537 cpus: size.cpus.max(1),
1538 memory_bytes: size.memory_bytes.max(1),
1539 };
1540 };
1541 HostContainerSize {
1542 cpus: size.cpus.min(limits.cpus.max(1)),
1543 memory_bytes: size.memory_bytes.min(limits.memory_bytes.max(1)),
1544 }
1545}
1546
1547pub fn default_container_size(
1550 remembered: Option<HostContainerSize>,
1551 limits: Option<HostContainerSize>,
1552) -> HostContainerSize {
1553 clamp_container_size(
1554 remembered.unwrap_or(HostContainerSize {
1555 cpus: BASELINE_CONTAINER_CPUS,
1556 memory_bytes: BASELINE_CONTAINER_MEMORY_BYTES,
1557 }),
1558 limits,
1559 )
1560}
1561
1562pub fn preferred_aws_allocation<'a>(
1565 options: &'a [SessionResourceAllocation],
1566 previous: Option<&SessionResourceAllocation>,
1567) -> Option<&'a SessionResourceAllocation> {
1568 if let Some(SessionResourceAllocation::AwsEc2 { instance_type, .. }) = previous
1569 && let Some(option) = options.iter().find(|option| {
1570 matches!(option, SessionResourceAllocation::AwsEc2 { instance_type: candidate, .. } if candidate == instance_type)
1571 })
1572 {
1573 return Some(option);
1574 }
1575 options
1576 .iter()
1577 .find(|option| allocation_cpus(option) == BASELINE_CONTAINER_CPUS)
1578 .or_else(|| options.first())
1579}
1580
1581fn default_session_workspace_id() -> String {
1582 crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1583}
1584
1585pub const DESTRUCTION_FAILURE_PREFIX: &str = "the destruction did not finish";
1594
1595pub const CLOSE_FAILURE_PREFIX: &str = "the suspension did not finish";
1596
1597pub const MOVE_FAILURE_PREFIX: &str = "the move did not finish";
1598
1599pub fn is_public_lifecycle_error(error: &str) -> bool {
1601 error.starts_with(CLOSE_FAILURE_PREFIX)
1602 || error.starts_with(MOVE_FAILURE_PREFIX)
1603 || error.starts_with(DESTRUCTION_FAILURE_PREFIX)
1604 || error.starts_with("the close did not finish")
1605}
1606
1607#[must_use]
1620pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1621 if !matches!(
1622 config.targets.get(target_id),
1623 Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1624 ) {
1625 return target_id.to_owned();
1626 }
1627 project.and_then(Path::file_name).map_or_else(
1628 || target_id.to_owned(),
1629 |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1630 )
1631}
1632
1633#[derive(Debug, Clone, Default, PartialEq, Eq)]
1635pub struct StartSelection {
1636 pub at: Option<String>,
1638 pub branch: Option<String>,
1640 pub base: Option<String>,
1642}
1643
1644impl SessionRecord {
1645 #[must_use]
1649 pub fn checkout(&self) -> Checkout<'_> {
1650 derive_record_checkout(self)
1651 }
1652
1653 pub fn start_selection(&self) -> StartSelection {
1657 let at = self
1658 .checkout
1659 .as_ref()
1660 .map(|checkout| checkout.commit.clone());
1661 StartSelection {
1662 branch: self
1663 .checkout
1664 .as_ref()
1665 .and_then(|checkout| checkout.branch.clone())
1666 .or_else(|| self.launch_branch.clone()),
1667 base: self.launch_base.clone().or_else(|| at.clone()),
1668 at,
1669 }
1670 }
1671
1672 pub fn publication_state(&self) -> Option<PublicationState> {
1675 let independent_clone = match self.checkout().effective() {
1676 Checkout::ManagedWorktree { worktree, .. } => {
1677 worktree.kind == ManagedCheckoutKind::Clone
1678 }
1679 Checkout::ManagedWorkspace => true,
1680 Checkout::Attached { .. } | Checkout::Borrowed { .. } => false,
1681 };
1682 if !independent_clone {
1683 return None;
1684 }
1685 if self.state.is_active() {
1686 return Some(PublicationState::Unknown);
1687 }
1688 Some(
1689 self.checkpoint
1690 .as_ref()
1691 .zip(self.publication.as_ref())
1692 .filter(|(checkpoint, assessment)| {
1693 assessment.checkpoint_sha256 == checkpoint.sha256
1694 })
1695 .map_or(PublicationState::Unknown, |(_, assessment)| {
1696 if assessment.dirty || assessment.stashed {
1697 PublicationState::Unpublished
1698 } else {
1699 assessment.state
1700 }
1701 }),
1702 )
1703 }
1704
1705 pub fn target_runtime_settings<'a>(
1710 &'a self,
1711 config: &Config,
1712 ) -> Result<std::borrow::Cow<'a, TargetRuntimeSettings>> {
1713 if let Some(runtime) = &self.target_runtime {
1714 if let Some(refreshed) =
1717 config
1718 .targets
1719 .get(&self.target_template_id)
1720 .and_then(|template| {
1721 runtime.with_current_ssh_options(&TargetRuntimeSettings::from(template))
1722 })
1723 {
1724 return Ok(std::borrow::Cow::Owned(refreshed));
1725 }
1726 return Ok(std::borrow::Cow::Borrowed(runtime));
1727 }
1728 let template = config.targets.get(&self.target_template_id).ok_or_else(|| {
1729 crate::refusal::Refusal::precondition(format!(
1730 "Session {:?} has no recorded target access settings. Restore target {:?} in config.toml once, then retry.",
1731 self.id, self.target_template_id))
1732 })?;
1733 let runtime = TargetRuntimeSettings::from(template);
1734 if let Some(locator) = &self.target {
1735 crate::targets::TargetLocator::try_from(crate::targets::RecordedTarget {
1736 locator, runtime: Some(&runtime), session_id: &self.id,
1737 }).map_err(|error| crate::refusal::Refusal::precondition(format!(
1738 "Session {:?} cannot recover target {:?}: {error}. Restore its original target settings, then retry.", self.id, self.target_template_id)))?;
1739 }
1740 Ok(std::borrow::Cow::Owned(runtime))
1741 }
1742
1743 #[must_use]
1747 pub fn public_error(&self) -> Option<&str> {
1748 self.last_error
1749 .as_deref()
1750 .filter(|error| is_public_lifecycle_error(error))
1751 }
1752
1753 pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1756 if !self.state.is_active() {
1757 return None;
1758 }
1759 let mut issues = Vec::new();
1760 match config.profiles.get(&self.last_profile) {
1761 None => issues.push(format!("missing profile {:?}", self.last_profile)),
1762 Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1763 "expects {:?}, but profile {:?} is {:?}",
1764 self.harness_kind, self.last_profile, profile.kind
1765 )),
1766 Some(_) => {}
1767 }
1768 if self.checkout().project_directory().is_none() && self.project_bundle(config).is_none() {
1769 issues.push(format!("missing bundle {:?}", self.bundle_id));
1770 }
1771 if self.target_runtime.is_none() && !config.targets.contains_key(&self.target_template_id) {
1772 issues.push(format!(
1773 "missing target template {:?}",
1774 self.target_template_id
1775 ));
1776 }
1777 (!issues.is_empty()).then(|| format!(
1778 "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.",
1779 self.id, issues.join("; ")
1780 ))
1781 }
1782
1783 pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1784 if let Some(issue) = self.configuration_issue(config) {
1785 return Err(crate::refusal::Refusal::precondition(issue).into());
1786 }
1787 Ok(())
1788 }
1789
1790 pub fn display_title(&self) -> &str {
1792 self.session_title_override
1793 .as_deref()
1794 .or(self.acp_session_title.as_deref())
1795 .unwrap_or(&self.id)
1796 }
1797
1798 pub fn listed_title(&self) -> &str {
1803 let named = self.session_title_override.is_some() || self.acp_session_title.is_some();
1804 if !named && !self.title.trim().is_empty() {
1805 return &self.title;
1806 }
1807 self.display_title()
1808 }
1809
1810 pub fn project_name(&self, config: &Config) -> String {
1815 if let Some(project) = &self.project {
1816 return project.name();
1817 }
1818 match self.checkout().effective() {
1819 Checkout::ManagedWorktree { worktree, .. } => path_leaf(&worktree.source_repository),
1820 Checkout::Attached { path } => path_leaf(path),
1821 Checkout::ManagedWorkspace => self.bundle_source_name(config),
1822 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1823 }
1824 }
1825
1826 pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1830 let checkout = self.checkout();
1831 let project = match checkout.effective() {
1832 Checkout::ManagedWorktree { worktree, .. } => {
1833 Some(worktree.source_project_directory.as_path())
1834 }
1835 Checkout::Attached { path } => Some(path),
1836 Checkout::ManagedWorkspace => None,
1837 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1838 };
1839 target_label(config, target_id, project)
1840 }
1841
1842 pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1847 if let Some(project) = &self.project {
1848 return ProjectSourceIdentity {
1849 key: project
1850 .source_key()
1851 .expect("accepted project has complete identities"),
1852 short: project.name(),
1853 full: project
1854 .identities
1855 .values()
1856 .map(crate::repository::RepositoryIdentity::key)
1857 .collect::<Vec<_>>()
1858 .join(" + "),
1859 };
1860 }
1861 match self.checkout().effective() {
1862 Checkout::ManagedWorktree { worktree, .. } => {
1863 ProjectSourceIdentity::path(&worktree.source_repository, None)
1864 }
1865 Checkout::Attached { path } => {
1866 let remote = match &self.target {
1867 Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1868 _ => None,
1869 };
1870 ProjectSourceIdentity::path(path, remote)
1871 }
1872 Checkout::ManagedWorkspace => {
1873 self.bundle_source_identity(config)
1874 .unwrap_or_else(|| ProjectSourceIdentity {
1875 key: format!("bundle:{}", self.bundle_id),
1876 short: path_leaf(Path::new(&self.bundle_id)),
1877 full: self.bundle_id.clone(),
1878 })
1879 }
1880 Checkout::Borrowed { .. } => unreachable!("effective checkout resolves borrowing"),
1881 }
1882 }
1883
1884 fn bundle_source_name(&self, config: &Config) -> String {
1887 self.bundle_source_identity(config)
1888 .map(|source| source.short)
1889 .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1890 }
1891
1892 fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1895 let bundle = self.project_bundle(config)?;
1896 let sources = bundle
1897 .repositories
1898 .iter()
1899 .map(repository_source_identity)
1900 .collect::<Option<Vec<_>>>()?;
1901 ProjectSourceIdentity::bundle(sources)
1902 }
1903
1904 pub fn project_bundle<'a>(
1906 &'a self,
1907 config: &'a Config,
1908 ) -> Option<&'a crate::config::ProjectBundle> {
1909 self.project
1910 .as_ref()
1911 .map(|project| &project.bundle)
1912 .or_else(|| config.bundles.get(&self.bundle_id))
1913 }
1914
1915 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1919 self.creation_order_key().cmp(&other.creation_order_key())
1920 }
1921
1922 pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1924 let timestamp = created_at_seconds(&self.created_at);
1925 (timestamp.is_none(), timestamp, &self.id)
1926 }
1927
1928 fn validate(&self, map_id: &str) -> Result<()> {
1929 validate_id("session", &self.id)?;
1930 if self.id != map_id {
1931 bail!(
1932 "session map key {map_id:?} does not match record id {:?}",
1933 self.id
1934 );
1935 }
1936 if let Some(project) = &self.project {
1937 project.key()?;
1938 }
1939 validate_id("workspace", &self.workspace_id)?;
1940 validate_id("profile", &self.last_profile)?;
1941 validate_id("bundle", &self.bundle_id)?;
1942 if let Some(project_directory) = &self.project_directory
1943 && (!crate::target_path::is_absolute_on_host_or_target(project_directory)
1944 || project_directory
1945 .components()
1946 .any(|part| part == Component::ParentDir))
1947 {
1948 bail!("session {:?} has an unsafe project directory", self.id);
1949 }
1950 if let Some(managed_worktree) = &self.managed_worktree {
1951 managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1952 }
1953 validate_id("target template", &self.target_template_id)?;
1954 if let Some(allocation) = &self.resource_allocation {
1955 allocation.validate()?;
1956 }
1957 validate_additional_mounts(&self.additional_mounts)?;
1958 if self.title.trim().is_empty() {
1959 bail!("session {:?} has an empty title", self.id);
1960 }
1961 if self
1962 .acp_session_title
1963 .as_ref()
1964 .is_some_and(|title| title.trim().is_empty())
1965 || self
1966 .session_title_override
1967 .as_ref()
1968 .is_some_and(|title| title.trim().is_empty())
1969 {
1970 bail!("session {:?} has an empty display title", self.id);
1971 }
1972 if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1973 bail!("session {:?} has an empty timestamp", self.id);
1974 }
1975 if let Some(target) = &self.target {
1976 target.validate(&self.id)?;
1977 }
1978 if let Some(checkpoint) = &self.checkpoint {
1979 checkpoint.validate()?;
1980 }
1981 Ok(())
1982 }
1983}
1984
1985fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1986 repository
1987 .github
1988 .as_deref()
1989 .and_then(ProjectSourceIdentity::git_remote)
1990 .or_else(|| {
1991 repository
1992 .local
1993 .as_deref()
1994 .map(|path| ProjectSourceIdentity::path(path, None))
1995 })
1996}
1997
1998#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1999pub struct ProjectSourceIdentity {
2000 pub key: String,
2001 pub short: String,
2002 pub full: String,
2003}
2004
2005impl ProjectSourceIdentity {
2006 pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
2008 if sources.is_empty() {
2009 return None;
2010 }
2011 sources.sort_by(|left, right| {
2012 left.key
2013 .cmp(&right.key)
2014 .then_with(|| left.full.cmp(&right.full))
2015 .then_with(|| left.short.cmp(&right.short))
2016 });
2017 sources.dedup_by(|left, right| left.key == right.key);
2018 if sources.len() == 1 {
2019 return sources.pop();
2020 }
2021 let keys = sources
2022 .iter()
2023 .map(|source| source.key.clone())
2024 .collect::<Vec<_>>();
2025 let key = serde_json::to_string(&keys).ok()?;
2026 Some(Self {
2027 key: format!("bundle:{key}"),
2028 short: sources
2029 .iter()
2030 .map(|source| source.short.as_str())
2031 .collect::<Vec<_>>()
2032 .join(" + "),
2033 full: sources
2034 .iter()
2035 .map(|source| source.full.as_str())
2036 .collect::<Vec<_>>()
2037 .join(" + "),
2038 })
2039 }
2040
2041 pub fn git_remote(source: &str) -> Option<Self> {
2044 let identity = crate::repository::RepositoryIdentity::from_remote(source)?;
2045 let full = crate::repository::RepositoryIdentity::remote_label(source)?;
2046 Some(Self {
2047 key: identity.key(),
2048 short: full.rsplit(['/', ':']).next()?.to_owned(),
2049 full,
2050 })
2051 }
2052
2053 pub fn path(path: &Path, remote: Option<&str>) -> Self {
2056 let normalized = path.components().collect::<PathBuf>();
2057 let path_text = crate::target_path::text(&normalized);
2058 let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
2059 let key = remote.map_or_else(
2060 || format!("path:{path_text}"),
2061 |host| format!("path:{}:{path_text}", host.to_lowercase()),
2062 );
2063 Self {
2064 key,
2065 short: path_leaf(path),
2066 full,
2067 }
2068 }
2069}
2070
2071fn path_leaf(path: &Path) -> String {
2073 path.file_name()
2074 .unwrap_or(path.as_os_str())
2075 .to_string_lossy()
2076 .into_owned()
2077}
2078
2079fn created_at_seconds(timestamp: &str) -> Option<i64> {
2080 chrono::DateTime::parse_from_rfc3339(timestamp)
2081 .ok()
2082 .map(|timestamp| timestamp.timestamp())
2083}
2084
2085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2086#[serde(deny_unknown_fields)]
2087pub struct State {
2088 #[serde(
2089 default,
2090 deserialize_with = "crate::subagent::deserialize_launch_policy"
2091 )]
2092 pub last_subagent_policy: crate::subagent::SubagentPolicy,
2093 pub version: u32,
2094 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2095 pub sessions: SnapshotMap<String, SessionRecord>,
2096 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2099 pub subagents: SnapshotMap<String, SubagentRecord>,
2100 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2102 pub mount_history: SnapshotMap<String, Vec<PathBuf>>,
2103 #[serde(default, skip_serializing_if = "SnapshotMap::is_empty")]
2105 pub container_sizes: SnapshotMap<String, HostContainerSize>,
2106}
2107
2108impl Default for State {
2109 fn default() -> Self {
2110 Self {
2111 version: STATE_VERSION,
2112 last_subagent_policy: Default::default(),
2113 sessions: SnapshotMap::new(),
2114 subagents: SnapshotMap::new(),
2115 mount_history: SnapshotMap::new(),
2116 container_sizes: SnapshotMap::new(),
2117 }
2118 }
2119}
2120
2121#[derive(Debug, Clone, PartialEq, Eq)]
2128pub enum Checkout<'a> {
2129 Attached { path: &'a Path },
2131 ManagedWorktree {
2138 worktree: &'a ManagedWorktree,
2139 project_directory: Option<&'a Path>,
2140 },
2141 ManagedWorkspace,
2144 Borrowed {
2146 owner: &'a SessionRecord,
2147 checkout: Box<Checkout<'a>>,
2148 },
2149}
2150
2151fn derive_record_checkout(session: &SessionRecord) -> Checkout<'_> {
2153 if let Some(worktree) = &session.managed_worktree {
2154 return Checkout::ManagedWorktree {
2155 worktree,
2156 project_directory: session.project_directory.as_deref(),
2157 };
2158 }
2159 if let Some(path) = session.project_directory.as_deref() {
2160 return Checkout::Attached { path };
2161 }
2162 Checkout::ManagedWorkspace
2163}
2164
2165impl<'a> Checkout<'a> {
2166 #[must_use]
2169 pub fn managed_worktree(&self) -> Option<&'a ManagedWorktree> {
2170 match self {
2171 Self::ManagedWorktree { worktree, .. } => Some(*worktree),
2172 Self::Attached { .. } | Self::ManagedWorkspace | Self::Borrowed { .. } => None,
2173 }
2174 }
2175
2176 #[must_use]
2180 pub fn effective(&self) -> Checkout<'a> {
2181 match self {
2182 Self::Borrowed { checkout, .. } => checkout.effective(),
2183 checkout => checkout.clone(),
2184 }
2185 }
2186
2187 #[must_use]
2190 pub fn project_directory(&self) -> Option<&'a Path> {
2191 match self {
2192 Self::Attached { path } => Some(*path),
2193 Self::ManagedWorktree {
2194 project_directory, ..
2195 } => *project_directory,
2196 Self::ManagedWorkspace => None,
2197 Self::Borrowed { checkout, .. } => checkout.project_directory(),
2198 }
2199 }
2200}
2201
2202pub fn session_is_live(record: &SessionRecord, in_operation: bool, parent_live: bool) -> bool {
2210 record.state != SessionState::Stopped || in_operation || parent_live
2211}
2212
2213pub fn live_session_ids(
2217 sessions: &SnapshotMap<String, SessionRecord>,
2218 subagents: &SnapshotMap<String, SubagentRecord>,
2219 operations: &BTreeSet<String>,
2220) -> BTreeSet<String> {
2221 let mut live = sessions
2222 .iter()
2223 .filter(|(id, record)| session_is_live(record, operations.contains(*id), false))
2224 .map(|(id, _)| id.clone())
2225 .collect::<BTreeSet<_>>();
2226 loop {
2227 let joined = subagents
2228 .iter()
2229 .filter(|(child, relation)| {
2230 !live.contains(*child)
2231 && sessions.contains_key(*child)
2232 && live.contains(&relation.parent_session_id)
2233 })
2234 .map(|(child, _)| child.clone())
2235 .collect::<Vec<_>>();
2236 if joined.is_empty() {
2237 return live;
2238 }
2239 live.extend(joined);
2240 }
2241}
2242
2243impl State {
2244 pub fn checkout(&self, session_id: &str) -> Result<Checkout<'_>> {
2260 let session = self
2261 .sessions
2262 .get(session_id)
2263 .with_context(|| format!("unknown session {session_id}"))?;
2264 self.checkout_for_record(session_id, session)
2265 }
2266
2267 pub fn checkout_for_record<'a>(
2270 &'a self,
2271 session_id: &str,
2272 session: &'a SessionRecord,
2273 ) -> Result<Checkout<'a>> {
2274 self.checkout_for_record_inner(session_id, session, &mut BTreeSet::new())
2275 }
2276
2277 fn checkout_for_record_inner<'a>(
2278 &'a self,
2279 session_id: &str,
2280 session: &'a SessionRecord,
2281 visited: &mut BTreeSet<String>,
2282 ) -> Result<Checkout<'a>> {
2283 if !visited.insert(session_id.to_owned()) {
2284 bail!("sub-agent checkout ownership contains a cycle at {session_id}");
2285 }
2286 if let Some(relation) = self.subagents.get(session_id) {
2287 let Some(owner) = self.sessions.get(&relation.parent_session_id) else {
2288 return Ok(session.checkout());
2289 };
2290 return Ok(Checkout::Borrowed {
2291 owner,
2292 checkout: Box::new(self.checkout_for_record_inner(&owner.id, owner, visited)?),
2293 });
2294 }
2295 Ok(session.checkout())
2296 }
2297
2298 #[must_use]
2303 pub fn session_notice_name(&self, session_id: &str) -> String {
2304 match self.sessions.get(session_id) {
2305 Some(session) if session.listed_title() != session.id => {
2306 session.listed_title().to_owned()
2307 }
2308 _ => short_id(session_id).to_owned(),
2309 }
2310 }
2311
2312 #[must_use]
2321 pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
2322 self.subagents
2323 .get(&session.id)
2324 .and_then(|record| self.sessions.get(&record.parent_session_id))
2325 .unwrap_or(session)
2326 }
2327
2328 #[must_use]
2335 pub fn is_subagent_session(&self, id: &str) -> bool {
2336 self.subagents.contains_key(id) || crate::native_agent::is_view_id(id)
2337 }
2338
2339 pub fn validate(&self) -> Result<()> {
2340 if self.version != STATE_VERSION {
2341 bail!(
2342 "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
2343 self.version
2344 );
2345 }
2346 for (id, session) in &self.sessions {
2347 session.validate(id)?;
2348 }
2349 for child_id in self.subagents.keys() {
2350 self.validate_subagent(child_id)?;
2351 }
2352 for (host, sources) in &self.mount_history {
2353 if host.trim().is_empty() {
2354 bail!("mount history contains an empty host key");
2355 }
2356 if sources
2357 .iter()
2358 .any(|source| !crate::target_path::is_absolute_on_host_or_target(source))
2359 {
2360 bail!("mount history for {host:?} contains a non-absolute source path");
2361 }
2362 }
2363 for (host, size) in &self.container_sizes {
2364 if host.trim().is_empty() {
2365 bail!("container size history contains an empty host key");
2366 }
2367 if size.cpus == 0 || size.memory_bytes == 0 {
2368 bail!("container size history for {host:?} contains a zero value");
2369 }
2370 if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
2371 bail!("container size history for {host:?} exceeds SQLite integer range");
2372 }
2373 }
2374 Ok(())
2375 }
2376
2377 pub fn validate_subagent(&self, child_id: &str) -> Result<()> {
2379 let Some(subagent) = self.subagents.get(child_id) else {
2380 return Ok(());
2381 };
2382 if child_id != subagent.child_session_id {
2383 bail!("sub-agent key {child_id:?} does not match its child session id");
2384 }
2385 if child_id == subagent.parent_session_id {
2386 bail!("sub-agent {child_id:?} cannot be its own parent");
2387 }
2388 if !self.sessions.contains_key(child_id) {
2389 bail!("sub-agent {child_id:?} has no child session");
2390 }
2391 if !self.sessions.contains_key(&subagent.parent_session_id) {
2392 bail!(
2393 "sub-agent {child_id:?} has unknown parent {:?}",
2394 subagent.parent_session_id
2395 );
2396 }
2397 if self.subagents.contains_key(&subagent.parent_session_id) {
2398 bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
2399 }
2400 if subagent.task_name.trim().is_empty()
2401 || subagent.profile_id.trim().is_empty()
2402 || subagent.request_key.trim().is_empty()
2403 {
2404 bail!("sub-agent {child_id:?} has incomplete relationship metadata");
2405 }
2406 Ok(())
2407 }
2408
2409 pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
2410 if mounts.is_empty() {
2411 return;
2412 }
2413 let sources = self
2414 .mount_history
2415 .entry(host.to_owned())
2416 .or_insert_with(Vec::new);
2417 for mount in mounts.iter().rev() {
2418 sources.retain(|source| source != &mount.source);
2419 sources.insert(0, mount.source.clone());
2420 }
2421 sources.truncate(20);
2422 }
2423
2424 pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
2425 self.container_sizes.insert(host.to_owned(), size);
2426 }
2427
2428 pub fn project_directories(&self, host: &str) -> &[PathBuf] {
2429 self.mount_history
2430 .get(&project_history_key(host))
2431 .map(Vec::as_slice)
2432 .unwrap_or_default()
2433 }
2434
2435 pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
2436 let key = project_history_key(host);
2437 let directories = self.mount_history.entry(key).or_insert_with(Vec::new);
2438 directories.retain(|existing| existing != directory);
2439 directories.insert(0, directory.to_path_buf());
2440 directories.truncate(20);
2441 }
2442
2443 pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
2444 let session = self
2445 .sessions
2446 .get(session_id)
2447 .with_context(|| format!("unknown session {session_id}"))?;
2448 if session.state.is_active() {
2449 bail!("refusing to destroy active session {session_id}");
2450 }
2451 Ok(self
2452 .sessions
2453 .remove(session_id)
2454 .expect("session checked above"))
2455 }
2456
2457 pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
2463 self.sessions
2464 .get(session_id)
2465 .with_context(|| format!("unknown session {session_id}"))?;
2466 Ok(self
2467 .sessions
2468 .remove(session_id)
2469 .expect("session checked above"))
2470 }
2471
2472 pub fn bundle_users(&self, bundle_id: &str) -> Vec<&SessionRecord> {
2477 self.sessions
2478 .values()
2479 .filter(|session| {
2480 session.bundle_id == bundle_id
2481 && self
2482 .checkout(&session.id)
2483 .is_ok_and(|checkout| checkout.project_directory().is_none())
2484 && session.state != SessionState::DestroyedWithDataLoss
2485 })
2486 .collect()
2487 }
2488
2489 pub fn bundle_removal_refusal(&self, bundle_id: &str) -> Option<String> {
2492 let users = self.bundle_users(bundle_id);
2493 if users.is_empty() {
2494 return None;
2495 }
2496 let mut names = users
2497 .iter()
2498 .take(3)
2499 .map(|session| format!("{:?}", session.listed_title()))
2500 .collect::<Vec<_>>();
2501 if users.len() > 3 {
2502 names.push(format!("{} more", users.len() - 3));
2503 }
2504 Some(format!(
2505 "Project {bundle_id:?} is used by {}: {}. Destroy those sessions before removing it.",
2506 if users.len() == 1 {
2507 "a session"
2508 } else {
2509 "sessions"
2510 },
2511 names.join(", ")
2512 ))
2513 }
2514
2515 pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
2518 for session in self
2519 .sessions
2520 .values()
2521 .filter(|session| session.state.is_active())
2522 {
2523 let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
2524 let mut comparable = profile.clone();
2525 if let Some(updated) = after.profiles.get(&session.last_profile) {
2526 comparable.enabled = updated.enabled;
2527 comparable.legacy_subagents = updated.legacy_subagents.clone();
2528 }
2529 profile.kind == session.harness_kind
2531 && after.profiles.get(&session.last_profile) != Some(&comparable)
2532 } else {
2533 false
2534 };
2535 let bundle_changed = self
2536 .checkout(&session.id)
2537 .is_ok_and(|checkout| checkout.project_directory().is_none())
2538 && before
2539 .bundles
2540 .get(&session.bundle_id)
2541 .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
2542 let target_changed =
2546 before
2547 .targets
2548 .get(&session.target_template_id)
2549 .is_some_and(|target| {
2550 after
2551 .targets
2552 .get(&session.target_template_id)
2553 .map(TargetTemplate::without_launch_only_settings)
2554 != Some(target.without_launch_only_settings())
2555 });
2556 if protected || bundle_changed || target_changed {
2557 let mut used = Vec::new();
2561 if protected {
2562 used.push(format!("agent profile {:?}", session.last_profile));
2563 }
2564 if bundle_changed {
2565 used.push(format!("project {:?}", session.project_name(before)));
2566 }
2567 if target_changed {
2568 used.push(format!("runtime {:?}", session.target_template_id));
2569 }
2570 let used = match used.as_slice() {
2571 [only] => only.clone(),
2572 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2573 [] => unreachable!("something changed"),
2574 };
2575 let title = session.display_title();
2576 let named = if title == session.id {
2577 format!(
2578 "a running session in project {:?}",
2579 session.project_name(before)
2580 )
2581 } else {
2582 format!("the running session {title:?}")
2583 };
2584 bail!(
2585 "Setup would change the {used} that {named} uses. Save the new settings under a new name, or stop the session first."
2586 );
2587 }
2588 }
2589 Ok(())
2590 }
2591
2592 pub fn validate_against_config(&self, config: &Config) -> Result<()> {
2594 self.validate()?;
2595 config.validate()?;
2596 for session in self.sessions.values() {
2597 session.validate_configuration(config)?;
2598 }
2599 Ok(())
2600 }
2601}
2602
2603fn project_history_key(host: &str) -> String {
2604 format!("project:{host}")
2605}
2606
2607pub fn new_session_id() -> Result<String> {
2609 let mut random = [0u8; 16];
2610 getrandom::fill(&mut random)
2611 .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
2612 Ok(crate::hex::lower_hex(random))
2613}
2614
2615pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
2617 events.iter().rev().find_map(|event| {
2618 let WorkerEvent::Adapter { payload, .. } = &event.event else {
2619 return None;
2620 };
2621 let crate::acp::RuntimeEvent::SessionUpdate { update } =
2622 serde_json::from_value(payload.clone()).ok()?
2623 else {
2624 return None;
2625 };
2626 let kind = update
2627 .get("sessionUpdate")
2628 .and_then(serde_json::Value::as_str)?;
2629 let title = match kind {
2630 "session_info_update" | "session_title" => {
2631 update.get("title").and_then(serde_json::Value::as_str)
2632 }
2633 _ => None,
2634 }?;
2635 normalize_session_title(title)
2636 })
2637}
2638
2639pub fn default_session_title(
2644 project_directory: Option<&Path>,
2645 bundle_id: &str,
2646 profile_id: &str,
2647) -> String {
2648 let project = project_directory.and_then(Path::file_name).map_or_else(
2649 || bundle_id.to_owned(),
2650 |name| name.to_string_lossy().into_owned(),
2651 );
2652 format!("{project} via {profile_id}")
2653}
2654
2655pub const MAX_SESSION_TITLE_CHARS: usize = 256;
2656
2657pub fn normalize_session_title(title: &str) -> Option<String> {
2659 let normalized = crate::relay::strip_hidden_prompt_context(title)
2660 .split_whitespace()
2661 .collect::<Vec<_>>()
2662 .join(" ");
2663 (!normalized.is_empty()).then(|| truncate_session_title(normalized, MAX_SESSION_TITLE_CHARS))
2664}
2665
2666fn truncate_session_title(title: String, maximum_chars: usize) -> String {
2667 if title.chars().count() <= maximum_chars {
2668 return title;
2669 }
2670
2671 let mut truncated = title.chars().take(maximum_chars - 1).collect::<String>();
2672 if let Some(boundary) = truncated.rfind(char::is_whitespace) {
2673 truncated.truncate(boundary);
2674 }
2675 truncated.push('…');
2676 truncated
2677}
2678
2679pub fn provisional_session_title(prompt: &str) -> Option<String> {
2685 const MAX_TITLE_CHARS: usize = 64;
2686
2687 let normalized = normalize_session_title(prompt)?;
2688 Some(truncate_session_title(normalized, MAX_TITLE_CHARS))
2689}
2690
2691pub fn short_id(id: &str) -> &str {
2692 id.get(..8).unwrap_or(id)
2693}
2694
2695#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2696pub struct RecoveryCandidate {
2697 pub session_id: String,
2698 pub target_template_id: String,
2699 pub locator: TargetLocator,
2700 pub ownership: Option<crate::worker_launch::WorkerOwnership>,
2701 #[serde(default)]
2704 pub instance_id: Option<String>,
2705 #[serde(default, skip_serializing_if = "Option::is_none")]
2710 pub tracked_session: Option<SessionState>,
2711}
2712
2713#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2714pub struct RecoveryScan {
2715 pub candidates: Vec<RecoveryCandidate>,
2716 pub warnings: Vec<String>,
2717 #[serde(default)]
2719 pub instance_id: String,
2720 #[serde(default)]
2723 pub hidden_other_instances: usize,
2724}
2725
2726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2727#[serde(deny_unknown_fields)]
2728pub struct ResumeRepositorySourceReceipt {
2729 pub session_id: String,
2730 pub bundle_id: String,
2731 pub checkpoint_sha256: String,
2732 pub repositories: Vec<crate::config::ProjectRepository>,
2733}
2734
2735#[cfg(test)]
2736mod tests;