use anyhow::{Result, bail};
use rusqlite::{Connection, OptionalExtension, Transaction};
use serde_json::{Value, json};
pub fn register(name: &str) -> Result<Value> {
let conn = crate::db::open_existing()?;
register_inner(&conn, name)
}
fn register_inner(conn: &Connection, name: &str) -> Result<Value> {
let name = name.trim();
if name.is_empty() {
bail!("agent name must not be empty or whitespace-only");
}
let result = conn.execute("INSERT INTO agents (name) VALUES (?1)", [name]);
match result {
Ok(_) => {
let id = conn.last_insert_rowid();
let (id, name, created_at): (i64, String, String) = conn.query_row(
"SELECT id, name, created_at FROM agents WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
Ok(json!({"id": id, "name": name, "created_at": created_at}))
}
Err(rusqlite::Error::SqliteFailure(ffi_err, _))
if ffi_err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
bail!("agent '{name}' already exists")
}
Err(e) => Err(e.into()),
}
}
pub fn list() -> Result<Value> {
let conn = crate::db::open_existing()?;
list_inner(&conn)
}
fn list_inner(conn: &Connection) -> Result<Value> {
let mut stmt = conn.prepare("SELECT id, name, created_at FROM agents ORDER BY name")?;
let rows = stmt.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let created_at: String = row.get(2)?;
Ok(json!({"id": id, "name": name, "created_at": created_at}))
})?;
let agents: std::result::Result<Vec<Value>, rusqlite::Error> = rows.collect();
Ok(Value::Array(agents?))
}
pub fn remove(name: &str) -> Result<Value> {
let mut conn = crate::db::open_existing()?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let result = remove_inner(&tx, name)?;
tx.commit()?;
Ok(result)
}
fn remove_inner(tx: &Transaction, name: &str) -> Result<Value> {
let id: Option<i64> = tx
.query_row("SELECT id FROM agents WHERE name = ?1", [name], |row| {
row.get(0)
})
.optional()?;
let Some(id) = id else {
bail!("agent '{name}' not found");
};
let released_tasks: Vec<i64> = {
let mut stmt = tx.prepare(
"UPDATE tasks SET executor = NULL, status = 'todo', updated_at = datetime('now') \
WHERE executor = ?1 RETURNING id",
)?;
let rows = stmt.query_map([id], |row| row.get::<_, i64>(0))?;
rows.collect::<std::result::Result<Vec<i64>, rusqlite::Error>>()?
};
let deleted = tx.execute("DELETE FROM agents WHERE id = ?1", [id])?;
if deleted == 0 {
bail!("agent '{name}' not found");
}
Ok(json!({"removed": name, "released_tasks": released_tasks}))
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
const SCHEMA: &str = r"
CREATE TABLE agents (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
priority TEXT NOT NULL CHECK (priority IN ('low','medium','high','urgent')),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('backlog','todo','in_progress','review','done')),
executor INTEGER REFERENCES agents(id),
tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags)),
tests TEXT NOT NULL CHECK (json_valid(tests) AND json_array_length(tests) > 0),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
";
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
conn.execute_batch(SCHEMA).unwrap();
conn
}
fn setup_file_backed() -> (tempfile::TempDir, Connection) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("board.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
conn.execute_batch(SCHEMA).unwrap();
(dir, conn)
}
#[test]
fn register_propagates_non_constraint_errors_uncaught() {
let (dir, conn) = setup_file_backed();
let db_path = dir.path().join("board.db");
drop(conn);
let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&db_path, perms).unwrap();
let conn = Connection::open(&db_path).unwrap();
let err = register_inner(&conn, "agent-1").unwrap_err();
assert!(
!err.to_string().contains("already exists"),
"unexpected message: {err}"
);
assert!(
err.to_string().contains("readonly"),
"expected a readonly-database error, got: {err}"
);
let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&db_path, perms).unwrap();
}
#[test]
fn register_succeeds_and_returns_expected_json() {
let conn = setup();
let result = register_inner(&conn, "agent-1").unwrap();
assert_eq!(result["name"], "agent-1");
assert!(result["id"].is_i64());
assert!(result["created_at"].is_string());
}
#[test]
fn register_rejects_empty_name() {
let conn = setup();
let err = register_inner(&conn, " ").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn register_duplicate_returns_clean_error() {
let conn = setup();
register_inner(&conn, "agent-1").unwrap();
let err = register_inner(&conn, "agent-1").unwrap_err();
assert_eq!(err.to_string(), "agent 'agent-1' already exists");
}
#[test]
fn list_returns_registered_agents() {
let conn = setup();
register_inner(&conn, "bravo").unwrap();
register_inner(&conn, "alpha").unwrap();
let result = list_inner(&conn).unwrap();
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "alpha");
assert_eq!(arr[1]["name"], "bravo");
}
#[test]
fn remove_cascades_and_releases_tasks() {
let mut conn = setup();
let agent = register_inner(&conn, "agent-1").unwrap();
let agent_id = agent["id"].as_i64().unwrap();
conn.execute(
"INSERT INTO tasks (id, title, priority, status, executor, tests, updated_at) \
VALUES (1, 'do thing', 'medium', 'in_progress', ?1, '[\"test\"]', '2000-01-01 00:00:00')",
[agent_id],
)
.unwrap();
let tx = conn.transaction().unwrap();
let result = remove_inner(&tx, "agent-1").unwrap();
tx.commit().unwrap();
assert_eq!(result["removed"], "agent-1");
assert_eq!(result["released_tasks"].as_array().unwrap().len(), 1);
assert_eq!(result["released_tasks"][0], 1);
let (executor, status, updated_at): (Option<i64>, String, String) = conn
.query_row(
"SELECT executor, status, updated_at FROM tasks WHERE id = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(executor, None);
assert_eq!(status, "todo");
assert_ne!(updated_at, "2000-01-01 00:00:00");
let agent_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agents WHERE id = ?1",
[agent_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(agent_count, 0);
}
#[test]
fn remove_reports_not_found_if_row_vanishes_between_lookup_and_delete() {
let mut conn = setup();
conn.execute_batch(
"CREATE TRIGGER steal_agent_row AFTER UPDATE OF executor ON tasks \
WHEN OLD.executor IS NOT NULL AND NEW.executor IS NULL \
BEGIN DELETE FROM agents WHERE id = OLD.executor; END;",
)
.unwrap();
let agent = register_inner(&conn, "agent-1").unwrap();
let agent_id = agent["id"].as_i64().unwrap();
conn.execute(
"INSERT INTO tasks (id, title, priority, status, executor, tests) \
VALUES (1, 'do thing', 'medium', 'in_progress', ?1, '[\"test\"]')",
[agent_id],
)
.unwrap();
let tx = conn.transaction().unwrap();
let err = remove_inner(&tx, "agent-1").unwrap_err();
assert_eq!(err.to_string(), "agent 'agent-1' not found");
}
#[test]
fn remove_nonexistent_returns_clean_error() {
let mut conn = setup();
let tx = conn.transaction().unwrap();
let err = remove_inner(&tx, "ghost").unwrap_err();
assert_eq!(err.to_string(), "agent 'ghost' not found");
}
#[test]
fn register_trims_whitespace() {
let conn = setup();
let result = register_inner(&conn, " alice ").unwrap();
assert_eq!(result["name"], "alice");
let list = list_inner(&conn).unwrap();
let arr = list.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], "alice");
}
}