fabro/lib/apps/fabro-server/src/run_compiler.rs
Bryan Helmkamp 619cb44e3c
Import lithos-llm types directly instead of through fabro-types
fabro-types no longer re-exports the lithos catalog and request types
(ProviderId, ModelId, ModelHandle, Message, ContentPart, TokenCounts,
Cost, Speed, ReasoningEffort, ReasoningOutput, and the rest). Every
crate that uses them depends on lithos-llm and names them there, and
the fabro-api progenitor replacements point at the lithos paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:03:08 -06:00

1083 lines
35 KiB
Rust

//! The create-time run compiler: the single pipeline that turns an acquired
//! workflow bundle into a complete, persistable run.
//!
//! The pipeline has four stages, each its own function with typed input and
//! output:
//!
//! 1. [`normalize_source`] — resolve the bundle entrypoint and parse the
//! bundle-relative settings sources (workflow and project layers, with
//! dockerfile references inlined from bundled files).
//! 2. [`layer_settings`] + [`apply_run_variables`] + graph compilation — layer
//! settings from every configured source, substitute the run-scoped variable
//! snapshot, then parse/transform/validate the graph through the
//! fabro-workflow pipeline.
//! 3. Model pinning — materialize run-level model settings against the catalog
//! and the configured provider set. Stages 2's graph compilation and stage 3
//! share one blocking dispatch via [`compile_and_pin`].
//! 4. [`assemble_run`] — purely assemble the complete persistence input; no
//! field is mutated after assembly.
//!
//! The input is deliberately source-neutral: it speaks in terms of an
//! acquired [`WorkflowBundle`], not any wire request type, so non-HTTP
//! callers and alternative workflow sources can drive the same pipeline.
//! Callers own source acquisition, run-id resolution, variable snapshotting,
//! and (for HTTP callers) all wire mapping — including turning
//! [`RunCompilerError`] into HTTP responses.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fabro_config::parse::{self, ParseError, SettingsSource};
use fabro_config::{
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, MergeMap,
RunLayer, SettingsLayer, WorkflowSettingsBuilder,
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::settings::interp::{InterpString, ResolveError};
use fabro_types::settings::run::{McpServerSettings, RunGoal};
use fabro_types::{
AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, RunTarget, WorkflowSettings,
WorkflowVersionId,
};
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::operations::{
self, CompiledRun, CreateRunCompileInput, CreateRunPersistenceInput,
CreateRunPersistenceMetadata, MaterializedRun, WorkflowInput,
};
use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle};
use lithos_llm::catalog::ProviderId;
use tokio::task;
/// One project settings source in the acquired source's path namespace.
#[derive(Debug)]
pub(crate) struct ProjectSettingsSource {
pub(crate) path: std::result::Result<ManifestPath, ProjectSettingsPathError>,
pub(crate) toml: String,
}
/// A project settings path that the source adapter could not normalize.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ProjectSettingsPathError {
#[error("project settings path is missing")]
Missing,
#[error("invalid project settings path: {path}")]
Invalid { path: String },
}
/// Transport-neutral inputs for compiling one submitted run.
///
/// Identity (`run_id`), lineage, title, git metadata, and provenance are
/// resolved by the caller. This boundary owns only source normalization,
/// settings resolution, workflow compilation, model pinning, and
/// persistence-input assembly.
#[derive(Debug)]
pub(crate) struct RawRunCompilerInput {
pub(crate) workflow_bundle: WorkflowBundle,
pub(crate) entrypoint: ManifestPath,
pub(crate) cwd: PathBuf,
pub(crate) server_run_defaults: RunLayer,
pub(crate) server_environment_defaults: MergeMap<EnvironmentLayer>,
pub(crate) server_mcp_catalog: HashMap<String, McpServerSettings>,
pub(crate) settings_input: RunCompilerSettingsInput,
pub(crate) user_toml: Vec<String>,
pub(crate) run_overrides: Option<RunLayer>,
pub(crate) cli_overrides: Option<CliLayer>,
pub(crate) input_overrides: HashMap<String, toml::Value>,
pub(crate) inline_goal_override: Option<String>,
pub(crate) run_id: Option<RunId>,
pub(crate) title: Option<String>,
pub(crate) parent_id: Option<RunId>,
pub(crate) git: Option<GitContext>,
pub(crate) storage_root: PathBuf,
pub(crate) workflow_slug: Option<String>,
pub(crate) workflow_version_id: Option<WorkflowVersionId>,
pub(crate) target: Option<RunTarget>,
pub(crate) provenance: RunProvenance,
pub(crate) web_url: Option<String>,
pub(crate) submitted_manifest_bytes: Option<Vec<u8>>,
pub(crate) automation: Option<AutomationRef>,
}
/// Settings already admitted by the caller, or the unchanged legacy manifest
/// inputs that still need their historical parsing and lookup behavior.
#[derive(Debug)]
pub(crate) enum RunCompilerSettingsInput {
LegacyManifest {
project_settings: Vec<ProjectSettingsSource>,
},
Admitted {
workflow_layer: Option<Box<SettingsLayer>>,
},
}
/// Stage-one output: the selected bundled workflow and all client settings
/// sources have been parsed and normalized, but no settings have been layered.
pub(crate) struct NormalizedRun {
workflow_bundle: WorkflowBundle,
entrypoint: ManifestPath,
workflow: BundledWorkflow,
workflow_layer: Option<SettingsLayer>,
project_layers: Vec<SettingsLayer>,
user_toml: Vec<String>,
cwd: PathBuf,
server_run_defaults: RunLayer,
server_environment_defaults: MergeMap<EnvironmentLayer>,
server_mcp_catalog: HashMap<String, McpServerSettings>,
run_overrides: Option<RunLayer>,
cli_overrides: Option<CliLayer>,
input_overrides: HashMap<String, toml::Value>,
inline_goal_override: Option<String>,
metadata: RunMetadata,
}
struct RunMetadata {
run_id: Option<RunId>,
storage_root: PathBuf,
workflow_slug: Option<String>,
workflow_version_id: Option<WorkflowVersionId>,
target: Option<RunTarget>,
submitted_manifest_bytes: Option<Vec<u8>>,
title: Option<String>,
automation: Option<AutomationRef>,
git: Option<GitContext>,
parent_id: Option<RunId>,
provenance: RunProvenance,
web_url: Option<String>,
}
/// Settings-layered output. Variable substitution is a separate stage so
/// callers can snapshot run variables after settings resolution and apply
/// the snapshot through [`apply_run_variables`].
pub(crate) struct LayeredRun {
workflow_bundle: WorkflowBundle,
entrypoint: ManifestPath,
workflow: BundledWorkflow,
settings: WorkflowSettings,
cwd: PathBuf,
metadata: RunMetadata,
}
/// Variable-substituted stage output. Callers may inspect the resolved
/// settings before policy checks, then move it into [`compile_and_pin`].
pub(crate) struct PreparedRun {
layered: LayeredRun,
vars: HashMap<String, String>,
}
impl PreparedRun {
pub(crate) fn settings(&self) -> &WorkflowSettings {
&self.layered.settings
}
pub(crate) fn with_target_and_git(
mut self,
target: RunTarget,
git: Option<GitContext>,
) -> Self {
self.layered.metadata.target = Some(target);
self.layered.metadata.git = git;
self
}
pub(crate) fn with_identity(
mut self,
run_id: Option<RunId>,
parent_id: Option<RunId>,
title: Option<String>,
) -> Self {
self.layered.metadata.run_id = run_id;
self.layered.metadata.parent_id = parent_id;
self.layered.metadata.title = title;
self
}
pub(crate) fn parent_id(&self) -> Option<RunId> {
self.layered.metadata.parent_id
}
pub(crate) fn resolve_run_id(mut self) -> (Self, RunId) {
let run_id = self.layered.metadata.run_id.unwrap_or_default();
self.layered.metadata.run_id = Some(run_id);
(self, run_id)
}
pub(crate) fn with_web_url(mut self, web_url: Option<String>) -> Self {
self.layered.metadata.web_url = web_url;
self
}
}
/// Graph-compiled stage output, retaining the metadata needed by later pure
/// assembly.
struct GraphCompiledRun {
compiled: CompiledRun,
metadata: RunMetadata,
}
/// Model-pinned stage output ready for pure persistence-input assembly.
pub(crate) struct PinnedRun {
materialized: MaterializedRun,
metadata: RunMetadata,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum RunCompilerError {
/// The acquired source bundle is invalid: missing entrypoint or broken
/// bundled-file references.
#[error(transparent)]
InvalidSource(#[from] InvalidSourceError),
/// A settings source or path is invalid, or the layered settings failed to
/// resolve.
#[error(transparent)]
InvalidSettings(Box<InvalidSettingsError>),
/// The run-variable snapshot could not be substituted into the resolved
/// run settings.
#[error("Run config variable interpolation failed: {0}")]
VariableInterpolation(#[from] VariableInterpolationError),
/// Graph compilation or model pinning failed in the workflow engine. The
/// full [`WorkflowError`] is preserved so callers can distinguish
/// validation, parse, and model-selection failures.
#[error(transparent)]
Workflow(#[from] WorkflowError),
}
// The `Display` strings below are pinned to the pre-extraction wire
// contract: both the create handler and the manifest preparation path render
// them directly into HTTP 400 details.
#[derive(Debug, thiserror::Error)]
pub(crate) enum InvalidSourceError {
#[error("manifest target path is missing from workflows map")]
MissingEntrypoint { entrypoint: ManifestPath },
#[error("unsupported dockerfile reference: {reference}")]
UnsupportedDockerfileReference {
config_path: ManifestPath,
reference: String,
},
#[error("missing bundled dockerfile: {dockerfile_path}")]
MissingDockerfile {
config_path: ManifestPath,
dockerfile_path: ManifestPath,
},
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum InvalidSettingsError {
#[error("Failed to parse run config TOML")]
Parse {
path: ManifestPath,
#[source]
source: ParseError,
},
#[error(transparent)]
User(fabro_config::Error),
#[error("failed to resolve manifest settings")]
Resolve {
#[source]
source: fabro_config::ResolveErrors,
},
#[error("{}", project_path_error(.source))]
ProjectPath { source: ProjectSettingsPathError },
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum VariableInterpolationError {
#[error(transparent)]
Interpolation(#[from] ResolveError),
#[error("run.artifacts.include[{index}]: {source}")]
ArtifactGlob {
index: usize,
#[source]
source: WorkspaceGlobError,
},
}
pub(crate) type Result<T> = std::result::Result<T, RunCompilerError>;
fn project_path_error(source: &ProjectSettingsPathError) -> String {
match source {
ProjectSettingsPathError::Missing => {
"invalid manifest project config path: missing path".to_string()
}
ProjectSettingsPathError::Invalid { path } => {
format!("invalid manifest project config path: {path}")
}
}
}
fn invalid_settings(source: InvalidSettingsError) -> RunCompilerError {
RunCompilerError::InvalidSettings(Box::new(source))
}
/// Normalize the bundle entrypoint and parse workflow/project settings while
/// resolving dockerfile references against the selected workflow's files.
pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedRun> {
let RawRunCompilerInput {
workflow_bundle,
entrypoint,
cwd,
server_run_defaults,
server_environment_defaults,
server_mcp_catalog,
settings_input,
user_toml,
run_overrides,
cli_overrides,
input_overrides,
inline_goal_override,
run_id,
title,
parent_id,
git,
storage_root,
workflow_slug,
workflow_version_id,
target,
provenance,
web_url,
submitted_manifest_bytes,
automation,
} = input;
let mut workflow = workflow_bundle
.workflow(&entrypoint)
.cloned()
.ok_or_else(|| InvalidSourceError::MissingEntrypoint {
entrypoint: entrypoint.clone(),
})?;
workflow.path = entrypoint.clone();
let (workflow_layer, project_layers) = match settings_input {
RunCompilerSettingsInput::LegacyManifest { project_settings } => {
let workflow_layer = workflow
.config
.as_ref()
.map(|config| {
settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&workflow.files,
SettingsSource::Workflow,
)
})
.transpose()?;
let project_layers = project_settings
.into_iter()
.map(|project| {
let path = project.path.map_err(|source| {
invalid_settings(InvalidSettingsError::ProjectPath { source })
})?;
settings_layer_with_resolved_dockerfiles(
&project.toml,
&path,
&workflow.files,
SettingsSource::Project,
)
})
.collect::<Result<Vec<_>>>()?;
(workflow_layer, project_layers)
}
RunCompilerSettingsInput::Admitted { workflow_layer } => {
(workflow_layer.map(|layer| *layer), Vec::new())
}
};
Ok(NormalizedRun {
workflow_bundle,
entrypoint,
workflow,
workflow_layer,
project_layers,
user_toml,
cwd,
server_run_defaults,
server_environment_defaults,
server_mcp_catalog,
run_overrides,
cli_overrides,
input_overrides,
inline_goal_override,
metadata: RunMetadata {
run_id,
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
git,
parent_id,
provenance,
web_url,
},
})
}
/// Layer settings from every configured source and apply the submitted input
/// and goal overrides.
pub(crate) fn layer_settings(normalized: NormalizedRun) -> Result<LayeredRun> {
let NormalizedRun {
workflow_bundle,
entrypoint,
workflow,
workflow_layer,
project_layers,
user_toml,
cwd,
server_run_defaults,
server_environment_defaults,
server_mcp_catalog,
run_overrides,
cli_overrides,
input_overrides,
inline_goal_override,
metadata,
} = normalized;
let mut builder = WorkflowSettingsBuilder::new()
.server_manifest_defaults(server_run_defaults, server_environment_defaults)
.server_mcp_catalog(server_mcp_catalog);
if let Some(run) = run_overrides {
builder = builder.run_overrides(run);
}
if let Some(cli) = cli_overrides {
builder = builder.cli_overrides(cli);
}
if let Some(layer) = workflow_layer {
builder = builder.workflow_layer(layer);
}
for layer in project_layers {
builder = builder.project_layer(layer);
}
for source in user_toml {
builder = builder
.user_toml(&source)
.map_err(|source| invalid_settings(InvalidSettingsError::User(source)))?;
}
let mut settings = builder
.build()
.map_err(|source| invalid_settings(InvalidSettingsError::Resolve { source }))?;
settings.run.inputs.extend(input_overrides);
if let Some(goal) = inline_goal_override {
settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal)));
}
Ok(LayeredRun {
workflow_bundle,
entrypoint,
workflow,
settings,
cwd,
metadata,
})
}
/// Apply a run-variable snapshot to the layered settings and validate the
/// resulting artifact globs. The snapshot is also retained for graph template
/// rendering during compilation.
pub(crate) fn apply_run_variables(
mut layered: LayeredRun,
vars: HashMap<String, String>,
) -> Result<PreparedRun> {
substitute_run_variables(&vars, &mut layered.settings)?;
Ok(PreparedRun { layered, vars })
}
/// Compile and validate the graph, then pin run-level model settings, in one
/// dispatch on Tokio's blocking pool: graph compilation is CPU-heavy and may
/// read a goal file, and pinning is pure CPU that belongs alongside it.
pub(crate) async fn compile_and_pin(
prepared: PreparedRun,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
) -> Result<PinnedRun> {
task::spawn_blocking(move || {
let compiled = compile_graph(prepared, configured_providers, Arc::clone(&catalog))?;
pin_models(compiled, &catalog)
})
.await
.map_err(|source| {
RunCompilerError::Workflow(WorkflowError::engine_with_source(
"workflow create task failed",
source,
))
})?
}
/// Stage two's graph compilation: parse, transform, and validate through the
/// fabro-workflow pipeline, with undefined template variables promoted to
/// hard errors.
fn compile_graph(
prepared: PreparedRun,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
) -> Result<GraphCompiledRun> {
let PreparedRun {
layered:
LayeredRun {
workflow_bundle,
entrypoint,
workflow,
settings,
cwd,
metadata,
},
vars,
} = prepared;
let compiled = operations::compile_create_run(
CreateRunCompileInput {
workflow: WorkflowInput::Bundled(workflow),
settings,
vars,
cwd,
workflow_path: Some(entrypoint),
workflow_bundle: Some(workflow_bundle),
configured_providers,
},
catalog,
)?;
Ok(GraphCompiledRun { compiled, metadata })
}
/// Stage three: pin concrete model and provider selections against the
/// catalog and the configured provider set.
fn pin_models(compiled: GraphCompiledRun, catalog: &Catalog) -> Result<PinnedRun> {
let GraphCompiledRun { compiled, metadata } = compiled;
let materialized = operations::materialize_create_run(compiled, catalog)?;
Ok(PinnedRun {
materialized,
metadata,
})
}
/// Stage four: purely assemble the complete persistence input. Every durable
/// field — run id, submitted source bytes, automation reference — is set here
/// once; nothing mutates the result afterwards.
pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput {
let PinnedRun {
materialized,
metadata,
} = pinned;
let RunMetadata {
run_id,
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
git,
parent_id,
provenance,
web_url,
} = metadata;
operations::assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata {
run_id: run_id.expect("run ID should be resolved before compilation"),
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
git,
fork_source_ref: None,
parent_id,
provenance,
web_url,
})
}
/// Parse one bundle-relative settings source, rejecting keys that are not
/// allowed for `settings_source` and inlining dockerfile references from the
/// bundled files.
///
/// Parses via [`SettingsLayer`] so unknown nested keys (like a stale
/// `[server.integrations.github.permissions]` after the move to
/// `[run.integrations.github.permissions]`) trip `deny_unknown_fields`.
pub(crate) fn settings_layer_with_resolved_dockerfiles(
source: &str,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
settings_source: SettingsSource,
) -> Result<SettingsLayer> {
let parse_error = |source| {
invalid_settings(InvalidSettingsError::Parse {
path: config_path.clone(),
source,
})
};
let mut layer = source.parse::<SettingsLayer>().map_err(parse_error)?;
parse::validate_settings_source(&layer, settings_source).map_err(parse_error)?;
resolve_dockerfiles(&mut layer, config_path, files)?;
Ok(layer)
}
fn resolve_dockerfiles(
layer: &mut SettingsLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
for image in layer.environment_images_mut() {
resolve_dockerfile(image, config_path, files)?;
}
Ok(())
}
fn resolve_dockerfile(
image: &mut EnvironmentImageLayer,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
return Ok(());
};
let reference = path.clone();
let dockerfile_path = ManifestPath::from_reference(config_path.parent_or_dot(), &reference)
.ok_or_else(|| InvalidSourceError::UnsupportedDockerfileReference {
config_path: config_path.clone(),
reference: reference.clone(),
})?;
let content = files.get(&dockerfile_path).cloned().ok_or_else(|| {
InvalidSourceError::MissingDockerfile {
config_path: config_path.clone(),
dockerfile_path: dockerfile_path.clone(),
}
})?;
image.dockerfile = Some(EnvironmentDockerfileLayer::Inline(content));
Ok(())
}
/// Substitute run-scoped variables into the resolved run settings, then
/// re-validate the artifact-include globs: a substituted variable can make a
/// previously-safe glob unsafe.
pub(crate) fn substitute_run_variables(
variables: &HashMap<String, String>,
settings: &mut WorkflowSettings,
) -> std::result::Result<(), VariableInterpolationError> {
settings
.run
.substitute_variables(|name| variables.get(name).cloned())?;
for (index, pattern) in settings.run.artifacts.include.iter().enumerate() {
WorkspaceGlob::try_new(pattern)
.map_err(|source| VariableInterpolationError::ArtifactGlob { index, source })?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::error::Error as _;
use fabro_config::EnvironmentDockerfileLayer;
use fabro_graphviz::graph::AttrValue;
use fabro_types::settings::interp::ResolveCtx;
use fabro_types::settings::run::RunGoal;
use fabro_types::{AutomationRef, Principal, RunProvenance, SystemActorKind};
use fabro_workflow::workflow_bundle::ParsedWorkflowConfig;
use super::*;
const DOT: &str = r#"digraph Test {
graph [goal="Graph goal"]
start [shape=Mdiamond]
work [prompt="Ship {{ inputs.target }} for {{ vars.owner }}", model="gpt-5.4"]
exit [shape=Msquare]
start -> work -> exit
}"#;
fn manifest_path(value: &str) -> ManifestPath {
ManifestPath::from_wire(value).expect("fixture manifest path should be valid")
}
fn provenance() -> RunProvenance {
RunProvenance {
server: None,
client: None,
subject: Principal::System {
system_kind: SystemActorKind::Engine,
},
}
}
fn workflow(
entrypoint: &ManifestPath,
workflow_toml: Option<&str>,
files: HashMap<ManifestPath, String>,
) -> BundledWorkflow {
BundledWorkflow {
path: entrypoint.clone(),
source: DOT.to_string(),
config: workflow_toml.map(|source| ParsedWorkflowConfig {
path: manifest_path("flows/workflow.toml"),
source: source.to_string(),
}),
files,
}
}
fn raw_input(
workflow_toml: Option<&str>,
files: HashMap<ManifestPath, String>,
) -> RawRunCompilerInput {
let entrypoint = manifest_path("flows/workflow.fabro");
let workflow = workflow(&entrypoint, workflow_toml, files);
RawRunCompilerInput {
workflow_bundle: WorkflowBundle::new(HashMap::from([(entrypoint.clone(), workflow)])),
entrypoint,
cwd: PathBuf::from("/workspace"),
server_run_defaults: RunLayer::default(),
server_environment_defaults: fabro_environment::seeded_catalog_layer(),
server_mcp_catalog: HashMap::new(),
settings_input: RunCompilerSettingsInput::LegacyManifest {
project_settings: Vec::new(),
},
user_toml: Vec::new(),
run_overrides: None,
cli_overrides: None,
input_overrides: HashMap::new(),
inline_goal_override: None,
run_id: Some(RunId::new()),
title: None,
parent_id: None,
git: None,
storage_root: PathBuf::from("/tmp/fabro-storage"),
workflow_slug: None,
workflow_version_id: None,
target: None,
provenance: provenance(),
web_url: None,
submitted_manifest_bytes: None,
automation: None,
}
}
fn test_provider_ids() -> Vec<ProviderId> {
fabro_llm::test_support::test_catalog()
.enabled_provider_ids()
.into_iter()
.collect()
}
fn prepare_run(
input: RawRunCompilerInput,
vars: HashMap<String, String>,
) -> Result<PreparedRun> {
apply_run_variables(layer_settings(normalize_source(input)?)?, vars)
}
#[test]
fn normalize_source_rejects_missing_entrypoint() {
let mut input = raw_input(None, HashMap::new());
input.entrypoint = manifest_path("flows/missing.fabro");
let Err(error) = normalize_source(input) else {
panic!("missing entrypoint should fail");
};
assert!(matches!(
error,
RunCompilerError::InvalidSource(InvalidSourceError::MissingEntrypoint { .. })
));
}
#[test]
fn normalize_source_rejects_missing_dockerfile_with_pinned_message() {
let workflow_toml = r#"
_version = 1
[run.environment.image]
dockerfile = { path = "Dockerfile" }
"#;
let Err(error) = normalize_source(raw_input(Some(workflow_toml), HashMap::new())) else {
panic!("missing dockerfile should fail");
};
assert!(matches!(
error,
RunCompilerError::InvalidSource(InvalidSourceError::MissingDockerfile { .. })
));
assert_eq!(
error.to_string(),
"missing bundled dockerfile: flows/Dockerfile"
);
}
#[test]
fn normalize_source_preserves_settings_parse_source_chain() {
let workflow_toml = r#"
_version = 1
[run.unknown-table]
key = "value"
"#;
let Err(error) = normalize_source(raw_input(Some(workflow_toml), HashMap::new())) else {
panic!("unknown settings key should fail");
};
assert_eq!(error.to_string(), "Failed to parse run config TOML");
let source = error
.source()
.expect("parse error should retain the TOML source");
assert!(source.to_string().contains("unknown"));
}
#[test]
fn normalize_source_resolves_bundled_dockerfile() {
let workflow_toml = r#"
_version = 1
[run.environment.image]
dockerfile = { path = "Dockerfile" }
"#;
let normalized = normalize_source(raw_input(
Some(workflow_toml),
HashMap::from([(
manifest_path("flows/Dockerfile"),
"FROM ubuntu:24.04\n".to_string(),
)]),
))
.expect("bundled dockerfile should resolve");
let dockerfile = normalized
.workflow_layer
.as_ref()
.and_then(|layer| layer.run.as_ref())
.and_then(|run| run.environment.as_ref())
.and_then(|environment| environment.image.as_ref())
.and_then(|image| image.dockerfile.as_ref());
assert_eq!(
dockerfile,
Some(&EnvironmentDockerfileLayer::Inline(
"FROM ubuntu:24.04\n".to_string()
))
);
}
#[test]
fn settings_apply_precedence_vars_inputs_and_safe_artifact_globs() {
let workflow_toml = r#"
_version = 1
[run.metadata]
layer = "workflow"
owner = "{{ vars.owner }}"
[run.inputs]
target = "workflow"
[run.artifacts]
include = ["reports/{{ vars.owner }}/*.json"]
"#;
let mut input = raw_input(Some(workflow_toml), HashMap::new());
let RunCompilerSettingsInput::LegacyManifest { project_settings } =
&mut input.settings_input
else {
panic!("test fixture should use legacy manifest settings");
};
project_settings.push(ProjectSettingsSource {
path: Ok(manifest_path(".fabro/project.toml")),
toml: r#"
_version = 1
[run.metadata]
layer = "project"
"#
.to_string(),
});
input.user_toml = vec![
r#"
_version = 1
[run.metadata]
layer = "user"
"#
.to_string(),
];
input.run_overrides = Some(
toml::from_str::<SettingsLayer>(
r#"
_version = 1
[run.metadata]
layer = "args"
owner = "{{ vars.owner }}"
"#,
)
.expect("args settings should parse")
.run
.expect("args run layer should exist"),
);
input.input_overrides.insert(
"target".to_string(),
toml::Value::String("override".to_string()),
);
input.inline_goal_override = Some("Ship {{ vars.owner }}".to_string());
let prepared = prepare_run(
input,
HashMap::from([("owner".to_string(), "payments".to_string())]),
)
.expect("settings should prepare");
let settings = prepared.settings();
assert_eq!(
settings.run.metadata.get("layer").map(String::as_str),
Some("args")
);
assert_eq!(
settings.run.metadata.get("owner").map(String::as_str),
Some("payments")
);
assert_eq!(
settings.run.inputs.get("target"),
Some(&toml::Value::String("override".to_string()))
);
assert_eq!(settings.run.artifacts.include, vec![
"reports/payments/*.json"
]);
let Some(RunGoal::Inline(goal)) = settings.run.goal.as_ref() else {
panic!("inline goal override should win");
};
assert_eq!(
goal.resolve_with(&mut ResolveCtx::default()).unwrap(),
"Ship payments"
);
}
#[test]
fn settings_reject_artifact_glob_made_unsafe_by_variable() {
let workflow_toml = r#"
_version = 1
[run.artifacts]
include = ["reports/{{ vars.path }}/*.json"]
"#;
let input = raw_input(Some(workflow_toml), HashMap::new());
let Err(error) = prepare_run(
input,
HashMap::from([("path".to_string(), "../secrets".to_string())]),
) else {
panic!("unsafe artifact glob should fail");
};
assert!(matches!(
error,
RunCompilerError::VariableInterpolation(VariableInterpolationError::ArtifactGlob {
index: 0,
source: WorkspaceGlobError::ParentTraversal { .. },
})
));
}
#[test]
fn graph_vars_are_hard_errors_and_successfully_render_when_present() {
let catalog = Arc::new(fabro_llm::test_support::test_catalog());
let missing = prepare_run(raw_input(None, HashMap::new()), HashMap::new())
.expect("settings preparation should not compile graph vars");
let Err(error) = compile_graph(missing, test_provider_ids(), Arc::clone(&catalog)) else {
panic!("missing graph variable should be a hard error");
};
assert!(matches!(
error,
RunCompilerError::Workflow(WorkflowError::ValidationFailed { .. })
));
let mut input = raw_input(None, HashMap::new());
input.input_overrides.insert(
"target".to_string(),
toml::Value::String("checkout".to_string()),
);
let prepared = prepare_run(
input,
HashMap::from([("owner".to_string(), "payments".to_string())]),
)
.expect("settings should prepare");
let compiled = compile_graph(prepared, test_provider_ids(), catalog)
.expect("graph variables should render");
let work = &compiled.compiled.validated().graph().nodes["work"];
assert_eq!(
work.attrs.get("prompt").and_then(AttrValue::as_str),
Some("Ship checkout for payments")
);
assert_eq!(
work.attrs.get("provider").and_then(AttrValue::as_str),
Some("openai")
);
}
#[test]
fn assembly_retains_entrypoint_and_run_metadata() {
let run_id = RunId::new();
let parent_id = RunId::new();
let automation = AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
workflow_source: None,
};
let submitted = b"submitted manifest".to_vec();
let workflow_version_id = fabro_types::test_support::test_workflow_version_id();
let mut input = raw_input(None, HashMap::new());
input.run_id = Some(run_id);
input.parent_id = Some(parent_id);
input.title = Some("Compiler boundary".to_string());
input.workflow_slug = Some("compiler-boundary".to_string());
input.workflow_version_id = Some(workflow_version_id);
input.web_url = Some(format!("https://fabro.test/runs/{run_id}"));
input.submitted_manifest_bytes = Some(submitted.clone());
input.automation = Some(automation.clone());
input.input_overrides.insert(
"target".to_string(),
toml::Value::String("checkout".to_string()),
);
let expected_entrypoint = input.entrypoint.clone();
let catalog = Arc::new(fabro_llm::test_support::test_catalog());
let prepared = prepare_run(
input,
HashMap::from([("owner".to_string(), "payments".to_string())]),
)
.expect("settings should prepare");
let compiled = compile_graph(prepared, test_provider_ids(), Arc::clone(&catalog))
.expect("graph should compile");
let pinned = pin_models(compiled, &catalog).expect("models should pin");
let persistence = assemble_run(pinned);
assert_eq!(persistence.run_id(), run_id);
assert_eq!(persistence.workflow_slug(), Some("compiler-boundary"));
assert_eq!(persistence.workflow_version_id(), Some(workflow_version_id));
assert_eq!(
persistence.submitted_manifest_bytes(),
Some(submitted.as_slice())
);
assert_eq!(persistence.automation(), Some(&automation));
assert_eq!(
persistence
.definition()
.map(|definition| &definition.workflow_path),
Some(&expected_entrypoint)
);
assert_eq!(
persistence.materialized().settings().run.goal.as_ref(),
Some(&RunGoal::Inline(InterpString::parse("Graph goal")))
);
}
}