jacquardSnapshot

← snapshot

20968 bytes
//! The engine: where the ports meet.

use std::collections::{BTreeMap, BTreeSet, VecDeque};

use jac_core::{
    Author, Clock, DecisionId, HumanIdentity, Provenance, RepoPath, SnapshotId, Timestamp,
};
use jac_decision::{
    Attestation, Decision, DecisionError, DecisionLedger, DecisionRecord, Settled, Statement,
    Unsettled,
};
use jac_gate::Verdict;
use jac_object::diff::DiffError;
use jac_object::{
    Blob, ObjectError, ObjectStore, Segment, Snapshot, Tree, TreeEntry, TreeError, TreeNode,
    diff_trees,
};
use jac_sketch::{ContentSketch, DecisionSketch, SketchBuilder};
use smallvec::SmallVec;
use thiserror::Error;

use crate::refs::{RefName, RefStore};

/// Failures in repository operations.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum RepoError {
    /// A referenced object could not be read.
    #[error("object store error: {0}")]
    Store(#[from] ObjectError),
    /// A tree could not be constructed from the given files.
    #[error("tree construction failed: {0}")]
    Tree(#[from] TreeError),
    /// Diffing failed.
    #[error("diff failed: {0}")]
    Diff(#[from] DiffError),
    /// A path in the file list was invalid.
    #[error("invalid path: {0}")]
    Path(jac_core::CoreError),
    /// The named ref does not exist.
    #[error("ref not found: {0}")]
    RefNotFound(String),
    /// The decision is not pending (unknown, or already settled).
    #[error("no pending decision with that id")]
    NoSuchPendingDecision,
    /// The attestation did not settle the decision.
    #[error("decision error: {0}")]
    Decision(#[from] DecisionError),
    /// Promotion is fast-forward-only in this milestone; the source branch
    /// does not descend from the target.
    #[error("promotion is fast-forward-only and `{from}` does not descend from `{into}`")]
    NonFastForward {
        /// Source ref.
        from: String,
        /// Target ref.
        into: String,
    },
}

/// The repository engine, generic over its ports.
#[derive(Debug)]
pub struct Repo<S, R, L, C> {
    store: S,
    refs: R,
    ledger: L,
    clock: C,
    pending: BTreeMap<DecisionId, DecisionRecord<Unsettled>>,
    settled: BTreeMap<DecisionId, DecisionRecord<Settled>>,
}

impl<S, R, L, C> Repo<S, R, L, C>
where
    S: ObjectStore,
    R: RefStore,
    L: DecisionLedger,
    C: Clock,
{
    /// Assembles an engine from its ports.
    #[must_use]
    pub const fn new(store: S, refs: R, ledger: L, clock: C) -> Self {
        Self {
            store,
            refs,
            ledger,
            clock,
            pending: BTreeMap::new(),
            settled: BTreeMap::new(),
        }
    }

    /// Read access to the object store, for narration and bridging.
    #[must_use]
    pub const fn store(&self) -> &S {
        &self.store
    }

    /// Mutable access to the object store, for the git bridge's import path.
    pub const fn store_mut(&mut self) -> &mut S {
        &mut self.store
    }

    /// Read access to the ledger, for the gate and narration.
    #[must_use]
    pub const fn ledger(&self) -> &L {
        &self.ledger
    }

    /// Read access to the ref store, for surfaces that enumerate refs.
    #[must_use]
    pub const fn refs(&self) -> &R {
        &self.refs
    }

    /// Pending (unsettled) decision records, for listing surfaces.
    pub fn pending_decisions(&self) -> impl Iterator<Item = &DecisionRecord<Unsettled>> {
        self.pending.values()
    }

    /// Settled decision records, for listing surfaces.
    pub fn settled_decisions(&self) -> impl Iterator<Item = &DecisionRecord<Settled>> {
        self.settled.values()
    }

    /// One settled record — with its attestation — if the decision settled.
    #[must_use]
    pub fn settled_decision(&self, id: DecisionId) -> Option<&DecisionRecord<Settled>> {
        self.settled.get(&id)
    }

    /// Where a ref points, if anywhere.
    #[must_use]
    pub fn head(&self, name: &RefName) -> Option<SnapshotId> {
        self.refs.get(name)
    }

    /// Points a ref at a snapshot directly — creating a branch.
    ///
    /// Deliberately not gated: branching is how work *starts*; promotion is
    /// where the gate stands.
    pub fn branch(&mut self, name: &RefName, at: SnapshotId) {
        self.refs.set(name, at);
    }

    /// The current time, from the injected clock.
    #[must_use]
    pub fn now(&self) -> Timestamp {
        self.clock.now()
    }

    /// Mutable access to the clock, so a stepping clock can be advanced
    /// between scenes in tests and the demo.
    pub const fn clock_mut(&mut self) -> &mut C {
        &mut self.clock
    }

    /// Commits a full file listing as a new snapshot on `branch`.
    ///
    /// Milestone-1 honesty: the caller supplies the *entire* tree, not a
    /// delta. The parent is the branch's current head, if any.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::Path`] or [`RepoError::Tree`] if the listing is
    /// malformed.
    pub fn commit(
        &mut self,
        branch: &RefName,
        files: &[(&str, &[u8])],
        author: Author,
        provenance: Provenance,
        message: &str,
    ) -> Result<SnapshotId, RepoError> {
        let mut manifest = Vec::with_capacity(files.len());
        for (path, content) in files {
            let path = RepoPath::parse(path).map_err(RepoError::Path)?;
            let blob = self.store.put_blob(Blob::new(content.to_vec()));
            manifest.push((path, blob));
        }
        manifest.sort();
        let tree = plant(&mut self.store, &manifest)?;

        let parents = match self.refs.get(branch) {
            Some(head) => SmallVec::from_slice(&[head]),
            None => SmallVec::new(),
        };
        let snapshot = Snapshot {
            tree,
            parents,
            author,
            provenance,
            message: message.to_owned(),
            at: self.clock.now(),
        };
        let id = self.store.put_snapshot(snapshot);
        self.refs.set(branch, id);
        Ok(id)
    }

    /// Proposes a decision. Anyone may call this — including on behalf of an
    /// agent; the decision's `proposed_by` says so.
    pub fn propose_decision(&mut self, decision: Decision) -> DecisionId {
        let record = DecisionRecord::propose(decision);
        let id = record.id();
        self.ledger.record_proposed(&record);
        self.pending.insert(id, record);
        id
    }

    /// A human attests a pending decision, settling it.
    ///
    /// The typestate transition happens here and nowhere else in the
    /// workspace: the pending `DecisionRecord<Unsettled>` is consumed and
    /// only its `Settled` form is stored back.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::NoSuchPendingDecision`] if `id` is unknown or
    /// already settled, or [`RepoError::Decision`] if the attestation fails.
    pub fn attest(
        &mut self,
        id: DecisionId,
        by: &HumanIdentity,
        statement: Statement,
    ) -> Result<(), RepoError> {
        let record = self
            .pending
            .remove(&id)
            .ok_or(RepoError::NoSuchPendingDecision)?;
        let attestation = Attestation::new(by, id, statement, self.clock.now());
        let settled = record.attest(attestation)?;
        self.ledger.record_settled(&settled);
        self.settled.insert(id, settled);
        Ok(())
    }

    /// The decision behind an id, pending or settled, for narration and
    /// sketching.
    #[must_use]
    pub fn decision(&self, id: DecisionId) -> Option<&Decision> {
        self.pending
            .get(&id)
            .map(DecisionRecord::decision)
            .or_else(|| self.settled.get(&id).map(DecisionRecord::decision))
    }

    /// Paths that differ between two snapshots' trees.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::Store`] or [`RepoError::Diff`] on read failures.
    pub fn changed_between(
        &self,
        a: SnapshotId,
        b: SnapshotId,
    ) -> Result<BTreeSet<RepoPath>, RepoError> {
        let tree_a = self.store.snapshot(a)?.tree;
        let tree_b = self.store.snapshot(b)?.tree;
        Ok(diff_trees(&self.store, tree_a, tree_b)?)
    }

    /// Promotes `from` into `into`, fast-forward-only, gated.
    ///
    /// The blast radius is the tree diff between the two heads; the verdict
    /// is [`jac_gate::evaluate`] over it. The ref moves only on a literal
    /// [`Verdict::Admitted`] — the wildcard is the blocked path, so a future
    /// verdict variant fails closed.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::RefNotFound`] if `from` is unbound, or
    /// [`RepoError::NonFastForward`] if `into` exists and is not an ancestor
    /// of `from`. A blocked verdict is a *result*, not an error: the caller
    /// gets it back to route a human to the unsettled decisions.
    pub fn promote(&mut self, from: &RefName, into: &RefName) -> Result<Verdict, RepoError> {
        let from_head = self
            .refs
            .get(from)
            .ok_or_else(|| RepoError::RefNotFound(from.as_str().to_owned()))?;

        let changed = if let Some(into_head) = self.refs.get(into) {
            if !self.descends_from(from_head, into_head)? {
                return Err(RepoError::NonFastForward {
                    from: from.as_str().to_owned(),
                    into: into.as_str().to_owned(),
                });
            }
            self.changed_between(into_head, from_head)?
        } else {
            // A new target ref: everything in `from` is the blast radius.
            let empty = self.store.put_tree(Tree::empty());
            let from_tree = self.store.snapshot(from_head)?.tree;
            diff_trees(&self.store, empty, from_tree)?
        };

        let verdict = jac_gate::evaluate(&self.ledger, &changed);
        // Only the literal Admitted variant moves the ref; anything else —
        // present or future — moves nothing.
        if let Verdict::Admitted { .. } = verdict {
            self.refs.set(into, from_head);
        }
        Ok(verdict)
    }

    /// Whether `descendant` can reach `ancestor` through parent edges.
    fn descends_from(
        &self,
        descendant: SnapshotId,
        ancestor: SnapshotId,
    ) -> Result<bool, RepoError> {
        if descendant == ancestor {
            return Ok(true);
        }
        let mut seen = BTreeSet::new();
        let mut queue = VecDeque::from([descendant]);
        while let Some(id) = queue.pop_front() {
            if id == ancestor {
                return Ok(true);
            }
            if !seen.insert(id) {
                continue;
            }
            for parent in &self.store.snapshot(id)?.parents {
                queue.push_back(*parent);
            }
        }
        Ok(false)
    }

    /// Sketches a snapshot's content for the registry.
    ///
    /// The one sanctioned crossing: content in, [`ContentSketch`] out. Blob
    /// bytes are read as lossy UTF-8 and fed through the shingler along with
    /// their paths; what returns is 64 minima and nothing else.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::Store`] on read failures.
    pub fn sketch_snapshot(&self, id: SnapshotId) -> Result<ContentSketch, RepoError> {
        let tree = self.store.snapshot(id)?.tree;
        let mut builder = SketchBuilder::new();
        self.feed_tree(tree, &mut builder)?;
        Ok(ContentSketch(builder.finish()))
    }

    fn feed_tree(
        &self,
        tree: jac_core::TreeId,
        builder: &mut SketchBuilder,
    ) -> Result<(), RepoError> {
        let tree = self.store.tree(tree)?;
        for entry in tree.entries() {
            builder.feed_text(entry.name.as_str());
            match entry.node {
                TreeNode::Blob(id) => {
                    let blob = self.store.blob(id)?;
                    builder.feed_text(&String::from_utf8_lossy(blob.as_bytes()));
                }
                TreeNode::Tree(id) => self.feed_tree(id, builder)?,
                // An unknown node kind contributes nothing to the sketch
                // rather than failing a publish.
                _ => {}
            }
        }
        Ok(())
    }

    /// Sketches a decision (title and rationale) for the registry.
    ///
    /// # Errors
    ///
    /// Returns [`RepoError::NoSuchPendingDecision`] if the id is unknown.
    pub fn sketch_decision(&self, id: DecisionId) -> Result<DecisionSketch, RepoError> {
        let decision = self.decision(id).ok_or(RepoError::NoSuchPendingDecision)?;
        let mut builder = SketchBuilder::new();
        builder.feed_text(&decision.title);
        builder.feed_text(&decision.rationale);
        Ok(DecisionSketch(builder.finish()))
    }
}

/// Builds nested trees from a sorted `(path, blob)` manifest, returning the
/// root tree id.
fn plant<S: ObjectStore>(
    store: &mut S,
    manifest: &[(RepoPath, jac_core::BlobId)],
) -> Result<jac_core::TreeId, RepoError> {
    fn build<S: ObjectStore>(
        store: &mut S,
        items: &[(String, jac_core::BlobId)],
    ) -> Result<jac_core::TreeId, RepoError> {
        let mut blobs: Vec<(String, jac_core::BlobId)> = Vec::new();
        let mut children: BTreeMap<String, Vec<(String, jac_core::BlobId)>> = BTreeMap::new();
        for (path, blob) in items {
            match path.split_once('/') {
                None => blobs.push((path.clone(), *blob)),
                Some((head, rest)) => children
                    .entry(head.to_owned())
                    .or_default()
                    .push((rest.to_owned(), *blob)),
            }
        }
        let mut entries = Vec::new();
        for (name, blob) in blobs {
            entries.push(TreeEntry {
                name: Segment::parse(&name)?,
                node: TreeNode::Blob(blob),
            });
        }
        for (name, inner) in children {
            let sub = build(store, &inner)?;
            entries.push(TreeEntry {
                name: Segment::parse(&name)?,
                node: TreeNode::Tree(sub),
            });
        }
        Ok(store.put_tree(Tree::new(entries)?))
    }

    let items: Vec<(String, jac_core::BlobId)> = manifest
        .iter()
        .map(|(p, b)| (p.as_str().to_owned(), *b))
        .collect();
    build(store, &items)
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::{AgentId, FixedClock, HumanId};
    use jac_decision::{DecisionScope, MemoryLedger, RationaleFamily};
    use jac_object::MemoryObjectStore;

    use super::*;
    use crate::refs::MemoryRefStore;

    type TestRepo = Repo<MemoryObjectStore, MemoryRefStore, MemoryLedger, FixedClock>;

    fn repo() -> TestRepo {
        Repo::new(
            MemoryObjectStore::new(),
            MemoryRefStore::new(),
            MemoryLedger::new(),
            FixedClock::new(Timestamp::from_millis(1_000)),
        )
    }

    fn ada() -> HumanIdentity {
        HumanIdentity {
            id: HumanId::new(1).unwrap(),
            display_name: "Ada".to_owned(),
        }
    }

    fn loom_bot() -> Author {
        Author::Agent(AgentId::new(2).unwrap())
    }

    #[test]
    fn the_whole_gate_story() {
        let mut repo = repo();
        let main = RefName::parse("main").unwrap();
        let feature = RefName::parse("feature/backoff").unwrap();

        // Ada lays down main.
        repo.commit(
            &main,
            &[("src/auth/login.rs", b"fn login() {}")],
            Author::Human(HumanId::new(1).unwrap()),
            Provenance::Human,
            "initial auth",
        )
        .unwrap();

        // The agent branches from main and adds a retry layer.
        let main_head = repo.head(&main).unwrap();
        repo.refs.set(&feature, main_head);
        repo.commit(
            &feature,
            &[
                ("src/auth/login.rs", b"fn login() {}"),
                ("src/auth/backoff.rs", b"fn retry() {}"),
            ],
            loom_bot(),
            Provenance::Agent,
            "add retry backoff",
        )
        .unwrap();

        // The agent proposes the governing decision; unsettled.
        let decision_id = repo.propose_decision(Decision {
            title: "auth retry fails closed".to_owned(),
            rationale: "drafted from the diff by the interviewer".to_owned(),
            families: vec![RationaleFamily::Constraints],
            proposed_by: loom_bot(),
            scope: DecisionScope {
                path_prefixes: vec![RepoPath::parse("src/auth").unwrap()],
            },
            at: Timestamp::from_millis(1_000),
        });

        // Blocked: the blast radius touches an unsettled decision.
        let verdict = repo.promote(&feature, &main).unwrap();
        assert!(!verdict.is_admitted());
        assert_eq!(repo.head(&main), Some(main_head), "the ref did not move");

        // Ada attests; the same promote admits.
        repo.attest(
            decision_id,
            &ada(),
            Statement::new("Walked the 401 path; the retry gives up rather than replaying.")
                .unwrap(),
        )
        .unwrap();
        let verdict = repo.promote(&feature, &main).unwrap();
        assert_eq!(
            verdict,
            Verdict::Admitted {
                decisions_checked: 1
            }
        );
        assert_eq!(repo.head(&main), repo.head(&feature));
    }

    #[test]
    fn non_fast_forward_is_refused() {
        let mut repo = repo();
        let main = RefName::parse("main").unwrap();
        let stray = RefName::parse("stray").unwrap();

        repo.commit(
            &main,
            &[("a.rs", b"1")],
            loom_bot(),
            Provenance::Agent,
            "on main",
        )
        .unwrap();
        // `stray` has no shared history with main.
        repo.commit(
            &stray,
            &[("b.rs", b"2")],
            loom_bot(),
            Provenance::Agent,
            "unrelated",
        )
        .unwrap();

        assert!(matches!(
            repo.promote(&stray, &main),
            Err(RepoError::NonFastForward { .. })
        ));
    }

    #[test]
    fn attesting_twice_is_refused() {
        let mut repo = repo();
        let id = repo.propose_decision(Decision {
            title: "t".to_owned(),
            rationale: "r".to_owned(),
            families: vec![],
            proposed_by: loom_bot(),
            scope: DecisionScope::default(),
            at: Timestamp::from_millis(1),
        });
        repo.attest(id, &ada(), Statement::new("read it").unwrap())
            .unwrap();
        assert!(matches!(
            repo.attest(id, &ada(), Statement::new("again").unwrap()),
            Err(RepoError::NoSuchPendingDecision)
        ));
    }

    #[test]
    fn decision_listings_track_the_typestate() {
        let mut repo = repo();
        let id = repo.propose_decision(Decision {
            title: "t".to_owned(),
            rationale: "r".to_owned(),
            families: vec![],
            proposed_by: loom_bot(),
            scope: DecisionScope::default(),
            at: Timestamp::from_millis(1),
        });
        assert_eq!(repo.pending_decisions().count(), 1);
        assert_eq!(repo.settled_decisions().count(), 0);
        assert!(repo.settled_decision(id).is_none());

        repo.attest(id, &ada(), Statement::new("read it").unwrap())
            .unwrap();
        assert_eq!(repo.pending_decisions().count(), 0);
        let settled: Vec<_> = repo.settled_decisions().collect();
        assert_eq!(settled.len(), 1);
        assert_eq!(settled[0].id(), id);
        assert_eq!(repo.settled_decision(id).map(DecisionRecord::id), Some(id));
    }

    #[test]
    fn refs_are_enumerable() {
        let mut repo = repo();
        let main = RefName::parse("main").unwrap();
        let feature = RefName::parse("feature/x").unwrap();
        repo.commit(&main, &[("a.rs", b"1")], loom_bot(), Provenance::Agent, "a")
            .unwrap();
        let head = repo.head(&main).unwrap();
        repo.branch(&feature, head);

        let listed: Vec<_> = repo.refs().iter().collect();
        assert_eq!(listed, vec![(&feature, head), (&main, head)]);
    }

    #[test]
    fn sketches_come_out_digest_shaped() {
        let mut repo = repo();
        let main = RefName::parse("main").unwrap();
        repo.commit(
            &main,
            &[(
                "src/auth/backoff.rs",
                b"retry with exponential backoff fails closed",
            )],
            loom_bot(),
            Provenance::Agent,
            "retry",
        )
        .unwrap();
        let head = repo.head(&main).unwrap();
        let sketch = repo.sketch_snapshot(head).unwrap();
        // The only thing a holder can do with it: compare.
        assert_eq!(sketch.similarity(&sketch).permille(), 1000);
    }
}