1use std::fs::OpenOptions;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use rusqlite::{Connection, OptionalExtension, params};
8use talos_core::submission::{
9 PendingSubmissionState, StructuredSubmission, SubmissionKind, SubmissionReceiptDisposition,
10 SubmissionRejectionReason,
11};
12use thiserror::Error;
13use uuid::Uuid;
14
15use crate::runtime_state::{
16 SessionRuntimeActivation, SessionRuntimeActivationStatus, SessionRuntimeIdentity,
17 SessionRuntimeState,
18};
19use crate::turn_outcome::decode_turn_transcript_outcome;
20use crate::{CompactTextSessionStore, JsonlSessionStore, SessionStore, TurnTranscriptOutcome};
21
22const SCHEMA_VERSION: i64 = 1;
23const RUNTIME_GENERATION_KEY: &str = "runtime_generation";
24mod codec;
25mod storage;
26
27use codec::*;
28use storage::*;
29
30#[derive(Debug, Error)]
32pub enum PendingSubmissionError {
33 #[error("pending submission journal failed: {0}")]
35 Storage(#[from] rusqlite::Error),
36 #[error("pending submission payload failed: {0}")]
38 Payload(#[from] serde_json::Error),
39 #[error("pending submission journal I/O failed: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("pending submission transcript inspection failed: {0}")]
44 Transcript(#[from] crate::SessionError),
45 #[error("pending submission journal lock poisoned")]
47 LockPoisoned,
48 #[error("unsupported pending submission journal schema version: {0}")]
50 UnsupportedSchema(i64),
51 #[error("pending submission record not found")]
53 MissingRecord,
54 #[error("invalid pending submission state transition")]
56 InvalidTransition,
57 #[error("runtime generation conflict: expected {expected}, found {actual}")]
59 GenerationConflict {
60 expected: u64,
62 actual: u64,
64 },
65 #[error("runtime generation {generation} still owns {pending} non-terminal submission(s)")]
67 GenerationBusy {
68 generation: u64,
70 pending: usize,
72 },
73 #[error("runtime generation exhausted")]
75 GenerationExhausted,
76 #[error("invalid Session runtime activation: {0}")]
78 InvalidRuntimeActivation(String),
79 #[error("Session runtime activation conflict: existing {existing}, requested {requested}")]
81 RuntimeActivationConflict { existing: String, requested: String },
82}
83
84#[derive(Debug, Clone, PartialEq)]
86pub struct PendingSubmissionRecord {
87 pub receipt_id: String,
89 pub payload_fingerprint: String,
91 pub submission: StructuredSubmission,
93 pub state: PendingSubmissionState,
95 pub turn_id: Option<String>,
97}
98
99#[derive(Debug, Clone)]
101pub struct PendingSubmissionStore {
102 path: Arc<PathBuf>,
103 session_file: Arc<PathBuf>,
104 session_id: Arc<String>,
105 lock: Arc<Mutex<()>>,
106}
107
108impl PendingSubmissionStore {
109 #[must_use]
111 pub fn for_session(session: &crate::Session) -> Self {
112 Self::for_session_file(&session.file_path, &session.id.to_string())
113 }
114
115 #[must_use]
117 pub fn for_session_file(session_file: &Path, session_id: &str) -> Self {
118 let parent = session_file.parent().unwrap_or_else(|| Path::new("."));
119 Self {
120 path: Arc::new(parent.join(format!("{session_id}.pending.sqlite"))),
121 session_file: Arc::new(session_file.to_path_buf()),
122 session_id: Arc::new(session_id.to_owned()),
123 lock: Arc::new(Mutex::new(())),
124 }
125 }
126
127 #[must_use]
129 pub fn path(&self) -> &Path {
130 self.path.as_ref()
131 }
132
133 pub fn runtime_generation(&self) -> Result<u64, PendingSubmissionError> {
139 let _guard = self.guard()?;
140 let mut connection = self.connection()?;
141 let transaction = immediate(&mut connection)?;
142 ensure_schema(&transaction)?;
143 let generation = load_or_initialize_runtime_generation(&transaction)?;
144 transaction.commit()?;
145 Ok(generation)
146 }
147
148 pub fn runtime_state(&self) -> Result<Option<SessionRuntimeState>, PendingSubmissionError> {
150 let _guard = self.guard()?;
151 let connection = self.connection()?;
152 ensure_schema(&connection)?;
153 load_runtime_state(&connection)
154 }
155
156 pub fn has_nonterminal_submissions(&self) -> Result<bool, PendingSubmissionError> {
158 if !self.path.exists() {
159 return Ok(false);
160 }
161 let _guard = self.guard()?;
162 let connection = self.connection()?;
163 ensure_schema(&connection)?;
164 Ok(count_nonterminal(&connection)? > 0)
165 }
166
167 pub fn initialize_runtime_identity(
173 &self,
174 identity: SessionRuntimeIdentity,
175 ) -> Result<SessionRuntimeState, PendingSubmissionError> {
176 self.ensure_transcript_owner_marker()?;
177 let _guard = self.guard()?;
178 let mut connection = self.connection()?;
179 let transaction = immediate(&mut connection)?;
180 ensure_schema(&transaction)?;
181 let generation = load_or_initialize_runtime_generation(&transaction)?;
182 let activation = SessionRuntimeActivation::new(generation, identity.clone(), identity);
183 if let Some(existing) = load_runtime_state(&transaction)? {
184 if existing.activation == activation
185 && existing.status == SessionRuntimeActivationStatus::Committed
186 {
187 transaction.commit()?;
188 return Ok(existing);
189 }
190 return Err(PendingSubmissionError::RuntimeActivationConflict {
191 existing: existing.activation.activation_id,
192 requested: activation.activation_id,
193 });
194 }
195 write_runtime_state(
196 &transaction,
197 &SessionRuntimeState {
198 activation: activation.clone(),
199 status: SessionRuntimeActivationStatus::Committed,
200 },
201 )?;
202 transaction.commit()?;
203 Ok(SessionRuntimeState {
204 activation,
205 status: SessionRuntimeActivationStatus::Committed,
206 })
207 }
208
209 pub fn stage_runtime_activation(
217 &self,
218 expected: u64,
219 activation: &SessionRuntimeActivation,
220 ) -> Result<u64, PendingSubmissionError> {
221 if !activation.is_valid() {
222 return Err(PendingSubmissionError::InvalidRuntimeActivation(
223 "activation digest/version mismatch".to_string(),
224 ));
225 }
226 let expected_next = expected
227 .checked_add(1)
228 .ok_or(PendingSubmissionError::GenerationExhausted)?;
229 if activation.generation != expected_next {
230 return Err(PendingSubmissionError::InvalidRuntimeActivation(format!(
231 "activation generation {} does not follow expected generation {expected}",
232 activation.generation
233 )));
234 }
235
236 let _guard = self.guard()?;
237 let mut connection = self.connection()?;
238 let transaction = immediate(&mut connection)?;
239 ensure_schema(&transaction)?;
240 let current = load_or_initialize_runtime_generation(&transaction)?;
241
242 if current == activation.generation {
243 let existing = load_runtime_state(&transaction)?.ok_or_else(|| {
244 PendingSubmissionError::InvalidRuntimeActivation(
245 "generation advanced without runtime activation state".to_string(),
246 )
247 })?;
248 if existing.activation == *activation {
249 transaction.commit()?;
250 return Ok(current);
251 }
252 return Err(PendingSubmissionError::RuntimeActivationConflict {
253 existing: existing.activation.activation_id,
254 requested: activation.activation_id.clone(),
255 });
256 }
257 if current != expected {
258 return Err(PendingSubmissionError::GenerationConflict {
259 expected,
260 actual: current,
261 });
262 }
263 let pending = count_nonterminal(&transaction)?;
264 if pending > 0 {
265 return Err(PendingSubmissionError::GenerationBusy {
266 generation: current,
267 pending,
268 });
269 }
270 transaction.execute(
271 "UPDATE pending_journal_meta SET value = ?2 WHERE key = ?1",
272 params![RUNTIME_GENERATION_KEY, to_i64(activation.generation)],
273 )?;
274 write_runtime_state(
275 &transaction,
276 &SessionRuntimeState {
277 activation: activation.clone(),
278 status: SessionRuntimeActivationStatus::PendingMarker,
279 },
280 )?;
281 transaction.commit()?;
282 Ok(activation.generation)
283 }
284
285 pub fn commit_runtime_activation(
287 &self,
288 activation_id: &str,
289 ) -> Result<SessionRuntimeState, PendingSubmissionError> {
290 let _guard = self.guard()?;
291 let mut connection = self.connection()?;
292 let transaction = immediate(&mut connection)?;
293 ensure_schema(&transaction)?;
294 let mut state = load_runtime_state(&transaction)?.ok_or_else(|| {
295 PendingSubmissionError::InvalidRuntimeActivation(
296 "no staged runtime activation exists".to_string(),
297 )
298 })?;
299 if state.activation.activation_id != activation_id {
300 return Err(PendingSubmissionError::RuntimeActivationConflict {
301 existing: state.activation.activation_id,
302 requested: activation_id.to_string(),
303 });
304 }
305 let generation = load_or_initialize_runtime_generation(&transaction)?;
306 if generation != state.activation.generation {
307 return Err(PendingSubmissionError::InvalidRuntimeActivation(format!(
308 "runtime state generation {} differs from durable generation {generation}",
309 state.activation.generation
310 )));
311 }
312 state.status = SessionRuntimeActivationStatus::Committed;
313 write_runtime_state(&transaction, &state)?;
314 transaction.commit()?;
315 Ok(state)
316 }
317
318 pub fn advance_runtime_generation(&self, expected: u64) -> Result<u64, PendingSubmissionError> {
326 let _guard = self.guard()?;
327 let mut connection = self.connection()?;
328 let transaction = immediate(&mut connection)?;
329 ensure_schema(&transaction)?;
330 let current = load_or_initialize_runtime_generation(&transaction)?;
331 if current != expected {
332 return Err(PendingSubmissionError::GenerationConflict {
333 expected,
334 actual: current,
335 });
336 }
337 let pending = count_nonterminal(&transaction)?;
338 if pending > 0 {
339 return Err(PendingSubmissionError::GenerationBusy {
340 generation: current,
341 pending,
342 });
343 }
344 let next = current
345 .checked_add(1)
346 .ok_or(PendingSubmissionError::GenerationExhausted)?;
347 transaction.execute(
348 "UPDATE pending_journal_meta SET value = ?2 WHERE key = ?1",
349 params![RUNTIME_GENERATION_KEY, to_i64(next)],
350 )?;
351 transaction.commit()?;
352 Ok(next)
353 }
354
355 pub fn accept(
357 &self,
358 submission: &StructuredSubmission,
359 ) -> Result<(String, SubmissionReceiptDisposition), PendingSubmissionError> {
360 if let Err(reason) = submission.validate() {
361 return Ok(rejected(reason));
362 }
363 let encoded = serde_json::to_string(submission)?;
364 let fingerprint = fingerprint(encoded.as_bytes());
365 let text_bytes = submission.total_text_bytes();
366 let (image_count, image_bytes) = submission.image_totals();
367 let generation = match i64::try_from(submission.sender_generation) {
368 Ok(value) => value,
369 Err(_) => return Ok(rejected(SubmissionRejectionReason::InvalidStructure)),
370 };
371
372 let _guard = self.guard()?;
373 let mut connection = self.connection()?;
374 let transaction = immediate(&mut connection)?;
375 ensure_schema(&transaction)?;
376
377 if let Some(row) = lookup(&transaction, &submission.id)? {
378 let disposition = if identity_matches(&row, &fingerprint, &encoded) {
379 SubmissionReceiptDisposition::AlreadyAccepted {
380 state: decode_state(&row.state)?,
381 turn_id: row.turn_id,
382 }
383 } else {
384 SubmissionReceiptDisposition::Rejected {
385 reason: SubmissionRejectionReason::IdentityConflict,
386 }
387 };
388 transaction.commit()?;
389 return Ok((row.receipt_id, disposition));
390 }
391
392 let runtime_generation = load_or_initialize_runtime_generation(&transaction)?;
393 if submission.sender_generation != runtime_generation {
394 transaction.commit()?;
395 return Ok(rejected(SubmissionRejectionReason::WrongGeneration));
396 }
397
398 prune_tombstones(&transaction)?;
399 if exceeds_pending_bounds(&transaction, text_bytes, image_count, image_bytes)? {
400 transaction.commit()?;
401 return Ok(rejected(SubmissionRejectionReason::LimitExceeded));
402 }
403
404 let receipt_id = Uuid::new_v4().to_string();
405 let reservation_id = format!("reservation:{}", submission.id);
406 transaction.execute(
407 "INSERT INTO pending_submissions (
408 batch_id, reservation_id, session_id, session_generation,
409 receipt_id, fingerprint, submission_json, text_bytes,
410 image_count, image_bytes, state, turn_id
411 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
412 'accepted_pending', NULL)",
413 params![
414 submission.id,
415 reservation_id,
416 self.session_id.as_str(),
417 generation,
418 receipt_id,
419 fingerprint,
420 encoded,
421 to_i64(text_bytes),
422 to_i64(image_count),
423 to_i64(image_bytes),
424 ],
425 )?;
426 transaction.commit()?;
427 Ok((receipt_id, SubmissionReceiptDisposition::AcceptedPending))
428 }
429
430 pub fn reconcile(
432 &self,
433 submission: &StructuredSubmission,
434 ) -> Result<(String, SubmissionReceiptDisposition), PendingSubmissionError> {
435 if let Err(reason) = submission.validate() {
436 return Ok(rejected(reason));
437 }
438 let encoded = serde_json::to_string(submission)?;
439 let payload_fingerprint = fingerprint(encoded.as_bytes());
440 let _guard = self.guard()?;
441 let connection = self.connection()?;
442 ensure_schema(&connection)?;
443 Ok(match lookup(&connection, &submission.id)? {
444 Some(row) if identity_matches(&row, &payload_fingerprint, &encoded) => (
445 row.receipt_id,
446 SubmissionReceiptDisposition::AlreadyAccepted {
447 state: decode_state(&row.state)?,
448 turn_id: row.turn_id,
449 },
450 ),
451 Some(row) => (
452 row.receipt_id,
453 SubmissionReceiptDisposition::Rejected {
454 reason: SubmissionRejectionReason::IdentityConflict,
455 },
456 ),
457 None => (String::new(), SubmissionReceiptDisposition::NotAccepted),
458 })
459 }
460
461 pub fn mark_running(
463 &self,
464 submission_id: &str,
465 turn_id: &str,
466 ) -> Result<(), PendingSubmissionError> {
467 self.transition(
468 submission_id,
469 PendingSubmissionState::Running,
470 Some(turn_id),
471 &[
472 PendingSubmissionState::AcceptedPending,
473 PendingSubmissionState::PausedPending,
474 ],
475 )
476 }
477
478 pub fn mark_paused(&self, submission_id: &str) -> Result<(), PendingSubmissionError> {
480 self.transition(
481 submission_id,
482 PendingSubmissionState::PausedPending,
483 None,
484 &[
485 PendingSubmissionState::AcceptedPending,
486 PendingSubmissionState::PausedPending,
487 ],
488 )
489 }
490
491 pub fn pause_unstarted(&self) -> Result<usize, PendingSubmissionError> {
493 let _guard = self.guard()?;
494 let mut connection = self.connection()?;
495 let transaction = immediate(&mut connection)?;
496 ensure_schema(&transaction)?;
497 let changed = transaction.execute(
498 "UPDATE pending_submissions SET state = 'paused_pending'
499 WHERE state = 'accepted_pending'",
500 [],
501 )?;
502 transaction.commit()?;
503 Ok(changed)
504 }
505
506 pub fn cancel_unstarted(&self, submission_id: &str) -> Result<(), PendingSubmissionError> {
512 self.transition(
513 submission_id,
514 PendingSubmissionState::TerminalCancelled,
515 None,
516 &[
517 PendingSubmissionState::AcceptedPending,
518 PendingSubmissionState::PausedPending,
519 PendingSubmissionState::TerminalCancelled,
520 ],
521 )
522 }
523
524 pub fn mark_terminal(
526 &self,
527 submission_id: &str,
528 state: PendingSubmissionState,
529 turn_id: &str,
530 ) -> Result<(), PendingSubmissionError> {
531 if !matches!(
532 state,
533 PendingSubmissionState::TerminalCancelled | PendingSubmissionState::TerminalError
534 ) {
535 return Err(PendingSubmissionError::InvalidTransition);
536 }
537 self.transition(
538 submission_id,
539 state,
540 Some(turn_id),
541 &[PendingSubmissionState::Running, state],
542 )
543 }
544
545 pub fn mark_committed(
554 &self,
555 submission_id: &str,
556 turn_id: &str,
557 ) -> Result<(), PendingSubmissionError> {
558 let preview = self.get(submission_id)?.is_some_and(|record| {
559 record.submission.common_kind() == Some(SubmissionKind::PreviewRequest)
560 });
561 if preview {
562 return self.transition(
563 submission_id,
564 PendingSubmissionState::Committed,
565 Some(turn_id),
566 &[
567 PendingSubmissionState::Running,
568 PendingSubmissionState::Committed,
569 ],
570 );
571 }
572
573 let outcome = self.transcript_outcome_for_turn(turn_id)?;
574 match outcome {
575 Some(TurnTranscriptOutcome::Success) => self.transition(
576 submission_id,
577 PendingSubmissionState::Committed,
578 Some(turn_id),
579 &[
580 PendingSubmissionState::Running,
581 PendingSubmissionState::Committed,
582 ],
583 ),
584 Some(TurnTranscriptOutcome::Cancelled) => self.mark_terminal(
585 submission_id,
586 PendingSubmissionState::TerminalCancelled,
587 turn_id,
588 ),
589 Some(TurnTranscriptOutcome::Error) => self.mark_terminal(
590 submission_id,
591 PendingSubmissionState::TerminalError,
592 turn_id,
593 ),
594 None if !self.session_file.exists() => self.transition(
595 submission_id,
596 PendingSubmissionState::Committed,
597 Some(turn_id),
598 &[
599 PendingSubmissionState::Running,
600 PendingSubmissionState::Committed,
601 ],
602 ),
603 None => Err(PendingSubmissionError::InvalidTransition),
604 }
605 }
606
607 pub fn recover_unstarted(
609 &self,
610 ) -> Result<Vec<PendingSubmissionRecord>, PendingSubmissionError> {
611 self.recover_states(&["accepted_pending", "paused_pending"])
612 }
613
614 pub fn recover_running(&self) -> Result<Vec<PendingSubmissionRecord>, PendingSubmissionError> {
616 self.recover_states(&["running"])
617 }
618
619 fn recover_states(
620 &self,
621 states: &[&str],
622 ) -> Result<Vec<PendingSubmissionRecord>, PendingSubmissionError> {
623 let _guard = self.guard()?;
624 let connection = self.connection()?;
625 ensure_schema(&connection)?;
626 let state_filter = states
627 .iter()
628 .map(|state| format!("'{state}'"))
629 .collect::<Vec<_>>()
630 .join(", ");
631 let sql = format!(
632 "SELECT receipt_id, fingerprint, submission_json, state, turn_id
633 FROM pending_submissions
634 WHERE state IN ({state_filter})
635 ORDER BY rowid ASC"
636 );
637 let mut statement = connection.prepare(&sql)?;
638 let rows = statement.query_map([], read_record_tuple)?;
639 rows.map(|row| tuple_to_record(row?)).collect()
640 }
641
642 pub fn get(
644 &self,
645 submission_id: &str,
646 ) -> Result<Option<PendingSubmissionRecord>, PendingSubmissionError> {
647 let _guard = self.guard()?;
648 let connection = self.connection()?;
649 ensure_schema(&connection)?;
650 connection
651 .query_row(
652 "SELECT receipt_id, fingerprint, submission_json, state, turn_id
653 FROM pending_submissions WHERE batch_id = ?1",
654 params![submission_id],
655 read_record_tuple,
656 )
657 .optional()?
658 .map(tuple_to_record)
659 .transpose()
660 }
661
662 fn transcript_outcome_for_turn(
663 &self,
664 turn_id: &str,
665 ) -> Result<Option<TurnTranscriptOutcome>, PendingSubmissionError> {
666 if !self.session_file.exists() {
667 return Ok(None);
668 }
669 let entries = if self
670 .session_file
671 .extension()
672 .and_then(|value| value.to_str())
673 == Some("jsonl")
674 {
675 JsonlSessionStore.read_entries(self.session_file.as_ref())?
676 } else {
677 CompactTextSessionStore.read_entries(self.session_file.as_ref())?
678 };
679 Ok(entries
680 .into_iter()
681 .filter_map(|entry| decode_turn_transcript_outcome(&entry.content))
682 .filter(|record| record.turn_id == turn_id)
683 .map(|record| record.outcome)
684 .next_back())
685 }
686
687 fn transition(
688 &self,
689 submission_id: &str,
690 next: PendingSubmissionState,
691 turn_id: Option<&str>,
692 expected: &[PendingSubmissionState],
693 ) -> Result<(), PendingSubmissionError> {
694 let _guard = self.guard()?;
695 let mut connection = self.connection()?;
696 let transaction = immediate(&mut connection)?;
697 ensure_schema(&transaction)?;
698 let current = transaction
699 .query_row(
700 "SELECT state FROM pending_submissions WHERE batch_id = ?1",
701 params![submission_id],
702 |row| row.get::<_, String>(0),
703 )
704 .optional()?
705 .ok_or(PendingSubmissionError::MissingRecord)?;
706 if !expected.contains(&decode_state(¤t)?) {
707 return Err(PendingSubmissionError::InvalidTransition);
708 }
709 transaction.execute(
710 "UPDATE pending_submissions SET state = ?2, turn_id = ?3 WHERE batch_id = ?1",
711 params![submission_id, encode_state(next), turn_id],
712 )?;
713 prune_tombstones(&transaction)?;
714 transaction.commit()?;
715 Ok(())
716 }
717
718 fn guard(&self) -> Result<std::sync::MutexGuard<'_, ()>, PendingSubmissionError> {
719 self.lock
720 .lock()
721 .map_err(|_| PendingSubmissionError::LockPoisoned)
722 }
723
724 fn ensure_transcript_owner_marker(&self) -> Result<(), PendingSubmissionError> {
725 if self.session_file.exists() {
726 return Ok(());
727 }
728 if let Some(parent) = self.session_file.parent() {
729 std::fs::create_dir_all(parent)?;
730 }
731 match OpenOptions::new()
732 .write(true)
733 .create_new(true)
734 .open(self.session_file.as_ref())
735 {
736 Ok(_) => Ok(()),
737 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
738 Err(error) => Err(error.into()),
739 }
740 }
741
742 fn connection(&self) -> Result<Connection, PendingSubmissionError> {
743 if let Some(parent) = self.path.parent() {
744 std::fs::create_dir_all(parent)?;
745 }
746 let connection = Connection::open(self.path.as_ref())?;
747 connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
748 retry_sqlite_busy(SQLITE_BUSY_TIMEOUT, || {
749 connection.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")
750 })?;
751 Ok(connection)
752 }
753}
754
755#[cfg(test)]
756mod tests;