1pub 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#[derive(Debug, Clone)]
22pub struct PendingRequest {
23 pub request_id: String,
25 pub purpose: HumanPurpose,
27 pub mode: Option<HumanMode>,
29 pub prompt: String,
31 pub presents: Value,
33 pub decisions: Option<Vec<String>>,
35 pub output_schema: Option<pointlock_ir::JsonSchemaDocument>,
37 pub deadline_at_ms: Option<u64>,
39 pub run_path: RunPath,
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum HumanCliError {
46 #[error("store: {0}")]
48 Store(#[from] StoreError),
49 #[error("io: {0}")]
51 Io(#[from] std::io::Error),
52 #[error("invalid answer: {0}")]
54 InvalidAnswer(String),
55 #[error("no pending request '{0}' on the ledger")]
57 NotPending(String),
58}
59
60pub 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
106pub 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
118pub 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 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
141pub 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
172pub 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
183pub 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 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
247pub 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 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 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 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 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 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}