1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::intent::IntentId;
10use crate::object::ObjectId;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
20pub enum EpochScope {
21 #[default]
24 All,
25 Branch(String),
27 Plan(String),
30 Workspace(String),
32}
33
34impl EpochScope {
35 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Epoch {
73 pub id: String,
75 pub description: String,
77 pub root_intents: Vec<IntentId>,
79 pub status: EpochStatus,
81 pub created_at: DateTime<Utc>,
83 pub sealed_at: Option<DateTime<Utc>>,
85 pub seal_summary: Option<String>,
87 pub seal_hash: Option<ObjectId>,
89 pub commits: Vec<ObjectId>,
91 pub agents: Vec<String>,
93 pub branches: Vec<String>,
95 pub tags: Vec<String>,
97 #[serde(default)]
103 pub sealed_commits: Vec<ObjectId>,
104 #[serde(default)]
107 pub scope: EpochScope,
108 #[serde(default)]
111 pub namespace: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub enum EpochStatus {
117 Active,
119 Sealed,
121 Archived,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct CrossReference {
128 pub from_epoch: String,
130 pub to_epoch: String,
132 pub relationship: CrossRefType,
134 pub description: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
140pub enum CrossRefType {
141 FollowUp,
143 Dependency,
145 Related,
147 Reverts,
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct Registry {
154 pub epochs: Vec<EpochEntry>,
156 pub cross_references: Vec<CrossReference>,
158}
159
160#[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 #[serde(default)]
174 pub scope: EpochScope,
175 #[serde(default)]
177 pub namespace: Option<String>,
178 pub tags: Vec<String>,
179}
180
181impl Epoch {
182 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 pub fn with_scope(mut self, scope: EpochScope) -> Self {
209 self.scope = scope;
210 self
211 }
212
213 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
215 self.namespace = Some(namespace.into());
216 self
217 }
218
219 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 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 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 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 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 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 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 assert!(
324 epoch
325 .add_commit(ObjectId::hash(b"c3"), "agent/test")
326 .is_err()
327 );
328
329 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}