1use 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#[derive(Debug)]
26pub struct SqliteCheckpointBackend {
27 pool: DbPool,
28 run_id: RunId,
29 checkpoint: RwLock<Option<RunCheckpoint>>,
31}
32
33impl SqliteCheckpointBackend {
34 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 fn writer(&self) -> EventWriter {
45 EventWriter::new(self.pool.clone())
46 }
47
48 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 fn rebuild_checkpoint(&self) -> anyhow::Result<Option<RunCheckpoint>> {
66 self.block_on(async {
67 let run_id = self.run_id;
68
69 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 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 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 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 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 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 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 #[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 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 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 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 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 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 self.block_on(async {
479 self.writer().write_event(event).await
480 }).map_err(|e| anyhow::anyhow!("EventWriter error: {e}"))?;
481
482 self.update_checkpoint_table(event)?;
484
485 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 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 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 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 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 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 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 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
800fn 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
830fn 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 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}