1use 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 clear_resource_allocation: bool,
17 pub session_id: String,
18 pub profile_id: Option<String>,
19 pub target_template_id: Option<String>,
20 pub additional_mounts: Option<Vec<AdditionalMount>>,
21 pub resource_allocation: Option<SessionResourceAllocation>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct RawConversionPreview {
33 pub checkout: PathBuf,
36 pub destination: PathBuf,
38 pub branch: Option<String>,
40 pub fetch_url: String,
41 pub push_urls: Vec<String>,
42 pub default_branch: String,
45 pub unpushed_commits: u64,
48 pub staged_files: u64,
49 pub unstaged_files: u64,
50 pub untracked_files: u64,
51 pub untracked_bytes: u64,
52 pub host_checkout_retained: bool,
55}
56
57impl RawConversionPreview {
58 pub fn summary_line(&self) -> String {
61 let push = if self.push_urls.is_empty() {
62 self.fetch_url.clone()
63 } else {
64 self.push_urls.join(", ")
65 };
66 format!(
67 "Clone {} (default branch {}) into {} on branch {}; push to {push}.",
68 self.fetch_url,
69 self.default_branch,
70 self.destination.display(),
71 self.branch.as_deref().unwrap_or("a detached head"),
72 )
73 }
74
75 pub fn warning_lines(&self) -> Vec<String> {
81 let mut lines = Vec::new();
82 let dirty = self.staged_files + self.unstaged_files + self.untracked_files;
83 if dirty > 0 {
84 lines.push(format!(
85 "{} staged, {} unstaged, and {} untracked {} ({}) will be copied into the container. \
86 Ignored files such as build output, .env, and node_modules will not.",
87 self.staged_files,
88 self.unstaged_files,
89 self.untracked_files,
90 if dirty == 1 { "file" } else { "files" },
91 format_conversion_bytes(self.untracked_bytes),
92 ));
93 }
94 if self.unpushed_commits > 0 {
95 let mut line = format!(
96 "{} {} not on {} {} in the checkpoint.",
97 self.unpushed_commits,
98 if self.unpushed_commits == 1 {
99 "commit"
100 } else {
101 "commits"
102 },
103 self.fetch_url,
104 if self.unpushed_commits == 1 {
105 "travels"
106 } else {
107 "travel"
108 },
109 );
110 if self.unpushed_commits > 200 {
111 line.push_str(" That is a large history; consider pushing first.");
112 }
113 lines.push(line);
114 }
115 if self.host_checkout_retained {
116 lines.push(format!(
117 "{} stays on this machine and will no longer track this session. \
118 Edits made in the container do not come back automatically; \
119 push the branch or move the session back.",
120 self.checkout.display(),
121 ));
122 }
123 lines
124 }
125}
126
127fn format_conversion_bytes(bytes: u64) -> String {
131 const KB: f64 = 1024.0;
132 const MB: f64 = KB * 1024.0;
133 let bytes = bytes as f64;
134 if bytes < MB {
135 format!("{:.1} KB", bytes / KB)
136 } else {
137 format!("{:.1} MB", bytes / MB)
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct MovePreparation {
144 #[serde(default)]
145 pub source_unavailable: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub conversion: Option<Box<RawConversionPreview>>,
151 pub selection: MoveSelection,
152 pub source_profile_id: String,
153 pub source_target_template_id: String,
154 pub cross_harness: bool,
155 pub active: bool,
156 pub queued_commands: Vec<MaterializedQueuedPrompt>,
157 pub fingerprint: String,
158 pub operation_id: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct MoveSessionRequest {
164 pub preparation: MovePreparation,
165 pub queue: Option<ResumeQueueDisposition>,
166 pub acknowledge_interruption: bool,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct MoveOutcome {
172 pub operation_id: String,
173 pub session_id: String,
174 pub profile_id: String,
175 pub target_template_id: String,
176 pub outcome: String,
177 pub error: Option<String>,
178 pub recovery: Option<String>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum MovePhase {
184 Preparing,
185 ClosingSource,
186 ResumingDestination,
187 StartingQueue,
188 Completed,
189 Failed,
190 Cancelled,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct MoveOperation {
196 #[serde(default)]
198 pub source_checkpoint_only: bool,
199 pub operation_id: String,
200 pub selection: MoveSelection,
201 pub source_profile_id: String,
202 pub source_target_template_id: String,
203 pub source_target: Option<TargetLocator>,
204 pub source_native_session_id: Option<String>,
205 pub source_additional_mounts: Vec<AdditionalMount>,
206 pub source_resource_allocation: Option<SessionResourceAllocation>,
207 pub destination_target: Option<TargetLocator>,
208 pub destination_native_session_id: Option<String>,
209 pub destination_store_id: Option<String>,
210 pub configuration_fingerprint: String,
211 pub checkpoint: Option<CheckpointMetadata>,
212 pub recovery_session: Option<SessionRecord>,
214 pub queue: ResumeQueueDisposition,
215 pub phase: MovePhase,
216 pub queue_admission_started: bool,
218 pub queue_admission_finished: bool,
219 pub cancellation_requested: bool,
220 pub created_at: String,
221 pub updated_at: String,
222 pub error: Option<String>,
223}
224
225impl MoveOperation {
226 pub fn retains_checkpoint(&self) -> bool {
227 !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
228 || (self.queue_admission_started && !self.queue_admission_finished)
229 }
230
231 pub fn is_active(&self) -> bool {
232 matches!(
233 self.phase,
234 MovePhase::Preparing
235 | MovePhase::ClosingSource
236 | MovePhase::ResumingDestination
237 | MovePhase::StartingQueue
238 )
239 }
240}