agentstategraph-core 1.4.1

Core types and algorithms for AgentStateGraph — zero I/O dependencies
Documentation
//! Epochs — bounded, sealable segments of work for lifecycle management.
//!
//! An epoch groups related commits, intents, and resolutions into a coherent
//! unit that can be sealed (made immutable), exported, and archived.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::intent::IntentId;
use crate::object::ObjectId;

/// What an epoch's seal actually covers.
///
/// Recorded explicitly rather than inferred from which commit list is
/// populated: an epoch of 400 commits is a very different claim depending on
/// whether that means "this plan did 400 things" or "the workspace happened to
/// contain 400 commits at seal time", and a reader cannot tell those apart
/// after the fact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum EpochScope {
    /// Everything reachable from `main` at seal time. The historical default,
    /// and what a `.db` written before scopes existed deserializes to.
    #[default]
    All,
    /// Everything reachable from one named ref at seal time.
    Branch(String),
    /// Only the commits tagged to this epoch while it was the active epoch —
    /// i.e. one plan's own work.
    Plan(String),
    /// Everything in one workspace (namespace) at seal time.
    Workspace(String),
}

impl EpochScope {
    /// Storage form: a single flat string, so it round-trips through one TEXT
    /// column without a nested encoding.
    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}"),
        }
    }

    /// Parse the storage form. An unknown or malformed value falls back to
    /// [`EpochScope::All`] — the historical behaviour — so a row written by a
    /// newer version can never make an older one fail to read its own store.
    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,
        }
    }

    /// The ref whose reachable set this scope seals, if it is ref-shaped.
    /// `Plan` returns `None` — it is defined by tagged commits, not a walk.
    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,
        }
    }
}

/// A bounded segment of work within a AgentStateGraph instance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Epoch {
    /// Unique identifier for this epoch.
    pub id: String,
    /// Human-readable description.
    pub description: String,
    /// The top-level intents that define this epoch's scope.
    pub root_intents: Vec<IntentId>,
    /// Current status.
    pub status: EpochStatus,
    /// When this epoch was created.
    pub created_at: DateTime<Utc>,
    /// When this epoch was sealed (if sealed).
    pub sealed_at: Option<DateTime<Utc>>,
    /// Summary filed at seal time.
    pub seal_summary: Option<String>,
    /// Merkle root of all epoch contents when sealed (tamper-evident).
    pub seal_hash: Option<ObjectId>,
    /// All commits in this epoch.
    pub commits: Vec<ObjectId>,
    /// All agents that participated.
    pub agents: Vec<String>,
    /// All branches created during this epoch.
    pub branches: Vec<String>,
    /// Queryable tags.
    pub tags: Vec<String>,
    /// Commits that must remain reachable from main after sealing. Populated
    /// by `Repository::seal_epoch` from the then-current main chain. Empty
    /// on active epochs and on `.db` files written before V8 landed —
    /// existing serialized epochs deserialize cleanly via `#[serde(default)]`.
    /// (security threat model v3+, V8)
    #[serde(default)]
    pub sealed_commits: Vec<ObjectId>,
    /// What this epoch's seal covers. Defaults to [`EpochScope::All`] so
    /// epochs written before scopes existed deserialize unchanged.
    #[serde(default)]
    pub scope: EpochScope,
    /// Workspace (namespace) this epoch belongs to. `None` on epochs written
    /// before the epochs table was namespace-aware, which are global.
    #[serde(default)]
    pub namespace: Option<String>,
}

/// Status of an epoch.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EpochStatus {
    /// Work in progress, commits still being added.
    Active,
    /// Work complete, read-only, hash-verified.
    Sealed,
    /// Sealed and moved to cold storage, queryable via index.
    Archived,
}

/// A cross-reference between epochs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrossReference {
    /// Source epoch.
    pub from_epoch: String,
    /// Target epoch.
    pub to_epoch: String,
    /// Nature of the relationship.
    pub relationship: CrossRefType,
    /// Description of the relationship.
    pub description: String,
}

/// Types of cross-epoch relationships.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CrossRefType {
    /// This epoch's work was a follow-up from that epoch.
    FollowUp,
    /// This epoch depends on that epoch's state.
    Dependency,
    /// General relation.
    Related,
    /// This epoch reverts work from that epoch.
    Reverts,
}

/// The registry — a master index of all epochs.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Registry {
    /// All epochs in this AgentStateGraph instance.
    pub epochs: Vec<EpochEntry>,
    /// Cross-references between epochs.
    pub cross_references: Vec<CrossReference>,
}

/// A lightweight index entry for an epoch.
#[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>,
    /// What this epoch covers — see [`EpochScope`].
    #[serde(default)]
    pub scope: EpochScope,
    /// Owning workspace, or `None` for a pre-namespace (global) epoch.
    #[serde(default)]
    pub namespace: Option<String>,
    pub tags: Vec<String>,
}

impl Epoch {
    /// Create a new active 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(),
        }
    }

    /// Declare what this epoch covers. Defaults to [`EpochScope::All`].
    pub fn with_scope(mut self, scope: EpochScope) -> Self {
        self.scope = scope;
        self
    }

    /// Attach this epoch to a workspace (namespace).
    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
        self.namespace = Some(namespace.into());
        self
    }

    /// Seal this epoch, making it immutable.
    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(())
    }

    /// Archive this epoch.
    pub fn archive(&mut self) -> Result<(), EpochError> {
        if self.status != EpochStatus::Sealed {
            return Err(EpochError::NotSealed(self.id.clone()));
        }
        self.status = EpochStatus::Archived;
        Ok(())
    }

    /// Add a commit to this epoch.
    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(())
    }

    /// Number of commits this epoch covers.
    ///
    /// `commits` — the commits actually tagged to this epoch — is the precise
    /// answer and wins whenever it is populated. It is empty for an epoch that
    /// was never made active, which is how a checkpoint-style epoch is created:
    /// seal immediately, tagging nothing. For those, `sealed_commits` (recorded
    /// at seal time) is the only record of what was covered, and counting
    /// `commits` alone reports "0 commits" for work that definitely happened.
    pub fn commit_count(&self) -> usize {
        if self.commits.is_empty() {
            self.sealed_commits.len()
        } else {
            self.commits.len()
        }
    }

    /// Convert to a lightweight index entry.
    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);

        // Add commits while 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);

        // Seal
        epoch
            .seal("All done".to_string(), ObjectId::hash(b"seal"))
            .unwrap();
        assert_eq!(epoch.status, EpochStatus::Sealed);
        assert!(epoch.sealed_at.is_some());

        // Can't add commits after seal
        assert!(
            epoch
                .add_commit(ObjectId::hash(b"c3"), "agent/test")
                .is_err()
        );

        // Archive
        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"]);
    }
}