1use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
7use serde_json::{Map, Value};
8
9use crate::checkpoint::{CheckpointError, CheckpointResult, ClaimMode, EventCursor};
10use crate::runtime::checkpoint_codec::{checkpoint_from_value, checkpoint_to_value};
11use crate::runtime::state::{
12 apply_claim, claim_candidate, prepare_ack, prepare_commit, prepare_event_delivery,
13 prepare_finalize, prepare_finalize_claimed, prepare_progress, prepare_suspend, Checkpoint,
14 CheckpointStore,
15};
16
17const MAX_EXTENSION_STATE_BYTES: u64 = crate::checkpoint::MAX_WIRE_INTEGER;
18const CREATE_CHECKPOINTS_TABLE_SQL: &str = r#"
19CREATE TABLE IF NOT EXISTS checkpoints (
20 checkpoint_key TEXT PRIMARY KEY,
21 schema_version TEXT NOT NULL CHECK (schema_version = 'vv-agent.checkpoint.v3'),
22 run_definition_schema TEXT NOT NULL CHECK (run_definition_schema = 'vv-agent.run-definition.v2'),
23 run_definition TEXT NOT NULL,
24 task_id TEXT NOT NULL,
25 root_run_id TEXT NOT NULL,
26 trace_id TEXT NOT NULL,
27 run_definition_digest TEXT NOT NULL,
28 resume_attempt INTEGER NOT NULL CHECK (resume_attempt >= 1),
29 cycle_index INTEGER NOT NULL CHECK (cycle_index >= 0),
30 status TEXT NOT NULL,
31 messages TEXT NOT NULL,
32 cycles TEXT NOT NULL,
33 model_calls TEXT NOT NULL,
34 shared_state TEXT NOT NULL,
35 budget_usage TEXT,
36 event_cursor TEXT,
37 event_outbox TEXT NOT NULL,
38 extension_state TEXT NOT NULL,
39 model_call_journal TEXT NOT NULL,
40 tool_journal TEXT NOT NULL,
41 revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
42 claim_token TEXT,
43 claimed_cycle INTEGER,
44 lease_expires_at_ms INTEGER,
45 terminal_result TEXT,
46 terminal_acknowledged INTEGER NOT NULL DEFAULT 0 CHECK (terminal_acknowledged IN (0, 1)),
47 CHECK (
48 (claim_token IS NULL AND claimed_cycle IS NULL AND lease_expires_at_ms IS NULL)
49 OR
50 (claim_token IS NOT NULL AND claimed_cycle IS NOT NULL AND lease_expires_at_ms IS NOT NULL)
51 ),
52 CHECK (claim_token IS NULL OR claimed_cycle = cycle_index + 1),
53 CHECK (terminal_result IS NULL OR claim_token IS NULL)
54)
55"#;
56const CREATE_CHECKPOINTS_STATUS_INDEX_SQL: &str = r#"
57CREATE INDEX IF NOT EXISTS checkpoints_status_idx ON checkpoints(status)
58"#;
59
60pub struct SqliteCheckpointStore {
61 connection: Mutex<Connection>,
62 location: PathBuf,
63}
64
65impl std::fmt::Debug for SqliteCheckpointStore {
66 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 formatter
68 .debug_struct("SqliteCheckpointStore")
69 .field("location", &self.location)
70 .finish_non_exhaustive()
71 }
72}
73
74impl SqliteCheckpointStore {
75 pub fn new(path: impl AsRef<Path>) -> CheckpointResult<Self> {
76 let path = path.as_ref().to_path_buf();
77 let connection = Connection::open(&path).map_err(sqlite_error)?;
78 initialize_schema(&connection)?;
79 Ok(Self {
80 connection: Mutex::new(connection),
81 location: path,
82 })
83 }
84
85 pub fn location(&self) -> &Path {
86 &self.location
87 }
88
89 pub fn save_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointResult<()> {
90 checkpoint.validate()?;
91 let values = SqlValues::from_checkpoint(&checkpoint)?;
92 let connection = self.lock()?;
93 connection
94 .execute(
95 r#"
96 INSERT INTO checkpoints (
97 checkpoint_key, schema_version, run_definition_schema, run_definition,
98 task_id, root_run_id, trace_id, run_definition_digest, resume_attempt,
99 cycle_index, status, messages, cycles, model_calls, shared_state,
100 budget_usage, event_cursor, event_outbox, extension_state,
101 model_call_journal, tool_journal, revision, claim_token,
102 claimed_cycle, lease_expires_at_ms, terminal_result,
103 terminal_acknowledged
104 ) VALUES (
105 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
106 ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27
107 )
108 ON CONFLICT(checkpoint_key) DO UPDATE SET
109 schema_version = excluded.schema_version,
110 run_definition_schema = excluded.run_definition_schema,
111 run_definition = excluded.run_definition,
112 task_id = excluded.task_id,
113 root_run_id = excluded.root_run_id,
114 trace_id = excluded.trace_id,
115 run_definition_digest = excluded.run_definition_digest,
116 resume_attempt = excluded.resume_attempt,
117 cycle_index = excluded.cycle_index,
118 status = excluded.status,
119 messages = excluded.messages,
120 cycles = excluded.cycles,
121 model_calls = excluded.model_calls,
122 shared_state = excluded.shared_state,
123 budget_usage = excluded.budget_usage,
124 event_cursor = excluded.event_cursor,
125 event_outbox = excluded.event_outbox,
126 extension_state = excluded.extension_state,
127 model_call_journal = excluded.model_call_journal,
128 tool_journal = excluded.tool_journal,
129 revision = excluded.revision,
130 claim_token = excluded.claim_token,
131 claimed_cycle = excluded.claimed_cycle,
132 lease_expires_at_ms = excluded.lease_expires_at_ms,
133 terminal_result = excluded.terminal_result,
134 terminal_acknowledged = excluded.terminal_acknowledged
135 "#,
136 values.params(),
137 )
138 .map_err(sqlite_error)?;
139 Ok(())
140 }
141
142 fn lock(&self) -> CheckpointResult<std::sync::MutexGuard<'_, Connection>> {
143 self.connection.lock().map_err(|_| {
144 CheckpointError::new(
145 "checkpoint_store_lock_poisoned",
146 "SQLite store lock poisoned",
147 )
148 })
149 }
150}
151
152fn initialize_schema(connection: &Connection) -> CheckpointResult<()> {
153 connection
154 .execute_batch("PRAGMA journal_mode=WAL;")
155 .map_err(sqlite_error)?;
156 match schema_sql(connection, "table", "checkpoints")? {
157 None => {
158 connection
159 .execute_batch(CREATE_CHECKPOINTS_TABLE_SQL)
160 .map_err(sqlite_error)?;
161 connection
162 .execute_batch(CREATE_CHECKPOINTS_STATUS_INDEX_SQL)
163 .map_err(sqlite_error)?;
164 }
165 Some(existing) => {
166 if normalize_schema_sql(&existing) != normalize_schema_sql(CREATE_CHECKPOINTS_TABLE_SQL)
167 {
168 return Err(schema_mismatch(
169 "existing checkpoints table does not match the current schema; create a new database",
170 ));
171 }
172 let existing_index = schema_sql(connection, "index", "checkpoints_status_idx")?
173 .ok_or_else(|| {
174 schema_mismatch(
175 "existing checkpoints index does not match the current schema; create a new database",
176 )
177 })?;
178 if normalize_schema_sql(&existing_index)
179 != normalize_schema_sql(CREATE_CHECKPOINTS_STATUS_INDEX_SQL)
180 {
181 return Err(schema_mismatch(
182 "existing checkpoints index does not match the current schema; create a new database",
183 ));
184 }
185 }
186 }
187 Ok(())
188}
189
190fn schema_sql(
191 connection: &Connection,
192 object_type: &str,
193 name: &str,
194) -> CheckpointResult<Option<String>> {
195 connection
196 .query_row(
197 "SELECT sql FROM sqlite_master WHERE type = ?1 AND name = ?2",
198 params![object_type, name],
199 |row| row.get::<_, Option<String>>(0),
200 )
201 .optional()
202 .map(Option::flatten)
203 .map_err(sqlite_error)
204}
205
206fn normalize_schema_sql(sql: &str) -> String {
207 sql.replace("IF NOT EXISTS", "")
208 .split_whitespace()
209 .collect::<Vec<_>>()
210 .join(" ")
211}
212
213fn schema_mismatch(message: &str) -> CheckpointError {
214 CheckpointError::new("checkpoint_store_schema_mismatch", message)
215}
216
217impl CheckpointStore for SqliteCheckpointStore {
218 fn create_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointResult<bool> {
219 checkpoint.validate()?;
220 let values = SqlValues::from_checkpoint(&checkpoint)?;
221 let connection = self.lock()?;
222 let changed = connection
223 .execute(
224 r#"
225 INSERT OR IGNORE INTO checkpoints (
226 checkpoint_key, schema_version, run_definition_schema, run_definition,
227 task_id, root_run_id, trace_id, run_definition_digest, resume_attempt,
228 cycle_index, status, messages, cycles, model_calls, shared_state,
229 budget_usage, event_cursor, event_outbox, extension_state,
230 model_call_journal, tool_journal, revision, claim_token,
231 claimed_cycle, lease_expires_at_ms, terminal_result,
232 terminal_acknowledged
233 ) VALUES (
234 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
235 ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27
236 )
237 "#,
238 values.params(),
239 )
240 .map_err(sqlite_error)?;
241 Ok(changed == 1)
242 }
243
244 fn load_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<Option<Checkpoint>> {
245 let connection = self.lock()?;
246 load_row(&connection, checkpoint_key)
247 }
248
249 fn claim_checkpoint(
250 &self,
251 checkpoint_key: &str,
252 cycle_index: u64,
253 claim_token: &str,
254 lease_expires_at_ms: u64,
255 now_ms: u64,
256 claim_mode: ClaimMode,
257 ) -> CheckpointResult<Option<Checkpoint>> {
258 if claim_token.trim().is_empty() || lease_expires_at_ms <= now_ms {
259 return Err(CheckpointError::new(
260 "checkpoint_claim_invalid",
261 "claim token must be non-empty and lease must be in the future",
262 ));
263 }
264 let mut connection = self.lock()?;
265 let transaction = connection
266 .transaction_with_behavior(TransactionBehavior::Immediate)
267 .map_err(sqlite_error)?;
268 let Some(current) = load_row_transaction(&transaction, checkpoint_key)? else {
269 transaction.commit().map_err(sqlite_error)?;
270 return Ok(None);
271 };
272 if !claim_candidate(¤t, cycle_index, now_ms, claim_mode)? {
273 transaction.commit().map_err(sqlite_error)?;
274 return Ok(None);
275 }
276 let mut claimed = current;
277 apply_claim(
278 &mut claimed,
279 cycle_index,
280 claim_token,
281 lease_expires_at_ms,
282 claim_mode,
283 )?;
284 let values = SqlValues::from_checkpoint(&claimed)?;
285 let changed = update_row(&transaction, &values, Some(claimed.revision - 1), None)?;
286 transaction.commit().map_err(sqlite_error)?;
287 if changed {
288 Ok(Some(claimed))
289 } else {
290 Ok(None)
291 }
292 }
293
294 fn progress_checkpoint(
295 &self,
296 checkpoint: Checkpoint,
297 claim_token: &str,
298 expected_revision: u64,
299 ) -> CheckpointResult<bool> {
300 self.replace_claimed(
301 checkpoint,
302 claim_token,
303 expected_revision,
304 ReplaceKind::Progress,
305 )
306 }
307
308 fn suspend_checkpoint(
309 &self,
310 checkpoint: Checkpoint,
311 claim_token: &str,
312 expected_revision: u64,
313 ) -> CheckpointResult<bool> {
314 self.replace_claimed(
315 checkpoint,
316 claim_token,
317 expected_revision,
318 ReplaceKind::Suspend,
319 )
320 }
321
322 fn commit_checkpoint(
323 &self,
324 checkpoint: Checkpoint,
325 claim_token: &str,
326 expected_revision: u64,
327 ) -> CheckpointResult<bool> {
328 self.replace_claimed(
329 checkpoint,
330 claim_token,
331 expected_revision,
332 ReplaceKind::Commit,
333 )
334 }
335
336 fn finalize_claimed_checkpoint(
337 &self,
338 checkpoint: Checkpoint,
339 claim_token: &str,
340 expected_revision: u64,
341 ) -> CheckpointResult<bool> {
342 self.replace_claimed(
343 checkpoint,
344 claim_token,
345 expected_revision,
346 ReplaceKind::FinalizeClaimed,
347 )
348 }
349
350 fn finalize_checkpoint(
351 &self,
352 checkpoint: Checkpoint,
353 expected_revision: u64,
354 ) -> CheckpointResult<bool> {
355 let mut connection = self.lock()?;
356 let transaction = connection
357 .transaction_with_behavior(TransactionBehavior::Immediate)
358 .map_err(sqlite_error)?;
359 let Some(current) = load_row_transaction(&transaction, &checkpoint.checkpoint_key)? else {
360 transaction.commit().map_err(sqlite_error)?;
361 return Ok(false);
362 };
363 let Some(updated) = prepare_finalize(¤t, checkpoint, expected_revision)? else {
364 transaction.commit().map_err(sqlite_error)?;
365 return Ok(false);
366 };
367 let values = SqlValues::from_checkpoint(&updated)?;
368 let changed = update_row(&transaction, &values, Some(expected_revision), None)?;
369 transaction.commit().map_err(sqlite_error)?;
370 Ok(changed)
371 }
372
373 fn renew_checkpoint_claim(
374 &self,
375 checkpoint_key: &str,
376 claim_token: &str,
377 lease_expires_at_ms: u64,
378 now_ms: u64,
379 ) -> CheckpointResult<bool> {
380 if claim_token.trim().is_empty() || lease_expires_at_ms <= now_ms {
381 return Err(CheckpointError::new(
382 "checkpoint_claim_invalid",
383 "claim token must be non-empty and lease must be in the future",
384 ));
385 }
386 let mut connection = self.lock()?;
387 let transaction = connection
388 .transaction_with_behavior(TransactionBehavior::Immediate)
389 .map_err(sqlite_error)?;
390 let Some(current) = load_row_transaction(&transaction, checkpoint_key)? else {
391 transaction.commit().map_err(sqlite_error)?;
392 return Ok(false);
393 };
394 if current.claim_token.as_deref() != Some(claim_token)
395 || current
396 .lease_expires_at_ms
397 .is_none_or(|expiry| expiry <= now_ms)
398 {
399 transaction.commit().map_err(sqlite_error)?;
400 return Ok(false);
401 }
402 let changed = transaction
403 .execute(
404 "UPDATE checkpoints SET lease_expires_at_ms = ?1 WHERE checkpoint_key = ?2 AND claim_token = ?3 AND lease_expires_at_ms > ?4",
405 params![
406 to_i64(lease_expires_at_ms, "lease_expires_at_ms")?,
407 checkpoint_key,
408 claim_token,
409 to_i64(now_ms, "now_ms")?
410 ],
411 )
412 .map_err(sqlite_error)?;
413 transaction.commit().map_err(sqlite_error)?;
414 Ok(changed == 1)
415 }
416
417 fn acknowledge_terminal(
418 &self,
419 checkpoint_key: &str,
420 expected_revision: u64,
421 ) -> CheckpointResult<bool> {
422 let mut connection = self.lock()?;
423 let transaction = connection
424 .transaction_with_behavior(TransactionBehavior::Immediate)
425 .map_err(sqlite_error)?;
426 let Some(current) = load_row_transaction(&transaction, checkpoint_key)? else {
427 transaction.commit().map_err(sqlite_error)?;
428 return Ok(false);
429 };
430 let Some(updated) = prepare_ack(¤t, expected_revision)? else {
431 transaction.commit().map_err(sqlite_error)?;
432 return Ok(false);
433 };
434 let values = SqlValues::from_checkpoint(&updated)?;
435 let changed = update_row(&transaction, &values, Some(expected_revision), None)?;
436 transaction.commit().map_err(sqlite_error)?;
437 Ok(changed)
438 }
439
440 fn record_event_delivery(
441 &self,
442 checkpoint_key: &str,
443 claim_token: Option<&str>,
444 expected_revision: u64,
445 event_id: &str,
446 payload_digest: &str,
447 cursor: EventCursor,
448 ) -> CheckpointResult<bool> {
449 let mut connection = self.lock()?;
450 let transaction = connection
451 .transaction_with_behavior(TransactionBehavior::Immediate)
452 .map_err(sqlite_error)?;
453 let Some(current) = load_row_transaction(&transaction, checkpoint_key)? else {
454 transaction.commit().map_err(sqlite_error)?;
455 return Ok(false);
456 };
457 let Some(updated) = prepare_event_delivery(
458 ¤t,
459 claim_token,
460 expected_revision,
461 event_id,
462 payload_digest,
463 cursor,
464 )?
465 else {
466 transaction.commit().map_err(sqlite_error)?;
467 return Ok(false);
468 };
469 let values = SqlValues::from_checkpoint(&updated)?;
470 let changed = update_row(&transaction, &values, Some(expected_revision), claim_token)?;
471 transaction.commit().map_err(sqlite_error)?;
472 Ok(changed)
473 }
474
475 fn delete_checkpoint(&self, checkpoint_key: &str) -> CheckpointResult<()> {
476 self.lock()?
477 .execute(
478 "DELETE FROM checkpoints WHERE checkpoint_key = ?1",
479 params![checkpoint_key],
480 )
481 .map_err(sqlite_error)?;
482 Ok(())
483 }
484
485 fn list_checkpoints(&self) -> CheckpointResult<Vec<String>> {
486 let connection = self.lock()?;
487 let mut statement = connection
488 .prepare("SELECT checkpoint_key FROM checkpoints ORDER BY checkpoint_key")
489 .map_err(sqlite_error)?;
490 let rows = statement
491 .query_map([], |row| row.get::<_, String>(0))
492 .map_err(sqlite_error)?;
493 rows.collect::<rusqlite::Result<Vec<_>>>()
494 .map_err(sqlite_error)
495 }
496}
497
498impl SqliteCheckpointStore {
499 fn replace_claimed(
500 &self,
501 checkpoint: Checkpoint,
502 claim_token: &str,
503 expected_revision: u64,
504 kind: ReplaceKind,
505 ) -> CheckpointResult<bool> {
506 let mut connection = self.lock()?;
507 let transaction = connection
508 .transaction_with_behavior(TransactionBehavior::Immediate)
509 .map_err(sqlite_error)?;
510 let Some(current) = load_row_transaction(&transaction, &checkpoint.checkpoint_key)? else {
511 transaction.commit().map_err(sqlite_error)?;
512 return Ok(false);
513 };
514 let updated = match kind {
515 ReplaceKind::Progress => {
516 prepare_progress(¤t, checkpoint, claim_token, expected_revision)?
517 }
518 ReplaceKind::Suspend => {
519 prepare_suspend(¤t, checkpoint, claim_token, expected_revision)?
520 }
521 ReplaceKind::Commit => {
522 prepare_commit(¤t, checkpoint, claim_token, expected_revision)?
523 }
524 ReplaceKind::FinalizeClaimed => {
525 prepare_finalize_claimed(¤t, checkpoint, claim_token, expected_revision)?
526 }
527 };
528 let Some(updated) = updated else {
529 transaction.commit().map_err(sqlite_error)?;
530 return Ok(false);
531 };
532 let values = SqlValues::from_checkpoint(&updated)?;
533 let changed = update_row(
534 &transaction,
535 &values,
536 Some(expected_revision),
537 Some(claim_token),
538 )?;
539 transaction.commit().map_err(sqlite_error)?;
540 Ok(changed)
541 }
542}
543
544#[derive(Clone, Copy)]
545enum ReplaceKind {
546 Progress,
547 Suspend,
548 Commit,
549 FinalizeClaimed,
550}
551
552struct SqlValues {
553 checkpoint_key: String,
554 schema_version: String,
555 run_definition_schema: String,
556 run_definition: String,
557 task_id: String,
558 root_run_id: String,
559 trace_id: String,
560 run_definition_digest: String,
561 resume_attempt: i64,
562 cycle_index: i64,
563 status: String,
564 messages: String,
565 cycles: String,
566 model_calls: String,
567 shared_state: String,
568 budget_usage: Option<String>,
569 event_cursor: Option<String>,
570 event_outbox: String,
571 extension_state: String,
572 model_call_journal: String,
573 tool_journal: String,
574 revision: i64,
575 claim_token: Option<String>,
576 claimed_cycle: Option<i64>,
577 lease_expires_at_ms: Option<i64>,
578 terminal_result: Option<String>,
579 terminal_acknowledged: i64,
580}
581
582impl SqlValues {
583 fn from_checkpoint(checkpoint: &Checkpoint) -> CheckpointResult<Self> {
584 let value = checkpoint_to_value(checkpoint, MAX_EXTENSION_STATE_BYTES)?;
585 let object = value.as_object().expect("codec emits an object");
586 Ok(Self {
587 checkpoint_key: string_field(object, "checkpoint_key")?,
588 schema_version: string_field(object, "schema_version")?,
589 run_definition_schema: string_field(object, "run_definition_schema")?,
590 run_definition: json_field(object, "run_definition")?,
591 task_id: string_field(object, "task_id")?,
592 root_run_id: string_field(object, "root_run_id")?,
593 trace_id: string_field(object, "trace_id")?,
594 run_definition_digest: string_field(object, "run_definition_digest")?,
595 resume_attempt: to_i64(checkpoint.resume_attempt, "resume_attempt")?,
596 cycle_index: to_i64(checkpoint.cycle_index, "cycle_index")?,
597 status: string_field(object, "status")?,
598 messages: json_field(object, "messages")?,
599 cycles: json_field(object, "cycles")?,
600 model_calls: json_field(object, "model_calls")?,
601 shared_state: json_field(object, "shared_state")?,
602 budget_usage: nullable_json_field(object, "budget_usage")?,
603 event_cursor: nullable_json_field(object, "event_cursor")?,
604 event_outbox: json_field(object, "event_outbox")?,
605 extension_state: json_field(object, "extension_state")?,
606 model_call_journal: json_field(object, "model_call_journal")?,
607 tool_journal: json_field(object, "tool_journal")?,
608 revision: to_i64(checkpoint.revision, "revision")?,
609 claim_token: checkpoint.claim_token.clone(),
610 claimed_cycle: checkpoint
611 .claimed_cycle
612 .map(|value| to_i64(value, "claimed_cycle"))
613 .transpose()?,
614 lease_expires_at_ms: checkpoint
615 .lease_expires_at_ms
616 .map(|value| to_i64(value, "lease_expires_at_ms"))
617 .transpose()?,
618 terminal_result: nullable_json_field(object, "terminal_result")?,
619 terminal_acknowledged: i64::from(checkpoint.terminal_acknowledged),
620 })
621 }
622
623 fn params(&self) -> [&(dyn rusqlite::ToSql + Sync); 27] {
624 [
625 &self.checkpoint_key,
626 &self.schema_version,
627 &self.run_definition_schema,
628 &self.run_definition,
629 &self.task_id,
630 &self.root_run_id,
631 &self.trace_id,
632 &self.run_definition_digest,
633 &self.resume_attempt,
634 &self.cycle_index,
635 &self.status,
636 &self.messages,
637 &self.cycles,
638 &self.model_calls,
639 &self.shared_state,
640 &self.budget_usage,
641 &self.event_cursor,
642 &self.event_outbox,
643 &self.extension_state,
644 &self.model_call_journal,
645 &self.tool_journal,
646 &self.revision,
647 &self.claim_token,
648 &self.claimed_cycle,
649 &self.lease_expires_at_ms,
650 &self.terminal_result,
651 &self.terminal_acknowledged,
652 ]
653 }
654}
655
656fn update_row(
657 transaction: &Transaction<'_>,
658 values: &SqlValues,
659 expected_revision: Option<u64>,
660 claim_token: Option<&str>,
661) -> CheckpointResult<bool> {
662 let Some(expected_revision) = expected_revision else {
663 return Err(CheckpointError::new(
664 "checkpoint_revision_conflict",
665 "an expected revision is required for an update",
666 ));
667 };
668 let changed = transaction
669 .execute(
670 r#"
671 UPDATE checkpoints SET
672 schema_version = ?1, run_definition_schema = ?2, run_definition = ?3,
673 task_id = ?4, root_run_id = ?5, trace_id = ?6, run_definition_digest = ?7,
674 resume_attempt = ?8, cycle_index = ?9, status = ?10, messages = ?11,
675 cycles = ?12, model_calls = ?13, shared_state = ?14, budget_usage = ?15,
676 event_cursor = ?16, event_outbox = ?17, extension_state = ?18,
677 model_call_journal = ?19, tool_journal = ?20, revision = ?21,
678 claim_token = ?22, claimed_cycle = ?23, lease_expires_at_ms = ?24,
679 terminal_result = ?25, terminal_acknowledged = ?26
680 WHERE checkpoint_key = ?27 AND revision = ?28
681 AND (?29 IS NULL OR claim_token = ?29)
682 "#,
683 params![
684 values.schema_version,
685 values.run_definition_schema,
686 values.run_definition,
687 values.task_id,
688 values.root_run_id,
689 values.trace_id,
690 values.run_definition_digest,
691 values.resume_attempt,
692 values.cycle_index,
693 values.status,
694 values.messages,
695 values.cycles,
696 values.model_calls,
697 values.shared_state,
698 values.budget_usage,
699 values.event_cursor,
700 values.event_outbox,
701 values.extension_state,
702 values.model_call_journal,
703 values.tool_journal,
704 values.revision,
705 values.claim_token,
706 values.claimed_cycle,
707 values.lease_expires_at_ms,
708 values.terminal_result,
709 values.terminal_acknowledged,
710 values.checkpoint_key,
711 to_i64(expected_revision, "revision")?,
712 claim_token,
713 ],
714 )
715 .map_err(sqlite_error)?;
716 Ok(changed == 1)
717}
718
719fn load_row(connection: &Connection, checkpoint_key: &str) -> CheckpointResult<Option<Checkpoint>> {
720 let mut statement = connection
721 .prepare(
722 r#"
723 SELECT checkpoint_key, schema_version, run_definition_schema, run_definition,
724 task_id, root_run_id, trace_id, run_definition_digest, resume_attempt,
725 cycle_index, status, messages, cycles, model_calls, shared_state,
726 budget_usage, event_cursor, event_outbox, extension_state,
727 model_call_journal, tool_journal, revision, claim_token, claimed_cycle,
728 lease_expires_at_ms, terminal_result, terminal_acknowledged
729 FROM checkpoints WHERE checkpoint_key = ?1
730 "#,
731 )
732 .map_err(sqlite_error)?;
733 statement
734 .query_row(params![checkpoint_key], row_to_checkpoint)
735 .optional()
736 .map_err(sqlite_error)?
737 .transpose()
738}
739
740fn load_row_transaction(
741 transaction: &Transaction<'_>,
742 checkpoint_key: &str,
743) -> CheckpointResult<Option<Checkpoint>> {
744 let mut statement = transaction
745 .prepare(
746 r#"
747 SELECT checkpoint_key, schema_version, run_definition_schema, run_definition,
748 task_id, root_run_id, trace_id, run_definition_digest, resume_attempt,
749 cycle_index, status, messages, cycles, model_calls, shared_state,
750 budget_usage, event_cursor, event_outbox, extension_state,
751 model_call_journal, tool_journal, revision, claim_token, claimed_cycle,
752 lease_expires_at_ms, terminal_result, terminal_acknowledged
753 FROM checkpoints WHERE checkpoint_key = ?1
754 "#,
755 )
756 .map_err(sqlite_error)?;
757 statement
758 .query_row(params![checkpoint_key], row_to_checkpoint)
759 .optional()
760 .map_err(sqlite_error)?
761 .transpose()
762}
763
764fn row_to_checkpoint(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointResult<Checkpoint>> {
765 let checkpoint_key: String = row.get(0)?;
766 let schema_version: String = row.get(1)?;
767 let run_definition_schema: String = row.get(2)?;
768 let run_definition: String = row.get(3)?;
769 let task_id: String = row.get(4)?;
770 let root_run_id: String = row.get(5)?;
771 let trace_id: String = row.get(6)?;
772 let run_definition_digest: String = row.get(7)?;
773 let resume_attempt: i64 = row.get(8)?;
774 let cycle_index: i64 = row.get(9)?;
775 let status: String = row.get(10)?;
776 let messages: String = row.get(11)?;
777 let cycles: String = row.get(12)?;
778 let model_calls: String = row.get(13)?;
779 let shared_state: String = row.get(14)?;
780 let budget_usage: Option<String> = row.get(15)?;
781 let event_cursor: Option<String> = row.get(16)?;
782 let event_outbox: String = row.get(17)?;
783 let extension_state: String = row.get(18)?;
784 let model_call_journal: String = row.get(19)?;
785 let tool_journal: String = row.get(20)?;
786 let revision: i64 = row.get(21)?;
787 let claim_token: Option<String> = row.get(22)?;
788 let claimed_cycle: Option<i64> = row.get(23)?;
789 let lease_expires_at_ms: Option<i64> = row.get(24)?;
790 let terminal_result: Option<String> = row.get(25)?;
791 let terminal_acknowledged: i64 = row.get(26)?;
792
793 let result = (|| {
794 let mut object = Map::new();
795 object.insert("schema_version".to_string(), Value::String(schema_version));
796 object.insert(
797 "run_definition_schema".to_string(),
798 Value::String(run_definition_schema),
799 );
800 object.insert("run_definition".to_string(), parse_value(&run_definition)?);
801 object.insert("checkpoint_key".to_string(), Value::String(checkpoint_key));
802 object.insert("task_id".to_string(), Value::String(task_id));
803 object.insert("root_run_id".to_string(), Value::String(root_run_id));
804 object.insert("trace_id".to_string(), Value::String(trace_id));
805 object.insert(
806 "run_definition_digest".to_string(),
807 Value::String(run_definition_digest),
808 );
809 object.insert(
810 "resume_attempt".to_string(),
811 Value::from(to_u64(resume_attempt)?),
812 );
813 object.insert("cycle_index".to_string(), Value::from(to_u64(cycle_index)?));
814 object.insert("status".to_string(), Value::String(status));
815 object.insert("messages".to_string(), parse_value(&messages)?);
816 object.insert("cycles".to_string(), parse_value(&cycles)?);
817 object.insert("model_calls".to_string(), parse_value(&model_calls)?);
818 object.insert("shared_state".to_string(), parse_value(&shared_state)?);
819 object.insert(
820 "budget_usage".to_string(),
821 optional_value(budget_usage.as_deref())?,
822 );
823 object.insert(
824 "event_cursor".to_string(),
825 optional_value(event_cursor.as_deref())?,
826 );
827 object.insert("event_outbox".to_string(), parse_value(&event_outbox)?);
828 object.insert(
829 "extension_state".to_string(),
830 parse_value(&extension_state)?,
831 );
832 object.insert(
833 "model_call_journal".to_string(),
834 parse_value(&model_call_journal)?,
835 );
836 object.insert("tool_journal".to_string(), parse_value(&tool_journal)?);
837 object.insert("revision".to_string(), Value::from(to_u64(revision)?));
838 object.insert(
839 "claim_token".to_string(),
840 claim_token.map_or(Value::Null, Value::String),
841 );
842 object.insert(
843 "claimed_cycle".to_string(),
844 claimed_cycle.map_or(Ok(Value::Null), |value| to_u64(value).map(Value::from))?,
845 );
846 object.insert(
847 "lease_expires_at_ms".to_string(),
848 lease_expires_at_ms.map_or(Ok(Value::Null), |value| to_u64(value).map(Value::from))?,
849 );
850 object.insert(
851 "terminal_result".to_string(),
852 optional_value(terminal_result.as_deref())?,
853 );
854 object.insert(
855 "terminal_acknowledged".to_string(),
856 Value::Bool(terminal_acknowledged != 0),
857 );
858 checkpoint_from_value(&Value::Object(object), MAX_EXTENSION_STATE_BYTES)
859 })();
860 Ok(result)
861}
862
863fn string_field(object: &Map<String, Value>, field: &str) -> CheckpointResult<String> {
864 object
865 .get(field)
866 .and_then(Value::as_str)
867 .map(str::to_string)
868 .ok_or_else(|| {
869 CheckpointError::new("checkpoint_row_invalid", format!("{field} is not a string"))
870 })
871}
872
873fn json_field(object: &Map<String, Value>, field: &str) -> CheckpointResult<String> {
874 serde_json::to_string(object.get(field).ok_or_else(|| {
875 CheckpointError::new("checkpoint_row_invalid", format!("{field} is missing"))
876 })?)
877 .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))
878}
879
880fn nullable_json_field(
881 object: &Map<String, Value>,
882 field: &str,
883) -> CheckpointResult<Option<String>> {
884 match object.get(field) {
885 None | Some(Value::Null) => Ok(None),
886 Some(value) => serde_json::to_string(value)
887 .map(Some)
888 .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string())),
889 }
890}
891
892fn parse_value(raw: &str) -> CheckpointResult<Value> {
893 serde_json::from_str(raw)
894 .map_err(|error| CheckpointError::new("checkpoint_json_invalid", error.to_string()))
895}
896
897fn optional_value(raw: Option<&str>) -> CheckpointResult<Value> {
898 raw.map_or(Ok(Value::Null), parse_value)
899}
900
901fn to_i64(value: u64, field: &str) -> CheckpointResult<i64> {
902 i64::try_from(value).map_err(|_| {
903 CheckpointError::new(
904 "checkpoint_integer_invalid",
905 format!("{field} does not fit SQLite INTEGER"),
906 )
907 })
908}
909
910fn to_u64(value: i64) -> CheckpointResult<u64> {
911 u64::try_from(value).map_err(|_| {
912 CheckpointError::new(
913 "checkpoint_row_invalid",
914 "negative SQLite integer in checkpoint",
915 )
916 })
917}
918
919fn sqlite_error(error: rusqlite::Error) -> CheckpointError {
920 CheckpointError::new("checkpoint_store_sqlite", error.to_string())
921}