Skip to main content

harn_hostlib/code_index/
versions.rs

1//! Append-only version log of file mutations.
2//!
3//! Every successful write through the host's `version_record` op lands here
4//! with a monotonic sequence number. Agents call `changes_since(seq)`
5//! to catch up between turns without re-reading every file, and the seq
6//! numbers let higher-level tooling spot "agent fighting itself" loops
7//! before they cause damage.
8//!
9//! Per-file history is capped at [`HISTORY_LIMIT`] entries so an agent
10//! that thrashes one path doesn't blow up the log indefinitely. The seq
11//! counter itself is global.
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Maximum number of entries kept per path. Older entries roll off the
17/// front in FIFO order.
18pub const HISTORY_LIMIT: usize = 200;
19
20/// Edit-classification for one record. The string forms ride out to Harn
21/// scripts and the cross-repo schema so callers can switch on them.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum EditOp {
25    /// Mark a snapshot/checkpoint, not a content change.
26    Snapshot,
27    /// Whole-file write.
28    Write,
29    /// Targeted line-range replacement.
30    ReplaceRange,
31    /// Insertion after a specified line.
32    InsertAfter,
33    /// Targeted line-range deletion.
34    DeleteRange,
35    /// Patch/diff application.
36    Patch,
37    /// File deletion.
38    Delete,
39}
40
41impl EditOp {
42    /// String form used by the Harn host bridge.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            EditOp::Snapshot => "snapshot",
46            EditOp::Write => "write",
47            EditOp::ReplaceRange => "replace_range",
48            EditOp::InsertAfter => "insert_after",
49            EditOp::DeleteRange => "delete_range",
50            EditOp::Patch => "patch",
51            EditOp::Delete => "delete",
52        }
53    }
54
55    /// Parse from the string form; returns `None` for unknown variants so
56    /// the caller can decide whether to default to `Write` or to error.
57    pub fn parse(raw: &str) -> Option<Self> {
58        Some(match raw {
59            "snapshot" => EditOp::Snapshot,
60            "write" => EditOp::Write,
61            "replace_range" => EditOp::ReplaceRange,
62            "insert_after" => EditOp::InsertAfter,
63            "delete_range" => EditOp::DeleteRange,
64            "patch" => EditOp::Patch,
65            "delete" => EditOp::Delete,
66            _ => return None,
67        })
68    }
69}
70
71/// One entry in the per-file history.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub struct VersionEntry {
74    /// Globally monotonic sequence number.
75    pub seq: u64,
76    /// Agent that recorded the edit.
77    pub agent_id: u64,
78    /// Wall-clock ms since the Unix epoch.
79    pub timestamp_ms: i64,
80    /// Edit classification.
81    pub op: EditOp,
82    /// Post-edit content hash (or `0` if not applicable, e.g. `Delete`).
83    pub hash: u64,
84    /// Post-edit byte size.
85    pub size: u64,
86}
87
88/// Public denormalised form returned by `changes_since`.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ChangeRecord {
91    /// Workspace-relative path the edit was attributed to.
92    pub path: String,
93    /// Globally monotonic sequence number.
94    pub seq: u64,
95    /// Agent id that recorded the edit.
96    pub agent_id: u64,
97    /// Edit classification.
98    pub op: EditOp,
99    /// Post-edit hash.
100    pub hash: u64,
101    /// Post-edit size.
102    pub size: u64,
103    /// Wall-clock ms since the Unix epoch.
104    pub timestamp_ms: i64,
105}
106
107/// Append-only log keyed by path. Both forward query patterns —
108/// "everything since X" and "the latest entry for this path" — are
109/// served from the same map.
110#[derive(Debug, Default, Clone, Serialize, Deserialize)]
111pub struct VersionLog {
112    /// Latest assigned sequence number. Monotonically increases.
113    #[serde(default)]
114    pub current_seq: u64,
115    /// Per-path history, newest-last; capped at [`HISTORY_LIMIT`] entries.
116    #[serde(default)]
117    pub history: HashMap<String, Vec<VersionEntry>>,
118}
119
120impl VersionLog {
121    /// Construct an empty log.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Record one edit. Returns the assigned seq, which is what the host
127    /// builtin echoes back to the caller.
128    pub fn record(
129        &mut self,
130        path: impl Into<String>,
131        agent_id: u64,
132        op: EditOp,
133        hash: u64,
134        size: u64,
135        timestamp_ms: i64,
136    ) -> u64 {
137        self.current_seq = self.current_seq.saturating_add(1);
138        let entry = VersionEntry {
139            seq: self.current_seq,
140            agent_id,
141            timestamp_ms,
142            op,
143            hash,
144            size,
145        };
146        let path = path.into();
147        let list = self.history.entry(path).or_default();
148        list.push(entry);
149        if list.len() > HISTORY_LIMIT {
150            let drop = list.len() - HISTORY_LIMIT;
151            list.drain(0..drop);
152        }
153        self.current_seq
154    }
155
156    /// Every change record with `seq > since`, ordered by seq ascending.
157    /// `limit` (when present) keeps the *most recent* `limit` entries.
158    pub fn changes_since(&self, since: u64, limit: Option<usize>) -> Vec<ChangeRecord> {
159        let mut out: Vec<ChangeRecord> = Vec::new();
160        for (path, entries) in &self.history {
161            for entry in entries {
162                if entry.seq > since {
163                    out.push(ChangeRecord {
164                        path: path.clone(),
165                        seq: entry.seq,
166                        agent_id: entry.agent_id,
167                        op: entry.op,
168                        hash: entry.hash,
169                        size: entry.size,
170                        timestamp_ms: entry.timestamp_ms,
171                    });
172                }
173            }
174        }
175        out.sort_by_key(|r| r.seq);
176        if let Some(limit) = limit {
177            if out.len() > limit {
178                let start = out.len() - limit;
179                out = out.split_off(start);
180            }
181        }
182        out
183    }
184
185    /// Latest entry recorded for `path`, or `None` if untracked.
186    pub fn last_entry(&self, path: &str) -> Option<&VersionEntry> {
187        self.history.get(path).and_then(|v| v.last())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn record_assigns_monotonic_seqs() {
197        let mut log = VersionLog::new();
198        let s1 = log.record("a.rs", 1, EditOp::Write, 10, 5, 100);
199        let s2 = log.record("b.rs", 1, EditOp::Write, 11, 5, 110);
200        let s3 = log.record("a.rs", 2, EditOp::Write, 12, 5, 120);
201        assert_eq!((s1, s2, s3), (1, 2, 3));
202        assert_eq!(log.current_seq, 3);
203    }
204
205    #[test]
206    fn changes_since_returns_sorted_records() {
207        let mut log = VersionLog::new();
208        log.record("a.rs", 1, EditOp::Write, 1, 1, 100);
209        log.record("b.rs", 2, EditOp::Write, 2, 2, 110);
210        log.record("a.rs", 3, EditOp::Patch, 3, 3, 120);
211        let changes = log.changes_since(1, None);
212        let seqs: Vec<u64> = changes.iter().map(|c| c.seq).collect();
213        assert_eq!(seqs, vec![2, 3]);
214    }
215
216    #[test]
217    fn changes_since_respects_limit_and_keeps_most_recent() {
218        let mut log = VersionLog::new();
219        for i in 1..=5 {
220            log.record("a.rs", 1, EditOp::Write, i, i, i as i64);
221        }
222        let limited = log.changes_since(0, Some(2));
223        let seqs: Vec<u64> = limited.iter().map(|c| c.seq).collect();
224        assert_eq!(seqs, vec![4, 5]);
225    }
226
227    #[test]
228    fn history_caps_at_history_limit() {
229        let mut log = VersionLog::new();
230        for i in 0..(HISTORY_LIMIT + 50) {
231            log.record("a.rs", 1, EditOp::Write, i as u64, 0, 0);
232        }
233        let entries = log.history.get("a.rs").unwrap();
234        assert_eq!(entries.len(), HISTORY_LIMIT);
235        // Front of the list rolled off — the very first record is gone.
236        assert!(entries.first().unwrap().seq > 1);
237    }
238
239    #[test]
240    fn edit_op_round_trips_through_str_form() {
241        for op in [
242            EditOp::Snapshot,
243            EditOp::Write,
244            EditOp::ReplaceRange,
245            EditOp::InsertAfter,
246            EditOp::DeleteRange,
247            EditOp::Patch,
248            EditOp::Delete,
249        ] {
250            assert_eq!(EditOp::parse(op.as_str()), Some(op));
251        }
252        assert_eq!(EditOp::parse("unknown"), None);
253    }
254}