feat(core): 增加 handoff 队列存储模型
- 新增 handoff 状态与队列项 DTO - 为 Storage 增加 handoffs 表和 pending 读写方法 - 补充 handoff 队列保存、排序和状态过滤单测
This commit is contained in:
parent
81a6dce5ac
commit
72690f6ebf
|
|
@ -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<Utc>,
|
||||
pub created_by: String,
|
||||
pub connection_name: String,
|
||||
pub database: Option<String>,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub sql: String,
|
||||
pub operation_class: OperationClass,
|
||||
pub risk_level: RiskLevel,
|
||||
pub is_production: bool,
|
||||
pub status: HandoffStatus,
|
||||
pub result_summary: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl HandoffItem {
|
||||
pub fn queued(
|
||||
connection_name: String,
|
||||
database: Option<String>,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Vec<HandoffItem>, 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<_>>(), 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue