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
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct MovePreparation {
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub workspace: Option<crate::move_workspace::WorkspaceAssessment>,
155    #[serde(default)]
156    pub source_unavailable: bool,
157    /// The destination is the source target: only the harness is replaced;
158    /// the container or worker root and the workspace are kept.
159    #[serde(default)]
160    pub in_place: bool,
161    /// Present only when this move converts a local checkout into an isolated
162    /// workspace. Boxed because this preparation travels inside several
163    /// request enums whose other variants are far smaller.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub conversion: Option<Box<RawConversionPreview>>,
166    pub selection: MoveSelection,
167    pub source_profile_id: String,
168    pub source_target_template_id: String,
169    pub cross_harness: bool,
170    pub active: bool,
171    pub queued_commands: Vec<MaterializedQueuedPrompt>,
172    pub fingerprint: String,
173    pub operation_id: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct MoveSessionRequest {
179    pub preparation: MovePreparation,
180    pub queue: Option<ResumeQueueDisposition>,
181    pub acknowledge_interruption: bool,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct MoveOutcome {
187    pub operation_id: String,
188    pub session_id: String,
189    pub profile_id: String,
190    pub target_template_id: String,
191    pub outcome: String,
192    pub error: Option<String>,
193    pub recovery: Option<String>,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum MovePhase {
199    Preparing,
200    ClosingSource,
201    ResumingDestination,
202    StartingQueue,
203    Completed,
204    Failed,
205    Cancelled,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct MoveOperation {
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub workspace_transfer: Option<crate::move_workspace::WorkspaceTransfer>,
213    /// Move-owned session state, never a portable workspace checkpoint.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub handoff: Option<CheckpointMetadata>,
216    /// Keep the source harness stopped across recovery until destination restoration.
217    #[serde(default)]
218    pub source_checkpoint_only: bool,
219    /// The destination is the source target: only the harness is replaced;
220    /// the container or worker root and the workspace are kept.
221    #[serde(default)]
222    pub in_place: bool,
223    pub operation_id: String,
224    pub selection: MoveSelection,
225    pub source_profile_id: String,
226    pub source_target_template_id: String,
227    pub source_target: Option<TargetLocator>,
228    pub source_native_session_id: Option<String>,
229    pub source_additional_mounts: Vec<AdditionalMount>,
230    pub source_resource_allocation: Option<SessionResourceAllocation>,
231    pub destination_target: Option<TargetLocator>,
232    pub destination_native_session_id: Option<String>,
233    pub destination_store_id: Option<String>,
234    pub configuration_fingerprint: String,
235    pub checkpoint: Option<CheckpointMetadata>,
236    /// Stopped identity retained across partially written resume conversions.
237    pub recovery_session: Option<SessionRecord>,
238    pub queue: ResumeQueueDisposition,
239    pub phase: MovePhase,
240    /// A durable boundary: once set, never restore or replay on another relay.
241    pub queue_admission_started: bool,
242    pub queue_admission_finished: bool,
243    pub cancellation_requested: bool,
244    pub created_at: String,
245    pub updated_at: String,
246    pub error: Option<String>,
247}
248
249impl MoveOperation {
250    pub fn retains_source_environment(&self) -> bool {
251        self.in_place || self.workspace_transfer.is_some()
252    }
253    pub fn restore_artifact(&self) -> Option<&CheckpointMetadata> {
254        self.handoff.as_ref().or(self.checkpoint.as_ref())
255    }
256
257    pub fn retains_checkpoint(&self) -> bool {
258        (self.handoff.is_some() && self.phase == MovePhase::Cancelled)
259            || !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
260            || (self.queue_admission_started && !self.queue_admission_finished)
261    }
262
263    /// Every checkpoint archive this Move keeps on disk: its handoff and the
264    /// source's last full checkpoint. The startup archive sweep and the
265    /// superseded-checkpoint prune both ask this one question, so neither can
266    /// delete an archive the Move still restores from.
267    pub fn retained_archives(&self) -> impl Iterator<Item = &CheckpointMetadata> {
268        self.retains_checkpoint()
269            .then_some([self.handoff.as_ref(), self.checkpoint.as_ref()])
270            .into_iter()
271            .flatten()
272            .flatten()
273    }
274
275    /// Whether an unfinished Move still has the checkpoint a retry restores.
276    ///
277    /// The Move record owns this fact. When its archive is found missing, the
278    /// reference is removed from the record, so the recovery guidance, the API
279    /// and the retry admission all read the loss from here.
280    pub fn checkpoint_retained(&self) -> bool {
281        self.phase != MovePhase::Completed && self.restore_artifact().is_some()
282    }
283
284    /// Whether a surface should offer to retry this failed or cancelled Move:
285    /// it still has its checkpoint, or queued work already began on the
286    /// destination and only this Move may finish admitting it.
287    pub fn offers_retry(&self) -> bool {
288        matches!(self.phase, MovePhase::Failed | MovePhase::Cancelled)
289            && (self.checkpoint_retained()
290                || (self.queue_admission_started && !self.queue_admission_finished))
291    }
292
293    /// Whether this unfinished Move holds the source environment (the target,
294    /// its worker root and the checkout) for an explicit retry. While it does,
295    /// Resume would recreate what the Move promised to keep, so only a retried
296    /// Move or Destroy may act on the session.
297    pub fn holds_source_environment(&self) -> bool {
298        self.retains_source_environment()
299            && self.phase != MovePhase::Completed
300            && self.recovery_session.is_some()
301    }
302
303    pub fn is_active(&self) -> bool {
304        matches!(
305            self.phase,
306            MovePhase::Preparing
307                | MovePhase::ClosingSource
308                | MovePhase::ResumingDestination
309                | MovePhase::StartingQueue
310        )
311    }
312}