fabro/lib/apps/fabro-server/src/run_intent.rs
Scott Werner da0b4c259a Return 404 when a worker's originating run is missing
The worker folder-target guard opened a run reader and mapped every
failure, including a run that no longer exists, to HTTP 500 with an
error log. Load the projection through the store's lookup instead so a
missing run is a 404 with its own error code, and run the check after
environment selection so ordinary environment errors are reported
first.

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

908 lines
30 KiB
Rust

use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use fabro_config::parse::SettingsSource;
use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, SettingsLayer};
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
use fabro_manifest::CollectedWorkflowClosure;
use fabro_types::settings::InterpString;
use fabro_types::{
GitContext, ManifestPath, RunId, RunTarget, SandboxProviderKind, TargetValidationError,
WorkflowPath, WorkflowVersion, WorkflowVersionId,
};
use fabro_workflow::git;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use fabro_workflow_version::{LoadedWorkflowVersionClosure, ValidatedWorkflowVersion};
use thiserror::Error;
use tokio::{fs, task};
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
#[derive(Debug, Error)]
pub(crate) enum RunIntentAdmissionError {
#[error("workflow-version closure could not be loaded")]
VersionStore {
#[source]
source: fabro_workflow_version::WorkflowVersionStoreError,
},
#[error(transparent)]
Lowering(#[from] WorkflowClosureLoweringError),
#[error(transparent)]
Target(#[from] TargetValidationError),
#[error(transparent)]
FolderTarget(#[from] FolderTargetValidationError),
#[error(transparent)]
Environment(#[from] EnvironmentSelectionError),
#[error(transparent)]
Compiler(#[from] RunCompilerError),
#[error("run variables could not be loaded")]
VariableSnapshot {
#[source]
source: fabro_variable::Error,
},
#[error("originating worker run `{run_id}` could not be loaded")]
WorkerRun {
run_id: RunId,
#[source]
source: fabro_store::Error,
},
#[error("originating worker run `{run_id}` was not found")]
WorkerRunNotFound { run_id: RunId },
}
#[derive(Debug, Error)]
pub(crate) enum FolderTargetValidationError {
#[error("folder target path must be absolute")]
Relative,
#[error("folder target path does not name an accessible filesystem entry")]
Canonicalize {
#[source]
source: std::io::Error,
},
#[error("folder target path must name a directory")]
NotDirectory,
#[error("folder target canonical path must be valid UTF-8")]
NonUtf8,
}
#[derive(Debug)]
pub(crate) struct PreparedIntentTarget {
pub(crate) target: RunTarget,
pub(crate) git: Option<GitContext>,
}
/// Materialize filesystem-backed target facts after the effective environment
/// has been admitted as Local and before run allocation. Folder targets are
/// canonicalized once for durable identity and their optional Git metadata is
/// observed under the same provider gate, so rejected requests never scan host
/// repositories. Other targets pass through with their validated projection.
pub(crate) async fn prepare_intent_target(
target: RunTarget,
git: Option<GitContext>,
) -> Result<PreparedIntentTarget, FolderTargetValidationError> {
let RunTarget::Folder { path } = target else {
return Ok(PreparedIntentTarget { target, git });
};
let submitted = PathBuf::from(path);
if !submitted.is_absolute() {
return Err(FolderTargetValidationError::Relative);
}
let canonical = fs::canonicalize(&submitted)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
let metadata = fs::metadata(&canonical)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
if !metadata.is_dir() {
return Err(FolderTargetValidationError::NotDirectory);
}
let path = canonical_folder_text(&canonical)?;
let git = task::spawn_blocking(move || {
git::observe_git_context(&canonical).unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path = %canonical.display(),
"failed to observe optional git metadata for folder target"
);
None
})
})
.await
.unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path,
"folder target git observation task failed"
);
None
});
Ok(PreparedIntentTarget {
target: RunTarget::Folder { path },
git,
})
}
fn canonical_folder_text(path: &Path) -> Result<String, FolderTargetValidationError> {
path.to_str()
.map(str::to_string)
.ok_or(FolderTargetValidationError::NonUtf8)
}
#[derive(Debug, Error)]
pub(crate) enum EnvironmentSelectionError {
#[error("invalid environment ID `{value}`")]
InvalidId {
value: String,
#[source]
source: EnvironmentValidationError,
},
#[error("environment `{id}` not found")]
NotFound { id: EnvironmentId },
#[error("{detail}")]
TargetUnsupported { detail: &'static str },
#[error(
"automatic pull requests require a clone-based Docker or Daytona environment; disable run.pull_request.enabled for Local execution"
)]
AutomaticPullRequestUnsupported,
#[error("{detail}")]
ProviderDisabled {
provider: SandboxProviderKind,
detail: String,
},
#[error("{name} is not configured for sandbox provider `{provider}`")]
MissingCredential {
provider: SandboxProviderKind,
name: &'static str,
},
#[error("failed to read sandbox credential `{name}`")]
CredentialStore {
name: &'static str,
#[source]
source: fabro_vault::SecretStoreError,
},
}
#[derive(Debug)]
pub(crate) struct LoweredWorkflowClosure {
pub(crate) workflow_bundle: WorkflowBundle,
pub(crate) entrypoint: ManifestPath,
pub(crate) workflow_layer: Option<SettingsLayer>,
}
/// Ceiling on the number of distinct workflow mounts one closure may expand
/// into. Mounts are keyed by rebased path, so a small dependency graph that
/// re-mounts shared versions along many paths can otherwise expand
/// exponentially and stall admission on a single request.
const MAX_WORKFLOW_MOUNTS: usize = 256;
#[derive(Debug, Error)]
pub(crate) enum WorkflowClosureLoweringError {
#[error("workflow version `{id}` is missing from the loaded closure")]
MissingVersion { id: WorkflowVersionId },
#[error("workflow path `{path}` cannot be mounted at `{mount}`")]
InvalidMount {
path: WorkflowPath,
mount: ManifestPath,
},
#[error("workflow mount `{path}` resolves to two different workflow versions")]
ConflictingMount { path: ManifestPath },
#[error("workflow version closure expands into more than {limit} workflow mounts")]
MountLimitExceeded { limit: usize },
#[error("workflow-version settings are unusable")]
Settings {
#[source]
source: Box<RunCompilerError>,
},
}
/// Read access to a workflow-version closure, whether loaded from the store
/// or collected from a local checkout, so both lower through one path.
trait WorkflowClosureView {
fn root_id(&self) -> WorkflowVersionId;
fn validated_root(&self) -> &ValidatedWorkflowVersion;
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion>;
fn root(&self) -> &WorkflowVersion {
self.validated_root().version()
}
}
impl WorkflowClosureView for LoadedWorkflowVersionClosure {
fn root_id(&self) -> WorkflowVersionId {
self.root_id()
}
fn validated_root(&self) -> &ValidatedWorkflowVersion {
self.validated_root()
}
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
self.get(id)
}
}
struct CollectedWorkflowClosureView<'a> {
root_id: WorkflowVersionId,
root: &'a ValidatedWorkflowVersion,
versions: HashMap<WorkflowVersionId, &'a ValidatedWorkflowVersion>,
}
impl<'a> CollectedWorkflowClosureView<'a> {
fn new(closure: &'a CollectedWorkflowClosure) -> Result<Self, WorkflowClosureLoweringError> {
let root_id = closure.root_id();
let versions = closure.versions().collect::<HashMap<_, _>>();
let root = *versions
.get(&root_id)
.ok_or(WorkflowClosureLoweringError::MissingVersion { id: root_id })?;
Ok(Self {
root_id,
root,
versions,
})
}
}
impl WorkflowClosureView for CollectedWorkflowClosureView<'_> {
fn root_id(&self) -> WorkflowVersionId {
self.root_id
}
fn validated_root(&self) -> &ValidatedWorkflowVersion {
self.root
}
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
self.versions.get(id).map(|version| version.version())
}
}
fn lower_workflow_closure_view(
closure: &impl WorkflowClosureView,
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
let entrypoint = manifest_path(closure.root().entrypoint(), closure.root().entrypoint())?;
let mut mounts = HashMap::new();
let mut workflows = HashMap::new();
mount_version(
closure,
closure.root_id(),
entrypoint.clone(),
&mut mounts,
&mut workflows,
)?;
let root_workflow = workflows
.get(&entrypoint)
.expect("root workflow should be mounted");
let workflow_layer = root_workflow
.config
.as_ref()
.map(|config| {
settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&root_workflow.files,
SettingsSource::Workflow,
)
.map_err(|source| WorkflowClosureLoweringError::Settings {
source: Box::new(source),
})
.map(|mut layer| {
inline_goal_file(&mut layer, closure.validated_root());
layer
})
})
.transpose()?;
Ok(LoweredWorkflowClosure {
workflow_bundle: WorkflowBundle::new(workflows),
entrypoint,
workflow_layer,
})
}
pub(crate) fn lower_workflow_closure(
closure: &LoadedWorkflowVersionClosure,
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
lower_workflow_closure_view(closure)
}
pub(crate) fn lower_collected_workflow_closure(
closure: &CollectedWorkflowClosure,
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
let view = CollectedWorkflowClosureView::new(closure)?;
lower_workflow_closure_view(&view)
}
pub(crate) fn pin_workflow_environment_authority(layer: &mut SettingsLayer, environment_id: &str) {
// Both blocks destructure without `..` so adding a field to either layer
// type forces a compile-time decision here: server-owned facts are
// cleared off the immutable workflow layer, workflow-owned facts pass
// through.
if let Some(environment) = layer.environments.get_mut(environment_id) {
let EnvironmentLayer {
provider,
cwd,
image,
resources: _,
network: _,
lifecycle: _,
labels: _,
env: _,
} = environment;
*provider = None;
*cwd = None;
*image = None;
}
if let Some(environment) = layer.run.as_mut().and_then(|run| run.environment.as_mut()) {
let RunEnvironmentLayer {
id: _,
image,
resources: _,
network: _,
lifecycle: _,
labels: _,
env: _,
} = environment;
*image = None;
}
}
fn mount_version(
closure: &impl WorkflowClosureView,
id: WorkflowVersionId,
mounted_entrypoint: ManifestPath,
mounts: &mut HashMap<ManifestPath, WorkflowVersionId>,
workflows: &mut HashMap<ManifestPath, BundledWorkflow>,
) -> Result<(), WorkflowClosureLoweringError> {
if let Some(existing) = mounts.get(&mounted_entrypoint) {
return if *existing == id {
Ok(())
} else {
Err(WorkflowClosureLoweringError::ConflictingMount {
path: mounted_entrypoint,
})
};
}
mounts.insert(mounted_entrypoint.clone(), id);
// Recursion depth is bounded by the mount count (every level inserts a
// distinct mount before descending), so this cap also bounds the stack.
if mounts.len() > MAX_WORKFLOW_MOUNTS {
return Err(WorkflowClosureLoweringError::MountLimitExceeded {
limit: MAX_WORKFLOW_MOUNTS,
});
}
let version = closure
.get(&id)
.ok_or(WorkflowClosureLoweringError::MissingVersion { id })?;
let mut files = HashMap::new();
for (path, content) in version.files() {
files.insert(
manifest_path(version.entrypoint(), path).and_then(|local| {
rebase_path(version.entrypoint(), &mounted_entrypoint, &local, path)
})?,
content.clone(),
);
}
let source = version
.files()
.get(version.entrypoint())
.cloned()
.expect("validated workflow versions contain their entrypoint file");
let config_local = version.config_path();
let config_path = version.files().get(&config_local).map(|source| {
rebase_path(
version.entrypoint(),
&mounted_entrypoint,
&ManifestPath::from_wire(config_local.as_str())
.expect("validated workflow path should be a manifest path"),
&config_local,
)
.map(|path| ParsedWorkflowConfig {
path,
source: source.clone(),
})
});
let config = config_path.transpose()?;
workflows.insert(mounted_entrypoint.clone(), BundledWorkflow {
path: mounted_entrypoint.clone(),
source,
config,
files,
});
for (binding, dependency_id) in version.workflow_dependencies() {
let local = ManifestPath::from_wire(binding.as_str())
.expect("validated workflow path should be a manifest path");
let dependency_mount =
rebase_path(version.entrypoint(), &mounted_entrypoint, &local, binding)?;
mount_version(closure, *dependency_id, dependency_mount, mounts, workflows)?;
}
Ok(())
}
fn manifest_path(
entrypoint: &WorkflowPath,
path: &WorkflowPath,
) -> Result<ManifestPath, WorkflowClosureLoweringError> {
ManifestPath::from_wire(path.as_str()).ok_or_else(|| {
WorkflowClosureLoweringError::InvalidMount {
path: path.clone(),
mount: ManifestPath::from_wire(entrypoint.as_str())
.expect("validated entrypoint should be a manifest path"),
}
})
}
fn rebase_path(
local_entrypoint: &WorkflowPath,
mounted_entrypoint: &ManifestPath,
local_path: &ManifestPath,
workflow_path: &WorkflowPath,
) -> Result<ManifestPath, WorkflowClosureLoweringError> {
let relative = relative_path(
local_entrypoint_parent(local_entrypoint),
local_path.as_path(),
);
let mapped = ManifestPath::from_reference(
mounted_entrypoint.parent_or_dot(),
&relative.to_string_lossy(),
)
.filter(|path| !path.as_path().starts_with(".."))
.ok_or_else(|| WorkflowClosureLoweringError::InvalidMount {
path: workflow_path.clone(),
mount: mounted_entrypoint.clone(),
})?;
Ok(mapped)
}
fn local_entrypoint_parent(entrypoint: &WorkflowPath) -> &Path {
Path::new(entrypoint.as_str())
.parent()
.unwrap_or_else(|| Path::new("."))
}
fn relative_path(base: &Path, path: &Path) -> PathBuf {
let base = base
.components()
.filter_map(normal_component)
.collect::<Vec<_>>();
let path = path
.components()
.filter_map(normal_component)
.collect::<Vec<_>>();
let common = base
.iter()
.zip(&path)
.take_while(|(left, right)| left == right)
.count();
let mut relative = PathBuf::new();
for _ in &base[common..] {
relative.push("..");
}
for component in &path[common..] {
relative.push(component);
}
relative
}
fn normal_component(component: Component<'_>) -> Option<&std::ffi::OsStr> {
match component {
Component::Normal(value) => Some(value),
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
None
}
}
}
/// Inline a file-form run goal using the goal-file resolution the
/// workflow-version store certified, so the resolution grammar has exactly
/// one owner in `fabro-workflow-version`.
fn inline_goal_file(layer: &mut SettingsLayer, root: &ValidatedWorkflowVersion) {
let Some(goal) = layer.run.as_mut().and_then(|run| run.goal.as_mut()) else {
return;
};
if !matches!(&*goal, RunGoalLayer::File { .. }) {
return;
}
// `layer` is parsed from the same `workflow.toml` source the certified
// root version carries, so a file-form goal here always resolves there.
let content = root
.resolved_goal_file_content()
.expect("stored workflow versions certify their goal-file references");
*goal = RunGoalLayer::Inline(InterpString::parse(content));
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fabro_types::{WorkflowVersion, WorkflowVersionId};
use fabro_workflow_version::{ValidatedWorkflowVersion, WorkflowVersionStore};
use super::*;
fn workflow_path(value: &str) -> WorkflowPath {
WorkflowPath::new(value).unwrap()
}
fn version(
entrypoint: &str,
files: impl IntoIterator<Item = (&'static str, &'static str)>,
dependencies: impl IntoIterator<Item = (&'static str, WorkflowVersionId)>,
) -> ValidatedWorkflowVersion {
let files = files
.into_iter()
.map(|(path, source)| (workflow_path(path), source.to_string()))
.collect();
let dependencies = dependencies
.into_iter()
.map(|(path, id)| (workflow_path(path), id))
.collect::<BTreeMap<_, _>>();
ValidatedWorkflowVersion::new(
WorkflowVersion::new(workflow_path(entrypoint), files, dependencies).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn collected_and_stored_closures_lower_through_the_same_path() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
let child = temp.path().join("child");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&child).unwrap();
fs::write(
root.join("workflow.toml"),
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"goal.md\"\n",
)
.await
.unwrap();
fs::write(
root.join("workflow.fabro"),
"digraph Root { child [stack.child_workflow=\"../child/workflow.fabro\"] }",
)
.await
.unwrap();
fs::write(root.join("goal.md"), "Ship it").await.unwrap();
fs::write(child.join("workflow.fabro"), "digraph Child {}")
.await
.unwrap();
let collected =
fabro_manifest::collect_workflow_versions(&root.join("workflow.toml"), temp.path())
.unwrap();
let (database, _) = crate::test_support::test_store_bundle();
let store = WorkflowVersionStore::new(database.blobs());
for (_, version) in collected.versions() {
store.put(version).await.unwrap();
}
let stored = store
.get_closure(&collected.root_id())
.await
.unwrap()
.unwrap();
let from_collected = lower_collected_workflow_closure(&collected).unwrap();
let from_stored = lower_workflow_closure(&stored).unwrap();
assert_eq!(from_collected.entrypoint, from_stored.entrypoint);
assert_eq!(
serde_json::to_value(from_collected.workflow_bundle.workflows()).unwrap(),
serde_json::to_value(from_stored.workflow_bundle.workflows()).unwrap(),
);
assert_eq!(
format!("{:?}", from_collected.workflow_layer),
format!("{:?}", from_stored.workflow_layer),
);
}
#[tokio::test]
async fn prepares_a_canonical_folder_target_without_git_projection() {
let dir = tempfile::tempdir().unwrap();
let target_dir = dir.path().join("target");
std::fs::create_dir(&target_dir).unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let submitted = dir.path().join("nested").join("..").join("target");
let prepared = prepare_intent_target(
RunTarget::Folder {
path: submitted.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap();
let canonical = target_dir.canonicalize().unwrap();
assert_eq!(prepared.git, None);
assert_eq!(prepared.target, RunTarget::Folder {
path: canonical.to_string_lossy().to_string(),
});
}
#[tokio::test]
async fn rejects_relative_missing_and_file_folder_targets() {
let relative = prepare_intent_target(
RunTarget::Folder {
path: "relative/path".to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(relative, FolderTargetValidationError::Relative));
let dir = tempfile::tempdir().unwrap();
let missing = prepare_intent_target(
RunTarget::Folder {
path: dir.path().join("missing").to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(
missing,
FolderTargetValidationError::Canonicalize { .. }
));
let file = dir.path().join("file");
fs::write(&file, "not a directory").await.unwrap();
let file = prepare_intent_target(
RunTarget::Folder {
path: file.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(file, FolderTargetValidationError::NotDirectory));
}
#[cfg(unix)]
#[test]
fn rejects_a_non_utf8_canonical_folder_target() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = PathBuf::from(OsString::from_vec(vec![b'f', b'o', 0x80]));
let error = canonical_folder_text(&path).unwrap_err();
assert!(matches!(error, FolderTargetValidationError::NonUtf8));
}
#[tokio::test]
async fn lowers_nested_entrypoints_and_inlines_goal_files() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let grandchild = version(
"deep/leaf.fabro",
[("deep/leaf.fabro", "digraph Leaf {}")],
[],
);
let grandchild_id = store.put(&grandchild).await.unwrap();
let child = version(
"pkg/child.fabro",
[(
"pkg/child.fabro",
"digraph Child { leaf [stack.child_workflow=\"../nested/leaf.fabro\"] }",
)],
[("nested/leaf.fabro", grandchild_id)],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"flows/root.fabro",
[
(
"flows/root.fabro",
"digraph Root { child [stack.child_workflow=\"../deps/run.fabro\"] }",
),
(
"flows/workflow.toml",
"_version = 1\n[run.goal]\nfile = \"goal.md\"\n",
),
("flows/goal.md", "Ship {{ vars.owner }}"),
],
[("deps/run.fabro", child_id)],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let lowered = lower_workflow_closure(&closure).unwrap();
assert!(
lowered
.workflow_bundle
.workflow(&lowered.entrypoint)
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("deps/run.fabro").unwrap())
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("nested/leaf.fabro").unwrap())
.is_some()
);
assert!(matches!(
lowered
.workflow_layer
.as_ref()
.and_then(|layer| layer.run.as_ref())
.and_then(|run| run.goal.as_ref()),
Some(RunGoalLayer::Inline(_))
));
}
#[tokio::test]
async fn lowers_same_version_at_distinct_mount_paths() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let child = version(
"pkg/child.fabro",
[("pkg/child.fabro", "digraph Child {}")],
[],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"flows/root.fabro",
[(
"flows/root.fabro",
"digraph Root { one [stack.child_workflow=\"../children/one.fabro\"] two [stack.child_workflow=\"../children/two.fabro\"] }",
)],
[
("children/one.fabro", child_id),
("children/two.fabro", child_id),
],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let lowered = lower_workflow_closure(&closure).unwrap();
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("children/one.fabro").unwrap())
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("children/two.fabro").unwrap())
.is_some()
);
}
#[tokio::test]
async fn rejects_closures_that_expand_past_the_mount_limit() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
// A chain of tiny versions where each level mounts the next twice is
// cheap to store and load (the closure dedupes by id) but expands to
// 2^depth distinct mounts.
let leaf = version("flow.fabro", [("flow.fabro", "digraph Leaf {}")], []);
let mut previous = store.put(&leaf).await.unwrap();
for _ in 0..9 {
let fan = version(
"flow.fabro",
[(
"flow.fabro",
"digraph Fan { a [stack.child_workflow=\"a/next.fabro\"] b [stack.child_workflow=\"b/next.fabro\"] }",
)],
[("a/next.fabro", previous), ("b/next.fabro", previous)],
);
previous = store.put(&fan).await.unwrap();
}
let closure = store.get_closure(&previous).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::MountLimitExceeded {
limit: MAX_WORKFLOW_MOUNTS,
}
));
}
#[tokio::test]
async fn rejects_distinct_versions_that_converge_on_one_mount_path() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let first_leaf = version(
"leaf/first.fabro",
[("leaf/first.fabro", "digraph FirstLeaf {}")],
[],
);
let first_leaf_id = store.put(&first_leaf).await.unwrap();
let second_leaf = version(
"leaf/second.fabro",
[("leaf/second.fabro", "digraph SecondLeaf {}")],
[],
);
let second_leaf_id = store.put(&second_leaf).await.unwrap();
let first_parent = version(
"a/first.fabro",
[(
"a/first.fabro",
"digraph FirstParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }",
)],
[("shared/collision.fabro", first_leaf_id)],
);
let first_parent_id = store.put(&first_parent).await.unwrap();
let second_parent = version(
"b/second.fabro",
[(
"b/second.fabro",
"digraph SecondParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }",
)],
[("shared/collision.fabro", second_leaf_id)],
);
let second_parent_id = store.put(&second_parent).await.unwrap();
let root = version(
"flows/root.fabro",
[(
"flows/root.fabro",
"digraph Root { first [stack.child_workflow=\"../left/first.fabro\"] second [stack.child_workflow=\"../right/second.fabro\"] }",
)],
[
("left/first.fabro", first_parent_id),
("right/second.fabro", second_parent_id),
],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::ConflictingMount { .. }
));
}
#[tokio::test]
async fn rejects_rebased_files_that_escape_the_runtime_root() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let child = version(
"nested/child.fabro",
[
("nested/child.fabro", "digraph Child {}"),
("workflow.toml", "_version = 1"),
],
[],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"root.fabro",
[(
"root.fabro",
"digraph Root { child [stack.child_workflow=\"child.fabro\"] }",
)],
[("child.fabro", child_id)],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::InvalidMount { .. }
));
}
}