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