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