1
0
forked from boos/BoosAPI

feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,250 @@
use crate::entity::agent_sessions;
use crate::error::{BoosApiError, Result};
use crate::types::AgentSession;
use crate::utils::gen_id;
use sea_orm::*;
use sea_orm::sea_query::Expr;
fn model_to_agent_session(model: agent_sessions::Model) -> AgentSession {
AgentSession {
id: model.id,
conversation_id: model.conversation_id,
cwd: model.cwd,
permission_mode: model.permission_mode,
runtime_status: model.runtime_status,
sdk_context_json: model.sdk_context_json,
sdk_context_backup_json: model.sdk_context_backup_json,
total_tokens: model.total_tokens,
total_cost_usd: model.total_cost_usd,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
/// Upsert an agent session. If no session exists for the conversation, create one.
/// If one exists, update the provided fields.
pub async fn upsert_agent_session(
db: &DatabaseConnection,
conversation_id: &str,
cwd: Option<&str>,
permission_mode: Option<&str>,
) -> Result<AgentSession> {
let existing = agent_sessions::Entity::find()
.filter(agent_sessions::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
if let Some(model) = existing {
let mut am: agent_sessions::ActiveModel = model.into();
if let Some(cwd) = cwd {
am.cwd = Set(Some(cwd.to_string()));
}
if let Some(pm) = permission_mode {
am.permission_mode = Set(pm.to_string());
}
am.updated_at = Set(now);
let updated = am.update(db).await?;
Ok(model_to_agent_session(updated))
} else {
let id = gen_id();
let model = agent_sessions::ActiveModel {
id: Set(id),
conversation_id: Set(conversation_id.to_string()),
cwd: Set(cwd.map(|s| s.to_string())),
permission_mode: Set(permission_mode.unwrap_or("default").to_string()),
runtime_status: Set("idle".to_string()),
sdk_context_json: Set(None),
sdk_context_backup_json: Set(None),
total_tokens: Set(0),
total_cost_usd: Set(0.0),
created_at: Set(now.clone()),
updated_at: Set(now),
};
let inserted = model.insert(db).await?;
Ok(model_to_agent_session(inserted))
}
}
/// Get agent session by conversation ID.
pub async fn get_agent_session_by_conversation_id(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Option<AgentSession>> {
let model = agent_sessions::Entity::find()
.filter(agent_sessions::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
Ok(model.map(model_to_agent_session))
}
/// Update runtime status of an agent session.
pub async fn update_agent_session_status(
db: &DatabaseConnection,
id: &str,
runtime_status: &str,
) -> Result<()> {
let model = agent_sessions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("AgentSession {}", id)))?;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut am: agent_sessions::ActiveModel = model.into();
am.runtime_status = Set(runtime_status.to_string());
am.updated_at = Set(now);
am.update(db).await?;
Ok(())
}
/// Update the working directory of an agent session.
pub async fn update_agent_session_cwd(
db: &DatabaseConnection,
id: &str,
cwd: &str,
) -> Result<()> {
let model = agent_sessions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("AgentSession {}", id)))?;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut am: agent_sessions::ActiveModel = model.into();
am.cwd = Set(Some(cwd.to_string()));
am.updated_at = Set(now);
am.update(db).await?;
Ok(())
}
/// Update the permission mode of an agent session.
pub async fn update_agent_session_permission_mode(
db: &DatabaseConnection,
id: &str,
permission_mode: &str,
) -> Result<()> {
let model = agent_sessions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("AgentSession {}", id)))?;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut am: agent_sessions::ActiveModel = model.into();
am.permission_mode = Set(permission_mode.to_string());
am.updated_at = Set(now);
am.update(db).await?;
Ok(())
}
/// Reset all running/waiting_approval sessions to idle (for app startup recovery).
pub async fn reset_running_sessions(db: &DatabaseConnection) -> Result<u64> {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let result = agent_sessions::Entity::update_many()
.col_expr(
agent_sessions::Column::RuntimeStatus,
Expr::value("idle"),
)
.col_expr(
agent_sessions::Column::UpdatedAt,
Expr::value(now),
)
.filter(
Condition::any()
.add(agent_sessions::Column::RuntimeStatus.eq("running"))
.add(agent_sessions::Column::RuntimeStatus.eq("waiting_approval")),
)
.exec(db)
.await?;
Ok(result.rows_affected)
}
/// Update sdk_context_json, total_tokens, and total_cost_usd after a successful query.
pub async fn update_agent_session_after_query(
db: &DatabaseConnection,
id: &str,
runtime_status: &str,
sdk_context_json: Option<&str>,
tokens_delta: i32,
cost_delta: f64,
) -> Result<()> {
let model = agent_sessions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("AgentSession {}", id)))?;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut am: agent_sessions::ActiveModel = model.clone().into();
am.runtime_status = Set(runtime_status.to_string());
if let Some(ctx) = sdk_context_json {
am.sdk_context_json = Set(Some(ctx.to_string()));
}
am.total_tokens = Set(model.total_tokens + tokens_delta);
am.total_cost_usd = Set(model.total_cost_usd + cost_delta);
am.updated_at = Set(now);
am.update(db).await?;
Ok(())
}
/// Clear the sdk_context_json for an agent session by conversation_id.
/// Called when conversation messages are cleared to prevent stale history.
pub async fn clear_sdk_context_by_conversation_id(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<()> {
let session = agent_sessions::Entity::find()
.filter(agent_sessions::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
if let Some(model) = session {
let mut am: agent_sessions::ActiveModel = model.into();
am.sdk_context_json = Set(None);
am.sdk_context_backup_json = Set(None);
am.update(db).await?;
}
Ok(())
}
/// Backup current sdk_context_json and clear it.
/// Called when a context-clear marker is inserted.
pub async fn backup_and_clear_sdk_context_by_conversation_id(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<()> {
let session = agent_sessions::Entity::find()
.filter(agent_sessions::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
if let Some(model) = session {
let mut am: agent_sessions::ActiveModel = model.clone().into();
am.sdk_context_backup_json = Set(model.sdk_context_json);
am.sdk_context_json = Set(None);
am.update(db).await?;
}
Ok(())
}
/// Restore sdk_context_json from backup.
/// Called when a context-clear marker is removed (undo).
pub async fn restore_sdk_context_from_backup_by_conversation_id(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<()> {
let session = agent_sessions::Entity::find()
.filter(agent_sessions::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
if let Some(model) = session {
if model.sdk_context_backup_json.is_some() {
let mut am: agent_sessions::ActiveModel = model.clone().into();
am.sdk_context_json = Set(model.sdk_context_backup_json);
am.sdk_context_backup_json = Set(None);
am.update(db).await?;
}
}
Ok(())
}
@@ -0,0 +1,103 @@
use sea_orm::*;
use crate::entity::artifacts;
use crate::error::{BoosApiError, Result};
use crate::types::{Artifact, CreateArtifactInput, UpdateArtifactInput};
use crate::utils::gen_id;
fn now_datetime() -> String {
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
}
fn model_to_artifact(m: artifacts::Model) -> Artifact {
Artifact {
id: m.id,
conversation_id: m.conversation_id,
kind: m.kind,
title: m.title,
content: m.content,
format: m.format,
pinned: m.pinned != 0,
updated_at: m.updated_at,
}
}
pub async fn list_artifacts(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<Artifact>> {
let models = artifacts::Entity::find()
.filter(artifacts::Column::ConversationId.eq(conversation_id))
.order_by_desc(artifacts::Column::UpdatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_artifact).collect())
}
pub async fn get_artifact(db: &DatabaseConnection, id: &str) -> Result<Artifact> {
let model = artifacts::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Artifact {}", id)))?;
Ok(model_to_artifact(model))
}
pub async fn create_artifact(
db: &DatabaseConnection,
input: &CreateArtifactInput,
) -> Result<Artifact> {
let id = gen_id();
let am = artifacts::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(input.conversation_id.clone()),
kind: Set(input.kind.clone()),
title: Set(input.title.clone()),
content: Set(input.content.clone()),
format: Set(input.format.clone()),
pinned: Set(0),
updated_at: Set(now_datetime()),
};
am.insert(db).await?;
get_artifact(db, &id).await
}
pub async fn update_artifact(
db: &DatabaseConnection,
id: &str,
input: &UpdateArtifactInput,
) -> Result<Artifact> {
let model = artifacts::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Artifact {}", id)))?;
let existing = model_to_artifact(model.clone());
let mut am: artifacts::ActiveModel = model.into();
am.title = Set(input.title.clone().unwrap_or(existing.title));
am.content = Set(input.content.clone().unwrap_or(existing.content));
am.format = Set(input.format.clone().unwrap_or(existing.format));
am.pinned = Set(if input.pinned.unwrap_or(existing.pinned) {
1
} else {
0
});
am.updated_at = Set(now_datetime());
am.update(db).await?;
get_artifact(db, id).await
}
pub async fn delete_artifact(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = artifacts::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("Artifact {}", id)));
}
Ok(())
}
@@ -0,0 +1,267 @@
use sea_orm::*;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use crate::entity::backup_manifests;
use crate::error::{BoosApiError, Result};
use crate::types::BackupManifest;
use crate::utils::gen_id;
fn model_to_manifest(m: backup_manifests::Model) -> BackupManifest {
BackupManifest {
id: m.id,
version: m.version,
created_at: m.created_at,
encrypted: m.encrypted != 0,
checksum: m.checksum,
object_counts_json: m.object_counts_json,
source_app_version: m.source_app_version,
file_path: m.file_path.as_ref().map(|p| crate::path_vars::decode_path(p)),
file_size: m.file_size,
}
}
/// Get the backup directory, using the configured path or defaulting to the BoosApi home backups dir.
pub fn resolve_backup_dir(backup_dir_setting: Option<&str>, app_data_dir: &Path) -> PathBuf {
if let Some(dir) = backup_dir_setting {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
app_data_dir.join("backups")
}
/// Ensure the backup directory exists
pub fn ensure_backup_dir(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir)
.map_err(|e| BoosApiError::Gateway(format!("Failed to create backup directory: {}", e)))
}
/// Create a real backup file (SQLite copy or JSON export)
pub async fn create_backup(
db: &DatabaseConnection,
format: &str,
backup_dir: &Path,
) -> Result<BackupManifest> {
ensure_backup_dir(backup_dir)?;
let id = gen_id();
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let extension = match format {
"sqlite" => "db",
_ => "json",
};
let filename = format!("boosapi-backup-{}.{}", timestamp, extension);
let file_path = backup_dir.join(&filename);
match format {
"sqlite" => {
create_sqlite_backup(db, &file_path).await?;
}
_ => {
create_json_backup(db, &file_path).await?;
}
}
let file_size = std::fs::metadata(&file_path)
.map(|m| m.len() as i64)
.unwrap_or(0);
let checksum = compute_file_checksum(&file_path)?;
// Count objects for manifest
let object_counts = count_objects(db).await?;
let am = backup_manifests::ActiveModel {
id: Set(id.clone()),
version: Set(format.to_string()),
encrypted: Set(0),
checksum: Set(checksum),
object_counts_json: Set(object_counts),
source_app_version: Set(env!("CARGO_PKG_VERSION").to_string()),
file_path: Set(Some(crate::path_vars::encode_path(&file_path.to_string_lossy()))),
file_size: Set(file_size),
..Default::default()
};
am.insert(db).await?;
get_backup(db, &id).await
}
/// Create a SQLite backup using VACUUM INTO
async fn create_sqlite_backup(db: &DatabaseConnection, dest: &Path) -> Result<()> {
let dest_str = dest.to_string_lossy().to_string();
// Remove existing file if present (VACUUM INTO fails otherwise)
if dest.exists() {
std::fs::remove_file(dest).map_err(|e| {
BoosApiError::Gateway(format!("Failed to remove existing backup file: {}", e))
})?;
}
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
format!("VACUUM INTO '{}'", dest_str.replace('\'', "''")),
))
.await
.map_err(|e| BoosApiError::Gateway(format!("VACUUM INTO failed: {}", e)))?;
Ok(())
}
/// Create a JSON backup by exporting all important tables
async fn create_json_backup(db: &DatabaseConnection, dest: &Path) -> Result<()> {
use crate::entity::*;
let conversations = conversations::Entity::find().all(db).await?;
let messages = messages::Entity::find().all(db).await?;
let providers = providers::Entity::find().all(db).await?;
let provider_keys = provider_keys::Entity::find().all(db).await?;
let models = models::Entity::find().all(db).await?;
let settings = settings::Entity::find().all(db).await?;
let gateway_keys = gateway_keys::Entity::find().all(db).await?;
let data = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"exported_at": chrono::Utc::now().to_rfc3339(),
"tables": {
"conversations": conversations,
"messages": messages,
"providers": providers,
"provider_keys": provider_keys,
"models": models,
"settings": settings,
"gateway_keys": gateway_keys,
}
});
let json_str = serde_json::to_string_pretty(&data)
.map_err(|e| BoosApiError::Gateway(format!("JSON serialization failed: {}", e)))?;
std::fs::write(dest, json_str)
.map_err(|e| BoosApiError::Gateway(format!("Failed to write backup file: {}", e)))?;
Ok(())
}
fn compute_file_checksum(path: &Path) -> Result<String> {
let data = std::fs::read(path)
.map_err(|e| BoosApiError::Gateway(format!("Failed to read file for checksum: {}", e)))?;
let hash = Sha256::digest(&data);
Ok(format!("{:x}", hash))
}
async fn count_objects(db: &DatabaseConnection) -> Result<String> {
use crate::entity::*;
let conv_count = conversations::Entity::find().count(db).await.unwrap_or(0);
let msg_count = messages::Entity::find().count(db).await.unwrap_or(0);
let provider_count = providers::Entity::find().count(db).await.unwrap_or(0);
let counts = serde_json::json!({
"conversations": conv_count,
"messages": msg_count,
"providers": provider_count,
});
Ok(counts.to_string())
}
pub async fn list_backups(db: &DatabaseConnection) -> Result<Vec<BackupManifest>> {
let models = backup_manifests::Entity::find()
.order_by_desc(backup_manifests::Column::CreatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_manifest).collect())
}
pub async fn get_backup(db: &DatabaseConnection, id: &str) -> Result<BackupManifest> {
let model = backup_manifests::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("BackupManifest {}", id)))?;
Ok(model_to_manifest(model))
}
pub async fn delete_backup(db: &DatabaseConnection, id: &str) -> Result<()> {
let manifest = get_backup(db, id).await?;
// Delete the file from disk if it exists
if let Some(ref path) = manifest.file_path {
let p = Path::new(path);
if p.exists() {
std::fs::remove_file(p).ok();
}
}
let result = backup_manifests::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("BackupManifest {}", id)));
}
Ok(())
}
pub async fn batch_delete_backups(db: &DatabaseConnection, ids: &[String]) -> Result<()> {
for id in ids {
delete_backup(db, id).await?;
}
Ok(())
}
/// Restore from a SQLite backup by replacing the current database file
pub async fn restore_sqlite_backup(backup_path: &str, current_db_path: &str) -> Result<()> {
let src = Path::new(backup_path);
if !src.exists() {
return Err(BoosApiError::NotFound(format!(
"Backup file not found: {}",
backup_path
)));
}
std::fs::copy(src, current_db_path)
.map_err(|e| BoosApiError::Gateway(format!("Failed to restore backup: {}", e)))?;
Ok(())
}
/// Clean up old backups exceeding max_count (keeps most recent)
pub async fn cleanup_old_backups(db: &DatabaseConnection, max_count: u32) -> Result<u32> {
let all = list_backups(db).await?;
if all.len() <= max_count as usize {
return Ok(0);
}
let to_delete = &all[max_count as usize..];
let mut deleted = 0u32;
for backup in to_delete {
delete_backup(db, &backup.id).await?;
deleted += 1;
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::resolve_backup_dir;
use std::path::PathBuf;
#[test]
fn resolve_backup_dir_defaults_to_boosapi_backups_subdir() {
let boosapi_home = PathBuf::from("/Users/test/.boosapi");
assert_eq!(
resolve_backup_dir(None, &boosapi_home),
boosapi_home.join("backups")
);
assert_eq!(
resolve_backup_dir(Some(""), &boosapi_home),
boosapi_home.join("backups")
);
}
#[test]
fn resolve_backup_dir_honors_explicit_absolute_override() {
let boosapi_home = PathBuf::from("/Users/test/.boosapi");
let override_dir = PathBuf::from("/Volumes/external/boosapi-backups");
assert_eq!(
resolve_backup_dir(Some(override_dir.to_str().unwrap()), &boosapi_home),
override_dir
);
}
}
@@ -0,0 +1,780 @@
use sea_orm::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use crate::entity::{conversations, import_jobs, messages};
use crate::error::{BoosApiError, Result};
use crate::repo::settings::get_settings;
use crate::utils::{gen_id, now_ts};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ChatGptImportWarning {
pub code: String,
pub message: String,
pub source_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChatGptImportSummary {
pub conversation_count: u32,
pub message_count: u32,
pub skipped_empty_conversation_count: u32,
pub duplicate_conversation_count: u32,
pub warnings: Vec<ChatGptImportWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChatGptImportResult {
pub imported_conversation_count: u32,
pub imported_message_count: u32,
pub skipped_duplicate_conversation_count: u32,
pub warnings: Vec<ChatGptImportWarning>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ChatGptConversation {
#[serde(default)]
id: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
create_time: Option<Value>,
#[serde(default)]
update_time: Option<Value>,
#[serde(default)]
current_node: Option<String>,
#[serde(default)]
mapping: HashMap<String, ChatGptNode>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ChatGptNode {
#[serde(default)]
id: Option<String>,
#[serde(default)]
message: Option<ChatGptMessage>,
#[serde(default)]
parent: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ChatGptMessage {
#[serde(default)]
id: Option<String>,
#[serde(default)]
author: Option<ChatGptAuthor>,
#[serde(default)]
create_time: Option<Value>,
#[serde(default)]
content: Option<ChatGptContent>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
metadata: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ChatGptAuthor {
#[serde(default)]
role: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct ChatGptContent {
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
parts: Vec<Value>,
#[serde(default)]
text: Option<String>,
}
struct MaterializedConversation {
id: String,
title: String,
created_at: i64,
updated_at: i64,
messages: Vec<MaterializedMessage>,
warnings: Vec<ChatGptImportWarning>,
}
struct MaterializedMessage {
id: String,
role: String,
content: String,
created_at: i64,
status: String,
}
pub async fn scan_chatgpt_import_from_path(
db: &DatabaseConnection,
path: &Path,
) -> Result<ChatGptImportSummary> {
let conversations = parse_chatgpt_export(path)?;
let mut summary = ChatGptImportSummary::default();
for (index, conversation) in conversations.iter().enumerate() {
let materialized = materialize_conversation(conversation, index);
summary.warnings.extend(materialized.warnings);
if materialized.messages.is_empty() {
summary.skipped_empty_conversation_count += 1;
continue;
}
summary.conversation_count += 1;
summary.message_count += materialized.messages.len() as u32;
if conversations::Entity::find_by_id(&materialized.id)
.one(db)
.await?
.is_some()
{
summary.duplicate_conversation_count += 1;
}
}
Ok(summary)
}
pub async fn import_chatgpt_export_from_path(
db: &DatabaseConnection,
path: &Path,
) -> Result<ChatGptImportResult> {
let conversations = parse_chatgpt_export(path)?;
let settings = get_settings(db).await.unwrap_or_default();
let fallback_provider_id = settings
.default_provider_id
.clone()
.unwrap_or_else(|| "chatgpt-export".to_string());
let fallback_model_id = settings
.default_model_id
.clone()
.unwrap_or_else(|| "unknown-model".to_string());
let txn = db.begin().await?;
let mut result = ChatGptImportResult::default();
for (index, conversation) in conversations.iter().enumerate() {
let materialized = materialize_conversation(conversation, index);
result.warnings.extend(materialized.warnings);
if materialized.messages.is_empty() {
continue;
}
if conversations::Entity::find_by_id(&materialized.id)
.one(&txn)
.await?
.is_some()
{
result.skipped_duplicate_conversation_count += 1;
continue;
}
conversations::ActiveModel {
id: Set(materialized.id.clone()),
title: Set(materialized.title),
model_id: Set(fallback_model_id.clone()),
provider_id: Set(fallback_provider_id.clone()),
system_prompt: Set(None),
temperature: Set(None),
max_tokens: Set(None),
top_p: Set(None),
frequency_penalty: Set(None),
search_enabled: Set(0),
search_provider_id: Set(None),
thinking_budget: Set(None),
thinking_level: Set(None),
enabled_mcp_server_ids: Set("[]".to_string()),
enabled_knowledge_base_ids: Set("[]".to_string()),
enabled_memory_namespace_ids: Set("[]".to_string()),
message_count: Set(materialized.messages.len() as i32),
created_at: Set(materialized.created_at),
updated_at: Set(materialized.updated_at),
is_pinned: Set(0),
is_archived: Set(0),
workspace_snapshot_json: Set("{}".to_string()),
active_branch_id: Set(None),
active_artifact_id: Set(None),
research_mode: Set(0),
context_compression: Set(0),
category_id: Set(None),
parent_conversation_id: Set(None),
mode: Set("chat".to_string()),
}
.insert(&txn)
.await?;
result.imported_conversation_count += 1;
let mut previous_message_id: Option<String> = None;
for message in materialized.messages {
messages::ActiveModel {
id: Set(message.id.clone()),
conversation_id: Set(materialized.id.clone()),
role: Set(message.role),
content: Set(message.content),
provider_id: Set(Some(fallback_provider_id.clone())),
model_id: Set(Some(fallback_model_id.clone())),
token_count: Set(None),
prompt_tokens: Set(None),
completion_tokens: Set(None),
attachments: Set("[]".to_string()),
thinking: Set(None),
created_at: Set(message.created_at),
branch_id: Set(None),
parent_message_id: Set(previous_message_id.clone()),
version_index: Set(0),
is_active: Set(1),
tool_calls_json: Set(None),
tool_call_id: Set(None),
status: Set(message.status),
tokens_per_second: Set(None),
first_token_latency_ms: Set(None),
}
.insert(&txn)
.await?;
previous_message_id = Some(message.id);
result.imported_message_count += 1;
}
}
import_jobs::ActiveModel {
id: Set(gen_id()),
source_type: Set("chatgpt_export".to_string()),
status: Set("success".to_string()),
summary_json: Set(Some(serde_json::to_string(&result).unwrap_or_default())),
conflict_count: Set(result.skipped_duplicate_conversation_count as i32),
created_at: Set(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()),
}
.insert(&txn)
.await?;
txn.commit().await?;
Ok(result)
}
fn parse_chatgpt_export(path: &Path) -> Result<Vec<ChatGptConversation>> {
let bytes = if path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
{
read_conversations_json_from_zip(path)?
} else {
std::fs::read(path)?
};
let value = serde_json::from_slice::<Value>(&bytes).map_err(|e| {
BoosApiError::Validation(format!("Invalid ChatGPT conversations.json: {e}"))
})?;
let conversations_value = value.get("conversations").cloned().unwrap_or(value);
serde_json::from_value::<Vec<ChatGptConversation>>(conversations_value).map_err(|e| {
BoosApiError::Validation(format!("Invalid ChatGPT conversations.json structure: {e}"))
})
}
fn read_conversations_json_from_zip(path: &Path) -> Result<Vec<u8>> {
let file = File::open(path)?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| BoosApiError::Validation(format!("Invalid ChatGPT zip export: {e}")))?;
let mut entry = archive
.by_name("conversations.json")
.map_err(|_| BoosApiError::Validation("ChatGPT export is missing conversations.json".into()))?;
let mut data = Vec::new();
entry.read_to_end(&mut data)?;
Ok(data)
}
fn materialize_conversation(
conversation: &ChatGptConversation,
index: usize,
) -> MaterializedConversation {
let source_id = non_empty(conversation.id.as_deref())
.map(str::to_string)
.unwrap_or_else(|| format!("conversation-{index}"));
let id = prefixed_id("chatgpt", &source_id);
let created_at = parse_chatgpt_ts(conversation.create_time.as_ref()).unwrap_or_else(now_ts);
let updated_at = parse_chatgpt_ts(conversation.update_time.as_ref()).unwrap_or(created_at);
let title = non_empty(conversation.title.as_deref())
.unwrap_or("ChatGPT Chat")
.to_string();
let mut warnings = Vec::new();
let mut seen = HashSet::new();
let messages = ordered_node_ids(conversation)
.into_iter()
.filter_map(|node_id| {
let node = conversation.mapping.get(&node_id)?;
let message = node.message.as_ref()?;
let source_message_id = non_empty(message.id.as_deref())
.or_else(|| non_empty(node.id.as_deref()))
.unwrap_or(node_id.as_str());
if !seen.insert(source_message_id.to_string()) {
return None;
}
materialize_message(message, source_message_id, &mut warnings)
})
.collect();
MaterializedConversation {
id,
title,
created_at,
updated_at,
messages,
warnings,
}
}
fn ordered_node_ids(conversation: &ChatGptConversation) -> Vec<String> {
if let Some(current_node) = non_empty(conversation.current_node.as_deref()) {
let mut ids = Vec::new();
let mut seen = HashSet::new();
let mut cursor = Some(current_node.to_string());
while let Some(node_id) = cursor {
if !seen.insert(node_id.clone()) {
break;
}
let Some(node) = conversation.mapping.get(&node_id) else {
break;
};
ids.push(node_id);
cursor = node.parent.clone().and_then(|parent| non_empty(Some(&parent)).map(str::to_string));
}
ids.reverse();
if ids
.iter()
.any(|id| conversation.mapping.get(id).and_then(|node| node.message.as_ref()).is_some())
{
return ids;
}
}
let mut nodes = conversation
.mapping
.iter()
.filter(|(_, node)| node.message.is_some())
.map(|(id, node)| {
(
id.clone(),
node.message
.as_ref()
.and_then(|message| parse_chatgpt_ts(message.create_time.as_ref()))
.unwrap_or(0),
)
})
.collect::<Vec<_>>();
nodes.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
nodes.into_iter().map(|(id, _)| id).collect()
}
fn materialize_message(
message: &ChatGptMessage,
source_id: &str,
warnings: &mut Vec<ChatGptImportWarning>,
) -> Option<MaterializedMessage> {
if metadata_bool(&message.metadata, "is_visually_hidden_from_conversation")
|| metadata_bool(&message.metadata, "is_user_system_message")
{
return None;
}
let role = map_role(
message
.author
.as_ref()
.and_then(|author| author.role.as_deref()),
);
let content = content_to_markdown(message.content.as_ref(), source_id, warnings);
if content.trim().is_empty() {
return None;
}
if role == "system" && content.trim().is_empty() {
return None;
}
Some(MaterializedMessage {
id: prefixed_id("chatgpt", source_id),
role,
content,
created_at: parse_chatgpt_ts(message.create_time.as_ref()).unwrap_or_else(now_ts),
status: map_status(message.status.as_deref()),
})
}
fn content_to_markdown(
content: Option<&ChatGptContent>,
source_id: &str,
warnings: &mut Vec<ChatGptImportWarning>,
) -> String {
let Some(content) = content else {
return String::new();
};
if let Some(text) = non_empty(content.text.as_deref()) {
return text.to_string();
}
if content.parts.is_empty() {
if let Some(content_type) = non_empty(content.content_type.as_deref()) {
if content_type != "text" {
warnings.push(warning(
"unsupported_content_type",
format!("Unsupported ChatGPT content type '{content_type}' had no text parts."),
Some(source_id.to_string()),
));
}
}
return String::new();
}
content
.parts
.iter()
.filter_map(|part| part_to_markdown(part, source_id, warnings))
.collect::<Vec<_>>()
.join("\n\n")
}
fn part_to_markdown(
part: &Value,
source_id: &str,
warnings: &mut Vec<ChatGptImportWarning>,
) -> Option<String> {
match part {
Value::String(text) => non_empty(Some(text)).map(str::to_string),
Value::Object(map) => {
let part_type = map
.get("content_type")
.and_then(Value::as_str)
.or_else(|| map.get("type").and_then(Value::as_str))
.unwrap_or("object");
if matches!(part_type, "text" | "input_text" | "output_text") {
if let Some(text) = map.get("text").and_then(Value::as_str).and_then(|text| non_empty(Some(text))) {
return Some(text.to_string());
}
}
warnings.push(warning(
"unsupported_content_part",
format!("Unsupported ChatGPT content part '{part_type}' was preserved as Markdown."),
Some(source_id.to_string()),
));
Some(format!(
"### Unsupported ChatGPT content part: {part_type}\n```json\n{}\n```",
serde_json::to_string(part).unwrap_or_else(|_| part.to_string())
))
}
Value::Null => None,
other => {
warnings.push(warning(
"unsupported_content_part",
"Unsupported ChatGPT content part was preserved as Markdown.",
Some(source_id.to_string()),
));
Some(other.to_string())
}
}
}
fn parse_chatgpt_ts(value: Option<&Value>) -> Option<i64> {
let value = value?;
let seconds = match value {
Value::Number(number) => number.as_f64()?,
Value::String(text) => text
.parse::<f64>()
.ok()
.or_else(|| chrono::DateTime::parse_from_rfc3339(text).ok().map(|value| value.timestamp() as f64))?,
_ => return None,
};
Some(if seconds > 10_000_000_000.0 {
(seconds / 1000.0) as i64
} else {
seconds as i64
})
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn prefixed_id(prefix: &str, source_id: &str) -> String {
format!("{prefix}-{source_id}")
}
fn map_role(role: Option<&str>) -> String {
match role {
Some("system") => "system",
Some("user") => "user",
Some("tool") => "tool",
_ => "assistant",
}
.to_string()
}
fn map_status(status: Option<&str>) -> String {
match status.unwrap_or("") {
value if value.contains("error") || value.contains("failed") => "error",
_ => "complete",
}
.to_string()
}
fn metadata_bool(metadata: &HashMap<String, Value>, key: &str) -> bool {
metadata.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn warning(
code: impl Into<String>,
message: impl Into<String>,
source_id: Option<String>,
) -> ChatGptImportWarning {
ChatGptImportWarning {
code: code.into(),
message: message.into(),
source_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::create_test_pool;
use crate::entity::{conversations, messages};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde_json::json;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use tempfile::tempdir;
use zip::write::SimpleFileOptions;
fn write_chatgpt_zip(path: &Path, data: serde_json::Value) {
let file = File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("conversations.json", SimpleFileOptions::default())
.unwrap();
zip.write_all(serde_json::to_string(&data).unwrap().as_bytes())
.unwrap();
zip.finish().unwrap();
}
fn sample_export_json() -> serde_json::Value {
json!([
{
"id": "conv-1",
"title": "Mainline chat",
"create_time": 1780000000.0,
"update_time": 1780000200.0,
"current_node": "msg-assistant-2",
"mapping": {
"root": {
"id": "root",
"message": null,
"parent": null,
"children": ["msg-system"]
},
"msg-system": {
"id": "msg-system",
"message": {
"id": "msg-system",
"author": {"role": "system"},
"create_time": 1780000001.0,
"content": {"content_type": "text", "parts": [""]},
"status": "finished_successfully",
"metadata": {"is_visually_hidden_from_conversation": true}
},
"parent": "root",
"children": ["msg-user-1"]
},
"msg-user-1": {
"id": "msg-user-1",
"message": {
"id": "msg-user-1",
"author": {"role": "user"},
"create_time": 1780000010.0,
"content": {"content_type": "text", "parts": ["hello"]},
"status": "finished_successfully",
"metadata": {}
},
"parent": "msg-system",
"children": ["msg-assistant-1", "branch-assistant"]
},
"branch-assistant": {
"id": "branch-assistant",
"message": {
"id": "branch-assistant",
"author": {"role": "assistant"},
"create_time": 1780000015.0,
"content": {"content_type": "text", "parts": ["old branch"]},
"status": "finished_successfully",
"metadata": {}
},
"parent": "msg-user-1",
"children": []
},
"msg-assistant-1": {
"id": "msg-assistant-1",
"message": {
"id": "msg-assistant-1",
"author": {"role": "assistant"},
"create_time": 1780000020.0,
"content": {
"content_type": "multimodal_text",
"parts": [
{"content_type": "text", "text": "hi"},
{"content_type": "image_asset_pointer", "asset_pointer": "file-service://image-1"}
]
},
"status": "finished_successfully",
"metadata": {"model_slug": "gpt-4o"}
},
"parent": "msg-user-1",
"children": ["msg-tool-1"]
},
"msg-tool-1": {
"id": "msg-tool-1",
"message": {
"id": "msg-tool-1",
"author": {"role": "tool"},
"create_time": 1780000025.0,
"content": {"content_type": "text", "parts": ["tool output"]},
"status": "finished_successfully",
"metadata": {}
},
"parent": "msg-assistant-1",
"children": ["msg-assistant-2"]
},
"msg-assistant-2": {
"id": "msg-assistant-2",
"message": {
"id": "msg-assistant-2",
"author": {"role": "assistant"},
"create_time": 1780000030.0,
"content": {"content_type": "text", "parts": ["final"]},
"status": "finished_successfully",
"metadata": {"model_slug": "gpt-4o"}
},
"parent": "msg-tool-1",
"children": []
}
}
},
{
"id": "conv-empty",
"title": "",
"create_time": null,
"update_time": null,
"current_node": null,
"mapping": {}
}
])
}
#[tokio::test]
async fn scan_chatgpt_zip_summarizes_importable_conversations() {
let dir = tempdir().unwrap();
let zip_path = dir.path().join("chatgpt.zip");
write_chatgpt_zip(&zip_path, sample_export_json());
let db = create_test_pool().await.unwrap();
let summary = scan_chatgpt_import_from_path(&db.conn, &zip_path)
.await
.unwrap();
assert_eq!(summary.conversation_count, 1);
assert_eq!(summary.message_count, 4);
assert_eq!(summary.skipped_empty_conversation_count, 1);
assert_eq!(summary.duplicate_conversation_count, 0);
assert!(summary
.warnings
.iter()
.any(|warning| warning.code == "unsupported_content_part"));
}
#[tokio::test]
async fn import_chatgpt_json_materializes_mainline_messages_and_skips_duplicates() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("conversations.json");
std::fs::write(&json_path, serde_json::to_vec(&sample_export_json()).unwrap()).unwrap();
let db = create_test_pool().await.unwrap();
let result = import_chatgpt_export_from_path(&db.conn, &json_path)
.await
.unwrap();
assert_eq!(result.imported_conversation_count, 1);
assert_eq!(result.imported_message_count, 4);
assert_eq!(result.skipped_duplicate_conversation_count, 0);
let conversation = conversations::Entity::find_by_id("chatgpt-conv-1")
.one(&db.conn)
.await
.unwrap()
.unwrap();
assert_eq!(conversation.title, "Mainline chat");
assert_eq!(conversation.provider_id, "chatgpt-export");
assert_eq!(conversation.model_id, "unknown-model");
assert_eq!(conversation.message_count, 4);
assert_eq!(conversation.created_at, 1780000000);
assert_eq!(conversation.updated_at, 1780000200);
let imported_messages = messages::Entity::find()
.filter(messages::Column::ConversationId.eq("chatgpt-conv-1"))
.order_by_asc(messages::Column::CreatedAt)
.all(&db.conn)
.await
.unwrap();
let ids = imported_messages
.iter()
.map(|message| message.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
ids,
vec![
"chatgpt-msg-user-1",
"chatgpt-msg-assistant-1",
"chatgpt-msg-tool-1",
"chatgpt-msg-assistant-2",
]
);
assert_eq!(imported_messages[0].role, "user");
assert_eq!(imported_messages[0].content, "hello");
assert_eq!(imported_messages[1].role, "assistant");
assert!(imported_messages[1].content.contains("hi"));
assert!(imported_messages[1]
.content
.contains("Unsupported ChatGPT content part"));
assert_eq!(
imported_messages[1].parent_message_id.as_deref(),
Some("chatgpt-msg-user-1")
);
assert_eq!(
imported_messages[2].parent_message_id.as_deref(),
Some("chatgpt-msg-assistant-1")
);
assert_eq!(
imported_messages[3].parent_message_id.as_deref(),
Some("chatgpt-msg-tool-1")
);
assert_eq!(imported_messages[1].model_id.as_deref(), Some("unknown-model"));
let duplicate = import_chatgpt_export_from_path(&db.conn, &json_path)
.await
.unwrap();
assert_eq!(duplicate.imported_conversation_count, 0);
assert_eq!(duplicate.skipped_duplicate_conversation_count, 1);
}
#[tokio::test]
async fn scan_chatgpt_zip_rejects_missing_conversations_json() {
let dir = tempdir().unwrap();
let zip_path = dir.path().join("bad.zip");
zip::ZipWriter::new(File::create(&zip_path).unwrap())
.finish()
.unwrap();
let db = create_test_pool().await.unwrap();
let err = scan_chatgpt_import_from_path(&db.conn, &zip_path)
.await
.unwrap_err();
assert!(err.to_string().contains("conversations.json"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
use std::fs;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde_json::json;
use super::{
json_error, Result, SessionChange, GLOBAL_STATE_BACKUP_FILE, GLOBAL_STATE_FILE, STATE_DB_FILE,
};
pub(super) fn create_backup(codex_home: &Path, changes: &[SessionChange]) -> Result<PathBuf> {
let backup_dir = codex_home
.join("backups_state")
.join("boosapi-session-visibility")
.join(Utc::now().format("%Y%m%d-%H%M%S-%3f").to_string());
fs::create_dir_all(&backup_dir)?;
for change in changes {
copy_if_exists(&change.path, &backup_dir.join(&change.relative_path))?;
}
for file_name in [STATE_DB_FILE, GLOBAL_STATE_FILE, GLOBAL_STATE_BACKUP_FILE] {
copy_if_exists(&codex_home.join(file_name), &backup_dir.join(file_name))?;
}
write_backup_metadata(&backup_dir, changes.len())?;
Ok(backup_dir)
}
fn copy_if_exists(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dest)?;
Ok(())
}
fn write_backup_metadata(backup_dir: &Path, changed_files: usize) -> Result<()> {
let content = serde_json::to_string_pretty(&json!({
"version": 1,
"namespace": "boosapi-session-visibility",
"createdAt": Utc::now().to_rfc3339(),
"changedSessionFiles": changed_files
}))
.map_err(json_error)?;
fs::write(backup_dir.join("metadata.json"), content)?;
Ok(())
}
@@ -0,0 +1,150 @@
mod backup;
mod repair;
mod session_files;
mod sqlite_state;
mod sqlite_status;
mod status;
mod workspace_roots;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::error::{BoosApiError, Result};
use crate::types::CodexSessionVisibilityStatus;
pub use repair::{
repair_codex_session_visibility, repair_codex_session_visibility_at,
repair_codex_session_visibility_with_backup, repair_codex_session_visibility_with_backup_at,
};
const DEFAULT_PROVIDER: &str = "openai";
const SESSION_DIRS: [&str; 2] = ["sessions", "archived_sessions"];
const GLOBAL_STATE_FILE: &str = ".codex-global-state.json";
const GLOBAL_STATE_BACKUP_FILE: &str = ".codex-global-state.json.bak";
const STATE_DB_FILE: &str = "state_5.sqlite";
#[derive(Debug, Clone)]
pub(super) struct SessionChange {
pub path: PathBuf,
pub relative_path: PathBuf,
pub original_first_line: String,
pub updated_first_line: String,
pub separator: String,
pub offset: u64,
pub original_mtime: Option<SystemTime>,
}
#[derive(Debug, Default)]
pub(super) struct SessionScan {
pub changes: Vec<SessionChange>,
pub user_event_thread_ids: BTreeSet<String>,
pub thread_cwd_by_id: BTreeMap<String, String>,
pub encrypted_content_by_provider: BTreeMap<String, usize>,
}
#[derive(Debug, Default)]
pub(super) struct SqliteUpdateResult {
pub present: bool,
pub provider_rows: usize,
pub user_event_rows: usize,
pub cwd_rows: usize,
}
#[derive(Debug, Default)]
pub(super) struct SqliteStatus {
pub present: bool,
pub rows: usize,
pub mismatched_rows: usize,
pub user_event_rows: usize,
pub cwd_rows: usize,
pub provider_counts: BTreeMap<String, usize>,
}
impl SqliteUpdateResult {
pub(super) fn total_rows(&self) -> usize {
self.provider_rows + self.user_event_rows + self.cwd_rows
}
}
pub async fn get_codex_session_visibility_status() -> Result<CodexSessionVisibilityStatus> {
let home = default_codex_home()?;
get_codex_session_visibility_status_at(&home).await
}
pub async fn get_codex_session_visibility_status_at(
codex_home: &Path,
) -> Result<CodexSessionVisibilityStatus> {
status::get_status(codex_home)
}
fn default_codex_home() -> Result<PathBuf> {
if let Some(value) = std::env::var_os("CODEX_HOME") {
return Ok(PathBuf::from(value));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| BoosApiError::NotFound("Could not determine home directory".into()))?;
Ok(home.join(".codex"))
}
fn ensure_codex_home(codex_home: &Path) -> Result<()> {
if codex_home.exists() {
Ok(())
} else {
Err(BoosApiError::NotFound(format!(
"Codex home not found: {}",
codex_home.display()
)))
}
}
fn read_target_provider(codex_home: &Path) -> Result<String> {
let config_path = codex_home.join("config.toml");
let content = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(DEFAULT_PROVIDER.into());
}
Err(error) => return Err(BoosApiError::Io(error)),
};
let doc = content.parse::<toml_edit::DocumentMut>().map_err(|error| {
BoosApiError::Gateway(format!("Failed to parse Codex config.toml: {}", error))
})?;
Ok(doc
.get("model_provider")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.unwrap_or(DEFAULT_PROVIDER)
.to_string())
}
fn encrypted_content_warning(
counts: &BTreeMap<String, usize>,
target_provider: &str,
) -> Option<String> {
let risky: Vec<String> = counts
.iter()
.filter(|(provider, count)| provider.as_str() != target_provider && **count > 0)
.map(|(provider, _)| provider.clone())
.collect();
if risky.is_empty() {
None
} else {
Some(format!(
"{} rollout file(s) contain encrypted_content from provider(s) {}. Visibility metadata can be synchronized to {}, but continuing or compacting those histories may fail with invalid_encrypted_content.",
counts.values().sum::<usize>(),
risky.join(", "),
target_provider
))
}
}
pub(super) fn state_db_path(codex_home: &Path) -> PathBuf {
codex_home.join(STATE_DB_FILE)
}
pub(super) fn json_error(error: serde_json::Error) -> BoosApiError {
BoosApiError::Gateway(format!("Failed to process Codex JSON metadata: {}", error))
}
@@ -0,0 +1,192 @@
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::types::CodexSessionVisibilityRepairResult;
use super::{
backup, default_codex_home, encrypted_content_warning, ensure_codex_home, read_target_provider,
session_files, sqlite_state, state_db_path, workspace_roots, SessionChange, SessionScan,
SqliteUpdateResult, GLOBAL_STATE_FILE,
};
pub async fn repair_codex_session_visibility() -> Result<CodexSessionVisibilityRepairResult> {
let home = default_codex_home()?;
repair_codex_session_visibility_with_backup_at(&home, true).await
}
pub async fn repair_codex_session_visibility_at(
codex_home: &Path,
) -> Result<CodexSessionVisibilityRepairResult> {
repair_codex_session_visibility_with_backup_at(codex_home, true).await
}
pub async fn repair_codex_session_visibility_with_backup(
create_backup: bool,
) -> Result<CodexSessionVisibilityRepairResult> {
let home = default_codex_home()?;
repair_codex_session_visibility_with_backup_at(&home, create_backup).await
}
pub async fn repair_codex_session_visibility_with_backup_at(
codex_home: &Path,
create_backup: bool,
) -> Result<CodexSessionVisibilityRepairResult> {
ensure_codex_home(codex_home)?;
let target_provider = read_target_provider(codex_home)?;
let scan = session_files::collect_session_changes(codex_home, &target_provider)?;
sqlite_state::assert_sqlite_writable(codex_home)?;
let backup_dir = if create_backup {
Some(backup::create_backup(codex_home, &scan.changes)?)
} else {
None
};
let mut sqlite_result = SqliteUpdateResult::default();
let mut applied_changes: Vec<SessionChange> = Vec::new();
let global_state_backup = backup_dir.as_ref().map(|path| path.join(GLOBAL_STATE_FILE));
let global_state_original = if create_backup {
None
} else {
workspace_roots::read_global_state_text(codex_home)?
};
let mut global_state_updated = false;
match apply_repair(
codex_home,
&target_provider,
&scan,
&mut sqlite_result,
&mut applied_changes,
&mut global_state_updated,
) {
Ok(updated_workspace_roots) => Ok(build_result(
target_provider,
scan,
sqlite_result,
updated_workspace_roots,
backup_dir,
)),
Err(error) => {
session_files::restore_applied_changes(&applied_changes);
restore_global_state(
codex_home,
global_state_updated,
global_state_backup.as_ref(),
global_state_original.as_deref(),
);
Err(error)
}
}
}
fn restore_global_state(
codex_home: &Path,
updated: bool,
backup_path: Option<&PathBuf>,
original: Option<&str>,
) {
if !updated {
return;
}
if let Some(backup_path) = backup_path {
workspace_roots::restore_global_state(codex_home, backup_path);
} else {
workspace_roots::restore_global_state_text(codex_home, original);
}
}
fn apply_repair(
codex_home: &Path,
target_provider: &str,
scan: &SessionScan,
sqlite_result: &mut SqliteUpdateResult,
applied_changes: &mut Vec<SessionChange>,
global_state_updated: &mut bool,
) -> Result<usize> {
if state_db_path(codex_home).exists() {
return apply_repair_with_sqlite(
codex_home,
target_provider,
scan,
sqlite_result,
applied_changes,
global_state_updated,
);
}
session_files::apply_session_changes(&scan.changes, applied_changes)?;
let updated_roots = workspace_roots::sync_workspace_roots(codex_home, &scan.thread_cwd_by_id)?;
*global_state_updated = updated_roots > 0;
*sqlite_result = SqliteUpdateResult::default();
Ok(updated_roots)
}
fn apply_repair_with_sqlite(
codex_home: &Path,
target_provider: &str,
scan: &SessionScan,
sqlite_result: &mut SqliteUpdateResult,
applied_changes: &mut Vec<SessionChange>,
global_state_updated: &mut bool,
) -> Result<usize> {
let db = sqlite_state::SqliteDb::open(&state_db_path(codex_home))?;
db.exec("BEGIN IMMEDIATE")?;
let result = apply_repair_with_open_tx(
codex_home,
target_provider,
scan,
&db,
applied_changes,
global_state_updated,
);
match result {
Ok((sqlite_stats, updated_roots)) => {
db.exec("COMMIT")?;
*sqlite_result = sqlite_stats;
Ok(updated_roots)
}
Err(error) => {
let _ = db.exec("ROLLBACK");
Err(error)
}
}
}
fn apply_repair_with_open_tx(
codex_home: &Path,
target_provider: &str,
scan: &SessionScan,
db: &sqlite_state::SqliteDb,
applied_changes: &mut Vec<SessionChange>,
global_state_updated: &mut bool,
) -> Result<(SqliteUpdateResult, usize)> {
let sqlite_stats = sqlite_state::update_sqlite_provider(db, target_provider, scan)?;
session_files::apply_session_changes(&scan.changes, applied_changes)?;
let updated_roots = workspace_roots::sync_workspace_roots(codex_home, &scan.thread_cwd_by_id)?;
*global_state_updated = updated_roots > 0;
Ok((sqlite_stats, updated_roots))
}
fn build_result(
target_provider: String,
scan: SessionScan,
sqlite: SqliteUpdateResult,
updated_workspace_roots: usize,
backup_dir: Option<PathBuf>,
) -> CodexSessionVisibilityRepairResult {
CodexSessionVisibilityRepairResult {
target_provider: target_provider.clone(),
changed_session_files: scan.changes.len(),
skipped_locked_session_files: 0,
sqlite_rows_updated: sqlite.total_rows(),
sqlite_provider_rows_updated: sqlite.provider_rows,
sqlite_user_event_rows_updated: sqlite.user_event_rows,
sqlite_cwd_rows_updated: sqlite.cwd_rows,
sqlite_present: sqlite.present,
updated_workspace_roots,
backup_dir: backup_dir.map(|path| path.display().to_string()),
encrypted_content_warning: encrypted_content_warning(
&scan.encrypted_content_by_provider,
&target_provider,
),
}
}
@@ -0,0 +1,300 @@
use std::fs::{self, File, FileTimes};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use super::{json_error, workspace_roots, Result, SessionChange, SessionScan, SESSION_DIRS};
use serde_json::Value;
#[derive(Debug, Clone)]
struct FirstLineRecord {
first_line: String,
separator: String,
offset: u64,
}
pub(super) fn collect_session_changes(
codex_home: &Path,
target_provider: &str,
) -> Result<SessionScan> {
let mut scan = SessionScan::default();
for dir_name in SESSION_DIRS {
let root = codex_home.join(dir_name);
if root.exists() {
for path in list_rollout_files(&root)? {
collect_rollout_change(codex_home, &path, target_provider, &mut scan)?;
}
}
}
Ok(scan)
}
fn collect_rollout_change(
codex_home: &Path,
path: &Path,
target_provider: &str,
scan: &mut SessionScan,
) -> Result<()> {
let record = read_first_line_record(path)?;
let mut meta = parse_session_meta(&record.first_line)?;
if meta.is_null() {
return Ok(());
}
let payload = meta.get_mut("payload").and_then(Value::as_object_mut);
let Some(payload) = payload else {
return Ok(());
};
let current_provider = payload
.get("model_provider")
.and_then(Value::as_str)
.unwrap_or("(missing)")
.to_string();
record_rollout_facts(path, &record, payload, &current_provider, scan)?;
if current_provider != target_provider {
payload.insert(
"model_provider".into(),
Value::String(target_provider.into()),
);
scan.changes
.push(build_change(codex_home, path, record, meta)?);
}
Ok(())
}
fn build_change(
codex_home: &Path,
path: &Path,
record: FirstLineRecord,
meta: Value,
) -> Result<SessionChange> {
Ok(SessionChange {
path: path.to_path_buf(),
relative_path: path.strip_prefix(codex_home).unwrap_or(path).to_path_buf(),
original_first_line: record.first_line,
updated_first_line: serde_json::to_string(&meta).map_err(json_error)?,
separator: record.separator,
offset: record.offset,
original_mtime: fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok()),
})
}
fn record_rollout_facts(
path: &Path,
record: &FirstLineRecord,
payload: &serde_json::Map<String, Value>,
provider: &str,
scan: &mut SessionScan,
) -> Result<()> {
let thread_id = payload
.get("id")
.and_then(Value::as_str)
.unwrap_or_default();
if !thread_id.is_empty() {
record_thread_cwd(payload, thread_id, scan);
if file_has_user_event(path, &record.first_line, record.offset)? {
scan.user_event_thread_ids.insert(thread_id.into());
}
}
if file_has_encrypted_content(path, &record.first_line, record.offset)? {
*scan
.encrypted_content_by_provider
.entry(provider.to_string())
.or_default() += 1;
}
Ok(())
}
fn record_thread_cwd(
payload: &serde_json::Map<String, Value>,
thread_id: &str,
scan: &mut SessionScan,
) {
let Some(cwd) = payload.get("cwd").and_then(Value::as_str) else {
return;
};
if !cwd.trim().is_empty() {
scan.thread_cwd_by_id.insert(
thread_id.into(),
workspace_roots::to_desktop_workspace_path(cwd),
);
}
}
fn list_rollout_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
list_rollout_files_into(root, &mut files)?;
files.sort();
Ok(files)
}
fn list_rollout_files_into(root: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
list_rollout_files_into(&path, files)?;
} else if file_type.is_file() && is_rollout_file(&path) {
files.push(path);
}
}
Ok(())
}
fn is_rollout_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
.unwrap_or(false)
}
fn read_first_line_record(path: &Path) -> Result<FirstLineRecord> {
let mut file = File::open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let newline = bytes.iter().position(|byte| *byte == b'\n');
let (line_bytes, separator, offset) = match newline {
Some(index) if index > 0 && bytes[index - 1] == b'\r' => {
(&bytes[..index - 1], "\r\n".to_string(), index as u64 + 1)
}
Some(index) => (&bytes[..index], "\n".to_string(), index as u64 + 1),
None => (&bytes[..], String::new(), bytes.len() as u64),
};
Ok(FirstLineRecord {
first_line: String::from_utf8_lossy(line_bytes).to_string(),
separator,
offset,
})
}
fn parse_session_meta(first_line: &str) -> Result<Value> {
if first_line.trim().is_empty() {
return Ok(Value::Null);
}
let parsed: Value = serde_json::from_str(first_line).map_err(json_error)?;
if parsed.get("type").and_then(Value::as_str) == Some("session_meta")
&& parsed.get("payload").is_some()
{
Ok(parsed)
} else {
Ok(Value::Null)
}
}
fn file_has_encrypted_content(path: &Path, first_line: &str, offset: u64) -> Result<bool> {
Ok(first_line.contains("encrypted_content")
|| file_tail_contains(path, offset, "encrypted_content")?)
}
fn file_tail_contains(path: &Path, offset: u64, needle: &str) -> Result<bool> {
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content.contains(needle))
}
fn file_has_user_event(path: &Path, first_line: &str, offset: u64) -> Result<bool> {
if line_has_user_event(first_line) {
return Ok(true);
}
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content.lines().any(line_has_user_event))
}
fn line_has_user_event(line: &str) -> bool {
let Ok(value) = serde_json::from_str::<Value>(line) else {
return false;
};
let event_msg = value.get("type").and_then(Value::as_str) == Some("event_msg")
&& value
.get("payload")
.and_then(|payload| payload.get("type"))
.and_then(Value::as_str)
== Some("user_message");
event_msg
|| ["payload", "item", "msg"]
.iter()
.any(|key| user_message_at(&value, key))
}
fn user_message_at(value: &Value, key: &str) -> bool {
let Some(message) = value.get(key) else {
return false;
};
message.get("type").and_then(Value::as_str) == Some("message")
&& message.get("role").and_then(Value::as_str) == Some("user")
}
pub(super) fn apply_session_changes(
changes: &[SessionChange],
applied: &mut Vec<SessionChange>,
) -> Result<usize> {
let mut changed = 0;
for change in changes {
if rewrite_collected_first_line(change)? {
changed += 1;
applied.push(change.clone());
}
}
Ok(changed)
}
fn rewrite_collected_first_line(change: &SessionChange) -> Result<bool> {
let current = read_first_line_record(&change.path)?;
if current.first_line != change.original_first_line || current.offset != change.offset {
return Ok(false);
}
let tmp_path = change.path.with_extension(format!(
"boosapi-session-visibility-{}.tmp",
chrono::Utc::now().timestamp_millis()
));
rewrite_first_line_to_tmp(change, &tmp_path)?;
fs::rename(&tmp_path, &change.path)?;
restore_mtime(&change.path, change.original_mtime);
Ok(true)
}
fn rewrite_first_line_to_tmp(change: &SessionChange, tmp_path: &Path) -> Result<()> {
let mut source = File::open(&change.path)?;
source.seek(SeekFrom::Start(change.offset))?;
let mut tmp = File::create(tmp_path)?;
tmp.write_all(change.updated_first_line.as_bytes())?;
tmp.write_all(change.separator.as_bytes())?;
std::io::copy(&mut source, &mut tmp)?;
tmp.flush()?;
Ok(())
}
pub(super) fn restore_applied_changes(changes: &[SessionChange]) {
for change in changes {
let _ = rewrite_first_line(&change.path, &change.original_first_line, &change.separator);
restore_mtime(&change.path, change.original_mtime);
}
}
fn rewrite_first_line(path: &Path, first_line: &str, separator: &str) -> Result<()> {
let current = read_first_line_record(path)?;
let tmp_path = path.with_extension("boosapi-session-visibility-restore.tmp");
let mut source = File::open(path)?;
source.seek(SeekFrom::Start(current.offset))?;
let mut tmp = File::create(&tmp_path)?;
tmp.write_all(first_line.as_bytes())?;
tmp.write_all(separator.as_bytes())?;
std::io::copy(&mut source, &mut tmp)?;
tmp.flush()?;
fs::rename(tmp_path, path)?;
Ok(())
}
fn restore_mtime(path: &Path, modified: Option<std::time::SystemTime>) {
let Some(modified) = modified else { return };
let Ok(file) = File::options().write(true).open(path) else {
return;
};
let _ = file.set_times(FileTimes::new().set_modified(modified));
}
@@ -0,0 +1,241 @@
use std::collections::BTreeSet;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::path::Path;
use std::ptr;
use libsqlite3_sys as sqlite;
use super::{state_db_path, BoosApiError, Result, SessionScan, SqliteUpdateResult};
pub(super) fn assert_sqlite_writable(codex_home: &Path) -> Result<()> {
let db_path = state_db_path(codex_home);
if !db_path.exists() {
return Ok(());
}
let db = SqliteDb::open(&db_path)?;
db.exec("BEGIN IMMEDIATE")?;
db.exec("ROLLBACK")
}
pub(super) fn update_sqlite_provider(
db: &SqliteDb,
target_provider: &str,
scan: &SessionScan,
) -> Result<SqliteUpdateResult> {
let columns = db.table_columns("threads")?;
let provider_rows = db.execute_with_params(
"UPDATE threads SET model_provider = ? WHERE COALESCE(model_provider, '') <> ?",
&[target_provider, target_provider],
)?;
let user_event_rows = update_user_event_rows(db, &columns, scan)?;
let cwd_rows = update_cwd_rows(db, &columns, scan)?;
Ok(SqliteUpdateResult {
present: true,
provider_rows,
user_event_rows,
cwd_rows,
})
}
fn update_user_event_rows(
db: &SqliteDb,
columns: &BTreeSet<String>,
scan: &SessionScan,
) -> Result<usize> {
if !columns.contains("has_user_event") {
return Ok(0);
}
let mut rows = 0;
for thread_id in &scan.user_event_thread_ids {
rows += db.execute_with_params(
"UPDATE threads SET has_user_event = 1 WHERE id = ? AND COALESCE(has_user_event, 0) <> 1",
&[thread_id],
)?;
}
Ok(rows)
}
fn update_cwd_rows(db: &SqliteDb, columns: &BTreeSet<String>, scan: &SessionScan) -> Result<usize> {
if !columns.contains("cwd") {
return Ok(0);
}
let mut rows = 0;
for (thread_id, cwd) in &scan.thread_cwd_by_id {
rows += db.execute_with_params(
"UPDATE threads SET cwd = ? WHERE id = ? AND COALESCE(cwd, '') <> ?",
&[cwd, thread_id, cwd],
)?;
}
Ok(rows)
}
pub(super) struct SqliteDb {
conn: *mut sqlite::sqlite3,
}
impl SqliteDb {
pub(super) fn open(path: &Path) -> Result<Self> {
let path = CString::new(path.to_string_lossy().as_bytes())
.map_err(|_| BoosApiError::Gateway("Invalid state_5.sqlite path".into()))?;
let mut conn = ptr::null_mut();
let flags = sqlite::SQLITE_OPEN_READWRITE | sqlite::SQLITE_OPEN_URI;
let rc = unsafe { sqlite::sqlite3_open_v2(path.as_ptr(), &mut conn, flags, ptr::null()) };
if rc == sqlite::SQLITE_OK {
return Ok(Self { conn });
}
let error = sqlite_error(conn, "open state_5.sqlite");
if !conn.is_null() {
unsafe { sqlite::sqlite3_close(conn) };
}
Err(error)
}
pub(super) fn exec(&self, sql: &str) -> Result<()> {
let sql = CString::new(sql).map_err(|_| BoosApiError::Gateway("Invalid SQL".into()))?;
let mut error: *mut c_char = ptr::null_mut();
let rc = unsafe {
sqlite::sqlite3_exec(self.conn, sql.as_ptr(), None, ptr::null_mut(), &mut error)
};
if rc == sqlite::SQLITE_OK {
return Ok(());
}
Err(BoosApiError::Gateway(format!(
"state_5.sqlite: {}",
sqlite_exec_error(self.conn, error)
)))
}
pub(super) fn table_columns(&self, table: &str) -> Result<BTreeSet<String>> {
let sql = format!("PRAGMA table_info({})", quote_sql_identifier(table));
let mut stmt = self.prepare(&sql)?;
let mut columns = BTreeSet::new();
while stmt.step_row()? {
if let Some(name) = stmt.column_text(1) {
columns.insert(name);
}
}
Ok(columns)
}
fn execute_with_params(&self, sql: &str, params: &[&str]) -> Result<usize> {
let mut stmt = self.prepare(sql)?;
for (index, value) in params.iter().enumerate() {
stmt.bind_text(index as c_int + 1, value)?;
}
stmt.step_done()?;
Ok(unsafe { sqlite::sqlite3_changes(self.conn) as usize })
}
pub(super) fn prepare(&self, sql: &str) -> Result<SqliteStatement<'_>> {
let sql = CString::new(sql).map_err(|_| BoosApiError::Gateway("Invalid SQL".into()))?;
let mut stmt = ptr::null_mut();
let rc = unsafe {
sqlite::sqlite3_prepare_v2(self.conn, sql.as_ptr(), -1, &mut stmt, ptr::null_mut())
};
if rc == sqlite::SQLITE_OK {
Ok(SqliteStatement { db: self, stmt })
} else {
Err(sqlite_error(self.conn, "prepare state_5.sqlite statement"))
}
}
}
impl Drop for SqliteDb {
fn drop(&mut self) {
if !self.conn.is_null() {
unsafe { sqlite::sqlite3_close(self.conn) };
}
}
}
pub(super) struct SqliteStatement<'a> {
db: &'a SqliteDb,
stmt: *mut sqlite::sqlite3_stmt,
}
impl SqliteStatement<'_> {
pub(super) fn bind_text(&mut self, index: c_int, value: &str) -> Result<()> {
let value = CString::new(value)
.map_err(|_| BoosApiError::Gateway("Invalid SQLite bind value".into()))?;
let rc = unsafe {
sqlite::sqlite3_bind_text(
self.stmt,
index,
value.as_ptr(),
-1,
sqlite::SQLITE_TRANSIENT(),
)
};
if rc == sqlite::SQLITE_OK {
Ok(())
} else {
Err(sqlite_error(self.db.conn, "bind state_5.sqlite parameter"))
}
}
fn step_done(&mut self) -> Result<()> {
match unsafe { sqlite::sqlite3_step(self.stmt) } {
sqlite::SQLITE_DONE => Ok(()),
_ => Err(sqlite_error(
self.db.conn,
"execute state_5.sqlite statement",
)),
}
}
pub(super) fn step_row(&mut self) -> Result<bool> {
match unsafe { sqlite::sqlite3_step(self.stmt) } {
sqlite::SQLITE_ROW => Ok(true),
sqlite::SQLITE_DONE => Ok(false),
_ => Err(sqlite_error(self.db.conn, "query state_5.sqlite")),
}
}
pub(super) fn column_text(&self, index: c_int) -> Option<String> {
let text = unsafe { sqlite::sqlite3_column_text(self.stmt, index) };
if text.is_null() {
return None;
}
let cstr = unsafe { CStr::from_ptr(text as *const c_char) };
Some(cstr.to_string_lossy().to_string())
}
pub(super) fn column_i64(&self, index: c_int) -> i64 {
unsafe { sqlite::sqlite3_column_int64(self.stmt, index) }
}
}
impl Drop for SqliteStatement<'_> {
fn drop(&mut self) {
if !self.stmt.is_null() {
unsafe { sqlite::sqlite3_finalize(self.stmt) };
}
}
}
fn quote_sql_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn sqlite_error(conn: *mut sqlite::sqlite3, action: &str) -> BoosApiError {
let message = if conn.is_null() {
"unknown SQLite error".into()
} else {
let ptr = unsafe { sqlite::sqlite3_errmsg(conn) };
unsafe { CStr::from_ptr(ptr).to_string_lossy().to_string() }
};
BoosApiError::Gateway(format!("Failed to {}: {}", action, message))
}
fn sqlite_exec_error(conn: *mut sqlite::sqlite3, error: *mut c_char) -> String {
if error.is_null() {
return match sqlite_error(conn, "execute") {
BoosApiError::Gateway(message) => message,
other => other.to_string(),
};
}
let message = unsafe { CStr::from_ptr(error).to_string_lossy().to_string() };
unsafe { sqlite::sqlite3_free(error as *mut _) };
message
}
@@ -0,0 +1,98 @@
use std::collections::BTreeMap;
use std::path::Path;
use super::{sqlite_state::SqliteDb, state_db_path, Result, SessionScan, SqliteStatus};
pub(super) fn read_sqlite_status(
codex_home: &Path,
target_provider: &str,
scan: &SessionScan,
) -> Result<SqliteStatus> {
let db_path = state_db_path(codex_home);
if !db_path.exists() {
return Ok(SqliteStatus::default());
}
let db = SqliteDb::open(&db_path)?;
let provider_counts = read_provider_counts(&db)?;
let rows = provider_counts.values().sum();
let mismatched_rows = provider_counts
.iter()
.filter(|(provider, _)| provider.as_str() != target_provider)
.map(|(_, count)| *count)
.sum();
let columns = db.table_columns("threads")?;
Ok(SqliteStatus {
present: true,
rows,
mismatched_rows,
user_event_rows: count_user_event_rows(&db, &columns, scan)?,
cwd_rows: count_cwd_rows(&db, &columns, scan)?,
provider_counts,
})
}
fn read_provider_counts(db: &SqliteDb) -> Result<BTreeMap<String, usize>> {
let mut stmt = db.prepare(
"SELECT COALESCE(model_provider, '') AS provider, COUNT(*) \
FROM threads GROUP BY COALESCE(model_provider, '')",
)?;
let mut counts = BTreeMap::new();
while stmt.step_row()? {
let provider = stmt
.column_text(0)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "(missing)".into());
counts.insert(provider, stmt.column_i64(1).max(0) as usize);
}
Ok(counts)
}
fn count_user_event_rows(
db: &SqliteDb,
columns: &std::collections::BTreeSet<String>,
scan: &SessionScan,
) -> Result<usize> {
if !columns.contains("has_user_event") {
return Ok(0);
}
let mut rows = 0;
for thread_id in &scan.user_event_thread_ids {
rows += count_with_params(
db,
"SELECT COUNT(*) FROM threads WHERE id = ? AND COALESCE(has_user_event, 0) <> 1",
&[thread_id],
)?;
}
Ok(rows)
}
fn count_cwd_rows(
db: &SqliteDb,
columns: &std::collections::BTreeSet<String>,
scan: &SessionScan,
) -> Result<usize> {
if !columns.contains("cwd") {
return Ok(0);
}
let mut rows = 0;
for (thread_id, cwd) in &scan.thread_cwd_by_id {
rows += count_with_params(
db,
"SELECT COUNT(*) FROM threads WHERE id = ? AND COALESCE(cwd, '') <> ?",
&[thread_id, cwd],
)?;
}
Ok(rows)
}
fn count_with_params(db: &SqliteDb, sql: &str, params: &[&str]) -> Result<usize> {
let mut stmt = db.prepare(sql)?;
for (index, value) in params.iter().enumerate() {
stmt.bind_text(index as i32 + 1, value)?;
}
if stmt.step_row()? {
Ok(stmt.column_i64(0).max(0) as usize)
} else {
Ok(0)
}
}
@@ -0,0 +1,181 @@
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{
encrypted_content_warning, ensure_codex_home, json_error, read_target_provider, session_files,
sqlite_status, workspace_roots, Result, SESSION_DIRS,
};
use crate::types::{CodexSessionVisibilityStatus, CodexSessionVisibilityStatusRow};
#[derive(Debug, Default)]
struct SessionProviderCounts {
counts: BTreeMap<(String, String), usize>,
total: usize,
}
pub(super) fn get_status(codex_home: &Path) -> Result<CodexSessionVisibilityStatus> {
ensure_codex_home(codex_home)?;
let target_provider = read_target_provider(codex_home)?;
let scan = session_files::collect_session_changes(codex_home, &target_provider)?;
let session_counts = collect_session_provider_counts(codex_home)?;
let sqlite = sqlite_status::read_sqlite_status(codex_home, &target_provider, &scan)?;
let workspace_roots =
workspace_roots::preview_workspace_roots(codex_home, &scan.thread_cwd_by_id)?;
let mut rows = build_session_rows(&session_counts.counts, &target_provider);
rows.extend(build_provider_rows(
"state_5.sqlite",
&sqlite.provider_counts,
&target_provider,
));
if workspace_roots > 0 {
rows.push(CodexSessionVisibilityStatusRow {
scope: ".codex-global-state.json".into(),
provider: None,
count: workspace_roots,
mismatched_count: workspace_roots,
status: "needs_repair".into(),
});
}
Ok(CodexSessionVisibilityStatus {
target_provider: target_provider.clone(),
codex_home: codex_home.display().to_string(),
total_session_files: session_counts.total,
mismatched_session_files: scan.changes.len(),
sqlite_present: sqlite.present,
sqlite_rows: sqlite.rows,
sqlite_mismatched_rows: sqlite.mismatched_rows,
sqlite_user_event_rows_needing_repair: sqlite.user_event_rows,
sqlite_cwd_rows_needing_repair: sqlite.cwd_rows,
workspace_roots_needing_update: workspace_roots,
status_rows: rows,
encrypted_content_warning: encrypted_content_warning(
&scan.encrypted_content_by_provider,
&target_provider,
),
})
}
fn build_session_rows(
counts: &BTreeMap<(String, String), usize>,
target_provider: &str,
) -> Vec<CodexSessionVisibilityStatusRow> {
counts
.iter()
.map(|((scope, provider), count)| provider_row(scope, provider, *count, target_provider))
.collect()
}
fn build_provider_rows(
scope: &str,
counts: &BTreeMap<String, usize>,
target_provider: &str,
) -> Vec<CodexSessionVisibilityStatusRow> {
counts
.iter()
.map(|(provider, count)| provider_row(scope, provider, *count, target_provider))
.collect()
}
fn provider_row(
scope: &str,
provider: &str,
count: usize,
target_provider: &str,
) -> CodexSessionVisibilityStatusRow {
let mismatched_count = if provider == target_provider {
0
} else {
count
};
CodexSessionVisibilityStatusRow {
scope: scope.into(),
provider: Some(provider.into()),
count,
mismatched_count,
status: if mismatched_count == 0 {
"ok"
} else {
"needs_repair"
}
.into(),
}
}
fn collect_session_provider_counts(codex_home: &Path) -> Result<SessionProviderCounts> {
let mut result = SessionProviderCounts::default();
for dir_name in SESSION_DIRS {
let root = codex_home.join(dir_name);
if root.exists() {
for path in list_rollout_files(&root)? {
collect_file_provider(dir_name, &path, &mut result)?;
}
}
}
Ok(result)
}
fn collect_file_provider(
scope: &str,
path: &Path,
counts: &mut SessionProviderCounts,
) -> Result<()> {
let Some(first_line) = read_first_line(path)? else {
return Ok(());
};
let parsed: Value = serde_json::from_str(&first_line).map_err(json_error)?;
if parsed.get("type").and_then(Value::as_str) != Some("session_meta") {
return Ok(());
}
let provider = parsed
.get("payload")
.and_then(|payload| payload.get("model_provider"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("(missing)")
.to_string();
*counts.counts.entry((scope.into(), provider)).or_default() += 1;
counts.total += 1;
Ok(())
}
fn list_rollout_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
list_rollout_files_into(root, &mut files)?;
files.sort();
Ok(files)
}
fn list_rollout_files_into(root: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
list_rollout_files_into(&path, files)?;
} else if file_type.is_file() && is_rollout_file(&path) {
files.push(path);
}
}
Ok(())
}
fn is_rollout_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
.unwrap_or(false)
}
fn read_first_line(path: &Path) -> Result<Option<String>> {
let mut reader = BufReader::new(File::open(path)?);
let mut first_line = String::new();
if reader.read_line(&mut first_line)? == 0 {
return Ok(None);
}
Ok(Some(first_line.trim_end_matches(['\r', '\n']).to_string()))
}
@@ -0,0 +1,231 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use serde_json::{json, Value};
use super::{json_error, BoosApiError, Result, GLOBAL_STATE_BACKUP_FILE, GLOBAL_STATE_FILE};
pub(super) fn sync_workspace_roots(
codex_home: &Path,
cwd_by_id: &BTreeMap<String, String>,
) -> Result<usize> {
let path = codex_home.join(GLOBAL_STATE_FILE);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(BoosApiError::Io(error)),
};
let mut state: Value = serde_json::from_str(&content).map_err(json_error)?;
let original_state = state.clone();
let known_cwds = dedupe_paths(cwd_by_id.values().cloned().collect());
let changed = rewrite_workspace_root_state(&mut state, &known_cwds);
let backup_missing = !codex_home.join(GLOBAL_STATE_BACKUP_FILE).exists();
if changed == 0 && state == original_state && !backup_missing {
return Ok(0);
}
let next = format!(
"{}\n",
serde_json::to_string_pretty(&state).map_err(json_error)?
);
fs::write(&path, &next)?;
fs::write(codex_home.join(GLOBAL_STATE_BACKUP_FILE), next)?;
Ok(changed)
}
pub(super) fn preview_workspace_roots(
codex_home: &Path,
cwd_by_id: &BTreeMap<String, String>,
) -> Result<usize> {
let Some(content) = read_global_state_text(codex_home)? else {
return Ok(0);
};
let mut state: Value = serde_json::from_str(&content).map_err(json_error)?;
let original_state = state.clone();
let known_cwds = dedupe_paths(cwd_by_id.values().cloned().collect());
let changed = rewrite_workspace_root_state(&mut state, &known_cwds);
if state == original_state {
Ok(0)
} else {
Ok(changed.max(1))
}
}
pub(super) fn read_global_state_text(codex_home: &Path) -> Result<Option<String>> {
match fs::read_to_string(codex_home.join(GLOBAL_STATE_FILE)) {
Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(BoosApiError::Io(error)),
}
}
pub(super) fn restore_global_state_text(codex_home: &Path, content: Option<&str>) {
let path = codex_home.join(GLOBAL_STATE_FILE);
match content {
Some(content) => {
let _ = fs::write(path, content);
}
None => {
let _ = fs::remove_file(path);
}
}
}
fn rewrite_workspace_root_state(state: &mut Value, known_cwds: &[String]) -> usize {
let saved = to_path_array(state.get("electron-saved-workspace-roots"));
let project_order = to_path_array(state.get("project-order"));
let active = to_path_array(state.get("active-workspace-roots"));
let next_saved = build_next_saved_roots(&project_order, &saved, &active, known_cwds);
let next_order = build_next_project_order(&project_order, &saved, &next_saved, known_cwds);
let next_active = dedupe_paths(resolve_paths(active, known_cwds));
let changed = count_array_changes(&saved, &next_saved);
state["electron-saved-workspace-roots"] = json!(next_saved);
state["project-order"] = json!(next_order);
rewrite_active_roots(state, next_active);
rewrite_object_keys(state, "electron-workspace-root-labels", known_cwds);
rewrite_open_target_preferences(state, known_cwds);
changed
}
fn build_next_saved_roots(
project_order: &[String],
saved: &[String],
active: &[String],
known_cwds: &[String],
) -> Vec<String> {
let mut combined = Vec::new();
if !project_order.is_empty() {
combined.extend_from_slice(project_order);
}
combined.extend_from_slice(saved);
combined.extend_from_slice(active);
dedupe_paths(resolve_paths(combined, known_cwds))
}
fn build_next_project_order(
project_order: &[String],
saved: &[String],
next_saved: &[String],
known_cwds: &[String],
) -> Vec<String> {
if project_order.is_empty() {
return next_saved.to_vec();
}
let mut combined = Vec::new();
combined.extend_from_slice(project_order);
combined.extend_from_slice(saved);
dedupe_paths(resolve_paths(combined, known_cwds))
}
fn rewrite_active_roots(state: &mut Value, next_active: Vec<String>) {
let original_is_array = state
.get("active-workspace-roots")
.map(Value::is_array)
.unwrap_or(false);
if original_is_array {
state["active-workspace-roots"] = json!(next_active);
} else if let Some(first) = next_active.first() {
state["active-workspace-roots"] = json!(first);
}
}
fn rewrite_object_keys(state: &mut Value, key: &str, known_cwds: &[String]) {
let Some(object) = state.get(key).and_then(Value::as_object) else {
return;
};
let mut next = serde_json::Map::new();
for (path, value) in object {
next.insert(resolve_stored_path(path, known_cwds), value.clone());
}
state[key] = Value::Object(next);
}
fn rewrite_open_target_preferences(state: &mut Value, known_cwds: &[String]) {
let Some(preferences) = state.get_mut("open-in-target-preferences") else {
return;
};
let Some(per_path) = preferences.get_mut("perPath") else {
return;
};
let Some(object) = per_path.as_object() else {
return;
};
let mut next = serde_json::Map::new();
for (path, value) in object {
next.insert(resolve_stored_path(path, known_cwds), value.clone());
}
*per_path = Value::Object(next);
}
fn to_path_array(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(values)) => values
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.collect(),
Some(Value::String(value)) if !value.trim().is_empty() => vec![value.to_string()],
_ => Vec::new(),
}
}
fn resolve_paths(paths: Vec<String>, known_cwds: &[String]) -> Vec<String> {
paths
.into_iter()
.map(|path| resolve_stored_path(&path, known_cwds))
.collect()
}
fn resolve_stored_path(value: &str, known_cwds: &[String]) -> String {
let comparable = normalize_comparable_path(value);
known_cwds
.iter()
.find(|cwd| normalize_comparable_path(cwd) == comparable)
.cloned()
.unwrap_or_else(|| to_desktop_workspace_path(value))
}
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut result = Vec::new();
for path in paths {
let comparable = normalize_comparable_path(&path);
if !comparable.is_empty() && seen.insert(comparable) {
result.push(path);
}
}
result
}
fn count_array_changes(previous: &[String], next: &[String]) -> usize {
let count = previous.len().max(next.len());
(0..count)
.filter(|index| previous.get(*index) != next.get(*index))
.count()
}
fn normalize_comparable_path(value: &str) -> String {
let mut normalized = to_desktop_workspace_path(value).trim().replace('/', "\\");
while normalized.ends_with('\\') && normalized.len() > 3 {
normalized.pop();
}
normalized.to_lowercase()
}
pub(super) fn to_desktop_workspace_path(value: &str) -> String {
let trimmed = value.trim();
if let Some(rest) = trimmed.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{}", rest).replace('/', "\\");
}
if let Some(rest) = trimmed.strip_prefix(r"\\?\") {
return rest.replace('/', "\\");
}
trimmed.trim_end_matches(['/', '\\']).to_string()
}
pub(super) fn restore_global_state(codex_home: &Path, backup_path: &Path) {
if backup_path.exists() {
let _ = fs::copy(backup_path, codex_home.join(GLOBAL_STATE_FILE));
}
}
@@ -0,0 +1,85 @@
use sea_orm::*;
use crate::entity::context_sources;
use crate::error::{BoosApiError, Result};
use crate::types::{ContextSource, CreateContextSourceInput};
use crate::utils::gen_id;
fn model_to_context_source(m: context_sources::Model) -> ContextSource {
ContextSource {
id: m.id,
conversation_id: m.conversation_id,
message_id: m.message_id,
source_type: m.source_type,
ref_id: m.ref_id,
title: m.title,
enabled: m.enabled != 0,
summary: m.summary,
}
}
pub async fn list_context_sources(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<ContextSource>> {
let models = context_sources::Entity::find()
.filter(context_sources::Column::ConversationId.eq(conversation_id))
.all(db)
.await?;
Ok(models.into_iter().map(model_to_context_source).collect())
}
pub async fn get_context_source(db: &DatabaseConnection, id: &str) -> Result<ContextSource> {
let model = context_sources::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ContextSource {}", id)))?;
Ok(model_to_context_source(model))
}
pub async fn add_context_source(
db: &DatabaseConnection,
input: &CreateContextSourceInput,
) -> Result<ContextSource> {
let id = gen_id();
let am = context_sources::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(input.conversation_id.clone()),
message_id: Set(input.message_id.clone()),
source_type: Set(input.source_type.clone()),
ref_id: Set(input.ref_id.clone()),
title: Set(input.title.clone()),
enabled: Set(1),
summary: Set(input.summary.clone()),
};
am.insert(db).await?;
get_context_source(db, &id).await
}
pub async fn remove_context_source(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = context_sources::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("ContextSource {}", id)));
}
Ok(())
}
pub async fn toggle_context_source(db: &DatabaseConnection, id: &str) -> Result<ContextSource> {
let model = context_sources::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ContextSource {}", id)))?;
let new_enabled = if model.enabled != 0 { 0 } else { 1 };
let mut am: context_sources::ActiveModel = model.into();
am.enabled = Set(new_enabled);
am.update(db).await?;
get_context_source(db, id).await
}
@@ -0,0 +1,635 @@
use sea_orm::*;
use serde_json;
use crate::entity::{conversation_summaries, conversations, messages};
use crate::error::{BoosApiError, Result};
use crate::types::{
Conversation, ConversationSearchResult, ConversationSummary, UpdateConversationInput,
};
use crate::utils::{gen_id, now_ts};
fn conversation_from_entity(m: conversations::Model) -> Conversation {
Conversation {
id: m.id,
title: m.title,
model_id: m.model_id,
provider_id: m.provider_id,
system_prompt: m.system_prompt,
temperature: m.temperature.map(|v| v as f32),
max_tokens: m.max_tokens.map(|v| v as u32),
top_p: m.top_p.map(|v| v as f32),
frequency_penalty: m.frequency_penalty.map(|v| v as f32),
search_enabled: m.search_enabled != 0,
search_provider_id: m.search_provider_id,
thinking_budget: m.thinking_budget,
thinking_level: m.thinking_level,
enabled_mcp_server_ids: parse_string_list(&m.enabled_mcp_server_ids),
enabled_knowledge_base_ids: parse_string_list(&m.enabled_knowledge_base_ids),
enabled_memory_namespace_ids: parse_string_list(&m.enabled_memory_namespace_ids),
message_count: m.message_count as u32,
is_pinned: m.is_pinned != 0,
is_archived: m.is_archived != 0,
context_compression: m.context_compression != 0,
category_id: m.category_id,
parent_conversation_id: m.parent_conversation_id,
mode: m.mode,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
fn parse_string_list(raw: &str) -> Vec<String> {
serde_json::from_str(raw)
.expect("conversation preference JSON is invalid; database contents are corrupted")
}
fn stringify_string_list(values: &[String]) -> String {
serde_json::to_string(values).expect("failed to serialize conversation preference JSON")
}
pub async fn list_conversations(db: &DatabaseConnection) -> Result<Vec<Conversation>> {
let rows = conversations::Entity::find()
.filter(conversations::Column::IsArchived.eq(0))
.order_by_desc(conversations::Column::IsPinned)
.order_by_desc(conversations::Column::UpdatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(conversation_from_entity).collect())
}
pub async fn list_archived_conversations(db: &DatabaseConnection) -> Result<Vec<Conversation>> {
let rows = conversations::Entity::find()
.filter(conversations::Column::IsArchived.ne(0))
.order_by_desc(conversations::Column::UpdatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(conversation_from_entity).collect())
}
pub async fn get_conversation(db: &DatabaseConnection, id: &str) -> Result<Conversation> {
let row = conversations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Conversation {}", id)))?;
Ok(conversation_from_entity(row))
}
pub async fn create_conversation(
db: &DatabaseConnection,
title: &str,
model_id: &str,
provider_id: &str,
system_prompt: Option<&str>,
) -> Result<Conversation> {
let id = gen_id();
let now = now_ts();
conversations::ActiveModel {
id: Set(id.clone()),
title: Set(title.to_string()),
model_id: Set(model_id.to_string()),
provider_id: Set(provider_id.to_string()),
system_prompt: Set(system_prompt.map(|s| s.to_string())),
message_count: Set(0),
is_pinned: Set(0),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
}
.insert(db)
.await?;
get_conversation(db, &id).await
}
pub async fn update_conversation(
db: &DatabaseConnection,
id: &str,
input: UpdateConversationInput,
) -> Result<Conversation> {
let row = conversations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Conversation {}", id)))?;
let now = now_ts();
let existing = conversation_from_entity(row.clone());
let title = input.title.unwrap_or(existing.title);
let provider_id = input.provider_id.unwrap_or(existing.provider_id);
let model_id = input.model_id.unwrap_or(existing.model_id);
let is_pinned = input.is_pinned.unwrap_or(existing.is_pinned);
let is_archived = input.is_archived.unwrap_or(existing.is_archived);
let mut am: conversations::ActiveModel = row.into();
am.title = Set(title);
am.provider_id = Set(provider_id);
am.model_id = Set(model_id);
am.is_pinned = Set(if is_pinned { 1 } else { 0 });
am.is_archived = Set(if is_archived { 1 } else { 0 });
if let Some(ref sp) = input.system_prompt {
am.system_prompt = Set(if sp.is_empty() {
None
} else {
Some(sp.clone())
});
}
if let Some(temperature) = input.temperature {
am.temperature = Set(temperature);
}
if let Some(max_tokens) = input.max_tokens {
am.max_tokens = Set(max_tokens);
}
if let Some(top_p) = input.top_p {
am.top_p = Set(top_p);
}
if let Some(frequency_penalty) = input.frequency_penalty {
am.frequency_penalty = Set(frequency_penalty);
}
if let Some(search_enabled) = input.search_enabled {
am.search_enabled = Set(if search_enabled { 1 } else { 0 });
}
if let Some(search_provider_id) = input.search_provider_id {
am.search_provider_id = Set(search_provider_id);
}
if let Some(thinking_budget) = input.thinking_budget {
am.thinking_budget = Set(thinking_budget);
}
if let Some(thinking_level) = input.thinking_level {
am.thinking_level = Set(thinking_level);
}
if let Some(enabled_mcp_server_ids) = input.enabled_mcp_server_ids {
am.enabled_mcp_server_ids = Set(stringify_string_list(&enabled_mcp_server_ids));
}
if let Some(enabled_knowledge_base_ids) = input.enabled_knowledge_base_ids {
am.enabled_knowledge_base_ids = Set(stringify_string_list(&enabled_knowledge_base_ids));
}
if let Some(enabled_memory_namespace_ids) = input.enabled_memory_namespace_ids {
am.enabled_memory_namespace_ids = Set(stringify_string_list(&enabled_memory_namespace_ids));
}
if let Some(context_compression) = input.context_compression {
am.context_compression = Set(if context_compression { 1 } else { 0 });
}
if let Some(category_id) = input.category_id {
am.category_id = Set(category_id);
}
if let Some(parent_conversation_id) = input.parent_conversation_id {
am.parent_conversation_id = Set(parent_conversation_id);
}
if let Some(mode) = input.mode {
am.mode = Set(mode);
}
am.updated_at = Set(now);
am.update(db).await?;
get_conversation(db, id).await
}
pub async fn update_conversation_title(
db: &DatabaseConnection,
id: &str,
title: &str,
) -> Result<()> {
if let Some(row) = conversations::Entity::find_by_id(id).one(db).await? {
let mut am: conversations::ActiveModel = row.into();
am.title = Set(title.to_string());
am.updated_at = Set(now_ts());
am.update(db).await?;
}
Ok(())
}
pub async fn toggle_pin(db: &DatabaseConnection, id: &str) -> Result<Conversation> {
let row = conversations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Conversation {}", id)))?;
let new_pinned = if row.is_pinned != 0 { 0 } else { 1 };
let now = now_ts();
let mut am: conversations::ActiveModel = row.into();
am.is_pinned = Set(new_pinned);
am.updated_at = Set(now);
am.update(db).await?;
get_conversation(db, id).await
}
pub async fn toggle_archive(db: &DatabaseConnection, id: &str) -> Result<Conversation> {
let row = conversations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Conversation {}", id)))?;
let new_archived = if row.is_archived != 0 { 0 } else { 1 };
let now = now_ts();
let mut am: conversations::ActiveModel = row.into();
am.is_archived = Set(new_archived);
am.updated_at = Set(now);
am.update(db).await?;
get_conversation(db, id).await
}
pub async fn delete_conversation(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = conversations::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("Conversation {}", id)));
}
Ok(())
}
/// Branch a conversation: copy settings + messages up to `until_message_id`.
/// If `as_child` is true, the new conversation is nested under the source (or its parent).
pub async fn branch_conversation(
db: &DatabaseConnection,
conversation_id: &str,
until_message_id: &str,
as_child: bool,
custom_title: Option<&str>,
) -> Result<Conversation> {
// 1. Load source conversation
let source = conversations::Entity::find_by_id(conversation_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Conversation {}", conversation_id)))?;
// 2. Load all active messages ordered by created_at
let all_msgs = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::IsActive.eq(1))
.order_by_asc(messages::Column::CreatedAt)
.all(db)
.await?;
// 3. Build the branch candidate list. Normal branches target an active
// message. Multi-model cards may target an inactive assistant version, in
// which case the selected version replaces its active sibling for the
// branch snapshot.
let candidate_msgs: Vec<messages::Model> = if let Some(target_idx) = all_msgs
.iter()
.position(|m| m.id == until_message_id)
{
all_msgs[..=target_idx].to_vec()
} else {
let target = messages::Entity::find_by_id(until_message_id)
.one(db)
.await?
.ok_or_else(|| {
BoosApiError::NotFound(format!("Message {} in conversation", until_message_id))
})?;
if target.conversation_id != conversation_id {
return Err(BoosApiError::NotFound(format!(
"Message {} in conversation {}",
until_message_id, conversation_id
)));
}
let parent_message_id = target.parent_message_id.clone().ok_or_else(|| {
BoosApiError::NotFound(format!("Message {} in conversation", until_message_id))
})?;
if target.role != "assistant" {
return Err(BoosApiError::NotFound(format!(
"Message {} in conversation",
until_message_id
)));
}
let parent_idx = all_msgs
.iter()
.position(|m| m.id == parent_message_id)
.ok_or_else(|| {
BoosApiError::NotFound(format!("Message {} in conversation", until_message_id))
})?;
let mut selected_branch = all_msgs[..=parent_idx].to_vec();
selected_branch.push(target);
selected_branch
};
// 4. Find last context-clear marker to determine effective start
let start_idx = candidate_msgs
.iter()
.rposition(|m| {
m.role == "system"
&& (m.content == "<!-- context-clear -->"
|| m.content == "<!-- context-compressed -->")
})
.map(|idx| idx + 1) // skip the marker itself
.unwrap_or(0);
let effective_msgs = &candidate_msgs[start_idx..];
// 5. Create new conversation with copied settings
let new_id = gen_id();
let now = now_ts();
let branch_title = custom_title
.map(|t| t.to_string())
.unwrap_or_else(|| source.title.clone());
// Determine parent_conversation_id
let parent_id = if as_child {
// If source already has a parent, new branch is a sibling (same parent)
// Otherwise, source becomes the parent
Some(
source
.parent_conversation_id
.clone()
.unwrap_or_else(|| source.id.clone()),
)
} else {
None
};
conversations::ActiveModel {
id: Set(new_id.clone()),
title: Set(branch_title),
model_id: Set(source.model_id.clone()),
provider_id: Set(source.provider_id.clone()),
system_prompt: Set(source.system_prompt.clone()),
temperature: Set(source.temperature),
max_tokens: Set(source.max_tokens),
top_p: Set(source.top_p),
frequency_penalty: Set(source.frequency_penalty),
search_enabled: Set(source.search_enabled),
search_provider_id: Set(source.search_provider_id.clone()),
thinking_budget: Set(source.thinking_budget),
thinking_level: Set(source.thinking_level.clone()),
enabled_mcp_server_ids: Set(source.enabled_mcp_server_ids.clone()),
enabled_knowledge_base_ids: Set(source.enabled_knowledge_base_ids.clone()),
enabled_memory_namespace_ids: Set(source.enabled_memory_namespace_ids.clone()),
message_count: Set(effective_msgs.len() as i32),
is_pinned: Set(0),
is_archived: Set(0),
context_compression: Set(source.context_compression),
category_id: Set(source.category_id.clone()),
parent_conversation_id: Set(parent_id),
research_mode: Set(source.research_mode),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
}
.insert(db)
.await?;
// 6. Copy messages — assign new IDs and remap parent_message_id references
let mut id_map = std::collections::HashMap::new();
for msg in effective_msgs {
let new_msg_id = gen_id();
id_map.insert(msg.id.clone(), new_msg_id.clone());
let new_parent = msg
.parent_message_id
.as_ref()
.and_then(|pid| id_map.get(pid))
.cloned();
messages::ActiveModel {
id: Set(new_msg_id),
conversation_id: Set(new_id.clone()),
role: Set(msg.role.clone()),
content: Set(msg.content.clone()),
provider_id: Set(msg.provider_id.clone()),
model_id: Set(msg.model_id.clone()),
token_count: Set(msg.token_count),
prompt_tokens: Set(msg.prompt_tokens),
completion_tokens: Set(msg.completion_tokens),
attachments: Set(msg.attachments.clone()),
thinking: Set(msg.thinking.clone()),
created_at: Set(msg.created_at),
parent_message_id: Set(new_parent),
version_index: Set(msg.version_index),
is_active: Set(1),
tool_calls_json: Set(msg.tool_calls_json.clone()),
tool_call_id: Set(msg.tool_call_id.clone()),
status: Set(msg.status.clone()),
tokens_per_second: Set(msg.tokens_per_second),
first_token_latency_ms: Set(msg.first_token_latency_ms),
..Default::default()
}
.insert(db)
.await?;
}
get_conversation(db, &new_id).await
}
pub async fn search_conversations(
db: &DatabaseConnection,
query: &str,
) -> Result<Vec<ConversationSearchResult>> {
#[derive(Debug, FromQueryResult)]
struct FtsRow {
conversation_id: String,
preview: String,
}
let fts_rows = FtsRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT m.conversation_id, snippet(messages_fts, 0, '', '', '...', 32) as preview \
FROM messages_fts \
JOIN messages m ON m.rowid = messages_fts.rowid \
WHERE messages_fts MATCH ? \
GROUP BY m.conversation_id \
ORDER BY rank",
[query.into()],
))
.all(db)
.await?;
let mut results = Vec::with_capacity(fts_rows.len());
for fts in fts_rows {
if let Ok(conv) = get_conversation(db, &fts.conversation_id).await {
results.push(ConversationSearchResult {
conversation: conv,
matched_message_preview: Some(fts.preview),
});
}
}
Ok(results)
}
pub async fn increment_message_count(db: &DatabaseConnection, conversation_id: &str) -> Result<()> {
db.execute(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"UPDATE conversations SET message_count = message_count + 1, updated_at = ? WHERE id = ?",
[now_ts().into(), conversation_id.into()],
))
.await?;
Ok(())
}
pub async fn decrement_message_count(db: &DatabaseConnection, conversation_id: &str) -> Result<()> {
db.execute(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"UPDATE conversations SET message_count = MAX(0, message_count - 1), updated_at = ? WHERE id = ?",
[now_ts().into(), conversation_id.into()],
))
.await?;
Ok(())
}
// ── Conversation summaries ──────────────────────────────────────────────
fn summary_from_entity(m: conversation_summaries::Model) -> ConversationSummary {
ConversationSummary {
id: m.id,
conversation_id: m.conversation_id,
summary_text: m.summary_text,
compressed_until_message_id: m.compressed_until_message_id,
token_count: m.token_count.map(|v| v as u32),
model_used: m.model_used,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
pub async fn get_summary(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Option<ConversationSummary>> {
let row = conversation_summaries::Entity::find()
.filter(conversation_summaries::Column::ConversationId.eq(conversation_id))
.order_by_desc(conversation_summaries::Column::UpdatedAt)
.one(db)
.await?;
Ok(row.map(summary_from_entity))
}
pub async fn upsert_summary(
db: &DatabaseConnection,
conversation_id: &str,
summary_text: &str,
compressed_until_message_id: Option<&str>,
token_count: Option<u32>,
model_used: Option<&str>,
) -> Result<ConversationSummary> {
let now = now_ts();
let existing = conversation_summaries::Entity::find()
.filter(conversation_summaries::Column::ConversationId.eq(conversation_id))
.one(db)
.await?;
match existing {
Some(row) => {
let mut am: conversation_summaries::ActiveModel = row.into();
am.summary_text = Set(summary_text.to_string());
am.compressed_until_message_id =
Set(compressed_until_message_id.map(|s| s.to_string()));
am.token_count = Set(token_count.map(|v| v as i64));
am.model_used = Set(model_used.map(|s| s.to_string()));
am.updated_at = Set(now);
am.update(db).await?;
}
None => {
let id = gen_id();
conversation_summaries::ActiveModel {
id: Set(id),
conversation_id: Set(conversation_id.to_string()),
summary_text: Set(summary_text.to_string()),
compressed_until_message_id: Set(
compressed_until_message_id.map(|s| s.to_string()),
),
token_count: Set(token_count.map(|v| v as i64)),
model_used: Set(model_used.map(|s| s.to_string())),
created_at: Set(now),
updated_at: Set(now),
}
.insert(db)
.await?;
}
}
get_summary(db, conversation_id).await?.ok_or_else(|| {
BoosApiError::Database(sea_orm::DbErr::Custom(
"Failed to read back upserted summary".into(),
))
})
}
pub async fn delete_summary(db: &DatabaseConnection, conversation_id: &str) -> Result<()> {
conversation_summaries::Entity::delete_many()
.filter(conversation_summaries::Column::ConversationId.eq(conversation_id))
.exec(db)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::create_test_pool;
use crate::repo::message;
use crate::types::MessageRole;
#[tokio::test]
async fn branch_conversation_from_inactive_assistant_version_uses_selected_version() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = create_conversation(db, "Branch Source", "model-a", "provider-a", None)
.await
.unwrap();
let user = message::create_message(
db,
&conv.id,
MessageRole::User,
"Compare answers",
&[],
None,
0,
)
.await
.unwrap();
let active = message::create_message(
db,
&conv.id,
MessageRole::Assistant,
"Active answer",
&[],
Some(&user.id),
0,
)
.await
.unwrap();
let inactive = message::create_message(
db,
&conv.id,
MessageRole::Assistant,
"Inactive answer",
&[],
Some(&user.id),
1,
)
.await
.unwrap();
message::set_active_version(db, &conv.id, &user.id, &active.id)
.await
.unwrap();
let branched = branch_conversation(
db,
&conv.id,
&inactive.id,
false,
Some("Branched from inactive"),
)
.await
.unwrap();
let branched_messages = message::list_messages(db, &branched.id).await.unwrap();
assert_eq!(branched_messages.len(), 2);
assert_eq!(branched_messages[0].content, "Compare answers");
assert_eq!(branched_messages[1].content, "Inactive answer");
assert!(branched_messages[1].is_active);
}
}
@@ -0,0 +1,70 @@
use sea_orm::*;
use crate::entity::conversation_branches;
use crate::error::{BoosApiError, Result};
use crate::types::ConversationBranch;
use crate::utils::gen_id;
fn model_to_branch(m: conversation_branches::Model) -> ConversationBranch {
ConversationBranch {
id: m.id,
conversation_id: m.conversation_id,
parent_message_id: m.parent_message_id,
branch_label: m.branch_label,
branch_index: m.branch_index,
compared_message_ids_json: m.compared_message_ids_json,
created_at: m.created_at,
}
}
pub async fn list_branches(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<ConversationBranch>> {
let models = conversation_branches::Entity::find()
.filter(conversation_branches::Column::ConversationId.eq(conversation_id))
.order_by_asc(conversation_branches::Column::BranchIndex)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_branch).collect())
}
pub async fn get_branch(db: &DatabaseConnection, id: &str) -> Result<ConversationBranch> {
let model = conversation_branches::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ConversationBranch {}", id)))?;
Ok(model_to_branch(model))
}
pub async fn create_branch(
db: &DatabaseConnection,
conversation_id: &str,
parent_message_id: &str,
label: &str,
) -> Result<ConversationBranch> {
let id = gen_id();
let count = conversation_branches::Entity::find()
.filter(conversation_branches::Column::ConversationId.eq(conversation_id))
.count(db)
.await? as i32;
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let am = conversation_branches::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(conversation_id.to_string()),
parent_message_id: Set(parent_message_id.to_string()),
branch_label: Set(label.to_string()),
branch_index: Set(count),
compared_message_ids_json: Set(None),
created_at: Set(now),
};
am.insert(db).await?;
get_branch(db, &id).await
}
@@ -0,0 +1,184 @@
use sea_orm::*;
use sea_orm::prelude::Expr;
use crate::entity::conversation_categories;
use crate::error::{BoosApiError, Result};
use crate::types::{
ConversationCategory, CreateConversationCategoryInput, UpdateConversationCategoryInput,
};
use crate::utils::{gen_id, now_ts};
fn category_from_entity(m: conversation_categories::Model) -> ConversationCategory {
ConversationCategory {
id: m.id,
name: m.name,
icon_type: m.icon_type,
icon_value: m.icon_value,
system_prompt: m.system_prompt,
default_provider_id: m.default_provider_id,
default_model_id: m.default_model_id,
default_temperature: m.default_temperature,
default_max_tokens: m.default_max_tokens,
default_top_p: m.default_top_p,
default_frequency_penalty: m.default_frequency_penalty,
sort_order: m.sort_order,
is_collapsed: m.is_collapsed != 0,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
pub async fn list_conversation_categories(
db: &DatabaseConnection,
) -> Result<Vec<ConversationCategory>> {
let rows = conversation_categories::Entity::find()
.order_by_asc(conversation_categories::Column::SortOrder)
.all(db)
.await?;
Ok(rows.into_iter().map(category_from_entity).collect())
}
pub async fn create_conversation_category(
db: &DatabaseConnection,
input: CreateConversationCategoryInput,
) -> Result<ConversationCategory> {
let id = gen_id();
let now = now_ts();
let max_order = conversation_categories::Entity::find()
.select_only()
.column_as(conversation_categories::Column::SortOrder.max(), "m")
.into_tuple::<Option<i32>>()
.one(db)
.await?
.flatten();
let sort_order = max_order.unwrap_or(-1) + 1;
let am = conversation_categories::ActiveModel {
id: Set(id),
name: Set(input.name),
icon_type: Set(input.icon_type),
icon_value: Set(input.icon_value),
system_prompt: Set(input.system_prompt),
default_provider_id: Set(input.default_provider_id),
default_model_id: Set(input.default_model_id),
default_temperature: Set(input.default_temperature),
default_max_tokens: Set(input.default_max_tokens),
default_top_p: Set(input.default_top_p),
default_frequency_penalty: Set(input.default_frequency_penalty),
sort_order: Set(sort_order),
is_collapsed: Set(1),
created_at: Set(now),
updated_at: Set(now),
};
let row = am.insert(db).await?;
Ok(category_from_entity(row))
}
pub async fn update_conversation_category(
db: &DatabaseConnection,
id: &str,
input: UpdateConversationCategoryInput,
) -> Result<ConversationCategory> {
let row = conversation_categories::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ConversationCategory {}", id)))?;
let mut am: conversation_categories::ActiveModel = row.into();
if let Some(name) = input.name {
am.name = Set(name);
}
if let Some(icon_type) = input.icon_type {
am.icon_type = Set(icon_type);
}
if let Some(icon_value) = input.icon_value {
am.icon_value = Set(icon_value);
}
if let Some(system_prompt) = input.system_prompt {
am.system_prompt = Set(system_prompt);
}
if let Some(default_provider_id) = input.default_provider_id {
am.default_provider_id = Set(default_provider_id);
}
if let Some(default_model_id) = input.default_model_id {
am.default_model_id = Set(default_model_id);
}
if let Some(default_temperature) = input.default_temperature {
am.default_temperature = Set(default_temperature);
}
if let Some(default_max_tokens) = input.default_max_tokens {
am.default_max_tokens = Set(default_max_tokens);
}
if let Some(default_top_p) = input.default_top_p {
am.default_top_p = Set(default_top_p);
}
if let Some(default_frequency_penalty) = input.default_frequency_penalty {
am.default_frequency_penalty = Set(default_frequency_penalty);
}
am.updated_at = Set(now_ts());
am.update(db).await?;
let updated = conversation_categories::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ConversationCategory {}", id)))?;
Ok(category_from_entity(updated))
}
pub async fn delete_conversation_category(db: &DatabaseConnection, id: &str) -> Result<()> {
// Unset category_id on conversations that belong to this category
use crate::entity::conversations;
conversations::Entity::update_many()
.col_expr(
conversations::Column::CategoryId,
Expr::value(Option::<String>::None),
)
.filter(conversations::Column::CategoryId.eq(id))
.exec(db)
.await?;
conversation_categories::Entity::delete_by_id(id)
.exec(db)
.await?;
Ok(())
}
pub async fn reorder_conversation_categories(
db: &DatabaseConnection,
category_ids: &[String],
) -> Result<()> {
let now = now_ts();
for (i, id) in category_ids.iter().enumerate() {
conversation_categories::Entity::update_many()
.col_expr(
conversation_categories::Column::SortOrder,
Expr::value(i as i32),
)
.col_expr(
conversation_categories::Column::UpdatedAt,
Expr::value(now),
)
.filter(conversation_categories::Column::Id.eq(id))
.exec(db)
.await?;
}
Ok(())
}
pub async fn set_conversation_category_collapsed(
db: &DatabaseConnection,
id: &str,
collapsed: bool,
) -> Result<()> {
let row = conversation_categories::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ConversationCategory {}", id)))?;
let mut am: conversation_categories::ActiveModel = row.into();
am.is_collapsed = Set(if collapsed { 1 } else { 0 });
am.updated_at = Set(now_ts());
am.update(db).await?;
Ok(())
}
@@ -0,0 +1,443 @@
use sea_orm::*;
use serde::{Deserialize, Serialize};
use crate::entity::{drawing_generations, drawing_images};
use crate::error::{BoosApiError, Result};
use crate::repo::stored_file::StoredFile;
use crate::utils::{gen_id, now_ts};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingImage {
pub id: String,
pub generation_id: String,
pub stored_file_id: String,
pub storage_path: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub revised_prompt: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingGeneration {
pub id: String,
pub parent_generation_id: Option<String>,
pub provider_id: String,
pub key_id: String,
pub model_id: String,
pub api_kind: String,
pub action: String,
pub prompt: String,
pub parameters_json: String,
pub reference_file_ids_json: String,
pub source_image_ids_json: String,
pub mask_file_id: Option<String>,
pub status: String,
pub error_message: Option<String>,
pub response_id: Option<String>,
pub usage_json: Option<String>,
pub created_at: i64,
pub completed_at: Option<i64>,
pub images: Vec<DrawingImage>,
#[serde(default)]
pub reference_files: Vec<StoredFile>,
#[serde(default)]
pub source_images: Vec<DrawingImage>,
pub mask_file: Option<StoredFile>,
}
#[derive(Debug, Clone)]
pub struct NewDrawingGeneration {
pub parent_generation_id: Option<String>,
pub provider_id: String,
pub key_id: String,
pub model_id: String,
pub action: String,
pub prompt: String,
pub parameters_json: String,
pub reference_file_ids_json: String,
pub source_image_ids_json: String,
pub mask_file_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewDrawingImage {
pub generation_id: String,
pub stored_file_id: String,
pub storage_path: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub revised_prompt: Option<String>,
}
fn image_from_entity(model: drawing_images::Model) -> DrawingImage {
DrawingImage {
id: model.id,
generation_id: model.generation_id,
stored_file_id: model.stored_file_id,
storage_path: model.storage_path,
mime_type: model.mime_type,
width: model.width,
height: model.height,
revised_prompt: model.revised_prompt,
created_at: model.created_at,
}
}
fn generation_from_entity(
model: drawing_generations::Model,
images: Vec<DrawingImage>,
reference_files: Vec<StoredFile>,
source_images: Vec<DrawingImage>,
mask_file: Option<StoredFile>,
) -> DrawingGeneration {
DrawingGeneration {
id: model.id,
parent_generation_id: model.parent_generation_id,
provider_id: model.provider_id,
key_id: model.key_id,
model_id: model.model_id,
api_kind: model.api_kind,
action: model.action,
prompt: model.prompt,
parameters_json: model.parameters_json,
reference_file_ids_json: model.reference_file_ids_json,
source_image_ids_json: model.source_image_ids_json,
mask_file_id: model.mask_file_id,
status: model.status,
error_message: model.error_message,
response_id: model.response_id,
usage_json: model.usage_json,
created_at: model.created_at,
completed_at: model.completed_at,
images,
reference_files,
source_images,
mask_file,
}
}
fn parse_id_list(raw: &str) -> Vec<String> {
serde_json::from_str::<Vec<String>>(raw).unwrap_or_default()
}
async fn list_reference_files(
db: &DatabaseConnection,
ids_json: &str,
) -> Result<Vec<StoredFile>> {
let mut files = Vec::new();
for id in parse_id_list(ids_json) {
if let Ok(file) = crate::repo::stored_file::get_stored_file(db, &id).await {
files.push(file);
}
}
Ok(files)
}
async fn list_source_images(
db: &DatabaseConnection,
ids_json: &str,
) -> Result<Vec<DrawingImage>> {
let mut images = Vec::new();
for id in parse_id_list(ids_json) {
if let Ok(image) = get_image(db, &id).await {
images.push(image);
}
}
Ok(images)
}
async fn get_mask_file(
db: &DatabaseConnection,
mask_file_id: Option<&str>,
) -> Result<Option<StoredFile>> {
let Some(mask_file_id) = mask_file_id else {
return Ok(None);
};
Ok(crate::repo::stored_file::get_stored_file(db, mask_file_id)
.await
.ok())
}
async fn hydrate_generation(
db: &DatabaseConnection,
row: drawing_generations::Model,
) -> Result<DrawingGeneration> {
let id = row.id.clone();
let images = list_images_for_generation(db, &id).await?;
let reference_files = list_reference_files(db, &row.reference_file_ids_json).await?;
let source_images = list_source_images(db, &row.source_image_ids_json).await?;
let mask_file = get_mask_file(db, row.mask_file_id.as_deref()).await?;
Ok(generation_from_entity(
row,
images,
reference_files,
source_images,
mask_file,
))
}
pub async fn create_generation(
db: &DatabaseConnection,
input: NewDrawingGeneration,
) -> Result<DrawingGeneration> {
let id = gen_id();
let now = now_ts();
drawing_generations::ActiveModel {
id: Set(id.clone()),
parent_generation_id: Set(input.parent_generation_id),
provider_id: Set(input.provider_id),
key_id: Set(input.key_id),
model_id: Set(input.model_id),
api_kind: Set("image_api".to_string()),
action: Set(input.action),
prompt: Set(input.prompt),
parameters_json: Set(input.parameters_json),
reference_file_ids_json: Set(input.reference_file_ids_json),
source_image_ids_json: Set(input.source_image_ids_json),
mask_file_id: Set(input.mask_file_id),
status: Set("running".to_string()),
error_message: Set(None),
response_id: Set(None),
usage_json: Set(None),
created_at: Set(now),
completed_at: Set(None),
}
.insert(db)
.await?;
get_generation(db, &id).await
}
pub async fn add_image(db: &DatabaseConnection, input: NewDrawingImage) -> Result<DrawingImage> {
let id = gen_id();
let now = now_ts();
drawing_images::ActiveModel {
id: Set(id.clone()),
generation_id: Set(input.generation_id),
stored_file_id: Set(input.stored_file_id),
storage_path: Set(input.storage_path),
mime_type: Set(input.mime_type),
width: Set(input.width),
height: Set(input.height),
revised_prompt: Set(input.revised_prompt),
created_at: Set(now),
}
.insert(db)
.await?;
get_image(db, &id).await
}
pub async fn mark_generation_succeeded(
db: &DatabaseConnection,
id: &str,
response_id: Option<String>,
usage_json: Option<String>,
) -> Result<()> {
let row = drawing_generations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("DrawingGeneration {}", id)))?;
let mut am: drawing_generations::ActiveModel = row.into();
am.status = Set("succeeded".to_string());
am.error_message = Set(None);
am.response_id = Set(response_id);
am.usage_json = Set(usage_json);
am.completed_at = Set(Some(now_ts()));
am.update(db).await?;
Ok(())
}
pub async fn mark_generation_failed(
db: &DatabaseConnection,
id: &str,
error_message: String,
) -> Result<()> {
let row = drawing_generations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("DrawingGeneration {}", id)))?;
let mut am: drawing_generations::ActiveModel = row.into();
am.status = Set("failed".to_string());
am.error_message = Set(Some(error_message));
am.completed_at = Set(Some(now_ts()));
am.update(db).await?;
Ok(())
}
pub async fn get_image(db: &DatabaseConnection, id: &str) -> Result<DrawingImage> {
let row = drawing_images::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("DrawingImage {}", id)))?;
Ok(image_from_entity(row))
}
pub async fn get_generation(db: &DatabaseConnection, id: &str) -> Result<DrawingGeneration> {
let row = drawing_generations::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("DrawingGeneration {}", id)))?;
hydrate_generation(db, row).await
}
pub async fn list_images_for_generation(
db: &DatabaseConnection,
generation_id: &str,
) -> Result<Vec<DrawingImage>> {
let rows = drawing_images::Entity::find()
.filter(drawing_images::Column::GenerationId.eq(generation_id))
.order_by_asc(drawing_images::Column::CreatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(image_from_entity).collect())
}
pub async fn list_generations(
db: &DatabaseConnection,
limit: u64,
cursor: Option<i64>,
) -> Result<Vec<DrawingGeneration>> {
let mut query = drawing_generations::Entity::find()
.order_by_desc(drawing_generations::Column::CreatedAt)
.limit(limit.min(100));
if let Some(cursor) = cursor {
query = query.filter(drawing_generations::Column::CreatedAt.lt(cursor));
}
let rows = query.all(db).await?;
let mut result = Vec::with_capacity(rows.len());
for row in rows {
result.push(hydrate_generation(db, row).await?);
}
Ok(result)
}
pub async fn delete_generation(db: &DatabaseConnection, id: &str) -> Result<()> {
drawing_images::Entity::delete_many()
.filter(drawing_images::Column::GenerationId.eq(id))
.exec(db)
.await?;
let result = drawing_generations::Entity::delete_by_id(id)
.exec(db)
.await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("DrawingGeneration {}", id)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::create_test_pool;
use crate::repo::stored_file;
#[tokio::test]
async fn hydrates_reference_source_and_mask_files() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let source_file = stored_file::create_stored_file(
db,
&gen_id(),
"source-hash",
"source.png",
"image/png",
1024,
"images/source.png",
None,
)
.await
.unwrap();
let ref_file = stored_file::create_stored_file(
db,
&gen_id(),
"ref-hash",
"ref.png",
"image/png",
2048,
"images/ref.png",
None,
)
.await
.unwrap();
let mask_file = stored_file::create_stored_file(
db,
&gen_id(),
"mask-hash",
"mask.png",
"image/png",
512,
"images/mask.png",
None,
)
.await
.unwrap();
let source_generation = create_generation(
db,
NewDrawingGeneration {
parent_generation_id: None,
provider_id: "provider-1".into(),
key_id: "key-1".into(),
model_id: "gpt-image-2".into(),
action: "generate".into(),
prompt: "source".into(),
parameters_json: "{}".into(),
reference_file_ids_json: "[]".into(),
source_image_ids_json: "[]".into(),
mask_file_id: None,
},
)
.await
.unwrap();
let source_image = add_image(
db,
NewDrawingImage {
generation_id: source_generation.id.clone(),
stored_file_id: source_file.id.clone(),
storage_path: source_file.storage_path.clone(),
mime_type: source_file.mime_type.clone(),
width: Some(1024),
height: Some(1024),
revised_prompt: None,
},
)
.await
.unwrap();
let edit_generation = create_generation(
db,
NewDrawingGeneration {
parent_generation_id: Some(source_generation.id),
provider_id: "provider-1".into(),
key_id: "key-1".into(),
model_id: "gpt-image-2".into(),
action: "mask_edit".into(),
prompt: "edit".into(),
parameters_json: "{}".into(),
reference_file_ids_json: serde_json::to_string(&vec![ref_file.id.clone()]).unwrap(),
source_image_ids_json: serde_json::to_string(&vec![source_image.id.clone()])
.unwrap(),
mask_file_id: Some(mask_file.id.clone()),
},
)
.await
.unwrap();
let fetched = get_generation(db, &edit_generation.id).await.unwrap();
assert_eq!(fetched.reference_files.len(), 1);
assert_eq!(fetched.reference_files[0].id, ref_file.id);
assert_eq!(fetched.source_images.len(), 1);
assert_eq!(fetched.source_images[0].id, source_image.id);
assert_eq!(
fetched.mask_file.as_ref().map(|file| file.id.as_str()),
Some(mask_file.id.as_str())
);
}
}
@@ -0,0 +1,354 @@
use sea_orm::*;
use crate::crypto;
use crate::entity::{gateway_keys, gateway_usage};
use crate::error::{BoosApiError, Result};
use crate::types::*;
use crate::utils::now_ts;
fn key_from_entity(m: gateway_keys::Model) -> GatewayKey {
GatewayKey {
id: m.id,
name: m.name,
key_hash: m.key_hash,
key_prefix: m.key_prefix,
enabled: m.enabled != 0,
created_at: m.created_at,
last_used_at: m.last_used_at,
has_encrypted_key: m.encrypted_key.is_some(),
}
}
// --- Gateway Key CRUD ---
pub async fn list_gateway_keys(db: &DatabaseConnection) -> Result<Vec<GatewayKey>> {
let rows = gateway_keys::Entity::find()
.order_by_desc(gateway_keys::Column::CreatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(key_from_entity).collect())
}
pub async fn create_gateway_key(
db: &DatabaseConnection,
name: &str,
master_key: Option<&[u8; 32]>,
) -> Result<CreateGatewayKeyResult> {
crate::repo::gateway_key::create_gateway_key(db, name, master_key).await
}
pub async fn delete_gateway_key(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = gateway_keys::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("GatewayKey {}", id)));
}
Ok(())
}
pub async fn toggle_gateway_key(db: &DatabaseConnection, id: &str, enabled: bool) -> Result<()> {
let row = gateway_keys::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("GatewayKey {}", id)))?;
let mut am: gateway_keys::ActiveModel = row.into();
am.enabled = Set(if enabled { 1 } else { 0 });
am.update(db).await?;
Ok(())
}
/// Verify an incoming API key against stored hashes. Returns the matching key if found.
pub async fn verify_key(db: &DatabaseConnection, plain_key: &str) -> Result<GatewayKey> {
let key_hash = crypto::sha256_hash(plain_key);
let row = gateway_keys::Entity::find()
.filter(gateway_keys::Column::KeyHash.eq(&key_hash))
.filter(gateway_keys::Column::Enabled.eq(1))
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound("Invalid or disabled gateway key".to_string()))?;
Ok(key_from_entity(row))
}
pub async fn update_last_used(db: &DatabaseConnection, id: &str) -> Result<()> {
if let Some(row) = gateway_keys::Entity::find_by_id(id).one(db).await? {
let mut am: gateway_keys::ActiveModel = row.into();
am.last_used_at = Set(Some(now_ts()));
am.update(db).await?;
}
Ok(())
}
// --- Gateway Usage ---
pub async fn record_usage(
db: &DatabaseConnection,
key_id: &str,
provider_id: &str,
model_id: Option<&str>,
request_tokens: u64,
response_tokens: u64,
) -> Result<()> {
gateway_usage::ActiveModel {
key_id: Set(key_id.to_string()),
provider_id: Set(provider_id.to_string()),
model_id: Set(model_id.map(|s| s.to_string())),
request_tokens: Set(request_tokens as i32),
response_tokens: Set(response_tokens as i32),
created_at: Set(now_ts()),
..Default::default()
}
.insert(db)
.await?;
Ok(())
}
pub async fn get_gateway_metrics(db: &DatabaseConnection) -> Result<GatewayMetrics> {
let today_start = chrono::Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp();
#[derive(Debug, FromQueryResult)]
struct MetricsRow {
total_requests: i64,
total_tokens: i64,
request_tokens: i64,
response_tokens: i64,
}
let all = MetricsRow::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) as total_requests, \
COALESCE(SUM(request_tokens + response_tokens), 0) as total_tokens, \
COALESCE(SUM(request_tokens), 0) as request_tokens, \
COALESCE(SUM(response_tokens), 0) as response_tokens \
FROM gateway_usage",
))
.one(db)
.await?
.unwrap_or(MetricsRow {
total_requests: 0,
total_tokens: 0,
request_tokens: 0,
response_tokens: 0,
});
let today = MetricsRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) as total_requests, \
COALESCE(SUM(request_tokens + response_tokens), 0) as total_tokens, \
COALESCE(SUM(request_tokens), 0) as request_tokens, \
COALESCE(SUM(response_tokens), 0) as response_tokens \
FROM gateway_usage WHERE created_at >= ?",
[today_start.into()],
))
.one(db)
.await?
.unwrap_or(MetricsRow {
total_requests: 0,
total_tokens: 0,
request_tokens: 0,
response_tokens: 0,
});
Ok(GatewayMetrics {
total_requests: all.total_requests as u64,
total_tokens: all.total_tokens as u64,
total_request_tokens: all.request_tokens as u64,
total_response_tokens: all.response_tokens as u64,
active_connections: 0,
today_requests: today.total_requests as u64,
today_tokens: today.total_tokens as u64,
today_request_tokens: today.request_tokens as u64,
today_response_tokens: today.response_tokens as u64,
})
}
pub async fn get_usage_by_key(db: &DatabaseConnection) -> Result<Vec<UsageByKey>> {
#[derive(Debug, FromQueryResult)]
struct Row {
key_id: String,
key_name: String,
request_count: i64,
token_count: i64,
request_tokens: i64,
response_tokens: i64,
}
let rows = Row::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT gu.key_id, gk.name as key_name, \
COUNT(*) as request_count, \
COALESCE(SUM(gu.request_tokens + gu.response_tokens), 0) as token_count, \
COALESCE(SUM(gu.request_tokens), 0) as request_tokens, \
COALESCE(SUM(gu.response_tokens), 0) as response_tokens \
FROM gateway_usage gu \
JOIN gateway_keys gk ON gk.id = gu.key_id \
GROUP BY gu.key_id \
ORDER BY token_count DESC",
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByKey {
key_id: r.key_id,
key_name: r.key_name,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
request_tokens: r.request_tokens as u64,
response_tokens: r.response_tokens as u64,
})
.collect())
}
pub async fn get_usage_by_provider(db: &DatabaseConnection) -> Result<Vec<UsageByProvider>> {
#[derive(Debug, FromQueryResult)]
struct Row {
provider_id: String,
provider_name: String,
request_count: i64,
token_count: i64,
request_tokens: i64,
response_tokens: i64,
}
let rows = Row::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT gu.provider_id, COALESCE(p.name, gu.provider_id) as provider_name, \
COUNT(*) as request_count, \
COALESCE(SUM(gu.request_tokens + gu.response_tokens), 0) as token_count, \
COALESCE(SUM(gu.request_tokens), 0) as request_tokens, \
COALESCE(SUM(gu.response_tokens), 0) as response_tokens \
FROM gateway_usage gu \
LEFT JOIN providers p ON p.id = gu.provider_id \
GROUP BY gu.provider_id \
ORDER BY token_count DESC",
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByProvider {
provider_id: r.provider_id,
provider_name: r.provider_name,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
request_tokens: r.request_tokens as u64,
response_tokens: r.response_tokens as u64,
})
.collect())
}
pub async fn get_usage_by_day(db: &DatabaseConnection, days: u32) -> Result<Vec<UsageByDay>> {
#[derive(Debug, FromQueryResult)]
struct Row {
date: String,
request_count: i64,
token_count: i64,
request_tokens: i64,
response_tokens: i64,
}
let since = now_ts() - (days as i64 * 86400);
let rows = Row::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT date(created_at, 'unixepoch') as date, \
COUNT(*) as request_count, \
COALESCE(SUM(request_tokens + response_tokens), 0) as token_count, \
COALESCE(SUM(request_tokens), 0) as request_tokens, \
COALESCE(SUM(response_tokens), 0) as response_tokens \
FROM gateway_usage \
WHERE created_at >= ? \
GROUP BY date \
ORDER BY date ASC",
[since.into()],
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByDay {
date: r.date,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
request_tokens: r.request_tokens as u64,
response_tokens: r.response_tokens as u64,
})
.collect())
}
pub async fn get_connected_programs(db: &DatabaseConnection) -> Result<Vec<ConnectedProgram>> {
let today_start = chrono::Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp();
let active_threshold = now_ts() - 300;
#[derive(Debug, FromQueryResult)]
struct Row {
key_id: String,
key_name: String,
key_prefix: String,
today_requests: i64,
today_tokens: i64,
today_request_tokens: i64,
today_response_tokens: i64,
last_active_at: Option<i64>,
}
let rows = Row::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT gk.id as key_id, gk.name as key_name, gk.key_prefix, \
COALESCE(t.cnt, 0) as today_requests, \
COALESCE(t.tokens, 0) as today_tokens, \
COALESCE(t.request_tokens, 0) as today_request_tokens, \
COALESCE(t.response_tokens, 0) as today_response_tokens, \
gk.last_used_at as last_active_at \
FROM gateway_keys gk \
LEFT JOIN ( \
SELECT key_id, COUNT(*) as cnt, \
SUM(request_tokens + response_tokens) as tokens, \
SUM(request_tokens) as request_tokens, \
SUM(response_tokens) as response_tokens \
FROM gateway_usage WHERE created_at >= ? \
GROUP BY key_id \
) t ON t.key_id = gk.id \
WHERE gk.enabled = 1 \
ORDER BY gk.created_at DESC",
[today_start.into()],
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| ConnectedProgram {
key_id: r.key_id,
key_name: r.key_name,
key_prefix: r.key_prefix,
today_requests: r.today_requests as u64,
today_tokens: r.today_tokens as u64,
today_request_tokens: r.today_request_tokens as u64,
today_response_tokens: r.today_response_tokens as u64,
last_active_at: r.last_active_at,
is_active: r
.last_active_at
.map(|t| t >= active_threshold)
.unwrap_or(false),
})
.collect())
}
@@ -0,0 +1,39 @@
use crate::error::Result;
use crate::types::GatewayDiagnostic;
use crate::utils::gen_id;
use sea_orm::DatabaseConnection;
pub async fn get_diagnostics(_db: &DatabaseConnection) -> Result<Vec<GatewayDiagnostic>> {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
Ok(vec![
GatewayDiagnostic {
id: gen_id(),
category: "port".to_string(),
status: "ok".to_string(),
message: "Gateway port 8080 is available".to_string(),
created_at: now.clone(),
},
GatewayDiagnostic {
id: gen_id(),
category: "auth".to_string(),
status: "ok".to_string(),
message: "API key authentication is configured".to_string(),
created_at: now.clone(),
},
GatewayDiagnostic {
id: gen_id(),
category: "proxy".to_string(),
status: "ok".to_string(),
message: "Proxy settings are valid".to_string(),
created_at: now.clone(),
},
GatewayDiagnostic {
id: gen_id(),
category: "provider_latency".to_string(),
status: "warning".to_string(),
message: "No providers configured for latency testing".to_string(),
created_at: now,
},
])
}
@@ -0,0 +1,136 @@
use sea_orm::*;
use crate::crypto;
use crate::entity::gateway_keys;
use crate::error::{BoosApiError, Result};
use crate::types::{CreateGatewayKeyResult, GatewayKey};
use crate::utils::{gen_id, now_ts};
fn key_from_entity(m: gateway_keys::Model) -> GatewayKey {
GatewayKey {
id: m.id,
name: m.name,
key_hash: m.key_hash,
key_prefix: m.key_prefix,
enabled: m.enabled != 0,
created_at: m.created_at,
last_used_at: m.last_used_at,
has_encrypted_key: m.encrypted_key.is_some(),
}
}
pub async fn list_gateway_keys(db: &DatabaseConnection) -> Result<Vec<GatewayKey>> {
let rows = gateway_keys::Entity::find()
.order_by_desc(gateway_keys::Column::CreatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(key_from_entity).collect())
}
pub async fn create_gateway_key(
db: &DatabaseConnection,
name: &str,
master_key: Option<&[u8; 32]>,
) -> Result<CreateGatewayKeyResult> {
let id = gen_id();
let now = now_ts();
let plain_key = crypto::generate_gateway_key();
let key_hash = crypto::sha256_hash(&plain_key);
let key_prefix = crypto::key_prefix(&plain_key);
let encrypted_key = master_key
.map(|mk| crypto::encrypt_key(&plain_key, mk))
.transpose()?;
gateway_keys::ActiveModel {
id: Set(id.clone()),
name: Set(name.to_string()),
key_hash: Set(key_hash),
key_prefix: Set(key_prefix),
enabled: Set(1),
created_at: Set(now),
last_used_at: Set(None),
encrypted_key: Set(encrypted_key),
}
.insert(db)
.await?;
let row = gateway_keys::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("GatewayKey {}", id)))?;
Ok(CreateGatewayKeyResult {
gateway_key: key_from_entity(row),
plain_key,
})
}
pub async fn delete_gateway_key(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = gateway_keys::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("GatewayKey {}", id)));
}
Ok(())
}
pub async fn toggle_gateway_key(db: &DatabaseConnection, id: &str) -> Result<GatewayKey> {
let row = gateway_keys::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("GatewayKey {}", id)))?;
let new_enabled = if row.enabled != 0 { 0 } else { 1 };
let mut am: gateway_keys::ActiveModel = row.into();
am.enabled = Set(new_enabled);
am.update(db).await?;
let row = gateway_keys::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("GatewayKey {}", id)))?;
Ok(key_from_entity(row))
}
/// Verify an incoming API key against stored hashes. Returns the matching key if found.
pub async fn verify_key(db: &DatabaseConnection, plain_key: &str) -> Result<GatewayKey> {
let key_hash = crypto::sha256_hash(plain_key);
let row = gateway_keys::Entity::find()
.filter(gateway_keys::Column::KeyHash.eq(&key_hash))
.filter(gateway_keys::Column::Enabled.eq(1))
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound("Invalid or disabled gateway key".to_string()))?;
Ok(key_from_entity(row))
}
pub async fn update_last_used(db: &DatabaseConnection, id: &str) -> Result<()> {
if let Some(row) = gateway_keys::Entity::find_by_id(id).one(db).await? {
let mut am: gateway_keys::ActiveModel = row.into();
am.last_used_at = Set(Some(now_ts()));
am.update(db).await?;
}
Ok(())
}
/// Decrypt and return the plain key for a given key ID.
pub async fn get_plain_key(
db: &DatabaseConnection,
master_key: &[u8; 32],
key_id: &str,
) -> Result<String> {
let row = gateway_keys::Entity::find_by_id(key_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("GatewayKey {}", key_id)))?;
let encrypted = row.encrypted_key.ok_or_else(|| {
BoosApiError::Crypto("Key was created before encrypted storage was available".to_string())
})?;
crypto::decrypt_key(&encrypted, master_key)
}
@@ -0,0 +1,76 @@
use crate::entity::gateway_request_logs;
use crate::error::Result;
use crate::types::GatewayRequestLog;
use crate::utils::{gen_id, now_ts};
use sea_orm::*;
pub async fn record_request_log(
db: &DatabaseConnection,
key_id: &str,
key_name: &str,
method: &str,
path: &str,
model: Option<&str>,
provider_id: Option<&str>,
status_code: i32,
duration_ms: i32,
request_tokens: i32,
response_tokens: i32,
error_message: Option<&str>,
) -> Result<()> {
gateway_request_logs::ActiveModel {
id: Set(gen_id()),
key_id: Set(key_id.to_string()),
key_name: Set(key_name.to_string()),
method: Set(method.to_string()),
path: Set(path.to_string()),
model: Set(model.map(|s| s.to_string())),
provider_id: Set(provider_id.map(|s| s.to_string())),
status_code: Set(status_code),
duration_ms: Set(duration_ms),
request_tokens: Set(request_tokens),
response_tokens: Set(response_tokens),
error_message: Set(error_message.map(|s| s.to_string())),
created_at: Set(now_ts()),
}
.insert(db)
.await?;
Ok(())
}
pub async fn list_request_logs(
db: &DatabaseConnection,
limit: u64,
offset: u64,
) -> Result<Vec<GatewayRequestLog>> {
let rows = gateway_request_logs::Entity::find()
.order_by_desc(gateway_request_logs::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| GatewayRequestLog {
id: r.id,
key_id: r.key_id,
key_name: r.key_name,
method: r.method,
path: r.path,
model: r.model,
provider_id: r.provider_id,
status_code: r.status_code,
duration_ms: r.duration_ms,
request_tokens: r.request_tokens,
response_tokens: r.response_tokens,
error_message: r.error_message,
created_at: r.created_at,
})
.collect())
}
pub async fn clear_request_logs(db: &DatabaseConnection) -> Result<u64> {
let result = gateway_request_logs::Entity::delete_many().exec(db).await?;
Ok(result.rows_affected)
}
@@ -0,0 +1,228 @@
use sea_orm::*;
use crate::entity::gateway_usage;
use crate::error::Result;
use crate::types::*;
use crate::utils::now_ts;
pub async fn record_usage(
db: &DatabaseConnection,
key_id: &str,
provider_id: &str,
model_id: Option<&str>,
request_tokens: u64,
response_tokens: u64,
) -> Result<()> {
gateway_usage::ActiveModel {
key_id: Set(key_id.to_string()),
provider_id: Set(provider_id.to_string()),
model_id: Set(model_id.map(|s| s.to_string())),
request_tokens: Set(request_tokens as i32),
response_tokens: Set(response_tokens as i32),
created_at: Set(now_ts()),
..Default::default()
}
.insert(db)
.await?;
Ok(())
}
pub async fn get_metrics(db: &DatabaseConnection) -> Result<GatewayMetrics> {
let today_start = chrono::Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp();
#[derive(Debug, FromQueryResult)]
struct MetricsRow {
total_requests: i64,
total_tokens: i64,
}
let all = MetricsRow::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) as total_requests, \
COALESCE(SUM(request_tokens + response_tokens), 0) as total_tokens \
FROM gateway_usage",
))
.one(db)
.await?
.unwrap_or(MetricsRow { total_requests: 0, total_tokens: 0 });
let today = MetricsRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) as total_requests, \
COALESCE(SUM(request_tokens + response_tokens), 0) as total_tokens \
FROM gateway_usage WHERE created_at >= ?",
[today_start.into()],
))
.one(db)
.await?
.unwrap_or(MetricsRow { total_requests: 0, total_tokens: 0 });
Ok(GatewayMetrics {
total_requests: all.total_requests as u64,
total_tokens: all.total_tokens as u64,
active_connections: 0, // runtime state, not tracked in DB
today_requests: today.total_requests as u64,
today_tokens: today.total_tokens as u64,
})
}
pub async fn get_usage_by_key(db: &DatabaseConnection) -> Result<Vec<UsageByKey>> {
#[derive(Debug, FromQueryResult)]
struct Row {
key_id: String,
key_name: String,
request_count: i64,
token_count: i64,
}
let rows = Row::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT gu.key_id, gk.name as key_name, \
COUNT(*) as request_count, \
COALESCE(SUM(gu.request_tokens + gu.response_tokens), 0) as token_count \
FROM gateway_usage gu \
JOIN gateway_keys gk ON gk.id = gu.key_id \
GROUP BY gu.key_id \
ORDER BY token_count DESC",
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByKey {
key_id: r.key_id,
key_name: r.key_name,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
})
.collect())
}
pub async fn get_usage_by_provider(db: &DatabaseConnection) -> Result<Vec<UsageByProvider>> {
#[derive(Debug, FromQueryResult)]
struct Row {
provider_id: String,
provider_name: String,
request_count: i64,
token_count: i64,
}
let rows = Row::find_by_statement(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT gu.provider_id, COALESCE(p.name, gu.provider_id) as provider_name, \
COUNT(*) as request_count, \
COALESCE(SUM(gu.request_tokens + gu.response_tokens), 0) as token_count \
FROM gateway_usage gu \
LEFT JOIN providers p ON p.id = gu.provider_id \
GROUP BY gu.provider_id \
ORDER BY token_count DESC",
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByProvider {
provider_id: r.provider_id,
provider_name: r.provider_name,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
})
.collect())
}
pub async fn get_usage_by_day(db: &DatabaseConnection, days: u32) -> Result<Vec<UsageByDay>> {
#[derive(Debug, FromQueryResult)]
struct Row {
date: String,
request_count: i64,
token_count: i64,
}
let since = now_ts() - (days as i64 * 86400);
let rows = Row::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT date(created_at, 'unixepoch') as date, \
COUNT(*) as request_count, \
COALESCE(SUM(request_tokens + response_tokens), 0) as token_count \
FROM gateway_usage \
WHERE created_at >= ? \
GROUP BY date \
ORDER BY date ASC",
[since.into()],
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| UsageByDay {
date: r.date,
request_count: r.request_count as u64,
token_count: r.token_count as u64,
})
.collect())
}
pub async fn get_connected_programs(db: &DatabaseConnection) -> Result<Vec<ConnectedProgram>> {
let today_start = chrono::Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp();
let active_threshold = now_ts() - 300; // active within last 5 minutes
#[derive(Debug, FromQueryResult)]
struct Row {
key_id: String,
key_name: String,
key_prefix: String,
today_requests: i64,
today_tokens: i64,
last_active_at: Option<i64>,
}
let rows = Row::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"SELECT gk.id as key_id, gk.name as key_name, gk.key_prefix, \
COALESCE(t.cnt, 0) as today_requests, \
COALESCE(t.tokens, 0) as today_tokens, \
gk.last_used_at as last_active_at \
FROM gateway_keys gk \
LEFT JOIN ( \
SELECT key_id, COUNT(*) as cnt, \
SUM(request_tokens + response_tokens) as tokens \
FROM gateway_usage WHERE created_at >= ? \
GROUP BY key_id \
) t ON t.key_id = gk.id \
WHERE gk.enabled = 1 \
ORDER BY gk.created_at DESC",
[today_start.into()],
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| ConnectedProgram {
key_id: r.key_id,
key_name: r.key_name,
key_prefix: r.key_prefix,
today_requests: r.today_requests as u64,
today_tokens: r.today_tokens as u64,
last_active_at: r.last_active_at,
is_active: r
.last_active_at
.map(|t| t >= active_threshold)
.unwrap_or(false),
})
.collect())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
use sea_orm::sea_query::Expr;
use sea_orm::*;
use crate::entity::{knowledge_bases, knowledge_documents};
use crate::error::{BoosApiError, Result};
use crate::types::{
CreateKnowledgeBaseInput, KnowledgeBase, KnowledgeDocument, UpdateKnowledgeBaseInput,
};
use crate::utils::gen_id;
fn model_to_kb(m: knowledge_bases::Model) -> KnowledgeBase {
KnowledgeBase {
id: m.id,
name: m.name,
description: m.description,
embedding_provider: m.embedding_provider,
enabled: m.enabled != 0,
icon_type: m.icon_type,
icon_value: m.icon_value,
sort_order: m.sort_order,
embedding_dimensions: m.embedding_dimensions,
retrieval_threshold: m.retrieval_threshold,
retrieval_top_k: m.retrieval_top_k,
rerank_provider: m.rerank_provider,
rerank_candidate_k: m.rerank_candidate_k,
chunk_size: m.chunk_size,
chunk_overlap: m.chunk_overlap,
separator: m.separator,
index_concurrency: m.index_concurrency,
index_interval_ms: m.index_interval_ms,
}
}
fn model_to_doc(m: knowledge_documents::Model) -> KnowledgeDocument {
KnowledgeDocument {
id: m.id,
knowledge_base_id: m.knowledge_base_id,
title: m.title,
source_path: m.source_path,
mime_type: m.mime_type,
size_bytes: m.size_bytes,
indexing_status: m.indexing_status,
doc_type: m.doc_type,
index_error: m.index_error,
}
}
pub async fn list_knowledge_bases(db: &DatabaseConnection) -> Result<Vec<KnowledgeBase>> {
let models = knowledge_bases::Entity::find()
.order_by_asc(knowledge_bases::Column::SortOrder)
.order_by_asc(knowledge_bases::Column::Name)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_kb).collect())
}
pub async fn get_knowledge_base(db: &DatabaseConnection, id: &str) -> Result<KnowledgeBase> {
let model = knowledge_bases::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("KnowledgeBase {}", id)))?;
Ok(model_to_kb(model))
}
pub async fn create_knowledge_base(
db: &DatabaseConnection,
input: CreateKnowledgeBaseInput,
) -> Result<KnowledgeBase> {
let id = gen_id();
let am = knowledge_bases::ActiveModel {
id: Set(id.clone()),
name: Set(input.name),
description: Set(input.description),
embedding_provider: Set(input.embedding_provider),
enabled: Set(if input.enabled.unwrap_or(true) { 1 } else { 0 }),
icon_type: Set(None),
icon_value: Set(None),
sort_order: Set(0),
embedding_dimensions: Set(None),
retrieval_threshold: Set(None),
retrieval_top_k: Set(None),
rerank_provider: Set(None),
rerank_candidate_k: Set(None),
chunk_size: Set(None),
chunk_overlap: Set(None),
separator: Set(None),
index_concurrency: Set(None),
index_interval_ms: Set(None),
};
am.insert(db).await?;
get_knowledge_base(db, &id).await
}
pub async fn update_knowledge_base(
db: &DatabaseConnection,
id: &str,
input: UpdateKnowledgeBaseInput,
) -> Result<KnowledgeBase> {
let model = knowledge_bases::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("KnowledgeBase {}", id)))?;
let existing = model_to_kb(model.clone());
let mut am: knowledge_bases::ActiveModel = model.into();
am.name = Set(input.name.unwrap_or(existing.name));
am.description = Set(input.description.or(existing.description));
am.embedding_provider = Set(input.embedding_provider.or(existing.embedding_provider));
am.enabled = Set(if input.enabled.unwrap_or(existing.enabled) {
1
} else {
0
});
if input.update_icon {
am.icon_type = Set(input.icon_type);
am.icon_value = Set(input.icon_value);
}
if input.update_embedding_dimensions {
am.embedding_dimensions = Set(input.embedding_dimensions);
}
if input.update_retrieval_threshold {
am.retrieval_threshold = Set(input.retrieval_threshold);
}
if input.update_retrieval_top_k {
am.retrieval_top_k = Set(input.retrieval_top_k);
}
if input.update_rerank_provider {
am.rerank_provider = Set(input.rerank_provider);
}
if input.update_rerank_candidate_k {
am.rerank_candidate_k = Set(input.rerank_candidate_k);
}
if input.update_chunk_size {
am.chunk_size = Set(input.chunk_size);
}
if input.update_chunk_overlap {
am.chunk_overlap = Set(input.chunk_overlap);
}
if input.update_separator {
am.separator = Set(input.separator);
}
if input.update_index_concurrency {
am.index_concurrency = Set(input.index_concurrency);
}
if input.update_index_interval_ms {
am.index_interval_ms = Set(input.index_interval_ms);
}
am.update(db).await?;
get_knowledge_base(db, id).await
}
pub async fn reorder_knowledge_bases(db: &DatabaseConnection, base_ids: &[String]) -> Result<()> {
for (i, id) in base_ids.iter().enumerate() {
knowledge_bases::Entity::update_many()
.col_expr(knowledge_bases::Column::SortOrder, Expr::value(i as i32))
.filter(knowledge_bases::Column::Id.eq(id))
.exec(db)
.await?;
}
Ok(())
}
pub async fn delete_knowledge_base(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = knowledge_bases::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("KnowledgeBase {}", id)));
}
Ok(())
}
pub async fn list_documents(
db: &DatabaseConnection,
base_id: &str,
) -> Result<Vec<KnowledgeDocument>> {
let models = knowledge_documents::Entity::find()
.filter(knowledge_documents::Column::KnowledgeBaseId.eq(base_id))
.order_by_asc(knowledge_documents::Column::Title)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_doc).collect())
}
pub async fn add_document(
db: &DatabaseConnection,
knowledge_base_id: &str,
title: &str,
source_path: &str,
mime_type: &str,
doc_type: Option<&str>,
) -> Result<KnowledgeDocument> {
let id = gen_id();
// Read actual file size from disk
let file_size = std::fs::metadata(source_path)
.map(|m| m.len() as i64)
.unwrap_or(0);
let am = knowledge_documents::ActiveModel {
id: Set(id.clone()),
knowledge_base_id: Set(knowledge_base_id.to_string()),
title: Set(title.to_string()),
source_path: Set(source_path.to_string()),
mime_type: Set(mime_type.to_string()),
size_bytes: Set(file_size),
doc_type: Set(doc_type.unwrap_or("file").to_string()),
..Default::default()
};
am.insert(db).await?;
let model = knowledge_documents::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("KnowledgeDocument {}", id)))?;
Ok(model_to_doc(model))
}
pub async fn update_document_status(db: &DatabaseConnection, id: &str, status: &str) -> Result<()> {
update_document_status_with_error(db, id, status, None).await
}
pub async fn update_document_status_with_error(
db: &DatabaseConnection,
id: &str,
status: &str,
error: Option<&str>,
) -> Result<()> {
let mut am: knowledge_documents::ActiveModel = knowledge_documents::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("KnowledgeDocument {}", id)))?
.into();
am.indexing_status = Set(status.to_string());
am.index_error = Set(error.map(|e| e.to_string()));
am.update(db).await?;
Ok(())
}
pub async fn delete_document(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = knowledge_documents::Entity::delete_by_id(id)
.exec(db)
.await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("KnowledgeDocument {}", id)));
}
Ok(())
}
/// Batch lookup document titles by IDs. Returns a map of document_id -> title.
pub async fn get_document_titles(
db: &DatabaseConnection,
doc_ids: &[String],
) -> Result<std::collections::HashMap<String, String>> {
if doc_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let models = knowledge_documents::Entity::find()
.filter(knowledge_documents::Column::Id.is_in(doc_ids.iter().map(|s| s.as_str())))
.all(db)
.await?;
Ok(models.into_iter().map(|m| (m.id, m.title)).collect())
}
@@ -0,0 +1,515 @@
use sea_orm::*;
use crate::entity::{mcp_servers, tool_descriptors};
use crate::error::{BoosApiError, Result};
use crate::repo::settings;
use crate::types::{CreateMcpServerInput, McpServer, ToolDescriptor, UpdateMcpServerInput};
use crate::utils::gen_id;
// ── Builtin server definitions (not stored in DB) ───────────────────────
const BUILTIN_FETCH_ID: &str = "builtin-fetch";
const BUILTIN_SEARCH_FILE_ID: &str = "builtin-search-file";
struct BuiltinDef {
id: &'static str,
name: &'static str,
default_enabled: bool,
}
const BUILTIN_DEFS: &[BuiltinDef] = &[
BuiltinDef {
id: BUILTIN_FETCH_ID,
name: "@boosapi/fetch",
default_enabled: true,
},
BuiltinDef {
id: BUILTIN_SEARCH_FILE_ID,
name: "@boosapi/search-file",
default_enabled: false,
},
];
fn builtin_setting_key(name: &str) -> String {
format!("builtin_mcp:{name}:enabled")
}
fn make_builtin_server(def: &BuiltinDef, enabled: bool) -> McpServer {
McpServer {
id: def.id.to_string(),
name: def.name.to_string(),
transport: "builtin".to_string(),
command: None,
args_json: None,
endpoint: None,
env_json: None,
enabled,
permission_policy: "auto".to_string(),
source: "builtin".to_string(),
discover_timeout_secs: None,
execute_timeout_secs: None,
headers_json: None,
icon_type: None,
icon_value: None,
}
}
async fn get_builtin_enabled(db: &DatabaseConnection, name: &str, default: bool) -> bool {
match settings::get_setting(db, &builtin_setting_key(name)).await {
Ok(Some(v)) => v == "true",
_ => default,
}
}
/// Return all builtin servers with their persisted enabled state.
pub async fn list_builtin_servers(db: &DatabaseConnection) -> Vec<McpServer> {
let mut out = Vec::with_capacity(BUILTIN_DEFS.len());
for def in BUILTIN_DEFS {
let enabled = get_builtin_enabled(db, def.name, def.default_enabled).await;
out.push(make_builtin_server(def, enabled));
}
out
}
/// Check whether a server ID belongs to a builtin.
pub fn is_builtin_id(id: &str) -> bool {
BUILTIN_DEFS.iter().any(|d| d.id == id)
}
/// Toggle enabled state for a builtin server (persists to settings table).
pub async fn set_builtin_enabled(
db: &DatabaseConnection,
id: &str,
enabled: bool,
) -> Result<McpServer> {
let def = BUILTIN_DEFS
.iter()
.find(|d| d.id == id)
.ok_or_else(|| BoosApiError::NotFound(format!("Builtin server {id}")))?;
settings::set_setting(
db,
&builtin_setting_key(def.name),
if enabled { "true" } else { "false" },
)
.await?;
Ok(make_builtin_server(def, enabled))
}
/// Get a single builtin server by ID.
pub async fn get_builtin_server(db: &DatabaseConnection, id: &str) -> Result<McpServer> {
let def = BUILTIN_DEFS
.iter()
.find(|d| d.id == id)
.ok_or_else(|| BoosApiError::NotFound(format!("Builtin server {id}")))?;
let enabled = get_builtin_enabled(db, def.name, def.default_enabled).await;
Ok(make_builtin_server(def, enabled))
}
// ── DB-backed custom servers ────────────────────────────────────────────
fn model_to_mcp_server(m: mcp_servers::Model) -> McpServer {
McpServer {
id: m.id,
name: m.name,
transport: m.transport,
command: m.command,
args_json: m.args_json,
endpoint: m.endpoint,
env_json: m.env_json,
enabled: m.enabled != 0,
permission_policy: m.permission_policy,
source: m.source,
discover_timeout_secs: m.discover_timeout_secs,
execute_timeout_secs: m.execute_timeout_secs,
headers_json: m.headers_json,
icon_type: m.icon_type,
icon_value: m.icon_value,
}
}
pub async fn list_mcp_servers(db: &DatabaseConnection) -> Result<Vec<McpServer>> {
let mut servers = list_builtin_servers(db).await;
let custom_rows = mcp_servers::Entity::find()
.order_by_asc(mcp_servers::Column::Name)
.all(db)
.await?;
servers.extend(custom_rows.into_iter().map(model_to_mcp_server));
Ok(servers)
}
pub async fn get_mcp_server(db: &DatabaseConnection, id: &str) -> Result<McpServer> {
// Check builtins first
if is_builtin_id(id) {
return get_builtin_server(db, id).await;
}
let model = mcp_servers::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("McpServer {}", id)))?;
Ok(model_to_mcp_server(model))
}
pub async fn create_mcp_server(
db: &DatabaseConnection,
input: CreateMcpServerInput,
) -> Result<McpServer> {
let id = gen_id();
let args_json = input
.args
.as_ref()
.map(|a| serde_json::to_string(a).unwrap_or_default());
let env_json = input
.env
.as_ref()
.map(|e| serde_json::to_string(e).unwrap_or_default());
mcp_servers::ActiveModel {
id: Set(id.clone()),
name: Set(input.name),
transport: Set(input.transport),
command: Set(input.command),
args_json: Set(args_json),
endpoint: Set(input.endpoint),
env_json: Set(env_json),
enabled: Set(if input.enabled.unwrap_or(true) { 1 } else { 0 }),
permission_policy: Set(input.permission_policy.unwrap_or_else(|| "ask".to_string())),
source: Set(input.source.unwrap_or_else(|| "custom".to_string())),
discover_timeout_secs: Set(input.discover_timeout_secs),
execute_timeout_secs: Set(input.execute_timeout_secs),
headers_json: Set(input.headers_json),
icon_type: Set(input.icon_type),
icon_value: Set(input.icon_value),
}
.insert(db)
.await?;
get_mcp_server(db, &id).await
}
pub async fn update_mcp_server(
db: &DatabaseConnection,
id: &str,
input: UpdateMcpServerInput,
) -> Result<McpServer> {
// Builtin servers only support toggling enabled
if is_builtin_id(id) {
let enabled = input.enabled.unwrap_or(true);
return set_builtin_enabled(db, id, enabled).await;
}
let model = mcp_servers::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("McpServer {}", id)))?;
let mut am: mcp_servers::ActiveModel = model.into();
if let Some(name) = input.name {
if !name.is_empty() {
am.name = Set(name);
}
}
if let Some(transport) = input.transport {
if !transport.is_empty() {
am.transport = Set(transport);
}
}
if let Some(command) = input.command {
am.command = Set(command.filter(|v| !v.is_empty()));
}
if let Some(args) = input.args {
am.args_json = Set(args.map(|a| serde_json::to_string(&a).unwrap_or_default()));
}
if let Some(endpoint) = input.endpoint {
am.endpoint = Set(endpoint.filter(|v| !v.is_empty()));
}
if let Some(env) = input.env {
am.env_json = Set(env.map(|e| serde_json::to_string(&e).unwrap_or_default()));
}
if let Some(enabled) = input.enabled {
am.enabled = Set(if enabled { 1 } else { 0 });
}
if let Some(permission_policy) = input.permission_policy {
am.permission_policy = Set(permission_policy);
}
if let Some(discover_timeout_secs) = input.discover_timeout_secs {
am.discover_timeout_secs = Set(Some(discover_timeout_secs));
}
if let Some(execute_timeout_secs) = input.execute_timeout_secs {
am.execute_timeout_secs = Set(Some(execute_timeout_secs));
}
if let Some(headers_json) = input.headers_json {
am.headers_json = Set(headers_json.filter(|v| !v.is_empty()));
}
if let Some(icon_type) = input.icon_type {
am.icon_type = Set(icon_type.filter(|v| !v.is_empty()));
}
if let Some(icon_value) = input.icon_value {
am.icon_value = Set(icon_value.filter(|v| !v.is_empty()));
}
am.update(db).await?;
get_mcp_server(db, id).await
}
pub async fn delete_mcp_server(db: &DatabaseConnection, id: &str) -> Result<()> {
// Prevent deletion of built-in MCP servers
let server = get_mcp_server(db, id).await?;
if server.source == "builtin" {
return Err(BoosApiError::Gateway(
"Cannot delete built-in MCP server".to_string(),
));
}
let result = mcp_servers::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("McpServer {}", id)));
}
Ok(())
}
/// Return tool descriptors for a given MCP server.
pub async fn list_tools_for_server(
db: &DatabaseConnection,
server_id: &str,
) -> Result<Vec<ToolDescriptor>> {
// Builtins: resolve name from definition, no DB lookup needed
if let Some(def) = BUILTIN_DEFS.iter().find(|d| d.id == server_id) {
return Ok(builtin_tools(server_id, def.name));
}
// Custom servers: read from tool_descriptors table
let rows = tool_descriptors::Entity::find()
.filter(tool_descriptors::Column::ServerId.eq(server_id))
.order_by_asc(tool_descriptors::Column::Name)
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|m| ToolDescriptor {
id: m.id,
server_id: m.server_id,
name: m.name,
description: m.description,
input_schema_json: m.input_schema_json,
})
.collect())
}
/// Save discovered tool descriptors for a server (replaces existing).
pub async fn save_tool_descriptors(
db: &DatabaseConnection,
server_id: &str,
tools: Vec<crate::mcp_client::DiscoveredTool>,
) -> Result<Vec<ToolDescriptor>> {
// Delete existing tools for this server
tool_descriptors::Entity::delete_many()
.filter(tool_descriptors::Column::ServerId.eq(server_id))
.exec(db)
.await?;
// Insert new tools
let mut result = Vec::with_capacity(tools.len());
for tool in tools {
let id = gen_id();
let input_schema_json = tool
.input_schema
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_default());
tool_descriptors::ActiveModel {
id: Set(id.clone()),
server_id: Set(server_id.to_string()),
name: Set(tool.name.clone()),
description: Set(tool.description.clone()),
input_schema_json: Set(input_schema_json.clone()),
}
.insert(db)
.await?;
result.push(ToolDescriptor {
id,
server_id: server_id.to_string(),
name: tool.name,
description: tool.description,
input_schema_json,
});
}
Ok(result)
}
fn builtin_tools(server_id: &str, server_name: &str) -> Vec<ToolDescriptor> {
match server_name {
"@boosapi/fetch" => vec![
ToolDescriptor {
id: format!("{server_id}-fetch-url"),
server_id: server_id.to_string(),
name: "fetch_url".into(),
description: Some("Fetch a URL and return its content".into()),
input_schema_json: Some(r#"{"type":"object","properties":{"url":{"type":"string","description":"URL to fetch"},"max_length":{"type":"integer","description":"Maximum content length"}},"required":["url"]}"#.into()),
},
ToolDescriptor {
id: format!("{server_id}-fetch-markdown"),
server_id: server_id.to_string(),
name: "fetch_markdown".into(),
description: Some("Fetch a URL and convert the content to markdown".into()),
input_schema_json: Some(r#"{"type":"object","properties":{"url":{"type":"string","description":"URL to fetch"}},"required":["url"]}"#.into()),
},
],
"@boosapi/search-file" => vec![
ToolDescriptor {
id: format!("{server_id}-read-file"),
server_id: server_id.to_string(),
name: "read_file".into(),
description: Some("Read the contents of a file".into()),
input_schema_json: Some(r#"{"type":"object","properties":{"path":{"type":"string","description":"File path to read"}},"required":["path"]}"#.into()),
},
ToolDescriptor {
id: format!("{server_id}-list-directory"),
server_id: server_id.to_string(),
name: "list_directory".into(),
description: Some("List files and directories in a given path".into()),
input_schema_json: Some(r#"{"type":"object","properties":{"path":{"type":"string","description":"Directory path to list"}},"required":["path"]}"#.into()),
},
ToolDescriptor {
id: format!("{server_id}-search-files"),
server_id: server_id.to_string(),
name: "search_files".into(),
description: Some("Search for files matching a pattern".into()),
input_schema_json: Some(r#"{"type":"object","properties":{"path":{"type":"string","description":"Base directory"},"pattern":{"type":"string","description":"Search pattern"}},"required":["path","pattern"]}"#.into()),
},
],
_ => vec![],
}
}
/// Find which MCP server owns a given tool, searching across the provided server IDs.
pub async fn find_server_for_tool(
db: &DatabaseConnection,
tool_name: &str,
server_ids: &[String],
) -> Result<Option<(McpServer, ToolDescriptor)>> {
for server_id in server_ids {
if let Ok(tools) = list_tools_for_server(db, server_id).await {
if let Some(td) = tools.into_iter().find(|t| t.name == tool_name) {
if let Ok(server) = get_mcp_server(db, server_id).await {
return Ok(Some((server, td)));
}
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::create_test_pool;
use crate::types::UpdateMcpServerInput;
fn remote_server_input(headers_json: Option<String>) -> CreateMcpServerInput {
CreateMcpServerInput {
name: "Remote MCP".into(),
transport: "http".into(),
command: None,
args: None,
endpoint: Some("https://example.com/mcp".into()),
env: None,
enabled: Some(false),
permission_policy: Some("ask".into()),
source: Some("custom".into()),
discover_timeout_secs: Some(30),
execute_timeout_secs: Some(30),
headers_json,
icon_type: None,
icon_value: None,
}
}
#[tokio::test]
async fn update_mcp_server_preserves_headers_when_field_is_omitted() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let created = create_mcp_server(
db,
remote_server_input(Some(r#"{"Authorization":"Bearer old"}"#.into())),
)
.await
.unwrap();
let updated = update_mcp_server(
db,
&created.id,
UpdateMcpServerInput {
name: Some("Renamed MCP".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "Renamed MCP");
assert_eq!(
updated.headers_json.as_deref(),
Some(r#"{"Authorization":"Bearer old"}"#)
);
}
#[tokio::test]
async fn update_mcp_server_replaces_headers_when_value_is_provided() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let created = create_mcp_server(
db,
remote_server_input(Some(r#"{"Authorization":"Bearer old"}"#.into())),
)
.await
.unwrap();
let updated = update_mcp_server(
db,
&created.id,
UpdateMcpServerInput {
headers_json: Some(Some(r#"{"Authorization":"Bearer new"}"#.into())),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(
updated.headers_json.as_deref(),
Some(r#"{"Authorization":"Bearer new"}"#)
);
}
#[tokio::test]
async fn update_mcp_server_clears_headers_when_null_is_provided() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let created = create_mcp_server(
db,
remote_server_input(Some(r#"{"Authorization":"Bearer old"}"#.into())),
)
.await
.unwrap();
update_mcp_server(
db,
&created.id,
UpdateMcpServerInput {
headers_json: Some(None),
..Default::default()
},
)
.await
.unwrap();
let fetched = get_mcp_server(db, &created.id).await.unwrap();
assert_eq!(fetched.headers_json, None);
}
}
@@ -0,0 +1,229 @@
use sea_orm::sea_query::Expr;
use sea_orm::*;
use crate::entity::{memory_items, memory_namespaces};
use crate::error::{BoosApiError, Result};
use crate::types::{
CreateMemoryItemInput, CreateMemoryNamespaceInput, MemoryItem, MemoryNamespace,
UpdateMemoryItemInput, UpdateMemoryNamespaceInput,
};
use crate::utils::gen_id;
fn model_to_namespace(m: memory_namespaces::Model) -> MemoryNamespace {
MemoryNamespace {
id: m.id,
name: m.name,
scope: m.scope,
embedding_provider: m.embedding_provider,
embedding_dimensions: m.embedding_dimensions,
retrieval_threshold: m.retrieval_threshold,
retrieval_top_k: m.retrieval_top_k,
icon_type: m.icon_type,
icon_value: m.icon_value,
sort_order: m.sort_order,
}
}
fn model_to_item(m: memory_items::Model) -> MemoryItem {
MemoryItem {
id: m.id,
namespace_id: m.namespace_id,
title: m.title,
content: m.content,
source: m.source,
index_status: m.index_status,
index_error: m.index_error,
updated_at: m.updated_at,
}
}
pub async fn list_namespaces(db: &DatabaseConnection) -> Result<Vec<MemoryNamespace>> {
let models = memory_namespaces::Entity::find()
.order_by_asc(memory_namespaces::Column::SortOrder)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_namespace).collect())
}
pub async fn get_namespace(db: &DatabaseConnection, id: &str) -> Result<MemoryNamespace> {
let model = memory_namespaces::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryNamespace {}", id)))?;
Ok(model_to_namespace(model))
}
pub async fn create_namespace(
db: &DatabaseConnection,
input: CreateMemoryNamespaceInput,
) -> Result<MemoryNamespace> {
let id = gen_id();
let am = memory_namespaces::ActiveModel {
id: Set(id.clone()),
name: Set(input.name),
scope: Set(input.scope),
embedding_provider: Set(input.embedding_provider),
embedding_dimensions: Set(input.embedding_dimensions),
retrieval_threshold: Set(input.retrieval_threshold),
retrieval_top_k: Set(input.retrieval_top_k),
icon_type: Set(input.icon_type),
icon_value: Set(input.icon_value),
sort_order: Set(0),
};
am.insert(db).await?;
get_namespace(db, &id).await
}
pub async fn delete_namespace(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = memory_namespaces::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("MemoryNamespace {}", id)));
}
Ok(())
}
pub async fn update_namespace(
db: &DatabaseConnection,
id: &str,
input: UpdateMemoryNamespaceInput,
) -> Result<MemoryNamespace> {
let model = memory_namespaces::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryNamespace {}", id)))?;
let mut am: memory_namespaces::ActiveModel = model.clone().into();
if let Some(name) = input.name {
am.name = Set(name);
}
if input.update_embedding_provider {
am.embedding_provider = Set(input.embedding_provider);
}
if input.update_embedding_dimensions {
am.embedding_dimensions = Set(input.embedding_dimensions);
}
if input.update_retrieval_threshold {
am.retrieval_threshold = Set(input.retrieval_threshold);
}
if input.update_retrieval_top_k {
am.retrieval_top_k = Set(input.retrieval_top_k);
}
if input.update_icon {
am.icon_type = Set(input.icon_type);
am.icon_value = Set(input.icon_value);
}
if let Some(sort_order) = input.sort_order {
am.sort_order = Set(sort_order);
}
am.update(db).await?;
get_namespace(db, id).await
}
pub async fn reorder_namespaces(db: &DatabaseConnection, namespace_ids: &[String]) -> Result<()> {
for (i, id) in namespace_ids.iter().enumerate() {
memory_namespaces::Entity::update_many()
.col_expr(memory_namespaces::Column::SortOrder, Expr::value(i as i32))
.filter(memory_namespaces::Column::Id.eq(id))
.exec(db)
.await?;
}
Ok(())
}
pub async fn list_items(db: &DatabaseConnection, namespace_id: &str) -> Result<Vec<MemoryItem>> {
let models = memory_items::Entity::find()
.filter(memory_items::Column::NamespaceId.eq(namespace_id))
.order_by_desc(memory_items::Column::UpdatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_item).collect())
}
pub async fn add_item(db: &DatabaseConnection, input: CreateMemoryItemInput) -> Result<MemoryItem> {
let id = gen_id();
let source = input.source.unwrap_or_else(|| "manual".to_string());
let am = memory_items::ActiveModel {
id: Set(id.clone()),
namespace_id: Set(input.namespace_id),
title: Set(input.title),
content: Set(input.content),
source: Set(source),
..Default::default()
};
am.insert(db).await?;
let model = memory_items::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryItem {}", id)))?;
Ok(model_to_item(model))
}
pub async fn delete_item(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = memory_items::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("MemoryItem {}", id)));
}
Ok(())
}
pub async fn update_item(
db: &DatabaseConnection,
id: &str,
input: UpdateMemoryItemInput,
) -> Result<MemoryItem> {
let model = memory_items::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryItem {}", id)))?;
let mut am: memory_items::ActiveModel = model.into();
if let Some(title) = input.title {
am.title = Set(title);
}
if let Some(content) = input.content {
am.content = Set(content);
// Content changed — reset index status to pending
am.index_status = Set("pending".to_string());
}
am.updated_at = Set(chrono::Utc::now().to_rfc3339());
am.update(db).await?;
let updated = memory_items::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryItem {}", id)))?;
Ok(model_to_item(updated))
}
pub async fn update_item_index_status(
db: &DatabaseConnection,
id: &str,
status: &str,
error: Option<&str>,
) -> Result<()> {
let model = memory_items::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("MemoryItem {}", id)))?;
let mut am: memory_items::ActiveModel = model.into();
am.index_status = Set(status.to_string());
am.index_error = Set(error.map(|e| e.to_string()));
am.update(db).await?;
Ok(())
}
@@ -0,0 +1,855 @@
use sea_orm::sea_query::Expr;
use sea_orm::*;
use std::collections::HashSet;
use crate::entity::messages;
use crate::error::{BoosApiError, Result};
use crate::types::{Attachment, ConversationStats, Message, MessagePage, MessageRole};
use crate::utils::{gen_id, now_ts};
fn parse_role(s: &str) -> MessageRole {
match s {
"system" => MessageRole::System,
"user" => MessageRole::User,
"tool" => MessageRole::Tool,
_ => MessageRole::Assistant,
}
}
fn role_str(role: &MessageRole) -> &'static str {
match role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
}
}
fn parse_attachment_list(raw: &str) -> Result<Vec<Attachment>> {
serde_json::from_str(raw)
.map_err(|e| BoosApiError::Validation(format!("Invalid message attachments JSON: {e}")))
}
fn stringify_attachment_list(attachments: &[Attachment]) -> Result<String> {
serde_json::to_string(attachments).map_err(|e| {
BoosApiError::Validation(format!("Failed to serialize message attachments: {e}"))
})
}
const STALE_PARTIAL_ASSISTANT_ERROR: &str = "BoosApi was closed while this response was running. This stale response has been marked as failed.";
fn message_from_entity(m: messages::Model) -> Result<Message> {
Ok(Message {
id: m.id,
conversation_id: m.conversation_id,
role: parse_role(&m.role),
content: m.content,
provider_id: m.provider_id,
model_id: m.model_id,
token_count: m.token_count.map(|v| v as u32),
prompt_tokens: m.prompt_tokens.map(|v| v as u32),
completion_tokens: m.completion_tokens.map(|v| v as u32),
attachments: parse_attachment_list(&m.attachments)?,
thinking: m.thinking,
created_at: m.created_at,
parent_message_id: m.parent_message_id,
version_index: m.version_index,
is_active: m.is_active == 1,
tool_calls_json: m.tool_calls_json,
tool_call_id: m.tool_call_id,
status: m.status,
tokens_per_second: m.tokens_per_second,
first_token_latency_ms: m.first_token_latency_ms,
})
}
pub async fn list_messages(db: &DatabaseConnection, conversation_id: &str) -> Result<Vec<Message>> {
let rows = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::IsActive.eq(1))
.order_by_asc(messages::Column::CreatedAt)
.all(db)
.await?;
rows.into_iter().map(message_from_entity).collect()
}
pub async fn list_messages_for_model_context(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<Message>> {
let rows = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(
Condition::any()
.add(messages::Column::IsActive.eq(1))
.add(
Condition::all()
.add(messages::Column::VersionIndex.eq(-1))
.add(messages::Column::Role.is_in(["assistant", "tool"])),
),
)
.order_by_asc(messages::Column::CreatedAt)
.order_by_asc(messages::Column::Id)
.all(db)
.await?;
rows.into_iter().map(message_from_entity).collect()
}
pub async fn mark_stale_partial_assistant_messages_failed(db: &DatabaseConnection) -> Result<u64> {
let rows = messages::Entity::find()
.filter(messages::Column::Role.eq("assistant"))
.filter(messages::Column::Status.eq("partial"))
.filter(messages::Column::IsActive.eq(1))
.all(db)
.await?;
let mut changed = 0;
for row in rows {
let mut am: messages::ActiveModel = row.clone().into();
am.status = Set("error".to_string());
am.content = Set(if row.content.contains(STALE_PARTIAL_ASSISTANT_ERROR) {
row.content
} else {
format!(
"{}\n\n<!-- boosapi-stream-error -->\n{}",
row.content, STALE_PARTIAL_ASSISTANT_ERROR
)
});
am.update(db).await?;
changed += 1;
}
Ok(changed)
}
pub async fn list_messages_page(
db: &DatabaseConnection,
conversation_id: &str,
limit: u64,
before_message_id: Option<&str>,
) -> Result<MessagePage> {
let total_active_count = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::IsActive.eq(1))
.count(db)
.await?;
let mut query = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::IsActive.eq(1));
if let Some(cursor_id) = before_message_id {
let cursor = messages::Entity::find_by_id(cursor_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", cursor_id)))?;
query = query.filter(
Condition::any()
.add(messages::Column::CreatedAt.lt(cursor.created_at))
.add(
Condition::all()
.add(messages::Column::CreatedAt.eq(cursor.created_at))
.add(messages::Column::Id.lt(cursor.id.clone())),
),
);
}
let mut rows = query
.order_by_desc(messages::Column::CreatedAt)
.order_by_desc(messages::Column::Id)
.limit(limit + 1)
.all(db)
.await?;
let has_older = rows.len() > limit as usize;
if has_older {
rows.truncate(limit as usize);
}
rows.reverse();
let messages = rows
.into_iter()
.map(message_from_entity)
.collect::<Result<Vec<_>>>()?;
let oldest_message_id = messages.first().map(|message| message.id.clone());
Ok(MessagePage {
messages,
has_older,
oldest_message_id,
total_active_count,
})
}
pub async fn create_message(
db: &DatabaseConnection,
conversation_id: &str,
role: MessageRole,
content: &str,
attachments: &[Attachment],
parent_message_id: Option<&str>,
version_index: i32,
) -> Result<Message> {
let id = gen_id();
let now = now_ts();
let role_s = role_str(&role);
let attachments_json = stringify_attachment_list(attachments)?;
messages::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(conversation_id.to_string()),
role: Set(role_s.to_string()),
content: Set(content.to_string()),
attachments: Set(attachments_json),
created_at: Set(now),
parent_message_id: Set(parent_message_id.map(|s| s.to_string())),
version_index: Set(version_index),
is_active: Set(1),
..Default::default()
}
.insert(db)
.await?;
let row = messages::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", id)))?;
message_from_entity(row)
}
pub async fn update_message_content(
db: &DatabaseConnection,
id: &str,
content: &str,
) -> Result<Message> {
let row = messages::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", id)))?;
let mut am: messages::ActiveModel = row.into();
am.content = Set(content.to_string());
am.update(db).await?;
let row = messages::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", id)))?;
message_from_entity(row)
}
/// Update token usage stats on an existing message.
pub async fn update_message_usage(
db: &DatabaseConnection,
id: &str,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
) -> Result<()> {
let row = messages::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", id)))?;
let mut am: messages::ActiveModel = row.into();
if let Some(pt) = prompt_tokens {
am.prompt_tokens = Set(Some(pt));
}
if let Some(ct) = completion_tokens {
am.completion_tokens = Set(Some(ct));
}
am.update(db).await?;
Ok(())
}
fn compare_version_priority(left: &messages::Model, right: &messages::Model) -> std::cmp::Ordering {
right
.version_index
.cmp(&left.version_index)
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| right.id.cmp(&left.id))
}
fn select_next_active_version(
deleted: &messages::Model,
versions: &[messages::Model],
) -> Option<messages::Model> {
let remaining: Vec<messages::Model> = versions
.iter()
.filter(|version| version.id != deleted.id)
.cloned()
.collect();
if remaining.is_empty() {
return None;
}
let same_model: Vec<messages::Model> = if let Some(model_id) = deleted.model_id.as_ref() {
remaining
.iter()
.filter(|version| version.model_id.as_ref() == Some(model_id))
.cloned()
.collect()
} else {
Vec::new()
};
let mut candidates = if same_model.is_empty() { remaining } else { same_model };
candidates.sort_by(compare_version_priority);
candidates.into_iter().next()
}
pub async fn delete_message(db: &DatabaseConnection, id: &str) -> Result<()> {
let target = messages::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", id)))?;
let txn = db.begin().await?;
if target.role == "assistant" && target.is_active == 1 {
if let Some(parent_message_id) = target.parent_message_id.as_ref() {
let sibling_versions = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(&target.conversation_id))
.filter(messages::Column::ParentMessageId.eq(parent_message_id))
.filter(messages::Column::Role.eq("assistant"))
.filter(messages::Column::VersionIndex.gte(0))
.all(&txn)
.await?;
if let Some(next_version) = select_next_active_version(&target, &sibling_versions) {
let mut next_active: messages::ActiveModel = next_version.into();
next_active.is_active = Set(1);
next_active.update(&txn).await?;
}
}
}
let result = messages::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("Message {}", id)));
}
Ok(())
}
/// Delete all messages in a conversation.
pub async fn clear_conversation_messages(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<u64> {
let result = messages::Entity::delete_many()
.filter(messages::Column::ConversationId.eq(conversation_id))
.exec(db)
.await?;
Ok(result.rows_affected)
}
/// Delete all messages in a conversation created after the given timestamp (inclusive).
pub async fn delete_messages_after(
db: &DatabaseConnection,
conversation_id: &str,
created_at: i64,
) -> Result<u64> {
let result = messages::Entity::delete_many()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::CreatedAt.gte(created_at))
.exec(db)
.await?;
Ok(result.rows_affected)
}
pub async fn list_message_versions(
db: &DatabaseConnection,
conversation_id: &str,
parent_message_id: &str,
) -> Result<Vec<Message>> {
let rows = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::ParentMessageId.eq(parent_message_id))
.filter(messages::Column::Role.eq("assistant"))
.filter(messages::Column::VersionIndex.gte(0))
.order_by_asc(messages::Column::VersionIndex)
.all(db)
.await?;
if rows.is_empty() {
return Ok(Vec::new());
}
let candidate_ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect();
let tool_rows = messages::Entity::find()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::Role.eq("tool"))
.filter(messages::Column::ParentMessageId.is_in(candidate_ids.clone()))
.all(db)
.await?;
let tool_parent_ids: HashSet<String> = tool_rows
.into_iter()
.filter_map(|row| row.parent_message_id)
.collect();
rows.into_iter()
.filter(|row| !tool_parent_ids.contains(&row.id))
.map(message_from_entity)
.collect()
}
pub async fn set_active_version(
db: &DatabaseConnection,
conversation_id: &str,
parent_message_id: &str,
target_message_id: &str,
) -> Result<()> {
// Deactivate all versions for this parent
messages::Entity::update_many()
.filter(messages::Column::ConversationId.eq(conversation_id))
.filter(messages::Column::ParentMessageId.eq(parent_message_id))
.col_expr(messages::Column::IsActive, Expr::value(0))
.exec(db)
.await?;
// Activate target version
let row = messages::Entity::find_by_id(target_message_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Message {}", target_message_id)))?;
let mut am: messages::ActiveModel = row.into();
am.is_active = Set(1);
am.update(db).await?;
Ok(())
}
pub async fn delete_message_group(db: &DatabaseConnection, user_message_id: &str) -> Result<u64> {
// Delete all assistant versions for this user message
let ai_result = messages::Entity::delete_many()
.filter(messages::Column::ParentMessageId.eq(user_message_id))
.exec(db)
.await?;
// Delete the user message itself
messages::Entity::delete_by_id(user_message_id)
.exec(db)
.await?;
Ok(ai_result.rows_affected + 1)
}
/// Create a message with role "tool" for storing tool execution results.
pub async fn create_tool_result_message(
db: &DatabaseConnection,
conversation_id: &str,
tool_call_id: &str,
content: &str,
parent_message_id: &str,
) -> Result<()> {
let id = crate::utils::gen_id();
crate::entity::messages::ActiveModel {
id: Set(id),
conversation_id: Set(conversation_id.to_string()),
role: Set("tool".to_string()),
content: Set(content.to_string()),
provider_id: Set(None),
model_id: Set(None),
token_count: Set(None),
prompt_tokens: Set(None),
completion_tokens: Set(None),
attachments: Set("[]".to_string()),
thinking: Set(None),
created_at: Set(crate::utils::now_ts()),
branch_id: Set(None),
parent_message_id: Set(Some(parent_message_id.to_string())),
// Tool-result scaffolding: excluded from history reload (paired with intermediate assistant).
version_index: Set(-1),
is_active: Set(0),
tool_calls_json: Set(None),
tool_call_id: Set(Some(tool_call_id.to_string())),
status: Set("complete".to_string()),
tokens_per_second: Set(None),
first_token_latency_ms: Set(None),
}
.insert(db)
.await?;
Ok(())
}
/// Create an assistant message that contains tool_calls (intermediate message in tool loop).
pub async fn create_assistant_tool_call_message(
db: &DatabaseConnection,
conversation_id: &str,
content: &str,
tool_calls_json: Option<&str>,
provider_id: &str,
model_id: &str,
parent_message_id: &str,
) -> Result<String> {
let id = crate::utils::gen_id();
crate::entity::messages::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(conversation_id.to_string()),
role: Set("assistant".to_string()),
content: Set(content.to_string()),
provider_id: Set(Some(provider_id.to_string())),
model_id: Set(Some(model_id.to_string())),
token_count: Set(None),
prompt_tokens: Set(None),
completion_tokens: Set(None),
attachments: Set("[]".to_string()),
thinking: Set(None),
created_at: Set(crate::utils::now_ts()),
branch_id: Set(None),
parent_message_id: Set(Some(parent_message_id.to_string())),
// Intermediate tool-call scaffolding message. Excluded from user-visible AI version pagination.
version_index: Set(-1),
is_active: Set(0),
tool_calls_json: Set(tool_calls_json.map(|s| s.to_string())),
tool_call_id: Set(None),
status: Set("complete".to_string()),
tokens_per_second: Set(None),
first_token_latency_ms: Set(None),
}
.insert(db)
.await?;
Ok(id)
}
pub async fn get_conversation_stats(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<ConversationStats> {
use sea_orm::{FromQueryResult, Statement};
#[derive(Debug, FromQueryResult)]
struct StatsRow {
total_messages: i64,
total_user_messages: i64,
total_assistant_messages: i64,
total_prompt_tokens: i64,
total_completion_tokens: i64,
avg_tokens_per_second: Option<f64>,
avg_first_token_latency_ms: Option<f64>,
avg_response_time_ms: Option<f64>,
}
let sql = r#"
SELECT
COUNT(*) AS total_messages,
SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END) AS total_user_messages,
SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END) AS total_assistant_messages,
COALESCE(SUM(prompt_tokens), 0) AS total_prompt_tokens,
COALESCE(SUM(completion_tokens), 0) AS total_completion_tokens,
AVG(CASE WHEN tokens_per_second IS NOT NULL AND tokens_per_second > 0 THEN tokens_per_second ELSE NULL END) AS avg_tokens_per_second,
AVG(CASE WHEN first_token_latency_ms IS NOT NULL THEN first_token_latency_ms ELSE NULL END) AS avg_first_token_latency_ms,
AVG(CASE
WHEN first_token_latency_ms IS NOT NULL AND tokens_per_second IS NOT NULL AND tokens_per_second > 0 AND completion_tokens IS NOT NULL AND completion_tokens > 0
THEN first_token_latency_ms + (completion_tokens * 1000.0 / tokens_per_second)
ELSE NULL
END) AS avg_response_time_ms
FROM messages
WHERE conversation_id = ? AND is_active = 1
"#;
let row = StatsRow::find_by_statement(Statement::from_sql_and_values(
db.get_database_backend(),
sql,
vec![conversation_id.into()],
))
.one(db)
.await?
.unwrap_or(StatsRow {
total_messages: 0,
total_user_messages: 0,
total_assistant_messages: 0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
avg_tokens_per_second: None,
avg_first_token_latency_ms: None,
avg_response_time_ms: None,
});
let total_prompt = row.total_prompt_tokens as u64;
let total_completion = row.total_completion_tokens as u64;
Ok(ConversationStats {
total_messages: row.total_messages as u64,
total_user_messages: row.total_user_messages as u64,
total_assistant_messages: row.total_assistant_messages as u64,
total_prompt_tokens: total_prompt,
total_completion_tokens: total_completion,
total_tokens: total_prompt + total_completion,
avg_tokens_per_second: row.avg_tokens_per_second,
avg_first_token_latency_ms: row.avg_first_token_latency_ms,
avg_response_time_ms: row.avg_response_time_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::create_test_pool;
use crate::repo::conversation;
#[tokio::test]
async fn create_message_round_trips_attachment_metadata() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Attach Chat", "m1", "p1", None)
.await
.unwrap();
let msg = create_message(
db,
&conv.id,
MessageRole::User,
"See attached",
&[Attachment {
id: "att-1".into(),
file_name: "image.png".into(),
file_type: "image/png".into(),
file_path: "conv-1/image.png".into(),
file_size: 3,
data: None,
}],
None,
0,
)
.await
.unwrap();
assert_eq!(msg.attachments.len(), 1);
assert_eq!(msg.attachments[0].file_name, "image.png");
assert_eq!(msg.attachments[0].file_type, "image/png");
assert_eq!(msg.attachments[0].file_path, "conv-1/image.png");
assert_eq!(msg.attachments[0].file_size, 3);
}
#[tokio::test]
async fn list_messages_for_model_context_includes_inactive_tool_scaffolding_only() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Tool Chat", "model-1", "prov-1", None)
.await
.unwrap();
let user_msg = create_message(db, &conv.id, MessageRole::User, "Run tool", &[], None, 0)
.await
.unwrap();
let visible_reply = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Done",
&[],
Some(&user_msg.id),
0,
)
.await
.unwrap();
let scaffold_id = create_assistant_tool_call_message(
db,
&conv.id,
"",
Some(r#"[{"id":"call-1","type":"function","function":{"name":"read_file","arguments":"{}"}}]"#),
"prov-1",
"model-1",
&user_msg.id,
)
.await
.unwrap();
create_tool_result_message(db, &conv.id, "call-1", "tool result", &scaffold_id)
.await
.unwrap();
let stale_version = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Old inactive visible reply",
&[],
Some(&user_msg.id),
1,
)
.await
.unwrap();
let stale_row = messages::Entity::find_by_id(&stale_version.id)
.one(db)
.await
.unwrap()
.unwrap();
let mut stale_am: messages::ActiveModel = stale_row.into();
stale_am.is_active = Set(0);
stale_am.update(db).await.unwrap();
let visible_messages = list_messages(db, &conv.id).await.unwrap();
assert_eq!(visible_messages.len(), 2);
assert!(visible_messages.iter().any(|message| message.id == visible_reply.id));
let context_messages = list_messages_for_model_context(db, &conv.id).await.unwrap();
let ids = context_messages
.iter()
.map(|message| message.id.as_str())
.collect::<Vec<_>>();
assert!(ids.contains(&user_msg.id.as_str()));
assert!(ids.contains(&visible_reply.id.as_str()));
assert!(ids.contains(&scaffold_id.as_str()));
assert!(!ids.contains(&stale_version.id.as_str()));
assert_eq!(
context_messages
.iter()
.filter(|message| message.role == MessageRole::Tool)
.count(),
1
);
}
#[tokio::test]
async fn delete_active_assistant_version_promotes_remaining_version() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Version Chat", "model-1", "prov-1", None)
.await
.unwrap();
let user_msg = create_message(db, &conv.id, MessageRole::User, "Hello!", &[], None, 0)
.await
.unwrap();
let version_a = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Reply A",
&[],
Some(&user_msg.id),
0,
)
.await
.unwrap();
let version_b = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Reply B",
&[],
Some(&user_msg.id),
1,
)
.await
.unwrap();
set_active_version(db, &conv.id, &user_msg.id, &version_a.id)
.await
.unwrap();
delete_message(db, &version_a.id).await.unwrap();
let visible_messages = list_messages(db, &conv.id).await.unwrap();
assert_eq!(visible_messages.len(), 2);
let active_reply = visible_messages
.iter()
.find(|message| message.role == MessageRole::Assistant)
.expect("assistant reply should remain visible");
assert_eq!(active_reply.id, version_b.id);
assert!(active_reply.is_active);
let versions = list_message_versions(db, &conv.id, &user_msg.id)
.await
.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].id, version_b.id);
assert!(versions[0].is_active);
}
#[tokio::test]
async fn stale_partial_assistant_messages_are_marked_error_on_startup() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Crash Chat", "model-1", "prov-1", None)
.await
.unwrap();
let user_msg = create_message(db, &conv.id, MessageRole::User, "Call MCP", &[], None, 0)
.await
.unwrap();
let active_partial = create_message(
db,
&conv.id,
MessageRole::Assistant,
":::mcp {\"name\":\"server\",\"tool\":\"fetch\"}\n",
&[],
Some(&user_msg.id),
0,
)
.await
.unwrap();
let complete = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Done",
&[],
Some(&user_msg.id),
1,
)
.await
.unwrap();
let inactive_partial = create_message(
db,
&conv.id,
MessageRole::Assistant,
"Old partial",
&[],
Some(&user_msg.id),
2,
)
.await
.unwrap();
for (id, status, is_active) in [
(&active_partial.id, "partial", 1),
(&complete.id, "complete", 1),
(&inactive_partial.id, "partial", 0),
] {
let row = messages::Entity::find_by_id(id).one(db).await.unwrap().unwrap();
let mut am: messages::ActiveModel = row.into();
am.status = Set(status.to_string());
am.is_active = Set(is_active);
am.update(db).await.unwrap();
}
let changed = mark_stale_partial_assistant_messages_failed(db).await.unwrap();
assert_eq!(changed, 1);
let active = messages::Entity::find_by_id(&active_partial.id)
.one(db)
.await
.unwrap()
.unwrap();
assert_eq!(active.status, "error");
assert!(active.content.contains("BoosApi was closed while this response was running"));
let still_complete = messages::Entity::find_by_id(&complete.id)
.one(db)
.await
.unwrap()
.unwrap();
assert_eq!(still_complete.status, "complete");
assert_eq!(still_complete.content, "Done");
let still_inactive_partial = messages::Entity::find_by_id(&inactive_partial.id)
.one(db)
.await
.unwrap()
.unwrap();
assert_eq!(still_inactive_partial.status, "partial");
assert_eq!(still_inactive_partial.content, "Old partial");
}
}
@@ -0,0 +1,82 @@
use sea_orm::*;
use sea_query::OnConflict;
use crate::entity::program_policies;
use crate::error::{BoosApiError, Result};
use crate::types::{ProgramPolicy, SaveProgramPolicyInput};
use crate::utils::gen_id;
fn model_to_program_policy(m: program_policies::Model) -> ProgramPolicy {
ProgramPolicy {
id: m.id,
program_name: m.program_name,
allowed_provider_ids_json: m.allowed_provider_ids_json,
allowed_model_ids_json: m.allowed_model_ids_json,
default_provider_id: m.default_provider_id,
default_model_id: m.default_model_id,
rate_limit_per_minute: m.rate_limit_per_minute.map(|v| v as i32),
}
}
pub async fn list_program_policies(db: &DatabaseConnection) -> Result<Vec<ProgramPolicy>> {
let rows = program_policies::Entity::find()
.order_by_asc(program_policies::Column::ProgramName)
.all(db)
.await?;
Ok(rows.into_iter().map(model_to_program_policy).collect())
}
pub async fn save_program_policy(
db: &DatabaseConnection,
input: &SaveProgramPolicyInput,
) -> Result<ProgramPolicy> {
let id = gen_id();
let allowed_provider_ids_json =
serde_json::to_string(&input.allowed_provider_ids).unwrap_or_else(|_| "[]".to_string());
let allowed_model_ids_json =
serde_json::to_string(&input.allowed_model_ids).unwrap_or_else(|_| "[]".to_string());
program_policies::Entity::insert(program_policies::ActiveModel {
id: Set(id.clone()),
program_name: Set(input.program_name.clone()),
allowed_provider_ids_json: Set(allowed_provider_ids_json.clone()),
allowed_model_ids_json: Set(allowed_model_ids_json.clone()),
default_provider_id: Set(input.default_provider_id.clone()),
default_model_id: Set(input.default_model_id.clone()),
rate_limit_per_minute: Set(input.rate_limit_per_minute.map(|v| v as i64)),
})
.on_conflict(
OnConflict::column(program_policies::Column::ProgramName)
.update_columns([
program_policies::Column::AllowedProviderIdsJson,
program_policies::Column::AllowedModelIdsJson,
program_policies::Column::DefaultProviderId,
program_policies::Column::DefaultModelId,
program_policies::Column::RateLimitPerMinute,
])
.to_owned(),
)
.exec(db)
.await?;
Ok(ProgramPolicy {
id,
program_name: input.program_name.clone(),
allowed_provider_ids_json,
allowed_model_ids_json,
default_provider_id: input.default_provider_id.clone(),
default_model_id: input.default_model_id.clone(),
rate_limit_per_minute: input.rate_limit_per_minute,
})
}
pub async fn delete_program_policy(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = program_policies::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("ProgramPolicy {}", id)));
}
Ok(())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
use sea_orm::*;
use crate::entity::provider_keys;
use crate::error::{BoosApiError, Result};
use crate::types::ProviderKey;
use crate::utils::{gen_id, now_ts};
fn key_from_entity(m: provider_keys::Model) -> ProviderKey {
ProviderKey {
id: m.id,
provider_id: m.provider_id,
key_encrypted: m.key_encrypted,
key_prefix: m.key_prefix,
enabled: m.enabled != 0,
last_validated_at: m.last_validated_at,
last_error: m.last_error,
rotation_index: m.rotation_index as u32,
created_at: m.created_at,
}
}
pub async fn list_keys_for_provider(db: &DatabaseConnection, provider_id: &str) -> Result<Vec<ProviderKey>> {
let rows = provider_keys::Entity::find()
.filter(provider_keys::Column::ProviderId.eq(provider_id))
.order_by_asc(provider_keys::Column::RotationIndex)
.all(db)
.await?;
Ok(rows.into_iter().map(key_from_entity).collect())
}
pub async fn add_provider_key(
db: &DatabaseConnection,
provider_id: &str,
key_encrypted: &str,
key_prefix: &str,
) -> Result<ProviderKey> {
let id = gen_id();
let now = now_ts();
let max_idx = provider_keys::Entity::find()
.filter(provider_keys::Column::ProviderId.eq(provider_id))
.select_only()
.column_as(provider_keys::Column::RotationIndex.max(), "m")
.into_tuple::<Option<i32>>()
.one(db)
.await?
.flatten();
let rotation_index = max_idx.unwrap_or(-1) + 1;
provider_keys::ActiveModel {
id: Set(id.clone()),
provider_id: Set(provider_id.to_string()),
key_encrypted: Set(key_encrypted.to_string()),
key_prefix: Set(key_prefix.to_string()),
enabled: Set(1),
last_validated_at: Set(None),
last_error: Set(None),
rotation_index: Set(rotation_index),
created_at: Set(now),
}
.insert(db)
.await?;
let row = provider_keys::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ProviderKey {}", id)))?;
Ok(key_from_entity(row))
}
pub async fn delete_provider_key(db: &DatabaseConnection, key_id: &str) -> Result<()> {
let result = provider_keys::Entity::delete_by_id(key_id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("ProviderKey {}", key_id)));
}
Ok(())
}
/// Toggles enabled state (NOT enabled). Different from provider.rs which takes an explicit bool.
pub async fn toggle_provider_key(db: &DatabaseConnection, key_id: &str) -> Result<ProviderKey> {
let row = provider_keys::Entity::find_by_id(key_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ProviderKey {}", key_id)))?;
let new_enabled = if row.enabled != 0 { 0 } else { 1 };
let mut am: provider_keys::ActiveModel = row.into();
am.enabled = Set(new_enabled);
am.update(db).await?;
let row = provider_keys::Entity::find_by_id(key_id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ProviderKey {}", key_id)))?;
Ok(key_from_entity(row))
}
/// Takes an optional error message. Different from provider.rs which takes a bool.
pub async fn update_key_validation(
db: &DatabaseConnection,
key_id: &str,
error: Option<&str>,
) -> Result<()> {
if let Some(row) = provider_keys::Entity::find_by_id(key_id).one(db).await? {
let mut am: provider_keys::ActiveModel = row.into();
am.last_validated_at = Set(Some(now_ts()));
am.last_error = Set(error.map(|s| s.to_string()));
am.update(db).await?;
}
Ok(())
}
pub async fn get_enabled_keys(db: &DatabaseConnection, provider_id: &str) -> Result<Vec<ProviderKey>> {
let rows = provider_keys::Entity::find()
.filter(provider_keys::Column::ProviderId.eq(provider_id))
.filter(provider_keys::Column::Enabled.eq(1))
.order_by_asc(provider_keys::Column::RotationIndex)
.all(db)
.await?;
Ok(rows.into_iter().map(key_from_entity).collect())
}
pub async fn update_rotation_index(db: &DatabaseConnection, key_id: &str, index: u32) -> Result<()> {
if let Some(row) = provider_keys::Entity::find_by_id(key_id).one(db).await? {
let mut am: provider_keys::ActiveModel = row.into();
am.rotation_index = Set(index as i32);
am.update(db).await?;
}
Ok(())
}
@@ -0,0 +1,119 @@
use sea_orm::*;
use crate::entity::search_providers;
use crate::error::{BoosApiError, Result};
use crate::types::{CreateSearchProviderInput, SearchProvider};
use crate::utils::gen_id;
fn model_to_search_provider(m: search_providers::Model) -> SearchProvider {
SearchProvider {
id: m.id,
name: m.name,
provider_type: m.provider_type,
endpoint: m.endpoint,
has_api_key: m.api_key_ref.as_ref().map_or(false, |k| !k.is_empty()),
enabled: m.enabled != 0,
region: m.region,
language: m.language,
safe_search: m.safe_search.map(|v| v != 0),
result_limit: m.result_limit,
timeout_ms: m.timeout_ms,
}
}
pub async fn list_search_providers(db: &DatabaseConnection) -> Result<Vec<SearchProvider>> {
let rows = search_providers::Entity::find()
.order_by_asc(search_providers::Column::Name)
.all(db)
.await?;
Ok(rows.into_iter().map(model_to_search_provider).collect())
}
pub async fn get_search_provider(db: &DatabaseConnection, id: &str) -> Result<SearchProvider> {
let model = search_providers::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("SearchProvider {}", id)))?;
Ok(model_to_search_provider(model))
}
pub async fn create_search_provider(
db: &DatabaseConnection,
input: CreateSearchProviderInput,
) -> Result<SearchProvider> {
let id = gen_id();
search_providers::ActiveModel {
id: Set(id.clone()),
name: Set(input.name),
provider_type: Set(input.provider_type),
endpoint: Set(input.endpoint),
api_key_ref: Set(input.api_key),
enabled: Set(if input.enabled.unwrap_or(true) { 1 } else { 0 }),
region: Set(input.region),
language: Set(input.language),
safe_search: Set(input.safe_search.map(|b| if b { 1i64 } else { 0 })),
result_limit: Set(input.result_limit.unwrap_or(10)),
timeout_ms: Set(input.timeout_ms.unwrap_or(5000)),
}
.insert(db)
.await?;
get_search_provider(db, &id).await
}
pub async fn update_search_provider(
db: &DatabaseConnection,
id: &str,
input: CreateSearchProviderInput,
) -> Result<SearchProvider> {
let model = search_providers::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("SearchProvider {}", id)))?;
let name = if input.name.is_empty() {
model.name.clone()
} else {
input.name
};
let provider_type = if input.provider_type.is_empty() {
model.provider_type.clone()
} else {
input.provider_type
};
let endpoint = input.endpoint.or(model.endpoint.clone());
let api_key_ref = input.api_key.or(model.api_key_ref.clone());
let enabled = input.enabled.unwrap_or(model.enabled != 0);
let region = input.region.or(model.region.clone());
let language = input.language.or(model.language.clone());
let safe_search = input.safe_search.or(model.safe_search.map(|v| v != 0));
let result_limit = input.result_limit.unwrap_or(model.result_limit);
let timeout_ms = input.timeout_ms.unwrap_or(model.timeout_ms);
let mut am: search_providers::ActiveModel = model.into();
am.name = Set(name);
am.provider_type = Set(provider_type);
am.endpoint = Set(endpoint);
am.api_key_ref = Set(api_key_ref);
am.enabled = Set(if enabled { 1 } else { 0 });
am.region = Set(region);
am.language = Set(language);
am.safe_search = Set(safe_search.map(|b| if b { 1i64 } else { 0 }));
am.result_limit = Set(result_limit);
am.timeout_ms = Set(timeout_ms);
am.update(db).await?;
get_search_provider(db, id).await
}
pub async fn delete_search_provider(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = search_providers::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("SearchProvider {}", id)));
}
Ok(())
}
@@ -0,0 +1,76 @@
use sea_orm::*;
use sea_query::OnConflict;
use crate::entity::settings;
use crate::error::{BoosApiError, Result};
use crate::types::AppSettings;
pub async fn get_settings(db: &DatabaseConnection) -> Result<AppSettings> {
let rows = settings::Entity::find().all(db).await?;
let mut map = serde_json::Map::new();
for row in &rows {
let val = serde_json::from_str::<serde_json::Value>(&row.value)
.unwrap_or_else(|_| serde_json::Value::String(row.value.clone()));
map.insert(row.key.clone(), val);
}
let settings: AppSettings =
serde_json::from_value(serde_json::Value::Object(map)).unwrap_or_default();
Ok(settings)
}
pub async fn save_settings(db: &DatabaseConnection, settings: &AppSettings) -> Result<()> {
let value = serde_json::to_value(settings).unwrap_or_default();
if let serde_json::Value::Object(map) = value {
db.transaction::<_, _, sea_orm::DbErr>(|txn| {
Box::pin(async move {
for (key, val) in map {
let val_str = match &val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
settings::Entity::insert(settings::ActiveModel {
key: Set(key),
value: Set(val_str),
})
.on_conflict(
OnConflict::column(settings::Column::Key)
.update_column(settings::Column::Value)
.to_owned(),
)
.exec(txn)
.await?;
}
Ok(())
})
})
.await
.map_err(|e| match e {
sea_orm::TransactionError::Connection(db_err) => BoosApiError::from(db_err),
sea_orm::TransactionError::Transaction(db_err) => BoosApiError::from(db_err),
})?;
}
Ok(())
}
pub async fn get_setting(db: &DatabaseConnection, key: &str) -> Result<Option<String>> {
let row = settings::Entity::find_by_id(key).one(db).await?;
Ok(row.map(|r| r.value))
}
pub async fn set_setting(db: &DatabaseConnection, key: &str, value: &str) -> Result<()> {
settings::Entity::insert(settings::ActiveModel {
key: Set(key.to_string()),
value: Set(value.to_string()),
})
.on_conflict(
OnConflict::column(settings::Column::Key)
.update_column(settings::Column::Value)
.to_owned(),
)
.exec(db)
.await?;
Ok(())
}
@@ -0,0 +1,46 @@
use sea_orm::*;
use sea_orm::prelude::Expr;
use std::collections::HashSet;
use crate::entity::skill_states;
use crate::error::Result;
use crate::utils::now_ts;
/// Get all disabled skill names from the database.
pub async fn get_disabled_skills(db: &DatabaseConnection) -> Result<HashSet<String>> {
let rows = skill_states::Entity::find()
.filter(skill_states::Column::Enabled.eq(0))
.all(db)
.await?;
Ok(rows.into_iter().map(|r| r.name).collect())
}
/// Set a skill's enabled state. Creates or updates the record.
pub async fn set_skill_enabled(
db: &DatabaseConnection,
name: &str,
enabled: bool,
) -> Result<()> {
let now = now_ts();
let existing = skill_states::Entity::find_by_id(name).one(db).await?;
if let Some(_) = existing {
skill_states::Entity::update_many()
.col_expr(
skill_states::Column::Enabled,
Expr::value(if enabled { 1 } else { 0 }),
)
.col_expr(skill_states::Column::UpdatedAt, Expr::value(now))
.filter(skill_states::Column::Name.eq(name))
.exec(db)
.await?;
} else {
let am = skill_states::ActiveModel {
name: Set(name.to_string()),
enabled: Set(if enabled { 1 } else { 0 }),
updated_at: Set(now),
};
am.insert(db).await?;
}
Ok(())
}
@@ -0,0 +1,127 @@
use sea_orm::*;
use serde::{Deserialize, Serialize};
use crate::entity::stored_files;
use crate::error::{BoosApiError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredFile {
pub id: String,
pub hash: String,
pub original_name: String,
pub mime_type: String,
pub size_bytes: i64,
pub storage_path: String,
pub conversation_id: Option<String>,
pub created_at: String,
}
fn model_to_stored_file(m: stored_files::Model) -> StoredFile {
StoredFile {
id: m.id,
hash: m.hash,
original_name: m.original_name,
mime_type: m.mime_type,
size_bytes: m.size_bytes,
storage_path: m.storage_path,
conversation_id: m.conversation_id,
created_at: m.created_at,
}
}
pub async fn create_stored_file(
db: &DatabaseConnection,
id: &str,
hash: &str,
original_name: &str,
mime_type: &str,
size_bytes: i64,
storage_path: &str,
conversation_id: Option<&str>,
) -> Result<StoredFile> {
let am = stored_files::ActiveModel {
id: Set(id.to_string()),
hash: Set(hash.to_string()),
original_name: Set(original_name.to_string()),
mime_type: Set(mime_type.to_string()),
size_bytes: Set(size_bytes),
storage_path: Set(storage_path.to_string()),
conversation_id: Set(conversation_id.map(|s| s.to_string())),
..Default::default()
};
am.insert(db).await?;
get_stored_file(db, id).await
}
pub async fn get_stored_file(db: &DatabaseConnection, id: &str) -> Result<StoredFile> {
let model = stored_files::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("StoredFile {}", id)))?;
Ok(model_to_stored_file(model))
}
pub async fn list_stored_files_by_conversation(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<StoredFile>> {
let models = stored_files::Entity::find()
.filter(stored_files::Column::ConversationId.eq(conversation_id))
.order_by_desc(stored_files::Column::CreatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_stored_file).collect())
}
pub async fn delete_stored_file(db: &DatabaseConnection, id: &str) -> Result<()> {
let result = stored_files::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("StoredFile {}", id)));
}
Ok(())
}
pub async fn delete_stored_files_by_conversation(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<()> {
stored_files::Entity::delete_many()
.filter(stored_files::Column::ConversationId.eq(conversation_id))
.exec(db)
.await?;
Ok(())
}
pub async fn list_all_stored_files(db: &DatabaseConnection) -> Result<Vec<StoredFile>> {
let models = stored_files::Entity::find()
.order_by_desc(stored_files::Column::CreatedAt)
.all(db)
.await?;
Ok(models.into_iter().map(model_to_stored_file).collect())
}
pub async fn count_stored_files_with_storage_path(
db: &DatabaseConnection,
storage_path: &str,
) -> Result<u64> {
stored_files::Entity::find()
.filter(stored_files::Column::StoragePath.eq(storage_path))
.count(db)
.await
.map_err(Into::into)
}
pub async fn find_by_hash(db: &DatabaseConnection, hash: &str) -> Result<Option<StoredFile>> {
let model = stored_files::Entity::find()
.filter(stored_files::Column::Hash.eq(hash))
.one(db)
.await?;
Ok(model.map(model_to_stored_file))
}
@@ -0,0 +1,111 @@
use sea_orm::*;
use crate::entity::tool_executions;
use crate::error::{BoosApiError, Result};
use crate::types::ToolExecution;
use crate::utils::gen_id;
fn model_to_tool_execution(m: tool_executions::Model) -> ToolExecution {
ToolExecution {
id: m.id,
conversation_id: m.conversation_id,
message_id: m.message_id,
server_id: m.server_id,
tool_name: m.tool_name,
status: m.status,
input_preview: m.input_preview,
output_preview: m.output_preview,
error_message: m.error_message,
duration_ms: m.duration_ms,
created_at: m.created_at,
approval_status: m.approval_status,
}
}
pub async fn list_tool_executions(
db: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<ToolExecution>> {
let rows = tool_executions::Entity::find()
.filter(tool_executions::Column::ConversationId.eq(conversation_id))
.order_by_desc(tool_executions::Column::CreatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(model_to_tool_execution).collect())
}
pub async fn create_tool_execution(
db: &DatabaseConnection,
conversation_id: &str,
message_id: Option<&str>,
server_id: &str,
tool_name: &str,
input_preview: Option<&str>,
approval_status: Option<&str>,
) -> Result<ToolExecution> {
let id = gen_id();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
tool_executions::ActiveModel {
id: Set(id.clone()),
conversation_id: Set(conversation_id.to_string()),
message_id: Set(message_id.map(|s| s.to_string())),
server_id: Set(server_id.to_string()),
tool_name: Set(tool_name.to_string()),
status: Set("pending".to_string()),
input_preview: Set(input_preview.map(|s| s.to_string())),
output_preview: Set(None),
error_message: Set(None),
duration_ms: Set(None),
created_at: Set(now),
approval_status: Set(approval_status.map(|s| s.to_string())),
}
.insert(db)
.await?;
let model = tool_executions::Entity::find_by_id(&id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ToolExecution {}", id)))?;
Ok(model_to_tool_execution(model))
}
pub async fn update_tool_execution_status(
db: &DatabaseConnection,
id: &str,
status: &str,
output: Option<&str>,
error: Option<&str>,
) -> Result<()> {
let model = tool_executions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ToolExecution {}", id)))?;
let mut am: tool_executions::ActiveModel = model.into();
am.status = Set(status.to_string());
am.output_preview = Set(output.map(|s| s.to_string()));
am.error_message = Set(error.map(|s| s.to_string()));
am.update(db).await?;
Ok(())
}
pub async fn update_tool_execution_approval_status(
db: &DatabaseConnection,
id: &str,
approval_status: &str,
) -> Result<()> {
let model = tool_executions::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("ToolExecution {}", id)))?;
let mut am: tool_executions::ActiveModel = model.into();
am.approval_status = Set(Some(approval_status.to_string()));
am.update(db).await?;
Ok(())
}