From c0cd4f453130cdf4d5d47b5df8a02f1e2f99d909 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Mon, 14 Sep 2026 18:42:18 -0400 Subject: [PATCH 1/2] fix(planner): preserve unavailable all-target plans Keep --all dry runs inspectable when no providers are installed. Retain fail-closed preflight and deterministic regression coverage. Refs: #410 Roadmap-Step: REN-Q02 --- crates/renderflow-core/src/planning.rs | 156 +++++++++++++++++++++---- tests/cli_tests.rs | 52 +++++++++ 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index ede477e..b63ce7f 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -350,11 +350,11 @@ pub fn resolve(request: PlanningRequest) -> Result { 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 = @@ -363,31 +363,26 @@ pub fn resolve(request: PlanningRequest) -> Result { .reachable_from(source_format) .into_iter() .collect::>(); - 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 = targets.iter().map(|target| target.format).collect(); let optimization = spec.execution.optimization; let (dag, used_blocked_provider_fallback) = match available_graph @@ -419,7 +414,7 @@ pub fn resolve(request: PlanningRequest) -> Result { &spec, &policy_graph, source_format, - &targets, + &available_targets, &pruned_unavailable, &pruned_budget, )); @@ -436,9 +431,14 @@ pub fn resolve(request: PlanningRequest) -> Result { "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", ); } @@ -498,6 +498,40 @@ pub fn resolve(request: PlanningRequest) -> Result { }) } +/// 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, + available_formats: &HashSet, + all_reachable: bool, +) -> (Vec, Vec) { + 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, 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 { let started_at_unix_ms = unix_time_ms(); let predicted = resolved.predicted_output_paths()?; @@ -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::>(), + [Format::Html] + ); + assert_eq!( + unavailable + .iter() + .map(|target| target.format) + .collect::>(), + [Format::Pdf] + ); + assert_eq!( + planned + .iter() + .map(|target| target.format) + .collect::>(), + [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::>(), + [Format::Html, Format::Pdf] + ); + assert_eq!( + planned + .iter() + .map(|target| target.format) + .collect::>(), + [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"); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 858b067..1e092e7 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -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"); @@ -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] From 9a83038fd90f82c49ddf438497445c59a116abe1 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Mon, 14 Sep 2026 18:42:18 -0400 Subject: [PATCH 2/2] docs(cli): clarify builtin graph planning Document transform-free target discovery, unavailable dry-run branches, and fail-closed execution. Refs: #410 Roadmap-Step: REN-Q02 --- docs/cli-reference/build.md | 12 ++++++++---- docs/getting-started/cli-overview.md | 9 ++++++++- docs/index.md | 5 ++++- docs/user-guide/troubleshooting.md | 10 ++++++++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/cli-reference/build.md b/docs/cli-reference/build.md index 7810648..a4c6f4f 100644 --- a/docs/cli-reference/build.md +++ b/docs/cli-reference/build.md @@ -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 @@ -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 diff --git a/docs/getting-started/cli-overview.md b/docs/getting-started/cli-overview.md index 545e262..92ddce2 100644 --- a/docs/getting-started/cli-overview.md +++ b/docs/getting-started/cli-overview.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 7b84279..0decf23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md index cce9e73..0bb6577 100644 --- a/docs/user-guide/troubleshooting.md +++ b/docs/user-guide/troubleshooting.md @@ -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 ` to inspect the planned path.