Skip to main content

lex_api/
review_http.rs

1//! The human review surface over HTTP (lex-hub#92, first slice).
2//!
3//! In an agent-native VCS humans are not the gate — agents produce and verify
4//! continuously. The human's job is to **adjudicate exceptions** and **arbitrate
5//! intent**: not to read every diff, but to look at the changes a machine gate
6//! couldn't close, see *why* each was made (its recorded Intent), and record a
7//! verdict. Those verdicts are `Review` attestations — a human decision lives in
8//! the same typed attestation graph as `lex-hub-ci`'s TypeCheck or a Replay, and
9//! the same gates consume it (a standing Reject blocks promotion).
10//!
11//! Two endpoints, tenant-scoped (auth + store selection live in lex-hub):
12//!   * `GET  /v1/review/inbox[?branch=<b>]` — the head's stages, each with its
13//!     intent and current review state; `needs_review` flags the exceptions.
14//!   * `POST /v1/review/verdict` — record an Approve / Reject / RequestChanges
15//!     verdict as a `Review` attestation.
16
17use serde::Deserialize;
18use std::collections::BTreeMap;
19use std::io::Cursor;
20use tiny_http::Response;
21
22use crate::handlers::{error_response, json_response, State};
23
24/// `GET /v1/review/inbox[?branch=<name>]` — the review inbox for a branch head:
25/// each head stage with its declaration name, the intent it was made under (the
26/// *why*), its latest review verdict, and whether it still needs a human look.
27pub fn review_inbox_handler(state: &State, query: &str) -> Response<Cursor<Vec<u8>>> {
28    let branch = query
29        .split('&')
30        .find_map(|kv| kv.strip_prefix("branch="))
31        .map(str::to_string);
32    let store = state.store.lock().unwrap();
33    let branch = branch.unwrap_or_else(|| store.current_branch());
34
35    let head_op = match store.get_branch(&branch) {
36        Ok(Some(b)) => b.head_op,
37        Ok(None) => return error_response(404, format!("unknown branch {branch:?}")),
38        Err(e) => return error_response(500, format!("get_branch: {e}")),
39    };
40    let Some(head_op) = head_op else {
41        return json_response(200, &serde_json::json!({ "branch": branch, "items": [] }));
42    };
43
44    // stage_id → intent prompt (first line), from the op that produced it.
45    let stage_intent = match stage_intents(&store, &head_op) {
46        Ok(m) => m,
47        Err(e) => return error_response(500, format!("reading intents: {e}")),
48    };
49
50    // The head's stages, read per-SigId so distinct names survive a shared StageId.
51    let head = store.branch_head(&branch).unwrap_or_default();
52    let pairs: Vec<(String, String)> = head.iter().map(|(s, st)| (s.clone(), st.clone())).collect();
53    let asts = store.get_asts_for_sigs_bulk(&pairs);
54
55    let mut items = Vec::new();
56    for ((_sig, stage_id), ast) in pairs.iter().zip(asts) {
57        let name = match ast {
58            Ok(lex_ast::Stage::FnDecl(fd)) => fd.name,
59            Ok(lex_ast::Stage::TypeDecl(td)) => td.name,
60            _ => continue,
61        };
62        let verdict = store
63            .latest_review_verdict(stage_id)
64            .ok()
65            .flatten()
66            .map(|v| match v {
67                lex_vcs::ReviewVerdict::Approve => "approved",
68                lex_vcs::ReviewVerdict::Reject => "rejected",
69                lex_vcs::ReviewVerdict::RequestChanges => "changes_requested",
70            })
71            .unwrap_or("none");
72        // The exception rule: a stage needs a human unless it is already
73        // approved. Unreviewed, rejected, and changes-requested all surface.
74        let needs_review = verdict != "approved";
75        items.push(serde_json::json!({
76            "stage_id": stage_id,
77            "name": name,
78            "intent": stage_intent.get(stage_id),
79            "review": verdict,
80            "needs_review": needs_review,
81        }));
82    }
83
84    json_response(200, &serde_json::json!({
85        "branch": branch,
86        "head_op": head_op,
87        "items": items,
88    }))
89}
90
91#[derive(Deserialize)]
92struct VerdictReq {
93    stage_id: String,
94    /// "approve" | "reject" | "request_changes".
95    verdict: String,
96    reviewer: String,
97    #[serde(default)]
98    note: Option<String>,
99}
100
101/// `POST /v1/review/verdict` — record a human review verdict as a `Review`
102/// attestation on a stage. The verdict enters the same attestation graph the
103/// gates consume (a Reject blocks promotion via `latest_review_verdict`).
104pub fn review_verdict_handler(state: &State, body: &str) -> Response<Cursor<Vec<u8>>> {
105    let req: VerdictReq = match serde_json::from_str(body) {
106        Ok(r) => r,
107        Err(e) => return error_response(400, format!("bad request: {e}")),
108    };
109    let verdict = match req.verdict.as_str() {
110        "approve" => lex_vcs::ReviewVerdict::Approve,
111        "reject" => lex_vcs::ReviewVerdict::Reject,
112        "request_changes" => lex_vcs::ReviewVerdict::RequestChanges,
113        other => return error_response(400, format!("verdict must be approve|reject|request_changes, got {other:?}")),
114    };
115    if req.reviewer.trim().is_empty() {
116        return error_response(400, "reviewer must be non-empty");
117    }
118    let store = state.store.lock().unwrap();
119    // Guard: the stage must exist, so a verdict can't be filed on a typo.
120    if store.get_metadata(&req.stage_id).is_err() {
121        return error_response(404, format!("unknown stage {:?}", req.stage_id));
122    }
123    match store.record_review(&req.stage_id, None, &req.reviewer, verdict, req.note) {
124        Ok(id) => json_response(201, &serde_json::json!({
125            "attestation_id": id,
126            "stage_id": req.stage_id,
127            "reviewer": req.reviewer,
128        })),
129        Err(e) => error_response(500, format!("record_review: {e}")),
130    }
131}
132
133/// Map each head stage to the intent prompt (first line) of the op that
134/// produced it, by walking the op log and joining to the intent log.
135fn stage_intents(
136    store: &lex_store::Store,
137    head_op: &str,
138) -> Result<BTreeMap<String, String>, String> {
139    let log = lex_vcs::OpLog::open(store.root()).map_err(|e| e.to_string())?;
140    let intents = lex_vcs::IntentLog::open(store.root()).map_err(|e| e.to_string())?;
141    let mut out: BTreeMap<String, String> = BTreeMap::new();
142    for rec in log.walk_forward(&head_op.to_string(), None).map_err(|e| e.to_string())? {
143        let Some(intent_id) = &rec.op.intent_id else { continue };
144        let Some(intent) = intents.get(intent_id).map_err(|e| e.to_string())? else { continue };
145        let first_line = intent.prompt.lines().next().unwrap_or("").to_string();
146        for stage_id in rec.produces.stage_ids() {
147            out.insert(stage_id, first_line.clone());
148        }
149    }
150    Ok(out)
151}