1use std::sync::Arc;
4
5use ag_agent::{self as agent, AgentKind, AgentModel, ReasoningLevel, SessionStats, SpeedMode};
6use ag_session::{FocusedReviewStatus, SessionMessageKind};
7use async_trait::async_trait;
8use sqlx::SqlitePool;
9use tracing::warn;
10
11use super::review::SessionReviewRequestRow;
12use super::session_message::SessionMessageStore;
13use super::session_snapshot::SessionSnapshotStore;
14use super::status;
15use crate::DbError;
16use crate::timestamp::TimestampSource;
17
18pub struct SessionTurnMetadata {
28 pub applied_personality_id: Option<String>,
31 pub applied_personality_prompt_hash: Option<String>,
34 pub instruction_conversation_id: Option<String>,
36 pub model: String,
38 pub provider_conversation_id: Option<String>,
40 pub questions_json: String,
42 pub summary: String,
44 pub token_usage_delta: SessionStats,
46}
47
48pub struct PersistedSessionCreation<'a> {
51 pub agent: &'a str,
53 pub base_branch: &'a str,
55 pub id: &'a str,
57 pub is_draft: bool,
59 pub model: &'a str,
61 pub orchestration_task_id: Option<i64>,
63 pub parent_session_id: Option<&'a str>,
65 pub personality_id: Option<&'a str>,
67 pub project_id: i64,
69 pub reasoning_level: ReasoningLevel,
71 pub role: Option<&'a str>,
73 pub speed_mode: SpeedMode,
75 pub status: &'a str,
77}
78
79pub struct ForkSessionSnapshot<'a> {
81 pub new_session_id: &'a str,
83 pub source_session_id: &'a str,
86 pub status: &'a str,
88}
89
90pub struct SessionRow {
96 pub added_lines: i64,
98 pub agent: String,
100 pub base_branch: String,
102 pub created_at: i64,
104 pub deleted_lines: i64,
106 pub has_diff: Option<bool>,
109 pub id: String,
111 pub in_progress_started_at: Option<i64>,
113 pub in_progress_total_seconds: i64,
115 pub input_tokens: i64,
117 pub is_draft: bool,
119 pub model: String,
121 pub output_tokens: i64,
123 pub parent_session_id: Option<String>,
125 pub personality_id: Option<String>,
127 pub project_id: Option<i64>,
129 pub prompt: String,
131 pub published_upstream_ref: Option<String>,
133 pub questions: Option<String>,
135 pub reasoning_level_override: Option<String>,
137 pub review_request: Option<SessionReviewRequestRow>,
139 pub role: Option<String>,
141 pub size: String,
143 pub speed_mode: String,
145 pub status: String,
147 pub summary: Option<String>,
149 pub title: Option<String>,
151 pub updated_at: i64,
153}
154
155pub struct SessionListRow {
161 pub added_lines: i64,
163 pub agent: String,
165 pub base_branch: String,
167 pub created_at: i64,
169 pub deleted_lines: i64,
171 pub has_diff: Option<bool>,
174 pub id: String,
176 pub in_progress_started_at: Option<i64>,
178 pub in_progress_total_seconds: i64,
180 pub input_tokens: i64,
182 pub is_draft: bool,
184 pub model: String,
186 pub output_tokens: i64,
188 pub parent_session_id: Option<String>,
190 pub personality_id: Option<String>,
192 pub project_id: Option<i64>,
194 pub published_upstream_ref: Option<String>,
196 pub reasoning_level_override: Option<String>,
198 pub review_request: Option<SessionReviewRequestRow>,
200 pub role: Option<String>,
202 pub size: String,
204 pub speed_mode: String,
206 pub status: String,
208 pub title: Option<String>,
210 pub updated_at: i64,
212}
213
214#[derive(sqlx::FromRow)]
216pub struct SessionAgentModelRow {
217 pub agent: String,
219 pub id: String,
221 pub model: String,
223 pub status: String,
225}
226
227pub struct SessionDetailRow {
229 pub prompt: String,
231 pub questions: Option<String>,
233 pub summary: Option<String>,
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct SessionMessageRow {
240 pub content: String,
242 pub kind: String,
244 pub position: i64,
246}
247
248#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct SessionFocusedReviewRow {
251 pub diff_hash: String,
253 pub session_id: String,
255 pub text: String,
257}
258
259#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct SessionPersonalityState {
262 pub applied_personality_id: Option<String>,
264 pub applied_personality_prompt_hash: Option<String>,
266 pub personality_id: Option<String>,
268}
269
270#[async_trait]
272pub trait SessionRepository: Send + Sync {
273 async fn append_session_message(
276 &self,
277 id: &str,
278 kind: SessionMessageKind,
279 content: &str,
280 ) -> Result<(), DbError>;
281
282 async fn backfill_session_project(&self, project_id: i64) -> Result<(), DbError>;
284
285 async fn delete_session(&self, id: &str) -> Result<(), DbError>;
287
288 async fn get_session_base_branch(&self, id: &str) -> Result<Option<String>, DbError>;
290
291 async fn get_session_parent_session_id(&self, id: &str) -> Result<Option<String>, DbError>;
293
294 async fn get_session_stack_base_commit_hash(&self, id: &str)
297 -> Result<Option<String>, DbError>;
298
299 async fn get_session_instruction_conversation_id(
302 &self,
303 id: &str,
304 ) -> Result<Option<String>, DbError>;
305
306 async fn get_session_provider_conversation_id(
309 &self,
310 id: &str,
311 ) -> Result<Option<String>, DbError>;
312
313 async fn insert_draft_session(
315 &self,
316 id: &str,
317 model: &str,
318 base_branch: &str,
319 status: &str,
320 project_id: i64,
321 ) -> Result<(), DbError>;
322
323 async fn insert_stacked_draft_session(
325 &self,
326 id: &str,
327 model: &str,
328 base_branch: &str,
329 status: &str,
330 parent_session_id: &str,
331 project_id: i64,
332 ) -> Result<(), DbError>;
333
334 async fn insert_session(
336 &self,
337 id: &str,
338 model: &str,
339 base_branch: &str,
340 status: &str,
341 project_id: i64,
342 ) -> Result<(), DbError>;
343
344 async fn insert_session_with_agent(
346 &self,
347 session: PersistedSessionCreation<'_>,
348 ) -> Result<(), DbError>;
349
350 async fn fork_session_snapshot(&self, snapshot: ForkSessionSnapshot<'_>)
353 -> Result<(), DbError>;
354
355 async fn load_session(&self, session_id: &str) -> Result<Option<SessionRow>, DbError>;
357
358 async fn load_active_session_agent_models(&self) -> Result<Vec<SessionAgentModelRow>, DbError>;
361
362 #[cfg(any(test, feature = "test-utils"))]
363 async fn load_sessions(&self) -> Result<Vec<SessionRow>, DbError>;
365
366 async fn load_sessions_for_project(
369 &self,
370 project_id: i64,
371 ) -> Result<Vec<SessionListRow>, DbError>;
372
373 async fn load_session_detail(
375 &self,
376 session_id: &str,
377 ) -> Result<Option<SessionDetailRow>, DbError>;
378
379 async fn load_session_messages(
381 &self,
382 session_id: &str,
383 ) -> Result<Vec<SessionMessageRow>, DbError>;
384
385 async fn load_session_focused_reviews_for_project(
387 &self,
388 project_id: i64,
389 ) -> Result<Vec<SessionFocusedReviewRow>, DbError>;
390
391 async fn load_sessions_metadata(&self) -> Result<(i64, i64), DbError>;
393
394 async fn load_session_project_id(&self, session_id: &str) -> Result<Option<i64>, DbError>;
396
397 async fn load_session_personality_state(
399 &self,
400 session_id: &str,
401 ) -> Result<Option<SessionPersonalityState>, DbError>;
402
403 async fn load_pending_stack_restack_session_ids(
406 &self,
407 project_id: i64,
408 ) -> Result<Vec<String>, DbError>;
409
410 async fn load_session_published_upstream_ref(
413 &self,
414 id: &str,
415 ) -> Result<Option<String>, DbError>;
416
417 async fn load_session_merged_commit_hash(
419 &self,
420 session_id: &str,
421 ) -> Result<Option<String>, DbError>;
422
423 async fn load_session_archived_diff(&self, session_id: &str)
425 -> Result<Option<String>, DbError>;
426
427 async fn restack_child_sessions_after_parent_merge(
431 &self,
432 parent_session_id: &str,
433 base_branch: &str,
434 parent_commit_hash: Option<String>,
435 ) -> Result<Vec<String>, DbError>;
436
437 async fn load_session_reasoning_level(
439 &self,
440 session_id: &str,
441 ) -> Result<ReasoningLevel, DbError>;
442
443 async fn load_session_speed_mode(&self, session_id: &str) -> Result<SpeedMode, DbError>;
445
446 async fn load_session_summary(&self, session_id: &str) -> Result<Option<String>, DbError>;
448
449 async fn load_session_timestamps(
451 &self,
452 session_id: &str,
453 ) -> Result<Option<(i64, i64)>, DbError>;
454
455 async fn persist_session_turn_metadata(
458 &self,
459 session_id: &str,
460 turn_metadata: &SessionTurnMetadata,
461 ) -> Result<(), DbError>;
462
463 async fn mark_session_diff_unknown(&self, id: &str) -> Result<(), DbError>;
466
467 async fn update_session_diff_stats(
470 &self,
471 added_lines: u64,
472 deleted_lines: u64,
473 has_diff: bool,
474 id: &str,
475 size: &str,
476 ) -> Result<(), DbError>;
477
478 async fn update_session_instruction_conversation_id(
481 &self,
482 id: &str,
483 provider_conversation_id: Option<String>,
484 ) -> Result<(), DbError>;
485
486 async fn update_session_model(&self, id: &str, model: &str) -> Result<(), DbError>;
488
489 async fn update_session_personality_id(
491 &self,
492 id: &str,
493 personality_id: Option<String>,
494 ) -> Result<(), DbError>;
495
496 async fn update_session_agent_model(
498 &self,
499 id: &str,
500 agent: &str,
501 model: &str,
502 ) -> Result<(), DbError>;
503
504 async fn update_active_session_agent_model(
507 &self,
508 id: &str,
509 agent: &str,
510 model: &str,
511 ) -> Result<(), DbError>;
512
513 async fn clear_session_draft_flag(&self, id: &str) -> Result<(), DbError>;
516
517 async fn update_session_merged_commit_hash(
519 &self,
520 id: &str,
521 merged_commit_hash: Option<String>,
522 ) -> Result<(), DbError>;
523
524 async fn update_session_archived_diff(
526 &self,
527 id: &str,
528 archived_diff: Option<String>,
529 ) -> Result<(), DbError>;
530
531 async fn update_session_stack_base_commit_hash(
534 &self,
535 id: &str,
536 stack_base_commit_hash: Option<String>,
537 ) -> Result<(), DbError>;
538
539 async fn update_session_prompt(&self, id: &str, prompt: &str) -> Result<(), DbError>;
541
542 async fn update_session_provider_conversation_id(
544 &self,
545 id: &str,
546 provider_conversation_id: Option<String>,
547 ) -> Result<(), DbError>;
548
549 async fn update_session_questions(&self, id: &str, questions: &str) -> Result<(), DbError>;
551
552 async fn update_session_reasoning_level(
554 &self,
555 id: &str,
556 reasoning_level: ReasoningLevel,
557 ) -> Result<(), DbError>;
558
559 async fn update_session_speed_mode(
561 &self,
562 id: &str,
563 speed_mode: SpeedMode,
564 ) -> Result<(), DbError>;
565
566 async fn update_session_published_upstream_ref(
569 &self,
570 id: &str,
571 published_upstream_ref: Option<String>,
572 ) -> Result<(), DbError>;
573
574 async fn update_session_stats(&self, id: &str, stats: &SessionStats) -> Result<(), DbError>;
576
577 async fn update_session_status_with_timing_at(
581 &self,
582 id: &str,
583 status: &str,
584 timestamp_seconds: i64,
585 ) -> Result<(), DbError>;
586
587 async fn update_session_summary(&self, id: &str, summary: &str) -> Result<(), DbError>;
589
590 async fn update_session_focused_review(
592 &self,
593 id: &str,
594 status: Option<FocusedReviewStatus>,
595 diff_hash: Option<String>,
596 text: Option<String>,
597 ) -> Result<(), DbError>;
598
599 async fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError>;
601
602 async fn update_session_provisional_title(&self, id: &str, title: &str) -> Result<(), DbError>;
605
606 async fn begin_session_title_generation(
611 &self,
612 id: &str,
613 requires_provisional_title: bool,
614 ) -> Result<Option<i64>, DbError>;
615
616 async fn update_session_title_for_generation(
619 &self,
620 id: &str,
621 expected_generation: i64,
622 title: &str,
623 ) -> Result<bool, DbError>;
624
625 #[cfg(any(test, feature = "test-utils"))]
627 async fn update_session_created_at(&self, id: &str, created_at: i64) -> Result<(), DbError>;
628
629 #[cfg(any(test, feature = "test-utils"))]
630 async fn update_session_updated_at(&self, id: &str, updated_at: i64) -> Result<(), DbError>;
632}
633
634#[derive(Clone)]
636pub(crate) struct SqliteSessionRepository(
637 SqlitePool,
638 Arc<dyn TimestampSource>,
639 SessionMessageStore,
640 SessionSnapshotStore,
641);
642
643impl SqliteSessionRepository {
644 pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
646 Self(
647 pool.clone(),
648 Arc::clone(×tamp_source),
649 SessionMessageStore::new(pool.clone(), Arc::clone(×tamp_source)),
650 SessionSnapshotStore::new(pool, timestamp_source),
651 )
652 }
653
654 fn now(&self) -> i64 {
656 self.1.now_timestamp_seconds()
657 }
658}
659
660struct RequiredStringValueRow {
662 value: String,
663}
664
665struct SessionStatsMetadataRow {
667 max_updated_at: i64,
669 session_count: i64,
671}
672
673struct OptionalI64ValueRow {
675 value: Option<i64>,
676}
677
678struct SessionInstructionStateRow {
681 app_server_instruction_provider_conversation_id: Option<String>,
682}
683
684impl SessionInstructionStateRow {
685 fn into_instruction_conversation_id(self) -> Option<String> {
688 agent::normalize_instruction_conversation_id(
689 self.app_server_instruction_provider_conversation_id
690 .as_deref(),
691 )
692 }
693}
694
695struct SessionTimestampsRow {
697 created_at: i64,
698 updated_at: i64,
699}
700
701struct SessionRowMetadata {
704 added_lines: i64,
705 agent: String,
706 base_branch: String,
707 created_at: i64,
708 deleted_lines: i64,
709 has_diff: Option<bool>,
710 id: String,
711 in_progress_started_at: Option<i64>,
712 in_progress_total_seconds: i64,
713 input_tokens: i64,
714 is_draft: bool,
715 model: String,
716 output_tokens: i64,
717 parent_session_id: Option<String>,
718 personality_id: Option<String>,
719 project_id: Option<i64>,
720 published_upstream_ref: Option<String>,
721 reasoning_level_override: Option<String>,
722 role: Option<String>,
723 size: String,
724 speed_mode: String,
725 status: String,
726 title: Option<String>,
727 updated_at: i64,
728}
729
730impl SessionRowMetadata {
731 fn into_session_row(
733 self,
734 prompt: String,
735 questions: Option<String>,
736 summary: Option<String>,
737 review_request: Option<SessionReviewRequestRow>,
738 ) -> SessionRow {
739 SessionRow {
740 added_lines: self.added_lines,
741 agent: self.agent,
742 base_branch: self.base_branch,
743 created_at: self.created_at,
744 deleted_lines: self.deleted_lines,
745 has_diff: self.has_diff,
746 id: self.id,
747 in_progress_started_at: self.in_progress_started_at,
748 in_progress_total_seconds: self.in_progress_total_seconds,
749 input_tokens: self.input_tokens,
750 is_draft: self.is_draft,
751 model: self.model,
752 output_tokens: self.output_tokens,
753 parent_session_id: self.parent_session_id,
754 personality_id: self.personality_id,
755 project_id: self.project_id,
756 prompt,
757 published_upstream_ref: self.published_upstream_ref,
758 questions,
759 reasoning_level_override: self.reasoning_level_override,
760 review_request,
761 role: self.role,
762 size: self.size,
763 speed_mode: self.speed_mode,
764 status: self.status,
765 summary,
766 title: self.title,
767 updated_at: self.updated_at,
768 }
769 }
770
771 fn into_session_list_row(
773 self,
774 review_request: Option<SessionReviewRequestRow>,
775 ) -> SessionListRow {
776 SessionListRow {
777 added_lines: self.added_lines,
778 agent: self.agent,
779 base_branch: self.base_branch,
780 created_at: self.created_at,
781 deleted_lines: self.deleted_lines,
782 has_diff: self.has_diff,
783 id: self.id,
784 in_progress_started_at: self.in_progress_started_at,
785 in_progress_total_seconds: self.in_progress_total_seconds,
786 input_tokens: self.input_tokens,
787 is_draft: self.is_draft,
788 model: self.model,
789 output_tokens: self.output_tokens,
790 parent_session_id: self.parent_session_id,
791 personality_id: self.personality_id,
792 project_id: self.project_id,
793 published_upstream_ref: self.published_upstream_ref,
794 reasoning_level_override: self.reasoning_level_override,
795 review_request,
796 role: self.role,
797 size: self.size,
798 speed_mode: self.speed_mode,
799 status: self.status,
800 title: self.title,
801 updated_at: self.updated_at,
802 }
803 }
804}
805
806#[derive(sqlx::FromRow)]
809struct SessionJoinRow {
810 added_lines: i64,
811 agent: String,
812 base_branch: String,
813 created_at: i64,
814 deleted_lines: i64,
815 has_diff: Option<bool>,
816 id: String,
817 in_progress_started_at: Option<i64>,
818 in_progress_total_seconds: i64,
819 input_tokens: i64,
820 is_draft: bool,
821 model: String,
822 output_tokens: i64,
823 parent_session_id: Option<String>,
824 personality_id: Option<String>,
825 project_id: Option<i64>,
826 prompt: String,
827 published_upstream_ref: Option<String>,
828 questions: Option<String>,
829 reasoning_level_override: Option<String>,
830 review_request_display_id: Option<String>,
831 review_request_forge_kind: Option<String>,
832 review_request_last_refreshed_at: Option<i64>,
833 review_request_source_branch: Option<String>,
834 review_request_state: Option<String>,
835 review_request_status_summary: Option<String>,
836 review_request_target_branch: Option<String>,
837 review_request_title: Option<String>,
838 review_request_web_url: Option<String>,
839 role: Option<String>,
840 size: String,
841 speed_mode: String,
842 status: String,
843 summary: Option<String>,
844 title: Option<String>,
845 updated_at: i64,
846}
847
848impl SessionJoinRow {
849 fn has_loadable_status(&self) -> bool {
854 if let Err(error) = status::validate_session(&self.status) {
855 warn!(
856 session_id = %self.id,
857 %error,
858 "Skipping session with invalid persisted status"
859 );
860
861 return false;
862 }
863
864 true
865 }
866
867 fn into_session_row(self) -> SessionRow {
869 let (metadata, detail, review_request) = self.into_parts();
870
871 metadata.into_session_row(
872 detail.prompt,
873 detail.questions,
874 detail.summary,
875 review_request,
876 )
877 }
878
879 fn into_session_list_row(self) -> SessionListRow {
882 let (metadata, _, review_request) = self.into_parts();
883
884 metadata.into_session_list_row(review_request)
885 }
886
887 fn into_parts(
890 self,
891 ) -> (
892 SessionRowMetadata,
893 SessionDetailRow,
894 Option<SessionReviewRequestRow>,
895 ) {
896 let Self {
897 added_lines,
898 agent,
899 base_branch,
900 created_at,
901 deleted_lines,
902 has_diff,
903 id,
904 in_progress_started_at,
905 in_progress_total_seconds,
906 input_tokens,
907 is_draft,
908 model,
909 output_tokens,
910 parent_session_id,
911 personality_id,
912 project_id,
913 prompt,
914 published_upstream_ref,
915 questions,
916 reasoning_level_override,
917 review_request_display_id,
918 review_request_forge_kind,
919 review_request_last_refreshed_at,
920 review_request_source_branch,
921 review_request_state,
922 review_request_status_summary,
923 review_request_target_branch,
924 review_request_title,
925 review_request_web_url,
926 role,
927 size,
928 speed_mode,
929 status,
930 summary,
931 title,
932 updated_at,
933 } = self;
934
935 let metadata = SessionRowMetadata {
936 added_lines,
937 agent,
938 base_branch,
939 created_at,
940 deleted_lines,
941 has_diff,
942 id,
943 in_progress_started_at,
944 in_progress_total_seconds,
945 input_tokens,
946 is_draft,
947 model,
948 output_tokens,
949 parent_session_id,
950 personality_id,
951 project_id,
952 published_upstream_ref,
953 reasoning_level_override,
954 role,
955 size,
956 speed_mode,
957 status,
958 title,
959 updated_at,
960 };
961 let detail = SessionDetailRow {
962 prompt,
963 questions,
964 summary,
965 };
966 let review_request = SessionReviewRequestJoinRow {
967 display_id: review_request_display_id,
968 forge_kind: review_request_forge_kind,
969 last_refreshed_at: review_request_last_refreshed_at,
970 source_branch: review_request_source_branch,
971 state: review_request_state,
972 status_summary: review_request_status_summary,
973 target_branch: review_request_target_branch,
974 title: review_request_title,
975 web_url: review_request_web_url,
976 }
977 .into_review_request_row();
978
979 (metadata, detail, review_request)
980 }
981}
982
983struct SessionReviewRequestJoinRow {
986 display_id: Option<String>,
987 forge_kind: Option<String>,
988 last_refreshed_at: Option<i64>,
989 source_branch: Option<String>,
990 state: Option<String>,
991 status_summary: Option<String>,
992 target_branch: Option<String>,
993 title: Option<String>,
994 web_url: Option<String>,
995}
996
997impl SessionReviewRequestJoinRow {
998 fn into_review_request_row(self) -> Option<SessionReviewRequestRow> {
1001 let Self {
1002 display_id,
1003 forge_kind,
1004 last_refreshed_at,
1005 source_branch,
1006 state,
1007 status_summary,
1008 target_branch,
1009 title,
1010 web_url,
1011 } = self;
1012
1013 Some(SessionReviewRequestRow {
1014 display_id: display_id?,
1015 forge_kind: forge_kind?,
1016 last_refreshed_at: last_refreshed_at?,
1017 source_branch: source_branch?,
1018 state: state?,
1019 status_summary,
1020 target_branch: target_branch?,
1021 title: title?,
1022 web_url: web_url?,
1023 })
1024 }
1025}
1026
1027#[async_trait]
1028impl SessionRepository for SqliteSessionRepository {
1029 async fn append_session_message(
1030 &self,
1031 id: &str,
1032 kind: SessionMessageKind,
1033 content: &str,
1034 ) -> Result<(), DbError> {
1035 self.2.append(id, kind, content).await
1036 }
1037
1038 async fn backfill_session_project(&self, project_id: i64) -> Result<(), DbError> {
1039 let now = self.now();
1040
1041 sqlx::query!(
1042 r"
1043UPDATE session
1044SET project_id = ?,
1045 updated_at = ?
1046WHERE project_id IS NULL
1047",
1048 project_id,
1049 now
1050 )
1051 .execute(&self.0)
1052 .await?;
1053
1054 Ok(())
1055 }
1056
1057 async fn delete_session(&self, id: &str) -> Result<(), DbError> {
1058 let now = self.now();
1059 let mut transaction = self.0.begin().await?;
1060
1061 sqlx::query!(
1068 r"
1069UPDATE session
1070SET parent_session_id = NULL,
1071 base_branch = COALESCE((SELECT base_branch FROM session WHERE id = ?), base_branch),
1072 updated_at = ?
1073WHERE parent_session_id = ?
1074 AND status <> 'Canceled'
1075",
1076 id,
1077 now,
1078 id
1079 )
1080 .execute(&mut *transaction)
1081 .await?;
1082
1083 sqlx::query!(
1084 r"
1085DELETE FROM session
1086WHERE id = ?
1087",
1088 id
1089 )
1090 .execute(&mut *transaction)
1091 .await?;
1092
1093 transaction.commit().await?;
1094
1095 Ok(())
1096 }
1097
1098 async fn get_session_base_branch(&self, id: &str) -> Result<Option<String>, DbError> {
1099 let row = sqlx::query_as!(
1100 RequiredStringValueRow,
1101 r#"
1102SELECT base_branch AS "value!: _"
1103FROM session
1104WHERE id = ?
1105"#,
1106 id
1107 )
1108 .fetch_optional(&self.0)
1109 .await?;
1110
1111 Ok(row.map(|row| row.value))
1112 }
1113
1114 async fn get_session_parent_session_id(&self, id: &str) -> Result<Option<String>, DbError> {
1115 let value = sqlx::query_scalar!(
1116 r"
1117SELECT parent_session_id
1118FROM session
1119WHERE id = ?
1120",
1121 id
1122 )
1123 .fetch_optional(&self.0)
1124 .await?
1125 .flatten();
1126
1127 Ok(value)
1128 }
1129
1130 async fn get_session_stack_base_commit_hash(
1131 &self,
1132 id: &str,
1133 ) -> Result<Option<String>, DbError> {
1134 let value = sqlx::query_scalar!(
1135 r"
1136SELECT stack_base_commit_hash
1137FROM session
1138WHERE id = ?
1139",
1140 id
1141 )
1142 .fetch_optional(&self.0)
1143 .await?
1144 .flatten();
1145
1146 Ok(value)
1147 }
1148
1149 async fn get_session_instruction_conversation_id(
1150 &self,
1151 id: &str,
1152 ) -> Result<Option<String>, DbError> {
1153 let row = sqlx::query_as!(
1154 SessionInstructionStateRow,
1155 r"
1156SELECT app_server_instruction_provider_conversation_id
1157FROM session
1158WHERE id = ?
1159",
1160 id
1161 )
1162 .fetch_optional(&self.0)
1163 .await?;
1164
1165 Ok(row.and_then(SessionInstructionStateRow::into_instruction_conversation_id))
1166 }
1167
1168 async fn get_session_provider_conversation_id(
1169 &self,
1170 id: &str,
1171 ) -> Result<Option<String>, DbError> {
1172 let value = sqlx::query_scalar!(
1173 r"SELECT provider_conversation_id FROM session WHERE id = ?",
1174 id
1175 )
1176 .fetch_optional(&self.0)
1177 .await?
1178 .flatten();
1179
1180 Ok(value)
1181 }
1182
1183 async fn insert_draft_session(
1184 &self,
1185 id: &str,
1186 model: &str,
1187 base_branch: &str,
1188 status: &str,
1189 project_id: i64,
1190 ) -> Result<(), DbError> {
1191 let agent = persisted_agent_for_model(model);
1192
1193 insert_session_with_draft_mode(
1194 &self.0,
1195 self.now(),
1196 InsertSessionRow {
1197 agent: &agent,
1198 base_branch,
1199 id,
1200 is_draft: true,
1201 model,
1202 orchestration_task_id: None,
1203 parent_session_id: None,
1204 personality_id: None,
1205 project_id,
1206 reasoning_level: ReasoningLevel::default(),
1207 role: None,
1208 speed_mode: SpeedMode::Normal,
1209 status,
1210 },
1211 )
1212 .await
1213 }
1214
1215 async fn insert_stacked_draft_session(
1216 &self,
1217 id: &str,
1218 model: &str,
1219 base_branch: &str,
1220 status: &str,
1221 parent_session_id: &str,
1222 project_id: i64,
1223 ) -> Result<(), DbError> {
1224 let agent = persisted_agent_for_model(model);
1225
1226 insert_session_with_draft_mode(
1227 &self.0,
1228 self.now(),
1229 InsertSessionRow {
1230 agent: &agent,
1231 base_branch,
1232 id,
1233 is_draft: true,
1234 model,
1235 orchestration_task_id: None,
1236 parent_session_id: Some(parent_session_id),
1237 personality_id: None,
1238 project_id,
1239 reasoning_level: ReasoningLevel::default(),
1240 role: None,
1241 speed_mode: SpeedMode::Normal,
1242 status,
1243 },
1244 )
1245 .await
1246 }
1247
1248 async fn insert_session(
1249 &self,
1250 id: &str,
1251 model: &str,
1252 base_branch: &str,
1253 status: &str,
1254 project_id: i64,
1255 ) -> Result<(), DbError> {
1256 let agent = persisted_agent_for_model(model);
1257
1258 insert_session_with_draft_mode(
1259 &self.0,
1260 self.now(),
1261 InsertSessionRow {
1262 agent: &agent,
1263 base_branch,
1264 id,
1265 is_draft: false,
1266 model,
1267 orchestration_task_id: None,
1268 parent_session_id: None,
1269 personality_id: None,
1270 project_id,
1271 reasoning_level: ReasoningLevel::default(),
1272 role: None,
1273 speed_mode: SpeedMode::Normal,
1274 status,
1275 },
1276 )
1277 .await
1278 }
1279
1280 async fn insert_session_with_agent(
1281 &self,
1282 session: PersistedSessionCreation<'_>,
1283 ) -> Result<(), DbError> {
1284 let PersistedSessionCreation {
1285 agent,
1286 base_branch,
1287 id,
1288 is_draft,
1289 model,
1290 orchestration_task_id,
1291 parent_session_id,
1292 personality_id,
1293 project_id,
1294 reasoning_level,
1295 role,
1296 speed_mode,
1297 status,
1298 } = session;
1299
1300 insert_session_with_draft_mode(
1301 &self.0,
1302 self.now(),
1303 InsertSessionRow {
1304 agent,
1305 base_branch,
1306 id,
1307 is_draft,
1308 model,
1309 orchestration_task_id,
1310 parent_session_id,
1311 personality_id,
1312 project_id,
1313 reasoning_level,
1314 role,
1315 speed_mode,
1316 status,
1317 },
1318 )
1319 .await
1320 }
1321
1322 async fn fork_session_snapshot(
1323 &self,
1324 snapshot: ForkSessionSnapshot<'_>,
1325 ) -> Result<(), DbError> {
1326 self.3.fork(snapshot).await
1327 }
1328
1329 async fn load_session(&self, session_id: &str) -> Result<Option<SessionRow>, DbError> {
1330 let row = sqlx::query_as::<_, SessionJoinRow>(
1331 r"
1332SELECT session.base_branch AS base_branch,
1333 session.added_lines AS added_lines,
1334 session.agent AS agent,
1335 session.created_at AS created_at,
1336 session.deleted_lines AS deleted_lines,
1337 session.has_diff AS has_diff,
1338 session.id AS id,
1339 session.in_progress_started_at,
1340 session.in_progress_total_seconds AS in_progress_total_seconds,
1341 session.input_tokens AS input_tokens,
1342 session.is_draft AS is_draft,
1343 session.model AS model,
1344 session.output_tokens AS output_tokens,
1345 session.parent_session_id,
1346 session.personality_id,
1347 session.project_id,
1348 session.prompt AS prompt,
1349 session.reasoning_level AS reasoning_level_override,
1350 session.speed_mode AS speed_mode,
1351 session.published_upstream_ref,
1352 session.questions,
1353 session_review_request.display_id AS review_request_display_id,
1354 session_review_request.forge_kind AS review_request_forge_kind,
1355 session_review_request.last_refreshed_at AS review_request_last_refreshed_at,
1356 session_review_request.source_branch AS review_request_source_branch,
1357 session_review_request.state AS review_request_state,
1358 session_review_request.status_summary AS review_request_status_summary,
1359 session_review_request.target_branch AS review_request_target_branch,
1360 session_review_request.title AS review_request_title,
1361 session_review_request.web_url AS review_request_web_url,
1362 session.role,
1363 session.size AS size,
1364 session.status AS status,
1365 session.summary,
1366 session.title,
1367 session.updated_at AS updated_at
1368FROM session
1369LEFT JOIN session_review_request
1370ON session_review_request.session_id = session.id
1371WHERE session.id = ?
1372",
1373 )
1374 .bind(session_id)
1375 .fetch_optional(&self.0)
1376 .await?;
1377
1378 let row = row.map(SessionJoinRow::into_session_row);
1379 if let Some(row) = &row {
1380 status::validate_session(&row.status)?;
1381 }
1382
1383 Ok(row)
1384 }
1385
1386 async fn load_active_session_agent_models(&self) -> Result<Vec<SessionAgentModelRow>, DbError> {
1387 let rows = sqlx::query_as::<_, SessionAgentModelRow>(
1388 r"
1389SELECT agent,
1390 id,
1391 model,
1392 status
1393FROM session
1394WHERE status NOT IN ('Merged', 'Done', 'Canceled')
1395ORDER BY id
1396",
1397 )
1398 .fetch_all(&self.0)
1399 .await?;
1400
1401 Ok(rows)
1402 }
1403
1404 #[cfg(any(test, feature = "test-utils"))]
1405 async fn load_sessions(&self) -> Result<Vec<SessionRow>, DbError> {
1406 let rows = sqlx::query_as!(
1407 SessionJoinRow,
1408 r#"
1409SELECT session.base_branch AS base_branch,
1410 session.added_lines AS added_lines,
1411 session.agent AS agent,
1412 session.created_at AS created_at,
1413 session.deleted_lines AS deleted_lines,
1414 session.has_diff AS "has_diff: bool",
1415 session.id AS id,
1416 session.in_progress_started_at,
1417 session.in_progress_total_seconds AS in_progress_total_seconds,
1418 session.input_tokens AS input_tokens,
1419 session.is_draft AS "is_draft: bool",
1420 session.model AS model,
1421 session.output_tokens AS output_tokens,
1422 session.parent_session_id,
1423 session.personality_id,
1424 session.project_id,
1425 session.prompt AS prompt,
1426 session.reasoning_level AS reasoning_level_override,
1427 session.speed_mode AS speed_mode,
1428 session.published_upstream_ref,
1429 session.questions,
1430 session_review_request.display_id AS review_request_display_id,
1431 session_review_request.forge_kind AS review_request_forge_kind,
1432 session_review_request.last_refreshed_at AS review_request_last_refreshed_at,
1433 session_review_request.source_branch AS review_request_source_branch,
1434 session_review_request.state AS review_request_state,
1435 session_review_request.status_summary AS review_request_status_summary,
1436 session_review_request.target_branch AS review_request_target_branch,
1437 session_review_request.title AS review_request_title,
1438 session_review_request.web_url AS review_request_web_url,
1439 session.role,
1440 session.size AS size,
1441 session.status AS status,
1442 session.summary,
1443 session.title,
1444 session.updated_at AS updated_at
1445FROM session
1446LEFT JOIN session_review_request
1447ON session_review_request.session_id = session.id
1448ORDER BY session.updated_at DESC, session.created_at DESC, session.id
1449"#
1450 )
1451 .fetch_all(&self.0)
1452 .await?;
1453
1454 let rows = rows
1455 .into_iter()
1456 .filter(SessionJoinRow::has_loadable_status)
1457 .map(SessionJoinRow::into_session_row)
1458 .collect::<Vec<_>>();
1459
1460 Ok(rows)
1461 }
1462
1463 async fn load_sessions_for_project(
1464 &self,
1465 project_id: i64,
1466 ) -> Result<Vec<SessionListRow>, DbError> {
1467 let rows = sqlx::query_as!(
1468 SessionJoinRow,
1469 r#"
1470SELECT session.base_branch AS base_branch,
1471 session.added_lines AS added_lines,
1472 session.agent AS agent,
1473 session.created_at AS created_at,
1474 session.deleted_lines AS deleted_lines,
1475 session.has_diff AS "has_diff: bool",
1476 session.id AS id,
1477 session.in_progress_started_at,
1478 session.in_progress_total_seconds AS in_progress_total_seconds,
1479 session.input_tokens AS input_tokens,
1480 session.is_draft AS "is_draft: bool",
1481 session.model AS model,
1482 session.output_tokens AS output_tokens,
1483 session.parent_session_id,
1484 session.personality_id,
1485 session.project_id,
1486 '' AS "prompt!: String",
1487 session.reasoning_level AS reasoning_level_override,
1488 session.speed_mode AS speed_mode,
1489 session.published_upstream_ref,
1490 NULL AS "questions: String",
1491 session_review_request.display_id AS review_request_display_id,
1492 session_review_request.forge_kind AS review_request_forge_kind,
1493 session_review_request.last_refreshed_at AS review_request_last_refreshed_at,
1494 session_review_request.source_branch AS review_request_source_branch,
1495 session_review_request.state AS review_request_state,
1496 session_review_request.status_summary AS review_request_status_summary,
1497 session_review_request.target_branch AS review_request_target_branch,
1498 session_review_request.title AS review_request_title,
1499 session_review_request.web_url AS review_request_web_url,
1500 session.role,
1501 session.size AS size,
1502 session.status AS status,
1503 NULL AS "summary: String",
1504 session.title,
1505 session.updated_at AS updated_at
1506FROM session
1507LEFT JOIN session_review_request
1508ON session_review_request.session_id = session.id
1509WHERE session.project_id = ?
1510ORDER BY session.updated_at DESC, session.created_at DESC, session.id
1511"#,
1512 project_id
1513 )
1514 .fetch_all(&self.0)
1515 .await?;
1516
1517 let rows = rows
1518 .into_iter()
1519 .filter(SessionJoinRow::has_loadable_status)
1520 .map(SessionJoinRow::into_session_list_row)
1521 .collect::<Vec<_>>();
1522
1523 Ok(rows)
1524 }
1525
1526 async fn load_session_detail(
1527 &self,
1528 session_id: &str,
1529 ) -> Result<Option<SessionDetailRow>, DbError> {
1530 let row = sqlx::query_as!(
1531 SessionDetailRow,
1532 r"
1533SELECT prompt,
1534 questions,
1535 summary
1536FROM session
1537WHERE id = ?
1538",
1539 session_id
1540 )
1541 .fetch_optional(&self.0)
1542 .await?;
1543
1544 Ok(row)
1545 }
1546
1547 async fn load_session_messages(
1548 &self,
1549 session_id: &str,
1550 ) -> Result<Vec<SessionMessageRow>, DbError> {
1551 let rows = sqlx::query_as!(
1552 SessionMessageRow,
1553 r"
1554SELECT content,
1555 kind,
1556 position
1557FROM session_message
1558WHERE session_id = ?
1559ORDER BY position, id
1560",
1561 session_id
1562 )
1563 .fetch_all(&self.0)
1564 .await?;
1565
1566 Ok(rows)
1567 }
1568
1569 async fn load_session_focused_reviews_for_project(
1570 &self,
1571 project_id: i64,
1572 ) -> Result<Vec<SessionFocusedReviewRow>, DbError> {
1573 let rows = sqlx::query_as!(
1574 SessionFocusedReviewRow,
1575 r#"
1576SELECT id AS session_id,
1577 focused_review_diff_hash AS "diff_hash!: String",
1578 focused_review_text AS "text!: String"
1579FROM session
1580WHERE project_id = ?
1581 AND focused_review_diff_hash IS NOT NULL
1582 AND focused_review_text IS NOT NULL
1583 AND focused_review_text <> ''
1584ORDER BY updated_at DESC, id
1585"#,
1586 project_id
1587 )
1588 .fetch_all(&self.0)
1589 .await?;
1590
1591 Ok(rows)
1592 }
1593
1594 async fn load_sessions_metadata(&self) -> Result<(i64, i64), DbError> {
1595 let row = sqlx::query_as!(
1596 SessionStatsMetadataRow,
1597 r#"
1598SELECT (SELECT COUNT(*) FROM session) AS "session_count!: _",
1599 COALESCE(
1600 (
1601 SELECT updated_at
1602 FROM session
1603 ORDER BY updated_at DESC, id
1604 LIMIT 1
1605 ),
1606 0
1607 ) AS "max_updated_at!: _"
1608"#
1609 )
1610 .fetch_one(&self.0)
1611 .await?;
1612
1613 Ok((row.session_count, row.max_updated_at))
1614 }
1615
1616 async fn load_session_project_id(&self, session_id: &str) -> Result<Option<i64>, DbError> {
1617 let row = sqlx::query_as!(
1618 OptionalI64ValueRow,
1619 r#"
1620SELECT project_id AS "value: _"
1621FROM session
1622WHERE id = ?
1623"#,
1624 session_id
1625 )
1626 .fetch_optional(&self.0)
1627 .await?;
1628
1629 Ok(row.and_then(|row| row.value))
1630 }
1631
1632 async fn load_session_personality_state(
1633 &self,
1634 session_id: &str,
1635 ) -> Result<Option<SessionPersonalityState>, DbError> {
1636 let state = sqlx::query_as!(
1637 SessionPersonalityState,
1638 r"
1639SELECT applied_personality_id,
1640 applied_personality_prompt_hash,
1641 personality_id
1642FROM session
1643WHERE id = ?
1644",
1645 session_id
1646 )
1647 .fetch_optional(&self.0)
1648 .await?;
1649
1650 Ok(state)
1651 }
1652
1653 async fn load_pending_stack_restack_session_ids(
1654 &self,
1655 project_id: i64,
1656 ) -> Result<Vec<String>, DbError> {
1657 let session_ids = sqlx::query_scalar!(
1658 r"
1659SELECT id
1660FROM session
1661WHERE project_id = ?
1662 AND parent_session_id IS NULL
1663 AND stack_base_commit_hash IS NOT NULL
1664 AND status IN ('Review', 'AgentReview')
1665ORDER BY updated_at ASC, id ASC
1666",
1667 project_id
1668 )
1669 .fetch_all(&self.0)
1670 .await?;
1671
1672 Ok(session_ids)
1673 }
1674
1675 async fn load_session_published_upstream_ref(
1676 &self,
1677 id: &str,
1678 ) -> Result<Option<String>, DbError> {
1679 let value = sqlx::query_scalar!(
1680 r"SELECT published_upstream_ref FROM session WHERE id = ?",
1681 id
1682 )
1683 .fetch_optional(&self.0)
1684 .await?
1685 .flatten();
1686
1687 Ok(value)
1688 }
1689
1690 async fn load_session_merged_commit_hash(
1691 &self,
1692 session_id: &str,
1693 ) -> Result<Option<String>, DbError> {
1694 let row = sqlx::query_scalar!(
1695 r"
1696SELECT merged_commit_hash
1697FROM session
1698WHERE id = ?
1699",
1700 session_id
1701 )
1702 .fetch_optional(&self.0)
1703 .await?;
1704
1705 Ok(row.flatten())
1706 }
1707
1708 async fn load_session_archived_diff(
1709 &self,
1710 session_id: &str,
1711 ) -> Result<Option<String>, DbError> {
1712 let row = sqlx::query_scalar!(
1713 r"
1714SELECT archived_diff
1715FROM session
1716WHERE id = ?
1717",
1718 session_id
1719 )
1720 .fetch_optional(&self.0)
1721 .await?;
1722
1723 Ok(row.flatten())
1724 }
1725
1726 async fn load_session_reasoning_level(
1727 &self,
1728 session_id: &str,
1729 ) -> Result<ReasoningLevel, DbError> {
1730 let value = sqlx::query_scalar!(
1731 r"SELECT reasoning_level FROM session WHERE id = ?",
1732 session_id
1733 )
1734 .fetch_optional(&self.0)
1735 .await?
1736 .flatten();
1737
1738 Ok(value
1739 .and_then(|value| value.parse::<ReasoningLevel>().ok())
1740 .unwrap_or_default())
1741 }
1742
1743 async fn load_session_speed_mode(&self, session_id: &str) -> Result<SpeedMode, DbError> {
1744 let value = sqlx::query_scalar!(r"SELECT speed_mode FROM session WHERE id = ?", session_id)
1745 .fetch_optional(&self.0)
1746 .await?;
1747
1748 Ok(value
1749 .and_then(|value| value.parse::<SpeedMode>().ok())
1750 .unwrap_or_default())
1751 }
1752
1753 async fn restack_child_sessions_after_parent_merge(
1754 &self,
1755 parent_session_id: &str,
1756 base_branch: &str,
1757 parent_commit_hash: Option<String>,
1758 ) -> Result<Vec<String>, DbError> {
1759 let now = self.now();
1760 let mut transaction = self.0.begin().await?;
1761 let materialized_child_ids = sqlx::query_scalar!(
1762 r"
1763SELECT id
1764FROM session
1765WHERE parent_session_id = ?
1766 AND status NOT IN ('Canceled', 'Draft')
1767ORDER BY created_at ASC, id ASC
1768",
1769 parent_session_id
1770 )
1771 .fetch_all(&mut *transaction)
1772 .await?;
1773
1774 sqlx::query!(
1775 r"
1776UPDATE session
1777SET parent_session_id = NULL,
1778 base_branch = ?,
1779 stack_base_commit_hash = CASE
1780 WHEN status = 'Draft' THEN NULL
1781 ELSE COALESCE(stack_base_commit_hash, ?)
1782 END,
1783 updated_at = ?
1784WHERE parent_session_id = ?
1785 AND status <> 'Canceled'
1786",
1787 base_branch,
1788 parent_commit_hash,
1789 now,
1790 parent_session_id
1791 )
1792 .execute(&mut *transaction)
1793 .await?;
1794
1795 transaction.commit().await?;
1796
1797 Ok(materialized_child_ids)
1798 }
1799
1800 async fn load_session_summary(&self, session_id: &str) -> Result<Option<String>, DbError> {
1801 let row = sqlx::query_scalar!(
1802 r"
1803SELECT summary
1804FROM session
1805WHERE id = ?
1806",
1807 session_id
1808 )
1809 .fetch_optional(&self.0)
1810 .await?;
1811
1812 Ok(row.flatten())
1813 }
1814
1815 async fn load_session_timestamps(
1816 &self,
1817 session_id: &str,
1818 ) -> Result<Option<(i64, i64)>, DbError> {
1819 let row = sqlx::query_as!(
1820 SessionTimestampsRow,
1821 r#"
1822SELECT created_at, updated_at
1823FROM session
1824WHERE id = ?
1825 "#,
1826 session_id
1827 )
1828 .fetch_optional(&self.0)
1829 .await?;
1830
1831 Ok(row.map(|row| (row.created_at, row.updated_at)))
1832 }
1833
1834 async fn persist_session_turn_metadata(
1835 &self,
1836 session_id: &str,
1837 turn_metadata: &SessionTurnMetadata,
1838 ) -> Result<(), DbError> {
1839 let now = self.now();
1840 let mut transaction = self.0.begin().await?;
1841
1842 let session_update = sqlx::query!(
1843 r"
1844UPDATE session
1845SET questions = ?,
1846 summary = ?,
1847 provider_conversation_id = ?,
1848 app_server_instruction_provider_conversation_id = ?,
1849 applied_personality_id = ?,
1850 applied_personality_prompt_hash = ?,
1851 updated_at = ?
1852WHERE id = ?
1853",
1854 turn_metadata.questions_json.as_str(),
1855 turn_metadata.summary.as_str(),
1856 turn_metadata.provider_conversation_id.as_deref(),
1857 turn_metadata.instruction_conversation_id.as_deref(),
1858 turn_metadata.applied_personality_id.as_deref(),
1859 turn_metadata.applied_personality_prompt_hash.as_deref(),
1860 now,
1861 session_id
1862 )
1863 .execute(&mut *transaction)
1864 .await?;
1865 if session_update.rows_affected() != 1 {
1866 return Err(sqlx::Error::RowNotFound.into());
1867 }
1868
1869 if turn_metadata.token_usage_delta.input_tokens != 0
1870 || turn_metadata.token_usage_delta.output_tokens != 0
1871 {
1872 sqlx::query!(
1873 r"
1874UPDATE session
1875SET input_tokens = input_tokens + ?,
1876 output_tokens = output_tokens + ?,
1877 updated_at = ?
1878WHERE id = ?
1879",
1880 turn_metadata.token_usage_delta.input_tokens.cast_signed(),
1881 turn_metadata.token_usage_delta.output_tokens.cast_signed(),
1882 now,
1883 session_id
1884 )
1885 .execute(&mut *transaction)
1886 .await?;
1887
1888 sqlx::query!(
1889 r"
1890INSERT INTO session_usage (
1891 session_id, model, created_at, input_tokens, output_tokens, invocation_count
1892)
1893VALUES (?, ?, ?, ?, ?, 1)
1894ON CONFLICT(session_id, model) DO UPDATE SET
1895 input_tokens = input_tokens + excluded.input_tokens,
1896 output_tokens = output_tokens + excluded.output_tokens,
1897 invocation_count = invocation_count + 1
1898",
1899 session_id,
1900 turn_metadata.model.as_str(),
1901 now,
1902 turn_metadata.token_usage_delta.input_tokens.cast_signed(),
1903 turn_metadata.token_usage_delta.output_tokens.cast_signed()
1904 )
1905 .execute(&mut *transaction)
1906 .await?;
1907 }
1908
1909 transaction.commit().await?;
1910
1911 Ok(())
1912 }
1913
1914 async fn update_session_diff_stats(
1915 &self,
1916 added_lines: u64,
1917 deleted_lines: u64,
1918 has_diff: bool,
1919 id: &str,
1920 size: &str,
1921 ) -> Result<(), DbError> {
1922 let now = self.now();
1923
1924 sqlx::query!(
1925 r"
1926UPDATE session
1927SET added_lines = ?,
1928 deleted_lines = ?,
1929 has_diff = ?,
1930 size = ?,
1931 updated_at = ?
1932WHERE id = ?
1933 AND (
1934 added_lines <> ?
1935 OR deleted_lines <> ?
1936 OR has_diff IS NOT ?
1937 OR size <> ?
1938 )
1939",
1940 added_lines.cast_signed(),
1941 deleted_lines.cast_signed(),
1942 has_diff,
1943 size,
1944 now,
1945 id,
1946 added_lines.cast_signed(),
1947 deleted_lines.cast_signed(),
1948 has_diff,
1949 size
1950 )
1951 .execute(&self.0)
1952 .await?;
1953
1954 Ok(())
1955 }
1956
1957 async fn mark_session_diff_unknown(&self, id: &str) -> Result<(), DbError> {
1958 let now = self.now();
1959
1960 sqlx::query!(
1961 r"
1962UPDATE session
1963SET has_diff = NULL,
1964 updated_at = ?
1965WHERE id = ?
1966 AND has_diff IS NOT NULL
1967",
1968 now,
1969 id
1970 )
1971 .execute(&self.0)
1972 .await?;
1973
1974 Ok(())
1975 }
1976
1977 async fn update_session_instruction_conversation_id(
1978 &self,
1979 id: &str,
1980 provider_conversation_id: Option<String>,
1981 ) -> Result<(), DbError> {
1982 let now = self.now();
1983
1984 sqlx::query!(
1985 r"
1986UPDATE session
1987SET app_server_instruction_provider_conversation_id = ?,
1988 updated_at = ?
1989WHERE id = ?
1990",
1991 provider_conversation_id.as_deref(),
1992 now,
1993 id
1994 )
1995 .execute(&self.0)
1996 .await?;
1997
1998 Ok(())
1999 }
2000
2001 async fn update_session_model(&self, id: &str, model: &str) -> Result<(), DbError> {
2002 let agent = persisted_agent_for_model(model);
2003 let now = self.now();
2004
2005 sqlx::query!(
2006 r"
2007UPDATE session
2008SET agent = ?,
2009 model = ?,
2010 updated_at = ?
2011WHERE id = ?
2012",
2013 agent,
2014 model,
2015 now,
2016 id
2017 )
2018 .execute(&self.0)
2019 .await?;
2020
2021 Ok(())
2022 }
2023
2024 async fn update_session_personality_id(
2025 &self,
2026 id: &str,
2027 personality_id: Option<String>,
2028 ) -> Result<(), DbError> {
2029 let now = self.now();
2030
2031 sqlx::query!(
2032 r"
2033UPDATE session
2034SET personality_id = ?,
2035 updated_at = ?
2036WHERE id = ?
2037",
2038 personality_id.as_deref(),
2039 now,
2040 id
2041 )
2042 .execute(&self.0)
2043 .await?;
2044
2045 Ok(())
2046 }
2047
2048 async fn update_session_agent_model(
2049 &self,
2050 id: &str,
2051 agent: &str,
2052 model: &str,
2053 ) -> Result<(), DbError> {
2054 let now = self.now();
2055
2056 sqlx::query!(
2057 r"
2058UPDATE session
2059SET agent = ?,
2060 model = ?,
2061 updated_at = ?
2062WHERE id = ?
2063",
2064 agent,
2065 model,
2066 now,
2067 id
2068 )
2069 .execute(&self.0)
2070 .await?;
2071
2072 Ok(())
2073 }
2074
2075 async fn update_active_session_agent_model(
2076 &self,
2077 id: &str,
2078 agent: &str,
2079 model: &str,
2080 ) -> Result<(), DbError> {
2081 sqlx::query!(
2082 r"
2083UPDATE session
2084SET agent = ?,
2085 model = ?
2086WHERE id = ?
2087 AND status NOT IN ('Merged', 'Done', 'Canceled')
2088",
2089 agent,
2090 model,
2091 id
2092 )
2093 .execute(&self.0)
2094 .await?;
2095
2096 Ok(())
2097 }
2098
2099 async fn clear_session_draft_flag(&self, id: &str) -> Result<(), DbError> {
2100 let now = self.now();
2101
2102 sqlx::query!(
2103 r"
2104UPDATE session
2105SET is_draft = 0,
2106 updated_at = ?
2107WHERE id = ?
2108",
2109 now,
2110 id
2111 )
2112 .execute(&self.0)
2113 .await?;
2114
2115 Ok(())
2116 }
2117
2118 async fn update_session_merged_commit_hash(
2119 &self,
2120 id: &str,
2121 merged_commit_hash: Option<String>,
2122 ) -> Result<(), DbError> {
2123 let now = self.now();
2124
2125 sqlx::query!(
2126 r"
2127UPDATE session
2128SET merged_commit_hash = ?,
2129 updated_at = ?
2130WHERE id = ?
2131",
2132 merged_commit_hash.as_deref(),
2133 now,
2134 id
2135 )
2136 .execute(&self.0)
2137 .await?;
2138
2139 Ok(())
2140 }
2141
2142 async fn update_session_archived_diff(
2143 &self,
2144 id: &str,
2145 archived_diff: Option<String>,
2146 ) -> Result<(), DbError> {
2147 let now = self.now();
2148
2149 sqlx::query!(
2150 r"
2151UPDATE session
2152SET archived_diff = ?,
2153 updated_at = ?
2154WHERE id = ?
2155",
2156 archived_diff.as_deref(),
2157 now,
2158 id
2159 )
2160 .execute(&self.0)
2161 .await?;
2162
2163 Ok(())
2164 }
2165
2166 async fn update_session_stack_base_commit_hash(
2167 &self,
2168 id: &str,
2169 stack_base_commit_hash: Option<String>,
2170 ) -> Result<(), DbError> {
2171 let now = self.now();
2172
2173 sqlx::query(
2174 r"
2175UPDATE session
2176SET stack_base_commit_hash = ?,
2177 updated_at = ?
2178WHERE id = ?
2179",
2180 )
2181 .bind(stack_base_commit_hash)
2182 .bind(now)
2183 .bind(id)
2184 .execute(&self.0)
2185 .await?;
2186
2187 Ok(())
2188 }
2189
2190 async fn update_session_prompt(&self, id: &str, prompt: &str) -> Result<(), DbError> {
2191 let now = self.now();
2192
2193 sqlx::query!(
2194 r"
2195UPDATE session
2196SET prompt = ?,
2197 updated_at = ?
2198WHERE id = ?
2199",
2200 prompt,
2201 now,
2202 id
2203 )
2204 .execute(&self.0)
2205 .await?;
2206
2207 Ok(())
2208 }
2209
2210 async fn update_session_provider_conversation_id(
2211 &self,
2212 id: &str,
2213 provider_conversation_id: Option<String>,
2214 ) -> Result<(), DbError> {
2215 let now = self.now();
2216
2217 sqlx::query!(
2218 r"
2219UPDATE session
2220SET provider_conversation_id = ?,
2221 updated_at = ?
2222WHERE id = ?
2223",
2224 provider_conversation_id.as_deref(),
2225 now,
2226 id
2227 )
2228 .execute(&self.0)
2229 .await?;
2230
2231 Ok(())
2232 }
2233
2234 async fn update_session_questions(&self, id: &str, questions: &str) -> Result<(), DbError> {
2235 let now = self.now();
2236
2237 sqlx::query!(
2238 r"
2239UPDATE session
2240SET questions = ?,
2241 updated_at = ?
2242WHERE id = ?
2243",
2244 questions,
2245 now,
2246 id
2247 )
2248 .execute(&self.0)
2249 .await?;
2250
2251 Ok(())
2252 }
2253
2254 async fn update_session_reasoning_level(
2255 &self,
2256 id: &str,
2257 reasoning_level: ReasoningLevel,
2258 ) -> Result<(), DbError> {
2259 let now = self.now();
2260
2261 sqlx::query!(
2262 r#"
2263UPDATE session
2264SET reasoning_level = ?,
2265 updated_at = ?
2266WHERE id = ?
2267 "#,
2268 reasoning_level.as_str(),
2269 now,
2270 id
2271 )
2272 .execute(&self.0)
2273 .await?;
2274
2275 Ok(())
2276 }
2277
2278 async fn update_session_speed_mode(
2279 &self,
2280 id: &str,
2281 speed_mode: SpeedMode,
2282 ) -> Result<(), DbError> {
2283 let now = self.now();
2284
2285 sqlx::query!(
2286 r#"
2287UPDATE session
2288SET speed_mode = ?,
2289 updated_at = ?
2290WHERE id = ?
2291 "#,
2292 speed_mode.as_str(),
2293 now,
2294 id
2295 )
2296 .execute(&self.0)
2297 .await?;
2298
2299 Ok(())
2300 }
2301
2302 async fn update_session_published_upstream_ref(
2303 &self,
2304 id: &str,
2305 published_upstream_ref: Option<String>,
2306 ) -> Result<(), DbError> {
2307 let now = self.now();
2308
2309 sqlx::query!(
2310 r"
2311UPDATE session
2312SET published_upstream_ref = ?,
2313 updated_at = ?
2314WHERE id = ?
2315",
2316 published_upstream_ref.as_deref(),
2317 now,
2318 id
2319 )
2320 .execute(&self.0)
2321 .await?;
2322
2323 Ok(())
2324 }
2325
2326 async fn update_session_stats(&self, id: &str, stats: &SessionStats) -> Result<(), DbError> {
2327 if stats.input_tokens == 0 && stats.output_tokens == 0 {
2328 return Ok(());
2329 }
2330
2331 let now = self.now();
2332
2333 sqlx::query!(
2334 r"
2335UPDATE session
2336SET input_tokens = input_tokens + ?,
2337 output_tokens = output_tokens + ?,
2338 updated_at = ?
2339WHERE id = ?
2340",
2341 stats.input_tokens.cast_signed(),
2342 stats.output_tokens.cast_signed(),
2343 now,
2344 id
2345 )
2346 .execute(&self.0)
2347 .await?;
2348
2349 Ok(())
2350 }
2351
2352 async fn update_session_status_with_timing_at(
2353 &self,
2354 id: &str,
2355 status: &str,
2356 timestamp_seconds: i64,
2357 ) -> Result<(), DbError> {
2358 status::validate_session(status)?;
2359 let now = self.now();
2360
2361 sqlx::query!(
2362 r"
2363UPDATE session
2364SET status = ?,
2365 in_progress_total_seconds = CASE
2366 WHEN ? = 'InProgress' OR in_progress_started_at IS NULL THEN in_progress_total_seconds
2367 ELSE in_progress_total_seconds + MAX(0, ? - in_progress_started_at)
2368 END,
2369 in_progress_started_at = CASE
2370 WHEN ? = 'InProgress' THEN COALESCE(in_progress_started_at, ?)
2371 ELSE NULL
2372 END,
2373 updated_at = ?
2374WHERE id = ?
2375",
2376 status,
2377 status,
2378 timestamp_seconds,
2379 status,
2380 timestamp_seconds,
2381 now,
2382 id
2383 )
2384 .execute(&self.0)
2385 .await?;
2386
2387 Ok(())
2388 }
2389
2390 async fn update_session_summary(&self, id: &str, summary: &str) -> Result<(), DbError> {
2391 let now = self.now();
2392
2393 sqlx::query!(
2394 r"
2395UPDATE session
2396SET summary = ?,
2397 updated_at = ?
2398WHERE id = ?
2399",
2400 summary,
2401 now,
2402 id
2403 )
2404 .execute(&self.0)
2405 .await?;
2406
2407 Ok(())
2408 }
2409
2410 async fn update_session_focused_review(
2411 &self,
2412 id: &str,
2413 status: Option<FocusedReviewStatus>,
2414 diff_hash: Option<String>,
2415 text: Option<String>,
2416 ) -> Result<(), DbError> {
2417 let now = self.now();
2418
2419 sqlx::query!(
2420 r"
2421UPDATE session
2422SET focused_review_status = ?,
2423 focused_review_diff_hash = ?,
2424 focused_review_text = ?,
2425 updated_at = ?
2426WHERE id = ?
2427",
2428 status.map(|status| status.to_string()),
2429 diff_hash.as_deref(),
2430 text.as_deref(),
2431 now,
2432 id
2433 )
2434 .execute(&self.0)
2435 .await?;
2436
2437 Ok(())
2438 }
2439
2440 async fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError> {
2441 let now = self.now();
2442
2443 sqlx::query!(
2444 r#"
2445UPDATE session
2446SET title = ?,
2447 is_title_provisional = 0,
2448 title_generation = title_generation + 1,
2449 applied_title_generation = title_generation + 1,
2450 updated_at = ?
2451WHERE id = ?
2452"#,
2453 title,
2454 now,
2455 id,
2456 )
2457 .execute(&self.0)
2458 .await?;
2459
2460 Ok(())
2461 }
2462
2463 async fn update_session_provisional_title(&self, id: &str, title: &str) -> Result<(), DbError> {
2464 let now = self.now();
2465
2466 sqlx::query!(
2467 r#"
2468UPDATE session
2469SET title = ?,
2470 is_title_provisional = 1,
2471 title_generation = title_generation + 1,
2472 applied_title_generation = title_generation + 1,
2473 updated_at = ?
2474WHERE id = ?
2475"#,
2476 title,
2477 now,
2478 id,
2479 )
2480 .execute(&self.0)
2481 .await?;
2482
2483 Ok(())
2484 }
2485
2486 async fn begin_session_title_generation(
2487 &self,
2488 id: &str,
2489 requires_provisional_title: bool,
2490 ) -> Result<Option<i64>, DbError> {
2491 let now = self.now();
2492 let generation = if requires_provisional_title {
2493 sqlx::query_scalar!(
2494 r#"
2495UPDATE session
2496SET is_title_provisional = 1,
2497 title_generation = title_generation + 1,
2498 updated_at = ?
2499WHERE id = ?
2500 AND is_title_provisional = 1
2501RETURNING title_generation
2502"#,
2503 now,
2504 id,
2505 )
2506 .fetch_optional(&self.0)
2507 .await?
2508 } else {
2509 sqlx::query_scalar!(
2510 r#"
2511UPDATE session
2512SET is_title_provisional = 1,
2513 title_generation = title_generation + 1,
2514 updated_at = ?
2515WHERE id = ?
2516RETURNING title_generation
2517"#,
2518 now,
2519 id,
2520 )
2521 .fetch_optional(&self.0)
2522 .await?
2523 };
2524
2525 Ok(generation)
2526 }
2527
2528 async fn update_session_title_for_generation(
2529 &self,
2530 id: &str,
2531 expected_generation: i64,
2532 title: &str,
2533 ) -> Result<bool, DbError> {
2534 let now = self.now();
2535
2536 let result = sqlx::query!(
2537 r#"
2538UPDATE session
2539SET title = ?,
2540 is_title_provisional = 0,
2541 applied_title_generation = ?,
2542 updated_at = ?
2543WHERE id = ?
2544 AND title_generation >= ?
2545 AND applied_title_generation < ?
2546"#,
2547 title,
2548 expected_generation,
2549 now,
2550 id,
2551 expected_generation,
2552 expected_generation,
2553 )
2554 .execute(&self.0)
2555 .await?;
2556
2557 Ok(result.rows_affected() > 0)
2558 }
2559
2560 #[cfg(any(test, feature = "test-utils"))]
2561 async fn update_session_created_at(&self, id: &str, created_at: i64) -> Result<(), DbError> {
2562 sqlx::query!(
2563 r"
2564UPDATE session
2565SET created_at = ?
2566WHERE id = ?
2567",
2568 created_at,
2569 id
2570 )
2571 .execute(&self.0)
2572 .await?;
2573
2574 Ok(())
2575 }
2576
2577 #[cfg(any(test, feature = "test-utils"))]
2578 async fn update_session_updated_at(&self, id: &str, updated_at: i64) -> Result<(), DbError> {
2579 sqlx::query!(
2580 r"
2581UPDATE session
2582SET updated_at = ?
2583WHERE id = ?
2584",
2585 updated_at,
2586 id
2587 )
2588 .execute(&self.0)
2589 .await?;
2590
2591 Ok(())
2592 }
2593}
2594
2595struct InsertSessionRow<'a> {
2597 agent: &'a str,
2599 base_branch: &'a str,
2601 id: &'a str,
2603 is_draft: bool,
2605 model: &'a str,
2607 orchestration_task_id: Option<i64>,
2609 parent_session_id: Option<&'a str>,
2611 personality_id: Option<&'a str>,
2613 project_id: i64,
2615 reasoning_level: ReasoningLevel,
2617 role: Option<&'a str>,
2619 speed_mode: SpeedMode,
2621 status: &'a str,
2623}
2624
2625async fn insert_session_with_draft_mode(
2628 pool: &SqlitePool,
2629 timestamp_seconds: i64,
2630 row: InsertSessionRow<'_>,
2631) -> Result<(), DbError> {
2632 let InsertSessionRow {
2633 agent,
2634 base_branch,
2635 id,
2636 is_draft,
2637 model,
2638 orchestration_task_id,
2639 parent_session_id,
2640 personality_id,
2641 project_id,
2642 reasoning_level,
2643 role,
2644 speed_mode,
2645 status,
2646 } = row;
2647 status::validate_session(status)?;
2648
2649 sqlx::query(
2650 r"
2651INSERT INTO session (
2652 id,
2653 agent,
2654 model,
2655 base_branch,
2656 status,
2657 has_diff,
2658 is_draft,
2659 parent_session_id,
2660 personality_id,
2661 project_id,
2662 reasoning_level,
2663 role,
2664 speed_mode,
2665 orchestration_task_id,
2666 prompt,
2667 created_at,
2668 updated_at
2669)
2670VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2671",
2672 )
2673 .bind(id)
2674 .bind(agent)
2675 .bind(model)
2676 .bind(base_branch)
2677 .bind(status)
2678 .bind(Option::<bool>::None)
2680 .bind(is_draft)
2681 .bind(parent_session_id)
2682 .bind(personality_id)
2683 .bind(project_id)
2684 .bind(reasoning_level.as_str())
2685 .bind(role)
2686 .bind(speed_mode.as_str())
2687 .bind(orchestration_task_id)
2688 .bind("")
2689 .bind(timestamp_seconds)
2690 .bind(timestamp_seconds)
2691 .execute(pool)
2692 .await?;
2693
2694 Ok(())
2695}
2696
2697fn persisted_agent_for_model(model: &str) -> String {
2699 AgentModel::parse_persisted(model).map_or_else(
2700 |_| persisted_agent_for_unknown_model(model).to_string(),
2701 |agent_model| persisted_agent_for_known_model(model, agent_model).to_string(),
2702 )
2703}
2704
2705fn persisted_agent_for_known_model(model: &str, agent_model: AgentModel) -> AgentKind {
2708 if model.starts_with("claude-") {
2709 return AgentKind::Claude;
2710 }
2711
2712 if model.starts_with("gpt-") {
2713 return AgentKind::Codex;
2714 }
2715
2716 if model.starts_with("gemini-") {
2717 return AgentKind::Antigravity;
2718 }
2719
2720 AgentKind::ALL
2721 .iter()
2722 .copied()
2723 .find(|agent_kind| agent_kind.supports_model(agent_model))
2724 .unwrap_or(AgentKind::Antigravity)
2725}
2726
2727fn persisted_agent_for_unknown_model(model: &str) -> AgentKind {
2730 if model.starts_with("claude-") {
2731 return AgentKind::Claude;
2732 }
2733
2734 if model.starts_with("gpt-") {
2735 return AgentKind::Codex;
2736 }
2737
2738 if model.starts_with("gemini-") {
2739 return AgentKind::Antigravity;
2740 }
2741
2742 AgentKind::Antigravity
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747 use ag_session::{ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary};
2748
2749 use super::*;
2750 use crate::AppRepositories;
2751
2752 struct ForkResetRow {
2754 applied_personality_id: Option<String>,
2755 applied_personality_prompt_hash: Option<String>,
2756 app_server_instruction_provider_conversation_id: Option<String>,
2757 focused_review_diff_hash: Option<String>,
2758 focused_review_text: Option<String>,
2759 in_progress_started_at: Option<i64>,
2760 in_progress_total_seconds: i64,
2761 is_draft: bool,
2762 merged_commit_hash: Option<String>,
2763 parent_session_id: Option<String>,
2764 provider_conversation_id: Option<String>,
2765 published_upstream_ref: Option<String>,
2766 questions: Option<String>,
2767 stack_base_commit_hash: Option<String>,
2768 }
2769
2770 impl SessionJoinRow {
2771 fn fixture_for_test() -> Self {
2774 Self {
2775 added_lines: 14,
2776 agent: "codex".to_string(),
2777 base_branch: "main".to_string(),
2778 created_at: 100,
2779 deleted_lines: 6,
2780 has_diff: Some(true),
2781 id: "session-a".to_string(),
2782 in_progress_started_at: None,
2783 in_progress_total_seconds: 0,
2784 input_tokens: 11,
2785 is_draft: false,
2786 model: "gpt-5.6-sol".to_string(),
2787 output_tokens: 29,
2788 parent_session_id: Some("parent-session".to_string()),
2789 personality_id: Some("reviewer".to_string()),
2790 project_id: Some(7),
2791 prompt: "Implement feature".to_string(),
2792 published_upstream_ref: Some("origin/session-a".to_string()),
2793 questions: Some("Question text".to_string()),
2794 reasoning_level_override: None,
2795 review_request_display_id: Some("#42".to_string()),
2796 review_request_forge_kind: Some("GitHub".to_string()),
2797 review_request_last_refreshed_at: Some(456),
2798 review_request_source_branch: Some("feature/forge".to_string()),
2799 review_request_state: Some("Open".to_string()),
2800 review_request_status_summary: Some("2 approvals, checks passing".to_string()),
2801 review_request_target_branch: Some("main".to_string()),
2802 review_request_title: Some("Add forge review support".to_string()),
2803 review_request_web_url: Some(
2804 "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
2805 ),
2806 role: Some("Orchestrator".to_string()),
2807 size: "M".to_string(),
2808 speed_mode: "normal".to_string(),
2809 status: "Review".to_string(),
2810 summary: Some("Summary text".to_string()),
2811 title: Some("Review session".to_string()),
2812 updated_at: 200,
2813 }
2814 }
2815 }
2816
2817 fn expected_review_request_row() -> SessionReviewRequestRow {
2820 SessionReviewRequestRow {
2821 display_id: "#42".to_string(),
2822 forge_kind: "GitHub".to_string(),
2823 last_refreshed_at: 456,
2824 source_branch: "feature/forge".to_string(),
2825 state: "Open".to_string(),
2826 status_summary: Some("2 approvals, checks passing".to_string()),
2827 target_branch: "main".to_string(),
2828 title: "Add forge review support".to_string(),
2829 web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
2830 }
2831 }
2832
2833 fn review_request_fixture() -> ReviewRequest {
2835 ReviewRequest {
2836 last_refreshed_at: 456,
2837 summary: ReviewRequestSummary {
2838 display_id: "#42".to_string(),
2839 forge_kind: ForgeKind::GitHub,
2840 source_branch: "feature/forge".to_string(),
2841 state: ReviewRequestState::Open,
2842 status_summary: Some("2 approvals, checks passing".to_string()),
2843 target_branch: "main".to_string(),
2844 title: "Add forge review support".to_string(),
2845 web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
2846 },
2847 }
2848 }
2849
2850 async fn load_fork_reset_row(pool: &SqlitePool, session_id: &str) -> ForkResetRow {
2853 sqlx::query_as!(
2854 ForkResetRow,
2855 r#"
2856SELECT app_server_instruction_provider_conversation_id,
2857 applied_personality_id,
2858 applied_personality_prompt_hash,
2859 focused_review_diff_hash,
2860 focused_review_text,
2861 in_progress_started_at,
2862 in_progress_total_seconds,
2863 is_draft AS "is_draft: bool",
2864 merged_commit_hash,
2865 parent_session_id,
2866 provider_conversation_id,
2867 published_upstream_ref,
2868 questions,
2869 stack_base_commit_hash
2870FROM session
2871WHERE id = ?
2872"#,
2873 session_id
2874 )
2875 .fetch_one(pool)
2876 .await
2877 .expect("failed to load fork reset row")
2878 }
2879
2880 async fn seed_fork_snapshot_source(
2883 database: &AppRepositories,
2884 pool: &SqlitePool,
2885 ) -> (ForkResetRow, Option<SessionReviewRequestRow>) {
2886 let project_id = database
2887 .projects()
2888 .upsert_project("/tmp/project", None)
2889 .await
2890 .expect("failed to upsert project");
2891 database
2892 .sessions()
2893 .insert_session(
2894 "parent-session",
2895 "gpt-5.6-sol",
2896 "main",
2897 "Review",
2898 project_id,
2899 )
2900 .await
2901 .expect("failed to insert parent session");
2902 database
2903 .sessions()
2904 .insert_stacked_draft_session(
2905 "source-session",
2906 "gpt-5.6-sol",
2907 "wt/parent",
2908 "Review",
2909 "parent-session",
2910 project_id,
2911 )
2912 .await
2913 .expect("failed to insert source session");
2914
2915 seed_fork_snapshot_source_linkage(database).await;
2916 seed_fork_snapshot_source_timing(database, pool).await;
2917
2918 let source_reset_row = load_fork_reset_row(pool, "source-session").await;
2919 let source_review_request = database
2920 .reviews()
2921 .load_session_review_request("source-session")
2922 .await
2923 .expect("failed to load source review request");
2924
2925 (source_reset_row, source_review_request)
2926 }
2927
2928 async fn seed_fork_snapshot_source_linkage(database: &AppRepositories) {
2930 database
2931 .sessions()
2932 .update_session_personality_id("source-session", Some("reviewer".to_string()))
2933 .await
2934 .expect("failed to update personality id");
2935 database
2936 .sessions()
2937 .persist_session_turn_metadata(
2938 "source-session",
2939 &SessionTurnMetadata {
2940 applied_personality_id: Some("reviewer".to_string()),
2941 applied_personality_prompt_hash: Some("personality-hash".to_string()),
2942 instruction_conversation_id: None,
2943 model: "gpt-5.6-sol".to_string(),
2944 provider_conversation_id: None,
2945 questions_json: "[]".to_string(),
2946 summary: String::new(),
2947 token_usage_delta: SessionStats::default(),
2948 },
2949 )
2950 .await
2951 .expect("failed to persist applied personality");
2952 database
2953 .sessions()
2954 .update_session_provider_conversation_id(
2955 "source-session",
2956 Some("provider-thread".to_string()),
2957 )
2958 .await
2959 .expect("failed to update provider conversation id");
2960 database
2961 .sessions()
2962 .update_session_instruction_conversation_id(
2963 "source-session",
2964 Some("instruction-thread".to_string()),
2965 )
2966 .await
2967 .expect("failed to update instruction conversation id");
2968 database
2969 .sessions()
2970 .update_session_questions("source-session", r#"["Need detail?"]"#)
2971 .await
2972 .expect("failed to update questions");
2973 database
2974 .sessions()
2975 .update_session_published_upstream_ref(
2976 "source-session",
2977 Some("origin/wt/source-session".to_string()),
2978 )
2979 .await
2980 .expect("failed to update published upstream ref");
2981 database
2982 .sessions()
2983 .update_session_merged_commit_hash("source-session", Some("merged123".to_string()))
2984 .await
2985 .expect("failed to update merged commit hash");
2986 database
2987 .sessions()
2988 .update_session_focused_review(
2989 "source-session",
2990 Some(FocusedReviewStatus::Ready),
2991 Some("diff123".to_string()),
2992 Some("Focused review text".to_string()),
2993 )
2994 .await
2995 .expect("failed to update focused review");
2996 database
2997 .sessions()
2998 .update_session_stack_base_commit_hash(
2999 "source-session",
3000 Some("stackbase123".to_string()),
3001 )
3002 .await
3003 .expect("failed to update stack base commit hash");
3004 database
3005 .sessions()
3006 .update_session_stats(
3007 "source-session",
3008 &SessionStats {
3009 added_lines: 0,
3010 deleted_lines: 0,
3011 diff_state: agent::SessionDiffState::Unknown,
3012 input_tokens: 11,
3013 output_tokens: 29,
3014 },
3015 )
3016 .await
3017 .expect("failed to update token stats");
3018 database
3019 .sessions()
3020 .update_session_diff_stats(7, 3, true, "source-session", "S")
3021 .await
3022 .expect("failed to update source diff stats");
3023 database
3024 .reviews()
3025 .update_session_review_request("source-session", Some(review_request_fixture()))
3026 .await
3027 .expect("failed to update review request");
3028 }
3029
3030 async fn seed_fork_snapshot_source_timing(database: &AppRepositories, pool: &SqlitePool) {
3032 database
3033 .sessions()
3034 .update_session_status_with_timing_at("source-session", "InProgress", 100)
3035 .await
3036 .expect("failed to open timing interval");
3037 sqlx::query!(
3038 r"
3039UPDATE session
3040SET in_progress_total_seconds = ?
3041WHERE id = ?
3042",
3043 75_i64,
3044 "source-session"
3045 )
3046 .execute(pool)
3047 .await
3048 .expect("failed to seed elapsed timing");
3049 }
3050
3051 fn assert_source_reset_state(
3054 source_row: &SessionRow,
3055 source_reset_row: &ForkResetRow,
3056 source_review_request: Option<&SessionReviewRequestRow>,
3057 ) {
3058 assert_eq!(source_row.added_lines, 7);
3059 assert_eq!(source_row.deleted_lines, 3);
3060 assert_eq!(source_row.has_diff, Some(true));
3061 assert_eq!(source_row.size, "S");
3062 assert!(source_reset_row.is_draft);
3063 assert_eq!(source_row.personality_id.as_deref(), Some("reviewer"));
3064 assert_eq!(
3065 source_reset_row.applied_personality_id.as_deref(),
3066 Some("reviewer")
3067 );
3068 assert_eq!(
3069 source_reset_row.applied_personality_prompt_hash.as_deref(),
3070 Some("personality-hash")
3071 );
3072 assert_eq!(
3073 source_reset_row.parent_session_id.as_deref(),
3074 Some("parent-session")
3075 );
3076 assert_eq!(
3077 source_reset_row.provider_conversation_id.as_deref(),
3078 Some("provider-thread")
3079 );
3080 assert_eq!(
3081 source_reset_row
3082 .app_server_instruction_provider_conversation_id
3083 .as_deref(),
3084 Some("instruction-thread")
3085 );
3086 assert_eq!(
3087 source_reset_row.published_upstream_ref.as_deref(),
3088 Some("origin/wt/source-session")
3089 );
3090 assert_eq!(
3091 source_reset_row.questions.as_deref(),
3092 Some(r#"["Need detail?"]"#)
3093 );
3094 assert_eq!(
3095 source_reset_row.merged_commit_hash.as_deref(),
3096 Some("merged123")
3097 );
3098 assert_eq!(
3099 source_reset_row.focused_review_diff_hash.as_deref(),
3100 Some("diff123")
3101 );
3102 assert_eq!(
3103 source_reset_row.focused_review_text.as_deref(),
3104 Some("Focused review text")
3105 );
3106 assert_eq!(
3107 source_reset_row.stack_base_commit_hash.as_deref(),
3108 Some("stackbase123")
3109 );
3110 assert_eq!(source_reset_row.in_progress_started_at, Some(100));
3111 assert_eq!(source_reset_row.in_progress_total_seconds, 75);
3112 assert_eq!(
3113 source_review_request.map(|review_request| review_request.display_id.as_str()),
3114 Some("#42")
3115 );
3116 }
3117
3118 fn assert_fork_reset_state(
3121 fork_row: &SessionRow,
3122 fork_reset_row: &ForkResetRow,
3123 fork_review_request: Option<&SessionReviewRequestRow>,
3124 ) {
3125 assert_eq!(fork_row.status, "Review");
3126 assert!(!fork_row.is_draft);
3127 assert_eq!(fork_row.parent_session_id, None);
3128 assert_eq!(fork_row.personality_id.as_deref(), Some("reviewer"));
3129 assert_eq!(fork_row.input_tokens, 0);
3130 assert_eq!(fork_row.output_tokens, 0);
3131 assert_eq!(fork_row.added_lines, 0);
3132 assert_eq!(fork_row.deleted_lines, 0);
3133 assert_eq!(fork_row.has_diff, None);
3134 assert_eq!(fork_row.size, "XS");
3135 assert_eq!(fork_row.questions, None);
3136 assert_eq!(fork_row.published_upstream_ref, None);
3137 assert_eq!(fork_row.review_request, None);
3138 assert_eq!(fork_reset_row.provider_conversation_id, None);
3139 assert_eq!(fork_reset_row.applied_personality_id, None);
3140 assert_eq!(fork_reset_row.applied_personality_prompt_hash, None);
3141 assert_eq!(
3142 fork_reset_row.app_server_instruction_provider_conversation_id,
3143 None
3144 );
3145 assert_eq!(fork_reset_row.merged_commit_hash, None);
3146 assert_eq!(fork_reset_row.focused_review_diff_hash, None);
3147 assert_eq!(fork_reset_row.focused_review_text, None);
3148 assert_eq!(fork_reset_row.questions, None);
3149 assert_eq!(fork_reset_row.stack_base_commit_hash, None);
3150 assert_eq!(fork_reset_row.in_progress_started_at, None);
3151 assert_eq!(fork_reset_row.in_progress_total_seconds, 0);
3152 assert_eq!(fork_review_request, None);
3153 }
3154
3155 #[tokio::test]
3156 async fn test_load_session_rejects_unknown_status() {
3157 let (database, pool) = AppRepositories::in_memory_with_pool()
3159 .await
3160 .expect("db should open");
3161 let project_id = database
3162 .projects()
3163 .upsert_project("/tmp/invalid-session", None)
3164 .await
3165 .expect("failed to upsert project");
3166 database
3167 .sessions()
3168 .insert_session("session-a", "gpt-5.6-sol", "main", "Draft", project_id)
3169 .await
3170 .expect("failed to insert session");
3171 sqlx::query("UPDATE session SET status = 'Unknown' WHERE id = 'session-a'")
3172 .execute(&pool)
3173 .await
3174 .expect("failed to corrupt session status");
3175
3176 let result = database.sessions().load_session("session-a").await;
3178
3179 assert!(matches!(
3181 result,
3182 Err(DbError::InvalidStatus {
3183 entity: "session",
3184 value,
3185 }) if value == "Unknown"
3186 ));
3187 }
3188
3189 #[tokio::test]
3190 async fn test_load_session_collections_skip_unknown_status() {
3191 let (database, pool) = AppRepositories::in_memory_with_pool()
3193 .await
3194 .expect("db should open");
3195 let project_id = database
3196 .projects()
3197 .upsert_project("/tmp/invalid-session-list", None)
3198 .await
3199 .expect("failed to upsert project");
3200 for session_id in ["session-valid", "session-invalid"] {
3201 database
3202 .sessions()
3203 .insert_session(session_id, "gpt-5.6-sol", "main", "Draft", project_id)
3204 .await
3205 .expect("failed to insert session");
3206 }
3207 sqlx::query("UPDATE session SET status = 'Unknown' WHERE id = 'session-invalid'")
3208 .execute(&pool)
3209 .await
3210 .expect("failed to corrupt session status");
3211
3212 let all_sessions = database
3214 .sessions()
3215 .load_sessions()
3216 .await
3217 .expect("failed to load all sessions");
3218 let project_sessions = database
3219 .sessions()
3220 .load_sessions_for_project(project_id)
3221 .await
3222 .expect("failed to load project sessions");
3223
3224 assert_eq!(
3226 all_sessions
3227 .iter()
3228 .map(|session| session.id.as_str())
3229 .collect::<Vec<_>>(),
3230 ["session-valid"]
3231 );
3232 assert_eq!(
3233 project_sessions
3234 .iter()
3235 .map(|session| session.id.as_str())
3236 .collect::<Vec<_>>(),
3237 ["session-valid"]
3238 );
3239 }
3240
3241 #[tokio::test]
3242 async fn test_insert_session_starts_with_unknown_diff() {
3243 let (database, _) = AppRepositories::in_memory_with_pool()
3245 .await
3246 .expect("db should open");
3247 let project_id = database
3248 .projects()
3249 .upsert_project("/tmp/project", None)
3250 .await
3251 .expect("failed to upsert project");
3252
3253 database
3255 .sessions()
3256 .insert_session("session-a", "gpt-5.6-sol", "main", "Draft", project_id)
3257 .await
3258 .expect("failed to insert session");
3259 let session = database
3260 .sessions()
3261 .load_sessions()
3262 .await
3263 .expect("failed to load sessions")
3264 .into_iter()
3265 .next()
3266 .expect("missing inserted session");
3267
3268 assert_eq!(session.has_diff, None);
3270 }
3271
3272 #[tokio::test]
3273 async fn test_load_sessions_uses_created_at_to_break_updated_at_ties() {
3274 let (database, pool) = AppRepositories::in_memory_with_pool()
3276 .await
3277 .expect("db should open");
3278 let project_id = database
3279 .projects()
3280 .upsert_project("/tmp/project", None)
3281 .await
3282 .expect("failed to upsert project");
3283 for session_id in ["a-older", "z-newer"] {
3284 database
3285 .sessions()
3286 .insert_session(session_id, "gpt-5.6-sol", "main", "Review", project_id)
3287 .await
3288 .expect("failed to insert session");
3289 }
3290 sqlx::query!(
3291 r"
3292UPDATE session
3293SET created_at = CASE id WHEN 'a-older' THEN 100 ELSE 200 END,
3294 updated_at = 300
3295WHERE id IN ('a-older', 'z-newer')
3296"
3297 )
3298 .execute(&pool)
3299 .await
3300 .expect("failed to set session timestamps");
3301
3302 let all_session_ids = database
3304 .sessions()
3305 .load_sessions()
3306 .await
3307 .expect("failed to load sessions")
3308 .into_iter()
3309 .map(|session| session.id)
3310 .collect::<Vec<_>>();
3311 let project_session_ids = database
3312 .sessions()
3313 .load_sessions_for_project(project_id)
3314 .await
3315 .expect("failed to load project sessions")
3316 .into_iter()
3317 .map(|session| session.id)
3318 .collect::<Vec<_>>();
3319
3320 assert_eq!(all_session_ids, ["z-newer", "a-older"]);
3322 assert_eq!(project_session_ids, ["z-newer", "a-older"]);
3323 }
3324
3325 #[tokio::test]
3326 async fn test_fork_session_snapshot_resets_source_specific_state() {
3327 let (database, pool) = AppRepositories::in_memory_with_pool()
3329 .await
3330 .expect("db should open");
3331 let (source_reset_row, source_review_request) =
3332 seed_fork_snapshot_source(&database, &pool).await;
3333
3334 database
3336 .sessions()
3337 .fork_session_snapshot(ForkSessionSnapshot {
3338 new_session_id: "fork-session",
3339 source_session_id: "source-session",
3340 status: "Review",
3341 })
3342 .await
3343 .expect("failed to fork session snapshot");
3344
3345 let session_rows = database
3347 .sessions()
3348 .load_sessions()
3349 .await
3350 .expect("failed to load sessions");
3351 let source_row = session_rows
3352 .iter()
3353 .find(|session_row| session_row.id == "source-session")
3354 .expect("missing source session row");
3355 let fork_row = session_rows
3356 .iter()
3357 .find(|session_row| session_row.id == "fork-session")
3358 .expect("missing forked session row");
3359 let fork_reset_row = load_fork_reset_row(&pool, "fork-session").await;
3360 let fork_review_request = database
3361 .reviews()
3362 .load_session_review_request("fork-session")
3363 .await
3364 .expect("failed to load fork review request");
3365
3366 assert_source_reset_state(
3367 source_row,
3368 &source_reset_row,
3369 source_review_request.as_ref(),
3370 );
3371 assert_fork_reset_state(fork_row, &fork_reset_row, fork_review_request.as_ref());
3372 }
3373
3374 #[tokio::test]
3375 async fn test_clear_session_draft_flag_marks_draft_session_live() {
3376 let (database, _pool) = AppRepositories::in_memory_with_pool()
3378 .await
3379 .expect("db should open");
3380 let project_id = database
3381 .projects()
3382 .upsert_project("/tmp/project", None)
3383 .await
3384 .expect("failed to upsert project");
3385 database
3386 .sessions()
3387 .insert_draft_session("draft-session", "gpt-5.6-sol", "main", "Draft", project_id)
3388 .await
3389 .expect("failed to insert draft session");
3390
3391 database
3393 .sessions()
3394 .clear_session_draft_flag("draft-session")
3395 .await
3396 .expect("failed to clear session draft flag");
3397
3398 let session_row = database
3400 .sessions()
3401 .load_sessions()
3402 .await
3403 .expect("failed to load sessions")
3404 .into_iter()
3405 .find(|session_row| session_row.id == "draft-session")
3406 .expect("missing draft session row");
3407 assert!(!session_row.is_draft);
3408 }
3409
3410 #[test]
3414 fn test_session_join_row_ignores_partial_review_request_columns() {
3415 let mut session_join_row = SessionJoinRow::fixture_for_test();
3417 session_join_row.review_request_last_refreshed_at = None;
3418
3419 let session_row = session_join_row.into_session_row();
3421
3422 assert_eq!(session_row.id, "session-a");
3424 assert_eq!(session_row.project_id, Some(7));
3425 assert_eq!(
3426 session_row.parent_session_id.as_deref(),
3427 Some("parent-session")
3428 );
3429 assert_eq!(session_row.status, "Review");
3430 assert_eq!(session_row.added_lines, 14);
3431 assert_eq!(session_row.deleted_lines, 6);
3432 assert_eq!(session_row.review_request, None);
3433 }
3434
3435 #[test]
3438 fn test_session_join_row_maps_review_request_columns() {
3439 let session_join_row = SessionJoinRow::fixture_for_test();
3441
3442 let session_row = session_join_row.into_session_row();
3444
3445 assert_eq!(session_row.id, "session-a");
3447 assert_eq!(session_row.added_lines, 14);
3448 assert_eq!(session_row.deleted_lines, 6);
3449 assert_eq!(session_row.project_id, Some(7));
3450 assert_eq!(session_row.personality_id.as_deref(), Some("reviewer"));
3451 assert_eq!(
3452 session_row.parent_session_id.as_deref(),
3453 Some("parent-session")
3454 );
3455 assert_eq!(
3456 session_row.published_upstream_ref.as_deref(),
3457 Some("origin/session-a")
3458 );
3459 assert_eq!(session_row.questions.as_deref(), Some("Question text"));
3460 assert_eq!(session_row.summary.as_deref(), Some("Summary text"));
3461 assert_eq!(session_row.title.as_deref(), Some("Review session"));
3462 assert_eq!(
3463 session_row.review_request,
3464 Some(expected_review_request_row())
3465 );
3466 }
3467}