← 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)))
}