refactor(manifest): type bundle paths end-to-end

Introduce ManifestPath as the canonical in-memory key for run manifests so CLI-produced bundle keys and workflow/server consumers share the same normalization rules. Validate wire keys at the server boundary and add a CLI-to-server round-trip test for user-global @path references.
This commit is contained in:
Bryan Helmkamp 2026-04-29 12:26:39 -04:00
parent c93c52e131
commit 786e01c7a7
No known key found for this signature in database
17 changed files with 774 additions and 311 deletions

View file

@ -4,8 +4,10 @@
)]
mod args;
mod manifest_builder;
use clap::{Command, CommandFactory};
pub use manifest_builder::{BuiltManifest, ManifestBuildInput, build_run_manifest};
pub fn command_for_reference() -> Command {
args::Cli::command()

View file

@ -10,6 +10,10 @@ mod gh;
mod landing;
mod local_server;
mod logging;
#[allow(
unreachable_pub,
reason = "The library exports manifest builder helpers for tests; the binary includes the same module privately."
)]
mod manifest_builder;
mod server_client;
mod server_runs;

View file

@ -16,12 +16,13 @@ use fabro_graphviz::parser;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
use fabro_types::{RunId, WorkflowSettings};
use fabro_workflow::ManifestPath;
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
use crate::args::{PreflightArgs, RunArgs};
#[derive(Debug)]
pub(crate) struct ManifestBuildInput {
pub struct ManifestBuildInput {
pub workflow: PathBuf,
pub cwd: PathBuf,
pub run_overrides: Option<RunLayer>,
@ -34,7 +35,7 @@ pub(crate) struct ManifestBuildInput {
}
#[derive(Debug)]
pub(crate) struct BuiltManifest {
pub struct BuiltManifest {
pub manifest: types::RunManifest,
pub target_path: PathBuf,
}
@ -48,11 +49,11 @@ struct CollectContext<'a> {
#[derive(Clone)]
struct WorkflowScanInput {
absolute_dot_path: PathBuf,
logical_dot_path: PathBuf,
dot_path: ManifestPath,
source: String,
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
if root_resolution.workflow_toml_path.is_none()
&& !root_resolution.resolved_workflow_path.is_file()
@ -91,8 +92,8 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
.build()
.map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?;
let target_path = root_resolution.dot_path.clone();
let target_logical_path = to_logical_path(&target_path, &input.cwd)?;
let target_logical_path_string = logical_path_string(&target_logical_path);
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,
@ -103,7 +104,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
let root_source = context
.workflows
.get(&target_logical_path_string)
.get(&target_key)
.map(|workflow| workflow.source.clone())
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
@ -151,7 +152,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
run_id: input.run_id.map(|run_id| run_id.to_string()),
target: types::ManifestTarget {
identifier: input.workflow.display().to_string(),
path: target_logical_path_string,
path: target_key,
},
version: 1,
workflows: context.workflows,
@ -210,9 +211,9 @@ fn collect_workflow_entry(
workflow.to_path_buf()
};
let resolution = resolve_workflow_path(&normalized_workflow, resolve_from)?;
let logical_dot_path = to_logical_path(&resolution.dot_path, context.cwd)?;
let logical_dot_key = logical_path_string(&logical_dot_path);
if !context.visited_workflows.insert(logical_dot_key.clone()) {
let dot_path = manifest_path_from_absolute(&resolution.dot_path, context.cwd)?;
let dot_key = dot_path.to_string();
if !context.visited_workflows.insert(dot_key.clone()) {
return Ok(());
}
@ -220,7 +221,7 @@ fn collect_workflow_entry(
.with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?;
let config = if let Some(workflow_toml_path) = resolution.workflow_toml_path.as_ref() {
Some(types::ManifestWorkflowConfig {
path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?),
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()))?,
})
@ -230,7 +231,7 @@ fn collect_workflow_entry(
let scan = WorkflowScanInput {
absolute_dot_path: resolution.dot_path,
logical_dot_path,
dot_path,
source: source.clone(),
};
let mut files = HashMap::new();
@ -240,13 +241,11 @@ fn collect_workflow_entry(
}
collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?;
context
.workflows
.insert(logical_dot_key, types::ManifestWorkflow {
config,
files,
source,
});
context.workflows.insert(dot_key, types::ManifestWorkflow {
config,
files,
source,
});
Ok(())
}
@ -275,7 +274,7 @@ fn collect_workflow_files(
context.cwd,
goal_ref.trim_start_matches('@'),
types::ManifestFileRefType::FileInline,
Some(workflow.logical_dot_path.clone()),
Some(workflow.dot_path.clone()),
)?;
}
}
@ -292,7 +291,7 @@ fn collect_workflow_files(
context.cwd,
prompt_ref.trim_start_matches('@'),
types::ManifestFileRefType::FileInline,
Some(workflow.logical_dot_path.clone()),
Some(workflow.dot_path.clone()),
)?;
}
}
@ -307,9 +306,9 @@ fn collect_workflow_files(
context.cwd,
import_ref,
types::ManifestFileRefType::Import,
Some(workflow.logical_dot_path.clone()),
Some(workflow.dot_path.clone()),
)?;
let import_key = logical_path_string(&imported.logical_path);
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(|| {
@ -317,7 +316,7 @@ fn collect_workflow_files(
})?;
let imported_scan = WorkflowScanInput {
absolute_dot_path: imported.absolute_path,
logical_dot_path: imported.logical_path,
dot_path: imported.path,
source: imported_source,
};
collect_workflow_files(context, &imported_scan, files, visited_imports)?;
@ -370,21 +369,25 @@ fn collect_workflow_config_files(
return Ok(());
};
let config_path = context.cwd.join(&config.path);
let config_path = ManifestPath::from_wire(&config.path)
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
let absolute_config_path = context.cwd.join(config_path.as_path());
collect_bundled_file(
files,
config_path.parent().unwrap_or_else(|| Path::new(".")),
absolute_config_path
.parent()
.unwrap_or_else(|| Path::new(".")),
context.cwd,
path,
types::ManifestFileRefType::Dockerfile,
Some(PathBuf::from(&config.path)),
Some(config_path),
)?;
Ok(())
}
struct BundledFile {
absolute_path: PathBuf,
logical_path: PathBuf,
path: ManifestPath,
}
fn collect_bundled_file(
@ -393,19 +396,19 @@ fn collect_bundled_file(
cwd: &Path,
reference: &str,
ref_type: types::ManifestFileRefType,
from: Option<PathBuf>,
from: Option<ManifestPath>,
) -> Result<BundledFile> {
let absolute_path = normalize_absolute_path(base_dir, reference)
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
let logical_path = to_logical_path(&absolute_path, cwd)?;
let key = logical_path_string(&logical_path);
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| logical_path_string(&value)),
from: from.map(|value| value.to_string()),
original: reference.to_string(),
type_: ref_type,
},
@ -414,7 +417,7 @@ fn collect_bundled_file(
Ok(BundledFile {
absolute_path,
logical_path,
path,
})
}
@ -528,49 +531,9 @@ fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option<PathBuf>
Some(normalized)
}
fn to_logical_path(path: &Path, cwd: &Path) -> Result<PathBuf> {
if let Ok(stripped) = path.strip_prefix(cwd) {
return Ok(stripped.to_path_buf());
}
relative_path_from(path, cwd)
.ok_or_else(|| anyhow!("Failed to compute logical path for {}", path.display()))
}
fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
let path_components = path.components().collect::<Vec<_>>();
let base_components = base.components().collect::<Vec<_>>();
if path_components.is_empty() || base_components.is_empty() {
return None;
}
let mut common = 0;
while common < path_components.len()
&& common < base_components.len()
&& path_components[common] == base_components[common]
{
common += 1;
}
let mut relative = PathBuf::new();
for component in &base_components[common..] {
if matches!(component, Component::Normal(_)) {
relative.push("..");
}
}
for component in &path_components[common..] {
match component {
Component::Normal(part) => relative.push(part),
Component::CurDir => {}
Component::ParentDir => relative.push(".."),
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(relative)
}
fn logical_path_string(path: &Path) -> String {
path.to_string_lossy().to_string()
fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath> {
ManifestPath::from_absolute(path, cwd)
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
}
fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {

View file

@ -0,0 +1,57 @@
#![expect(
clippy::disallowed_methods,
reason = "Sync temp fixture writes keep this manifest round-trip test simple and isolated."
)]
use std::path::PathBuf;
use fabro_cli::{ManifestBuildInput, build_run_manifest};
use fabro_workflow::ManifestPath;
#[test]
fn cli_built_manifest_resolves_user_global_at_path() {
let temp = tempfile::tempdir().unwrap();
let workflow_dir = temp.path().join(".fabro/workflows/demo");
let project = temp.path().join("project");
std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
workflow_dir.join("workflow.fabro"),
r#"digraph Demo {
graph [goal="Demo"]
start [shape=Mdiamond]
prompt [prompt="@prompts/hello.md"]
exit [shape=Msquare]
start -> prompt -> exit
}"#,
)
.unwrap();
std::fs::write(workflow_dir.join("prompts/hello.md"), "hello from bundle").unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: workflow_dir.join("workflow.fabro"),
cwd: project,
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_settings_path: None,
})
.unwrap();
let bundle = fabro_server::workflow_bundle_from_manifest(&built.manifest.workflows).unwrap();
let target_path = ManifestPath::from_wire(&built.manifest.target.path).unwrap();
let workflow = bundle
.workflow(&target_path)
.expect("root workflow should be present");
let resolved = workflow
.file_resolver()
.resolve(&workflow.current_dir(), "prompts/hello.md")
.expect("prompt should resolve from bundle");
assert_eq!(resolved.content, "hello from bundle");
assert_eq!(
resolved.path,
PathBuf::from("../.fabro/workflows/demo/prompts/hello.md")
);
}

View file

@ -38,5 +38,6 @@ pub mod web_auth;
mod worker_token;
pub use error::{ApiError, Error, Result};
pub use run_manifest::workflow_bundle_from_manifest;
pub use server_secrets::process_env_snapshot;
pub use startup::validate_startup;

View file

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
@ -29,11 +29,11 @@ use fabro_types::settings::run::{
use fabro_types::{RunId, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::operations::{CreateRunInput, ValidateInput, WorkflowInput, validate};
use fabro_workflow::pipeline::Validated;
use fabro_workflow::run_materialization::materialize_run;
use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle};
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use fabro_workflow::{Error as WorkflowError, ManifestPath};
use crate::server::AppState;
@ -44,7 +44,7 @@ pub(crate) struct PreparedManifest {
pub root_source: String,
pub run_id: Option<RunId>,
pub settings: WorkflowSettings,
pub target_path: PathBuf,
pub target_path: ManifestPath,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
pub working_directory: PathBuf,
@ -70,7 +70,8 @@ pub(crate) fn prepare_manifest(
}
let cwd = PathBuf::from(&manifest.cwd);
let target_path = PathBuf::from(&manifest.target.path);
let target_path = ManifestPath::from_wire(&manifest.target.path)
.ok_or_else(|| anyhow!("invalid manifest target path: {}", manifest.target.path))?;
let workflow_bundle = workflow_bundle_from_manifest(&manifest.workflows)?;
let workflow_input = workflow_bundle
.workflow(&target_path)
@ -79,7 +80,7 @@ pub(crate) fn prepare_manifest(
let root_source = workflow_input.source.clone();
let args_overrides = manifest_args_overrides(manifest.args.as_ref());
let workflow_run_layer = root_workflow_run_layer(manifest, &workflow_input)?;
let workflow_run_layer = root_workflow_run_layer(&workflow_input)?;
let mut workflow_settings_builder =
WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone());
if let Some(run) = args_overrides.run {
@ -178,7 +179,12 @@ pub(crate) async fn run_preflight(
let (report, checks_ok) = build_preflight_report(state, prepared, validated).await?;
let preflight_ok = !validated.has_errors() && checks_ok;
Ok((
preflight_response(validated, &prepared.target_path, &report, preflight_ok),
preflight_response(
validated,
prepared.target_path.as_path(),
&report,
preflight_ok,
),
preflight_ok,
))
}
@ -190,35 +196,77 @@ pub(crate) fn graph_source(prepared: &PreparedManifest, direction: Option<&str>)
)
}
fn workflow_bundle_from_manifest(
pub fn workflow_bundle_from_manifest(
workflows: &HashMap<String, types::ManifestWorkflow>,
) -> Result<WorkflowBundle> {
let workflows = workflows
.iter()
.map(|(path, workflow)| {
let files = workflow
.files
.iter()
.map(|(key, entry)| (PathBuf::from(key), entry.content.clone()))
.collect::<HashMap<_, _>>();
Ok::<_, anyhow::Error>((PathBuf::from(path), BundledWorkflow {
logical_path: PathBuf::from(path),
source: workflow.source.clone(),
files,
}))
})
.collect::<Result<HashMap<_, _>>>()?;
Ok(WorkflowBundle::new(workflows))
let mut bundled = HashMap::new();
let mut workflow_wire_keys = HashMap::new();
for (wire_key, workflow) in workflows {
let path = ManifestPath::from_wire(wire_key)
.ok_or_else(|| anyhow!("invalid manifest workflow key: {wire_key}"))?;
if let Some(previous) = workflow_wire_keys.get(&path) {
bail!(
"duplicate canonical workflow key: {path} (from wire keys {previous:?} and \
{wire_key:?})"
);
}
workflow_wire_keys.insert(path.clone(), wire_key.clone());
let files = workflow_files_from_manifest(&workflow.files)?;
let config = workflow
.config
.as_ref()
.map(|config| {
let path = ManifestPath::from_wire(&config.path).ok_or_else(|| {
anyhow!("invalid manifest workflow config path: {}", config.path)
})?;
Ok::<_, anyhow::Error>(ParsedWorkflowConfig {
path,
source: config.source.clone(),
})
})
.transpose()?;
bundled.insert(path.clone(), BundledWorkflow {
path,
source: workflow.source.clone(),
config,
files,
});
}
Ok(WorkflowBundle::new(bundled))
}
fn root_workflow_run_layer(
manifest: &types::RunManifest,
workflow: &BundledWorkflow,
) -> Result<RunLayer> {
let Some(root) = manifest.workflows.get(&manifest.target.path) else {
bail!("manifest target path is missing from workflows map");
};
let Some(config) = root.config.as_ref() else {
fn workflow_files_from_manifest(
files: &HashMap<String, types::ManifestFileEntry>,
) -> Result<HashMap<ManifestPath, String>> {
let mut bundled = HashMap::new();
let mut file_wire_keys = HashMap::new();
for (wire_key, entry) in files {
let path = ManifestPath::from_wire(wire_key)
.ok_or_else(|| anyhow!("invalid manifest file key: {wire_key}"))?;
if let Some(previous) = file_wire_keys.get(&path) {
bail!(
"duplicate canonical file key: {path} (from wire keys {previous:?} and \
{wire_key:?})"
);
}
if let Some(from) = entry.ref_.from.as_deref() {
ManifestPath::from_wire(from)
.ok_or_else(|| anyhow!("invalid manifest file ref from: {from}"))?;
}
file_wire_keys.insert(path.clone(), wire_key.clone());
bundled.insert(path, entry.content.clone());
}
Ok(bundled)
}
fn root_workflow_run_layer(workflow: &BundledWorkflow) -> Result<RunLayer> {
let Some(config) = workflow.config.as_ref() else {
return Ok(RunLayer::default());
};
@ -232,7 +280,7 @@ fn root_workflow_run_layer(
.transpose()
.map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?
.unwrap_or_default();
resolve_manifest_dockerfile(&mut run, Path::new(&config.path), &workflow.files)?;
resolve_manifest_dockerfile(&mut run, &config.path, &workflow.files)?;
Ok(run)
}
@ -321,8 +369,8 @@ fn resolve_working_directory(settings: &WorkflowSettings, caller_cwd: &Path) ->
fn resolve_manifest_dockerfile(
run: &mut RunLayer,
config_path: &Path,
files: &HashMap<PathBuf, String>,
config_path: &ManifestPath,
files: &HashMap<ManifestPath, String>,
) -> Result<()> {
let source = run
.sandbox
@ -337,43 +385,19 @@ fn resolve_manifest_dockerfile(
return Ok(());
};
let path_owned = path.clone();
let logical_path = normalize_logical_path(
let manifest_path = ManifestPath::from_reference(
config_path.parent().unwrap_or_else(|| Path::new(".")),
&path_owned,
)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?;
let content = files
.get(&logical_path)
.get(&manifest_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?;
.ok_or_else(|| anyhow!("missing bundled dockerfile: {manifest_path}"))?;
*source = DaytonaDockerfileLayer::Inline(content);
Ok(())
}
fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
}
let mut normalized = PathBuf::new();
for component in current_dir.join(path).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
if normalized.file_name().is_some() {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(normalized)
}
async fn build_preflight_report(
state: &AppState,
prepared: &PreparedManifest,
@ -991,6 +1015,88 @@ mod tests {
RunLayer::default()
}
fn manifest_workflow() -> types::ManifestWorkflow {
types::ManifestWorkflow {
config: None,
files: HashMap::new(),
source: "digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
.to_string(),
}
}
fn manifest_file(content: &str) -> types::ManifestFileEntry {
types::ManifestFileEntry {
content: content.to_string(),
ref_: types::ManifestFileRef {
from: Some("workflow.fabro".to_string()),
original: "prompt.md".to_string(),
type_: types::ManifestFileRefType::FileInline,
},
}
}
#[test]
fn workflow_bundle_rejects_duplicate_canonical_workflow_keys() {
let workflows = HashMap::from([
("bar.fabro".to_string(), manifest_workflow()),
("./foo/../bar.fabro".to_string(), manifest_workflow()),
]);
let error = workflow_bundle_from_manifest(&workflows).unwrap_err();
assert!(
error
.to_string()
.contains("duplicate canonical workflow key: bar.fabro")
);
}
#[test]
fn workflow_bundle_rejects_duplicate_canonical_file_keys() {
let mut workflow = manifest_workflow();
workflow.files = HashMap::from([
("prompts/hello.md".to_string(), manifest_file("first")),
("./prompts/./hello.md".to_string(), manifest_file("second")),
]);
let workflows = HashMap::from([("workflow.fabro".to_string(), workflow)]);
let error = workflow_bundle_from_manifest(&workflows).unwrap_err();
assert!(
error
.to_string()
.contains("duplicate canonical file key: prompts/hello.md")
);
}
#[test]
fn workflow_bundle_rejects_invalid_workflow_key() {
let workflows = HashMap::from([("/abs/path.fabro".to_string(), manifest_workflow())]);
let error = workflow_bundle_from_manifest(&workflows).unwrap_err();
assert!(
error
.to_string()
.contains("invalid manifest workflow key: /abs/path.fabro")
);
}
#[test]
fn workflow_bundle_rejects_invalid_file_key() {
let mut workflow = manifest_workflow();
workflow.files = HashMap::from([("~/foo.md".to_string(), manifest_file("content"))]);
let workflows = HashMap::from([("workflow.fabro".to_string(), workflow)]);
let error = workflow_bundle_from_manifest(&workflows).unwrap_err();
assert!(
error
.to_string()
.contains("invalid manifest file key: ~/foo.md")
);
}
#[test]
fn prepare_manifest_preserves_explicit_manifest_dry_run() {
let server_settings = manifest_run_defaults(Some(&server_settings_fixture(

View file

@ -4,7 +4,9 @@
)]
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use crate::ManifestPath;
pub trait FileResolver: Send + Sync {
fn resolve(&self, current_dir: &Path, reference: &str) -> Option<ResolvedFile>;
@ -12,61 +14,32 @@ pub trait FileResolver: Send + Sync {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedFile {
pub logical_path: PathBuf,
pub content: String,
pub path: PathBuf,
pub content: String,
}
#[derive(Clone, Debug, Default)]
pub struct BundleFileResolver {
files: HashMap<PathBuf, String>,
files: HashMap<ManifestPath, String>,
}
impl BundleFileResolver {
#[must_use]
pub fn new(files: HashMap<PathBuf, String>) -> Self {
pub fn new(files: HashMap<ManifestPath, String>) -> Self {
Self { files }
}
}
impl FileResolver for BundleFileResolver {
fn resolve(&self, current_dir: &Path, reference: &str) -> Option<ResolvedFile> {
let logical_path = normalize_logical_path(current_dir, reference)?;
self.files.get(&logical_path).map(|content| ResolvedFile {
logical_path,
let path = ManifestPath::from_reference(current_dir, reference)?;
self.files.get(&path).map(|content| ResolvedFile {
path: path.as_path().to_path_buf(),
content: content.clone(),
})
}
}
pub(crate) fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
}
let mut normalized = PathBuf::new();
for component in current_dir.join(path).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
// If the last component is a normal name, collapse it.
// Otherwise (empty path or trailing `..`), preserve the
// `..` so that leading parent-dir segments survive — these
// occur when a bundled workflow lives outside the CWD
// (e.g. user-global workflows under ~/.fabro/).
if normalized.file_name().is_some() {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(normalized)
}
#[derive(Clone, Debug, Default)]
pub struct FilesystemFileResolver {
fallback_dir: Option<PathBuf>,
@ -106,7 +79,7 @@ impl FileResolver for FilesystemFileResolver {
match std::fs::read_to_string(&resolved_path) {
Ok(content) => Some(ResolvedFile {
logical_path: resolved_path,
path: resolved_path,
content,
}),
Err(error) => {
@ -125,10 +98,14 @@ impl FileResolver for FilesystemFileResolver {
mod tests {
use super::*;
fn manifest_path(value: &str) -> ManifestPath {
ManifestPath::from_wire(value).expect("path should parse")
}
#[test]
fn bundle_resolver_returns_exact_match() {
let resolver = BundleFileResolver::new(HashMap::from([(
PathBuf::from("prompts/review.md"),
manifest_path("prompts/review.md"),
"check it".to_string(),
)]));
@ -136,14 +113,14 @@ mod tests {
.resolve(Path::new("."), "prompts/review.md")
.expect("file should resolve");
assert_eq!(resolved.logical_path, PathBuf::from("prompts/review.md"));
assert_eq!(resolved.path, PathBuf::from("prompts/review.md"));
assert_eq!(resolved.content, "check it");
}
#[test]
fn bundle_resolver_normalizes_relative_segments() {
let resolver = BundleFileResolver::new(HashMap::from([(
PathBuf::from("prompts/review.md"),
manifest_path("prompts/review.md"),
"check it".to_string(),
)]));
@ -151,7 +128,7 @@ mod tests {
.resolve(Path::new("subflows"), "../prompts/review.md")
.expect("file should resolve");
assert_eq!(resolved.logical_path, PathBuf::from("prompts/review.md"));
assert_eq!(resolved.path, PathBuf::from("prompts/review.md"));
}
#[test]
@ -160,42 +137,10 @@ mod tests {
assert!(resolver.resolve(Path::new("."), "missing.md").is_none());
}
#[test]
fn normalize_preserves_leading_parent_dir() {
// When a bundled workflow lives outside the CWD (e.g. ~/.fabro/),
// the manifest builder produces logical paths with leading `..`
// segments. The normalizer must preserve these so that the lookup
// key matches the stored key.
assert_eq!(
normalize_logical_path(Path::new("../.fabro/workflows/demo"), "prompts/hello.md"),
Some(PathBuf::from("../.fabro/workflows/demo/prompts/hello.md"))
);
}
#[test]
fn normalize_preserves_multiple_leading_parent_dirs() {
assert_eq!(
normalize_logical_path(Path::new("../../shared/workflows"), "file.md"),
Some(PathBuf::from("../../shared/workflows/file.md"))
);
}
#[test]
fn normalize_collapses_mid_path_parent_dir() {
// ../foo/../bar should normalize to ../bar
assert_eq!(
normalize_logical_path(Path::new("../foo"), "../bar/file.md"),
Some(PathBuf::from("../bar/file.md"))
);
}
#[test]
fn bundle_resolver_resolves_outside_cwd_paths() {
// Reproduces the user-global workflow scenario from issue #175:
// files are keyed with leading `..` because the workflow lives
// outside the CWD.
let resolver = BundleFileResolver::new(HashMap::from([(
PathBuf::from("../.fabro/workflows/demo/prompts/hello.md"),
manifest_path("../.fabro/workflows/demo/prompts/hello.md"),
"prompt content".to_string(),
)]));

View file

@ -19,10 +19,10 @@ use crate::context::{Context, WorkflowContext, keys};
use crate::error::Error;
use crate::operations::{ValidateInput, WorkflowInput, validate};
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::pipeline;
use crate::pipeline::types::Initialized;
use crate::run_dir::visit_from_context;
use crate::run_options::RunOptions;
use crate::{ManifestPath, pipeline};
/// Orchestrates a child workflow engine, polling for completion or stop
/// conditions.
@ -30,7 +30,7 @@ pub struct SubWorkflowHandler;
struct ParsedChildWorkflow {
graph: Graph,
workflow_path: Option<PathBuf>,
workflow_path: Option<ManifestPath>,
}
/// Parse a duration string like "45s", "200ms", "5m" into a Duration.
@ -109,9 +109,8 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result<ParsedChi
(None, _) => WorkflowInput::Path(PathBuf::from(path)),
};
let workflow_path = match &workflow {
WorkflowInput::Bundled(workflow) => Some(workflow.logical_path.clone()),
WorkflowInput::Path(path) => Some(path.clone()),
WorkflowInput::DotSource { .. } => None,
WorkflowInput::Bundled(workflow) => Some(workflow.path.clone()),
WorkflowInput::Path(_) | WorkflowInput::DotSource { .. } => None,
};
let validated = validate(ValidateInput {
workflow,
@ -591,13 +590,14 @@ mod tests {
);
let mut services = make_services();
services.workflow_path = Some(PathBuf::from("workflow.fabro"));
services.workflow_path = Some(ManifestPath::from_wire("workflow.fabro").unwrap());
services.workflow_bundle = Some(Arc::new(WorkflowBundle::new(HashMap::from([(
PathBuf::from("children/review.fabro"),
ManifestPath::from_wire("children/review.fabro").unwrap(),
BundledWorkflow {
logical_path: PathBuf::from("children/review.fabro"),
source: child_dot_succeeds().to_string(),
files: HashMap::new(),
path: ManifestPath::from_wire("children/review.fabro").unwrap(),
source: child_dot_succeeds().to_string(),
config: None,
files: HashMap::new(),
},
)]))));
@ -632,7 +632,7 @@ mod tests {
);
let mut services = make_services();
services.workflow_path = Some(PathBuf::from("workflow.fabro"));
services.workflow_path = Some(ManifestPath::from_wire("workflow.fabro").unwrap());
services.workflow_bundle = Some(Arc::new(WorkflowBundle::new(HashMap::new())));
let context = Context::new();

View file

@ -132,6 +132,7 @@ mod hook_context;
reason = "The lifecycle module remains crate-visible for tests and pending integrations."
)]
pub(crate) mod lifecycle;
mod manifest_path;
pub(crate) mod node_handler;
pub mod operations;
pub mod outcome;
@ -145,6 +146,7 @@ pub mod run_dump;
pub mod run_lookup;
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
pub use manifest_path::ManifestPath;
pub mod run_materialization;
pub mod run_options;
pub mod run_status;

View file

@ -0,0 +1,365 @@
use std::fmt;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
/// A path used as a key inside a run manifest. It is anchored at the run's
/// cwd, and may contain leading `..` segments for files outside that cwd.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ManifestPath(PathBuf);
impl ManifestPath {
#[must_use]
pub fn from_reference(current_dir: &Path, reference: &str) -> Option<Self> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
}
normalize_components(current_dir.join(path)).map(Self)
}
#[must_use]
pub fn from_absolute(absolute: &Path, cwd: &Path) -> Option<Self> {
if let Ok(stripped) = absolute.strip_prefix(cwd) {
return normalize_components(stripped).map(Self);
}
relative_path_from(absolute, cwd).and_then(|path| normalize_components(path).map(Self))
}
#[must_use]
pub fn from_wire(value: &str) -> Option<Self> {
Self::from_reference(Path::new("."), value)
}
#[must_use]
pub fn as_path(&self) -> &Path {
&self.0
}
#[must_use]
pub fn parent(&self) -> Option<&Path> {
self.0.parent()
}
}
impl TryFrom<String> for ManifestPath {
type Error = ManifestPathParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_wire(&value).ok_or(ManifestPathParseError(value))
}
}
impl From<ManifestPath> for String {
fn from(value: ManifestPath) -> Self {
value.to_string()
}
}
impl fmt::Display for ManifestPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.display())
}
}
#[derive(Debug)]
pub struct ManifestPathParseError(String);
impl fmt::Display for ManifestPathParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid ManifestPath: {}", self.0)
}
}
impl std::error::Error for ManifestPathParseError {}
fn normalize_components(path: impl AsRef<Path>) -> Option<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.as_ref().components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
if normalized.file_name().is_some() {
normalized.pop();
} else {
normalized.push("..");
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(normalized)
}
fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
let path_components = path.components().collect::<Vec<_>>();
let base_components = base.components().collect::<Vec<_>>();
if path_components.is_empty() || base_components.is_empty() {
return None;
}
let mut common = 0;
while common < path_components.len()
&& common < base_components.len()
&& path_components[common] == base_components[common]
{
common += 1;
}
let mut relative = PathBuf::new();
for component in &base_components[common..] {
if matches!(component, Component::Normal(_)) {
relative.push("..");
}
}
for component in &path_components[common..] {
match component {
Component::Normal(part) => relative.push(part),
Component::CurDir => {}
Component::ParentDir => relative.push(".."),
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(relative)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
fn manifest_path(value: &str) -> ManifestPath {
ManifestPath::from_wire(value).expect("path should parse")
}
#[test]
fn from_reference_rejects_absolute_path() {
assert!(ManifestPath::from_reference(Path::new("."), "/tmp/workflow.fabro").is_none());
}
#[test]
fn from_reference_rejects_tilde_reference() {
assert!(ManifestPath::from_reference(Path::new("."), "~/.fabro/workflow.fabro").is_none());
}
#[test]
fn from_reference_simple_relative() {
let path = ManifestPath::from_reference(Path::new("flows"), "workflow.fabro").unwrap();
assert_eq!(path.as_path(), Path::new("flows/workflow.fabro"));
}
#[test]
fn from_reference_collapses_curdir_segments() {
let path = ManifestPath::from_reference(Path::new("./flows"), "./workflow.fabro").unwrap();
assert_eq!(path.as_path(), Path::new("flows/workflow.fabro"));
}
#[test]
fn from_reference_collapses_mid_path_parent_dir() {
let path = ManifestPath::from_reference(Path::new("../foo"), "../bar/file.md").unwrap();
assert_eq!(path.as_path(), Path::new("../bar/file.md"));
}
#[test]
fn from_reference_preserves_single_leading_parent_dir() {
let path =
ManifestPath::from_reference(Path::new("../.fabro/workflows/demo"), "prompts/hello.md")
.unwrap();
assert_eq!(
path.as_path(),
Path::new("../.fabro/workflows/demo/prompts/hello.md")
);
}
#[test]
fn from_reference_preserves_multiple_leading_parent_dirs() {
let path =
ManifestPath::from_reference(Path::new("../../shared/workflows"), "file.md").unwrap();
assert_eq!(path.as_path(), Path::new("../../shared/workflows/file.md"));
}
#[test]
fn from_reference_collapses_then_escapes() {
let path = ManifestPath::from_reference(Path::new("."), "foo/../..").unwrap();
assert_eq!(path.as_path(), Path::new(".."));
}
#[test]
fn from_absolute_file_inside_cwd_strips_prefix() {
let path = ManifestPath::from_absolute(
Path::new("/repo/project/workflow.fabro"),
Path::new("/repo/project"),
)
.unwrap();
assert_eq!(path.as_path(), Path::new("workflow.fabro"));
}
#[test]
fn from_absolute_sibling_directory_uses_single_parent_dir() {
let path = ManifestPath::from_absolute(
Path::new("/repo/shared/workflow.fabro"),
Path::new("/repo/project"),
)
.unwrap();
assert_eq!(path.as_path(), Path::new("../shared/workflow.fabro"));
}
#[test]
fn from_absolute_grandparent_uses_two_parent_dirs() {
let path = ManifestPath::from_absolute(
Path::new("/repo/shared/workflow.fabro"),
Path::new("/repo/project/nested"),
)
.unwrap();
assert_eq!(path.as_path(), Path::new("../../shared/workflow.fabro"));
}
#[test]
fn from_absolute_user_global_workflow_from_unrelated_cwd() {
let path = ManifestPath::from_absolute(
Path::new("/tmp/.fabro/workflows/demo/workflow.fabro"),
Path::new("/tmp/project"),
)
.unwrap();
assert_eq!(
path.as_path(),
Path::new("../.fabro/workflows/demo/workflow.fabro")
);
}
#[test]
fn from_wire_accepts_canonical_relative() {
let path = ManifestPath::from_wire("workflow.fabro").unwrap();
assert_eq!(path.as_path(), Path::new("workflow.fabro"));
}
#[test]
fn from_wire_rejects_absolute_path() {
assert!(ManifestPath::from_wire("/tmp/workflow.fabro").is_none());
}
#[test]
fn from_wire_rejects_tilde_path() {
assert!(ManifestPath::from_wire("~/.fabro/workflow.fabro").is_none());
}
#[test]
fn from_wire_renormalizes_uncollapsed_curdir() {
let path = ManifestPath::from_wire("./foo/./bar").unwrap();
assert_eq!(path.as_path(), Path::new("foo/bar"));
}
#[test]
fn from_wire_renormalizes_mid_path_parent_dir() {
let path = ManifestPath::from_wire("foo/../bar").unwrap();
assert_eq!(path.as_path(), Path::new("bar"));
}
#[test]
fn from_wire_preserves_leading_parent_dir() {
let path = ManifestPath::from_wire("../.fabro/workflows/demo/workflow.fabro").unwrap();
assert_eq!(
path.as_path(),
Path::new("../.fabro/workflows/demo/workflow.fabro")
);
}
#[test]
fn deserialize_rejects_absolute_path() {
assert!(serde_json::from_str::<ManifestPath>("\"/abs\"").is_err());
}
#[test]
fn deserialize_rejects_tilde_path() {
assert!(serde_json::from_str::<ManifestPath>("\"~/.fabro/x\"").is_err());
}
#[test]
fn deserialize_renormalizes_non_canonical() {
let path: ManifestPath = serde_json::from_str("\"foo/../bar\"").unwrap();
assert_eq!(path, manifest_path("bar"));
}
#[test]
fn round_trip_file_inside_cwd() {
let produced = ManifestPath::from_absolute(
Path::new("/repo/project/prompts/hello.md"),
Path::new("/repo/project"),
)
.unwrap();
let consumed = ManifestPath::from_reference(Path::new("prompts"), "hello.md").unwrap();
assert_eq!(produced, consumed);
}
#[test]
fn round_trip_user_global_workflow() {
let produced = ManifestPath::from_absolute(
Path::new("/tmp/.fabro/workflows/demo/prompts/hello.md"),
Path::new("/tmp/project"),
)
.unwrap();
let workflow = ManifestPath::from_absolute(
Path::new("/tmp/.fabro/workflows/demo/workflow.fabro"),
Path::new("/tmp/project"),
)
.unwrap();
let consumed =
ManifestPath::from_reference(workflow.parent().unwrap(), "prompts/hello.md").unwrap();
assert_eq!(produced, consumed);
}
#[test]
fn round_trip_subworkflow_relative_reference() {
let produced = ManifestPath::from_absolute(
Path::new("/repo/.fabro/workflows/child/workflow.fabro"),
Path::new("/repo"),
)
.unwrap();
let root = ManifestPath::from_absolute(
Path::new("/repo/.fabro/workflows/demo/workflow.fabro"),
Path::new("/repo"),
)
.unwrap();
let consumed =
ManifestPath::from_reference(root.parent().unwrap(), "../child/workflow.fabro")
.unwrap();
assert_eq!(produced, consumed);
}
#[test]
fn serializes_as_plain_string() {
let serialized = serde_json::to_string(&manifest_path("../workflow.fabro")).unwrap();
assert_eq!(serialized, "\"../workflow.fabro\"");
}
#[test]
fn deserializes_from_plain_string() {
let path: ManifestPath = serde_json::from_str("\"workflow.fabro\"").unwrap();
assert_eq!(path.as_path(), Path::new("workflow.fabro"));
}
}

View file

@ -21,6 +21,7 @@ use fabro_util::json::normalize_json_value;
use tokio::task::spawn_blocking;
use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow};
use crate::ManifestPath;
use crate::error::Error;
use crate::event::{Event, append_event, to_run_event_at};
use crate::file_resolver::FileResolver;
@ -38,7 +39,7 @@ pub struct CreateRunInput {
pub settings: WorkflowSettings,
pub cwd: PathBuf,
pub workflow_slug: Option<String>,
pub workflow_path: Option<PathBuf>,
pub workflow_path: Option<ManifestPath>,
pub workflow_bundle: Option<WorkflowBundle>,
pub submitted_manifest_bytes: Option<Vec<u8>>,
pub run_id: Option<RunId>,
@ -657,8 +658,8 @@ mod tests {
fn validate_from_bundle_resolves_nested_import_files_relative_to_imported_graph() {
let validated = validate(ValidateInput {
workflow: WorkflowInput::Bundled(BundledWorkflow {
logical_path: PathBuf::from("workflow.fabro"),
source: r#"digraph Test {
path: ManifestPath::from_wire("workflow.fabro").unwrap(),
source: r#"digraph Test {
graph [goal="Ship"]
start [shape=Mdiamond]
validate [import="./child/validate.fabro"]
@ -666,9 +667,10 @@ mod tests {
start -> validate -> exit
}"#
.to_string(),
files: HashMap::from([
config: None,
files: HashMap::from([
(
PathBuf::from("child/validate.fabro"),
ManifestPath::from_wire("child/validate.fabro").unwrap(),
r#"digraph Validate {
start [shape=Mdiamond]
lint [prompt="@../prompts/lint.md"]
@ -678,7 +680,7 @@ mod tests {
.to_string(),
),
(
PathBuf::from("prompts/lint.md"),
ManifestPath::from_wire("prompts/lint.md").unwrap(),
"Lint {{ goal }}".to_string(),
),
]),

View file

@ -120,9 +120,9 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
Ok(ResolvedWorkflow {
raw_source: workflow.source.clone(),
settings,
workflow_slug: workflow_slug_from_path(&workflow.logical_path),
workflow_slug: workflow_slug_from_path(workflow.path.as_path()),
workflow_toml_path: None,
dot_path: Some(workflow.logical_path.clone()),
dot_path: Some(workflow.path.as_path().to_path_buf()),
current_dir: Some(workflow.current_dir()),
file_resolver: Some(workflow.file_resolver()),
goal_override,

View file

@ -31,6 +31,7 @@ use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
use crate::ManifestPath;
use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::Error;
@ -77,7 +78,7 @@ struct RunSession {
pr_github_app: Option<fabro_github::GitHubCredentials>,
pr_origin_url: Option<String>,
pr_model: String,
workflow_path: Option<PathBuf>,
workflow_path: Option<ManifestPath>,
workflow_bundle: Option<Arc<WorkflowBundle>>,
run_control: Option<Arc<RunControlState>>,
vault: Option<Arc<AsyncRwLock<Vault>>>,
@ -981,6 +982,7 @@ mod tests {
use object_store::memory::InMemory;
use super::*;
use crate::ManifestPath;
use crate::context::Context;
use crate::event::{Emitter, EventBody};
use crate::handler::HandlerRegistry;
@ -1175,9 +1177,11 @@ mod tests {
let registry = Arc::new(test_registry());
let store = memory_store();
let workflow_bundle = WorkflowBundle::new(HashMap::from([
(PathBuf::from("workflow.fabro"), BundledWorkflow {
logical_path: PathBuf::from("workflow.fabro"),
source: r#"digraph Root {
(
ManifestPath::from_wire("workflow.fabro").unwrap(),
BundledWorkflow {
path: ManifestPath::from_wire("workflow.fabro").unwrap(),
source: r#"digraph Root {
graph [goal="Bundle child"]
start [shape=Mdiamond]
manager [
@ -1189,19 +1193,25 @@ mod tests {
exit [shape=Msquare]
start -> manager -> exit
}"#
.to_string(),
files: HashMap::new(),
}),
(PathBuf::from("children/review.fabro"), BundledWorkflow {
logical_path: PathBuf::from("children/review.fabro"),
source: r"digraph Review {
.to_string(),
config: None,
files: HashMap::new(),
},
),
(
ManifestPath::from_wire("children/review.fabro").unwrap(),
BundledWorkflow {
path: ManifestPath::from_wire("children/review.fabro").unwrap(),
source: r"digraph Review {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"
.to_string(),
files: HashMap::new(),
}),
.to_string(),
config: None,
files: HashMap::new(),
},
),
]));
crate::operations::create(
@ -1209,7 +1219,7 @@ mod tests {
crate::operations::CreateRunInput {
workflow: crate::operations::WorkflowInput::Bundled(
workflow_bundle
.workflow(Path::new("workflow.fabro"))
.workflow(&ManifestPath::from_wire("workflow.fabro").unwrap())
.unwrap()
.clone(),
),
@ -1222,7 +1232,7 @@ mod tests {
}),
cwd: temp.path().to_path_buf(),
workflow_slug: Some("bundle-child".to_string()),
workflow_path: Some(PathBuf::from("workflow.fabro")),
workflow_path: Some(ManifestPath::from_wire("workflow.fabro").unwrap()),
workflow_bundle: Some(workflow_bundle),
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),

View file

@ -16,6 +16,7 @@ use fabro_validate::{Diagnostic, Severity};
use fabro_vault::Vault;
use tokio::sync::RwLock as AsyncRwLock;
use crate::ManifestPath;
use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::Error;
@ -241,7 +242,7 @@ pub struct InitOptions {
pub interviewer: Arc<dyn Interviewer>,
pub lifecycle: LifecycleOptions,
pub run_options: RunOptions,
pub workflow_path: Option<PathBuf>,
pub workflow_path: Option<ManifestPath>,
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
pub hooks: fabro_hooks::HookSettings,
pub sandbox_env: SandboxEnvSpec,

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
#[cfg(test)]
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@ -13,6 +14,7 @@ use fabro_model::Provider;
use tokio::time;
use tokio_util::sync::CancellationToken;
use crate::ManifestPath;
use crate::event::Emitter;
use crate::handler::HandlerRegistry;
use crate::runtime_store::RunStoreHandle;
@ -121,8 +123,8 @@ pub struct EngineServices {
/// When true, handlers should skip real execution and return simulated
/// results.
pub dry_run: bool,
/// Logical path of the current workflow when running from a bundle.
pub workflow_path: Option<PathBuf>,
/// Manifest path of the current workflow when running from a bundle.
pub workflow_path: Option<ManifestPath>,
/// Bundled workflows available for child-workflow resolution.
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
}

View file

@ -110,10 +110,10 @@ impl ImportTransform {
return Ok(());
};
if import_stack.contains(&resolved_file.logical_path) {
if import_stack.contains(&resolved_file.path) {
let cycle = import_stack
.iter()
.chain(std::iter::once(&resolved_file.logical_path))
.chain(std::iter::once(&resolved_file.path))
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(" -> ");
@ -137,7 +137,7 @@ impl ImportTransform {
if let Err(message) = Self::splice_import(
graph,
placeholder_id,
&resolved_file.logical_path,
&resolved_file.path,
&placeholder,
prepared,
) {
@ -152,52 +152,47 @@ impl ImportTransform {
resolved_file: &ResolvedFile,
import_stack: &mut Vec<PathBuf>,
) -> Result<PreparedImport, ImportPrepareError> {
Self::with_import_stack(
import_stack,
resolved_file.logical_path.clone(),
|import_stack| {
let rendered_source = render_template(
&resolved_file.content,
&TemplateContext::new()
.with_goal("{{ goal }}")
.with_inputs(self.inputs.clone()),
)
.map_err(|error| ImportPrepareError::Hard(Error::Validation(error.to_string())))?;
Self::with_import_stack(import_stack, resolved_file.path.clone(), |import_stack| {
let rendered_source = render_template(
&resolved_file.content,
&TemplateContext::new()
.with_goal("{{ goal }}")
.with_inputs(self.inputs.clone()),
)
.map_err(|error| ImportPrepareError::Hard(Error::Validation(error.to_string())))?;
let mut graph = parser::parse(&rendered_source).map_err(|error| {
ImportPrepareError::Soft(format!(
"failed to parse {}: {error}",
resolved_file.logical_path.display()
))
})?;
let mut graph = parser::parse(&rendered_source).map_err(|error| {
ImportPrepareError::Soft(format!(
"failed to parse {}: {error}",
resolved_file.path.display()
))
})?;
let import_base_dir = resolved_file
.logical_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
graph =
FileInliningTransform::new(import_base_dir.clone(), Arc::clone(&self.resolver))
.apply(graph)
.map_err(ImportPrepareError::Hard)?;
let import_base_dir = resolved_file
.path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
graph = FileInliningTransform::new(import_base_dir.clone(), Arc::clone(&self.resolver))
.apply(graph)
.map_err(ImportPrepareError::Hard)?;
if let Some(message) = Self::unresolved_imported_prompt_error(&graph) {
return Err(ImportPrepareError::Soft(message));
}
if let Some(message) = Self::unresolved_imported_prompt_error(&graph) {
return Err(ImportPrepareError::Soft(message));
}
let nested_imports = Self::collect_import_nodes(&graph);
for (placeholder_id, import_path) in nested_imports {
self.expand_import(
&mut graph,
&placeholder_id,
&import_path,
&import_base_dir,
import_stack,
)?;
}
let nested_imports = Self::collect_import_nodes(&graph);
for (placeholder_id, import_path) in nested_imports {
self.expand_import(
&mut graph,
&placeholder_id,
&import_path,
&import_base_dir,
import_stack,
)?;
}
Self::validate_imported_graph(graph).map_err(ImportPrepareError::Soft)
},
)
Self::validate_imported_graph(graph).map_err(ImportPrepareError::Soft)
})
}
fn splice_import(

View file

@ -4,13 +4,21 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::file_resolver::{BundleFileResolver, FileResolver, normalize_logical_path};
use crate::ManifestPath;
use crate::file_resolver::{BundleFileResolver, FileResolver};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ParsedWorkflowConfig {
pub path: ManifestPath,
pub source: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BundledWorkflow {
pub logical_path: PathBuf,
pub source: String,
pub files: HashMap<PathBuf, String>,
pub path: ManifestPath,
pub source: String,
pub config: Option<ParsedWorkflowConfig>,
pub files: HashMap<ManifestPath, String>,
}
impl BundledWorkflow {
@ -21,7 +29,7 @@ impl BundledWorkflow {
#[must_use]
pub fn current_dir(&self) -> PathBuf {
self.logical_path
self.path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
@ -29,46 +37,46 @@ impl BundledWorkflow {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct WorkflowBundle {
workflows: HashMap<PathBuf, BundledWorkflow>,
workflows: HashMap<ManifestPath, BundledWorkflow>,
}
impl WorkflowBundle {
#[must_use]
pub fn new(workflows: HashMap<PathBuf, BundledWorkflow>) -> Self {
pub fn new(workflows: HashMap<ManifestPath, BundledWorkflow>) -> Self {
Self { workflows }
}
pub fn workflow(&self, logical_path: &Path) -> Option<&BundledWorkflow> {
self.workflows.get(logical_path)
pub fn workflow(&self, path: &ManifestPath) -> Option<&BundledWorkflow> {
self.workflows.get(path)
}
pub fn resolve_child(
&self,
current_workflow_path: &Path,
current_workflow_path: &ManifestPath,
reference: &str,
) -> Option<&BundledWorkflow> {
let current_dir = current_workflow_path
.parent()
.unwrap_or_else(|| Path::new("."));
let logical_path = normalize_logical_path(current_dir, reference)?;
self.workflows.get(&logical_path)
let path = ManifestPath::from_reference(current_dir, reference)?;
self.workflows.get(&path)
}
#[must_use]
pub fn workflows(&self) -> &HashMap<PathBuf, BundledWorkflow> {
pub fn workflows(&self) -> &HashMap<ManifestPath, BundledWorkflow> {
&self.workflows
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunDefinition {
pub workflow_path: PathBuf,
pub workflows: HashMap<PathBuf, BundledWorkflow>,
pub workflow_path: ManifestPath,
pub workflows: HashMap<ManifestPath, BundledWorkflow>,
}
impl RunDefinition {
#[must_use]
pub fn new(workflow_path: PathBuf, bundle: WorkflowBundle) -> Self {
pub fn new(workflow_path: ManifestPath, bundle: WorkflowBundle) -> Self {
Self {
workflow_path,
workflows: bundle.workflows,