Skip to main content

forge_workspace/store/
task_history.rs

1//! Durable task history: one append-only row per status transition.
2//!
3//! Read by the board's worked-time computation and the retro's metrics;
4//! never rewritten. Keys carry project, task and the instant, so a read
5//! comes back grouped by task and ordered by time within each.
6
7use anyhow::Context;
8use forge_primitives::tasks::TaskTransition;
9use redb::{ReadableTable, TableDefinition};
10
11use super::Db;
12
13const TASK_HISTORY: TableDefinition<&str, &[u8]> = TableDefinition::new("task_history");
14
15/// The row key for one transition: project, task, and the instant as
16/// zero-padded nanos, so a task's rows read in the order they happened.
17fn row_key(transition: &TaskTransition) -> String {
18    let nanos =
19        transition.at.duration_since(std::time::SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
20    format!("{}\u{0}{}\u{0}{nanos:040}", transition.project_name, transition.task_id.as_str())
21}
22
23/// Append `transitions`; never rewrites what is there.
24pub fn append(db: &Db, transitions: &[TaskTransition]) -> anyhow::Result<()> {
25    let txn = db.database().begin_write()?;
26    {
27        let mut table = txn.open_table(TASK_HISTORY)?;
28        for transition in transitions {
29            let value = serde_json::to_vec(transition).context("serialize transition")?;
30            table.insert(row_key(transition).as_str(), value.as_slice())?;
31        }
32    }
33    txn.commit()?;
34    Ok(())
35}
36
37/// `project`'s transitions, grouped by task and ordered by time.
38pub fn list_for_project(db: &Db, project: &str) -> anyhow::Result<Vec<TaskTransition>> {
39    let txn = db.database().begin_read()?;
40    let table = match txn.open_table(TASK_HISTORY) {
41        Ok(t) => t,
42        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
43        Err(e) => return Err(e.into()),
44    };
45    let prefix = format!("{project}\u{0}");
46    let mut out = Vec::new();
47    for entry in table.iter()? {
48        let (key, value) = entry?;
49        if !key.value().starts_with(&prefix) {
50            continue;
51        }
52        match serde_json::from_slice::<TaskTransition>(value.value()) {
53            Ok(transition) => out.push(transition),
54            Err(err) => tracing::warn!(
55                target: "forge_workspace::store::task_history",
56                row = %key.value(),
57                error = %err,
58                "skipping history row that failed to decode",
59            ),
60        }
61    }
62    Ok(out)
63}
64
65#[cfg(test)]
66mod tests {
67    use std::path::Path;
68
69    use super::*;
70    use forge_primitives::tasks::{By, TaskId, TaskStatus};
71    use tempfile::tempdir;
72
73    fn open_db(dir: &Path) -> Db {
74        Db::open(&dir.join("db.redb")).expect("open db")
75    }
76
77    fn transition(task: &str, project: &str, at_secs: u64, to: TaskStatus) -> TaskTransition {
78        TaskTransition {
79            task_id: TaskId::from(task),
80            project_name: project.to_owned(),
81            from: Some(TaskStatus::Pending),
82            to,
83            at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(at_secs),
84            by: By::System,
85        }
86    }
87
88    #[test]
89    fn history_appends_and_reads_back_for_its_project() {
90        let dir = tempdir().expect("tempdir");
91        let db = open_db(dir.path());
92        append(
93            &db,
94            &[
95                transition("t-1", "forge", 20, TaskStatus::Completed),
96                transition("t-1", "forge", 10, TaskStatus::InProgress),
97                transition("t-2", "other", 30, TaskStatus::InProgress),
98            ],
99        )
100        .expect("append");
101        let forge = list_for_project(&db, "forge").expect("list");
102        assert_eq!(forge.len(), 2, "only forge's transitions: {forge:?}");
103        assert!(forge[0].at < forge[1].at, "oldest first within the task");
104        assert_eq!(forge[0].to, TaskStatus::InProgress);
105        assert_eq!(forge[1].to, TaskStatus::Completed);
106    }
107
108    #[test]
109    fn an_absent_history_table_reads_as_empty() {
110        let dir = tempdir().expect("tempdir");
111        let db = open_db(dir.path());
112        assert!(list_for_project(&db, "forge").expect("list").is_empty());
113    }
114}