Extract working-tree collection from manifest assembly

This commit is contained in:
Scott Werner 2026-08-04 13:59:48 -04:00
parent 5ed32c1d20
commit 9f13611e83
3 changed files with 2094 additions and 469 deletions

View file

@ -314,7 +314,8 @@ fn validate_reports_missing_template_dependency() {
exit_code: 1
----- stdout -----
----- stderr -----
× failed to discover template dependencies: missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
× failed to discover template dependencies
missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
");
}

View file

@ -3,33 +3,33 @@
reason = "CLI manifest builder: sync file I/O building install manifests"
)]
use std::collections::{HashMap, HashSet};
mod working_tree;
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
use fabro_config::project::{self, WorkflowLocation, discover_project_config};
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
use fabro_config::{
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
EnvironmentLifecycleLayer, MergeMap, ReplaceMap, RunEnvironmentLayer, RunExecutionLayer,
RunGoalLayer, RunLayer, RunModelLayer, SettingsLayer, WorkflowSettingsBuilder,
CliLayer, EnvironmentLayer, EnvironmentLifecycleLayer, MergeMap, ReplaceMap,
RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
WorkflowSettingsBuilder,
};
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
use fabro_template::{
BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext,
TemplateRenderMode, TemplateSource, discover_static_dependency_closure, render_source,
};
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
use fabro_types::{DirtyStatus, GitContext, ManifestPath, WorkflowSettings};
use fabro_workflow::git::{
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
};
use fabro_workflow::static_reference::{
AttributeScope, ReferenceKind, reference_kind_for_attribute,
use fabro_workflow::static_reference::ReferenceKind;
use crate::working_tree::{
CollectWorkingTreeInput, CollectedDocument, CollectedFileReferenceType, CollectedSourceInput,
CollectedWorkingTree,
};
#[derive(Debug, Default)]
@ -127,20 +127,6 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLaye
.then_some(run)
}
struct CollectContext<'a> {
cwd: &'a Path,
inputs: HashMap<String, toml::Value>,
workflows: HashMap<String, types::ManifestWorkflow>,
visited_workflows: HashSet<String>,
}
#[derive(Clone)]
struct WorkflowScanInput {
absolute_dot_path: PathBuf,
dot_path: ManifestPath,
source: String,
}
pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let root_location = WorkflowLocation::resolve(&input.workflow, &input.cwd)?;
if root_location.toml.is_none() && !root_location.graph.is_file() {
@ -155,8 +141,10 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
.map(|path| {
let source = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let manifest_path = manifest_path_from_absolute(path, &input.cwd)?;
Ok::<_, anyhow::Error>((path.clone(), manifest_path, source))
Ok::<_, anyhow::Error>(CollectedSourceInput {
access_path: path.clone(),
source,
})
})
.transpose()?;
@ -186,47 +174,27 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
.context("failed to resolve manifest settings")?;
workflow_settings.run.inputs.extend(input.input_overrides);
let target_path = root_location.graph.clone();
let target_manifest_path = manifest_path_from_absolute(&target_path, &input.cwd)?;
let target_key = target_manifest_path.to_string();
let mut context = CollectContext {
cwd: &input.cwd,
inputs: workflow_settings.run.inputs.clone(),
workflows: HashMap::new(),
visited_workflows: HashSet::new(),
};
collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?;
if let Some((_, config_path, source)) = project_config_source.as_ref() {
let workflow = context
.workflows
.get_mut(&target_key)
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
collect_config_dockerfile(context.cwd, config_path, source, &mut workflow.files)?;
}
let root_source = context
.workflows
.get(&target_key)
.map(|workflow| workflow.source.clone())
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
let mut configs = Vec::new();
if let Some((path, _, source)) = project_config_source {
configs.push(types::ManifestConfig {
path: Some(path.display().to_string()),
source: Some(source),
type_: types::ManifestConfigType::Project,
});
}
if let Some(path) = input.user_settings_path.filter(|p| p.is_file()) {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {
path: Some(path.display().to_string()),
source: Some(source),
type_: types::ManifestConfigType::User,
});
}
let user_config_source = input
.user_settings_path
.as_ref()
.filter(|path| path.is_file())
.map(|path| {
let source = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
Ok::<_, anyhow::Error>(CollectedSourceInput {
access_path: path.clone(),
source,
})
})
.transpose()?;
let collected = working_tree::collect_working_tree(CollectWorkingTreeInput {
cwd: &input.cwd,
root_location,
inputs: &workflow_settings.run.inputs,
project_config: project_config_source,
user_config: user_config_source,
})?;
let assembled = assemble_current_manifest(&collected, &input.cwd)?;
let working_directory =
project::resolve_working_directory_from_run(&workflow_settings.run, &input.cwd);
@ -234,7 +202,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let goal = resolve_manifest_goal(
input.run_overrides.as_ref(),
&workflow_settings,
&root_source,
&assembled.root_source,
&target_path,
&working_directory,
)?;
@ -246,423 +214,118 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
Ok(BuiltManifest {
manifest: types::RunManifest {
args,
configs,
cwd: input.cwd.display().to_string(),
git,
goal,
parent_id: None,
title: None,
target: types::ManifestTarget { path: target_key },
target: types::ManifestTarget {
path: assembled.target_key,
},
version: 1,
workflows: context.workflows,
workflows: assembled.workflows,
configs: assembled.configs,
},
target_path,
})
}
fn collect_workflow_entry(
context: &mut CollectContext<'_>,
workflow: &Path,
resolve_from: &Path,
) -> Result<()> {
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
anyhow!(
"unsupported manifest workflow reference: {}",
workflow.display()
)
})?
} else {
workflow.to_path_buf()
};
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
let dot_path = manifest_path_from_absolute(&location.graph, context.cwd)?;
let dot_key = dot_path.to_string();
if !context.visited_workflows.insert(dot_key.clone()) {
return Ok(());
}
let source = std::fs::read_to_string(&location.graph)
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
Some(types::ManifestWorkflowConfig {
path: manifest_path_from_absolute(workflow_toml_path, context.cwd)?.to_string(),
source: std::fs::read_to_string(workflow_toml_path)
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
})
} else {
None
};
let scan = WorkflowScanInput {
absolute_dot_path: location.graph,
dot_path,
source: source.clone(),
};
let mut files = HashMap::new();
let mut visited_imports = HashSet::new();
if let Some(config) = config.as_ref() {
let config_path = ManifestPath::from_wire(&config.path)
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
collect_config_dockerfile(context.cwd, &config_path, &config.source, &mut files)?;
}
collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?;
context.workflows.insert(dot_key, types::ManifestWorkflow {
config,
files,
source,
});
Ok(())
struct AssembledCurrentManifest {
target_key: String,
root_source: String,
workflows: HashMap<String, types::ManifestWorkflow>,
configs: Vec<types::ManifestConfig>,
}
fn collect_workflow_files(
context: &mut CollectContext<'_>,
workflow: &WorkflowScanInput,
files: &mut HashMap<String, types::ManifestFileEntry>,
visited_imports: &mut HashSet<String>,
) -> Result<()> {
let graph = parser::parse(&workflow.source).map_err(|err| {
anyhow!(
"Failed to parse {}: {err}",
workflow.absolute_dot_path.display()
)
})?;
let workflow_base_dir = workflow
.absolute_dot_path
.parent()
.unwrap_or_else(|| Path::new("."));
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) {
if goal_ref.starts_with('@') {
let bundled = collect_bundled_file(
files,
workflow_base_dir,
context.cwd,
goal_ref.trim_start_matches('@'),
types::ManifestFileRefType::FileInline,
manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_ref)?,
Some(workflow.dot_path.clone()),
)?;
let source = std::fs::read_to_string(&bundled.absolute_path)
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
let template_root =
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
collect_template_include_files(
files,
context.cwd,
TemplateSource::new(bundled.path.clone(), template_root, source),
Some(&bundled.path),
&context.inputs,
)?;
} else {
collect_template_include_files(
files,
context.cwd,
TemplateSource::new(
workflow.dot_path.clone(),
workflow_template_root.clone(),
goal_ref.to_owned(),
),
Some(&workflow.dot_path),
&context.inputs,
)?;
}
}
for node in graph.nodes.values() {
if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) {
if !prompt_ref.starts_with('@') {
collect_template_include_files(
files,
context.cwd,
TemplateSource::new(
workflow.dot_path.clone(),
workflow_template_root.clone(),
prompt_ref.to_owned(),
),
Some(&workflow.dot_path),
&context.inputs,
)?;
}
}
for (name, value) in &node.attrs {
let Some(value) = value.as_str() else {
continue;
};
let Some(ReferenceKind::FileInline) =
reference_kind_for_attribute(AttributeScope::Node, name, value)
else {
continue;
};
let reference = value.strip_prefix('@').ok_or_else(|| {
anyhow!("file inline reference must start with '@': {name}={value}")
})?;
let bundled = collect_bundled_file(
files,
workflow_base_dir,
context.cwd,
reference,
types::ManifestFileRefType::FileInline,
ReferenceKind::FileInline,
Some(workflow.dot_path.clone()),
)?;
if name == "prompt" {
let source =
std::fs::read_to_string(&bundled.absolute_path).with_context(|| {
format!("Failed to read {}", bundled.absolute_path.display())
})?;
let template_root =
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
collect_template_include_files(
files,
context.cwd,
TemplateSource::new(bundled.path.clone(), template_root, source),
Some(&bundled.path),
&context.inputs,
)?;
}
}
if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) {
let imported = collect_bundled_file(
files,
workflow_base_dir,
context.cwd,
import_ref,
types::ManifestFileRefType::Import,
manifest_attr_reference_kind(AttributeScope::Node, "import", import_ref)?,
Some(workflow.dot_path.clone()),
)?;
let import_key = imported.path.to_string();
if visited_imports.insert(import_key) {
let imported_source = std::fs::read_to_string(&imported.absolute_path)
.with_context(|| {
format!("Failed to read {}", imported.absolute_path.display())
})?;
let imported_scan = WorkflowScanInput {
absolute_dot_path: imported.absolute_path,
dot_path: imported.path,
source: imported_source,
};
collect_workflow_files(context, &imported_scan, files, visited_imports)?;
}
}
if let Some(child_ref) = node
.attrs
.get("stack.child_workflow")
.and_then(AttrValue::as_str)
{
manifest_attr_reference_kind(AttributeScope::Node, "stack.child_workflow", child_ref)?
.validate(child_ref)
.map_err(anyhow::Error::new)?;
collect_workflow_entry(context, Path::new(child_ref), workflow_base_dir)?;
}
}
Ok(())
}
fn collect_template_include_files(
files: &mut HashMap<String, types::ManifestFileEntry>,
fn assemble_current_manifest(
collected: &CollectedWorkingTree,
cwd: &Path,
source: TemplateSource,
from: Option<&ManifestPath>,
inputs: &HashMap<String, toml::Value>,
) -> Result<()> {
let source_path = source.path.clone();
let store = FilesystemTemplateStore::new(cwd.to_path_buf());
let closure = discover_static_dependency_closure([source], &store)
.map_err(|err| anyhow!("failed to discover template dependencies: {err}"))?;
verify_recorded_template_dependencies(&source_path, &closure, files, from, inputs)?;
for (path, source) in closure.sources {
if path == source_path {
continue;
}
let key = path.to_string();
files
.entry(key)
.or_insert_with(|| types::ManifestFileEntry {
content: source.content,
) -> Result<AssembledCurrentManifest> {
let mut workflows = HashMap::new();
for workflow in collected.workflows().values() {
let graph = workflow.graph();
let graph_key = manifest_path_from_absolute(graph.access_path(), cwd)?.to_string();
let config = workflow
.config()
.map(|config| {
Ok::<_, anyhow::Error>(types::ManifestWorkflowConfig {
path: manifest_path_from_absolute(config.access_path(), cwd)?.to_string(),
source: config.source().to_owned(),
})
})
.transpose()?;
let mut files = HashMap::new();
for file in workflow.files().values() {
let document = file.document();
let reference = file.reference();
let key = manifest_path_from_absolute(document.access_path(), cwd)?.to_string();
let from = reference
.source_access_path()
.map(|path| manifest_path_from_absolute(path, cwd).map(|path| path.to_string()))
.transpose()?;
let type_ = match reference.type_() {
CollectedFileReferenceType::FileInline => types::ManifestFileRefType::FileInline,
CollectedFileReferenceType::Import => types::ManifestFileRefType::Import,
CollectedFileReferenceType::Dockerfile => types::ManifestFileRefType::Dockerfile,
};
files.insert(key, types::ManifestFileEntry {
content: document.source().to_owned(),
ref_: types::ManifestFileRef {
from: from.map(std::string::ToString::to_string),
original: path.to_string(),
type_: types::ManifestFileRefType::FileInline,
from,
original: reference.original().to_owned(),
type_,
},
});
}
Ok(())
}
fn template_root_for_bundled_file(
path: &ManifestPath,
workflow_template_root: &ManifestPath,
) -> Result<ManifestPath> {
if manifest_path_is_within_root(path, workflow_template_root) {
Ok(workflow_template_root.clone())
} else {
manifest_parent_or_dot(path)
}
}
fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
if root.as_path().as_os_str().is_empty() {
return !matches!(
path.as_path().components().next(),
Some(Component::ParentDir)
);
}
path.starts_with(root)
}
fn verify_recorded_template_dependencies(
source_path: &ManifestPath,
closure: &fabro_template::TemplateDependencyClosure,
files: &HashMap<String, types::ManifestFileEntry>,
from: Option<&ManifestPath>,
inputs: &HashMap<String, toml::Value>,
) -> Result<()> {
let Some(source) = closure.sources.get(source_path) else {
return Ok(());
};
let mut bundled_files = closure
.sources
.iter()
.map(|(path, source)| (path.clone(), source.content.clone()))
.collect::<HashMap<_, _>>();
for (path, entry) in files {
if let Some(path) = ManifestPath::from_wire(path) {
bundled_files.insert(path, entry.content.clone());
}
}
let allowed = bundled_files.keys().cloned().collect();
let store =
RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed);
let ctx = TemplateContext::for_input_scan(inputs.clone());
render_source(source, &ctx, Arc::new(store), TemplateRenderMode::Lenient).with_context(
|| {
let from =
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
format!("failed to verify template dependencies for {from}")
},
)?;
Ok(())
}
fn manifest_attr_reference_kind(
scope: AttributeScope,
key: &str,
value: &str,
) -> Result<ReferenceKind> {
reference_kind_for_attribute(scope, key, value)
.ok_or_else(|| anyhow!("unsupported manifest reference attribute: {key}={value}"))
}
fn collect_config_dockerfile(
cwd: &Path,
config_path: &ManifestPath,
source: &str,
files: &mut HashMap<String, types::ManifestFileEntry>,
) -> Result<()> {
let layer = source
.parse::<SettingsLayer>()
.context("Failed to parse run config TOML")?;
let absolute_config_path = cwd.join(config_path.as_path());
let base_dir = absolute_config_path
.parent()
.unwrap_or_else(|| Path::new("."));
for environment in layer.environments.values() {
collect_environment_dockerfile(
workflows.insert(graph_key, types::ManifestWorkflow {
source: graph.source().to_owned(),
config,
files,
base_dir,
cwd,
config_path,
environment.image.as_ref(),
)?;
}
if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) {
collect_environment_dockerfile(
files,
base_dir,
cwd,
config_path,
run_environment.image.as_ref(),
)?;
}
Ok(())
}
fn collect_environment_dockerfile(
files: &mut HashMap<String, types::ManifestFileEntry>,
base_dir: &Path,
cwd: &Path,
config_path: &ManifestPath,
image: Option<&EnvironmentImageLayer>,
) -> Result<()> {
let dockerfile = image.and_then(|image| image.dockerfile.as_ref());
let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else {
return Ok(());
};
collect_bundled_file(
files,
base_dir,
cwd,
path,
types::ManifestFileRefType::Dockerfile,
ReferenceKind::Dockerfile,
Some(config_path.clone()),
)?;
Ok(())
}
struct BundledFile {
absolute_path: PathBuf,
path: ManifestPath,
}
fn collect_bundled_file(
files: &mut HashMap<String, types::ManifestFileEntry>,
base_dir: &Path,
cwd: &Path,
reference: &str,
ref_type: types::ManifestFileRefType,
reference_kind: ReferenceKind,
from: Option<ManifestPath>,
) -> Result<BundledFile> {
reference_kind
.validate(reference)
.map_err(anyhow::Error::new)?;
let absolute_path = normalize_absolute_path(base_dir, reference)
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
let path = manifest_path_from_absolute(&absolute_path, cwd)?;
let key = path.to_string();
if !files.contains_key(&key) {
let content = std::fs::read_to_string(&absolute_path)
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
files.insert(key.clone(), types::ManifestFileEntry {
content,
ref_: types::ManifestFileRef {
from: from.map(|value| value.to_string()),
original: reference.to_string(),
type_: ref_type,
},
});
}
Ok(BundledFile {
absolute_path,
path,
let target_key = manifest_path_from_absolute(
collected
.workflows()
.get(collected.entrypoint())
.ok_or_else(|| anyhow!("root workflow missing from collected working tree"))?
.graph()
.access_path(),
cwd,
)?
.to_string();
let root_source = workflows
.get(&target_key)
.map(|workflow| workflow.source.clone())
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
let mut configs = Vec::new();
if let Some(config) = collected.project_config() {
configs.push(manifest_config(config, types::ManifestConfigType::Project));
}
if let Some(config) = collected.user_config() {
configs.push(manifest_config(config, types::ManifestConfigType::User));
}
Ok(AssembledCurrentManifest {
target_key,
root_source,
workflows,
configs,
})
}
fn manifest_config(
document: &CollectedDocument,
type_: types::ManifestConfigType,
) -> types::ManifestConfig {
types::ManifestConfig {
path: Some(document.access_path().display().to_string()),
source: Some(document.source().to_owned()),
type_,
}
}
fn resolve_manifest_goal(
run_overrides: Option<&RunLayer>,
settings: &WorkflowSettings,
@ -854,12 +517,6 @@ fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath>
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
}
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
let parent = path.parent_or_dot().to_string_lossy();
ManifestPath::from_wire(&parent)
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
}
pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
args.auto_approve.is_none()
&& args.dry_run.is_none()
@ -932,6 +589,269 @@ mod tests {
assert_eq!(schema.content, schema_source);
}
#[test]
fn build_manifest_characterizes_the_complete_legacy_projection() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let root = project.join(".fabro/workflows/root");
let child = project.join(".fabro/workflows/child");
let user_config_path = temp.path().join("home/.fabro/config.toml");
let project_config = r#"_version = 1
[environments.project]
provider = "docker"
[environments.project.image]
dockerfile = { path = "Project.Dockerfile" }
"#;
let root_config = r#"_version = 1
[workflow]
graph = "workflow.fabro"
"#;
let child_config = root_config;
let user_config = "_version = 1\n";
let root_graph = r#"digraph Root {
graph [goal="@goals/goal.md"]
start [shape=Mdiamond]
prompt [prompt="@prompts/plan.md"]
schema [type="agent", prompt="schema", output_schema="@schemas/output.json"]
imported [import="imports/shared.fabro"]
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
exit [shape=Msquare]
start -> prompt -> schema -> imported -> child -> exit
}"#;
let child_graph =
"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
let imported_graph = r#"digraph Shared {
start [shape=Mdiamond]
shared [prompt="@../prompts/shared.md"]
exit [shape=Msquare]
start -> shared -> exit
}"#;
let plan_prompt = "{% include \"partial.md\" %}\n{% from \"helpers.md\" import render %}";
let helpers = "{% macro render() %}{% include \"deep.md\" %}{% endmacro %}";
let output_schema = r#"{"type":"object"}"#;
let write = |path: &Path, source: &str| {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, source).unwrap();
};
write(&project.join(".fabro/project.toml"), project_config);
write(&project.join(".fabro/Project.Dockerfile"), "FROM project\n");
write(&user_config_path, user_config);
write(&root.join("workflow.toml"), root_config);
write(&root.join("workflow.fabro"), root_graph);
write(&root.join("goals/goal.md"), "ship it\n");
write(&root.join("prompts/plan.md"), plan_prompt);
write(&root.join("prompts/partial.md"), "partial\n");
write(&root.join("prompts/helpers.md"), helpers);
write(&root.join("prompts/deep.md"), "deep\n");
write(&root.join("prompts/shared.md"), "shared\n");
write(&root.join("schemas/output.json"), output_schema);
write(&root.join("imports/shared.fabro"), imported_graph);
write(&child.join("workflow.toml"), child_config);
write(&child.join("workflow.fabro"), child_graph);
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/root/workflow.toml"),
cwd: project.clone(),
input_overrides: HashMap::from([("feature".to_owned(), toml::Value::Boolean(true))]),
args: Some(types::ManifestArgs {
dry_run: Some(true),
input: vec!["feature=true".to_owned()],
label: vec!["suite=characterization".to_owned()],
..types::ManifestArgs::default()
}),
environment_defaults: test_environment_defaults(),
user_settings_path: Some(user_config_path),
..ManifestBuildInput::default()
})
.unwrap();
let mut actual = serde_json::to_value(&built.manifest).unwrap();
actual["cwd"] = serde_json::json!("<cwd>");
actual["configs"][0]["path"] = serde_json::json!("<project-config>");
actual["configs"][1]["path"] = serde_json::json!("<user-config>");
let file = |content: &str, from: &str, original: &str, type_: &str| {
serde_json::json!({
"content": content,
"ref": {
"from": from,
"original": original,
"type": type_,
}
})
};
assert_eq!(
actual,
serde_json::json!({
"args": {
"dry_run": true,
"input": ["feature=true"],
"label": ["suite=characterization"],
},
"configs": [
{
"path": "<project-config>",
"source": project_config,
"type": "project",
},
{
"path": "<user-config>",
"source": user_config,
"type": "user",
},
],
"cwd": "<cwd>",
"goal": { "type": "graph", "text": "ship it\n" },
"target": { "path": ".fabro/workflows/root/workflow.fabro" },
"version": 1,
"workflows": {
".fabro/workflows/child/workflow.fabro": {
"config": {
"path": ".fabro/workflows/child/workflow.toml",
"source": child_config,
},
"source": child_graph,
},
".fabro/workflows/root/workflow.fabro": {
"config": {
"path": ".fabro/workflows/root/workflow.toml",
"source": root_config,
},
"files": {
".fabro/Project.Dockerfile": file(
"FROM project\n",
".fabro/project.toml",
"Project.Dockerfile",
"dockerfile",
),
".fabro/workflows/root/goals/goal.md": file(
"ship it\n",
".fabro/workflows/root/workflow.fabro",
"goals/goal.md",
"file_inline",
),
".fabro/workflows/root/imports/shared.fabro": file(
imported_graph,
".fabro/workflows/root/workflow.fabro",
"imports/shared.fabro",
"import",
),
".fabro/workflows/root/prompts/deep.md": file(
"deep\n",
".fabro/workflows/root/workflow.fabro",
".fabro/workflows/root/prompts/deep.md",
"file_inline",
),
".fabro/workflows/root/prompts/helpers.md": file(
helpers,
".fabro/workflows/root/workflow.fabro",
".fabro/workflows/root/prompts/helpers.md",
"file_inline",
),
".fabro/workflows/root/prompts/partial.md": file(
"partial\n",
".fabro/workflows/root/workflow.fabro",
".fabro/workflows/root/prompts/partial.md",
"file_inline",
),
".fabro/workflows/root/prompts/plan.md": file(
plan_prompt,
".fabro/workflows/root/workflow.fabro",
"prompts/plan.md",
"file_inline",
),
".fabro/workflows/root/prompts/shared.md": file(
"shared\n",
".fabro/workflows/root/imports/shared.fabro",
"../prompts/shared.md",
"file_inline",
),
".fabro/workflows/root/schemas/output.json": file(
output_schema,
".fabro/workflows/root/workflow.fabro",
"schemas/output.json",
"file_inline",
),
},
"source": root_graph,
},
},
})
);
}
#[test]
fn build_manifest_keeps_legacy_parent_paths_for_external_siblings() {
let temp = tempfile::tempdir().unwrap();
let cwd = temp.path().join("checkout");
let root = temp.path().join("user/workflows/root");
let child = temp.path().join("user/workflows/child");
std::fs::create_dir_all(&cwd).unwrap();
for directory in [&root, &child] {
std::fs::create_dir_all(directory.join("prompts")).unwrap();
std::fs::write(
directory.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
}
std::fs::write(
root.join("workflow.fabro"),
r#"digraph Root {
start [shape=Mdiamond]
prompt [prompt="@prompts/root.md"]
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
exit [shape=Msquare]
start -> prompt -> child -> exit
}"#,
)
.unwrap();
std::fs::write(root.join("prompts/root.md"), "root prompt\n").unwrap();
std::fs::write(
child.join("workflow.fabro"),
r#"digraph Child {
start [shape=Mdiamond]
prompt [prompt="@prompts/child.md"]
exit [shape=Msquare]
start -> prompt -> exit
}"#,
)
.unwrap();
std::fs::write(child.join("prompts/child.md"), "child prompt\n").unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: root.join("workflow.fabro"),
cwd,
environment_defaults: test_environment_defaults(),
..ManifestBuildInput::default()
})
.unwrap();
let root_key = "../user/workflows/root/workflow.fabro";
let child_key = "../user/workflows/child/workflow.fabro";
assert_eq!(built.manifest.target.path, root_key);
let root_workflow = &built.manifest.workflows[root_key];
assert_eq!(
root_workflow.config.as_ref().unwrap().path,
"../user/workflows/root/workflow.toml"
);
let root_prompt = &root_workflow.files["../user/workflows/root/prompts/root.md"];
assert_eq!(root_prompt.ref_.from.as_deref(), Some(root_key));
assert_eq!(root_prompt.ref_.original, "prompts/root.md");
let child_workflow = &built.manifest.workflows[child_key];
assert_eq!(
child_workflow.config.as_ref().unwrap().path,
"../user/workflows/child/workflow.toml"
);
let child_prompt = &child_workflow.files["../user/workflows/child/prompts/child.md"];
assert_eq!(child_prompt.ref_.from.as_deref(), Some(child_key));
}
#[test]
fn build_run_overrides_sets_common_cli_and_mcp_layers() {
let overrides = build_run_overrides(RunOverrideInput {
@ -1290,7 +1210,8 @@ mod tests {
.unwrap_err();
assert!(
err.to_string().contains("dynamic template dependency"),
err.chain()
.any(|cause| cause.to_string().contains("dynamic template dependency")),
"unexpected error: {err:#}"
);
}

File diff suppressed because it is too large Load diff