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