1use super::*;
2
3#[derive(Debug, Clone)]
5pub struct InterruptedTurn {
6 pub chat_id: i64,
7 pub channel: String,
8 pub chat_type: String,
9 pub started_at: String,
10 pub progress_text: Option<String>,
13 pub phase: String,
15 pub iteration: i64,
17 pub session_json: Option<String>,
19 pub resumable: bool,
21 pub tool_summary: Option<String>,
23 pub last_checkpoint_at: Option<String>,
24 pub run_id: Option<String>,
25}
26
27pub(crate) fn map_interrupted_turn(row: &rusqlite::Row<'_>) -> rusqlite::Result<InterruptedTurn> {
28 Ok(InterruptedTurn {
29 chat_id: row.get(0)?,
30 channel: row.get(1)?,
31 chat_type: row.get(2)?,
32 started_at: row.get(3)?,
33 progress_text: row.get(4)?,
34 phase: row.get(5)?,
35 iteration: row.get(6)?,
36 session_json: row.get(7)?,
37 resumable: row.get::<_, i64>(8)? != 0,
38 tool_summary: row.get(9)?,
39 last_checkpoint_at: row.get(10)?,
40 run_id: row.get(11)?,
41 })
42}
43
44impl Database {
45 pub fn mark_turn_active(&self, chat_id: i64, channel: &str) -> Result<(), MicroClawError> {
49 self.mark_turn_active_with_context(chat_id, channel, "private", None)
50 }
51
52 pub fn mark_turn_active_with_context(
55 &self,
56 chat_id: i64,
57 channel: &str,
58 chat_type: &str,
59 run_id: Option<&str>,
60 ) -> Result<(), MicroClawError> {
61 let conn = self.lock_conn();
62 let now = chrono::Utc::now().to_rfc3339();
63 conn.execute(
64 "INSERT INTO active_turns (
65 chat_id, channel, chat_type, started_at, progress_text,
66 phase, iteration, session_json, resumable, tool_summary,
67 last_checkpoint_at, run_id
68 )
69 VALUES (?1, ?2, ?3, ?4, NULL, 'starting', 0, NULL, 0, NULL, ?4, ?5)
70 ON CONFLICT(chat_id) DO UPDATE SET
71 channel = ?2,
72 chat_type = ?3,
73 started_at = ?4,
74 progress_text = NULL,
75 phase = 'starting',
76 iteration = 0,
77 session_json = NULL,
78 resumable = 0,
79 tool_summary = NULL,
80 last_checkpoint_at = ?4,
81 run_id = ?5",
82 params![chat_id, channel, chat_type, now, run_id],
83 )?;
84 Ok(())
85 }
86
87 pub fn mark_turn_recovery_started(
91 &self,
92 chat_id: i64,
93 run_id: &str,
94 ) -> Result<(), MicroClawError> {
95 let conn = self.lock_conn();
96 conn.execute(
97 "UPDATE active_turns
98 SET phase = 'resuming', run_id = ?2
99 WHERE chat_id = ?1",
100 params![chat_id, run_id],
101 )?;
102 Ok(())
103 }
104
105 pub fn update_turn_progress(&self, chat_id: i64, progress: &str) -> Result<(), MicroClawError> {
108 let conn = self.lock_conn();
109 conn.execute(
110 "UPDATE active_turns SET progress_text = ?2 WHERE chat_id = ?1",
111 params![chat_id, progress],
112 )?;
113 Ok(())
114 }
115
116 #[allow(clippy::too_many_arguments)]
123 pub fn checkpoint_active_turn(
124 &self,
125 chat_id: i64,
126 phase: &str,
127 iteration: i64,
128 session_json: Option<&str>,
129 resumable: bool,
130 progress_text: Option<&str>,
131 tool_summary: Option<&str>,
132 ) -> Result<(), MicroClawError> {
133 let conn = self.lock_conn();
134 let now = chrono::Utc::now().to_rfc3339();
135 conn.execute(
136 "UPDATE active_turns
137 SET phase = ?2,
138 iteration = ?3,
139 session_json = COALESCE(?4, session_json),
140 resumable = ?5,
141 progress_text = COALESCE(?6, progress_text),
142 tool_summary = ?7,
143 last_checkpoint_at = ?8
144 WHERE chat_id = ?1",
145 params![
146 chat_id,
147 phase,
148 iteration,
149 session_json,
150 i64::from(resumable),
151 progress_text,
152 tool_summary,
153 now
154 ],
155 )?;
156 Ok(())
157 }
158
159 pub fn list_active_turns(&self) -> Result<Vec<InterruptedTurn>, MicroClawError> {
161 let conn = self.lock_conn();
162 let mut stmt = conn.prepare(
163 "SELECT chat_id, channel, chat_type, started_at, progress_text,
164 phase, iteration, session_json, resumable, tool_summary,
165 last_checkpoint_at, run_id
166 FROM active_turns ORDER BY started_at",
167 )?;
168 let rows = stmt
169 .query_map([], map_interrupted_turn)?
170 .collect::<Result<Vec<_>, _>>()?;
171 Ok(rows)
172 }
173
174 pub fn clear_turn_active(&self, chat_id: i64) -> Result<(), MicroClawError> {
176 let conn = self.lock_conn();
177 conn.execute(
178 "DELETE FROM active_turns WHERE chat_id = ?1",
179 params![chat_id],
180 )?;
181 Ok(())
182 }
183
184 pub fn take_interrupted_turns(&self) -> Result<Vec<InterruptedTurn>, MicroClawError> {
188 let mut conn = self.lock_conn();
189 let tx = conn.transaction()?;
190 let rows: Vec<InterruptedTurn> = {
191 let mut stmt = tx.prepare(
192 "SELECT chat_id, channel, chat_type, started_at, progress_text,
193 phase, iteration, session_json, resumable, tool_summary,
194 last_checkpoint_at, run_id
195 FROM active_turns ORDER BY started_at",
196 )?;
197 let collected = stmt
198 .query_map([], map_interrupted_turn)?
199 .collect::<Result<Vec<InterruptedTurn>, _>>()?;
200 collected
201 };
202 tx.execute("DELETE FROM active_turns", [])?;
203 tx.commit()?;
204 Ok(rows)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use crate::internal::storage::db::test_support::*;
211
212 #[test]
213 fn test_active_turns_mark_clear_and_take() {
214 let (db, dir) = test_db();
215
216 assert!(db.take_interrupted_turns().unwrap().is_empty());
218
219 db.mark_turn_active(10, "telegram").unwrap();
220 db.mark_turn_active(20, "discord").unwrap();
221 db.mark_turn_active(10, "telegram").unwrap();
223 db.update_turn_progress(10, "step 3: web_search, read_file")
225 .unwrap();
226 db.update_turn_progress(999, "ignored").unwrap();
227 db.clear_turn_active(20).unwrap();
229
230 let orphans = db.take_interrupted_turns().unwrap();
231 assert_eq!(orphans.len(), 1);
232 assert_eq!(orphans[0].chat_id, 10);
233 assert_eq!(orphans[0].channel, "telegram");
234 assert!(!orphans[0].started_at.is_empty());
235 assert_eq!(
236 orphans[0].progress_text.as_deref(),
237 Some("step 3: web_search, read_file")
238 );
239
240 assert!(db.take_interrupted_turns().unwrap().is_empty());
242 db.clear_turn_active(999).unwrap();
244 cleanup(&dir);
245 }
246
247 #[test]
248 fn test_active_turn_checkpoint_survives_recovery_restart_boundary() {
249 let (db, dir) = test_db();
250 let session = r#"[{"role":"user","content":"finish the task"}]"#;
251
252 db.mark_turn_active_with_context(42, "slack", "group", Some("run-original"))
253 .unwrap();
254 db.checkpoint_active_turn(
255 42,
256 "ready_for_llm",
257 3,
258 Some(session),
259 true,
260 Some("step 3 complete"),
261 None,
262 )
263 .unwrap();
264 let checkpoint_at = db.list_active_turns().unwrap()[0]
265 .last_checkpoint_at
266 .clone();
267
268 db.mark_turn_recovery_started(42, "run-recovery").unwrap();
271 let turns = db.list_active_turns().unwrap();
272 assert_eq!(turns.len(), 1);
273 assert_eq!(turns[0].chat_type, "group");
274 assert_eq!(turns[0].phase, "resuming");
275 assert_eq!(turns[0].iteration, 3);
276 assert_eq!(turns[0].session_json.as_deref(), Some(session));
277 assert!(turns[0].resumable);
278 assert_eq!(turns[0].run_id.as_deref(), Some("run-recovery"));
279 assert_eq!(turns[0].last_checkpoint_at, checkpoint_at);
280
281 db.checkpoint_active_turn(
284 42,
285 "executing_tools",
286 4,
287 None,
288 false,
289 None,
290 Some("send_message(medium)"),
291 )
292 .unwrap();
293 let turn = db.list_active_turns().unwrap().pop().unwrap();
294 assert!(!turn.resumable);
295 assert_eq!(turn.session_json.as_deref(), Some(session));
296 assert_eq!(turn.tool_summary.as_deref(), Some("send_message(medium)"));
297
298 db.clear_turn_active(42).unwrap();
299 cleanup(&dir);
300 }
301}