From 72690f6ebfccf7948fa43fc08a25b635f7fab0f0 Mon Sep 17 00:00:00 2001 From: Illuminated2020 <2357303264@qq.com> Date: Sun, 10 May 2026 17:36:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=A2=9E=E5=8A=A0=20handoff=20?= =?UTF-8?q?=E9=98=9F=E5=88=97=E5=AD=98=E5=82=A8=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 handoff 状态与队列项 DTO - 为 Storage 增加 handoffs 表和 pending 读写方法 - 补充 handoff 队列保存、排序和状态过滤单测 --- crates/dbx-core/src/handoff.rs | 65 ++++++++++++++++++++ crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 crates/dbx-core/src/handoff.rs diff --git a/crates/dbx-core/src/handoff.rs b/crates/dbx-core/src/handoff.rs new file mode 100644 index 000000000..4f4d980c7 --- /dev/null +++ b/crates/dbx-core/src/handoff.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::sql_safety::{OperationClass, RiskLevel}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum HandoffStatus { + Queued, + Shown, + Approved, + Rejected, + Executed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffItem { + pub id: String, + pub created_at: DateTime, + pub created_by: String, + pub connection_name: String, + pub database: Option, + pub title: String, + pub description: Option, + pub sql: String, + pub operation_class: OperationClass, + pub risk_level: RiskLevel, + pub is_production: bool, + pub status: HandoffStatus, + pub result_summary: Option, + pub error: Option, +} + +impl HandoffItem { + pub fn queued( + connection_name: String, + database: Option, + title: String, + description: Option, + sql: String, + operation_class: OperationClass, + risk_level: RiskLevel, + is_production: bool, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + created_at: Utc::now(), + created_by: "dbx-cli".to_string(), + connection_name, + database, + title, + description, + sql, + operation_class, + risk_level, + is_production, + status: HandoffStatus::Queued, + result_summary: None, + error: None, + } + } +} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 0636c7e43..6f309d441 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod connection; pub mod connection_secrets; pub mod db; pub mod external; +pub mod handoff; pub mod history; pub mod models; pub mod mongo_ops; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 27d588737..43686c03a 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; use crate::ai::{AiChatMessage, AiConfig, AiConversation}; +use crate::handoff::HandoffItem; use crate::history::HistoryEntry; use crate::models::connection::ConnectionConfig; use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary}; @@ -84,6 +85,12 @@ const SCHEMA_STATEMENTS: &[&str] = &[ created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", + "CREATE TABLE IF NOT EXISTS handoffs ( + id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + )", ]; // --------------------------------------------------------------------------- @@ -242,6 +249,47 @@ impl Storage { } } +// --------------------------------------------------------------------------- +// Handoffs +// --------------------------------------------------------------------------- + +impl Storage { + pub async fn save_handoff(&self, item: &HandoffItem) -> Result<(), String> { + let json = serde_json::to_string(item).map_err(|e| e.to_string())?; + let status = serde_json::to_value(&item.status) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .ok_or_else(|| "Failed to serialize handoff status".to_string())?; + + sqlx::query( + "INSERT OR REPLACE INTO handoffs (id, payload_json, status, created_at) \ + VALUES (?, ?, ?, ?)", + ) + .bind(&item.id) + .bind(json) + .bind(status) + .bind(item.created_at.to_rfc3339()) + .execute(&self.db) + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn load_pending_handoffs(&self) -> Result, String> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT payload_json FROM handoffs \ + WHERE status IN ('queued', 'shown') \ + ORDER BY created_at ASC", + ) + .fetch_all(&self.db) + .await + .map_err(|e| e.to_string())?; + + rows.into_iter().map(|(json,)| serde_json::from_str(&json).map_err(|e| e.to_string())).collect() + } +} + // --------------------------------------------------------------------------- // AI Config // --------------------------------------------------------------------------- @@ -898,6 +946,67 @@ impl Storage { } } +#[cfg(test)] +mod handoff_tests { + use super::*; + use crate::handoff::{HandoffItem, HandoffStatus}; + use crate::sql_safety::{OperationClass, RiskLevel}; + + async fn open_temp_storage() -> Storage { + let path = std::env::temp_dir().join(format!("dbx-handoff-test-{}.db", uuid::Uuid::new_v4())); + Storage::open(&path).await.unwrap() + } + + fn queued_handoff(title: &str) -> HandoffItem { + HandoffItem::queued( + "prod-main".to_string(), + Some("app".to_string()), + title.to_string(), + Some("review write".to_string()), + "UPDATE users SET active = 0".to_string(), + OperationClass::Write, + RiskLevel::High, + true, + ) + } + + #[tokio::test] + async fn save_handoff_loads_pending_records_in_created_order() { + let storage = open_temp_storage().await; + let first = queued_handoff("first"); + let mut second = queued_handoff("second"); + second.created_at = first.created_at + chrono::Duration::seconds(1); + second.status = HandoffStatus::Shown; + + storage.save_handoff(&second).await.unwrap(); + storage.save_handoff(&first).await.unwrap(); + + let loaded = storage.load_pending_handoffs().await.unwrap(); + + assert_eq!(loaded.iter().map(|item| item.title.as_str()).collect::>(), vec!["first", "second"]); + assert_eq!(loaded[0].status, HandoffStatus::Queued); + assert_eq!(loaded[1].status, HandoffStatus::Shown); + assert_eq!(loaded[0].operation_class, OperationClass::Write); + assert!(loaded[0].is_production); + } + + #[tokio::test] + async fn load_pending_handoffs_excludes_terminal_statuses() { + let storage = open_temp_storage().await; + let queued = queued_handoff("queued"); + let mut executed = queued_handoff("executed"); + executed.status = HandoffStatus::Executed; + + storage.save_handoff(&queued).await.unwrap(); + storage.save_handoff(&executed).await.unwrap(); + + let loaded = storage.load_pending_handoffs().await.unwrap(); + + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, queued.id); + } +} + // --------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------