use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; #[derive(Clone)] pub struct Database { pub pool: SqlitePool, } impl Database { pub async fn new(path: &str) -> anyhow::Result { let url = format!("sqlite:{}?mode=rwc", path); let pool = SqlitePoolOptions::new() .max_connections(5) .connect(&url) .await?; Ok(Self { pool }) } pub async fn migrate(&self) -> anyhow::Result<()> { sqlx::query( "CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), requirement TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflows(id), content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; // Migration: add report column to workflows let _ = sqlx::query( "ALTER TABLE workflows ADD COLUMN report TEXT NOT NULL DEFAULT ''" ) .execute(&self.pool) .await; // Migration: add template_id column to workflows let _ = sqlx::query( "ALTER TABLE workflows ADD COLUMN template_id TEXT NOT NULL DEFAULT ''" ) .execute(&self.pool) .await; // Migration: add deleted column to projects let _ = sqlx::query( "ALTER TABLE projects ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0" ) .execute(&self.pool) .await; // Migration: add owner_id column to projects let _ = sqlx::query( "ALTER TABLE projects ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''" ) .execute(&self.pool) .await; // KB tables sqlx::query( "CREATE TABLE IF NOT EXISTS kb_articles ( id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS kb_chunks ( id TEXT PRIMARY KEY, article_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL, embedding BLOB NOT NULL )" ) .execute(&self.pool) .await?; // Migration: add article_id to kb_chunks if missing let _ = sqlx::query( "ALTER TABLE kb_chunks ADD COLUMN article_id TEXT NOT NULL DEFAULT ''" ) .execute(&self.pool) .await; // Migrate old kb_content to kb_articles let has_old_table: bool = sqlx::query_scalar( "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='kb_content'" ) .fetch_one(&self.pool) .await .unwrap_or(false); if has_old_table { let old_content: Option = sqlx::query_scalar( "SELECT content FROM kb_content WHERE id = 1" ) .fetch_optional(&self.pool) .await .unwrap_or(None); if let Some(content) = old_content { if !content.is_empty() { let id = uuid::Uuid::new_v4().to_string(); let _ = sqlx::query( "INSERT OR IGNORE INTO kb_articles (id, title, content) VALUES (?, '导入的知识库', ?)" ) .bind(&id) .bind(&content) .execute(&self.pool) .await; } } let _ = sqlx::query("DROP TABLE kb_content") .execute(&self.pool) .await; } // New tables: agent_state_snapshots + execution_log sqlx::query( "CREATE TABLE IF NOT EXISTS agent_state_snapshots ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflows(id), step_order INTEGER NOT NULL, state_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS execution_log ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflows(id), step_order INTEGER NOT NULL, tool_name TEXT NOT NULL, tool_input TEXT NOT NULL DEFAULT '', output TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running', created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS llm_call_log ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflows(id), step_order INTEGER NOT NULL, phase TEXT NOT NULL, messages_count INTEGER NOT NULL, tools_count INTEGER NOT NULL, tool_calls TEXT NOT NULL DEFAULT '[]', text_response TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER, completion_tokens INTEGER, latency_ms INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; // Migration: add text_response column to llm_call_log let _ = sqlx::query( "ALTER TABLE llm_call_log ADD COLUMN text_response TEXT NOT NULL DEFAULT ''" ) .execute(&self.pool) .await; sqlx::query( "CREATE TABLE IF NOT EXISTS timers ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, interval_secs INTEGER NOT NULL, requirement TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, last_run_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', picture TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_login_at TEXT NOT NULL DEFAULT (datetime('now')) )" ) .execute(&self.pool) .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS step_artifacts ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, step_order INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, artifact_type TEXT NOT NULL DEFAULT 'file', description TEXT NOT NULL DEFAULT '' )" ) .execute(&self.pool) .await?; // Migration: add role column to users (admin = see all projects) let _ = sqlx::query( "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'" ) .execute(&self.pool) .await; sqlx::query( "CREATE TABLE IF NOT EXISTS project_members ( project_id TEXT NOT NULL REFERENCES projects(id), user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'owner', created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (project_id, user_id) )" ) .execute(&self.pool) .await?; // Migration: assign all existing memberless projects to the first user (or leave for manual assignment) // When auth is not configured, owner_id is empty — these projects are visible to everyone // When a user logs in and creates projects, they get auto-added as admin { // Find existing projects with owner_id set but no members yet let owned: Vec<(String, String)> = sqlx::query_as( "SELECT p.id, p.owner_id FROM projects p \ WHERE p.deleted = 0 AND p.owner_id != '' \ AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)" ) .fetch_all(&self.pool) .await .unwrap_or_default(); for (pid, uid) in owned { let _ = sqlx::query( "INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')" ) .bind(&pid) .bind(&uid) .execute(&self.pool) .await; } // For orphan projects (no owner, no members), assign to first user if one exists let first_user: Option<(String,)> = sqlx::query_as( "SELECT id FROM users ORDER BY created_at ASC LIMIT 1" ) .fetch_optional(&self.pool) .await .unwrap_or(None); if let Some((first_uid,)) = first_user { let orphans: Vec<(String,)> = sqlx::query_as( "SELECT p.id FROM projects p \ WHERE p.deleted = 0 \ AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)" ) .fetch_all(&self.pool) .await .unwrap_or_default(); for (pid,) in orphans { let _ = sqlx::query( "INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')" ) .bind(&pid) .bind(&first_uid) .execute(&self.pool) .await; } } } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Project { pub id: String, pub name: String, pub description: String, pub created_at: String, pub updated_at: String, #[serde(default)] pub deleted: bool, #[serde(default)] pub owner_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Workflow { pub id: String, pub project_id: String, pub requirement: String, pub status: String, pub created_at: String, pub report: String, pub template_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct ExecutionLogEntry { pub id: String, pub workflow_id: String, pub step_order: i32, pub tool_name: String, pub tool_input: String, pub output: String, pub status: String, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Comment { pub id: String, pub workflow_id: String, pub content: String, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct KbArticle { pub id: String, pub title: String, pub content: String, pub updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Timer { pub id: String, pub project_id: String, pub name: String, pub interval_secs: i64, pub requirement: String, pub enabled: bool, pub last_run_at: String, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct LlmCallLogEntry { pub id: String, pub workflow_id: String, pub step_order: i32, pub phase: String, pub messages_count: i32, pub tools_count: i32, pub tool_calls: String, pub text_response: String, pub prompt_tokens: Option, pub completion_tokens: Option, pub latency_ms: i32, pub created_at: String, }