1use super::*;
4use mj_core::state::MoveOperation;
5
6pub fn save_move_operation(operation: &MoveOperation) -> Result<()> {
7 let operation = operation.clone();
8 submit_database_write("save_move_operation", move |connection| {
9 save_move_operation_with(connection, &operation)
10 })
11}
12
13pub(super) fn save_move_operation_with(
14 connection: &Connection,
15 operation: &MoveOperation,
16) -> Result<()> {
17 connection.execute(
18 "INSERT INTO session_moves(session_id, operation_id, operation_json) VALUES (?1, ?2, ?3)
19 ON CONFLICT(session_id) DO UPDATE SET operation_id=excluded.operation_id,
20 operation_json=CASE WHEN session_moves.operation_id=excluded.operation_id
21 AND json_extract(session_moves.operation_json, '$.cancellation_requested')=1
22 THEN json_set(excluded.operation_json, '$.cancellation_requested', json('true'))
23 ELSE excluded.operation_json END",
24 params![
25 operation.selection.session_id,
26 operation.operation_id,
27 serde_json::to_string(operation)?
28 ],
29 )?;
30 Ok(())
31}
32
33pub fn load_move_operation(session_id: &str) -> Result<Option<MoveOperation>> {
34 let connection = open_reader(&database_path())?;
35 load_move_operation_with(&connection, session_id)
36}
37
38pub(super) fn load_move_operation_with(
39 connection: &Connection,
40 session_id: &str,
41) -> Result<Option<MoveOperation>> {
42 let json: Option<String> = connection
43 .query_row(
44 "SELECT operation_json FROM session_moves WHERE session_id=?1",
45 [session_id],
46 |row| row.get(0),
47 )
48 .optional()?;
49 json.map(|json| serde_json::from_str(&json).context("decode durable move intent"))
50 .transpose()
51}
52
53pub fn load_move_operations() -> Result<Vec<MoveOperation>> {
54 let connection = open_reader(&database_path())?;
55 let mut statement =
56 connection.prepare("SELECT operation_json FROM session_moves ORDER BY session_id")?;
57 statement
58 .query_map([], |row| row.get::<_, String>(0))?
59 .map(|row| serde_json::from_str(&row?).context("decode durable move intent"))
60 .collect()
61}
62
63pub fn move_checkpoint_is_retained(path: &Path) -> Result<bool> {
64 Ok(load_move_operations()?.iter().any(|operation| {
65 operation.retains_checkpoint()
66 && operation
67 .checkpoint
68 .as_ref()
69 .is_some_and(|checkpoint| checkpoint.archive_path == path)
70 }))
71}
72
73pub fn request_move_cancellation(session_id: &str) -> Result<()> {
74 let session_id = session_id.to_owned();
75 submit_database_write("request_move_cancellation", move |connection| {
76 connection.execute(
77 "UPDATE session_moves SET operation_json=json_set(operation_json, '$.cancellation_requested', json('true'))
78 WHERE session_id=?1 AND json_extract(operation_json, '$.phase') IN ('preparing','closing_source','resuming_destination','starting_queue')",
79 [session_id],
80 )?;
81 Ok(())
82 })
83}
84
85pub fn clear_move_cancellation_for_retry(session_id: &str) -> Result<()> {
86 let session_id = session_id.to_owned();
87 submit_database_write("clear_move_cancellation_for_retry", move |connection| {
88 connection.execute(
89 "UPDATE session_moves SET operation_json=json_set(operation_json, '$.cancellation_requested', json('false')) WHERE session_id=?1",
90 [session_id],
91 )?;
92 Ok(())
93 })
94}
95
96pub fn move_pending_work(session_id: &str) -> Result<(bool, Vec<MaterializedQueuedPrompt>)> {
98 let connection = open_reader(&database_path())?;
99 let running: Option<String> = connection
100 .query_row(
101 "SELECT execution_state FROM materialized_sessions WHERE session_id=?1",
102 [session_id],
103 |row| row.get(0),
104 )
105 .optional()?;
106 Ok((
107 running.as_deref() == Some("running"),
108 read_materialized_queued_prompts(&connection, session_id)?,
109 ))
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use mj_core::state::{MovePhase, MoveSelection, ResumeQueueDisposition};
116
117 fn operation(session: &SessionRecord) -> MoveOperation {
118 MoveOperation {
119 source_checkpoint_only: false,
120 operation_id: "move-one".into(),
121 selection: MoveSelection {
122 clear_resource_allocation: false,
123 session_id: session.id.clone(),
124 profile_id: Some("destination".into()),
125 target_template_id: Some("local".into()),
126 additional_mounts: Some(Vec::new()),
127 resource_allocation: None,
128 },
129 source_profile_id: session.last_profile.clone(),
130 source_target_template_id: session.target_template_id.clone(),
131 source_target: session.target.clone(),
132 source_native_session_id: session.native_session_id.clone(),
133 source_additional_mounts: session.additional_mounts.clone(),
134 source_resource_allocation: session.resource_allocation.clone(),
135 destination_target: None,
136 destination_native_session_id: None,
137 destination_store_id: None,
138 configuration_fingerprint: "fingerprint".into(),
139 checkpoint: session.checkpoint.clone(),
140 recovery_session: Some(session.clone()),
141 queue: ResumeQueueDisposition::Start,
142 phase: MovePhase::Preparing,
143 queue_admission_started: false,
144 queue_admission_finished: false,
145 cancellation_requested: false,
146 created_at: session.created_at.clone(),
147 updated_at: session.updated_at.clone(),
148 error: None,
149 }
150 }
151
152 #[test]
153 fn move_boundaries_survive_database_reopen_and_retain_the_source_locator() {
154 let directory = tempfile::tempdir().unwrap();
155 let path = directory.path().join("mj.sqlite3");
156 let session = super::super::tests::session("move-reopen", "project");
157 save_session_to(&path, &session).unwrap();
158 let mut intent = operation(&session);
159 for phase in [
160 MovePhase::Preparing,
161 MovePhase::ClosingSource,
162 MovePhase::ResumingDestination,
163 MovePhase::StartingQueue,
164 MovePhase::Failed,
165 MovePhase::Completed,
166 ] {
167 intent.phase = phase;
168 if phase == MovePhase::StartingQueue {
169 intent.queue_admission_started = true;
170 intent.destination_target = session.target.clone();
171 intent.destination_store_id = Some("durable-destination".into());
172 }
173 if phase == MovePhase::Completed {
174 intent.queue_admission_finished = true;
175 }
176 let connection = open(&path).unwrap();
177 save_move_operation_with(&connection, &intent).unwrap();
178 drop(connection);
179 let reopened = open_reader(&path).unwrap();
180 let restored = load_move_operation_with(&reopened, &session.id)
181 .unwrap()
182 .unwrap();
183 assert_eq!(restored, intent);
184 assert_eq!(restored.source_target, session.target);
185 assert_eq!(restored.retains_checkpoint(), phase != MovePhase::Completed);
186 }
187 }
188
189 #[test]
190 fn concurrent_phase_save_cannot_erase_durable_cancellation() {
191 let directory = tempfile::tempdir().unwrap();
192 let path = directory.path().join("mj.sqlite3");
193 let session = super::super::tests::session("move-cancel", "project");
194 save_session_to(&path, &session).unwrap();
195 let connection = open(&path).unwrap();
196 let mut intent = operation(&session);
197 save_move_operation_with(&connection, &intent).unwrap();
198 let mut cancelled = intent.clone();
199 cancelled.cancellation_requested = true;
200 save_move_operation_with(&connection, &cancelled).unwrap();
201 intent.phase = MovePhase::ClosingSource;
202 save_move_operation_with(&connection, &intent).unwrap();
203 let restored = load_move_operation_with(&connection, &session.id)
204 .unwrap()
205 .unwrap();
206 assert!(restored.cancellation_requested);
207 assert_eq!(restored.phase, MovePhase::ClosingSource);
208 intent.operation_id = "explicit-new-operation".into();
209 save_move_operation_with(&connection, &intent).unwrap();
210 assert!(
211 !load_move_operation_with(&connection, &session.id)
212 .unwrap()
213 .unwrap()
214 .cancellation_requested
215 );
216 }
217
218 #[test]
219 fn cancelling_partial_queue_admission_does_not_release_its_archive() {
220 let session = super::super::tests::session("move-queue", "project");
221 let mut intent = operation(&session);
222 intent.phase = MovePhase::Cancelled;
223 intent.queue_admission_started = true;
224 assert!(intent.retains_checkpoint());
225 intent.queue_admission_finished = true;
226 assert!(!intent.retains_checkpoint());
227 }
228
229 #[test]
230 fn destination_record_install_keeps_drafts_and_titles_edited_during_move() {
231 let directory = tempfile::tempdir().unwrap();
232 let path = directory.path().join("mj.sqlite3");
233 let mut stale = super::super::tests::session("move-draft", "project");
234 save_session_to(&path, &stale).unwrap();
235 let connection = open(&path).unwrap();
236 save_move_operation_with(&connection, &operation(&stale)).unwrap();
237 connection.execute("UPDATE sessions SET draft_input='keep this draft', session_title_override='new title' WHERE session_id=?1", [&stale.id]).unwrap();
238 stale.last_profile = "destination".into();
239 stale.state = SessionState::Provisioning;
240 save_session_to(&path, &stale).unwrap();
241 let current = load_state_from(&path)
242 .unwrap()
243 .sessions
244 .remove(&stale.id)
245 .unwrap();
246 assert_eq!(current.draft_input, "keep this draft");
247 assert_eq!(current.session_title_override.as_deref(), Some("new title"));
248 assert_eq!(current.last_profile, "destination");
249 }
250}