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
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#[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 #[serde(default)]
220 pub in_place: bool,
221 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub handoff: Option<CheckpointMetadata>,
282 #[serde(default)]
284 pub source_checkpoint_only: bool,
285 #[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 pub recovery_session: Option<SessionRecord>,
304 pub queue: ResumeQueueDisposition,
305 pub phase: MovePhase,
306 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 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 pub fn checkpoint_retained(&self) -> bool {
347 self.phase != MovePhase::Completed && self.restore_artifact().is_some()
348 }
349
350 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 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}