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, PathFrame, 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). A terminal exit of the awaiting step
63/// (or an ancestor of it) settles its request without a response — the
64/// lazy timeout settlement and the aborted disposition (06 §5.3) — so the
65/// channel's view matches the store's, which rejects such requests as
66/// settled.
67pub fn pending_requests(store: &Store, run_id: &str) -> Result<Vec<PendingRequest>, HumanCliError> {
68    let events = store.events(run_id)?;
69    let mut pending: Vec<PendingRequest> = Vec::new();
70    for event in &events {
71        match &event.payload {
72            RunLogPayload::HumanRequested {
73                request_id,
74                purpose,
75                mode,
76                prompt,
77                presents,
78                decisions,
79                output_schema,
80                deadline_at_ms,
81            } => pending.push(PendingRequest {
82                request_id: request_id.clone(),
83                purpose: *purpose,
84                mode: *mode,
85                prompt: prompt.clone(),
86                presents: presents.clone(),
87                decisions: decisions.clone(),
88                output_schema: output_schema.clone(),
89                deadline_at_ms: *deadline_at_ms,
90                run_path: event.run_path.clone(),
91            }),
92            RunLogPayload::HumanResponded {
93                request_id,
94                purpose,
95                response,
96                ..
97            } => {
98                let non_final = *purpose == HumanPurpose::Supervision
99                    && response.get("decision").and_then(Value::as_str) == Some("suspend");
100                if !non_final {
101                    pending.retain(|request| request.request_id != *request_id);
102                }
103            }
104            RunLogPayload::StepExited { .. } => {
105                pending.retain(|request| !exit_settles_pending(&event.run_path, &request.run_path));
106            }
107            _ => {}
108        }
109    }
110    Ok(pending)
111}
112
113/// Whether a `stepExited` at `exited` settles the request anchored at
114/// `pending`: the exited step IS the awaiting step or an ancestor of it,
115/// compared site-wise (hash-insensitive, so a cross-IR resume still
116/// settles a request recorded under the old flow hashes). Mirrors the
117/// store fold's rule so the two surfaces cannot diverge.
118fn exit_settles_pending(exited: &[PathFrame], pending: &[PathFrame]) -> bool {
119    pending.len() >= exited.len()
120        && pending
121            .iter()
122            .zip(exited.iter())
123            .all(|(a, b)| same_site(a, b))
124}
125
126fn same_site(a: &PathFrame, b: &PathFrame) -> bool {
127    match (a, b) {
128        (PathFrame::Flow { flow_id: a, .. }, PathFrame::Flow { flow_id: b, .. }) => a == b,
129        (
130            PathFrame::Call {
131                step_id: a,
132                callee_flow_id: af,
133                ..
134            },
135            PathFrame::Call {
136                step_id: b,
137                callee_flow_id: bf,
138                ..
139            },
140        ) => a == b && af == bf,
141        (a, b) => a == b,
142    }
143}
144
145/// Finds one pending request by id.
146pub fn find_pending(
147    store: &Store,
148    run_id: &str,
149    request_id: &str,
150) -> Result<PendingRequest, HumanCliError> {
151    pending_requests(store, run_id)?
152        .into_iter()
153        .find(|request| request.request_id == request_id)
154        .ok_or_else(|| HumanCliError::NotPending(request_id.to_owned()))
155}
156
157/// The answer vocabulary line for a request (what the human may type).
158pub fn answer_hint(request: &PendingRequest) -> String {
159    match (request.purpose, request.mode) {
160        (HumanPurpose::Supervision, _) => "answer: proceed | abort | suspend".to_owned(),
161        (_, Some(HumanMode::Confirm)) => {
162            let labels = request.decisions.as_deref().unwrap_or(&[]).join("' | '");
163            format!("answer: '{labels}'")
164        }
165        (_, Some(HumanMode::Judge)) => "answer: pass | fail | unknown".to_owned(),
166        (_, Some(HumanMode::ProvideInput)) => {
167            "answer: one line of JSON matching the declared schema".to_owned()
168        }
169        (_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
170            // A declaring request (the reconcile adjudication's
171            // adopt|redo|abort, 07 §4.4) is answered in its declared
172            // vocabulary; otherwise the 06 §2.1 base vocabulary.
173            Some(labels) => format!("answer: '{}'", labels.join("' | '")),
174            None => "answer: done | cannotRepair".to_owned(),
175        },
176        (_, None) => "answer: (unknown request shape)".to_owned(),
177    }
178}
179
180/// Renders one pending request for a terminal.
181pub fn render(w: &mut impl Write, request: &PendingRequest) -> std::io::Result<()> {
182    writeln!(w, "── human request {} ──", request.request_id)?;
183    let kind = match (request.purpose, request.mode) {
184        (HumanPurpose::Supervision, _) => "supervision gate".to_owned(),
185        (_, Some(mode)) => format!(
186            "human step ({})",
187            serde_json::to_value(mode)
188                .ok()
189                .and_then(|v| v.as_str().map(str::to_owned))
190                .unwrap_or_default()
191        ),
192        (_, None) => "human step".to_owned(),
193    };
194    writeln!(w, "kind: {kind}")?;
195    writeln!(w, "prompt: {}", request.prompt)?;
196    if let Value::Array(items) = &request.presents
197        && !items.is_empty()
198    {
199        writeln!(w, "presents:")?;
200        for (index, item) in items.iter().enumerate() {
201            writeln!(w, "  [{index}] {item}")?;
202        }
203    }
204    if let Some(deadline) = request.deadline_at_ms {
205        writeln!(w, "deadlineAtMs: {deadline}")?;
206    }
207    writeln!(w, "{}", answer_hint(request))?;
208    Ok(())
209}
210
211/// The CLI channel's actor string: `cli:os:<user>@<host>` (06 §4.4).
212/// Attribution, not authentication — the v0.1 trust boundary is the
213/// machine itself; the report honestly records who was at the keyboard.
214pub fn cli_actor() -> String {
215    let user = std::env::var("USER")
216        .or_else(|_| std::env::var("USERNAME"))
217        .unwrap_or_else(|_| "unknown".to_owned());
218    let host = gethostname::gethostname().to_string_lossy().into_owned();
219    format!("cli:os:{user}@{host}")
220}
221
222/// Interprets one answer line into the mode-shaped response payload the
223/// store arbitration validates (06 §2.1 union).
224pub fn interpret_answer(request: &PendingRequest, line: &str) -> Result<Value, HumanCliError> {
225    let answer = line.trim();
226    if answer.is_empty() {
227        return Err(HumanCliError::InvalidAnswer("empty answer".to_owned()));
228    }
229    match (request.purpose, request.mode) {
230        (HumanPurpose::Supervision, _) => match answer {
231            "proceed" | "abort" | "suspend" => Ok(serde_json::json!({ "decision": answer })),
232            other => Err(HumanCliError::InvalidAnswer(format!(
233                "'{other}' is not a supervision decision (proceed|abort|suspend)"
234            ))),
235        },
236        (_, Some(HumanMode::Confirm)) => {
237            let labels = request.decisions.as_deref().unwrap_or(&[]);
238            if labels.iter().any(|label| label == answer) {
239                Ok(serde_json::json!({ "decision": answer }))
240            } else {
241                Err(HumanCliError::InvalidAnswer(format!(
242                    "'{answer}' is not one of the confirm labels {labels:?}"
243                )))
244            }
245        }
246        (_, Some(HumanMode::Judge)) => match answer {
247            "pass" | "fail" | "unknown" => Ok(serde_json::json!({ "status": answer })),
248            other => Err(HumanCliError::InvalidAnswer(format!(
249                "'{other}' is not a judge status (pass|fail|unknown)"
250            ))),
251        },
252        (_, Some(HumanMode::ProvideInput)) => {
253            let input: Value = serde_json::from_str(answer).map_err(|err| {
254                HumanCliError::InvalidAnswer(format!("provideInput answer is not JSON: {err}"))
255            })?;
256            Ok(serde_json::json!({ "input": input }))
257        }
258        (_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
259            // Declared-first, mirroring the store arbitration exactly: a
260            // declaring request (the reconcile adjudication's
261            // adopt|redo|abort, 07 §4.4) is valid only in its declared
262            // vocabulary; without a declaration the 06 §2.1 base
263            // vocabulary governs.
264            Some(labels) => {
265                if labels.iter().any(|label| label == answer) {
266                    Ok(serde_json::json!({ "decision": answer }))
267                } else {
268                    Err(HumanCliError::InvalidAnswer(format!(
269                        "'{answer}' is not one of the declared repairWorld decisions {labels:?}"
270                    )))
271                }
272            }
273            None => match answer {
274                "done" | "cannotRepair" => Ok(serde_json::json!({ "decision": answer })),
275                other => Err(HumanCliError::InvalidAnswer(format!(
276                    "'{other}' is not a repairWorld decision (done|cannotRepair)"
277                ))),
278            },
279        },
280        (_, None) => Err(HumanCliError::InvalidAnswer(
281            "request carries no mode".to_owned(),
282        )),
283    }
284}
285
286/// Renders the request, reads one answer line, and submits it through the
287/// store arbitration. Returns the appended `humanResponded` seq and the
288/// interpreted response (the interactive loop inspects supervision
289/// `suspend` answers to stop re-prompting).
290pub fn collect(
291    store: &mut Store,
292    run_id: &str,
293    request_id: &str,
294    actor: &str,
295    at_ms: u64,
296    reader: &mut impl BufRead,
297    writer: &mut impl Write,
298) -> Result<(u64, Value), HumanCliError> {
299    let request = find_pending(store, run_id, request_id)?;
300    render(writer, &request)?;
301    writer.flush()?;
302    let mut line = String::new();
303    reader.read_line(&mut line)?;
304    let response = interpret_answer(&request, &line)?;
305    let seq = store.submit_human_response(run_id, request_id, actor, at_ms, response.clone())?;
306    writeln!(writer, "response recorded (seq {seq})")?;
307    Ok((seq, response))
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn request(purpose: HumanPurpose, mode: Option<HumanMode>) -> PendingRequest {
315        PendingRequest {
316            request_id: "req-1".to_owned(),
317            purpose,
318            mode,
319            prompt: "p".to_owned(),
320            presents: Value::Array(Vec::new()),
321            decisions: Some(vec!["yes".to_owned(), "no".to_owned()]),
322            output_schema: None,
323            deadline_at_ms: None,
324            run_path: Vec::new(),
325        }
326    }
327
328    fn temp_root(tag: &str) -> std::path::PathBuf {
329        let nanos = std::time::SystemTime::now()
330            .duration_since(std::time::UNIX_EPOCH)
331            .expect("clock")
332            .as_nanos();
333        std::env::temp_dir().join(format!(
334            "pointlock-human-cli-{tag}-{}-{nanos}",
335            std::process::id()
336        ))
337    }
338
339    fn hash(digit: char) -> pointlock_ir::Hash {
340        pointlock_ir::Hash::try_from(format!("sha256:{}", digit.to_string().repeat(64)))
341            .expect("hash")
342    }
343
344    /// Ledger shape of the lazy timeout settlement (06 §5.3): a request is
345    /// raised, then the awaiting step exits terminally with NO
346    /// `humanResponded`. The store's fold clears `human_pending` and
347    /// rejects a late answer as settled; the channel must agree (I1).
348    #[test]
349    fn terminal_step_exit_settles_pending_requests() {
350        use pointlock_ir::{
351            BindingState, EventCursor, PathFrame, StepState, Verdict, VerdictStatus,
352        };
353        use pointlock_store::NewRun;
354        use serde_json::json;
355
356        let root_dir = temp_root("settle");
357        let mut store = Store::open(&root_dir).expect("open store");
358        let run_id = store
359            .begin_run(NewRun {
360                run_id: Some("run-timeout".to_owned()),
361                flow_id: "demo".try_into().expect("flow id"),
362                ir_hash: hash('a'),
363                lockfile_digest: hash('b'),
364                params_snapshot: json!({}),
365                binding: BindingState {
366                    device_id: "fake-device-1".to_owned(),
367                    session_lineage: vec!["session-1".to_owned()],
368                    event_cursor: EventCursor {
369                        session_id: "session-1".to_owned(),
370                        last_sequence: 0,
371                    },
372                },
373                created_at_ms: 4_000,
374            })
375            .expect("begin run");
376        let flow = PathFrame::Flow {
377            flow_id: "demo".try_into().expect("flow id"),
378            ir_hash: hash('a'),
379        };
380        let root: RunPath = vec![flow.clone()];
381        let gate: RunPath = vec![
382            flow,
383            PathFrame::Step {
384                step_id: "ask".try_into().expect("step id"),
385            },
386        ];
387        let events: Vec<(RunPath, RunLogPayload)> = vec![
388            (
389                root,
390                RunLogPayload::RunStarted {
391                    ir_hash: hash('a'),
392                    lockfile_digest: hash('b'),
393                    params_snapshot: json!({}),
394                    supervise_policy: None,
395                },
396            ),
397            (
398                gate.clone(),
399                RunLogPayload::StepEntered {
400                    step_id: "ask".try_into().expect("step id"),
401                    effect_hash: hash('c'),
402                    judge_hash: hash('d'),
403                    resolved_inputs: Value::Null,
404                },
405            ),
406            (
407                gate.clone(),
408                RunLogPayload::HumanRequested {
409                    request_id: "req-t".to_owned(),
410                    purpose: HumanPurpose::Step,
411                    mode: Some(HumanMode::Confirm),
412                    prompt: "confirm?".to_owned(),
413                    presents: json!([]),
414                    decisions: Some(vec!["yes".to_owned(), "no".to_owned()]),
415                    output_schema: None,
416                    deadline_at_ms: Some(4_050),
417                },
418            ),
419            (
420                gate.clone(),
421                RunLogPayload::VerdictRecorded {
422                    verdict: Verdict {
423                        status: VerdictStatus::Unknown,
424                        degraded: false,
425                        summary: "timed out".to_owned(),
426                        evidence: Vec::new(),
427                        supersedes: None,
428                    },
429                    localized: Vec::new(),
430                    localization_gaps: Vec::new(),
431                    remote_archival_error: None,
432                },
433            ),
434            (
435                gate.clone(),
436                RunLogPayload::StepExited {
437                    provider_state_summary: None,
438                    state: StepState::Judged,
439                    output: None,
440                    localized: Vec::new(),
441                    localization_gaps: Vec::new(),
442                },
443            ),
444        ];
445        let mut at = 4_000u64;
446        for (path, payload) in &events {
447            at += 10;
448            store
449                .append_event(&run_id, at, path, payload)
450                .expect("append");
451        }
452
453        let pending = pending_requests(&store, &run_id).expect("pending");
454        assert!(
455            pending.is_empty(),
456            "terminal exit settles the request without a response: {pending:?}"
457        );
458        assert!(matches!(
459            find_pending(&store, &run_id, "req-t"),
460            Err(HumanCliError::NotPending(_))
461        ));
462        let _ = std::fs::remove_dir_all(&root_dir);
463    }
464
465    #[test]
466    fn interprets_the_mode_vocabularies() {
467        let judge = request(HumanPurpose::Step, Some(HumanMode::Judge));
468        assert_eq!(
469            interpret_answer(&judge, "pass\n").expect("judge"),
470            serde_json::json!({ "status": "pass" })
471        );
472        assert!(interpret_answer(&judge, "yes").is_err());
473
474        let confirm = request(HumanPurpose::Step, Some(HumanMode::Confirm));
475        assert_eq!(
476            interpret_answer(&confirm, "no").expect("confirm"),
477            serde_json::json!({ "decision": "no" })
478        );
479        assert!(interpret_answer(&confirm, "maybe").is_err());
480
481        let gate = request(HumanPurpose::Supervision, None);
482        assert_eq!(
483            interpret_answer(&gate, "suspend").expect("gate"),
484            serde_json::json!({ "decision": "suspend" })
485        );
486
487        let provide = request(HumanPurpose::Step, Some(HumanMode::ProvideInput));
488        assert_eq!(
489            interpret_answer(&provide, r#"{"ssid":"lab"}"#).expect("provide"),
490            serde_json::json!({ "input": { "ssid": "lab" } })
491        );
492        assert!(interpret_answer(&provide, "not json").is_err());
493
494        let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
495        repair.decisions = None;
496        assert_eq!(
497            interpret_answer(&repair, "done").expect("repair"),
498            serde_json::json!({ "decision": "done" })
499        );
500        assert_eq!(
501            interpret_answer(&repair, "cannotRepair").expect("repair"),
502            serde_json::json!({ "decision": "cannotRepair" })
503        );
504        // The retired as-built vocabulary must stay rejected
505        // (2026-07-28 unification).
506        assert!(interpret_answer(&repair, "repaired").is_err());
507        assert!(interpret_answer(&repair, "abort").is_err());
508    }
509
510    #[test]
511    fn repair_world_honors_declared_decisions() {
512        // The reconcile adjudication (07 §4.4) declares its own
513        // vocabulary; the CLI must accept exactly that set — mirroring
514        // the store's declared-first arbitration.
515        let mut adjudicate = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
516        adjudicate.decisions = Some(vec![
517            "adopt".to_owned(),
518            "redo".to_owned(),
519            "abort".to_owned(),
520        ]);
521        assert_eq!(
522            interpret_answer(&adjudicate, "adopt").expect("declared"),
523            serde_json::json!({ "decision": "adopt" })
524        );
525        // Negative control: the base vocabulary does NOT leak into a
526        // declaring request.
527        assert!(interpret_answer(&adjudicate, "done").is_err());
528        assert!(answer_hint(&adjudicate).contains("'adopt' | 'redo' | 'abort'"));
529    }
530
531    #[test]
532    fn cli_actor_carries_the_os_principal() {
533        // 06 §4.4: `cli:os:<user>@<host>` — attribution from OS identity,
534        // never a hardcoded placeholder.
535        let actor = cli_actor();
536        assert!(actor.starts_with("cli:os:"), "{actor}");
537        assert!(actor.contains('@'), "{actor}");
538        assert_ne!(actor, "cli:os:@");
539    }
540
541    #[test]
542    fn repair_world_hint_matches_the_accepted_vocabulary() {
543        // The hint must never advertise words interpret_answer rejects
544        // (the retired `repaired | abort` hint did exactly that).
545        let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
546        repair.decisions = None;
547        let hint = answer_hint(&repair);
548        assert!(hint.contains("done") && hint.contains("cannotRepair"));
549        assert!(!hint.contains("repaired"));
550    }
551}