diff --git a/Cargo.lock b/Cargo.lock index c779b34bf..493f3ae65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1544,6 +1544,7 @@ name = "fabro-api" version = "0.208.0-nightly.1" dependencies = [ "chrono", + "fabro-types", "openapiv3", "prettyplease", "progenitor", diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index 98ef6836d..cf3335ea6 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -15,6 +15,7 @@ wildcard_imports = "warn" [dependencies] chrono = { workspace = true, features = ["serde"] } +fabro-types = { path = "../fabro-types" } progenitor-client = "0.13" regress = "0.10" reqwest.workspace = true diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index df667b1d5..353472285 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::{env, fs}; -use progenitor::{GenerationSettings, Generator, InterfaceStyle}; +use progenitor::{GenerationSettings, Generator, InterfaceStyle, TypeImpl}; /// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: /// true`. @@ -160,6 +160,31 @@ fn main() { let mut settings = GenerationSettings::default(); settings.with_interface(InterfaceStyle::Builder); + settings.with_replacement( + "RunStatus", + "fabro_types::status::RunStatus", + [TypeImpl::FromStr, TypeImpl::Display].into_iter(), + ); + settings.with_replacement( + "StatusReason", + "fabro_types::status::StatusReason", + std::iter::empty::(), + ); + settings.with_replacement( + "BlockedReason", + "fabro_types::status::BlockedReason", + std::iter::empty::(), + ); + settings.with_replacement( + "RunControlAction", + "fabro_types::status::RunControlAction", + std::iter::empty::(), + ); + settings.with_replacement( + "RunStatusRecord", + "fabro_types::status::RunStatusRecord", + std::iter::empty::(), + ); let mut generator = Generator::new(&settings); let tokens = generator diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index ca2774c83..b039d6c12 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -13,4 +13,11 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } -pub use generated::{Client as ApiClient, types}; +pub mod types { + pub use fabro_types::status::{ + BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason, + }; + + pub use crate::generated::types::*; +} +pub use generated::Client as ApiClient; diff --git a/lib/crates/fabro-api/tests/status_round_trip.rs b/lib/crates/fabro-api/tests/status_round_trip.rs new file mode 100644 index 000000000..fa1c53ff1 --- /dev/null +++ b/lib/crates/fabro-api/tests/status_round_trip.rs @@ -0,0 +1,192 @@ +use std::any::{TypeId, type_name}; + +use chrono::{TimeZone, Utc}; +use fabro_api::types::{ + BlockedReason as ApiBlockedReason, RunControlAction as ApiRunControlAction, + RunStatus as ApiRunStatus, RunStatusRecord as ApiRunStatusRecord, + StatusReason as ApiStatusReason, +}; +use fabro_types::status::{ + BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason, +}; +use serde::Serialize; +use serde_json::{Value, json}; + +#[test] +fn status_family_reuses_domain_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn status_family_json_tokens_match_openapi() { + for (api_status, workflow_status, expected) in [ + (ApiRunStatus::Submitted, RunStatus::Submitted, "submitted"), + (ApiRunStatus::Queued, RunStatus::Queued, "queued"), + (ApiRunStatus::Starting, RunStatus::Starting, "starting"), + (ApiRunStatus::Running, RunStatus::Running, "running"), + (ApiRunStatus::Blocked, RunStatus::Blocked, "blocked"), + (ApiRunStatus::Paused, RunStatus::Paused, "paused"), + (ApiRunStatus::Removing, RunStatus::Removing, "removing"), + (ApiRunStatus::Succeeded, RunStatus::Succeeded, "succeeded"), + (ApiRunStatus::Failed, RunStatus::Failed, "failed"), + (ApiRunStatus::Dead, RunStatus::Dead, "dead"), + (ApiRunStatus::Archived, RunStatus::Archived, "archived"), + ] { + assert_string_json(api_status, expected); + assert_string_json(workflow_status, expected); + } + + for (api_reason, workflow_reason, expected) in [ + ( + ApiStatusReason::Completed, + StatusReason::Completed, + "completed", + ), + ( + ApiStatusReason::PartialSuccess, + StatusReason::PartialSuccess, + "partial_success", + ), + ( + ApiStatusReason::WorkflowError, + StatusReason::WorkflowError, + "workflow_error", + ), + ( + ApiStatusReason::Cancelled, + StatusReason::Cancelled, + "cancelled", + ), + ( + ApiStatusReason::Terminated, + StatusReason::Terminated, + "terminated", + ), + ( + ApiStatusReason::TransientInfra, + StatusReason::TransientInfra, + "transient_infra", + ), + ( + ApiStatusReason::BudgetExhausted, + StatusReason::BudgetExhausted, + "budget_exhausted", + ), + ( + ApiStatusReason::LaunchFailed, + StatusReason::LaunchFailed, + "launch_failed", + ), + ( + ApiStatusReason::BootstrapFailed, + StatusReason::BootstrapFailed, + "bootstrap_failed", + ), + ( + ApiStatusReason::SandboxInitFailed, + StatusReason::SandboxInitFailed, + "sandbox_init_failed", + ), + ( + ApiStatusReason::SandboxInitializing, + StatusReason::SandboxInitializing, + "sandbox_initializing", + ), + ] { + assert_string_json(api_reason, expected); + assert_string_json(workflow_reason, expected); + } + + assert_string_json(ApiBlockedReason::HumanInputRequired, "human_input_required"); + assert_string_json(BlockedReason::HumanInputRequired, "human_input_required"); + + for (api_action, workflow_action, expected) in [ + ( + ApiRunControlAction::Cancel, + RunControlAction::Cancel, + "cancel", + ), + (ApiRunControlAction::Pause, RunControlAction::Pause, "pause"), + ( + ApiRunControlAction::Unpause, + RunControlAction::Unpause, + "unpause", + ), + ] { + assert_string_json(api_action, expected); + assert_string_json(workflow_action, expected); + } +} + +#[test] +fn run_status_record_json_matches_openapi_shape() { + let updated_at = Utc + .with_ymd_and_hms(2026, 1, 2, 3, 4, 5) + .single() + .expect("fixed timestamp should be valid"); + let expected = json!({ + "status": "failed", + "status_reason": "cancelled", + "blocked_reason": "human_input_required", + "updated_at": "2026-01-02T03:04:05Z" + }); + + let api_record = ApiRunStatusRecord { + status: ApiRunStatus::Failed, + status_reason: Some(ApiStatusReason::Cancelled), + blocked_reason: Some(ApiBlockedReason::HumanInputRequired), + updated_at, + }; + assert_eq!(serde_json::to_value(&api_record).unwrap(), expected); + let api_round_trip: ApiRunStatusRecord = serde_json::from_value(expected.clone()).unwrap(); + assert_eq!(api_round_trip.status, ApiRunStatus::Failed); + assert_eq!( + api_round_trip.status_reason, + Some(ApiStatusReason::Cancelled) + ); + assert_eq!( + api_round_trip.blocked_reason, + Some(ApiBlockedReason::HumanInputRequired) + ); + assert_eq!(api_round_trip.updated_at, updated_at); + + let workflow_record = RunStatusRecord { + status: RunStatus::Failed, + status_reason: Some(StatusReason::Cancelled), + blocked_reason: Some(BlockedReason::HumanInputRequired), + updated_at, + }; + assert_eq!(serde_json::to_value(&workflow_record).unwrap(), expected); + let workflow_round_trip: RunStatusRecord = serde_json::from_value(expected).unwrap(); + assert_eq!(workflow_round_trip.status, RunStatus::Failed); + assert_eq!( + workflow_round_trip.status_reason, + Some(StatusReason::Cancelled) + ); + assert_eq!( + workflow_round_trip.blocked_reason, + Some(BlockedReason::HumanInputRequired) + ); + assert_eq!(workflow_round_trip.updated_at, updated_at); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} + +fn assert_string_json(value: T, expected: &str) { + assert_eq!( + serde_json::to_value(value).unwrap(), + Value::String(expected.into()) + ); +}