1use redb::{ReadableTable, ReadableTableMetadata, TableDefinition};
11use serde::{Deserialize, Serialize};
12
13use super::Db;
14
15const DYNAMIC_WORKERS: TableDefinition<(&str, &str), &[u8]> =
16 TableDefinition::new("dynamic_workers");
17
18#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct DynamicWorker {
23 pub project_key: String,
24 pub label: String,
25 pub charter: String,
26 pub kick: Option<String>,
27 pub resume_kick: Option<String>,
30 #[serde(default)]
36 pub interactive: bool,
37}
38
39pub fn list_all(db: &Db) -> anyhow::Result<Vec<DynamicWorker>> {
42 let txn = db.database().begin_read()?;
43 let table = match txn.open_table(DYNAMIC_WORKERS) {
44 Ok(t) => t,
45 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
46 Err(e) => return Err(e.into()),
47 };
48 let mut out = Vec::new();
49 for entry in table.iter()? {
50 let (key, value) = entry?;
51 match serde_json::from_slice::<DynamicWorker>(value.value()) {
52 Ok(worker) => out.push(worker),
53 Err(err) => {
58 let (row_project, row_label) = key.value();
59 tracing::warn!(
60 target: "forge_workspace::store::dynamic_workers",
61 project = %row_project,
62 label = %row_label,
63 error = %err,
64 "skipping dynamic worker record that failed to decode",
65 );
66 }
67 }
68 }
69 Ok(out)
70}
71
72pub fn count(db: &Db) -> anyhow::Result<usize> {
79 let txn = db.database().begin_read()?;
80 let table = match txn.open_table(DYNAMIC_WORKERS) {
81 Ok(t) => t,
82 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
83 Err(e) => return Err(e.into()),
84 };
85 Ok(usize::try_from(table.len()?)?)
86}
87
88pub fn delete(db: &Db, project_key: &str, label: &str) -> anyhow::Result<bool> {
93 let txn = db.database().begin_write()?;
94 let removed = match txn.open_table(DYNAMIC_WORKERS) {
95 Ok(mut table) => table.remove((project_key, label))?.is_some(),
96 Err(redb::TableError::TableDoesNotExist(_)) => false,
97 Err(e) => return Err(e.into()),
98 };
99 txn.commit()?;
100 Ok(removed)
101}
102
103pub fn drop_table(db: &Db) -> anyhow::Result<bool> {
107 let txn = db.database().begin_write()?;
108 let dropped = txn.delete_table(DYNAMIC_WORKERS)?;
111 txn.commit()?;
112 Ok(dropped)
113}
114
115#[cfg(test)]
118pub(crate) fn insert_for_test(db: &Db, worker: &DynamicWorker) -> anyhow::Result<()> {
119 put_raw_for_test(db, &worker.project_key, &worker.label, &serde_json::to_vec(worker)?)
120}
121
122#[cfg(test)]
125pub(crate) fn put_raw_for_test(
126 db: &Db,
127 project_key: &str,
128 label: &str,
129 value: &[u8],
130) -> anyhow::Result<()> {
131 let txn = db.database().begin_write()?;
132 {
133 let mut table = txn.open_table(DYNAMIC_WORKERS)?;
134 table.insert((project_key, label), value)?;
135 }
136 txn.commit()?;
137 Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use tempfile::tempdir;
144
145 fn worker(project: &str, label: &str) -> DynamicWorker {
146 DynamicWorker {
147 project_key: project.to_owned(),
148 label: label.to_owned(),
149 charter: format!("charter for {label}"),
150 kick: Some(format!("kick for {label}")),
151 resume_kick: Some(format!("resume kick for {label}")),
152 interactive: false,
153 }
154 }
155
156 #[test]
162 fn row_written_before_interactive_existed_loads_as_not_interactive() {
163 let dir = tempdir().expect("tempdir");
164 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
165
166 let old_shape = serde_json::json!({
167 "project_key": "proj-a",
168 "label": "steward",
169 "charter": "mind the queues",
170 "kick": "begin",
171 "resume_kick": "re-read the notes",
172 });
173 let txn = db.database().begin_write().expect("begin");
174 {
175 let mut table = txn.open_table(DYNAMIC_WORKERS).expect("open table");
176 let value = serde_json::to_vec(&old_shape).expect("serialize old row");
177 table.insert(("proj-a", "steward"), value.as_slice()).expect("insert old row");
178 }
179 txn.commit().expect("commit");
180
181 let loaded = list_all(&db).expect("list");
182 assert_eq!(loaded.len(), 1, "a row written before `interactive` existed still decodes");
183 assert_eq!(
184 loaded[0],
185 DynamicWorker {
186 project_key: "proj-a".to_owned(),
187 label: "steward".to_owned(),
188 charter: "mind the queues".to_owned(),
189 kick: Some("begin".to_owned()),
190 resume_kick: Some("re-read the notes".to_owned()),
191 interactive: false,
192 },
193 "every prior field survives and an absent `interactive` reads as not-interactive",
194 );
195 }
196
197 #[test]
198 fn list_all_round_trips_and_skips_a_corrupt_row() {
199 let dir = tempdir().expect("tempdir");
200 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
201 insert_for_test(&db, &worker("proj-a", "reviewer")).expect("insert reviewer");
202 insert_for_test(&db, &worker("proj-b", "tester")).expect("insert tester");
203 let txn = db.database().begin_write().expect("begin");
204 {
205 let mut table = txn.open_table(DYNAMIC_WORKERS).expect("open table");
206 table.insert(("proj-a", "corrupt"), "not a worker".as_bytes()).expect("insert corrupt");
207 }
208 txn.commit().expect("commit");
209
210 let all = list_all(&db).expect("list tolerates the corrupt blob");
211 assert_eq!(all.len(), 2, "the good records survive a corrupt sibling");
212 assert_eq!(
213 all[1].resume_kick.as_deref(),
214 Some("resume kick for tester"),
215 "the record round-trips through redb",
216 );
217 }
218
219 #[test]
223 fn drop_table_reports_whether_the_table_was_there() {
224 let dir = tempdir().expect("tempdir");
225 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
226 assert!(
227 !drop_table(&db).expect("drop an absent table"),
228 "a store that never had the table drops nothing",
229 );
230
231 insert_for_test(&db, &worker("proj-a", "reviewer")).expect("insert reviewer");
232 assert!(drop_table(&db).expect("drop the table"), "a planted table drops to true");
233 assert!(list_all(&db).expect("list a dropped table").is_empty(), "and its rows are gone");
234 }
235}