jacquardSnapshot

← snapshot

16810 bytes
//! Repo initialisation — the defined concept — plus listing and identities.

use std::collections::BTreeMap;

use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use jac_core::{AgentId, AgentIdentity, Author, HumanId, HumanIdentity, OrgId, Provenance};
use jac_decision::{MemoryLedger, Statement};
use jac_object::{MemoryObjectStore, ObjectStore as _};
use jac_repo::{MemoryRefStore, RefName, Repo};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::dto::{self, ActorView, DecisionDto, RefDto, SnapshotDto};
use crate::error::ApiError;
use crate::routes::{read_repos, with_repo, with_repo_mut, write_repos};
use crate::state::{AppState, RepoEntry, SharedState};

/// The init request: what "initialising a jacquard repo" means.
#[derive(Debug, Deserialize)]
pub(crate) struct InitRepoRequest {
    /// Organisation / repo display name; the slug derives from it.
    pub name: String,
    /// The founding person. Every repo has at least one: only a human can
    /// attest, so a repo without one is a repo nothing can settle.
    pub founder: FounderReq,
    /// Default ref name; `main` when omitted.
    #[serde(default)]
    pub default_ref: Option<String>,
    /// Agents to register alongside the founder.
    #[serde(default)]
    pub agents: Vec<AgentReq>,
    /// Optional first commit, authored by the founder as `human`.
    #[serde(default)]
    pub initial_commit: Option<InitialCommitReq>,
    /// Optional founding decision, proposed by the founder.
    #[serde(default)]
    pub founding_decision: Option<FoundingDecisionReq>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct FounderReq {
    pub display_name: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AgentReq {
    pub model: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct InitialCommitReq {
    pub message: String,
    pub files: Vec<FileReq>,
    /// Whose hands made the first cloth: `human` (default), `agent`, or
    /// `mixed`. The author is always the founder; the label is the honest
    /// account of how the content came to be.
    #[serde(default)]
    pub provenance: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct FileReq {
    pub path: String,
    pub content: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct FoundingDecisionReq {
    pub title: String,
    pub rationale: String,
    #[serde(default)]
    pub families: Vec<String>,
    #[serde(default)]
    pub scope: Vec<String>,
    /// When present, the founder signs immediately and the decision lands
    /// settled. Absent, it lands unsettled — honestly: proposing is not
    /// attesting, even for the proposer.
    #[serde(default)]
    pub attestation: Option<AttestationReq>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AttestationReq {
    pub statement: String,
}

#[derive(Debug, Serialize)]
pub(crate) struct InitRepoResponse {
    pub slug: String,
    pub org: OrgView,
    pub founder: ActorView,
    pub agents: Vec<ActorView>,
    pub default_ref: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub head: Option<SnapshotDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub founding_decision: Option<DecisionDto>,
    pub created_at: i64,
    /// Stated on every init, so no client can miss it.
    pub honesty: &'static str,
}

#[derive(Debug, Serialize)]
pub(crate) struct OrgView {
    pub id: String,
    pub name: String,
}

/// `POST /api/repos` — initialise a repo.
pub(crate) async fn init(
    State(state): State<SharedState>,
    Json(req): Json<InitRepoRequest>,
) -> Result<(StatusCode, Json<InitRepoResponse>), ApiError> {
    let response = create_repo(&state, req)?;
    Ok((StatusCode::CREATED, Json(response)))
}

/// The whole init sequence, callable from the demo seed as well.
#[expect(
    clippy::too_many_lines,
    reason = "the init ceremony is one sequence; splitting it would scatter its invariants"
)]
pub(crate) fn create_repo(
    state: &AppState,
    req: InitRepoRequest,
) -> Result<InitRepoResponse, ApiError> {
    let slug = slugify(&req.name)?;
    let default_ref = req.default_ref.as_deref().unwrap_or("main");
    let default_ref = RefName::parse(default_ref)?;
    if req.founder.display_name.trim().is_empty() {
        return Err(ApiError::invalid("founder display_name must not be empty"));
    }

    let org_id = OrgId::from_non_zero(state.mint_id());
    let founder = HumanIdentity {
        id: HumanId::from_non_zero(state.mint_id()),
        display_name: req.founder.display_name.trim().to_owned(),
    };
    let agents: Vec<AgentIdentity> = req
        .agents
        .iter()
        .map(|a| AgentIdentity {
            id: AgentId::from_non_zero(state.mint_id()),
            model: a.model.clone(),
        })
        .collect();

    let repo = Repo::new(
        MemoryObjectStore::new(),
        MemoryRefStore::new(),
        MemoryLedger::new(),
        crate::hostclock::HostClock::system(),
    );
    let created_at = repo.now().as_millis();

    let mut entry = RepoEntry {
        repo,
        slug: slug.clone(),
        org_id,
        org_name: req.name.trim().to_owned(),
        default_ref: default_ref.clone(),
        humans: [(founder.id, founder.clone())].into_iter().collect(),
        agents: agents.iter().map(|a| (a.id, a.clone())).collect(),
        created_at,
        remarks: BTreeMap::new(),
    };

    // Optional first commit, authored by the founder. Provenance defaults
    // to human but is the caller's honest declaration.
    let head = if let Some(commit) = req.initial_commit {
        let provenance = match commit.provenance.as_deref() {
            None => Provenance::Human,
            Some(label) => dto::parse_provenance(label)?,
        };
        let owned: Vec<(String, Vec<u8>)> = commit
            .files
            .iter()
            .map(|f| (f.path.clone(), f.content.clone().into_bytes()))
            .collect();
        let files: Vec<(&str, &[u8])> = owned
            .iter()
            .map(|(p, c)| (p.as_str(), c.as_slice()))
            .collect();
        let id = entry.repo.commit(
            &default_ref,
            &files,
            Author::Human(founder.id),
            provenance,
            &commit.message,
        )?;
        let snapshot = entry.repo.store().snapshot(id)?.clone();
        Some(SnapshotDto::of(id, &snapshot, &entry))
    } else {
        None
    };

    // Optional founding decision; settled only if a statement was signed.
    let founding_decision = if let Some(founding) = req.founding_decision {
        let at = entry.repo.now();
        let families = founding
            .families
            .iter()
            .map(|f| dto::parse_family(f))
            .collect::<Result<Vec<_>, _>>()?;
        let path_prefixes = founding
            .scope
            .iter()
            .map(|p| dto::parse_repo_path(p))
            .collect::<Result<Vec<_>, _>>()?;
        let id = entry.repo.propose_decision(jac_decision::Decision {
            title: founding.title,
            rationale: founding.rationale,
            families,
            proposed_by: Author::Human(founder.id),
            scope: jac_decision::DecisionScope { path_prefixes },
            at,
        });
        if let Some(attestation) = founding.attestation {
            let statement = Statement::new(&attestation.statement)?;
            entry.repo.attest(id, &founder, statement)?;
            let record = entry
                .repo
                .settled_decision(id)
                .ok_or_else(|| ApiError::internal("attested decision vanished"))?;
            let mut decision_dto = DecisionDto::of(record, &entry);
            decision_dto.attestation = record
                .attestation()
                .map(|a| dto::AttestationDto::of(a, &entry));
            Some(decision_dto)
        } else {
            entry
                .repo
                .pending_decisions()
                .find(|r| r.id() == id)
                .map(|r| DecisionDto::of(r, &entry))
        }
    } else {
        None
    };

    let response = InitRepoResponse {
        slug: slug.clone(),
        org: OrgView {
            id: org_id.to_string(),
            name: entry.org_name.clone(),
        },
        founder: ActorView::of(Author::Human(founder.id), &entry),
        agents: agents
            .iter()
            .map(|a| ActorView::of(Author::Agent(a.id), &entry))
            .collect(),
        default_ref: default_ref.as_str().to_owned(),
        head,
        founding_decision,
        created_at,
        honesty: "identities are declared, not authenticated; repos live for the process lifetime only",
    };

    let mut repos = write_repos(state)?;
    if repos.contains_key(&slug) {
        return Err(ApiError::Conflict(format!(
            "a repo named `{slug}` already exists"
        )));
    }
    repos.insert(slug, entry);
    drop(repos);

    state.telemetry.emit(
        jac_telemetry::Envelope::event("jac.repo.init", response.created_at)
            .attr("repo", response.slug.clone())
            .measurement("files", f64::from(u8::from(response.head.is_some())))
            .measurement(
                "agents",
                f64::from(u32::try_from(response.agents.len()).unwrap_or(u32::MAX)),
            ),
    );
    Ok(response)
}

/// Derives a URL-safe slug from a display name.
fn slugify(name: &str) -> Result<String, ApiError> {
    let mut slug = String::new();
    let mut last_dash = true;
    for c in name.trim().chars() {
        if c.is_ascii_alphanumeric() {
            slug.push(c.to_ascii_lowercase());
            last_dash = false;
        } else if !last_dash {
            slug.push('-');
            last_dash = true;
        }
    }
    while slug.ends_with('-') {
        slug.pop();
    }
    if slug.is_empty() {
        return Err(ApiError::invalid(
            "name must contain at least one alphanumeric character",
        ));
    }
    Ok(slug)
}

/// `GET /api/repos` — summaries of every hosted repo.
pub(crate) async fn list(State(state): State<SharedState>) -> Result<Json<Value>, ApiError> {
    let repos = read_repos(&state)?;
    let summaries: Vec<Value> = repos.values().map(summary).collect();
    Ok(Json(json!({ "repos": summaries })))
}

fn summary(entry: &RepoEntry) -> Value {
    let unsettled = entry.repo.pending_decisions().count();
    let settled = entry.repo.settled_decisions().count();
    let refs = entry.repo.refs().iter().count();
    let head = entry.repo.head(&entry.default_ref).and_then(|id| {
        entry
            .repo
            .store()
            .snapshot(id)
            .ok()
            .map(|s| serde_json::to_value(SnapshotDto::of(id, s, entry)).unwrap_or(Value::Null))
    });
    json!({
        "slug": entry.slug,
        "org": { "id": entry.org_id.to_string(), "name": entry.org_name },
        "default_ref": entry.default_ref.as_str(),
        "refs": refs,
        "decisions": { "unsettled": unsettled, "settled": settled },
        "head": head,
        "created_at": entry.created_at,
    })
}

/// `GET /api/repos/{slug}` — full detail.
pub(crate) async fn detail(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let refs: Vec<RefDto> = entry
            .repo
            .refs()
            .iter()
            .map(|(name, head)| RefDto {
                name: name.as_str().to_owned(),
                head: head.digest().to_hex().as_str().to_owned(),
            })
            .collect();
        let humans: Vec<ActorView> = entry
            .humans
            .keys()
            .map(|id| ActorView::of(Author::Human(*id), entry))
            .collect();
        let agents: Vec<ActorView> = entry
            .agents
            .keys()
            .map(|id| ActorView::of(Author::Agent(*id), entry))
            .collect();
        Ok(Json(json!({
            "slug": entry.slug,
            "org": { "id": entry.org_id.to_string(), "name": entry.org_name },
            "default_ref": entry.default_ref.as_str(),
            "refs": refs,
            "humans": humans,
            "agents": agents,
            "decisions": {
                "unsettled": entry.repo.pending_decisions().count(),
                "settled": entry.repo.settled_decisions().count(),
            },
            "object_count": entry.repo.store().object_count(),
            "created_at": entry.created_at,
        })))
    })
}

/// A new identity: exactly one of `human` or `agent`.
#[derive(Debug, Deserialize)]
pub(crate) struct AddIdentityRequest {
    #[serde(default)]
    pub human: Option<FounderReq>,
    #[serde(default)]
    pub agent: Option<AgentReq>,
}

/// `POST /api/repos/{slug}/identities` — register a person or an agent.
pub(crate) async fn add_identity(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<AddIdentityRequest>,
) -> Result<(StatusCode, Json<ActorView>), ApiError> {
    let minted = state.mint_id();
    let view = with_repo_mut(&state, &slug, |entry| match (&req.human, &req.agent) {
        (Some(human), None) => {
            if human.display_name.trim().is_empty() {
                return Err(ApiError::invalid("display_name must not be empty"));
            }
            let identity = HumanIdentity {
                id: HumanId::from_non_zero(minted),
                display_name: human.display_name.trim().to_owned(),
            };
            let view = {
                entry.humans.insert(identity.id, identity.clone());
                ActorView::of(Author::Human(identity.id), entry)
            };
            Ok(view)
        }
        (None, Some(agent)) => {
            if agent.model.trim().is_empty() {
                return Err(ApiError::invalid("model must not be empty"));
            }
            let identity = AgentIdentity {
                id: AgentId::from_non_zero(minted),
                model: agent.model.trim().to_owned(),
            };
            let view = {
                entry.agents.insert(identity.id, identity.clone());
                ActorView::of(Author::Agent(identity.id), entry)
            };
            Ok(view)
        }
        _ => Err(ApiError::invalid(
            "provide exactly one of `human` or `agent`",
        )),
    })?;
    Ok((StatusCode::CREATED, Json(view)))
}

/// `GET /api/repos/{slug}/refs` — every ref and its head.
pub(crate) async fn list_refs(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let refs: Vec<RefDto> = entry
            .repo
            .refs()
            .iter()
            .map(|(name, head)| RefDto {
                name: name.as_str().to_owned(),
                head: head.digest().to_hex().as_str().to_owned(),
            })
            .collect();
        Ok(Json(json!({ "refs": refs })))
    })
}

/// Where a new ref starts: another ref's head, or a snapshot directly.
#[derive(Debug, Deserialize)]
pub(crate) struct BranchRequest {
    pub name: String,
    pub from: BranchFrom,
}

#[derive(Debug, Deserialize)]
pub(crate) struct BranchFrom {
    #[serde(default)]
    pub r#ref: Option<String>,
    #[serde(default)]
    pub snapshot: Option<String>,
}

/// `POST /api/repos/{slug}/refs` — create a branch. Ungated by design:
/// branching is how work starts; promotion is where the gate stands.
pub(crate) async fn branch(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<BranchRequest>,
) -> Result<(StatusCode, Json<RefDto>), ApiError> {
    let dto = with_repo_mut(&state, &slug, |entry| {
        let name = RefName::parse(&req.name)?;
        let at = match (&req.from.r#ref, &req.from.snapshot) {
            (Some(source), None) => {
                let source = RefName::parse(source)?;
                entry.repo.head(&source).ok_or_else(|| {
                    ApiError::not_found(format!("ref `{}` is unbound", source.as_str()))
                })?
            }
            (None, Some(hex)) => {
                let id = dto::parse_snapshot_id(hex)?;
                // Refuse to point a ref at a snapshot the store cannot resolve.
                entry.repo.store().snapshot(id)?;
                id
            }
            _ => {
                return Err(ApiError::invalid(
                    "provide exactly one of `from.ref` or `from.snapshot`",
                ));
            }
        };
        entry.repo.branch(&name, at);
        Ok(RefDto {
            name: name.as_str().to_owned(),
            head: at.digest().to_hex().as_str().to_owned(),
        })
    })?;
    Ok((StatusCode::CREATED, Json(dto)))
}