fabro/lib/components/fabro-interview/src/lib.rs
Bryan Helmkamp 8e066ecf7b
refactor: remove duplicated review target rendering and validation
The review question sentence was written in four places and the URL
safety rules in three. Collapse each to one definition.

- Add `ReviewTarget::question_text_with_link` as the single definition of
  the question wording. `question_text()` and the Slack header both use
  it, so a wording change is now one edit.
- Delete `ReviewTargetKind::noun()`. The enum already derives
  `strum::Display` with the same snake_case output.
- Share one `review_target_line` helper between the console interviewer
  and the CLI attach client, which held a byte-identical copy. Print only
  the URL: `question.text` already carries the label and the noun.
- Trim the web-side check to the URL scheme, host, and credentials, which
  are what a raw `href` can act on. Label length and control characters
  cannot affect the DOM and stay server-side.
- Split validation from presentation in the web UI. `safeReviewTarget`
  returns the target or null, and each caller picks its own fallback, so
  an unsafe target now falls back to the same Markdown rendering as a
  question with no target.
- Derive the resource noun from `kind` in the web UI instead of
  hardcoding "document".
- Use `ReviewTargetKind.DOCUMENT` and the shared `isRecord` guard when
  parsing events, instead of a raw string and a hand-rolled object check
  that accepted arrays.
- Drop `deny_unknown_fields` from the wire struct. The OpenAPI schema
  leaves `additionalProperties` permissive, so an added field would
  otherwise make persisted events unreadable.
- Import `ReviewTarget` by name, and stop naming Slack in a fabro-types
  error message.
- Document that `review_target=true` replaces the gate's `label`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:14:23 -04:00

429 lines
12 KiB
Rust

mod auto_approve;
mod callback;
mod console;
mod control;
mod control_protocol;
mod queue;
mod recording;
mod replay;
use std::collections::HashMap;
use async_trait::async_trait;
use fabro_types::{InterviewOption, Principal, QuestionType, ReviewTarget, SystemActorKind};
use serde::{Deserialize, Serialize};
use tokio::time;
/// A question presented to the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Question {
#[serde(default)]
pub id: String,
pub text: String,
pub question_type: QuestionType,
pub options: Vec<InterviewOption>,
pub allow_freeform: bool,
pub default: Option<Answer>,
pub timeout_seconds: Option<f64>,
pub stage: String,
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default)]
pub context_display: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_target: Option<ReviewTarget>,
}
impl Question {
pub fn new(text: impl Into<String>, question_type: QuestionType) -> Self {
Self {
id: String::new(),
text: text.into(),
question_type,
options: Vec::new(),
allow_freeform: false,
default: None,
timeout_seconds: None,
stage: String::new(),
metadata: HashMap::new(),
context_display: None,
review_target: None,
}
}
}
/// The value of an answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnswerValue {
Yes,
No,
Cancelled,
Interrupted,
Skipped,
Timeout,
Selected(String),
MultiSelected(Vec<String>),
Text(String),
}
/// An answer from the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Answer {
pub value: AnswerValue,
pub selected_option: Option<InterviewOption>,
pub text: Option<String>,
}
impl Answer {
#[must_use]
pub fn yes() -> Self {
Self {
value: AnswerValue::Yes,
selected_option: None,
text: None,
}
}
#[must_use]
pub fn no() -> Self {
Self {
value: AnswerValue::No,
selected_option: None,
text: None,
}
}
#[must_use]
pub fn cancelled() -> Self {
Self {
value: AnswerValue::Cancelled,
selected_option: None,
text: None,
}
}
#[must_use]
pub fn interrupted() -> Self {
Self {
value: AnswerValue::Interrupted,
selected_option: None,
text: None,
}
}
#[must_use]
pub fn skipped() -> Self {
Self {
value: AnswerValue::Skipped,
selected_option: None,
text: None,
}
}
#[must_use]
pub fn timeout() -> Self {
Self {
value: AnswerValue::Timeout,
selected_option: None,
text: None,
}
}
pub fn selected(key: impl Into<String>, option: InterviewOption) -> Self {
let key = key.into();
Self {
value: AnswerValue::Selected(key),
selected_option: Some(option),
text: None,
}
}
pub fn multi_selected(keys: Vec<String>) -> Self {
Self {
value: AnswerValue::MultiSelected(keys),
selected_option: None,
text: None,
}
}
pub fn text(text: impl Into<String>) -> Self {
let t = text.into();
Self {
value: AnswerValue::Text(t.clone()),
selected_option: None,
text: Some(t),
}
}
}
/// An answer plus the principal that supplied it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnswerSubmission {
pub answer: Answer,
pub actor: Principal,
}
impl AnswerSubmission {
#[must_use]
pub fn new(answer: Answer, actor: Principal) -> Self {
Self { answer, actor }
}
#[must_use]
pub fn system(answer: Answer, system_kind: SystemActorKind) -> Self {
Self {
answer,
actor: Principal::System { system_kind },
}
}
}
/// Apply timeout enforcement to an interviewer ask call.
/// Per spec 6.5: if `timeout_seconds` is set, returns default answer or
/// `Answer::timeout()`.
pub async fn ask_with_timeout(
interviewer: &dyn Interviewer,
question: Question,
) -> AnswerSubmission {
let timeout_secs = question.timeout_seconds;
let default_answer = question.default.clone();
if let Some(secs) = timeout_secs {
let duration = std::time::Duration::from_secs_f64(secs);
match time::timeout(duration, interviewer.ask(question)).await {
Ok(answer) => answer,
Err(_elapsed) => AnswerSubmission::system(
default_answer.unwrap_or_else(Answer::timeout),
SystemActorKind::Timeout,
),
}
} else {
interviewer.ask(question).await
}
}
/// The interviewer trait for human-in-the-loop interactions.
#[async_trait]
pub trait Interviewer: Send + Sync {
async fn ask(&self, question: Question) -> AnswerSubmission;
async fn ask_multiple(&self, questions: Vec<Question>) -> Vec<AnswerSubmission> {
let mut answers = Vec::with_capacity(questions.len());
for q in questions {
answers.push(self.ask(q).await);
}
answers
}
async fn inform(&self, _message: &str, _stage: &str) {
// Default no-op
}
}
// Re-export all implementors at the crate root
pub use auto_approve::AutoApproveInterviewer;
pub use callback::CallbackInterviewer;
pub use console::{ConsoleInterviewer, review_target_line};
pub use control::{ControlInterviewer, SubmitError};
pub use control_protocol::{
WORKER_CONTROL_INVALID_CURSOR_REASON, WORKER_CONTROL_PONG_TIMEOUT_REASON,
WORKER_CONTROL_PROTOCOL_VERSION, WORKER_CONTROL_WS_LIVENESS_TIMEOUT,
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlAnswer, WorkerControlDeliveryFrame,
WorkerControlEnvelope, WorkerControlMessage,
};
pub use queue::QueueInterviewer;
pub use recording::RecordingInterviewer;
pub use replay::ReplayInterviewer;
#[cfg(test)]
mod tests {
use std::sync::Arc;
use tokio::time;
use super::*;
#[test]
fn question_type_display() {
assert_eq!(QuestionType::YesNo.to_string(), "yes_no");
assert_eq!(QuestionType::MultipleChoice.to_string(), "multiple_choice");
assert_eq!(QuestionType::MultiSelect.to_string(), "multi_select");
assert_eq!(QuestionType::Freeform.to_string(), "freeform");
assert_eq!(QuestionType::Confirmation.to_string(), "confirmation");
}
#[test]
fn question_new() {
let q = Question::new("Do you approve?", QuestionType::YesNo);
assert!(q.id.is_empty());
assert_eq!(q.text, "Do you approve?");
assert_eq!(q.question_type, QuestionType::YesNo);
assert!(q.options.is_empty());
assert!(!q.allow_freeform);
assert!(q.default.is_none());
assert!(q.timeout_seconds.is_none());
assert!(q.stage.is_empty());
assert!(q.metadata.is_empty());
assert!(q.review_target.is_none());
}
#[test]
fn answer_yes() {
let a = Answer::yes();
assert_eq!(a.value, AnswerValue::Yes);
assert!(a.selected_option.is_none());
assert!(a.text.is_none());
}
#[test]
fn answer_no() {
let a = Answer::no();
assert_eq!(a.value, AnswerValue::No);
}
#[test]
fn answer_cancelled() {
let a = Answer::cancelled();
assert_eq!(a.value, AnswerValue::Cancelled);
}
#[test]
fn answer_skipped() {
let a = Answer::skipped();
assert_eq!(a.value, AnswerValue::Skipped);
}
#[test]
fn answer_interrupted() {
let a = Answer::interrupted();
assert_eq!(a.value, AnswerValue::Interrupted);
}
#[test]
fn answer_timeout() {
let a = Answer::timeout();
assert_eq!(a.value, AnswerValue::Timeout);
}
#[test]
fn answer_selected() {
let opt = InterviewOption {
key: "A".to_string(),
label: "Approve".to_string(),
description: None,
preview: None,
};
let a = Answer::selected("A", opt.clone());
assert_eq!(a.value, AnswerValue::Selected("A".to_string()));
assert_eq!(a.selected_option, Some(opt));
}
#[test]
fn answer_text() {
let a = Answer::text("free input");
assert_eq!(a.value, AnswerValue::Text("free input".to_string()));
assert_eq!(a.text, Some("free input".to_string()));
}
#[test]
fn question_option_eq() {
let a = InterviewOption {
key: "Y".to_string(),
label: "Yes".to_string(),
description: None,
preview: None,
};
let b = InterviewOption {
key: "Y".to_string(),
label: "Yes".to_string(),
description: None,
preview: None,
};
assert_eq!(a, b);
}
#[test]
fn answer_value_variants() {
assert_ne!(AnswerValue::Yes, AnswerValue::No);
assert_ne!(AnswerValue::Cancelled, AnswerValue::Interrupted);
assert_ne!(AnswerValue::Skipped, AnswerValue::Timeout);
assert_ne!(AnswerValue::Interrupted, AnswerValue::Timeout);
assert_eq!(
AnswerValue::Selected("x".to_string()),
AnswerValue::Selected("x".to_string())
);
assert_eq!(
AnswerValue::Text("hello".to_string()),
AnswerValue::Text("hello".to_string())
);
}
#[test]
fn question_type_multi_select_exists() {
let q = Question::new("Pick many:", QuestionType::MultiSelect);
assert_eq!(q.question_type, QuestionType::MultiSelect);
}
/// A slow interviewer that waits before answering -- for testing timeouts.
struct SlowInterviewer;
#[async_trait]
impl Interviewer for SlowInterviewer {
async fn ask(&self, _question: Question) -> AnswerSubmission {
time::sleep(std::time::Duration::from_mins(1)).await;
AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine)
}
}
#[tokio::test]
async fn ask_with_timeout_returns_timeout_when_expired() {
let interviewer = SlowInterviewer;
let mut q = Question::new("approve?", QuestionType::YesNo);
q.timeout_seconds = Some(0.01);
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::Timeout);
}
#[tokio::test]
async fn ask_with_timeout_returns_default_when_set() {
let interviewer = SlowInterviewer;
let mut q = Question::new("approve?", QuestionType::YesNo);
q.timeout_seconds = Some(0.01);
q.default = Some(Answer::no());
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::No);
}
#[tokio::test]
async fn ask_with_timeout_no_timeout_returns_normally() {
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("approve?", QuestionType::YesNo);
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn control_interviewer_routes_answers_by_question_id() {
let interviewer = Arc::new(ControlInterviewer::new());
let mut question = Question::new("Approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let ask_interviewer = Arc::clone(&interviewer);
let ask = tokio::spawn(async move { ask_interviewer.ask(question).await });
time::sleep(std::time::Duration::from_millis(10)).await;
interviewer
.submit(
"q-1",
AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine),
)
.await
.unwrap();
let answer = ask.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
}