Skip to main content

luft_storage/
checkpoint.rs

1//! SQLite-backed `CheckpointBackend` implementation.
2//!
3//! Replaces file-based `checkpoint.json` + `events.jsonl` with a unified
4//! SQLite store. All methods are synchronous; async sqlx calls are bridged
5//! via `block_in_place` + `Handle::block_on`.
6
7use crate::db::DbPool;
8use crate::writer::EventWriter;
9use luft_core::contract::event::AgentEvent;
10use luft_core::contract::finding::Finding;
11use luft_core::contract::ids::{AgentId, RunId};
12use luft_core::state::{
13    AgentResultCache, AgentSessionCheckpoint, CheckpointBackend, CheckpointStatus, PhaseSummary,
14    RunCheckpoint,
15};
16use sqlx::Row;
17use std::collections::HashMap;
18use std::sync::RwLock;
19
20/// SQLite-backed checkpoint backend.
21///
22/// Wraps an `EventWriter` for structured event-to-SQL translation and adds
23/// checkpoint table management on top. Maintains an in-memory checkpoint
24/// cache for O(1) hot-path reads.
25#[derive(Debug)]
26pub struct SqliteCheckpointBackend {
27    pool: DbPool,
28    run_id: RunId,
29    /// In-memory checkpoint cache (hot-path read).
30    checkpoint: RwLock<Option<RunCheckpoint>>,
31}
32
33impl SqliteCheckpointBackend {
34    /// Create a new backend for the given run.
35    pub fn new(pool: DbPool, run_id: RunId) -> Self {
36        Self {
37            pool,
38            run_id,
39            checkpoint: RwLock::new(None),
40        }
41    }
42
43    /// Get a reference to the EventWriter for event persistence.
44    fn writer(&self) -> EventWriter {
45        EventWriter::new(self.pool.clone())
46    }
47
48    /// Bridge sync → async.
49    /// Uses `block_in_place` inside a runtime (requires multi-thread flavor);
50    /// creates a standalone runtime when called from plain sync code.
51    fn block_on<F: std::future::Future>(&self, f: F) -> F::Output {
52        match tokio::runtime::Handle::try_current() {
53            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(f)),
54            Err(_) => {
55                let rt = tokio::runtime::Builder::new_current_thread()
56                    .enable_all()
57                    .build()
58                    .expect("create tokio runtime");
59                rt.block_on(f)
60            }
61        }
62    }
63
64    /// Rebuild the in-memory checkpoint from SQLite tables.
65    fn rebuild_checkpoint(&self) -> anyhow::Result<Option<RunCheckpoint>> {
66        self.block_on(async {
67            let run_id = self.run_id;
68
69            // Fetch checkpoint row
70            let cp_row = sqlx::query(
71                "SELECT status, current_phase, total_tokens, created_at, updated_at,
72                        workflow_meta, started_agent_ids
73                 FROM checkpoints WHERE run_id = ?",
74            )
75            .bind(run_id)
76            .fetch_optional(&self.pool)
77            .await?;
78
79            let Some(row) = cp_row else {
80                return Ok(None);
81            };
82
83            let status: String = row.try_get("status")?;
84            let current_phase: u32 = row.try_get::<i64, _>("current_phase")? as u32;
85            let total_tokens: u64 = row.try_get::<i64, _>("total_tokens")? as u64;
86            let created_at: u64 = row.try_get::<i64, _>("created_at")? as u64;
87            let updated_at: u64 = row.try_get::<i64, _>("updated_at")? as u64;
88            let workflow_meta: Option<String> = row.try_get("workflow_meta")?;
89            let started_agent_ids_json: String = row.try_get("started_agent_ids")?;
90
91            // Fetch task from runs table
92            let task: String =
93                sqlx::query_scalar("SELECT task FROM runs WHERE run_id = ?")
94                    .bind(run_id)
95                    .fetch_one(&self.pool)
96                    .await?;
97
98            // Fetch phases
99            let phase_rows = sqlx::query(
100                "SELECT phase_id, label, planned, ok, failed, description, role
101                 FROM phases WHERE run_id = ? ORDER BY phase_id",
102            )
103            .bind(run_id)
104            .fetch_all(&self.pool)
105            .await?;
106
107            let completed_phases: Vec<PhaseSummary> = phase_rows
108                .into_iter()
109                .map(|r| PhaseSummary {
110                    phase_id: r.try_get::<i64, _>("phase_id").unwrap_or(0) as u32,
111                    label: r.try_get("label").unwrap_or_default(),
112                    planned: r.try_get::<i64, _>("planned").unwrap_or(0) as usize,
113                    ok: r.try_get::<i64, _>("ok").unwrap_or(0) as usize,
114                    failed: r.try_get::<i64, _>("failed").unwrap_or(0) as usize,
115                    description: r.try_get("description").unwrap_or(None),
116                    role: r.try_get("role").unwrap_or(None),
117                })
118                .collect();
119
120            // Fetch agent results
121            let agent_rows = sqlx::query(
122                "SELECT agent_id, phase_id, status, output, findings_json, input_tokens + output_tokens as tokens,
123                        cache_key_hash, description, role, completed_at
124                 FROM agents WHERE run_id = ? AND status != 'running'",
125            )
126            .bind(run_id)
127            .fetch_all(&self.pool)
128            .await?;
129
130            let mut agent_results: HashMap<AgentId, AgentResultCache> = HashMap::new();
131            for r in agent_rows {
132                let agent_id_bytes: Vec<u8> = r.try_get("agent_id").unwrap_or_default();
133                let agent_id = AgentId::from_slice(&agent_id_bytes).unwrap_or_default();
134                let output_str: Option<String> = r.try_get("output").unwrap_or(None);
135                let output: serde_json::Value = output_str
136                    .and_then(|s| serde_json::from_str(&s).ok())
137                    .unwrap_or(serde_json::Value::Null);
138                let findings_str: String = r.try_get("findings_json").unwrap_or_else(|_| "[]".into());
139                let findings: Vec<Finding> =
140                    serde_json::from_str(&findings_str).unwrap_or_default();
141
142                agent_results.insert(
143                    agent_id,
144                    AgentResultCache {
145                        agent_id,
146                        phase_id: r.try_get::<i64, _>("phase_id").unwrap_or(0) as u32,
147                        status: r.try_get("status").unwrap_or_else(|_| "unknown".into()),
148                        output,
149                        findings,
150                        tokens: r.try_get::<i64, _>("tokens").unwrap_or(0) as u64,
151                        completed_at: r.try_get::<i64, _>("completed_at").unwrap_or(0) as u64,
152                        cache_key_hash: r.try_get("cache_key_hash").unwrap_or(None),
153                        description: r.try_get("description").unwrap_or(None),
154                        role: r.try_get("role").unwrap_or(None),
155                    },
156                );
157            }
158
159            // Fetch agent sessions
160            let session_rows = sqlx::query(
161                "SELECT agent_id, backend_id, protocol_session_id, session_id, status, updated_at, resumable
162                 FROM agent_sessions WHERE run_id = ?",
163            )
164            .bind(run_id)
165            .fetch_all(&self.pool)
166            .await?;
167
168            let mut agent_sessions: HashMap<AgentId, AgentSessionCheckpoint> = HashMap::new();
169            for r in session_rows {
170                let agent_id_bytes: Vec<u8> = r.try_get("agent_id").unwrap_or_default();
171                let agent_id = AgentId::from_slice(&agent_id_bytes).unwrap_or_default();
172                agent_sessions.insert(
173                    agent_id,
174                    AgentSessionCheckpoint {
175                        agent_id,
176                        backend_id: r.try_get("backend_id").unwrap_or(None),
177                        protocol_session_id: r.try_get("protocol_session_id").unwrap_or(None),
178                        session_id: r.try_get("session_id").unwrap_or_default(),
179                        status: r.try_get("status").unwrap_or_default(),
180                        updated_at: r.try_get::<i64, _>("updated_at").unwrap_or(0) as u64,
181                        resumable: r.try_get::<i64, _>("resumable").unwrap_or(0) != 0,
182                    },
183                );
184            }
185
186            // Fetch findings
187            let finding_rows = sqlx::query(
188                "SELECT kind, severity, title, detail, file_path, line_start, line_end, evidence, data
189                 FROM findings WHERE run_id = ?",
190            )
191            .bind(run_id)
192            .fetch_all(&self.pool)
193            .await?;
194
195            let findings: Vec<Finding> = finding_rows
196                .into_iter()
197                .filter_map(|r| {
198                    let data_str: Option<String> = r.try_get("data").ok()?;
199                    serde_json::from_str(&data_str.unwrap_or_default()).ok()
200                })
201                .collect();
202
203            let workflow_meta = workflow_meta
204                .and_then(|s| serde_json::from_str(&s).ok());
205
206            let started_agent_ids: Vec<AgentId> =
207                serde_json::from_str(&started_agent_ids_json).unwrap_or_default();
208
209            let checkpoint = RunCheckpoint {
210                run_id,
211                task,
212                status: CheckpointStatus::parse_str(&status),
213                current_phase,
214                completed_phases,
215                agent_results,
216                agent_sessions,
217                findings,
218                total_tokens,
219                created_at,
220                updated_at,
221                workflow_meta,
222                started_agent_ids,
223            };
224
225            Ok(Some(checkpoint))
226        })
227    }
228
229    /// Update checkpoint in-memory cache after an event.
230    fn update_checkpoint_cache(&self, event: &AgentEvent) {
231        let mut cache = self.checkpoint.write().unwrap();
232        if let Some(ref mut cp) = *cache {
233            cp.updated_at = luft_core::state::current_timestamp();
234
235            match event {
236                AgentEvent::PhaseStarted {
237                    phase_id,
238                    label,
239                    planned,
240                    description,
241                    role,
242                    ..
243                } => {
244                    cp.current_phase = cp.current_phase.max(*phase_id);
245                    cp.completed_phases.push(PhaseSummary {
246                        phase_id: *phase_id,
247                        label: label.clone(),
248                        planned: *planned,
249                        ok: 0,
250                        failed: 0,
251                        description: description.clone(),
252                        role: role.clone(),
253                    });
254                }
255                AgentEvent::PhaseDone {
256                    phase_id,
257                    ok,
258                    failed,
259                    ..
260                } => {
261                    if let Some(phase) =
262                        cp.completed_phases.iter_mut().find(|p| p.phase_id == *phase_id)
263                    {
264                        phase.ok = *ok;
265                        phase.failed = *failed;
266                    }
267                }
268                AgentEvent::AgentDone {
269                    agent_id,
270                    tokens,
271                    status,
272                    ..
273                } => {
274                    cp.total_tokens += tokens.total();
275                    let existing = cp.agent_results.get(agent_id).cloned();
276                    if let Some(mut existing) = existing {
277                        existing.status = status.as_str().to_string();
278                        existing.tokens = tokens.total();
279                        existing.completed_at = luft_core::state::current_timestamp();
280                        cp.agent_results.insert(*agent_id, existing);
281                    }
282                }
283                AgentEvent::RunDone {
284                    status, total_tokens, ..
285                } => {
286                    cp.total_tokens = total_tokens.total();
287                    cp.status = match status {
288                        luft_core::contract::event::RunStatus::Completed => {
289                            CheckpointStatus::Completed
290                        }
291                        luft_core::contract::event::RunStatus::Failed => {
292                            CheckpointStatus::Failed
293                        }
294                        luft_core::contract::event::RunStatus::Cancelled => {
295                            CheckpointStatus::Cancelled
296                        }
297                        _ => CheckpointStatus::Completed,
298                    };
299                }
300                _ => {}
301            }
302        }
303    }
304
305    /// Write event to events audit table.
306    #[allow(dead_code)]
307    fn write_event_audit(&self, event: &AgentEvent) -> anyhow::Result<()> {
308        let payload = serde_json::to_string(event)?;
309        let type_tag = event_type_tag(event);
310        let run_id = self.run_id;
311        self.block_on(async {
312            sqlx::query("INSERT INTO events (run_id, type, payload) VALUES (?, ?, ?)")
313                .bind(run_id)
314                .bind(type_tag)
315                .bind(&payload)
316                .execute(&self.pool)
317                .await?;
318            Ok::<(), anyhow::Error>(())
319        })
320    }
321
322    /// Update checkpoints table after an event.
323    fn update_checkpoint_table(&self, event: &AgentEvent) -> anyhow::Result<()> {
324        let run_id = self.run_id;
325        let now = luft_core::state::current_timestamp();
326
327        match event {
328            AgentEvent::PhaseStarted { phase_id, .. } => {
329                self.block_on(async {
330                    sqlx::query(
331                        "UPDATE checkpoints SET current_phase = MAX(current_phase, ?), updated_at = ? WHERE run_id = ?",
332                    )
333                    .bind(*phase_id as i64)
334                    .bind(now as i64)
335                    .bind(run_id)
336                    .execute(&self.pool)
337                    .await?;
338                    Ok::<_, anyhow::Error>(())
339                })?;
340            }
341            AgentEvent::AgentDone { tokens, .. } => {
342                self.block_on(async {
343                    sqlx::query(
344                        "UPDATE checkpoints SET total_tokens = total_tokens + ?, updated_at = ? WHERE run_id = ?",
345                    )
346                    .bind(tokens.total() as i64)
347                    .bind(now as i64)
348                    .bind(run_id)
349                    .execute(&self.pool)
350                    .await?;
351                    Ok::<_, anyhow::Error>(())
352                })?;
353            }
354            AgentEvent::RunDone { status, total_tokens, .. } => {
355                let status_str = match status {
356                    luft_core::contract::event::RunStatus::Completed => "completed",
357                    luft_core::contract::event::RunStatus::Failed => "failed",
358                    luft_core::contract::event::RunStatus::Cancelled => "cancelled",
359                    _ => "completed",
360                };
361                let total = total_tokens.total() as i64;
362                self.block_on(async {
363                    sqlx::query(
364                        "UPDATE checkpoints SET status = ?, total_tokens = ?, updated_at = ? WHERE run_id = ?",
365                    )
366                    .bind(status_str)
367                    .bind(total)
368                    .bind(now as i64)
369                    .bind(run_id)
370                    .execute(&self.pool)
371                    .await?;
372                    Ok::<_, anyhow::Error>(())
373                })?;
374            }
375            _ => {}
376        }
377        Ok::<(), anyhow::Error>(())
378    }
379}
380
381impl CheckpointBackend for SqliteCheckpointBackend {
382    fn init_run(&self, run_id: RunId, task: &str, run_dir: &str) -> anyhow::Result<()> {
383        let now = luft_core::state::current_timestamp();
384        self.block_on(async {
385            sqlx::query(
386                "INSERT OR IGNORE INTO runs (run_id, task, status, started_ts, run_dir)
387                 VALUES (?, ?, 'running', ?, ?)",
388            )
389            .bind(run_id)
390            .bind(task)
391            .bind(format!("{}", now))
392            .bind(run_dir)
393            .execute(&self.pool)
394            .await?;
395
396            // Insert into checkpoints table
397            sqlx::query(
398                "INSERT OR REPLACE INTO checkpoints (run_id, status, current_phase, total_tokens, created_at, updated_at, started_agent_ids)
399                 VALUES (?, 'running', 0, 0, ?, ?, '[]')",
400            )
401            .bind(run_id)
402            .bind(now as i64)
403            .bind(now as i64)
404            .execute(&self.pool)
405            .await?;
406
407            Ok::<(), anyhow::Error>(())
408        })?;
409
410        // Initialize in-memory cache
411        let cp = RunCheckpoint {
412            run_id,
413            task: task.to_string(),
414            status: CheckpointStatus::Running,
415            current_phase: 0,
416            completed_phases: vec![],
417            agent_results: HashMap::new(),
418            agent_sessions: HashMap::new(),
419            findings: vec![],
420            total_tokens: 0,
421            created_at: now,
422            updated_at: now,
423            workflow_meta: None,
424            started_agent_ids: vec![],
425        };
426        *self.checkpoint.write().unwrap() = Some(cp);
427        Ok::<(), anyhow::Error>(())
428    }
429
430    fn init_run_with_meta(
431        &self,
432        run_id: RunId,
433        task: &str,
434        run_dir: &str,
435        workflow_meta: serde_json::Value,
436    ) -> anyhow::Result<()> {
437        self.init_run(run_id, task, run_dir)?;
438        let meta_str = serde_json::to_string(&workflow_meta)?;
439        self.block_on(async {
440            sqlx::query("UPDATE checkpoints SET workflow_meta = ? WHERE run_id = ?")
441                .bind(&meta_str)
442                .bind(run_id)
443                .execute(&self.pool)
444                .await?;
445            Ok::<(), anyhow::Error>(())
446        })?;
447
448        // Update cache
449        if let Some(ref mut cp) = *self.checkpoint.write().unwrap() {
450            cp.workflow_meta = Some(workflow_meta);
451        }
452        Ok::<(), anyhow::Error>(())
453    }
454
455    fn open_run(&self, _run_id: RunId) -> anyhow::Result<Option<RunCheckpoint>> {
456        let checkpoint = self.rebuild_checkpoint()?;
457        if checkpoint.is_some() {
458            *self.checkpoint.write().unwrap() = checkpoint.clone();
459        }
460        Ok(checkpoint)
461    }
462
463    fn append_event(&self, event: &AgentEvent) -> anyhow::Result<()> {
464        // Safety net: ensure the event's run_id exists in runs table (FK constraint)
465        let event_rid = event_run_id(event);
466        if event_rid != uuid::Uuid::nil() && event_rid != self.run_id {
467            let now = luft_core::state::current_timestamp();
468            self.block_on(async {
469                sqlx::query("INSERT OR IGNORE INTO runs (run_id, task, status, started_ts, run_dir) VALUES (?, '', 'running', ?, NULL)")
470                    .bind(event_rid)
471                    .bind(format!("{}", now))
472                    .execute(&self.pool)
473                    .await
474            }).map_err(|e| anyhow::anyhow!("FK safety net failed: {e}"))?;
475        }
476
477        // 1. Update structured tables + events audit log (via EventWriter)
478        self.block_on(async {
479            self.writer().write_event(event).await
480        }).map_err(|e| anyhow::anyhow!("EventWriter error: {e}"))?;
481
482        // 2. Update checkpoints table
483        self.update_checkpoint_table(event)?;
484
485        // 3. Update in-memory cache
486        self.update_checkpoint_cache(event);
487
488        Ok::<(), anyhow::Error>(())
489    }
490
491    fn upsert_agent_result(&self, cache: &AgentResultCache) -> anyhow::Result<()> {
492        let output_str = serde_json::to_string(&cache.output)?;
493        let findings_str = serde_json::to_string(&cache.findings)?;
494        let now = luft_core::state::current_timestamp();
495
496        self.block_on(async {
497            sqlx::query(
498                "INSERT INTO agents (run_id, agent_id, phase_id, status, output, findings_json,
499                     input_tokens, output_tokens, started_ts, done_ts, elapsed_ms,
500                     cache_key_hash, description, role, completed_at, retry_count)
501                 VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 0, ?, ?, ?, ?, 0)
502                 ON CONFLICT(run_id, agent_id) DO UPDATE SET
503                     status = excluded.status,
504                     output = excluded.output,
505                     findings_json = excluded.findings_json,
506                     cache_key_hash = excluded.cache_key_hash,
507                     description = excluded.description,
508                     role = excluded.role,
509                     completed_at = excluded.completed_at",
510            )
511            .bind(self.run_id)
512            .bind(cache.agent_id)
513            .bind(cache.phase_id as i64)
514            .bind(&cache.status)
515            .bind(&output_str)
516            .bind(&findings_str)
517            .bind(format!("{}", now))
518            .bind(format!("{}", cache.completed_at))
519            .bind(&cache.cache_key_hash)
520            .bind(&cache.description)
521            .bind(&cache.role)
522            .bind(cache.completed_at as i64)
523            .execute(&self.pool)
524            .await?;
525            Ok::<(), anyhow::Error>(())
526        })?;
527
528        // Update in-memory cache
529        if let Some(ref mut cp) = *self.checkpoint.write().unwrap() {
530            cp.agent_results.insert(cache.agent_id, cache.clone());
531        }
532        Ok::<(), anyhow::Error>(())
533    }
534
535    fn upsert_agent_session(&self, session: &AgentSessionCheckpoint) -> anyhow::Result<()> {
536        self.block_on(async {
537            sqlx::query(
538                "INSERT INTO agent_sessions (run_id, agent_id, backend_id, protocol_session_id, session_id, status, updated_at, resumable)
539                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
540                 ON CONFLICT(run_id, agent_id) DO UPDATE SET
541                     backend_id = excluded.backend_id,
542                     protocol_session_id = excluded.protocol_session_id,
543                     session_id = excluded.session_id,
544                     status = excluded.status,
545                     updated_at = excluded.updated_at,
546                     resumable = excluded.resumable",
547            )
548            .bind(self.run_id)
549            .bind(session.agent_id)
550            .bind(&session.backend_id)
551            .bind(&session.protocol_session_id)
552            .bind(&session.session_id)
553            .bind(&session.status)
554            .bind(session.updated_at as i64)
555            .bind(session.resumable as i64)
556            .execute(&self.pool)
557            .await?;
558            Ok::<(), anyhow::Error>(())
559        })?;
560
561        // Update in-memory cache
562        if let Some(ref mut cp) = *self.checkpoint.write().unwrap() {
563            cp.agent_sessions.insert(session.agent_id, session.clone());
564        }
565        Ok::<(), anyhow::Error>(())
566    }
567
568    fn get_checkpoint(&self) -> Option<RunCheckpoint> {
569        self.checkpoint.read().unwrap().clone()
570    }
571
572    fn get_findings(&self) -> Vec<Finding> {
573        self.checkpoint
574            .read()
575            .unwrap()
576            .as_ref()
577            .map(|cp| cp.findings.clone())
578            .unwrap_or_default()
579    }
580
581    fn get_event_log(&self) -> anyhow::Result<Vec<AgentEvent>> {
582        self.block_on(async {
583            let rows = sqlx::query("SELECT payload FROM events WHERE run_id = ? ORDER BY seq")
584                .bind(self.run_id)
585                .fetch_all(&self.pool)
586                .await?;
587
588            let events: Vec<AgentEvent> = rows
589                .into_iter()
590                .filter_map(|r| {
591                    let payload: String = r.try_get("payload").ok()?;
592                    serde_json::from_str(&payload).ok()
593                })
594                .collect();
595            Ok(events)
596        })
597    }
598
599    fn can_resume(&self) -> bool {
600        self.checkpoint
601            .read()
602            .unwrap()
603            .as_ref()
604            .map(|cp| {
605                matches!(
606                    cp.status,
607                    CheckpointStatus::Running | CheckpointStatus::Failed | CheckpointStatus::Cancelled
608                )
609            })
610            .unwrap_or(false)
611    }
612
613    fn reset_status_to_running(&self) -> anyhow::Result<()> {
614        let now = luft_core::state::current_timestamp();
615        self.block_on(async {
616            sqlx::query(
617                "UPDATE checkpoints SET status = 'running', updated_at = ? WHERE run_id = ?",
618            )
619            .bind(now as i64)
620            .bind(self.run_id)
621            .execute(&self.pool)
622            .await?;
623            Ok::<(), anyhow::Error>(())
624        })?;
625
626        if let Some(ref mut cp) = *self.checkpoint.write().unwrap() {
627            cp.status = CheckpointStatus::Running;
628            cp.updated_at = now;
629        }
630        Ok::<(), anyhow::Error>(())
631    }
632
633    fn cancel(&self) -> anyhow::Result<()> {
634        let now = luft_core::state::current_timestamp();
635        self.block_on(async {
636            sqlx::query(
637                "UPDATE checkpoints SET status = 'cancelled', updated_at = ? WHERE run_id = ? AND status = 'running'",
638            )
639            .bind(now as i64)
640            .bind(self.run_id)
641            .execute(&self.pool)
642            .await?;
643            Ok::<(), anyhow::Error>(())
644        })?;
645
646        if let Some(ref mut cp) = *self.checkpoint.write().unwrap() {
647            cp.status = CheckpointStatus::Cancelled;
648            cp.updated_at = now;
649        }
650        Ok::<(), anyhow::Error>(())
651    }
652
653    fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> anyhow::Result<()> {
654        let meta_str = checkpoint
655            .workflow_meta
656            .as_ref()
657            .map(|v| serde_json::to_string(v).unwrap_or_default());
658        let started_ids_str = serde_json::to_string(&checkpoint.started_agent_ids)?;
659        let now = luft_core::state::current_timestamp();
660        let run_id = checkpoint.run_id;
661
662        self.block_on(async {
663            // 1. Upsert checkpoint row
664            sqlx::query(
665                "INSERT OR REPLACE INTO checkpoints
666                    (run_id, status, current_phase, total_tokens, created_at, updated_at, workflow_meta, started_agent_ids)
667                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
668            )
669            .bind(run_id)
670            .bind(checkpoint.status.as_str())
671            .bind(checkpoint.current_phase as i64)
672            .bind(checkpoint.total_tokens as i64)
673            .bind(checkpoint.created_at as i64)
674            .bind(now as i64)
675            .bind(&meta_str)
676            .bind(&started_ids_str)
677            .execute(&self.pool)
678            .await?;
679
680            // 2. Sync phases: delete + re-insert
681            sqlx::query("DELETE FROM phases WHERE run_id = ?")
682                .bind(run_id)
683                .execute(&self.pool)
684                .await?;
685
686            for phase in &checkpoint.completed_phases {
687                sqlx::query(
688                    "INSERT INTO phases (run_id, phase_id, label, planned, ok, failed, description, role)
689                     VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
690                )
691                .bind(run_id)
692                .bind(phase.phase_id as i64)
693                .bind(&phase.label)
694                .bind(phase.planned as i64)
695                .bind(phase.ok as i64)
696                .bind(phase.failed as i64)
697                .bind(&phase.description)
698                .bind(&phase.role)
699                .execute(&self.pool)
700                .await?;
701            }
702
703            // 3. Sync agent results
704            for (agent_id, cache) in &checkpoint.agent_results {
705                let output_str = serde_json::to_string(&cache.output)?;
706                let findings_str = serde_json::to_string(&cache.findings)?;
707                sqlx::query(
708                    "INSERT INTO agents (run_id, agent_id, phase_id, status, output, findings_json,
709                         input_tokens, output_tokens, started_ts, done_ts, elapsed_ms,
710                         cache_key_hash, description, role, completed_at, retry_count)
711                     VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 0, ?, ?, ?, ?, 0)
712                     ON CONFLICT(run_id, agent_id) DO UPDATE SET
713                         status = excluded.status,
714                         output = excluded.output,
715                         findings_json = excluded.findings_json,
716                         output_tokens = excluded.output_tokens,
717                         cache_key_hash = excluded.cache_key_hash,
718                         description = excluded.description,
719                         role = excluded.role,
720                         completed_at = excluded.completed_at",
721                )
722                .bind(run_id)
723                .bind(agent_id)
724                .bind(cache.phase_id as i64)
725                .bind(&cache.status)
726                .bind(&output_str)
727                .bind(&findings_str)
728                .bind(cache.tokens as i64)
729                .bind(format!("{}", cache.completed_at))
730                .bind(format!("{}", cache.completed_at))
731                .bind(&cache.cache_key_hash)
732                .bind(&cache.description)
733                .bind(&cache.role)
734                .bind(cache.completed_at as i64)
735                .execute(&self.pool)
736                .await?;
737            }
738
739            // 4. Sync agent sessions
740            for (agent_id, session) in &checkpoint.agent_sessions {
741                sqlx::query(
742                    "INSERT INTO agent_sessions (run_id, agent_id, backend_id, protocol_session_id, session_id, status, updated_at, resumable)
743                     VALUES (?, ?, ?, ?, ?, ?, ?, ?)
744                     ON CONFLICT(run_id, agent_id) DO UPDATE SET
745                         backend_id = excluded.backend_id,
746                         protocol_session_id = excluded.protocol_session_id,
747                         session_id = excluded.session_id,
748                         status = excluded.status,
749                         updated_at = excluded.updated_at,
750                         resumable = excluded.resumable",
751                )
752                .bind(run_id)
753                .bind(agent_id)
754                .bind(&session.backend_id)
755                .bind(&session.protocol_session_id)
756                .bind(&session.session_id)
757                .bind(&session.status)
758                .bind(session.updated_at as i64)
759                .bind(session.resumable as i64)
760                .execute(&self.pool)
761                .await?;
762            }
763
764            // 5. Sync findings
765            sqlx::query("DELETE FROM findings WHERE run_id = ?")
766                .bind(run_id)
767                .execute(&self.pool)
768                .await?;
769            for finding in &checkpoint.findings {
770                let data = serde_json::to_string(finding)?;
771                let (file_path, line) = finding.location.as_ref().map_or((String::new(), None), |l| {
772                    (l.file.to_string_lossy().to_string(), l.line.map(|n| n as i64))
773                });
774                sqlx::query(
775                    "INSERT INTO findings (run_id, kind, severity, title, detail, file_path, line_start, line_end, evidence, data)
776                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
777                )
778                .bind(run_id)
779                .bind(&finding.kind)
780                .bind(format!("{:?}", finding.severity))
781                .bind(&finding.title)
782                .bind(&finding.detail)
783                .bind(&file_path)
784                .bind(line)
785                .bind(line)
786                .bind(serde_json::to_string(&finding.evidence).unwrap_or_default())
787                .bind(&data)
788                .execute(&self.pool)
789                .await?;
790            }
791
792            Ok::<(), anyhow::Error>(())
793        })?;
794
795        *self.checkpoint.write().unwrap() = Some(checkpoint.clone());
796        Ok::<(), anyhow::Error>(())
797    }
798}
799
800/// Extract run_id from an AgentEvent via pattern matching.
801fn event_run_id(event: &AgentEvent) -> RunId {
802    match event {
803        AgentEvent::RunStarted { run_id, .. }
804        | AgentEvent::PhaseStarted { run_id, .. }
805        | AgentEvent::AgentStarted { run_id, .. }
806        | AgentEvent::AgentProgress { run_id, .. }
807        | AgentEvent::AgentDone { run_id, .. }
808        | AgentEvent::PhaseDone { run_id, .. }
809        | AgentEvent::RunDone { run_id, .. }
810        | AgentEvent::Log { run_id, .. }
811        | AgentEvent::BudgetSet { run_id, .. }
812        | AgentEvent::ReportEmitted { run_id, .. }
813        | AgentEvent::ParallelStarted { run_id, .. }
814        | AgentEvent::ParallelDone { run_id, .. }
815        | AgentEvent::WorkflowStarted { run_id, .. }
816        | AgentEvent::WorkflowDone { run_id, .. }
817        | AgentEvent::ConvergeStarted { run_id, .. }
818        | AgentEvent::ConvergeDone { run_id, .. }
819        | AgentEvent::PipelineStarted { run_id, .. }
820        | AgentEvent::PipelineStageStarted { run_id, .. }
821        | AgentEvent::PipelineItemDone { run_id, .. }
822        | AgentEvent::PipelineDone { run_id, .. } => *run_id,
823        AgentEvent::SignalReceived { run_id, .. } => {
824            run_id.unwrap_or_default()
825        }
826        _ => uuid::Uuid::nil(),
827    }
828}
829
830/// Get a string type tag for an AgentEvent.
831fn event_type_tag(event: &AgentEvent) -> &'static str {
832    match event {
833        AgentEvent::RunStarted { .. } => "run_started",
834        AgentEvent::PhaseStarted { .. } => "phase_started",
835        AgentEvent::AgentStarted { .. } => "agent_started",
836        AgentEvent::AgentProgress { .. } => "agent_progress",
837        AgentEvent::AgentDone { .. } => "agent_done",
838        AgentEvent::PhaseDone { .. } => "phase_done",
839        AgentEvent::RunDone { .. } => "run_done",
840        AgentEvent::Log { .. } => "log",
841        AgentEvent::SignalReceived { .. } => "signal_received",        AgentEvent::BudgetSet { .. } => "budget_set",
842        AgentEvent::ReportEmitted { .. } => "report_emitted",
843        AgentEvent::ParallelStarted { .. } => "parallel_started",
844        AgentEvent::ParallelDone { .. } => "parallel_done",
845        AgentEvent::WorkflowStarted { .. } => "workflow_started",
846        AgentEvent::WorkflowDone { .. } => "workflow_done",
847        AgentEvent::ConvergeStarted { .. } => "converge_started",
848        AgentEvent::ConvergeDone { .. } => "converge_done",
849        AgentEvent::PipelineStarted { .. } => "pipeline_started",
850        AgentEvent::PipelineStageStarted { .. } => "pipeline_stage_started",
851        AgentEvent::PipelineItemDone { .. } => "pipeline_item_done",
852        AgentEvent::PipelineDone { .. } => "pipeline_done",
853        _ => "other",
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::db::open_db;
861    use luft_core::contract::backend::AgentStatus;
862    use luft_core::contract::ids::TokenUsage;
863    use tempfile::tempdir;
864
865    fn make_backend(run_id: RunId) -> SqliteCheckpointBackend {
866        let dir = tempdir().unwrap();
867        let db_path = dir.path().join("test.db");
868        // Keep tempdir alive for the test by leaking it
869        let dir_box = Box::leak(Box::new(dir));
870        let pool = tokio::runtime::Runtime::new().unwrap().block_on(async {
871            open_db(&db_path).await.unwrap()
872        });
873        SqliteCheckpointBackend::new(pool, run_id)
874    }
875
876    #[test]
877    fn test_init_and_get_checkpoint() {
878        let run_id = uuid::Uuid::now_v7();
879        let backend = make_backend(run_id);
880        backend.init_run(run_id, "Test task", "test_run").unwrap();
881
882        let cp = backend.get_checkpoint().unwrap();
883        assert_eq!(cp.run_id, run_id);
884        assert_eq!(cp.task, "Test task");
885        assert_eq!(cp.status, CheckpointStatus::Running);
886    }
887
888    #[test]
889    fn test_cancel() {
890        let run_id = uuid::Uuid::now_v7();
891        let backend = make_backend(run_id);
892        backend.init_run(run_id, "Cancel me", "cancel_run").unwrap();
893        backend.cancel().unwrap();
894
895        let cp = backend.get_checkpoint().unwrap();
896        assert_eq!(cp.status, CheckpointStatus::Cancelled);
897    }
898
899    #[test]
900    fn test_upsert_agent_result() {
901        let run_id = uuid::Uuid::now_v7();
902        let backend = make_backend(run_id);
903        backend.init_run(run_id, "Agent test", "agent_run").unwrap();
904
905        let agent_id = uuid::Uuid::now_v7();
906        let cache = AgentResultCache {
907            agent_id,
908            phase_id: 1,
909            status: "ok".to_string(),
910            output: serde_json::json!({"result": "success"}),
911            findings: vec![],
912            tokens: 150,
913            completed_at: luft_core::state::current_timestamp(),
914            cache_key_hash: Some("abc123".to_string()),
915            description: None,
916            role: None,
917        };
918        backend.upsert_agent_result(&cache).unwrap();
919
920        let cp = backend.get_checkpoint().unwrap();
921        let cached = cp.agent_results.get(&agent_id).unwrap();
922        assert_eq!(cached.status, "ok");
923        assert_eq!(cached.tokens, 150);
924    }
925
926    #[test]
927    fn test_open_run() {
928        let run_id = uuid::Uuid::now_v7();
929        let dir = tempdir().unwrap();
930        let db_path = dir.path().join("test.db");
931        let _dir_box = Box::leak(Box::new(dir));
932        let pool = tokio::runtime::Runtime::new().unwrap().block_on(async {
933            open_db(&db_path).await.unwrap()
934        });
935
936        let backend1 = SqliteCheckpointBackend::new(pool.clone(), run_id);
937        backend1.init_run(run_id, "Persist test", "persist_run").unwrap();
938
939        let agent_id = uuid::Uuid::now_v7();
940        let cache = AgentResultCache {
941            agent_id,
942            phase_id: 0,
943            status: "ok".to_string(),
944            output: serde_json::json!({"survived": true}),
945            findings: vec![],
946            tokens: 2,
947            completed_at: 0,
948            cache_key_hash: Some("key1".to_string()),
949            description: None,
950            role: None,
951        };
952        backend1.upsert_agent_result(&cache).unwrap();
953        drop(backend1);
954
955        let backend2 = SqliteCheckpointBackend::new(pool, run_id);
956        let cp = backend2.open_run(run_id).unwrap().unwrap();
957        assert_eq!(cp.task, "Persist test");
958        assert_eq!(cp.status, CheckpointStatus::Running);
959        let cached = cp.agent_results.get(&agent_id).unwrap();
960        assert_eq!(cached.output, serde_json::json!({"survived": true}));
961    }
962}