1use std::collections::BTreeMap;
4
5use serde::Deserialize;
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::BoxFuture;
10use crate::Error;
11use crate::Result;
12use crate::backend::model::ToolCall;
13use crate::backend::sandbox::NetworkAccess;
14use crate::backend::sandbox::SandboxMode;
15use crate::protocol::Event;
16use crate::protocol::EventMsg;
17use crate::protocol::MAX_CAPABILITY_INPUT_BYTES;
18use crate::protocol::MessageTarget;
19use crate::protocol::ModelStepContentPhase;
20use crate::protocol::SessionContext;
21use crate::protocol::TokenUsage;
22
23pub mod sqlite;
24
25pub(crate) const CHECKPOINT_VERSION: u32 = 6;
26pub(crate) const MAX_QUEUED_INPUTS: usize = 1_024;
27const TURN_PAGE_BATCH_SIZE: usize = 100;
28const MAX_QUEUED_OWNER_BYTES: usize = 256;
29const MAX_QUEUED_ID_BYTES: usize = 4 * 1024;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ActiveExecution {
34 pub submission_id: String,
35 pub turn_id: String,
36 pub started_at_ms: i64,
37 pub model_calls: u64,
38 pub tool_calls: u64,
39 pub failed_tool_calls: u64,
40 pub usage: TokenUsage,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ActiveModelStep {
46 pub model_step_id: String,
47 pub step_index: usize,
48 pub started_at_ms: i64,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ExecutionOutcome {
55 Completed,
56 Aborted,
57 Failed,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ExecutionRecord {
63 pub session_id: String,
64 pub submission_id: String,
65 pub turn_id: String,
66 pub started_at_ms: i64,
67 pub finished_at_ms: i64,
68 pub elapsed_ms: u64,
69 pub outcome: ExecutionOutcome,
70 pub model_calls: u64,
71 pub tool_calls: u64,
72 pub failed_tool_calls: u64,
73 pub usage: TokenUsage,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ExecutionStats {
79 pub run_count: u64,
80 pub failed_run_count: u64,
81 pub aborted_run_count: u64,
82 pub model_calls: u64,
83 pub tool_calls: u64,
84 pub failed_tool_calls: u64,
85 pub elapsed_ms: u64,
86 pub usage: TokenUsage,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ContextRewriteReason {
93 ContextOffloading,
94 Compaction,
95 Scratchpad,
96}
97
98impl ContextRewriteReason {
99 pub(crate) const fn as_str(self) -> &'static str {
100 match self {
101 Self::ContextOffloading => "context_offloading",
102 Self::Compaction => "compaction",
103 Self::Scratchpad => "scratchpad",
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ContextRewrite {
111 pub epoch: u64,
112 pub reasons: Vec<ContextRewriteReason>,
113}
114
115impl ExecutionStats {
116 pub(crate) fn checked_record(&mut self, record: &ExecutionRecord) -> Option<()> {
117 let run_count = self.run_count.checked_add(1)?;
118 let failed_run_count = self
119 .failed_run_count
120 .checked_add(u64::from(record.outcome == ExecutionOutcome::Failed))?;
121 let aborted_run_count = self
122 .aborted_run_count
123 .checked_add(u64::from(record.outcome == ExecutionOutcome::Aborted))?;
124 let model_calls = self.model_calls.checked_add(record.model_calls)?;
125 let tool_calls = self.tool_calls.checked_add(record.tool_calls)?;
126 let failed_tool_calls = self
127 .failed_tool_calls
128 .checked_add(record.failed_tool_calls)?;
129 let elapsed_ms = self.elapsed_ms.checked_add(record.elapsed_ms)?;
130 let mut usage = self.usage.clone();
131 usage.checked_add(&record.usage)?;
132 *self = Self {
133 run_count,
134 failed_run_count,
135 aborted_run_count,
136 model_calls,
137 tool_calls,
138 failed_tool_calls,
139 elapsed_ms,
140 usage,
141 };
142 Some(())
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct PendingApproval {
149 pub submission_id: String,
150 pub turn_id: String,
151 pub request_id: String,
152 pub approval_call_ids: Vec<String>,
153 pub authorized_call_ids: Vec<String>,
154 pub calls: Vec<ToolCall>,
155 pub reason: String,
156 pub sandbox_mode: SandboxMode,
157 pub network_access: NetworkAccess,
158 pub decision_received: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct QueuedInput {
164 owner: String,
165 id: String,
166 text: String,
167}
168
169impl QueuedInput {
170 pub(crate) fn new(owner: &str, id: &str, text: &str) -> Result<Self> {
171 validate_queued_input(owner, id, text).map_err(|message| Error::Config(message.into()))?;
172 Ok(Self {
173 owner: owner.into(),
174 id: id.into(),
175 text: text.into(),
176 })
177 }
178
179 pub(crate) fn validate(&self) -> std::result::Result<(), &'static str> {
180 validate_queued_input(&self.owner, &self.id, &self.text)
181 }
182
183 pub(crate) fn owner(&self) -> &str {
184 &self.owner
185 }
186
187 #[must_use]
189 pub fn id(&self) -> &str {
190 &self.id
191 }
192
193 #[must_use]
195 pub fn text(&self) -> &str {
196 &self.text
197 }
198
199 pub(crate) fn into_text(self) -> String {
200 self.text
201 }
202
203 pub(crate) fn into_id_and_text(self) -> (String, String) {
204 (self.id, self.text)
205 }
206}
207
208fn validate_queued_input(
209 owner: &str,
210 id: &str,
211 text: &str,
212) -> std::result::Result<(), &'static str> {
213 if owner.trim().is_empty() || owner.len() > MAX_QUEUED_OWNER_BYTES {
214 return Err("queued input owner is invalid");
215 }
216 if id.trim().is_empty() || id.len() > MAX_QUEUED_ID_BYTES {
217 return Err("queued input ID is invalid");
218 }
219 if text.trim().is_empty() || text.len() > MAX_CAPABILITY_INPUT_BYTES {
220 return Err("queued input text is invalid");
221 }
222 Ok(())
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct Checkpoint {
228 pub version: u32,
229 pub session_id: String,
230 pub session_context: SessionContext,
231 pub metadata: BTreeMap<String, Value>,
232 pub catalog_visible: bool,
233 pub first_user_message: Option<String>,
234 pub model_route: Option<String>,
235 pub sequence: u64,
236 pub context: Vec<Value>,
237 pub context_epoch: u64,
238 pub compaction_count: u64,
239 pub last_context_rewrite: Option<ContextRewrite>,
240 pub total_usage: TokenUsage,
241 pub last_usage: Option<TokenUsage>,
242 pub pending_input: Vec<QueuedInput>,
243 pub active_execution: Option<ActiveExecution>,
244 pub active_model_step: Option<ActiveModelStep>,
245 pub execution_stats: ExecutionStats,
246 pub pending_tools: Vec<ToolCall>,
247 pub pending_approval: Option<PendingApproval>,
248}
249
250impl Checkpoint {
251 #[must_use]
253 pub fn empty(session_id: impl Into<String>) -> Self {
254 Self {
255 version: CHECKPOINT_VERSION,
256 session_id: session_id.into(),
257 session_context: SessionContext::default(),
258 metadata: BTreeMap::new(),
259 catalog_visible: true,
260 first_user_message: None,
261 model_route: None,
262 sequence: 0,
263 context: Vec::new(),
264 context_epoch: 0,
265 compaction_count: 0,
266 last_context_rewrite: None,
267 total_usage: TokenUsage::default(),
268 last_usage: None,
269 pending_input: Vec::new(),
270 active_execution: None,
271 active_model_step: None,
272 execution_stats: ExecutionStats::default(),
273 pending_tools: Vec::new(),
274 pending_approval: None,
275 }
276 }
277
278 pub(crate) fn finish_execution(
279 &mut self,
280 outcome: ExecutionOutcome,
281 finished_at_ms: i64,
282 ) -> Result<ExecutionRecord> {
283 if self.active_model_step.is_some() {
284 return Err(Error::Checkpoint(
285 "turn ended with an active model step".into(),
286 ));
287 }
288 let active = self
289 .active_execution
290 .as_ref()
291 .ok_or_else(|| Error::Checkpoint("turn ended without an active execution".into()))?;
292 let finished_at_ms = finished_at_ms.max(active.started_at_ms);
293 let elapsed_ms = u64::try_from(finished_at_ms - active.started_at_ms)
294 .map_err(|_| Error::Checkpoint("execution elapsed time is unsupported".into()))?;
295 let record = ExecutionRecord {
296 session_id: self.session_id.clone(),
297 submission_id: active.submission_id.clone(),
298 turn_id: active.turn_id.clone(),
299 started_at_ms: active.started_at_ms,
300 finished_at_ms,
301 elapsed_ms,
302 outcome,
303 model_calls: active.model_calls,
304 tool_calls: active.tool_calls,
305 failed_tool_calls: active.failed_tool_calls,
306 usage: active.usage.clone(),
307 };
308 let mut stats = self.execution_stats.clone();
309 stats.checked_record(&record).ok_or_else(|| {
310 Error::Checkpoint("execution statistics exceed the supported range".into())
311 })?;
312 self.active_execution = None;
313 self.execution_stats = stats;
314 Ok(record)
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct SessionSummary {
321 pub session_id: String,
322 pub session_context: SessionContext,
323 pub parent_session_id: Option<String>,
324 pub parent_sequence: Option<u64>,
325 pub sequence: u64,
326 pub catalog_visible: bool,
327 pub first_user_message: Option<String>,
328 pub execution_stats: ExecutionStats,
329 pub created_at: i64,
330 pub updated_at: i64,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct SessionCursor {
336 pub updated_at: i64,
337 pub sequence: u64,
338 pub session_id: String,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct SessionPageRequest {
344 pub cursor: Option<SessionCursor>,
345 pub limit: usize,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub struct SessionPage {
351 pub sessions: Vec<SessionSummary>,
352 pub next_cursor: Option<SessionCursor>,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct TranscriptBatch {
358 pub sequence: u64,
359 pub created_at: i64,
360 pub items: Vec<Value>,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct ExecutionPageRequest {
366 pub before_sequence: Option<u64>,
367 pub limit: usize,
368}
369
370#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
372pub struct ExecutionPage {
373 pub executions: Vec<ExecutionRecord>,
374 pub next_before_sequence: Option<u64>,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct TranscriptPageRequest {
380 pub before_sequence: Option<u64>,
381 pub max_batches: usize,
382}
383
384#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
386pub struct TranscriptPage {
387 pub batches: Vec<TranscriptBatch>,
388 pub next_before_sequence: Option<u64>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393pub struct JournalEvent {
394 pub sequence: u64,
396 pub recorded_at_ms: i64,
398 pub event: Event,
400 pub stream_metrics: Vec<StreamMetrics>,
402}
403
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406pub struct TimestampedEvent {
407 pub recorded_at_ms: i64,
408 pub event: Event,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct StreamMetrics {
414 pub phase: ModelStepContentPhase,
415 pub first_delta_at_ms: i64,
416 pub last_delta_at_ms: i64,
417 pub chunk_count: u64,
418 pub utf8_bytes: u64,
419 pub longest_gap_ms: u64,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct EventPageRequest {
425 pub before_sequence: Option<u64>,
426 pub limit: usize,
427}
428
429#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct EventPage {
432 pub latest_sequence: u64,
434 pub events: Vec<JournalEvent>,
435 pub next_before_sequence: Option<u64>,
436}
437
438impl EventPage {
439 #[must_use]
441 pub fn into_chronological(mut self) -> Vec<JournalEvent> {
442 self.events.reverse();
443 self.events
444 }
445}
446
447pub async fn event_turn_page(
449 checkpoints: &dyn CheckpointStore,
450 session_id: &str,
451 before_sequence: Option<u64>,
452) -> Result<EventPage> {
453 let mut cursor = before_sequence;
454 let mut latest_sequence = 0;
455 let mut events = Vec::new();
456 let mut found_start = false;
457 let mut has_earlier_turn = false;
458
459 loop {
460 let page = checkpoints
461 .event_page(
462 session_id,
463 EventPageRequest {
464 before_sequence: cursor,
465 limit: TURN_PAGE_BATCH_SIZE,
466 },
467 )
468 .await?;
469 if events.is_empty() {
470 latest_sequence = page.latest_sequence;
471 }
472 for event in page.events {
473 if found_start {
474 if matches!(&event.event.msg, EventMsg::TurnStarted(_)) {
475 has_earlier_turn = true;
476 break;
477 }
478 } else {
479 found_start = matches!(&event.event.msg, EventMsg::TurnStarted(_));
480 events.push(event);
481 }
482 }
483 if has_earlier_turn {
484 break;
485 }
486 let Some(next) = page.next_before_sequence else {
487 break;
488 };
489 cursor = Some(next);
490 }
491
492 let Some((start_index, turn_id)) = events.iter().enumerate().find_map(|(index, event)| {
493 let EventMsg::TurnStarted(started) = &event.event.msg else {
494 return None;
495 };
496 Some((index, started.turn_id.as_str()))
497 }) else {
498 return Ok(EventPage {
499 latest_sequence,
500 events: Vec::new(),
501 next_before_sequence: None,
502 });
503 };
504 let page_start = events[..start_index]
505 .iter()
506 .position(|event| match &event.event.msg {
507 EventMsg::TurnComplete(completed) => completed.turn_id == turn_id,
508 EventMsg::TurnAborted(aborted) => aborted.turn_id == turn_id,
509 _ => false,
510 })
511 .unwrap_or(0);
512 let next_before_sequence = has_earlier_turn.then_some(events[start_index].sequence);
513 let events = events.drain(page_start..=start_index).collect();
514
515 Ok(EventPage {
516 latest_sequence,
517 events,
518 next_before_sequence,
519 })
520}
521
522impl TranscriptPage {
523 #[must_use]
525 pub fn into_positioned_items_chronological(self) -> Vec<(MessageTarget, Value)> {
526 self.batches
527 .into_iter()
528 .rev()
529 .flat_map(|batch| {
530 batch
531 .items
532 .into_iter()
533 .enumerate()
534 .map(move |(index, item)| {
535 (
536 MessageTarget {
537 checkpoint_sequence: batch.sequence,
538 batch_item_count: index + 1,
539 },
540 item,
541 )
542 })
543 })
544 .collect()
545 }
546}
547
548pub trait CheckpointStore: Send + Sync {
550 fn load<'a>(&'a self, session_id: &'a str) -> BoxFuture<'a, Result<Option<Checkpoint>>>;
552
553 fn delete_session<'a>(&'a self, session_id: &'a str) -> BoxFuture<'a, Result<bool>>;
557
558 fn save<'a>(
560 &'a self,
561 checkpoint: &'a Checkpoint,
562 transcript_delta: &'a [Value],
563 execution: Option<&'a ExecutionRecord>,
564 ) -> BoxFuture<'a, Result<()>>;
565
566 fn save_with_events<'a>(
568 &'a self,
569 checkpoint: &'a Checkpoint,
570 transcript_delta: &'a [Value],
571 execution: Option<&'a ExecutionRecord>,
572 events: &'a [TimestampedEvent],
573 ) -> BoxFuture<'a, Result<Vec<JournalEvent>>>;
574
575 fn append_event<'a>(
577 &'a self,
578 session_id: &'a str,
579 recorded_at_ms: i64,
580 event: &'a Event,
581 ) -> BoxFuture<'a, Result<JournalEvent>>;
582
583 fn event_page<'a>(
585 &'a self,
586 session_id: &'a str,
587 request: EventPageRequest,
588 ) -> BoxFuture<'a, Result<EventPage>>;
589
590 fn list_sessions_page(
592 &self,
593 _request: SessionPageRequest,
594 ) -> BoxFuture<'_, Result<SessionPage>> {
595 Box::pin(async {
596 Err(Error::Checkpoint(
597 "this checkpoint backend has no session catalog".into(),
598 ))
599 })
600 }
601
602 fn transcript_page<'a>(
604 &'a self,
605 session_id: &'a str,
606 request: TranscriptPageRequest,
607 ) -> BoxFuture<'a, Result<TranscriptPage>> {
608 Box::pin(async move {
609 if request.max_batches == 0 {
610 return Err(Error::Checkpoint(
611 "transcript page limit must be positive".into(),
612 ));
613 }
614 let Some(checkpoint) = self.load(session_id).await? else {
615 return Ok(TranscriptPage::default());
616 };
617 if checkpoint.context.is_empty()
618 || request
619 .before_sequence
620 .is_some_and(|before| checkpoint.sequence >= before)
621 {
622 return Ok(TranscriptPage::default());
623 }
624 Ok(TranscriptPage {
625 batches: vec![TranscriptBatch {
626 sequence: checkpoint.sequence,
627 created_at: 0,
628 items: checkpoint.context,
629 }],
630 next_before_sequence: None,
631 })
632 })
633 }
634
635 fn execution_page<'a>(
637 &'a self,
638 _session_id: &'a str,
639 _request: ExecutionPageRequest,
640 ) -> BoxFuture<'a, Result<ExecutionPage>> {
641 Box::pin(async {
642 Err(Error::Checkpoint(
643 "this checkpoint backend has no execution journal".into(),
644 ))
645 })
646 }
647
648 fn recent_executions(&self, _limit: usize) -> BoxFuture<'_, Result<Vec<ExecutionRecord>>> {
650 Box::pin(async {
651 Err(Error::Checkpoint(
652 "this checkpoint backend has no execution journal".into(),
653 ))
654 })
655 }
656
657 fn fork<'a>(
659 &'a self,
660 _parent_session_id: &'a str,
661 _parent_sequence: u64,
662 _checkpoint: &'a Checkpoint,
663 ) -> BoxFuture<'a, Result<SessionSummary>> {
664 Box::pin(async {
665 Err(Error::Checkpoint(
666 "this checkpoint backend cannot fork sessions".into(),
667 ))
668 })
669 }
670
671 fn load_state<'a>(
673 &'a self,
674 scope: &'a str,
675 key: &'a str,
676 ) -> BoxFuture<'a, Result<Option<Value>>>;
677
678 fn save_state<'a>(
680 &'a self,
681 scope: &'a str,
682 key: &'a str,
683 value: &'a Value,
684 ) -> BoxFuture<'a, Result<()>>;
685}