1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10use crate::config::{Config, HarnessKind, ProjectRepository, TargetTemplate, validate_id};
11use crate::credentials::CredentialSyncSignal;
12use crate::relay::{
13 RELAY_EVENT_GENESIS_DIGEST, RelayOperationalState, SequencedEvent, WorkerEvent,
14};
15use crate::subagent::SubagentRecord;
16use crate::targets::{AdditionalMount, validate_additional_mounts};
17
18pub const STATE_VERSION: u32 = 1;
19
20mod session_move;
21pub use session_move::*;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum SessionState {
26 Provisioning,
27 Running,
28 Disconnected,
29 Checkpointing,
30 Closing,
31 Destroying,
32 #[serde(alias = "archived")]
35 Stopped,
36 Lost,
37 Error,
38 DestroyedWithDataLoss,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum SessionTransitionKind {
46 Starting,
47 Resuming,
48 Moving,
49 Stopping,
50 Destroying,
51}
52
53impl SessionTransitionKind {
54 pub const fn label(self) -> &'static str {
55 match self {
56 Self::Starting => "Starting",
57 Self::Resuming => "Resuming",
58 Self::Moving => "Moving",
59 Self::Stopping => "Stopping",
60 Self::Destroying => "Destroying",
61 }
62 }
63
64 pub fn for_session(state: SessionState, operation: Option<Self>) -> Option<Self> {
65 operation.or_else(|| state.transition_kind())
66 }
67}
68
69#[cfg(test)]
70mod transition_tests {
71 use super::{SessionState, SessionTransitionKind};
72
73 #[test]
74 fn operation_ownership_hides_intermediate_move_states_but_not_ordinary_live_work() {
75 for state in [
76 SessionState::Stopped,
77 SessionState::Running,
78 SessionState::Disconnected,
79 ] {
80 assert_eq!(
81 SessionTransitionKind::for_session(state, Some(SessionTransitionKind::Moving)),
82 Some(SessionTransitionKind::Moving)
83 );
84 assert_eq!(SessionTransitionKind::for_session(state, None), None);
85 }
86 assert_eq!(SessionState::Checkpointing.transition_kind(), None);
87 assert_eq!(
88 SessionState::Closing.transition_kind(),
89 Some(SessionTransitionKind::Stopping)
90 );
91 }
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(tag = "state", rename_all = "snake_case")]
97pub enum MaterializedExecutionState {
98 #[default]
99 Idle,
100 Running {
101 started_at_ms: i64,
102 },
103 Closing,
104 Closed,
105}
106
107pub use crate::transcript::{TerminalOutputRecord, TranscriptBody, TranscriptItem};
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum QueuedCommandKind {
116 #[default]
117 Prompt,
118 SetConfig {
119 key: String,
120 value: String,
121 },
122}
123
124impl QueuedCommandKind {
125 pub fn is_prompt(&self) -> bool {
126 matches!(self, Self::Prompt)
127 }
128}
129
130pub fn config_command_text(key: &str, value: &str) -> String {
133 if key == "fast-mode" {
134 "/fast".to_owned()
135 } else {
136 format!("/{key} {value}")
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct MaterializedQueuedPrompt {
143 pub command_id: String,
144 #[serde(default, skip_serializing_if = "QueuedCommandKind::is_prompt")]
145 pub kind: QueuedCommandKind,
146 pub content: Vec<serde_json::Value>,
147 pub queued_at_ms: i64,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub accepted_ordinal: Option<u64>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct MaterializedTurn {
160 pub command_id: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub accepted_ordinal: Option<u64>,
163 pub turn_start_position: u64,
166 pub started_at_ms: i64,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "kind", rename_all = "snake_case")]
172pub enum TurnOutcomeKind {
173 Completed { stop_reason: String },
175 Rejected { message: String },
177 Interrupted { message: String },
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum PromptCompletion {
183 Finished,
184 Cancelled,
185 QuotaLimit,
186 Error,
187}
188
189pub fn classify_prompt_completion(stop_reason: &str) -> PromptCompletion {
191 let normalized = stop_reason
192 .chars()
193 .filter(|character| *character != '_' && *character != '-')
194 .flat_map(char::to_lowercase)
195 .collect::<String>();
196 match normalized.as_str() {
197 "endturn" => PromptCompletion::Finished,
198 "cancelled" | "canceled" => PromptCompletion::Cancelled,
199 "quotalimit" => PromptCompletion::QuotaLimit,
200 _ if crate::relay::is_capacity_stop_reason(stop_reason) => PromptCompletion::QuotaLimit,
201 _ => PromptCompletion::Error,
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct MaterializedTurnOutcome {
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub diagnostic: Option<crate::diagnostic::TurnDiagnostic>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub usage: Option<crate::usage::TokenUsage>,
214 pub command_id: String,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub accepted_ordinal: Option<u64>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub turn_start_position: Option<u64>,
219 pub completed_ordinal: u64,
220 pub completed_at_ms: i64,
221 pub outcome: TurnOutcomeKind,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct MaterializedSession {
228 pub session_id: String,
229 pub applied_event_ordinal: u64,
230 pub applied_event_digest: String,
231 pub last_activity_at_ms: Option<i64>,
234 pub execution: MaterializedExecutionState,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub session_title: Option<String>,
237 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
238 pub configuration: BTreeMap<String, serde_json::Value>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub transcript: Vec<Arc<TranscriptItem>>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub queued_prompts: Vec<MaterializedQueuedPrompt>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub pending_elicitations: Vec<crate::elicitation::ElicitationRequest>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub active_turn: Option<MaterializedTurn>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct MaterializedSessionSummary {
262 pub session_id: String,
263 pub applied_event_ordinal: u64,
264 pub last_activity_at_ms: Option<i64>,
265 pub execution: MaterializedExecutionState,
266 pub session_title: Option<String>,
267 pub last_agent_message: Option<String>,
268 pub last_user_message: Option<String>,
269 pub last_agent_message_follows_last_user: bool,
272 pub agent_message_latest_content_ordinals: Vec<u64>,
273 pub session_restart_event_ordinals: Vec<u64>,
274}
275
276impl MaterializedSession {
277 pub fn empty(session_id: impl Into<String>) -> Self {
278 Self {
279 session_id: session_id.into(),
280 applied_event_ordinal: 0,
281 applied_event_digest: RELAY_EVENT_GENESIS_DIGEST.into(),
282 last_activity_at_ms: None,
283 execution: MaterializedExecutionState::Idle,
284 session_title: None,
285 configuration: BTreeMap::new(),
286 transcript: Vec::new(),
287 queued_prompts: Vec::new(),
288 pending_elicitations: Vec::new(),
289 active_turn: None,
290 last_turn_outcome: None,
291 }
292 }
293
294 pub fn last_activity_at_ms(&self) -> Option<i64> {
295 self.last_activity_at_ms
296 }
297
298 pub fn resolved_title(&self) -> Option<String> {
304 self.session_title
305 .as_deref()
306 .and_then(normalize_session_title)
307 .or_else(|| {
308 self.transcript.iter().find_map(|item| {
309 let TranscriptBody::User { content } = &item.body else {
310 return None;
311 };
312 provisional_session_title(&crate::transcript::materialized_content_text(
313 content,
314 ))
315 })
316 })
317 .or_else(|| {
318 self.queued_prompts
319 .iter()
320 .filter(|prompt| prompt.kind.is_prompt())
321 .find_map(|prompt| {
322 provisional_session_title(&crate::transcript::materialized_content_text(
323 &prompt.content,
324 ))
325 })
326 })
327 }
328
329 pub fn unread_agent_messages_after(&self, viewed_through_event_ordinal: u64) -> u64 {
330 self.transcript
331 .iter()
332 .filter(|item| {
333 item.latest_content_event_ordinal
334 .is_some_and(|ordinal| ordinal > viewed_through_event_ordinal)
335 && item.is_nonempty_agent_message()
336 })
337 .count() as u64
338 }
339
340 pub fn unread_session_restarts_after(&self, viewed_through_event_ordinal: u64) -> u64 {
341 self.transcript
342 .iter()
343 .filter(|item| {
344 item.position > viewed_through_event_ordinal && item.is_session_restart()
345 })
346 .count() as u64
347 }
348
349 pub fn validate(&self) -> Result<()> {
350 validate_id("session", &self.session_id)?;
351 validate_relay_event_frontier(
352 self.applied_event_ordinal,
353 &self.applied_event_digest,
354 "materialized session event frontier",
355 )?;
356 if self
357 .session_title
358 .as_ref()
359 .is_some_and(|title| title.trim().is_empty())
360 {
361 bail!("materialized session has an empty title");
362 }
363 let mut item_ids = BTreeSet::new();
364 for item in &self.transcript {
365 item.validate(self.applied_event_ordinal)?;
366 if !item_ids.insert(item.stable_id.as_str()) {
367 bail!(
368 "materialized transcript contains duplicate item {:?}",
369 item.stable_id
370 );
371 }
372 }
373 let mut command_ids = BTreeSet::new();
374 for prompt in &self.queued_prompts {
375 if prompt.command_id.trim().is_empty() {
376 bail!("materialized prompt queue has an empty command id");
377 }
378 if !command_ids.insert(prompt.command_id.as_str()) {
379 bail!(
380 "materialized prompt queue contains duplicate command {:?}",
381 prompt.command_id
382 );
383 }
384 if let QueuedCommandKind::SetConfig { key, value } = &prompt.kind
385 && (key.trim().is_empty() || value.trim().is_empty())
386 {
387 bail!(
388 "materialized queued configuration change {:?} is incomplete",
389 prompt.command_id
390 );
391 }
392 }
393 Ok(())
394 }
395}
396
397#[derive(Debug, Clone, PartialEq)]
401pub struct ManagedSessionSnapshot {
402 pub materialized: MaterializedSession,
403 pub window: ProjectionWindow,
406 pub operational: RelayOperationalState,
407 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
411 pub worker_build: Option<String>,
416 pub subagent_requests: Vec<crate::subagent::SubagentToolRequest>,
418 pub subagent_results: Vec<crate::subagent::SubagentToolResult>,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct ProjectionWindow {
435 pub omitted_items: usize,
437 pub provisional_title: Option<String>,
439 pub latest_turn_start_position: Option<u64>,
443}
444
445impl ProjectionWindow {
446 #[must_use]
448 pub fn of(session: &MaterializedSession) -> Self {
449 Self {
450 omitted_items: 0,
451 provisional_title: session.transcript.iter().find_map(|item| {
452 let TranscriptBody::User { content } = &item.body else {
453 return None;
454 };
455 provisional_session_title(&crate::transcript::materialized_content_text(content))
456 }),
457 latest_turn_start_position: session
458 .transcript
459 .iter()
460 .rev()
461 .find(|item| item.is_turn_start())
462 .map(|item| item.position),
463 }
464 }
465}
466
467impl ManagedSessionSnapshot {
468 #[must_use]
473 pub fn resolved_title(&self) -> Option<String> {
474 self.materialized
475 .session_title
476 .as_deref()
477 .and_then(normalize_session_title)
478 .or_else(|| self.window.provisional_title.clone())
479 .or_else(|| {
480 self.materialized
481 .queued_prompts
482 .iter()
483 .filter(|prompt| prompt.kind.is_prompt())
484 .find_map(|prompt| {
485 provisional_session_title(&crate::transcript::materialized_content_text(
486 &prompt.content,
487 ))
488 })
489 })
490 }
491
492 #[must_use]
497 pub fn latest_completed_turn_ordinal(&self) -> Option<u64> {
498 if self.materialized.execution != MaterializedExecutionState::Idle {
499 return None;
500 }
501 self.window.latest_turn_start_position
502 }
503}
504
505#[derive(Debug, Clone)]
507pub struct RecoveryObservation {
508 pub session: SessionRecord,
509 pub config: Config,
510 pub latest_completed_turn_ordinal: Option<u64>,
511 pub execution: MaterializedExecutionState,
512 pub checkpoint_safe: bool,
516}
517
518pub fn latest_completed_turn_ordinal(session: &MaterializedSession) -> Option<u64> {
523 if session.execution != MaterializedExecutionState::Idle {
524 return None;
525 }
526 session
527 .transcript
528 .iter()
529 .rev()
530 .find(|item| item.is_turn_start())
531 .map(|item| item.position)
532}
533
534pub fn validate_relay_event_digest(digest: &str, name: &str) -> Result<()> {
535 if digest.len() != 64
536 || !digest
537 .bytes()
538 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
539 {
540 bail!("{name} must be a lowercase SHA-256 digest");
541 }
542 Ok(())
543}
544
545pub fn validate_relay_event_frontier(ordinal: u64, digest: &str, name: &str) -> Result<()> {
546 validate_relay_event_digest(digest, name)?;
547 if (ordinal == 0) != (digest == RELAY_EVENT_GENESIS_DIGEST) {
548 bail!("{name} has inconsistent ordinal {ordinal} and digest {digest}");
549 }
550 Ok(())
551}
552
553fn is_false(value: &bool) -> bool {
554 !*value
555}
556
557impl SessionState {
558 pub const fn as_str(self) -> &'static str {
560 match self {
561 Self::Provisioning => "provisioning",
562 Self::Running => "running",
563 Self::Disconnected => "disconnected",
564 Self::Checkpointing => "checkpointing",
565 Self::Closing => "closing",
566 Self::Destroying => "destroying",
567 Self::Stopped => "stopped",
568 Self::Lost => "lost",
569 Self::Error => "error",
570 Self::DestroyedWithDataLoss => "destroyed-with-data-loss",
571 }
572 }
573
574 pub fn from_stored(value: &str) -> Option<Self> {
577 Some(match value {
578 "provisioning" => Self::Provisioning,
579 "running" => Self::Running,
580 "disconnected" => Self::Disconnected,
581 "checkpointing" => Self::Checkpointing,
582 "closing" => Self::Closing,
583 "destroying" => Self::Destroying,
584 "stopped" | "archived" => Self::Stopped,
585 "lost" => Self::Lost,
586 "error" => Self::Error,
587 "destroyed-with-data-loss" => Self::DestroyedWithDataLoss,
588 _ => return None,
589 })
590 }
591
592 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
595 match self {
596 Self::Provisioning => Some(SessionTransitionKind::Starting),
597 Self::Closing => Some(SessionTransitionKind::Stopping),
598 Self::Destroying => Some(SessionTransitionKind::Destroying),
599 _ => None,
600 }
601 }
602
603 pub const fn is_active(self) -> bool {
607 matches!(
608 self,
609 Self::Provisioning
610 | Self::Running
611 | Self::Disconnected
612 | Self::Checkpointing
613 | Self::Closing
614 | Self::Destroying
615 | Self::Error
616 )
617 }
618}
619
620#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(tag = "kind", rename_all = "kebab-case")]
622pub enum PodmanWorkspaceLocator {
623 #[default]
624 ContainerLayer,
625 Volume {
626 name: String,
627 },
628 HostPath {
629 path: PathBuf,
630 helper: Vec<String>,
631 resource: String,
632 },
633}
634
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(tag = "kind", rename_all = "kebab-case")]
637pub enum TargetLocator {
638 LocalBare {
639 worker_root: PathBuf,
640 },
641 LocalPodman {
642 container_id: String,
643 #[serde(default)]
644 workspace_storage: PodmanWorkspaceLocator,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
649 borrowed_from: Option<String>,
650 },
651 LocalDocker {
652 container_id: String,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
657 borrowed_from: Option<String>,
658 },
659 AppleContainer {
660 container_id: String,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
665 borrowed_from: Option<String>,
666 },
667 AwsEc2 {
668 instance_id: String,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 address: Option<String>,
671 },
672 SshBare {
673 host: String,
674 workspace: PathBuf,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 worker_id: Option<String>,
677 },
678 SshPodman {
679 host: String,
680 container_id: String,
681 #[serde(default)]
682 workspace_storage: PodmanWorkspaceLocator,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
687 borrowed_from: Option<String>,
688 },
689 SshDocker {
690 host: String,
691 container_id: String,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
696 borrowed_from: Option<String>,
697 },
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(tag = "kind", rename_all = "kebab-case")]
702pub enum ManagedWorktreeTarget {
703 Local,
704 Ssh {
705 destination: String,
706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 ssh_args: Vec<String>,
708 },
709}
710
711#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct ManagedWorktreeOptions {
715 pub available: bool,
716 pub default_create: bool,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(deny_unknown_fields)]
721pub struct ManagedWorktree {
722 pub source_project_directory: PathBuf,
723 pub source_repository: PathBuf,
724 pub worktree_root: PathBuf,
725 pub branch: String,
726 pub target: ManagedWorktreeTarget,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub base_commit: Option<String>,
732}
733
734impl ManagedWorktree {
735 fn validate(&self, session_id: &str, project_directory: Option<&Path>) -> Result<()> {
736 for (label, path) in [
737 ("source project directory", &self.source_project_directory),
738 ("source repository", &self.source_repository),
739 ("worktree root", &self.worktree_root),
740 ] {
741 if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
742 bail!("managed worktree {label} must be an absolute safe path");
743 }
744 }
745 if !self
746 .source_project_directory
747 .starts_with(&self.source_repository)
748 {
749 bail!("managed worktree source directory is outside its repository");
750 }
751 let expected_root = self
752 .source_repository
753 .join(".mj")
754 .join("worktrees")
755 .join(session_id);
756 if self.worktree_root != expected_root {
757 bail!("managed worktree root does not match the session-owned path");
758 }
759 if self.branch != format!("mj/{session_id}") {
760 bail!("managed worktree branch does not match the session id");
761 }
762 let relative = self
763 .source_project_directory
764 .strip_prefix(&self.source_repository)
765 .expect("source relationship checked above");
766 if project_directory != Some(self.worktree_root.join(relative).as_path()) {
767 bail!("session project directory does not match its managed worktree");
768 }
769 match &self.target {
770 ManagedWorktreeTarget::Local => {}
771 ManagedWorktreeTarget::Ssh { destination, .. } if destination.trim().is_empty() => {
772 bail!("managed SSH worktree has an empty destination")
773 }
774 ManagedWorktreeTarget::Ssh { .. } => {}
775 }
776 Ok(())
777 }
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(tag = "kind", rename_all = "kebab-case")]
782pub enum SessionResourceAllocation {
783 Container {
784 cpus: u64,
785 memory_bytes: u64,
786 },
787 AwsEc2 {
788 instance_type: String,
789 vcpus: u64,
790 memory_bytes: u64,
791 },
792}
793
794impl SessionResourceAllocation {
795 pub fn validate(&self) -> Result<()> {
796 match self {
797 Self::Container { cpus, memory_bytes } if *cpus == 0 || *memory_bytes == 0 => {
798 bail!("container resource allocation must have non-zero CPU and memory")
799 }
800 Self::AwsEc2 {
801 instance_type,
802 vcpus,
803 memory_bytes,
804 } if instance_type.trim().is_empty() || *vcpus == 0 || *memory_bytes == 0 => {
805 bail!("EC2 resource allocation must have an instance type, CPU, and memory")
806 }
807 _ => Ok(()),
808 }
809 }
810}
811
812pub fn allocation_cpus(allocation: &SessionResourceAllocation) -> u64 {
814 match allocation {
815 SessionResourceAllocation::Container { cpus, .. } => *cpus,
816 SessionResourceAllocation::AwsEc2 { vcpus, .. } => *vcpus,
817 }
818}
819
820pub fn allocation_memory(allocation: &SessionResourceAllocation) -> u64 {
822 match allocation {
823 SessionResourceAllocation::Container { memory_bytes, .. }
824 | SessionResourceAllocation::AwsEc2 { memory_bytes, .. } => *memory_bytes,
825 }
826}
827
828impl TargetLocator {
829 fn validate(&self, session_id: &str) -> Result<()> {
830 match self {
831 Self::LocalBare { worker_root } => {
832 if !worker_root.is_absolute()
833 || worker_root
834 .components()
835 .any(|part| part == Component::ParentDir)
836 || !worker_root.ends_with(session_id)
837 {
838 bail!(
839 "local bare worker root must be an absolute safe path ending in the session id"
840 );
841 }
842 }
843 Self::LocalPodman { container_id, .. }
844 | Self::LocalDocker { container_id, .. }
845 | Self::AppleContainer { container_id, .. }
846 | Self::SshPodman { container_id, .. }
847 | Self::SshDocker { container_id, .. }
848 if container_id.trim().is_empty() =>
849 {
850 bail!("target locator has an empty container id")
851 }
852 Self::AwsEc2 { instance_id, .. } if instance_id.trim().is_empty() => {
853 bail!("target locator has an empty AWS instance id")
854 }
855 Self::SshBare {
856 host, workspace, ..
857 } => {
858 if host.trim().is_empty() {
859 bail!("bare SSH target locator has an empty host");
860 }
861 if workspace.as_os_str().is_empty()
862 || workspace
863 .components()
864 .any(|part| part == Component::ParentDir)
865 || !workspace.ends_with(session_id)
866 {
867 bail!("bare SSH target locator must be a safe path ending in the session id");
868 }
869 }
870 Self::SshPodman { host, .. } if host.trim().is_empty() => {
871 bail!("SSH Podman target locator has an empty host")
872 }
873 Self::SshDocker { host, .. } if host.trim().is_empty() => {
874 bail!("SSH Docker target locator has an empty host")
875 }
876 _ => {}
877 }
878 Ok(())
879 }
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883#[serde(deny_unknown_fields)]
884pub struct CheckpointMetadata {
885 pub archive_path: PathBuf,
886 pub sha256: String,
888 pub created_at: String,
889 pub event_frontier: u64,
890}
891
892impl CheckpointMetadata {
893 fn validate(&self) -> Result<()> {
894 if self.archive_path.as_os_str().is_empty() {
895 bail!("checkpoint archive path is empty");
896 }
897 if self.sha256.len() != 64
898 || !self
899 .sha256
900 .bytes()
901 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
902 {
903 bail!("checkpoint SHA-256 must be 64 lowercase hexadecimal characters");
904 }
905 if self.created_at.trim().is_empty() {
906 bail!("checkpoint timestamp is empty");
907 }
908 Ok(())
909 }
910}
911
912#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
916#[serde(deny_unknown_fields)]
917pub struct SessionBuildCache {
918 pub host: String,
921 pub directory: PathBuf,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
925 pub max_size: Option<String>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub target_root: Option<PathBuf>,
930}
931
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936pub struct ArchiveSpacePreview {
937 pub sessions: usize,
939 pub bytes: u64,
941 pub reclaimable_sessions: usize,
944 pub reclaimable_bytes: u64,
945}
946
947#[derive(Debug, Clone, PartialEq, Eq)]
951pub struct BuildCachePreview {
952 pub native_mbx: Option<String>,
954 pub directory: Option<PathBuf>,
956 pub max_size: Option<BuildCacheLimit>,
958 pub off_reason: Option<String>,
961}
962
963#[derive(Debug, Clone, PartialEq, Eq)]
965pub enum BuildCacheLimit {
966 Size(String),
968 HostConfiguration(Option<String>),
971}
972
973#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
974#[serde(deny_unknown_fields)]
975pub struct SessionRecord {
976 pub id: String,
977 #[serde(default = "default_session_workspace_id")]
982 pub workspace_id: String,
983 pub title: String,
984 pub harness_kind: HarnessKind,
985 pub last_profile: String,
986 pub bundle_id: String,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub project_directory: Option<PathBuf>,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
992 pub managed_worktree: Option<ManagedWorktree>,
993 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub create_managed_worktree: Option<bool>,
996 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub mjolnir_subagents: Option<bool>,
1000 pub target_template_id: String,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub resource_allocation: Option<SessionResourceAllocation>,
1003 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004 pub additional_mounts: Vec<AdditionalMount>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub container_cpus: Option<String>,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub container_memory: Option<String>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub container_workspace: Option<PathBuf>,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub build_cache: Option<SessionBuildCache>,
1025 pub state: SessionState,
1026 #[serde(default, skip_serializing_if = "is_false")]
1029 pub archived: bool,
1030 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub target: Option<TargetLocator>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub native_session_id: Option<String>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub acp_session_title: Option<String>,
1036 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub session_title_override: Option<String>,
1038 pub created_at: String,
1039 pub updated_at: String,
1040 #[serde(default, alias = "detached_after_event_ordinal")]
1041 pub viewed_through_event_ordinal: u64,
1042 #[serde(default, skip_serializing_if = "String::is_empty")]
1045 pub draft_input: String,
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub last_error: Option<String>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub last_checkpoint_error: Option<String>,
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub checkpoint: Option<CheckpointMetadata>,
1059}
1060
1061#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(deny_unknown_fields)]
1063pub struct HostContainerSize {
1064 pub cpus: u64,
1065 pub memory_bytes: u64,
1066}
1067
1068fn default_session_workspace_id() -> String {
1069 crate::workspace::DEFAULT_WORKSPACE_ID.to_owned()
1070}
1071
1072pub const CLOSE_FAILURE_PREFIX: &str = "the close did not finish";
1081
1082#[must_use]
1095pub fn target_label(config: &Config, target_id: &str, project: Option<&Path>) -> String {
1096 if !matches!(
1097 config.targets.get(target_id),
1098 Some(TargetTemplate::LocalBare | TargetTemplate::SshBare { .. })
1099 ) {
1100 return target_id.to_owned();
1101 }
1102 project.and_then(Path::file_name).map_or_else(
1103 || target_id.to_owned(),
1104 |directory| format!("{target_id}/{}", directory.to_string_lossy()),
1105 )
1106}
1107
1108impl SessionRecord {
1109 #[must_use]
1113 pub fn public_error(&self) -> Option<&str> {
1114 self.last_error
1115 .as_deref()
1116 .filter(|error| error.starts_with(CLOSE_FAILURE_PREFIX))
1117 }
1118
1119 pub fn configuration_issue(&self, config: &Config) -> Option<String> {
1122 if !self.state.is_active() {
1123 return None;
1124 }
1125 let mut issues = Vec::new();
1126 match config.profiles.get(&self.last_profile) {
1127 None => issues.push(format!("missing profile {:?}", self.last_profile)),
1128 Some(profile) if profile.kind != self.harness_kind => issues.push(format!(
1129 "expects {:?}, but profile {:?} is {:?}",
1130 self.harness_kind, self.last_profile, profile.kind
1131 )),
1132 Some(_) => {}
1133 }
1134 if self.project_directory.is_none() && !config.bundles.contains_key(&self.bundle_id) {
1135 issues.push(format!("missing bundle {:?}", self.bundle_id));
1136 }
1137 if !config.targets.contains_key(&self.target_template_id) {
1138 issues.push(format!(
1139 "missing target template {:?}",
1140 self.target_template_id
1141 ));
1142 }
1143 (!issues.is_empty()).then(|| format!(
1144 "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.",
1145 self.id, issues.join("; ")
1146 ))
1147 }
1148
1149 pub fn validate_configuration(&self, config: &Config) -> Result<()> {
1150 if let Some(issue) = self.configuration_issue(config) {
1151 bail!("{issue}");
1152 }
1153 Ok(())
1154 }
1155
1156 pub fn display_title(&self) -> &str {
1158 self.session_title_override
1159 .as_deref()
1160 .or(self.acp_session_title.as_deref())
1161 .unwrap_or(&self.id)
1162 }
1163
1164 pub fn project_name(&self, config: &Config) -> String {
1169 if let Some(worktree) = &self.managed_worktree {
1170 return path_leaf(&worktree.source_repository);
1171 }
1172 if let Some(project_directory) = &self.project_directory {
1173 return path_leaf(project_directory);
1174 }
1175 self.bundle_source_name(config)
1176 }
1177
1178 pub fn project_target(&self, config: &Config, target_id: &str) -> String {
1182 let project = self
1183 .managed_worktree
1184 .as_ref()
1185 .map(|worktree| &worktree.source_project_directory)
1186 .or(self.project_directory.as_ref());
1187 target_label(config, target_id, project.map(PathBuf::as_path))
1188 }
1189
1190 pub fn project_source(&self, config: &Config) -> ProjectSourceIdentity {
1195 if let Some(worktree) = &self.managed_worktree {
1196 return ProjectSourceIdentity::path(&worktree.source_repository, None);
1197 }
1198 if let Some(project_directory) = &self.project_directory {
1199 let remote = match &self.target {
1200 Some(TargetLocator::SshBare { host, .. }) => Some(host.as_str()),
1201 _ => None,
1202 };
1203 return ProjectSourceIdentity::path(project_directory, remote);
1204 }
1205 self.bundle_source_identity(config)
1206 .unwrap_or_else(|| ProjectSourceIdentity {
1207 key: format!("bundle:{}", self.bundle_id),
1208 short: path_leaf(Path::new(&self.bundle_id)),
1209 full: self.bundle_id.clone(),
1210 })
1211 }
1212
1213 fn bundle_source_name(&self, config: &Config) -> String {
1216 self.bundle_source_identity(config)
1217 .map(|source| source.short)
1218 .unwrap_or_else(|| path_leaf(Path::new(&self.bundle_id)))
1219 }
1220
1221 fn bundle_source_identity(&self, config: &Config) -> Option<ProjectSourceIdentity> {
1224 let bundle = config.bundles.get(&self.bundle_id)?;
1225 let sources = bundle
1226 .repositories
1227 .iter()
1228 .map(repository_source_identity)
1229 .collect::<Option<Vec<_>>>()?;
1230 ProjectSourceIdentity::bundle(sources)
1231 }
1232
1233 pub fn compare_by_creation(&self, other: &Self) -> std::cmp::Ordering {
1237 self.creation_order_key().cmp(&other.creation_order_key())
1238 }
1239
1240 pub fn creation_order_key(&self) -> (bool, Option<i64>, &str) {
1242 let timestamp = created_at_seconds(&self.created_at);
1243 (timestamp.is_none(), timestamp, &self.id)
1244 }
1245
1246 fn validate(&self, map_id: &str) -> Result<()> {
1247 validate_id("session", &self.id)?;
1248 if self.id != map_id {
1249 bail!(
1250 "session map key {map_id:?} does not match record id {:?}",
1251 self.id
1252 );
1253 }
1254 validate_id("workspace", &self.workspace_id)?;
1255 validate_id("profile", &self.last_profile)?;
1256 validate_id("bundle", &self.bundle_id)?;
1257 if let Some(project_directory) = &self.project_directory
1258 && (!project_directory.is_absolute()
1259 || project_directory
1260 .components()
1261 .any(|part| part == Component::ParentDir))
1262 {
1263 bail!("session {:?} has an unsafe project directory", self.id);
1264 }
1265 if let Some(managed_worktree) = &self.managed_worktree {
1266 managed_worktree.validate(&self.id, self.project_directory.as_deref())?;
1267 }
1268 validate_id("target template", &self.target_template_id)?;
1269 if let Some(allocation) = &self.resource_allocation {
1270 allocation.validate()?;
1271 }
1272 validate_additional_mounts(&self.additional_mounts)?;
1273 if self.title.trim().is_empty() {
1274 bail!("session {:?} has an empty title", self.id);
1275 }
1276 if self
1277 .acp_session_title
1278 .as_ref()
1279 .is_some_and(|title| title.trim().is_empty())
1280 || self
1281 .session_title_override
1282 .as_ref()
1283 .is_some_and(|title| title.trim().is_empty())
1284 {
1285 bail!("session {:?} has an empty display title", self.id);
1286 }
1287 if self.created_at.trim().is_empty() || self.updated_at.trim().is_empty() {
1288 bail!("session {:?} has an empty timestamp", self.id);
1289 }
1290 if let Some(target) = &self.target {
1291 target.validate(&self.id)?;
1292 }
1293 if let Some(checkpoint) = &self.checkpoint {
1294 checkpoint.validate()?;
1295 }
1296 Ok(())
1297 }
1298}
1299
1300fn repository_source_identity(repository: &ProjectRepository) -> Option<ProjectSourceIdentity> {
1301 repository
1302 .github
1303 .as_deref()
1304 .and_then(ProjectSourceIdentity::git_remote)
1305 .or_else(|| {
1306 repository
1307 .local
1308 .as_deref()
1309 .map(|path| ProjectSourceIdentity::path(path, None))
1310 })
1311}
1312
1313#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1314pub struct ProjectSourceIdentity {
1315 pub key: String,
1316 pub short: String,
1317 pub full: String,
1318}
1319
1320impl ProjectSourceIdentity {
1321 pub fn bundle(mut sources: Vec<Self>) -> Option<Self> {
1323 if sources.is_empty() {
1324 return None;
1325 }
1326 sources.sort_by(|left, right| {
1327 left.key
1328 .cmp(&right.key)
1329 .then_with(|| left.full.cmp(&right.full))
1330 .then_with(|| left.short.cmp(&right.short))
1331 });
1332 sources.dedup_by(|left, right| left.key == right.key);
1333 if sources.len() == 1 {
1334 return sources.pop();
1335 }
1336 let keys = sources
1337 .iter()
1338 .map(|source| source.key.clone())
1339 .collect::<Vec<_>>();
1340 let key = serde_json::to_string(&keys).ok()?;
1341 Some(Self {
1342 key: format!("bundle:{key}"),
1343 short: sources
1344 .iter()
1345 .map(|source| source.short.as_str())
1346 .collect::<Vec<_>>()
1347 .join(" + "),
1348 full: sources
1349 .iter()
1350 .map(|source| source.full.as_str())
1351 .collect::<Vec<_>>()
1352 .join(" + "),
1353 })
1354 }
1355
1356 pub fn git_remote(source: &str) -> Option<Self> {
1359 if let Some(normalized) = normalize_github_source(source) {
1360 let short = normalized
1361 .rsplit_once('/')
1362 .map_or(normalized.as_str(), |(_, repository)| repository)
1363 .to_owned();
1364 return Some(Self {
1365 key: format!("github:{}", normalized.to_lowercase()),
1366 short,
1367 full: normalized,
1368 });
1369 }
1370 let normalized = source.trim().trim_end_matches('/').trim_end_matches(".git");
1371 if normalized.is_empty() {
1372 return None;
1373 }
1374 let short = normalized
1375 .rsplit(['/', ':'])
1376 .find(|part| !part.is_empty())
1377 .unwrap_or(normalized)
1378 .to_owned();
1379 Some(Self {
1380 key: format!("git:{}", normalized.to_lowercase()),
1381 short,
1382 full: normalized.to_owned(),
1383 })
1384 }
1385
1386 pub fn path(path: &Path, remote: Option<&str>) -> Self {
1388 let normalized = path.components().collect::<PathBuf>();
1389 let path_text = normalized.to_string_lossy().into_owned();
1390 let full = remote.map_or_else(|| path_text.clone(), |host| format!("{host}:{path_text}"));
1391 let key = remote.map_or_else(
1392 || format!("path:{path_text}"),
1393 |host| format!("path:{}:{path_text}", host.to_lowercase()),
1394 );
1395 Self {
1396 key,
1397 short: path_leaf(path),
1398 full,
1399 }
1400 }
1401}
1402
1403fn normalize_github_source(source: &str) -> Option<String> {
1404 let source = source.trim();
1405 let path = source
1406 .strip_prefix("https://github.com/")
1407 .or_else(|| source.strip_prefix("http://github.com/"))
1408 .or_else(|| source.strip_prefix("git@github.com:"))
1409 .or_else(|| source.strip_prefix("ssh://git@github.com/"))
1410 .or_else(|| {
1411 (!source.contains("://") && !source.contains('@') && !source.contains(':'))
1412 .then_some(source)
1413 })?
1414 .trim_end_matches(".git");
1415 let mut parts = path.split('/');
1416 let owner = parts.next()?;
1417 let repository = parts.next()?;
1418 (!owner.is_empty() && !repository.is_empty() && parts.next().is_none())
1419 .then(|| format!("{owner}/{repository}"))
1420}
1421
1422fn path_leaf(path: &Path) -> String {
1424 path.file_name()
1425 .unwrap_or(path.as_os_str())
1426 .to_string_lossy()
1427 .into_owned()
1428}
1429
1430fn created_at_seconds(timestamp: &str) -> Option<i64> {
1431 chrono::DateTime::parse_from_rfc3339(timestamp)
1432 .ok()
1433 .map(|timestamp| timestamp.timestamp())
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1437#[serde(deny_unknown_fields)]
1438pub struct State {
1439 pub version: u32,
1440 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1441 pub sessions: BTreeMap<String, SessionRecord>,
1442 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1445 pub subagents: BTreeMap<String, SubagentRecord>,
1446 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1448 pub mount_history: BTreeMap<String, Vec<PathBuf>>,
1449 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1451 pub container_sizes: BTreeMap<String, HostContainerSize>,
1452}
1453
1454impl Default for State {
1455 fn default() -> Self {
1456 Self {
1457 version: STATE_VERSION,
1458 sessions: BTreeMap::new(),
1459 subagents: BTreeMap::new(),
1460 mount_history: BTreeMap::new(),
1461 container_sizes: BTreeMap::new(),
1462 }
1463 }
1464}
1465
1466impl State {
1467 #[must_use]
1476 pub fn project_identity_session<'a>(&'a self, session: &'a SessionRecord) -> &'a SessionRecord {
1477 self.subagents
1478 .get(&session.id)
1479 .and_then(|record| self.sessions.get(&record.parent_session_id))
1480 .unwrap_or(session)
1481 }
1482
1483 pub fn validate(&self) -> Result<()> {
1484 if self.version != STATE_VERSION {
1485 bail!(
1486 "unsupported Mjolnir state version {}; expected {STATE_VERSION}",
1487 self.version
1488 );
1489 }
1490 for (id, session) in &self.sessions {
1491 session.validate(id)?;
1492 }
1493 for (child_id, subagent) in &self.subagents {
1494 if child_id != &subagent.child_session_id {
1495 bail!("sub-agent key {child_id:?} does not match its child session id");
1496 }
1497 if child_id == &subagent.parent_session_id {
1498 bail!("sub-agent {child_id:?} cannot be its own parent");
1499 }
1500 if !self.sessions.contains_key(child_id) {
1501 bail!("sub-agent {child_id:?} has no child session");
1502 }
1503 if !self.sessions.contains_key(&subagent.parent_session_id) {
1504 bail!(
1505 "sub-agent {child_id:?} has unknown parent {:?}",
1506 subagent.parent_session_id
1507 );
1508 }
1509 if self.subagents.contains_key(&subagent.parent_session_id) {
1510 bail!("sub-agent {child_id:?} cannot belong to another sub-agent");
1511 }
1512 if subagent.task_name.trim().is_empty()
1513 || subagent.profile_id.trim().is_empty()
1514 || subagent.request_key.trim().is_empty()
1515 {
1516 bail!("sub-agent {child_id:?} has incomplete relationship metadata");
1517 }
1518 }
1519 for (host, sources) in &self.mount_history {
1520 if host.trim().is_empty() {
1521 bail!("mount history contains an empty host key");
1522 }
1523 if sources.iter().any(|source| !source.is_absolute()) {
1524 bail!("mount history for {host:?} contains a non-absolute source path");
1525 }
1526 }
1527 for (host, size) in &self.container_sizes {
1528 if host.trim().is_empty() {
1529 bail!("container size history contains an empty host key");
1530 }
1531 if size.cpus == 0 || size.memory_bytes == 0 {
1532 bail!("container size history for {host:?} contains a zero value");
1533 }
1534 if size.cpus > i64::MAX as u64 || size.memory_bytes > i64::MAX as u64 {
1535 bail!("container size history for {host:?} exceeds SQLite integer range");
1536 }
1537 }
1538 Ok(())
1539 }
1540
1541 pub fn remember_mount_sources(&mut self, host: &str, mounts: &[AdditionalMount]) {
1542 if mounts.is_empty() {
1543 return;
1544 }
1545 let sources = self.mount_history.entry(host.to_owned()).or_default();
1546 for mount in mounts.iter().rev() {
1547 sources.retain(|source| source != &mount.source);
1548 sources.insert(0, mount.source.clone());
1549 }
1550 sources.truncate(20);
1551 }
1552
1553 pub fn remember_container_size(&mut self, host: &str, size: HostContainerSize) {
1554 self.container_sizes.insert(host.to_owned(), size);
1555 }
1556
1557 pub fn project_directories(&self, host: &str) -> &[PathBuf] {
1558 self.mount_history
1559 .get(&project_history_key(host))
1560 .map(Vec::as_slice)
1561 .unwrap_or_default()
1562 }
1563
1564 pub fn remember_project_directory(&mut self, host: &str, directory: &Path) {
1565 let key = project_history_key(host);
1566 let directories = self.mount_history.entry(key).or_default();
1567 directories.retain(|existing| existing != directory);
1568 directories.insert(0, directory.to_path_buf());
1569 directories.truncate(20);
1570 }
1571
1572 pub fn destroy_stopped_session(&mut self, session_id: &str) -> Result<SessionRecord> {
1573 let session = self
1574 .sessions
1575 .get(session_id)
1576 .with_context(|| format!("unknown session {session_id}"))?;
1577 if session.state.is_active() {
1578 bail!("refusing to destroy active session {session_id}");
1579 }
1580 Ok(self
1581 .sessions
1582 .remove(session_id)
1583 .expect("session checked above"))
1584 }
1585
1586 pub fn destroy_session_force(&mut self, session_id: &str) -> Result<SessionRecord> {
1592 self.sessions
1593 .get(session_id)
1594 .with_context(|| format!("unknown session {session_id}"))?;
1595 Ok(self
1596 .sessions
1597 .remove(session_id)
1598 .expect("session checked above"))
1599 }
1600
1601 pub fn validate_setup_update(&self, before: &Config, after: &Config) -> Result<()> {
1604 for session in self
1605 .sessions
1606 .values()
1607 .filter(|session| session.state.is_active())
1608 {
1609 let protected = if let Some(profile) = before.profiles.get(&session.last_profile) {
1610 let mut comparable = profile.clone();
1611 if let Some(updated) = after.profiles.get(&session.last_profile) {
1612 comparable.enabled = updated.enabled;
1613 }
1614 profile.kind == session.harness_kind
1616 && after.profiles.get(&session.last_profile) != Some(&comparable)
1617 } else {
1618 false
1619 };
1620 let bundle_changed = session.project_directory.is_none()
1621 && before
1622 .bundles
1623 .get(&session.bundle_id)
1624 .is_some_and(|bundle| after.bundles.get(&session.bundle_id) != Some(bundle));
1625 let target_changed =
1626 before
1627 .targets
1628 .get(&session.target_template_id)
1629 .is_some_and(|target| {
1630 after.targets.get(&session.target_template_id) != Some(target)
1631 });
1632 if protected || bundle_changed || target_changed {
1633 bail!(
1634 "Setup would change configuration used by active session {:?}. Keep its profile {:?}, bundle {:?}, and target {:?}; add a separate entry for new settings, or stop the session before editing its configuration.",
1635 session.id,
1636 session.last_profile,
1637 session.bundle_id,
1638 session.target_template_id
1639 );
1640 }
1641 }
1642 Ok(())
1643 }
1644
1645 pub fn validate_against_config(&self, config: &Config) -> Result<()> {
1647 self.validate()?;
1648 config.validate()?;
1649 for session in self.sessions.values() {
1650 session.validate_configuration(config)?;
1651 }
1652 Ok(())
1653 }
1654}
1655
1656fn project_history_key(host: &str) -> String {
1657 format!("project:{host}")
1658}
1659
1660pub fn new_session_id() -> Result<String> {
1662 let mut random = [0u8; 16];
1663 getrandom::fill(&mut random)
1664 .map_err(|error| anyhow::anyhow!("generate Mjolnir session id: {error}"))?;
1665 Ok(crate::hex::lower_hex(random))
1666}
1667
1668pub fn harness_session_title(events: &[SequencedEvent]) -> Option<String> {
1670 events.iter().rev().find_map(|event| {
1671 let WorkerEvent::Adapter { payload, .. } = &event.event else {
1672 return None;
1673 };
1674 let crate::acp::RuntimeEvent::SessionUpdate { update } =
1675 serde_json::from_value(payload.clone()).ok()?
1676 else {
1677 return None;
1678 };
1679 let kind = update
1680 .get("sessionUpdate")
1681 .and_then(serde_json::Value::as_str)?;
1682 let title = match kind {
1683 "session_info_update" | "session_title" => {
1684 update.get("title").and_then(serde_json::Value::as_str)
1685 }
1686 _ => None,
1687 }?;
1688 normalize_session_title(title)
1689 })
1690}
1691
1692pub fn normalize_session_title(title: &str) -> Option<String> {
1693 let normalized = crate::relay::strip_hidden_prompt_context(title)
1694 .split_whitespace()
1695 .collect::<Vec<_>>()
1696 .join(" ");
1697 (!normalized.is_empty()).then_some(normalized)
1698}
1699
1700pub fn provisional_session_title(prompt: &str) -> Option<String> {
1706 const MAX_TITLE_CHARS: usize = 64;
1707
1708 let normalized = normalize_session_title(prompt)?;
1709 if normalized.chars().count() <= MAX_TITLE_CHARS {
1710 return Some(normalized);
1711 }
1712
1713 let mut truncated = normalized
1714 .chars()
1715 .take(MAX_TITLE_CHARS - 1)
1716 .collect::<String>();
1717 if let Some(boundary) = truncated.rfind(char::is_whitespace) {
1718 truncated.truncate(boundary);
1719 }
1720 truncated.push('…');
1721 Some(truncated)
1722}
1723
1724pub fn short_id(id: &str) -> &str {
1725 id.get(..8).unwrap_or(id)
1726}
1727
1728#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1729pub struct RecoveryCandidate {
1730 pub session_id: String,
1731 pub target_template_id: String,
1732 pub locator: TargetLocator,
1733 pub ownership: Option<crate::worker_launch::WorkerOwnership>,
1734 #[serde(default)]
1737 pub instance_id: Option<String>,
1738 #[serde(default, skip_serializing_if = "Option::is_none")]
1743 pub tracked_session: Option<SessionState>,
1744}
1745
1746#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1747pub struct RecoveryScan {
1748 pub candidates: Vec<RecoveryCandidate>,
1749 pub warnings: Vec<String>,
1750 #[serde(default)]
1752 pub instance_id: String,
1753 #[serde(default)]
1756 pub hidden_other_instances: usize,
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1760#[serde(deny_unknown_fields)]
1761pub struct ResumeRepositorySourceReceipt {
1762 pub session_id: String,
1763 pub bundle_id: String,
1764 pub checkpoint_sha256: String,
1765 pub repositories: Vec<crate::config::ProjectRepository>,
1766}
1767
1768#[cfg(test)]
1769mod tests;