Interpolate run variables ({{ vars.* }}) in node prompts and goals (#524)

## What

Threads the run's variable store through the workflow transform pipeline
so
node `prompt`s and the graph `goal` can interpolate `{{ vars.* }}`.

Until now `{{ vars.* }}` only resolved in settings-level fields (e.g.
`run.goal`) via the server-side `substitute_variables` pass. Node
prompts are
DOT graph attributes that pass never touched, so `{{ vars.* }}` in a
prompt
rendered as undefined. This closes that gap.

Builds on the earlier template-context slice (adds `vars` to
`TemplateContext`); this PR wires it end to end.

## How

- `TransformOptions` carries a `vars` map, threaded into the import,
  file-inlining, and template transforms — and propagated into imported
subgraphs, so imported prompts interpolate vars too. Every prompt/goal
render
  context gains the variable map.
- The create API accepts `vars` (`CreateRunInput` →
`preprocess_and_validate` →
  `TransformOptions`).
- The server snapshots its `VariableStore` at run creation
  (`VariableStore::value_map()`) and passes it in — the same store the
  settings-goal substitution already reads.

## Scope decisions

- Goal `@file` contents interpolate vars too; **import paths stay
inputs-only**
(structural file resolution, conceptually outside the prompt/goal
scope).
- Offline / CLI / `fabro validate` render with an empty var map, so
`{{ vars.* }}` is undefined there: a warning at validate, a hard error
at
  run-create — identical to how `inputs` behaves offline.

## Testing

- Transform-level: node-prompt and goal interpolation; unknown-var
warning.
- Create-pipeline: vars resolve; an unknown var warns at validate and
promotes
  to a hard error at run-create.
- End-to-end server test: `POST /variables` + `POST /runs`, asserting
the
  rendered prompt in the persisted `run.created` event.

Verified: `cargo +nightly fmt --check`, nightly `clippy -D warnings`
(including
the `test-support`-gated server integration binary), the tests above,
and a
full-workspace `cargo check`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-24 16:18:09 -04:00 • committed by GitHub
parent 274203bfda
commit a483402092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 535 additions and 60 deletions

View file

@ -184,10 +184,19 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
pub(crate) fn validate_prepared_manifest(
prepared: &PreparedManifest,
catalog: Arc<Catalog>,
) -> Result<Validated, WorkflowError> {
validate_prepared_manifest_with_vars(prepared, catalog, HashMap::new())
}
pub(crate) fn validate_prepared_manifest_with_vars(
prepared: &PreparedManifest,
catalog: Arc<Catalog>,
vars: HashMap<String, String>,
) -> Result<Validated, WorkflowError> {
validate(ValidateInput {
workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()),
settings: prepared.settings.clone(),
vars,
cwd: prepared.cwd.clone(),
custom_transforms: Vec::new(),
catalog,
@ -199,10 +208,12 @@ pub(crate) fn create_run_input(
configured_providers: Vec<ProviderId>,
provenance: RunProvenance,
web_url: Option<String>,
vars: HashMap<String, String>,
) -> CreateRunInput {
CreateRunInput {
workflow: WorkflowInput::Bundled(prepared.workflow_input),
settings: prepared.settings,
vars,
cwd: prepared.cwd,
workflow_slug: None,
workflow_path: Some(prepared.target_path),

View file

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::io::ErrorKind;
use std::sync::Arc;
@ -645,7 +645,8 @@ pub(crate) async fn create_run_from_manifest(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
let vars = snapshot_run_variables(&state).await;
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
@ -691,6 +692,7 @@ pub(crate) async fn create_run_from_manifest(
ready_provider_ids.clone(),
provenance,
web_url.clone(),
vars,
);
create_input.run_id = Some(run_id);
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
@ -901,11 +903,16 @@ async fn run_preflight(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
let vars = snapshot_run_variables(&state).await;
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let mut validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
let mut validated = match run_manifest::validate_prepared_manifest_with_vars(
&prepared,
state.catalog(),
vars,
) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
@ -938,11 +945,16 @@ async fn validate_run_manifest(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
let vars = snapshot_run_variables(&state).await;
if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
let validated = match run_manifest::validate_prepared_manifest_with_vars(
&prepared,
state.catalog(),
vars,
) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
return ApiError::bad_request("Validation failed").into_response();
@ -956,14 +968,17 @@ async fn validate_run_manifest(
.into_response()
}
async fn substitute_run_variables(
state: &AppState,
async fn snapshot_run_variables(state: &AppState) -> HashMap<String, String> {
state.variables.read().await.value_map()
}
fn substitute_run_variables(
variables: &HashMap<String, String>,
settings: &mut WorkflowSettings,
) -> Result<(), ResolveError> {
let variables = state.variables.read().await;
settings
.run
.substitute_variables(|name| variables.get_value(name).map(str::to_string))
.substitute_variables(|name| variables.get(name).cloned())
}
async fn get_run_status(

View file

@ -4,7 +4,7 @@ use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, response_status,
test_app_state,
test_app_state, test_app_state_with_options, test_settings,
};
fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request<Body> {
@ -246,3 +246,123 @@ id = "local"
assert_eq!(body["run"]["goal"]["value"], "secret: token-from-variable");
}
#[tokio::test]
async fn run_create_interpolates_variables_into_node_prompts() {
// End-to-end through the real run-create path: a server variable resolves
// inside a node `prompt` (a DOT graph attribute the settings substitution
// pass never touches), proving the variable store is snapshotted into the
// template render context at create time.
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
test_settings(),
5,
));
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({ "name": "SERVICE", "value": "billing" }),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond]
work [shape=box, prompt="Service: {{ vars.SERVICE }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let create_run = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs",
&minimal_manifest_json(dot),
))
.await
.expect("POST /runs should route");
let create_status = create_run.status();
let create_body = body_json(create_run.into_body()).await;
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let run_id = create_body["id"]
.as_str()
.expect("create run response should include id");
// The persisted `run.created` event carries the fully-rendered graph.
let events = app
.oneshot(empty_request(
Method::GET,
&format!("/runs/{run_id}/events"),
))
.await
.expect("GET run events should route");
let body = response_json(
events,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events"),
)
.await;
let created = body["data"]
.as_array()
.expect("events response should include data")
.iter()
.find(|event| event["event"] == "run.created")
.expect("expected a run.created event");
assert_eq!(
created["properties"]["graph"]["nodes"]["work"]["attrs"]["prompt"]["String"],
"Service: billing",
"node prompt should interpolate the run variable; event: {created}"
);
}
#[tokio::test]
async fn run_validate_resolves_variables_in_node_prompts() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
test_settings(),
5,
));
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({ "name": "SERVICE", "value": "billing" }),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond]
work [shape=box, prompt="Service: {{ vars.SERVICE }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let validate = app
.oneshot(json_request(
Method::POST,
"/validate",
&minimal_manifest_json(dot),
))
.await
.expect("POST /validate should route");
let body = response_json(validate, StatusCode::OK, "POST /api/v1/validate").await;
assert_eq!(body["ok"], true, "{body}");
let diagnostics = body["workflow"]["diagnostics"]
.as_array()
.expect("validate response should include diagnostics");
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic["rule"] == "template_undefined_variable"),
"vars.SERVICE should resolve during validation; diagnostics: {diagnostics:?}"
);
}

View file

@ -48,13 +48,25 @@ pub struct TemplateErrorLocation {
pub span_len: Option<usize>,
}
#[derive(Debug, Default, Clone)]
#[derive(Debug, Clone)]
pub struct TemplateContext {
goal: Option<String>,
inputs: HashMap<String, toml::Value>,
inputs: Value,
vars: Value,
env: Option<Value>,
}
impl Default for TemplateContext {
fn default() -> Self {
Self {
goal: None,
inputs: Value::from_serialize(HashMap::<String, toml::Value>::new()),
vars: Value::from_serialize(HashMap::<String, String>::new()),
env: None,
}
}
}
impl TemplateContext {
#[must_use]
pub fn new() -> Self {
@ -69,7 +81,14 @@ impl TemplateContext {
#[must_use]
pub fn with_inputs(mut self, inputs: HashMap<String, toml::Value>) -> Self {
self.inputs = inputs;
self.inputs = Value::from_serialize(inputs);
self
}
/// Run-scoped `{{ vars.* }}` available to prompts and goals.
#[must_use]
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = Value::from_serialize(vars);
self
}
@ -107,9 +126,15 @@ impl TemplateContext {
fn into_value(self) -> Value {
let goal = self.goal.map(Value::from);
let inputs = Value::from_serialize(self.inputs);
let inputs = self.inputs;
let vars = self.vars;
let env = self.env;
Value::from_object(RenderContext { goal, inputs, env })
Value::from_object(RenderContext {
goal,
inputs,
vars,
env,
})
}
}
@ -117,6 +142,7 @@ impl TemplateContext {
struct RenderContext {
goal: Option<Value>,
inputs: Value,
vars: Value,
env: Option<Value>,
}
@ -125,6 +151,7 @@ impl Object for RenderContext {
match key {
"goal" => self.goal.clone(),
"inputs" => Some(self.inputs.clone()),
"vars" => Some(self.vars.clone()),
"env" => self.env.clone(),
_ => None,
}
@ -834,6 +861,30 @@ mod tests {
));
}
#[test]
fn renders_vars_variable() {
let ctx = TemplateContext::new().with_vars(HashMap::from([(
"SERVICE".to_string(),
"billing".to_string(),
)]));
let rendered = render("Service: {{ vars.SERVICE }}", &ctx).unwrap();
assert_eq!(rendered, "Service: billing");
}
#[test]
fn unknown_vars_member_is_strict_error() {
let ctx = TemplateContext::new().with_vars(HashMap::from([(
"SERVICE".to_string(),
"billing".to_string(),
)]));
let err = render("{{ vars.MISSING }}", &ctx).unwrap_err();
assert!(err.to_string().to_lowercase().contains("undefined"));
}
#[test]
fn renders_typed_input_values() {
let ctx = TemplateContext::new().with_inputs(HashMap::from([

View file

@ -158,6 +158,17 @@ impl VariableStore {
data
}
/// Snapshot every variable as a `name -> value` map, dropping descriptions
/// and timestamps. Used to seed the template render context (`{{ vars.*
/// }}`) at run creation.
#[must_use]
pub fn value_map(&self) -> HashMap<String, String> {
self.entries
.iter()
.map(|(name, entry)| (name.clone(), entry.value.clone()))
.collect()
}
pub fn remove(&mut self, name: &str) -> Result<(), Error> {
Self::validate_name(name)?;
if self.entries.remove(name).is_none() {

View file

@ -72,6 +72,7 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result<ParsedChi
base_dir: None,
},
settings: WorkflowSettings::default(),
vars: std::collections::HashMap::new(),
cwd: cwd.clone(),
custom_transforms: Vec::new(),
catalog: Arc::clone(&services.run.catalog),
@ -116,6 +117,7 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result<ParsedChi
let mut validated = validate(ValidateInput {
workflow,
settings: WorkflowSettings::default(),
vars: std::collections::HashMap::new(),
cwd,
custom_transforms: Vec::new(),
catalog: Arc::clone(&services.run.catalog),

View file

@ -12,6 +12,7 @@ use fabro_config::Storage;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, ProviderId};
use fabro_store::Database;
use fabro_template::TemplateContext;
use fabro_types::{
AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
};
@ -34,6 +35,10 @@ use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
pub struct CreateRunInput {
pub workflow: WorkflowInput,
pub settings: WorkflowSettings,
/// Run-scoped variables (`{{ vars.* }}`) snapshotted from the server's
/// variable store at create time, threaded into the template render
/// context for prompts and goals. Empty for offline/CLI callers.
pub vars: HashMap<String, String>,
pub cwd: PathBuf,
pub workflow_slug: Option<String>,
pub workflow_path: Option<ManifestPath>,
@ -63,6 +68,7 @@ pub struct CreatedRun {
struct PersistCreateOptions {
settings: WorkflowSettings,
vars: HashMap<String, String>,
run_id: Option<RunId>,
run_dir: Option<PathBuf>,
workflow_slug: Option<String>,
@ -97,6 +103,7 @@ pub async fn create(
let CreateRunInput {
workflow: _,
settings: _,
vars,
cwd: _,
workflow_slug,
workflow_path,
@ -141,6 +148,7 @@ pub async fn create(
&raw_source,
PersistCreateOptions {
settings,
vars,
run_id: Some(run_id),
run_dir: Some(persisted_run_dir),
workflow_slug: workflow_slug.or(resolved_workflow_slug),
@ -292,6 +300,7 @@ fn create_from_source(
file_resolver,
Vec::new(),
Some(&options.settings),
options.vars.clone(),
goal_override,
RenderMode::Structural,
&options.catalog,
@ -314,6 +323,7 @@ pub(super) fn preprocess_and_validate(
file_resolver: Option<Arc<dyn FileResolver>>,
custom_transforms: Vec<Box<dyn Transform>>,
settings: Option<&WorkflowSettings>,
vars: HashMap<String, String>,
goal_override: Option<&str>,
render_mode: RenderMode,
catalog: &Arc<Catalog>,
@ -321,11 +331,12 @@ pub(super) fn preprocess_and_validate(
let inputs = run_inputs(settings);
let mut parsed = pipeline::parse(dot_source)?;
apply_goal_override(&mut parsed.graph, goal_override);
let template_context = TemplateContext::new().with_inputs(inputs).with_vars(vars);
let transformed = pipeline::transform(parsed, &TransformOptions {
current_dir,
file_resolver,
inputs,
template_context,
source_name,
render_mode,
custom_transforms,
@ -355,6 +366,7 @@ fn persist_validated(
) -> Result<Persisted, Error> {
let PersistCreateOptions {
settings,
vars: _,
run_id,
run_dir,
workflow_slug,
@ -473,6 +485,7 @@ mod tests {
base_dir: None,
},
settings,
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -480,6 +493,25 @@ mod tests {
.unwrap()
}
/// Drive the create-time pipeline with an explicit variable snapshot, the
/// way the server does (`Structural` render mode, undefined vars promoted
/// to errors at run-create).
fn validate_dot_with_vars(dot_source: &str, vars: HashMap<String, String>) -> Validated {
preprocess_and_validate(
dot_source,
Some("workflow.fabro".to_string()),
Some(PathBuf::from(".")),
None,
Vec::new(),
Some(&WorkflowSettings::default()),
vars,
None,
RenderMode::Structural,
&test_catalog(),
)
.unwrap()
}
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
@ -552,6 +584,57 @@ mod tests {
);
}
#[test]
fn vars_resolve_in_node_prompt_through_create_pipeline() {
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [label="Work", prompt="Service: {{ vars.SERVICE }}"]
start -> work -> exit
}"#;
let vars = HashMap::from([("SERVICE".to_string(), "billing".to_string())]);
let validated = validate_dot_with_vars(dot, vars);
validated.raise_on_errors().unwrap();
assert!(
!validated
.diagnostics()
.iter()
.any(|d| d.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE),
"vars.SERVICE should resolve through the create pipeline; got: {:?}",
validated.diagnostics()
);
}
#[test]
fn unknown_var_in_prompt_warns_at_validate_then_errors_at_run_create() {
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [label="Work", prompt="Service: {{ vars.MISSING }}"]
start -> work -> exit
}"#;
let mut validated = validate_dot_with_vars(dot, HashMap::new());
// `fabro validate` surfaces a warning, not a hard failure.
let diagnostic = validated
.diagnostics()
.iter()
.find(|d| d.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE)
.expect("expected a template_undefined_variable diagnostic");
assert_eq!(diagnostic.severity, Severity::Warning);
assert!(
diagnostic.message.contains("vars.MISSING"),
"message: {}",
diagnostic.message
);
// Run-create promotes the same diagnostic to a hard error.
validated.promote_template_undefined_variables_to_errors();
assert!(validated.has_errors());
}
#[test]
fn promote_template_undefined_rule_turns_warning_into_error() {
let dot = r#"digraph Test {
@ -590,6 +673,7 @@ mod tests {
None,
Vec::new(),
Some(&WorkflowSettings::default()),
HashMap::new(),
None,
RenderMode::Strict,
&test_catalog(),
@ -626,6 +710,7 @@ mod tests {
))),
Vec::new(),
Some(&WorkflowSettings::default()),
HashMap::new(),
None,
RenderMode::Strict,
&test_catalog(),
@ -684,6 +769,7 @@ mod tests {
..RunLayer::default()
}
}),
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -732,6 +818,7 @@ mod tests {
base_dir: Some(dir.path().to_path_buf()),
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -750,6 +837,7 @@ mod tests {
base_dir: Some(dir.path().to_path_buf()),
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -773,6 +861,7 @@ mod tests {
base_dir: Some(dir.path().to_path_buf()),
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -791,6 +880,7 @@ mod tests {
base_dir: Some(dir.path().to_path_buf()),
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -882,6 +972,7 @@ mod tests {
base_dir: None,
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -926,6 +1017,7 @@ mod tests {
base_dir: None,
},
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: vec![Box::new(TagTransform)],
catalog: test_catalog(),
@ -960,6 +1052,7 @@ mod tests {
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(dot_path),
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -1001,6 +1094,7 @@ mod tests {
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(dot_path),
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -1050,6 +1144,7 @@ mod tests {
]),
}),
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -1100,6 +1195,7 @@ mod tests {
]),
}),
settings: WorkflowSettings::default(),
vars: HashMap::new(),
cwd: PathBuf::from("."),
custom_transforms: Vec::new(),
catalog: test_catalog(),
@ -1134,6 +1230,7 @@ mod tests {
base_dir: None,
},
settings: test_default_settings(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
@ -1200,6 +1297,7 @@ mod tests {
..RunLayer::default()
}
}),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: Some("slug".to_string()),
workflow_path: None,
@ -1314,6 +1412,7 @@ mod tests {
..RunLayer::default()
}
}),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
@ -1354,6 +1453,7 @@ mod tests {
base_dir: None,
},
settings: dry_run_only_settings(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
@ -1433,6 +1533,7 @@ mod tests {
base_dir: None,
},
settings: dry_run_with_storage(&storage_dir),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: Some("slug".to_string()),
workflow_path: None,
@ -1486,6 +1587,7 @@ mod tests {
base_dir: None,
},
settings: dry_run_with_storage(&storage_dir),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: Some("slug".to_string()),
workflow_path: None,

View file

@ -1418,6 +1418,7 @@ reasoning = false
}),
..RunLayer::default()
}),
vars: std::collections::HashMap::new(),
cwd: storage_root
.parent()
.unwrap_or_else(|| Path::new("."))
@ -1843,6 +1844,7 @@ reasoning = false
}),
..RunLayer::default()
}),
vars: std::collections::HashMap::new(),
cwd: temp.path().to_path_buf(),
workflow_slug: Some("bundle-child".to_string()),
workflow_path: Some(ManifestPath::from_wire("workflow.fabro").unwrap()),

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@ -14,6 +15,9 @@ use crate::transforms::Transform;
pub struct ValidateInput {
pub workflow: WorkflowInput,
pub settings: WorkflowSettings,
/// Run-scoped variables (`{{ vars.* }}`) available to prompts and goals.
/// Empty for offline/CLI validation.
pub vars: HashMap<String, String>,
pub cwd: PathBuf,
pub custom_transforms: Vec<Box<dyn Transform>>,
pub catalog: Arc<Catalog>,
@ -41,6 +45,7 @@ pub fn validate(input: ValidateInput) -> Result<Validated, Error> {
resolved.file_resolver,
input.custom_transforms,
Some(&resolved.settings),
input.vars,
resolved.goal_override.as_deref(),
RenderMode::Structural,
&input.catalog,

View file

@ -22,7 +22,7 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
let (graph, transform_diagnostics) = ImportTransform::new(
current_dir.clone(),
Arc::clone(file_resolver),
options.inputs.clone(),
options.template_context.clone(),
)
.with_template_options(
options.source_name.clone(),
@ -42,7 +42,7 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
let (graph, transform_diagnostics) =
FileInliningTransform::new(current_dir.clone(), Arc::clone(file_resolver))
.with_template_options(
options.inputs.clone(),
options.template_context.clone(),
options.source_name.clone(),
Some(source.clone()),
options.render_mode,
@ -55,7 +55,7 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
};
let (graph, transform_diagnostics) = TemplateTransform {
inputs: options.inputs.clone(),
context: options.template_context.clone(),
source_name: options.source_name.clone(),
source_text: Some(source.clone()),
render_mode: options.render_mode,
@ -91,7 +91,7 @@ mod tests {
use super::*;
use crate::file_resolver::FilesystemFileResolver;
use crate::pipeline::parse::parse;
use crate::pipeline::types::GOAL_SELF_REFERENCE_RULE;
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
@ -108,7 +108,7 @@ mod tests {
TransformOptions {
current_dir: None,
file_resolver: None,
inputs: HashMap::new(),
template_context: fabro_template::TemplateContext::new(),
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],
@ -170,7 +170,7 @@ mod tests {
let transformed = transform(parsed, &TransformOptions {
current_dir: Some(dir.path().to_path_buf()),
file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))),
inputs: HashMap::new(),
template_context: fabro_template::TemplateContext::new(),
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],
@ -217,10 +217,12 @@ mod tests {
let transformed = transform(parsed, &TransformOptions {
current_dir: Some(dir.path().to_path_buf()),
file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))),
inputs: HashMap::from([(
"task".to_string(),
toml::Value::String("Launch".to_string()),
)]),
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([
(
"task".to_string(),
toml::Value::String("Launch".to_string()),
),
])),
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],
@ -239,6 +241,99 @@ mod tests {
);
}
#[test]
fn transform_interpolates_vars_in_node_prompt() {
let dot = r#"digraph Test {
graph [goal="Fix bugs"]
start [shape=Mdiamond]
work [prompt="Service: {{ vars.SERVICE }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let parsed = parse(dot).unwrap();
let transformed = transform(parsed, &TransformOptions {
template_context: fabro_template::TemplateContext::new().with_vars(HashMap::from([(
"SERVICE".to_string(),
"billing".to_string(),
)])),
..transform_options()
})
.unwrap();
let prompt = transformed.graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.unwrap();
assert_eq!(prompt, "Service: billing");
}
#[test]
fn transform_interpolates_vars_in_graph_goal_and_through_prompt() {
// The goal interpolates `{{ vars.* }}`, and a prompt that embeds the
// goal sees the vars-resolved text.
let dot = r#"digraph Test {
graph [goal="Ship {{ vars.SERVICE }}"]
start [shape=Mdiamond]
work [prompt="Goal: {{ goal }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let parsed = parse(dot).unwrap();
let transformed = transform(parsed, &TransformOptions {
template_context: fabro_template::TemplateContext::new().with_vars(HashMap::from([(
"SERVICE".to_string(),
"billing".to_string(),
)])),
..transform_options()
})
.unwrap();
assert_eq!(
transformed
.graph
.attrs
.get("goal")
.and_then(AttrValue::as_str),
Some("Ship billing")
);
assert_eq!(
transformed.graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Goal: Ship billing")
);
}
#[test]
fn transform_with_empty_vars_warns_on_unknown_var() {
// Offline / no variable store: `{{ vars.* }}` is undefined, surfacing a
// structural-mode warning (promoted to a hard error at run-create).
let dot = r#"digraph Test {
graph [goal="Fix bugs"]
start [shape=Mdiamond]
work [prompt="Service: {{ vars.MISSING }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let parsed = parse(dot).unwrap();
let transformed = transform(parsed, &TransformOptions {
template_context: fabro_template::TemplateContext::new(),
render_mode: crate::operations::RenderMode::Structural,
..transform_options()
})
.unwrap();
let diag = transformed
.diagnostics
.iter()
.find(|d| d.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE)
.expect("expected a template_undefined_variable diagnostic for vars.MISSING");
assert!(
diag.message.contains("vars.MISSING"),
"message: {}",
diag.message
);
}
#[test]
fn transform_reports_goal_self_reference_once_across_passes() {
// FileInlining renders the goal for prompt context, but TemplateTransform
@ -257,7 +352,7 @@ mod tests {
let transformed = transform(parsed, &TransformOptions {
current_dir: Some(dir.path().to_path_buf()),
file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))),
inputs: HashMap::new(),
template_context: fabro_template::TemplateContext::new(),
source_name: None,
render_mode: crate::operations::RenderMode::Structural,
custom_transforms: vec![],

View file

@ -7,6 +7,7 @@ use fabro_interview::Interviewer;
use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, FallbackTarget, ProviderId};
use fabro_sandbox::SandboxSpec;
use fabro_template::TemplateContext;
use fabro_types::settings::run::{PullRequestSettings, RunModelControls};
use fabro_types::{ManifestPath, RunId};
use fabro_validate::{Diagnostic, Severity};
@ -325,7 +326,7 @@ pub struct Finalized {
pub struct TransformOptions {
pub current_dir: Option<PathBuf>,
pub file_resolver: Option<Arc<dyn FileResolver>>,
pub inputs: HashMap<String, toml::Value>,
pub template_context: TemplateContext,
pub source_name: Option<String>,
pub render_mode: RenderMode,
pub custom_transforms: Vec<Box<dyn Transform>>,

View file

@ -44,7 +44,7 @@ mod tests {
let transformed = transform::transform(parsed, &TransformOptions {
current_dir: None,
file_resolver: None,
inputs: std::collections::HashMap::new(),
template_context: fabro_template::TemplateContext::new(),
source_name: None,
render_mode: crate::operations::RenderMode::Strict,
custom_transforms: vec![],

View file

@ -126,7 +126,7 @@ fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> boo
pub struct FileInliningTransform {
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
inputs: HashMap<String, toml::Value>,
context: TemplateContext,
source_name: Option<String>,
source_text: Option<String>,
goal_override: Option<String>,
@ -139,7 +139,7 @@ impl FileInliningTransform {
Self {
current_dir,
resolver,
inputs: HashMap::new(),
context: TemplateContext::new(),
source_name: None,
source_text: None,
goal_override: None,
@ -150,12 +150,12 @@ impl FileInliningTransform {
#[must_use]
pub fn with_template_options(
mut self,
inputs: HashMap<String, toml::Value>,
context: TemplateContext,
source_name: Option<String>,
source_text: Option<String>,
render_mode: RenderMode,
) -> Self {
self.inputs = inputs;
self.context = context;
self.source_name = source_name;
self.source_text = source_text;
self.render_mode = render_mode;
@ -168,6 +168,13 @@ impl FileInliningTransform {
self
}
/// Run-scoped `{{ vars.* }}` available to prompts and the goal.
#[must_use]
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.context = self.context.with_vars(vars);
self
}
pub(crate) fn apply_with_diagnostics(
&self,
graph: Graph,
@ -183,9 +190,7 @@ impl FileInliningTransform {
// pass owns canonical goal validation diagnostics.
None => graph.goal().to_string(),
};
let ctx = TemplateContext::new()
.with_goal(resolved_goal)
.with_inputs(self.inputs.clone());
let ctx = self.context.clone().with_goal(resolved_goal);
for (node_id, node) in &mut graph.nodes {
// `prompt` is an importable template: MiniJinja-render the value,
@ -248,7 +253,7 @@ impl FileInliningTransform {
let Some(AttrValue::String(goal)) = graph.attrs.get("goal") else {
return Ok(());
};
let ctx = TemplateContext::for_input_scan(self.inputs.clone());
let ctx = self.context.clone().with_goal("{{ goal }}");
let target = TemplateRenderTarget::graph_attr(self.source_name.clone(), "goal")
.with_source_origin(self.source_text.as_deref(), goal)
.with_template_store(template_render_store(

View file

@ -19,7 +19,7 @@ use crate::transforms::variable_expansion::{
pub struct ImportTransform {
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
inputs: HashMap<String, toml::Value>,
context: TemplateContext,
source_name: Option<String>,
source_text: Option<String>,
render_mode: RenderMode,
@ -56,18 +56,26 @@ impl ImportTransform {
pub fn new(
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
inputs: HashMap<String, toml::Value>,
context: TemplateContext,
) -> Self {
Self {
current_dir,
resolver,
inputs,
context,
source_name: None,
source_text: None,
render_mode: RenderMode::Structural,
}
}
/// Run-scoped `{{ vars.* }}`, propagated into imported subgraphs so their
/// prompts and goals interpolate variables too.
#[must_use]
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.context = self.context.with_vars(vars);
self
}
#[must_use]
pub fn with_template_options(
mut self,
@ -205,7 +213,7 @@ impl ImportTransform {
let (inlined_graph, file_diagnostics) =
FileInliningTransform::new(import_base_dir.clone(), Arc::clone(&self.resolver))
.with_template_options(
self.inputs.clone(),
self.context.clone(),
Some(source_name.clone()),
Some(source_text.clone()),
self.render_mode,
@ -221,7 +229,7 @@ impl ImportTransform {
AttrValue::String(parent_goal.to_string()),
);
let (templated_graph, template_diagnostics) = TemplateTransform {
inputs: self.inputs.clone(),
context: self.context.clone(),
source_name: Some(source_name),
source_text: Some(source_text),
render_mode: self.render_mode,
@ -672,7 +680,7 @@ impl ImportTransform {
let imports = Self::collect_import_nodes(&graph);
let mut import_stack = Vec::new();
let mut diagnostics = Vec::new();
let path_ctx = TemplateContext::for_input_scan(self.inputs.clone());
let path_ctx = self.context.clone().with_goal("{{ goal }}");
let mut ignored_goal_diagnostics = Vec::new();
let goal_target = TemplateRenderTarget::graph_attr(self.source_name.clone(), "goal")
.with_source_origin(self.source_text.as_deref(), graph.goal())
@ -754,7 +762,7 @@ mod tests {
Arc::new(FilesystemFileResolver::new(
fallback_dir.map(Path::to_path_buf),
)),
HashMap::new(),
TemplateContext::new(),
)
.apply(graph)
.unwrap()
@ -775,7 +783,7 @@ mod tests {
let graph = ImportTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
HashMap::new(),
TemplateContext::new(),
)
.with_template_options(
Some("workflow.fabro".to_string()),
@ -821,7 +829,7 @@ mod tests {
let err = ImportTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
HashMap::new(),
TemplateContext::new(),
)
.with_template_options(
Some("workflow.fabro".to_string()),
@ -847,6 +855,48 @@ mod tests {
}"#
}
#[test]
fn import_parent_goal_resolves_vars_before_imported_prompt_uses_goal() {
let dir = tempfile::tempdir().unwrap();
write_file(
&dir.path().join("validate.fabro"),
r#"digraph validate {
start [shape=Mdiamond]
lint [prompt="Goal: {{ goal }}"]
exit [shape=Msquare]
start -> lint -> exit
}"#,
);
let graph = parse_graph(
r#"digraph Deploy {
graph [goal="Ship {{ vars.SERVICE }}"]
start [shape=Mdiamond]
validate [import="./validate.fabro"]
exit [shape=Msquare]
start -> validate -> exit
}"#,
);
let graph = ImportTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
TemplateContext::new().with_vars(HashMap::from([(
"SERVICE".to_string(),
"billing".to_string(),
)])),
)
.apply(graph)
.unwrap();
assert_eq!(
graph.nodes["validate.lint"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Goal: Ship billing")
);
}
#[test]
fn basic_import_replaces_placeholder_and_rewires_edges() {
let dir = tempfile::tempdir().unwrap();
@ -934,7 +984,7 @@ mod tests {
let (graph, diagnostics) = ImportTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
HashMap::new(),
TemplateContext::new(),
)
.apply_with_diagnostics(graph)
.unwrap();
@ -1635,7 +1685,7 @@ mod tests {
let graph = ImportTransform::new(
dir.path().to_path_buf(),
Arc::new(FilesystemFileResolver::new(None)),
HashMap::new(),
TemplateContext::new(),
)
.apply(graph)
.unwrap();

View file

@ -289,9 +289,10 @@ fn goal_self_reference_diagnostic(
}
}
/// Expands `{{ goal }}` / `{{ inputs.* }}` across all string attributes.
/// Expands `{{ goal }}` / `{{ inputs.* }}` / `{{ vars.* }}` across all string
/// attributes.
pub struct TemplateTransform {
pub inputs: HashMap<String, toml::Value>,
pub context: TemplateContext,
pub source_name: Option<String>,
pub source_text: Option<String>,
pub render_mode: RenderMode,
@ -301,13 +302,19 @@ impl TemplateTransform {
#[must_use]
pub fn new(inputs: HashMap<String, toml::Value>) -> Self {
Self {
inputs,
context: TemplateContext::new().with_inputs(inputs),
source_name: None,
source_text: None,
render_mode: RenderMode::Structural,
}
}
#[must_use]
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
self.context = self.context.with_vars(vars);
self
}
pub(crate) fn resolved_goal(
&self,
graph: &Graph,
@ -332,7 +339,7 @@ impl TemplateTransform {
));
return Ok(goal.to_string());
}
let ctx = TemplateContext::new().with_inputs(self.inputs.clone());
let ctx = self.context.clone();
render_template_for_target(goal, &ctx, self.render_mode, &target, diagnostics)
}
@ -341,7 +348,7 @@ impl TemplateTransform {
goal: &str,
target: &TemplateRenderTarget,
) -> Option<TemplateError> {
let ctx = TemplateContext::new().with_inputs(self.inputs.clone());
let ctx = self.context.clone();
match render_template_with_mode(goal, &ctx, TemplateRenderMode::Strict, target) {
Err(err @ TemplateError::UndefinedVariable { .. })
if err.expression() == Some("goal") =>
@ -405,9 +412,7 @@ impl TemplateTransform {
graph
.attrs
.insert("goal".to_string(), AttrValue::String(resolved_goal.clone()));
let ctx = TemplateContext::new()
.with_goal(resolved_goal)
.with_inputs(self.inputs.clone());
let ctx = self.context.clone().with_goal(resolved_goal);
Self::render_attrs(
&mut graph.attrs,
@ -613,7 +618,7 @@ mod tests {
graph.nodes.insert("plan".to_string(), node);
let transform = TemplateTransform {
inputs: HashMap::new(),
context: TemplateContext::new(),
source_name: Some("workflow.fabro".to_string()),
source_text: Some(source.to_string()),
render_mode: RenderMode::Structural,
@ -792,7 +797,7 @@ mod tests {
graph.nodes.insert("plan".to_string(), node);
let transform = TemplateTransform {
inputs: HashMap::new(),
context: TemplateContext::new(),
source_name: Some("workflow.fabro".to_string()),
source_text: None,
render_mode: RenderMode::Structural,

View file

@ -4684,7 +4684,7 @@ async fn import_e2e_through_engine() {
file_resolver: Some(std::sync::Arc::new(
fabro_workflow::file_resolver::FilesystemFileResolver::new(None),
)),
inputs: std::collections::HashMap::new(),
template_context: fabro_template::TemplateContext::new(),
source_name: None,
render_mode: fabro_workflow::operations::RenderMode::Strict,
custom_transforms: vec![],