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