1pub 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#[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> {
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
113fn 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
145pub 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
157pub 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 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
180pub 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
211pub 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
222pub 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 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
286pub 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 #[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 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 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 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 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 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}