Skip to main content

ag_store/
session.rs

1//! Session-scoped persistence adapters and query helpers.
2
3use 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
20/// Transactional turn-metadata payload persisted after one completed agent
21/// turn.
22///
23/// Owns its fields so the persistence trait method stays lifetime-free. A
24/// borrowed variant (`SessionTurnMetadata<'a>`) forced the persist method to
25/// carry a generic lifetime, which `mockall::automock` drops in the generated
26/// mock and newer `clippy` then rejects via `extra_unused_lifetimes`. Owning
27/// the data is allocation-cheap on this once-per-turn path and keeps the trait
28/// signature stable across toolchains.
29pub struct SessionTurnMetadata {
30    /// Personality id successfully delivered for this turn, or `None` when
31    /// the turn cleared or had no personality.
32    pub applied_personality_id: Option<String>,
33    /// Fingerprint of the personality prompt successfully delivered for this
34    /// turn.
35    pub applied_personality_prompt_hash: Option<String>,
36    /// Session-scoped instruction bootstrap marker for app-server providers.
37    pub instruction_conversation_id: Option<String>,
38    /// Model identifier used for per-model usage aggregation.
39    pub model: String,
40    /// Persisted provider-native conversation identifier for future resumes.
41    pub provider_conversation_id: Option<String>,
42    /// Serialized clarification-question payload stored on the session row.
43    pub questions_json: String,
44    /// Serialized structured summary payload stored on the session row.
45    pub summary: String,
46    /// Token-usage delta attributed to the completed turn.
47    pub token_usage_delta: SessionStats,
48}
49
50/// Borrowed values used to persist a newly created session with explicit
51/// provider identity and reasoning configuration.
52pub struct PersistedSessionCreation<'a> {
53    /// Persisted agent provider kind for the session.
54    pub agent: &'a str,
55    /// Base branch or parent branch used for future worktree materialization.
56    pub base_branch: &'a str,
57    /// Stable session identifier.
58    pub id: &'a str,
59    /// Whether the row was created through explicit draft staging.
60    pub is_draft: bool,
61    /// Persisted model identifier for the session.
62    pub model: &'a str,
63    /// Orchestration task that owns this child session, when applicable.
64    pub orchestration_task_id: Option<i64>,
65    /// Optional parent session id for one-level stacked drafts.
66    pub parent_session_id: Option<&'a str>,
67    /// Provider permission mode captured for the session.
68    pub permission_mode: PermissionMode,
69    /// Workspace personality selected for future turns, when present.
70    pub personality_id: Option<&'a str>,
71    /// Owning project identifier.
72    pub project_id: i64,
73    /// Reasoning level captured from the project default at creation.
74    pub reasoning_level: ReasoningLevel,
75    /// Persisted session role, or `None` for the default worker role.
76    pub role: Option<&'a str>,
77    /// Response-speed preference captured for the session.
78    pub speed_mode: SpeedMode,
79    /// Initial lifecycle status string.
80    pub status: &'a str,
81}
82
83/// Borrowed identifiers used to persist one forked session snapshot.
84pub struct ForkSessionSnapshot<'a> {
85    /// Stable id assigned to the newly forked session.
86    pub new_session_id: &'a str,
87    /// Stable id of the source session whose metadata and transcript are
88    /// copied.
89    pub source_session_id: &'a str,
90    /// Initial lifecycle status for the forked session.
91    pub status: &'a str,
92}
93
94/// Row returned when loading a session from the `session` table.
95///
96/// Includes optional normalized forge review-request linkage metadata loaded
97/// through the `session_review_request` table when the session has been
98/// published for remote review.
99pub struct SessionRow {
100    /// Persisted added-line count from the latest diff stats refresh.
101    pub added_lines: i64,
102    /// Persisted agent provider kind selected for this session.
103    pub agent: String,
104    /// Base branch used to create the session worktree.
105    pub base_branch: String,
106    /// Session creation timestamp in Unix seconds.
107    pub created_at: i64,
108    /// Persisted deleted-line count from the latest diff stats refresh.
109    pub deleted_lines: i64,
110    /// Whether the latest successful diff refresh returned content, or
111    /// `None` when diff availability is unknown.
112    pub has_diff: Option<bool>,
113    /// Stable session identifier.
114    pub id: String,
115    /// Open active-work interval start timestamp, if any.
116    pub in_progress_started_at: Option<i64>,
117    /// Completed active-work duration in whole seconds.
118    pub in_progress_total_seconds: i64,
119    /// Total input tokens accumulated for the session.
120    pub input_tokens: i64,
121    /// Whether the session is still an explicit draft.
122    pub is_draft: bool,
123    /// Persisted agent model identifier.
124    pub model: String,
125    /// Total output tokens accumulated for the session.
126    pub output_tokens: i64,
127    /// Parent session id when this is a one-level stacked draft.
128    pub parent_session_id: Option<String>,
129    /// Persisted provider permission mode for future turns.
130    pub permission_mode: String,
131    /// Workspace personality selected for future turns, when present.
132    pub personality_id: Option<String>,
133    /// Owning project identifier, when present.
134    pub project_id: Option<i64>,
135    /// Initial or staged prompt text.
136    pub prompt: String,
137    /// Published upstream branch reference, when present.
138    pub published_upstream_ref: Option<String>,
139    /// Serialized clarification-question payload, when present.
140    pub questions: Option<String>,
141    /// Persisted session-specific reasoning override, when present.
142    pub reasoning_level_override: Option<String>,
143    /// Joined forge review-request metadata, when present and complete.
144    pub review_request: Option<SessionReviewRequestRow>,
145    /// Persisted session role string, or `None` for the default worker role.
146    pub role: Option<String>,
147    /// Persisted size bucket string.
148    pub size: String,
149    /// Persisted session response-speed preference.
150    pub speed_mode: String,
151    /// Persisted lifecycle status string.
152    pub status: String,
153    /// Persisted structured summary text, when present.
154    pub summary: Option<String>,
155    /// Optional display title.
156    pub title: Option<String>,
157    /// Last update timestamp in Unix seconds.
158    pub updated_at: i64,
159}
160
161/// Lightweight row returned when loading session-list metadata.
162///
163/// Omits transcript-scale fields (`prompt`, `questions`, and `summary`) so
164/// list refreshes scale with visible metadata instead of the cumulative size
165/// of every saved conversation.
166pub struct SessionListRow {
167    /// Persisted added-line count from the latest diff stats refresh.
168    pub added_lines: i64,
169    /// Persisted agent provider kind for this session.
170    pub agent: String,
171    /// Base branch used to create the session worktree.
172    pub base_branch: String,
173    /// Session creation timestamp in Unix seconds.
174    pub created_at: i64,
175    /// Persisted deleted-line count from the latest diff stats refresh.
176    pub deleted_lines: i64,
177    /// Whether the latest successful diff refresh returned content, or
178    /// `None` when diff availability is unknown.
179    pub has_diff: Option<bool>,
180    /// Stable session identifier.
181    pub id: String,
182    /// Open active-work interval start timestamp, if any.
183    pub in_progress_started_at: Option<i64>,
184    /// Completed active-work duration in whole seconds.
185    pub in_progress_total_seconds: i64,
186    /// Total input tokens accumulated for the session.
187    pub input_tokens: i64,
188    /// Whether the session is still an explicit draft.
189    pub is_draft: bool,
190    /// Persisted agent model identifier.
191    pub model: String,
192    /// Total output tokens accumulated for the session.
193    pub output_tokens: i64,
194    /// Parent session id when this row is a one-level stacked draft.
195    pub parent_session_id: Option<String>,
196    /// Persisted provider permission mode for future turns.
197    pub permission_mode: String,
198    /// Workspace personality selected for future turns, when present.
199    pub personality_id: Option<String>,
200    /// Owning project identifier, when present.
201    pub project_id: Option<i64>,
202    /// Published upstream branch reference, when present.
203    pub published_upstream_ref: Option<String>,
204    /// Persisted session-specific reasoning override, when present.
205    pub reasoning_level_override: Option<String>,
206    /// Joined forge review-request metadata, when present and complete.
207    pub review_request: Option<SessionReviewRequestRow>,
208    /// Persisted session role string, or `None` for the default worker role.
209    pub role: Option<String>,
210    /// Persisted size bucket string.
211    pub size: String,
212    /// Persisted session response-speed preference.
213    pub speed_mode: String,
214    /// Persisted lifecycle status string.
215    pub status: String,
216    /// Optional display title.
217    pub title: Option<String>,
218    /// Last update timestamp in Unix seconds.
219    pub updated_at: i64,
220}
221
222/// Minimal provider/model row used to migrate active sessions across projects.
223#[derive(sqlx::FromRow)]
224pub struct SessionAgentModelRow {
225    /// Persisted agent provider kind for this session.
226    pub agent: String,
227    /// Stable session identifier.
228    pub id: String,
229    /// Persisted agent model identifier.
230    pub model: String,
231    /// Persisted lifecycle status string.
232    pub status: String,
233}
234
235/// Transcript-detail row loaded lazily for the session being viewed.
236pub struct SessionDetailRow {
237    /// Initial or staged prompt text.
238    pub prompt: String,
239    /// Serialized clarification-question payload, when present.
240    pub questions: Option<String>,
241    /// Persisted structured summary text, when present.
242    pub summary: Option<String>,
243}
244
245/// Row returned when loading one persisted `session_message`.
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct SessionMessageRow {
248    /// Canonical transcript text for this message.
249    pub content: String,
250    /// Stable message-kind string.
251    pub kind: String,
252    /// Monotonic position within the owning session transcript.
253    pub position: i64,
254}
255
256/// Row returned when hydrating persisted focused-review cache entries.
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub struct SessionFocusedReviewRow {
259    /// Diff-content hash captured when the focused review was generated.
260    pub diff_hash: String,
261    /// Stable session identifier.
262    pub session_id: String,
263    /// Generated focused-review markdown text.
264    pub text: String,
265}
266
267/// Persisted selected and successfully applied personality state.
268#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct SessionPersonalityState {
270    /// Personality id delivered during the latest successful turn.
271    pub applied_personality_id: Option<String>,
272    /// Fingerprint of the personality prompt delivered during that turn.
273    pub applied_personality_prompt_hash: Option<String>,
274    /// Personality id selected for the session's next turn.
275    pub personality_id: Option<String>,
276}
277
278/// Session-focused persistence boundary used by app orchestration and tests.
279#[async_trait]
280pub trait SessionRepository: Send + Sync {
281    /// Appends one typed transcript message and refreshes session ordering
282    /// metadata.
283    async fn append_session_message(
284        &self,
285        id: &str,
286        kind: SessionMessageKind,
287        content: &str,
288    ) -> Result<(), DbError>;
289
290    /// Sets `project_id` for sessions that do not yet reference a project.
291    async fn backfill_session_project(&self, project_id: i64) -> Result<(), DbError>;
292
293    /// Deletes a session row by identifier.
294    async fn delete_session(&self, id: &str) -> Result<(), DbError>;
295
296    /// Returns the persisted base branch for a session, when present.
297    async fn get_session_base_branch(&self, id: &str) -> Result<Option<String>, DbError>;
298
299    /// Returns the parent session id for a stacked session, when present.
300    async fn get_session_parent_session_id(&self, id: &str) -> Result<Option<String>, DbError>;
301
302    /// Returns the parent/base commit hash that a stacked child branch was
303    /// last known to contain.
304    async fn get_session_stack_base_commit_hash(&self, id: &str)
305    -> Result<Option<String>, DbError>;
306
307    /// Returns the persisted app-server instruction bootstrap marker for a
308    /// session, when present.
309    async fn get_session_instruction_conversation_id(
310        &self,
311        id: &str,
312    ) -> Result<Option<String>, DbError>;
313
314    /// Returns the provider conversation identifier for a session, when
315    /// present.
316    async fn get_session_provider_conversation_id(
317        &self,
318        id: &str,
319    ) -> Result<Option<String>, DbError>;
320
321    /// Inserts a newly created draft-session row.
322    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    /// Inserts a newly created stacked draft-session row.
332    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    /// Inserts a newly created session row.
343    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    /// Inserts a newly created session row with explicit provider identity.
353    async fn insert_session_with_agent(
354        &self,
355        session: PersistedSessionCreation<'_>,
356    ) -> Result<(), DbError>;
357
358    /// Inserts a new session by snapshotting source metadata and ordered
359    /// transcript messages while clearing source-specific runtime linkage.
360    async fn fork_session_snapshot(&self, snapshot: ForkSessionSnapshot<'_>)
361    -> Result<(), DbError>;
362
363    /// Loads one complete persisted session row by stable identifier.
364    async fn load_session(&self, session_id: &str) -> Result<Option<SessionRow>, DbError>;
365
366    /// Loads provider/model metadata for every non-terminal session across
367    /// projects.
368    async fn load_active_session_agent_models(&self) -> Result<Vec<SessionAgentModelRow>, DbError>;
369
370    #[cfg(any(test, feature = "test-utils"))]
371    /// Loads all sessions ordered by most recent update.
372    async fn load_sessions(&self) -> Result<Vec<SessionRow>, DbError>;
373
374    /// Loads lightweight session-list metadata ordered by most recent update
375    /// for one project.
376    async fn load_sessions_for_project(
377        &self,
378        project_id: i64,
379    ) -> Result<Vec<SessionListRow>, DbError>;
380
381    /// Loads transcript-scale detail for one session when it becomes active.
382    async fn load_session_detail(
383        &self,
384        session_id: &str,
385    ) -> Result<Option<SessionDetailRow>, DbError>;
386
387    /// Loads ordered transcript messages for one session.
388    async fn load_session_messages(
389        &self,
390        session_id: &str,
391    ) -> Result<Vec<SessionMessageRow>, DbError>;
392
393    /// Loads persisted focused-review cache rows for one project.
394    async fn load_session_focused_reviews_for_project(
395        &self,
396        project_id: i64,
397    ) -> Result<Vec<SessionFocusedReviewRow>, DbError>;
398
399    /// Loads lightweight session metadata used for cheap change detection.
400    async fn load_sessions_metadata(&self) -> Result<(i64, i64), DbError>;
401
402    /// Loads the project identifier associated with one session.
403    async fn load_session_project_id(&self, session_id: &str) -> Result<Option<i64>, DbError>;
404
405    /// Loads selected and last-applied personality state for one session.
406    async fn load_session_personality_state(
407        &self,
408        session_id: &str,
409    ) -> Result<Option<SessionPersonalityState>, DbError>;
410
411    /// Loads parentless review-ready sessions that still need their recorded
412    /// stack-base commit replayed onto their current base branch.
413    async fn load_pending_stack_restack_session_ids(
414        &self,
415        project_id: i64,
416    ) -> Result<Vec<String>, DbError>;
417
418    /// Returns the persisted upstream reference for a published session
419    /// branch, when present.
420    async fn load_session_published_upstream_ref(
421        &self,
422        id: &str,
423    ) -> Result<Option<String>, DbError>;
424
425    /// Loads the persisted merged commit hash for one session, when present.
426    async fn load_session_merged_commit_hash(
427        &self,
428        session_id: &str,
429    ) -> Result<Option<String>, DbError>;
430
431    /// Loads the immutable diff archived before managed-session cleanup.
432    async fn load_session_archived_diff(&self, session_id: &str)
433    -> Result<Option<String>, DbError>;
434
435    /// Clears parent links for children after their parent session merges
436    /// into its base branch, returning materialized children that may need a
437    /// follow-up branch restack.
438    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    /// Loads the persisted session reasoning level.
446    async fn load_session_reasoning_level(
447        &self,
448        session_id: &str,
449    ) -> Result<ReasoningLevel, DbError>;
450
451    /// Loads the persisted provider permission mode for future turns.
452    async fn load_session_permission_mode(
453        &self,
454        session_id: &str,
455    ) -> Result<PermissionMode, DbError>;
456
457    /// Loads the persisted session response-speed preference.
458    async fn load_session_speed_mode(&self, session_id: &str) -> Result<SpeedMode, DbError>;
459
460    /// Loads the persisted summary text associated with one session.
461    async fn load_session_summary(&self, session_id: &str) -> Result<Option<String>, DbError>;
462
463    /// Returns `(created_at, updated_at)` timestamps for a session.
464    async fn load_session_timestamps(
465        &self,
466        session_id: &str,
467    ) -> Result<Option<(i64, i64)>, DbError>;
468
469    /// Persists all canonical turn metadata for one completed agent turn in a
470    /// single transaction.
471    async fn persist_session_turn_metadata(
472        &self,
473        session_id: &str,
474        turn_metadata: &SessionTurnMetadata,
475    ) -> Result<(), DbError>;
476
477    /// Marks persisted diff availability unknown while retaining the last
478    /// known size and line counts.
479    async fn mark_session_diff_unknown(&self, id: &str) -> Result<(), DbError>;
480
481    /// Updates persisted diff-derived presence, size, and line-count fields
482    /// for a session row.
483    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    /// Updates the persisted app-server instruction bootstrap marker for a
493    /// session.
494    async fn update_session_instruction_conversation_id(
495        &self,
496        id: &str,
497        provider_conversation_id: Option<String>,
498    ) -> Result<(), DbError>;
499
500    /// Updates the persisted model for a session.
501    async fn update_session_model(&self, id: &str, model: &str) -> Result<(), DbError>;
502
503    /// Updates or clears the personality selected for future turns.
504    async fn update_session_personality_id(
505        &self,
506        id: &str,
507        personality_id: Option<String>,
508    ) -> Result<(), DbError>;
509
510    /// Updates the persisted agent provider and model for a session.
511    async fn update_session_agent_model(
512        &self,
513        id: &str,
514        agent: &str,
515        model: &str,
516    ) -> Result<(), DbError>;
517
518    /// Updates the persisted agent provider and model only while the session
519    /// remains non-terminal, without changing its activity timestamp.
520    async fn update_active_session_agent_model(
521        &self,
522        id: &str,
523        agent: &str,
524        model: &str,
525    ) -> Result<(), DbError>;
526
527    /// Clears the draft flag for a session row once its staged draft bundle
528    /// starts the first live turn.
529    async fn clear_session_draft_flag(&self, id: &str) -> Result<(), DbError>;
530
531    /// Updates the persisted merged commit hash for a session row.
532    async fn update_session_merged_commit_hash(
533        &self,
534        id: &str,
535        merged_commit_hash: Option<String>,
536    ) -> Result<(), DbError>;
537
538    /// Persists or clears the immutable diff retained for archived sessions.
539    async fn update_session_archived_diff(
540        &self,
541        id: &str,
542        archived_diff: Option<String>,
543    ) -> Result<(), DbError>;
544
545    /// Persists or clears the parent/base commit hash used for deterministic
546    /// stacked-child rebases.
547    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    /// Updates the saved prompt for a session row.
554    async fn update_session_prompt(&self, id: &str, prompt: &str) -> Result<(), DbError>;
555
556    /// Updates the persisted provider conversation identifier for a session.
557    async fn update_session_provider_conversation_id(
558        &self,
559        id: &str,
560        provider_conversation_id: Option<String>,
561    ) -> Result<(), DbError>;
562
563    /// Updates the model clarification questions for a session row.
564    async fn update_session_questions(&self, id: &str, questions: &str) -> Result<(), DbError>;
565
566    /// Updates the persisted session reasoning level.
567    async fn update_session_reasoning_level(
568        &self,
569        id: &str,
570        reasoning_level: ReasoningLevel,
571    ) -> Result<(), DbError>;
572
573    /// Updates the persisted provider permission mode for future turns.
574    async fn update_session_permission_mode(
575        &self,
576        id: &str,
577        permission_mode: PermissionMode,
578    ) -> Result<(), DbError>;
579
580    /// Updates the persisted session response-speed preference.
581    async fn update_session_speed_mode(
582        &self,
583        id: &str,
584        speed_mode: SpeedMode,
585    ) -> Result<(), DbError>;
586
587    /// Updates the persisted upstream reference for a published session
588    /// branch.
589    async fn update_session_published_upstream_ref(
590        &self,
591        id: &str,
592        published_upstream_ref: Option<String>,
593    ) -> Result<(), DbError>;
594
595    /// Accumulates token statistics for a session.
596    async fn update_session_stats(&self, id: &str, stats: &SessionStats) -> Result<(), DbError>;
597
598    /// Updates the status for a session row and opens or closes the persisted
599    /// cumulative active-work interval when crossing the `InProgress`
600    /// boundary.
601    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    /// Updates the persisted session summary text for a session row.
609    async fn update_session_summary(&self, id: &str, summary: &str) -> Result<(), DbError>;
610
611    /// Updates or clears the persisted focused-review cache for a session.
612    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    /// Updates the display title for a session row.
621    async fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError>;
622
623    /// Stores a fallback title that can be refined by a later substantive
624    /// user prompt.
625    async fn update_session_provisional_title(&self, id: &str, title: &str) -> Result<(), DbError>;
626
627    /// Claims the next ordered title candidate for a session.
628    ///
629    /// When `requires_provisional_title` is true, no candidate is claimed
630    /// after a generated or commit-derived title becomes authoritative.
631    async fn begin_session_title_generation(
632        &self,
633        id: &str,
634        requires_provisional_title: bool,
635    ) -> Result<Option<i64>, DbError>;
636
637    /// Applies one generated title unless a newer candidate or authoritative
638    /// title has already been accepted.
639    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    /// Overrides the `created_at` timestamp for one session row.
647    #[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    /// Overrides the `updated_at` timestamp for one session row.
652    async fn update_session_updated_at(&self, id: &str, updated_at: i64) -> Result<(), DbError>;
653}
654
655/// `SQLite` implementation of [`SessionRepository`].
656#[derive(Clone)]
657pub(crate) struct SqliteSessionRepository(
658    SqlitePool,
659    Arc<dyn TimestampSource>,
660    SessionMessageStore,
661    SessionSnapshotStore,
662);
663
664impl SqliteSessionRepository {
665    /// Creates a session repository backed by the provided pool.
666    pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
667        Self(
668            pool.clone(),
669            Arc::clone(&timestamp_source),
670            SessionMessageStore::new(pool.clone(), Arc::clone(&timestamp_source)),
671            SessionSnapshotStore::new(pool, timestamp_source),
672        )
673    }
674
675    /// Returns the shared persistence timestamp in Unix seconds.
676    fn now(&self) -> i64 {
677        self.1.now_timestamp_seconds()
678    }
679}
680
681/// Row returned when loading a required string scalar value.
682struct RequiredStringValueRow {
683    value: String,
684}
685
686/// Row returned when loading session count and latest-update metadata.
687struct SessionStatsMetadataRow {
688    /// Latest `session.updated_at` timestamp across rows.
689    max_updated_at: i64,
690    /// Total number of persisted sessions.
691    session_count: i64,
692}
693
694/// Row returned when loading an optional `i64` scalar value.
695struct OptionalI64ValueRow {
696    value: Option<i64>,
697}
698
699/// Row returned when loading the persisted instruction bootstrap marker for
700/// one session.
701struct SessionInstructionStateRow {
702    app_server_instruction_provider_conversation_id: Option<String>,
703}
704
705impl SessionInstructionStateRow {
706    /// Converts the optional stored provider conversation id into one
707    /// normalized bootstrap conversation id when present and non-empty.
708    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
716/// Row returned when loading both persisted timestamps for one session.
717struct SessionTimestampsRow {
718    created_at: i64,
719    updated_at: i64,
720}
721
722/// Shared columns for session metadata rows used by both session and
723/// session-list mappings.
724struct 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    /// Converts shared metadata fields into a complete session row.
754    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    /// Converts shared metadata fields into a session-list row.
795    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/// Row returned when loading one complete `session` plus aliased
831/// `session_review_request` join columns.
832#[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    /// Returns whether this row can be included in a collection load.
875    ///
876    /// Invalid persisted statuses are logged and omitted so one corrupt row
877    /// cannot hide every otherwise valid session in the project list.
878    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    /// Converts the query-mapped join row into a complete [`SessionRow`].
893    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    /// Converts placeholder-detail query rows into a lightweight
905    /// [`SessionListRow`].
906    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    /// Splits the flat query row into shared metadata, transcript detail, and
913    /// normalized review-request data.
914    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
1010/// Aliased nullable `session_review_request` columns loaded through a joined
1011/// session query.
1012struct 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    /// Converts the joined nullable columns into a review-request row only
1026    /// when every required field is present.
1027    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        // Retarget any stacked children onto this session's base branch before
1089        // the row is removed. The `ON DELETE SET NULL` foreign key clears the
1090        // child parent link automatically, but it leaves children pointing at
1091        // the deleted parent's worktree branch, which no longer exists. Mirror
1092        // the post-merge restack so a surviving child rebases against the
1093        // parent's base branch instead of an orphaned `wt/<parent>` ref.
1094        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
2670/// Borrowed values used to insert one newly created session row.
2671struct InsertSessionRow<'a> {
2672    /// Agent provider kind persisted alongside the model for this session.
2673    agent: &'a str,
2674    /// Base branch or parent branch used for future worktree materialization.
2675    base_branch: &'a str,
2676    /// Stable session identifier.
2677    id: &'a str,
2678    /// Whether the row was created through explicit draft staging.
2679    is_draft: bool,
2680    /// Agent model identifier persisted for the session.
2681    model: &'a str,
2682    /// Orchestration task that owns this child session, when applicable.
2683    orchestration_task_id: Option<i64>,
2684    /// Optional parent session id for one-level stacked drafts.
2685    parent_session_id: Option<&'a str>,
2686    /// Provider permission mode captured for the session.
2687    permission_mode: PermissionMode,
2688    /// Workspace personality selected for future turns, when present.
2689    personality_id: Option<&'a str>,
2690    /// Owning project identifier.
2691    project_id: i64,
2692    /// Reasoning level captured from the project default at creation.
2693    reasoning_level: ReasoningLevel,
2694    /// Persisted session role, or `None` for the default worker role.
2695    role: Option<&'a str>,
2696    /// Response-speed preference captured for the session.
2697    speed_mode: SpeedMode,
2698    /// Initial lifecycle status string.
2699    status: &'a str,
2700}
2701
2702/// Inserts one newly created session row with explicit draft-mode
2703/// persistence.
2704async 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    // Diff availability remains unknown until the worktree is refreshed.
2758    .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
2777/// Returns the persisted agent value paired with a newly saved model string.
2778fn 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
2785/// Returns a compatibility agent value for known model strings passed through
2786/// model-only legacy persistence helpers.
2787fn 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
2807/// Returns a compatibility agent value for tests or older callers that pass
2808/// model strings outside the current curated model set.
2809fn 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    /// Session columns that must be reset when snapshotting a fork.
2833    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        /// Builds a deterministic joined-session row fixture for conversion
2852        /// tests.
2853        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    /// Builds the fully populated review-request row expected by join-row
2899    /// conversion tests.
2900    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    /// Builds the review-request domain fixture used by fork snapshot tests.
2915    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    /// Loads reset-sensitive fork columns that are not exposed by public
2932    /// session row projections.
2933    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    /// Seeds a forkable source session with every source-only field that the
2962    /// snapshot insert is expected to clear.
2963    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    /// Persists source-only linkage and counters on the fork source row.
3010    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    /// Persists the session settings that a fork must inherit.
3108    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    /// Persists active-work timing fields on the fork source row.
3122    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    /// Asserts the fixture source row actually had source-only state before
3143    /// the snapshot was taken.
3144    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    /// Asserts the forked row kept durable snapshot state while clearing
3215    /// source-only linkage.
3216    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        // Arrange
3255        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        // Act
3274        let result = database.sessions().load_session("session-a").await;
3275
3276        // Assert
3277        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        // Arrange
3289        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        // Act
3310        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
3322        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        // Arrange
3341        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        // Act
3351        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
3366        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        // Arrange
3372        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        // Act
3400        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
3418        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        // Arrange
3425        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        // Act
3432        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        // Assert
3443        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        // Arrange
3480        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        // Act
3495        database
3496            .sessions()
3497            .clear_session_draft_flag("draft-session")
3498            .await
3499            .expect("failed to clear session draft flag");
3500
3501        // Assert
3502        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    /// Verifies `SessionJoinRow::into_session_row()` drops partially
3514    /// populated review-request columns instead of surfacing an invalid row
3515    /// model.
3516    #[test]
3517    fn test_session_join_row_ignores_partial_review_request_columns() {
3518        // Arrange
3519        let mut session_join_row = SessionJoinRow::fixture_for_test();
3520        session_join_row.review_request_last_refreshed_at = None;
3521
3522        // Act
3523        let session_row = session_join_row.into_session_row();
3524
3525        // Assert
3526        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    /// Verifies `SessionJoinRow::into_session_row()` maps a fully populated
3539    /// review-request into the public session row model.
3540    #[test]
3541    fn test_session_join_row_maps_review_request_columns() {
3542        // Arrange
3543        let session_join_row = SessionJoinRow::fixture_for_test();
3544
3545        // Act
3546        let session_row = session_join_row.into_session_row();
3547
3548        // Assert
3549        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}