pub mod webhook;
use std::io::{BufRead, Write};
use pointlock_ir::{HumanMode, HumanPurpose, RunLogPayload, RunPath};
use pointlock_store::{Store, StoreError};
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct PendingRequest {
pub request_id: String,
pub purpose: HumanPurpose,
pub mode: Option<HumanMode>,
pub prompt: String,
pub presents: Value,
pub decisions: Option<Vec<String>>,
pub output_schema: Option<pointlock_ir::JsonSchemaDocument>,
pub deadline_at_ms: Option<u64>,
pub run_path: RunPath,
}
#[derive(Debug, thiserror::Error)]
pub enum HumanCliError {
#[error("store: {0}")]
Store(#[from] StoreError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid answer: {0}")]
InvalidAnswer(String),
#[error("no pending request '{0}' on the ledger")]
NotPending(String),
}
pub fn pending_requests(store: &Store, run_id: &str) -> Result<Vec<PendingRequest>, HumanCliError> {
let events = store.events(run_id)?;
let mut pending: Vec<PendingRequest> = Vec::new();
for event in &events {
match &event.payload {
RunLogPayload::HumanRequested {
request_id,
purpose,
mode,
prompt,
presents,
decisions,
output_schema,
deadline_at_ms,
} => pending.push(PendingRequest {
request_id: request_id.clone(),
purpose: *purpose,
mode: *mode,
prompt: prompt.clone(),
presents: presents.clone(),
decisions: decisions.clone(),
output_schema: output_schema.clone(),
deadline_at_ms: *deadline_at_ms,
run_path: event.run_path.clone(),
}),
RunLogPayload::HumanResponded {
request_id,
purpose,
response,
..
} => {
let non_final = *purpose == HumanPurpose::Supervision
&& response.get("decision").and_then(Value::as_str) == Some("suspend");
if !non_final {
pending.retain(|request| request.request_id != *request_id);
}
}
_ => {}
}
}
Ok(pending)
}
pub fn find_pending(
store: &Store,
run_id: &str,
request_id: &str,
) -> Result<PendingRequest, HumanCliError> {
pending_requests(store, run_id)?
.into_iter()
.find(|request| request.request_id == request_id)
.ok_or_else(|| HumanCliError::NotPending(request_id.to_owned()))
}
pub fn answer_hint(request: &PendingRequest) -> String {
match (request.purpose, request.mode) {
(HumanPurpose::Supervision, _) => "answer: proceed | abort | suspend".to_owned(),
(_, Some(HumanMode::Confirm)) => {
let labels = request.decisions.as_deref().unwrap_or(&[]).join("' | '");
format!("answer: '{labels}'")
}
(_, Some(HumanMode::Judge)) => "answer: pass | fail | unknown".to_owned(),
(_, Some(HumanMode::ProvideInput)) => {
"answer: one line of JSON matching the declared schema".to_owned()
}
(_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
Some(labels) => format!("answer: '{}'", labels.join("' | '")),
None => "answer: done | cannotRepair".to_owned(),
},
(_, None) => "answer: (unknown request shape)".to_owned(),
}
}
pub fn render(w: &mut impl Write, request: &PendingRequest) -> std::io::Result<()> {
writeln!(w, "── human request {} ──", request.request_id)?;
let kind = match (request.purpose, request.mode) {
(HumanPurpose::Supervision, _) => "supervision gate".to_owned(),
(_, Some(mode)) => format!(
"human step ({})",
serde_json::to_value(mode)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
),
(_, None) => "human step".to_owned(),
};
writeln!(w, "kind: {kind}")?;
writeln!(w, "prompt: {}", request.prompt)?;
if let Value::Array(items) = &request.presents
&& !items.is_empty()
{
writeln!(w, "presents:")?;
for (index, item) in items.iter().enumerate() {
writeln!(w, " [{index}] {item}")?;
}
}
if let Some(deadline) = request.deadline_at_ms {
writeln!(w, "deadlineAtMs: {deadline}")?;
}
writeln!(w, "{}", answer_hint(request))?;
Ok(())
}
pub fn cli_actor() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_owned());
let host = gethostname::gethostname().to_string_lossy().into_owned();
format!("cli:os:{user}@{host}")
}
pub fn interpret_answer(request: &PendingRequest, line: &str) -> Result<Value, HumanCliError> {
let answer = line.trim();
if answer.is_empty() {
return Err(HumanCliError::InvalidAnswer("empty answer".to_owned()));
}
match (request.purpose, request.mode) {
(HumanPurpose::Supervision, _) => match answer {
"proceed" | "abort" | "suspend" => Ok(serde_json::json!({ "decision": answer })),
other => Err(HumanCliError::InvalidAnswer(format!(
"'{other}' is not a supervision decision (proceed|abort|suspend)"
))),
},
(_, Some(HumanMode::Confirm)) => {
let labels = request.decisions.as_deref().unwrap_or(&[]);
if labels.iter().any(|label| label == answer) {
Ok(serde_json::json!({ "decision": answer }))
} else {
Err(HumanCliError::InvalidAnswer(format!(
"'{answer}' is not one of the confirm labels {labels:?}"
)))
}
}
(_, Some(HumanMode::Judge)) => match answer {
"pass" | "fail" | "unknown" => Ok(serde_json::json!({ "status": answer })),
other => Err(HumanCliError::InvalidAnswer(format!(
"'{other}' is not a judge status (pass|fail|unknown)"
))),
},
(_, Some(HumanMode::ProvideInput)) => {
let input: Value = serde_json::from_str(answer).map_err(|err| {
HumanCliError::InvalidAnswer(format!("provideInput answer is not JSON: {err}"))
})?;
Ok(serde_json::json!({ "input": input }))
}
(_, Some(HumanMode::RepairWorld)) => match request.decisions.as_deref() {
Some(labels) => {
if labels.iter().any(|label| label == answer) {
Ok(serde_json::json!({ "decision": answer }))
} else {
Err(HumanCliError::InvalidAnswer(format!(
"'{answer}' is not one of the declared repairWorld decisions {labels:?}"
)))
}
}
None => match answer {
"done" | "cannotRepair" => Ok(serde_json::json!({ "decision": answer })),
other => Err(HumanCliError::InvalidAnswer(format!(
"'{other}' is not a repairWorld decision (done|cannotRepair)"
))),
},
},
(_, None) => Err(HumanCliError::InvalidAnswer(
"request carries no mode".to_owned(),
)),
}
}
pub fn collect(
store: &mut Store,
run_id: &str,
request_id: &str,
actor: &str,
at_ms: u64,
reader: &mut impl BufRead,
writer: &mut impl Write,
) -> Result<(u64, Value), HumanCliError> {
let request = find_pending(store, run_id, request_id)?;
render(writer, &request)?;
writer.flush()?;
let mut line = String::new();
reader.read_line(&mut line)?;
let response = interpret_answer(&request, &line)?;
let seq = store.submit_human_response(run_id, request_id, actor, at_ms, response.clone())?;
writeln!(writer, "response recorded (seq {seq})")?;
Ok((seq, response))
}
#[cfg(test)]
mod tests {
use super::*;
fn request(purpose: HumanPurpose, mode: Option<HumanMode>) -> PendingRequest {
PendingRequest {
request_id: "req-1".to_owned(),
purpose,
mode,
prompt: "p".to_owned(),
presents: Value::Array(Vec::new()),
decisions: Some(vec!["yes".to_owned(), "no".to_owned()]),
output_schema: None,
deadline_at_ms: None,
run_path: Vec::new(),
}
}
#[test]
fn interprets_the_mode_vocabularies() {
let judge = request(HumanPurpose::Step, Some(HumanMode::Judge));
assert_eq!(
interpret_answer(&judge, "pass\n").expect("judge"),
serde_json::json!({ "status": "pass" })
);
assert!(interpret_answer(&judge, "yes").is_err());
let confirm = request(HumanPurpose::Step, Some(HumanMode::Confirm));
assert_eq!(
interpret_answer(&confirm, "no").expect("confirm"),
serde_json::json!({ "decision": "no" })
);
assert!(interpret_answer(&confirm, "maybe").is_err());
let gate = request(HumanPurpose::Supervision, None);
assert_eq!(
interpret_answer(&gate, "suspend").expect("gate"),
serde_json::json!({ "decision": "suspend" })
);
let provide = request(HumanPurpose::Step, Some(HumanMode::ProvideInput));
assert_eq!(
interpret_answer(&provide, r#"{"ssid":"lab"}"#).expect("provide"),
serde_json::json!({ "input": { "ssid": "lab" } })
);
assert!(interpret_answer(&provide, "not json").is_err());
let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
repair.decisions = None;
assert_eq!(
interpret_answer(&repair, "done").expect("repair"),
serde_json::json!({ "decision": "done" })
);
assert_eq!(
interpret_answer(&repair, "cannotRepair").expect("repair"),
serde_json::json!({ "decision": "cannotRepair" })
);
assert!(interpret_answer(&repair, "repaired").is_err());
assert!(interpret_answer(&repair, "abort").is_err());
}
#[test]
fn repair_world_honors_declared_decisions() {
let mut adjudicate = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
adjudicate.decisions = Some(vec![
"adopt".to_owned(),
"redo".to_owned(),
"abort".to_owned(),
]);
assert_eq!(
interpret_answer(&adjudicate, "adopt").expect("declared"),
serde_json::json!({ "decision": "adopt" })
);
assert!(interpret_answer(&adjudicate, "done").is_err());
assert!(answer_hint(&adjudicate).contains("'adopt' | 'redo' | 'abort'"));
}
#[test]
fn cli_actor_carries_the_os_principal() {
let actor = cli_actor();
assert!(actor.starts_with("cli:os:"), "{actor}");
assert!(actor.contains('@'), "{actor}");
assert_ne!(actor, "cli:os:@");
}
#[test]
fn repair_world_hint_matches_the_accepted_vocabulary() {
let mut repair = request(HumanPurpose::Step, Some(HumanMode::RepairWorld));
repair.decisions = None;
let hint = answer_hint(&repair);
assert!(hint.contains("done") && hint.contains("cannotRepair"));
assert!(!hint.contains("repaired"));
}
}