1use super::*;
4
5pub 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 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub subagents: Option<crate::subagent::SubagentPolicy>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct RawConversionPreview {
42 pub checkout: PathBuf,
45 pub destination: PathBuf,
47 pub branch: Option<String>,
49 pub fetch_url: String,
50 pub push_urls: Vec<String>,
51 pub default_branch: String,
54 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 pub host_checkout_retained: bool,
64}
65
66impl RawConversionPreview {
67 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 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
136fn 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 #[serde(default)]
160 pub in_place: bool,
161 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub handoff: Option<CheckpointMetadata>,
216 #[serde(default)]
218 pub source_checkpoint_only: bool,
219 #[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 pub recovery_session: Option<SessionRecord>,
238 pub queue: ResumeQueueDisposition,
239 pub phase: MovePhase,
240 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 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 pub fn checkpoint_retained(&self) -> bool {
281 self.phase != MovePhase::Completed && self.restore_artifact().is_some()
282 }
283
284 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 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}