mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
## Summary - **Unify foreground and detach code paths**: Both `fabro run` modes now go through the same `create_run() + start_run()` pipeline, with foreground adding `attach_run()`. Only `--preflight` remains as a special case. - **Fix three bugs in create→start→attach path**: (1) `_run_engine` crashed for `.fabro` workflows by hardcoding `run.toml` — now falls back to `graph.fabro`; (2) `attach_run` couldn't detect crashed engines due to zombie processes — `start_run` now returns the `Child` handle; (3) `create_run` ignored `--run-id`. - **Configure nextest slow-timeout profiles**: Tighten unit test timeout to 2s slow / 4s kill, add `e2e` profile with 10s/30s. Switch CI and docs to `cargo nextest run`. ## Test plan - [ ] `cargo nextest run --workspace` passes with new timeout profiles - [ ] `fabro run <workflow>` works in foreground mode (create + start + attach) - [ ] `fabro run --detach <workflow>` prints run ID and exits - [ ] `fabro attach <run>` works standalone (without child handle) - [ ] `fabro resume <run>` works for both `.toml` and `.fabro` workflows 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Fabro <noreply@fabro.sh> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.2 KiB
Rust
202 lines
6.2 KiB
Rust
use std::sync::Arc;
|
|
|
|
use fabro_interview::{
|
|
Answer, AnswerValue, Interviewer, Question, QuestionOption, QuestionType, WebInterviewer,
|
|
};
|
|
use fabro_slack::blocks::{answered_blocks, question_to_blocks};
|
|
use fabro_slack::client::{PostedMessage, SlackClient};
|
|
use fabro_slack::connection;
|
|
use fabro_slack::threads::ThreadRegistry;
|
|
|
|
struct TestCase {
|
|
label: &'static str,
|
|
question: Question,
|
|
}
|
|
|
|
fn test_cases() -> Vec<TestCase> {
|
|
let mut mc = Question::new("Pick a language:", QuestionType::MultipleChoice);
|
|
mc.options = vec![
|
|
QuestionOption {
|
|
key: "rs".to_string(),
|
|
label: "Rust".to_string(),
|
|
},
|
|
QuestionOption {
|
|
key: "ts".to_string(),
|
|
label: "TypeScript".to_string(),
|
|
},
|
|
QuestionOption {
|
|
key: "py".to_string(),
|
|
label: "Python".to_string(),
|
|
},
|
|
];
|
|
|
|
let mut ms = Question::new("Select features to enable:", QuestionType::MultiSelect);
|
|
ms.options = vec![
|
|
QuestionOption {
|
|
key: "auth".to_string(),
|
|
label: "Authentication".to_string(),
|
|
},
|
|
QuestionOption {
|
|
key: "billing".to_string(),
|
|
label: "Billing".to_string(),
|
|
},
|
|
QuestionOption {
|
|
key: "notifications".to_string(),
|
|
label: "Notifications".to_string(),
|
|
},
|
|
];
|
|
|
|
vec![
|
|
TestCase {
|
|
label: "YesNo",
|
|
question: Question::new("Do you approve this deployment?", QuestionType::YesNo),
|
|
},
|
|
TestCase {
|
|
label: "Confirmation",
|
|
question: Question::new(
|
|
"This will delete all staging data. Continue?",
|
|
QuestionType::Confirmation,
|
|
),
|
|
},
|
|
TestCase {
|
|
label: "MultipleChoice",
|
|
question: mc,
|
|
},
|
|
TestCase {
|
|
label: "MultiSelect",
|
|
question: ms,
|
|
},
|
|
TestCase {
|
|
label: "Freeform",
|
|
question: Question::new("What is the repository URL?", QuestionType::Freeform),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn format_answer(answer: &Answer) -> String {
|
|
match &answer.value {
|
|
AnswerValue::Yes => "Yes".to_string(),
|
|
AnswerValue::No => "No".to_string(),
|
|
AnswerValue::Aborted => "Aborted".to_string(),
|
|
AnswerValue::Text(t) => t.clone(),
|
|
AnswerValue::Selected(k) => {
|
|
if let Some(opt) = &answer.selected_option {
|
|
format!("{} ({})", opt.label, k)
|
|
} else {
|
|
k.clone()
|
|
}
|
|
}
|
|
AnswerValue::MultiSelected(keys) => keys.join(", "),
|
|
AnswerValue::Skipped => "Skipped".to_string(),
|
|
AnswerValue::Timeout => "Timed out".to_string(),
|
|
}
|
|
}
|
|
|
|
async fn ask_question(
|
|
test_case: TestCase,
|
|
interviewer: &Arc<WebInterviewer>,
|
|
thread_registry: &ThreadRegistry,
|
|
slack_client: &SlackClient,
|
|
channel: &str,
|
|
) {
|
|
eprintln!("\n--- {} ---", test_case.label);
|
|
|
|
let question_text = test_case.question.text.clone();
|
|
let is_freeform = test_case.question.question_type == QuestionType::Freeform;
|
|
let interviewer_clone = Arc::clone(interviewer);
|
|
let ask_handle = tokio::spawn(async move { interviewer_clone.ask(test_case.question).await });
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
let pending = interviewer.pending_questions();
|
|
let pq = pending
|
|
.iter()
|
|
.find(|pq| pq.question.text == question_text)
|
|
.expect("Question should be pending");
|
|
|
|
let question_id = pq.id.clone();
|
|
let blocks = question_to_blocks(&question_id, &pq.question);
|
|
|
|
let posted: PostedMessage = slack_client
|
|
.post_message(channel, &blocks, None)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("Failed to post message: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
// For freeform questions, register the message ts so thread replies get routed
|
|
if is_freeform {
|
|
thread_registry.register(&posted.ts, &question_id);
|
|
eprintln!("Posted. Reply in thread in Slack...");
|
|
} else {
|
|
eprintln!("Posted. Respond in Slack...");
|
|
}
|
|
|
|
let answer = ask_handle.await.expect("ask task panicked");
|
|
let answer_text = format_answer(&answer);
|
|
eprintln!("Got answer: {answer_text}");
|
|
|
|
// Clean up thread registration
|
|
if is_freeform {
|
|
thread_registry.remove(&posted.ts);
|
|
}
|
|
|
|
let updated = answered_blocks(&question_text, &answer_text);
|
|
if let Err(e) = slack_client
|
|
.update_message(&posted.channel_id, &posted.ts, &updated)
|
|
.await
|
|
{
|
|
eprintln!("Failed to update message: {e}");
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter("fabro_slack=debug,info")
|
|
.init();
|
|
|
|
let bot_token = std::env::var("FABRO_SLACK_BOT_TOKEN").expect("FABRO_SLACK_BOT_TOKEN required");
|
|
let app_token = std::env::var("FABRO_SLACK_APP_TOKEN").expect("FABRO_SLACK_APP_TOKEN required");
|
|
let channel = std::env::var("FABRO_SLACK_CHANNEL").unwrap_or_else(|_| "#arc-test".to_string());
|
|
|
|
eprintln!("Connecting to Slack Socket Mode...");
|
|
|
|
let slack_client = SlackClient::new(bot_token);
|
|
let wss_url = connection::open_socket_url(slack_client.http(), &app_token)
|
|
.await
|
|
.expect("Failed to open socket URL");
|
|
|
|
let interviewer = Arc::new(WebInterviewer::new());
|
|
let thread_registry = Arc::new(ThreadRegistry::new());
|
|
|
|
// Start the event loop in the background
|
|
let interviewer_for_loop = Arc::clone(&interviewer);
|
|
let thread_registry_for_loop = Arc::clone(&thread_registry);
|
|
tokio::spawn(async move {
|
|
connection::run_event_loop(&wss_url, &interviewer_for_loop, &thread_registry_for_loop)
|
|
.await
|
|
.ok();
|
|
});
|
|
|
|
// Wait for the socket to connect
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
eprintln!("Connected. Running all question types...\n");
|
|
|
|
let cases = test_cases();
|
|
for case in cases {
|
|
ask_question(
|
|
case,
|
|
&interviewer,
|
|
&thread_registry,
|
|
&slack_client,
|
|
&channel,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
eprintln!("\nAll question types tested!");
|
|
}
|