use anyhow::{Result, bail};
use rusqlite::{Connection, OptionalExtension};
use serde_json::Value;
pub fn claim(id: i64, agent: &str) -> Result<Value> {
let conn = crate::db::open_existing()?;
claim_inner(&conn, id, agent)
}
fn claim_inner(conn: &Connection, id: i64, agent: &str) -> Result<Value> {
let agent_id: Option<i64> = conn
.query_row("SELECT id FROM agents WHERE name = ?1", [agent], |row| {
row.get(0)
})
.optional()?;
let Some(agent_id) = agent_id else {
bail!("agent '{agent}' is not registered; run `agent-kanban agent register {agent}` first");
};
claim_with_agent_id(conn, id, agent_id, agent)
}
fn claim_with_agent_id(conn: &Connection, id: i64, agent_id: i64, agent: &str) -> Result<Value> {
let result = conn.execute(
"UPDATE tasks SET executor = ?1, status = 'in_progress', updated_at = datetime('now') \
WHERE id = ?2 AND executor IS NULL",
rusqlite::params![agent_id, id],
);
let changed = match result {
Ok(changed) => changed,
Err(rusqlite::Error::SqliteFailure(ffi_err, _))
if ffi_err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY =>
{
bail!(
"agent '{agent}' is not registered; run `agent-kanban agent register {agent}` first"
)
}
Err(e) => return Err(e.into()),
};
if changed == 0 {
let executor: Option<Option<i64>> = conn
.query_row("SELECT executor FROM tasks WHERE id = ?1", [id], |row| {
row.get::<_, Option<i64>>(0)
})
.optional()?;
match executor {
None => bail!("task {id} not found"),
Some(_) => bail!("task {id} is already claimed"),
}
}
crate::commands::fetch_task(conn, id)
}
pub fn move_status(id: i64, status: &str) -> Result<Value> {
let conn = crate::db::open_existing()?;
move_status_inner(&conn, id, status)
}
fn move_status_inner(conn: &Connection, id: i64, status: &str) -> Result<Value> {
const VALID: [&str; 5] = ["backlog", "todo", "in_progress", "review", "done"];
if !VALID.contains(&status) {
bail!("invalid status '{status}': must be one of backlog, todo, in_progress, review, done");
}
let changed = conn.execute(
"UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2",
rusqlite::params![status, id],
)?;
if changed == 0 {
bail!("task {id} not found");
}
crate::commands::fetch_task(conn, id)
}
pub fn release(id: i64) -> Result<Value> {
let conn = crate::db::open_existing()?;
release_inner(&conn, id)
}
fn release_inner(conn: &Connection, id: i64) -> Result<Value> {
let changed = conn.execute(
"UPDATE tasks SET executor = NULL, status = 'todo', updated_at = datetime('now') \
WHERE id = ?1 AND executor IS NOT NULL",
[id],
)?;
if changed == 0 {
return Err(diagnose_release_guard_failure(conn, id));
}
crate::commands::fetch_task(conn, id)
}
fn diagnose_release_guard_failure(conn: &Connection, id: i64) -> anyhow::Error {
let executor: rusqlite::Result<Option<Option<i64>>> = conn
.query_row("SELECT executor FROM tasks WHERE id = ?1", [id], |row| {
row.get::<_, Option<i64>>(0)
})
.optional();
match executor {
Err(e) => e.into(),
Ok(None) => anyhow::anyhow!("task {id} not found"),
Ok(Some(None)) => anyhow::anyhow!("task {id} is not claimed"),
Ok(Some(Some(_))) => anyhow::anyhow!(
"task {id} could not be released (state changed concurrently; try again)"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
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
}
#[test]
fn claim_propagates_non_constraint_errors_uncaught() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("board.db");
let setup_conn = Connection::open(&path).unwrap();
setup_conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
setup_conn.execute_batch(SCHEMA).unwrap();
let agent_id = insert_agent(&setup_conn, "agent-a");
let task_id = insert_task(&setup_conn, "do thing");
drop(setup_conn);
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&path, perms).unwrap();
let conn = Connection::open(&path).unwrap();
let err = claim_with_agent_id(&conn, task_id, agent_id, "agent-a").unwrap_err();
assert!(
!err.to_string().contains("not registered"),
"unexpected message: {err}"
);
assert!(
err.to_string().contains("readonly"),
"expected a readonly-database error, got: {err}"
);
let mut perms = std::fs::metadata(&path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&path, perms).unwrap();
}
fn insert_agent(conn: &Connection, name: &str) -> i64 {
conn.execute("INSERT INTO agents (name) VALUES (?1)", [name])
.unwrap();
conn.last_insert_rowid()
}
fn insert_task(conn: &Connection, title: &str) -> i64 {
conn.execute(
"INSERT INTO tasks (title, priority, tests) VALUES (?1, 'medium', '[\"t\"]')",
[title],
)
.unwrap();
conn.last_insert_rowid()
}
fn get_raw_executor(conn: &Connection, id: i64) -> Option<i64> {
conn.query_row("SELECT executor FROM tasks WHERE id = ?1", [id], |row| {
row.get(0)
})
.unwrap()
}
#[test]
fn claim_succeeds_and_sets_executor_and_status() {
let conn = setup();
insert_agent(&conn, "agent-a");
let task_id = insert_task(&conn, "do thing");
let result = claim_inner(&conn, task_id, "agent-a").unwrap();
assert_eq!(result["executor"], "agent-a");
assert_eq!(result["status"], "in_progress");
}
#[test]
fn claim_unregistered_agent_fails_and_leaves_executor_null() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
let result = claim_inner(&conn, task_id, "ghost-agent");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("not registered"), "unexpected message: {msg}");
assert_eq!(get_raw_executor(&conn, task_id), None);
}
#[test]
fn claim_already_claimed_task_fails_and_does_not_change_holder() {
let conn = setup();
let a1 = insert_agent(&conn, "agent-a");
insert_agent(&conn, "agent-b");
let task_id = insert_task(&conn, "do thing");
claim_inner(&conn, task_id, "agent-a").unwrap();
let result = claim_inner(&conn, task_id, "agent-b");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("already claimed"), "unexpected message: {msg}");
assert_eq!(get_raw_executor(&conn, task_id), Some(a1));
}
#[test]
fn claim_nonexistent_task_fails_with_not_found() {
let conn = setup();
insert_agent(&conn, "agent-a");
let result = claim_inner(&conn, 999, "agent-a");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("not found"), "unexpected message: {msg}");
}
#[test]
fn claim_with_stale_agent_id_fails_cleanly_and_leaves_executor_null() {
let conn = setup();
let stale_agent_id = insert_agent(&conn, "agent-a");
let task_id = insert_task(&conn, "do thing");
conn.execute("DELETE FROM agents WHERE id = ?1", [stale_agent_id])
.unwrap();
let result = claim_with_agent_id(&conn, task_id, stale_agent_id, "agent-a");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
!msg.contains("FOREIGN KEY"),
"raw sqlite error leaked: {msg}"
);
assert!(msg.contains("not registered"), "unexpected message: {msg}");
assert_eq!(get_raw_executor(&conn, task_id), None);
}
#[test]
fn move_status_allows_transition_out_of_done() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
let done = move_status_inner(&conn, task_id, "done").unwrap();
assert_eq!(done["status"], "done");
let back = move_status_inner(&conn, task_id, "todo").unwrap();
assert_eq!(back["status"], "todo");
}
#[test]
fn move_status_updates_status_and_bumps_updated_at() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
conn.execute(
"UPDATE tasks SET updated_at = '2000-01-01 00:00:00' WHERE id = ?1",
[task_id],
)
.unwrap();
let result = move_status_inner(&conn, task_id, "review").unwrap();
assert_eq!(result["status"], "review");
assert_ne!(result["updated_at"], "2000-01-01 00:00:00");
}
#[test]
fn move_status_rejects_invalid_status() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
let result = move_status_inner(&conn, task_id, "bogus");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("invalid status"), "unexpected message: {msg}");
}
#[test]
fn move_status_nonexistent_task_fails() {
let conn = setup();
let result = move_status_inner(&conn, 999, "todo");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn release_clears_executor_and_resets_status() {
let conn = setup();
insert_agent(&conn, "agent-a");
let task_id = insert_task(&conn, "do thing");
claim_inner(&conn, task_id, "agent-a").unwrap();
let result = release_inner(&conn, task_id).unwrap();
assert_eq!(result["executor"], Value::Null);
assert_eq!(result["status"], "todo");
assert_eq!(get_raw_executor(&conn, task_id), None);
}
#[test]
fn release_guard_sql_is_noop_on_unclaimed_task() {
const GUARDED_RELEASE: &str = "UPDATE tasks SET executor = NULL, status = 'todo', \
updated_at = datetime('now') WHERE id = ?1 AND executor IS NOT NULL";
let conn = setup();
let task_id = insert_task(&conn, "do thing");
let changed = conn.execute(GUARDED_RELEASE, [task_id]).unwrap();
assert_eq!(
changed, 0,
"releasing an already-unclaimed task must be a no-op"
);
}
#[test]
fn release_unclaimed_task_errors() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
let result = release_inner(&conn, task_id);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("not claimed"), "unexpected message: {msg}");
}
#[test]
fn release_nonexistent_task_errors() {
let conn = setup();
let result = release_inner(&conn, 999);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn diagnose_release_guard_failure_propagates_non_missing_row_errors_uncaught() {
let conn = setup();
let task_id = insert_task(&conn, "do thing");
conn.execute_batch("DROP TABLE tasks;").unwrap();
let err = diagnose_release_guard_failure(&conn, task_id);
assert!(
!err.to_string().contains("not found"),
"unexpected message: {err}"
);
assert!(
err.to_string().contains("no such table"),
"expected a 'no such table' error, got: {err}"
);
}
#[test]
fn diagnose_release_guard_failure_reports_state_changed_concurrently() {
let conn = setup();
insert_agent(&conn, "agent-a");
let task_id = insert_task(&conn, "do thing");
claim_inner(&conn, task_id, "agent-a").unwrap();
let err = diagnose_release_guard_failure(&conn, task_id);
assert!(
err.to_string().contains("state changed concurrently"),
"unexpected message: {err}"
);
}
}