Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 135 additions & 21 deletions crates/renderflow-core/src/planning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,11 +350,11 @@ pub fn resolve(request: PlanningRequest) -> Result<ResolvedExecution> {

register_builtin_strategy_edges(&mut graph, &mut tool_registry)?;
let policy_graph = apply_execution_policy(&graph, &tool_registry, &spec);
let mut targets = resolve_target_intent(&spec, &policy_graph, source_format)?;
if targets.is_empty() {
let requested_targets = resolve_target_intent(&spec, &policy_graph, source_format)?;
if requested_targets.is_empty() {
anyhow::bail!("target selection resolved to no executable artifact formats");
}
validate_publication_target_roles(&spec, &targets)?;
validate_publication_target_roles(&spec, &requested_targets)?;

let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids());
let available_graph =
Expand All @@ -363,31 +363,26 @@ pub fn resolve(request: PlanningRequest) -> Result<ResolvedExecution> {
.reachable_from(source_format)
.into_iter()
.collect::<HashSet<_>>();
let mut pruned_unavailable = Vec::new();
targets.retain(|target| {
let available = available_formats.contains(&target.format);
if !available
&& (spec.targets.all_reachable || target.requirement == TargetRequirement::Optional)
{
pruned_unavailable.push(target.clone());
false
} else {
true
}
});
let (mut available_targets, pruned_unavailable) = partition_targets_by_availability(
requested_targets,
&available_formats,
spec.targets.all_reachable,
);
let mut pruned_budget = Vec::new();
if let Some(max_artifacts) = spec.execution.budgets.max_artifacts {
let limit = usize::try_from(max_artifacts).unwrap_or(usize::MAX);
if targets.len() > limit {
pruned_budget.extend(targets[limit..].iter().cloned());
targets.truncate(limit);
if available_targets.len() > limit {
pruned_budget.extend(available_targets[limit..].iter().cloned());
available_targets.truncate(limit);
}
}
if targets.is_empty() {
if available_targets.is_empty() && pruned_unavailable.is_empty() {
anyhow::bail!(
"target selection resolved to no available branches; inspect the artifact-forest plan or relax provider/policy constraints"
);
}
let (targets, used_unavailable_only_fallback) =
planning_targets(&available_targets, &pruned_unavailable);
let target_formats: Vec<Format> = targets.iter().map(|target| target.format).collect();
let optimization = spec.execution.optimization;
let (dag, used_blocked_provider_fallback) = match available_graph
Expand Down Expand Up @@ -419,7 +414,7 @@ pub fn resolve(request: PlanningRequest) -> Result<ResolvedExecution> {
&spec,
&policy_graph,
source_format,
&targets,
&available_targets,
&pruned_unavailable,
&pruned_budget,
));
Expand All @@ -436,9 +431,14 @@ pub fn resolve(request: PlanningRequest) -> Result<ResolvedExecution> {
"v1 configuration normalized into renderflow/v2 before canonical planning",
);
}
if used_unavailable_only_fallback {
plan.add_tool_diagnostic(
"no provider-available branches were found; policy-allowed unavailable branches were retained so the plan remains inspectable, while execution stays blocked by provider preflight",
);
}
if used_blocked_provider_fallback {
plan.add_tool_diagnostic(
"one or more selected paths require providers unavailable on this host; dry-run remains inspectable but execution preflight will fail until dependencies are available",
"one or more planned paths require providers unavailable on this host; dry-run remains inspectable but execution preflight will fail until dependencies are available",
);
}

Expand Down Expand Up @@ -498,6 +498,40 @@ pub fn resolve(request: PlanningRequest) -> Result<ResolvedExecution> {
})
}

/// Separate provider-available targets from optional or all-reachable targets
/// whose registered paths cannot execute on the current host.
///
/// Required exact targets remain selected so the canonical blocked-provider
/// fallback and execution preflight can report their failure explicitly.
fn partition_targets_by_availability(
targets: Vec<ResolvedTarget>,
available_formats: &HashSet<Format>,
all_reachable: bool,
) -> (Vec<ResolvedTarget>, Vec<ResolvedTarget>) {
targets.into_iter().partition(|target| {
available_formats.contains(&target.format)
|| (!all_reachable && target.requirement == TargetRequirement::Required)
})
}

/// Keep an all-reachable plan inspectable when the current host provides none
/// of its policy-allowed branches.
///
/// The unavailable targets remain classified as unavailable in the artifact
/// forest. They are used here only to construct a truthful blocked plan; a
/// real execution still fails before any transform runs during provider
/// preflight.
fn planning_targets(
available: &[ResolvedTarget],
unavailable: &[ResolvedTarget],
) -> (Vec<ResolvedTarget>, bool) {
if available.is_empty() && !unavailable.is_empty() {
(unavailable.to_vec(), true)
} else {
(available.to_vec(), false)
}
}

pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result<CanonicalExecutionResult> {
let started_at_unix_ms = unix_time_ms();
let predicted = resolved.predicted_output_paths()?;
Expand Down Expand Up @@ -2583,6 +2617,86 @@ mod tests {
assert_eq!(targets[0].format, Format::Html);
}

#[test]
fn all_reachable_partition_preserves_mixed_availability_states() {
let requested = vec![
ResolvedTarget::generated(Format::Html),
ResolvedTarget::generated(Format::Pdf),
];
let available_formats = HashSet::from([Format::Html]);

let (available, unavailable) =
partition_targets_by_availability(requested, &available_formats, true);
let (planned, used_unavailable_only_fallback) =
planning_targets(&available, &unavailable);

assert_eq!(
available
.iter()
.map(|target| target.format)
.collect::<Vec<_>>(),
[Format::Html]
);
assert_eq!(
unavailable
.iter()
.map(|target| target.format)
.collect::<Vec<_>>(),
[Format::Pdf]
);
assert_eq!(
planned
.iter()
.map(|target| target.format)
.collect::<Vec<_>>(),
[Format::Html]
);
assert!(!used_unavailable_only_fallback);
}

#[test]
fn all_reachable_partition_retains_unavailable_only_plan_for_inspection() {
let requested = vec![
ResolvedTarget::generated(Format::Html),
ResolvedTarget::generated(Format::Pdf),
];

let (available, unavailable) =
partition_targets_by_availability(requested, &HashSet::new(), true);
let (planned, used_unavailable_only_fallback) =
planning_targets(&available, &unavailable);

assert!(available.is_empty());
assert_eq!(
unavailable
.iter()
.map(|target| target.format)
.collect::<Vec<_>>(),
[Format::Html, Format::Pdf]
);
assert_eq!(
planned
.iter()
.map(|target| target.format)
.collect::<Vec<_>>(),
[Format::Html, Format::Pdf]
);
assert!(used_unavailable_only_fallback);
}

#[test]
fn required_exact_target_keeps_blocked_provider_fallback() {
let mut target = ResolvedTarget::generated(Format::Html);
target.requirement = TargetRequirement::Required;

let (available, unavailable) =
partition_targets_by_availability(vec![target], &HashSet::new(), false);

assert_eq!(available.len(), 1);
assert_eq!(available[0].format, Format::Html);
assert!(unavailable.is_empty());
}

#[test]
fn policy_filters_denied_provider_before_pathfinding() {
let spec = minimal_spec("markdown");
Expand Down
12 changes: 8 additions & 4 deletions docs/cli-reference/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ renderflow build [--config FILE] [--dry-run] [--resume] [--optimization MODE] [-
| `--dry-run` | log intended actions without writing files or running commands |
| `--resume` | reuse only compatible, validated node checkpoints |
| `--optimization MODE` | override config optimization mode |
| `--target FORMAT` | graph-build one reachable target; requires `transforms` |
| `--target FORMAT` | graph-build one reachable target through registered capabilities |
| `--profile PROFILE` | build a named versioned profile; `everything` and `magazine` are bundled |
| `--all` | graph-build all reachable targets; requires `transforms` |
| `--all` | graph-build all policy-allowed reachable targets |

## Standard build behavior

Expand All @@ -36,14 +36,18 @@ With `--target` or `--all`, `main.rs` dispatches to `src/commands/graph_build.rs

That mode:

- loads `transforms:` from config,
- registers built-in capabilities and merges optional `transforms:` from config,
- constructs a `TransformGraph`,
- resolves targets by optimization mode,
- executes the merged DAG,
- writes every produced non-source format to `output_dir`.

!!! note
Graph build can work even when `outputs:` is omitted because it uses `load_config_for_graph` instead of full standard-build validation.
Graph build can work when `outputs:` and `transforms:` are omitted because
it uses `load_config_for_graph` and the built-in capability registry. A
clean-host dry run preserves unavailable branches for inspection without
claiming their providers are installed; real execution remains blocked by
provider preflight.

## Examples

Expand Down
9 changes: 8 additions & 1 deletion docs/getting-started/cli-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ Uses `outputs:` from `renderflow.yaml` and runs the built-in transform + render

### Graph build

`renderflow build --target pdf` or `renderflow build --all` requires a `transforms` YAML file and resolves reachable formats through the transform graph.
`renderflow build --target pdf` or `renderflow build --all` resolves reachable
formats through the built-in capability registry. A `transforms` YAML file is
optional and adds project-specific edges to that graph.

Planning keeps provider availability explicit. A dry run can retain a
policy-allowed branch as `unavailable` so it remains inspectable; execution
still fails preflight before running a transform when a required provider is
missing.

## Inspection commands

Expand Down
5 changes: 4 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ Renderflow is a spec-driven rendering engine for turning a single source documen
- **Fast rebuilds** use content hashes, dependency tracking, and watch mode.

!!! tip
Use standard `renderflow build` when you already know your output list, and use `renderflow build --target ...` or `renderflow build --all` when you want graph-based path resolution from a transform YAML file.
Use standard `renderflow build` when you already know your output list. Use
`renderflow build --target ...` or `renderflow build --all` when you want
graph-based path resolution through the built-in capability registry plus
any optional transform YAML file.

## Quick start in 30 seconds

Expand Down
10 changes: 8 additions & 2 deletions docs/user-guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,24 @@ Audio and image rendering depends on FFmpeg.

Standard builds require at least one `outputs[]` entry in `renderflow.yaml`.

If you intended to use graph mode, add a `transforms:` file and run one of:
If you intended to use graph mode, run one of:

```bash
renderflow build --target pdf
renderflow build --all
```

A `transforms:` file is only required for project-specific graph edges. The
built-in capability registry supports graph planning without one. On a clean
host, `--dry-run` reports policy-allowed branches as unavailable; install the
reported provider before attempting execution.

## Graph target is unreachable

If `--target` or `--all` fails:

- confirm the `transforms` file path is correct,
- confirm any configured `transforms` file path is correct,
- inspect provider availability with `renderflow doctor --strict`,
- ensure the source format and requested target are connected,
- run `renderflow graph explain --config renderflow.yaml`,
- run `renderflow inspect --target <format>` to inspect the planned path.
Expand Down
52 changes: 52 additions & 0 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,12 +506,14 @@ fn test_target_without_transforms_uses_builtin_capability_registry() {
#[test]
fn test_all_without_transforms_uses_builtin_capability_registry() {
let (f, _dir) = common::valid_config_file();
let empty_path = tempfile::tempdir().expect("failed to create empty PATH directory");
let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))
.arg("build")
.arg("--config")
.arg(f.path())
.arg("--all")
.arg("--dry-run")
.env("PATH", empty_path.path())
.output()
.expect("failed to execute renderflow");

Expand All @@ -526,6 +528,56 @@ fn test_all_without_transforms_uses_builtin_capability_registry() {
assert!(plan["targets"]
.as_array()
.is_some_and(|targets| !targets.is_empty()));
let branches = plan["artifact_forest"]["branches"]
.as_array()
.expect("all-reachable plan should include artifact-forest branches");
assert!(
branches
.iter()
.any(|branch| branch["state"] == "unavailable"),
"clean-host plan should preserve unavailable branches: {plan}"
);
assert!(
branches
.iter()
.all(|branch| branch["state"] != "selected"),
"clean-host plan must not report unavailable providers as selected: {plan}"
);
}

#[test]
fn test_all_without_available_providers_fails_closed_before_execution() {
let (config_file, dir) = common::valid_config_file();
let empty_path = tempfile::tempdir().expect("failed to create empty PATH directory");
let output = Command::new(env!("CARGO_BIN_EXE_renderflow"))
.arg("build")
.arg("--config")
.arg(config_file.path())
.arg("--all")
.env("PATH", empty_path.path())
.output()
.expect("failed to execute renderflow");

assert!(
!output.status.success(),
"--all must fail closed when every provider is unavailable"
);
let manifest_path = dir.path().join("dist/renderflow-run.json");
let manifest: serde_json::Value = serde_json::from_slice(
&std::fs::read(&manifest_path).expect("failed to read preflight failure manifest"),
)
.expect("preflight failure manifest should be valid JSON");
assert_eq!(manifest["state"], "failed");
assert_eq!(
manifest["artifact_manifest"]["outputs"],
serde_json::json!([])
);
assert_eq!(manifest["steps"], serde_json::json!([]));
assert!(manifest["diagnostics"]
.as_array()
.is_some_and(|diagnostics| diagnostics
.iter()
.any(|diagnostic| diagnostic["code"] == "execution.preflight_failed")));
}

#[test]
Expand Down
Loading