1use globset::Glob;
9use rusqlite::{params, Connection};
10use std::path::Path;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum BoardError {
15 #[error("board storage error: {0}")]
16 Storage(#[from] rusqlite::Error),
17 #[error("invalid claim glob '{glob}': {message}")]
18 BadGlob { glob: String, message: String },
19}
20
21#[derive(Debug)]
23pub struct Peer {
24 pub agent: String,
25 pub status: Option<String>,
26}
27
28#[derive(Debug)]
30pub struct Message {
31 pub id: i64,
32 pub from_agent: String,
33 pub body: String,
34}
35
36#[derive(Debug)]
38pub struct Claim {
39 pub agent: String,
40 pub path_glob: String,
41}
42
43pub struct Board {
44 conn: Connection,
45 run_id: String,
46}
47
48impl Board {
49 pub fn open(path: &Path, run_id: &str) -> Result<Self, BoardError> {
54 let conn = Connection::open(path)?;
55 conn.pragma_update(None, "journal_mode", "WAL")?;
56 conn.execute_batch(
59 "CREATE TABLE IF NOT EXISTS board_schema (version INTEGER NOT NULL);
60 CREATE TABLE IF NOT EXISTS agents (
61 run_id TEXT NOT NULL,
62 agent TEXT NOT NULL,
63 status TEXT,
64 registered_at_ms INTEGER NOT NULL,
65 PRIMARY KEY (run_id, agent)
66 );
67 CREATE TABLE IF NOT EXISTS messages (
68 id INTEGER PRIMARY KEY AUTOINCREMENT,
69 run_id TEXT NOT NULL,
70 from_agent TEXT NOT NULL,
71 to_agent TEXT,
72 body TEXT NOT NULL,
73 sent_at_ms INTEGER NOT NULL
74 );
75 CREATE TABLE IF NOT EXISTS cursors (
76 run_id TEXT NOT NULL,
77 agent TEXT NOT NULL,
78 last_read_id INTEGER NOT NULL,
79 PRIMARY KEY (run_id, agent)
80 );
81 CREATE TABLE IF NOT EXISTS claims (
82 run_id TEXT NOT NULL,
83 agent TEXT NOT NULL,
84 path_glob TEXT NOT NULL,
85 claimed_at_ms INTEGER NOT NULL,
86 PRIMARY KEY (run_id, path_glob)
87 );",
88 )?;
89 let versions: i64 =
90 conn.query_row("SELECT COUNT(*) FROM board_schema", [], |row| row.get(0))?;
91 if versions == 0 {
92 conn.execute("INSERT INTO board_schema (version) VALUES (1)", [])?;
93 }
94 Ok(Self {
95 conn,
96 run_id: run_id.to_owned(),
97 })
98 }
99
100 pub fn register(&self, agent: &str) -> Result<(), BoardError> {
105 self.conn.execute(
106 "INSERT INTO agents (run_id, agent, status, registered_at_ms)
107 VALUES (?1, ?2, NULL, ?3)
108 ON CONFLICT (run_id, agent) DO NOTHING",
109 params![self.run_id, agent, now_ms()],
110 )?;
111 Ok(())
112 }
113
114 pub fn set_status(&self, agent: &str, status: &str) -> Result<(), BoardError> {
119 self.register(agent)?;
120 self.conn.execute(
121 "UPDATE agents SET status = ?3 WHERE run_id = ?1 AND agent = ?2",
122 params![self.run_id, agent, status],
123 )?;
124 Ok(())
125 }
126
127 pub fn peers(&self, agent: &str) -> Result<Vec<Peer>, BoardError> {
132 let mut statement = self.conn.prepare(
133 "SELECT agent, status FROM agents
134 WHERE run_id = ?1 AND agent != ?2 ORDER BY agent",
135 )?;
136 let peers = statement
137 .query_map(params![self.run_id, agent], |row| {
138 Ok(Peer {
139 agent: row.get(0)?,
140 status: row.get(1)?,
141 })
142 })?
143 .collect::<Result<Vec<_>, _>>()?;
144 Ok(peers)
145 }
146}
147
148impl Board {
149 pub fn broadcast(&self, from_agent: &str, body: &str) -> Result<(), BoardError> {
154 self.conn.execute(
155 "INSERT INTO messages (run_id, from_agent, to_agent, body, sent_at_ms)
156 VALUES (?1, ?2, NULL, ?3, ?4)",
157 params![self.run_id, from_agent, body, now_ms()],
158 )?;
159 Ok(())
160 }
161
162 pub fn send(&self, from_agent: &str, to_agent: &str, body: &str) -> Result<(), BoardError> {
167 self.conn.execute(
168 "INSERT INTO messages (run_id, from_agent, to_agent, body, sent_at_ms)
169 VALUES (?1, ?2, ?3, ?4, ?5)",
170 params![self.run_id, from_agent, to_agent, body, now_ms()],
171 )?;
172 Ok(())
173 }
174
175 pub fn read(&self, agent: &str) -> Result<Vec<Message>, BoardError> {
181 let cursor: i64 = self
182 .conn
183 .query_row(
184 "SELECT last_read_id FROM cursors WHERE run_id = ?1 AND agent = ?2",
185 params![self.run_id, agent],
186 |row| row.get(0),
187 )
188 .unwrap_or(0);
189 let mut statement = self.conn.prepare(
190 "SELECT id, from_agent, body FROM messages
191 WHERE run_id = ?1 AND id > ?2
192 AND from_agent != ?3
193 AND (to_agent IS NULL OR to_agent = ?3)
194 ORDER BY id",
195 )?;
196 let messages = statement
197 .query_map(params![self.run_id, cursor, agent], |row| {
198 Ok(Message {
199 id: row.get(0)?,
200 from_agent: row.get(1)?,
201 body: row.get(2)?,
202 })
203 })?
204 .collect::<Result<Vec<_>, _>>()?;
205 if let Some(last) = messages.last() {
206 self.conn.execute(
207 "INSERT INTO cursors (run_id, agent, last_read_id) VALUES (?1, ?2, ?3)
208 ON CONFLICT (run_id, agent) DO UPDATE SET last_read_id = ?3",
209 params![self.run_id, agent, last.id],
210 )?;
211 }
212 Ok(messages)
213 }
214
215 pub fn claim(&self, agent: &str, path_glob: &str) -> Result<(), BoardError> {
222 Glob::new(path_glob).map_err(|error| BoardError::BadGlob {
223 glob: path_glob.to_owned(),
224 message: error.to_string(),
225 })?;
226 self.register(agent)?;
227 let changed = self.conn.execute(
228 "INSERT INTO claims (run_id, agent, path_glob, claimed_at_ms)
229 VALUES (?1, ?2, ?3, ?4)
230 ON CONFLICT (run_id, path_glob) DO NOTHING",
231 params![self.run_id, agent, path_glob, now_ms()],
232 )?;
233 if changed == 0 {
234 let holder: String = self.conn.query_row(
235 "SELECT agent FROM claims WHERE run_id = ?1 AND path_glob = ?2",
236 params![self.run_id, path_glob],
237 |row| row.get(0),
238 )?;
239 if holder != agent {
240 return Err(BoardError::Storage(rusqlite::Error::SqliteFailure(
241 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
242 Some(format!("path already claimed by {holder}")),
243 )));
244 }
245 }
246 Ok(())
247 }
248
249 pub fn release(&self, agent: &str, path_glob: &str) -> Result<bool, BoardError> {
254 let changed = self.conn.execute(
255 "DELETE FROM claims WHERE run_id = ?1 AND agent = ?2 AND path_glob = ?3",
256 params![self.run_id, agent, path_glob],
257 )?;
258 Ok(changed > 0)
259 }
260
261 pub fn claims(&self) -> Result<Vec<Claim>, BoardError> {
266 let mut statement = self
267 .conn
268 .prepare("SELECT agent, path_glob FROM claims WHERE run_id = ?1 ORDER BY path_glob")?;
269 let claims = statement
270 .query_map(params![self.run_id], |row| {
271 Ok(Claim {
272 agent: row.get(0)?,
273 path_glob: row.get(1)?,
274 })
275 })?
276 .collect::<Result<Vec<_>, _>>()?;
277 Ok(claims)
278 }
279
280 pub fn blocking_holder(&self, agent: &str, file: &str) -> Result<Option<String>, BoardError> {
286 for claim in self.claims()? {
287 if claim.agent != agent && glob_matches(&claim.path_glob, file) {
288 return Ok(Some(claim.agent));
289 }
290 }
291 Ok(None)
292 }
293}
294
295#[must_use]
299pub fn glob_matches(glob: &str, file: &str) -> bool {
300 Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
301}
302
303fn now_ms() -> i64 {
304 i64::try_from(
305 std::time::SystemTime::now()
306 .duration_since(std::time::UNIX_EPOCH)
307 .map_or(0, |duration| duration.as_millis()),
308 )
309 .unwrap_or(i64::MAX)
310}