1use anyhow::Context;
8use forge_primitives::tasks::{Estimate, LinkKind, Task, TaskId, TaskLink, TaskStatus, Waiting};
9use redb::{ReadableTable, TableDefinition};
10
11use super::Db;
12
13const TASKS: TableDefinition<&str, &[u8]> = TableDefinition::new("tasks");
14
15#[derive(serde::Deserialize)]
20struct TaskRecord {
21 id: forge_primitives::tasks::TaskId,
22 project_name: String,
23 subject: String,
24 #[serde(default)]
25 active_form: Option<String>,
26 #[serde(default)]
27 detail: Option<String>,
28 status: RecordStatus,
29 #[serde(default)]
30 owner: Option<forge_primitives::SessionSlot>,
31 #[serde(default)]
32 parent: Option<forge_primitives::tasks::TaskId>,
33 #[serde(default)]
34 waiting_on: Option<Waiting>,
35 #[serde(default)]
36 estimate: Option<RecordEstimate>,
37 #[serde(default)]
38 rank: Option<i64>,
39 #[serde(default)]
40 verify: Option<forge_primitives::tasks::Verify>,
41 #[serde(default)]
42 attested: Option<forge_primitives::tasks::Attestation>,
43 #[serde(default)]
44 reserved: Option<forge_primitives::tasks::ReservedBy>,
45 #[serde(default)]
46 links: Vec<TaskLink>,
47 #[serde(default)]
48 attempt: u32,
49 #[serde(default)]
50 archived_at: Option<std::time::SystemTime>,
51 created_at: std::time::SystemTime,
52 updated_at: std::time::SystemTime,
53 #[serde(default)]
54 artifact: Option<String>,
55}
56
57#[derive(Clone, Copy, serde::Deserialize)]
58#[serde(rename_all = "snake_case")]
59enum RecordStatus {
60 Pending,
61 InProgress,
62 Waiting,
63 Completed,
64 Failed,
65 Canceled,
66 Blocked,
68}
69
70#[derive(serde::Deserialize)]
71#[serde(untagged)]
72enum RecordEstimate {
73 Words(String),
75 Parsed(Estimate),
76}
77
78impl TaskRecord {
79 fn into_task(self) -> Task {
80 let blocked = matches!(self.status, RecordStatus::Blocked);
81 let status = match self.status {
82 RecordStatus::Pending => TaskStatus::Pending,
83 RecordStatus::InProgress => TaskStatus::InProgress,
84 RecordStatus::Waiting | RecordStatus::Blocked => TaskStatus::Waiting,
85 RecordStatus::Completed => TaskStatus::Completed,
86 RecordStatus::Failed => TaskStatus::Failed,
87 RecordStatus::Canceled => TaskStatus::Canceled,
88 };
89 let mut links = self.links;
90 if let Some(artifact) = &self.artifact
91 && !links.iter().any(|l| l.target == *artifact)
92 {
93 links.push(TaskLink {
94 kind: LinkKind::for_target(artifact),
95 label: None,
96 target: artifact.clone(),
97 state: None,
98 added_at: self.created_at,
99 });
100 }
101 Task {
102 id: self.id,
103 project_name: self.project_name,
104 subject: self.subject,
105 active_form: self.active_form,
106 detail: self.detail,
107 status,
108 owner: self.owner,
109 parent: self.parent,
110 waiting_on: if blocked {
111 Some(Waiting {
114 kind: None,
115 detail: None,
116 on: None,
117 verification: false,
118 parked: false,
119 since: None,
120 })
121 } else {
122 self.waiting_on
123 },
124 estimate: self.estimate.map(|estimate| match estimate {
125 RecordEstimate::Words(words) => {
129 Estimate::parse(&words).unwrap_or(Estimate { secs: 0, words })
130 }
131 RecordEstimate::Parsed(parsed) => parsed,
132 }),
133 rank: self.rank,
134 verify: self.verify,
135 attested: self.attested,
136 reserved: self.reserved,
137 links,
138 attempt: self.attempt,
139 archived_at: self.archived_at,
140 created_at: self.created_at,
141 updated_at: self.updated_at,
142 }
143 }
144}
145
146fn row_key(task: &Task) -> String {
149 row_key_for(&task.project_name, &task.id)
150}
151
152fn row_key_for(project_name: &str, id: &TaskId) -> String {
154 format!("{project_name}\u{0}{}", id.as_str())
155}
156
157pub fn list(db: &Db) -> anyhow::Result<Vec<Task>> {
159 let txn = db.database().begin_read()?;
160 let table = match txn.open_table(TASKS) {
161 Ok(t) => t,
162 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
165 Err(e) => return Err(e.into()),
166 };
167 let mut out = Vec::new();
168 for entry in table.iter()? {
169 let (key, value) = entry?;
170 match serde_json::from_slice::<TaskRecord>(value.value()) {
171 Ok(record) => out.push(record.into_task()),
172 Err(err) => tracing::warn!(
175 target: "forge_workspace::store::tasks",
176 row = %key.value(),
177 error = %err,
178 "skipping task record that failed to decode",
179 ),
180 }
181 }
182 Ok(out)
183}
184
185pub fn upsert(db: &Db, task: &Task) -> anyhow::Result<()> {
188 let value = serde_json::to_vec(task).context("serialize task")?;
189 let txn = db.database().begin_write()?;
190 {
191 let mut table = txn.open_table(TASKS)?;
192 table.insert(row_key(task).as_str(), value.as_slice())?;
193 }
194 txn.commit()?;
195 Ok(())
196}
197
198pub fn delete(db: &Db, project_name: &str, id: &TaskId) -> anyhow::Result<bool> {
200 let key = row_key_for(project_name, id);
201 let txn = db.database().begin_write()?;
202 let existed = {
203 let mut table = txn.open_table(TASKS)?;
204 table.remove(key.as_str())?.is_some()
205 };
206 txn.commit()?;
207 Ok(existed)
208}
209
210#[cfg(test)]
211mod tests {
212 use std::path::Path;
213
214 use super::*;
215 use forge_primitives::tasks::{TaskId, TaskStatus};
216 use tempfile::tempdir;
217
218 fn open_db(dir: &Path) -> Db {
219 Db::open(&dir.join("db.redb")).expect("open db")
220 }
221
222 fn sample_task(id: &str, project: &str) -> Task {
223 Task {
224 id: TaskId::from(id),
225 project_name: project.to_owned(),
226 subject: format!("subject {id}"),
227 active_form: None,
228 detail: None,
229 status: TaskStatus::Pending,
230 owner: None,
231 parent: None,
232 waiting_on: None,
233 estimate: None,
234 rank: None,
235 verify: None,
236 attested: None,
237 reserved: None,
238 links: Vec::new(),
239 attempt: 0,
240 archived_at: None,
241 created_at: std::time::SystemTime::UNIX_EPOCH,
242 updated_at: std::time::SystemTime::UNIX_EPOCH,
243 }
244 }
245
246 #[test]
247 fn tasks_are_scoped_by_project() {
248 let dir = tempdir().expect("tempdir");
249 let db = open_db(dir.path());
250 let forge_task = sample_task("t-1", "forge");
251 let other_task = sample_task("t-2", "other");
252 upsert(&db, &forge_task).expect("write forge's");
253 upsert(&db, &other_task).expect("write the other project's");
254 let read = list(&db).expect("list");
255 assert_eq!(read.len(), 2, "both projects' tasks are stored");
256 assert!(
257 read.iter().any(|t| t.project_name == "forge" && t.id == TaskId::from("t-1")),
258 "the forge task is stored under its own project",
259 );
260 }
261
262 #[test]
263 fn the_same_id_in_two_projects_stores_two_rows() {
264 let dir = tempdir().expect("tempdir");
265 let db = open_db(dir.path());
266 upsert(&db, &sample_task("t-1", "forge")).expect("write forge's");
267 upsert(&db, &sample_task("t-1", "other")).expect("write the other project's");
268 let read = list(&db).expect("list");
269 assert_eq!(read.len(), 2, "one project's row cannot overwrite another's on a shared id");
270 }
271
272 #[test]
277 fn a_delete_drops_only_the_named_row() {
278 let dir = tempdir().expect("tempdir");
279 let db = open_db(dir.path());
280 upsert(&db, &sample_task("t-1", "forge")).expect("seed t-1");
281 upsert(&db, &sample_task("t-2", "forge")).expect("seed t-2");
282 upsert(&db, &sample_task("t-1", "other")).expect("seed the other project's t-1");
283
284 assert!(delete(&db, "forge", &TaskId::from("t-1")).expect("delete"));
285 assert!(
286 !delete(&db, "forge", &TaskId::from("t-1")).expect("delete again"),
287 "a row that is not there deletes to false",
288 );
289 let read = list(&db).expect("list");
290 assert_eq!(read.len(), 2, "one row left per project: {read:?}");
291 assert!(
292 read.iter().any(|t| t.project_name == "forge" && t.id == TaskId::from("t-2")),
293 "the deleted row is gone from the store, not just from memory: {read:?}",
294 );
295 assert!(
296 read.iter().any(|t| t.project_name == "other" && t.id == TaskId::from("t-1")),
297 "and a same-named row under another project is its own row: {read:?}",
298 );
299 }
300
301 #[test]
302 fn an_absent_table_reads_as_empty_not_an_error() {
303 let dir = tempdir().expect("tempdir");
304 let db = open_db(dir.path());
305 assert!(list(&db).expect("list").is_empty(), "a fresh database has no tasks");
306 }
307
308 fn write_raw(db: &Db, key: &str, json: &str) {
310 let txn = db.database().begin_write().expect("write txn");
311 {
312 let mut table = txn.open_table(TASKS).expect("open");
313 table.insert(key, json.as_bytes()).expect("insert");
314 }
315 txn.commit().expect("commit");
316 }
317
318 #[test]
321 fn a_legacy_artifact_row_reads_as_a_link() {
322 let dir = tempdir().expect("tempdir");
323 let db = open_db(dir.path());
324 write_raw(
325 &db,
326 "forge\u{0}t-1",
327 r#"{
328 "id": "t-1", "project_name": "forge", "subject": "old row",
329 "active_form": null, "detail": null, "status": "in_progress",
330 "owner": null, "parent": null,
331 "artifact": "https://github.com/busytools/forge/pull/1889",
332 "estimate": "1d",
333 "created_at": {"secs_since_epoch": 0, "nanos_since_epoch": 0},
334 "updated_at": {"secs_since_epoch": 0, "nanos_since_epoch": 0}
335 }"#,
336 );
337 let read = list(&db).expect("list");
338 assert_eq!(read.len(), 1, "the legacy row loads: {read:?}");
339 let task = &read[0];
340 assert_eq!(task.links.len(), 1, "the artifact folded into a link");
341 assert_eq!(task.links[0].kind, LinkKind::Pr, "classified by its shape");
342 assert_eq!(task.links[0].target, "https://github.com/busytools/forge/pull/1889",);
343 assert_eq!(
344 task.estimate.as_ref().map(|e| (e.words.as_str(), e.secs)),
345 Some(("1d", 86_400)),
346 "the legacy estimate parsed with its words kept",
347 );
348 }
349
350 #[test]
353 fn a_legacy_blocked_row_reads_as_an_unstated_wait() {
354 let dir = tempdir().expect("tempdir");
355 let db = open_db(dir.path());
356 write_raw(
357 &db,
358 "forge\u{0}t-2",
359 r#"{
360 "id": "t-2", "project_name": "forge", "subject": "stuck row",
361 "active_form": null, "detail": null, "status": "blocked",
362 "owner": null, "parent": null,
363 "created_at": {"secs_since_epoch": 0, "nanos_since_epoch": 0},
364 "updated_at": {"secs_since_epoch": 0, "nanos_since_epoch": 0}
365 }"#,
366 );
367 let read = list(&db).expect("list");
368 assert_eq!(read.len(), 1, "the blocked row loads: {read:?}");
369 assert_eq!(read[0].status, TaskStatus::Waiting);
370 assert_eq!(
371 read[0].waiting_on,
372 Some(Waiting {
373 kind: None,
374 detail: None,
375 on: None,
376 verification: false,
377 parked: false,
378 since: None,
379 }),
380 "no kind, no words - the miss the board shows",
381 );
382 }
383
384 #[test]
386 fn a_current_row_round_trips_through_the_store() {
387 let dir = tempdir().expect("tempdir");
388 let db = open_db(dir.path());
389 let mut task = sample_task("t-3", "forge");
390 task.estimate = Some(Estimate { words: "2h".to_owned(), secs: 7_200 });
391 task.links.push(TaskLink {
392 kind: LinkKind::Issue,
393 label: Some("#1886".to_owned()),
394 target: "https://example.invalid/issues/1886".to_owned(),
395 state: Some("open".to_owned()),
396 added_at: std::time::SystemTime::UNIX_EPOCH,
397 });
398 upsert(&db, &task).expect("write");
399 assert_eq!(list(&db).expect("list"), vec![task], "no migration touches a current row");
400 }
401}