Skip to main content

mj_core/state/
session_move.rs

1//! Durable intent for a verified stop followed by destination restoration.
2
3use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "kebab-case")]
7pub enum ResumeQueueDisposition {
8    Start,
9    Discard,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct MoveSelection {
15    #[serde(default)]
16    pub clear_resource_allocation: bool,
17    pub session_id: String,
18    pub profile_id: Option<String>,
19    pub target_template_id: Option<String>,
20    pub additional_mounts: Option<Vec<AdditionalMount>>,
21    pub resource_allocation: Option<SessionResourceAllocation>,
22}
23
24/// What moving a local checkout into an isolated workspace will do, shown
25/// before anything is stopped or provisioned.
26///
27/// Every field is read from the host checkout and its remote. The dirty counts
28/// are deliberately not part of a move fingerprint: a running local session has
29/// an agent editing files, so they change under the confirmation.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct RawConversionPreview {
33    /// The checkout that is snapshotted: a managed worktree, or the user's own
34    /// directory when the session opened one directly.
35    pub checkout: PathBuf,
36    /// Where the checkout lands inside the target.
37    pub destination: PathBuf,
38    /// The branch the session continues on, or `None` for a detached head.
39    pub branch: Option<String>,
40    pub fetch_url: String,
41    pub push_urls: Vec<String>,
42    /// The branch the remote's `HEAD` names, which is what a fresh clone
43    /// starts on before the session's own branch is restored.
44    pub default_branch: String,
45    /// Commits reachable from `HEAD` that are on no origin ref, and so have to
46    /// travel in the conversion archive.
47    pub unpushed_commits: u64,
48    pub staged_files: u64,
49    pub unstaged_files: u64,
50    pub untracked_files: u64,
51    pub untracked_bytes: u64,
52    /// True when the session opened the user's own checkout, which stays on
53    /// this machine untouched after the move.
54    pub host_checkout_retained: bool,
55}
56
57impl RawConversionPreview {
58    /// The one line every surface shows: what is cloned, where it lands, which
59    /// branch the session continues on, and where `git push` goes.
60    pub fn summary_line(&self) -> String {
61        let push = if self.push_urls.is_empty() {
62            self.fetch_url.clone()
63        } else {
64            self.push_urls.join(", ")
65        };
66        format!(
67            "Clone {} (default branch {}) into {} on branch {}; push to {push}.",
68            self.fetch_url,
69            self.default_branch,
70            self.destination.display(),
71            self.branch.as_deref().unwrap_or("a detached head"),
72        )
73    }
74
75    /// What a person needs to read before the checkout moves: which
76    /// uncommitted work travels, which commits travel, and what stays behind.
77    ///
78    /// Every surface renders these in its own warning style, so the wording
79    /// lives here rather than in each of the TUI, the CLI, and the browser.
80    pub fn warning_lines(&self) -> Vec<String> {
81        let mut lines = Vec::new();
82        let dirty = self.staged_files + self.unstaged_files + self.untracked_files;
83        if dirty > 0 {
84            lines.push(format!(
85                "{} staged, {} unstaged, and {} untracked {} ({}) will be copied into the container. \
86                 Ignored files such as build output, .env, and node_modules will not.",
87                self.staged_files,
88                self.unstaged_files,
89                self.untracked_files,
90                if dirty == 1 { "file" } else { "files" },
91                format_conversion_bytes(self.untracked_bytes),
92            ));
93        }
94        if self.unpushed_commits > 0 {
95            let mut line = format!(
96                "{} {} not on {} {} in the checkpoint.",
97                self.unpushed_commits,
98                if self.unpushed_commits == 1 {
99                    "commit"
100                } else {
101                    "commits"
102                },
103                self.fetch_url,
104                if self.unpushed_commits == 1 {
105                    "travels"
106                } else {
107                    "travel"
108                },
109            );
110            if self.unpushed_commits > 200 {
111                line.push_str(" That is a large history; consider pushing first.");
112            }
113            lines.push(line);
114        }
115        if self.host_checkout_retained {
116            lines.push(format!(
117                "{} stays on this machine and will no longer track this session. \
118                 Edits made in the container do not come back automatically; \
119                 push the branch or move the session back.",
120                self.checkout.display(),
121            ));
122        }
123        lines
124    }
125}
126
127/// Untracked size as a person reads it. Only KB and MB appear: a checkout's
128/// untracked work is never usefully described in bytes, and anything above a
129/// gigabyte is already a warning in megabytes.
130fn format_conversion_bytes(bytes: u64) -> String {
131    const KB: f64 = 1024.0;
132    const MB: f64 = KB * 1024.0;
133    let bytes = bytes as f64;
134    if bytes < MB {
135        format!("{:.1} KB", bytes / KB)
136    } else {
137        format!("{:.1} MB", bytes / MB)
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct MovePreparation {
144    #[serde(default)]
145    pub source_unavailable: bool,
146    /// The destination is the source target: only the harness is replaced;
147    /// the container or worker root and the workspace are kept.
148    #[serde(default)]
149    pub in_place: bool,
150    /// Present only when this move converts a local checkout into an isolated
151    /// workspace. Boxed because this preparation travels inside several
152    /// request enums whose other variants are far smaller.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub conversion: Option<Box<RawConversionPreview>>,
155    pub selection: MoveSelection,
156    pub source_profile_id: String,
157    pub source_target_template_id: String,
158    pub cross_harness: bool,
159    pub active: bool,
160    pub queued_commands: Vec<MaterializedQueuedPrompt>,
161    pub fingerprint: String,
162    pub operation_id: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct MoveSessionRequest {
168    pub preparation: MovePreparation,
169    pub queue: Option<ResumeQueueDisposition>,
170    pub acknowledge_interruption: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct MoveOutcome {
176    pub operation_id: String,
177    pub session_id: String,
178    pub profile_id: String,
179    pub target_template_id: String,
180    pub outcome: String,
181    pub error: Option<String>,
182    pub recovery: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum MovePhase {
188    Preparing,
189    ClosingSource,
190    ResumingDestination,
191    StartingQueue,
192    Completed,
193    Failed,
194    Cancelled,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct MoveOperation {
200    /// Keep the source harness stopped across recovery until destination restoration.
201    #[serde(default)]
202    pub source_checkpoint_only: bool,
203    /// The destination is the source target: only the harness is replaced;
204    /// the container or worker root and the workspace are kept.
205    #[serde(default)]
206    pub in_place: bool,
207    pub operation_id: String,
208    pub selection: MoveSelection,
209    pub source_profile_id: String,
210    pub source_target_template_id: String,
211    pub source_target: Option<TargetLocator>,
212    pub source_native_session_id: Option<String>,
213    pub source_additional_mounts: Vec<AdditionalMount>,
214    pub source_resource_allocation: Option<SessionResourceAllocation>,
215    pub destination_target: Option<TargetLocator>,
216    pub destination_native_session_id: Option<String>,
217    pub destination_store_id: Option<String>,
218    pub configuration_fingerprint: String,
219    pub checkpoint: Option<CheckpointMetadata>,
220    /// Stopped identity retained across partially written resume conversions.
221    pub recovery_session: Option<SessionRecord>,
222    pub queue: ResumeQueueDisposition,
223    pub phase: MovePhase,
224    /// A durable boundary: once set, never restore or replay on another relay.
225    pub queue_admission_started: bool,
226    pub queue_admission_finished: bool,
227    pub cancellation_requested: bool,
228    pub created_at: String,
229    pub updated_at: String,
230    pub error: Option<String>,
231}
232
233impl MoveOperation {
234    pub fn retains_checkpoint(&self) -> bool {
235        !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
236            || (self.queue_admission_started && !self.queue_admission_finished)
237    }
238
239    pub fn is_active(&self) -> bool {
240        matches!(
241            self.phase,
242            MovePhase::Preparing
243                | MovePhase::ClosingSource
244                | MovePhase::ResumingDestination
245                | MovePhase::StartingQueue
246        )
247    }
248}