1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::extension::ExtensionDurableEntry;
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum SessionErrorCode {
12 NotFound,
13 AlreadyExists,
14 Corrupted,
15 StorageFailure,
16 Aborted,
17 Unknown,
18}
19
20#[derive(Clone, Debug, thiserror::Error)]
21#[error("{message}")]
22pub struct SessionError {
23 pub code: SessionErrorCode,
24 pub message: String,
25}
26
27impl SessionError {
28 pub fn new(code: SessionErrorCode, message: impl Into<String>) -> Self {
29 Self {
30 code,
31 message: message.into(),
32 }
33 }
34
35 pub fn corrupted(message: impl Into<String>) -> Self {
36 Self::new(SessionErrorCode::Corrupted, message)
37 }
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct SessionMetadata {
42 pub id: String,
43 #[serde(rename = "createdAt")]
44 pub created_at: String,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct SessionRuntimeContext {
51 #[serde(rename = "workDir")]
52 pub work_dir: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub provider: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub model: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none", rename = "baseUrl")]
58 pub base_url: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub thinking: Option<bool>,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct SessionBinding {
67 #[serde(rename = "clientKey")]
68 pub client_key: String,
69 pub runtime: SessionRuntimeContext,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct JsonlSessionMetadata {
74 #[serde(flatten)]
75 pub base: SessionMetadata,
76 pub cwd: String,
77 pub path: String,
78 #[serde(
79 default,
80 skip_serializing_if = "Option::is_none",
81 rename = "parentSessionPath"
82 )]
83 pub parent_session_path: Option<String>,
84 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 rename = "importedFrom"
88 )]
89 pub imported_from: Option<SessionImportOrigin>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub binding: Option<SessionBinding>,
92 #[serde(
93 default,
94 skip_serializing_if = "Option::is_none",
95 rename = "collapseNodeId"
96 )]
97 pub collapse_node_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub collapsed: Option<bool>,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct SessionImportOrigin {
104 #[serde(rename = "sessionId")]
105 pub session_id: String,
106 pub cwd: String,
107 #[serde(rename = "exportedAt")]
108 pub exported_at: String,
109 #[serde(rename = "thewayVersion")]
110 pub theway_version: String,
111}
112
113#[derive(Clone, Debug, PartialEq)]
116pub struct StoredSessionEntry {
117 pub id: String,
118 pub parent_id: Option<String>,
119 pub entry_type: String,
120 pub timestamp: String,
121 pub payload: Value,
122}
123
124impl StoredSessionEntry {
125 pub fn from_payload(payload: Value) -> Result<Self, SessionError> {
126 let object = payload
127 .as_object()
128 .ok_or_else(|| SessionError::corrupted("session entry must be a JSON object"))?;
129 let string = |field: &str| -> Result<String, SessionError> {
130 object
131 .get(field)
132 .and_then(Value::as_str)
133 .filter(|value| !value.is_empty())
134 .map(str::to_string)
135 .ok_or_else(|| {
136 SessionError::corrupted(format!("session entry has invalid {field}"))
137 })
138 };
139 let parent_id = match object.get("parentId") {
140 None | Some(Value::Null) => None,
141 Some(Value::String(value)) => Some(value.clone()),
142 Some(_) => {
143 return Err(SessionError::corrupted(
144 "session entry has invalid parentId",
145 ));
146 }
147 };
148 let entry = Self {
149 id: string("id")?,
150 parent_id,
151 entry_type: string("type")?,
152 timestamp: string("timestamp")?,
153 payload,
154 };
155 entry.validate_shape()?;
156 Ok(entry)
157 }
158
159 pub fn leaf(
160 id: String,
161 parent_id: Option<String>,
162 timestamp: String,
163 target_id: Option<String>,
164 ) -> Result<Self, SessionError> {
165 Self::from_payload(serde_json::json!({
166 "type": "leaf",
167 "id": id,
168 "parentId": parent_id,
169 "timestamp": timestamp,
170 "targetId": target_id,
171 }))
172 }
173
174 pub fn session_info(
175 id: String,
176 parent_id: Option<String>,
177 timestamp: String,
178 name: String,
179 ) -> Result<Self, SessionError> {
180 Self::from_payload(serde_json::json!({
181 "type": "session_info",
182 "id": id,
183 "parentId": parent_id,
184 "timestamp": timestamp,
185 "name": name,
186 }))
187 }
188
189 pub fn collapse(
190 id: String,
191 parent_id: Option<String>,
192 timestamp: String,
193 source_session_id: String,
194 child_session_id: String,
195 compact_text: String,
196 raw_text_ref: String,
197 collapsed_at: String,
198 ) -> Result<Self, SessionError> {
199 Self::from_payload(serde_json::json!({
200 "type": "collapse",
201 "id": id,
202 "parentId": parent_id,
203 "timestamp": timestamp,
204 "sourceSessionId": source_session_id,
205 "childSessionId": child_session_id,
206 "compactText": compact_text,
207 "rawTextRef": raw_text_ref,
208 "collapsedAt": collapsed_at,
209 }))
210 }
211
212 pub fn session_graph_state(
213 id: String,
214 parent_id: Option<String>,
215 timestamp: String,
216 data: Value,
217 ) -> Result<Self, SessionError> {
218 Self::from_payload(serde_json::json!({
219 "type": "custom",
220 "id": id,
221 "parentId": parent_id,
222 "timestamp": timestamp,
223 "customType": "session_graph_state",
224 "data": data,
225 }))
226 }
227
228 pub fn extension(
229 id: String,
230 parent_id: Option<String>,
231 timestamp: String,
232 extension: ExtensionDurableEntry,
233 ) -> Result<Self, SessionError> {
234 extension
235 .validate()
236 .map_err(|error| SessionError::corrupted(error.to_string()))?;
237 Self::from_payload(serde_json::json!({
238 "type": "extension",
239 "id": id,
240 "parentId": parent_id,
241 "timestamp": timestamp,
242 "extension": extension,
243 }))
244 }
245
246 pub fn leaf_target_id(&self) -> Option<Option<&str>> {
247 (self.entry_type == "leaf").then(|| self.payload.get("targetId").and_then(Value::as_str))
248 }
249
250 pub fn label_update(&self) -> Option<(&str, Option<&str>)> {
251 if self.entry_type != "label" {
252 return None;
253 }
254 let target = self.payload.get("targetId")?.as_str()?;
255 let label = self.payload.get("label").and_then(Value::as_str);
256 Some((target, label))
257 }
258
259 pub fn extension_payload(&self) -> Result<Option<ExtensionDurableEntry>, SessionError> {
260 if self.entry_type != "extension" {
261 return Ok(None);
262 }
263 let extension =
264 self.payload.get("extension").cloned().ok_or_else(|| {
265 SessionError::corrupted("extension session entry has no envelope")
266 })?;
267 let extension: ExtensionDurableEntry = serde_json::from_value(extension)
268 .map_err(|error| SessionError::corrupted(error.to_string()))?;
269 extension
270 .validate()
271 .map_err(|error| SessionError::corrupted(error.to_string()))?;
272 Ok(Some(extension))
273 }
274
275 fn validate_shape(&self) -> Result<(), SessionError> {
276 let object = self.payload.as_object().expect("validated object");
277 let require_string = |field: &str| {
278 object
279 .get(field)
280 .and_then(Value::as_str)
281 .map(|_| ())
282 .ok_or_else(|| {
283 SessionError::corrupted(format!(
284 "{} session entry has invalid {field}",
285 self.entry_type
286 ))
287 })
288 };
289 match self.entry_type.as_str() {
290 "message" => {
291 if !object.get("message").is_some_and(Value::is_object) {
292 return Err(SessionError::corrupted(
293 "message session entry has invalid message",
294 ));
295 }
296 }
297 "thinking_level_change" => require_string("thinkingLevel")?,
298 "model_change" => {
299 require_string("provider")?;
300 require_string("modelId")?;
301 }
302 "compaction" => {
303 require_string("summary")?;
304 require_string("firstKeptEntryId")?;
305 if !object.get("tokensBefore").is_some_and(Value::is_u64) {
306 return Err(SessionError::corrupted(
307 "compaction session entry has invalid tokensBefore",
308 ));
309 }
310 }
311 "branch_summary" => {
312 require_string("fromId")?;
313 require_string("summary")?;
314 }
315 "extension" => {
316 self.extension_payload()?;
317 }
318 "custom" => {
319 require_string("customType")?;
320 if object.get("customType").and_then(Value::as_str) == Some("session_graph_state") {
321 let data = object.get("data").ok_or_else(|| {
322 SessionError::corrupted(
323 "session_graph_state custom entry has no data object",
324 )
325 })?;
326 if !data.is_object() {
327 return Err(SessionError::corrupted(
328 "session_graph_state custom entry has invalid data",
329 ));
330 }
331 if !data.get("dags").is_some_and(Value::is_array) {
332 return Err(SessionError::corrupted(
333 "session_graph_state custom entry has invalid dags",
334 ));
335 }
336 if !data.get("subagents").is_some_and(Value::is_array) {
337 return Err(SessionError::corrupted(
338 "session_graph_state custom entry has invalid subagents",
339 ));
340 }
341 }
342 }
343 "custom_message" => {
344 require_string("customType")?;
345 if !object.contains_key("content")
346 || !object.get("display").is_some_and(Value::is_boolean)
347 {
348 return Err(SessionError::corrupted(
349 "custom_message session entry has invalid content or display",
350 ));
351 }
352 }
353 "collapse" => {
354 require_string("sourceSessionId")?;
355 require_string("childSessionId")?;
356 require_string("compactText")?;
357 require_string("rawTextRef")?;
358 require_string("collapsedAt")?;
359 }
360 "label" => require_string("targetId")?,
361 "session_info" => {
362 if object.get("name").is_some_and(|value| !value.is_string()) {
363 return Err(SessionError::corrupted(
364 "session_info session entry has invalid name",
365 ));
366 }
367 }
368 "leaf" => {
369 if object
370 .get("targetId")
371 .is_some_and(|value| !value.is_null() && !value.is_string())
372 {
373 return Err(SessionError::corrupted(
374 "leaf session entry has invalid targetId",
375 ));
376 }
377 }
378 other => {
379 return Err(SessionError::corrupted(format!(
380 "unknown session entry type {other}"
381 )));
382 }
383 }
384 Ok(())
385 }
386}
387
388pub fn validate_session_entries(
390 entries: &[StoredSessionEntry],
391) -> Result<Option<String>, SessionError> {
392 let mut seen = std::collections::HashSet::new();
393 let mut active_leaf_id = None;
394 for entry in entries {
395 if !seen.insert(entry.id.clone()) {
396 return Err(SessionError::corrupted(
397 "session transcript contains duplicate entry id",
398 ));
399 }
400 if let Some(parent) = &entry.parent_id
401 && !seen.contains(parent)
402 {
403 return Err(SessionError::corrupted(
404 "session transcript contains dangling parent reference",
405 ));
406 }
407 active_leaf_id = match entry.leaf_target_id() {
408 Some(Some(target)) if !seen.contains(target) => {
409 return Err(SessionError::corrupted(
410 "session transcript contains dangling leaf target",
411 ));
412 }
413 Some(target) => target.map(str::to_string),
414 None => Some(entry.id.clone()),
415 };
416 }
417 Ok(active_leaf_id)
418}
419
420#[async_trait]
421pub trait SessionReader: Send + Sync {
422 async fn get_metadata_json(&self) -> Result<Value, SessionError>;
423 async fn get_leaf_id(&self) -> Result<Option<String>, SessionError>;
424 async fn get_entry(&self, id: &str) -> Result<Option<StoredSessionEntry>, SessionError>;
425 async fn get_entries(&self) -> Result<Vec<StoredSessionEntry>, SessionError>;
426 async fn get_path_to_root(
427 &self,
428 leaf_id: Option<&str>,
429 ) -> Result<Vec<StoredSessionEntry>, SessionError>;
430 async fn find_entries(&self, entry_type: &str)
431 -> Result<Vec<StoredSessionEntry>, SessionError>;
432 async fn get_label(&self, id: &str) -> Result<Option<String>, SessionError>;
433
434 async fn get_extension_entries(
437 &self,
438 extension_id: &str,
439 leaf_id: Option<&str>,
440 ) -> Result<Vec<StoredSessionEntry>, SessionError> {
441 let selected_leaf = match leaf_id {
442 Some(id) => Some(id.to_string()),
443 None => self.get_leaf_id().await?,
444 };
445 let path = self.get_path_to_root(selected_leaf.as_deref()).await?;
446 let mut entries = Vec::new();
447 for stored in path {
448 let Some(extension) = stored.extension_payload()? else {
449 continue;
450 };
451 if extension.extension_id == extension_id {
452 entries.push(stored);
453 }
454 }
455 Ok(entries)
456 }
457}
458
459#[async_trait]
460pub trait SessionStore: SessionReader {
461 async fn set_leaf_id(&self, id: Option<String>) -> Result<(), SessionError>;
462 async fn create_entry_id(&self) -> Result<String, SessionError>;
463 async fn append_entries(&self, entries: Vec<StoredSessionEntry>) -> Result<(), SessionError>;
466
467 async fn set_binding(&self, _binding: Option<SessionBinding>) -> Result<(), SessionError> {
470 Err(SessionError::new(
471 SessionErrorCode::StorageFailure,
472 "session store does not support binding updates",
473 ))
474 }
475
476 async fn set_collapse_node_id(&self, _id: Option<String>) -> Result<(), SessionError> {
480 Err(SessionError::new(
481 SessionErrorCode::StorageFailure,
482 "session store does not support collapse metadata",
483 ))
484 }
485
486 async fn set_collapsed(&self, _collapsed: bool) -> Result<(), SessionError> {
489 Err(SessionError::new(
490 SessionErrorCode::StorageFailure,
491 "session store does not support collapse metadata",
492 ))
493 }
494
495 async fn append_entry(&self, entry: StoredSessionEntry) -> Result<(), SessionError> {
496 self.append_entries(vec![entry]).await
497 }
498}