1use rusqlite::{params, OptionalExtension, Row};
17use serde::{Deserialize, Serialize};
18
19use super::store::Store;
20use super::StoreError;
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct WorkClaim {
27 pub id: String,
28 pub agent: String,
29 #[serde(default)]
31 pub agent_uid: String,
32 pub channel: String,
33 pub repo: Option<String>,
34 pub path: Option<String>,
36 pub absolute_path: Option<String>,
37 pub branch: Option<String>,
38 pub topic: Option<String>,
39 pub note: String,
40 pub created_at: i64,
41 pub expires_at: i64,
42}
43
44pub(crate) const SCHEMA: &str = "
49 CREATE TABLE IF NOT EXISTS db_work_claims (
50 id TEXT PRIMARY KEY,
51 agent TEXT NOT NULL,
52 agent_uid TEXT NOT NULL DEFAULT '',
53 channel TEXT NOT NULL DEFAULT '',
54 repo TEXT,
55 path TEXT,
56 absolute_path TEXT,
57 branch TEXT,
58 topic TEXT,
59 note TEXT NOT NULL DEFAULT '',
60 created_at INTEGER NOT NULL,
61 expires_at INTEGER NOT NULL
62 );
63 CREATE INDEX IF NOT EXISTS idx_ids_work_claims_expires ON db_work_claims(expires_at);";
64
65const COLS: &str =
66 "id, agent, agent_uid, channel, repo, path, absolute_path, branch, topic, note, created_at, expires_at";
67
68fn row_to_claim(row: &Row) -> rusqlite::Result<WorkClaim> {
69 Ok(WorkClaim {
70 id: row.get("id")?,
71 agent: row.get("agent")?,
72 agent_uid: row.get("agent_uid")?,
73 channel: row.get("channel")?,
74 repo: row.get("repo")?,
75 path: row.get("path")?,
76 absolute_path: row.get("absolute_path")?,
77 branch: row.get("branch")?,
78 topic: row.get("topic")?,
79 note: row.get("note")?,
80 created_at: row.get("created_at")?,
81 expires_at: row.get("expires_at")?,
82 })
83}
84
85const HOLDER: &str = "((agent_uid <> '' AND ?2 <> '' AND agent_uid = ?2) \
88 OR ((agent_uid = '' OR ?2 = '') AND agent = ?1 COLLATE NOCASE))";
89
90impl Store {
91 pub fn work_claim_put(&self, claim: &WorkClaim, now_ms: i64) -> Result<WorkClaim, StoreError> {
95 let conn = self.conn().lock().unwrap();
96 conn.execute("DELETE FROM db_work_claims WHERE expires_at <= ?1", params![now_ms])?;
97 let sql = format!(
100 "SELECT id, created_at FROM db_work_claims
101 WHERE {HOLDER}
102 AND repo IS ?3 AND path IS ?4 AND absolute_path IS ?5 AND branch IS ?6
103 AND topic IS ?7 COLLATE NOCASE
104 LIMIT 1"
105 );
106 let existing: Option<(String, i64)> = conn
107 .query_row(
108 &sql,
109 params![
110 claim.agent,
111 claim.agent_uid,
112 claim.repo,
113 claim.path,
114 claim.absolute_path,
115 claim.branch,
116 claim.topic
117 ],
118 |r| Ok((r.get(0)?, r.get(1)?)),
119 )
120 .optional()?;
121 let mut stored = claim.clone();
122 if let Some((id, created_at)) = existing {
123 stored.id = id;
124 stored.created_at = created_at;
125 }
126 conn.execute(
127 &format!("INSERT OR REPLACE INTO db_work_claims ({COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)"),
128 params![
129 stored.id,
130 stored.agent,
131 stored.agent_uid,
132 stored.channel,
133 stored.repo,
134 stored.path,
135 stored.absolute_path,
136 stored.branch,
137 stored.topic,
138 stored.note,
139 stored.created_at,
140 stored.expires_at
141 ],
142 )?;
143 Ok(stored)
144 }
145
146 pub fn work_claims_live(&self, now_ms: i64) -> Result<Vec<WorkClaim>, StoreError> {
148 let conn = self.conn().lock().unwrap();
149 let mut stmt =
150 conn.prepare(&format!("SELECT {COLS} FROM db_work_claims WHERE expires_at > ?1 ORDER BY created_at, id"))?;
151 let rows = stmt.query_map(params![now_ms], row_to_claim)?;
152 Ok(rows.collect::<Result<Vec<_>, _>>()?)
153 }
154
155 pub fn work_claim_release(
159 &self,
160 agent: &str,
161 agent_uid: &str,
162 id: Option<&str>,
163 now_ms: i64,
164 ) -> Result<Vec<WorkClaim>, StoreError> {
165 let conn = self.conn().lock().unwrap();
166 let sql = format!(
167 "DELETE FROM db_work_claims
168 WHERE {HOLDER} AND (?3 IS NULL OR id = ?3) AND expires_at > ?4
169 RETURNING {COLS}"
170 );
171 let mut stmt = conn.prepare(&sql)?;
172 let rows = stmt.query_map(params![agent, agent_uid, id, now_ms], row_to_claim)?;
173 let released = rows.collect::<Result<Vec<_>, _>>()?;
174 drop(stmt);
175 conn.execute("DELETE FROM db_work_claims WHERE expires_at <= ?1", params![now_ms])?;
176 Ok(released)
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 const NOW: i64 = 1_000_000_000;
185 const MIN: i64 = 60_000;
186
187 fn store() -> (tempfile::TempDir, Store) {
188 let dir = tempfile::tempdir().unwrap();
189 let s = Store::open_identity_store(&dir.path().join("identity-store.db")).unwrap();
190 (dir, s)
191 }
192
193 fn claim(id: &str, agent: &str, path: &str) -> WorkClaim {
194 WorkClaim {
195 id: id.into(),
196 agent: agent.into(),
197 channel: "stable".into(),
198 repo: Some("o/r".into()),
199 path: Some(path.into()),
200 note: "presence work".into(),
201 created_at: NOW,
202 expires_at: NOW + 120 * MIN,
203 ..Default::default()
204 }
205 }
206
207 #[test]
208 fn a_claim_is_listed_until_it_expires() {
209 let (_d, s) = store();
210 s.work_claim_put(&claim("c1", "Agent5", "crates/srv/src/muxbus"), NOW).unwrap();
211 assert_eq!(s.work_claims_live(NOW).unwrap().len(), 1);
212 assert_eq!(s.work_claims_live(NOW + 120 * MIN - 1).unwrap().len(), 1);
213 assert!(s.work_claims_live(NOW + 120 * MIN).unwrap().is_empty(), "expired");
214 }
215
216 #[test]
217 fn claiming_the_same_target_again_renews_it_and_keeps_its_id() {
218 let (_d, s) = store();
219 s.work_claim_put(&claim("c1", "Agent5", "a.rs"), NOW).unwrap();
220 let again = WorkClaim {
221 note: "still on it".into(),
222 created_at: NOW + 30 * MIN,
223 expires_at: NOW + 200 * MIN,
224 ..claim("c2", "agent5", "a.rs")
225 };
226 let stored = s.work_claim_put(&again, NOW + 30 * MIN).unwrap();
227 assert_eq!((stored.id.as_str(), stored.created_at), ("c1", NOW));
228 let live = s.work_claims_live(NOW + 30 * MIN).unwrap();
229 assert_eq!(live.len(), 1);
230 assert_eq!((live[0].note.as_str(), live[0].expires_at), ("still on it", NOW + 200 * MIN));
231
232 s.work_claim_put(&claim("c3", "Agent5", "b.rs"), NOW).unwrap();
234 s.work_claim_put(&claim("c4", "Agent4", "a.rs"), NOW).unwrap();
235 assert_eq!(s.work_claims_live(NOW).unwrap().len(), 3);
236 }
237
238 #[test]
239 fn only_the_holder_releases_one_claim_or_all_of_its_own() {
240 let (_d, s) = store();
241 s.work_claim_put(&claim("c1", "Agent5", "a.rs"), NOW).unwrap();
242 s.work_claim_put(&claim("c2", "Agent5", "b.rs"), NOW).unwrap();
243 s.work_claim_put(&claim("c3", "Agent4", "a.rs"), NOW).unwrap();
244
245 assert!(s.work_claim_release("Agent4", "", Some("c1"), NOW).unwrap().is_empty(), "not Agent4's");
246 let one = s.work_claim_release("agent5", "", Some("c1"), NOW).unwrap();
247 assert_eq!(one.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec!["c1"]);
248 let rest = s.work_claim_release("Agent5", "", None, NOW).unwrap();
249 assert_eq!(rest.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec!["c2"]);
250 let left = s.work_claims_live(NOW).unwrap();
251 assert_eq!(left.iter().map(|c| c.agent.as_str()).collect::<Vec<_>>(), vec!["Agent4"]);
252 }
253
254 #[test]
255 fn a_uid_decides_when_both_sides_have_one() {
256 let (_d, s) = store();
257 let mine = WorkClaim { agent_uid: "u1".into(), ..claim("c1", "Agent5", "a.rs") };
258 s.work_claim_put(&mine, NOW).unwrap();
259 assert!(s.work_claim_release("Agent5", "u2", None, NOW).unwrap().is_empty(), "same name, another UID");
260 assert_eq!(s.work_claim_release("Agent5", "u1", None, NOW).unwrap().len(), 1);
261 }
262}