返回 CodeWhale
journal.rs
根目录 / crates / protocol / src / 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
12 use serde::{Deserialize, Serialize};
13 use 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)]
17 pub 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)]
35 pub 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
42 impl 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)]
118 mod 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 }
169
169 lines RUST