jacquardSnapshot

← snapshot

8314 bytes
//! Sketches and the rendezvous board.
//!
//! What can leave an org through these endpoints is exactly what the
//! registry can hold: org ids, 64 lane minima, timestamps. The publications
//! listing deliberately omits even the lanes, mirroring the demo's "what
//! left the org, in its entirety" scene.

use core::str::FromStr as _;

use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use jac_core::{Clock as _, OrgId, SystemClock};
use jac_rendezvous::{Publication, PublishedSketch, Registry as _};
use jac_sketch::Similarity;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::dto::{self, SketchDto};
use crate::error::ApiError;
use crate::routes::with_repo;
use crate::state::SharedState;

/// `GET /api/repos/{slug}/sketches/snapshot/{hex}`.
pub(crate) async fn sketch_snapshot(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<SketchDto>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_snapshot_id(&hex)?;
        let sketch = entry.repo.sketch_snapshot(id)?;
        Ok(Json(SketchDto::of("content", &sketch.0)))
    })
}

/// `GET /api/repos/{slug}/sketches/decision/{hex}`.
pub(crate) async fn sketch_decision(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<SketchDto>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_decision_id(&hex)?;
        let sketch = entry.repo.sketch_decision(id)?;
        Ok(Json(SketchDto::of("decision", &sketch.0)))
    })
}

/// What to publish: a content sketch of a snapshot, or a decision sketch.
#[derive(Debug, Deserialize)]
pub(crate) struct PublishRequest {
    pub kind: String,
    #[serde(default)]
    pub snapshot: Option<String>,
    #[serde(default)]
    pub decision: Option<String>,
}

fn probe_sketch(
    entry: &crate::state::RepoEntry,
    kind: &str,
    snapshot: Option<&str>,
    decision: Option<&str>,
) -> Result<PublishedSketch, ApiError> {
    match kind {
        "content" => {
            let hex =
                snapshot.ok_or_else(|| ApiError::invalid("kind `content` requires `snapshot`"))?;
            let id = dto::parse_snapshot_id(hex)?;
            Ok(PublishedSketch::Content(entry.repo.sketch_snapshot(id)?))
        }
        "decision" => {
            let hex =
                decision.ok_or_else(|| ApiError::invalid("kind `decision` requires `decision`"))?;
            let id = dto::parse_decision_id(hex)?;
            Ok(PublishedSketch::Decision(entry.repo.sketch_decision(id)?))
        }
        other => Err(ApiError::invalid(format!(
            "unknown sketch kind `{other}` (content|decision)"
        ))),
    }
}

/// `POST /api/repos/{slug}/rendezvous/publish`.
pub(crate) async fn publish(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<PublishRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
    let (org, sketch) = with_repo(&state, &slug, |entry| {
        let sketch = probe_sketch(
            entry,
            &req.kind,
            req.snapshot.as_deref(),
            req.decision.as_deref(),
        )?;
        Ok((entry.org_id, sketch))
    })?;
    let at = SystemClock.now();
    let publication = {
        let mut registry = state
            .registry
            .lock()
            .map_err(|_| ApiError::internal("registry lock poisoned"))?;
        registry.publish(Publication { org, sketch, at })
    };
    state.telemetry.emit(
        jac_telemetry::Envelope::event("jac.rendezvous.published", at.as_millis())
            .attr("kind", sketch.kind()),
    );
    Ok((
        StatusCode::CREATED,
        Json(json!({
            "publication": publication.to_string(),
            "org": org.to_string(),
            "kind": sketch.kind(),
            "at": at.as_millis(),
        })),
    ))
}

/// A similarity probe against the shared board.
#[derive(Debug, Deserialize)]
pub(crate) struct FindSimilarRequest {
    pub kind: String,
    #[serde(default)]
    pub snapshot: Option<String>,
    #[serde(default)]
    pub decision: Option<String>,
    /// Minimum similarity in permille (0..=1000).
    #[serde(default)]
    pub min_permille: Option<u16>,
}

/// `POST /api/repos/{slug}/rendezvous/find-similar`.
pub(crate) async fn find_similar(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Json(req): Json<FindSimilarRequest>,
) -> Result<Json<Value>, ApiError> {
    let (org, probe) = with_repo(&state, &slug, |entry| {
        let sketch = probe_sketch(
            entry,
            &req.kind,
            req.snapshot.as_deref(),
            req.decision.as_deref(),
        )?;
        Ok((entry.org_id, sketch))
    })?;
    let min = Similarity::from_permille(req.min_permille.unwrap_or(150));
    let matches: Vec<Value> = {
        let registry = state
            .registry
            .lock()
            .map_err(|_| ApiError::internal("registry lock poisoned"))?;
        registry
            .find_similar(&probe, min, org)
            .iter()
            .map(|m| {
                json!({
                    "publication": m.publication.to_string(),
                    "org": m.org.to_string(),
                    "similarity_permille": m.similarity.permille(),
                    "similarity_percent": m.similarity.to_string(),
                })
            })
            .collect()
    };
    Ok(Json(json!({ "matches": matches })))
}

/// `GET /api/rendezvous/publications` — the whole board: org, kind, when.
/// Lanes are deliberately absent here; per-sketch endpoints serve them.
pub(crate) async fn publications(
    State(state): State<SharedState>,
) -> Result<Json<Value>, ApiError> {
    let registry = state
        .registry
        .lock()
        .map_err(|_| ApiError::internal("registry lock poisoned"))?;
    let list: Vec<Value> = registry
        .publications()
        .iter()
        .map(|p| {
            json!({
                "org": p.org.to_string(),
                "kind": p.sketch.kind(),
                "at": p.at.as_millis(),
            })
        })
        .collect();
    Ok(Json(json!({
        "publications": list,
        "note": "64 u64 lane minima and a timestamp per row — the registry cannot hold content",
    })))
}

/// `GET /api/rendezvous/introductions`.
pub(crate) async fn introductions(
    State(state): State<SharedState>,
) -> Result<Json<Value>, ApiError> {
    let registry = state
        .registry
        .lock()
        .map_err(|_| ApiError::internal("registry lock poisoned"))?;
    let list: Vec<Value> = registry
        .introductions()
        .iter()
        .map(|i| {
            json!({
                "token": i.token.to_string(),
                "parties": [i.parties.0.to_string(), i.parties.1.to_string()],
                "at": i.at.as_millis(),
            })
        })
        .collect();
    Ok(Json(json!({ "introductions": list })))
}

/// Two orgs to introduce.
#[derive(Debug, Deserialize)]
pub(crate) struct BrokerRequest {
    pub a: String,
    pub b: String,
}

/// `POST /api/rendezvous/introductions` — broker an introduction: a token,
/// two names, nothing else. The conversation happens outside the system.
pub(crate) async fn broker(
    State(state): State<SharedState>,
    Json(req): Json<BrokerRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
    let a = OrgId::from_str(&req.a).map_err(|e| ApiError::invalid(format!("bad org id: {e}")))?;
    let b = OrgId::from_str(&req.b).map_err(|e| ApiError::invalid(format!("bad org id: {e}")))?;
    let at = SystemClock.now();
    let introduction = {
        let mut registry = state
            .registry
            .lock()
            .map_err(|_| ApiError::internal("registry lock poisoned"))?;
        registry.broker(a, b, at)
    };
    state.telemetry.emit(jac_telemetry::Envelope::event(
        "jac.rendezvous.introduction",
        at.as_millis(),
    ));
    Ok((
        StatusCode::CREATED,
        Json(json!({
            "token": introduction.token.to_string(),
            "parties": [
                introduction.parties.0.to_string(),
                introduction.parties.1.to_string(),
            ],
            "at": introduction.at.as_millis(),
        })),
    ))
}