fabro/lib/components/fabro-workflow/src/handler/human.rs
2026-07-31 08:19:10 -04:00

1318 lines
45 KiB
Rust

use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
use fabro_interview::{Answer, AnswerValue, Interviewer, Question, ask_with_timeout};
use fabro_types::{InterviewOption, Principal, QuestionType, ReviewTarget, SystemActorKind};
use ulid::Ulid;
use super::{EngineServices, Handler, NodeTimeoutPolicy};
use crate::context::{Context, keys};
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::millis_u64;
use crate::outcome::{Outcome, OutcomeExt};
/// A choice derived from an outgoing edge.
struct Choice {
key: String,
label: String,
to: String,
}
struct ChoiceMatch<'a> {
route: &'a Choice,
selected_key: String,
selected_label: String,
}
struct HumanGateQuestion {
choices: Vec<Choice>,
freeform_target: Option<String>,
question: Question,
}
/// Parse an accelerator key from a label.
/// Patterns: `[K] Label`, `K) Label`, `K - Label`, or first character.
fn parse_accelerator_key(label: &str) -> String {
let trimmed = label.trim();
// Pattern: [K] Label
if trimmed.starts_with('[') {
if let Some(end) = trimmed.find(']') {
let key = &trimmed[1..end];
if !key.is_empty() {
return key.to_string();
}
}
}
// Pattern: K) Label
if let Some(paren_pos) = trimmed.find(')') {
if paren_pos > 0 && paren_pos <= 3 {
let key = &trimmed[..paren_pos];
if key.chars().all(char::is_alphanumeric) {
return key.to_string();
}
}
}
// Pattern: K - Label
if let Some(dash_pos) = trimmed.find(" - ") {
if dash_pos > 0 && dash_pos <= 3 {
let key = &trimmed[..dash_pos];
if key.chars().all(char::is_alphanumeric) {
return key.to_string();
}
}
}
// Fallback: first character
trimmed
.chars()
.next()
.map(|c| c.to_string())
.unwrap_or_default()
}
fn build_human_gate_question(
node: &Node,
context: &Context,
graph: &Graph,
) -> Result<HumanGateQuestion, String> {
let edges = graph.outgoing_edges(&node.id);
let mut freeform_target: Option<String> = None;
let mut choices: Vec<Choice> = Vec::new();
for edge in &edges {
if edge.freeform() {
freeform_target = Some(edge.to.clone());
continue;
}
let label = edge.label().filter(|l| !l.is_empty()).unwrap_or(&edge.to);
let key = parse_accelerator_key(label);
choices.push(Choice {
key,
label: label.to_string(),
to: edge.to.clone(),
});
}
if choices.is_empty() && freeform_target.is_none() {
return Err("No outgoing edges for human gate".to_string());
}
let question_type = question_type_for_node(node, choices.is_empty())?;
let mut question = Question::new(node.label(), question_type);
question.id = Ulid::new().to_string();
question.options = choices
.iter()
.map(|choice| InterviewOption {
key: choice.key.clone(),
label: choice.label.clone(),
description: None,
preview: None,
})
.collect();
question.allow_freeform = freeform_target.is_some();
question.stage.clone_from(&node.id);
question.timeout_seconds = node.timeout().map(|duration| duration.as_secs_f64());
if node.review_target() {
let value = context.get(keys::REVIEW_TARGET).ok_or_else(|| {
format!(
"Human gate \"{}\" has review_target=true but context.review_target is missing",
node.id
)
})?;
let review_target = serde_json::from_value::<ReviewTarget>(value).map_err(|error| {
format!(
"Human gate \"{}\" has invalid context.review_target: {error}",
node.id
)
})?;
question.text = review_target.question_text();
question.review_target = Some(review_target);
}
if let Some(serde_json::Value::String(last_node)) = context.get(keys::LAST_STAGE) {
if let Some(serde_json::Value::String(response)) =
context.get(&keys::response_key(&last_node))
{
let text = response.trim();
if !text.is_empty() {
question.context_display = Some(text.to_owned());
}
}
}
Ok(HumanGateQuestion {
choices,
freeform_target,
question,
})
}
/// Blocks until a human selects an option derived from outgoing edges.
pub struct HumanHandler {
interviewer: Arc<dyn Interviewer>,
emitter: Option<Arc<Emitter>>,
}
impl HumanHandler {
pub fn new(interviewer: Arc<dyn Interviewer>) -> Self {
Self {
interviewer,
emitter: None,
}
}
#[must_use]
pub fn with_emitter(mut self, emitter: Arc<Emitter>) -> Self {
self.emitter = Some(emitter);
self
}
fn emit(&self, default_emitter: &Arc<Emitter>, event: &Event, scope: &StageScope) {
match &self.emitter {
Some(emitter) => emitter.emit_scoped(event, scope),
None => default_emitter.emit_scoped(event, scope),
}
}
}
#[async_trait]
impl Handler for HumanHandler {
async fn simulate(
&self,
node: &Node,
_context: &Context,
graph: &Graph,
_run_dir: &Path,
_services: &EngineServices,
) -> Result<Outcome, Error> {
let edges = graph.outgoing_edges(&node.id);
let first_choice = edges.iter().find(|e| !e.freeform());
if let Some(edge) = first_choice {
let label = edge.label().filter(|l| !l.is_empty()).unwrap_or(&edge.to);
let key = parse_accelerator_key(label);
let mut outcome = Outcome::simulated(&node.id);
outcome.preferred_label = Some(label.to_string());
outcome.suggested_next_ids = vec![edge.to.clone()];
outcome.context_updates.insert(
keys::HUMAN_GATE_SELECTED.to_string(),
serde_json::json!(key),
);
outcome
.context_updates
.insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label));
Ok(outcome)
} else if let Some(edge) = edges.first() {
// Only freeform edges — pick the first one
let mut outcome = Outcome::simulated(&node.id);
outcome.suggested_next_ids = vec![edge.to.clone()];
outcome.context_updates.insert(
keys::HUMAN_GATE_SELECTED.to_string(),
serde_json::json!("freeform"),
);
outcome.context_updates.insert(
keys::HUMAN_GATE_LABEL.to_string(),
serde_json::json!("[Simulated] auto-selected"),
);
Ok(outcome)
} else {
Ok(Outcome::simulated(&node.id))
}
}
async fn execute(
&self,
node: &Node,
context: &Context,
graph: &Graph,
_run_dir: &Path,
services: &EngineServices,
) -> Result<Outcome, Error> {
let HumanGateQuestion {
choices,
freeform_target,
question,
} = match build_human_gate_question(node, context, graph) {
Ok(question) => question,
Err(reason) => return Ok(Outcome::fail_deterministic(reason)),
};
// Present to interviewer
let question_text = question.text.clone();
let question_id = question.id.clone();
let stage_scope = StageScope::for_handler(context, &node.id);
self.emit(
&services.run.emitter,
&Event::InterviewStarted {
question_id: question_id.clone(),
question: question_text.clone(),
stage: node.id.clone(),
question_type: question.question_type.to_string(),
options: question
.options
.iter()
.map(|option| InterviewOption {
key: option.key.clone(),
label: option.label.clone(),
description: option.description.clone(),
preview: option.preview.clone(),
})
.collect(),
allow_freeform: question.allow_freeform,
timeout_seconds: question.timeout_seconds,
context_display: question.context_display.clone(),
review_target: question.review_target.clone(),
},
&stage_scope,
);
let interview_guard = services
.run
.interview_blocker
.block(Arc::clone(&services.run.emitter), stage_scope.stage_id());
let interview_start = Instant::now();
let answer_submission = ask_with_timeout(self.interviewer.as_ref(), question).await;
let answer_actor = answer_submission.actor.clone();
let answer = answer_submission.answer;
// Handle timeout
if answer.value == AnswerValue::Timeout {
self.emit(
&services.run.emitter,
&Event::InterviewTimeout {
actor: Some(Principal::System {
system_kind: SystemActorKind::Timeout,
}),
question_id: question_id.clone(),
question: question_text.clone(),
stage: node.id.clone(),
duration_ms: millis_u64(interview_start.elapsed()),
},
&stage_scope,
);
interview_guard.resolve();
let default_choice = node
.attrs
.get("human.default_choice")
.and_then(|v| v.as_str());
if let Some(default_target) = default_choice {
let mut outcome =
make_choice_outcome(default_target, default_target, default_target);
add_answer_context(
&mut outcome,
&node.id,
&question_text,
"timeout",
Some(default_target),
);
return Ok(outcome);
}
return Ok(Outcome::retry_classify("human gate timeout, no default"));
}
if answer.value == AnswerValue::Cancelled {
return Err(Error::Cancelled);
}
// Handle unanswered / interrupted interview sessions.
if answer.value == AnswerValue::Interrupted {
if services.run.cancel_token().is_cancelled() {
return Err(Error::Cancelled);
}
self.emit(
&services.run.emitter,
&Event::InterviewInterrupted {
actor: Some(Principal::System {
system_kind: SystemActorKind::Engine,
}),
question_id: question_id.clone(),
question: question_text.clone(),
stage: node.id.clone(),
reason: "interrupted".to_string(),
duration_ms: millis_u64(interview_start.elapsed()),
},
&stage_scope,
);
interview_guard.resolve();
return Ok(unanswered_human_gate(
"human interaction interrupted before an answer was provided",
));
}
if answer.value == AnswerValue::Skipped {
self.emit(
&services.run.emitter,
&Event::InterviewCompleted {
actor: Some(answer_actor),
question_id,
question: question_text.clone(),
answer: answer_text(&answer),
duration_ms: millis_u64(interview_start.elapsed()),
},
&stage_scope,
);
interview_guard.resolve();
return Ok(unanswered_human_gate("human skipped interaction"));
}
// Emit interview completed for successful interactions
self.emit(
&services.run.emitter,
&Event::InterviewCompleted {
actor: Some(answer_actor),
question_id,
question: question_text.clone(),
answer: answer_text(&answer),
duration_ms: millis_u64(interview_start.elapsed()),
},
&stage_scope,
);
interview_guard.resolve();
// Try fixed-choice match
if let Some(selected) = find_choice_match(&answer, &choices) {
let mut outcome = make_choice_outcome(
&selected.selected_key,
&selected.selected_label,
&selected.route.to,
);
add_answer_context(
&mut outcome,
&node.id,
&question_text,
&answer_text(&answer),
Some(&selected.selected_label),
);
return Ok(outcome);
}
// Freeform fallback
if let Some(freeform_to) = &freeform_target {
let text = answer_text(&answer);
let mut outcome = Outcome::success();
outcome.suggested_next_ids = vec![freeform_to.clone()];
outcome.context_updates.insert(
keys::HUMAN_GATE_SELECTED.to_string(),
serde_json::json!("freeform"),
);
outcome
.context_updates
.insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(text));
outcome
.context_updates
.insert(keys::HUMAN_GATE_TEXT.to_string(), serde_json::json!(text));
add_answer_context(
&mut outcome,
&node.id,
&question_text,
&answer_text(&answer),
None,
);
return Ok(outcome);
}
// Fallback to first choice
if let Some(first) = choices.first() {
let mut outcome = make_choice_outcome(&first.key, &first.label, &first.to);
add_answer_context(
&mut outcome,
&node.id,
&question_text,
&answer_text(&answer),
Some(&first.label),
);
return Ok(outcome);
}
Ok(Outcome::fail_deterministic("No matching choice"))
}
fn node_timeout_policy(&self, _node: &Node) -> NodeTimeoutPolicy {
NodeTimeoutPolicy::HandlerManaged
}
}
fn make_choice_outcome(key: &str, label: &str, to: &str) -> Outcome {
let mut outcome = Outcome::success();
outcome.preferred_label = Some(label.to_string());
outcome.suggested_next_ids = vec![to.to_string()];
outcome.context_updates.insert(
keys::HUMAN_GATE_SELECTED.to_string(),
serde_json::json!(key),
);
outcome
.context_updates
.insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label));
outcome
}
fn unanswered_human_gate(reason: impl Into<String>) -> Outcome {
Outcome::fail_deterministic(reason)
}
fn question_type_for_node(node: &Node, default_freeform: bool) -> Result<QuestionType, String> {
if let Some(value) = node
.attrs
.get("question_type")
.and_then(|value| value.as_str())
{
return QuestionType::from_str(value)
.map_err(|_| format!("invalid human question_type: {value}"));
}
if default_freeform {
Ok(QuestionType::Freeform)
} else {
Ok(QuestionType::MultipleChoice)
}
}
fn find_choice_match<'a>(answer: &Answer, choices: &'a [Choice]) -> Option<ChoiceMatch<'a>> {
match &answer.value {
AnswerValue::Selected(key) => {
choices
.iter()
.find(|choice| choice.key == *key)
.map(|choice| ChoiceMatch {
route: choice,
selected_key: choice.key.clone(),
selected_label: choice.label.clone(),
})
}
AnswerValue::MultiSelected(keys) => {
let selected: Vec<&Choice> = keys
.iter()
.filter_map(|key| choices.iter().find(|choice| choice.key == *key))
.collect();
selected.first().map(|first| ChoiceMatch {
route: first,
selected_key: selected
.iter()
.map(|choice| choice.key.as_str())
.collect::<Vec<_>>()
.join(","),
selected_label: selected
.iter()
.map(|choice| choice.label.as_str())
.collect::<Vec<_>>()
.join(", "),
})
}
AnswerValue::Yes => find_yes_no_choice(choices, true).map(|choice| ChoiceMatch {
route: choice,
selected_key: choice.key.clone(),
selected_label: choice.label.clone(),
}),
AnswerValue::No => find_yes_no_choice(choices, false).map(|choice| ChoiceMatch {
route: choice,
selected_key: choice.key.clone(),
selected_label: choice.label.clone(),
}),
AnswerValue::Text(text) => {
// Try matching by key or label
choices
.iter()
.find(|c| c.key.eq_ignore_ascii_case(text) || c.label.eq_ignore_ascii_case(text))
.map(|choice| ChoiceMatch {
route: choice,
selected_key: choice.key.clone(),
selected_label: choice.label.clone(),
})
}
_ => None,
}
}
fn find_yes_no_choice(choices: &[Choice], yes: bool) -> Option<&Choice> {
let expected_keys = if yes {
&["Y", "YES"][..]
} else {
&["N", "NO"][..]
};
let expected_word = if yes { "yes" } else { "no" };
choices.iter().find(|choice| {
expected_keys
.iter()
.any(|expected| choice.key.eq_ignore_ascii_case(expected))
|| choice.label.eq_ignore_ascii_case(expected_word)
})
}
fn add_answer_context(
outcome: &mut Outcome,
node_id: &str,
question: &str,
answer: &str,
selected_label: Option<&str>,
) {
outcome.context_updates.insert(
format!("human.gate.{node_id}.question"),
serde_json::json!(question),
);
outcome.context_updates.insert(
format!("human.gate.{node_id}.answer"),
serde_json::json!(answer),
);
if let Some(label) = selected_label {
outcome.context_updates.insert(
format!("human.gate.{node_id}.label"),
serde_json::json!(label),
);
}
}
fn answer_text(answer: &Answer) -> String {
if let Some(text) = &answer.text {
return text.clone();
}
match &answer.value {
AnswerValue::Text(t) => t.clone(),
AnswerValue::Selected(s) => s.clone(),
AnswerValue::MultiSelected(keys) => keys.join(", "),
AnswerValue::Yes => "yes".to_string(),
AnswerValue::No => "no".to_string(),
AnswerValue::Cancelled => "cancelled".to_string(),
AnswerValue::Interrupted => "interrupted".to_string(),
AnswerValue::Skipped => "skipped".to_string(),
AnswerValue::Timeout => "timeout".to_string(),
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::time::Duration;
use fabro_graphviz::graph::{AttrValue, Edge};
use fabro_interview::{AutoApproveInterviewer, CallbackInterviewer, RecordingInterviewer};
use super::*;
use crate::event::EventBody;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn make_services_with_events(events: Arc<Mutex<Vec<fabro_types::RunEvent>>>) -> EngineServices {
let mut services = EngineServices::test_default();
let emitter = Arc::new(Emitter::default());
emitter.on_event(move |event| {
events
.lock()
.expect("event log lock poisoned")
.push(event.clone());
});
services.run = services.run.with_emitter(emitter);
services
}
fn build_graph_with_human_gate() -> Graph {
let mut graph = Graph::new("test");
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
let mut e1 = Edge::new("gate", "approve");
e1.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
let mut e2 = Edge::new("gate", "reject");
e2.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e1);
graph.edges.push(e2);
graph
}
fn build_graph_with_typed_gate(question_type: &str) -> Graph {
let mut graph = build_graph_with_human_gate();
graph.nodes.get_mut("gate").unwrap().attrs.insert(
"question_type".to_string(),
AttrValue::String(question_type.to_string()),
);
graph
}
fn enable_review_target(graph: &mut Graph) {
graph
.nodes
.get_mut("gate")
.unwrap()
.attrs
.insert("review_target".to_string(), AttrValue::Boolean(true));
}
#[test]
fn parse_accelerator_key_bracket() {
assert_eq!(parse_accelerator_key("[A] Approve"), "A");
assert_eq!(parse_accelerator_key("[Y] Yes, deploy"), "Y");
}
#[test]
fn parse_accelerator_key_paren() {
assert_eq!(parse_accelerator_key("Y) Yes, deploy"), "Y");
}
#[test]
fn parse_accelerator_key_dash() {
assert_eq!(parse_accelerator_key("Y - Yes, deploy"), "Y");
}
#[test]
fn parse_accelerator_key_first_char() {
assert_eq!(parse_accelerator_key("Yes, deploy"), "Y");
}
#[test]
fn parse_accelerator_key_empty() {
assert_eq!(parse_accelerator_key(""), "");
}
#[tokio::test]
async fn review_target_gate_snapshots_validated_context_into_question_and_event() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let mut graph = build_graph_with_human_gate();
enable_review_target(&mut graph);
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let target_value = serde_json::json!({
"label": "Quarry review exercise",
"url": "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
"kind": "document",
});
context.set(keys::REVIEW_TARGET, target_value.clone());
let events = Arc::new(Mutex::new(Vec::new()));
let outcome = handler
.execute(
node,
&context,
&graph,
Path::new("/tmp/test"),
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1);
let question = &recordings[0].0;
assert_eq!(
question.text,
"Review the Quarry review exercise document, then choose the next action."
);
assert_eq!(
question.review_target.as_ref().map(ReviewTarget::url),
Some("https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef")
);
let event_target = events
.lock()
.expect("event log lock poisoned")
.iter()
.find_map(|event| match &event.body {
EventBody::InterviewStarted(props) => props.review_target.clone(),
_ => None,
})
.expect("interview.started should carry the review target");
assert_eq!(event_target.label(), "Quarry review exercise");
context.apply_updates(&outcome.context_updates);
assert_eq!(context.get(keys::REVIEW_TARGET), Some(target_value));
}
#[tokio::test]
async fn review_target_gate_fails_before_interview_when_context_is_missing() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let mut graph = build_graph_with_human_gate();
enable_review_target(&mut graph);
let node = graph.nodes.get("gate").unwrap();
let outcome = handler
.execute(
node,
&Context::new(),
&graph,
Path::new("/tmp/test"),
&make_services(),
)
.await
.unwrap();
assert!(outcome.status.is_failure());
assert_eq!(
outcome.failure_reason(),
Some("Human gate \"gate\" has review_target=true but context.review_target is missing")
);
assert!(recorder.recordings().is_empty());
}
#[tokio::test]
async fn review_target_gate_rejects_unsafe_url_without_echoing_it() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let mut graph = build_graph_with_human_gate();
enable_review_target(&mut graph);
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
context.set(
keys::REVIEW_TARGET,
serde_json::json!({
"label": "Unsafe review",
"url": "javascript:alert(1)",
"kind": "document",
}),
);
let outcome = handler
.execute(
node,
&context,
&graph,
Path::new("/tmp/test"),
&make_services(),
)
.await
.unwrap();
let failure = outcome
.failure_reason()
.expect("invalid review target should fail");
assert!(failure.contains("review target URL must use http or https"));
assert!(!failure.contains("javascript:alert"));
assert!(recorder.recordings().is_empty());
}
#[tokio::test]
async fn human_gate_ignores_review_target_context_without_opt_in() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
context.set(
keys::REVIEW_TARGET,
serde_json::json!({
"label": "Unsafe review",
"url": "javascript:alert(1)",
"kind": "document",
}),
);
let outcome = handler
.execute(
node,
&context,
&graph,
Path::new("/tmp/test"),
&make_services(),
)
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
let recordings = recorder.recordings();
assert_eq!(recordings[0].0.text, "Review Changes");
assert!(recordings[0].0.review_target.is_none());
}
#[tokio::test]
async fn wait_human_auto_approve_selects_first() {
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
// Auto-approve picks first option key "A"
assert_eq!(
outcome.context_updates.get(keys::HUMAN_GATE_SELECTED),
Some(&serde_json::json!("A"))
);
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
}
#[tokio::test]
async fn wait_human_no_edges_returns_fail() {
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let mut graph = Graph::new("test");
let gate = Node::new("gate");
graph.nodes.insert("gate".to_string(), gate);
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed {
retry_requested: false,
});
}
#[tokio::test]
async fn wait_human_interrupted_returns_fail_without_routing_hints() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| Answer::interrupted()));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed {
retry_requested: false,
});
assert!(outcome.preferred_label.is_none());
assert!(outcome.suggested_next_ids.is_empty());
assert_eq!(
outcome.failure_reason(),
Some("human interaction interrupted before an answer was provided")
);
}
#[tokio::test]
async fn wait_human_cancelled_returns_cancelled_error() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| Answer::cancelled()));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let error = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap_err();
assert!(matches!(error, Error::Cancelled));
}
#[tokio::test]
async fn wait_human_skipped_returns_fail_without_routing_hints() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| Answer::skipped()));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed {
retry_requested: false,
});
assert!(outcome.preferred_label.is_none());
assert!(outcome.suggested_next_ids.is_empty());
assert_eq!(outcome.failure_reason(), Some("human skipped interaction"));
}
#[tokio::test]
async fn wait_human_interrupted_emits_interview_interrupted_event() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| Answer::interrupted()));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let events = Arc::new(Mutex::new(Vec::new()));
let _ = handler
.execute(
node,
&context,
&graph,
run_dir,
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
assert!(
events
.lock()
.expect("event log lock poisoned")
.iter()
.any(|event| matches!(
&event.body,
EventBody::InterviewInterrupted(props)
if props.reason == "interrupted"
))
);
}
#[tokio::test]
async fn wait_human_skipped_emits_interview_completed_event() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| Answer::skipped()));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let events = Arc::new(Mutex::new(Vec::new()));
let _ = handler
.execute(
node,
&context,
&graph,
run_dir,
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
assert!(
events
.lock()
.expect("event log lock poisoned")
.iter()
.any(|event| matches!(
&event.body,
EventBody::InterviewCompleted(props)
if props.answer == "skipped"
))
);
}
#[tokio::test]
async fn wait_human_emits_blocked_then_unblocked_around_interview() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| {
Answer::selected("A", InterviewOption {
key: "A".to_string(),
label: "Approve".to_string(),
description: None,
preview: None,
})
}));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let events = Arc::new(Mutex::new(Vec::new()));
handler
.execute(
node,
&context,
&graph,
run_dir,
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
let event_names = events
.lock()
.expect("event log lock poisoned")
.iter()
.map(|event| event.event_name().to_string())
.collect::<Vec<_>>();
assert_eq!(event_names, vec![
"interview.started",
"run.blocked",
"interview.completed",
"run.unblocked",
]);
}
#[tokio::test]
async fn wait_human_with_freeform_edge() {
let interviewer = Arc::new(fabro_interview::CallbackInterviewer::new(|_| {
Answer::text("custom input")
}));
let handler = HumanHandler::new(interviewer);
let mut graph = Graph::new("test");
let mut gate = Node::new("gate");
gate.attrs
.insert("label".to_string(), AttrValue::String("Choose".to_string()));
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
let mut edge = Edge::new("gate", "freeform_target");
edge.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(edge);
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
assert_eq!(outcome.suggested_next_ids, vec!["freeform_target"]);
assert_eq!(
outcome.context_updates.get(keys::HUMAN_GATE_TEXT),
Some(&serde_json::json!("custom input"))
);
}
#[tokio::test]
async fn freeform_only_gate_uses_freeform_question_type() {
let inner = Box::new(fabro_interview::CallbackInterviewer::new(|_| {
Answer::text("hello")
}));
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let mut graph = Graph::new("test");
let mut gate = Node::new("gate");
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Enter prompt".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("target".to_string(), Node::new("target"));
let mut edge = Edge::new("gate", "target");
edge.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(edge);
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].0.question_type, QuestionType::Freeform);
}
#[tokio::test]
async fn explicit_yes_no_gate_uses_yes_no_question_type() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let graph = build_graph_with_typed_gate("yes_no");
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].0.question_type, QuestionType::YesNo);
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
assert_eq!(
outcome.context_updates.get("human.gate.gate.answer"),
Some(&serde_json::json!("yes"))
);
}
#[tokio::test]
async fn wait_human_copies_node_timeout_to_question_and_started_event() {
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = Arc::new(RecordingInterviewer::new(inner));
let handler = HumanHandler::new(recorder.clone());
let mut graph = build_graph_with_human_gate();
let timeout = Duration::from_millis(125);
let timeout_seconds = timeout.as_secs_f64();
graph
.nodes
.get_mut("gate")
.unwrap()
.attrs
.insert("timeout".to_string(), AttrValue::Duration(timeout));
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let events = Arc::new(Mutex::new(Vec::new()));
handler
.execute(
node,
&context,
&graph,
run_dir,
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].0.timeout_seconds, Some(timeout_seconds));
let started_timeout = events
.lock()
.expect("event log lock poisoned")
.iter()
.find_map(|event| match &event.body {
EventBody::InterviewStarted(props) => props.timeout_seconds,
_ => None,
});
assert_eq!(started_timeout, Some(timeout_seconds));
}
#[tokio::test]
async fn explicit_multi_select_gate_records_all_selected_keys() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| {
Answer::multi_selected(vec!["A".to_string(), "R".to_string()])
}));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_typed_gate("multi_select");
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.execute(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
assert_eq!(
outcome.context_updates.get(keys::HUMAN_GATE_SELECTED),
Some(&serde_json::json!("A,R"))
);
assert_eq!(
outcome.context_updates.get("human.gate.gate.answer"),
Some(&serde_json::json!("A, R"))
);
}
#[tokio::test]
async fn simulate_selects_first_choice() {
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let outcome = handler
.simulate(node, &context, &graph, run_dir, &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
assert_eq!(
outcome.context_updates.get(keys::HUMAN_GATE_SELECTED),
Some(&serde_json::json!("A"))
);
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
}
#[test]
fn blocked_state_tracker_emits_once_across_parallel_interview_races() {
let blocker = Arc::new(crate::interview_runtime::RunInterviewBlocker::new());
let emitter = Arc::new(Emitter::new(fabro_types::fixtures::RUN_1));
let event_names = Arc::new(Mutex::new(Vec::new()));
let guards = Arc::new(Mutex::new(Vec::new()));
let block_state = blocker.subscribe();
let stage_id = fabro_types::StageId::new("gate", 1);
emitter.on_event({
let event_names = Arc::clone(&event_names);
move |event| {
let name = match &event.body {
EventBody::RunBlocked(_) => Some("run.blocked"),
EventBody::RunUnblocked(_) => Some("run.unblocked"),
_ => None,
};
if let Some(name) = name {
event_names.lock().unwrap().push(name.to_string());
}
}
});
std::thread::scope(|scope| {
for _ in 0..8 {
let blocker = Arc::clone(&blocker);
let emitter = Arc::clone(&emitter);
let guards = Arc::clone(&guards);
let stage_id = stage_id.clone();
scope.spawn(move || {
guards
.lock()
.unwrap()
.push(blocker.block(emitter, stage_id));
});
}
});
assert!(block_state.borrow().is_run_blocked());
assert!(block_state.borrow().is_stage_blocked(&stage_id));
std::thread::scope(|scope| {
for _ in 0..8 {
let guards = Arc::clone(&guards);
scope.spawn(move || {
let guard = guards.lock().unwrap().pop().unwrap();
guard.resolve();
});
}
});
assert_eq!(event_names.lock().unwrap().as_slice(), [
"run.blocked",
"run.unblocked"
],);
assert!(!block_state.borrow().is_run_blocked());
assert!(!block_state.borrow().is_stage_blocked(&stage_id));
}
}