Skip to main content

codewhale_protocol/
journal.rs

1//! Session tree journal placeholder (issue #5262).
2//!
3//! The journal is append-only with an in-memory tree projection. Every
4//! non-header entry carries `id` + `parentId`; the active position is a
5//! `leafId`; appending creates a child of the leaf; branching only moves the
6//! leaf — it never rewrites history. This file lands the *entry shape* that
7//! #5262's tree operations (`/tree`, `/branch`, `/fork`, `/resume`) and the
8//! deferred compaction/branch-summary entry kinds hang off of. The strategies
9//! themselves are deferred, but the shape must be stable now so no migration
10//! is needed later.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// One journal entry. All entries except the root header have `id` + `parent_id`.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct JournalEntry {
18    /// Stable entry id (`entry-{uuid}`).
19    pub id: String,
20    /// Parent entry id; `None` only for the root header.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub parent_id: Option<String>,
23    /// Entry kind (`header`, `user`, `assistant`, `tool_result`, `compaction`, `branch_summary`, …).
24    pub kind: String,
25    /// Payload (text, tool output, compaction summary, etc).
26    #[serde(default)]
27    pub payload: Value,
28    /// When the entry was created (unix seconds).
29    pub created_at: i64,
30}
31
32/// Append-only journal with a `leafId` cursor. The tree projection is
33/// derived root→leaf; moving `leaf_id` branches without rewriting history.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
35pub struct Journal {
36    pub entries: Vec<JournalEntry>,
37    /// Active position. `None` before the header is appended.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub leaf_id: Option<String>,
40}
41
42impl Journal {
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    #[must_use]
49    pub fn len(&self) -> usize {
50        self.entries.len()
51    }
52
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.entries.is_empty()
56    }
57
58    /// Append a new entry as a child of the current leaf. Returns the new id.
59    pub fn append(&mut self, kind: impl Into<String>, payload: Value) -> String {
60        let id = format!("entry-{}", uuid::Uuid::new_v4());
61        let parent_id = self.leaf_id.clone();
62        let entry = JournalEntry {
63            id: id.clone(),
64            parent_id,
65            kind: kind.into(),
66            payload,
67            created_at: chrono::Utc::now().timestamp(),
68        };
69        self.entries.push(entry);
70        self.leaf_id = Some(id.clone());
71        id
72    }
73
74    /// Branch: move `leaf_id` to an existing ancestor without rewriting.
75    /// Returns `false` when `target` is not found.
76    pub fn branch_to(&mut self, target: &str) -> bool {
77        if self.entries.iter().any(|e| e.id == target) {
78            self.leaf_id = Some(target.to_string());
79            true
80        } else {
81            false
82        }
83    }
84
85    /// Project the active path root→leaf as a slice of entries in order.
86    #[must_use]
87    pub fn active_path(&self) -> Vec<&JournalEntry> {
88        let Some(leaf) = self.leaf_id.as_deref() else {
89            return Vec::new();
90        };
91        // Build id→parent map for walk.
92        let mut by_id = std::collections::HashMap::new();
93        for e in &self.entries {
94            by_id.insert(e.id.as_str(), e);
95        }
96        let mut path = Vec::new();
97        let mut cur: Option<&str> = Some(leaf);
98        while let Some(id) = cur {
99            if let Some(entry) = by_id.get(id) {
100                path.push(*entry);
101                cur = entry.parent_id.as_deref();
102            } else {
103                break;
104            }
105        }
106        path.reverse();
107        path
108    }
109
110    /// Find entry by id.
111    #[must_use]
112    pub fn get(&self, id: &str) -> Option<&JournalEntry> {
113        self.entries.iter().find(|e| e.id == id)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use serde_json::json;
121
122    #[test]
123    fn append_sets_parent_and_leaf() {
124        let mut j = Journal::new();
125        let a = j.append("header", json!({}));
126        assert_eq!(j.leaf_id.as_deref(), Some(a.as_str()));
127        let b = j.append("user", json!("hi"));
128        let entry = j.get(&b).unwrap();
129        assert_eq!(entry.parent_id.as_deref(), Some(a.as_str()));
130        assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
131    }
132
133    #[test]
134    fn branching_only_moves_leaf() {
135        let mut j = Journal::new();
136        let a = j.append("header", json!({}));
137        let b = j.append("user", json!("b"));
138        let c = j.append("assistant", json!("c"));
139        assert_eq!(j.entries.len(), 3);
140        assert!(j.branch_to(&b));
141        assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
142        // History untouched.
143        assert_eq!(j.entries.len(), 3);
144        // Active path is now a→b.
145        let path = j.active_path();
146        assert_eq!(path.len(), 2);
147        assert_eq!(path[0].id, a);
148        assert_eq!(path[1].id, b);
149        let d = j.append("user", json!("d after branch"));
150        let ent = j.get(&d).unwrap();
151        assert_eq!(ent.parent_id.as_deref(), Some(b.as_str()));
152        // Old c still exists as a sibling branch that is no longer on the active path.
153        assert!(j.get(&c).is_some());
154        let path2 = j.active_path();
155        assert_eq!(path2.len(), 3);
156        assert_eq!(path2[2].id, d);
157    }
158
159    #[test]
160    fn journal_is_serializable_and_preserves_shape() {
161        let mut j = Journal::new();
162        j.append("header", json!({}));
163        j.append("user", json!("hello"));
164        let s = serde_json::to_string(&j).unwrap();
165        let back: Journal = serde_json::from_str(&s).unwrap();
166        assert_eq!(back, j);
167    }
168}