fix(cli): keep validate off runtime preflight

Add a validation-only API response and route while keeping fabro validate local so it does not start or contact the server for structural workflow checks.
This commit is contained in:
Bryan Helmkamp 2026-04-28 14:50:04 -07:00
parent 2cbd3768b8
commit 4ca8962a02
No known key found for this signature in database
13 changed files with 283 additions and 24 deletions

View file

@ -617,7 +617,7 @@ paths:
operationId: runPreflight
tags: [Runs]
summary: Validate Workflow Manifest
description: Validates a workflow manifest without creating a run.
description: Validates runtime readiness for a workflow manifest without creating a run.
requestBody:
required: true
content:
@ -638,6 +638,32 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/validate:
post:
operationId: validateRunManifest
tags: [Runs]
summary: Validate Workflow Manifest
description: Validates workflow structure and diagnostics without runtime readiness checks.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RunManifest"
responses:
"200":
description: Validation result
content:
application/json:
schema:
$ref: "#/components/schemas/ValidateResponse"
"400":
description: Invalid manifest or workflow
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/graph/render:
post:
operationId: renderWorkflowGraph
@ -3865,6 +3891,18 @@ components:
checks:
$ref: "#/components/schemas/PreflightCheckReport"
ValidateResponse:
type: object
required:
- ok
- workflow
properties:
ok:
type: boolean
description: Whether validation passed with no error diagnostics.
workflow:
$ref: "#/components/schemas/PreflightWorkflowSummary"
RenderWorkflowGraphRequest:
type: object
required:

View file

@ -388,9 +388,6 @@ pub(crate) struct LogsArgs {
#[derive(Args)]
pub(crate) struct ValidateArgs {
#[command(flatten)]
pub(crate) target: ServerTargetArgs,
/// Path to the .fabro workflow file
pub(crate) workflow: PathBuf,
}

View file

@ -1,5 +1,7 @@
use anyhow::bail;
use fabro_config::RunLayer;
use fabro_config::user::active_settings_path;
use fabro_server::manifest_validation;
use fabro_util::terminal::Styles;
use crate::args::ValidateArgs;
@ -14,21 +16,19 @@ pub(crate) async fn run(
base_ctx: &CommandContext,
) -> anyhow::Result<()> {
let printer = base_ctx.printer();
let ctx = base_ctx.with_target(&args.target)?;
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
cwd: base_ctx.cwd().to_path_buf(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;
let response = client.run_preflight(built.manifest).await?;
let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);
if ctx.json_output() {
if base_ctx.json_output() {
print_json_pretty(&serde_json::json!({
"workflow_name": response.workflow.name,
"nodes": response.workflow.nodes,

View file

@ -1,5 +1,7 @@
use fabro_test::{fabro_snapshot, test_context};
use crate::support::LightweightCli;
fn fixture(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
@ -25,7 +27,6 @@ fn help() {
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
@ -51,6 +52,23 @@ fn simple() {
");
}
#[test]
fn simple_does_not_connect_to_configured_server() {
let cli = LightweightCli::new();
let mut cmd = cli.command();
cmd.env("FABRO_SERVER", "http://127.0.0.1:9")
.arg("validate")
.arg(fixture("simple.fabro"));
let output = cmd.output().expect("validate should execute");
assert!(
output.status.success(),
"validate should run locally without connecting to FABRO_SERVER\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn branching() {
let context = test_context!();

View file

@ -695,6 +695,21 @@ impl Client {
.map(progenitor_client::ResponseValue::into_inner)
}
pub async fn validate_run_manifest(
&self,
manifest: types::RunManifest,
) -> Result<types::ValidateResponse> {
self.send_api(|client| async move {
client
.validate_run_manifest()
.body(manifest.clone())
.send()
.await
})
.await
.map(progenitor_client::ResponseValue::into_inner)
}
pub async fn render_workflow_graph(
&self,
request: types::RenderWorkflowGraphRequest,

View file

@ -24,6 +24,7 @@ pub mod github_webhooks;
pub mod install;
pub mod ip_allowlist;
pub mod jwt_auth;
pub mod manifest_validation;
mod run_files;
mod run_files_security;
mod run_manifest;

View file

@ -0,0 +1,20 @@
use anyhow::{Result, anyhow};
use fabro_api::types;
use fabro_config::RunLayer;
use fabro_workflow::Error as WorkflowError;
pub fn validate_manifest(
manifest_run_defaults: &RunLayer,
manifest: &types::RunManifest,
) -> Result<types::ValidateResponse> {
let prepared = crate::run_manifest::prepare_manifest(manifest_run_defaults, manifest)?;
let validated = crate::run_manifest::validate_prepared_manifest(&prepared)
.map_err(validation_error_to_anyhow)?;
Ok(crate::run_manifest::validate_response(
&prepared, &validated,
))
}
fn validation_error_to_anyhow(err: WorkflowError) -> anyhow::Error {
anyhow!("{err}")
}

View file

@ -186,6 +186,16 @@ pub(crate) async fn run_preflight(
))
}
pub(crate) fn validate_response(
prepared: &PreparedManifest,
validated: &Validated,
) -> types::ValidateResponse {
types::ValidateResponse {
ok: !validated.has_errors(),
workflow: workflow_summary(validated, &prepared.target_path),
}
}
pub(crate) fn graph_source(prepared: &PreparedManifest, direction: Option<&str>) -> String {
direction.map_or_else(
|| prepared.root_source.clone(),
@ -980,16 +990,20 @@ fn preflight_response(
types::PreflightResponse {
ok,
checks: report_to_api(report),
workflow: types::PreflightWorkflowSummary {
diagnostics: diagnostics_to_api(validated.diagnostics()),
edges: i64::try_from(validated.graph().edges.len())
.expect("graph edge count should fit in i64"),
goal: validated.graph().goal().to_string(),
graph_path: Some(target_path.display().to_string()),
name: validated.graph().name.clone(),
nodes: i64::try_from(validated.graph().nodes.len())
.expect("graph node count should fit in i64"),
},
workflow: workflow_summary(validated, target_path),
}
}
fn workflow_summary(validated: &Validated, target_path: &Path) -> types::PreflightWorkflowSummary {
types::PreflightWorkflowSummary {
diagnostics: diagnostics_to_api(validated.diagnostics()),
edges: i64::try_from(validated.graph().edges.len())
.expect("graph edge count should fit in i64"),
goal: validated.graph().goal().to_string(),
graph_path: Some(target_path.display().to_string()),
name: validated.graph().name.clone(),
nodes: i64::try_from(validated.graph().nodes.len())
.expect("graph node count should fit in i64"),
}
}

View file

@ -1118,6 +1118,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
.route("/runs/resolve", get(demo::resolve_run))
.route("/boards/runs", get(demo::list_board_runs))
.route("/preflight", post(run_preflight))
.route("/validate", post(validate_run_manifest))
.route("/graph/render", post(render_graph_from_manifest))
.route("/attach", get(demo::attach_events_stub))
.route("/runs/{id}", get(demo::get_run_status))
@ -1201,6 +1202,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/runs", get(list_runs).post(create_run))
.route("/runs/resolve", get(resolve_run))
.route("/preflight", post(run_preflight))
.route("/validate", post(validate_run_manifest))
.route("/graph/render", post(render_graph_from_manifest))
.route("/attach", get(attach_events))
.route("/boards/runs", get(list_board_runs))
@ -4322,6 +4324,30 @@ async fn run_preflight(
(StatusCode::OK, Json(response)).into_response()
}
async fn validate_run_manifest(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(req): Json<RunManifest>,
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) {
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(),
};
(
StatusCode::OK,
Json(run_manifest::validate_response(&prepared, &validated)),
)
.into_response()
}
async fn render_graph_from_manifest(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -9468,6 +9494,29 @@ allowed_usernames = ["octocat"]
body["id"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn validate_endpoint_returns_workflow_summary_without_preflight_checks() {
let app = test_app_with();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/validate"))
.header("content-type", "application/json")
.body(manifest_body(MINIMAL_DOT))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["ok"], true);
assert_eq!(body["workflow"]["name"], "Test");
assert_eq!(body["workflow"]["nodes"], 2);
assert_eq!(body["workflow"]["edges"], 1);
assert!(body.get("checks").is_none());
}
async fn create_run_for_target(app: &Router, target_path: &str, dot_source: &str) -> String {
let req = Request::builder()
.method("POST")
@ -11811,6 +11860,7 @@ slug = "fabro"
(Method::POST, "/runs".to_string()),
(Method::GET, "/runs/resolve".to_string()),
(Method::POST, "/preflight".to_string()),
(Method::POST, "/validate".to_string()),
(Method::POST, "/graph/render".to_string()),
(Method::GET, "/attach".to_string()),
(Method::GET, "/boards/runs".to_string()),

View file

@ -257,6 +257,7 @@ models/timeline-entry-response.ts
models/tool-stage-turn.ts
models/tool-use.ts
models/user-response.ts
models/validate-response.ts
models/webhook-strategy.ts
models/workflow-diagnostic.ts
models/workflow-reference.ts

View file

@ -61,6 +61,8 @@ import type { RunSummary } from '../models';
import type { StartRunRequest } from '../models';
// @ts-ignore
import type { TimelineEntryResponse } from '../models';
// @ts-ignore
import type { ValidateResponse } from '../models';
/**
* RunsApi - axios parameter creator
*/
@ -870,7 +872,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Validates a workflow manifest without creating a run.
* Validates runtime readiness for a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
@ -1028,6 +1030,47 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
validateRunManifest: async (runManifest: RunManifest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'runManifest' is not null or undefined
assertParamExists('validateRunManifest', 'runManifest', runManifest)
const localVarPath = `/api/v1/validate`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(runManifest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -1298,7 +1341,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates a workflow manifest without creating a run.
* Validates runtime readiness for a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
@ -1350,6 +1393,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.unpauseRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async validateRunManifest(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ValidateResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.validateRunManifest(runManifest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.validateRunManifest']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@ -1558,7 +1614,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.rewindRun(id, rewindRequest, options).then((request) => request(axios, basePath));
},
/**
* Validates a workflow manifest without creating a run.
* Validates runtime readiness for a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
@ -1598,6 +1654,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
unpauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
return localVarFp.unpauseRun(id, options).then((request) => request(axios, basePath));
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
validateRunManifest(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise<ValidateResponse> {
return localVarFp.validateRunManifest(runManifest, options).then((request) => request(axios, basePath));
},
};
};
@ -1823,7 +1889,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Validates a workflow manifest without creating a run.
* Validates runtime readiness for a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
@ -1866,5 +1932,16 @@ export class RunsApi extends BaseAPI {
public unpauseRun(id: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).unpauseRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public validateRunManifest(runManifest: RunManifest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).validateRunManifest(runManifest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -236,6 +236,7 @@ export * from './timeline-entry-response';
export * from './tool-stage-turn';
export * from './tool-use';
export * from './user-response';
export * from './validate-response';
export * from './webhook-strategy';
export * from './workflow-diagnostic';
export * from './workflow-reference';

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { PreflightWorkflowSummary } from './preflight-workflow-summary';
export interface ValidateResponse {
/**
* Whether validation passed with no error diagnostics.
*/
'ok': boolean;
'workflow': PreflightWorkflowSummary;
}