Skip to main content

pointlock_human_cli/
lib.rs

1//! The CLI human channel (06 §4: the `cli` notification/collection
2//! channel): renders pending human requests from the ledger and collects
3//! responses through the store's single-writer arbitration.
4//!
5//! This crate is presentation + collection only. It never judges, never
6//! writes ledger events itself (submission goes through
7//! [`pointlock_store::Store::submit_human_response`], the one arbitration
8//! door), and reads request context straight from the `humanRequested`
9//! events — the log is the truth (I1).
10
11pub mod webhook;
12
13use std::io::{BufRead, Write};
14
15use pointlock_ir::{HumanMode, HumanPurpose, RunLogPayload, RunPath};
16use pointlock_store::{Store, StoreError};
17use serde_json::Value;
18
19/// One unanswered human request, reconstructed from the ledger (the
20/// `humanRequested` payload plus its pairing state).
21#[derive(Debug, Clone)]
22pub struct PendingRequest {
23    /// The id a response must pair with.
24    pub request_id: String,
25    /// Step vs supervision gate (R13).
26    pub purpose: HumanPurpose,
27    /// Interaction mode (`purpose = step` only).
28    pub mode: Option<HumanMode>,
29    /// The prompt shown to the human.
30    pub prompt: String,
31    /// The materialized exhibits.
32    pub presents: Value,
33    /// Confirm labels, when the mode declares them.
34    pub decisions: Option<Vec<String>>,
35    /// The provideInput contract, when the mode declares one.
36    pub output_schema: Option<pointlock_ir::JsonSchemaDocument>,
37    /// Absolute response deadline (ms); absent for supervision gates.
38    pub deadline_at_ms: Option<u64>,
39    /// The awaiting/gated step's run path.
40    pub run_path: RunPath,
41}
42
43/// Errors of the collection channel.
44#[derive(Debug, thiserror::Error)]
45pub enum HumanCliError {
46    /// Reading the ledger failed.
47    #[error("store: {0}")]
48    Store(#[from] StoreError),
49    /// Terminal I/O failed.
50    #[error("io: {0}")]
51    Io(#[from] std::io::Error),
52    /// The answer could not be interpreted for the request's mode.
53    #[error("invalid answer: {0}")]
54    InvalidAnswer(String),
55    /// No pending request with that id exists on the ledger.
56    #[error("no pending request '{0}' on the ledger")]
57    NotPending(String),
58}
59
60/// Scans one run's ledger for unanswered human requests, in request
61/// order. A supervision `suspend` answer is non-final and keeps its
62/// request pending (spine §6.9).
63pub fn pending_requests(store: &Store, run_id: &str) -> Result<Vec<PendingRequest>, HumanCliError> {
64    let events = store.events(run_id)?;
65    let mut pending: Vec<PendingRequest> = Vec::new();
66    for event in &events {
67        match &event.payload {
68            RunLogPayload::HumanRequested {
69                request_id,
70                purpose,
71                mode,
72                prompt,
73                presents,
74                decisions,
75                output_schema,
76                deadline_at_ms,
77            } => pending.push(PendingRequest {
78                request_id: request_id.clone(),
79                purpose: *purpose,
80                mode: *mode,
81                prompt: prompt.clone(),
82                presents: presents.clone(),
83                decisions: decisions.clone(),
84                output_schema: output_schema.clone(),
85                deadline_at_ms: *deadline_at_ms,
86                run_path: event.run_path.clone(),
87            }),
88            RunLogPayload::HumanResponded {
89                request_id,
90                purpose,
91                response,
92                ..
93            } => {
94                let non_final = *purpose == HumanPurpose::Supervision
95                    && response.get("decision").and_then(Value::as_str) == Some("suspend");
96                if !non_final {
97                    pending.retain(|request| request.request_id != *request_id);
98                }
99            }
100            _ => {}
101        }
102    }
103    Ok(pending)
104}
105
106/// Finds one pending request by id.
107pub fn find_pending(
108    store: &Store,
109    run_id: &str,
110    request_id: &str,
111) -> Result<PendingRequest, HumanCliError> {
112    pending_requests(store, run_id)?
113        .into_iter()
114        .find(|request| request.request_id == request_id)
115        .ok_or_else(|| HumanCliError::NotPending(request_id.to_owned()))
116}
117
118/// The answer vocabulary line for a request (what the human may type).
119pub fn answer_hint(request: &PendingRequest) -> String {
120    match (request.purpose, request.mode) {
121        (HumanPurpose::Supervision, _) => "answer: proceed | abort | suspend".to_owned(),
122        (_, Some(HumanMode::Confirm)) => {
123            let labels = request.decisions.as_deref().unwrap_or(&[]).join("' | '");
124            format!("answer: '{labels}'")
125        }
126        (_, Some(HumanMode::Judge)) => "answer: pass | fail | unknown".to_owned(),
127        (_, Some(HumanMode::ProvideInput)) => {
128            "answer: one line of JSON matching the declared schema".to_owned()
129        }
130        (_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
131            // A declaring request (the reconcile adjudication's
132            // adopt|redo|abort, 07 §4.4) is answered in its declared
133            // vocabulary; otherwise the 06 §2.1 base vocabulary.
134            Some(labels) => format!("answer: '{}'", labels.join("' | '")),
135            None => "answer: done | cannotRepair".to_owned(),
136        },
137        (_, None) => "answer: (unknown request shape)".to_owned(),
138    }
139}
140
141/// Renders one pending request for a terminal.
142pub fn render(w: &mut impl Write, request: &PendingRequest) -> std::io::Result<()> {
143    writeln!(w, "── human request {} ──", request.request_id)?;
144    let kind = match (request.purpose, request.mode) {
145        (HumanPurpose::Supervision, _) => "supervision gate".to_owned(),
146        (_, Some(mode)) => format!(
147            "human step ({})",
148            serde_json::to_value(mode)
149                .ok()
150                .and_then(|v| v.as_str().map(str::to_owned))
151                .unwrap_or_default()
152        ),
153        (_, None) => "human step".to_owned(),
154    };
155    writeln!(w, "kind: {kind}")?;
156    writeln!(w, "prompt: {}", request.prompt)?;
157    if let Value::Array(items) = &request.presents
158        && !items.is_empty()
159    {
160        writeln!(w, "presents:")?;
161        for (index, item) in items.iter().enumerate() {
162            writeln!(w, "  [{index}] {item}")?;
163        }
164    }
165    if let Some(deadline) = request.deadline_at_ms {
166        writeln!(w, "deadlineAtMs: {deadline}")?;
167    }
168    writeln!(w, "{}", answer_hint(request))?;
169    Ok(())
170}
171
172/// The CLI channel's actor string: `cli:os:<user>@<host>` (06 §4.4).
173/// Attribution, not authentication — the v0.1 trust boundary is the
174/// machine itself; the report honestly records who was at the keyboard.
175pub fn cli_actor() -> String {
176    let user = std::env::var("USER")
177        .or_else(|_| std::env::var("USERNAME"))
178        .unwrap_or_else(|_| "unknown".to_owned());
179    let host = gethostname::gethostname().to_string_lossy().into_owned();
180    format!("cli:os:{user}@{host}")
181}
182
183/// Interprets one answer line into the mode-shaped response payload the
184/// store arbitration validates (06 §2.1 union).
185pub fn interpret_answer(request: &PendingRequest, line: &str) -> Result<Value, HumanCliError> {
186    let answer = line.trim();
187    if answer.is_empty() {
188        return Err(HumanCliError::InvalidAnswer("empty answer".to_owned()));
189    }
190    match (request.purpose, request.mode) {
191        (HumanPurpose::Supervision, _) => match answer {
192            "proceed" | "abort" | "suspend" => Ok(serde_json::json!({ "decision": answer })),
193            other => Err(HumanCliError::InvalidAnswer(format!(
194                "'{other}' is not a supervision decision (proceed|abort|suspend)"
195            ))),
196        },
197        (_, Some(HumanMode::Confirm)) => {
198            let labels = request.decisions.as_deref().unwrap_or(&[]);
199            if labels.iter().any(|label| label == answer) {
200                Ok(serde_json::json!({ "decision": answer }))
201            } else {
202                Err(HumanCliError::InvalidAnswer(format!(
203                    "'{answer}' is not one of the confirm labels {labels:?}"
204                )))
205            }
206        }
207        (_, Some(HumanMode::Judge)) => match answer {
208            "pass" | "fail" | "unknown" => Ok(serde_json::json!({ "status": answer })),
209            other => Err(HumanCliError::InvalidAnswer(format!(
210                "'{other}' is not a judge status (pass|fail|unknown)"
211            ))),
212        },
213        (_, Some(HumanMode::ProvideInput)) => {
214            let input: Value = serde_json::from_str(answer).map_err(|err| {
215                HumanCliError::InvalidAnswer(format!("provideInput answer is not JSON: {err}"))
216            })?;
217            Ok(serde_json::json!({ "input": input }))
218        }
219        (_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
220            // Declared-first, mirroring the store arbitration exactly: a
221            // declaring request (the reconcile adjudication's
222            // adopt|redo|abort, 07 §4.4) is valid only in its declared
223            // vocabulary; without a declaration the 06 §2.1 base
224            // vocabulary governs.
225            Some(labels) => {
226                if labels.iter().any(|label| label == answer) {
227                    Ok(serde_json::json!({ "decision": answer }))
228                } else {
229                    Err(HumanCliError::InvalidAnswer(format!(
230                        "'{answer}' is not one of the declared repairWorld decisions {labels:?}"
231                    )))
232                }
233            }
234            None => match answer {
235                "done" | "cannotRepair" => Ok(serde_json::json!({ "decision": answer })),
236                other => Err(HumanCliError::InvalidAnswer(format!(
237                    "'{other}' is not a repairWorld decision (done|cannotRepair)"
238                ))),
239            },
240        },
241        (_, None) => Err(HumanCliError::InvalidAnswer(
242            "request carries no mode".to_owned(),
243        )),
244    }
245}
246
247/// Renders the request, reads one answer line, and submits it through the
248/// store arbitration. Returns the appended `humanResponded` seq and the
249/// interpreted response (the interactive loop inspects supervision
250/// `suspend` answers to stop re-prompting).
251pub fn collect(
252    store: &mut Store,
253    run_id: &str,
254    request_id: &str,
255    actor: &str,
256    at_ms: u64,
257    reader: &mut impl BufRead,
258    writer: &mut impl Write,
259) -> Result<(u64, Value), HumanCliError> {
260    let request = find_pending(store, run_id, request_id)?;
261    render(writer, &request)?;
262    writer.flush()?;
263    let mut line = String::new();
264    reader.read_line(&mut line)?;
265    let response = interpret_answer(&request, &line)?;
266    let seq = store.submit_human_response(run_id, request_id, actor, at_ms, response.clone())?;
267    writeln!(writer, "response recorded (seq {seq})")?;
268    Ok((seq, response))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn request(purpose: HumanPurpose, mode: Option<HumanMode>) -> PendingRequest {
276        PendingRequest {
277            request_id: "req-1".to_owned(),
278            purpose,
279            mode,
280            prompt: "p".to_owned(),
281            presents: Value::Array(Vec::new()),
282            decisions: Some(vec!["yes".to_owned(), "no".to_owned()]),
283            output_schema: None,
284            deadline_at_ms: None,
285            run_path: Vec::new(),
286        }
287    }
288
289    #[test]
290    fn interprets_the_mode_vocabularies() {
291        let judge = request(HumanPurpose::Step, Some(HumanMode::Judge));
292        assert_eq!(
293            interpret_answer(&judge, "pass\n").expect("judge"),
294            serde_json::json!({ "status": "pass" })
295        );
296        assert!(interpret_answer(&judge, "yes").is_err());
297
298        let confirm = request(HumanPurpose::Step, Some(HumanMode::Confirm));
299        assert_eq!(
300            interpret_answer(&confirm, "no").expect("confirm"),
301            serde_json::json!({ "decision": "no" })
302        );
303        assert!(interpret_answer(&confirm, "maybe").is_err());
304
305        let gate = request(HumanPurpose::Supervision, None);
306        assert_eq!(
307            interpret_answer(&gate, "suspend").expect("gate"),
308            serde_json::json!({ "decision": "suspend" })
309        );
310
311        let provide = request(HumanPurpose::Step, Some(HumanMode::ProvideInput));
312        assert_eq!(
313            interpret_answer(&provide, r#"{"ssid":"lab"}"#).expect("provide"),
314            serde_json::json!({ "input": { "ssid": "lab" } })
315        );
316        assert!(interpret_answer(&provide, "not json").is_err());
317
318        let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
319        repair.decisions = None;
320        assert_eq!(
321            interpret_answer(&repair, "done").expect("repair"),
322            serde_json::json!({ "decision": "done" })
323        );
324        assert_eq!(
325            interpret_answer(&repair, "cannotRepair").expect("repair"),
326            serde_json::json!({ "decision": "cannotRepair" })
327        );
328        // The retired as-built vocabulary must stay rejected
329        // (2026-07-28 unification).
330        assert!(interpret_answer(&repair, "repaired").is_err());
331        assert!(interpret_answer(&repair, "abort").is_err());
332    }
333
334    #[test]
335    fn repair_world_honors_declared_decisions() {
336        // The reconcile adjudication (07 §4.4) declares its own
337        // vocabulary; the CLI must accept exactly that set — mirroring
338        // the store's declared-first arbitration.
339        let mut adjudicate = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
340        adjudicate.decisions = Some(vec![
341            "adopt".to_owned(),
342            "redo".to_owned(),
343            "abort".to_owned(),
344        ]);
345        assert_eq!(
346            interpret_answer(&adjudicate, "adopt").expect("declared"),
347            serde_json::json!({ "decision": "adopt" })
348        );
349        // Negative control: the base vocabulary does NOT leak into a
350        // declaring request.
351        assert!(interpret_answer(&adjudicate, "done").is_err());
352        assert!(answer_hint(&adjudicate).contains("'adopt' | 'redo' | 'abort'"));
353    }
354
355    #[test]
356    fn cli_actor_carries_the_os_principal() {
357        // 06 §4.4: `cli:os:<user>@<host>` — attribution from OS identity,
358        // never a hardcoded placeholder.
359        let actor = cli_actor();
360        assert!(actor.starts_with("cli:os:"), "{actor}");
361        assert!(actor.contains('@'), "{actor}");
362        assert_ne!(actor, "cli:os:@");
363    }
364
365    #[test]
366    fn repair_world_hint_matches_the_accepted_vocabulary() {
367        // The hint must never advertise words interpret_answer rejects
368        // (the retired `repaired | abort` hint did exactly that).
369        let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
370        repair.decisions = None;
371        let hint = answer_hint(&repair);
372        assert!(hint.contains("done") && hint.contains("cannotRepair"));
373        assert!(!hint.contains("repaired"));
374    }
375}