1use rusqlite::Connection;
8use std::path::Path;
9use std::sync::{Arc, Mutex};
10
11use crate::error::ServerError;
12
13#[derive(Clone)]
15pub struct Database {
16 conn: Arc<Mutex<Connection>>,
17}
18
19impl Database {
20 fn ignore_duplicate_column(
21 result: Result<usize, rusqlite::Error>,
22 ) -> Result<(), rusqlite::Error> {
23 match result {
24 Ok(_) => Ok(()),
25 Err(error)
26 if error
27 .to_string()
28 .to_ascii_lowercase()
29 .contains("duplicate column name") =>
30 {
31 Ok(())
32 }
33 Err(error) => Err(error),
34 }
35 }
36
37 pub fn open(db_path: &str) -> Result<Self, ServerError> {
39 let path = Path::new(db_path);
40 if let Some(parent) = path.parent() {
41 std::fs::create_dir_all(parent).ok();
42 }
43
44 let conn = Connection::open(db_path)
45 .map_err(|e| ServerError::Database(format!("Failed to open database: {e}")))?;
46
47 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
48 .map_err(|e| ServerError::Database(format!("Failed to set pragmas: {e}")))?;
49
50 let db = Self {
51 conn: Arc::new(Mutex::new(conn)),
52 };
53
54 db.initialize_tables()?;
55
56 tracing::info!("SQLite database opened at: {}", db_path);
57 Ok(db)
58 }
59
60 pub fn open_in_memory() -> Result<Self, ServerError> {
62 let conn = Connection::open_in_memory()
63 .map_err(|e| ServerError::Database(format!("Failed to open in-memory db: {e}")))?;
64
65 conn.execute_batch("PRAGMA foreign_keys=ON;")
66 .map_err(|e| ServerError::Database(format!("Failed to set pragmas: {e}")))?;
67
68 let db = Self {
69 conn: Arc::new(Mutex::new(conn)),
70 };
71
72 db.initialize_tables()?;
73 Ok(db)
74 }
75
76 pub fn with_conn<F, T>(&self, f: F) -> Result<T, ServerError>
79 where
80 F: FnOnce(&Connection) -> Result<T, rusqlite::Error>,
81 {
82 let conn = self
83 .conn
84 .lock()
85 .map_err(|e| ServerError::Database(format!("Lock poisoned: {e}")))?;
86 f(&conn).map_err(|e| ServerError::Database(e.to_string()))
87 }
88
89 pub async fn with_conn_async<F, T>(&self, f: F) -> Result<T, ServerError>
91 where
92 F: FnOnce(&Connection) -> Result<T, rusqlite::Error> + Send + 'static,
93 T: Send + 'static,
94 {
95 let db = self.clone();
96 tokio::task::spawn_blocking(move || db.with_conn(f))
97 .await
98 .map_err(|e| ServerError::Database(format!("Task join error: {e}")))?
99 }
100
101 fn initialize_tables(&self) -> Result<(), ServerError> {
103 self.with_conn(|conn| {
104 conn.execute_batch(
105 "
106 CREATE TABLE IF NOT EXISTS workspaces (
107 id TEXT PRIMARY KEY,
108 title TEXT NOT NULL,
109 status TEXT NOT NULL DEFAULT 'active',
110 metadata TEXT NOT NULL DEFAULT '{}',
111 created_at INTEGER NOT NULL,
112 updated_at INTEGER NOT NULL
113 );
114
115 CREATE TABLE IF NOT EXISTS codebases (
116 id TEXT PRIMARY KEY,
117 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
118 repo_path TEXT NOT NULL,
119 branch TEXT,
120 label TEXT,
121 is_default INTEGER NOT NULL DEFAULT 0,
122 source_type TEXT,
123 source_url TEXT,
124 created_at INTEGER NOT NULL,
125 updated_at INTEGER NOT NULL
126 );
127 CREATE INDEX IF NOT EXISTS idx_codebases_workspace ON codebases(workspace_id);
128
129 CREATE TABLE IF NOT EXISTS acp_sessions (
130 id TEXT PRIMARY KEY,
131 name TEXT,
132 cwd TEXT NOT NULL,
133 branch TEXT,
134 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
135 routa_agent_id TEXT,
136 provider_session_id TEXT,
137 provider TEXT,
138 role TEXT,
139 mode_id TEXT,
140 custom_command TEXT,
141 custom_args TEXT NOT NULL DEFAULT '[]',
142 first_prompt_sent INTEGER DEFAULT 0,
143 message_history TEXT NOT NULL DEFAULT '[]',
144 created_at INTEGER NOT NULL,
145 updated_at INTEGER NOT NULL
146 );
147 CREATE INDEX IF NOT EXISTS idx_acp_sessions_workspace ON acp_sessions(workspace_id);
148
149 CREATE TABLE IF NOT EXISTS skills (
150 id TEXT PRIMARY KEY,
151 name TEXT NOT NULL,
152 description TEXT NOT NULL DEFAULT '',
153 source TEXT NOT NULL,
154 catalog_type TEXT NOT NULL DEFAULT 'skillssh',
155 files TEXT NOT NULL DEFAULT '[]',
156 license TEXT,
157 metadata TEXT NOT NULL DEFAULT '{}',
158 installs INTEGER NOT NULL DEFAULT 0,
159 created_at INTEGER NOT NULL,
160 updated_at INTEGER NOT NULL
161 );
162
163 CREATE TABLE IF NOT EXISTS workspace_skills (
164 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
165 skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
166 installed_at INTEGER NOT NULL,
167 PRIMARY KEY (workspace_id, skill_id)
168 );
169
170 CREATE TABLE IF NOT EXISTS agents (
171 id TEXT PRIMARY KEY,
172 name TEXT NOT NULL,
173 role TEXT NOT NULL,
174 model_tier TEXT NOT NULL DEFAULT 'SMART',
175 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
176 parent_id TEXT,
177 status TEXT NOT NULL DEFAULT 'PENDING',
178 metadata TEXT NOT NULL DEFAULT '{}',
179 created_at INTEGER NOT NULL,
180 updated_at INTEGER NOT NULL
181 );
182
183 CREATE TABLE IF NOT EXISTS tasks (
184 id TEXT PRIMARY KEY,
185 title TEXT NOT NULL,
186 objective TEXT NOT NULL,
187 comment TEXT,
188 scope TEXT,
189 acceptance_criteria TEXT,
190 verification_commands TEXT,
191 test_cases TEXT,
192 assigned_to TEXT,
193 status TEXT NOT NULL DEFAULT 'PENDING',
194 board_id TEXT,
195 column_id TEXT,
196 position INTEGER NOT NULL DEFAULT 0,
197 priority TEXT,
198 labels TEXT NOT NULL DEFAULT '[]',
199 assignee TEXT,
200 assigned_provider TEXT,
201 assigned_role TEXT,
202 assigned_specialist_id TEXT,
203 assigned_specialist_name TEXT,
204 trigger_session_id TEXT,
205 github_id TEXT,
206 github_number INTEGER,
207 github_url TEXT,
208 github_repo TEXT,
209 github_state TEXT,
210 github_synced_at INTEGER,
211 last_sync_error TEXT,
212 dependencies TEXT NOT NULL DEFAULT '[]',
213 parallel_group TEXT,
214 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
215 session_id TEXT,
216 creation_source TEXT,
217 session_ids TEXT NOT NULL DEFAULT '[]',
218 lane_sessions TEXT NOT NULL DEFAULT '[]',
219 lane_handoffs TEXT NOT NULL DEFAULT '[]',
220 completion_summary TEXT,
221 verification_verdict TEXT,
222 verification_report TEXT,
223 codebase_ids TEXT NOT NULL DEFAULT '[]',
224 worktree_id TEXT,
225 version INTEGER NOT NULL DEFAULT 1,
226 created_at INTEGER NOT NULL,
227 updated_at INTEGER NOT NULL
228 );
229 CREATE TABLE IF NOT EXISTS artifacts (
230 id TEXT PRIMARY KEY,
231 type TEXT NOT NULL,
232 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
233 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
234 provided_by_agent_id TEXT,
235 requested_by_agent_id TEXT,
236 request_id TEXT,
237 content TEXT,
238 context TEXT,
239 status TEXT NOT NULL DEFAULT 'provided',
240 expires_at INTEGER,
241 metadata TEXT,
242 created_at INTEGER NOT NULL,
243 updated_at INTEGER NOT NULL
244 );
245 CREATE TABLE IF NOT EXISTS kanban_boards (
246 id TEXT PRIMARY KEY,
247 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
248 name TEXT NOT NULL,
249 is_default INTEGER NOT NULL DEFAULT 0,
250 columns TEXT NOT NULL DEFAULT '[]',
251 created_at INTEGER NOT NULL,
252 updated_at INTEGER NOT NULL
253 );
254 CREATE TABLE IF NOT EXISTS notes (
255 id TEXT NOT NULL,
256 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
257 session_id TEXT,
258 title TEXT NOT NULL,
259 content TEXT NOT NULL DEFAULT '',
260 type TEXT NOT NULL DEFAULT 'general',
261 task_status TEXT,
262 assigned_agent_ids TEXT,
263 parent_note_id TEXT,
264 linked_task_id TEXT,
265 custom_metadata TEXT,
266 created_at INTEGER NOT NULL,
267 updated_at INTEGER NOT NULL,
268 PRIMARY KEY (workspace_id, id)
269 );
270
271 CREATE TABLE IF NOT EXISTS messages (
272 id TEXT PRIMARY KEY,
273 agent_id TEXT NOT NULL,
274 role TEXT NOT NULL,
275 content TEXT NOT NULL,
276 timestamp INTEGER NOT NULL,
277 tool_name TEXT,
278 tool_args TEXT,
279 turn INTEGER
280 );
281
282 CREATE TABLE IF NOT EXISTS event_subscriptions (
283 id TEXT PRIMARY KEY,
284 agent_id TEXT NOT NULL,
285 agent_name TEXT NOT NULL,
286 event_types TEXT NOT NULL,
287 exclude_self INTEGER NOT NULL DEFAULT 1,
288 one_shot INTEGER NOT NULL DEFAULT 0,
289 wait_group_id TEXT,
290 priority INTEGER NOT NULL DEFAULT 0,
291 created_at INTEGER NOT NULL
292 );
293
294 CREATE TABLE IF NOT EXISTS pending_events (
295 id TEXT PRIMARY KEY,
296 agent_id TEXT NOT NULL,
297 event_type TEXT NOT NULL,
298 source_agent_id TEXT NOT NULL,
299 workspace_id TEXT NOT NULL,
300 data TEXT NOT NULL DEFAULT '{}',
301 timestamp INTEGER NOT NULL
302 );
303
304 CREATE INDEX IF NOT EXISTS idx_agents_workspace ON agents(workspace_id);
305 CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);
306 CREATE INDEX IF NOT EXISTS idx_artifacts_task ON artifacts(task_id);
307 CREATE INDEX IF NOT EXISTS idx_artifacts_workspace ON artifacts(workspace_id);
308 CREATE INDEX IF NOT EXISTS idx_kanban_boards_workspace ON kanban_boards(workspace_id);
309 CREATE UNIQUE INDEX IF NOT EXISTS uq_kanban_boards_default_workspace ON kanban_boards(workspace_id) WHERE is_default = 1;
310 CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id);
311 CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
312
313 CREATE TABLE IF NOT EXISTS schedules (
314 id TEXT PRIMARY KEY,
315 name TEXT NOT NULL,
316 cron_expr TEXT NOT NULL,
317 task_prompt TEXT NOT NULL,
318 agent_id TEXT NOT NULL,
319 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
320 enabled INTEGER NOT NULL DEFAULT 1,
321 last_run_at INTEGER,
322 next_run_at INTEGER,
323 last_task_id TEXT,
324 prompt_template TEXT,
325 created_at INTEGER NOT NULL,
326 updated_at INTEGER NOT NULL
327 );
328 CREATE INDEX IF NOT EXISTS idx_schedules_workspace ON schedules(workspace_id);
329 CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at) WHERE enabled = 1;
330
331 CREATE TABLE IF NOT EXISTS worktrees (
332 id TEXT PRIMARY KEY,
333 codebase_id TEXT NOT NULL REFERENCES codebases(id) ON DELETE CASCADE,
334 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
335 worktree_path TEXT NOT NULL,
336 branch TEXT NOT NULL,
337 base_branch TEXT NOT NULL,
338 status TEXT NOT NULL DEFAULT 'creating',
339 session_id TEXT,
340 label TEXT,
341 error_message TEXT,
342 created_at INTEGER NOT NULL,
343 updated_at INTEGER NOT NULL
344 );
345 CREATE INDEX IF NOT EXISTS idx_worktrees_workspace ON worktrees(workspace_id);
346 CREATE INDEX IF NOT EXISTS idx_worktrees_codebase ON worktrees(codebase_id);
347 CREATE UNIQUE INDEX IF NOT EXISTS uq_worktrees_codebase_branch
348 ON worktrees(codebase_id, branch);
349 CREATE UNIQUE INDEX IF NOT EXISTS uq_worktrees_path
350 ON worktrees(worktree_path);
351 "
352 )
353 })?;
354 self.run_migrations()
355 }
356
357 fn run_migrations(&self) -> Result<(), ServerError> {
359 self.with_conn(|conn| {
360 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN comment TEXT", []))?;
362 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN session_id TEXT", []))?;
363 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN board_id TEXT", []))?;
364 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN column_id TEXT", []))?;
365 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN position INTEGER NOT NULL DEFAULT 0", []))?;
366 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN priority TEXT", []))?;
367 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'", []))?;
368 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN assignee TEXT", []))?;
369 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN assigned_provider TEXT", []))?;
370 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN assigned_role TEXT", []))?;
371 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN assigned_specialist_id TEXT", []))?;
372 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN assigned_specialist_name TEXT", []))?;
373 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN trigger_session_id TEXT", []))?;
374 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_id TEXT", []))?;
375 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_number INTEGER", []))?;
376 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_url TEXT", []))?;
377 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_repo TEXT", []))?;
378 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_state TEXT", []))?;
379 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN github_synced_at INTEGER", []))?;
380 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN last_sync_error TEXT", []))?;
381 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN test_cases TEXT", []))?;
382 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN codebase_ids TEXT NOT NULL DEFAULT '[]'", []))?;
383 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN worktree_id TEXT", []))?;
384 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN creation_source TEXT", []))?;
385 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN session_ids TEXT NOT NULL DEFAULT '[]'", []))?;
386 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN lane_sessions TEXT NOT NULL DEFAULT '[]'", []))?;
387 Self::ignore_duplicate_column(conn.execute("ALTER TABLE tasks ADD COLUMN lane_handoffs TEXT NOT NULL DEFAULT '[]'", []))?;
388 Self::ignore_duplicate_column(conn.execute("ALTER TABLE notes ADD COLUMN session_id TEXT", []))?;
390 Self::ignore_duplicate_column(conn.execute("ALTER TABLE acp_sessions ADD COLUMN branch TEXT", []))?;
391 Self::ignore_duplicate_column(conn.execute("ALTER TABLE acp_sessions ADD COLUMN parent_session_id TEXT", []))?;
393 Self::ignore_duplicate_column(conn.execute("ALTER TABLE acp_sessions ADD COLUMN provider_session_id TEXT", []))?;
394 Self::ignore_duplicate_column(conn.execute("ALTER TABLE acp_sessions ADD COLUMN custom_command TEXT", []))?;
395 Self::ignore_duplicate_column(conn.execute("ALTER TABLE acp_sessions ADD COLUMN custom_args TEXT NOT NULL DEFAULT '[]'", []))?;
396 Self::ignore_duplicate_column(conn.execute("ALTER TABLE codebases ADD COLUMN source_type TEXT", []))?;
397 Self::ignore_duplicate_column(conn.execute("ALTER TABLE codebases ADD COLUMN source_url TEXT", []))?;
398 conn.execute_batch(
399 "CREATE TABLE IF NOT EXISTS kanban_boards (
400 id TEXT PRIMARY KEY,
401 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
402 name TEXT NOT NULL,
403 is_default INTEGER NOT NULL DEFAULT 0,
404 columns TEXT NOT NULL DEFAULT '[]',
405 created_at INTEGER NOT NULL,
406 updated_at INTEGER NOT NULL
407 );
408 CREATE INDEX IF NOT EXISTS idx_kanban_boards_workspace ON kanban_boards(workspace_id);
409 CREATE UNIQUE INDEX IF NOT EXISTS uq_kanban_boards_default_workspace ON kanban_boards(workspace_id) WHERE is_default = 1;"
410 )?;
411 conn.execute_batch(
412 "CREATE TABLE IF NOT EXISTS artifacts (
413 id TEXT PRIMARY KEY,
414 type TEXT NOT NULL,
415 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
416 workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
417 provided_by_agent_id TEXT,
418 requested_by_agent_id TEXT,
419 request_id TEXT,
420 content TEXT,
421 context TEXT,
422 status TEXT NOT NULL DEFAULT 'provided',
423 expires_at INTEGER,
424 metadata TEXT,
425 created_at INTEGER NOT NULL,
426 updated_at INTEGER NOT NULL
427 );
428 CREATE INDEX IF NOT EXISTS idx_artifacts_task ON artifacts(task_id);
429 CREATE INDEX IF NOT EXISTS idx_artifacts_workspace ON artifacts(workspace_id);"
430 )?;
431 Self::ignore_duplicate_column(conn.execute("ALTER TABLE kanban_boards ADD COLUMN columns TEXT NOT NULL DEFAULT '[]'", []))?;
432 let _ = conn.execute("UPDATE kanban_boards SET columns = columns_json WHERE (columns IS NULL OR columns = '[]') AND columns_json IS NOT NULL", []);
433 conn.execute_batch(
435 "CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
436 CREATE INDEX IF NOT EXISTS idx_notes_session ON notes(session_id);
437 CREATE INDEX IF NOT EXISTS idx_acp_sessions_parent ON acp_sessions(parent_session_id);"
438 )
439 })
440 }
441}