use async_trait::async_trait;
use sqlx::SqlitePool;
use super::review::SessionReviewRequestRow;
use crate::domain::agent::ReasoningLevel;
use crate::domain::session::{SessionFollowUpTask, SessionId, SessionStats};
use crate::infra::agent;
use crate::infra::db::DbError;
pub struct SessionTurnMetadata {
pub(crate) instruction_conversation_id: Option<String>,
pub(crate) model: String,
pub(crate) provider_conversation_id: Option<String>,
pub(crate) questions_json: String,
pub(crate) summary: String,
pub(crate) token_usage_delta: SessionStats,
}
pub struct SessionRow {
pub added_lines: i64,
pub base_branch: String,
pub created_at: i64,
pub deleted_lines: i64,
pub id: String,
pub in_progress_started_at: Option<i64>,
pub in_progress_total_seconds: i64,
pub input_tokens: i64,
pub is_draft: bool,
pub model: String,
pub output: String,
pub output_tokens: i64,
pub parent_session_id: Option<String>,
pub project_id: Option<i64>,
pub prompt: String,
pub reasoning_level_override: Option<String>,
pub published_upstream_ref: Option<String>,
pub questions: Option<String>,
pub review_request: Option<SessionReviewRequestRow>,
pub size: String,
pub status: String,
pub summary: Option<String>,
pub title: Option<String>,
pub updated_at: i64,
}
pub struct SessionListRow {
pub added_lines: i64,
pub base_branch: String,
pub created_at: i64,
pub deleted_lines: i64,
pub id: String,
pub in_progress_started_at: Option<i64>,
pub in_progress_total_seconds: i64,
pub input_tokens: i64,
pub is_draft: bool,
pub model: String,
pub output_tokens: i64,
pub parent_session_id: Option<String>,
pub project_id: Option<i64>,
pub reasoning_level_override: Option<String>,
pub published_upstream_ref: Option<String>,
pub review_request: Option<SessionReviewRequestRow>,
pub size: String,
pub status: String,
pub title: Option<String>,
pub updated_at: i64,
}
#[derive(sqlx::FromRow)]
pub struct SessionDetailRow {
pub output: String,
pub prompt: String,
pub questions: Option<String>,
pub summary: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, sqlx::FromRow)]
pub struct SessionFollowUpTaskRow {
pub id: i64,
pub launched_session_id: Option<String>,
pub position: i64,
pub session_id: String,
pub text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, sqlx::FromRow)]
pub struct SessionFocusedReviewRow {
pub(crate) diff_hash: String,
pub(crate) session_id: String,
pub(crate) text: String,
}
impl SessionFollowUpTaskRow {
pub(crate) fn into_session_follow_up_task(self) -> SessionFollowUpTask {
SessionFollowUpTask {
id: self.id,
launched_session_id: self.launched_session_id.map(SessionId::from),
position: usize::try_from(self.position).unwrap_or(usize::MAX),
text: self.text,
}
}
}
#[async_trait]
pub trait SessionRepository: Send + Sync {
async fn append_session_output(&self, id: &str, chunk: &str) -> Result<(), DbError>;
async fn backfill_session_project(&self, project_id: i64) -> Result<(), DbError>;
async fn delete_session(&self, id: &str) -> Result<(), DbError>;
async fn get_session_base_branch(&self, id: &str) -> Result<Option<String>, DbError>;
async fn get_session_instruction_conversation_id(
&self,
id: &str,
) -> Result<Option<String>, DbError>;
async fn get_session_provider_conversation_id(
&self,
id: &str,
) -> Result<Option<String>, DbError>;
async fn insert_draft_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
project_id: i64,
) -> Result<(), DbError>;
async fn insert_stacked_draft_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
parent_session_id: &str,
project_id: i64,
) -> Result<(), DbError>;
async fn insert_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
project_id: i64,
) -> Result<(), DbError>;
#[cfg(test)]
async fn load_sessions(&self) -> Result<Vec<SessionRow>, DbError>;
async fn load_sessions_for_project(
&self,
project_id: i64,
) -> Result<Vec<SessionListRow>, DbError>;
async fn load_session_detail(
&self,
session_id: &str,
) -> Result<Option<SessionDetailRow>, DbError>;
async fn load_session_follow_up_tasks(&self) -> Result<Vec<SessionFollowUpTaskRow>, DbError>;
async fn load_session_focused_reviews_for_project(
&self,
project_id: i64,
) -> Result<Vec<SessionFocusedReviewRow>, DbError>;
async fn load_sessions_metadata(&self) -> Result<(i64, i64), DbError>;
async fn load_session_project_id(&self, session_id: &str) -> Result<Option<i64>, DbError>;
async fn load_session_published_upstream_ref(
&self,
id: &str,
) -> Result<Option<String>, DbError>;
async fn load_session_merged_commit_hash(
&self,
session_id: &str,
) -> Result<Option<String>, DbError>;
async fn restack_child_sessions_after_parent_merge(
&self,
parent_session_id: &str,
base_branch: &str,
) -> Result<(), DbError>;
async fn load_session_reasoning_level_override(
&self,
session_id: &str,
) -> Result<Option<ReasoningLevel>, DbError>;
async fn load_session_summary(&self, session_id: &str) -> Result<Option<String>, DbError>;
async fn load_session_timestamps(
&self,
session_id: &str,
) -> Result<Option<(i64, i64)>, DbError>;
async fn persist_session_turn_metadata(
&self,
session_id: &str,
turn_metadata: &SessionTurnMetadata,
) -> Result<(), DbError>;
#[cfg(test)]
async fn replace_session_output(&self, id: &str, output: &str) -> Result<(), DbError>;
async fn replace_session_follow_up_tasks(
&self,
session_id: &str,
follow_up_tasks: &[String],
) -> Result<(), DbError>;
async fn update_session_diff_stats(
&self,
added_lines: u64,
deleted_lines: u64,
id: &str,
size: &str,
) -> Result<(), DbError>;
async fn update_session_follow_up_task_launched_session_id(
&self,
session_id: &str,
position: usize,
launched_session_id: Option<String>,
) -> Result<(), DbError>;
async fn update_session_instruction_conversation_id(
&self,
id: &str,
provider_conversation_id: Option<String>,
) -> Result<(), DbError>;
async fn update_session_model(&self, id: &str, model: &str) -> Result<(), DbError>;
async fn update_session_merged_commit_hash(
&self,
id: &str,
merged_commit_hash: Option<String>,
) -> Result<(), DbError>;
async fn update_session_prompt(&self, id: &str, prompt: &str) -> Result<(), DbError>;
async fn update_session_provider_conversation_id(
&self,
id: &str,
provider_conversation_id: Option<String>,
) -> Result<(), DbError>;
async fn update_session_questions(&self, id: &str, questions: &str) -> Result<(), DbError>;
async fn update_session_reasoning_level(
&self,
id: &str,
reasoning_level: Option<String>,
) -> Result<(), DbError>;
async fn update_session_published_upstream_ref(
&self,
id: &str,
published_upstream_ref: Option<String>,
) -> Result<(), DbError>;
async fn update_session_stats(&self, id: &str, stats: &SessionStats) -> Result<(), DbError>;
async fn update_session_status_with_timing_at(
&self,
id: &str,
status: &str,
timestamp_seconds: i64,
) -> Result<(), DbError>;
async fn update_session_summary(&self, id: &str, summary: &str) -> Result<(), DbError>;
async fn update_session_focused_review(
&self,
id: &str,
diff_hash: Option<String>,
text: Option<String>,
) -> Result<(), DbError>;
async fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError>;
async fn update_session_title_for_prompt(
&self,
id: &str,
expected_prompt: &str,
title: &str,
) -> Result<bool, DbError>;
#[cfg(test)]
async fn update_session_created_at(&self, id: &str, created_at: i64) -> Result<(), DbError>;
#[cfg(test)]
async fn update_session_updated_at(&self, id: &str, updated_at: i64) -> Result<(), DbError>;
}
#[derive(Clone)]
pub(crate) struct SqliteSessionRepository(SqlitePool);
impl SqliteSessionRepository {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self(pool)
}
}
struct RequiredStringValueRow {
value: String,
}
struct SessionMetadataRow {
max_updated_at: i64,
session_count: i64,
}
struct OptionalI64ValueRow {
value: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct SessionInstructionStateRow {
app_server_instruction_provider_conversation_id: Option<String>,
}
impl SessionInstructionStateRow {
fn into_instruction_conversation_id(self) -> Option<String> {
agent::normalize_instruction_conversation_id(
self.app_server_instruction_provider_conversation_id
.as_deref(),
)
}
}
struct SessionTimestampsRow {
created_at: i64,
updated_at: i64,
}
#[cfg(test)]
pub(crate) struct SessionJoinRow {
added_lines: i64,
base_branch: String,
created_at: i64,
deleted_lines: i64,
id: String,
in_progress_started_at: Option<i64>,
in_progress_total_seconds: i64,
input_tokens: i64,
is_draft: bool,
model: String,
output: String,
output_tokens: i64,
parent_session_id: Option<String>,
project_id: Option<i64>,
prompt: String,
reasoning_level_override: Option<String>,
published_upstream_ref: Option<String>,
questions: Option<String>,
review_request_display_id: Option<String>,
review_request_forge_kind: Option<String>,
pub(crate) review_request_last_refreshed_at: Option<i64>,
review_request_source_branch: Option<String>,
review_request_state: Option<String>,
review_request_status_summary: Option<String>,
review_request_target_branch: Option<String>,
review_request_title: Option<String>,
review_request_web_url: Option<String>,
size: String,
status: String,
summary: Option<String>,
title: Option<String>,
updated_at: i64,
}
#[cfg(test)]
impl SessionJoinRow {
pub(crate) fn into_session_row(self) -> SessionRow {
let Self {
added_lines,
base_branch,
created_at,
deleted_lines,
id,
in_progress_started_at,
in_progress_total_seconds,
input_tokens,
is_draft,
model,
output,
output_tokens,
parent_session_id,
project_id,
prompt,
reasoning_level_override,
published_upstream_ref,
questions,
review_request_display_id,
review_request_forge_kind,
review_request_last_refreshed_at,
review_request_source_branch,
review_request_state,
review_request_status_summary,
review_request_target_branch,
review_request_title,
review_request_web_url,
size,
status,
summary,
title,
updated_at,
} = self;
let review_request = SessionReviewRequestJoinRow {
display_id: review_request_display_id,
forge_kind: review_request_forge_kind,
last_refreshed_at: review_request_last_refreshed_at,
source_branch: review_request_source_branch,
state: review_request_state,
status_summary: review_request_status_summary,
target_branch: review_request_target_branch,
title: review_request_title,
web_url: review_request_web_url,
}
.into_review_request_row();
SessionRow {
added_lines,
base_branch,
created_at,
deleted_lines,
id,
in_progress_started_at,
in_progress_total_seconds,
input_tokens,
is_draft,
model,
output,
output_tokens,
parent_session_id,
project_id,
prompt,
reasoning_level_override,
published_upstream_ref,
questions,
review_request,
size,
status,
summary,
title,
updated_at,
}
}
#[cfg(test)]
pub(crate) fn fixture_for_test() -> Self {
Self {
added_lines: 14,
base_branch: "main".to_string(),
created_at: 100,
deleted_lines: 6,
id: "session-a".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
input_tokens: 11,
is_draft: false,
model: "gpt-5.5".to_string(),
output: "Saved output".to_string(),
output_tokens: 29,
parent_session_id: Some("parent-session".to_string()),
project_id: Some(7),
prompt: "Implement feature".to_string(),
reasoning_level_override: None,
published_upstream_ref: Some("origin/session-a".to_string()),
questions: Some("Question text".to_string()),
review_request_display_id: Some("#42".to_string()),
review_request_forge_kind: Some("GitHub".to_string()),
review_request_last_refreshed_at: Some(456),
review_request_source_branch: Some("feature/forge".to_string()),
review_request_state: Some("Open".to_string()),
review_request_status_summary: Some("2 approvals, checks passing".to_string()),
review_request_target_branch: Some("main".to_string()),
review_request_title: Some("Add forge review support".to_string()),
review_request_web_url: Some(
"https://github.com/agentty-xyz/agentty/pull/42".to_string(),
),
size: "M".to_string(),
status: "Review".to_string(),
summary: Some("Summary text".to_string()),
title: Some("Review session".to_string()),
updated_at: 200,
}
}
}
#[derive(sqlx::FromRow)]
struct SessionListJoinRow {
added_lines: i64,
base_branch: String,
created_at: i64,
deleted_lines: i64,
id: String,
in_progress_started_at: Option<i64>,
in_progress_total_seconds: i64,
input_tokens: i64,
is_draft: bool,
model: String,
output_tokens: i64,
parent_session_id: Option<String>,
project_id: Option<i64>,
reasoning_level_override: Option<String>,
published_upstream_ref: Option<String>,
review_request_display_id: Option<String>,
review_request_forge_kind: Option<String>,
review_request_last_refreshed_at: Option<i64>,
review_request_source_branch: Option<String>,
review_request_state: Option<String>,
review_request_status_summary: Option<String>,
review_request_target_branch: Option<String>,
review_request_title: Option<String>,
review_request_web_url: Option<String>,
size: String,
status: String,
title: Option<String>,
updated_at: i64,
}
impl SessionListJoinRow {
fn into_session_list_row(self) -> SessionListRow {
let Self {
added_lines,
base_branch,
created_at,
deleted_lines,
id,
in_progress_started_at,
in_progress_total_seconds,
input_tokens,
is_draft,
model,
output_tokens,
parent_session_id,
project_id,
reasoning_level_override,
published_upstream_ref,
review_request_display_id,
review_request_forge_kind,
review_request_last_refreshed_at,
review_request_source_branch,
review_request_state,
review_request_status_summary,
review_request_target_branch,
review_request_title,
review_request_web_url,
size,
status,
title,
updated_at,
} = self;
let review_request = SessionReviewRequestJoinRow {
display_id: review_request_display_id,
forge_kind: review_request_forge_kind,
last_refreshed_at: review_request_last_refreshed_at,
source_branch: review_request_source_branch,
state: review_request_state,
status_summary: review_request_status_summary,
target_branch: review_request_target_branch,
title: review_request_title,
web_url: review_request_web_url,
}
.into_review_request_row();
SessionListRow {
added_lines,
base_branch,
created_at,
deleted_lines,
id,
in_progress_started_at,
in_progress_total_seconds,
input_tokens,
is_draft,
model,
output_tokens,
parent_session_id,
project_id,
reasoning_level_override,
published_upstream_ref,
review_request,
size,
status,
title,
updated_at,
}
}
}
struct SessionReviewRequestJoinRow {
display_id: Option<String>,
forge_kind: Option<String>,
last_refreshed_at: Option<i64>,
source_branch: Option<String>,
state: Option<String>,
status_summary: Option<String>,
target_branch: Option<String>,
title: Option<String>,
web_url: Option<String>,
}
impl SessionReviewRequestJoinRow {
fn into_review_request_row(self) -> Option<SessionReviewRequestRow> {
let Self {
display_id,
forge_kind,
last_refreshed_at,
source_branch,
state,
status_summary,
target_branch,
title,
web_url,
} = self;
Some(SessionReviewRequestRow {
display_id: display_id?,
forge_kind: forge_kind?,
last_refreshed_at: last_refreshed_at?,
source_branch: source_branch?,
state: state?,
status_summary,
target_branch: target_branch?,
title: title?,
web_url: web_url?,
})
}
}
#[async_trait]
impl SessionRepository for SqliteSessionRepository {
async fn append_session_output(&self, id: &str, chunk: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET output = output || ?
WHERE id = ?
",
)
.bind(chunk)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn backfill_session_project(&self, project_id: i64) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET project_id = ?
WHERE project_id IS NULL
",
)
.bind(project_id)
.execute(&self.0)
.await?;
Ok(())
}
async fn delete_session(&self, id: &str) -> Result<(), DbError> {
sqlx::query(
r"
DELETE FROM session
WHERE id = ?
",
)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn get_session_base_branch(&self, id: &str) -> Result<Option<String>, DbError> {
let row = sqlx::query_as!(
RequiredStringValueRow,
r#"
SELECT base_branch AS "value!: _"
FROM session
WHERE id = ?
"#,
id
)
.fetch_optional(&self.0)
.await?;
Ok(row.map(|row| row.value))
}
async fn get_session_instruction_conversation_id(
&self,
id: &str,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_as::<_, SessionInstructionStateRow>(
r"
SELECT app_server_instruction_provider_conversation_id
FROM session
WHERE id = ?
",
)
.bind(id)
.fetch_optional(&self.0)
.await?;
Ok(row.and_then(SessionInstructionStateRow::into_instruction_conversation_id))
}
async fn get_session_provider_conversation_id(
&self,
id: &str,
) -> Result<Option<String>, DbError> {
let value = sqlx::query_scalar!(
r"SELECT provider_conversation_id FROM session WHERE id = ?",
id
)
.fetch_optional(&self.0)
.await?
.flatten();
Ok(value)
}
async fn insert_draft_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
project_id: i64,
) -> Result<(), DbError> {
insert_session_with_draft_mode(
&self.0,
InsertSessionRow {
base_branch,
id,
is_draft: true,
model,
parent_session_id: None,
project_id,
status,
},
)
.await
}
async fn insert_stacked_draft_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
parent_session_id: &str,
project_id: i64,
) -> Result<(), DbError> {
insert_session_with_draft_mode(
&self.0,
InsertSessionRow {
base_branch,
id,
is_draft: true,
model,
parent_session_id: Some(parent_session_id),
project_id,
status,
},
)
.await
}
async fn insert_session(
&self,
id: &str,
model: &str,
base_branch: &str,
status: &str,
project_id: i64,
) -> Result<(), DbError> {
insert_session_with_draft_mode(
&self.0,
InsertSessionRow {
base_branch,
id,
is_draft: false,
model,
parent_session_id: None,
project_id,
status,
},
)
.await
}
#[cfg(test)]
async fn load_sessions(&self) -> Result<Vec<SessionRow>, DbError> {
let rows = sqlx::query_as!(
SessionJoinRow,
r#"
SELECT session.base_branch AS "base_branch!",
session.added_lines AS "added_lines!",
session.created_at AS "created_at!",
session.deleted_lines AS "deleted_lines!",
session.id AS "id!",
session.in_progress_started_at,
session.in_progress_total_seconds AS "in_progress_total_seconds!",
session.input_tokens AS "input_tokens!",
session.is_draft AS "is_draft!: bool",
session.model AS "model!",
session.output AS "output!",
session.output_tokens AS "output_tokens!",
session.parent_session_id,
session.project_id,
session.prompt AS "prompt!",
session.reasoning_level AS "reasoning_level_override?",
session.published_upstream_ref,
session.questions,
session_review_request.display_id AS "review_request_display_id?",
session_review_request.forge_kind AS "review_request_forge_kind?",
session_review_request.last_refreshed_at AS "review_request_last_refreshed_at?",
session_review_request.source_branch AS "review_request_source_branch?",
session_review_request.state AS "review_request_state?",
session_review_request.status_summary AS "review_request_status_summary?",
session_review_request.target_branch AS "review_request_target_branch?",
session_review_request.title AS "review_request_title?",
session_review_request.web_url AS "review_request_web_url?",
session.size AS "size!",
session.status AS "status!",
session.summary,
session.title,
session.updated_at AS "updated_at!"
FROM session
LEFT JOIN session_review_request
ON session_review_request.session_id = session.id
ORDER BY session.updated_at DESC, session.id
"#
)
.fetch_all(&self.0)
.await?;
Ok(rows
.into_iter()
.map(SessionJoinRow::into_session_row)
.collect())
}
async fn load_sessions_for_project(
&self,
project_id: i64,
) -> Result<Vec<SessionListRow>, DbError> {
let rows = sqlx::query_as!(
SessionListJoinRow,
r#"
SELECT session.base_branch AS "base_branch!",
session.added_lines AS "added_lines!",
session.created_at AS "created_at!",
session.deleted_lines AS "deleted_lines!",
session.id AS "id!",
session.in_progress_started_at,
session.in_progress_total_seconds AS "in_progress_total_seconds!",
session.input_tokens AS "input_tokens!",
session.is_draft AS "is_draft!: bool",
session.model AS "model!",
session.output_tokens AS "output_tokens!",
session.parent_session_id,
session.project_id,
session.reasoning_level AS "reasoning_level_override?",
session.published_upstream_ref,
session_review_request.display_id AS "review_request_display_id?",
session_review_request.forge_kind AS "review_request_forge_kind?",
session_review_request.last_refreshed_at AS "review_request_last_refreshed_at?",
session_review_request.source_branch AS "review_request_source_branch?",
session_review_request.state AS "review_request_state?",
session_review_request.status_summary AS "review_request_status_summary?",
session_review_request.target_branch AS "review_request_target_branch?",
session_review_request.title AS "review_request_title?",
session_review_request.web_url AS "review_request_web_url?",
session.size AS "size!",
session.status AS "status!",
session.title,
session.updated_at AS "updated_at!"
FROM session
LEFT JOIN session_review_request
ON session_review_request.session_id = session.id
WHERE session.project_id = ?
ORDER BY session.updated_at DESC, session.id
"#,
project_id
)
.fetch_all(&self.0)
.await?;
Ok(rows
.into_iter()
.map(SessionListJoinRow::into_session_list_row)
.collect())
}
async fn load_session_detail(
&self,
session_id: &str,
) -> Result<Option<SessionDetailRow>, DbError> {
let row = sqlx::query_as!(
SessionDetailRow,
r#"
SELECT output AS "output!",
prompt AS "prompt!",
questions,
summary
FROM session
WHERE id = ?
"#,
session_id
)
.fetch_optional(&self.0)
.await?;
Ok(row)
}
async fn load_session_follow_up_tasks(&self) -> Result<Vec<SessionFollowUpTaskRow>, DbError> {
let rows = match sqlx::query_as::<_, SessionFollowUpTaskRow>(
r"
SELECT id,
launched_session_id,
position,
session_id,
text
FROM session_follow_up_task
ORDER BY session_id, position, id
",
)
.fetch_all(&self.0)
.await
{
Ok(rows) => rows,
Err(error) if is_missing_follow_up_task_table(&error) => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
Ok(rows)
}
async fn load_session_focused_reviews_for_project(
&self,
project_id: i64,
) -> Result<Vec<SessionFocusedReviewRow>, DbError> {
let rows = sqlx::query_as::<_, SessionFocusedReviewRow>(
r"
SELECT id AS session_id,
focused_review_diff_hash AS diff_hash,
focused_review_text AS text
FROM session
WHERE project_id = ?
AND focused_review_diff_hash IS NOT NULL
AND focused_review_text IS NOT NULL
AND focused_review_text <> ''
ORDER BY updated_at DESC, id
",
)
.bind(project_id)
.fetch_all(&self.0)
.await?;
Ok(rows)
}
async fn load_sessions_metadata(&self) -> Result<(i64, i64), DbError> {
let row = sqlx::query_as!(
SessionMetadataRow,
r#"
SELECT (SELECT COUNT(*) FROM session) AS "session_count!: _",
COALESCE(
(
SELECT updated_at
FROM session
ORDER BY updated_at DESC, id
LIMIT 1
),
0
) AS "max_updated_at!: _"
"#
)
.fetch_one(&self.0)
.await?;
Ok((row.session_count, row.max_updated_at))
}
async fn load_session_project_id(&self, session_id: &str) -> Result<Option<i64>, DbError> {
let row = sqlx::query_as!(
OptionalI64ValueRow,
r#"
SELECT project_id AS "value: _"
FROM session
WHERE id = ?
"#,
session_id
)
.fetch_optional(&self.0)
.await?;
Ok(row.and_then(|row| row.value))
}
async fn load_session_published_upstream_ref(
&self,
id: &str,
) -> Result<Option<String>, DbError> {
let value = sqlx::query_scalar!(
r"SELECT published_upstream_ref FROM session WHERE id = ?",
id
)
.fetch_optional(&self.0)
.await?
.flatten();
Ok(value)
}
async fn load_session_merged_commit_hash(
&self,
session_id: &str,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar::<_, Option<String>>(
r"
SELECT merged_commit_hash
FROM session
WHERE id = ?
",
)
.bind(session_id)
.fetch_optional(&self.0)
.await?;
Ok(row.flatten())
}
async fn load_session_reasoning_level_override(
&self,
session_id: &str,
) -> Result<Option<ReasoningLevel>, DbError> {
let value = sqlx::query_scalar!(
r"SELECT reasoning_level FROM session WHERE id = ?",
session_id
)
.fetch_optional(&self.0)
.await?
.flatten();
Ok(value.and_then(|value| value.parse::<ReasoningLevel>().ok()))
}
async fn restack_child_sessions_after_parent_merge(
&self,
parent_session_id: &str,
base_branch: &str,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET parent_session_id = NULL,
base_branch = ?
WHERE parent_session_id = ?
AND status <> 'Canceled'
",
)
.bind(base_branch)
.bind(parent_session_id)
.execute(&self.0)
.await?;
Ok(())
}
async fn load_session_summary(&self, session_id: &str) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar::<_, Option<String>>(
r"
SELECT summary
FROM session
WHERE id = ?
",
)
.bind(session_id)
.fetch_optional(&self.0)
.await?;
Ok(row.flatten())
}
async fn load_session_timestamps(
&self,
session_id: &str,
) -> Result<Option<(i64, i64)>, DbError> {
let row = sqlx::query_as!(
SessionTimestampsRow,
r#"
SELECT created_at, updated_at
FROM session
WHERE id = ?
"#,
session_id
)
.fetch_optional(&self.0)
.await?;
Ok(row.map(|row| (row.created_at, row.updated_at)))
}
async fn persist_session_turn_metadata(
&self,
session_id: &str,
turn_metadata: &SessionTurnMetadata,
) -> Result<(), DbError> {
let mut transaction = self.0.begin().await?;
let session_update = sqlx::query(
r"
UPDATE session
SET questions = ?,
summary = ?,
provider_conversation_id = ?,
app_server_instruction_provider_conversation_id = ?
WHERE id = ?
",
)
.bind(turn_metadata.questions_json.as_str())
.bind(turn_metadata.summary.as_str())
.bind(turn_metadata.provider_conversation_id.as_deref())
.bind(turn_metadata.instruction_conversation_id.as_deref())
.bind(session_id)
.execute(&mut *transaction)
.await?;
if session_update.rows_affected() != 1 {
return Err(sqlx::Error::RowNotFound.into());
}
if turn_metadata.token_usage_delta.input_tokens != 0
|| turn_metadata.token_usage_delta.output_tokens != 0
{
sqlx::query(
r"
UPDATE session
SET input_tokens = input_tokens + ?,
output_tokens = output_tokens + ?
WHERE id = ?
",
)
.bind(turn_metadata.token_usage_delta.input_tokens.cast_signed())
.bind(turn_metadata.token_usage_delta.output_tokens.cast_signed())
.bind(session_id)
.execute(&mut *transaction)
.await?;
sqlx::query(
r"
INSERT INTO session_usage (session_id, model, input_tokens, output_tokens, invocation_count)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(session_id, model) DO UPDATE SET
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
invocation_count = invocation_count + 1
",
)
.bind(session_id)
.bind(turn_metadata.model.as_str())
.bind(turn_metadata.token_usage_delta.input_tokens.cast_signed())
.bind(turn_metadata.token_usage_delta.output_tokens.cast_signed())
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
#[cfg(test)]
async fn replace_session_output(&self, id: &str, output: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET output = ?
WHERE id = ?
",
)
.bind(output)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn replace_session_follow_up_tasks(
&self,
session_id: &str,
follow_up_tasks: &[String],
) -> Result<(), DbError> {
let mut transaction = self.0.begin().await?;
let delete_result = sqlx::query(
r"
DELETE FROM session_follow_up_task
WHERE session_id = ?
",
)
.bind(session_id)
.execute(&mut *transaction)
.await;
match delete_result {
Ok(_) => {}
Err(error) if is_missing_follow_up_task_table(&error) => {
transaction.rollback().await?;
return Ok(());
}
Err(error) => {
transaction.rollback().await?;
return Err(error.into());
}
}
for (position, follow_up_task) in follow_up_tasks.iter().enumerate() {
sqlx::query(
r"
INSERT INTO session_follow_up_task (session_id, position, text)
VALUES (?, ?, ?)
",
)
.bind(session_id)
.bind(i64::try_from(position).unwrap_or(i64::MAX))
.bind(follow_up_task)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
async fn update_session_diff_stats(
&self,
added_lines: u64,
deleted_lines: u64,
id: &str,
size: &str,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET added_lines = ?,
deleted_lines = ?,
size = ?
WHERE id = ?
AND (
added_lines <> ?
OR deleted_lines <> ?
OR size <> ?
)
",
)
.bind(added_lines.cast_signed())
.bind(deleted_lines.cast_signed())
.bind(size)
.bind(id)
.bind(added_lines.cast_signed())
.bind(deleted_lines.cast_signed())
.bind(size)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_follow_up_task_launched_session_id(
&self,
session_id: &str,
position: usize,
launched_session_id: Option<String>,
) -> Result<(), DbError> {
let update_result = sqlx::query(
r"
UPDATE session_follow_up_task
SET launched_session_id = ?
WHERE session_id = ?
AND position = ?
",
)
.bind(launched_session_id.as_deref())
.bind(session_id)
.bind(i64::try_from(position).unwrap_or(i64::MAX))
.execute(&self.0)
.await;
match update_result {
Ok(_) => {}
Err(error) if is_missing_follow_up_task_table(&error) => return Ok(()),
Err(error) => return Err(error.into()),
}
Ok(())
}
async fn update_session_instruction_conversation_id(
&self,
id: &str,
provider_conversation_id: Option<String>,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET app_server_instruction_provider_conversation_id = ?
WHERE id = ?
",
)
.bind(provider_conversation_id.as_deref())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_model(&self, id: &str, model: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET model = ?
WHERE id = ?
",
)
.bind(model)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_merged_commit_hash(
&self,
id: &str,
merged_commit_hash: Option<String>,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET merged_commit_hash = ?
WHERE id = ?
",
)
.bind(merged_commit_hash.as_deref())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_prompt(&self, id: &str, prompt: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET prompt = ?
WHERE id = ?
",
)
.bind(prompt)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_provider_conversation_id(
&self,
id: &str,
provider_conversation_id: Option<String>,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET provider_conversation_id = ?
WHERE id = ?
",
)
.bind(provider_conversation_id.as_deref())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_questions(&self, id: &str, questions: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET questions = ?
WHERE id = ?
",
)
.bind(questions)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_reasoning_level(
&self,
id: &str,
reasoning_level: Option<String>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
UPDATE session
SET reasoning_level = ?
WHERE id = ?
"#,
reasoning_level,
id
)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_published_upstream_ref(
&self,
id: &str,
published_upstream_ref: Option<String>,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET published_upstream_ref = ?
WHERE id = ?
",
)
.bind(published_upstream_ref.as_deref())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_stats(&self, id: &str, stats: &SessionStats) -> Result<(), DbError> {
if stats.input_tokens == 0 && stats.output_tokens == 0 {
return Ok(());
}
sqlx::query(
r"
UPDATE session
SET input_tokens = input_tokens + ?,
output_tokens = output_tokens + ?
WHERE id = ?
",
)
.bind(stats.input_tokens.cast_signed())
.bind(stats.output_tokens.cast_signed())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_status_with_timing_at(
&self,
id: &str,
status: &str,
timestamp_seconds: i64,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET status = ?,
in_progress_total_seconds = CASE
WHEN ? = 'InProgress' OR in_progress_started_at IS NULL THEN in_progress_total_seconds
ELSE in_progress_total_seconds + MAX(0, ? - in_progress_started_at)
END,
in_progress_started_at = CASE
WHEN ? = 'InProgress' THEN COALESCE(in_progress_started_at, ?)
ELSE NULL
END
WHERE id = ?
",
)
.bind(status)
.bind(status)
.bind(timestamp_seconds)
.bind(status)
.bind(timestamp_seconds)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_summary(&self, id: &str, summary: &str) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET summary = ?
WHERE id = ?
",
)
.bind(summary)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_focused_review(
&self,
id: &str,
diff_hash: Option<String>,
text: Option<String>,
) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET focused_review_diff_hash = ?,
focused_review_text = ?
WHERE id = ?
",
)
.bind(diff_hash.as_deref())
.bind(text.as_deref())
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_title(&self, id: &str, title: &str) -> Result<(), DbError> {
sqlx::query!(
r#"
UPDATE session
SET title = ?
WHERE id = ?
"#,
title,
id,
)
.execute(&self.0)
.await?;
Ok(())
}
async fn update_session_title_for_prompt(
&self,
id: &str,
expected_prompt: &str,
title: &str,
) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
UPDATE session
SET title = ?
WHERE id = ?
AND prompt = ?
"#,
title,
id,
expected_prompt,
)
.execute(&self.0)
.await?;
Ok(result.rows_affected() > 0)
}
#[cfg(test)]
async fn update_session_created_at(&self, id: &str, created_at: i64) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET created_at = ?
WHERE id = ?
",
)
.bind(created_at)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
#[cfg(test)]
async fn update_session_updated_at(&self, id: &str, updated_at: i64) -> Result<(), DbError> {
sqlx::query(
r"
UPDATE session
SET updated_at = ?
WHERE id = ?
",
)
.bind(updated_at)
.bind(id)
.execute(&self.0)
.await?;
Ok(())
}
}
struct InsertSessionRow<'a> {
base_branch: &'a str,
id: &'a str,
is_draft: bool,
model: &'a str,
parent_session_id: Option<&'a str>,
project_id: i64,
status: &'a str,
}
async fn insert_session_with_draft_mode(
pool: &SqlitePool,
row: InsertSessionRow<'_>,
) -> Result<(), DbError> {
let InsertSessionRow {
base_branch,
id,
is_draft,
model,
parent_session_id,
project_id,
status,
} = row;
sqlx::query(
r"
INSERT INTO session (
id,
model,
base_branch,
status,
is_draft,
parent_session_id,
project_id,
prompt,
output
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(id)
.bind(model)
.bind(base_branch)
.bind(status)
.bind(is_draft)
.bind(parent_session_id)
.bind(project_id)
.bind("")
.bind("")
.execute(pool)
.await?;
Ok(())
}
fn is_missing_follow_up_task_table(error: &sqlx::Error) -> bool {
matches!(
error,
sqlx::Error::Database(database_error)
if database_error.message().contains("no such table: session_follow_up_task")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_follow_up_task_row_converts_to_domain_task() {
let row = SessionFollowUpTaskRow {
id: 7,
launched_session_id: Some("launched-session".to_string()),
position: 3,
session_id: "source-session".to_string(),
text: "Follow up on coverage".to_string(),
};
let follow_up_task = row.into_session_follow_up_task();
assert_eq!(follow_up_task.id, 7);
assert_eq!(
follow_up_task.launched_session_id,
Some(SessionId::from("launched-session"))
);
assert_eq!(follow_up_task.position, 3);
assert_eq!(follow_up_task.text, "Follow up on coverage");
}
#[test]
fn test_session_follow_up_task_row_clamps_invalid_position() {
let row = SessionFollowUpTaskRow {
id: 8,
launched_session_id: None,
position: -1,
session_id: "source-session".to_string(),
text: "Handle invalid position".to_string(),
};
let follow_up_task = row.into_session_follow_up_task();
assert_eq!(follow_up_task.launched_session_id, None);
assert_eq!(follow_up_task.position, usize::MAX);
}
}