use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::intent::IntentId;
use crate::object::ObjectId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum EpochScope {
#[default]
All,
Branch(String),
Plan(String),
Workspace(String),
}
impl EpochScope {
pub fn as_storage_str(&self) -> String {
match self {
Self::All => "all".to_string(),
Self::Branch(b) => format!("branch:{b}"),
Self::Plan(p) => format!("plan:{p}"),
Self::Workspace(w) => format!("workspace:{w}"),
}
}
pub fn from_storage_str(s: &str) -> Self {
match s.split_once(':') {
None => Self::All,
Some(("branch", b)) => Self::Branch(b.to_string()),
Some(("plan", p)) => Self::Plan(p.to_string()),
Some(("workspace", w)) => Self::Workspace(w.to_string()),
Some(_) => Self::All,
}
}
pub fn seal_ref(&self) -> Option<&str> {
match self {
Self::All | Self::Workspace(_) => Some("main"),
Self::Branch(b) => Some(b.as_str()),
Self::Plan(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Epoch {
pub id: String,
pub description: String,
pub root_intents: Vec<IntentId>,
pub status: EpochStatus,
pub created_at: DateTime<Utc>,
pub sealed_at: Option<DateTime<Utc>>,
pub seal_summary: Option<String>,
pub seal_hash: Option<ObjectId>,
pub commits: Vec<ObjectId>,
pub agents: Vec<String>,
pub branches: Vec<String>,
pub tags: Vec<String>,
#[serde(default)]
pub sealed_commits: Vec<ObjectId>,
#[serde(default)]
pub scope: EpochScope,
#[serde(default)]
pub namespace: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EpochStatus {
Active,
Sealed,
Archived,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrossReference {
pub from_epoch: String,
pub to_epoch: String,
pub relationship: CrossRefType,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CrossRefType {
FollowUp,
Dependency,
Related,
Reverts,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Registry {
pub epochs: Vec<EpochEntry>,
pub cross_references: Vec<CrossReference>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EpochEntry {
pub id: String,
pub description: String,
pub status: EpochStatus,
pub created_at: DateTime<Utc>,
pub sealed_at: Option<DateTime<Utc>>,
pub root_intents: Vec<IntentId>,
pub agents: Vec<String>,
pub commit_count: usize,
pub seal_hash: Option<ObjectId>,
#[serde(default)]
pub scope: EpochScope,
#[serde(default)]
pub namespace: Option<String>,
pub tags: Vec<String>,
}
impl Epoch {
pub fn new(
id: impl Into<String>,
description: impl Into<String>,
root_intents: Vec<IntentId>,
) -> Self {
Self {
id: id.into(),
description: description.into(),
root_intents,
status: EpochStatus::Active,
created_at: Utc::now(),
sealed_at: None,
seal_summary: None,
seal_hash: None,
commits: Vec::new(),
agents: Vec::new(),
branches: Vec::new(),
scope: EpochScope::All,
namespace: None,
tags: Vec::new(),
sealed_commits: Vec::new(),
}
}
pub fn with_scope(mut self, scope: EpochScope) -> Self {
self.scope = scope;
self
}
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
pub fn seal(&mut self, summary: String, seal_hash: ObjectId) -> Result<(), EpochError> {
if self.status != EpochStatus::Active {
return Err(EpochError::NotActive(self.id.clone()));
}
self.status = EpochStatus::Sealed;
self.sealed_at = Some(Utc::now());
self.seal_summary = Some(summary);
self.seal_hash = Some(seal_hash);
Ok(())
}
pub fn archive(&mut self) -> Result<(), EpochError> {
if self.status != EpochStatus::Sealed {
return Err(EpochError::NotSealed(self.id.clone()));
}
self.status = EpochStatus::Archived;
Ok(())
}
pub fn add_commit(&mut self, commit_id: ObjectId, agent_id: &str) -> Result<(), EpochError> {
if self.status != EpochStatus::Active {
return Err(EpochError::NotActive(self.id.clone()));
}
self.commits.push(commit_id);
if !self.agents.contains(&agent_id.to_string()) {
self.agents.push(agent_id.to_string());
}
Ok(())
}
pub fn commit_count(&self) -> usize {
if self.commits.is_empty() {
self.sealed_commits.len()
} else {
self.commits.len()
}
}
pub fn to_entry(&self) -> EpochEntry {
EpochEntry {
id: self.id.clone(),
description: self.description.clone(),
status: self.status.clone(),
created_at: self.created_at,
sealed_at: self.sealed_at,
root_intents: self.root_intents.clone(),
agents: self.agents.clone(),
commit_count: self.commit_count(),
seal_hash: self.seal_hash,
scope: self.scope.clone(),
namespace: self.namespace.clone(),
tags: self.tags.clone(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum EpochError {
#[error("epoch '{0}' is not active")]
NotActive(String),
#[error("epoch '{0}' is not sealed")]
NotSealed(String),
#[error("epoch not found: {0}")]
NotFound(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_epoch_lifecycle() {
let mut epoch = Epoch::new("test-epoch", "Test", vec!["intent-1".to_string()]);
assert_eq!(epoch.status, EpochStatus::Active);
epoch
.add_commit(ObjectId::hash(b"c1"), "agent/test")
.unwrap();
epoch
.add_commit(ObjectId::hash(b"c2"), "agent/test")
.unwrap();
assert_eq!(epoch.commits.len(), 2);
epoch
.seal("All done".to_string(), ObjectId::hash(b"seal"))
.unwrap();
assert_eq!(epoch.status, EpochStatus::Sealed);
assert!(epoch.sealed_at.is_some());
assert!(
epoch
.add_commit(ObjectId::hash(b"c3"), "agent/test")
.is_err()
);
epoch.archive().unwrap();
assert_eq!(epoch.status, EpochStatus::Archived);
}
#[test]
fn test_cannot_seal_non_active() {
let mut epoch = Epoch::new("test", "Test", vec![]);
epoch
.seal("done".to_string(), ObjectId::hash(b"seal"))
.unwrap();
assert!(
epoch
.seal("again".to_string(), ObjectId::hash(b"seal2"))
.is_err()
);
}
#[test]
fn test_cannot_archive_non_sealed() {
let mut epoch = Epoch::new("test", "Test", vec![]);
assert!(epoch.archive().is_err());
}
#[test]
fn test_to_entry() {
let mut epoch = Epoch::new("test", "Test epoch", vec!["i1".to_string()]);
epoch.tags = vec!["storage".to_string()];
epoch.add_commit(ObjectId::hash(b"c1"), "agent/a").unwrap();
let entry = epoch.to_entry();
assert_eq!(entry.id, "test");
assert_eq!(entry.commit_count, 1);
assert_eq!(entry.tags, vec!["storage"]);
}
}