mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-29 06:36:51 +00:00
1318 lines
45 KiB
Rust
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));
|
|
}
|
|
}
|