mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-30 06:50:16 +00:00
- Distinguish the two GitHub webhook auth-failure warn messages (missing signature header vs. HMAC mismatch) so logs can tell them apart. - Route update_github_app_webhook through fabro_github::github_api_base_url() so GITHUB_BASE_URL overrides the webhook config endpoint too. - Drop a narrative shutdown comment that restated the next two lines. - Replace concat!(file!(), ":", line!()) inside local test-helper wrappers; those macros expand at the wrapper definition site, so every panic reported the same phantom location. Pass the wrapper name instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11102 lines
382 KiB
Rust
11102 lines
382 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::str::FromStr;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, LazyLock, Mutex, RwLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Context as _;
|
|
use axum::body::Body;
|
|
#[cfg(test)]
|
|
use axum::body::to_bytes;
|
|
use axum::extract::{self as axum_extract, DefaultBodyLimit, Path, Query, State};
|
|
use axum::http::request::Parts;
|
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
|
use axum::middleware::{self, Next};
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use axum_extra::extract::cookie::Key;
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
|
use bytes::Bytes;
|
|
pub use fabro_api::types::{
|
|
AggregateBilling, AggregateBillingTotals, ApiQuestion, ApiQuestionOption, AppendEventResponse,
|
|
ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel,
|
|
BillingStageRef, BlockedReason as ApiBlockedReason, CompletionContentPart, CompletionMessage,
|
|
CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
|
|
CreateCompletionRequest, CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse,
|
|
DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, ModelReference,
|
|
PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest,
|
|
PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
|
|
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest,
|
|
RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
|
|
RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStage, RunStatus,
|
|
RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType,
|
|
ServerSettings, SshAccessRequest, SshAccessResponse, StageStatus as ApiStageStatus,
|
|
StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemFeatures,
|
|
SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
|
|
};
|
|
use fabro_auth::parse_credential_secret;
|
|
use fabro_config::{Storage, resolve_server_from_file};
|
|
use fabro_interview::{
|
|
Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope,
|
|
};
|
|
use fabro_llm::generate::{GenerateParams, generate_object};
|
|
use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client};
|
|
use fabro_llm::types::{
|
|
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Role, ToolChoice,
|
|
ToolDefinition,
|
|
};
|
|
use fabro_model::{BilledModelUsage, BilledTokenCounts};
|
|
use fabro_sandbox::daytona::DaytonaSandbox;
|
|
use fabro_sandbox::reconnect::reconnect;
|
|
use fabro_sandbox::{Sandbox, SandboxProvider};
|
|
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
|
|
use fabro_slack::config::resolve_credentials as resolve_slack_credentials;
|
|
use fabro_slack::payload::SlackAnswerSubmission;
|
|
use fabro_slack::threads::ThreadRegistry;
|
|
use fabro_slack::{blocks as slack_blocks, connection as slack_connection};
|
|
use fabro_store::{
|
|
ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId,
|
|
};
|
|
use fabro_types::settings::run::RunMode;
|
|
use fabro_types::settings::server::{GithubIntegrationSettings, GithubIntegrationStrategy};
|
|
use fabro_types::settings::{
|
|
InterpString, ServerSettings as ResolvedServerSettings, SettingsLayer,
|
|
};
|
|
use fabro_types::{
|
|
ActorRef, BlockedReason, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId,
|
|
RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance,
|
|
RunSubjectProvenance,
|
|
};
|
|
use fabro_util::redact::redact_jsonl_line;
|
|
use fabro_util::text::strip_goal_decoration;
|
|
use fabro_util::version::FABRO_VERSION;
|
|
use fabro_vault::{Error as VaultError, SecretType, Vault};
|
|
use fabro_workflow::Error as WorkflowError;
|
|
use fabro_workflow::artifact_upload::ArtifactSink;
|
|
use fabro_workflow::event::{self as workflow_event, Emitter};
|
|
use fabro_workflow::handler::HandlerRegistry;
|
|
use fabro_workflow::operations::{self};
|
|
use fabro_workflow::pipeline::Persisted;
|
|
use fabro_workflow::records::Checkpoint;
|
|
use fabro_workflow::run_lookup::{
|
|
RunInfo, StatusFilter, filter_runs, scan_runs_with_summaries, scratch_base,
|
|
};
|
|
use fabro_workflow::run_status::{
|
|
RunStatus as WorkflowRunStatus, StatusReason as WorkflowStatusReason,
|
|
};
|
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
|
use object_store::memory::InMemory as MemoryObjectStore;
|
|
use rand::TryRngCore;
|
|
use rand::rngs::OsRng;
|
|
use sha2::{Digest, Sha256};
|
|
use tempfile::NamedTempFile;
|
|
use tokio::fs;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::process::{ChildStderr, ChildStdin, Command};
|
|
use tokio::sync::broadcast::error::RecvError;
|
|
use tokio::sync::{Notify, RwLock as AsyncRwLock, Semaphore, broadcast, mpsc, oneshot};
|
|
use tokio::task::spawn_blocking;
|
|
use tokio::time::{sleep, timeout};
|
|
use tokio_stream::StreamExt;
|
|
use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream};
|
|
use tower::{ServiceExt, service_fn};
|
|
use tower_http::trace::TraceLayer;
|
|
use tracing::{debug, error, info, warn};
|
|
use ulid::Ulid;
|
|
|
|
use crate::bind::Bind;
|
|
use crate::error::ApiError;
|
|
use crate::github_webhooks::{
|
|
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
|
|
};
|
|
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
|
|
use crate::jwt_auth::{
|
|
AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts,
|
|
};
|
|
use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight};
|
|
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
|
use crate::server_secrets::{
|
|
LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message,
|
|
};
|
|
use crate::{
|
|
demo, diagnostics, run_manifest, security_headers, settings_view, static_files, web_auth,
|
|
};
|
|
|
|
pub(crate) type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
|
|
|
pub fn default_page_limit() -> u32 {
|
|
20
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
pub struct PaginationParams {
|
|
#[serde(rename = "page[limit]", default = "default_page_limit")]
|
|
pub limit: u32,
|
|
#[serde(rename = "page[offset]", default)]
|
|
pub offset: u32,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ListRunsParams {
|
|
#[serde(rename = "page[limit]", default = "default_page_limit")]
|
|
limit: u32,
|
|
#[serde(rename = "page[offset]", default)]
|
|
offset: u32,
|
|
#[serde(default)]
|
|
include_archived: bool,
|
|
}
|
|
|
|
impl ListRunsParams {
|
|
fn pagination(&self) -> PaginationParams {
|
|
PaginationParams {
|
|
limit: self.limit,
|
|
offset: self.offset,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ModelListParams {
|
|
#[serde(rename = "page[limit]", default = "default_page_limit")]
|
|
limit: u32,
|
|
#[serde(rename = "page[offset]", default)]
|
|
offset: u32,
|
|
#[serde(default)]
|
|
provider: Option<String>,
|
|
#[serde(default)]
|
|
query: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ModelTestParams {
|
|
#[serde(default)]
|
|
mode: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct EventListParams {
|
|
#[serde(default)]
|
|
since_seq: Option<u32>,
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
impl EventListParams {
|
|
fn since_seq(&self) -> u32 {
|
|
self.since_seq.unwrap_or(1).max(1)
|
|
}
|
|
|
|
fn limit(&self) -> usize {
|
|
self.limit.unwrap_or(100).clamp(1, 1000)
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct AttachParams {
|
|
#[serde(default)]
|
|
since_seq: Option<u32>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
pub(crate) struct DfParams {
|
|
#[serde(default)]
|
|
pub(crate) verbose: bool,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct GlobalAttachParams {
|
|
#[serde(default)]
|
|
run_id: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct ArtifactFilenameParams {
|
|
#[serde(default)]
|
|
filename: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SandboxFilesParams {
|
|
path: String,
|
|
#[serde(default)]
|
|
depth: Option<usize>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SandboxFileParams {
|
|
path: String,
|
|
}
|
|
|
|
/// Non-paginated list response wrapper with `has_more: false`.
|
|
#[derive(serde::Serialize)]
|
|
pub struct ListResponse<T: serde::Serialize> {
|
|
data: T,
|
|
meta: PaginationMeta,
|
|
}
|
|
|
|
impl<T: serde::Serialize> ListResponse<T> {
|
|
pub fn new(data: T) -> Self {
|
|
Self {
|
|
data,
|
|
meta: PaginationMeta { has_more: false },
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snapshot of a managed run.
|
|
struct ManagedRun {
|
|
dot_source: String,
|
|
status: RunStatus,
|
|
error: Option<String>,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
enqueued_at: Instant,
|
|
// Populated when running:
|
|
answer_transport: Option<RunAnswerTransport>,
|
|
accepted_questions: HashSet<String>,
|
|
event_tx: Option<broadcast::Sender<RunEvent>>,
|
|
checkpoint: Option<Checkpoint>,
|
|
cancel_tx: Option<oneshot::Sender<()>>,
|
|
cancel_token: Option<Arc<AtomicBool>>,
|
|
worker_pid: Option<u32>,
|
|
worker_pgid: Option<u32>,
|
|
run_dir: Option<std::path::PathBuf>,
|
|
execution_mode: RunExecutionMode,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum RunExecutionMode {
|
|
Start,
|
|
Resume,
|
|
}
|
|
|
|
enum ExecutionResult {
|
|
Completed(Box<Result<operations::Started, WorkflowError>>),
|
|
CancelledBySignal,
|
|
}
|
|
|
|
const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
|
|
const TERMINAL_DELETE_WORKER_GRACE: Duration = Duration::from_millis(50);
|
|
const WORKER_CONTROL_QUEUE_CAPACITY: usize = 8;
|
|
const WORKER_CONTROL_ENQUEUE_TIMEOUT: Duration = Duration::from_secs(1);
|
|
const ARTIFACT_UPLOAD_TOKEN_ISSUER: &str = "fabro-server-artifact-upload";
|
|
const ARTIFACT_UPLOAD_TOKEN_SCOPE: &str = "stage_artifacts:upload";
|
|
const ARTIFACT_UPLOAD_TOKEN_TTL_SECS: u64 = 24 * 60 * 60;
|
|
const MAX_SINGLE_ARTIFACT_BYTES: u64 = 10 * 1024 * 1024;
|
|
const MAX_MULTIPART_ARTIFACTS: usize = 100;
|
|
const RENDER_ERROR_PREFIX: &[u8] = b"RENDER_ERROR:";
|
|
const GRAPHVIZ_RENDER_CONCURRENCY_LIMIT: usize = 4;
|
|
|
|
static GRAPHVIZ_RENDER_SEMAPHORE: LazyLock<Semaphore> =
|
|
LazyLock::new(|| Semaphore::new(GRAPHVIZ_RENDER_CONCURRENCY_LIMIT));
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum RenderSubprocessError {
|
|
#[error("failed to spawn render subprocess: {0}")]
|
|
SpawnFailed(String),
|
|
#[error("render subprocess crashed: {0}")]
|
|
ChildCrashed(String),
|
|
#[error("render subprocess returned invalid output: {0}")]
|
|
ProtocolViolation(String),
|
|
#[error("{0}")]
|
|
RenderFailed(String),
|
|
}
|
|
const MAX_MULTIPART_REQUEST_BYTES: u64 = 50 * 1024 * 1024;
|
|
const MAX_MULTIPART_MANIFEST_BYTES: usize = 256 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
struct ArtifactUploadTokenKeys {
|
|
encoding: Arc<EncodingKey>,
|
|
decoding: Arc<DecodingKey>,
|
|
validation: Arc<Validation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
struct ArtifactUploadClaims {
|
|
iss: String,
|
|
iat: u64,
|
|
exp: u64,
|
|
run_id: String,
|
|
scope: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
struct ArtifactBatchUploadManifest {
|
|
entries: Vec<ArtifactBatchUploadEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
struct ArtifactBatchUploadEntry {
|
|
part: String,
|
|
path: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
sha256: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
expected_bytes: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
content_type: Option<String>,
|
|
}
|
|
|
|
/// Per-model billing totals.
|
|
#[derive(Default)]
|
|
struct ModelBillingTotals {
|
|
stages: i64,
|
|
billing: BilledTokenCounts,
|
|
}
|
|
|
|
/// In-memory aggregate billing counters, reset on server restart.
|
|
#[derive(Default)]
|
|
struct BillingAccumulator {
|
|
total_runs: i64,
|
|
total_runtime_secs: f64,
|
|
by_model: HashMap<String, ModelBillingTotals>,
|
|
}
|
|
|
|
pub(crate) type RegistryFactoryOverride =
|
|
dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync;
|
|
|
|
#[derive(Clone)]
|
|
enum RunAnswerTransport {
|
|
Subprocess {
|
|
control_tx: mpsc::Sender<WorkerControlEnvelope>,
|
|
},
|
|
InProcess {
|
|
interviewer: Arc<ControlInterviewer>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum AnswerTransportError {
|
|
Closed,
|
|
Timeout,
|
|
}
|
|
|
|
impl RunAnswerTransport {
|
|
async fn submit(&self, qid: &str, answer: Answer) -> Result<(), AnswerTransportError> {
|
|
match self {
|
|
Self::Subprocess { control_tx } => {
|
|
let message = WorkerControlEnvelope::interview_answer(qid.to_string(), answer);
|
|
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
|
|
.await
|
|
.map_err(|_| AnswerTransportError::Timeout)?
|
|
.map_err(|_| AnswerTransportError::Closed)
|
|
}
|
|
Self::InProcess { interviewer } => interviewer
|
|
.submit(qid, answer)
|
|
.await
|
|
.map_err(|_| AnswerTransportError::Closed),
|
|
}
|
|
}
|
|
|
|
async fn cancel_run(&self) -> Result<(), AnswerTransportError> {
|
|
match self {
|
|
Self::Subprocess { control_tx } => {
|
|
let message = WorkerControlEnvelope::cancel_run();
|
|
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
|
|
.await
|
|
.map_err(|_| AnswerTransportError::Timeout)?
|
|
.map_err(|_| AnswerTransportError::Closed)
|
|
}
|
|
Self::InProcess { interviewer } => {
|
|
interviewer.cancel_all().await;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct LoadedPendingInterview {
|
|
run_id: RunId,
|
|
qid: String,
|
|
question: InterviewQuestionRecord,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SlackService {
|
|
client: SlackClient,
|
|
app_token: String,
|
|
default_channel: String,
|
|
posted_messages: Arc<Mutex<HashMap<(RunId, String), SlackPostedMessage>>>,
|
|
thread_registry: Arc<ThreadRegistry>,
|
|
}
|
|
|
|
impl SlackService {
|
|
fn new(bot_token: String, app_token: String, default_channel: String) -> Self {
|
|
Self {
|
|
client: SlackClient::new(bot_token),
|
|
app_token,
|
|
default_channel,
|
|
posted_messages: Arc::new(Mutex::new(HashMap::new())),
|
|
thread_registry: Arc::new(ThreadRegistry::new()),
|
|
}
|
|
}
|
|
|
|
async fn handle_event(&self, event: &RunEvent) {
|
|
match &event.body {
|
|
EventBody::InterviewStarted(props) => {
|
|
if props.question_id.is_empty() {
|
|
return;
|
|
}
|
|
let key = (event.run_id, props.question_id.clone());
|
|
if self
|
|
.posted_messages
|
|
.lock()
|
|
.expect("slack posted messages lock poisoned")
|
|
.contains_key(&key)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let question = runtime_question_from_interview_record(&InterviewQuestionRecord {
|
|
id: props.question_id.clone(),
|
|
text: props.question.clone(),
|
|
stage: props.stage.clone(),
|
|
question_type: InterviewQuestionType::from_wire_name(&props.question_type),
|
|
options: props.options.clone(),
|
|
allow_freeform: props.allow_freeform,
|
|
timeout_seconds: props.timeout_seconds,
|
|
context_display: props.context_display.clone(),
|
|
});
|
|
let blocks = slack_blocks::question_to_blocks(
|
|
&event.run_id.to_string(),
|
|
&props.question_id,
|
|
&question,
|
|
);
|
|
|
|
if let Ok(posted) = self
|
|
.client
|
|
.post_message(&self.default_channel, &blocks, None)
|
|
.await
|
|
{
|
|
if question.allow_freeform || question.question_type == QuestionType::Freeform {
|
|
self.thread_registry.register(
|
|
&posted.ts,
|
|
&event.run_id.to_string(),
|
|
&props.question_id,
|
|
);
|
|
}
|
|
self.posted_messages
|
|
.lock()
|
|
.expect("slack posted messages lock poisoned")
|
|
.insert(key, posted);
|
|
}
|
|
}
|
|
EventBody::InterviewCompleted(props) => {
|
|
self.finish_interview(
|
|
event.run_id,
|
|
&props.question_id,
|
|
&props.question,
|
|
&props.answer,
|
|
)
|
|
.await;
|
|
}
|
|
EventBody::InterviewTimeout(props) => {
|
|
self.finish_interview(
|
|
event.run_id,
|
|
&props.question_id,
|
|
&props.question,
|
|
"Timed out",
|
|
)
|
|
.await;
|
|
}
|
|
EventBody::InterviewInterrupted(props) => {
|
|
self.finish_interview(
|
|
event.run_id,
|
|
&props.question_id,
|
|
&props.question,
|
|
"Interrupted",
|
|
)
|
|
.await;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn finish_interview(
|
|
&self,
|
|
run_id: RunId,
|
|
qid: &str,
|
|
question_text: &str,
|
|
answer_text: &str,
|
|
) {
|
|
let key = (run_id, qid.to_string());
|
|
let posted = self
|
|
.posted_messages
|
|
.lock()
|
|
.expect("slack posted messages lock poisoned")
|
|
.remove(&key);
|
|
let Some(posted) = posted else {
|
|
return;
|
|
};
|
|
|
|
self.thread_registry.remove(&posted.ts);
|
|
let blocks = slack_blocks::answered_blocks(question_text, answer_text);
|
|
let _ = self
|
|
.client
|
|
.update_message(&posted.channel_id, &posted.ts, &blocks)
|
|
.await;
|
|
}
|
|
|
|
async fn submit_answer(&self, state: Arc<AppState>, submission: SlackAnswerSubmission) {
|
|
let Ok(run_id) = RunId::from_str(&submission.run_id) else {
|
|
return;
|
|
};
|
|
|
|
let Ok(pending) = load_pending_interview(state.as_ref(), run_id, &submission.qid).await
|
|
else {
|
|
return;
|
|
};
|
|
let _ = submit_pending_interview_answer(state.as_ref(), &pending, submission.answer).await;
|
|
}
|
|
}
|
|
|
|
/// Shared application state for the server.
|
|
pub struct AppState {
|
|
runs: Mutex<HashMap<RunId, ManagedRun>>,
|
|
aggregate_billing: Mutex<BillingAccumulator>,
|
|
store: Arc<Database>,
|
|
artifact_store: ArtifactStore,
|
|
artifact_upload_tokens: ArtifactUploadTokenKeys,
|
|
started_at: Instant,
|
|
max_concurrent_runs: usize,
|
|
scheduler_notify: Notify,
|
|
global_event_tx: broadcast::Sender<EventEnvelope>,
|
|
/// Per-run coalescing registry for `GET /runs/{id}/files`. Concurrent
|
|
/// callers for the same run share one materialization; different runs
|
|
/// proceed in parallel. See `crate::run_files` for semantics.
|
|
pub(crate) files_in_flight: FilesInFlight,
|
|
|
|
pub(crate) vault: Arc<AsyncRwLock<Vault>>,
|
|
pub(crate) server_secrets: ServerSecrets,
|
|
pub(crate) provider_credentials: ProviderCredentials,
|
|
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
|
|
pub(crate) server_settings: RwLock<Arc<ResolvedServerSettings>>,
|
|
pub(crate) local_daemon_mode: bool,
|
|
http_client: Option<fabro_http::HttpClient>,
|
|
shutting_down: AtomicBool,
|
|
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
|
slack_service: Option<Arc<SlackService>>,
|
|
slack_started: AtomicBool,
|
|
}
|
|
|
|
pub(crate) struct AppStateConfig {
|
|
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
|
|
pub(crate) registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
|
pub(crate) max_concurrent_runs: usize,
|
|
pub(crate) store: Arc<Database>,
|
|
pub(crate) artifact_store: ArtifactStore,
|
|
pub(crate) vault_path: PathBuf,
|
|
pub(crate) server_env_path: PathBuf,
|
|
pub(crate) local_daemon_mode: bool,
|
|
pub(crate) env_lookup: EnvLookup,
|
|
pub(crate) http_client: Option<fabro_http::HttpClient>,
|
|
}
|
|
|
|
fn nonzero_i64(value: i64) -> Option<i64> {
|
|
(value != 0).then_some(value)
|
|
}
|
|
|
|
fn api_billed_token_counts_from_domain(billing: &BilledTokenCounts) -> ApiBilledTokenCounts {
|
|
ApiBilledTokenCounts {
|
|
cache_read_tokens: nonzero_i64(billing.cache_read_tokens),
|
|
cache_write_tokens: nonzero_i64(billing.cache_write_tokens),
|
|
input_tokens: billing.input_tokens,
|
|
output_tokens: billing.output_tokens,
|
|
reasoning_tokens: nonzero_i64(billing.reasoning_tokens),
|
|
total_tokens: billing.total_tokens,
|
|
total_usd_micros: billing.total_usd_micros,
|
|
}
|
|
}
|
|
|
|
fn api_billed_token_counts_from_usage(usage: &BilledModelUsage) -> ApiBilledTokenCounts {
|
|
let tokens = usage.tokens();
|
|
ApiBilledTokenCounts {
|
|
cache_read_tokens: nonzero_i64(tokens.cache_read_tokens),
|
|
cache_write_tokens: nonzero_i64(tokens.cache_write_tokens),
|
|
input_tokens: tokens.input_tokens,
|
|
output_tokens: tokens.output_tokens,
|
|
reasoning_tokens: nonzero_i64(tokens.reasoning_tokens),
|
|
total_tokens: tokens.total_tokens(),
|
|
total_usd_micros: usage.total_usd_micros,
|
|
}
|
|
}
|
|
|
|
fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelUsage) {
|
|
let tokens = usage.tokens();
|
|
entry.stages += 1;
|
|
entry.billing.input_tokens += tokens.input_tokens;
|
|
entry.billing.output_tokens += tokens.output_tokens;
|
|
entry.billing.reasoning_tokens += tokens.reasoning_tokens;
|
|
entry.billing.cache_read_tokens += tokens.cache_read_tokens;
|
|
entry.billing.cache_write_tokens += tokens.cache_write_tokens;
|
|
entry.billing.total_tokens += tokens.total_tokens();
|
|
if let Some(value) = usage.total_usd_micros {
|
|
*entry.billing.total_usd_micros.get_or_insert(0) += value;
|
|
}
|
|
}
|
|
|
|
impl AppState {
|
|
pub(crate) fn server_settings(&self) -> Arc<ResolvedServerSettings> {
|
|
Arc::clone(
|
|
&self
|
|
.server_settings
|
|
.read()
|
|
.expect("server settings lock poisoned"),
|
|
)
|
|
}
|
|
|
|
fn http_client(&self) -> Result<fabro_http::HttpClient, fabro_http::HttpClientBuildError> {
|
|
match &self.http_client {
|
|
Some(client) => Ok(client.clone()),
|
|
None => fabro_http::http_client(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn server_storage_dir(&self) -> PathBuf {
|
|
PathBuf::from(
|
|
resolve_interp_string(&self.server_settings().storage.root)
|
|
.expect("server storage root should be resolved at startup"),
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn build_llm_client(&self) -> Result<LlmClientResult, String> {
|
|
self.provider_credentials.build_llm_client().await
|
|
}
|
|
|
|
pub(crate) fn vault_or_env(&self, name: &str) -> Option<String> {
|
|
std::env::var(name).ok().or_else(|| {
|
|
self.vault
|
|
.try_read()
|
|
.ok()
|
|
.and_then(|vault| vault.get(name).map(str::to_string))
|
|
})
|
|
}
|
|
|
|
/// Public accessor used by `run_files` — mirrors `vault_or_env` without
|
|
/// changing its visibility semantics.
|
|
pub(crate) fn vault_or_env_pub(&self, name: &str) -> Option<String> {
|
|
self.vault_or_env(name)
|
|
}
|
|
|
|
/// Borrow the persistent store so sibling modules can open run readers
|
|
/// without cross-module state coupling on the `AppState` field layout.
|
|
pub(crate) fn store_ref(&self) -> &Arc<Database> {
|
|
&self.store
|
|
}
|
|
|
|
pub(crate) fn server_secret(&self, name: &str) -> Option<String> {
|
|
self.server_secrets.get(name)
|
|
}
|
|
|
|
pub(crate) fn session_key(&self) -> Option<Key> {
|
|
self.server_secret("SESSION_SECRET")
|
|
.map(|value| Key::derive_from(value.as_bytes()))
|
|
}
|
|
|
|
pub(crate) fn github_credentials(
|
|
&self,
|
|
settings: &GithubIntegrationSettings,
|
|
) -> Result<Option<fabro_github::GitHubCredentials>, String> {
|
|
match settings.strategy {
|
|
GithubIntegrationStrategy::App => {
|
|
let Some(app_id) = settings.app_id.as_ref().map(InterpString::as_source) else {
|
|
return Ok(None);
|
|
};
|
|
let raw = self.server_secret("GITHUB_APP_PRIVATE_KEY");
|
|
let Some(raw) = raw else {
|
|
return Ok(None);
|
|
};
|
|
let private_key_pem = decode_secret_pem("GITHUB_APP_PRIVATE_KEY", &raw)?;
|
|
Ok(Some(fabro_github::GitHubCredentials::App(
|
|
fabro_github::GitHubAppCredentials {
|
|
app_id,
|
|
private_key_pem,
|
|
},
|
|
)))
|
|
}
|
|
GithubIntegrationStrategy::Token => {
|
|
let token = self
|
|
.vault_or_env("GITHUB_TOKEN")
|
|
.or_else(|| self.vault_or_env("GH_TOKEN"))
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|token| !token.is_empty())
|
|
.map(str::to_string);
|
|
match token {
|
|
Some(token) => Ok(Some(fabro_github::GitHubCredentials::Token(token))),
|
|
None => Err(
|
|
"GITHUB_TOKEN not configured — run fabro install or set GITHUB_TOKEN"
|
|
.to_string(),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn issue_artifact_upload_token(&self, run_id: &RunId) -> Result<String, ApiError> {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |duration| duration.as_secs());
|
|
let claims = ArtifactUploadClaims {
|
|
iss: ARTIFACT_UPLOAD_TOKEN_ISSUER.to_string(),
|
|
iat: now,
|
|
exp: now + ARTIFACT_UPLOAD_TOKEN_TTL_SECS,
|
|
run_id: run_id.to_string(),
|
|
scope: ARTIFACT_UPLOAD_TOKEN_SCOPE.to_string(),
|
|
};
|
|
jsonwebtoken::encode(
|
|
&Header::new(Algorithm::HS256),
|
|
&claims,
|
|
&self.artifact_upload_tokens.encoding,
|
|
)
|
|
.map_err(|err| {
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("failed to sign artifact upload token: {err}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn begin_shutdown(&self) {
|
|
self.shutting_down.store(true, Ordering::Relaxed);
|
|
self.scheduler_notify.notify_waiters();
|
|
}
|
|
|
|
fn is_shutting_down(&self) -> bool {
|
|
self.shutting_down.load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub(crate) fn replace_settings(&self, settings: SettingsLayer) -> anyhow::Result<()> {
|
|
let resolved = Arc::new(resolve_server_from_file(&settings).map_err(|errors| {
|
|
anyhow::anyhow!(
|
|
"failed to resolve server settings:\n{}",
|
|
errors
|
|
.into_iter()
|
|
.map(|error| error.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
})?);
|
|
|
|
*self.settings.write().expect("settings lock poisoned") = settings;
|
|
*self
|
|
.server_settings
|
|
.write()
|
|
.expect("server settings lock poisoned") = resolved;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn artifact_upload_token_keys() -> ArtifactUploadTokenKeys {
|
|
let mut secret = [0_u8; 32];
|
|
OsRng.try_fill_bytes(&mut secret).expect("OS RNG");
|
|
|
|
let mut validation = Validation::new(Algorithm::HS256);
|
|
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
|
|
validation.set_issuer(&[ARTIFACT_UPLOAD_TOKEN_ISSUER]);
|
|
|
|
ArtifactUploadTokenKeys {
|
|
encoding: Arc::new(EncodingKey::from_secret(&secret)),
|
|
decoding: Arc::new(DecodingKey::from_secret(&secret)),
|
|
validation: Arc::new(validation),
|
|
}
|
|
}
|
|
|
|
fn maybe_authorize_artifact_upload_token(
|
|
parts: &Parts,
|
|
run_id: &RunId,
|
|
keys: &ArtifactUploadTokenKeys,
|
|
) -> Result<bool, ApiError> {
|
|
let Some(header) = parts
|
|
.headers
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let Some(token) = header.strip_prefix("Bearer ") else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let claims =
|
|
match jsonwebtoken::decode::<ArtifactUploadClaims>(token, &keys.decoding, &keys.validation)
|
|
{
|
|
Ok(token_data) => token_data.claims,
|
|
Err(_) => return Ok(false),
|
|
};
|
|
|
|
if claims.scope != ARTIFACT_UPLOAD_TOKEN_SCOPE {
|
|
return Err(ApiError::forbidden());
|
|
}
|
|
if claims.run_id != run_id.to_string() {
|
|
return Err(ApiError::forbidden());
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn authorize_artifact_upload(
|
|
parts: &Parts,
|
|
state: &AppState,
|
|
run_id: &RunId,
|
|
) -> Result<(), ApiError> {
|
|
if maybe_authorize_artifact_upload_token(parts, run_id, &state.artifact_upload_tokens)? {
|
|
return Ok(());
|
|
}
|
|
authenticate_service_parts(parts)
|
|
}
|
|
|
|
fn decode_secret_pem(name: &str, raw: &str) -> Result<String, String> {
|
|
if raw.starts_with("-----") {
|
|
return Ok(raw.to_string());
|
|
}
|
|
let pem_bytes = BASE64_STANDARD
|
|
.decode(raw)
|
|
.map_err(|err| format!("{name} is not valid PEM or base64: {err}"))?;
|
|
String::from_utf8(pem_bytes)
|
|
.map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}"))
|
|
}
|
|
|
|
fn resolve_interp_string(value: &InterpString) -> anyhow::Result<String> {
|
|
value
|
|
.resolve(|name| std::env::var(name).ok())
|
|
.map(|resolved| resolved.value)
|
|
.map_err(anyhow::Error::from)
|
|
}
|
|
|
|
fn start_optional_slack_service(state: &Arc<AppState>) {
|
|
let Some(service) = state.slack_service.clone() else {
|
|
return;
|
|
};
|
|
if state.slack_started.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
|
|
let event_state = Arc::clone(state);
|
|
let event_service = Arc::clone(&service);
|
|
tokio::spawn(async move {
|
|
let mut rx = event_state.global_event_tx.subscribe();
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(envelope) => {
|
|
if let Ok(event) = RunEvent::try_from(&envelope.payload) {
|
|
event_service.handle_event(&event).await;
|
|
}
|
|
}
|
|
Err(RecvError::Lagged(_)) => {}
|
|
Err(RecvError::Closed) => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
let socket_state = Arc::clone(state);
|
|
tokio::spawn(async move {
|
|
let submit_service = Arc::clone(&service);
|
|
let on_submit: Arc<dyn Fn(SlackAnswerSubmission) + Send + Sync> =
|
|
Arc::new(move |submission| {
|
|
let state = Arc::clone(&socket_state);
|
|
let service = Arc::clone(&submit_service);
|
|
tokio::spawn(async move {
|
|
service.submit_answer(state, submission).await;
|
|
});
|
|
});
|
|
slack_connection::run(
|
|
&service.client,
|
|
&service.app_token,
|
|
&service.thread_registry,
|
|
on_submit,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
/// Build the axum Router with all run endpoints and embedded static assets.
|
|
pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|
build_router_with_options(
|
|
state,
|
|
auth_mode,
|
|
Arc::new(IpAllowlistConfig::default()),
|
|
RouterOptions::default(),
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct RouterOptions {
|
|
pub web_enabled: bool,
|
|
pub github_webhook_ip_allowlist: Option<Arc<IpAllowlistConfig>>,
|
|
}
|
|
|
|
impl Default for RouterOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
web_enabled: true,
|
|
github_webhook_ip_allowlist: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn removed_web_route(path: &str) -> bool {
|
|
matches!(path, "/setup/complete") || path.starts_with("/install")
|
|
}
|
|
|
|
/// Build the axum Router with configurable web surface routing.
|
|
pub fn build_router_with_options(
|
|
state: Arc<AppState>,
|
|
auth_mode: AuthMode,
|
|
ip_allowlist_config: Arc<IpAllowlistConfig>,
|
|
options: RouterOptions,
|
|
) -> Router {
|
|
start_optional_slack_service(&state);
|
|
let web_enabled = options.web_enabled;
|
|
let webhook_ip_allowlist = options.github_webhook_ip_allowlist;
|
|
let middleware_state = Arc::clone(&state);
|
|
let webhook_secret = state.server_secret(WEBHOOK_SECRET_ENV);
|
|
let api_common = if web_enabled {
|
|
Router::new()
|
|
.route("/openapi.json", get(openapi_spec))
|
|
.merge(web_auth::api_routes())
|
|
} else {
|
|
Router::new().route("/openapi.json", get(openapi_spec))
|
|
};
|
|
|
|
let demo_router = Router::new()
|
|
.nest("/api/v1", api_common.clone().merge(demo_routes()))
|
|
.layer(axum::Extension(AuthMode::Disabled))
|
|
.with_state(state.clone());
|
|
|
|
let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes()));
|
|
if web_enabled {
|
|
real_router = real_router.nest("/auth", web_auth::routes());
|
|
}
|
|
let real_router = real_router
|
|
.layer(axum::Extension(auth_mode))
|
|
.with_state(state);
|
|
|
|
let dispatch = service_fn(move |req: axum_extract::Request| {
|
|
let demo = demo_router.clone();
|
|
let real = real_router.clone();
|
|
async move {
|
|
if web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") {
|
|
demo.oneshot(req).await
|
|
} else {
|
|
real.oneshot(req).await
|
|
}
|
|
}
|
|
});
|
|
|
|
let trace_layer = TraceLayer::new_for_http()
|
|
.make_span_with(|req: &axum_extract::Request| {
|
|
let method = req.method().as_str();
|
|
let path = req.uri().path();
|
|
tracing::debug_span!("http_request", method, path)
|
|
})
|
|
.on_request(|req: &axum_extract::Request, _span: &tracing::Span| {
|
|
debug!(method = %req.method(), path = %req.uri().path(), "HTTP request");
|
|
})
|
|
.on_response(
|
|
|response: &Response, latency: std::time::Duration, _span: &tracing::Span| {
|
|
let status = response.status().as_u16();
|
|
let latency_ms = latency.as_millis();
|
|
if status >= 500 {
|
|
error!(status, latency_ms, "HTTP response");
|
|
} else {
|
|
info!(status, latency_ms, "HTTP response");
|
|
}
|
|
},
|
|
);
|
|
|
|
let mut app_router = Router::new()
|
|
.route("/health", get(health))
|
|
.fallback_service(service_fn(move |req: axum_extract::Request| {
|
|
let dispatch = dispatch.clone();
|
|
async move {
|
|
let path = req.uri().path().to_string();
|
|
let dispatch_path = path.starts_with("/api/")
|
|
|| path == "/health"
|
|
|| (web_enabled && path.starts_with("/auth/"));
|
|
if dispatch_path {
|
|
dispatch.oneshot(req).await
|
|
} else if web_enabled && removed_web_route(&path) {
|
|
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
|
|
} else if web_enabled && matches!(req.method(), &Method::GET | &Method::HEAD) {
|
|
let headers = req.headers().clone();
|
|
Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await)
|
|
} else {
|
|
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
|
|
}
|
|
}
|
|
}));
|
|
|
|
if web_enabled {
|
|
app_router = app_router.layer(middleware::from_fn_with_state(
|
|
middleware_state,
|
|
cookie_and_demo_middleware,
|
|
));
|
|
}
|
|
|
|
app_router = app_router.layer(middleware::from_fn_with_state(
|
|
Arc::clone(&ip_allowlist_config),
|
|
ip_allowlist_middleware,
|
|
));
|
|
|
|
let mut router = app_router;
|
|
if let Some(secret) = webhook_secret {
|
|
let allowlist = webhook_ip_allowlist.unwrap_or(ip_allowlist_config);
|
|
let secret: Arc<[u8]> = Arc::from(secret.into_bytes().into_boxed_slice());
|
|
router = github_webhook_routes(secret, allowlist).merge(router);
|
|
}
|
|
|
|
router
|
|
.layer(middleware::from_fn(security_headers::layer))
|
|
.layer(trace_layer)
|
|
}
|
|
|
|
fn github_webhook_routes(secret: Arc<[u8]>, ip_allowlist_config: Arc<IpAllowlistConfig>) -> Router {
|
|
Router::new()
|
|
.route(WEBHOOK_ROUTE, post(github_webhook))
|
|
.with_state(secret)
|
|
.layer(middleware::from_fn_with_state(
|
|
ip_allowlist_config,
|
|
ip_allowlist_middleware,
|
|
))
|
|
}
|
|
|
|
fn demo_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
|
|
.route("/runs/resolve", get(demo::resolve_run))
|
|
.route("/boards/runs", get(demo::list_board_runs))
|
|
.route("/preflight", post(run_preflight))
|
|
.route("/graph/render", post(render_graph_from_manifest))
|
|
.route("/attach", get(demo::attach_events_stub))
|
|
.route("/runs/{id}", get(demo::get_run_status))
|
|
.route("/runs/{id}/questions", get(demo::get_questions_stub))
|
|
.route("/runs/{id}/questions/{qid}/answer", post(demo::answer_stub))
|
|
.route("/runs/{id}/state", get(not_implemented))
|
|
.route(
|
|
"/runs/{id}/events",
|
|
get(not_implemented).post(not_implemented),
|
|
)
|
|
.route("/runs/{id}/attach", get(demo::run_events_stub))
|
|
.route("/runs/{id}/blobs", post(not_implemented))
|
|
.route("/runs/{id}/blobs/{blobId}", get(not_implemented))
|
|
.route("/runs/{id}/checkpoint", get(demo::checkpoint_stub))
|
|
.route("/runs/{id}/cancel", post(demo::cancel_stub))
|
|
.route("/runs/{id}/start", post(demo::start_run_stub))
|
|
.route("/runs/{id}/pause", post(demo::pause_stub))
|
|
.route("/runs/{id}/unpause", post(demo::unpause_stub))
|
|
.route("/runs/{id}/graph", get(demo::get_run_graph))
|
|
.route("/runs/{id}/stages", get(demo::get_run_stages))
|
|
.route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub))
|
|
.route("/runs/{id}/files", get(demo::list_run_files_stub))
|
|
.route(
|
|
"/runs/{id}/stages/{stageId}/turns",
|
|
get(demo::get_stage_turns),
|
|
)
|
|
.route(
|
|
"/runs/{id}/stages/{stageId}/artifacts",
|
|
get(not_implemented).post(not_implemented),
|
|
)
|
|
.route(
|
|
"/runs/{id}/stages/{stageId}/artifacts/download",
|
|
get(not_implemented),
|
|
)
|
|
.route("/runs/{id}/billing", get(demo::get_run_billing))
|
|
.route("/runs/{id}/settings", get(demo::get_run_settings))
|
|
.route("/runs/{id}/preview", post(demo::generate_preview_url_stub))
|
|
.route("/runs/{id}/ssh", post(demo::create_ssh_access_stub))
|
|
.route(
|
|
"/runs/{id}/sandbox/files",
|
|
get(demo::list_sandbox_files_stub),
|
|
)
|
|
.route(
|
|
"/runs/{id}/sandbox/file",
|
|
get(demo::get_sandbox_file_stub).put(demo::put_sandbox_file_stub),
|
|
)
|
|
.route(
|
|
"/insights/queries",
|
|
get(demo::list_saved_queries).post(demo::save_query_stub),
|
|
)
|
|
.route(
|
|
"/insights/queries/{id}",
|
|
get(demo::get_saved_query)
|
|
.put(demo::update_query_stub)
|
|
.delete(demo::delete_query_stub),
|
|
)
|
|
.route("/insights/execute", post(demo::execute_query_stub))
|
|
.route("/insights/history", get(demo::list_query_history))
|
|
.route("/models", get(list_models))
|
|
.route("/models/{id}/test", post(test_model))
|
|
.route(
|
|
"/secrets",
|
|
get(demo::list_secrets)
|
|
.post(demo::create_secret)
|
|
.delete(demo::delete_secret_by_name),
|
|
)
|
|
.route("/repos/github/{owner}/{name}", get(demo::get_github_repo))
|
|
.route("/health/diagnostics", post(demo::run_diagnostics))
|
|
.route("/completions", post(create_completion))
|
|
.route("/settings", get(demo::get_server_settings))
|
|
.route("/system/info", get(demo::get_system_info))
|
|
.route("/system/df", get(demo::get_system_disk_usage))
|
|
.route("/system/prune/runs", post(demo::prune_runs))
|
|
.route("/billing", get(demo::get_aggregate_billing))
|
|
}
|
|
|
|
fn real_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/runs", get(list_runs).post(create_run))
|
|
.route("/runs/resolve", get(resolve_run))
|
|
.route("/preflight", post(run_preflight))
|
|
.route("/graph/render", post(render_graph_from_manifest))
|
|
.route("/attach", get(attach_events))
|
|
.route("/boards/runs", get(list_board_runs))
|
|
.route("/runs/{id}", get(get_run_status).delete(delete_run))
|
|
.route("/runs/{id}/questions", get(get_questions))
|
|
.route("/runs/{id}/questions/{qid}/answer", post(submit_answer))
|
|
.route("/runs/{id}/state", get(get_run_state))
|
|
.route(
|
|
"/runs/{id}/events",
|
|
get(list_run_events).post(append_run_event),
|
|
)
|
|
.route("/runs/{id}/attach", get(attach_run_events))
|
|
.route("/runs/{id}/blobs", post(write_run_blob))
|
|
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
|
|
.route("/runs/{id}/checkpoint", get(get_checkpoint))
|
|
.route("/runs/{id}/cancel", post(cancel_run))
|
|
.route("/runs/{id}/start", post(start_run))
|
|
.route("/runs/{id}/pause", post(pause_run))
|
|
.route("/runs/{id}/unpause", post(unpause_run))
|
|
.route("/runs/{id}/archive", post(archive_run))
|
|
.route("/runs/{id}/unarchive", post(unarchive_run))
|
|
.route("/runs/{id}/graph", get(get_graph))
|
|
.route("/runs/{id}/stages", get(list_run_stages))
|
|
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
|
.route("/runs/{id}/files", get(list_run_files))
|
|
.route("/runs/{id}/stages/{stageId}/turns", get(not_implemented))
|
|
.route(
|
|
"/runs/{id}/stages/{stageId}/artifacts",
|
|
get(list_stage_artifacts)
|
|
.post(put_stage_artifact)
|
|
.layer(DefaultBodyLimit::disable()),
|
|
)
|
|
.route(
|
|
"/runs/{id}/stages/{stageId}/artifacts/download",
|
|
get(get_stage_artifact),
|
|
)
|
|
.route("/runs/{id}/billing", get(get_run_billing))
|
|
.route("/runs/{id}/settings", get(get_run_settings))
|
|
.route("/runs/{id}/steer", post(not_implemented))
|
|
.route("/runs/{id}/preview", post(generate_preview_url))
|
|
.route("/runs/{id}/ssh", post(create_ssh_access))
|
|
.route("/runs/{id}/sandbox/files", get(list_sandbox_files))
|
|
.route(
|
|
"/runs/{id}/sandbox/file",
|
|
get(get_sandbox_file).put(put_sandbox_file),
|
|
)
|
|
.route("/workflows", get(not_implemented))
|
|
.route("/workflows/{name}", get(not_implemented))
|
|
.route("/workflows/{name}/runs", get(not_implemented))
|
|
.route(
|
|
"/insights/queries",
|
|
get(not_implemented).post(not_implemented),
|
|
)
|
|
.route(
|
|
"/insights/queries/{id}",
|
|
get(not_implemented)
|
|
.put(not_implemented)
|
|
.delete(not_implemented),
|
|
)
|
|
.route("/insights/execute", post(not_implemented))
|
|
.route("/insights/history", get(not_implemented))
|
|
.route("/models", get(list_models))
|
|
.route("/models/{id}/test", post(test_model))
|
|
.route(
|
|
"/secrets",
|
|
get(list_secrets)
|
|
.post(create_secret)
|
|
.delete(delete_secret_by_name),
|
|
)
|
|
.route("/repos/github/{owner}/{name}", get(get_github_repo))
|
|
.route("/health/diagnostics", post(run_diagnostics))
|
|
.route("/completions", post(create_completion))
|
|
.route("/settings", get(get_server_settings))
|
|
.route("/system/info", get(get_system_info))
|
|
.route("/system/df", get(get_system_df))
|
|
.route("/system/prune/runs", post(prune_runs))
|
|
.route("/billing", get(get_aggregate_billing))
|
|
}
|
|
|
|
async fn not_implemented() -> Response {
|
|
ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response()
|
|
}
|
|
|
|
async fn github_webhook(
|
|
State(secret): State<Arc<[u8]>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> StatusCode {
|
|
let delivery_id = headers
|
|
.get("x-github-delivery")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("unknown");
|
|
|
|
let Some(signature) = headers
|
|
.get("x-hub-signature-256")
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
warn!(delivery = %delivery_id, "Webhook missing X-Hub-Signature-256 header");
|
|
return StatusCode::UNAUTHORIZED;
|
|
};
|
|
|
|
if !verify_signature(&secret, &body, signature) {
|
|
warn!(delivery = %delivery_id, "Webhook HMAC signature mismatch");
|
|
return StatusCode::UNAUTHORIZED;
|
|
}
|
|
|
|
let event_type = headers
|
|
.get("x-github-event")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("unknown");
|
|
|
|
if tracing::enabled!(tracing::Level::DEBUG) {
|
|
let (repo, action) = parse_event_metadata(&body);
|
|
debug!(
|
|
event = %event_type,
|
|
delivery = %delivery_id,
|
|
repo = %repo,
|
|
action = %action,
|
|
"Webhook received"
|
|
);
|
|
} else {
|
|
info!(
|
|
event = %event_type,
|
|
delivery = %delivery_id,
|
|
"Webhook received"
|
|
);
|
|
}
|
|
|
|
StatusCode::OK
|
|
}
|
|
|
|
async fn health() -> Response {
|
|
Json(serde_json::json!({
|
|
"status": "ok",
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn get_server_settings(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<settings_view::SettingsQuery>,
|
|
) -> Response {
|
|
let settings = state.settings.read().unwrap().clone();
|
|
match query.view {
|
|
settings_view::SettingsApiView::Layer => {
|
|
let redacted = settings_view::redact_for_api(&settings);
|
|
let mut value = match serde_json::to_value(&redacted) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
strip_nulls(&mut value);
|
|
(StatusCode::OK, Json(value)).into_response()
|
|
}
|
|
settings_view::SettingsApiView::Resolved => {
|
|
let resolved = match fabro_config::resolve(&settings) {
|
|
Ok(settings) => settings,
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("failed to resolve settings: {err:?}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
let mut value = match settings_view::redact_resolved_value(&resolved) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
strip_nulls(&mut value);
|
|
let mut response = (StatusCode::OK, Json(value)).into_response();
|
|
response.headers_mut().insert(
|
|
settings_view::RESOLVED_VIEW_HEADER_NAME,
|
|
HeaderValue::from_static(settings_view::RESOLVED_VIEW_HEADER_VALUE),
|
|
);
|
|
response
|
|
}
|
|
}
|
|
}
|
|
|
|
fn strip_nulls(value: &mut serde_json::Value) {
|
|
settings_view::strip_nulls(value);
|
|
}
|
|
|
|
async fn get_system_info(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Response {
|
|
let settings = state.settings.read().unwrap().clone();
|
|
let (total_runs, active_runs) = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
let active = runs
|
|
.values()
|
|
.filter(|run| {
|
|
matches!(
|
|
run.status,
|
|
RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
)
|
|
})
|
|
.count();
|
|
(runs.len(), active)
|
|
};
|
|
|
|
let response = SystemInfoResponse {
|
|
version: Some(FABRO_VERSION.to_string()),
|
|
git_sha: option_env!("FABRO_GIT_SHA").map(str::to_string),
|
|
build_date: option_env!("FABRO_BUILD_DATE").map(str::to_string),
|
|
profile: option_env!("FABRO_BUILD_PROFILE").map(str::to_string),
|
|
os: Some(std::env::consts::OS.to_string()),
|
|
arch: Some(std::env::consts::ARCH.to_string()),
|
|
storage_engine: Some("slatedb".to_string()),
|
|
storage_dir: Some(state.server_storage_dir().display().to_string()),
|
|
uptime_secs: Some(to_i64(state.started_at.elapsed().as_secs())),
|
|
runs: Some(SystemRunCounts {
|
|
total: Some(to_i64(total_runs)),
|
|
active: Some(to_i64(active_runs)),
|
|
}),
|
|
sandbox_provider: Some(system_sandbox_provider(&settings)),
|
|
features: Some(system_features(&settings)),
|
|
};
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
fn system_features(settings: &SettingsLayer) -> SystemFeatures {
|
|
let session_sandboxes =
|
|
fabro_config::resolve_features_from_file(settings).is_ok_and(|s| s.session_sandboxes);
|
|
let retros = fabro_config::resolve_run_from_file(settings).is_ok_and(|s| s.execution.retros);
|
|
SystemFeatures {
|
|
session_sandboxes: Some(session_sandboxes),
|
|
retros: Some(retros),
|
|
}
|
|
}
|
|
|
|
async fn get_system_df(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(params): Query<DfParams>,
|
|
) -> Response {
|
|
let storage_dir = state.server_storage_dir();
|
|
let summaries = match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(summaries) => summaries,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let response = match spawn_blocking(move || {
|
|
build_disk_usage_response(&summaries, &storage_dir, params.verbose)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(response)) => response,
|
|
Ok(Err(err)) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
async fn prune_runs(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(body): Json<PruneRunsRequest>,
|
|
) -> Response {
|
|
let storage_dir = state.server_storage_dir();
|
|
let summaries = match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(summaries) => summaries,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let dry_run = body.dry_run;
|
|
let body_for_plan = body.clone();
|
|
let prune_plan =
|
|
match spawn_blocking(move || build_prune_plan(&body_for_plan, &summaries, &storage_dir))
|
|
.await
|
|
{
|
|
Ok(Ok(plan)) => plan,
|
|
Ok(Err(err)) => {
|
|
return ApiError::new(StatusCode::BAD_REQUEST, err.to_string()).into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if dry_run {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(PruneRunsResponse {
|
|
dry_run: Some(true),
|
|
runs: Some(prune_plan.rows),
|
|
total_count: Some(to_i64(prune_plan.run_ids.len())),
|
|
total_size_bytes: Some(to_i64(prune_plan.total_size_bytes)),
|
|
deleted_count: Some(0),
|
|
freed_bytes: Some(0),
|
|
}),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
for run_id in &prune_plan.run_ids {
|
|
if let Err(response) = delete_run_internal(&state, *run_id, true).await {
|
|
return response;
|
|
}
|
|
}
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(PruneRunsResponse {
|
|
dry_run: Some(false),
|
|
runs: None,
|
|
total_count: Some(to_i64(prune_plan.run_ids.len())),
|
|
total_size_bytes: Some(to_i64(prune_plan.total_size_bytes)),
|
|
deleted_count: Some(to_i64(prune_plan.run_ids.len())),
|
|
freed_bytes: Some(to_i64(prune_plan.total_size_bytes)),
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn attach_events(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(params): Query<GlobalAttachParams>,
|
|
) -> Response {
|
|
let run_filter = match parse_global_run_filter(params.run_id.as_deref()) {
|
|
Ok(filter) => filter,
|
|
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
|
|
};
|
|
|
|
let stream =
|
|
BroadcastStream::new(state.global_event_tx.subscribe()).filter_map(move |result| {
|
|
match result {
|
|
Ok(event) => {
|
|
if !event_matches_run_filter(&event, run_filter.as_ref()) {
|
|
return None;
|
|
}
|
|
sse_event_from_store(&event).map(Ok::<Event, std::convert::Infallible>)
|
|
}
|
|
Err(_) => None,
|
|
}
|
|
});
|
|
|
|
Sse::new(stream)
|
|
.keep_alive(KeepAlive::default())
|
|
.into_response()
|
|
}
|
|
|
|
struct PrunePlan {
|
|
run_ids: Vec<RunId>,
|
|
rows: Vec<PruneRunEntry>,
|
|
total_size_bytes: u64,
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync helper invoked from async handler via spawn_blocking (see callers at :1301 / :1341)"
|
|
)]
|
|
fn build_disk_usage_response(
|
|
summaries: &[fabro_store::RunSummary],
|
|
storage_dir: &std::path::Path,
|
|
verbose: bool,
|
|
) -> anyhow::Result<DiskUsageResponse> {
|
|
let scratch_base_dir = scratch_base(storage_dir);
|
|
let logs_base_dir = Storage::new(storage_dir).logs_dir();
|
|
let runs = scan_runs_with_summaries(summaries, &scratch_base_dir)?;
|
|
|
|
let mut active_count = 0u64;
|
|
let mut total_run_size = 0u64;
|
|
let mut reclaimable_run_size = 0u64;
|
|
let mut run_rows = Vec::new();
|
|
|
|
for run in &runs {
|
|
let size = dir_size(&run.path);
|
|
total_run_size += size;
|
|
if run.status().is_active() {
|
|
active_count += 1;
|
|
} else {
|
|
reclaimable_run_size += size;
|
|
}
|
|
if verbose {
|
|
run_rows.push(DiskUsageRunRow {
|
|
run_id: Some(run.run_id().to_string()),
|
|
workflow_name: Some(run.workflow_name()),
|
|
status: Some(run.status().to_string()),
|
|
start_time: Some(run.start_time()),
|
|
size_bytes: Some(to_i64(size)),
|
|
reclaimable: Some(!run.status().is_active()),
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut log_count = 0u64;
|
|
let mut total_log_size = 0u64;
|
|
if let Ok(entries) = std::fs::read_dir(logs_base_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_file() || path.extension().is_none_or(|ext| ext != "log") {
|
|
continue;
|
|
}
|
|
if let Ok(metadata) = path.metadata() {
|
|
log_count += 1;
|
|
total_log_size += metadata.len();
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(DiskUsageResponse {
|
|
summary: vec![
|
|
DiskUsageSummaryRow {
|
|
type_: Some("runs".to_string()),
|
|
count: Some(to_i64(runs.len())),
|
|
active: Some(to_i64(active_count)),
|
|
size_bytes: Some(to_i64(total_run_size)),
|
|
reclaimable_bytes: Some(to_i64(reclaimable_run_size)),
|
|
},
|
|
DiskUsageSummaryRow {
|
|
type_: Some("logs".to_string()),
|
|
count: Some(to_i64(log_count)),
|
|
active: None,
|
|
size_bytes: Some(to_i64(total_log_size)),
|
|
reclaimable_bytes: Some(to_i64(total_log_size)),
|
|
},
|
|
],
|
|
total_size_bytes: Some(to_i64(total_run_size + total_log_size)),
|
|
total_reclaimable_bytes: Some(to_i64(reclaimable_run_size + total_log_size)),
|
|
runs: verbose.then_some(run_rows),
|
|
})
|
|
}
|
|
|
|
fn build_prune_plan(
|
|
request: &PruneRunsRequest,
|
|
summaries: &[fabro_store::RunSummary],
|
|
storage_dir: &std::path::Path,
|
|
) -> anyhow::Result<PrunePlan> {
|
|
let scratch_base_dir = scratch_base(storage_dir);
|
|
let runs = scan_runs_with_summaries(summaries, &scratch_base_dir)?;
|
|
let label_filters = request
|
|
.labels
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut filtered = filter_runs(
|
|
&runs,
|
|
request.before.as_deref(),
|
|
request.workflow.as_deref(),
|
|
&label_filters,
|
|
request.orphans,
|
|
StatusFilter::All,
|
|
);
|
|
|
|
let has_explicit_filters =
|
|
request.before.is_some() || request.workflow.is_some() || !label_filters.is_empty();
|
|
let staleness_threshold = if let Some(duration) = request.older_than.as_deref() {
|
|
Some(parse_system_duration(duration)?)
|
|
} else if !has_explicit_filters {
|
|
Some(chrono::Duration::hours(24))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(threshold) = staleness_threshold {
|
|
let cutoff = chrono::Utc::now() - threshold;
|
|
filtered.retain(|run| {
|
|
run.end_time
|
|
.or(run.start_time_dt)
|
|
.is_some_and(|time| time < cutoff)
|
|
});
|
|
}
|
|
|
|
filtered.retain(|run| !run.status().is_active());
|
|
|
|
let rows = filtered
|
|
.iter()
|
|
.map(|run| PruneRunEntry {
|
|
run_id: Some(run.run_id().to_string()),
|
|
dir_name: Some(run.dir_name.clone()),
|
|
workflow_name: Some(run.workflow_name()),
|
|
size_bytes: Some(to_i64(dir_size(&run.path))),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let total_size_bytes = rows
|
|
.iter()
|
|
.map(|row| row.size_bytes.unwrap_or_default())
|
|
.sum::<i64>()
|
|
.max(0)
|
|
.try_into()
|
|
.unwrap_or_default();
|
|
|
|
Ok(PrunePlan {
|
|
run_ids: filtered.iter().map(RunInfo::run_id).collect(),
|
|
rows,
|
|
total_size_bytes,
|
|
})
|
|
}
|
|
|
|
fn system_sandbox_provider(settings: &SettingsLayer) -> String {
|
|
fabro_config::resolve_run_from_file(settings).map_or_else(
|
|
|_| SandboxProvider::default().to_string(),
|
|
|settings| settings.sandbox.provider,
|
|
)
|
|
}
|
|
|
|
fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String {
|
|
errors
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
fn resolved_storage_dir(settings: &SettingsLayer) -> Result<PathBuf, String> {
|
|
let resolved =
|
|
resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?;
|
|
resolved
|
|
.storage
|
|
.root
|
|
.resolve(|name| std::env::var(name).ok())
|
|
.map(|value| PathBuf::from(value.value))
|
|
.map_err(|err| {
|
|
format!(
|
|
"failed to resolve {}: {err}",
|
|
resolved.storage.root.as_source()
|
|
)
|
|
})
|
|
}
|
|
|
|
fn resolved_github_settings(settings: &SettingsLayer) -> Result<GithubIntegrationSettings, String> {
|
|
let resolved =
|
|
resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?;
|
|
Ok(resolved.integrations.github)
|
|
}
|
|
|
|
fn parse_system_duration(raw: &str) -> anyhow::Result<chrono::Duration> {
|
|
let raw = raw.trim();
|
|
anyhow::ensure!(!raw.is_empty(), "empty duration string");
|
|
let (num_str, unit) = raw.split_at(raw.len().saturating_sub(1));
|
|
let amount = num_str.parse::<u64>()?;
|
|
match unit {
|
|
"h" => Ok(chrono::Duration::hours(
|
|
i64::try_from(amount).unwrap_or(i64::MAX),
|
|
)),
|
|
"d" => Ok(chrono::Duration::days(
|
|
i64::try_from(amount).unwrap_or(i64::MAX),
|
|
)),
|
|
_ => anyhow::bail!("invalid duration unit '{unit}' in '{raw}' (expected 'h' or 'd')"),
|
|
}
|
|
}
|
|
|
|
fn parse_global_run_filter(raw: Option<&str>) -> Result<Option<HashSet<RunId>>, String> {
|
|
let Some(raw) = raw else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let mut run_ids = HashSet::new();
|
|
for part in raw
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|part| !part.is_empty())
|
|
{
|
|
let run_id = part
|
|
.parse::<RunId>()
|
|
.map_err(|err| format!("invalid run_id '{part}': {err}"))?;
|
|
run_ids.insert(run_id);
|
|
}
|
|
|
|
if run_ids.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(run_ids))
|
|
}
|
|
}
|
|
|
|
fn event_matches_run_filter(event: &EventEnvelope, run_filter: Option<&HashSet<RunId>>) -> bool {
|
|
let Some(run_filter) = run_filter else {
|
|
return true;
|
|
};
|
|
let Some(run_id) = event
|
|
.payload
|
|
.as_value()
|
|
.get("run_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|value| value.parse::<RunId>().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
run_filter.contains(&run_id)
|
|
}
|
|
|
|
fn sse_event_from_store(event: &EventEnvelope) -> Option<Event> {
|
|
let data = serde_json::to_string(event).ok()?;
|
|
let data = redact_jsonl_line(&data);
|
|
Some(Event::default().data(data))
|
|
}
|
|
|
|
fn attach_event_is_terminal(event: &EventEnvelope) -> bool {
|
|
let Ok(run_event) = RunEvent::try_from(&event.payload) else {
|
|
return false;
|
|
};
|
|
matches!(
|
|
run_event.body,
|
|
EventBody::RunCompleted(_) | EventBody::RunFailed(_)
|
|
)
|
|
}
|
|
|
|
fn run_projection_is_active(state: &fabro_store::RunProjection) -> bool {
|
|
state
|
|
.status
|
|
.as_ref()
|
|
.is_some_and(|record| record.status.is_active())
|
|
}
|
|
|
|
fn dir_size(path: &std::path::Path) -> u64 {
|
|
walkdir::WalkDir::new(path)
|
|
.into_iter()
|
|
.filter_map(std::result::Result::ok)
|
|
.filter_map(|entry| entry.metadata().ok())
|
|
.filter(std::fs::Metadata::is_file)
|
|
.map(|metadata| metadata.len())
|
|
.sum()
|
|
}
|
|
|
|
fn to_i64<T>(value: T) -> i64
|
|
where
|
|
i64: TryFrom<T>,
|
|
{
|
|
i64::try_from(value).unwrap_or(i64::MAX)
|
|
}
|
|
|
|
async fn list_secrets(_auth: AuthenticatedService, State(state): State<Arc<AppState>>) -> Response {
|
|
let data = state.vault.read().await.list();
|
|
(StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response()
|
|
}
|
|
|
|
fn secret_type_from_api(secret_type: ApiSecretType) -> SecretType {
|
|
match secret_type {
|
|
ApiSecretType::Environment => SecretType::Environment,
|
|
ApiSecretType::File => SecretType::File,
|
|
ApiSecretType::Credential => SecretType::Credential,
|
|
}
|
|
}
|
|
|
|
async fn create_secret(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(body): Json<CreateSecretRequest>,
|
|
) -> Response {
|
|
let secret_type = secret_type_from_api(body.type_);
|
|
let name = body.name;
|
|
let value = body.value;
|
|
let description = body.description;
|
|
if secret_type == SecretType::Credential {
|
|
if let Err(err) = parse_credential_secret(&name, &value) {
|
|
return ApiError::bad_request(err).into_response();
|
|
}
|
|
}
|
|
let state_for_write = Arc::clone(&state);
|
|
let result = spawn_blocking(move || {
|
|
let mut vault = state_for_write.vault.blocking_write();
|
|
vault.set(&name, &value, secret_type, description.as_deref())
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(meta)) => (StatusCode::OK, Json(meta)).into_response(),
|
|
Ok(Err(VaultError::InvalidName(_))) => {
|
|
ApiError::bad_request("invalid secret name").into_response()
|
|
}
|
|
Ok(Err(VaultError::Io(err))) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
Ok(Err(VaultError::Serde(err))) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
Ok(Err(VaultError::NotFound(_))) => ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"secret unexpectedly missing",
|
|
)
|
|
.into_response(),
|
|
Err(err) => ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("secret write task failed: {err}"),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn delete_secret_by_name(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(body): Json<DeleteSecretRequest>,
|
|
) -> Response {
|
|
let name = body.name;
|
|
let state_for_write = Arc::clone(&state);
|
|
let result = spawn_blocking(move || {
|
|
let mut vault = state_for_write.vault.blocking_write();
|
|
vault.remove(&name)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
|
Ok(Err(VaultError::InvalidName(_))) => {
|
|
ApiError::bad_request("invalid secret name").into_response()
|
|
}
|
|
Ok(Err(VaultError::NotFound(name))) => {
|
|
ApiError::new(StatusCode::NOT_FOUND, format!("secret not found: {name}"))
|
|
.into_response()
|
|
}
|
|
Ok(Err(VaultError::Io(err))) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
Ok(Err(VaultError::Serde(err))) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
Err(err) => ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("secret delete task failed: {err}"),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct GitHubRepoResponse {
|
|
default_branch: String,
|
|
private: bool,
|
|
permissions: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Reject owner/repo path segments that could rewrite the GitHub API endpoint
|
|
/// via `..` traversal after URL normalization. Conservative compared to
|
|
/// GitHub's real rules, which is fine for server-side input validation.
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "GitHub slug validation returns HTTP 400 responses directly."
|
|
)]
|
|
fn validate_github_slug(kind: &str, value: &str, max_len: usize) -> Result<(), Response> {
|
|
if value.is_empty() || value.len() > max_len || matches!(value, "." | "..") {
|
|
return Err(ApiError::bad_request(format!("invalid github {kind}")).into_response());
|
|
}
|
|
if !value
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
|
|
{
|
|
return Err(ApiError::bad_request(format!("invalid github {kind}")).into_response());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_github_repo(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path((owner, name)): Path<(String, String)>,
|
|
) -> Response {
|
|
if let Err(response) = validate_github_slug("owner", &owner, 39) {
|
|
return response;
|
|
}
|
|
if let Err(response) = validate_github_slug("repo", &name, 100) {
|
|
return response;
|
|
}
|
|
let settings = state.server_settings();
|
|
let github_settings = &settings.integrations.github;
|
|
let base_url = fabro_github::github_api_base_url();
|
|
let mut client: Option<fabro_http::HttpClient> = None;
|
|
let token = match github_settings.strategy {
|
|
GithubIntegrationStrategy::App => {
|
|
let Some(app_id) = github_settings.app_id.as_ref() else {
|
|
return ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"server.integrations.github.app_id is not configured",
|
|
)
|
|
.into_response();
|
|
};
|
|
if let Err(err) = resolve_interp_string(app_id) {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
.into_response();
|
|
}
|
|
let creds = match state.github_credentials(github_settings) {
|
|
Ok(Some(fabro_github::GitHubCredentials::App(creds))) => creds,
|
|
Ok(Some(_)) => unreachable!("app strategy should not return token credentials"),
|
|
Ok(None) => {
|
|
return ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"GITHUB_APP_PRIVATE_KEY is not configured",
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err).into_response();
|
|
}
|
|
};
|
|
|
|
let jwt = match fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) {
|
|
Ok(jwt) => jwt,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err).into_response();
|
|
}
|
|
};
|
|
let install_url = match github_settings.slug.as_ref() {
|
|
Some(slug) => match resolve_interp_string(slug) {
|
|
Ok(slug) => format!("https://github.com/apps/{slug}/installations/new"),
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
.into_response();
|
|
}
|
|
},
|
|
None => format!("https://github.com/organizations/{owner}/settings/installations"),
|
|
};
|
|
|
|
if client.is_none() {
|
|
client = Some(match state.http_client() {
|
|
Ok(http) => http,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
.into_response();
|
|
}
|
|
});
|
|
}
|
|
let client_ref = client.as_ref().expect("client initialized above");
|
|
let installed =
|
|
match fabro_github::check_app_installed(client_ref, &jwt, &owner, &name, &base_url)
|
|
.await
|
|
{
|
|
Ok(installed) => installed,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::BAD_GATEWAY, err).into_response();
|
|
}
|
|
};
|
|
|
|
if !installed {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"owner": owner,
|
|
"name": name,
|
|
"accessible": false,
|
|
"default_branch": null,
|
|
"private": null,
|
|
"permissions": null,
|
|
"install_url": install_url,
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
match fabro_github::create_installation_access_token_with_permissions(
|
|
client_ref,
|
|
&jwt,
|
|
&owner,
|
|
&name,
|
|
&base_url,
|
|
serde_json::json!({ "contents": "write", "pull_requests": "write" }),
|
|
)
|
|
.await
|
|
{
|
|
Ok(token) => token,
|
|
Err(err) => return ApiError::new(StatusCode::BAD_GATEWAY, err).into_response(),
|
|
}
|
|
}
|
|
GithubIntegrationStrategy::Token => match state.github_credentials(github_settings) {
|
|
Ok(Some(fabro_github::GitHubCredentials::Token(token))) => token,
|
|
Ok(Some(_)) => unreachable!("token strategy should not return app credentials"),
|
|
Ok(None) => {
|
|
return ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"GITHUB_TOKEN is not configured",
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err).into_response();
|
|
}
|
|
},
|
|
};
|
|
|
|
let client = match client {
|
|
Some(client) => client,
|
|
None => match state.http_client() {
|
|
Ok(http) => http,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
|
.into_response();
|
|
}
|
|
},
|
|
};
|
|
let repo_response = match client
|
|
.get(format!("{base_url}/repos/{owner}/{name}"))
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", "fabro-server")
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(response) if response.status().is_success() => response,
|
|
Ok(response)
|
|
if github_settings.strategy == GithubIntegrationStrategy::Token
|
|
&& matches!(
|
|
response.status(),
|
|
fabro_http::StatusCode::FORBIDDEN | fabro_http::StatusCode::NOT_FOUND
|
|
) =>
|
|
{
|
|
return (
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"owner": owner,
|
|
"name": name,
|
|
"accessible": false,
|
|
"default_branch": null,
|
|
"private": null,
|
|
"permissions": null,
|
|
"install_url": serde_json::Value::Null,
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Ok(response)
|
|
if github_settings.strategy == GithubIntegrationStrategy::Token
|
|
&& response.status() == fabro_http::StatusCode::UNAUTHORIZED =>
|
|
{
|
|
return ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Stored GitHub token is invalid — run fabro install or update GITHUB_TOKEN",
|
|
)
|
|
.into_response();
|
|
}
|
|
Ok(response) => {
|
|
return ApiError::new(
|
|
StatusCode::BAD_GATEWAY,
|
|
format!("GitHub repo lookup failed: {}", response.status()),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(err) => return ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(),
|
|
};
|
|
|
|
let repo = match repo_response.json::<GitHubRepoResponse>().await {
|
|
Ok(repo) => repo,
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::BAD_GATEWAY,
|
|
format!("Failed to parse GitHub repo response: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"owner": owner,
|
|
"name": name,
|
|
"accessible": true,
|
|
"default_branch": repo.default_branch,
|
|
"private": repo.private,
|
|
"permissions": repo.permissions,
|
|
"install_url": serde_json::Value::Null,
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn run_diagnostics(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Response {
|
|
(
|
|
StatusCode::OK,
|
|
Json(diagnostics::run_all(state.as_ref()).await),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn openapi_spec() -> Response {
|
|
let yaml = include_str!("../../../../docs/api-reference/fabro-api.yaml");
|
|
let value: serde_json::Value =
|
|
serde_yaml::from_str(yaml).expect("embedded OpenAPI YAML is invalid");
|
|
Json(value).into_response()
|
|
}
|
|
|
|
async fn cookie_and_demo_middleware(
|
|
State(state): State<Arc<AppState>>,
|
|
mut req: axum_extract::Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let cookies = web_auth::parse_cookie_header(req.headers());
|
|
if cookies
|
|
.get("fabro-demo")
|
|
.is_some_and(|cookie| cookie.value() == "1")
|
|
{
|
|
req.headers_mut()
|
|
.insert("x-fabro-demo", HeaderValue::from_static("1"));
|
|
}
|
|
if let Some(key) = state.session_key() {
|
|
if let Some(session) = web_auth::read_private_session(req.headers(), &key) {
|
|
req.extensions_mut().insert(session);
|
|
}
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
async fn get_aggregate_billing(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Response {
|
|
let agg = state
|
|
.aggregate_billing
|
|
.lock()
|
|
.expect("aggregate_billing lock poisoned");
|
|
let by_model: Vec<BillingByModel> = agg
|
|
.by_model
|
|
.iter()
|
|
.map(|(model, totals)| BillingByModel {
|
|
billing: api_billed_token_counts_from_domain(&totals.billing),
|
|
model: ModelReference { id: model.clone() },
|
|
stages: totals.stages,
|
|
})
|
|
.collect();
|
|
let total_billing = by_model
|
|
.iter()
|
|
.fold(BilledTokenCounts::default(), |mut acc, model| {
|
|
acc.input_tokens += model.billing.input_tokens;
|
|
acc.output_tokens += model.billing.output_tokens;
|
|
acc.reasoning_tokens += model.billing.reasoning_tokens.unwrap_or(0);
|
|
acc.cache_read_tokens += model.billing.cache_read_tokens.unwrap_or(0);
|
|
acc.cache_write_tokens += model.billing.cache_write_tokens.unwrap_or(0);
|
|
acc.total_tokens += model.billing.total_tokens;
|
|
if let Some(value) = model.billing.total_usd_micros {
|
|
*acc.total_usd_micros.get_or_insert(0) += value;
|
|
}
|
|
acc
|
|
});
|
|
let response = AggregateBilling {
|
|
totals: AggregateBillingTotals {
|
|
cache_read_tokens: nonzero_i64(total_billing.cache_read_tokens),
|
|
cache_write_tokens: nonzero_i64(total_billing.cache_write_tokens),
|
|
input_tokens: total_billing.input_tokens,
|
|
output_tokens: total_billing.output_tokens,
|
|
reasoning_tokens: nonzero_i64(total_billing.reasoning_tokens),
|
|
runs: agg.total_runs,
|
|
runtime_secs: agg.total_runtime_secs,
|
|
total_tokens: total_billing.total_tokens,
|
|
total_usd_micros: total_billing.total_usd_micros,
|
|
},
|
|
by_model,
|
|
};
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
async fn list_run_stages(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(_pagination): Query<PaginationParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
// Try live run first.
|
|
let (checkpoint, run_is_active) = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get(&id) {
|
|
Some(managed_run) => {
|
|
let active = !matches!(
|
|
managed_run.status,
|
|
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
|
|
);
|
|
(managed_run.checkpoint.clone(), active)
|
|
}
|
|
None => (None, false),
|
|
}
|
|
};
|
|
|
|
// Fall back to stored run.
|
|
let (checkpoint, run_is_active) = if checkpoint.is_some() {
|
|
(checkpoint, run_is_active)
|
|
} else {
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => {
|
|
let active = run_state
|
|
.status
|
|
.as_ref()
|
|
.is_some_and(|s| !s.status.is_terminal());
|
|
(run_state.checkpoint, active)
|
|
}
|
|
Err(_) => (None, false),
|
|
},
|
|
Err(_) => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
|
|
let Some(checkpoint) = checkpoint else {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(ListResponse::new(Vec::<RunStage>::new())),
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
// Get durations from events.
|
|
let stage_durations = match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.list_events().await {
|
|
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
|
|
Err(_) => HashMap::new(),
|
|
},
|
|
Err(_) => HashMap::new(),
|
|
};
|
|
|
|
let mut stages = Vec::new();
|
|
for node_id in &checkpoint.completed_nodes {
|
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
|
let status = match checkpoint.node_outcomes.get(node_id) {
|
|
Some(outcome) => {
|
|
use fabro_types::outcome::StageStatus;
|
|
match outcome.status {
|
|
StageStatus::Success | StageStatus::PartialSuccess => ApiStageStatus::Completed,
|
|
StageStatus::Fail => ApiStageStatus::Failed,
|
|
StageStatus::Skipped => ApiStageStatus::Cancelled,
|
|
StageStatus::Retry => ApiStageStatus::Pending,
|
|
}
|
|
}
|
|
None => ApiStageStatus::Completed,
|
|
};
|
|
stages.push(RunStage {
|
|
id: node_id.clone(),
|
|
name: node_id.clone(),
|
|
status,
|
|
duration_secs: Some(duration_ms as f64 / 1000.0),
|
|
dot_id: Some(node_id.clone()),
|
|
});
|
|
}
|
|
|
|
// Add next node as running if the run is still active.
|
|
// The checkpoint's current_node is the last *completed* stage; next_node_id
|
|
// is the stage that is currently executing.
|
|
if let Some(next_id) = &checkpoint.next_node_id {
|
|
if run_is_active && next_id != "exit" && !checkpoint.completed_nodes.contains(next_id) {
|
|
stages.push(RunStage {
|
|
id: next_id.clone(),
|
|
name: next_id.clone(),
|
|
status: ApiStageStatus::Running,
|
|
duration_secs: None,
|
|
dot_id: Some(next_id.clone()),
|
|
});
|
|
}
|
|
}
|
|
|
|
(StatusCode::OK, Json(ListResponse::new(stages))).into_response()
|
|
}
|
|
|
|
async fn get_run_billing(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<RunId>,
|
|
) -> Response {
|
|
let run_store = match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => run_store,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::NOT_FOUND, err.to_string()).into_response();
|
|
}
|
|
};
|
|
|
|
let checkpoint = match run_store.state().await {
|
|
Ok(state) => state.checkpoint,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let Some(checkpoint) = checkpoint else {
|
|
let empty = RunBilling {
|
|
by_model: Vec::new(),
|
|
stages: Vec::new(),
|
|
totals: RunBillingTotals {
|
|
cache_read_tokens: None,
|
|
cache_write_tokens: None,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
reasoning_tokens: None,
|
|
runtime_secs: 0.0,
|
|
total_tokens: 0,
|
|
total_usd_micros: None,
|
|
},
|
|
};
|
|
return (StatusCode::OK, Json(empty)).into_response();
|
|
};
|
|
|
|
let stage_durations = match run_store.list_events().await {
|
|
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let mut by_model_totals = HashMap::<String, ModelBillingTotals>::new();
|
|
let mut billed_usages = Vec::new();
|
|
let mut runtime_secs = 0.0_f64;
|
|
let mut stages = Vec::new();
|
|
|
|
for node_id in &checkpoint.completed_nodes {
|
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
|
runtime_secs += duration_ms as f64 / 1000.0;
|
|
|
|
let Some(usage) = checkpoint
|
|
.node_outcomes
|
|
.get(node_id)
|
|
.and_then(|outcome| outcome.usage.as_ref())
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
billed_usages.push(usage.clone());
|
|
let billing = api_billed_token_counts_from_usage(usage);
|
|
let model_id = usage.model_id().to_string();
|
|
accumulate_model_billing(by_model_totals.entry(model_id.clone()).or_default(), usage);
|
|
stages.push(RunBillingStage {
|
|
billing,
|
|
model: ModelReference { id: model_id },
|
|
runtime_secs: duration_ms as f64 / 1000.0,
|
|
stage: BillingStageRef {
|
|
id: node_id.clone(),
|
|
name: node_id.clone(),
|
|
},
|
|
});
|
|
}
|
|
|
|
let totals = BilledTokenCounts::from_billed_usage(&billed_usages);
|
|
let by_model = by_model_totals
|
|
.into_iter()
|
|
.map(|(model, totals)| BillingByModel {
|
|
billing: api_billed_token_counts_from_domain(&totals.billing),
|
|
model: ModelReference { id: model },
|
|
stages: totals.stages,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let response = RunBilling {
|
|
by_model,
|
|
stages,
|
|
totals: RunBillingTotals {
|
|
cache_read_tokens: nonzero_i64(totals.cache_read_tokens),
|
|
cache_write_tokens: nonzero_i64(totals.cache_write_tokens),
|
|
input_tokens: totals.input_tokens,
|
|
output_tokens: totals.output_tokens,
|
|
reasoning_tokens: nonzero_i64(totals.reasoning_tokens),
|
|
runtime_secs,
|
|
total_tokens: totals.total_tokens,
|
|
total_usd_micros: totals.total_usd_micros,
|
|
},
|
|
};
|
|
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
/// Create an `AppState` with default settings.
|
|
pub fn create_app_state() -> Arc<AppState> {
|
|
create_app_state_with_options(SettingsLayer::default(), 5)
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn create_app_state_with_registry_factory(
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
create_app_state_with_options_and_registry_factory(
|
|
SettingsLayer::default(),
|
|
5,
|
|
registry_factory_override,
|
|
)
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn create_app_state_with_settings_and_registry_factory(
|
|
settings: SettingsLayer,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
create_app_state_with_options_and_registry_factory(settings, 5, registry_factory_override)
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn create_app_state_with_options_and_registry_factory(
|
|
settings: SettingsLayer,
|
|
max_concurrent_runs: usize,
|
|
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
let env_lookup = default_env_lookup();
|
|
let mut config = default_test_app_state_config(
|
|
Arc::new(RwLock::new(settings)),
|
|
max_concurrent_runs,
|
|
env_lookup,
|
|
);
|
|
config.registry_factory_override = Some(Box::new(registry_factory_override));
|
|
build_app_state(config).expect("test app state should build")
|
|
}
|
|
|
|
/// Create an `AppState` with the given settings and concurrency limit.
|
|
pub fn create_app_state_with_options(
|
|
settings: SettingsLayer,
|
|
max_concurrent_runs: usize,
|
|
) -> Arc<AppState> {
|
|
let env_lookup = default_env_lookup();
|
|
build_app_state(default_test_app_state_config(
|
|
Arc::new(RwLock::new(settings)),
|
|
max_concurrent_runs,
|
|
env_lookup,
|
|
))
|
|
.expect("test app state should build")
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn create_app_state_with_env_lookup(
|
|
settings: SettingsLayer,
|
|
max_concurrent_runs: usize,
|
|
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
|
) -> Arc<AppState> {
|
|
let (store, artifact_store) = test_store_bundle();
|
|
let env_lookup: EnvLookup = Arc::new(env_lookup);
|
|
let mut config = default_test_app_state_config(
|
|
Arc::new(RwLock::new(settings)),
|
|
max_concurrent_runs,
|
|
env_lookup,
|
|
);
|
|
config.store = store;
|
|
config.artifact_store = artifact_store;
|
|
build_app_state(config).expect("test app state should build")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "test helper writes a fixture server.env with sync std::fs::write"
|
|
)]
|
|
pub(crate) fn create_test_app_state_with_session_key(
|
|
settings: SettingsLayer,
|
|
session_secret: Option<&str>,
|
|
local_daemon_mode: bool,
|
|
) -> Arc<AppState> {
|
|
let vault_path = test_secret_store_path();
|
|
let server_env_path = vault_path
|
|
.parent()
|
|
.expect("test secrets path should have parent")
|
|
.join("server.env");
|
|
if let Some(session_secret) = session_secret {
|
|
std::fs::write(
|
|
&server_env_path,
|
|
format!("SESSION_SECRET={session_secret}\n"),
|
|
)
|
|
.expect("test server env should be writable");
|
|
}
|
|
let (store, artifact_store) = test_store_bundle();
|
|
let env_lookup = default_env_lookup();
|
|
build_app_state(AppStateConfig {
|
|
settings: Arc::new(RwLock::new(settings)),
|
|
registry_factory_override: None,
|
|
max_concurrent_runs: 5,
|
|
store,
|
|
artifact_store,
|
|
vault_path,
|
|
server_env_path,
|
|
local_daemon_mode,
|
|
env_lookup,
|
|
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
|
})
|
|
.expect("test app state should build")
|
|
}
|
|
|
|
fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
|
|
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(MemoryObjectStore::new());
|
|
let store = Arc::new(fabro_store::Database::new(
|
|
Arc::clone(&object_store),
|
|
"",
|
|
Duration::from_millis(1),
|
|
None,
|
|
));
|
|
let artifact_store = ArtifactStore::new(object_store, "artifacts");
|
|
(store, artifact_store)
|
|
}
|
|
|
|
fn default_test_app_state_config(
|
|
settings: Arc<RwLock<SettingsLayer>>,
|
|
max_concurrent_runs: usize,
|
|
env_lookup: EnvLookup,
|
|
) -> AppStateConfig {
|
|
let (store, artifact_store) = test_store_bundle();
|
|
let vault_path = test_secret_store_path();
|
|
let server_env_path = vault_path.with_file_name("server.env");
|
|
AppStateConfig {
|
|
settings,
|
|
registry_factory_override: None,
|
|
max_concurrent_runs,
|
|
store,
|
|
artifact_store,
|
|
vault_path,
|
|
server_env_path,
|
|
local_daemon_mode: false,
|
|
env_lookup,
|
|
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
|
}
|
|
}
|
|
|
|
pub fn create_app_state_with_store(
|
|
settings: Arc<RwLock<SettingsLayer>>,
|
|
max_concurrent_runs: usize,
|
|
store: Arc<Database>,
|
|
artifact_store: ArtifactStore,
|
|
) -> Arc<AppState> {
|
|
let env_lookup = default_env_lookup();
|
|
create_app_state_with_store_and_env_lookup(
|
|
settings,
|
|
max_concurrent_runs,
|
|
store,
|
|
artifact_store,
|
|
&env_lookup,
|
|
)
|
|
}
|
|
|
|
fn create_app_state_with_store_and_env_lookup(
|
|
settings: Arc<RwLock<SettingsLayer>>,
|
|
max_concurrent_runs: usize,
|
|
store: Arc<Database>,
|
|
artifact_store: ArtifactStore,
|
|
env_lookup: &EnvLookup,
|
|
) -> Arc<AppState> {
|
|
let mut config =
|
|
default_test_app_state_config(settings, max_concurrent_runs, Arc::clone(env_lookup));
|
|
config.store = store;
|
|
config.artifact_store = artifact_store;
|
|
build_app_state(config).expect("test app state should build")
|
|
}
|
|
|
|
fn default_env_lookup() -> EnvLookup {
|
|
Arc::new(|name| std::env::var(name).ok())
|
|
}
|
|
|
|
pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppState>> {
|
|
let AppStateConfig {
|
|
settings,
|
|
registry_factory_override,
|
|
max_concurrent_runs,
|
|
store,
|
|
artifact_store,
|
|
vault_path,
|
|
server_env_path,
|
|
local_daemon_mode,
|
|
env_lookup,
|
|
http_client,
|
|
} = config;
|
|
|
|
let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path)?));
|
|
let server_secrets = ServerSecrets::with_env_lookup(server_env_path, {
|
|
let env_lookup = Arc::clone(&env_lookup);
|
|
move |name| env_lookup(name)
|
|
})?;
|
|
let provider_credentials = ProviderCredentials::with_env_lookup(Arc::clone(&vault), {
|
|
let env_lookup = Arc::clone(&env_lookup);
|
|
move |name| env_lookup(name)
|
|
});
|
|
let (global_event_tx, _) = broadcast::channel(4096);
|
|
let resolved_server_settings = {
|
|
let settings = settings.read().expect("settings lock poisoned");
|
|
Arc::new(resolve_server_from_file(&settings).map_err(|errors| {
|
|
anyhow::anyhow!(
|
|
"failed to resolve server settings:\n{}",
|
|
errors
|
|
.into_iter()
|
|
.map(|error| error.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
})?)
|
|
};
|
|
let slack_service = {
|
|
resolved_server_settings
|
|
.integrations
|
|
.slack
|
|
.default_channel
|
|
.as_ref()
|
|
.map(|value| {
|
|
value
|
|
.resolve(|name| std::env::var(name).ok())
|
|
.map(|resolved| resolved.value)
|
|
.map_err(anyhow::Error::from)
|
|
})
|
|
.transpose()?
|
|
.and_then(|default_channel| {
|
|
resolve_slack_credentials().map(|credentials| {
|
|
Arc::new(SlackService::new(
|
|
credentials.bot_token,
|
|
credentials.app_token,
|
|
default_channel,
|
|
))
|
|
})
|
|
})
|
|
};
|
|
Ok(Arc::new(AppState {
|
|
runs: Mutex::new(HashMap::new()),
|
|
aggregate_billing: Mutex::new(BillingAccumulator::default()),
|
|
store,
|
|
artifact_store,
|
|
artifact_upload_tokens: artifact_upload_token_keys(),
|
|
started_at: Instant::now(),
|
|
max_concurrent_runs,
|
|
scheduler_notify: Notify::new(),
|
|
global_event_tx,
|
|
files_in_flight: new_files_in_flight(),
|
|
vault,
|
|
server_secrets,
|
|
provider_credentials,
|
|
settings,
|
|
server_settings: RwLock::new(resolved_server_settings),
|
|
local_daemon_mode,
|
|
http_client,
|
|
shutting_down: AtomicBool::new(false),
|
|
registry_factory_override,
|
|
slack_service,
|
|
slack_started: AtomicBool::new(false),
|
|
}))
|
|
}
|
|
|
|
fn test_secret_store_path() -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("fabro-test-{}", Ulid::new()));
|
|
std::fs::create_dir_all(&dir).expect("test temp dir should be creatable");
|
|
dir.join("secrets.json")
|
|
}
|
|
|
|
fn board_column(status: WorkflowRunStatus) -> Option<&'static str> {
|
|
match status {
|
|
WorkflowRunStatus::Submitted | WorkflowRunStatus::Queued | WorkflowRunStatus::Starting => {
|
|
Some("initializing")
|
|
}
|
|
WorkflowRunStatus::Running | WorkflowRunStatus::Paused => Some("running"),
|
|
WorkflowRunStatus::Blocked => Some("blocked"),
|
|
WorkflowRunStatus::Succeeded => Some("succeeded"),
|
|
WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => Some("failed"),
|
|
WorkflowRunStatus::Removing | WorkflowRunStatus::Archived => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn board_columns() -> serde_json::Value {
|
|
serde_json::json!([
|
|
{"id": "initializing", "name": "Initializing"},
|
|
{"id": "running", "name": "Running"},
|
|
{"id": "blocked", "name": "Blocked"},
|
|
{"id": "succeeded", "name": "Succeeded"},
|
|
{"id": "failed", "name": "Failed"},
|
|
])
|
|
}
|
|
|
|
pub(crate) fn truncate_goal(goal: &str) -> String {
|
|
const MAX_LEN: usize = 100;
|
|
|
|
let stripped = strip_goal_decoration(goal);
|
|
let char_count = stripped.chars().count();
|
|
if char_count <= MAX_LEN {
|
|
return stripped.to_string();
|
|
}
|
|
|
|
let truncated: String = stripped.chars().take(MAX_LEN - 3).collect();
|
|
format!("{truncated}...")
|
|
}
|
|
|
|
fn repository_name(host_repo_path: Option<&str>) -> String {
|
|
host_repo_path
|
|
.and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()))
|
|
.unwrap_or("unknown")
|
|
.to_string()
|
|
}
|
|
|
|
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
|
|
duration_ms.map(|ms| ms as f64 / 1000.0)
|
|
}
|
|
|
|
fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::Value {
|
|
let goal = summary.goal.unwrap_or_default();
|
|
let title = truncate_goal(&goal);
|
|
let repository = repository_name(summary.host_repo_path.as_deref());
|
|
let created_at = summary.run_id.created_at().to_rfc3339();
|
|
|
|
serde_json::json!({
|
|
"run_id": summary.run_id.to_string(),
|
|
"workflow_name": summary.workflow_name,
|
|
"workflow_slug": summary.workflow_slug,
|
|
"goal": goal,
|
|
"title": title,
|
|
"labels": summary.labels,
|
|
"host_repo_path": summary.host_repo_path,
|
|
"repository": { "name": repository },
|
|
"start_time": summary.start_time.map(|time| time.to_rfc3339()),
|
|
"status": summary.status,
|
|
"status_reason": summary.status_reason.map(api_status_reason),
|
|
"blocked_reason": summary.blocked_reason.map(api_blocked_reason),
|
|
"pending_control": summary.pending_control.map(api_pending_control),
|
|
"duration_ms": summary.duration_ms,
|
|
"elapsed_secs": elapsed_secs(summary.duration_ms),
|
|
"total_usd_micros": summary.total_usd_micros,
|
|
"created_at": created_at,
|
|
})
|
|
}
|
|
|
|
async fn board_run_metadata(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut metadata = serde_json::Map::new();
|
|
let Ok(run_store) = state.store.open_run_reader(&run_id).await else {
|
|
return metadata;
|
|
};
|
|
let Ok(run_state) = run_store.state().await else {
|
|
return metadata;
|
|
};
|
|
|
|
if let Some(pull_request) = run_state.pull_request {
|
|
metadata.insert(
|
|
"pull_request".to_string(),
|
|
serde_json::json!({
|
|
"number": pull_request.number,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if let Some(sandbox) = run_state.sandbox {
|
|
if let Some(identifier) = sandbox.identifier {
|
|
metadata.insert(
|
|
"sandbox".to_string(),
|
|
serde_json::json!({
|
|
"id": identifier,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some((_, record)) =
|
|
run_state
|
|
.pending_interviews
|
|
.iter()
|
|
.min_by(|(left_id, left), (right_id, right)| {
|
|
left.started_at
|
|
.cmp(&right.started_at)
|
|
.then_with(|| left_id.cmp(right_id))
|
|
})
|
|
{
|
|
metadata.insert(
|
|
"question".to_string(),
|
|
serde_json::json!({
|
|
"text": record.question.text,
|
|
}),
|
|
);
|
|
}
|
|
|
|
metadata
|
|
}
|
|
|
|
const MAX_PAGE_OFFSET: u32 = 1_000_000;
|
|
|
|
fn paginate_items<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, bool) {
|
|
let limit = pagination.limit.clamp(1, 100) as usize;
|
|
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;
|
|
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
|
|
let has_more = data.len() > limit;
|
|
data.truncate(limit);
|
|
(data, has_more)
|
|
}
|
|
|
|
async fn list_board_runs(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(pagination): Query<PaginationParams>,
|
|
) -> Response {
|
|
let summaries = match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(runs) => runs,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let board_summaries: Vec<_> = summaries
|
|
.into_iter()
|
|
.filter_map(|summary| {
|
|
let column = board_column(summary.status)?;
|
|
Some((summary, column))
|
|
})
|
|
.collect();
|
|
let (page_summaries, has_more) = paginate_items(board_summaries, &pagination);
|
|
|
|
let mut data = Vec::with_capacity(page_summaries.len());
|
|
for (summary, column) in page_summaries {
|
|
let run_id = summary.run_id;
|
|
let mut item = summary_to_api_run_summary(summary);
|
|
item["column"] = serde_json::json!(column);
|
|
if let Some(object) = item.as_object_mut() {
|
|
object.extend(board_run_metadata(state.as_ref(), run_id).await);
|
|
}
|
|
data.push(item);
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"columns": board_columns(),
|
|
"data": data,
|
|
"meta": { "has_more": has_more }
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn list_runs(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(params): Query<ListRunsParams>,
|
|
) -> Response {
|
|
match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(runs) => {
|
|
let include_archived = params.include_archived;
|
|
let items = runs
|
|
.into_iter()
|
|
.filter(|summary| include_archived || summary.status != WorkflowRunStatus::Archived)
|
|
.map(summary_to_api_run_summary)
|
|
.collect::<Vec<_>>();
|
|
let (data, has_more) = paginate_items(items, ¶ms.pagination());
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"data": data,
|
|
"meta": { "has_more": has_more }
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct ResolveRunQuery {
|
|
selector: String,
|
|
}
|
|
|
|
#[derive(Debug, Default, serde::Deserialize)]
|
|
struct DeleteRunQuery {
|
|
#[serde(default)]
|
|
force: bool,
|
|
}
|
|
|
|
async fn resolve_run(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ResolveRunQuery>,
|
|
) -> Response {
|
|
let runs = match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(runs) => runs,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
match resolve_run_by_selector(
|
|
&runs,
|
|
&query.selector,
|
|
|run| run.run_id.to_string(),
|
|
|run| run.workflow_slug.clone(),
|
|
|run| run.workflow_name.clone(),
|
|
|run| run.run_id.created_at(),
|
|
) {
|
|
Ok(run) => (
|
|
StatusCode::OK,
|
|
Json(summary_to_api_run_summary(run.clone())),
|
|
)
|
|
.into_response(),
|
|
Err(err @ (ResolveRunError::InvalidSelector | ResolveRunError::AmbiguousPrefix { .. })) => {
|
|
ApiError::bad_request(err.to_string()).into_response()
|
|
}
|
|
Err(err @ ResolveRunError::NotFound { .. }) => {
|
|
ApiError::not_found(err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn delete_run(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<DeleteRunQuery>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match delete_run_internal(&state, id, query.force).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(response) => response,
|
|
}
|
|
}
|
|
|
|
async fn delete_run_internal(
|
|
state: &Arc<AppState>,
|
|
id: RunId,
|
|
force: bool,
|
|
) -> Result<(), Response> {
|
|
if !force {
|
|
reject_active_delete_without_force(state.as_ref(), &id).await?;
|
|
}
|
|
|
|
let managed_run = if let Ok(mut runs) = state.runs.lock() {
|
|
runs.remove(&id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(mut managed_run) = managed_run {
|
|
if let Some(token) = &managed_run.cancel_token {
|
|
token.store(true, Ordering::SeqCst);
|
|
}
|
|
if let Some(answer_transport) = managed_run.answer_transport.clone() {
|
|
let _ = answer_transport.cancel_run().await;
|
|
}
|
|
if let Some(cancel_tx) = managed_run.cancel_tx.take() {
|
|
let _ = cancel_tx.send(());
|
|
}
|
|
// Terminal runs can still carry a stale worker PID briefly after their
|
|
// completion events land, so avoid paying the full cancellation grace.
|
|
let delete_grace = if matches!(
|
|
managed_run.status,
|
|
RunStatus::Submitted
|
|
| RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
) {
|
|
WORKER_CANCEL_GRACE
|
|
} else {
|
|
TERMINAL_DELETE_WORKER_GRACE
|
|
};
|
|
terminate_worker_for_deletion(
|
|
managed_run.worker_pid,
|
|
managed_run.worker_pgid,
|
|
delete_grace,
|
|
)
|
|
.await;
|
|
if let Some(run_dir) = managed_run.run_dir.take() {
|
|
remove_run_dir(&run_dir).map_err(|err| {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
})?;
|
|
}
|
|
} else {
|
|
let storage = Storage::new(state.server_storage_dir());
|
|
let run_dir = storage.run_scratch(&id).root().to_path_buf();
|
|
remove_run_dir(&run_dir).map_err(|err| {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
})?;
|
|
}
|
|
|
|
state.store.delete_run(&id).await.map_err(|err| {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
})?;
|
|
state
|
|
.artifact_store
|
|
.delete_for_run(&id)
|
|
.await
|
|
.map_err(|err| {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn reject_active_delete_without_force(
|
|
state: &AppState,
|
|
run_id: &RunId,
|
|
) -> Result<(), Response> {
|
|
let managed_status = state
|
|
.runs
|
|
.lock()
|
|
.ok()
|
|
.and_then(|runs| runs.get(run_id).map(|managed_run| managed_run.status));
|
|
if let Some(status) = managed_status {
|
|
if matches!(
|
|
status,
|
|
RunStatus::Submitted
|
|
| RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
) {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
active_run_delete_message(*run_id, status),
|
|
)
|
|
.into_response());
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
match state.store.runs().find(run_id).await {
|
|
Ok(Some(summary)) if summary.status.is_active() => Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
active_run_delete_message(*run_id, summary.status),
|
|
)
|
|
.into_response()),
|
|
Ok(_) => Ok(()),
|
|
Err(err) => {
|
|
Err(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn active_run_delete_message(run_id: RunId, status: impl std::fmt::Display) -> String {
|
|
let run_id = run_id.to_string();
|
|
let short_run_id = &run_id[..12.min(run_id.len())];
|
|
format!(
|
|
"cannot remove active run {short_run_id} (status: {status}, use force=true or --force to force)"
|
|
)
|
|
}
|
|
|
|
async fn terminate_worker_for_deletion(
|
|
worker_pid: Option<u32>,
|
|
worker_pgid: Option<u32>,
|
|
grace: Duration,
|
|
) {
|
|
#[cfg(unix)]
|
|
if let Some(process_group_id) = worker_pgid.or(worker_pid) {
|
|
fabro_proc::sigterm_process_group(process_group_id);
|
|
|
|
let deadline = Instant::now() + grace;
|
|
while Instant::now() < deadline && fabro_proc::process_group_alive(process_group_id) {
|
|
sleep(Duration::from_millis(50)).await;
|
|
}
|
|
|
|
if fabro_proc::process_group_alive(process_group_id) {
|
|
fabro_proc::sigkill_process_group(process_group_id);
|
|
|
|
let kill_deadline = Instant::now() + Duration::from_secs(1);
|
|
while Instant::now() < kill_deadline
|
|
&& fabro_proc::process_group_alive(process_group_id)
|
|
{
|
|
sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
if let Some(worker_pid) = worker_pid {
|
|
fabro_proc::sigterm(worker_pid);
|
|
|
|
let deadline = Instant::now() + grace;
|
|
while Instant::now() < deadline && fabro_proc::process_alive(worker_pid) {
|
|
sleep(Duration::from_millis(50)).await;
|
|
}
|
|
|
|
if fabro_proc::process_alive(worker_pid) {
|
|
fabro_proc::sigkill(worker_pid);
|
|
|
|
let kill_deadline = Instant::now() + Duration::from_secs(1);
|
|
while Instant::now() < kill_deadline && fabro_proc::process_alive(worker_pid) {
|
|
sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remove_run_dir(run_dir: &std::path::Path) -> std::io::Result<()> {
|
|
match std::fs::remove_dir_all(run_dir) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
|
|
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
|
|
.iter()
|
|
.filter(|(_, r)| r.status == RunStatus::Queued)
|
|
.collect();
|
|
queued.sort_by_key(|(_, r)| r.created_at);
|
|
queued
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, (id, _))| (*id, i64::try_from(i + 1).unwrap()))
|
|
.collect()
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Run ID parsing returns HTTP 400 responses directly."
|
|
)]
|
|
fn parse_run_id_path(id: &str) -> Result<RunId, Response> {
|
|
id.parse::<RunId>()
|
|
.map_err(|_| ApiError::bad_request("Invalid run ID.").into_response())
|
|
}
|
|
|
|
/// Public re-export so sibling modules (e.g. `run_files`) can share the same
|
|
/// 400-on-invalid-ULID parse behavior without duplicating the helper.
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "This shared run ID parser returns HTTP 400 responses directly."
|
|
)]
|
|
pub(crate) fn parse_run_id_path_pub(id: &str) -> Result<RunId, Response> {
|
|
parse_run_id_path(id)
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Stage ID parsing returns HTTP 400 responses directly."
|
|
)]
|
|
fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
|
StageId::from_str(stage_id)
|
|
.map_err(|_| ApiError::bad_request("Invalid stage ID.").into_response())
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Blob ID parsing returns HTTP 400 responses directly."
|
|
)]
|
|
fn parse_blob_id_path(blob_id: &str) -> Result<RunBlobId, Response> {
|
|
RunBlobId::from_str(blob_id)
|
|
.map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response())
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Missing filename validation returns HTTP 400 responses directly."
|
|
)]
|
|
fn required_filename(params: ArtifactFilenameParams) -> Result<String, Response> {
|
|
match params.filename {
|
|
Some(filename) if !filename.is_empty() => Ok(filename),
|
|
_ => Err(ApiError::bad_request("Missing filename query parameter.").into_response()),
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Artifact path validation returns HTTP 400 responses directly."
|
|
)]
|
|
fn validate_relative_artifact_path(kind: &str, value: &str) -> Result<String, Response> {
|
|
if value.is_empty() {
|
|
return Err(ApiError::bad_request(format!("{kind} must not be empty")).into_response());
|
|
}
|
|
|
|
if value.contains('\\') {
|
|
return Err(
|
|
ApiError::bad_request(format!("{kind} must not contain backslashes")).into_response(),
|
|
);
|
|
}
|
|
|
|
let segments = value.split('/').collect::<Vec<_>>();
|
|
if segments.iter().any(|segment| segment.is_empty()) {
|
|
return Err(
|
|
ApiError::bad_request(format!("{kind} must not contain empty path segments"))
|
|
.into_response(),
|
|
);
|
|
}
|
|
if segments
|
|
.iter()
|
|
.any(|segment| matches!(*segment, "." | ".."))
|
|
{
|
|
return Err(ApiError::bad_request(format!(
|
|
"{kind} must be a relative path without '.' or '..' segments"
|
|
))
|
|
.into_response());
|
|
}
|
|
|
|
Ok(segments.join("/"))
|
|
}
|
|
|
|
fn bad_request_response(detail: impl Into<String>) -> Response {
|
|
ApiError::bad_request(detail.into()).into_response()
|
|
}
|
|
|
|
fn payload_too_large_response(detail: impl Into<String>) -> Response {
|
|
ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, detail.into()).into_response()
|
|
}
|
|
|
|
fn octet_stream_response(bytes: Bytes) -> Response {
|
|
(
|
|
StatusCode::OK,
|
|
[("content-type", "application/octet-stream")],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Stored event conversion surfaces HTTP errors directly."
|
|
)]
|
|
fn api_event_envelope_from_store(event: &EventEnvelope) -> Result<ApiEventEnvelope, Response> {
|
|
// The payload is already a serde_json::Value; merge `seq` into it
|
|
// instead of serializing the whole envelope and re-parsing.
|
|
let mut obj = event.payload.as_value().clone();
|
|
if let serde_json::Value::Object(ref mut map) = obj {
|
|
map.insert("seq".into(), serde_json::Value::from(event.seq));
|
|
}
|
|
serde_json::from_value(obj).map_err(|err| {
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to deserialize stored event: {err}"),
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
fn clear_live_run_state(run: &mut ManagedRun) {
|
|
run.answer_transport = None;
|
|
run.accepted_questions.clear();
|
|
run.event_tx = None;
|
|
run.cancel_tx = None;
|
|
run.cancel_token = None;
|
|
run.worker_pid = None;
|
|
run.worker_pgid = None;
|
|
}
|
|
|
|
fn reconcile_live_interview_state_for_event(run: &mut ManagedRun, event: &RunEvent) {
|
|
match &event.body {
|
|
EventBody::InterviewCompleted(props) => {
|
|
run.accepted_questions.remove(&props.question_id);
|
|
}
|
|
EventBody::InterviewTimeout(props) => {
|
|
run.accepted_questions.remove(&props.question_id);
|
|
}
|
|
EventBody::InterviewInterrupted(props) => {
|
|
run.accepted_questions.remove(&props.question_id);
|
|
}
|
|
EventBody::RunCompleted(_) | EventBody::RunFailed(_) | EventBody::RunRewound(_) => {
|
|
run.accepted_questions.clear();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn claim_run_answer_transport(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
qid: &str,
|
|
) -> Result<RunAnswerTransport, StatusCode> {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).ok_or(StatusCode::NOT_FOUND)?;
|
|
let transport = managed_run
|
|
.answer_transport
|
|
.clone()
|
|
.ok_or(StatusCode::CONFLICT)?;
|
|
|
|
if !managed_run.accepted_questions.insert(qid.to_string()) {
|
|
return Err(StatusCode::CONFLICT);
|
|
}
|
|
|
|
Ok(transport)
|
|
}
|
|
|
|
fn release_run_answer_claim(state: &AppState, run_id: RunId, qid: &str) {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.accepted_questions.remove(qid);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct LiveWorkerProcess {
|
|
run_id: RunId,
|
|
process_group_id: u32,
|
|
}
|
|
|
|
fn failure_for_incomplete_run(
|
|
pending_control: Option<RunControlAction>,
|
|
terminated_message: String,
|
|
) -> (WorkflowError, Option<WorkflowStatusReason>) {
|
|
if pending_control == Some(RunControlAction::Cancel) {
|
|
(
|
|
WorkflowError::Cancelled,
|
|
Some(WorkflowStatusReason::Cancelled),
|
|
)
|
|
} else {
|
|
(
|
|
WorkflowError::engine(terminated_message),
|
|
Some(WorkflowStatusReason::Terminated),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn should_reconcile_run_on_startup(status: WorkflowRunStatus) -> bool {
|
|
matches!(
|
|
status,
|
|
WorkflowRunStatus::Queued
|
|
| WorkflowRunStatus::Starting
|
|
| WorkflowRunStatus::Running
|
|
| WorkflowRunStatus::Blocked
|
|
| WorkflowRunStatus::Paused
|
|
| WorkflowRunStatus::Removing
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn reconcile_incomplete_runs_on_startup(
|
|
state: &Arc<AppState>,
|
|
) -> anyhow::Result<usize> {
|
|
let summaries = state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await?;
|
|
let mut reconciled = 0usize;
|
|
|
|
for summary in summaries {
|
|
if !should_reconcile_run_on_startup(summary.status) {
|
|
continue;
|
|
}
|
|
|
|
let run_store = state.store.open_run(&summary.run_id).await?;
|
|
let (error, reason) = failure_for_incomplete_run(
|
|
summary.pending_control,
|
|
"Fabro server restarted before the run reached a terminal state.".to_string(),
|
|
);
|
|
workflow_event::append_event(
|
|
&run_store,
|
|
&summary.run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error,
|
|
duration_ms: 0,
|
|
reason,
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await?;
|
|
reconciled += 1;
|
|
}
|
|
|
|
Ok(reconciled)
|
|
}
|
|
|
|
fn live_worker_processes(state: &AppState) -> Vec<LiveWorkerProcess> {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.iter()
|
|
.filter_map(|(run_id, managed_run)| {
|
|
managed_run
|
|
.worker_pgid
|
|
.or(managed_run.worker_pid)
|
|
.map(|process_group_id| LiveWorkerProcess {
|
|
run_id: *run_id,
|
|
process_group_id,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn persist_shutdown_run_failures(
|
|
state: &Arc<AppState>,
|
|
workers: &[LiveWorkerProcess],
|
|
) -> anyhow::Result<()> {
|
|
let run_ids = workers
|
|
.iter()
|
|
.map(|worker| worker.run_id)
|
|
.collect::<HashSet<_>>();
|
|
|
|
for run_id in run_ids {
|
|
let run_store = state.store.open_run(&run_id).await?;
|
|
let run_state = run_store.state().await?;
|
|
if run_state
|
|
.status
|
|
.as_ref()
|
|
.is_some_and(|status| status.status.is_terminal())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let (error, reason) = failure_for_incomplete_run(
|
|
run_state.pending_control,
|
|
"Fabro server shut down before the run reached a terminal state.".to_string(),
|
|
);
|
|
workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error,
|
|
duration_ms: 0,
|
|
reason,
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn shutdown_active_workers(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
|
shutdown_active_workers_with_grace(state, WORKER_CANCEL_GRACE, Duration::from_millis(50)).await
|
|
}
|
|
|
|
async fn shutdown_active_workers_with_grace(
|
|
state: &Arc<AppState>,
|
|
grace: Duration,
|
|
poll_interval: Duration,
|
|
) -> anyhow::Result<usize> {
|
|
state.begin_shutdown();
|
|
let workers = live_worker_processes(state.as_ref());
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let process_groups = workers
|
|
.iter()
|
|
.map(|worker| worker.process_group_id)
|
|
.collect::<HashSet<_>>();
|
|
|
|
for process_group_id in &process_groups {
|
|
fabro_proc::sigterm_process_group(*process_group_id);
|
|
}
|
|
|
|
let deadline = Instant::now() + grace;
|
|
while Instant::now() < deadline
|
|
&& process_groups
|
|
.iter()
|
|
.any(|process_group_id| fabro_proc::process_group_alive(*process_group_id))
|
|
{
|
|
sleep(poll_interval).await;
|
|
}
|
|
|
|
let survivors = process_groups
|
|
.into_iter()
|
|
.filter(|process_group_id| fabro_proc::process_group_alive(*process_group_id))
|
|
.collect::<Vec<_>>();
|
|
for process_group_id in &survivors {
|
|
fabro_proc::sigkill_process_group(*process_group_id);
|
|
}
|
|
if !survivors.is_empty() {
|
|
let kill_deadline = Instant::now() + Duration::from_secs(1);
|
|
while Instant::now() < kill_deadline
|
|
&& survivors
|
|
.iter()
|
|
.any(|process_group_id| fabro_proc::process_group_alive(*process_group_id))
|
|
{
|
|
sleep(poll_interval).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
persist_shutdown_run_failures(state, &workers).await?;
|
|
Ok(workers.len())
|
|
}
|
|
|
|
async fn persist_cancelled_run_status(state: &AppState, run_id: RunId) -> anyhow::Result<()> {
|
|
let run_store = state.store.open_run(&run_id).await?;
|
|
workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::Cancelled,
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::Cancelled),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn forward_run_events_to_global(
|
|
state: Arc<AppState>,
|
|
run_id: RunId,
|
|
mut run_events: broadcast::Receiver<EventEnvelope>,
|
|
) {
|
|
loop {
|
|
match run_events.recv().await {
|
|
Ok(event) => {
|
|
if let Ok(run_event) = RunEvent::try_from(&event.payload) {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
reconcile_live_interview_state_for_event(managed_run, &run_event);
|
|
}
|
|
}
|
|
let _ = state.global_event_tx.send(event);
|
|
}
|
|
Err(RecvError::Lagged(_)) => {}
|
|
Err(RecvError::Closed) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn managed_run(
|
|
dot_source: String,
|
|
status: RunStatus,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
run_dir: std::path::PathBuf,
|
|
execution_mode: RunExecutionMode,
|
|
) -> ManagedRun {
|
|
ManagedRun {
|
|
dot_source,
|
|
status,
|
|
error: None,
|
|
created_at,
|
|
enqueued_at: Instant::now(),
|
|
answer_transport: None,
|
|
accepted_questions: HashSet::new(),
|
|
event_tx: None,
|
|
checkpoint: None,
|
|
cancel_tx: None,
|
|
cancel_token: None,
|
|
worker_pid: None,
|
|
worker_pgid: None,
|
|
run_dir: Some(run_dir),
|
|
execution_mode,
|
|
}
|
|
}
|
|
|
|
fn api_status_from_workflow(status: WorkflowRunStatus) -> RunStatus {
|
|
match status {
|
|
WorkflowRunStatus::Submitted => RunStatus::Submitted,
|
|
WorkflowRunStatus::Queued => RunStatus::Queued,
|
|
WorkflowRunStatus::Starting => RunStatus::Starting,
|
|
WorkflowRunStatus::Running => RunStatus::Running,
|
|
WorkflowRunStatus::Blocked => RunStatus::Blocked,
|
|
WorkflowRunStatus::Paused => RunStatus::Paused,
|
|
WorkflowRunStatus::Removing => RunStatus::Removing,
|
|
WorkflowRunStatus::Succeeded => RunStatus::Succeeded,
|
|
WorkflowRunStatus::Failed => RunStatus::Failed,
|
|
WorkflowRunStatus::Dead => RunStatus::Dead,
|
|
WorkflowRunStatus::Archived => RunStatus::Archived,
|
|
}
|
|
}
|
|
|
|
fn api_blocked_reason(reason: BlockedReason) -> ApiBlockedReason {
|
|
match reason {
|
|
BlockedReason::HumanInputRequired => ApiBlockedReason::HumanInputRequired,
|
|
}
|
|
}
|
|
|
|
fn worker_mode_arg(mode: RunExecutionMode) -> &'static str {
|
|
match mode {
|
|
RunExecutionMode::Start => "start",
|
|
RunExecutionMode::Resume => "resume",
|
|
}
|
|
}
|
|
|
|
fn api_status_reason(reason: WorkflowStatusReason) -> ApiStatusReason {
|
|
match reason {
|
|
WorkflowStatusReason::Completed => ApiStatusReason::Completed,
|
|
WorkflowStatusReason::PartialSuccess => ApiStatusReason::PartialSuccess,
|
|
WorkflowStatusReason::WorkflowError => ApiStatusReason::WorkflowError,
|
|
WorkflowStatusReason::Cancelled => ApiStatusReason::Cancelled,
|
|
WorkflowStatusReason::Terminated => ApiStatusReason::Terminated,
|
|
WorkflowStatusReason::TransientInfra => ApiStatusReason::TransientInfra,
|
|
WorkflowStatusReason::BudgetExhausted => ApiStatusReason::BudgetExhausted,
|
|
WorkflowStatusReason::LaunchFailed => ApiStatusReason::LaunchFailed,
|
|
WorkflowStatusReason::BootstrapFailed => ApiStatusReason::BootstrapFailed,
|
|
WorkflowStatusReason::SandboxInitFailed => ApiStatusReason::SandboxInitFailed,
|
|
WorkflowStatusReason::SandboxInitializing => ApiStatusReason::SandboxInitializing,
|
|
}
|
|
}
|
|
|
|
fn api_pending_control(action: RunControlAction) -> ApiRunControlAction {
|
|
match action {
|
|
RunControlAction::Cancel => ApiRunControlAction::Cancel,
|
|
RunControlAction::Pause => ApiRunControlAction::Pause,
|
|
RunControlAction::Unpause => ApiRunControlAction::Unpause,
|
|
}
|
|
}
|
|
|
|
async fn load_run_status_metadata(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
) -> (
|
|
Option<ApiStatusReason>,
|
|
Option<ApiBlockedReason>,
|
|
Option<ApiRunControlAction>,
|
|
) {
|
|
match state.store.runs().find(&run_id).await {
|
|
Ok(Some(summary)) => (
|
|
summary.status_reason.map(api_status_reason),
|
|
summary.blocked_reason.map(api_blocked_reason),
|
|
summary.pending_control.map(api_pending_control),
|
|
),
|
|
_ => (None, None, None),
|
|
}
|
|
}
|
|
|
|
async fn load_pending_control(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
) -> anyhow::Result<Option<RunControlAction>> {
|
|
Ok(state
|
|
.store
|
|
.runs()
|
|
.find(&run_id)
|
|
.await?
|
|
.and_then(|summary| summary.pending_control))
|
|
}
|
|
|
|
fn fail_managed_run(state: &Arc<AppState>, run_id: RunId, message: String) {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(message);
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
}
|
|
|
|
fn update_live_run_from_event(state: &Arc<AppState>, run_id: RunId, event: &RunEvent) {
|
|
use fabro_types::EventBody;
|
|
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let Some(managed_run) = runs.get_mut(&run_id) else {
|
|
return;
|
|
};
|
|
|
|
match &event.body {
|
|
EventBody::RunQueued(_) => managed_run.status = RunStatus::Queued,
|
|
EventBody::RunStarting(_) => managed_run.status = RunStatus::Starting,
|
|
EventBody::RunRunning(_) | EventBody::RunUnpaused(_) => {
|
|
managed_run.status = RunStatus::Running;
|
|
}
|
|
EventBody::RunBlocked(_) if managed_run.status != RunStatus::Paused => {
|
|
managed_run.status = RunStatus::Blocked;
|
|
}
|
|
EventBody::RunUnblocked(_) if managed_run.status != RunStatus::Paused => {
|
|
managed_run.status = RunStatus::Running;
|
|
}
|
|
EventBody::RunPaused(_) => managed_run.status = RunStatus::Paused,
|
|
EventBody::RunRemoving(_) => managed_run.status = RunStatus::Removing,
|
|
EventBody::RunCompleted(_) => {
|
|
managed_run.status = RunStatus::Succeeded;
|
|
managed_run.error = None;
|
|
}
|
|
EventBody::RunFailed(props) => {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(props.error.clone());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn drain_worker_stderr(run_id: RunId, stderr: ChildStderr) -> anyhow::Result<()> {
|
|
let mut lines = BufReader::new(stderr).lines();
|
|
|
|
while let Some(line) = lines.next_line().await? {
|
|
tracing::warn!(run_id = %run_id, "Worker stderr: {line}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn pump_worker_control_jsonl(
|
|
mut stdin: ChildStdin,
|
|
mut control_rx: mpsc::Receiver<WorkerControlEnvelope>,
|
|
) -> anyhow::Result<()> {
|
|
while let Some(message) = control_rx.recv().await {
|
|
let mut line = serde_json::to_vec(&message)?;
|
|
line.push(b'\n');
|
|
stdin.write_all(&line).await?;
|
|
stdin.flush().await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn append_worker_exit_failure(
|
|
run_store: &fabro_store::RunDatabase,
|
|
run_id: RunId,
|
|
wait_status: &std::process::ExitStatus,
|
|
) {
|
|
let state = match run_store.state().await {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to load run state after worker exit");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let terminal = state
|
|
.status
|
|
.as_ref()
|
|
.is_some_and(|status| status.status.is_terminal());
|
|
if terminal {
|
|
return;
|
|
}
|
|
|
|
let (error, reason) = failure_for_incomplete_run(
|
|
state.pending_control,
|
|
format!("Worker exited before emitting a terminal run event: {wait_status}"),
|
|
);
|
|
|
|
if let Err(err) = workflow_event::append_event(
|
|
run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error,
|
|
duration_ms: 0,
|
|
reason,
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to append worker exit failure");
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct WorkerServerRecord {
|
|
bind: Bind,
|
|
}
|
|
|
|
fn current_server_target(storage_dir: &std::path::Path) -> anyhow::Result<String> {
|
|
let record_path = Storage::new(storage_dir).server_state().record_path();
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync helper invoked from worker_command (sync) via spawn_blocking at the async \
|
|
boundary in execute_run_subprocess; see commit 9d1c0d98c"
|
|
)]
|
|
let content = std::fs::read_to_string(&record_path)
|
|
.map_err(|err| anyhow::anyhow!("failed to read {}: {err}", record_path.display()))?;
|
|
let record: WorkerServerRecord = serde_json::from_str(&content).map_err(|err| {
|
|
anyhow::anyhow!(
|
|
"failed to parse server record {}: {err}",
|
|
record_path.display()
|
|
)
|
|
})?;
|
|
|
|
Ok(match record.bind {
|
|
Bind::Unix(path) => path.to_string_lossy().to_string(),
|
|
Bind::Tcp(addr) => format!("http://{addr}"),
|
|
})
|
|
}
|
|
|
|
fn worker_command(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
mode: RunExecutionMode,
|
|
run_dir: &std::path::Path,
|
|
) -> anyhow::Result<Command> {
|
|
let current_exe = std::env::current_exe().context("reading current executable path")?;
|
|
let exe = std::env::var_os("CARGO_BIN_EXE_fabro").map_or(current_exe, PathBuf::from);
|
|
let storage_dir = state.server_storage_dir();
|
|
let server_target = current_server_target(&storage_dir)?;
|
|
let artifact_upload_token = state
|
|
.issue_artifact_upload_token(&run_id)
|
|
.map_err(|_| anyhow::anyhow!("failed to sign artifact upload token"))?;
|
|
let mut cmd = Command::new(exe);
|
|
cmd.arg("__run-worker")
|
|
.arg("--server")
|
|
.arg(server_target)
|
|
.arg("--storage-dir")
|
|
.arg(&storage_dir)
|
|
.arg("--artifact-upload-token")
|
|
.arg(artifact_upload_token)
|
|
.arg("--run-dir")
|
|
.arg(run_dir)
|
|
.arg("--run-id")
|
|
.arg(run_id.to_string())
|
|
.arg("--mode")
|
|
.arg(worker_mode_arg(mode))
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped());
|
|
|
|
cmd.env_remove("FABRO_JSON");
|
|
if let Some(token) = std::env::var_os("FABRO_DEV_TOKEN") {
|
|
cmd.env("FABRO_DEV_TOKEN", token);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fabro_proc::pre_exec_setpgid(cmd.as_std_mut());
|
|
|
|
Ok(cmd)
|
|
}
|
|
|
|
fn api_question_type(question_type: InterviewQuestionType) -> ApiQuestionType {
|
|
match question_type {
|
|
InterviewQuestionType::YesNo => ApiQuestionType::YesNo,
|
|
InterviewQuestionType::MultipleChoice => ApiQuestionType::MultipleChoice,
|
|
InterviewQuestionType::MultiSelect => ApiQuestionType::MultiSelect,
|
|
InterviewQuestionType::Freeform => ApiQuestionType::Freeform,
|
|
InterviewQuestionType::Confirmation => ApiQuestionType::Confirmation,
|
|
}
|
|
}
|
|
|
|
fn runtime_question_type(question_type: InterviewQuestionType) -> QuestionType {
|
|
match question_type {
|
|
InterviewQuestionType::YesNo => QuestionType::YesNo,
|
|
InterviewQuestionType::MultipleChoice => QuestionType::MultipleChoice,
|
|
InterviewQuestionType::MultiSelect => QuestionType::MultiSelect,
|
|
InterviewQuestionType::Freeform => QuestionType::Freeform,
|
|
InterviewQuestionType::Confirmation => QuestionType::Confirmation,
|
|
}
|
|
}
|
|
|
|
fn runtime_question_from_interview_record(question: &InterviewQuestionRecord) -> Question {
|
|
Question {
|
|
id: question.id.clone(),
|
|
text: question.text.clone(),
|
|
question_type: runtime_question_type(question.question_type),
|
|
options: question
|
|
.options
|
|
.iter()
|
|
.map(|option| fabro_interview::QuestionOption {
|
|
key: option.key.clone(),
|
|
label: option.label.clone(),
|
|
})
|
|
.collect(),
|
|
allow_freeform: question.allow_freeform,
|
|
default: None,
|
|
timeout_seconds: question.timeout_seconds,
|
|
stage: question.stage.clone(),
|
|
metadata: HashMap::new(),
|
|
context_display: question.context_display.clone(),
|
|
}
|
|
}
|
|
|
|
fn api_question_from_interview_record(question: &InterviewQuestionRecord) -> ApiQuestion {
|
|
ApiQuestion {
|
|
id: question.id.clone(),
|
|
text: question.text.clone(),
|
|
stage: question.stage.clone(),
|
|
question_type: api_question_type(question.question_type),
|
|
options: question
|
|
.options
|
|
.iter()
|
|
.map(|option| ApiQuestionOption {
|
|
key: option.key.clone(),
|
|
label: option.label.clone(),
|
|
})
|
|
.collect(),
|
|
allow_freeform: question.allow_freeform,
|
|
timeout_seconds: question.timeout_seconds,
|
|
context_display: question.context_display.clone(),
|
|
}
|
|
}
|
|
|
|
fn api_question_from_pending_interview(record: &PendingInterviewRecord) -> ApiQuestion {
|
|
api_question_from_interview_record(&record.question)
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Pending-interview lookup maps storage failures to HTTP responses."
|
|
)]
|
|
async fn load_pending_interview(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
qid: &str,
|
|
) -> Result<LoadedPendingInterview, Response> {
|
|
let run_store = match state.store.open_run_reader(&run_id).await {
|
|
Ok(run_store) => run_store,
|
|
Err(fabro_store::Error::RunNotFound(_)) => {
|
|
return Err(ApiError::not_found("Run not found.").into_response());
|
|
}
|
|
Err(err) => {
|
|
return Err(
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
);
|
|
}
|
|
};
|
|
let run_state = match run_store.state().await {
|
|
Ok(run_state) => run_state,
|
|
Err(err) => {
|
|
return Err(
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
);
|
|
}
|
|
};
|
|
let Some(record) = run_state.pending_interviews.get(qid) else {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Question no longer exists or was already answered.",
|
|
)
|
|
.into_response());
|
|
};
|
|
|
|
Ok(LoadedPendingInterview {
|
|
run_id,
|
|
qid: qid.to_string(),
|
|
question: record.question.clone(),
|
|
})
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Interview answer validation returns HTTP 400 responses directly."
|
|
)]
|
|
fn validate_answer_for_question(
|
|
question: &InterviewQuestionRecord,
|
|
answer: &Answer,
|
|
) -> Result<(), Response> {
|
|
match (&question.question_type, &answer.value) {
|
|
(
|
|
InterviewQuestionType::YesNo | InterviewQuestionType::Confirmation,
|
|
fabro_interview::AnswerValue::Yes | fabro_interview::AnswerValue::No,
|
|
)
|
|
| (
|
|
_,
|
|
fabro_interview::AnswerValue::Interrupted
|
|
| fabro_interview::AnswerValue::Skipped
|
|
| fabro_interview::AnswerValue::Timeout,
|
|
) => Ok(()),
|
|
(InterviewQuestionType::MultipleChoice, fabro_interview::AnswerValue::Selected(key)) => {
|
|
if question.options.iter().any(|option| option.key == *key) {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::bad_request("Invalid option key.").into_response())
|
|
}
|
|
}
|
|
(InterviewQuestionType::MultiSelect, fabro_interview::AnswerValue::MultiSelected(keys)) => {
|
|
if keys
|
|
.iter()
|
|
.all(|key| question.options.iter().any(|option| option.key == *key))
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::bad_request("Invalid option key.").into_response())
|
|
}
|
|
}
|
|
(InterviewQuestionType::Freeform, fabro_interview::AnswerValue::Text(text))
|
|
if !text.trim().is_empty() =>
|
|
{
|
|
Ok(())
|
|
}
|
|
(_, fabro_interview::AnswerValue::Text(text))
|
|
if question.allow_freeform && !text.trim().is_empty() =>
|
|
{
|
|
Ok(())
|
|
}
|
|
_ => Err(ApiError::bad_request("Answer does not match question type.").into_response()),
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Interview submission maps validation failures to HTTP responses."
|
|
)]
|
|
async fn submit_pending_interview_answer(
|
|
state: &AppState,
|
|
pending: &LoadedPendingInterview,
|
|
answer: Answer,
|
|
) -> Result<(), Response> {
|
|
validate_answer_for_question(&pending.question, &answer)?;
|
|
deliver_answer_to_run(state, pending.run_id, &pending.qid, answer).await
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Interview delivery maps run-state failures to HTTP responses."
|
|
)]
|
|
async fn deliver_answer_to_run(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
qid: &str,
|
|
answer: Answer,
|
|
) -> Result<(), Response> {
|
|
let transport = match claim_run_answer_transport(state, run_id, qid) {
|
|
Ok(transport) => transport,
|
|
Err(StatusCode::NOT_FOUND) => {
|
|
return Err(ApiError::not_found("Run not found.").into_response());
|
|
}
|
|
Err(StatusCode::CONFLICT) => {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Question no longer exists or was already answered.",
|
|
)
|
|
.into_response());
|
|
}
|
|
Err(status) => {
|
|
return Err(
|
|
ApiError::new(status, "Run is not ready to accept answers.").into_response()
|
|
);
|
|
}
|
|
};
|
|
|
|
if let Ok(()) = transport.submit(qid, answer).await {
|
|
Ok(())
|
|
} else {
|
|
release_run_answer_claim(state, run_id, qid);
|
|
Err(ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Failed to deliver answer to the active run.",
|
|
)
|
|
.into_response())
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Answer request parsing returns HTTP 400 responses directly."
|
|
)]
|
|
fn answer_from_request(
|
|
req: SubmitAnswerRequest,
|
|
question: &InterviewQuestionRecord,
|
|
) -> Result<Answer, Response> {
|
|
if let Some(key) = req.selected_option_key {
|
|
let option = question
|
|
.options
|
|
.iter()
|
|
.find(|option| option.key == key)
|
|
.cloned();
|
|
match option {
|
|
Some(option) => Ok(Answer::selected(key, fabro_interview::QuestionOption {
|
|
key: option.key,
|
|
label: option.label,
|
|
})),
|
|
None => Err(ApiError::bad_request("Invalid option key.").into_response()),
|
|
}
|
|
} else if !req.selected_option_keys.is_empty() {
|
|
for key in &req.selected_option_keys {
|
|
let valid = question.options.iter().any(|option| option.key == *key);
|
|
if !valid {
|
|
return Err(ApiError::bad_request("Invalid option key.").into_response());
|
|
}
|
|
}
|
|
Ok(Answer::multi_selected(req.selected_option_keys))
|
|
} else if let Some(value) = req.value {
|
|
Ok(Answer::text(value))
|
|
} else {
|
|
Err(ApiError::bad_request(
|
|
"One of value, selected_option_key, or selected_option_keys is required.",
|
|
)
|
|
.into_response())
|
|
}
|
|
}
|
|
|
|
async fn create_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let req = match serde_json::from_slice::<RunManifest>(&body) {
|
|
Ok(req) => req,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
let prepared = match run_manifest::prepare_manifest_with_mode(
|
|
&state.settings.read().unwrap(),
|
|
&req,
|
|
state.local_daemon_mode,
|
|
) {
|
|
Ok(prepared) => prepared,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
|
|
info!(run_id = %run_id, "Run created");
|
|
|
|
let configured_providers = state.provider_credentials.configured_providers().await;
|
|
let mut create_input = run_manifest::create_run_input(prepared.clone(), configured_providers);
|
|
create_input.run_id = Some(run_id);
|
|
create_input.provenance = Some(run_provenance(&headers, &subject));
|
|
create_input.submitted_manifest_bytes = Some(body.to_vec());
|
|
|
|
let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await {
|
|
Ok(created) => created,
|
|
Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => {
|
|
return ApiError::bad_request("Validation failed").into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to persist run state: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
let created_at = created.run_id.created_at();
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.insert(
|
|
created.run_id,
|
|
managed_run(
|
|
created.persisted.source().to_string(),
|
|
RunStatus::Submitted,
|
|
created_at,
|
|
created.run_dir,
|
|
RunExecutionMode::Start,
|
|
),
|
|
);
|
|
}
|
|
|
|
(
|
|
StatusCode::CREATED,
|
|
Json(RunStatusResponse {
|
|
blocked_reason: None,
|
|
id: run_id.to_string(),
|
|
status: RunStatus::Submitted,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason: None,
|
|
pending_control: None,
|
|
created_at,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn run_provenance(headers: &HeaderMap, subject: &AuthenticatedSubject) -> RunProvenance {
|
|
RunProvenance {
|
|
server: Some(RunServerProvenance {
|
|
version: FABRO_VERSION.to_string(),
|
|
}),
|
|
client: run_client_provenance(headers),
|
|
subject: Some(RunSubjectProvenance {
|
|
login: subject.login.clone(),
|
|
auth_method: subject.auth_method,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn run_client_provenance(headers: &HeaderMap) -> Option<RunClientProvenance> {
|
|
let user_agent = headers
|
|
.get(header::USER_AGENT)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string)?;
|
|
let (name, version) = parse_known_fabro_user_agent(&user_agent)
|
|
.map_or((None, None), |(name, version)| {
|
|
(Some(name.to_string()), Some(version.to_string()))
|
|
});
|
|
Some(RunClientProvenance {
|
|
user_agent: Some(user_agent),
|
|
name,
|
|
version,
|
|
})
|
|
}
|
|
|
|
fn parse_known_fabro_user_agent(user_agent: &str) -> Option<(&str, &str)> {
|
|
let token = user_agent.split_whitespace().next()?;
|
|
let (name, version) = token.split_once('/')?;
|
|
if version.is_empty() {
|
|
return None;
|
|
}
|
|
match name {
|
|
"fabro-cli" | "fabro-web" => Some((name, version)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn run_preflight(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<RunManifest>,
|
|
) -> Response {
|
|
let prepared = match run_manifest::prepare_manifest_with_mode(
|
|
&state.settings.read().unwrap(),
|
|
&req,
|
|
state.local_daemon_mode,
|
|
) {
|
|
Ok(prepared) => prepared,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
let validated = match run_manifest::validate_prepared_manifest(&prepared) {
|
|
Ok(validated) => validated,
|
|
Err(WorkflowError::Parse(_)) => {
|
|
return ApiError::bad_request("Validation failed").into_response();
|
|
}
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
let response = match run_manifest::run_preflight(&state, &prepared, &validated).await {
|
|
Ok((response, _ok)) => response,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
async fn render_graph_from_manifest(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<RenderWorkflowGraphRequest>,
|
|
) -> Response {
|
|
let prepared = match run_manifest::prepare_manifest_with_mode(
|
|
&state.settings.read().unwrap(),
|
|
&req.manifest,
|
|
state.local_daemon_mode,
|
|
) {
|
|
Ok(prepared) => prepared,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
let validated = match run_manifest::validate_prepared_manifest(&prepared) {
|
|
Ok(validated) => validated,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
if validated.has_errors() {
|
|
return ApiError::bad_request("Validation failed").into_response();
|
|
}
|
|
|
|
let direction = req.direction.as_ref().map(|direction| match direction {
|
|
RenderWorkflowGraphDirection::Lr => "LR",
|
|
RenderWorkflowGraphDirection::Tb => "TB",
|
|
});
|
|
let dot_source = run_manifest::graph_source(&prepared, direction);
|
|
render_graph_bytes(&dot_source).await
|
|
}
|
|
|
|
async fn start_run(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
body: Option<Json<StartRunRequest>>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let resume = body.is_some_and(|Json(req)| req.resume);
|
|
|
|
{
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get(&id) {
|
|
if matches!(
|
|
managed_run.status,
|
|
RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
) {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
if resume {
|
|
"an engine process is still running for this run — cannot resume"
|
|
} else {
|
|
"an engine process is still running for this run — cannot start"
|
|
},
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
|
|
let Ok(run_store) = state.store.open_run(&id).await else {
|
|
return ApiError::not_found("Run not found.").into_response();
|
|
};
|
|
let run_state = match run_store.state().await {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to load run state: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if resume {
|
|
if run_state.checkpoint.is_none() {
|
|
return ApiError::new(StatusCode::CONFLICT, "no checkpoint to resume from")
|
|
.into_response();
|
|
}
|
|
} else if let Some(record) = run_state.status.as_ref() {
|
|
if !matches!(
|
|
record.status,
|
|
WorkflowRunStatus::Submitted | WorkflowRunStatus::Queued | WorkflowRunStatus::Starting
|
|
) {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
format!(
|
|
"cannot start run: status is {:?}, expected submitted",
|
|
record.status
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let Some(run_record) = run_state.run.as_ref() else {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"run record missing from store",
|
|
)
|
|
.into_response();
|
|
};
|
|
let run_dir = match resolved_storage_dir(&run_record.settings) {
|
|
Ok(storage_dir) => Storage::new(storage_dir)
|
|
.run_scratch(&id)
|
|
.root()
|
|
.to_path_buf(),
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("invalid persisted server storage settings: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
let dot_source = run_state.graph_source.unwrap_or_default();
|
|
if let Err(err) =
|
|
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunQueued).await
|
|
{
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
|
}
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.insert(
|
|
id,
|
|
managed_run(
|
|
dot_source,
|
|
RunStatus::Queued,
|
|
id.created_at(),
|
|
run_dir,
|
|
if resume {
|
|
RunExecutionMode::Resume
|
|
} else {
|
|
RunExecutionMode::Start
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
state.scheduler_notify.notify_one();
|
|
(
|
|
StatusCode::OK,
|
|
Json(RunStatusResponse {
|
|
blocked_reason: None,
|
|
id: id.to_string(),
|
|
status: RunStatus::Queued,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason: None,
|
|
pending_control: None,
|
|
created_at: id.created_at(),
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Execute a single run: transitions queued → starting → running →
|
|
/// completed/failed/cancelled.
|
|
async fn execute_run(state: Arc<AppState>, run_id: RunId) {
|
|
if state.is_shutting_down() {
|
|
return;
|
|
}
|
|
|
|
if state.registry_factory_override.is_some() {
|
|
execute_run_in_process(state, run_id).await;
|
|
return;
|
|
}
|
|
|
|
execute_run_subprocess(state, run_id).await;
|
|
}
|
|
|
|
async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|
// Transition to Starting and set up cancel infrastructure
|
|
let (cancel_rx, run_dir, event_tx, cancel_token, execution_mode, queued_for) = {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = match runs.get_mut(&run_id) {
|
|
Some(r) if r.status == RunStatus::Queued => r,
|
|
_ => return,
|
|
};
|
|
let Some(run_dir) = managed_run.run_dir.clone() else {
|
|
return;
|
|
};
|
|
|
|
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
|
|
let cancel_token = Arc::new(AtomicBool::new(false));
|
|
let (event_tx, _) = broadcast::channel(256);
|
|
|
|
managed_run.status = RunStatus::Starting;
|
|
managed_run.cancel_tx = Some(cancel_tx);
|
|
managed_run.cancel_token = Some(Arc::clone(&cancel_token));
|
|
managed_run.event_tx = Some(event_tx);
|
|
|
|
(
|
|
cancel_rx,
|
|
run_dir,
|
|
managed_run.event_tx.clone(),
|
|
cancel_token,
|
|
managed_run.execution_mode,
|
|
managed_run.enqueued_at.elapsed(),
|
|
)
|
|
};
|
|
let _ = queued_for;
|
|
|
|
// Create interviewer and event plumbing (this is the "provisioning" phase)
|
|
let interviewer = Arc::new(ControlInterviewer::new());
|
|
let interview_runtime: Arc<dyn Interviewer> = interviewer.clone();
|
|
let emitter = Emitter::new(run_id);
|
|
if let Some(tx_clone) = event_tx {
|
|
emitter.on_event(move |event| {
|
|
let _ = tx_clone.send(event.clone());
|
|
});
|
|
}
|
|
let registry_override = state
|
|
.registry_factory_override
|
|
.as_ref()
|
|
.map(|factory| Arc::new(factory(Arc::clone(&interview_runtime))));
|
|
let emitter = Arc::new(emitter);
|
|
|
|
// Transition to Running, populate interviewer
|
|
let cancelled_during_setup = {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
if managed_run.status == RunStatus::Starting {
|
|
managed_run.status = RunStatus::Running;
|
|
managed_run.answer_transport = Some(RunAnswerTransport::InProcess {
|
|
interviewer: Arc::clone(&interviewer),
|
|
});
|
|
false
|
|
} else {
|
|
// Was cancelled during setup
|
|
clear_live_run_state(managed_run);
|
|
state.scheduler_notify.notify_one();
|
|
true
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if cancelled_during_setup {
|
|
if let Err(err) = persist_cancelled_run_status(state.as_ref(), run_id).await {
|
|
error!(run_id = %run_id, error = %err, "Failed to persist cancelled run status");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let run_store = match state.store.open_run(&run_id).await {
|
|
Ok(run_store) => run_store,
|
|
Err(e) => {
|
|
tracing::error!(run_id = %run_id, error = %e, "Failed to open run store");
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(format!("Failed to open run store: {e}"));
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
tokio::spawn(forward_run_events_to_global(
|
|
Arc::clone(&state),
|
|
run_id,
|
|
run_store.subscribe(),
|
|
));
|
|
let persisted = match Persisted::load_from_store(&run_store.clone().into(), &run_dir).await {
|
|
Ok(persisted) => persisted,
|
|
Err(e) => {
|
|
tracing::error!(run_id = %run_id, error = %e, "Failed to load persisted run");
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(format!("Failed to load persisted run: {e}"));
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
let github_settings = match resolved_github_settings(&persisted.run_record().settings) {
|
|
Ok(settings) => settings,
|
|
Err(err) => {
|
|
tracing::error!(run_id = %run_id, error = %err, "Invalid GitHub integration config");
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(format!("Invalid GitHub integration config: {err}"));
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
let github_app_result = match fabro_config::resolve_run_from_file(
|
|
&persisted.run_record().settings,
|
|
) {
|
|
Ok(settings) => {
|
|
let required_github_credentials = (settings.execution.mode != RunMode::DryRun
|
|
&& settings.sandbox.provider == "daytona")
|
|
|| !github_settings.permissions.is_empty();
|
|
if required_github_credentials {
|
|
state.github_credentials(&github_settings)
|
|
} else if settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some()
|
|
{
|
|
match state.github_credentials(&github_settings) {
|
|
Ok(github_app) => Ok(github_app),
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
run_id = %run_id,
|
|
error = %err,
|
|
"GitHub credentials unavailable; pull request creation will be skipped"
|
|
);
|
|
Ok(None)
|
|
}
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
Err(_) => Ok(None),
|
|
};
|
|
let github_app = match github_app_result {
|
|
Ok(github_app) => github_app,
|
|
Err(e) => {
|
|
tracing::error!(run_id = %run_id, error = %e, "Invalid GitHub credentials");
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(format!("Invalid GitHub credentials: {e}"));
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
let services = operations::StartServices {
|
|
run_id,
|
|
cancel_token: Some(Arc::clone(&cancel_token)),
|
|
emitter: Arc::clone(&emitter),
|
|
interviewer: Arc::clone(&interview_runtime),
|
|
run_store: run_store.clone().into(),
|
|
event_sink: workflow_event::RunEventSink::store(run_store.clone()),
|
|
artifact_sink: Some(ArtifactSink::Store(state.artifact_store.clone())),
|
|
run_control: None,
|
|
github_app,
|
|
vault: Some(Arc::clone(&state.vault)),
|
|
on_node: None,
|
|
registry_override,
|
|
};
|
|
|
|
let execution = async {
|
|
match execution_mode {
|
|
RunExecutionMode::Start => operations::start(&run_dir, services).await,
|
|
RunExecutionMode::Resume => operations::resume(&run_dir, services).await,
|
|
}
|
|
};
|
|
|
|
let result = tokio::select! {
|
|
result = execution => ExecutionResult::Completed(Box::new(result)),
|
|
_ = cancel_rx => {
|
|
cancel_token.store(true, Ordering::SeqCst);
|
|
ExecutionResult::CancelledBySignal
|
|
}
|
|
};
|
|
|
|
if matches!(&result, ExecutionResult::CancelledBySignal) {
|
|
if let Err(err) = persist_cancelled_run_status(state.as_ref(), run_id).await {
|
|
error!(run_id = %run_id, error = %err, "Failed to persist cancelled run status");
|
|
}
|
|
}
|
|
|
|
// Save final checkpoint
|
|
let checkpoint = match run_store.state().await {
|
|
Ok(state) => state.checkpoint,
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to load run state from store");
|
|
None
|
|
}
|
|
};
|
|
|
|
// Accumulate aggregate usage after execution completes.
|
|
if let Some(ref cp) = checkpoint {
|
|
let stage_durations = match run_store.list_events().await {
|
|
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to load run events from store");
|
|
HashMap::default()
|
|
}
|
|
};
|
|
let mut agg = state
|
|
.aggregate_billing
|
|
.lock()
|
|
.expect("aggregate_billing lock poisoned");
|
|
agg.total_runs += 1;
|
|
let mut run_runtime: f64 = 0.0;
|
|
for (node_id, outcome) in &cp.node_outcomes {
|
|
if let Some(usage) = &outcome.usage {
|
|
let entry = agg
|
|
.by_model
|
|
.entry(usage.model_id().to_string())
|
|
.or_default();
|
|
accumulate_model_billing(entry, usage);
|
|
}
|
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
|
run_runtime += duration_ms as f64 / 1000.0;
|
|
}
|
|
agg.total_runtime_secs += run_runtime;
|
|
}
|
|
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
match &result {
|
|
ExecutionResult::Completed(result) => match result.as_ref() {
|
|
Ok(started) => match &started.finalized.outcome {
|
|
Ok(_) => {
|
|
info!(run_id = %run_id, "Run completed");
|
|
managed_run.status = RunStatus::Succeeded;
|
|
}
|
|
Err(WorkflowError::Cancelled) => {
|
|
info!(run_id = %run_id, "Run cancelled");
|
|
managed_run.status = RunStatus::Failed;
|
|
}
|
|
Err(e) => {
|
|
error!(run_id = %run_id, error = %e, "Run failed");
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(e.to_string());
|
|
}
|
|
},
|
|
Err(WorkflowError::Cancelled) => {
|
|
info!(run_id = %run_id, "Run cancelled");
|
|
managed_run.status = RunStatus::Failed;
|
|
}
|
|
Err(e) => {
|
|
error!(run_id = %run_id, error = %e, "Run failed");
|
|
managed_run.status = RunStatus::Failed;
|
|
managed_run.error = Some(e.to_string());
|
|
}
|
|
},
|
|
ExecutionResult::CancelledBySignal => {
|
|
info!(run_id = %run_id, "Run cancelled");
|
|
managed_run.status = RunStatus::Failed;
|
|
}
|
|
}
|
|
managed_run.checkpoint = checkpoint;
|
|
managed_run.run_dir = Some(run_dir);
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
drop(runs);
|
|
state.scheduler_notify.notify_one();
|
|
}
|
|
|
|
async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
|
let (run_dir, execution_mode) = {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if state.is_shutting_down() {
|
|
return;
|
|
}
|
|
let managed_run = match runs.get_mut(&run_id) {
|
|
Some(run) if run.status == RunStatus::Queued => run,
|
|
_ => return,
|
|
};
|
|
let Some(run_dir) = managed_run.run_dir.clone() else {
|
|
return;
|
|
};
|
|
managed_run.status = RunStatus::Starting;
|
|
(run_dir, managed_run.execution_mode)
|
|
};
|
|
|
|
let run_store = match state.store.open_run(&run_id).await {
|
|
Ok(run_store) => run_store,
|
|
Err(err) => {
|
|
tracing::error!(run_id = %run_id, error = %err, "Failed to open run store");
|
|
fail_managed_run(&state, run_id, format!("Failed to open run store: {err}"));
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
tokio::spawn(forward_run_events_to_global(
|
|
Arc::clone(&state),
|
|
run_id,
|
|
run_store.subscribe(),
|
|
));
|
|
|
|
let state_for_build = Arc::clone(&state);
|
|
let run_dir_for_build = run_dir.clone();
|
|
let build_cmd_result = spawn_blocking(move || {
|
|
worker_command(
|
|
state_for_build.as_ref(),
|
|
run_id,
|
|
execution_mode,
|
|
&run_dir_for_build,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
let mut child = match build_cmd_result
|
|
.map_err(|err| anyhow::anyhow!("worker_command task failed: {err}"))
|
|
.and_then(|inner| inner)
|
|
.and_then(|mut cmd| cmd.spawn().context("spawning run worker process"))
|
|
{
|
|
Ok(child) => child,
|
|
Err(err) => {
|
|
tracing::error!(run_id = %run_id, error = %err, "Failed to spawn worker");
|
|
let _ = workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::engine(err.to_string()),
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::LaunchFailed),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await;
|
|
fail_managed_run(&state, run_id, format!("Failed to spawn worker: {err}"));
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(worker_pid) = child.id() else {
|
|
let message = "Worker process did not report a PID".to_string();
|
|
tracing::error!(run_id = %run_id, "{message}");
|
|
let _ = child.start_kill();
|
|
let _ = workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::engine(message.clone()),
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::LaunchFailed),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await;
|
|
fail_managed_run(&state, run_id, message);
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
};
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.worker_pid = Some(worker_pid);
|
|
managed_run.worker_pgid = Some(worker_pid);
|
|
managed_run.run_dir = Some(run_dir.clone());
|
|
}
|
|
}
|
|
|
|
let Some(stdin) = child.stdin.take() else {
|
|
let message = "Worker stdin pipe was unavailable".to_string();
|
|
tracing::error!(run_id = %run_id, "{message}");
|
|
let _ = child.start_kill();
|
|
let _ = workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::engine(message.clone()),
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::LaunchFailed),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await;
|
|
fail_managed_run(&state, run_id, message);
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
};
|
|
|
|
let Some(stderr) = child.stderr.take() else {
|
|
let message = "Worker stderr pipe was unavailable".to_string();
|
|
tracing::error!(run_id = %run_id, "{message}");
|
|
let _ = child.start_kill();
|
|
let _ = workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::engine(message.clone()),
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::LaunchFailed),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await;
|
|
fail_managed_run(&state, run_id, message);
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
};
|
|
|
|
let (control_tx, control_rx) = mpsc::channel(WORKER_CONTROL_QUEUE_CAPACITY);
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
managed_run.answer_transport = Some(RunAnswerTransport::Subprocess { control_tx });
|
|
}
|
|
}
|
|
|
|
let control_task = tokio::spawn(pump_worker_control_jsonl(stdin, control_rx));
|
|
let stderr_task = tokio::spawn(drain_worker_stderr(run_id, stderr));
|
|
|
|
let wait_status = match child.wait().await {
|
|
Ok(status) => status,
|
|
Err(err) => {
|
|
tracing::error!(run_id = %run_id, error = %err, "Failed while waiting on worker");
|
|
let _ = child.start_kill();
|
|
let _ = workflow_event::append_event(
|
|
&run_store,
|
|
&run_id,
|
|
&workflow_event::Event::WorkflowRunFailed {
|
|
error: WorkflowError::engine(err.to_string()),
|
|
duration_ms: 0,
|
|
reason: Some(WorkflowStatusReason::Terminated),
|
|
git_commit_sha: None,
|
|
final_patch: None,
|
|
},
|
|
)
|
|
.await;
|
|
fail_managed_run(&state, run_id, format!("Worker wait failed: {err}"));
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
|
|
control_task.abort();
|
|
let _ = control_task.await;
|
|
|
|
match stderr_task.await {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(err)) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Worker stderr drain failed");
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Worker stderr task panicked");
|
|
}
|
|
}
|
|
|
|
let superseded = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.get(&run_id)
|
|
.is_some_and(|managed_run| managed_run.worker_pid != Some(worker_pid))
|
|
};
|
|
if superseded {
|
|
tracing::info!(
|
|
run_id = %run_id,
|
|
worker_pid,
|
|
"Skipping stale worker cleanup for superseded run execution"
|
|
);
|
|
return;
|
|
}
|
|
|
|
append_worker_exit_failure(&run_store, run_id, &wait_status).await;
|
|
|
|
let final_state = match run_store.state().await {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to load final run state from store");
|
|
fail_managed_run(
|
|
&state,
|
|
run_id,
|
|
format!("Failed to load final run state: {err}"),
|
|
);
|
|
state.scheduler_notify.notify_one();
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Some(ref checkpoint) = final_state.checkpoint {
|
|
let stage_durations = match run_store.list_events().await {
|
|
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %run_id, error = %err, "Failed to load run events from store");
|
|
HashMap::default()
|
|
}
|
|
};
|
|
let mut agg = state
|
|
.aggregate_billing
|
|
.lock()
|
|
.expect("aggregate_billing lock poisoned");
|
|
agg.total_runs += 1;
|
|
let mut run_runtime: f64 = 0.0;
|
|
for (node_id, outcome) in &checkpoint.node_outcomes {
|
|
if let Some(usage) = &outcome.usage {
|
|
let entry = agg
|
|
.by_model
|
|
.entry(usage.model_id().to_string())
|
|
.or_default();
|
|
accumulate_model_billing(entry, usage);
|
|
}
|
|
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
|
|
run_runtime += duration_ms as f64 / 1000.0;
|
|
}
|
|
agg.total_runtime_secs += run_runtime;
|
|
}
|
|
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
if let Some(managed_run) = runs.get_mut(&run_id) {
|
|
if let Some(status) = final_state.status.as_ref() {
|
|
managed_run.status = api_status_from_workflow(status.status);
|
|
} else if !wait_status.success() {
|
|
managed_run.status = RunStatus::Failed;
|
|
}
|
|
managed_run.error = final_state
|
|
.conclusion
|
|
.as_ref()
|
|
.and_then(|conclusion| conclusion.failure_reason.clone())
|
|
.or_else(|| managed_run.error.clone());
|
|
managed_run.checkpoint = final_state.checkpoint;
|
|
managed_run.run_dir = Some(run_dir);
|
|
clear_live_run_state(managed_run);
|
|
}
|
|
drop(runs);
|
|
state.scheduler_notify.notify_one();
|
|
}
|
|
|
|
/// Background task that promotes queued runs when capacity is available.
|
|
pub fn spawn_scheduler(state: Arc<AppState>) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
() = state.scheduler_notify.notified() => {},
|
|
() = sleep(std::time::Duration::from_secs(1)) => {},
|
|
}
|
|
if state.is_shutting_down() {
|
|
break;
|
|
}
|
|
// Promote as many queued runs as capacity allows
|
|
loop {
|
|
if state.is_shutting_down() {
|
|
break;
|
|
}
|
|
let run_to_start = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
let active = runs
|
|
.values()
|
|
.filter(|r| {
|
|
matches!(
|
|
r.status,
|
|
RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
)
|
|
})
|
|
.count();
|
|
if active >= state.max_concurrent_runs {
|
|
break;
|
|
}
|
|
runs.iter()
|
|
.filter(|(_, r)| r.status == RunStatus::Queued)
|
|
.min_by_key(|(_, r)| r.created_at)
|
|
.map(|(id, _)| *id)
|
|
};
|
|
match run_to_start {
|
|
Some(id) => {
|
|
let state_clone = Arc::clone(&state);
|
|
tokio::spawn(execute_run(state_clone, id));
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn get_run_status(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
match state
|
|
.store
|
|
.list_runs(&fabro_store::ListRunsQuery::default())
|
|
.await
|
|
{
|
|
Ok(runs) => match runs.into_iter().find(|run| run.run_id == id) {
|
|
Some(run) => (StatusCode::OK, Json(summary_to_api_run_summary(run))).into_response(),
|
|
None => ApiError::not_found("Run not found.").into_response(),
|
|
},
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_run_settings(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let run_store = match state.store.open_run_reader(&id).await {
|
|
Ok(store) => store,
|
|
Err(fabro_store::Error::RunNotFound(_)) => {
|
|
return ApiError::not_found("Run not found.").into_response();
|
|
}
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let run_state = match run_store.state().await {
|
|
Ok(state) => state,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let Some(run_record) = run_state.run else {
|
|
return ApiError::not_found("Run not found.").into_response();
|
|
};
|
|
let redacted = settings_view::redact_for_api(&run_record.settings);
|
|
let mut value = match serde_json::to_value(&redacted) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
strip_nulls(&mut value);
|
|
(StatusCode::OK, Json(value)).into_response()
|
|
}
|
|
|
|
async fn get_questions(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => {
|
|
let questions = run_state
|
|
.pending_interviews
|
|
.values()
|
|
.map(api_question_from_pending_interview)
|
|
.collect::<Vec<_>>();
|
|
(StatusCode::OK, Json(ListResponse::new(questions))).into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(fabro_store::Error::RunNotFound(_)) => {
|
|
ApiError::not_found("Run not found.").into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn submit_answer(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path((id, qid)): Path<(String, String)>,
|
|
Json(req): Json<SubmitAnswerRequest>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let pending = match load_pending_interview(state.as_ref(), id, &qid).await {
|
|
Ok(pending) => pending,
|
|
Err(response) => return response,
|
|
};
|
|
let answer = match answer_from_request(req, &pending.question) {
|
|
Ok(answer) => answer,
|
|
Err(response) => return response,
|
|
};
|
|
match submit_pending_interview_answer(state.as_ref(), &pending, answer).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(response) => response,
|
|
}
|
|
}
|
|
|
|
async fn get_run_state(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => Json(run_state).into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(_) => ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn append_run_event(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Json(value): Json<serde_json::Value>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let event = match RunEvent::from_value(value.clone()) {
|
|
Ok(event) => event,
|
|
Err(err) => {
|
|
return ApiError::bad_request(format!("Invalid run event: {err}")).into_response();
|
|
}
|
|
};
|
|
if event.run_id != id {
|
|
return ApiError::bad_request("Event run_id does not match path run ID.").into_response();
|
|
}
|
|
if let Some(denied) = denied_lifecycle_event_name(&event.body) {
|
|
return ApiError::bad_request(format!(
|
|
"{denied} is a lifecycle event; clients must call the corresponding operation endpoint instead of injecting it via append_run_event"
|
|
))
|
|
.into_response();
|
|
}
|
|
let payload = match EventPayload::new(value, &id) {
|
|
Ok(payload) => payload,
|
|
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
|
};
|
|
|
|
match state.store.open_run(&id).await {
|
|
Ok(run_store) => match run_store.append_event(&payload).await {
|
|
Ok(seq) => {
|
|
update_live_run_from_event(&state, id, &event);
|
|
Json(AppendEventResponse {
|
|
seq: i64::from(seq),
|
|
})
|
|
.into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(_) => ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_run_events(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<EventListParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let since_seq = params.since_seq();
|
|
let limit = params.limit();
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store
|
|
.list_events_from_with_limit(since_seq, limit)
|
|
.await
|
|
{
|
|
Ok(mut events) => {
|
|
let has_more = events.len() > limit;
|
|
events.truncate(limit);
|
|
let mut data = Vec::with_capacity(events.len());
|
|
for event in events {
|
|
let event = match api_event_envelope_from_store(&event) {
|
|
Ok(event) => event,
|
|
Err(response) => return response,
|
|
};
|
|
data.push(event);
|
|
}
|
|
Json(PaginatedEventList {
|
|
data,
|
|
meta: PaginationMeta { has_more },
|
|
})
|
|
.into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(_) => ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn attach_run_events(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<AttachParams>,
|
|
) -> Response {
|
|
const ATTACH_REPLAY_BATCH_LIMIT: usize = 256;
|
|
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let Ok(run_store) = state.store.open_run_reader(&id).await else {
|
|
return ApiError::not_found("Run not found.").into_response();
|
|
};
|
|
let start_seq = match params.since_seq {
|
|
Some(seq) if seq >= 1 => seq,
|
|
Some(_) => 1,
|
|
None => match run_store.list_events().await {
|
|
Ok(events) => events.last().map_or(1, |event| event.seq.saturating_add(1)),
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
},
|
|
};
|
|
let (sender, receiver) = mpsc::unbounded_channel();
|
|
tokio::spawn(async move {
|
|
let mut next_seq = start_seq;
|
|
|
|
loop {
|
|
let Ok(replay_batch) = run_store
|
|
.list_events_from_with_limit(next_seq, ATTACH_REPLAY_BATCH_LIMIT)
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let replay_has_more = replay_batch.len() > ATTACH_REPLAY_BATCH_LIMIT;
|
|
|
|
for event in replay_batch.into_iter().take(ATTACH_REPLAY_BATCH_LIMIT) {
|
|
next_seq = event.seq.saturating_add(1);
|
|
let terminal = attach_event_is_terminal(&event);
|
|
if let Some(sse_event) = sse_event_from_store(&event) {
|
|
if sender
|
|
.send(Ok::<Event, std::convert::Infallible>(sse_event))
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if terminal {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if replay_has_more {
|
|
continue;
|
|
}
|
|
|
|
let Ok(state) = run_store.state().await else {
|
|
return;
|
|
};
|
|
|
|
if run_projection_is_active(&state) {
|
|
break;
|
|
}
|
|
|
|
let Ok(tail_batch) = run_store
|
|
.list_events_from_with_limit(next_seq, ATTACH_REPLAY_BATCH_LIMIT)
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let tail_has_more = tail_batch.len() > ATTACH_REPLAY_BATCH_LIMIT;
|
|
|
|
for event in tail_batch.into_iter().take(ATTACH_REPLAY_BATCH_LIMIT) {
|
|
next_seq = event.seq.saturating_add(1);
|
|
let terminal = attach_event_is_terminal(&event);
|
|
if let Some(sse_event) = sse_event_from_store(&event) {
|
|
if sender
|
|
.send(Ok::<Event, std::convert::Infallible>(sse_event))
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if terminal {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if tail_has_more {
|
|
continue;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
let Ok(mut live_stream) = run_store.watch_events_from(next_seq) else {
|
|
return;
|
|
};
|
|
|
|
while let Some(result) = live_stream.next().await {
|
|
let Ok(event) = result else {
|
|
return;
|
|
};
|
|
let terminal = attach_event_is_terminal(&event);
|
|
if let Some(sse_event) = sse_event_from_store(&event) {
|
|
if sender
|
|
.send(Ok::<Event, std::convert::Infallible>(sse_event))
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if terminal {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
Sse::new(UnboundedReceiverStream::new(receiver))
|
|
.keep_alive(KeepAlive::default())
|
|
.into_response()
|
|
}
|
|
|
|
async fn get_checkpoint(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let live_checkpoint = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get(&id) {
|
|
Some(managed_run) => managed_run.checkpoint.clone(),
|
|
None => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
if let Some(cp) = live_checkpoint {
|
|
return (StatusCode::OK, Json(cp)).into_response();
|
|
}
|
|
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => match run_state.checkpoint {
|
|
Some(cp) => (StatusCode::OK, Json(cp)).into_response(),
|
|
None => (StatusCode::OK, Json(serde_json::json!(null))).into_response(),
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %id, error = %err, "Failed to load checkpoint state from store");
|
|
(StatusCode::OK, Json(serde_json::json!(null))).into_response()
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!(run_id = %id, error = %err, "Failed to open run store reader");
|
|
ApiError::not_found("Run not found.").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn write_run_blob(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
match state.store.open_run(&id).await {
|
|
Ok(run_store) => match run_store.write_blob(&body).await {
|
|
Ok(blob_id) => Json(WriteBlobResponse {
|
|
id: blob_id.to_string(),
|
|
})
|
|
.into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(_) => ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn read_run_blob(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path((id, blob_id)): Path<(String, String)>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let blob_id = match parse_blob_id_path(&blob_id) {
|
|
Ok(blob_id) => blob_id,
|
|
Err(response) => return response,
|
|
};
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.read_blob(&blob_id).await {
|
|
Ok(Some(bytes)) => octet_stream_response(bytes),
|
|
Ok(None) => ApiError::not_found("Blob not found.").into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
},
|
|
Err(_) => ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn load_run_record(
|
|
state: &AppState,
|
|
run_id: &RunId,
|
|
) -> Result<fabro_types::RunRecord, Response> {
|
|
let run_store = state
|
|
.store
|
|
.open_run_reader(run_id)
|
|
.await
|
|
.map_err(|_| ApiError::not_found("Run not found.").into_response())?;
|
|
let run_state = run_store.state().await.map_err(|err| {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
})?;
|
|
run_state.run.ok_or_else(|| {
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"run record missing from store",
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
async fn list_run_artifacts(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
|
|
match state.artifact_store.list_for_run(&id).await {
|
|
Ok(entries) => Json(RunArtifactListResponse {
|
|
data: entries
|
|
.into_iter()
|
|
.map(|entry| RunArtifactEntry {
|
|
stage_id: entry.node.to_string(),
|
|
node_slug: entry.node.node_id().to_string(),
|
|
retry: entry.node.visit().cast_signed(),
|
|
relative_path: entry.filename,
|
|
size: entry.size.cast_signed(),
|
|
})
|
|
.collect(),
|
|
})
|
|
.into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn list_stage_artifacts(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path((id, stage_id)): Path<(String, String)>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let stage_id = match parse_stage_id_path(&stage_id) {
|
|
Ok(stage_id) => stage_id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
|
|
match state.artifact_store.list_for_node(&id, &stage_id).await {
|
|
Ok(filenames) => Json(ArtifactListResponse {
|
|
data: filenames
|
|
.into_iter()
|
|
.map(|filename| ArtifactEntry { filename })
|
|
.collect(),
|
|
})
|
|
.into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ArtifactUploadContentType {
|
|
OctetStream,
|
|
Multipart { boundary: String },
|
|
}
|
|
|
|
struct ValidatedArtifactBatchEntry {
|
|
path: String,
|
|
sha256: Option<String>,
|
|
expected_bytes: Option<u64>,
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Upload content-type parsing returns HTTP client errors directly."
|
|
)]
|
|
fn artifact_upload_content_type(
|
|
headers: &HeaderMap,
|
|
) -> Result<ArtifactUploadContentType, Response> {
|
|
let value = headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.ok_or_else(|| {
|
|
ApiError::new(
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
"artifact uploads require a supported Content-Type",
|
|
)
|
|
.into_response()
|
|
})?;
|
|
|
|
let mime = value.split(';').next().unwrap_or(value).trim();
|
|
match mime {
|
|
"application/octet-stream" => Ok(ArtifactUploadContentType::OctetStream),
|
|
"multipart/form-data" => multer::parse_boundary(value)
|
|
.map(|boundary| ArtifactUploadContentType::Multipart { boundary })
|
|
.map_err(|err| bad_request_response(format!("invalid multipart boundary: {err}"))),
|
|
_ => Err(ApiError::new(
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
"artifact uploads only support application/octet-stream or multipart/form-data",
|
|
)
|
|
.into_response()),
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Content-Length parsing returns HTTP client errors directly."
|
|
)]
|
|
fn content_length_from_headers(headers: &HeaderMap) -> Result<Option<u64>, Response> {
|
|
headers
|
|
.get(header::CONTENT_LENGTH)
|
|
.map(|value| {
|
|
value
|
|
.to_str()
|
|
.map_err(|err| {
|
|
bad_request_response(format!("invalid content-length header: {err}"))
|
|
})
|
|
.and_then(|value| {
|
|
value.parse::<u64>().map_err(|err| {
|
|
bad_request_response(format!("invalid content-length header: {err}"))
|
|
})
|
|
})
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Multipart manifest parsing returns HTTP client errors directly."
|
|
)]
|
|
async fn read_multipart_manifest(
|
|
field: &mut multer::Field<'_>,
|
|
) -> Result<ArtifactBatchUploadManifest, Response> {
|
|
let mut manifest_bytes = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))?
|
|
{
|
|
manifest_bytes.extend_from_slice(&chunk);
|
|
if manifest_bytes.len() > MAX_MULTIPART_MANIFEST_BYTES {
|
|
return Err(payload_too_large_response(
|
|
"multipart manifest exceeds the server limit",
|
|
));
|
|
}
|
|
}
|
|
|
|
serde_json::from_slice(&manifest_bytes)
|
|
.map_err(|err| bad_request_response(format!("invalid multipart manifest: {err}")))
|
|
}
|
|
|
|
#[allow(
|
|
clippy::result_large_err,
|
|
reason = "Artifact batch validation returns HTTP client errors directly."
|
|
)]
|
|
fn validate_artifact_batch_manifest(
|
|
manifest: ArtifactBatchUploadManifest,
|
|
) -> Result<HashMap<String, ValidatedArtifactBatchEntry>, Response> {
|
|
if manifest.entries.is_empty() {
|
|
return Err(bad_request_response(
|
|
"multipart manifest must include at least one artifact entry",
|
|
));
|
|
}
|
|
if manifest.entries.len() > MAX_MULTIPART_ARTIFACTS {
|
|
return Err(payload_too_large_response(format!(
|
|
"multipart upload exceeds the {MAX_MULTIPART_ARTIFACTS} artifact limit"
|
|
)));
|
|
}
|
|
|
|
let mut entries = HashMap::with_capacity(manifest.entries.len());
|
|
let mut seen_paths = HashSet::new();
|
|
let mut expected_total_bytes = 0_u64;
|
|
|
|
for entry in manifest.entries {
|
|
if entry.part.is_empty() {
|
|
return Err(bad_request_response(
|
|
"multipart manifest part names must not be empty",
|
|
));
|
|
}
|
|
if entry.part == "manifest" {
|
|
return Err(bad_request_response(
|
|
"multipart manifest part name 'manifest' is reserved",
|
|
));
|
|
}
|
|
let path = validate_relative_artifact_path("manifest path", &entry.path)?;
|
|
if !seen_paths.insert(path.clone()) {
|
|
return Err(bad_request_response(format!(
|
|
"duplicate artifact path in multipart manifest: {path}"
|
|
)));
|
|
}
|
|
if let Some(sha256) = entry.sha256.as_ref() {
|
|
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
return Err(bad_request_response(format!(
|
|
"invalid sha256 for multipart part {}",
|
|
entry.part
|
|
)));
|
|
}
|
|
}
|
|
if let Some(expected_bytes) = entry.expected_bytes {
|
|
if expected_bytes > MAX_SINGLE_ARTIFACT_BYTES {
|
|
return Err(payload_too_large_response(format!(
|
|
"artifact {path} exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
|
|
)));
|
|
}
|
|
expected_total_bytes = expected_total_bytes.saturating_add(expected_bytes);
|
|
if expected_total_bytes > MAX_MULTIPART_REQUEST_BYTES {
|
|
return Err(payload_too_large_response(format!(
|
|
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
|
|
)));
|
|
}
|
|
}
|
|
if entries
|
|
.insert(entry.part.clone(), ValidatedArtifactBatchEntry {
|
|
path,
|
|
sha256: entry.sha256.map(|value| value.to_ascii_lowercase()),
|
|
expected_bytes: entry.expected_bytes,
|
|
})
|
|
.is_some()
|
|
{
|
|
return Err(bad_request_response(format!(
|
|
"duplicate multipart part name in manifest: {}",
|
|
entry.part
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
async fn upload_stage_artifact_octet_stream(
|
|
state: &AppState,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
filename: String,
|
|
body: Body,
|
|
content_length: Option<u64>,
|
|
) -> Response {
|
|
let relative_path = match validate_relative_artifact_path("filename", &filename) {
|
|
Ok(path) => path,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
if content_length.is_some_and(|length| length > MAX_SINGLE_ARTIFACT_BYTES) {
|
|
return payload_too_large_response(format!(
|
|
"artifact exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
|
|
));
|
|
}
|
|
|
|
let mut writer = match state
|
|
.artifact_store
|
|
.writer(run_id, stage_id, &relative_path)
|
|
{
|
|
Ok(writer) => writer,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let mut bytes_written = 0_u64;
|
|
let mut data_stream = body.into_data_stream();
|
|
while let Some(chunk) = data_stream.next().await {
|
|
let chunk = match chunk
|
|
.map_err(|err| bad_request_response(format!("invalid request body: {err}")))
|
|
{
|
|
Ok(chunk) => chunk,
|
|
Err(response) => return response,
|
|
};
|
|
bytes_written =
|
|
bytes_written.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
|
|
if bytes_written > MAX_SINGLE_ARTIFACT_BYTES {
|
|
return payload_too_large_response(format!(
|
|
"artifact exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
|
|
));
|
|
}
|
|
if let Err(err) = writer.write_all(&chunk).await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
match writer.shutdown().await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn upload_stage_artifact_multipart(
|
|
state: &AppState,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
boundary: String,
|
|
body: Body,
|
|
) -> Response {
|
|
let mut multipart = multer::Multipart::new(body.into_data_stream(), boundary);
|
|
let Some(mut manifest_field) = (match multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
|
|
{
|
|
Ok(field) => field,
|
|
Err(response) => return response,
|
|
}) else {
|
|
return bad_request_response("multipart upload must begin with a manifest part");
|
|
};
|
|
|
|
if manifest_field.name() != Some("manifest") {
|
|
return bad_request_response("multipart upload must begin with a manifest part");
|
|
}
|
|
|
|
let manifest = match read_multipart_manifest(&mut manifest_field).await {
|
|
Ok(manifest) => manifest,
|
|
Err(response) => return response,
|
|
};
|
|
drop(manifest_field);
|
|
let mut expected_parts = match validate_artifact_batch_manifest(manifest) {
|
|
Ok(entries) => entries,
|
|
Err(response) => return response,
|
|
};
|
|
let mut total_bytes = 0_u64;
|
|
|
|
while let Some(mut field) = match multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
|
|
{
|
|
Ok(field) => field,
|
|
Err(response) => return response,
|
|
} {
|
|
let Some(part_name) = field.name().map(ToOwned::to_owned) else {
|
|
return bad_request_response("multipart file parts must be named");
|
|
};
|
|
let Some(entry) = expected_parts.remove(&part_name) else {
|
|
return bad_request_response(format!("unexpected multipart part: {part_name}"));
|
|
};
|
|
|
|
let mut writer = match state.artifact_store.writer(run_id, stage_id, &entry.path) {
|
|
Ok(writer) => writer,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let mut bytes_written = 0_u64;
|
|
let mut sha256 = Sha256::new();
|
|
|
|
while let Some(chunk) = match field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
|
|
{
|
|
Ok(chunk) => chunk,
|
|
Err(response) => return response,
|
|
} {
|
|
let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
|
|
bytes_written = bytes_written.saturating_add(chunk_len);
|
|
total_bytes = total_bytes.saturating_add(chunk_len);
|
|
|
|
if bytes_written > MAX_SINGLE_ARTIFACT_BYTES {
|
|
return payload_too_large_response(format!(
|
|
"artifact {} exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit",
|
|
entry.path
|
|
));
|
|
}
|
|
if total_bytes > MAX_MULTIPART_REQUEST_BYTES {
|
|
return payload_too_large_response(format!(
|
|
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
|
|
));
|
|
}
|
|
|
|
sha256.update(&chunk);
|
|
if let Err(err) = writer.write_all(&chunk).await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if let Some(expected_bytes) = entry.expected_bytes {
|
|
if bytes_written != expected_bytes {
|
|
return bad_request_response(format!(
|
|
"multipart part {part_name} expected {expected_bytes} bytes but received {bytes_written}"
|
|
));
|
|
}
|
|
}
|
|
if let Some(expected_sha256) = entry.sha256.as_ref() {
|
|
let actual_sha256 = hex::encode(sha256.finalize());
|
|
if actual_sha256 != *expected_sha256 {
|
|
return bad_request_response(format!(
|
|
"multipart part {part_name} sha256 did not match manifest"
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Err(err) = writer.shutdown().await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if !expected_parts.is_empty() {
|
|
let mut missing = expected_parts.into_keys().collect::<Vec<_>>();
|
|
missing.sort();
|
|
return bad_request_response(format!(
|
|
"multipart upload is missing part(s): {}",
|
|
missing.join(", ")
|
|
));
|
|
}
|
|
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
async fn put_stage_artifact(
|
|
State(state): State<Arc<AppState>>,
|
|
Path((id, stage_id)): Path<(String, String)>,
|
|
Query(params): Query<ArtifactFilenameParams>,
|
|
request: axum_extract::Request,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let stage_id = match parse_stage_id_path(&stage_id) {
|
|
Ok(stage_id) => stage_id,
|
|
Err(response) => return response,
|
|
};
|
|
let (parts, body) = request.into_parts();
|
|
|
|
if let Err(err) = authorize_artifact_upload(&parts, state.as_ref(), &id) {
|
|
return err.into_response();
|
|
}
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
if let Err(response) = load_run_record(state.as_ref(), &id).await.map(|_| ()) {
|
|
return response;
|
|
}
|
|
|
|
let content_length = match content_length_from_headers(&parts.headers) {
|
|
Ok(length) => length,
|
|
Err(response) => return response,
|
|
};
|
|
match artifact_upload_content_type(&parts.headers) {
|
|
Ok(ArtifactUploadContentType::OctetStream) => {
|
|
let filename = match required_filename(params) {
|
|
Ok(filename) => filename,
|
|
Err(response) => return response,
|
|
};
|
|
upload_stage_artifact_octet_stream(
|
|
state.as_ref(),
|
|
&id,
|
|
&stage_id,
|
|
filename,
|
|
body,
|
|
content_length,
|
|
)
|
|
.await
|
|
}
|
|
Ok(ArtifactUploadContentType::Multipart { boundary }) => {
|
|
if content_length.is_some_and(|length| length > MAX_MULTIPART_REQUEST_BYTES) {
|
|
return payload_too_large_response(format!(
|
|
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
|
|
));
|
|
}
|
|
upload_stage_artifact_multipart(state.as_ref(), &id, &stage_id, boundary, body).await
|
|
}
|
|
Err(response) => response,
|
|
}
|
|
}
|
|
|
|
async fn get_stage_artifact(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path((id, stage_id)): Path<(String, String)>,
|
|
Query(params): Query<ArtifactFilenameParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let stage_id = match parse_stage_id_path(&stage_id) {
|
|
Ok(stage_id) => stage_id,
|
|
Err(response) => return response,
|
|
};
|
|
let filename = match required_filename(params) {
|
|
Ok(filename) => filename,
|
|
Err(response) => return response,
|
|
};
|
|
let relative_path = match validate_relative_artifact_path("filename", &filename) {
|
|
Ok(path) => path,
|
|
Err(response) => return response,
|
|
};
|
|
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
|
|
match state
|
|
.artifact_store
|
|
.get(&id, &stage_id, &relative_path)
|
|
.await
|
|
{
|
|
Ok(Some(bytes)) => octet_stream_response(bytes),
|
|
Ok(None) => ApiError::not_found("Artifact not found.").into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn generate_preview_url(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Json(request): Json<PreviewUrlRequest>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let Ok(port) = u16::try_from(request.port) else {
|
|
return ApiError::bad_request("Port must fit in a u16.").into_response();
|
|
};
|
|
let Ok(expires_in_secs) = i32::try_from(request.expires_in_secs.get()) else {
|
|
return ApiError::bad_request("Preview expiry exceeds supported range.").into_response();
|
|
};
|
|
|
|
let sandbox = match reconnect_daytona_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let response = if request.signed {
|
|
match sandbox
|
|
.get_signed_preview_url(port, Some(expires_in_secs))
|
|
.await
|
|
{
|
|
Ok(preview) => PreviewUrlResponse {
|
|
token: None,
|
|
url: preview.url,
|
|
},
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err).into_response();
|
|
}
|
|
}
|
|
} else {
|
|
match sandbox.get_preview_link(port).await {
|
|
Ok(preview) => PreviewUrlResponse {
|
|
token: Some(preview.token),
|
|
url: preview.url,
|
|
},
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err).into_response();
|
|
}
|
|
}
|
|
};
|
|
|
|
(StatusCode::CREATED, Json(response)).into_response()
|
|
}
|
|
|
|
async fn create_ssh_access(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Json(request): Json<SshAccessRequest>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_daytona_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
match sandbox.create_ssh_access(Some(request.ttl_minutes)).await {
|
|
Ok(command) => (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response(),
|
|
Err(err) => ApiError::new(StatusCode::CONFLICT, err).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_sandbox_files(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFilesParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
match sandbox.list_directory(¶ms.path, params.depth).await {
|
|
Ok(entries) => Json(SandboxFileListResponse {
|
|
data: entries
|
|
.into_iter()
|
|
.map(|entry| SandboxFileEntry {
|
|
is_dir: entry.is_dir,
|
|
name: entry.name,
|
|
size: entry.size.map(u64::cast_signed),
|
|
})
|
|
.collect(),
|
|
})
|
|
.into_response(),
|
|
Err(err) => ApiError::new(StatusCode::NOT_FOUND, err).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_sandbox_file(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFileParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let temp = match NamedTempFile::new() {
|
|
Ok(temp) => temp,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
if let Err(err) = sandbox
|
|
.download_file_to_local(¶ms.path, temp.path())
|
|
.await
|
|
{
|
|
return ApiError::new(StatusCode::NOT_FOUND, err).into_response();
|
|
}
|
|
match fs::read(temp.path()).await {
|
|
Ok(bytes) => octet_stream_response(bytes.into()),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn put_sandbox_file(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFileParams>,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let temp = match NamedTempFile::new() {
|
|
Ok(temp) => temp,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
if let Err(err) = fs::write(temp.path(), &body).await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
|
}
|
|
match sandbox
|
|
.upload_file_from_local(temp.path(), ¶ms.path)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn reconnect_run_sandbox(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
) -> Result<Box<dyn Sandbox>, Response> {
|
|
let record = load_run_sandbox_record(state, run_id).await?;
|
|
let daytona_api_key = state.vault_or_env("DAYTONA_API_KEY");
|
|
reconnect(&record, daytona_api_key)
|
|
.await
|
|
.map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err}")).into_response())
|
|
}
|
|
|
|
async fn reconnect_daytona_sandbox(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
) -> Result<DaytonaSandbox, Response> {
|
|
let record = load_run_sandbox_record(state, run_id).await?;
|
|
if record.provider != SandboxProvider::Daytona.to_string() {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Sandbox provider does not support this capability.",
|
|
)
|
|
.into_response());
|
|
}
|
|
let Some(name) = record.identifier.as_deref() else {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Sandbox record is missing the Daytona identifier.",
|
|
)
|
|
.into_response());
|
|
};
|
|
let daytona_api_key = state.vault_or_env("DAYTONA_API_KEY");
|
|
DaytonaSandbox::reconnect(name, daytona_api_key)
|
|
.await
|
|
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.clone()).into_response())
|
|
}
|
|
|
|
async fn load_run_sandbox_record(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
) -> Result<fabro_types::SandboxRecord, Response> {
|
|
match state.store.open_run_reader(run_id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => run_state.sandbox.ok_or_else(|| {
|
|
ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox.").into_response()
|
|
}),
|
|
Err(err) => Err(
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
),
|
|
},
|
|
Err(_) => Err(ApiError::not_found("Run not found.").into_response()),
|
|
}
|
|
}
|
|
|
|
async fn append_control_request(
|
|
state: &AppState,
|
|
run_id: RunId,
|
|
action: RunControlAction,
|
|
actor: Option<ActorRef>,
|
|
) -> anyhow::Result<()> {
|
|
let run_store = state.store.open_run(&run_id).await?;
|
|
let event = match action {
|
|
RunControlAction::Cancel => workflow_event::Event::RunCancelRequested { actor },
|
|
RunControlAction::Pause => workflow_event::Event::RunPauseRequested { actor },
|
|
RunControlAction::Unpause => workflow_event::Event::RunUnpauseRequested { actor },
|
|
};
|
|
workflow_event::append_event(&run_store, &run_id, &event).await
|
|
}
|
|
|
|
fn actor_from_subject(subject: &AuthenticatedSubject) -> Option<ActorRef> {
|
|
subject.login.clone().map(ActorRef::user)
|
|
}
|
|
|
|
/// Returns the wire event name if the given body has a dedicated operation
|
|
/// endpoint that clients must use instead of injecting via `append_run_event`.
|
|
/// These endpoints enforce authorization and status-transition preconditions
|
|
/// (e.g. "archive only from terminal") that a direct event append would
|
|
/// bypass. Other run-lifecycle events flow through this endpoint legitimately:
|
|
/// the worker subprocess emits state transitions during execution, and the
|
|
/// rewind CLI relays `RunRewound` / `RunSubmitted` here.
|
|
fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> {
|
|
match body {
|
|
EventBody::RunArchived(_) => Some("run.archived"),
|
|
EventBody::RunUnarchived(_) => Some("run.unarchived"),
|
|
EventBody::RunCancelRequested(_) => Some("run.cancel.requested"),
|
|
EventBody::RunPauseRequested(_) => Some("run.pause.requested"),
|
|
EventBody::RunUnpauseRequested(_) => Some("run.unpause.requested"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Returns a 409 response with an actionable "unarchive first" message if the
|
|
/// run is currently archived. Returns `None` otherwise (including when the run
|
|
/// doesn't exist — the caller's own not-found handling will surface that).
|
|
async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option<Response> {
|
|
let run_store = state.store.open_run_reader(run_id).await.ok()?;
|
|
let projection = run_store.state().await.ok()?;
|
|
let status = projection.status.as_ref()?.status;
|
|
(status == WorkflowRunStatus::Archived).then(|| {
|
|
ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
operations::archived_rejection_message(run_id),
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
|
|
tokio::spawn(async move {
|
|
sleep(WORKER_CANCEL_GRACE).await;
|
|
let current_pid = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.get(&run_id).and_then(|run| run.worker_pid)
|
|
};
|
|
if current_pid == Some(worker_pid) && fabro_proc::process_group_alive(worker_pid) {
|
|
#[cfg(unix)]
|
|
fabro_proc::sigkill_process_group(worker_pid);
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn cancel_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
|
Ok(pending_control) => pending_control,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let (
|
|
created_at,
|
|
response_status,
|
|
persist_cancelled_status,
|
|
answer_transport,
|
|
cancel_token,
|
|
cancel_tx,
|
|
worker_pid,
|
|
) = {
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get_mut(&id) {
|
|
Some(managed_run) => match managed_run.status {
|
|
RunStatus::Submitted
|
|
| RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused => {
|
|
let use_cancel_signal = !matches!(
|
|
managed_run.answer_transport,
|
|
Some(RunAnswerTransport::InProcess { .. })
|
|
);
|
|
let persist_cancelled_status =
|
|
matches!(managed_run.status, RunStatus::Submitted | RunStatus::Queued);
|
|
let response_status = if persist_cancelled_status {
|
|
managed_run.status = RunStatus::Failed;
|
|
RunStatus::Failed
|
|
} else {
|
|
managed_run.status
|
|
};
|
|
(
|
|
managed_run.created_at,
|
|
response_status,
|
|
persist_cancelled_status,
|
|
managed_run.answer_transport.clone(),
|
|
managed_run.cancel_token.clone(),
|
|
use_cancel_signal
|
|
.then(|| managed_run.cancel_tx.take())
|
|
.flatten(),
|
|
managed_run.worker_pid,
|
|
)
|
|
}
|
|
_ => {
|
|
return ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.")
|
|
.into_response();
|
|
}
|
|
},
|
|
None => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
|
|
if pending_control != Some(RunControlAction::Cancel) {
|
|
if let Err(err) = append_control_request(
|
|
state.as_ref(),
|
|
id,
|
|
RunControlAction::Cancel,
|
|
actor_from_subject(&subject),
|
|
)
|
|
.await
|
|
{
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if let Some(token) = &cancel_token {
|
|
token.store(true, Ordering::SeqCst);
|
|
}
|
|
let sent_cancel_signal = if let Some(cancel_tx) = cancel_tx {
|
|
let _ = cancel_tx.send(());
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
if let Some(answer_transport) = answer_transport {
|
|
if !(sent_cancel_signal && matches!(answer_transport, RunAnswerTransport::InProcess { .. }))
|
|
{
|
|
let _ = answer_transport.cancel_run().await;
|
|
}
|
|
}
|
|
if let Some(worker_pid) = worker_pid {
|
|
#[cfg(unix)]
|
|
fabro_proc::sigterm(worker_pid);
|
|
schedule_worker_kill(Arc::clone(&state), id, worker_pid);
|
|
}
|
|
|
|
if persist_cancelled_status {
|
|
if let Err(err) = persist_cancelled_run_status(state.as_ref(), id).await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
}
|
|
let (status_reason, blocked_reason, pending_control) =
|
|
load_run_status_metadata(state.as_ref(), id).await;
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(RunStatusResponse {
|
|
blocked_reason,
|
|
id: id.to_string(),
|
|
status: response_status,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason,
|
|
pending_control,
|
|
created_at,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// How `pause_run` should enact the transition, chosen from the current run
|
|
/// status.
|
|
enum PauseMode {
|
|
/// Worker is running; ask it to pause via SIGUSR1. Status flips to
|
|
/// `Paused` once the worker acknowledges.
|
|
Signal { worker_pid: u32 },
|
|
/// Worker is blocked on a human gate; flip to `Paused` directly by
|
|
/// appending `RunPaused` ourselves.
|
|
AppendEvent,
|
|
}
|
|
|
|
/// How `unpause_run` should enact the transition.
|
|
enum UnpauseMode {
|
|
/// No outstanding block; ask the worker to resume via SIGUSR2.
|
|
Signal { worker_pid: u32 },
|
|
/// Was paused while blocked; append `RunUnpaused` then re-assert
|
|
/// `RunBlocked` so the projection reports `Blocked`.
|
|
AppendEvents { blocked_reason: BlockedReason },
|
|
}
|
|
|
|
async fn pause_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
|
Ok(pending_control) => pending_control,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let (created_at, mode) = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get(&id) {
|
|
Some(managed_run) if managed_run.status == RunStatus::Running => {
|
|
let Some(worker_pid) = managed_run.worker_pid else {
|
|
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
|
|
.into_response();
|
|
};
|
|
(managed_run.created_at, PauseMode::Signal { worker_pid })
|
|
}
|
|
Some(managed_run) if managed_run.status == RunStatus::Blocked => {
|
|
(managed_run.created_at, PauseMode::AppendEvent)
|
|
}
|
|
Some(_) => {
|
|
return ApiError::new(StatusCode::CONFLICT, "Run is not pausable.").into_response();
|
|
}
|
|
None => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
|
|
if pending_control.is_some() {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Run control request is already pending.",
|
|
)
|
|
.into_response();
|
|
}
|
|
if let Err(err) = append_control_request(
|
|
state.as_ref(),
|
|
id,
|
|
RunControlAction::Pause,
|
|
actor_from_subject(&subject),
|
|
)
|
|
.await
|
|
{
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
|
}
|
|
let response_status = match mode {
|
|
PauseMode::Signal { worker_pid } => {
|
|
#[cfg(unix)]
|
|
fabro_proc::sigusr1(worker_pid);
|
|
#[cfg(not(unix))]
|
|
let _ = worker_pid;
|
|
RunStatus::Running
|
|
}
|
|
PauseMode::AppendEvent => {
|
|
if let Some(response) =
|
|
synchronous_transition(state.as_ref(), id, RunStatus::Paused, |events| {
|
|
events.push(workflow_event::Event::RunPaused);
|
|
})
|
|
.await
|
|
{
|
|
return response;
|
|
}
|
|
RunStatus::Paused
|
|
}
|
|
};
|
|
let (status_reason, blocked_reason, pending_control) =
|
|
load_run_status_metadata(state.as_ref(), id).await;
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(RunStatusResponse {
|
|
id: id.to_string(),
|
|
blocked_reason,
|
|
status: response_status,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason,
|
|
pending_control,
|
|
created_at,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn unpause_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
|
Ok(pending_control) => pending_control,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let paused_blocked_reason = match state.store.runs().find(&id).await {
|
|
Ok(Some(summary)) => summary.blocked_reason,
|
|
Ok(None) => None,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let (created_at, mode) = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get(&id) {
|
|
Some(managed_run) if managed_run.status == RunStatus::Paused => {
|
|
let mode = if let Some(blocked_reason) = paused_blocked_reason {
|
|
UnpauseMode::AppendEvents { blocked_reason }
|
|
} else {
|
|
let Some(worker_pid) = managed_run.worker_pid else {
|
|
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
|
|
.into_response();
|
|
};
|
|
UnpauseMode::Signal { worker_pid }
|
|
};
|
|
(managed_run.created_at, mode)
|
|
}
|
|
Some(_) => {
|
|
return ApiError::new(StatusCode::CONFLICT, "Run is not paused.").into_response();
|
|
}
|
|
None => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
|
|
if pending_control.is_some() {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Run control request is already pending.",
|
|
)
|
|
.into_response();
|
|
}
|
|
if let Err(err) = append_control_request(
|
|
state.as_ref(),
|
|
id,
|
|
RunControlAction::Unpause,
|
|
actor_from_subject(&subject),
|
|
)
|
|
.await
|
|
{
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
|
}
|
|
let response_status = match mode {
|
|
UnpauseMode::Signal { worker_pid } => {
|
|
#[cfg(unix)]
|
|
fabro_proc::sigusr2(worker_pid);
|
|
#[cfg(not(unix))]
|
|
let _ = worker_pid;
|
|
RunStatus::Paused
|
|
}
|
|
UnpauseMode::AppendEvents { blocked_reason } => {
|
|
if let Some(response) =
|
|
synchronous_transition(state.as_ref(), id, RunStatus::Blocked, |events| {
|
|
events.push(workflow_event::Event::RunUnpaused);
|
|
events.push(workflow_event::Event::RunBlocked { blocked_reason });
|
|
})
|
|
.await
|
|
{
|
|
return response;
|
|
}
|
|
RunStatus::Blocked
|
|
}
|
|
};
|
|
let (status_reason, blocked_reason, pending_control) =
|
|
load_run_status_metadata(state.as_ref(), id).await;
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(RunStatusResponse {
|
|
id: id.to_string(),
|
|
blocked_reason,
|
|
status: response_status,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason,
|
|
pending_control,
|
|
created_at,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn archive_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
run_archive_action(state, subject, id, ArchiveAction::Archive).await
|
|
}
|
|
|
|
async fn unarchive_run(
|
|
subject: AuthenticatedSubject,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
run_archive_action(state, subject, id, ArchiveAction::Unarchive).await
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ArchiveAction {
|
|
Archive,
|
|
Unarchive,
|
|
}
|
|
|
|
async fn run_archive_action(
|
|
state: Arc<AppState>,
|
|
subject: AuthenticatedSubject,
|
|
id: String,
|
|
action: ArchiveAction,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let actor = actor_from_subject(&subject);
|
|
let result = match action {
|
|
ArchiveAction::Archive => operations::archive(&state.store, &id, actor)
|
|
.await
|
|
.map(|_| ()),
|
|
ArchiveAction::Unarchive => operations::unarchive(&state.store, &id, actor)
|
|
.await
|
|
.map(|_| ()),
|
|
};
|
|
match result {
|
|
Ok(()) => archive_status_response(state.as_ref(), id).await,
|
|
Err(WorkflowError::Precondition(message)) => {
|
|
ApiError::new(StatusCode::CONFLICT, message).into_response()
|
|
}
|
|
Err(WorkflowError::RunNotFound(_)) => ApiError::not_found("Run not found.").into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build a `RunStatusResponse` reflecting the durable projection after an
|
|
/// archive/unarchive transition. The run is terminal in both directions, so no
|
|
/// live queue position or worker-only fields apply.
|
|
async fn archive_status_response(state: &AppState, id: RunId) -> Response {
|
|
let Ok(run_store) = state.store.open_run_reader(&id).await else {
|
|
return ApiError::not_found("Run not found.").into_response();
|
|
};
|
|
let projection = match run_store.state().await {
|
|
Ok(projection) => projection,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
let Some(record) = projection.status.as_ref() else {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"run has no status record after archive/unarchive",
|
|
)
|
|
.into_response();
|
|
};
|
|
let status = api_status_from_workflow(record.status);
|
|
let status_reason = record.status_reason.map(api_status_reason);
|
|
let blocked_reason = record.blocked_reason.map(api_blocked_reason);
|
|
(
|
|
StatusCode::OK,
|
|
Json(RunStatusResponse {
|
|
id: id.to_string(),
|
|
blocked_reason,
|
|
status,
|
|
error: None,
|
|
queue_position: None,
|
|
status_reason,
|
|
pending_control: None,
|
|
created_at: id.created_at(),
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Persist a synchronous pause/unpause transition: append the caller-supplied
|
|
/// events to the run store and mirror the new status in the in-memory run map.
|
|
/// Returns `Some(Response)` on error, `None` on success.
|
|
async fn synchronous_transition(
|
|
state: &AppState,
|
|
id: RunId,
|
|
new_status: RunStatus,
|
|
append_events: impl FnOnce(&mut Vec<workflow_event::Event>),
|
|
) -> Option<Response> {
|
|
let run_store = match state.store.open_run(&id).await {
|
|
Ok(run_store) => run_store,
|
|
Err(err) => {
|
|
return Some(
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
);
|
|
}
|
|
};
|
|
let mut events = Vec::new();
|
|
append_events(&mut events);
|
|
for event in events {
|
|
if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await {
|
|
return Some(
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
);
|
|
}
|
|
}
|
|
if let Ok(mut runs) = state.runs.lock() {
|
|
if let Some(managed_run) = runs.get_mut(&id) {
|
|
managed_run.status = new_status;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn list_models(
|
|
_auth: AuthenticatedService,
|
|
State(_state): State<Arc<AppState>>,
|
|
Query(params): Query<ModelListParams>,
|
|
) -> Response {
|
|
let provider = match params.provider.as_deref() {
|
|
Some(value) => match fabro_model::Provider::from_str(value) {
|
|
Ok(provider) => Some(provider),
|
|
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
|
|
},
|
|
None => None,
|
|
};
|
|
|
|
let query = params.query.as_ref().map(|value| value.to_lowercase());
|
|
let limit = params.limit.clamp(1, 100) as usize;
|
|
let offset = params.offset.min(MAX_PAGE_OFFSET) as usize;
|
|
|
|
let mut models = fabro_model::Catalog::builtin()
|
|
.list(provider)
|
|
.into_iter()
|
|
.filter(|model| match &query {
|
|
Some(query) => {
|
|
model.id.to_lowercase().contains(query)
|
|
|| model.display_name.to_lowercase().contains(query)
|
|
|| model
|
|
.aliases
|
|
.iter()
|
|
.any(|alias| alias.to_lowercase().contains(query))
|
|
}
|
|
None => true,
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
let has_more = models.len() > offset.saturating_add(limit);
|
|
let data = models.drain(offset..models.len().min(offset.saturating_add(limit)));
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"data": data.collect::<Vec<_>>(),
|
|
"meta": { "has_more": has_more }
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn test_model(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<ModelTestParams>,
|
|
) -> Response {
|
|
let mode = match params.mode.as_deref() {
|
|
Some(value) => match ModelTestMode::from_str(value) {
|
|
Ok(mode) => mode,
|
|
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
|
|
},
|
|
None => ModelTestMode::Basic,
|
|
};
|
|
let Some(info) = fabro_model::Catalog::builtin().get(&id) else {
|
|
return ApiError::not_found(format!("Model not found: {id}")).into_response();
|
|
};
|
|
|
|
let llm_result = match state.build_llm_client().await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to build LLM client: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
if let Some((_, issue)) = llm_result
|
|
.auth_issues
|
|
.iter()
|
|
.find(|(provider, _)| *provider == info.provider)
|
|
{
|
|
return ApiError::bad_request(auth_issue_message(info.provider, issue)).into_response();
|
|
}
|
|
if !llm_result
|
|
.client
|
|
.provider_names()
|
|
.contains(&info.provider.as_str())
|
|
{
|
|
return Json(serde_json::json!({
|
|
"model_id": info.id,
|
|
"status": "skip",
|
|
}))
|
|
.into_response();
|
|
}
|
|
let client = Arc::new(llm_result.client);
|
|
|
|
let outcome = run_model_test_with_client(info, mode, client).await;
|
|
Json(serde_json::json!({
|
|
"model_id": info.id,
|
|
"status": outcome.status.as_str(),
|
|
"error_message": outcome.error_message,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
|
|
match reason {
|
|
FinishReason::Stop => "end_turn".to_string(),
|
|
FinishReason::Length => "max_tokens".to_string(),
|
|
FinishReason::ToolCalls => "tool_calls".to_string(),
|
|
FinishReason::ContentFilter => "content_filter".to_string(),
|
|
FinishReason::Error => "error".to_string(),
|
|
FinishReason::Other(s) => s.clone(),
|
|
}
|
|
}
|
|
|
|
fn convert_api_message(msg: &CompletionMessage) -> LlmMessage {
|
|
let role = match msg.role {
|
|
CompletionMessageRole::System => Role::System,
|
|
CompletionMessageRole::User => Role::User,
|
|
CompletionMessageRole::Assistant => Role::Assistant,
|
|
CompletionMessageRole::Tool => Role::Tool,
|
|
CompletionMessageRole::Developer => Role::Developer,
|
|
};
|
|
let content: Vec<ContentPart> = msg
|
|
.content
|
|
.iter()
|
|
.filter_map(|part| {
|
|
let json = serde_json::to_value(part).ok()?;
|
|
serde_json::from_value(json).ok()
|
|
})
|
|
.collect();
|
|
LlmMessage {
|
|
role,
|
|
content,
|
|
name: msg.name.clone(),
|
|
tool_call_id: msg.tool_call_id.clone(),
|
|
}
|
|
}
|
|
|
|
fn convert_llm_message(msg: &LlmMessage) -> CompletionMessage {
|
|
let role = match msg.role {
|
|
Role::System => CompletionMessageRole::System,
|
|
Role::User => CompletionMessageRole::User,
|
|
Role::Assistant => CompletionMessageRole::Assistant,
|
|
Role::Tool => CompletionMessageRole::Tool,
|
|
Role::Developer => CompletionMessageRole::Developer,
|
|
};
|
|
let content: Vec<CompletionContentPart> = msg
|
|
.content
|
|
.iter()
|
|
.filter_map(|part| {
|
|
let json = serde_json::to_value(part).ok()?;
|
|
serde_json::from_value(json).ok()
|
|
})
|
|
.collect();
|
|
CompletionMessage {
|
|
role,
|
|
content,
|
|
name: msg.name.clone(),
|
|
tool_call_id: msg.tool_call_id.clone(),
|
|
}
|
|
}
|
|
|
|
async fn create_completion(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateCompletionRequest>,
|
|
) -> Response {
|
|
// Resolve model
|
|
let model_id = req.model.unwrap_or_else(|| {
|
|
fabro_model::Catalog::builtin()
|
|
.list(None)
|
|
.first()
|
|
.map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone())
|
|
});
|
|
|
|
let catalog_info = fabro_model::Catalog::builtin().get(&model_id);
|
|
|
|
// Resolve provider: explicit request > catalog > None
|
|
let provider_name = req
|
|
.provider
|
|
.or_else(|| catalog_info.map(|i| i.provider.to_string()));
|
|
|
|
info!(model = %model_id, provider = ?provider_name, "Completion request received");
|
|
|
|
// Build messages list
|
|
let mut messages: Vec<LlmMessage> = Vec::new();
|
|
if let Some(system) = req.system {
|
|
messages.push(LlmMessage::system(system));
|
|
}
|
|
for msg in &req.messages {
|
|
messages.push(convert_api_message(msg));
|
|
}
|
|
|
|
// Convert tools
|
|
let tools: Option<Vec<ToolDefinition>> = if req.tools.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
req.tools
|
|
.into_iter()
|
|
.map(|t| ToolDefinition {
|
|
name: t.name,
|
|
description: t.description,
|
|
parameters: t.parameters,
|
|
})
|
|
.collect(),
|
|
)
|
|
};
|
|
|
|
// Convert tool_choice
|
|
let tool_choice: Option<ToolChoice> = req.tool_choice.map(|tc| match tc.mode {
|
|
CompletionToolChoiceMode::Auto => ToolChoice::Auto,
|
|
CompletionToolChoiceMode::None => ToolChoice::None,
|
|
CompletionToolChoiceMode::Required => ToolChoice::Required,
|
|
CompletionToolChoiceMode::Named => ToolChoice::named(tc.tool_name.unwrap_or_default()),
|
|
});
|
|
|
|
// Build the LLM request
|
|
let request = LlmRequest {
|
|
model: model_id.clone(),
|
|
messages,
|
|
provider: provider_name,
|
|
tools,
|
|
tool_choice,
|
|
response_format: None,
|
|
temperature: req.temperature,
|
|
top_p: req.top_p,
|
|
max_tokens: req.max_tokens,
|
|
stop_sequences: if req.stop_sequences.is_empty() {
|
|
None
|
|
} else {
|
|
Some(req.stop_sequences)
|
|
},
|
|
reasoning_effort: req.reasoning_effort.as_deref().and_then(|s| s.parse().ok()),
|
|
speed: None,
|
|
metadata: None,
|
|
provider_options: req.provider_options,
|
|
};
|
|
|
|
// Force non-streaming for structured output
|
|
let use_stream = req.stream && req.schema.is_none();
|
|
|
|
// Get or create LLM client (cached in AppState)
|
|
let llm_result = match state.build_llm_client().await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
return ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to create LLM client: {err}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
for (provider, issue) in &llm_result.auth_issues {
|
|
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
|
|
}
|
|
let client = llm_result.client;
|
|
|
|
if use_stream {
|
|
// Streaming path: forward all StreamEvents as SSE
|
|
let stream_result = match client.stream(&request).await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
return ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let sse_stream = tokio_stream::StreamExt::filter_map(stream_result, |event| match event {
|
|
Ok(ref evt) => match serde_json::to_string(evt) {
|
|
Ok(json) => Some(Ok::<_, std::convert::Infallible>(
|
|
Event::default().event("stream_event").data(json),
|
|
)),
|
|
Err(e) => Some(Ok(Event::default().event("stream_event").data(
|
|
serde_json::json!({
|
|
"type": "error",
|
|
"error": {"Stream": {"message": format!("failed to serialize event: {e}")}},
|
|
"raw": null
|
|
})
|
|
.to_string(),
|
|
))),
|
|
},
|
|
Err(e) => Some(Ok(Event::default().event("stream_event").data(
|
|
serde_json::json!({
|
|
"type": "error",
|
|
"error": {"Stream": {"message": e.to_string()}},
|
|
"raw": null
|
|
})
|
|
.to_string(),
|
|
))),
|
|
});
|
|
|
|
Sse::new(sse_stream)
|
|
.keep_alive(
|
|
KeepAlive::new().interval(Duration::from_secs(15)).event(
|
|
Event::default()
|
|
.event("ping")
|
|
.data(serde_json::json!({"type": "ping"}).to_string()),
|
|
),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
// Non-streaming path
|
|
let msg_id = Ulid::new().to_string();
|
|
|
|
if let Some(schema) = req.schema {
|
|
// Structured output uses generate_object for JSON parsing logic
|
|
let mut params = GenerateParams::new(&request.model)
|
|
.messages(request.messages)
|
|
.client(std::sync::Arc::new(client.clone()));
|
|
if let Some(ref p) = request.provider {
|
|
params = params.provider(p);
|
|
}
|
|
if let Some(temp) = request.temperature {
|
|
params = params.temperature(temp);
|
|
}
|
|
if let Some(max_tokens) = request.max_tokens {
|
|
params = params.max_tokens(max_tokens);
|
|
}
|
|
if let Some(top_p) = request.top_p {
|
|
params = params.top_p(top_p);
|
|
}
|
|
match generate_object(params, schema).await {
|
|
Ok(result) => Json(CompletionResponse {
|
|
id: msg_id,
|
|
model: model_id,
|
|
message: convert_llm_message(&result.response.message),
|
|
stop_reason: finish_reason_to_api_stop_reason(&result.finish_reason),
|
|
usage: CompletionUsage {
|
|
input_tokens: result.usage.input_tokens,
|
|
output_tokens: result.usage.output_tokens,
|
|
},
|
|
output: result.output,
|
|
})
|
|
.into_response(),
|
|
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
|
|
.into_response(),
|
|
}
|
|
} else {
|
|
match client.complete(&request).await {
|
|
Ok(response) => Json(CompletionResponse {
|
|
id: response.id,
|
|
model: response.model,
|
|
message: convert_llm_message(&response.message),
|
|
stop_reason: finish_reason_to_api_stop_reason(&response.finish_reason),
|
|
usage: CompletionUsage {
|
|
input_tokens: response.usage.input_tokens,
|
|
output_tokens: response.usage.output_tokens,
|
|
},
|
|
output: None,
|
|
})
|
|
.into_response(),
|
|
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
|
|
.into_response(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_graph_subprocess_exe(
|
|
exe_override: Option<&std::path::Path>,
|
|
) -> Result<PathBuf, RenderSubprocessError> {
|
|
if let Some(path) = exe_override {
|
|
Ok(path.to_path_buf())
|
|
} else {
|
|
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_fabro").map(PathBuf::from) {
|
|
return Ok(path);
|
|
}
|
|
|
|
let current = std::env::current_exe()
|
|
.map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?;
|
|
let current_name = current.file_stem().and_then(|name| name.to_str());
|
|
if current_name == Some("fabro") {
|
|
return Ok(current);
|
|
}
|
|
|
|
let candidate = current
|
|
.parent()
|
|
.and_then(|parent| parent.parent())
|
|
.map(|parent| parent.join(if cfg!(windows) { "fabro.exe" } else { "fabro" }));
|
|
if let Some(candidate) = candidate.filter(|path| path.is_file()) {
|
|
return Ok(candidate);
|
|
}
|
|
|
|
Ok(current)
|
|
}
|
|
}
|
|
|
|
fn render_subprocess_failure(
|
|
status: std::process::ExitStatus,
|
|
stderr: &[u8],
|
|
) -> RenderSubprocessError {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::ExitStatusExt;
|
|
|
|
if let Some(signal) = status.signal() {
|
|
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
|
|
let detail = if stderr.is_empty() {
|
|
format!("terminated by signal {signal}")
|
|
} else {
|
|
format!("terminated by signal {signal}: {stderr}")
|
|
};
|
|
return RenderSubprocessError::ChildCrashed(detail);
|
|
}
|
|
}
|
|
|
|
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
|
|
let detail = match status.code() {
|
|
Some(code) if stderr.is_empty() => format!("exited with status {code}"),
|
|
Some(code) => format!("exited with status {code}: {stderr}"),
|
|
None if stderr.is_empty() => "child exited unsuccessfully".to_string(),
|
|
None => format!("child exited unsuccessfully: {stderr}"),
|
|
};
|
|
RenderSubprocessError::ChildCrashed(detail)
|
|
}
|
|
|
|
async fn render_dot_subprocess(
|
|
styled_source: &str,
|
|
exe_override: Option<&std::path::Path>,
|
|
) -> Result<Vec<u8>, RenderSubprocessError> {
|
|
let _permit = GRAPHVIZ_RENDER_SEMAPHORE
|
|
.acquire()
|
|
.await
|
|
.map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?;
|
|
let exe = render_graph_subprocess_exe(exe_override)?;
|
|
let mut cmd = Command::new(exe);
|
|
cmd.arg("__render-graph")
|
|
.env("FABRO_TELEMETRY", "off")
|
|
.env_remove("FABRO_JSON")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?;
|
|
let mut stdin = child.stdin.take().ok_or_else(|| {
|
|
RenderSubprocessError::SpawnFailed("render subprocess stdin was not piped".to_string())
|
|
})?;
|
|
if let Err(err) = stdin.write_all(styled_source.as_bytes()).await {
|
|
drop(stdin);
|
|
let output = child
|
|
.wait_with_output()
|
|
.await
|
|
.map_err(|wait_err| RenderSubprocessError::SpawnFailed(wait_err.to_string()))?;
|
|
return Err(RenderSubprocessError::ChildCrashed(format!(
|
|
"failed writing DOT to child stdin: {err}; {}",
|
|
render_subprocess_failure(output.status, &output.stderr)
|
|
)));
|
|
}
|
|
drop(stdin);
|
|
|
|
let output = child
|
|
.wait_with_output()
|
|
.await
|
|
.map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(render_subprocess_failure(output.status, &output.stderr));
|
|
}
|
|
|
|
if let Some(error) = output.stdout.strip_prefix(RENDER_ERROR_PREFIX) {
|
|
return Err(RenderSubprocessError::RenderFailed(
|
|
String::from_utf8_lossy(error).trim().to_string(),
|
|
));
|
|
}
|
|
|
|
if output.stdout.starts_with(b"<?xml") || output.stdout.starts_with(b"<svg") {
|
|
return Ok(output.stdout);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Err(RenderSubprocessError::ProtocolViolation(format!(
|
|
"stdout did not contain SVG or error protocol (stdout: {:?}, stderr: {:?})",
|
|
stdout.trim(),
|
|
stderr.trim()
|
|
)))
|
|
}
|
|
|
|
async fn render_graph_response(
|
|
dot_source: &str,
|
|
exe_override: Option<&std::path::Path>,
|
|
) -> Response {
|
|
use fabro_graphviz::render::{inject_dot_style_defaults, postprocess_svg};
|
|
|
|
let styled_source = inject_dot_style_defaults(dot_source);
|
|
match render_dot_subprocess(&styled_source, exe_override).await {
|
|
Ok(raw) => {
|
|
let bytes = postprocess_svg(raw);
|
|
(StatusCode::OK, [("content-type", "image/svg+xml")], bytes).into_response()
|
|
}
|
|
Err(RenderSubprocessError::RenderFailed(err)) => {
|
|
ApiError::new(StatusCode::BAD_REQUEST, err).into_response()
|
|
}
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn render_graph_bytes(dot_source: &str) -> Response {
|
|
render_graph_response(dot_source, None).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn render_graph_bytes_with_exe_override(
|
|
dot_source: &str,
|
|
exe_override: Option<&std::path::Path>,
|
|
) -> Response {
|
|
render_graph_response(dot_source, exe_override).await
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct GraphParams {
|
|
#[serde(default)]
|
|
direction: Option<String>,
|
|
}
|
|
|
|
async fn get_graph(
|
|
_auth: AuthenticatedService,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<GraphParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let live_dot_source = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.get(&id)
|
|
.map(|managed_run| managed_run.dot_source.clone())
|
|
};
|
|
|
|
let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) {
|
|
Some(dot)
|
|
} else {
|
|
match state.store.open_run_reader(&id).await {
|
|
Ok(run_store) => match run_store.state().await {
|
|
Ok(run_state) => run_state.graph_source,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response();
|
|
}
|
|
},
|
|
Err(_) => return ApiError::not_found("Run not found.").into_response(),
|
|
}
|
|
};
|
|
|
|
let Some(dot) = dot_source else {
|
|
return ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response();
|
|
};
|
|
|
|
let dot = match params.direction.as_deref() {
|
|
Some(dir @ ("LR" | "TB" | "BT" | "RL")) => {
|
|
use fabro_graphviz::render;
|
|
render::apply_direction(&dot, dir).into_owned()
|
|
}
|
|
_ => dot,
|
|
};
|
|
|
|
render_graph_bytes(&dot).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "server unit tests stage fixtures with sync std::fs writes"
|
|
)]
|
|
mod tests {
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
#[cfg(unix)]
|
|
use std::path::{Path, PathBuf};
|
|
#[cfg(unix)]
|
|
use std::process::Stdio;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, header};
|
|
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
|
use fabro_model::Provider;
|
|
use fabro_test::{assert_axum_status, expect_axum_bytes, expect_axum_json, expect_axum_status};
|
|
use fabro_types::settings::ServerAuthMethod;
|
|
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use super::*;
|
|
use crate::github_webhooks::compute_signature;
|
|
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
|
|
|
|
const MINIMAL_DOT: &str = r#"digraph Test {
|
|
graph [goal="Test"]
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
start -> exit
|
|
}"#;
|
|
const TEST_WEBHOOK_SECRET: &str = "webhook-secret";
|
|
const TEST_DEV_TOKEN: &str =
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
|
const WRONG_DEV_TOKEN: &str =
|
|
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
|
|
|
|
fn test_app_with() -> Router {
|
|
let state = create_app_state();
|
|
build_router(state, AuthMode::Disabled)
|
|
}
|
|
|
|
fn test_app_with_scheduler(state: Arc<AppState>) -> Router {
|
|
spawn_scheduler(Arc::clone(&state));
|
|
build_router(state, AuthMode::Disabled)
|
|
}
|
|
|
|
async fn body_json(body: Body) -> serde_json::Value {
|
|
let bytes = to_bytes(body, usize::MAX).await.unwrap();
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
}
|
|
|
|
async fn assert_status(response: axum::response::Response, expected: StatusCode) {
|
|
assert_axum_status(response, expected, "assert_status").await;
|
|
}
|
|
|
|
async fn checked_response(
|
|
response: axum::response::Response,
|
|
expected: StatusCode,
|
|
) -> axum::response::Response {
|
|
expect_axum_status(response, expected, "checked_response").await
|
|
}
|
|
|
|
async fn response_json(
|
|
response: axum::response::Response,
|
|
expected: StatusCode,
|
|
) -> serde_json::Value {
|
|
expect_axum_json(response, expected, "response_json").await
|
|
}
|
|
|
|
async fn response_bytes(response: axum::response::Response, expected: StatusCode) -> Vec<u8> {
|
|
expect_axum_bytes(response, expected, "response_bytes").await
|
|
}
|
|
|
|
fn api(path: &str) -> String {
|
|
format!("/api/v1{path}")
|
|
}
|
|
|
|
fn webhook_test_app(auth_mode: AuthMode) -> Router {
|
|
let secret = TEST_WEBHOOK_SECRET.to_string();
|
|
let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| {
|
|
(name == WEBHOOK_SECRET_ENV).then(|| secret.clone())
|
|
});
|
|
build_router_with_options(
|
|
state,
|
|
auth_mode,
|
|
Arc::new(IpAllowlistConfig::default()),
|
|
RouterOptions {
|
|
web_enabled: false,
|
|
..RouterOptions::default()
|
|
},
|
|
)
|
|
}
|
|
|
|
fn webhook_request(
|
|
signature: Option<&str>,
|
|
authorization: Option<&str>,
|
|
body: &[u8],
|
|
) -> Request<Body> {
|
|
let mut builder = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/webhooks/github"))
|
|
.header("x-github-event", "pull_request");
|
|
if let Some(sig) = signature {
|
|
builder = builder.header("x-hub-signature-256", sig);
|
|
}
|
|
if let Some(value) = authorization {
|
|
builder = builder.header(header::AUTHORIZATION, value);
|
|
}
|
|
builder.body(Body::from(body.to_vec())).unwrap()
|
|
}
|
|
|
|
fn dev_token_auth_mode() -> AuthMode {
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::DevToken],
|
|
dev_token: Some(TEST_DEV_TOKEN.to_string()),
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() {
|
|
let state = create_app_state();
|
|
*state.settings.write().unwrap() = fabro_config::parse_settings_layer(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
"#,
|
|
)
|
|
.expect("settings fixture should parse");
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/settings?view=resolved"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_status(response, StatusCode::INTERNAL_SERVER_ERROR).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "/tmp/test.pem",
|
|
"value": "pem-data",
|
|
"type": "file",
|
|
"description": "Test certificate",
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["name"], "/tmp/test.pem");
|
|
assert_eq!(body["type"], "file");
|
|
assert_eq!(body["description"], "Test certificate");
|
|
|
|
let vault = state.vault.read().await;
|
|
assert!(!vault.snapshot().contains_key("/tmp/test.pem"));
|
|
assert_eq!(vault.file_secrets(), vec![(
|
|
"/tmp/test.pem".to_string(),
|
|
"pem-data".to_string()
|
|
)]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_webhook_rejects_missing_signature() {
|
|
let app = webhook_test_app(AuthMode::Disabled);
|
|
let body = br#"{"action":"opened"}"#;
|
|
|
|
let response = app
|
|
.oneshot(webhook_request(None, None, body))
|
|
.await
|
|
.unwrap();
|
|
assert_status(response, StatusCode::UNAUTHORIZED).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_webhook_rejects_signature_signed_with_wrong_secret() {
|
|
let app = webhook_test_app(AuthMode::Disabled);
|
|
let body = br#"{"action":"opened"}"#;
|
|
let bad_signature = compute_signature(b"wrong-secret", body);
|
|
|
|
let response = app
|
|
.oneshot(webhook_request(Some(&bad_signature), None, body))
|
|
.await
|
|
.unwrap();
|
|
assert_status(response, StatusCode::UNAUTHORIZED).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_webhook_accepts_valid_signature_when_auth_disabled() {
|
|
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
|
|
let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body);
|
|
let app = webhook_test_app(AuthMode::Disabled);
|
|
|
|
let response = app
|
|
.oneshot(webhook_request(Some(&signature), None, body))
|
|
.await
|
|
.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_webhook_accepts_valid_signature_without_bearer_token() {
|
|
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
|
|
let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body);
|
|
let app = webhook_test_app(dev_token_auth_mode());
|
|
|
|
let response = app
|
|
.oneshot(webhook_request(Some(&signature), None, body))
|
|
.await
|
|
.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_webhook_accepts_valid_signature_with_wrong_bearer_token() {
|
|
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
|
|
let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body);
|
|
let app = webhook_test_app(dev_token_auth_mode());
|
|
|
|
let response = app
|
|
.oneshot(webhook_request(
|
|
Some(&signature),
|
|
Some(&format!("Bearer {WRONG_DEV_TOKEN}")),
|
|
body,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_secret_stores_valid_credential_entries() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let credential = fabro_auth::AuthCredential {
|
|
provider: Provider::OpenAi,
|
|
details: fabro_auth::AuthDetails::CodexOAuth {
|
|
tokens: fabro_auth::OAuthTokens {
|
|
access_token: "access".to_string(),
|
|
refresh_token: Some("refresh".to_string()),
|
|
expires_at: chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&chrono::Utc),
|
|
},
|
|
config: fabro_auth::OAuthConfig {
|
|
auth_url: "https://auth.openai.com".to_string(),
|
|
token_url: "https://auth.openai.com/oauth/token".to_string(),
|
|
client_id: "client".to_string(),
|
|
scopes: vec!["openid".to_string()],
|
|
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
|
use_pkce: true,
|
|
},
|
|
account_id: Some("acct_123".to_string()),
|
|
},
|
|
};
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "openai_codex",
|
|
"value": serde_json::to_string(&credential).unwrap(),
|
|
"type": "credential"
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
let listed = state.vault.read().await.list();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].name, "openai_codex");
|
|
assert_eq!(listed[0].secret_type, SecretType::Credential);
|
|
assert!(state.vault.read().await.get("openai_codex").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_secrets_includes_credential_metadata() {
|
|
let state = create_app_state();
|
|
{
|
|
let mut vault = state.vault.write().await;
|
|
vault
|
|
.set(
|
|
"anthropic",
|
|
"{\"provider\":\"anthropic\"}",
|
|
SecretType::Credential,
|
|
Some("saved auth"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/secrets"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let data = body["data"].as_array().expect("data should be an array");
|
|
let entry = data
|
|
.iter()
|
|
.find(|entry| entry["name"] == "anthropic")
|
|
.expect("credential metadata should be listed");
|
|
assert_eq!(entry["type"], "credential");
|
|
assert_eq!(entry["description"], "saved auth");
|
|
assert!(entry.get("updated_at").is_some());
|
|
assert!(entry.get("value").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_secret_rejects_invalid_credential_json() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "openai_codex",
|
|
"value": "{not-json",
|
|
"type": "credential"
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_secret_rejects_wrong_credential_name() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "openai",
|
|
"value": serde_json::to_string(&serde_json::json!({
|
|
"provider": "openai",
|
|
"type": "codex_oauth",
|
|
"tokens": {
|
|
"access_token": "access",
|
|
"refresh_token": "refresh",
|
|
"expires_at": "2030-01-01T00:00:00Z"
|
|
},
|
|
"config": {
|
|
"auth_url": "https://auth.openai.com",
|
|
"token_url": "https://auth.openai.com/oauth/token",
|
|
"client_id": "client",
|
|
"scopes": ["openid"],
|
|
"redirect_uri": "https://auth.openai.com/deviceauth/callback",
|
|
"use_pkce": true
|
|
}
|
|
}))
|
|
.unwrap(),
|
|
"type": "credential"
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_secret_by_name_removes_file_secret() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let create_req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "/tmp/test.pem",
|
|
"value": "pem-data",
|
|
"type": "file",
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let create_response = app.clone().oneshot(create_req).await.unwrap();
|
|
assert_status(create_response, StatusCode::OK).await;
|
|
|
|
let delete_req = Request::builder()
|
|
.method("DELETE")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"name": "/tmp/test.pem",
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let delete_response = app.oneshot(delete_req).await.unwrap();
|
|
assert_status(delete_response, StatusCode::NO_CONTENT).await;
|
|
assert!(state.vault.read().await.list().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn server_secrets_resolve_process_env_before_server_env() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(
|
|
dir.path().join("server.env"),
|
|
"SESSION_SECRET=file-value\nGITHUB_APP_CLIENT_SECRET=file-client\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let secrets =
|
|
ServerSecrets::with_env_lookup(dir.path().join("server.env"), |name| match name {
|
|
"SESSION_SECRET" => Some("env-value".to_string()),
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("env-value"));
|
|
assert_eq!(
|
|
secrets.get("GITHUB_APP_CLIENT_SECRET").as_deref(),
|
|
Some("file-client")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_credentials_resolve_process_env_before_vault() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
|
vault
|
|
.set("OPENAI_API_KEY", "vault-key", SecretType::Environment, None)
|
|
.unwrap();
|
|
|
|
let provider_credentials =
|
|
ProviderCredentials::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |name| {
|
|
match name {
|
|
"OPENAI_API_KEY" => Some("env-key".to_string()),
|
|
_ => None,
|
|
}
|
|
});
|
|
|
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
|
let resolved = runtime.block_on(provider_credentials.get("OPENAI_API_KEY"));
|
|
assert_eq!(resolved.as_deref(), Some("env-key"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() {
|
|
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
|
|
let transport = RunAnswerTransport::Subprocess { control_tx };
|
|
|
|
transport.cancel_run().await.unwrap();
|
|
|
|
assert_eq!(
|
|
control_rx.recv().await,
|
|
Some(WorkerControlEnvelope::cancel_run())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn in_process_answer_transport_cancel_run_cancels_pending_interviews() {
|
|
let interviewer = Arc::new(ControlInterviewer::new());
|
|
let transport = RunAnswerTransport::InProcess {
|
|
interviewer: Arc::clone(&interviewer),
|
|
};
|
|
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;
|
|
|
|
transport.cancel_run().await.unwrap();
|
|
|
|
let answer = answer_task.await.unwrap();
|
|
assert_eq!(answer.value, AnswerValue::Cancelled);
|
|
}
|
|
|
|
fn manifest_json(target_path: &str, dot_source: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"version": 1,
|
|
"cwd": "/tmp",
|
|
"target": {
|
|
"identifier": target_path,
|
|
"path": target_path,
|
|
},
|
|
"workflows": {
|
|
target_path: {
|
|
"source": dot_source,
|
|
"files": {},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
fn minimal_manifest_json(dot_source: &str) -> serde_json::Value {
|
|
manifest_json("workflow.fabro", dot_source)
|
|
}
|
|
|
|
fn manifest_body(dot_source: &str) -> Body {
|
|
Body::from(serde_json::to_string(&minimal_manifest_json(dot_source)).unwrap())
|
|
}
|
|
|
|
fn manifest_body_for(target_path: &str, dot_source: &str) -> Body {
|
|
Body::from(serde_json::to_string(&manifest_json(target_path, dot_source)).unwrap())
|
|
}
|
|
|
|
async fn create_run(app: &Router, dot_source: &str) -> String {
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(dot_source))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
body["id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
async fn create_run_for_target(app: &Router, target_path: &str, dot_source: &str) -> String {
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body_for(target_path, dot_source))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
body["id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
fn named_workflow_dot(name: &str, goal: &str) -> String {
|
|
format!(
|
|
r#"digraph {name} {{
|
|
graph [goal="{goal}"]
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
start -> exit
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
fn multipart_body(
|
|
boundary: &str,
|
|
manifest: &serde_json::Value,
|
|
files: &[(&str, &str, &[u8])],
|
|
) -> Body {
|
|
let mut body = Vec::new();
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(b"Content-Disposition: form-data; name=\"manifest\"\r\n");
|
|
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
|
|
body.extend_from_slice(serde_json::to_string(manifest).unwrap().as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
for (part, filename, bytes) in files {
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
format!(
|
|
"Content-Disposition: form-data; name=\"{part}\"; filename=\"{filename}\"\r\n"
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
|
|
body.extend_from_slice(bytes);
|
|
body.extend_from_slice(b"\r\n");
|
|
}
|
|
|
|
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
|
Body::from(body)
|
|
}
|
|
|
|
/// Create a run via POST /runs, then start it via POST /runs/{id}/start.
|
|
/// Returns the run_id string.
|
|
async fn create_and_start_run(app: &Router, dot_source: &str) -> String {
|
|
let run_id = create_run(app, dot_source).await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/start")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
run_id
|
|
}
|
|
|
|
async fn create_durable_run_with_events(
|
|
state: &Arc<AppState>,
|
|
run_id: RunId,
|
|
events: &[workflow_event::Event],
|
|
) {
|
|
let run_store = state.store.create_run(&run_id).await.unwrap();
|
|
for event in events {
|
|
workflow_event::append_event(&run_store, &run_id, event)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
async fn append_raw_run_event(
|
|
state: &Arc<AppState>,
|
|
run_id: RunId,
|
|
seq_hint: &str,
|
|
ts: &str,
|
|
event: &str,
|
|
properties: serde_json::Value,
|
|
node_id: Option<&str>,
|
|
) {
|
|
let run_store = state.store.open_run(&run_id).await.unwrap();
|
|
let payload = fabro_store::EventPayload::new(
|
|
json!({
|
|
"id": format!("evt-{seq_hint}"),
|
|
"ts": ts,
|
|
"run_id": run_id,
|
|
"event": event,
|
|
"node_id": node_id,
|
|
"properties": properties,
|
|
}),
|
|
&run_id,
|
|
)
|
|
.unwrap();
|
|
run_store.append_event(&payload).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_model_unknown_returns_404() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/models/nonexistent-model-xyz/test"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_model_alias_returns_canonical_model_id() {
|
|
let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, |_| None);
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/models/sonnet/test"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["model_id"], "claude-sonnet-4-6");
|
|
assert_eq!(body["status"], "skip");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_model_invalid_mode_returns_400() {
|
|
let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, |_| None);
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/models/claude-opus-4-6/test?mode=bogus"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_models_filters_by_provider() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/models?provider=anthropic"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let models = body["data"].as_array().unwrap();
|
|
assert!(!models.is_empty());
|
|
assert!(
|
|
models
|
|
.iter()
|
|
.all(|model| model["provider"] == serde_json::Value::String("anthropic".into()))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_models_filters_by_query_across_aliases() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/models?query=codex"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let model_ids = body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|model| model["id"].as_str().unwrap().to_string())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(model_ids, vec![
|
|
"gpt-5.2-codex".to_string(),
|
|
"gpt-5.3-codex".to_string(),
|
|
"gpt-5.3-codex-spark".to_string()
|
|
]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_models_invalid_provider_returns_400() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/models?provider=not-a-provider"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_login_github_redirects_to_github() {
|
|
let settings: SettingsLayer = fabro_config::parse_settings_layer(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.web]
|
|
enabled = true
|
|
url = "http://localhost:3000"
|
|
|
|
[server.integrations.github]
|
|
app_id = "123"
|
|
client_id = "Iv1.testclient"
|
|
slug = "fabro"
|
|
"#,
|
|
)
|
|
.expect("fixture should parse");
|
|
let app = build_router(
|
|
create_test_app_state_with_session_key(
|
|
settings,
|
|
Some("github-redirect-test-key-0123456789"),
|
|
false,
|
|
),
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::Github],
|
|
dev_token: None,
|
|
}),
|
|
);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/auth/login/github")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = checked_response(response, StatusCode::SEE_OTHER).await;
|
|
let location = response
|
|
.headers()
|
|
.get(axum::http::header::LOCATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap();
|
|
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn logout_redirects_to_login_page() {
|
|
let app = test_app_with();
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/auth/logout")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = checked_response(response, StatusCode::SEE_OTHER).await;
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get(axum::http::header::LOCATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("/login")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_favicon_is_served() {
|
|
let app = test_app_with();
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/favicon.svg")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = checked_response(response, StatusCode::OK).await;
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("image/svg+xml")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_runs_starts_run_and_returns_id() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CREATED).await;
|
|
assert!(body["id"].is_string());
|
|
assert!(!body["id"].as_str().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_runs_invalid_dot_returns_bad_request() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body("not a graph"))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn get_run_status_returns_status() {
|
|
let state = create_app_state();
|
|
let app = test_app_with_scheduler(state);
|
|
|
|
let run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
|
|
// Give run a moment to start
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
// Check status
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["run_id"].as_str().unwrap(), run_id);
|
|
assert_eq!(body["goal"].as_str().unwrap(), "Test");
|
|
assert_eq!(body["title"].as_str().unwrap(), "Test");
|
|
assert!(body["repository"].is_object());
|
|
assert!(!body["repository"]["name"].as_str().unwrap().is_empty());
|
|
assert!(body["created_at"].is_string());
|
|
assert!(body["labels"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_run_status_not_found() {
|
|
let app = test_app_with();
|
|
let missing_run_id = fixtures::RUN_64;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{missing_run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_run_returns_unique_run_id_prefix_match() {
|
|
let app = test_app_with();
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
let selector = &run_id[..8];
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/resolve?selector={selector}")))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["run_id"], run_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_run_returns_bad_request_for_ambiguous_prefix() {
|
|
let app = test_app_with();
|
|
let run_id_a = create_run(&app, MINIMAL_DOT).await;
|
|
let run_id_b = create_run(&app, MINIMAL_DOT).await;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/resolve?selector=0"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(response, StatusCode::BAD_REQUEST).await;
|
|
let detail = body["errors"][0]["detail"]
|
|
.as_str()
|
|
.expect("error detail should be present");
|
|
assert!(
|
|
detail.contains(&run_id_a),
|
|
"detail should mention first run: {detail}"
|
|
);
|
|
assert!(
|
|
detail.contains(&run_id_b),
|
|
"detail should mention second run: {detail}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_run_prefers_most_recent_exact_workflow_slug_match() {
|
|
let app = test_app_with();
|
|
let older_id = create_run_for_target(
|
|
&app,
|
|
"ship-feature.fabro",
|
|
&named_workflow_dot("ShipFeatureAlpha", "older"),
|
|
)
|
|
.await;
|
|
let newer_id = create_run_for_target(
|
|
&app,
|
|
"ship-feature.fabro",
|
|
&named_workflow_dot("ShipFeatureBeta", "newer"),
|
|
)
|
|
.await;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/resolve?selector=ship-feature"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["run_id"], newer_id);
|
|
assert_ne!(body["run_id"], older_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_run_prefers_most_recent_collapsed_workflow_name_match() {
|
|
let app = test_app_with();
|
|
let older_id = create_run_for_target(
|
|
&app,
|
|
"nightly-alpha.fabro",
|
|
&named_workflow_dot("Nightly_Build", "older"),
|
|
)
|
|
.await;
|
|
let newer_id = create_run_for_target(
|
|
&app,
|
|
"nightly-beta.fabro",
|
|
&named_workflow_dot("Nightly_Build", "newer"),
|
|
)
|
|
.await;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/resolve?selector=nightlybuild"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["run_id"], newer_id);
|
|
assert_ne!(body["run_id"], older_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_run_returns_not_found_for_unknown_selector() {
|
|
let app = test_app_with();
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/resolve?selector=missing-run"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_questions_returns_empty_list() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Start a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Get questions (should be empty for a run without wait.human nodes)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/questions")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert!(body["data"].is_array());
|
|
assert_eq!(body["meta"]["has_more"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_answer_not_found_run() {
|
|
let app = test_app_with();
|
|
let missing_run_id = fixtures::RUN_64;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{missing_run_id}/questions/q1/answer")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_pending_interview_answer_rejects_invalid_answer_shape() {
|
|
let state = create_app_state();
|
|
let pending = LoadedPendingInterview {
|
|
run_id: fixtures::RUN_1,
|
|
qid: "q-1".to_string(),
|
|
question: InterviewQuestionRecord {
|
|
id: "q-1".to_string(),
|
|
text: "Approve deploy?".to_string(),
|
|
stage: "gate".to_string(),
|
|
question_type: InterviewQuestionType::MultipleChoice,
|
|
options: vec![fabro_types::run_event::InterviewOption {
|
|
key: "approve".to_string(),
|
|
label: "Approve".to_string(),
|
|
}],
|
|
allow_freeform: false,
|
|
timeout_seconds: None,
|
|
context_display: None,
|
|
},
|
|
};
|
|
|
|
let response = submit_pending_interview_answer(
|
|
state.as_ref(),
|
|
&pending,
|
|
Answer::text("not a valid multiple choice answer"),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_events_not_found() {
|
|
let app = test_app_with();
|
|
let missing_run_id = fixtures::RUN_64;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{missing_run_id}/events")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_run_state_returns_projection() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/state")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert!(body["nodes"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_run_state_exposes_pending_interviews() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = fixtures::RUN_1;
|
|
|
|
create_durable_run_with_events(&state, run_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
])
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
run_id,
|
|
"pending-question",
|
|
"2026-04-19T12:00:00Z",
|
|
"interview.started",
|
|
json!({
|
|
"question_id": "q-1",
|
|
"question": "Approve deploy?",
|
|
"stage": "gate",
|
|
"question_type": "multiple_choice",
|
|
"options": [],
|
|
"allow_freeform": false,
|
|
"context_display": null,
|
|
"timeout_seconds": null,
|
|
}),
|
|
Some("gate"),
|
|
)
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/state")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(
|
|
body["pending_interviews"]["q-1"]["question"]["text"].as_str(),
|
|
Some("Approve deploy?")
|
|
);
|
|
assert_eq!(
|
|
body["pending_interviews"]["q-1"]["question"]["stage"].as_str(),
|
|
Some("gate")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_run_state_includes_provenance_from_user_agent() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.header("user-agent", "fabro-cli/1.2.3")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/state")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(
|
|
body["run"]["provenance"]["server"]["version"],
|
|
FABRO_VERSION
|
|
);
|
|
assert_eq!(
|
|
body["run"]["provenance"]["client"]["user_agent"],
|
|
"fabro-cli/1.2.3"
|
|
);
|
|
assert_eq!(body["run"]["provenance"]["client"]["name"], "fabro-cli");
|
|
assert_eq!(body["run"]["provenance"]["client"]["version"], "1.2.3");
|
|
assert_eq!(
|
|
body["run"]["provenance"]["subject"]["auth_method"],
|
|
"disabled"
|
|
);
|
|
assert!(body["run"]["provenance"]["subject"]["login"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dev_token_web_login_authorizes_cookie_backed_api_requests() {
|
|
const DEV_TOKEN: &str =
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
|
|
|
let state = create_test_app_state_with_session_key(
|
|
SettingsLayer::default(),
|
|
Some("server-test-session-key-0123456789"),
|
|
false,
|
|
);
|
|
let app = build_router(
|
|
Arc::clone(&state),
|
|
AuthMode::Enabled(ConfiguredAuth {
|
|
methods: vec![ServerAuthMethod::DevToken],
|
|
dev_token: Some(DEV_TOKEN.to_string()),
|
|
}),
|
|
);
|
|
|
|
let login_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/auth/login/dev-token")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(json!({ "token": DEV_TOKEN }).to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let login_response = checked_response(login_response, StatusCode::OK).await;
|
|
let session_cookie = login_response
|
|
.headers()
|
|
.get(header::SET_COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(';').next())
|
|
.expect("session cookie should be set")
|
|
.to_string();
|
|
|
|
let create_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::COOKIE, &session_cookie)
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let create_body = response_json(create_response, StatusCode::CREATED).await;
|
|
let run_id = create_body["id"].as_str().unwrap();
|
|
|
|
let state_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/state")))
|
|
.header(header::COOKIE, &session_cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let state_body = response_json(state_response, StatusCode::OK).await;
|
|
assert_eq!(
|
|
state_body["run"]["provenance"]["subject"]["auth_method"],
|
|
"dev_token"
|
|
);
|
|
assert_eq!(state_body["run"]["provenance"]["subject"]["login"], "dev");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_run_persists_manifest_and_definition_blobs_without_bundle_file() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let raw_manifest =
|
|
serde_json::to_string_pretty(&minimal_manifest_json(MINIMAL_DOT)).unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(raw_manifest.clone()))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CREATED).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
|
|
let events = run_store.list_events().await.unwrap();
|
|
let created = events[0].payload.as_value();
|
|
let submitted = events[1].payload.as_value();
|
|
let manifest_blob = created["properties"]["manifest_blob"]
|
|
.as_str()
|
|
.expect("run.created should carry manifest_blob")
|
|
.parse::<RunBlobId>()
|
|
.unwrap();
|
|
let definition_blob = submitted["properties"]["definition_blob"]
|
|
.as_str()
|
|
.expect("run.submitted should carry definition_blob")
|
|
.parse::<RunBlobId>()
|
|
.unwrap();
|
|
|
|
let submitted_manifest_bytes = run_store
|
|
.read_blob(&manifest_blob)
|
|
.await
|
|
.unwrap()
|
|
.expect("submitted manifest blob should exist");
|
|
assert_eq!(submitted_manifest_bytes.as_ref(), raw_manifest.as_bytes());
|
|
|
|
let accepted_definition_bytes = run_store
|
|
.read_blob(&definition_blob)
|
|
.await
|
|
.unwrap()
|
|
.expect("accepted definition blob should exist");
|
|
let accepted_definition: serde_json::Value =
|
|
serde_json::from_slice(&accepted_definition_bytes).unwrap();
|
|
assert!(
|
|
accepted_definition.get("version").is_none(),
|
|
"accepted run definition should not carry compatibility versioning"
|
|
);
|
|
assert_eq!(accepted_definition["workflow_path"], "workflow.fabro");
|
|
assert!(accepted_definition["workflows"]["workflow.fabro"].is_object());
|
|
|
|
created["properties"]["run_dir"]
|
|
.as_str()
|
|
.expect("run.created should include run_dir");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_run_events_returns_paginated_json() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/events?since_seq=1&limit=5")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert!(body["data"].is_array());
|
|
assert!(body["meta"]["has_more"].is_boolean());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_run_event_rejects_run_id_mismatch() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"id": "evt-test",
|
|
"ts": "2026-03-27T12:00:00Z",
|
|
"run_id": fixtures::RUN_64.to_string(),
|
|
"event": "run.submitted",
|
|
"properties": {}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_run_event_rejects_reserved_archive_event() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"id": "evt-run-archived",
|
|
"ts": "2026-04-19T12:00:00Z",
|
|
"run_id": run_id,
|
|
"event": "run.archived",
|
|
"properties": {
|
|
"actor": null
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::BAD_REQUEST).await;
|
|
assert!(
|
|
body["errors"][0]["detail"]
|
|
.as_str()
|
|
.is_some_and(|message| message.contains("run.archived is a lifecycle event")),
|
|
"expected lifecycle rejection, got: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_checkpoint_returns_null_initially() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Start a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Get checkpoint immediately (before run completes, may be null)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/checkpoint")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
checked_response(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_and_read_run_blob_round_trip() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/blobs")))
|
|
.header("content-type", "application/octet-stream")
|
|
.body(Body::from("hello blob"))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let blob_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/blobs/{blob_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let bytes = response_bytes(response, StatusCode::OK).await;
|
|
assert_eq!(&bytes[..], b"hello blob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stage_artifacts_round_trip() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
let stage_id = "code@2";
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!(
|
|
"/runs/{run_id}/stages/{stage_id}/artifacts?filename=src/lib.rs"
|
|
)))
|
|
.header("content-type", "application/octet-stream")
|
|
.body(Body::from("fn main() {}"))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NO_CONTENT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/stages/{stage_id}/artifacts")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["data"][0]["filename"], "src/lib.rs");
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!(
|
|
"/runs/{run_id}/stages/{stage_id}/artifacts/download?filename=src/lib.rs"
|
|
)))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let bytes = response_bytes(response, StatusCode::OK).await;
|
|
assert_eq!(&bytes[..], b"fn main() {}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_run_persists_run_record() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id = create_run(&app, MINIMAL_DOT)
|
|
.await
|
|
.parse::<RunId>()
|
|
.unwrap();
|
|
let run_state = state
|
|
.store
|
|
.open_run_reader(&run_id)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(run_state.run.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stage_artifact_upload_rejects_invalid_filename() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!(
|
|
"/runs/{run_id}/stages/code@2/artifacts?filename=../escape.txt"
|
|
)))
|
|
.header("content-type", "application/octet-stream")
|
|
.body(Body::from("nope"))
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stage_artifacts_multipart_round_trip() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
let stage_id = "code@2";
|
|
let source_bytes = b"fn main() {}\n";
|
|
let log_bytes = b"build ok\n";
|
|
let manifest = serde_json::json!({
|
|
"entries": [
|
|
{
|
|
"part": "file1",
|
|
"path": "src/lib.rs",
|
|
"sha256": hex::encode(Sha256::digest(source_bytes)),
|
|
"expected_bytes": source_bytes.len(),
|
|
"content_type": "text/plain"
|
|
},
|
|
{
|
|
"part": "file2",
|
|
"path": "logs/output.txt",
|
|
"sha256": hex::encode(Sha256::digest(log_bytes)),
|
|
"expected_bytes": log_bytes.len(),
|
|
"content_type": "text/plain"
|
|
}
|
|
]
|
|
});
|
|
let boundary = "fabro-test-boundary";
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/stages/{stage_id}/artifacts")))
|
|
.header(
|
|
"content-type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(multipart_body(boundary, &manifest, &[
|
|
("file1", "src/lib.rs", source_bytes),
|
|
("file2", "logs/output.txt", log_bytes),
|
|
]))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NO_CONTENT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/stages/{stage_id}/artifacts")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["data"][0]["filename"], "logs/output.txt");
|
|
assert_eq!(body["data"][1]["filename"], "src/lib.rs");
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!(
|
|
"/runs/{run_id}/stages/{stage_id}/artifacts/download?filename=logs/output.txt"
|
|
)))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let bytes = response_bytes(response, StatusCode::OK).await;
|
|
assert_eq!(&bytes[..], log_bytes);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stage_artifacts_multipart_requires_manifest_first() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id = create_run(&app, MINIMAL_DOT).await;
|
|
let boundary = "fabro-test-boundary";
|
|
let body = format!(
|
|
"--{boundary}\r\nContent-Disposition: form-data; name=\"file1\"; filename=\"src/lib.rs\"\r\n\r\nfn main() {{}}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"manifest\"\r\nContent-Type: application/json\r\n\r\n{{\"entries\":[{{\"part\":\"file1\",\"path\":\"src/lib.rs\"}}]}}\r\n--{boundary}--\r\n"
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/stages/code@2/artifacts")))
|
|
.header(
|
|
"content-type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_run_returns_submitted() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CREATED).await;
|
|
assert_eq!(body["status"], "submitted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_run_transitions_to_queued() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Create a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
// Start it
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/start")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"], "queued");
|
|
|
|
let status = state
|
|
.store
|
|
.open_run_reader(&run_id.parse::<RunId>().unwrap())
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap()
|
|
.status
|
|
.unwrap();
|
|
assert_eq!(status.status.to_string(), "queued");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_run_conflict_when_not_submitted() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Create a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
// Start it (transitions to queued)
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/start")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
// Start it again — should 409
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/start")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::CONFLICT).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_run_succeeds() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Start a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Cancel it
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
// Could be OK (cancelled) or CONFLICT (already completed)
|
|
let status = response.status();
|
|
assert!(
|
|
status == StatusCode::OK || status == StatusCode::CONFLICT,
|
|
"unexpected status: {status}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_nonexistent_run_returns_not_found() {
|
|
let app = test_app_with();
|
|
let missing_run_id = fixtures::RUN_64;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{missing_run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_graph_returns_svg() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Start a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"version": 1,
|
|
"cwd": "/tmp",
|
|
"target": {
|
|
"identifier": "workflow.fabro",
|
|
"path": "workflow.fabro",
|
|
},
|
|
"workflows": {
|
|
"workflow.fabro": {
|
|
"source": MINIMAL_DOT,
|
|
"files": {},
|
|
},
|
|
},
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Request graph SVG
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/graph")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
|
|
let response = checked_response(response, StatusCode::OK).await;
|
|
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.expect("content-type header should be present")
|
|
.to_str()
|
|
.unwrap();
|
|
assert_eq!(content_type, "image/svg+xml");
|
|
|
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
let svg = String::from_utf8_lossy(&bytes);
|
|
assert!(
|
|
svg.contains("<?xml") || svg.contains("<svg"),
|
|
"expected SVG content, got: {}",
|
|
&svg[..svg.len().min(200)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_graph_from_manifest_returns_svg() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/graph/render"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"manifest": {
|
|
"version": 1,
|
|
"cwd": "/tmp",
|
|
"target": {
|
|
"identifier": "workflow.fabro",
|
|
"path": "workflow.fabro",
|
|
},
|
|
"workflows": {
|
|
"workflow.fabro": {
|
|
"source": MINIMAL_DOT,
|
|
"files": {},
|
|
},
|
|
},
|
|
},
|
|
"format": "svg",
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
|
|
let response = checked_response(response, StatusCode::OK).await;
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("content-type")
|
|
.expect("content-type header should be present")
|
|
.to_str()
|
|
.unwrap(),
|
|
"image/svg+xml"
|
|
);
|
|
|
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
let svg = String::from_utf8_lossy(&bytes);
|
|
assert!(
|
|
svg.contains("<?xml") || svg.contains("<svg"),
|
|
"expected SVG content, got: {}",
|
|
&svg[..svg.len().min(200)]
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn render_graph_bytes_returns_bad_request_for_render_error_protocol() {
|
|
let (_dir, script_path) = write_test_executable(
|
|
"#!/bin/sh\ncat >/dev/null\nprintf 'RENDER_ERROR:failed to parse DOT source'\nexit 0\n",
|
|
);
|
|
|
|
let response =
|
|
render_graph_bytes_with_exe_override("not valid dot {{{", Some(&script_path)).await;
|
|
|
|
assert_status(response, StatusCode::BAD_REQUEST).await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_test_executable(script: &str) -> (tempfile::TempDir, PathBuf) {
|
|
let dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let path = dir.path().join("fake-fabro");
|
|
std::fs::write(&path, script).expect("script should be written");
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
|
.expect("script should be executable");
|
|
(dir, path)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn render_graph_with_override(dot_source: &str, exe_path: &Path) -> Response {
|
|
render_graph_bytes_with_exe_override(dot_source, Some(exe_path)).await
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn render_dot_subprocess_returns_child_crashed_for_nonzero_exit() {
|
|
let (_dir, script_path) = write_test_executable("#!/bin/sh\nexit 1\n");
|
|
|
|
let result = render_dot_subprocess("digraph { a -> b }", Some(&script_path)).await;
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(RenderSubprocessError::ChildCrashed(_))
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn render_graph_bytes_returns_internal_server_error_for_child_crash() {
|
|
let (_dir, script_path) = write_test_executable("#!/bin/sh\nexit 1\n");
|
|
|
|
let response = render_graph_with_override("digraph { a -> b }", &script_path).await;
|
|
|
|
assert_status(response, StatusCode::INTERNAL_SERVER_ERROR).await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn render_dot_subprocess_returns_protocol_violation_for_garbage_stdout() {
|
|
let (_dir, script_path) =
|
|
write_test_executable("#!/bin/sh\ncat >/dev/null\nprintf 'garbage'\nexit 0\n");
|
|
|
|
let result = render_dot_subprocess("digraph { a -> b }", Some(&script_path)).await;
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(RenderSubprocessError::ProtocolViolation(_))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_graph_not_found() {
|
|
let app = test_app_with();
|
|
let missing_run_id = fixtures::RUN_64;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{missing_run_id}/graph")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_runs_returns_started_run() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// List should be empty initially
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
|
assert_eq!(body["meta"]["has_more"].as_bool(), Some(false));
|
|
|
|
// Start a run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// List should now contain one run
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let items = body["data"].as_array().unwrap();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string());
|
|
assert!(items[0]["goal"].is_string());
|
|
assert!(items[0]["title"].is_string());
|
|
assert!(items[0]["repository"]["name"].is_string());
|
|
assert!(items[0]["created_at"].is_string());
|
|
assert!(items[0]["status"].as_str().is_some());
|
|
assert!(items[0]["labels"].is_object());
|
|
assert!(items[0]["status_reason"].is_null());
|
|
assert!(items[0]["pending_control"].is_null());
|
|
assert!(items[0]["total_usd_micros"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn archive_and_unarchive_updates_listing_visibility() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = fixtures::RUN_1;
|
|
|
|
create_durable_run_with_events(&state, run_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::WorkflowRunCompleted {
|
|
duration_ms: 1000,
|
|
artifact_count: 0,
|
|
status: "success".to_string(),
|
|
reason: None,
|
|
total_usd_micros: None,
|
|
final_git_commit_sha: None,
|
|
final_patch: None,
|
|
billing: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
let archive_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/archive")))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let archive_body = response_json(archive_response, StatusCode::OK).await;
|
|
assert_eq!(archive_body["status"].as_str(), Some("archived"));
|
|
|
|
let hidden_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let hidden_body = response_json(hidden_response, StatusCode::OK).await;
|
|
assert!(
|
|
!hidden_body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|item| item["run_id"].as_str() == Some(&run_id.to_string())),
|
|
"archived run should be hidden from default listing"
|
|
);
|
|
|
|
let visible_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs?include_archived=true"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let visible_body = response_json(visible_response, StatusCode::OK).await;
|
|
let archived_item = visible_body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
|
.expect("archived run should appear when include_archived=true");
|
|
assert_eq!(archived_item["status"].as_str(), Some("archived"));
|
|
|
|
let unarchive_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/unarchive")))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let unarchive_body = response_json(unarchive_response, StatusCode::OK).await;
|
|
assert_eq!(unarchive_body["status"].as_str(), Some("succeeded"));
|
|
|
|
let restored_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let restored_body = response_json(restored_response, StatusCode::OK).await;
|
|
let restored_item = restored_body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
|
.expect("unarchived run should reappear in default listing");
|
|
assert_eq!(restored_item["status"].as_str(), Some("succeeded"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn archive_unknown_run_returns_not_found() {
|
|
let app = test_app_with();
|
|
let run_id = fixtures::RUN_64;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/archive")))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_run_removes_durable_run() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("DELETE")
|
|
.uri(api(&format!("/runs/{run_id}?force=true")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NO_CONTENT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_active_run_requires_force() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("DELETE")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CONFLICT).await;
|
|
let short_run_id = &run_id[..12.min(run_id.len())];
|
|
let expected = format!(
|
|
"cannot remove active run {short_run_id} (status: submitted, use force=true or --force to force)"
|
|
);
|
|
assert_eq!(
|
|
body["errors"][0]["detail"].as_str(),
|
|
Some(expected.as_str())
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_active_run_force_succeeds() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("DELETE")
|
|
.uri(api(&format!("/runs/{run_id}?force=true")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NO_CONTENT).await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_aggregate_billing_returns_zeros_initially() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/billing"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["totals"]["runs"].as_i64().unwrap(), 0);
|
|
assert_eq!(body["totals"]["input_tokens"].as_i64().unwrap(), 0);
|
|
assert_eq!(body["totals"]["output_tokens"].as_i64().unwrap(), 0);
|
|
assert_eq!(body["totals"]["runtime_secs"].as_f64().unwrap(), 0.0);
|
|
assert!(body["totals"]["total_usd_micros"].is_null());
|
|
assert!(body["by_model"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_runs_returns_submitted_status() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CREATED).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Check status is submitted (no start, no scheduler running)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
assert_eq!(body["status"].as_str().unwrap(), "submitted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_run_persists_full_settings_snapshot() {
|
|
let settings: SettingsLayer = fabro_config::parse_settings_layer(
|
|
r#"
|
|
_version = 1
|
|
|
|
[run.execution]
|
|
mode = "dry_run"
|
|
|
|
[run.model]
|
|
provider = "anthropic"
|
|
name = "claude-sonnet-4-5"
|
|
|
|
[run.sandbox]
|
|
provider = "local"
|
|
|
|
[[run.hooks]]
|
|
name = "snapshot-hook"
|
|
event = "run_start"
|
|
command = ["echo", "snapshot"]
|
|
blocking = false
|
|
timeout = "1s"
|
|
sandbox = false
|
|
|
|
[run.git.author]
|
|
name = "Snapshot Bot"
|
|
email = "snapshot@example.com"
|
|
|
|
[server.integrations.github]
|
|
app_id = "12345"
|
|
|
|
[server.web]
|
|
url = "http://example.test"
|
|
|
|
[server.api]
|
|
url = "http://api.example.test"
|
|
|
|
[server.logging]
|
|
level = "debug"
|
|
"#,
|
|
)
|
|
.expect("fixture should parse");
|
|
let state = create_app_state_with_options(settings, 5);
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::CREATED).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
let _run_dir = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.get(&run_id)
|
|
.and_then(|run| run.run_dir.clone())
|
|
.expect("run_dir should be recorded")
|
|
};
|
|
let run_record = state
|
|
.store
|
|
.open_run_reader(&run_id)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap()
|
|
.run
|
|
.expect("run record should exist");
|
|
let resolved_run = fabro_config::resolve_run_from_file(&run_record.settings).unwrap();
|
|
let resolved_server = fabro_config::resolve_server_from_file(&run_record.settings).unwrap();
|
|
|
|
// Verify a sampling of the persisted v2 settings, including inherited
|
|
// run execution mode from server settings.
|
|
assert_eq!(
|
|
match resolved_run.goal {
|
|
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
|
|
_ => None,
|
|
}
|
|
.as_deref(),
|
|
Some("Test"),
|
|
"goal should be persisted from the manifest"
|
|
);
|
|
assert!(
|
|
resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun,
|
|
"run execution mode should inherit from server settings"
|
|
);
|
|
assert_eq!(
|
|
resolved_run
|
|
.model
|
|
.name
|
|
.as_ref()
|
|
.map(fabro_types::settings::InterpString::as_source)
|
|
.as_deref(),
|
|
Some("claude-sonnet-4-5"),
|
|
);
|
|
assert_eq!(
|
|
resolved_server
|
|
.integrations
|
|
.github
|
|
.app_id
|
|
.as_ref()
|
|
.map(fabro_types::settings::InterpString::as_source)
|
|
.as_deref(),
|
|
Some("12345"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_queued_run_succeeds() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Submit a run (no start, stays submitted)
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
|
|
|
// Cancel it
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
|
|
// Verify status is cancelled
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
assert_eq!(body["status"].as_str().unwrap(), "failed");
|
|
assert_eq!(body["status_reason"].as_str().unwrap(), "cancelled");
|
|
|
|
// Cancelled runs appear on the board in the "failed" column
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id_str = run_id.to_string();
|
|
let board_item = body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["run_id"].as_str() == Some(run_id_str.as_str()));
|
|
assert!(
|
|
board_item.is_some(),
|
|
"cancelled run should appear on the board"
|
|
);
|
|
assert_eq!(
|
|
board_item.unwrap()["status"].as_str(),
|
|
Some("failed"),
|
|
"cancelled run should preserve the failed lifecycle status"
|
|
);
|
|
assert_eq!(board_item.unwrap()["column"].as_str(), Some("failed"));
|
|
|
|
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
|
|
let status = run_store.state().await.unwrap().status.unwrap();
|
|
assert_eq!(status.status, WorkflowRunStatus::Failed);
|
|
assert_eq!(status.status_reason, Some(WorkflowStatusReason::Cancelled));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_run_overwrites_pending_pause_request() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Running;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
append_control_request(state.as_ref(), run_id, RunControlAction::Pause, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["pending_control"].as_str(), Some("cancel"));
|
|
|
|
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
|
|
assert_eq!(summary.pending_control, Some(RunControlAction::Cancel));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pause_run_rejects_when_control_is_already_pending() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Running;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
append_control_request(state.as_ref(), run_id, RunControlAction::Cancel, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/pause")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::CONFLICT).await;
|
|
|
|
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
|
|
assert_eq!(summary.pending_control, Some(RunControlAction::Cancel));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pause_run_sets_pending_control_on_board_response() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Running;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/pause")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"].as_str(), Some("running"));
|
|
assert_eq!(body["pending_control"].as_str(), Some("pause"));
|
|
|
|
// Verify pending_control via /runs/{id} (board no longer includes this field)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
assert_eq!(body["pending_control"].as_str(), Some("pause"));
|
|
|
|
// Verify the run appears on the board (store has Submitted status →
|
|
// "initializing" column)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let item = body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|item| item["run_id"].as_str() == Some(run_id_str.as_str()))
|
|
.expect("board item should exist");
|
|
assert!(item["status"].as_str().is_some());
|
|
assert_eq!(item["column"].as_str(), Some("initializing"));
|
|
assert_eq!(item["pending_control"].as_str(), Some("pause"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pause_run_immediately_pauses_blocked_run() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
append_raw_run_event(
|
|
&state,
|
|
run_id,
|
|
"pause-blocked",
|
|
"2026-04-19T12:00:00Z",
|
|
"run.blocked",
|
|
json!({ "blocked_reason": "human_input_required" }),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Blocked;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/pause")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"].as_str(), Some("paused"));
|
|
assert_eq!(
|
|
body["blocked_reason"].as_str(),
|
|
Some("human_input_required")
|
|
);
|
|
assert_eq!(body["pending_control"], serde_json::Value::Null);
|
|
|
|
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
|
|
assert_eq!(summary.status, WorkflowRunStatus::Paused);
|
|
assert_eq!(
|
|
summary.blocked_reason,
|
|
Some(BlockedReason::HumanInputRequired)
|
|
);
|
|
assert_eq!(summary.pending_control, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unpause_run_sets_pending_control() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Paused;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/unpause")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"].as_str(), Some("paused"));
|
|
assert_eq!(body["pending_control"].as_str(), Some("unpause"));
|
|
|
|
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
|
|
assert_eq!(summary.pending_control, Some(RunControlAction::Unpause));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
append_raw_run_event(
|
|
&state,
|
|
run_id,
|
|
"paused-blocked-paused",
|
|
"2026-04-19T12:00:00Z",
|
|
"run.paused",
|
|
json!({}),
|
|
None,
|
|
)
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
run_id,
|
|
"paused-blocked-status",
|
|
"2026-04-19T12:00:01Z",
|
|
"run.blocked",
|
|
json!({ "blocked_reason": "human_input_required" }),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
|
managed_run.status = RunStatus::Paused;
|
|
managed_run.worker_pid = Some(u32::MAX);
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/unpause")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"].as_str(), Some("blocked"));
|
|
assert_eq!(
|
|
body["blocked_reason"].as_str(),
|
|
Some("human_input_required")
|
|
);
|
|
assert_eq!(body["pending_control"], serde_json::Value::Null);
|
|
|
|
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
|
|
assert_eq!(summary.status, WorkflowRunStatus::Blocked);
|
|
assert_eq!(
|
|
summary.blocked_reason,
|
|
Some(BlockedReason::HumanInputRequired)
|
|
);
|
|
assert_eq!(summary.pending_control, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_reconciliation_marks_inflight_runs_terminal() {
|
|
let state = create_app_state();
|
|
|
|
create_durable_run_with_events(&state, fixtures::RUN_1, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
])
|
|
.await;
|
|
create_durable_run_with_events(&state, fixtures::RUN_2, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
])
|
|
.await;
|
|
create_durable_run_with_events(&state, fixtures::RUN_3, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::RunPaused,
|
|
workflow_event::Event::RunCancelRequested { actor: None },
|
|
])
|
|
.await;
|
|
|
|
let reconciled = reconcile_incomplete_runs_on_startup(&state).await.unwrap();
|
|
assert_eq!(reconciled, 2);
|
|
|
|
let run_1 = state
|
|
.store
|
|
.open_run_reader(&fixtures::RUN_1)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(run_1.status.unwrap().status, WorkflowRunStatus::Submitted);
|
|
|
|
let run_2 = state
|
|
.store
|
|
.open_run_reader(&fixtures::RUN_2)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap();
|
|
let run_2_status = run_2.status.unwrap();
|
|
assert_eq!(run_2_status.status, WorkflowRunStatus::Failed);
|
|
assert_eq!(
|
|
run_2_status.status_reason,
|
|
Some(WorkflowStatusReason::Terminated)
|
|
);
|
|
|
|
let run_3 = state
|
|
.store
|
|
.open_run_reader(&fixtures::RUN_3)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap();
|
|
let run_3_status = run_3.status.unwrap();
|
|
assert_eq!(run_3_status.status, WorkflowRunStatus::Failed);
|
|
assert_eq!(
|
|
run_3_status.status_reason,
|
|
Some(WorkflowStatusReason::Cancelled)
|
|
);
|
|
assert_eq!(run_3.pending_control, None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn shutdown_active_workers_terminates_process_groups() {
|
|
let state = create_app_state();
|
|
let run_id = fixtures::RUN_4;
|
|
|
|
create_durable_run_with_events(&state, run_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
])
|
|
.await;
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let mut child = tokio::process::Command::new("sh");
|
|
child
|
|
.arg("-c")
|
|
.arg("trap '' TERM; while :; do sleep 1; done")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
fabro_proc::pre_exec_setpgid(child.as_std_mut());
|
|
let mut child = child.spawn().unwrap();
|
|
let worker_pid = child.id().expect("worker pid should be available");
|
|
|
|
{
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let mut run = managed_run(
|
|
String::new(),
|
|
RunStatus::Running,
|
|
chrono::Utc::now(),
|
|
temp_dir.path().join(run_id.to_string()),
|
|
RunExecutionMode::Start,
|
|
);
|
|
run.worker_pid = Some(worker_pid);
|
|
run.worker_pgid = Some(worker_pid);
|
|
runs.insert(run_id, run);
|
|
}
|
|
|
|
let terminated = shutdown_active_workers_with_grace(
|
|
&state,
|
|
Duration::from_millis(50),
|
|
Duration::from_millis(10),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(terminated, 1);
|
|
|
|
let exit_status = tokio::time::timeout(Duration::from_secs(2), child.wait())
|
|
.await
|
|
.expect("worker should exit after shutdown")
|
|
.expect("wait should succeed");
|
|
assert!(!exit_status.success());
|
|
assert!(!fabro_proc::process_group_alive(worker_pid));
|
|
|
|
let run_state = state
|
|
.store
|
|
.open_run_reader(&run_id)
|
|
.await
|
|
.unwrap()
|
|
.state()
|
|
.await
|
|
.unwrap();
|
|
let run_status = run_state.status.unwrap();
|
|
assert_eq!(run_status.status, WorkflowRunStatus::Failed);
|
|
assert_eq!(
|
|
run_status.status_reason,
|
|
Some(WorkflowStatusReason::Terminated)
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cancel_during_startup_persists_cancelled_reason() {
|
|
let settings: SettingsLayer = fabro_config::parse_settings_layer(
|
|
r#"
|
|
_version = 1
|
|
|
|
[[run.prepare.steps]]
|
|
script = "sleep 5"
|
|
|
|
[run.prepare]
|
|
timeout = "30s"
|
|
"#,
|
|
)
|
|
.expect("fixture should parse");
|
|
let state = create_app_state_with_settings_and_registry_factory(settings, |interviewer| {
|
|
fabro_workflow::handler::default_registry(interviewer, || None)
|
|
});
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id));
|
|
let mut live_status_before_cancel = None;
|
|
for _ in 0..50 {
|
|
live_status_before_cancel = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
runs.get(&run_id).map(|run| run.status)
|
|
};
|
|
if matches!(
|
|
live_status_before_cancel,
|
|
Some(
|
|
RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
)
|
|
) {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
assert!(
|
|
matches!(
|
|
live_status_before_cancel,
|
|
Some(
|
|
RunStatus::Queued
|
|
| RunStatus::Starting
|
|
| RunStatus::Running
|
|
| RunStatus::Blocked
|
|
| RunStatus::Paused
|
|
)
|
|
),
|
|
"run should become cancellable before finishing, saw {live_status_before_cancel:?}"
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let response_status = response.status();
|
|
let response_body = body_json(response.into_body()).await;
|
|
assert_eq!(
|
|
response_status,
|
|
StatusCode::OK,
|
|
"unexpected cancel response body: {response_body}; live status before cancel: {live_status_before_cancel:?}"
|
|
);
|
|
|
|
runner.await.unwrap();
|
|
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get(&run_id).expect("run should exist");
|
|
assert_eq!(managed_run.status, RunStatus::Failed);
|
|
drop(runs);
|
|
|
|
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
|
|
|
|
let mut status_record = None;
|
|
for _ in 0..50 {
|
|
if let Some(record) = run_store.state().await.unwrap().status {
|
|
if record.status == WorkflowRunStatus::Failed
|
|
&& record.status_reason == Some(WorkflowStatusReason::Cancelled)
|
|
{
|
|
status_record = Some(record);
|
|
break;
|
|
}
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
|
|
let status_record = status_record.expect("status record should be persisted");
|
|
assert_eq!(status_record.status, WorkflowRunStatus::Failed);
|
|
assert_eq!(
|
|
status_record.status_reason,
|
|
Some(WorkflowStatusReason::Cancelled)
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "This test intentionally blocks inside a sync registry factory to simulate slow startup before cancellation."
|
|
)]
|
|
async fn cancel_before_run_transitions_to_running_returns_empty_attach_stream() {
|
|
let state = create_app_state_with_registry_factory(|interviewer| {
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
fabro_workflow::handler::default_registry(interviewer, || None)
|
|
});
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let run_id = run_id_str.parse::<RunId>().unwrap();
|
|
|
|
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id));
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/cancel")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::OK).await;
|
|
|
|
runner.await.unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/attach")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_bytes(response, StatusCode::OK).await;
|
|
assert!(body.is_empty(), "expected an empty attach stream");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_position_reported_for_queued_runs() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
// Create and start two runs (no scheduler, both stay queued)
|
|
let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
let second_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
|
|
// Queued runs are excluded from the board, so verify queue positions
|
|
// via the in-memory state directly.
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
let positions = compute_queue_positions(&runs);
|
|
let first_id = first_run_id.parse::<RunId>().unwrap();
|
|
let second_id = second_run_id.parse::<RunId>().unwrap();
|
|
assert_eq!(positions.get(&first_id).copied(), Some(1));
|
|
assert_eq!(positions.get(&second_id).copied(), Some(2));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn concurrency_limit_respected() {
|
|
let state = create_app_state_with_options(SettingsLayer::default(), 1);
|
|
let app = test_app_with_scheduler(Arc::clone(&state));
|
|
|
|
// Create and start two runs with max_concurrent_runs=1
|
|
create_and_start_run(&app, MINIMAL_DOT).await;
|
|
create_and_start_run(&app, MINIMAL_DOT).await;
|
|
|
|
// Give scheduler time to pick up the first run
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
// Board only shows runs with board-column statuses (Running -> "working",
|
|
// Paused -> "pending", Completed -> "merge"). Queued/Starting/Failed are
|
|
// excluded. With max_concurrent_runs=1, at most 1 should be active on
|
|
// the board.
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let items = body["data"].as_array().unwrap();
|
|
let active_count = items
|
|
.iter()
|
|
.filter(|item| item["status"].as_str() == Some("working"))
|
|
.count();
|
|
assert!(
|
|
active_count <= 1,
|
|
"expected at most 1 active run on the board, got {active_count}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_answer_to_queued_run_returns_conflict() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(manifest_body(MINIMAL_DOT))
|
|
.unwrap();
|
|
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let run_id = body["id"].as_str().unwrap().to_string();
|
|
|
|
// Try to submit an answer to a queued run
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/questions/q1/answer")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
|
|
))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::CONFLICT).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_completion_missing_messages_returns_422() {
|
|
let app = test_app_with();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/completions"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demo_boards_runs_returns_run_list_items() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.header("X-Fabro-Demo", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
assert!(!data.is_empty(), "demo should return runs");
|
|
let first = &data[0];
|
|
assert!(first["run_id"].is_string());
|
|
assert!(first["goal"].is_string());
|
|
assert!(first["repository"].is_object());
|
|
assert!(first["title"].is_string());
|
|
assert!(first["status"].is_string());
|
|
assert!(first["column"].is_string());
|
|
assert!(first["workflow_slug"].is_string() || first["workflow_slug"].is_null());
|
|
assert!(first["labels"].is_object());
|
|
assert!(first["created_at"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demo_get_run_returns_store_run_summary_shape() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/run-1"))
|
|
.header("X-Fabro-Demo", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
// Should have StoreRunSummary fields, not RunStatusResponse fields
|
|
assert!(body["run_id"].is_string(), "should have run_id field");
|
|
assert!(body["goal"].is_string(), "should have goal field");
|
|
assert!(
|
|
body["workflow_slug"].is_string(),
|
|
"should have workflow_slug field"
|
|
);
|
|
// Should NOT have RunStatusResponse-only fields
|
|
assert!(
|
|
body["queue_position"].is_null(),
|
|
"should not have queue_position"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demo_get_run_returns_404_for_unknown_run() {
|
|
let state = create_app_state();
|
|
let app = build_router(state, AuthMode::Disabled);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs/nonexistent-run-id"))
|
|
.header("X-Fabro-Demo", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_status(response, StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boards_runs_returns_run_list_items_with_board_columns() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
|
|
|
// Set run to running so it appears on the board
|
|
{
|
|
let id = run_id.parse::<RunId>().unwrap();
|
|
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
|
let managed_run = runs.get_mut(&id).expect("run should exist");
|
|
managed_run.status = RunStatus::Running;
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
let item = data
|
|
.iter()
|
|
.find(|i| i["run_id"].as_str() == Some(&run_id))
|
|
.expect("run should be in board");
|
|
// Should have canonical run summary fields plus board-specific column
|
|
assert!(item["goal"].is_string());
|
|
assert!(item["title"].is_string());
|
|
assert!(item["repository"].is_object());
|
|
assert!(item["workflow_slug"].is_string() || item["workflow_slug"].is_null());
|
|
assert!(item["workflow_name"].is_string() || item["workflow_name"].is_null());
|
|
assert!(item["labels"].is_object());
|
|
assert!(item["status"].is_string());
|
|
assert!(item["column"].is_string());
|
|
assert!(item["created_at"].is_string());
|
|
assert!(item["pending_control"].is_null());
|
|
assert!(item["status_reason"].is_null());
|
|
assert!(item["total_usd_micros"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boards_runs_excludes_removing_status() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = fixtures::RUN_1;
|
|
|
|
// A run in Removing status should not appear on the board
|
|
create_durable_run_with_events(&state, run_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::RunRemoving { reason: None },
|
|
])
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
let found = data
|
|
.iter()
|
|
.any(|i| i["run_id"].as_str() == Some(&run_id.to_string()));
|
|
assert!(!found, "removing run should not appear on the board");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_run_exposes_canonical_operator_statuses() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let succeeded_id = fixtures::RUN_1;
|
|
let removing_id = fixtures::RUN_2;
|
|
let blocked_id = fixtures::RUN_3;
|
|
|
|
create_durable_run_with_events(&state, succeeded_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::WorkflowRunCompleted {
|
|
duration_ms: 1000,
|
|
artifact_count: 0,
|
|
status: "success".to_string(),
|
|
reason: None,
|
|
total_usd_micros: None,
|
|
final_git_commit_sha: None,
|
|
final_patch: None,
|
|
billing: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
create_durable_run_with_events(&state, removing_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::RunRemoving { reason: None },
|
|
])
|
|
.await;
|
|
create_durable_run_with_events(&state, blocked_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
])
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
blocked_id,
|
|
"status-blocked",
|
|
"2026-04-19T12:00:00Z",
|
|
"run.blocked",
|
|
json!({ "blocked_reason": "human_input_required" }),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
for (run_id, expected_status) in [
|
|
(succeeded_id, "succeeded"),
|
|
(removing_id, "removing"),
|
|
(blocked_id, "blocked"),
|
|
] {
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["status"].as_str(), Some(expected_status));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boards_runs_maps_statuses_to_columns() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let paused_id = fixtures::RUN_1;
|
|
let succeeded_id = fixtures::RUN_2;
|
|
let blocked_id = fixtures::RUN_3;
|
|
|
|
create_durable_run_with_events(&state, paused_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::RunPaused,
|
|
])
|
|
.await;
|
|
create_durable_run_with_events(&state, succeeded_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::WorkflowRunCompleted {
|
|
duration_ms: 1000,
|
|
artifact_count: 0,
|
|
status: "success".to_string(),
|
|
reason: None,
|
|
total_usd_micros: None,
|
|
final_git_commit_sha: None,
|
|
final_patch: None,
|
|
billing: None,
|
|
},
|
|
])
|
|
.await;
|
|
create_durable_run_with_events(&state, blocked_id, &[
|
|
workflow_event::Event::RunSubmitted {
|
|
reason: None,
|
|
definition_blob: None,
|
|
},
|
|
workflow_event::Event::RunStarting { reason: None },
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
])
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
blocked_id,
|
|
"blocked-question-1",
|
|
"2026-04-19T12:00:00Z",
|
|
"interview.started",
|
|
json!({
|
|
"question_id": "q-older",
|
|
"question": "Older unresolved question?",
|
|
"stage": "gate",
|
|
"question_type": "multiple_choice",
|
|
"options": [],
|
|
"allow_freeform": false,
|
|
"context_display": null,
|
|
"timeout_seconds": null,
|
|
}),
|
|
Some("gate"),
|
|
)
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
blocked_id,
|
|
"blocked-question-2",
|
|
"2026-04-19T12:00:01Z",
|
|
"interview.started",
|
|
json!({
|
|
"question_id": "q-newer",
|
|
"question": "Newer unresolved question?",
|
|
"stage": "gate",
|
|
"question_type": "multiple_choice",
|
|
"options": [],
|
|
"allow_freeform": false,
|
|
"context_display": null,
|
|
"timeout_seconds": null,
|
|
}),
|
|
Some("gate"),
|
|
)
|
|
.await;
|
|
append_raw_run_event(
|
|
&state,
|
|
blocked_id,
|
|
"blocked-status",
|
|
"2026-04-19T12:00:02Z",
|
|
"run.blocked",
|
|
json!({ "blocked_reason": "human_input_required" }),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = body_json(response.into_body()).await;
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
|
|
let paused_item = data
|
|
.iter()
|
|
.find(|i| i["run_id"].as_str() == Some(&paused_id.to_string()))
|
|
.expect("paused run should be on board");
|
|
assert_eq!(paused_item["status"].as_str().unwrap(), "paused");
|
|
assert_eq!(paused_item["column"].as_str().unwrap(), "running");
|
|
|
|
let succeeded_item = data
|
|
.iter()
|
|
.find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string()))
|
|
.expect("succeeded run should be on board");
|
|
assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded");
|
|
assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded");
|
|
|
|
let blocked_item = data
|
|
.iter()
|
|
.find(|i| i["run_id"].as_str() == Some(&blocked_id.to_string()))
|
|
.expect("blocked run should be on board");
|
|
assert_eq!(blocked_item["status"].as_str().unwrap(), "blocked");
|
|
assert_eq!(blocked_item["column"].as_str().unwrap(), "blocked");
|
|
assert_eq!(
|
|
blocked_item["question"]["text"].as_str(),
|
|
Some("Older unresolved question?")
|
|
);
|
|
|
|
// Verify columns are included in the response
|
|
let columns = body["columns"].as_array().expect("columns should be array");
|
|
assert!(!columns.is_empty());
|
|
assert!(columns.iter().any(|c| c["id"].as_str() == Some("running")));
|
|
assert!(columns.iter().any(|c| c["id"].as_str() == Some("blocked")));
|
|
assert!(
|
|
columns
|
|
.iter()
|
|
.any(|c| c["id"].as_str() == Some("succeeded"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boards_runs_includes_live_board_metadata_from_run_state() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
let run_id = create_and_start_run(&app, MINIMAL_DOT)
|
|
.await
|
|
.parse::<RunId>()
|
|
.unwrap();
|
|
let run_store = state.store.open_run(&run_id).await.unwrap();
|
|
for event in [
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::SandboxInitialized {
|
|
provider: "local".to_string(),
|
|
working_directory: "/sandbox/workdir".to_string(),
|
|
identifier: Some("sb-test".to_string()),
|
|
host_working_directory: Some("/tmp/repo".to_string()),
|
|
container_mount_point: None,
|
|
},
|
|
workflow_event::Event::PullRequestCreated {
|
|
pr_url: "https://github.com/acme/repo/pull/42".to_string(),
|
|
pr_number: 42,
|
|
owner: "acme".to_string(),
|
|
repo: "repo".to_string(),
|
|
base_branch: "main".to_string(),
|
|
head_branch: "fabro/run".to_string(),
|
|
title: "Fix board metadata".to_string(),
|
|
draft: false,
|
|
},
|
|
workflow_event::Event::InterviewStarted {
|
|
question_id: "q-1".to_string(),
|
|
question: "Ship it?".to_string(),
|
|
stage: "review".to_string(),
|
|
question_type: "yes_no".to_string(),
|
|
options: vec![],
|
|
allow_freeform: false,
|
|
timeout_seconds: None,
|
|
context_display: None,
|
|
},
|
|
] {
|
|
workflow_event::append_event(&run_store, &run_id, &event)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
let item = data
|
|
.iter()
|
|
.find(|i| i["run_id"].as_str() == Some(&run_id.to_string()))
|
|
.expect("run should be in board");
|
|
|
|
assert_eq!(item["pull_request"]["number"].as_u64(), Some(42));
|
|
assert_eq!(item["sandbox"]["id"].as_str(), Some("sb-test"));
|
|
assert_eq!(item["question"]["text"].as_str(), Some("Ship it?"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn boards_runs_page_limit_preserves_metadata_for_paged_items() {
|
|
let state = create_app_state();
|
|
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
|
|
|
let first_run_id = create_and_start_run(&app, MINIMAL_DOT)
|
|
.await
|
|
.parse::<RunId>()
|
|
.unwrap();
|
|
let second_run_id = create_and_start_run(&app, MINIMAL_DOT)
|
|
.await
|
|
.parse::<RunId>()
|
|
.unwrap();
|
|
|
|
for (run_id, sandbox_id) in [(first_run_id, "sb-first"), (second_run_id, "sb-second")] {
|
|
let run_store = state.store.open_run(&run_id).await.unwrap();
|
|
for event in [
|
|
workflow_event::Event::RunRunning { reason: None },
|
|
workflow_event::Event::SandboxInitialized {
|
|
provider: "local".to_string(),
|
|
working_directory: "/sandbox/workdir".to_string(),
|
|
identifier: Some(sandbox_id.to_string()),
|
|
host_working_directory: Some("/tmp/repo".to_string()),
|
|
container_mount_point: None,
|
|
},
|
|
] {
|
|
workflow_event::append_event(&run_store, &run_id, &event)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/boards/runs?page[limit]=1"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK).await;
|
|
assert_eq!(body["meta"]["has_more"].as_bool(), Some(true));
|
|
|
|
let data = body["data"].as_array().expect("data should be array");
|
|
assert_eq!(data.len(), 1);
|
|
|
|
let item = &data[0];
|
|
let sandbox_id = item["sandbox"]["id"]
|
|
.as_str()
|
|
.expect("paged item should still include sandbox metadata");
|
|
assert!(matches!(sandbox_id, "sb-first" | "sb-second"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_github_slug_accepts_real_names() {
|
|
assert!(super::validate_github_slug("owner", "anthropic", 39).is_ok());
|
|
assert!(super::validate_github_slug("repo", "claude-code", 100).is_ok());
|
|
assert!(super::validate_github_slug("repo", "repo.name_1", 100).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_github_slug_rejects_path_traversal_and_separators() {
|
|
for bad in ["", "..", "foo/bar", "foo%2Fbar", "foo\\bar", "foo?x", "a b"] {
|
|
assert!(
|
|
super::validate_github_slug("owner", bad, 39).is_err(),
|
|
"expected rejection for {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_github_slug_rejects_overlong() {
|
|
let long = "a".repeat(40);
|
|
assert!(super::validate_github_slug("owner", &long, 39).is_err());
|
|
}
|
|
}
|