Skip to main content

agentstategraph_core/
epoch.rs

1//! Epochs — bounded, sealable segments of work for lifecycle management.
2//!
3//! An epoch groups related commits, intents, and resolutions into a coherent
4//! unit that can be sealed (made immutable), exported, and archived.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::intent::IntentId;
10use crate::object::ObjectId;
11
12/// What an epoch's seal actually covers.
13///
14/// Recorded explicitly rather than inferred from which commit list is
15/// populated: an epoch of 400 commits is a very different claim depending on
16/// whether that means "this plan did 400 things" or "the workspace happened to
17/// contain 400 commits at seal time", and a reader cannot tell those apart
18/// after the fact.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
20pub enum EpochScope {
21    /// Everything reachable from `main` at seal time. The historical default,
22    /// and what a `.db` written before scopes existed deserializes to.
23    #[default]
24    All,
25    /// Everything reachable from one named ref at seal time.
26    Branch(String),
27    /// Only the commits tagged to this epoch while it was the active epoch —
28    /// i.e. one plan's own work.
29    Plan(String),
30    /// Everything in one workspace (namespace) at seal time.
31    Workspace(String),
32}
33
34impl EpochScope {
35    /// Storage form: a single flat string, so it round-trips through one TEXT
36    /// column without a nested encoding.
37    pub fn as_storage_str(&self) -> String {
38        match self {
39            Self::All => "all".to_string(),
40            Self::Branch(b) => format!("branch:{b}"),
41            Self::Plan(p) => format!("plan:{p}"),
42            Self::Workspace(w) => format!("workspace:{w}"),
43        }
44    }
45
46    /// Parse the storage form. An unknown or malformed value falls back to
47    /// [`EpochScope::All`] — the historical behaviour — so a row written by a
48    /// newer version can never make an older one fail to read its own store.
49    pub fn from_storage_str(s: &str) -> Self {
50        match s.split_once(':') {
51            None => Self::All,
52            Some(("branch", b)) => Self::Branch(b.to_string()),
53            Some(("plan", p)) => Self::Plan(p.to_string()),
54            Some(("workspace", w)) => Self::Workspace(w.to_string()),
55            Some(_) => Self::All,
56        }
57    }
58
59    /// The ref whose reachable set this scope seals, if it is ref-shaped.
60    /// `Plan` returns `None` — it is defined by tagged commits, not a walk.
61    pub fn seal_ref(&self) -> Option<&str> {
62        match self {
63            Self::All | Self::Workspace(_) => Some("main"),
64            Self::Branch(b) => Some(b.as_str()),
65            Self::Plan(_) => None,
66        }
67    }
68}
69
70/// A bounded segment of work within a AgentStateGraph instance.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Epoch {
73    /// Unique identifier for this epoch.
74    pub id: String,
75    /// Human-readable description.
76    pub description: String,
77    /// The top-level intents that define this epoch's scope.
78    pub root_intents: Vec<IntentId>,
79    /// Current status.
80    pub status: EpochStatus,
81    /// When this epoch was created.
82    pub created_at: DateTime<Utc>,
83    /// When this epoch was sealed (if sealed).
84    pub sealed_at: Option<DateTime<Utc>>,
85    /// Summary filed at seal time.
86    pub seal_summary: Option<String>,
87    /// Merkle root of all epoch contents when sealed (tamper-evident).
88    pub seal_hash: Option<ObjectId>,
89    /// All commits in this epoch.
90    pub commits: Vec<ObjectId>,
91    /// All agents that participated.
92    pub agents: Vec<String>,
93    /// All branches created during this epoch.
94    pub branches: Vec<String>,
95    /// Queryable tags.
96    pub tags: Vec<String>,
97    /// Commits that must remain reachable from main after sealing. Populated
98    /// by `Repository::seal_epoch` from the then-current main chain. Empty
99    /// on active epochs and on `.db` files written before V8 landed —
100    /// existing serialized epochs deserialize cleanly via `#[serde(default)]`.
101    /// (security threat model v3+, V8)
102    #[serde(default)]
103    pub sealed_commits: Vec<ObjectId>,
104    /// What this epoch's seal covers. Defaults to [`EpochScope::All`] so
105    /// epochs written before scopes existed deserialize unchanged.
106    #[serde(default)]
107    pub scope: EpochScope,
108    /// Workspace (namespace) this epoch belongs to. `None` on epochs written
109    /// before the epochs table was namespace-aware, which are global.
110    #[serde(default)]
111    pub namespace: Option<String>,
112}
113
114/// Status of an epoch.
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub enum EpochStatus {
117    /// Work in progress, commits still being added.
118    Active,
119    /// Work complete, read-only, hash-verified.
120    Sealed,
121    /// Sealed and moved to cold storage, queryable via index.
122    Archived,
123}
124
125/// A cross-reference between epochs.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct CrossReference {
128    /// Source epoch.
129    pub from_epoch: String,
130    /// Target epoch.
131    pub to_epoch: String,
132    /// Nature of the relationship.
133    pub relationship: CrossRefType,
134    /// Description of the relationship.
135    pub description: String,
136}
137
138/// Types of cross-epoch relationships.
139#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
140pub enum CrossRefType {
141    /// This epoch's work was a follow-up from that epoch.
142    FollowUp,
143    /// This epoch depends on that epoch's state.
144    Dependency,
145    /// General relation.
146    Related,
147    /// This epoch reverts work from that epoch.
148    Reverts,
149}
150
151/// The registry — a master index of all epochs.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct Registry {
154    /// All epochs in this AgentStateGraph instance.
155    pub epochs: Vec<EpochEntry>,
156    /// Cross-references between epochs.
157    pub cross_references: Vec<CrossReference>,
158}
159
160/// A lightweight index entry for an epoch.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct EpochEntry {
163    pub id: String,
164    pub description: String,
165    pub status: EpochStatus,
166    pub created_at: DateTime<Utc>,
167    pub sealed_at: Option<DateTime<Utc>>,
168    pub root_intents: Vec<IntentId>,
169    pub agents: Vec<String>,
170    pub commit_count: usize,
171    pub seal_hash: Option<ObjectId>,
172    /// What this epoch covers — see [`EpochScope`].
173    #[serde(default)]
174    pub scope: EpochScope,
175    /// Owning workspace, or `None` for a pre-namespace (global) epoch.
176    #[serde(default)]
177    pub namespace: Option<String>,
178    pub tags: Vec<String>,
179}
180
181impl Epoch {
182    /// Create a new active epoch.
183    pub fn new(
184        id: impl Into<String>,
185        description: impl Into<String>,
186        root_intents: Vec<IntentId>,
187    ) -> Self {
188        Self {
189            id: id.into(),
190            description: description.into(),
191            root_intents,
192            status: EpochStatus::Active,
193            created_at: Utc::now(),
194            sealed_at: None,
195            seal_summary: None,
196            seal_hash: None,
197            commits: Vec::new(),
198            agents: Vec::new(),
199            branches: Vec::new(),
200            scope: EpochScope::All,
201            namespace: None,
202            tags: Vec::new(),
203            sealed_commits: Vec::new(),
204        }
205    }
206
207    /// Declare what this epoch covers. Defaults to [`EpochScope::All`].
208    pub fn with_scope(mut self, scope: EpochScope) -> Self {
209        self.scope = scope;
210        self
211    }
212
213    /// Attach this epoch to a workspace (namespace).
214    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
215        self.namespace = Some(namespace.into());
216        self
217    }
218
219    /// Seal this epoch, making it immutable.
220    pub fn seal(&mut self, summary: String, seal_hash: ObjectId) -> Result<(), EpochError> {
221        if self.status != EpochStatus::Active {
222            return Err(EpochError::NotActive(self.id.clone()));
223        }
224        self.status = EpochStatus::Sealed;
225        self.sealed_at = Some(Utc::now());
226        self.seal_summary = Some(summary);
227        self.seal_hash = Some(seal_hash);
228        Ok(())
229    }
230
231    /// Archive this epoch.
232    pub fn archive(&mut self) -> Result<(), EpochError> {
233        if self.status != EpochStatus::Sealed {
234            return Err(EpochError::NotSealed(self.id.clone()));
235        }
236        self.status = EpochStatus::Archived;
237        Ok(())
238    }
239
240    /// Add a commit to this epoch.
241    pub fn add_commit(&mut self, commit_id: ObjectId, agent_id: &str) -> Result<(), EpochError> {
242        if self.status != EpochStatus::Active {
243            return Err(EpochError::NotActive(self.id.clone()));
244        }
245        self.commits.push(commit_id);
246        if !self.agents.contains(&agent_id.to_string()) {
247            self.agents.push(agent_id.to_string());
248        }
249        Ok(())
250    }
251
252    /// Number of commits this epoch covers.
253    ///
254    /// `commits` — the commits actually tagged to this epoch — is the precise
255    /// answer and wins whenever it is populated. It is empty for an epoch that
256    /// was never made active, which is how a checkpoint-style epoch is created:
257    /// seal immediately, tagging nothing. For those, `sealed_commits` (recorded
258    /// at seal time) is the only record of what was covered, and counting
259    /// `commits` alone reports "0 commits" for work that definitely happened.
260    pub fn commit_count(&self) -> usize {
261        if self.commits.is_empty() {
262            self.sealed_commits.len()
263        } else {
264            self.commits.len()
265        }
266    }
267
268    /// Convert to a lightweight index entry.
269    pub fn to_entry(&self) -> EpochEntry {
270        EpochEntry {
271            id: self.id.clone(),
272            description: self.description.clone(),
273            status: self.status.clone(),
274            created_at: self.created_at,
275            sealed_at: self.sealed_at,
276            root_intents: self.root_intents.clone(),
277            agents: self.agents.clone(),
278            commit_count: self.commit_count(),
279            seal_hash: self.seal_hash,
280            scope: self.scope.clone(),
281            namespace: self.namespace.clone(),
282            tags: self.tags.clone(),
283        }
284    }
285}
286
287#[derive(Debug, thiserror::Error)]
288pub enum EpochError {
289    #[error("epoch '{0}' is not active")]
290    NotActive(String),
291    #[error("epoch '{0}' is not sealed")]
292    NotSealed(String),
293    #[error("epoch not found: {0}")]
294    NotFound(String),
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_epoch_lifecycle() {
303        let mut epoch = Epoch::new("test-epoch", "Test", vec!["intent-1".to_string()]);
304        assert_eq!(epoch.status, EpochStatus::Active);
305
306        // Add commits while active
307        epoch
308            .add_commit(ObjectId::hash(b"c1"), "agent/test")
309            .unwrap();
310        epoch
311            .add_commit(ObjectId::hash(b"c2"), "agent/test")
312            .unwrap();
313        assert_eq!(epoch.commits.len(), 2);
314
315        // Seal
316        epoch
317            .seal("All done".to_string(), ObjectId::hash(b"seal"))
318            .unwrap();
319        assert_eq!(epoch.status, EpochStatus::Sealed);
320        assert!(epoch.sealed_at.is_some());
321
322        // Can't add commits after seal
323        assert!(
324            epoch
325                .add_commit(ObjectId::hash(b"c3"), "agent/test")
326                .is_err()
327        );
328
329        // Archive
330        epoch.archive().unwrap();
331        assert_eq!(epoch.status, EpochStatus::Archived);
332    }
333
334    #[test]
335    fn test_cannot_seal_non_active() {
336        let mut epoch = Epoch::new("test", "Test", vec![]);
337        epoch
338            .seal("done".to_string(), ObjectId::hash(b"seal"))
339            .unwrap();
340        assert!(
341            epoch
342                .seal("again".to_string(), ObjectId::hash(b"seal2"))
343                .is_err()
344        );
345    }
346
347    #[test]
348    fn test_cannot_archive_non_sealed() {
349        let mut epoch = Epoch::new("test", "Test", vec![]);
350        assert!(epoch.archive().is_err());
351    }
352
353    #[test]
354    fn test_to_entry() {
355        let mut epoch = Epoch::new("test", "Test epoch", vec!["i1".to_string()]);
356        epoch.tags = vec!["storage".to_string()];
357        epoch.add_commit(ObjectId::hash(b"c1"), "agent/a").unwrap();
358
359        let entry = epoch.to_entry();
360        assert_eq!(entry.id, "test");
361        assert_eq!(entry.commit_count, 1);
362        assert_eq!(entry.tags, vec!["storage"]);
363    }
364}