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/// Refusal for a resource allocation aimed at a bare target. Callers match on
6/// it to add their own remedy (a flag, a checkbox).
7pub const BARE_TARGET_FIXED_RESOURCES: &str = "bare targets have fixed host resources";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ResumeQueueDisposition {
12    Start,
13    Discard,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct MoveSelection {
19    #[serde(default)]
20    pub workspace: crate::move_workspace::WorkspaceSelection,
21    #[serde(default)]
22    pub clear_resource_allocation: bool,
23    pub session_id: String,
24    pub profile_id: Option<String>,
25    pub target_template_id: Option<String>,
26    pub additional_mounts: Option<Vec<AdditionalMount>>,
27    pub resource_allocation: Option<SessionResourceAllocation>,
28    /// Delegation policy for the destination. `None` keeps the session's own.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub subagents: Option<crate::subagent::SubagentPolicy>,
31}
32
33/// What moving a local checkout into an isolated workspace will do, shown
34/// before anything is stopped or provisioned.
35///
36/// Every field is read from the host checkout and its remote. The dirty counts
37/// are deliberately not part of a move fingerprint: a running local session has
38/// an agent editing files, so they change under the confirmation.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct RawConversionPreview {
42    /// The checkout that is snapshotted: a managed worktree, or the user's own
43    /// directory when the session opened one directly.
44    pub checkout: PathBuf,
45    /// Where the checkout lands inside the target.
46    pub destination: PathBuf,
47    /// The branch the session continues on, or `None` for a detached head.
48    pub branch: Option<String>,
49    pub fetch_url: String,
50    pub push_urls: Vec<String>,
51    /// The branch the remote's `HEAD` names, which is what a fresh clone
52    /// starts on before the session's own branch is restored.
53    pub default_branch: String,
54    /// Commits reachable from `HEAD` that are on no origin ref, and so have to
55    /// travel in the conversion archive.
56    pub unpushed_commits: u64,
57    pub staged_files: u64,
58    pub unstaged_files: u64,
59    pub untracked_files: u64,
60    pub untracked_bytes: u64,
61    /// True when the session opened the user's own checkout, which stays on
62    /// this machine untouched after the move.
63    pub host_checkout_retained: bool,
64}
65
66impl RawConversionPreview {
67    /// The one line every surface shows: what is cloned, where it lands, which
68    /// branch the session continues on, and where `git push` goes.
69    pub fn summary_line(&self) -> String {
70        let push = if self.push_urls.is_empty() {
71            self.fetch_url.clone()
72        } else {
73            self.push_urls.join(", ")
74        };
75        format!(
76            "Clone {} (default branch {}) into {} on branch {}; push to {push}.",
77            self.fetch_url,
78            self.default_branch,
79            self.destination.display(),
80            self.branch.as_deref().unwrap_or("a detached head"),
81        )
82    }
83
84    /// What a person needs to read before the checkout moves: which
85    /// uncommitted work travels, which commits travel, and what stays behind.
86    ///
87    /// Every surface renders these in its own warning style, so the wording
88    /// lives here rather than in each of the TUI, the CLI, and the browser.
89    pub fn warning_lines(&self) -> Vec<String> {
90        let mut lines = Vec::new();
91        let dirty = self.staged_files + self.unstaged_files + self.untracked_files;
92        if dirty > 0 {
93            lines.push(format!(
94                "{} staged, {} unstaged, and {} untracked {} ({}) will be copied into the container. \
95                 Ignored files such as build output, .env, and node_modules will not.",
96                self.staged_files,
97                self.unstaged_files,
98                self.untracked_files,
99                if dirty == 1 { "file" } else { "files" },
100                format_conversion_bytes(self.untracked_bytes),
101            ));
102        }
103        if self.unpushed_commits > 0 {
104            let mut line = format!(
105                "{} {} not on {} {} in the checkpoint.",
106                self.unpushed_commits,
107                if self.unpushed_commits == 1 {
108                    "commit"
109                } else {
110                    "commits"
111                },
112                self.fetch_url,
113                if self.unpushed_commits == 1 {
114                    "travels"
115                } else {
116                    "travel"
117                },
118            );
119            if self.unpushed_commits > 200 {
120                line.push_str(" That is a large history; consider pushing first.");
121            }
122            lines.push(line);
123        }
124        if self.host_checkout_retained {
125            lines.push(format!(
126                "{} stays on this machine and will no longer track this session. \
127                 Edits made in the container do not come back automatically; \
128                 push the branch or move the session back.",
129                self.checkout.display(),
130            ));
131        }
132        lines
133    }
134}
135
136/// Untracked size as a person reads it. Only KB and MB appear: a checkout's
137/// untracked work is never usefully described in bytes, and anything above a
138/// gigabyte is already a warning in megabytes.
139fn format_conversion_bytes(bytes: u64) -> String {
140    const KB: f64 = 1024.0;
141    const MB: f64 = KB * 1024.0;
142    let bytes = bytes as f64;
143    if bytes < MB {
144        format!("{:.1} KB", bytes / KB)
145    } else {
146        format!("{:.1} MB", bytes / MB)
147    }
148}
149
150pub const EC2_MOVE_PREPARATION_NOTICE: &str =
151    "The EC2 instance will be created and checked after you confirm Move.";
152
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum DestinationChecks {
156    #[default]
157    Checked,
158    AfterProvisioning,
159}
160
161/// The launch request is immutable for an attempt, including its client token.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct PreparedMoveDestination {
165    pub launch_args: Vec<String>,
166    pub runtime: TargetRuntimeSettings,
167    pub state: PreparedDestinationState,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "phase", rename_all = "snake_case", deny_unknown_fields)]
172pub enum PreparedDestinationState {
173    LaunchPending,
174    Created { instance_id: String },
175    Checked { target: TargetLocator },
176    Adopted { target: TargetLocator },
177    CleanupPending { instance_id: Option<String> },
178    Released,
179}
180
181impl PreparedMoveDestination {
182    pub fn target(&self) -> Option<&TargetLocator> {
183        match &self.state {
184            PreparedDestinationState::Checked { target }
185            | PreparedDestinationState::Adopted { target } => Some(target),
186            _ => None,
187        }
188    }
189
190    pub fn instance_id(&self) -> Option<&str> {
191        match &self.state {
192            PreparedDestinationState::Created { instance_id }
193            | PreparedDestinationState::CleanupPending {
194                instance_id: Some(instance_id),
195            } => Some(instance_id),
196            _ => match self.target() {
197                Some(TargetLocator::AwsEc2 { instance_id, .. }) => Some(instance_id),
198                _ => None,
199            },
200        }
201    }
202
203    pub fn owns_resource(&self) -> bool {
204        !matches!(self.state, PreparedDestinationState::Released)
205    }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct MovePreparation {
211    #[serde(default)]
212    pub destination_checks: DestinationChecks,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub workspace: Option<crate::move_workspace::WorkspaceAssessment>,
215    #[serde(default)]
216    pub source_unavailable: bool,
217    /// The destination is the source target: only the harness is replaced;
218    /// the container or worker root and the workspace are kept.
219    #[serde(default)]
220    pub in_place: bool,
221    /// Present only when this move converts a local checkout into an isolated
222    /// workspace. Boxed because this preparation travels inside several
223    /// request enums whose other variants are far smaller.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub conversion: Option<Box<RawConversionPreview>>,
226    pub selection: MoveSelection,
227    pub source_profile_id: String,
228    pub source_target_template_id: String,
229    pub cross_harness: bool,
230    pub active: bool,
231    pub queued_commands: Vec<MaterializedQueuedPrompt>,
232    pub fingerprint: String,
233    pub operation_id: String,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct MoveSessionRequest {
239    pub preparation: MovePreparation,
240    pub queue: Option<ResumeQueueDisposition>,
241    pub acknowledge_interruption: bool,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct MoveOutcome {
247    pub operation_id: String,
248    pub session_id: String,
249    pub profile_id: String,
250    pub target_template_id: String,
251    pub outcome: String,
252    pub error: Option<String>,
253    pub recovery: Option<String>,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum MovePhase {
259    Preparing,
260    ClosingSource,
261    ResumingDestination,
262    StartingQueue,
263    Completed,
264    Failed,
265    Cancelled,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct MoveOperation {
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub prepared_destination: Option<PreparedMoveDestination>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub accepted_preparation: Option<Box<MovePreparation>>,
275    #[serde(default)]
276    pub acknowledge_interruption: bool,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub workspace_transfer: Option<crate::move_workspace::WorkspaceTransfer>,
279    /// Move-owned session state, never a portable workspace checkpoint.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub handoff: Option<CheckpointMetadata>,
282    /// Keep the source harness stopped across recovery until destination restoration.
283    #[serde(default)]
284    pub source_checkpoint_only: bool,
285    /// The destination is the source target: only the harness is replaced;
286    /// the container or worker root and the workspace are kept.
287    #[serde(default)]
288    pub in_place: bool,
289    pub operation_id: String,
290    pub selection: MoveSelection,
291    pub source_profile_id: String,
292    pub source_target_template_id: String,
293    pub source_target: Option<TargetLocator>,
294    pub source_native_session_id: Option<String>,
295    pub source_additional_mounts: Vec<AdditionalMount>,
296    pub source_resource_allocation: Option<SessionResourceAllocation>,
297    pub destination_target: Option<TargetLocator>,
298    pub destination_native_session_id: Option<String>,
299    pub destination_store_id: Option<String>,
300    pub configuration_fingerprint: String,
301    pub checkpoint: Option<CheckpointMetadata>,
302    /// Stopped identity retained across partially written resume conversions.
303    pub recovery_session: Option<SessionRecord>,
304    pub queue: ResumeQueueDisposition,
305    pub phase: MovePhase,
306    /// A durable boundary: once set, never restore or replay on another relay.
307    pub queue_admission_started: bool,
308    pub queue_admission_finished: bool,
309    pub cancellation_requested: bool,
310    pub created_at: String,
311    pub updated_at: String,
312    pub error: Option<String>,
313}
314
315impl MoveOperation {
316    pub fn retains_source_environment(&self) -> bool {
317        self.in_place || self.workspace_transfer.is_some()
318    }
319    pub fn restore_artifact(&self) -> Option<&CheckpointMetadata> {
320        self.handoff.as_ref().or(self.checkpoint.as_ref())
321    }
322
323    pub fn retains_checkpoint(&self) -> bool {
324        (self.handoff.is_some() && self.phase == MovePhase::Cancelled)
325            || !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
326            || (self.queue_admission_started && !self.queue_admission_finished)
327    }
328
329    /// Every checkpoint archive this Move keeps on disk: its handoff and the
330    /// source's last full checkpoint. The startup archive sweep and the
331    /// superseded-checkpoint prune both ask this one question, so neither can
332    /// delete an archive the Move still restores from.
333    pub fn retained_archives(&self) -> impl Iterator<Item = &CheckpointMetadata> {
334        self.retains_checkpoint()
335            .then_some([self.handoff.as_ref(), self.checkpoint.as_ref()])
336            .into_iter()
337            .flatten()
338            .flatten()
339    }
340
341    /// Whether an unfinished Move still has the checkpoint a retry restores.
342    ///
343    /// The Move record owns this fact. When its archive is found missing, the
344    /// reference is removed from the record, so the recovery guidance, the API
345    /// and the retry admission all read the loss from here.
346    pub fn checkpoint_retained(&self) -> bool {
347        self.phase != MovePhase::Completed && self.restore_artifact().is_some()
348    }
349
350    /// Whether a surface should offer to retry this failed or cancelled Move:
351    /// it still has its checkpoint, or queued work already began on the
352    /// destination and only this Move may finish admitting it.
353    pub fn offers_retry(&self) -> bool {
354        matches!(self.phase, MovePhase::Failed | MovePhase::Cancelled)
355            && (self.checkpoint_retained()
356                || (self.queue_admission_started && !self.queue_admission_finished))
357    }
358
359    /// Whether this unfinished Move holds the source environment (the target,
360    /// its worker root and the checkout) for an explicit retry. While it does,
361    /// Resume would recreate what the Move promised to keep, so only a retried
362    /// Move or Destroy may act on the session.
363    pub fn holds_source_environment(&self) -> bool {
364        self.retains_source_environment()
365            && self.phase != MovePhase::Completed
366            && self.recovery_session.is_some()
367    }
368
369    pub fn is_active(&self) -> bool {
370        matches!(
371            self.phase,
372            MovePhase::Preparing
373                | MovePhase::ClosingSource
374                | MovePhase::ResumingDestination
375                | MovePhase::StartingQueue
376        )
377    }
378}