awaken_runtime_contract/contract/
stream_checkpoint.rs1use std::collections::HashMap;
27use std::sync::Mutex;
28
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33use super::executor::InFlightTool;
34use super::message::ToolCall;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct StreamCheckpoint {
39 pub run_id: String,
42 pub thread_id: String,
45 pub upstream_model: String,
47 pub partial_text: String,
49 pub completed_tool_calls: Vec<ToolCall>,
53 pub in_flight_tool: Option<InFlightTool>,
56 pub updated_at_ms: u64,
59}
60
61#[derive(Debug, Error)]
66#[error("stream checkpoint backend error: {0}")]
67pub struct StreamCheckpointError(pub String);
68
69#[async_trait]
76pub trait StreamCheckpointStore: Send + Sync {
77 async fn put(&self, checkpoint: StreamCheckpoint) -> Result<(), StreamCheckpointError>;
79
80 async fn get(&self, run_id: &str) -> Result<Option<StreamCheckpoint>, StreamCheckpointError>;
82
83 async fn delete(&self, run_id: &str) -> Result<(), StreamCheckpointError>;
86}
87
88pub struct InMemoryStreamCheckpointStore {
93 data: Mutex<HashMap<String, StreamCheckpoint>>,
94}
95
96impl Default for InMemoryStreamCheckpointStore {
97 fn default() -> Self {
98 Self {
99 data: Mutex::new(HashMap::new()),
100 }
101 }
102}
103
104impl InMemoryStreamCheckpointStore {
105 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn len(&self) -> usize {
112 self.data.lock().unwrap().len()
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.len() == 0
118 }
119}
120
121#[async_trait]
122impl StreamCheckpointStore for InMemoryStreamCheckpointStore {
123 async fn put(&self, checkpoint: StreamCheckpoint) -> Result<(), StreamCheckpointError> {
124 let mut guard = self.data.lock().unwrap();
125 guard.insert(checkpoint.run_id.clone(), checkpoint);
126 Ok(())
127 }
128
129 async fn get(&self, run_id: &str) -> Result<Option<StreamCheckpoint>, StreamCheckpointError> {
130 Ok(self.data.lock().unwrap().get(run_id).cloned())
131 }
132
133 async fn delete(&self, run_id: &str) -> Result<(), StreamCheckpointError> {
134 self.data.lock().unwrap().remove(run_id);
135 Ok(())
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 fn sample(run_id: &str) -> StreamCheckpoint {
144 StreamCheckpoint {
145 run_id: run_id.into(),
146 thread_id: "thread-1".into(),
147 upstream_model: "test-model".into(),
148 partial_text: "hello".into(),
149 completed_tool_calls: vec![],
150 in_flight_tool: None,
151 updated_at_ms: 1_000,
152 }
153 }
154
155 #[tokio::test]
156 async fn in_memory_put_get_delete_roundtrip() {
157 let store = InMemoryStreamCheckpointStore::new();
158 assert!(store.is_empty());
159
160 store.put(sample("run-a")).await.unwrap();
161 store.put(sample("run-b")).await.unwrap();
162 assert_eq!(store.len(), 2);
163
164 let got = store.get("run-a").await.unwrap().unwrap();
165 assert_eq!(got.run_id, "run-a");
166 assert_eq!(got.partial_text, "hello");
167
168 store.delete("run-a").await.unwrap();
169 assert_eq!(store.len(), 1);
170 assert!(store.get("run-a").await.unwrap().is_none());
171 }
172
173 #[tokio::test]
174 async fn delete_nonexistent_is_not_an_error() {
175 let store = InMemoryStreamCheckpointStore::new();
176 store.delete("no-such-run").await.unwrap();
177 }
178
179 #[tokio::test]
180 async fn put_overwrites_existing_entry() {
181 let store = InMemoryStreamCheckpointStore::new();
182 store.put(sample("run-a")).await.unwrap();
183
184 let updated = StreamCheckpoint {
185 partial_text: "updated text".into(),
186 updated_at_ms: 2_000,
187 ..sample("run-a")
188 };
189 store.put(updated).await.unwrap();
190
191 let got = store.get("run-a").await.unwrap().unwrap();
192 assert_eq!(got.partial_text, "updated text");
193 assert_eq!(got.updated_at_ms, 2_000);
194 }
195
196 #[test]
197 fn checkpoint_serde_roundtrip() {
198 let checkpoint = sample("run-1");
199 let json = serde_json::to_string(&checkpoint).unwrap();
200 let parsed: StreamCheckpoint = serde_json::from_str(&json).unwrap();
201 assert_eq!(parsed.run_id, "run-1");
202 assert_eq!(parsed.partial_text, "hello");
203 }
204}