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 workspace: crate::move_workspace::WorkspaceSelection,
17 #[serde(default)]
18 pub clear_resource_allocation: bool,
19 pub session_id: String,
20 pub profile_id: Option<String>,
21 pub target_template_id: Option<String>,
22 pub additional_mounts: Option<Vec<AdditionalMount>>,
23 pub resource_allocation: Option<SessionResourceAllocation>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub subagents: Option<crate::subagent::SubagentPolicy>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct RawConversionPreview {
38 pub checkout: PathBuf,
41 pub destination: PathBuf,
43 pub branch: Option<String>,
45 pub fetch_url: String,
46 pub push_urls: Vec<String>,
47 pub default_branch: String,
50 pub unpushed_commits: u64,
53 pub staged_files: u64,
54 pub unstaged_files: u64,
55 pub untracked_files: u64,
56 pub untracked_bytes: u64,
57 pub host_checkout_retained: bool,
60}
61
62impl RawConversionPreview {
63 pub fn summary_line(&self) -> String {
66 let push = if self.push_urls.is_empty() {
67 self.fetch_url.clone()
68 } else {
69 self.push_urls.join(", ")
70 };
71 format!(
72 "Clone {} (default branch {}) into {} on branch {}; push to {push}.",
73 self.fetch_url,
74 self.default_branch,
75 self.destination.display(),
76 self.branch.as_deref().unwrap_or("a detached head"),
77 )
78 }
79
80 pub fn warning_lines(&self) -> Vec<String> {
86 let mut lines = Vec::new();
87 let dirty = self.staged_files + self.unstaged_files + self.untracked_files;
88 if dirty > 0 {
89 lines.push(format!(
90 "{} staged, {} unstaged, and {} untracked {} ({}) will be copied into the container. \
91 Ignored files such as build output, .env, and node_modules will not.",
92 self.staged_files,
93 self.unstaged_files,
94 self.untracked_files,
95 if dirty == 1 { "file" } else { "files" },
96 format_conversion_bytes(self.untracked_bytes),
97 ));
98 }
99 if self.unpushed_commits > 0 {
100 let mut line = format!(
101 "{} {} not on {} {} in the checkpoint.",
102 self.unpushed_commits,
103 if self.unpushed_commits == 1 {
104 "commit"
105 } else {
106 "commits"
107 },
108 self.fetch_url,
109 if self.unpushed_commits == 1 {
110 "travels"
111 } else {
112 "travel"
113 },
114 );
115 if self.unpushed_commits > 200 {
116 line.push_str(" That is a large history; consider pushing first.");
117 }
118 lines.push(line);
119 }
120 if self.host_checkout_retained {
121 lines.push(format!(
122 "{} stays on this machine and will no longer track this session. \
123 Edits made in the container do not come back automatically; \
124 push the branch or move the session back.",
125 self.checkout.display(),
126 ));
127 }
128 lines
129 }
130}
131
132fn format_conversion_bytes(bytes: u64) -> String {
136 const KB: f64 = 1024.0;
137 const MB: f64 = KB * 1024.0;
138 let bytes = bytes as f64;
139 if bytes < MB {
140 format!("{:.1} KB", bytes / KB)
141 } else {
142 format!("{:.1} MB", bytes / MB)
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct MovePreparation {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub workspace: Option<crate::move_workspace::WorkspaceAssessment>,
151 #[serde(default)]
152 pub source_unavailable: bool,
153 #[serde(default)]
156 pub in_place: bool,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub conversion: Option<Box<RawConversionPreview>>,
162 pub selection: MoveSelection,
163 pub source_profile_id: String,
164 pub source_target_template_id: String,
165 pub cross_harness: bool,
166 pub active: bool,
167 pub queued_commands: Vec<MaterializedQueuedPrompt>,
168 pub fingerprint: String,
169 pub operation_id: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct MoveSessionRequest {
175 pub preparation: MovePreparation,
176 pub queue: Option<ResumeQueueDisposition>,
177 pub acknowledge_interruption: bool,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct MoveOutcome {
183 pub operation_id: String,
184 pub session_id: String,
185 pub profile_id: String,
186 pub target_template_id: String,
187 pub outcome: String,
188 pub error: Option<String>,
189 pub recovery: Option<String>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum MovePhase {
195 Preparing,
196 ClosingSource,
197 ResumingDestination,
198 StartingQueue,
199 Completed,
200 Failed,
201 Cancelled,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct MoveOperation {
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub workspace_transfer: Option<crate::move_workspace::WorkspaceTransfer>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub handoff: Option<CheckpointMetadata>,
212 #[serde(default)]
214 pub source_checkpoint_only: bool,
215 #[serde(default)]
218 pub in_place: bool,
219 pub operation_id: String,
220 pub selection: MoveSelection,
221 pub source_profile_id: String,
222 pub source_target_template_id: String,
223 pub source_target: Option<TargetLocator>,
224 pub source_native_session_id: Option<String>,
225 pub source_additional_mounts: Vec<AdditionalMount>,
226 pub source_resource_allocation: Option<SessionResourceAllocation>,
227 pub destination_target: Option<TargetLocator>,
228 pub destination_native_session_id: Option<String>,
229 pub destination_store_id: Option<String>,
230 pub configuration_fingerprint: String,
231 pub checkpoint: Option<CheckpointMetadata>,
232 pub recovery_session: Option<SessionRecord>,
234 pub queue: ResumeQueueDisposition,
235 pub phase: MovePhase,
236 pub queue_admission_started: bool,
238 pub queue_admission_finished: bool,
239 pub cancellation_requested: bool,
240 pub created_at: String,
241 pub updated_at: String,
242 pub error: Option<String>,
243}
244
245impl MoveOperation {
246 pub fn retains_source_environment(&self) -> bool {
247 self.in_place || self.workspace_transfer.is_some()
248 }
249 pub fn restore_artifact(&self) -> Option<&CheckpointMetadata> {
250 self.handoff.as_ref().or(self.checkpoint.as_ref())
251 }
252
253 pub fn retains_checkpoint(&self) -> bool {
254 (self.handoff.is_some() && self.phase == MovePhase::Cancelled)
255 || !matches!(self.phase, MovePhase::Completed | MovePhase::Cancelled)
256 || (self.queue_admission_started && !self.queue_admission_finished)
257 }
258
259 pub fn is_active(&self) -> bool {
260 matches!(
261 self.phase,
262 MovePhase::Preparing
263 | MovePhase::ClosingSource
264 | MovePhase::ResumingDestination
265 | MovePhase::StartingQueue
266 )
267 }
268}