use globset::Glob;
use rusqlite::{params, Connection};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BoardError {
#[error("board storage error: {0}")]
Storage(#[from] rusqlite::Error),
#[error("invalid claim glob '{glob}': {message}")]
BadGlob { glob: String, message: String },
}
#[derive(Debug)]
pub struct Peer {
pub agent: String,
pub status: Option<String>,
}
#[derive(Debug)]
pub struct Message {
pub id: i64,
pub from_agent: String,
pub body: String,
}
#[derive(Debug)]
pub struct Claim {
pub agent: String,
pub path_glob: String,
}
pub struct Board {
conn: Connection,
run_id: String,
}
impl Board {
pub fn open(path: &Path, run_id: &str) -> Result<Self, BoardError> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS board_schema (version INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS agents (
run_id TEXT NOT NULL,
agent TEXT NOT NULL,
status TEXT,
registered_at_ms INTEGER NOT NULL,
PRIMARY KEY (run_id, agent)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
from_agent TEXT NOT NULL,
to_agent TEXT,
body TEXT NOT NULL,
sent_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cursors (
run_id TEXT NOT NULL,
agent TEXT NOT NULL,
last_read_id INTEGER NOT NULL,
PRIMARY KEY (run_id, agent)
);
CREATE TABLE IF NOT EXISTS claims (
run_id TEXT NOT NULL,
agent TEXT NOT NULL,
path_glob TEXT NOT NULL,
claimed_at_ms INTEGER NOT NULL,
PRIMARY KEY (run_id, path_glob)
);",
)?;
let versions: i64 =
conn.query_row("SELECT COUNT(*) FROM board_schema", [], |row| row.get(0))?;
if versions == 0 {
conn.execute("INSERT INTO board_schema (version) VALUES (1)", [])?;
}
Ok(Self {
conn,
run_id: run_id.to_owned(),
})
}
pub fn register(&self, agent: &str) -> Result<(), BoardError> {
self.conn.execute(
"INSERT INTO agents (run_id, agent, status, registered_at_ms)
VALUES (?1, ?2, NULL, ?3)
ON CONFLICT (run_id, agent) DO NOTHING",
params![self.run_id, agent, now_ms()],
)?;
Ok(())
}
pub fn set_status(&self, agent: &str, status: &str) -> Result<(), BoardError> {
self.register(agent)?;
self.conn.execute(
"UPDATE agents SET status = ?3 WHERE run_id = ?1 AND agent = ?2",
params![self.run_id, agent, status],
)?;
Ok(())
}
pub fn peers(&self, agent: &str) -> Result<Vec<Peer>, BoardError> {
let mut statement = self.conn.prepare(
"SELECT agent, status FROM agents
WHERE run_id = ?1 AND agent != ?2 ORDER BY agent",
)?;
let peers = statement
.query_map(params![self.run_id, agent], |row| {
Ok(Peer {
agent: row.get(0)?,
status: row.get(1)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(peers)
}
}
impl Board {
pub fn broadcast(&self, from_agent: &str, body: &str) -> Result<(), BoardError> {
self.conn.execute(
"INSERT INTO messages (run_id, from_agent, to_agent, body, sent_at_ms)
VALUES (?1, ?2, NULL, ?3, ?4)",
params![self.run_id, from_agent, body, now_ms()],
)?;
Ok(())
}
pub fn send(&self, from_agent: &str, to_agent: &str, body: &str) -> Result<(), BoardError> {
self.conn.execute(
"INSERT INTO messages (run_id, from_agent, to_agent, body, sent_at_ms)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![self.run_id, from_agent, to_agent, body, now_ms()],
)?;
Ok(())
}
pub fn read(&self, agent: &str) -> Result<Vec<Message>, BoardError> {
let cursor: i64 = self
.conn
.query_row(
"SELECT last_read_id FROM cursors WHERE run_id = ?1 AND agent = ?2",
params![self.run_id, agent],
|row| row.get(0),
)
.unwrap_or(0);
let mut statement = self.conn.prepare(
"SELECT id, from_agent, body FROM messages
WHERE run_id = ?1 AND id > ?2
AND from_agent != ?3
AND (to_agent IS NULL OR to_agent = ?3)
ORDER BY id",
)?;
let messages = statement
.query_map(params![self.run_id, cursor, agent], |row| {
Ok(Message {
id: row.get(0)?,
from_agent: row.get(1)?,
body: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
if let Some(last) = messages.last() {
self.conn.execute(
"INSERT INTO cursors (run_id, agent, last_read_id) VALUES (?1, ?2, ?3)
ON CONFLICT (run_id, agent) DO UPDATE SET last_read_id = ?3",
params![self.run_id, agent, last.id],
)?;
}
Ok(messages)
}
pub fn claim(&self, agent: &str, path_glob: &str) -> Result<(), BoardError> {
Glob::new(path_glob).map_err(|error| BoardError::BadGlob {
glob: path_glob.to_owned(),
message: error.to_string(),
})?;
self.register(agent)?;
let changed = self.conn.execute(
"INSERT INTO claims (run_id, agent, path_glob, claimed_at_ms)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (run_id, path_glob) DO NOTHING",
params![self.run_id, agent, path_glob, now_ms()],
)?;
if changed == 0 {
let holder: String = self.conn.query_row(
"SELECT agent FROM claims WHERE run_id = ?1 AND path_glob = ?2",
params![self.run_id, path_glob],
|row| row.get(0),
)?;
if holder != agent {
return Err(BoardError::Storage(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some(format!("path already claimed by {holder}")),
)));
}
}
Ok(())
}
pub fn release(&self, agent: &str, path_glob: &str) -> Result<bool, BoardError> {
let changed = self.conn.execute(
"DELETE FROM claims WHERE run_id = ?1 AND agent = ?2 AND path_glob = ?3",
params![self.run_id, agent, path_glob],
)?;
Ok(changed > 0)
}
pub fn claims(&self) -> Result<Vec<Claim>, BoardError> {
let mut statement = self
.conn
.prepare("SELECT agent, path_glob FROM claims WHERE run_id = ?1 ORDER BY path_glob")?;
let claims = statement
.query_map(params![self.run_id], |row| {
Ok(Claim {
agent: row.get(0)?,
path_glob: row.get(1)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(claims)
}
pub fn blocking_holder(&self, agent: &str, file: &str) -> Result<Option<String>, BoardError> {
for claim in self.claims()? {
if claim.agent != agent && glob_matches(&claim.path_glob, file) {
return Ok(Some(claim.agent));
}
}
Ok(None)
}
}
#[must_use]
pub fn glob_matches(glob: &str, file: &str) -> bool {
Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
}
fn now_ms() -> i64 {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis()),
)
.unwrap_or(i64::MAX)
}