fabro/lib/apps/fabro-cli/src/commands/run/runner.rs
Scott Werner f1c00a167e Rename blob read parameters from id to blob_hash
Finish the blob-hash vocabulary unification at the defining signatures:
RunStoreBackend::read_blob, RunStoreHandle, LocalRunStoreBackend, the
HTTP backend impl, RunDatabase::read_blob, and BlobStore::read/exists
all said `id`, which kept re-teaching the old vocabulary at every impl
site and inlay hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:56:19 -04:00

1800 lines
61 KiB
Rust

use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_client::ServerTarget;
use fabro_config::user::active_settings_path;
use fabro_config::{ServerSettingsBuilder, Storage, load_llm_catalog_settings};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WORKER_CONTROL_INVALID_CURSOR_REASON,
WORKER_CONTROL_PONG_TIMEOUT_REASON, WORKER_CONTROL_WS_LIVENESS_TIMEOUT,
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlDeliveryFrame, WorkerControlEnvelope,
WorkerControlMessage,
};
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
WorkflowSettings,
};
use fabro_vault::{SecretStore, Vault};
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
use fabro_workflow::operations::{self, StartServices};
use fabro_workflow::run_control::RunControlState;
use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle};
use fabro_workflow::services::FabroRunToolServices;
use futures::{SinkExt, StreamExt};
use jsonwebtoken::dangerous::insecure_decode;
#[cfg(test)]
use tokio::io::DuplexStream;
use tokio::net::TcpStream;
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(unix)]
use tokio::signal::unix::{SignalKind, signal};
use tokio::sync::{Mutex, RwLock as AsyncRwLock, oneshot};
use tokio::task::JoinHandle;
use tokio::time::{self, Instant, MissedTickBehavior};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::{HeaderValue, Request as WebSocketRequest, header};
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::{self, Message as WebSocketMessage};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite};
use tokio_util::sync::CancellationToken;
use crate::args::RunWorkerMode;
use crate::server_client;
use crate::shared::github::build_github_credentials;
const RUN_STORE_RETRY_DELAYS: [Duration; 3] = [
Duration::from_millis(50),
Duration::from_millis(100),
Duration::from_millis(250),
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WorkerTitlePhase {
Start,
Resume,
Init,
Running,
Waiting,
Paused,
Succeeded,
Failed,
Cancelled,
}
pub(crate) async fn execute(
run_id: RunId,
server: String,
storage_dir: PathBuf,
run_dir: PathBuf,
mode: RunWorkerMode,
worker_token: &str,
) -> Result<()> {
let _ = fabro_proc::title_init();
set_worker_title(&run_id, initial_worker_title_phase(mode));
let target = server.parse::<ServerTarget>()?;
let client = server_client::connect_server_target_with_bearer(&target, worker_token).await?;
let run_store = HttpRunStore::connect(run_id, client.clone_for_reuse()).await?;
let run_state = run_store
.state()
.await
.with_context(|| format!("failed to load run state for {run_id}"))?;
let run_spec = &run_state.spec;
let llm_catalog_settings =
load_llm_catalog_settings(None).context("failed to load worker LLM catalog settings")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("failed to build worker LLM catalog")?,
);
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
run_id,
client.clone_for_reuse(),
worker_token.to_owned(),
)));
let fabro_run_tools = if fabro_run_tools_enabled_from_worker_token(worker_token) {
build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
)
} else {
None
};
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let emitter = Arc::new(Emitter::new(run_id));
let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(Arc::clone(&emitter)));
let run_control = RunControlState::new();
install_signal_handlers(Arc::clone(&run_control), cancel_token.clone())?;
let mut control_manager = if run_state.status.is_terminal() {
None
} else {
Some(spawn_worker_control_manager(
target.clone(),
run_id,
worker_token.to_owned(),
Arc::clone(&interviewer),
cancel_token.clone(),
Arc::clone(&steering_hub),
Arc::clone(&run_control),
))
};
if let Some(control_manager) = &mut control_manager {
control_manager.wait_for_first_connection().await?;
}
let vault = load_worker_vault(&storage_dir).await?;
let github_app = {
let vault_guard = vault.read().await;
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
};
let services = StartServices {
run_id,
cancel_token: cancel_token.clone(),
emitter,
interviewer,
steering_hub,
run_store: run_store.clone(),
event_sink: RunEventSink::map(
stamp_system_worker,
RunEventSink::fanout(vec![
RunEventSink::backend(run_store),
RunEventSink::callback(move |event| {
update_worker_title_from_event(&event);
async move { Ok(()) }
}),
]),
),
artifact_sink,
run_control: Some(run_control),
github_app,
github_permissions: run_spec
.settings
.run
.integrations
.github
.resolve_permissions()
.context("failed to resolve github permissions")?,
vault,
catalog,
on_node: None,
registry_override: None,
fabro_run_tools,
};
let execution = async {
match mode {
RunWorkerMode::Start => operations::start(&run_dir, services).await,
RunWorkerMode::Resume => operations::resume(&run_dir, services).await,
}
};
if let Some(mut control_manager) = control_manager {
tokio::select! {
result = execution => {
control_manager.finish();
result?;
}
fatal = control_manager.fatal_control_loss() => {
control_manager.finish();
return Err(fatal);
}
}
} else {
execution.await?;
}
Ok(())
}
const WORKER_TOKEN_SCOPE: &str = "run:worker";
const WORKER_RUN_TOOLS_SCOPE: &str = "agent:run_tools";
#[derive(serde::Deserialize)]
struct WorkerTokenScopeClaim {
scope: String,
}
fn fabro_run_tools_enabled_from_worker_token(worker_token: &str) -> bool {
// Local tool registration only. The server validates the token signature and
// scopes.
insecure_decode::<WorkerTokenScopeClaim>(worker_token)
.is_ok_and(|token| worker_scope_has_run_tools(&token.claims.scope))
}
fn worker_scope_has_run_tools(scope_claim: &str) -> bool {
let mut has_run_worker = false;
let mut has_agent_run_tools = false;
for scope in scope_claim.split_whitespace() {
match scope {
WORKER_TOKEN_SCOPE => has_run_worker = true,
WORKER_RUN_TOOLS_SCOPE => has_agent_run_tools = true,
_ => return false,
}
}
has_run_worker && has_agent_run_tools
}
fn build_fabro_run_tool_services(
worker_token: &str,
client: fabro_client::Client,
current_run_id: RunId,
source_directory: Option<&str>,
run_dir: &Path,
) -> Option<FabroRunToolServices> {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder));
Some(FabroRunToolServices {
backend: Arc::new(backend),
current_run_id,
base_cwd: source_directory.map_or_else(|| run_dir.to_path_buf(), PathBuf::from),
user_settings_path: active_settings_path(None),
})
}
struct WorkerRunManifestBuilder;
impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &fabro_tool::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> fabro_tool::ToolResult<RunManifest> {
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}
/// Load the worker's secret vault from the run's storage root.
///
/// A worker always receives the server storage root so it can load the same
/// secret vault as the server.
async fn load_worker_vault(storage_dir: &Path) -> Result<Arc<AsyncRwLock<Vault>>> {
let storage = Storage::new(storage_dir);
let vault = SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
.await
.with_context(|| {
format!(
"failed to load worker secrets from {}",
storage.root().display()
)
})?
.into_vault();
Ok(Arc::new(AsyncRwLock::new(vault)))
}
const WORKER_CONTROL_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
const WORKER_CONTROL_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(5);
const WORKER_CONTROL_APPLIED_ID_DEDUPE_CAPACITY: usize = 2048;
#[derive(Default)]
struct AppliedWorkerControlDeliveryIds {
last: Option<String>,
order: VecDeque<String>,
recent: HashSet<String>,
}
impl AppliedWorkerControlDeliveryIds {
fn last_applied_id(&self) -> Option<&str> {
self.last.as_deref()
}
fn contains(&self, id: &str) -> bool {
self.recent.contains(id)
}
fn record(&mut self, id: String) {
if !self.recent.insert(id.clone()) {
self.last = Some(id);
return;
}
self.order.push_back(id.clone());
self.last = Some(id);
while self.order.len() > WORKER_CONTROL_APPLIED_ID_DEDUPE_CAPACITY {
if let Some(evicted) = self.order.pop_front() {
self.recent.remove(&evicted);
}
}
}
}
struct WorkerControlManagerHandle {
first_connection: Option<oneshot::Receiver<Result<()>>>,
fatal: Option<oneshot::Receiver<anyhow::Error>>,
done: CancellationToken,
task: JoinHandle<()>,
}
impl WorkerControlManagerHandle {
async fn wait_for_first_connection(&mut self) -> Result<()> {
let receiver = self
.first_connection
.take()
.context("worker control first-connection receiver missing")?;
receiver
.await
.context("worker control manager stopped before first connection")?
}
async fn fatal_control_loss(&mut self) -> anyhow::Error {
let Some(receiver) = self.fatal.take() else {
return anyhow!("worker control fatal receiver missing");
};
receiver
.await
.unwrap_or_else(|_| anyhow!("worker control manager stopped before workflow completed"))
}
fn finish(&self) {
self.done.cancel();
self.task.abort();
}
}
#[derive(Debug)]
struct WorkerControlStreamConnectRequest {
request: WebSocketRequest<()>,
unix_socket_path: Option<PathBuf>,
}
impl WorkerControlStreamConnectRequest {
fn request_for_tungstenite(&self) -> WebSocketRequest<()> {
self.request.clone()
}
#[cfg(test)]
fn uri(&self) -> String {
self.request.uri().to_string()
}
#[cfg(test)]
fn authorization(&self) -> Option<&str> {
self.request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
}
}
enum WorkerControlSocket {
Tcp(Box<WebSocketStream<MaybeTlsStream<TcpStream>>>),
#[cfg(unix)]
Unix(Box<WebSocketStream<UnixStream>>),
#[cfg(test)]
Test(Box<WebSocketStream<DuplexStream>>),
}
impl WorkerControlSocket {
async fn send(&mut self, message: WebSocketMessage) -> Result<(), tungstenite::Error> {
match self {
Self::Tcp(socket) => socket.send(message).await,
#[cfg(unix)]
Self::Unix(socket) => socket.send(message).await,
#[cfg(test)]
Self::Test(socket) => socket.send(message).await,
}
}
async fn next(&mut self) -> Option<Result<WebSocketMessage, tungstenite::Error>> {
match self {
Self::Tcp(socket) => socket.next().await,
#[cfg(unix)]
Self::Unix(socket) => socket.next().await,
#[cfg(test)]
Self::Test(socket) => socket.next().await,
}
}
}
#[derive(Debug)]
enum WorkerControlConnectError {
InvalidCursor,
Other(anyhow::Error),
}
fn spawn_worker_control_manager(
target: ServerTarget,
run_id: RunId,
worker_token: String,
interviewer: Arc<ControlInterviewer>,
cancel_token: CancellationToken,
steering_hub: Arc<fabro_workflow::SteeringHub>,
run_control: Arc<RunControlState>,
) -> WorkerControlManagerHandle {
let (first_tx, first_rx) = oneshot::channel();
let (fatal_tx, fatal_rx) = oneshot::channel();
let done = CancellationToken::new();
let task_done = done.clone();
let task = tokio::spawn(async move {
run_worker_control_manager(
target,
run_id,
worker_token,
interviewer,
cancel_token,
steering_hub,
run_control,
task_done,
first_tx,
fatal_tx,
)
.await;
});
WorkerControlManagerHandle {
first_connection: Some(first_rx),
fatal: Some(fatal_rx),
done,
task,
}
}
#[allow(
clippy::too_many_arguments,
reason = "Worker control manager owns the worker-side control dependencies."
)]
async fn run_worker_control_manager(
target: ServerTarget,
run_id: RunId,
worker_token: String,
interviewer: Arc<ControlInterviewer>,
cancel_token: CancellationToken,
steering_hub: Arc<fabro_workflow::SteeringHub>,
run_control: Arc<RunControlState>,
done: CancellationToken,
first_tx: oneshot::Sender<Result<()>>,
fatal_tx: oneshot::Sender<anyhow::Error>,
) {
let mut first_tx = Some(first_tx);
let mut fatal_tx = Some(fatal_tx);
let mut backoff = WORKER_CONTROL_RECONNECT_INITIAL_BACKOFF;
let mut applied_ids = AppliedWorkerControlDeliveryIds::default();
while !done.is_cancelled() {
let request = match build_worker_control_stream_request(
&target,
&run_id,
&worker_token,
applied_ids.last_applied_id(),
) {
Ok(request) => request,
Err(err) => {
report_fatal_control_loss(
&interviewer,
&cancel_token,
&mut first_tx,
&mut fatal_tx,
format!("failed to build worker control stream request: {err:#}"),
)
.await;
return;
}
};
match connect_worker_control_stream(request).await {
Ok(mut socket) => {
if let Some(first_tx) = first_tx.take() {
let _ = first_tx.send(Ok(()));
}
backoff = WORKER_CONTROL_RECONNECT_INITIAL_BACKOFF;
match handle_worker_control_socket(
&mut socket,
&interviewer,
&cancel_token,
&steering_hub,
&run_control,
&mut applied_ids,
&done,
)
.await
{
Ok(()) => {}
Err(WorkerControlConnectError::InvalidCursor) => {
report_fatal_control_loss(
&interviewer,
&cancel_token,
&mut first_tx,
&mut fatal_tx,
"worker control stream replay cursor is invalid".to_string(),
)
.await;
return;
}
Err(WorkerControlConnectError::Other(err)) => {
tracing::debug!(error = %err, "Worker control stream disconnected");
}
}
}
Err(WorkerControlConnectError::InvalidCursor) => {
report_fatal_control_loss(
&interviewer,
&cancel_token,
&mut first_tx,
&mut fatal_tx,
"worker control stream replay cursor is invalid".to_string(),
)
.await;
return;
}
Err(WorkerControlConnectError::Other(err)) => {
tracing::debug!(error = %err, "Worker control stream connection failed");
}
}
sleep_or_done(&done, backoff).await;
backoff = next_worker_control_reconnect_backoff(backoff);
}
}
async fn report_fatal_control_loss(
interviewer: &ControlInterviewer,
cancel_token: &CancellationToken,
first_tx: &mut Option<oneshot::Sender<Result<()>>>,
fatal_tx: &mut Option<oneshot::Sender<anyhow::Error>>,
detail: String,
) {
let message = format!("worker control channel lost: {detail}");
interviewer.interrupt_all().await;
if let Some(first_tx) = first_tx.take() {
let _ = first_tx.send(Err(anyhow!(message.clone())));
}
if let Some(fatal_tx) = fatal_tx.take() {
let _ = fatal_tx.send(anyhow!(message));
}
cancel_token.cancel();
}
async fn sleep_or_done(done: &CancellationToken, delay: Duration) {
tokio::select! {
() = done.cancelled() => {}
() = time::sleep(delay) => {}
}
}
fn next_worker_control_reconnect_backoff(current: Duration) -> Duration {
current
.saturating_mul(2)
.min(WORKER_CONTROL_RECONNECT_MAX_BACKOFF)
}
fn build_worker_control_stream_request(
target: &ServerTarget,
run_id: &RunId,
worker_token: &str,
after: Option<&str>,
) -> Result<WorkerControlStreamConnectRequest> {
let (url, unix_socket_path) = match target {
ServerTarget::HttpUrl(_) => {
let base = target
.as_http_url()
.context("HTTP server target missing URL")?;
let websocket_base = if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else {
anyhow::bail!("unsupported server URL scheme");
};
(
worker_control_stream_url(&websocket_base, run_id, after),
None,
)
}
ServerTarget::UnixSocket(path) => {
let url = worker_control_stream_url("ws://fabro", run_id, after);
(url, Some(path.as_path().to_path_buf()))
}
};
let mut request = url
.as_str()
.into_client_request()
.context("failed to build worker control stream request")?;
request.headers_mut().insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {worker_token}"))
.context("failed to build worker control stream authorization header")?,
);
Ok(WorkerControlStreamConnectRequest {
request,
unix_socket_path,
})
}
fn worker_control_stream_url(base: &str, run_id: &RunId, after: Option<&str>) -> String {
let mut url = format!("{base}/api/v1/runs/{run_id}/worker/control-stream");
if let Some(after) = after {
url.push_str("?after=");
url.push_str(after);
}
url
}
async fn connect_worker_control_stream(
request: WorkerControlStreamConnectRequest,
) -> Result<WorkerControlSocket, WorkerControlConnectError> {
if let Some(path) = request.unix_socket_path.as_ref() {
#[cfg(unix)]
{
let ws_request = request.request_for_tungstenite();
let stream = UnixStream::connect(path)
.await
.map_err(|err| WorkerControlConnectError::Other(anyhow::Error::new(err)))?;
let (socket, _) = tokio_tungstenite::client_async(ws_request, stream)
.await
.map_err(classify_tungstenite_error)?;
Ok(WorkerControlSocket::Unix(Box::new(socket)))
}
#[cfg(not(unix))]
{
let _ = path;
Err(WorkerControlConnectError::Other(anyhow!(
"Unix-socket worker control stream is not supported on this platform"
)))
}
} else {
let (socket, _) = connect_async(request.request)
.await
.map_err(classify_tungstenite_error)?;
Ok(WorkerControlSocket::Tcp(Box::new(socket)))
}
}
fn classify_tungstenite_error(error: tungstenite::Error) -> WorkerControlConnectError {
if let tungstenite::Error::Http(response) = &error {
if response.status().as_u16() == 410 {
return WorkerControlConnectError::InvalidCursor;
}
}
WorkerControlConnectError::Other(anyhow::Error::new(error))
}
async fn handle_worker_control_socket(
socket: &mut WorkerControlSocket,
interviewer: &ControlInterviewer,
cancel_token: &CancellationToken,
steering_hub: &fabro_workflow::SteeringHub,
run_control: &RunControlState,
applied_ids: &mut AppliedWorkerControlDeliveryIds,
done: &CancellationToken,
) -> Result<(), WorkerControlConnectError> {
let mut ping_interval = time::interval(WORKER_CONTROL_WS_PING_INTERVAL);
ping_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
let mut last_liveness = Instant::now();
let liveness_timeout = time::sleep_until(last_liveness + WORKER_CONTROL_WS_LIVENESS_TIMEOUT);
tokio::pin!(liveness_timeout);
loop {
liveness_timeout
.as_mut()
.reset(last_liveness + WORKER_CONTROL_WS_LIVENESS_TIMEOUT);
tokio::select! {
() = done.cancelled() => return Ok(()),
_ = ping_interval.tick() => {
socket
.send(WebSocketMessage::Ping(Vec::new().into()))
.await
.map_err(|err| WorkerControlConnectError::Other(anyhow::Error::new(err)))?;
}
() = &mut liveness_timeout => {
let _ = socket
.send(WebSocketMessage::Close(Some(protocol::CloseFrame {
code: CloseCode::Away,
reason: WORKER_CONTROL_PONG_TIMEOUT_REASON.into(),
})))
.await;
return Err(WorkerControlConnectError::Other(anyhow!(
"worker control WebSocket liveness timed out"
)));
}
message = socket.next() => {
let Some(message) = message else {
return Ok(());
};
match message {
Ok(WebSocketMessage::Text(text)) => {
last_liveness = Instant::now();
let frame = serde_json::from_str::<WorkerControlDeliveryFrame>(text.as_str())
.map_err(|err| WorkerControlConnectError::Other(anyhow::Error::new(err)))?;
apply_worker_control_delivery_frame(
interviewer,
cancel_token,
steering_hub,
run_control,
applied_ids,
frame,
)
.await;
}
Ok(WebSocketMessage::Ping(payload)) => {
last_liveness = Instant::now();
socket
.send(WebSocketMessage::Pong(payload))
.await
.map_err(|err| WorkerControlConnectError::Other(anyhow::Error::new(err)))?;
}
Ok(WebSocketMessage::Pong(_) | WebSocketMessage::Binary(_)) => {
last_liveness = Instant::now();
}
Ok(WebSocketMessage::Close(frame)) => {
if frame.as_ref().is_some_and(|frame| {
frame.reason.as_str() == WORKER_CONTROL_INVALID_CURSOR_REASON
}) {
return Err(WorkerControlConnectError::InvalidCursor);
}
return Ok(());
}
Ok(WebSocketMessage::Frame(_)) => {}
Err(err) => {
return Err(WorkerControlConnectError::Other(anyhow::Error::new(err)));
}
}
}
}
}
}
async fn apply_worker_control_delivery_frame(
interviewer: &ControlInterviewer,
cancel_token: &CancellationToken,
steering_hub: &fabro_workflow::SteeringHub,
run_control: &RunControlState,
applied_ids: &mut AppliedWorkerControlDeliveryIds,
frame: WorkerControlDeliveryFrame,
) -> bool {
// Duplicate ids cannot reach us under normal operation: the server replays
// strictly after the last applied id. Guard against a server-side bug or
// reconnect race by ignoring recently-applied delivery ids.
if applied_ids.contains(&frame.id) {
return false;
}
let frame_id = frame.id;
apply_worker_control_message(
interviewer,
cancel_token,
steering_hub,
run_control,
frame.envelope,
)
.await;
applied_ids.record(frame_id);
true
}
async fn apply_worker_control_message(
interviewer: &ControlInterviewer,
cancel_token: &CancellationToken,
steering_hub: &fabro_workflow::SteeringHub,
run_control: &RunControlState,
message: WorkerControlEnvelope,
) {
match message.message {
WorkerControlMessage::InterviewAnswer { qid, answer, actor } => {
let _ = interviewer
.submit(&qid, AnswerSubmission::new(answer.into(), actor))
.await;
}
WorkerControlMessage::RunCancel => {
cancel_token.cancel();
interviewer.interrupt_all().await;
}
WorkerControlMessage::RunPause => {
run_control.request_pause();
}
WorkerControlMessage::RunUnpause => {
run_control.request_unpause();
}
WorkerControlMessage::Steer { text, actor } => {
steering_hub.deliver_steer(text, Some(actor));
}
WorkerControlMessage::Interrupt { actor } => {
steering_hub.interrupt(Some(&actor));
}
WorkerControlMessage::InterruptThenSteer { text, actor } => {
steering_hub.interrupt_then_steer(&text, Some(&actor));
}
WorkerControlMessage::PairStart {
run_id,
pair_id,
target,
actor,
} => {
let _ = steering_hub.start_pair(run_id, pair_id, target, Some(actor));
}
WorkerControlMessage::PairMessage {
pair_id,
message_id,
text,
client_message_id,
actor,
} => {
let _ = steering_hub.send_pair_message(
pair_id,
message_id,
text,
client_message_id,
Some(actor),
);
}
WorkerControlMessage::PairEnd { pair_id, actor } => {
let _ = steering_hub.end_pair(pair_id, Some(actor));
}
}
}
fn build_artifact_uploader(
run_id: RunId,
client: server_client::Client,
worker_token: String,
) -> Arc<dyn StageArtifactUploader> {
Arc::new(HttpArtifactUploader {
run_id,
client,
worker_token,
})
}
struct HttpArtifactUploader {
run_id: RunId,
client: server_client::Client,
worker_token: String,
}
#[async_trait]
impl StageArtifactUploader for HttpArtifactUploader {
async fn upload_stage_artifacts(
&self,
stage_id: &fabro_types::StageId,
retry: u32,
artifact_capture_dir: &Path,
artifacts: &[ArtifactUpload],
) -> Result<()> {
if artifacts.is_empty() {
return Ok(());
}
if artifacts.len() == 1 {
let artifact = &artifacts[0];
return self
.client
.upload_stage_artifact_file(
&self.run_id,
stage_id,
retry,
&artifact.path,
&artifact_capture_dir.join(&artifact.path),
&self.worker_token,
)
.await;
}
self.client
.upload_stage_artifact_batch(
&self.run_id,
stage_id,
retry,
artifact_capture_dir,
artifacts,
&self.worker_token,
)
.await
}
}
#[derive(Clone)]
struct HttpRunStore {
run_id: RunId,
client: server_client::Client,
state: Arc<Mutex<RunProjection>>,
events: Arc<Mutex<Option<Vec<EventEnvelope>>>>,
}
impl HttpRunStore {
async fn connect(run_id: RunId, client: server_client::Client) -> Result<RunStoreHandle> {
let state = client
.get_run_state(&run_id)
.await
.with_context(|| format!("failed to fetch run state for {run_id}"))?;
Ok(RunStoreHandle::new(Arc::new(Self {
run_id,
client,
state: Arc::new(Mutex::new(state)),
events: Arc::new(Mutex::new(None)),
})))
}
async fn with_retries<T, F, Fut>(&self, operation: &'static str, mut op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut last_error = None;
for attempt in 0..=RUN_STORE_RETRY_DELAYS.len() {
match op().await {
Ok(value) => return Ok(value),
Err(err) => last_error = Some(err),
}
if let Some(delay) = RUN_STORE_RETRY_DELAYS.get(attempt) {
time::sleep(*delay).await;
}
}
Err(last_error
.unwrap_or_else(|| anyhow!("run store operation failed"))
.context(format!(
"worker lost canonical run store during {operation}"
)))
}
async fn refresh_state_from_server(&self) -> Result<RunProjection> {
self.with_retries("refresh state", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
async move { client.get_run_state(&run_id).await }
})
.await
}
async fn apply_acknowledged_event(&self, seq: u32, event: &RunEvent) -> Result<()> {
let envelope = EventEnvelope {
seq,
event: event.clone(),
};
{
let mut state = self.state.lock().await;
if let Err(err) = state.apply_event(&envelope) {
tracing::warn!(run_id = %self.run_id, error = %err, "failed to apply acknowledged event to local run-state mirror; refreshing from server");
drop(state);
let refreshed = self.refresh_state_from_server().await?;
*self.state.lock().await = refreshed;
}
}
let mut events = self.events.lock().await;
if let Some(cached) = events.as_mut() {
cached.push(envelope);
}
Ok(())
}
}
#[async_trait]
impl RunStoreBackend for HttpRunStore {
async fn load_state(&self) -> Result<RunProjection> {
Ok(self.state.lock().await.clone())
}
async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
let mut cached = self.events.lock().await;
if let Some(events) = cached.as_ref() {
return Ok(events.clone());
}
let events = self
.with_retries("list run events", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
async move { client.list_run_events(&run_id, None, None).await }
})
.await?;
*cached = Some(events.clone());
Ok(events)
}
async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
let seq = Box::pin(self.with_retries("append run event", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
let event = event.clone();
async move { client.append_run_event(&run_id, &event).await }
}))
.await?;
self.apply_acknowledged_event(seq, event).await
}
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
self.with_retries("write run blob", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
let data = data.to_vec();
async move { client.write_run_blob(&run_id, &data).await }
})
.await
}
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<bytes::Bytes>> {
self.with_retries("read run blob", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
let blob_hash = *blob_hash;
async move { client.read_run_blob(&run_id, &blob_hash).await }
})
.await
}
async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
self.with_retries("get run logs", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
async move { client.get_run_logs(&run_id).await }
})
.await
}
}
fn set_worker_title(run_id: &RunId, phase: WorkerTitlePhase) {
fabro_proc::title_set(&worker_title(run_id, phase));
}
fn initial_worker_title_phase(mode: RunWorkerMode) -> WorkerTitlePhase {
match mode {
RunWorkerMode::Start => WorkerTitlePhase::Start,
RunWorkerMode::Resume => WorkerTitlePhase::Resume,
}
}
fn worker_title(run_id: &RunId, phase: WorkerTitlePhase) -> String {
let short_id: String = run_id.to_string().chars().take(12).collect();
let phase = match phase {
WorkerTitlePhase::Start => "start",
WorkerTitlePhase::Resume => "resume",
WorkerTitlePhase::Init => "init",
WorkerTitlePhase::Running => "running",
WorkerTitlePhase::Waiting => "waiting",
WorkerTitlePhase::Paused => "paused",
WorkerTitlePhase::Succeeded => "succeeded",
WorkerTitlePhase::Failed => "failed",
WorkerTitlePhase::Cancelled => "cancelled",
};
format!("fabro {short_id} {phase}")
}
fn worker_title_phase_for_event(body: &EventBody) -> Option<WorkerTitlePhase> {
match body {
EventBody::RunStarting(_) => Some(WorkerTitlePhase::Init),
EventBody::RunRunning(_) | EventBody::RunUnpaused(_) => Some(WorkerTitlePhase::Running),
EventBody::InterviewStarted(_) => Some(WorkerTitlePhase::Waiting),
EventBody::InterviewCompleted(_) | EventBody::InterviewTimeout(_) => {
Some(WorkerTitlePhase::Running)
}
EventBody::RunPaused(_) => Some(WorkerTitlePhase::Paused),
EventBody::RunCompleted(_) => Some(WorkerTitlePhase::Succeeded),
EventBody::RunFailed(props) => Some(if props.failure.reason == FailureReason::Cancelled {
WorkerTitlePhase::Cancelled
} else {
WorkerTitlePhase::Failed
}),
_ => None,
}
}
fn update_worker_title_from_event(event: &RunEvent) {
if let Some(phase) = worker_title_phase_for_event(&event.body) {
set_worker_title(&event.run_id, phase);
}
}
fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
if event.actor.is_none() {
event.actor = Some(Principal::Worker {
run_id: event.run_id,
});
}
event
}
fn maybe_build_github_credentials(
settings: &WorkflowSettings,
vault: &fabro_vault::Vault,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = &settings.run;
let resolved_server = ServerSettingsBuilder::load_default().ok();
let server_ns = resolved_server.as_ref().map(|s| &s.server);
let strategy = server_ns
.map(|server| server.integrations.github.strategy)
.unwrap_or_default();
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
if requires_github_credentials(resolved_run) {
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
}
let pull_request_enabled =
resolved_run.execution.mode != RunMode::DryRun && resolved_run.pull_request.is_some();
if pull_request_enabled {
return Ok(build_github_credentials(
strategy,
app_id.as_deref(),
app_slug.as_deref(),
vault,
)
.ok()
.flatten());
}
Ok(None)
}
/// Hard-gate for the CLI worker path: a run-level token is requested, or
/// a clone-based sandbox in non-dry-run mode will need credentials to
/// pull the repository. Pull-request-driven credential acquisition is
/// handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace) -> bool {
if run.integrations.github.is_token_requested() {
return true;
}
run.execution.mode != RunMode::DryRun && run.environment.provider.is_clone_based()
}
fn install_signal_handlers(
run_control: Arc<RunControlState>,
cancel_token: CancellationToken,
) -> Result<()> {
#[cfg(unix)]
{
let mut pause = signal(SignalKind::user_defined1())?;
let pause_control = Arc::clone(&run_control);
tokio::spawn(async move {
while pause.recv().await.is_some() {
pause_control.request_pause();
}
});
let mut unpause = signal(SignalKind::user_defined2())?;
tokio::spawn(async move {
while unpause.recv().await.is_some() {
run_control.request_unpause();
}
});
let mut terminate = signal(SignalKind::terminate())?;
let terminate_cancel = cancel_token.clone();
tokio::spawn(async move {
while terminate.recv().await.is_some() {
terminate_cancel.cancel();
}
});
let mut interrupt = signal(SignalKind::interrupt())?;
tokio::spawn(async move {
while interrupt.recv().await.is_some() {
cancel_token.cancel();
}
});
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::absolute_paths,
reason = "This test module prefers explicit type paths over extra imports."
)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_client::ServerTarget;
use fabro_config::Storage;
use fabro_interview::{
AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope,
};
use fabro_types::run_event::{
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
RunFailedProps, RunStatusTransitionProps,
};
use fabro_types::{
AuthMethod, EventBody, FailureCategory, FailureDetail, FailureReason, IdpIdentity,
Principal, QuestionType, RunFailure, SuccessReason, fixtures,
};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::event::RunEventSink;
use fabro_workflow::run_control::RunControlState;
use tokio::time;
use tokio_tungstenite::tungstenite::protocol::{Message as TestWebSocketMessage, Role};
use tokio_util::sync::CancellationToken;
use super::{
AppliedWorkerControlDeliveryIds, WorkerControlConnectError, WorkerControlSocket,
WorkerTitlePhase, apply_worker_control_delivery_frame, apply_worker_control_message,
build_worker_control_stream_request, connect_worker_control_stream,
handle_worker_control_socket, initial_worker_title_phase, load_worker_vault,
next_worker_control_reconnect_backoff, stamp_system_worker, worker_title,
worker_title_phase_for_event,
};
use crate::args::RunWorkerMode;
fn test_steering_hub() -> Arc<fabro_workflow::SteeringHub> {
let emitter = Arc::new(fabro_workflow::event::Emitter::new(fixtures::RUN_1));
Arc::new(fabro_workflow::SteeringHub::new(emitter))
}
#[test]
fn clone_sandbox_credentials_are_required_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;
assert!(EnvironmentProvider::Docker.is_clone_based());
assert!(EnvironmentProvider::Daytona.is_clone_based());
assert!(!EnvironmentProvider::Local.is_clone_based());
}
#[test]
fn fabro_run_tools_enabled_token_requires_run_tools_scope() {
assert!(!super::fabro_run_tools_enabled_from_worker_token(
"not-a-jwt"
));
assert!(!super::fabro_run_tools_enabled_from_worker_token(
&worker_token_with_claims(&serde_json::json!({ "scope": "run:worker" })),
));
assert!(!super::fabro_run_tools_enabled_from_worker_token(
&worker_token_with_claims(&serde_json::json!({ "scope": "agent:run_tools" })),
));
assert!(!super::fabro_run_tools_enabled_from_worker_token(
&worker_token_with_claims(&serde_json::json!({ "scope": "run:worker agent:wrong" })),
));
assert!(!super::fabro_run_tools_enabled_from_worker_token(
&worker_token_with_claims(
&serde_json::json!({ "other": "run:worker agent:run_tools" }),
),
));
assert!(super::fabro_run_tools_enabled_from_worker_token(
&worker_token_with_claims(
&serde_json::json!({ "scope": "run:worker agent:run_tools" }),
),
));
}
fn worker_token_with_claims(claims: &serde_json::Value) -> String {
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
claims,
&jsonwebtoken::EncodingKey::from_secret(b"test-worker-token"),
)
.expect("test worker token should encode")
}
fn test_user_principal(login: &str) -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
login.to_string(),
AuthMethod::Github,
)
}
fn running_event(actor: Option<Principal>) -> fabro_types::RunEvent {
fabro_types::RunEvent {
id: "evt_1".to_string(),
ts: Utc::now(),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor,
body: EventBody::RunRunning(RunStatusTransitionProps::default()),
}
}
#[test]
fn worker_title_uses_short_run_id_and_phase() {
let short_id: String = fixtures::RUN_1.to_string().chars().take(12).collect();
assert_eq!(
worker_title(&fixtures::RUN_1, WorkerTitlePhase::Start),
format!("fabro {short_id} start")
);
assert_eq!(
worker_title(&fixtures::RUN_1, WorkerTitlePhase::Succeeded),
format!("fabro {short_id} succeeded")
);
}
#[test]
fn initial_worker_title_phase_matches_mode() {
assert_eq!(
initial_worker_title_phase(RunWorkerMode::Start),
WorkerTitlePhase::Start
);
assert_eq!(
initial_worker_title_phase(RunWorkerMode::Resume),
WorkerTitlePhase::Resume
);
}
#[test]
fn worker_title_phase_tracks_lifecycle_events() {
assert_eq!(
worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps {})),
Some(WorkerTitlePhase::Init)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::RunPaused(RunControlEffectProps::default())),
Some(WorkerTitlePhase::Paused)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::InterviewStarted(InterviewStartedProps {
question_id: "q-1".to_string(),
question: "Approve?".to_string(),
stage: "gate".to_string(),
question_type: "yes_no".to_string(),
options: Vec::new(),
allow_freeform: false,
timeout_seconds: None,
context_display: None,
review_target: None,
})),
Some(WorkerTitlePhase::Waiting)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::InterviewCompleted(InterviewCompletedProps {
question_id: "q-1".to_string(),
question: "Approve?".to_string(),
answer: "yes".to_string(),
duration_ms: 10,
})),
Some(WorkerTitlePhase::Running)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::RunCompleted(RunCompletedProps {
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})),
Some(WorkerTitlePhase::Succeeded)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
failure: RunFailure {
reason: FailureReason::Cancelled,
detail: FailureDetail::new("cancelled", FailureCategory::Canceled),
},
timing: fabro_types::RunTiming::wall_only(10),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})),
Some(WorkerTitlePhase::Cancelled)
);
assert_eq!(
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
failure: RunFailure {
reason: FailureReason::Terminated,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
},
timing: fabro_types::RunTiming::wall_only(10),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})),
Some(WorkerTitlePhase::Failed)
);
}
#[test]
fn stamp_system_worker_fills_missing_actor_only() {
let stamped = stamp_system_worker(running_event(None));
assert_eq!(
stamped.actor,
Some(Principal::Worker {
run_id: fixtures::RUN_1,
})
);
let existing_actor = test_user_principal("octocat");
let stamped = stamp_system_worker(running_event(Some(existing_actor.clone())));
assert_eq!(stamped.actor, Some(existing_actor));
}
#[tokio::test]
async fn worker_event_stamp_applies_to_all_fanout_sinks() {
let first = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let second = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let first_events = Arc::clone(&first);
let second_events = Arc::clone(&second);
let sink = RunEventSink::map(
stamp_system_worker,
RunEventSink::fanout(vec![
RunEventSink::callback(move |event| {
let first_events = Arc::clone(&first_events);
async move {
first_events.lock().await.push(event);
Ok(())
}
}),
RunEventSink::callback(move |event| {
let second_events = Arc::clone(&second_events);
async move {
second_events.lock().await.push(event);
Ok(())
}
}),
]),
);
let event = running_event(None);
sink.write_run_event(&event).await.unwrap();
let first = first.lock().await;
let second = second.lock().await;
assert_eq!(
first[0].actor,
Some(Principal::Worker {
run_id: fixtures::RUN_1,
})
);
assert_eq!(
second[0].actor,
Some(Principal::Worker {
run_id: fixtures::RUN_1,
})
);
}
#[tokio::test]
async fn worker_control_routes_answer_by_question_id() {
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let run_control = RunControlState::new();
let mut question = Question::new("Approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let ask_interviewer = Arc::clone(&interviewer);
let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await });
let hub = test_steering_hub();
apply_worker_control_message(
&interviewer,
&cancel_token,
&hub,
&run_control,
WorkerControlEnvelope::interview_answer(
"q-1",
fabro_interview::AnswerSubmission::system(
fabro_interview::Answer::yes(),
fabro_types::SystemActorKind::Engine,
),
),
)
.await;
let answer = answer_task.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Yes);
assert!(!cancel_token.is_cancelled());
}
#[tokio::test]
async fn worker_control_cancel_sets_cancel_token_and_interrupts_pending_interviews() {
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let run_control = RunControlState::new();
let mut question = Question::new("Approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let ask_interviewer = Arc::clone(&interviewer);
let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await });
tokio::task::yield_now().await;
let hub = test_steering_hub();
apply_worker_control_message(
&interviewer,
&cancel_token,
&hub,
&run_control,
WorkerControlEnvelope::cancel_run(),
)
.await;
let answer = answer_task.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
assert!(cancel_token.is_cancelled());
}
#[tokio::test]
async fn worker_control_pause_and_unpause_route_to_run_control() {
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let run_control = RunControlState::new();
let hub = test_steering_hub();
apply_worker_control_message(
&interviewer,
&cancel_token,
&hub,
&run_control,
WorkerControlEnvelope::pause_run(),
)
.await;
assert!(run_control.pause_requested());
apply_worker_control_message(
&interviewer,
&cancel_token,
&hub,
&run_control,
WorkerControlEnvelope::unpause_run(),
)
.await;
assert!(!run_control.pause_requested());
}
#[tokio::test]
async fn duplicate_delivery_ids_are_not_applied_twice() {
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let run_control = RunControlState::new();
let hub = test_steering_hub();
let mut applied_ids = AppliedWorkerControlDeliveryIds::default();
let frame = fabro_interview::WorkerControlDeliveryFrame {
id: "local:1".to_string(),
envelope: WorkerControlEnvelope::pause_run(),
};
assert!(
apply_worker_control_delivery_frame(
&interviewer,
&cancel_token,
&hub,
&run_control,
&mut applied_ids,
frame.clone(),
)
.await
);
assert!(
!apply_worker_control_delivery_frame(
&interviewer,
&cancel_token,
&hub,
&run_control,
&mut applied_ids,
frame,
)
.await
);
assert_eq!(applied_ids.last_applied_id(), Some("local:1"));
}
#[test]
fn worker_control_request_construction_for_http_targets() {
let run_id = fixtures::RUN_1;
let request = build_worker_control_stream_request(
&ServerTarget::http_url("http://example.com:3000").unwrap(),
&run_id,
"worker-token",
None,
)
.unwrap();
assert_eq!(
request.uri(),
format!("ws://example.com:3000/api/v1/runs/{run_id}/worker/control-stream")
);
assert_eq!(request.authorization(), Some("Bearer worker-token"));
let reconnect = build_worker_control_stream_request(
&ServerTarget::http_url("https://example.com").unwrap(),
&run_id,
"worker-token",
Some("local:42"),
)
.unwrap();
assert_eq!(
reconnect.uri(),
format!("wss://example.com/api/v1/runs/{run_id}/worker/control-stream?after=local:42")
);
}
#[cfg(unix)]
#[test]
fn worker_control_request_construction_for_unix_socket_targets() {
let run_id = fixtures::RUN_1;
let request = build_worker_control_stream_request(
&ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap(),
&run_id,
"worker-token",
Some("local:42"),
)
.unwrap();
assert_eq!(
request.uri(),
format!("ws://fabro/api/v1/runs/{run_id}/worker/control-stream?after=local:42")
);
assert_eq!(
request.unix_socket_path.as_deref(),
Some(std::path::Path::new("/tmp/fabro.sock"))
);
}
#[test]
fn worker_control_reconnect_backoff_is_bounded() {
assert_eq!(
next_worker_control_reconnect_backoff(Duration::from_millis(100)),
Duration::from_millis(200)
);
assert_eq!(
next_worker_control_reconnect_backoff(Duration::from_secs(4)),
Duration::from_secs(5)
);
assert_eq!(
next_worker_control_reconnect_backoff(Duration::from_secs(5)),
Duration::from_secs(5)
);
}
#[tokio::test(start_paused = true)]
async fn worker_control_socket_times_out_without_liveness() {
let (worker_io, _server_io) = tokio::io::duplex(1024);
let worker_ws =
tokio_tungstenite::WebSocketStream::from_raw_socket(worker_io, Role::Client, None)
.await;
let mut socket = WorkerControlSocket::Test(Box::new(worker_ws));
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let hub = test_steering_hub();
let run_control = RunControlState::new();
let mut applied_ids = AppliedWorkerControlDeliveryIds::default();
let done = CancellationToken::new();
let task = tokio::spawn(async move {
handle_worker_control_socket(
&mut socket,
&interviewer,
&cancel_token,
&hub,
&run_control,
&mut applied_ids,
&done,
)
.await
});
tokio::task::yield_now().await;
time::advance(Duration::from_secs(46)).await;
let result = task.await.unwrap();
assert!(matches!(result, Err(WorkerControlConnectError::Other(_))));
}
#[cfg(unix)]
#[tokio::test]
async fn worker_control_unix_socket_handshake_completes() {
let temp = tempfile::tempdir().unwrap();
let socket_path = temp.path().join("fabro.sock");
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
while let Some(message) = futures::StreamExt::next(&mut socket).await {
match message.unwrap() {
TestWebSocketMessage::Close(_) => break,
TestWebSocketMessage::Ping(payload) => {
futures::SinkExt::send(&mut socket, TestWebSocketMessage::Pong(payload))
.await
.unwrap();
}
_ => {}
}
}
});
let request = build_worker_control_stream_request(
&ServerTarget::unix_socket_path(&socket_path).unwrap(),
&fixtures::RUN_1,
"worker-token",
None,
)
.unwrap();
let mut socket = connect_worker_control_stream(request).await.unwrap();
socket
.send(TestWebSocketMessage::Close(None))
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn load_worker_vault_reads_credentials_from_storage_dir() {
let temp = tempfile::tempdir().unwrap();
let storage = Storage::new(temp.path());
let mut vault = Vault::load(storage.secrets_path()).unwrap();
vault
.set("ANTHROPIC_API_KEY", "vault-key", SecretType::Token, None)
.unwrap();
let loaded = load_worker_vault(temp.path()).await.unwrap();
let guard = loaded.read().await;
let credential = guard.get("ANTHROPIC_API_KEY").unwrap();
assert!(credential.contains("vault-key"));
}
mod requires_github_credentials_truth_table {
//! Truth-table coverage for the worker-side credential gate.
//! `InterpString` → `String` resolution is tested in `fabro-types`
//! next to `RunIntegrationsGithubSettings::resolve_permissions`.
use std::collections::HashMap;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
EnvironmentProvider, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode,
RunNamespace,
};
use super::super::requires_github_credentials;
fn run_with(
permissions: HashMap<String, InterpString>,
provider: &str,
mode: RunMode,
) -> RunNamespace {
let mut run = RunNamespace::default();
run.execution.mode = mode;
run.environment.provider = provider
.parse::<EnvironmentProvider>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings { permissions },
};
run
}
#[test]
fn requires_github_credentials_when_permissions_non_empty() {
let permissions = HashMap::from([("issues".to_string(), InterpString::parse("read"))]);
// Even with local sandbox + dry-run, non-empty permissions
// force credential acquisition.
let run = run_with(permissions, "local", RunMode::DryRun);
assert!(requires_github_credentials(&run));
}
#[test]
fn requires_github_credentials_for_clone_based_provider() {
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(requires_github_credentials(&run));
let daytona = run_with(HashMap::new(), "daytona", RunMode::Normal);
assert!(requires_github_credentials(&daytona));
}
#[test]
fn does_not_require_github_credentials_for_local_clean_run() {
let run = run_with(HashMap::new(), "local", RunMode::Normal);
assert!(!requires_github_credentials(&run));
}
#[test]
fn does_not_require_github_credentials_for_clone_provider_in_dry_run() {
let run = run_with(HashMap::new(), "docker", RunMode::DryRun);
assert!(!requires_github_credentials(&run));
}
}
}