From bd70f805beec4d849369626b1341e4800c957c32 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 5 Apr 2026 20:07:10 -0400 Subject: [PATCH] refactor(server): submit runs and preflight via manifests --- Cargo.lock | 1 + docs/api-reference/fabro-api.yaml | 435 ++++++++- lib/crates/fabro-cli/src/args.rs | 8 +- lib/crates/fabro-cli/src/commands/graph.rs | 78 +- .../fabro-cli/src/commands/preflight.rs | 485 +--------- .../fabro-cli/src/commands/run/create.rs | 38 +- .../fabro-cli/src/commands/run/output.rs | 106 ++- lib/crates/fabro-cli/src/commands/validate.rs | 56 +- lib/crates/fabro-cli/src/main.rs | 5 +- lib/crates/fabro-cli/src/manifest_builder.rs | 644 ++++++++++++++ lib/crates/fabro-cli/src/server_client.rs | 59 +- lib/crates/fabro-cli/tests/it/cmd/graph.rs | 42 +- .../fabro-cli/tests/it/cmd/preflight.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/validate.rs | 14 +- lib/crates/fabro-server/Cargo.toml | 1 + lib/crates/fabro-server/src/demo/mod.rs | 2 +- lib/crates/fabro-server/src/lib.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 826 ++++++++++++++++++ lib/crates/fabro-server/src/serve.rs | 59 +- lib/crates/fabro-server/src/server.rs | 337 ++++--- lib/crates/fabro-server/tests/it/helpers.rs | 34 +- .../fabro-server/tests/it/scenario/dry_run.rs | 5 +- .../tests/it/scenario/lifecycle.rs | 8 +- .../fabro-server/tests/it/scenario/sse.rs | 9 +- .../fabro-workflow/src/file_resolver.rs | 148 ++++ .../src/handler/manager_loop.rs | 89 +- lib/crates/fabro-workflow/src/handler/mod.rs | 9 +- .../fabro-workflow/src/handler/parallel.rs | 4 + lib/crates/fabro-workflow/src/lib.rs | 2 + .../fabro-workflow/src/operations/create.rs | 92 +- .../fabro-workflow/src/operations/source.rs | 69 +- .../fabro-workflow/src/operations/start.rs | 15 + .../fabro-workflow/src/operations/validate.rs | 3 +- .../fabro-workflow/src/pipeline/execute.rs | 4 + .../src/pipeline/execute/tests.rs | 6 + .../fabro-workflow/src/pipeline/initialize.rs | 6 + .../fabro-workflow/src/pipeline/transform.rs | 30 +- .../fabro-workflow/src/pipeline/types.rs | 9 +- .../fabro-workflow/src/pipeline/validate.rs | 3 +- lib/crates/fabro-workflow/src/test_support.rs | 2 + .../src/transforms/file_inlining.rs | 121 +-- .../fabro-workflow/src/transforms/import.rs | 133 ++- .../fabro-workflow/src/workflow_bundle.rs | 96 ++ .../fabro-workflow/tests/it/integration.rs | 5 +- .../src/.openapi-generator/FILES | 21 +- .../fabro-api-client/src/api/runs-api.ts | 196 ++++- .../fabro-api-client/src/models/index.ts | 21 +- .../src/models/manifest-args.ts | 31 + .../src/models/manifest-config.ts | 30 + .../src/models/manifest-file-entry.ts | 27 + .../src/models/manifest-file-ref.ts | 31 + .../src/models/manifest-git.ts | 38 + .../src/models/manifest-goal.ts | 40 + .../src/models/manifest-target.ts | 27 + .../src/models/manifest-workflow-config.ts | 21 + .../src/models/manifest-workflow.ts | 28 + .../src/models/preflight-check-detail.ts | 21 + .../src/models/preflight-check-report.ts | 24 + .../src/models/preflight-check-result.ts | 36 + .../src/models/preflight-check-section.ts | 24 + .../src/models/preflight-response.ts | 31 + .../src/models/preflight-workflow-summary.ts | 28 + .../models/render-workflow-graph-direction.ts | 26 + .../models/render-workflow-graph-format.ts | 26 + .../models/render-workflow-graph-request.ts | 33 + .../src/models/run-manifest.ts | 58 ++ .../src/models/workflow-diagnostic.ts | 34 + 67 files changed, 3987 insertions(+), 969 deletions(-) create mode 100644 lib/crates/fabro-cli/src/manifest_builder.rs create mode 100644 lib/crates/fabro-server/src/run_manifest.rs create mode 100644 lib/crates/fabro-workflow/src/file_resolver.rs create mode 100644 lib/crates/fabro-workflow/src/workflow_bundle.rs create mode 100644 lib/packages/fabro-api-client/src/models/manifest-args.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-config.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-file-entry.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-file-ref.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-git.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-goal.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-target.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-workflow-config.ts create mode 100644 lib/packages/fabro-api-client/src/models/manifest-workflow.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-check-detail.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-check-report.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-check-result.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-check-section.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-response.ts create mode 100644 lib/packages/fabro-api-client/src/models/preflight-workflow-summary.ts create mode 100644 lib/packages/fabro-api-client/src/models/render-workflow-graph-direction.ts create mode 100644 lib/packages/fabro-api-client/src/models/render-workflow-graph-format.ts create mode 100644 lib/packages/fabro-api-client/src/models/render-workflow-graph-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-manifest.ts create mode 100644 lib/packages/fabro-api-client/src/models/workflow-diagnostic.ts diff --git a/Cargo.lock b/Cargo.lock index 38ee4be13..334bfad6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1871,6 +1871,7 @@ dependencies = [ "fabro-store", "fabro-types", "fabro-util", + "fabro-validate", "fabro-workflow", "futures-util", "hex", diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 5e5cbff62..81f78de36 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -141,13 +141,13 @@ paths: operationId: createRun tags: [Runs] summary: Create Run - description: Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution. + description: Creates a new workflow run in `submitted` status from a self-contained manifest. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateRunRequest" + $ref: "#/components/schemas/RunManifest" responses: "201": description: Run created @@ -162,6 +162,69 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/preflight: + post: + operationId: runPreflight + tags: [Runs] + summary: Validate Workflow Manifest + description: Validates a workflow manifest without creating a run. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunManifest" + responses: + "200": + description: Preflight report + content: + application/json: + schema: + $ref: "#/components/schemas/PreflightResponse" + "400": + description: Invalid manifest or workflow + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/graph/render: + post: + operationId: renderWorkflowGraph + tags: [Runs] + summary: Render Workflow Graph + description: Validates and renders a workflow manifest as SVG or PNG without creating a run. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenderWorkflowGraphRequest" + responses: + "200": + description: Rendered graph image + content: + image/svg+xml: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + "400": + description: Invalid manifest or workflow + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "502": + description: Graphviz rendering failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}: get: operationId: retrieveRun @@ -2393,34 +2456,360 @@ components: - cancelled - paused - CreateRunRequest: - description: Request body for creating a new run, either from inline Graphviz source or from a local workflow path plus resolved settings. + RunManifest: + description: Self-contained workflow run manifest. type: object + required: + - version + - cwd + - target + - workflows properties: - dot_source: - type: string - nullable: true - description: Graphviz DOT language source defining the workflow graph. - example: 'digraph { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }' - workflow_path: - type: string - nullable: true - description: Absolute or relative path to the workflow file to load on the local machine. - example: "/tmp/project/fabro/workflows/simple/workflow.fabro" - cwd: - type: string - nullable: true - description: Working directory used to resolve the workflow path. - example: "/tmp/project" - settings_json: - type: string - nullable: true - description: JSON-serialized `fabro_types::Settings` payload resolved by the CLI. + version: + type: integer + description: Manifest schema version. + example: 1 run_id: type: string nullable: true description: Optional pre-generated run ID to use instead of allocating a new ULID. example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9" + cwd: + type: string + description: CLI working directory at invocation time. + example: "/tmp/project" + git: + $ref: "#/components/schemas/ManifestGit" + goal: + $ref: "#/components/schemas/ManifestGoal" + args: + $ref: "#/components/schemas/ManifestArgs" + target: + $ref: "#/components/schemas/ManifestTarget" + configs: + type: array + items: + $ref: "#/components/schemas/ManifestConfig" + workflows: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestWorkflow" + + ManifestGit: + description: Observable git state from the CLI working directory. + type: object + required: + - origin_url + - branch + - sha + - clean + properties: + origin_url: + type: string + description: Remote origin URL with any embedded credentials removed. + example: "https://github.com/acme/my-app.git" + branch: + type: string + description: Current branch name. + example: feature/foo + sha: + type: string + description: Current commit SHA. + example: abc123def + clean: + type: boolean + description: Whether the working tree has uncommitted changes. + + ManifestGoal: + description: Resolved goal with provenance. + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - value + - file + - graph + text: + type: string + description: Resolved goal content. + path: + type: string + nullable: true + description: Original goal file path when the goal came from a file. + + ManifestArgs: + description: Sparse command-local args that affect run settings. + type: object + properties: + model: + type: string + provider: + type: string + sandbox: + type: string + verbose: + type: boolean + dry_run: + type: boolean + auto_approve: + type: boolean + no_retro: + type: boolean + preserve_sandbox: + type: boolean + label: + type: array + items: + type: string + + ManifestTarget: + type: object + required: + - identifier + - path + properties: + identifier: + type: string + description: What the user typed. + example: smoke + path: + type: string + description: Resolved path that keys into the workflows map. + example: fabro/workflows/smoke/workflow.fabro + + ManifestConfig: + type: object + required: + - type + properties: + type: + type: string + enum: + - project + - user + path: + type: string + nullable: true + source: + type: string + nullable: true + + ManifestWorkflowConfig: + type: object + required: + - path + - source + properties: + path: + type: string + source: + type: string + + ManifestFileEntry: + description: A bundled file with discovery metadata. + type: object + required: + - content + - ref + properties: + content: + type: string + ref: + $ref: "#/components/schemas/ManifestFileRef" + + ManifestFileRef: + type: object + required: + - type + - original + properties: + type: + type: string + enum: + - file_inline + - import + - dockerfile + original: + type: string + from: + type: string + nullable: true + + ManifestWorkflow: + type: object + required: + - source + properties: + source: + type: string + config: + $ref: "#/components/schemas/ManifestWorkflowConfig" + files: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestFileEntry" + + PreflightResponse: + type: object + required: + - ok + - workflow + - checks + properties: + ok: + type: boolean + description: Whether preflight passed using the CLI-compatible success rule. + workflow: + $ref: "#/components/schemas/PreflightWorkflowSummary" + checks: + $ref: "#/components/schemas/PreflightCheckReport" + + RenderWorkflowGraphRequest: + type: object + required: + - manifest + properties: + manifest: + $ref: "#/components/schemas/RunManifest" + format: + $ref: "#/components/schemas/RenderWorkflowGraphFormat" + direction: + $ref: "#/components/schemas/RenderWorkflowGraphDirection" + + RenderWorkflowGraphFormat: + type: string + enum: + - svg + - png + + RenderWorkflowGraphDirection: + type: string + enum: + - lr + - tb + + PreflightWorkflowSummary: + type: object + required: + - name + - nodes + - edges + - goal + - diagnostics + properties: + name: + type: string + graph_path: + type: string + nullable: true + nodes: + type: integer + edges: + type: integer + goal: + type: string + diagnostics: + type: array + items: + $ref: "#/components/schemas/WorkflowDiagnostic" + + WorkflowDiagnostic: + type: object + required: + - rule + - severity + - message + properties: + rule: + type: string + severity: + type: string + enum: + - error + - warning + - info + message: + type: string + node_id: + type: string + nullable: true + edge: + type: array + nullable: true + minItems: 2 + maxItems: 2 + items: + type: string + fix: + type: string + nullable: true + + PreflightCheckReport: + type: object + required: + - title + - sections + properties: + title: + type: string + sections: + type: array + items: + $ref: "#/components/schemas/PreflightCheckSection" + + PreflightCheckSection: + type: object + required: + - title + - checks + properties: + title: + type: string + checks: + type: array + items: + $ref: "#/components/schemas/PreflightCheckResult" + + PreflightCheckResult: + type: object + required: + - name + - status + - summary + - details + properties: + name: + type: string + status: + type: string + enum: + - pass + - warning + - error + summary: + type: string + details: + type: array + items: + $ref: "#/components/schemas/PreflightCheckDetail" + remediation: + type: string + nullable: true + + PreflightCheckDetail: + type: object + required: + - text + - warn + properties: + text: + type: string + warn: + type: boolean StartRunRequest: description: Request body for starting or resuming a run. diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index fe61acf9d..6fa3f671e 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -187,7 +187,7 @@ pub(crate) struct RunArgs { #[derive(Args)] pub(crate) struct PreflightArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) target: ServerConnectionArgs, /// Path to a .fabro workflow file or .toml task config pub(crate) workflow: PathBuf, @@ -290,6 +290,9 @@ pub(crate) struct LogsArgs { #[derive(Args)] pub(crate) struct ValidateArgs { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + /// Path to the .fabro workflow file pub(crate) workflow: PathBuf, } @@ -337,6 +340,9 @@ impl fmt::Display for GraphOutputFormat { #[derive(Args)] pub(crate) struct GraphArgs { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + /// Path to the .fabro workflow file, .toml task config, or project workflow name pub(crate) workflow: PathBuf, diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index e0fedb9a5..e210111c1 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -1,51 +1,59 @@ -use std::borrow::Cow; use std::io::Write; -use std::sync::LazyLock; use anyhow::bail; +use fabro_api::types; use fabro_config::ConfigLayer; -use fabro_config::project::resolve_workflow_path; -use fabro_graphviz::render::render_dot; use fabro_util::terminal::Styles; -use fabro_validate::Severity; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use tracing::debug; -use crate::args::{GlobalArgs, GraphArgs, GraphDirection}; -use crate::shared::{ - absolute_or_current, print_diagnostics, print_json_pretty, read_workflow_file, relative_path, -}; +use crate::args::{GlobalArgs, GraphArgs, GraphDirection, GraphOutputFormat}; +use crate::commands::run::output::api_diagnostics_to_local; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; +use crate::server_client; +use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path}; -static RANKDIR_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap()); - -pub(crate) fn run(args: &GraphArgs, styles: &Styles, globals: &GlobalArgs) -> anyhow::Result<()> { +pub(crate) async fn run( + args: &GraphArgs, + styles: &Styles, + globals: &GlobalArgs, +) -> anyhow::Result<()> { if globals.json && args.output.is_none() { globals.require_no_json()?; } let cwd = std::env::current_dir()?; - let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? - .combine(ConfigLayer::user()?) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings, + let built = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), cwd, - custom_transforms: Vec::new(), + args_layer: ConfigLayer::default(), + args: None, + run_id: None, })?; - let diagnostics = validated.diagnostics(); + let client = server_client::connect_server_backed(&args.target).await?; + let preflight = client.run_preflight(built.manifest.clone()).await?; + let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); - print_diagnostics(diagnostics, styles); - - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + print_diagnostics(&diagnostics, styles); + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } - let source = read_workflow_file(&resolution.dot_path)?; - let source = apply_direction(&source, args.direction); - let rendered = render_dot(&source, args.format.into())?; + let rendered = client + .render_workflow_graph(types::RenderWorkflowGraphRequest { + manifest: built.manifest, + format: Some(match args.format { + GraphOutputFormat::Svg => types::RenderWorkflowGraphFormat::Svg, + GraphOutputFormat::Png => types::RenderWorkflowGraphFormat::Png, + }), + direction: args.direction.map(|direction| match direction { + GraphDirection::Lr => types::RenderWorkflowGraphDirection::Lr, + GraphDirection::Tb => types::RenderWorkflowGraphDirection::Tb, + }), + }) + .await?; if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered)?; @@ -60,20 +68,10 @@ pub(crate) fn run(args: &GraphArgs, styles: &Styles, globals: &GlobalArgs) -> an } debug!( - path = %relative_path(&resolution.dot_path), + path = %relative_path(&built.target_path), format = %args.format, "Rendered workflow graph" ); Ok(()) } - -fn apply_direction(source: &str, direction: Option) -> Cow<'_, str> { - match direction { - Some(dir) => { - let replacement = format!("rankdir={dir}"); - RANKDIR_RE.replace(source, replacement.as_str()) - } - None => Cow::Borrowed(source), - } -} diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index bad04f673..ac939cba7 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,484 +1,57 @@ -use std::path::Path; -use std::sync::Arc; - use anyhow::bail; use fabro_config::ConfigLayer; -use fabro_config::project::{resolve_workflow_path, resolve_working_directory}; -use fabro_graphviz::graph::{Graph, is_llm_handler_type}; -use fabro_llm::client::Client as LlmClient; -use fabro_model::{Catalog, Provider}; -use fabro_sandbox::daytona::{DaytonaConfig, detect_repo_info}; -use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; -use fabro_types::Settings; -use fabro_util::check_report::CheckReport; use fabro_util::terminal::Styles; -use fabro_workflow::git::{GitSyncStatus, sync_status}; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use crate::args::{GlobalArgs, PreflightArgs}; -use crate::shared::github::build_github_app_credentials; +use crate::commands::run::output::{ + api_check_report_to_local, api_diagnostics_to_local, print_preflight_workflow_summary, +}; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args}; +use crate::server_client; use crate::shared::print_json_pretty; -use crate::user_config::{load_user_settings_with_storage_dir, user_layer_with_storage_dir}; +use crate::user_config::load_user_settings_with_storage_dir; pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli = user_layer_with_storage_dir(args.storage_dir.as_deref())?; - let cli_settings: Settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_user_settings_with_storage_dir(args.target.storage_dir())?; args.verbose = args.verbose || cli_settings.verbose_enabled(); - let github_app = build_github_app_credentials(cli_settings.app_id())?; - let cli_args_config = ConfigLayer::try_from(&args)?; let cwd = std::env::current_dir()?; - let settings = cli_args_config - .combine(ConfigLayer::for_workflow(&args.workflow, &cwd)?) - .combine(cli) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let working_directory = resolve_working_directory(&settings, &cwd); - - let (origin_url, detected_base_branch) = detect_repo_info(&working_directory) - .map(|(url, branch)| (Some(url), branch)) - .unwrap_or((None, None)); - let git_status = sync_status( - &working_directory, - "origin", - detected_base_branch.as_deref(), - ); - - let sandbox_provider = resolve_sandbox_provider(args.sandbox.map(Into::into), &settings)?; - - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings: settings.clone(), + let manifest = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), cwd, - custom_transforms: Vec::new(), + args_layer: ConfigLayer::try_from(&args)?, + args: preflight_manifest_args(&args), + run_id: None, })?; - if !globals.json { - super::run::output::print_workflow_report(&validated, Some(&resolution.dot_path), styles); - if validated.has_errors() { - bail!("Validation failed"); - } - } - - let (report, preflight_ok) = run_preflight( - validated.graph(), - &settings, - args.model.as_deref(), - args.provider.as_deref(), - git_status, - sandbox_provider, - &working_directory, - styles, - github_app, - origin_url.as_deref(), - !globals.json, - ) - .await?; + let client = server_client::connect_server_backed(&args.target).await?; + let response = client.run_preflight(manifest.manifest).await?; + let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); if globals.json { - print_json_pretty(&serde_json::json!({ - "workflow": { - "name": validated.graph().name, - "graph_path": resolution.dot_path, - "nodes": validated.graph().nodes.len(), - "edges": validated.graph().edges.len(), - "goal": validated.graph().goal(), - "diagnostics": validated.diagnostics(), - }, - "checks": report, - }))?; + print_json_pretty(&response)?; } else { + print_preflight_workflow_summary(&response.workflow, Some(&manifest.target_path), styles); + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { + bail!("Validation failed"); + } + let report = api_check_report_to_local(&response.checks); let term_width = console::Term::stderr().size().1; print!("{}", report.render(styles, true, None, Some(term_width))); } - if validated.has_errors() { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } - - if !preflight_ok { + if !response.ok { std::process::exit(1); } Ok(()) } - -fn resolve_model_provider( - cli_model: Option<&str>, - cli_provider: Option<&str>, - settings: &Settings, - graph: &Graph, -) -> (String, Option) { - let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); - let configured_provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()); - - let provider = cli_provider - .or(configured_provider) - .or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str())) - .map(String::from); - - let model = cli_model - .or(configured_model) - .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) - .map_or_else( - || { - let catalog = Catalog::builtin(); - let info = provider - .as_deref() - .and_then(|s| s.parse::().ok()) - .and_then(|p| catalog.default_for_provider(p)) - .unwrap_or_else(|| catalog.default_from_env()); - info.id.clone() - }, - String::from, - ); - - match Catalog::builtin().get(&model) { - Some(info) => ( - info.id.clone(), - provider.or(Some(info.provider.to_string())), - ), - None => (model, provider), - } -} - -fn parse_sandbox_provider(settings: &Settings) -> anyhow::Result> { - settings - .sandbox_settings() - .and_then(|s| s.provider.as_deref()) - .map(str::parse::) - .transpose() - .map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}")) -} - -fn resolve_sandbox_provider( - cli: Option, - settings: &Settings, -) -> anyhow::Result { - Ok(cli - .or(parse_sandbox_provider(settings)?) - .unwrap_or_default()) -} - -fn resolve_daytona_config(settings: &Settings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) -} - -async fn mint_github_token( - creds: &fabro_github::GitHubAppCredentials, - origin_url: &str, - permissions: &std::collections::HashMap, -) -> anyhow::Result { - let https_url = fabro_github::ssh_url_to_https(origin_url); - let (owner, repo) = - fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; - let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) - .map_err(|e| anyhow::anyhow!("{e}"))?; - let client = reqwest::Client::new(); - let perms_json = serde_json::to_value(permissions)?; - let token = fabro_github::create_installation_access_token_with_permissions( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - perms_json, - ) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(token) -} - -#[allow(clippy::too_many_arguments)] -async fn run_preflight( - graph: &Graph, - settings: &Settings, - cli_model: Option<&str>, - cli_provider: Option<&str>, - git_status: GitSyncStatus, - sandbox_provider: SandboxProvider, - working_directory: &Path, - styles: &'static Styles, - github_app: Option, - origin_url: Option<&str>, - show_progress: bool, -) -> anyhow::Result<(CheckReport, bool)> { - use fabro_util::check_report::{ - CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, - }; - - let spinner = show_progress.then(|| { - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .expect("valid template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), - ); - spinner.set_message("Running preflight checks..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); - spinner - }); - - let mut checks: Vec = Vec::new(); - - let setup_command_count = settings.setup_commands().len(); - let repo_summary = origin_url.map_or_else( - || "unknown".into(), - |url| { - let https = fabro_github::ssh_url_to_https(url); - fabro_github::parse_github_owner_repo(&https).map_or_else( - |_| url.to_string(), - |(owner, repo)| format!("{owner}/{repo}"), - ) - }, - ); - - checks.push(CheckResult { - name: "Repository".into(), - status: CheckStatus::Pass, - summary: repo_summary, - details: vec![ - CheckDetail::new(format!("Setup commands: {setup_command_count}")), - CheckDetail { - text: format!("Git: {git_status}"), - warn: git_status != GitSyncStatus::Synced, - }, - ], - remediation: None, - }); - - let (model, provider) = resolve_model_provider(cli_model, cli_provider, settings, graph); - checks.push(CheckResult { - name: "Workflow".into(), - status: CheckStatus::Pass, - summary: graph.name.clone(), - details: vec![ - CheckDetail::new(format!("Nodes: {}", graph.nodes.len())), - CheckDetail::new(format!("Edges: {}", graph.edges.len())), - CheckDetail::new(format!("Goal: {}", graph.goal())), - ], - remediation: None, - }); - - let daytona_config = resolve_daytona_config(settings); - - let sandbox_result: Result, String> = match sandbox_provider { - SandboxProvider::Local => SandboxSpec::Local { - working_directory: working_directory.to_path_buf(), - } - .build(None) - .await - .map_err(|e| e.to_string()), - SandboxProvider::Docker => SandboxSpec::Docker { - config: DockerSandboxOptions { - host_working_directory: working_directory.to_string_lossy().to_string(), - ..DockerSandboxOptions::default() - }, - } - .build(None) - .await - .map_err(|e| e.to_string()), - SandboxProvider::Daytona => SandboxSpec::Daytona { - config: daytona_config.unwrap_or_default(), - github_app: github_app.clone(), - run_id: None, - clone_branch: None, - } - .build(None) - .await - .map_err(|e| format!("Daytona sandbox creation failed: {e}")), - }; - - let sandbox_ok = match sandbox_result { - Ok(sandbox) => match sandbox.initialize().await { - Ok(()) => { - let _ = sandbox.cleanup().await; - true - } - Err(e) => { - let _ = sandbox.cleanup().await; - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(format!("Sandbox init failed: {e}")), - }); - false - } - }, - Err(e) => { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(e), - }); - false - } - }; - - if sandbox_ok { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Pass, - summary: sandbox_provider.to_string(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: None, - }); - } - - let default_provider = provider.as_deref().unwrap_or("anthropic"); - let llm_ok = match LlmClient::from_env().await { - Ok(c) => { - let configured: Vec = c - .provider_names() - .iter() - .map(std::string::ToString::to_string) - .collect(); - - let mut model_providers = std::collections::BTreeSet::new(); - for node in graph.nodes.values() { - if !is_llm_handler_type(node.handler_type()) { - continue; - } - let node_model = node.model().unwrap_or(&model); - let node_provider = node.provider().unwrap_or(default_provider); - - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(node_model) { - (info.id.clone(), info.provider.to_string()) - } else { - (node_model.to_string(), node_provider.to_string()) - }; - - let final_provider = if node.provider().is_some() { - node_provider.to_string() - } else { - resolved_provider - }; - - model_providers.insert((resolved_model, final_provider)); - } - - if model_providers.is_empty() { - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(&model) { - (info.id.clone(), info.provider.to_string()) - } else { - (model.clone(), default_provider.to_string()) - }; - model_providers.insert((resolved_model, resolved_provider)); - } - - let mut all_ok = true; - for (model_id, provider_name) in &model_providers { - match provider_name.parse::() { - Ok(_) => { - let mut status = CheckStatus::Pass; - if !configured.iter().any(|n| n == provider_name) { - status = CheckStatus::Warning; - all_ok = false; - } - checks.push(CheckResult { - name: "LLM".into(), - status, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: if status == CheckStatus::Warning { - Some(format!("Provider \"{provider_name}\" is not configured")) - } else { - None - }, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: Some(format!("Invalid provider \"{provider_name}\": {e}")), - }); - all_ok = false; - } - } - } - all_ok - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: "initialization failed".into(), - details: vec![], - remediation: Some(format!("LLM client init failed: {e}")), - }); - false - } - }; - - if let Some(github_permissions) = settings.github_permissions() { - if !github_permissions.is_empty() { - let perm_details: Vec = github_permissions - .iter() - .map(|(k, v)| CheckDetail::new(format!("{k}: {v}"))) - .collect(); - match (&github_app, origin_url) { - (Some(creds), Some(url)) => { - match mint_github_token(creds, url, github_permissions).await { - Ok(_) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Pass, - summary: "minted".into(), - details: perm_details, - remediation: None, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: perm_details, - remediation: Some(format!("Failed to mint GitHub token: {e}")), - }); - } - } - } - _ => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Warning, - summary: "skipped".into(), - details: vec![], - remediation: Some( - "No GitHub App credentials or origin URL available".to_string(), - ), - }); - } - } - } - } - - let report = CheckReport { - title: "Run Preflight".into(), - sections: vec![CheckSection { - title: String::new(), - checks, - }], - }; - if let Some(spinner) = spinner { - spinner.finish_and_clear(); - } - let _ = styles; - - Ok((report, sandbox_ok && llm_ok)) -} diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 341de3c86..e8fe8ac5d 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -4,9 +4,10 @@ use crate::args::RunArgs; use fabro_config::ConfigLayer; use fabro_types::{RunId, Settings}; use fabro_util::terminal::Styles; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, make_run_dir, validate}; +use fabro_workflow::operations::make_run_dir; -use super::output::print_workflow_report; +use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args}; use crate::server_client; /// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir). @@ -25,10 +26,10 @@ pub(crate) async fn create_run( let cli_args_config = ConfigLayer::try_from(args)?; let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let settings: Settings = cli_args_config + .clone() .combine(ConfigLayer::for_workflow(workflow_path, &cwd)?) .combine(cli_defaults) .resolve()?; - let run_id = args .run_id .as_deref() @@ -36,24 +37,27 @@ pub(crate) async fn create_run( .transpose() .map_err(|err| anyhow::anyhow!("invalid run ID: {err}"))?; + let built = build_run_manifest(ManifestBuildInput { + workflow: workflow_path.clone(), + cwd, + args_layer: cli_args_config, + args: run_manifest_args(args), + run_id, + })?; + + let client = server_client::connect_server(settings.storage_dir().as_path()).await?; if !quiet { - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(workflow_path.clone()), - settings: settings.clone(), - cwd: cwd.clone(), - custom_transforms: Vec::new(), - }); - if let Ok(validated) = validated { - if !validated.has_errors() { - print_workflow_report(&validated, Some(workflow_path.as_path()), styles); - } + let preflight = client.run_preflight(built.manifest.clone()).await?; + let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); + if !diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { + print_preflight_workflow_summary(&preflight.workflow, Some(&built.target_path), styles); } } - let client = server_client::connect_server(settings.storage_dir().as_path()).await?; - let created_run_id = client - .create_run_from_workflow_path(workflow_path, &cwd, &settings, run_id.as_ref()) - .await?; + let created_run_id = client.create_run_from_manifest(built.manifest).await?; let run_dir = make_run_dir(&settings.storage_dir().join("runs"), &created_run_id); Ok((created_run_id, run_dir)) diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index ad3eaccbf..ab04a0c55 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -2,58 +2,124 @@ use std::path::Path; use std::time::Duration; use anyhow::Result; -use fabro_graphviz::graph::Graph; +use fabro_api::types; use fabro_store::RuntimeState; use fabro_types::PullRequestRecord; +use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_util::terminal::Styles; use fabro_util::text::strip_goal_decoration; use fabro_workflow::artifact_snapshot::collect_artifact_paths; use fabro_workflow::outcome::{StageStatus, format_cost}; -use fabro_workflow::pipeline::Validated; use fabro_workflow::records::Conclusion; use indicatif::HumanDuration; use crate::server_client; use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path}; -fn print_workflow_header( - graph: &Graph, - diagnostics: &[fabro_validate::Diagnostic], - dot_path: Option<&Path>, +pub(crate) fn print_preflight_workflow_summary( + workflow: &types::PreflightWorkflowSummary, + graph_path_override: Option<&Path>, styles: &Styles, ) { + let graph_path = graph_path_override + .map(relative_path) + .or_else(|| { + workflow.graph_path.as_deref().map(|path| { + let path = Path::new(path); + if path.is_absolute() { + relative_path(path) + } else { + path.display().to_string() + } + }) + }) + .unwrap_or_else(|| "".to_string()); + let diagnostics = workflow + .diagnostics + .iter() + .map(api_diagnostic_to_local) + .collect::>(); + eprintln!( "{} {} {}", styles.bold.apply_to("Workflow:"), - graph.name, + workflow.name, styles.dim.apply_to(format!( "({} nodes, {} edges)", - graph.nodes.len(), - graph.edges.len() + workflow.nodes, workflow.edges )), ); - let graph_path = dot_path.map_or_else(|| "".to_string(), relative_path); eprintln!( "{} {}", styles.dim.apply_to("Graph:"), styles.dim.apply_to(graph_path), ); - let goal = graph.goal(); - if !goal.is_empty() { - let stripped = strip_goal_decoration(goal); + if !workflow.goal.is_empty() { + let stripped = strip_goal_decoration(&workflow.goal); eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:")); } - print_diagnostics(diagnostics, styles); + print_diagnostics(&diagnostics, styles); } -pub(crate) fn print_workflow_report( - validated: &Validated, - dot_path: Option<&Path>, - styles: &Styles, -) { - print_workflow_header(validated.graph(), validated.diagnostics(), dot_path, styles); +fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic { + fabro_validate::Diagnostic { + rule: diagnostic.rule.clone(), + severity: match diagnostic.severity { + types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error, + types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning, + types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info, + }, + message: diagnostic.message.clone(), + node_id: diagnostic.node_id.clone(), + edge: diagnostic + .edge + .as_ref() + .map(|edge| (edge[0].clone(), edge[1].clone())), + fix: diagnostic.fix.clone(), + } +} + +pub(crate) fn api_diagnostics_to_local( + diagnostics: &[types::WorkflowDiagnostic], +) -> Vec { + diagnostics.iter().map(api_diagnostic_to_local).collect() +} + +pub(crate) fn api_check_report_to_local(report: &types::PreflightCheckReport) -> CheckReport { + CheckReport { + title: report.title.clone(), + sections: report + .sections + .iter() + .map(|section| CheckSection { + title: section.title.clone(), + checks: section + .checks + .iter() + .map(|check| CheckResult { + name: check.name.clone(), + status: match check.status { + types::PreflightCheckResultStatus::Pass => CheckStatus::Pass, + types::PreflightCheckResultStatus::Warning => CheckStatus::Warning, + types::PreflightCheckResultStatus::Error => CheckStatus::Error, + }, + summary: check.summary.clone(), + details: check + .details + .iter() + .map(|detail| CheckDetail { + text: detail.text.clone(), + warn: detail.warn, + }) + .collect(), + remediation: check.remediation.clone(), + }) + .collect(), + }) + .collect(), + } } pub(crate) async fn print_run_summary( diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 08aa26211..df3fb773b 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,42 +1,43 @@ use anyhow::bail; use fabro_config::ConfigLayer; -use fabro_config::project::resolve_workflow_path; use fabro_util::terminal::Styles; -use fabro_validate::Severity; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use crate::args::{GlobalArgs, ValidateArgs}; +use crate::commands::run::output::api_diagnostics_to_local; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; +use crate::server_client; use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; -pub(crate) fn run( +pub(crate) async fn run( args: &ValidateArgs, styles: &Styles, globals: &GlobalArgs, ) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; - let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? - .combine(ConfigLayer::user()?) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings, + let built = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), cwd, - custom_transforms: Vec::new(), + args_layer: ConfigLayer::default(), + args: None, + run_id: None, })?; - let graph = validated.graph(); - let diagnostics = validated.diagnostics(); + let client = server_client::connect_server_backed(&args.target).await?; + let response = client.run_preflight(built.manifest).await?; + let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); if globals.json { print_json_pretty(&serde_json::json!({ - "workflow_name": graph.name, - "nodes": graph.nodes.len(), - "edges": graph.edges.len(), - "valid": !diagnostics.iter().any(|d| d.severity == Severity::Error), + "workflow_name": response.workflow.name, + "nodes": response.workflow.nodes, + "edges": response.workflow.edges, + "valid": !diagnostics.iter().any(|d| d.severity == fabro_validate::Severity::Error), "diagnostics": diagnostics, }))?; - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } return Ok(()); @@ -44,19 +45,24 @@ pub(crate) fn run( eprintln!( "{} ({} nodes, {} edges)", - styles.bold.apply_to(format!("Workflow: {}", graph.name)), - graph.nodes.len(), - graph.edges.len(), + styles + .bold + .apply_to(format!("Workflow: {}", response.workflow.name)), + response.workflow.nodes, + response.workflow.edges, ); eprintln!( "{} {}", styles.dim.apply_to("Graph:"), - styles.dim.apply_to(relative_path(&resolution.dot_path)), + styles.dim.apply_to(relative_path(&built.target_path)), ); - print_diagnostics(diagnostics, styles); + print_diagnostics(&diagnostics, styles); - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index a60696e3f..3561eac6c 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -3,6 +3,7 @@ mod args; mod commands; mod logging; +mod manifest_builder; mod server_client; mod server_runs; mod shared; @@ -159,11 +160,11 @@ async fn main_inner() -> (String, Result<()>) { Commands::Preflight(args) => commands::preflight::execute(args, &globals).await?, Commands::Validate(args) => { let styles = Styles::detect_stderr(); - commands::validate::run(&args, &styles, &globals)?; + commands::validate::run(&args, &styles, &globals).await?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - commands::graph::run(&args, &styles, &globals)?; + commands::graph::run(&args, &styles, &globals).await?; } Commands::Parse(args) => { commands::parse::run(&args, &globals)?; diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs new file mode 100644 index 000000000..1233df83a --- /dev/null +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -0,0 +1,644 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use fabro_api::types; +use fabro_config::ConfigLayer; +use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; +use fabro_config::run::parse_run_config; +use fabro_config::sandbox::DockerfileSource; +use fabro_config::user::default_user_config_path; +use fabro_graphviz::graph::AttrValue; +use fabro_graphviz::parser; +use fabro_sandbox::daytona::detect_repo_info; +use fabro_types::{RunId, Settings}; +use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; + +use crate::args::{PreflightArgs, RunArgs}; + +#[derive(Debug)] +pub(crate) struct ManifestBuildInput { + pub workflow: PathBuf, + pub cwd: PathBuf, + pub args_layer: ConfigLayer, + pub args: Option, + pub run_id: Option, +} + +#[derive(Debug)] +pub(crate) struct BuiltManifest { + pub manifest: types::RunManifest, + pub target_path: PathBuf, +} + +struct CollectContext<'a> { + cwd: &'a Path, + workflows: HashMap, + visited_workflows: HashSet, +} + +#[derive(Clone)] +struct WorkflowScanInput { + absolute_dot_path: PathBuf, + logical_dot_path: PathBuf, + source: String, +} + +pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { + let user_layer = ConfigLayer::user()?; + let merged_settings = input + .args_layer + .clone() + .combine(ConfigLayer::for_workflow(&input.workflow, &input.cwd)?) + .combine(user_layer.clone()) + .resolve()?; + + let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; + 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 mut context = CollectContext { + cwd: &input.cwd, + workflows: HashMap::new(), + visited_workflows: HashSet::new(), + }; + collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?; + + let root_source = context + .workflows + .get(&target_logical_path_string) + .map(|workflow| workflow.source.clone()) + .ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?; + + let mut configs = Vec::new(); + if let Some((path, _config)) = discover_project_config( + root_resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::Project, + }); + } + if let Some(path) = default_user_config_path().filter(|path| path.is_file()) { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::User, + }); + } + + let goal = resolve_manifest_goal( + &input.args_layer, + &merged_settings, + &root_source, + &target_path, + &input.cwd, + )?; + + let git = build_manifest_git(&input.cwd); + let args = input.args.filter(|args| !manifest_args_is_empty(args)); + + Ok(BuiltManifest { + manifest: types::RunManifest { + args, + configs, + cwd: input.cwd.display().to_string(), + git, + goal, + 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, + }, + version: 1, + workflows: context.workflows, + }, + target_path, + }) +} + +pub(crate) fn run_manifest_args(args: &RunArgs) -> Option { + let payload = types::ManifestArgs { + auto_approve: args.auto_approve.then_some(true), + dry_run: args.dry_run.then_some(true), + label: args.label.clone(), + model: args.model.clone(), + no_retro: args.no_retro.then_some(true), + preserve_sandbox: args.preserve_sandbox.then_some(true), + provider: args.provider.clone(), + sandbox: args + .sandbox + .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), + verbose: args.verbose.then_some(true), + }; + (!manifest_args_is_empty(&payload)).then_some(payload) +} + +pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option { + let payload = types::ManifestArgs { + auto_approve: None, + dry_run: None, + label: Vec::new(), + model: args.model.clone(), + no_retro: None, + preserve_sandbox: None, + provider: args.provider.clone(), + sandbox: args + .sandbox + .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), + verbose: args.verbose.then_some(true), + }; + (!manifest_args_is_empty(&payload)).then_some(payload) +} + +fn collect_workflow_entry( + context: &mut CollectContext<'_>, + workflow: &Path, + resolve_from: &Path, +) -> Result<()> { + let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() { + normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| { + anyhow!( + "unsupported manifest workflow reference: {}", + workflow.display() + ) + })? + } else { + workflow.to_path_buf() + }; + let 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()) { + return Ok(()); + } + + let source = std::fs::read_to_string(&resolution.dot_path) + .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)?), + source: std::fs::read_to_string(workflow_toml_path) + .with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?, + }) + } else { + None + }; + + let scan = WorkflowScanInput { + absolute_dot_path: resolution.dot_path, + logical_dot_path, + source: source.clone(), + }; + let mut files = HashMap::new(); + let mut visited_imports = HashSet::new(); + if let Some(config) = config.as_ref() { + collect_workflow_config_files(context, config, &mut files)?; + } + collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?; + + context.workflows.insert( + logical_dot_key, + types::ManifestWorkflow { + config, + files, + source, + }, + ); + + Ok(()) +} + +fn collect_workflow_files( + context: &mut CollectContext<'_>, + workflow: &WorkflowScanInput, + files: &mut HashMap, + visited_imports: &mut HashSet, +) -> Result<()> { + let graph = parser::parse(&workflow.source).map_err(|err| { + anyhow!( + "Failed to parse {}: {err}", + workflow.absolute_dot_path.display() + ) + })?; + + if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) { + if goal_ref.starts_with('@') { + collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + goal_ref.trim_start_matches('@'), + types::ManifestFileRefType::FileInline, + Some(workflow.logical_dot_path.clone()), + )?; + } + } + + for node in graph.nodes.values() { + if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) { + if prompt_ref.starts_with('@') { + collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + prompt_ref.trim_start_matches('@'), + types::ManifestFileRefType::FileInline, + Some(workflow.logical_dot_path.clone()), + )?; + } + } + + if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) { + let imported = collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + import_ref, + types::ManifestFileRefType::Import, + Some(workflow.logical_dot_path.clone()), + )?; + let import_key = logical_path_string(&imported.logical_path); + if visited_imports.insert(import_key) { + let imported_source = std::fs::read_to_string(&imported.absolute_path) + .with_context(|| { + format!("Failed to read {}", imported.absolute_path.display()) + })?; + let imported_scan = WorkflowScanInput { + absolute_dot_path: imported.absolute_path, + logical_dot_path: imported.logical_path, + source: imported_source, + }; + collect_workflow_files(context, &imported_scan, files, visited_imports)?; + } + } + + if let Some(child_ref) = node + .attrs + .get("stack.child_workflow") + .or_else(|| node.attrs.get("stack.child_dotfile")) + .and_then(AttrValue::as_str) + { + collect_workflow_entry( + context, + Path::new(child_ref), + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )?; + } + } + + Ok(()) +} + +fn collect_workflow_config_files( + context: &CollectContext<'_>, + config: &types::ManifestWorkflowConfig, + files: &mut HashMap, +) -> Result<()> { + let config_layer = parse_run_config(&config.source)?; + let dockerfile = config_layer + .sandbox + .as_ref() + .and_then(|sandbox| sandbox.daytona.as_ref()) + .and_then(|daytona| daytona.snapshot.as_ref()) + .and_then(|snapshot| snapshot.dockerfile.as_ref()); + + let Some(DockerfileSource::Path { path }) = dockerfile else { + return Ok(()); + }; + + let config_path = context.cwd.join(&config.path); + collect_bundled_file( + files, + config_path.parent().unwrap_or_else(|| Path::new(".")), + context.cwd, + path, + types::ManifestFileRefType::Dockerfile, + Some(PathBuf::from(&config.path)), + )?; + Ok(()) +} + +struct BundledFile { + absolute_path: PathBuf, + logical_path: PathBuf, +} + +fn collect_bundled_file( + files: &mut HashMap, + base_dir: &Path, + cwd: &Path, + reference: &str, + ref_type: types::ManifestFileRefType, + from: Option, +) -> Result { + 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); + 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)), + original: reference.to_string(), + type_: ref_type, + }, + }, + ); + } + + Ok(BundledFile { + absolute_path, + logical_path, + }) +} + +fn resolve_manifest_goal( + args_layer: &ConfigLayer, + settings: &Settings, + root_source: &str, + root_dot_path: &Path, + cwd: &Path, +) -> Result> { + let working_directory = project::resolve_working_directory(settings, cwd); + + if let Some(goal) = args_layer.goal.as_ref() { + return Ok(Some(types::ManifestGoal { + path: None, + text: goal.clone(), + type_: types::ManifestGoalType::Value, + })); + } + if let Some(goal_file) = args_layer.goal_file.as_ref() { + return Ok(Some(types::ManifestGoal { + path: Some(goal_file.display().to_string()), + text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory)) + .with_context(|| format!("Failed to read {}", goal_file.display()))?, + type_: types::ManifestGoalType::File, + })); + } + if let Some(goal) = settings.goal.as_ref() { + return Ok(Some(types::ManifestGoal { + path: None, + text: goal.clone(), + type_: types::ManifestGoalType::Value, + })); + } + if let Some(goal_file) = settings.goal_file.as_ref() { + return Ok(Some(types::ManifestGoal { + path: Some(goal_file.display().to_string()), + text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory)) + .with_context(|| format!("Failed to read {}", goal_file.display()))?, + type_: types::ManifestGoalType::File, + })); + } + + let graph = parser::parse(root_source) + .map_err(|err| anyhow!("Failed to parse {}: {err}", root_dot_path.display()))?; + let Some(goal) = graph.attrs.get("goal").and_then(AttrValue::as_str) else { + return Ok(None); + }; + if let Some(reference) = goal.strip_prefix('@') { + let goal_path = normalize_absolute_path( + root_dot_path.parent().unwrap_or_else(|| Path::new(".")), + reference, + ) + .ok_or_else(|| anyhow!("unsupported manifest goal reference: {reference}"))?; + return Ok(Some(types::ManifestGoal { + path: Some(reference.to_string()), + text: std::fs::read_to_string(&goal_path) + .with_context(|| format!("Failed to read {}", goal_path.display()))?, + type_: types::ManifestGoalType::Graph, + })); + } + + Ok(Some(types::ManifestGoal { + path: None, + text: goal.to_string(), + type_: types::ManifestGoalType::Graph, + })) +} + +fn resolve_goal_file_path(goal_file: &Path, working_directory: &Path) -> PathBuf { + if goal_file.is_absolute() { + goal_file.to_path_buf() + } else { + working_directory.join(goal_file) + } +} + +fn build_manifest_git(cwd: &Path) -> Option { + let (origin_url, branch) = detect_repo_info(cwd).ok()?; + let branch = branch?; + let sha = head_sha(cwd).ok()?; + let clean = sync_status(cwd, "origin", Some(&branch)) != GitSyncStatus::Dirty; + Some(types::ManifestGit { + branch, + clean, + origin_url: sanitize_origin_url(&origin_url), + sha, + }) +} + +fn sanitize_origin_url(origin_url: &str) -> String { + if let Some(at_pos) = origin_url.find('@') { + return format!("https://***@{}", &origin_url[at_pos + 1..]); + } + origin_url.to_string() +} + +fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option { + let path = Path::new(reference); + if path.is_absolute() || reference.starts_with('~') { + return None; + } + + let mut normalized = PathBuf::new(); + for component in base_dir.join(path).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir => normalized.push(Path::new("/")), + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + } + } + Some(normalized) +} + +fn to_logical_path(path: &Path, cwd: &Path) -> Result { + 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 { + let path_components = path.components().collect::>(); + let base_components = base.components().collect::>(); + 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_args_is_empty(args: &types::ManifestArgs) -> bool { + args.auto_approve.is_none() + && args.dry_run.is_none() + && args.label.is_empty() + && args.model.is_none() + && args.no_retro.is_none() + && args.preserve_sandbox.is_none() + && args.provider.is_none() + && args.sandbox.is_none() + && args.verbose.is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_manifest_bundles_imports_prompts_and_children() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join("fabro/workflows/demo"); + let child_dir = project.join("fabro/workflows/child"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + std::fs::create_dir_all(workflow_dir.join("imports")).unwrap(); + std::fs::create_dir_all(&child_dir).unwrap(); + std::fs::write(project.join("fabro.toml"), "version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "version = 1\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r#"digraph Demo { + graph [goal="@prompts/goal.md"] + start [shape=Mdiamond] + exit [shape=Msquare] + plan [prompt="@prompts/plan.md"] + imported [import="./imports/checks.fabro"] + child [shape=house, stack.child_workflow="../child/workflow.fabro"] + start -> plan -> imported -> child -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/goal.md"), "ship it").unwrap(); + std::fs::write(workflow_dir.join("prompts/plan.md"), "plan it").unwrap(); + std::fs::write( + workflow_dir.join("imports/checks.fabro"), + r#"digraph Checks { + start [shape=Mdiamond] + exit [shape=Msquare] + lint [prompt="@../prompts/lint.md"] + start -> lint -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/lint.md"), "lint it").unwrap(); + std::fs::write( + child_dir.join("workflow.fabro"), + r"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: ConfigLayer::default(), + args: None, + run_id: None, + }) + .unwrap(); + + assert_eq!( + built.manifest.target.path, + "fabro/workflows/demo/workflow.fabro" + ); + assert_eq!(built.manifest.workflows.len(), 2); + let root = &built.manifest.workflows["fabro/workflows/demo/workflow.fabro"]; + assert!( + root.files + .contains_key("fabro/workflows/demo/prompts/goal.md") + ); + assert!( + root.files + .contains_key("fabro/workflows/demo/prompts/plan.md") + ); + assert!( + root.files + .contains_key("fabro/workflows/demo/imports/checks.fabro") + ); + assert!( + root.files + .contains_key("fabro/workflows/demo/prompts/lint.md") + ); + assert_eq!(built.manifest.goal.unwrap().text, "ship it"); + assert!( + built + .manifest + .workflows + .contains_key("fabro/workflows/child/workflow.fabro") + ); + } +} diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 605c42224..8ac65b880 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -9,8 +9,9 @@ use fabro_server::bind::Bind; use fabro_store::{EventEnvelope, RunSummary, StageId}; use fabro_types::{ Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunEvent, RunId, RunRecord, - RunStatusRecord, SandboxRecord, Settings, StartRecord, + RunStatusRecord, SandboxRecord, StartRecord, }; +use futures::StreamExt; use serde::de::DeserializeOwned; use tokio::time::sleep; @@ -105,6 +106,14 @@ pub(crate) async fn connect_server(storage_dir: &Path) -> Result Result { + Ok(ServerStoreClient { + client: connect_server_backed_api_client(args).await?, + }) +} + pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result { let bind = start::ensure_server_running(storage_dir) .with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?; @@ -185,23 +194,14 @@ async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> { } impl ServerStoreClient { - pub(crate) async fn create_run_from_workflow_path( + pub(crate) async fn create_run_from_manifest( &self, - workflow_path: &Path, - cwd: &Path, - settings: &Settings, - run_id: Option<&RunId>, + manifest: types::RunManifest, ) -> Result { let response = self .client .create_run() - .body(types::CreateRunRequest { - dot_source: None, - workflow_path: Some(workflow_path.display().to_string()), - cwd: Some(cwd.display().to_string()), - settings_json: Some(serde_json::to_string(settings)?), - run_id: run_id.map(ToString::to_string), - }) + .body(manifest) .send() .await .map_err(map_api_error)?; @@ -212,6 +212,39 @@ impl ServerStoreClient { .map_err(|err| anyhow!("invalid run ID from server: {err}")) } + pub(crate) async fn run_preflight( + &self, + manifest: types::RunManifest, + ) -> Result { + self.client + .run_preflight() + .body(manifest) + .send() + .await + .map(progenitor_client::ResponseValue::into_inner) + .map_err(map_api_error) + } + + pub(crate) async fn render_workflow_graph( + &self, + request: types::RenderWorkflowGraphRequest, + ) -> Result> { + let response = self + .client + .render_workflow_graph() + .body(request) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + pub(crate) async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<()> { self.client .start_run() diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index eced85a3c..caf51ba8b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -20,25 +20,45 @@ fn help() { Path to the .fabro workflow file, .toml task config, or project workflow name Options: - --format - Output format - - [default: svg] - [possible values: svg, png] - --json Output as JSON [env: FABRO_JSON=] + --storage-dir + Local storage directory (default: ~/.fabro) + + [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server + Fabro server target: http(s) URL or absolute Unix socket path + + [env: FABRO_SERVER=] + + --format + Output format + + [default: svg] + [possible values: svg, png] + + --no-upgrade-check + Disable automatic upgrade check + + [env: FABRO_NO_UPGRADE_CHECK=true] + -o, --output Output file path (defaults to stdout) + --quiet + Suppress non-essential output + + [env: FABRO_QUIET=] + -d, --direction Graph layout direction (overrides the DOT file's rankdir) @@ -46,16 +66,6 @@ fn help() { - lr: Left to right - tb: Top to bottom - --no-upgrade-check - Disable automatic upgrade check - - [env: FABRO_NO_UPGRADE_CHECK=true] - - --quiet - Suppress non-essential output - - [env: FABRO_QUIET=] - --verbose Enable verbose output diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs index a4d8f85d7..e615a1872 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs @@ -23,11 +23,12 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --goal Override the workflow goal (exposed as $goal in prompts) - --goal-file Read the workflow goal from a file --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --model Override default LLM model + --goal-file Read the workflow goal from a file --quiet Suppress non-essential output [env: FABRO_QUIET=] + --model Override default LLM model --provider Override default LLM provider -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] diff --git a/lib/crates/fabro-cli/tests/it/cmd/validate.rs b/lib/crates/fabro-cli/tests/it/cmd/validate.rs index fecd63ab9..ef2ca2f72 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/validate.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/validate.rs @@ -24,12 +24,14 @@ fn help() { Path to the .fabro workflow file Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 5f3d259e8..3d9f9f6bb 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -18,6 +18,7 @@ fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-interview = { path = "../fabro-interview" } fabro-workflow = { path = "../fabro-workflow" } +fabro-validate = { path = "../fabro-validate" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } fabro-github = { path = "../fabro-github" } fabro-agent = { path = "../fabro-agent" } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index ae678e15d..8dca9f722 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -240,7 +240,7 @@ pub(crate) async fn get_run_graph( // Use graphviz to render the demo DOT source let dot_source = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}"; - crate::server::render_dot_svg(dot_source).await + crate::server::render_graph_bytes(dot_source, fabro_graphviz::render::GraphFormat::Svg).await } pub(crate) async fn get_run_retro( diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index ed03f24a9..a5abd85ce 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod diagnostics; pub mod error; pub mod github_webhooks; pub mod jwt_auth; +mod run_manifest; pub mod secret_store; pub mod serve; pub mod server; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs new file mode 100644 index 000000000..2988d0cb5 --- /dev/null +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -0,0 +1,826 @@ +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Result, anyhow, bail}; +use fabro_api::types; +use fabro_config::ConfigLayer; +use fabro_config::project::resolve_working_directory; +use fabro_config::run::{LlmConfig, parse_run_config}; +use fabro_config::sandbox::{DockerfileSource, SandboxConfig}; +use fabro_graphviz::graph::{Graph, is_llm_handler_type}; +use fabro_graphviz::render::apply_direction; +use fabro_llm::Provider; +use fabro_model::Catalog; +use fabro_sandbox::daytona::DaytonaConfig; +use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; +use fabro_types::{RunId, Settings}; +use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; +use fabro_validate::Severity; +use fabro_workflow::error::FabroError; +use fabro_workflow::operations::{CreateRunInput, ValidateInput, WorkflowInput, validate}; +use fabro_workflow::pipeline::Validated; +use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle}; + +use crate::server::AppState; + +#[derive(Clone)] +pub(crate) struct PreparedManifest { + pub cwd: PathBuf, + pub git: Option, + pub root_source: String, + pub run_id: Option, + pub settings: Settings, + pub target_path: PathBuf, + pub workflow_bundle: WorkflowBundle, + pub workflow_input: BundledWorkflow, + pub working_directory: PathBuf, +} + +pub(crate) fn prepare_manifest( + server_settings: &Settings, + manifest: &types::RunManifest, +) -> Result { + if manifest.version != 1 { + bail!("unsupported manifest version {}", manifest.version); + } + + let cwd = PathBuf::from(&manifest.cwd); + let target_path = PathBuf::from(&manifest.target.path); + let workflow_bundle = workflow_bundle_from_manifest(&manifest.workflows)?; + let workflow_input = workflow_bundle + .workflow(&target_path) + .cloned() + .ok_or_else(|| anyhow!("manifest target path is missing from workflows map"))?; + let root_source = workflow_input.source.clone(); + + let args_layer = manifest_args_layer(manifest.args.as_ref()); + let workflow_layer = root_workflow_config_layer(manifest, &workflow_input)?; + let project_layer = manifest + .configs + .iter() + .filter(|config| config.type_ == types::ManifestConfigType::Project) + .try_fold(ConfigLayer::default(), |layer, config| { + Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) + })?; + let user_layer = manifest + .configs + .iter() + .filter(|config| config.type_ == types::ManifestConfigType::User) + .try_fold(ConfigLayer::default(), |layer, config| { + Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) + })?; + let server_defaults = server_defaults_layer(server_settings)?; + + let mut settings = args_layer + .combine(workflow_layer) + .combine(project_layer) + .combine(user_layer) + .combine(server_defaults) + .resolve()?; + settings + .storage_dir + .clone_from(&server_settings.storage_dir); + if let Some(goal) = manifest.goal.as_ref() { + settings.goal = Some(goal.text.clone()); + settings.goal_file = None; + } + + Ok(PreparedManifest { + cwd: cwd.clone(), + git: manifest.git.clone(), + root_source, + run_id: manifest + .run_id + .as_deref() + .map(str::parse::) + .transpose() + .map_err(|err| anyhow!("invalid run ID: {err}"))?, + settings: settings.clone(), + target_path, + workflow_bundle, + workflow_input, + working_directory: resolve_working_directory(&settings, &cwd), + }) +} + +pub(crate) fn validate_prepared_manifest( + prepared: &PreparedManifest, +) -> Result { + validate(ValidateInput { + workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()), + settings: prepared.settings.clone(), + cwd: prepared.cwd.clone(), + custom_transforms: Vec::new(), + }) +} + +pub(crate) fn create_run_input(prepared: PreparedManifest) -> CreateRunInput { + CreateRunInput { + workflow: WorkflowInput::Bundled(prepared.workflow_input), + settings: prepared.settings, + cwd: prepared.cwd, + workflow_slug: None, + workflow_path: Some(prepared.target_path), + workflow_bundle: Some(prepared.workflow_bundle), + run_id: prepared.run_id, + host_repo_path: Some(prepared.working_directory.display().to_string()), + base_branch: prepared.git.as_ref().map(|git| git.branch.clone()), + } +} + +pub(crate) async fn run_preflight( + state: &AppState, + prepared: &PreparedManifest, + validated: &Validated, +) -> Result<(types::PreflightResponse, bool)> { + 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_ok, + )) +} + +pub(crate) fn graph_source(prepared: &PreparedManifest, direction: Option<&str>) -> String { + direction.map_or_else( + || prepared.root_source.clone(), + |direction| apply_direction(&prepared.root_source, direction).into_owned(), + ) +} + +fn workflow_bundle_from_manifest( + workflows: &HashMap, +) -> Result { + let workflows = workflows + .iter() + .map(|(path, workflow)| { + let files = workflow + .files + .iter() + .map(|(key, entry)| (PathBuf::from(key), entry.content.clone())) + .collect::>(); + Ok::<_, anyhow::Error>(( + PathBuf::from(path), + BundledWorkflow { + logical_path: PathBuf::from(path), + source: workflow.source.clone(), + files, + }, + )) + }) + .collect::>>()?; + Ok(WorkflowBundle::new(workflows)) +} + +fn root_workflow_config_layer( + manifest: &types::RunManifest, + workflow: &BundledWorkflow, +) -> Result { + 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 { + return Ok(ConfigLayer::default()); + }; + + let mut layer = parse_run_config(&config.source)?; + resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; + strip_server_owned_fields(&mut layer); + Ok(layer) +} + +fn parse_manifest_config(config: &types::ManifestConfig) -> Result { + let Some(source) = config.source.as_deref() else { + return Ok(ConfigLayer::default()); + }; + let mut layer: ConfigLayer = toml::from_str(source)?; + strip_server_owned_fields(&mut layer); + Ok(layer) +} + +fn resolve_manifest_dockerfile( + layer: &mut ConfigLayer, + config_path: &Path, + files: &HashMap, +) -> Result<()> { + let source = layer + .sandbox + .as_mut() + .and_then(|sandbox| sandbox.daytona.as_mut()) + .and_then(|daytona| daytona.snapshot.as_mut()) + .and_then(|snapshot| snapshot.dockerfile.as_mut()); + let Some(DockerfileSource::Path { path }) = source else { + return Ok(()); + }; + let logical_path = + normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path) + .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?; + let content = files + .get(&logical_path) + .cloned() + .ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?; + *source.unwrap() = DockerfileSource::Inline(content); + Ok(()) +} + +fn server_defaults_layer(settings: &Settings) -> Result { + let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?; + // Run manifests carry their own dry-run intent. Do not let a daemon's + // startup-time fallback mode silently force every submitted run/preflight + // into simulation. + layer.dry_run = None; + Ok(layer) +} + +fn strip_server_owned_fields(layer: &mut ConfigLayer) { + layer.server = None; + layer.exec = None; + layer.storage_dir = None; + layer.max_concurrent_runs = None; + layer.web = None; + layer.api = None; + layer.features = None; + layer.log = None; +} + +fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { + let Some(args) = args else { + return ConfigLayer::default(); + }; + + let llm = (args.model.is_some() || args.provider.is_some()).then(|| LlmConfig { + model: args.model.clone(), + provider: args.provider.clone(), + fallbacks: None, + }); + let sandbox = + (args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| SandboxConfig { + provider: args.sandbox.clone(), + preserve: args.preserve_sandbox, + ..Default::default() + }); + + ConfigLayer { + llm, + sandbox, + verbose: args.verbose, + dry_run: args.dry_run, + auto_approve: args.auto_approve, + no_retro: args.no_retro, + labels: parse_labels(&args.label), + ..Default::default() + } +} + +fn parse_labels(labels: &[String]) -> HashMap { + labels + .iter() + .filter_map(|label| label.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option { + 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 => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(normalized) +} + +async fn build_preflight_report( + state: &AppState, + prepared: &PreparedManifest, + validated: &Validated, +) -> Result<(CheckReport, bool)> { + let graph = validated.graph(); + let settings = &prepared.settings; + let sandbox_provider = resolve_sandbox_provider(settings)?; + let github_app = state + .github_app_credentials(settings.app_id()) + .await + .map_err(|err| anyhow!(err))?; + let mut checks = Vec::new(); + + let setup_command_count = settings.setup_commands().len(); + let repo_summary = prepared.git.as_ref().map_or_else( + || "unknown".to_string(), + |git| { + let https = fabro_github::ssh_url_to_https(&git.origin_url); + fabro_github::parse_github_owner_repo(&https).map_or_else( + |_| git.origin_url.clone(), + |(owner, repo)| format!("{owner}/{repo}"), + ) + }, + ); + checks.push(CheckResult { + name: "Repository".into(), + status: CheckStatus::Pass, + summary: repo_summary, + details: vec![ + CheckDetail::new(format!("Setup commands: {setup_command_count}")), + CheckDetail { + text: format!( + "Git: {}", + prepared.git.as_ref().map_or("unknown", |git| if git.clean { + "clean" + } else { + "dirty" + }) + ), + warn: prepared.git.as_ref().is_some_and(|git| !git.clean), + }, + ], + remediation: None, + }); + checks.push(CheckResult { + name: "Workflow".into(), + status: CheckStatus::Pass, + summary: graph.name.clone(), + details: vec![ + CheckDetail::new(format!("Nodes: {}", graph.nodes.len())), + CheckDetail::new(format!("Edges: {}", graph.edges.len())), + CheckDetail::new(format!("Goal: {}", graph.goal())), + ], + remediation: None, + }); + + let sandbox_ok = + run_sandbox_check(&mut checks, sandbox_provider, prepared, github_app.clone()).await; + let llm_ok = run_llm_check(state, &mut checks, graph, settings).await; + run_github_token_check(&mut checks, prepared, settings, github_app).await; + + let checks_ok = sandbox_ok && llm_ok; + + Ok(( + CheckReport { + title: "Run Preflight".into(), + sections: vec![CheckSection { + title: String::new(), + checks, + }], + }, + checks_ok, + )) +} + +fn resolve_sandbox_provider(settings: &Settings) -> Result { + Ok(settings + .sandbox_settings() + .and_then(|sandbox| sandbox.provider.as_deref()) + .map(str::parse::) + .transpose() + .map_err(|err| anyhow!("Invalid sandbox provider: {err}"))? + .unwrap_or_default()) +} + +fn resolve_daytona_config(settings: &Settings) -> Option { + settings + .sandbox_settings() + .and_then(|sandbox| sandbox.daytona.clone()) +} + +async fn run_sandbox_check( + checks: &mut Vec, + sandbox_provider: SandboxProvider, + prepared: &PreparedManifest, + github_app: Option, +) -> bool { + let daytona_config = resolve_daytona_config(&prepared.settings); + let sandbox_result: Result, String> = match sandbox_provider { + SandboxProvider::Local => SandboxSpec::Local { + working_directory: prepared.working_directory.clone(), + } + .build(None) + .await + .map_err(|err| err.to_string()), + SandboxProvider::Docker => SandboxSpec::Docker { + config: DockerSandboxOptions { + host_working_directory: prepared.working_directory.to_string_lossy().to_string(), + ..DockerSandboxOptions::default() + }, + } + .build(None) + .await + .map_err(|err| err.to_string()), + SandboxProvider::Daytona => SandboxSpec::Daytona { + config: daytona_config.unwrap_or_default(), + github_app, + run_id: None, + clone_branch: prepared.git.as_ref().map(|git| git.branch.clone()), + } + .build(None) + .await + .map_err(|err| format!("Daytona sandbox creation failed: {err}")), + }; + + match sandbox_result { + Ok(sandbox) => match sandbox.initialize().await { + Ok(()) => { + let _ = sandbox.cleanup().await; + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Pass, + summary: sandbox_provider.to_string(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: None, + }); + true + } + Err(err) => { + let _ = sandbox.cleanup().await; + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: Some(format!("Sandbox init failed: {err}")), + }); + false + } + }, + Err(err) => { + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], + remediation: Some(err), + }); + false + } + } +} + +async fn run_llm_check( + state: &AppState, + checks: &mut Vec, + graph: &Graph, + settings: &Settings, +) -> bool { + let (model, provider) = resolve_model_provider(settings, graph); + let default_provider = provider.as_deref().unwrap_or("anthropic"); + + match state.build_llm_client().await { + Ok(client) => { + let configured = client + .provider_names() + .iter() + .map(std::string::ToString::to_string) + .collect::>(); + let mut model_providers = std::collections::BTreeSet::new(); + + for node in graph.nodes.values() { + if !is_llm_handler_type(node.handler_type()) { + continue; + } + let node_model = node.model().unwrap_or(&model); + let node_provider = node.provider().unwrap_or(default_provider); + let (resolved_model, resolved_provider) = + if let Some(info) = Catalog::builtin().get(node_model) { + (info.id.clone(), info.provider.to_string()) + } else { + (node_model.to_string(), node_provider.to_string()) + }; + let final_provider = if node.provider().is_some() { + node_provider.to_string() + } else { + resolved_provider + }; + model_providers.insert((resolved_model, final_provider)); + } + + if model_providers.is_empty() { + let (resolved_model, resolved_provider) = + if let Some(info) = Catalog::builtin().get(&model) { + (info.id.clone(), info.provider.to_string()) + } else { + (model.clone(), default_provider.to_string()) + }; + model_providers.insert((resolved_model, resolved_provider)); + } + + let mut all_ok = true; + for (model_id, provider_name) in &model_providers { + match provider_name.parse::() { + Ok(_) => { + let mut status = CheckStatus::Pass; + if !configured.iter().any(|name| name == provider_name) { + status = CheckStatus::Warning; + all_ok = false; + } + checks.push(CheckResult { + name: "LLM".into(), + status, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: (status == CheckStatus::Warning) + .then(|| format!("Provider \"{provider_name}\" is not configured")), + }); + } + Err(err) => { + checks.push(CheckResult { + name: "LLM".into(), + status: CheckStatus::Error, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: Some(format!( + "Invalid provider \"{provider_name}\": {err}" + )), + }); + all_ok = false; + } + } + } + all_ok + } + Err(err) => { + checks.push(CheckResult { + name: "LLM".into(), + status: CheckStatus::Error, + summary: "initialization failed".into(), + details: vec![], + remediation: Some(format!("LLM client init failed: {err}")), + }); + false + } + } +} + +fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option) { + let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); + let configured_provider = settings + .llm + .as_ref() + .and_then(|llm| llm.provider.as_deref()); + + let provider = configured_provider + .or_else(|| { + graph + .attrs + .get("default_provider") + .and_then(|value| value.as_str()) + }) + .map(String::from); + let model = configured_model + .or_else(|| { + graph + .attrs + .get("default_model") + .and_then(|value| value.as_str()) + }) + .map_or_else( + || { + let catalog = Catalog::builtin(); + let info = provider + .as_deref() + .and_then(|value| value.parse::().ok()) + .and_then(|provider| catalog.default_for_provider(provider)) + .unwrap_or_else(|| catalog.default_from_env()); + info.id.clone() + }, + String::from, + ); + + match Catalog::builtin().get(&model) { + Some(info) => ( + info.id.clone(), + provider.or(Some(info.provider.to_string())), + ), + None => (model, provider), + } +} + +async fn run_github_token_check( + checks: &mut Vec, + prepared: &PreparedManifest, + settings: &Settings, + github_app: Option, +) { + let Some(github_permissions) = settings.github_permissions() else { + return; + }; + if github_permissions.is_empty() { + return; + } + + let perm_details = github_permissions + .iter() + .map(|(key, value)| CheckDetail::new(format!("{key}: {value}"))) + .collect::>(); + match (&github_app, prepared.git.as_ref()) { + (Some(creds), Some(git)) => { + match mint_github_token(creds, &git.origin_url, github_permissions).await { + Ok(_) => checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Pass, + summary: "minted".into(), + details: perm_details, + remediation: None, + }), + Err(err) => checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: perm_details, + remediation: Some(format!("Failed to mint GitHub token: {err}")), + }), + } + } + _ => checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Warning, + summary: "skipped".into(), + details: vec![], + remediation: Some("No GitHub App credentials or origin URL available".to_string()), + }), + } +} + +async fn mint_github_token( + creds: &fabro_github::GitHubAppCredentials, + origin_url: &str, + permissions: &HashMap, +) -> Result { + let https_url = fabro_github::ssh_url_to_https(origin_url); + let (owner, repo) = + fabro_github::parse_github_owner_repo(&https_url).map_err(|err| anyhow!("{err}"))?; + let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) + .map_err(|err| anyhow!("{err}"))?; + let client = reqwest::Client::new(); + let perms_json = serde_json::to_value(permissions)?; + fabro_github::create_installation_access_token_with_permissions( + &client, + &jwt, + &owner, + &repo, + &fabro_github::github_api_base_url(), + perms_json, + ) + .await + .map_err(|err| anyhow!("{err}")) +} + +fn preflight_response( + validated: &Validated, + target_path: &Path, + report: &CheckReport, + ok: bool, +) -> types::PreflightResponse { + types::PreflightResponse { + ok, + checks: report_to_api(report), + workflow: types::PreflightWorkflowSummary { + diagnostics: diagnostics_to_api(validated.diagnostics()), + edges: i64::try_from(validated.graph().edges.len()).unwrap(), + goal: validated.graph().goal().to_string(), + graph_path: Some(target_path.display().to_string()), + name: validated.graph().name.clone(), + nodes: i64::try_from(validated.graph().nodes.len()).unwrap(), + }, + } +} + +fn diagnostics_to_api( + diagnostics: &[fabro_validate::Diagnostic], +) -> Vec { + diagnostics + .iter() + .map(|diagnostic| types::WorkflowDiagnostic { + edge: diagnostic + .edge + .as_ref() + .map(|edge: &(String, String)| [edge.0.clone(), edge.1.clone()]), + fix: diagnostic.fix.clone(), + message: diagnostic.message.clone(), + node_id: diagnostic.node_id.clone(), + rule: diagnostic.rule.clone(), + severity: match diagnostic.severity { + Severity::Error => types::WorkflowDiagnosticSeverity::Error, + Severity::Warning => types::WorkflowDiagnosticSeverity::Warning, + Severity::Info => types::WorkflowDiagnosticSeverity::Info, + }, + }) + .collect() +} + +fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport { + types::PreflightCheckReport { + sections: report + .sections + .iter() + .map(|section| types::PreflightCheckSection { + checks: section + .checks + .iter() + .map(|check| types::PreflightCheckResult { + details: check + .details + .iter() + .map(|detail| types::PreflightCheckDetail { + text: detail.text.clone(), + warn: detail.warn, + }) + .collect(), + name: check.name.clone(), + remediation: check.remediation.clone(), + status: match check.status { + CheckStatus::Pass => types::PreflightCheckResultStatus::Pass, + CheckStatus::Warning => types::PreflightCheckResultStatus::Warning, + CheckStatus::Error => types::PreflightCheckResultStatus::Error, + }, + summary: check.summary.clone(), + }) + .collect(), + title: section.title.clone(), + }) + .collect(), + title: report.title.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_manifest() -> types::RunManifest { + types::RunManifest { + args: None, + configs: Vec::new(), + cwd: "/tmp/project".to_string(), + git: None, + goal: None, + run_id: None, + target: types::ManifestTarget { + identifier: "workflow.fabro".to_string(), + path: "workflow.fabro".to_string(), + }, + version: 1, + workflows: HashMap::from([( + "workflow.fabro".to_string(), + types::ManifestWorkflow { + config: None, + files: HashMap::new(), + source: + "digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }" + .to_string(), + }, + )]), + } + } + + #[test] + fn prepare_manifest_does_not_inherit_server_dry_run_fallback() { + let server_settings = Settings { + dry_run: Some(true), + storage_dir: Some(PathBuf::from("/srv/fabro")), + ..Default::default() + }; + + let prepared = prepare_manifest(&server_settings, &minimal_manifest()).unwrap(); + + assert_eq!(prepared.settings.dry_run, None); + assert_eq!( + prepared.settings.storage_dir, + Some(PathBuf::from("/srv/fabro")) + ); + } + + #[test] + fn prepare_manifest_preserves_explicit_manifest_dry_run() { + let server_settings = Settings { + dry_run: Some(true), + storage_dir: Some(PathBuf::from("/srv/fabro")), + ..Default::default() + }; + let mut manifest = minimal_manifest(); + manifest.args = Some(types::ManifestArgs { + auto_approve: None, + dry_run: Some(true), + label: Vec::new(), + model: None, + no_retro: None, + preserve_sandbox: None, + provider: None, + sandbox: None, + verbose: None, + }); + + let prepared = prepare_manifest(&server_settings, &manifest).unwrap(); + + assert_eq!(prepared.settings.dry_run, Some(true)); + } +} diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index a6e11ff2b..5dd8cfe70 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -70,6 +70,17 @@ fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool) settings } +fn apply_runtime_settings( + base: &Settings, + args: &ServeArgs, + dry_run_mode: bool, + data_dir: &Path, +) -> Settings { + let mut settings = apply_serve_overrides(base, args, dry_run_mode); + settings.storage_dir = Some(data_dir.to_path_buf()); + settings +} + /// Start the HTTP API server. /// /// # Errors @@ -119,11 +130,8 @@ pub async fn serve_command( }; // Shared config for live reloading - let shared_settings = Arc::new(RwLock::new(apply_serve_overrides( - &disk_settings, - &args, - dry_run_mode, - ))); + let effective_settings = apply_runtime_settings(&disk_settings, &args, dry_run_mode, &data_dir); + let shared_settings = Arc::new(RwLock::new(effective_settings)); std::fs::create_dir_all(&data_dir)?; let (auth_mode, client_auth, max_concurrent_runs) = { let cfg = shared_settings.read().expect("config lock poisoned"); @@ -235,6 +243,7 @@ pub async fn serve_command( let settings_for_poll = Arc::clone(&shared_settings); let config_path_for_poll = config_path.clone(); let args_for_poll = args.clone(); + let data_dir_for_poll = data_dir.clone(); tokio::spawn(async move { let mut interval = interval(Duration::from_secs(5)); interval.tick().await; // skip first immediate tick @@ -242,8 +251,12 @@ pub async fn serve_command( interval.tick().await; match load_server_settings(config_path_for_poll.as_deref()) { Ok(new_disk_settings) => { - let effective = - apply_serve_overrides(&new_disk_settings, &args_for_poll, dry_run_mode); + let effective = apply_runtime_settings( + &new_disk_settings, + &args_for_poll, + dry_run_mode, + &data_dir_for_poll, + ); let changed = { let cfg = settings_for_poll.read().expect("config lock poisoned"); *cfg != effective @@ -359,3 +372,33 @@ fn client_auth_from_mode(auth_mode: &AuthMode) -> ClientAuth { ClientAuth::Required } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{ServeArgs, apply_runtime_settings}; + use fabro_types::Settings; + + #[test] + fn apply_runtime_settings_preserves_storage_dir() { + let base = Settings::default(); + let args = ServeArgs { + bind: None, + model: None, + provider: None, + dry_run: false, + sandbox: None, + max_concurrent_runs: None, + config: None, + }; + + let resolved = + apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro-storage")); + + assert_eq!( + resolved.storage_dir, + Some(PathBuf::from("/srv/fabro-storage")) + ); + } +} diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 31573f041..8f8d6d970 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -49,6 +49,7 @@ use crate::demo; use crate::diagnostics; use crate::error::ApiError; use crate::jwt_auth::{AuthMode, AuthenticatedService}; +use crate::run_manifest; use crate::secret_store::{SecretStore, SecretStoreError}; use crate::sessions as sessions_mod; use crate::sessions::{SessionStore, new_session_store}; @@ -56,7 +57,7 @@ use crate::static_files; use crate::web_auth; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_workflow::event::{self as workflow_event, Emitter}; -use fabro_workflow::operations::{self, CreateRunInput, WorkflowInput}; +use fabro_workflow::operations::{self}; use fabro_workflow::pipeline::Persisted; use fabro_workflow::records::Checkpoint; use fabro_workflow::run_status::RunStatus as WorkflowRunStatus; @@ -67,11 +68,13 @@ pub use fabro_api::types::{ AggregateUsage, ApiQuestion, ApiQuestionOption, AppendEventResponse, ArtifactEntry, ArtifactListResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - CreateRunRequest, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, - PaginatedRunList, PaginationMeta, QuestionType as ApiQuestionType, RunError, - RunEvent as ApiRunEvent, RunStatus, RunStatusResponse, SetSecretRequest, StartRunRequest, - SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse, + EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, PaginatedRunList, + PaginationMeta, PreflightResponse, QuestionType as ApiQuestionType, + RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest, RunError, + RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SetSecretRequest, + StartRunRequest, SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse, }; +use fabro_graphviz::render::GraphFormat; pub fn default_page_limit() -> u32 { 20 @@ -336,6 +339,8 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { fn demo_routes() -> Router> { Router::new() .route("/runs", get(demo::list_runs).post(demo::create_run_stub)) + .route("/preflight", post(run_preflight)) + .route("/graph/render", post(render_graph_from_manifest)) .route("/runs/{id}", get(demo::get_run_status)) .route("/runs/{id}/questions", get(demo::get_questions_stub)) .route("/runs/{id}/questions/{qid}/answer", post(demo::answer_stub)) @@ -434,6 +439,8 @@ fn demo_routes() -> Router> { fn real_routes() -> Router> { Router::new() .route("/runs", get(list_runs).post(create_run)) + .route("/preflight", post(run_preflight)) + .route("/graph/render", post(render_graph_from_manifest)) .route("/boards/runs", get(list_board_runs)) .route("/runs/{id}", get(get_run_status).delete(delete_run)) .route("/runs/{id}/questions", get(get_questions)) @@ -1090,73 +1097,17 @@ fn managed_run( async fn create_run( _auth: AuthenticatedService, State(state): State>, - Json(req): Json, + Json(req): Json, ) -> Response { - let run_id = match req.run_id.as_deref() { - Some(raw) => match raw.parse::() { - Ok(parsed) => parsed, - Err(_) => return ApiError::bad_request("Invalid run ID.").into_response(), - }, - None => RunId::new(), + let prepared = match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req) { + Ok(prepared) => prepared, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; + let run_id = prepared.run_id.unwrap_or_else(RunId::new); info!(run_id = %run_id, "Run created"); - let using_dot_source = req - .dot_source - .as_ref() - .is_some_and(|value| !value.is_empty()); - let using_local_workflow = req - .workflow_path - .as_ref() - .is_some_and(|value| !value.is_empty()); - - if using_dot_source == using_local_workflow { - return ApiError::bad_request( - "Provide exactly one of dot_source or workflow_path/cwd/settings_json.", - ) - .into_response(); - } - - let create_input = if let Some(dot_source) = req.dot_source.clone() { - CreateRunInput { - workflow: WorkflowInput::DotSource { - source: dot_source, - base_dir: None, - }, - settings: state.settings.read().unwrap().clone(), - cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), - workflow_slug: None, - run_id: Some(run_id), - host_repo_path: None, - base_branch: None, - } - } else { - let Some(workflow_path) = req.workflow_path.as_ref() else { - return ApiError::bad_request("workflow_path is required").into_response(); - }; - let Some(cwd) = req.cwd.as_ref() else { - return ApiError::bad_request("cwd is required").into_response(); - }; - let Some(settings_json) = req.settings_json.as_ref() else { - return ApiError::bad_request("settings_json is required").into_response(); - }; - let settings = match serde_json::from_str::(settings_json) { - Ok(settings) => settings, - Err(err) => { - return ApiError::bad_request(format!("Invalid settings_json payload: {err}")) - .into_response(); - } - }; - CreateRunInput { - workflow: WorkflowInput::Path(std::path::PathBuf::from(workflow_path)), - settings, - cwd: std::path::PathBuf::from(cwd), - workflow_slug: None, - run_id: Some(run_id), - host_repo_path: None, - base_branch: None, - } - }; + let mut create_input = run_manifest::create_run_input(prepared.clone()); + create_input.run_id = Some(run_id); let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await { Ok(created) => created, @@ -1200,6 +1151,62 @@ async fn create_run( .into_response() } +async fn run_preflight( + _auth: AuthenticatedService, + State(state): State>, + Json(req): Json, +) -> Response { + let prepared = match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req) { + Ok(prepared) => prepared, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; + let validated = match run_manifest::validate_prepared_manifest(&prepared) { + Ok(validated) => validated, + Err(FabroError::Parse(_)) => { + return ApiError::bad_request("Validation failed").into_response(); + } + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; + let response = match run_manifest::run_preflight(&state, &prepared, &validated).await { + Ok((response, _ok)) => response, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + (StatusCode::OK, Json(response)).into_response() +} + +async fn render_graph_from_manifest( + _auth: AuthenticatedService, + State(state): State>, + Json(req): Json, +) -> Response { + let prepared = + match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req.manifest) { + Ok(prepared) => prepared, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; + let validated = match run_manifest::validate_prepared_manifest(&prepared) { + Ok(validated) => validated, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; + if validated.has_errors() { + return ApiError::bad_request("Validation failed").into_response(); + } + + let format = match req.format.unwrap_or(RenderWorkflowGraphFormat::Svg) { + RenderWorkflowGraphFormat::Svg => GraphFormat::Svg, + RenderWorkflowGraphFormat::Png => GraphFormat::Png, + }; + let direction = req.direction.as_ref().map(|direction| match direction { + RenderWorkflowGraphDirection::Lr => "LR", + RenderWorkflowGraphDirection::Tb => "TB", + }); + let dot_source = run_manifest::graph_source(&prepared, direction); + render_graph_bytes(&dot_source, format).await +} + async fn start_run( _auth: AuthenticatedService, State(state): State>, @@ -2601,15 +2608,17 @@ async fn get_retro( } } -/// Render DOT source to a styled SVG via `render_dot` on a blocking thread. -pub(crate) async fn render_dot_svg(dot_source: &str) -> Response { - use fabro_graphviz::render::{GraphFormat, render_dot}; +/// Render DOT source to a styled image via `render_dot` on a blocking thread. +pub(crate) async fn render_graph_bytes(dot_source: &str, format: GraphFormat) -> Response { + use fabro_graphviz::render::render_dot; + let content_type = match format { + GraphFormat::Svg => "image/svg+xml", + GraphFormat::Png => "image/png", + }; let source = dot_source.to_owned(); - match spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await { - Ok(Ok(bytes)) => { - (StatusCode::OK, [("content-type", "image/svg+xml")], bytes).into_response() - } + match spawn_blocking(move || render_dot(&source, format)).await { + Ok(Ok(bytes)) => (StatusCode::OK, [("content-type", content_type)], bytes).into_response(), Ok(Err(e)) => ApiError::new(StatusCode::BAD_GATEWAY, e.to_string()).into_response(), Err(e) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } @@ -2632,13 +2641,13 @@ async fn get_graph( } }; if !live_dot_source.is_empty() { - return render_dot_svg(&live_dot_source).await; + return render_graph_bytes(&live_dot_source, GraphFormat::Svg).await; } match state.store.open_run_reader(&id).await { Ok(run_store) => match run_store.state().await { Ok(run_state) => match run_state.graph_source { - Some(dot_source) => render_dot_svg(&dot_source).await, + Some(dot_source) => render_graph_bytes(&dot_source, GraphFormat::Svg).await, None => ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response(), }, Err(err) => ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), @@ -2691,6 +2700,27 @@ mod tests { format!("/api/v1{path}") } + fn minimal_manifest_json(dot_source: &str) -> serde_json::Value { + serde_json::json!({ + "version": 1, + "cwd": "/tmp", + "target": { + "identifier": "workflow.fabro", + "path": "workflow.fabro", + }, + "workflows": { + "workflow.fabro": { + "source": dot_source, + "files": {}, + }, + }, + }) + } + + fn manifest_body(dot_source: &str) -> Body { + Body::from(serde_json::to_string(&minimal_manifest_json(dot_source)).unwrap()) + } + /// Create a run via POST /runs, then start it via POST /runs/{id}/start. /// Returns the run_id string. async fn create_and_start_run(app: &Router, dot_source: &str) -> String { @@ -2698,9 +2728,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": dot_source})).unwrap(), - )) + .body(manifest_body(dot_source)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; @@ -2989,9 +3017,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.oneshot(req).await.unwrap(); @@ -3010,9 +3036,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": "not a graph"})).unwrap(), - )) + .body(manifest_body("not a graph")) .unwrap(); let response = app.oneshot(req).await.unwrap(); @@ -3069,9 +3093,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3135,9 +3157,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3165,9 +3185,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3196,9 +3214,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3235,9 +3251,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3264,9 +3278,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3304,9 +3316,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3357,9 +3367,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.oneshot(req).await.unwrap(); @@ -3378,9 +3386,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; @@ -3408,9 +3414,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; @@ -3444,9 +3448,7 @@ mod tests { .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), - )) + .body(manifest_body(MINIMAL_DOT)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); @@ -3495,7 +3497,21 @@ mod tests { .uri(api("/runs")) .header("content-type", "application/json") .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(), + serde_json::to_string(&serde_json::json!({ + "version": 1, + "cwd": "/tmp", + "target": { + "identifier": "workflow.fabro", + "path": "workflow.fabro", + }, + "workflows": { + "workflow.fabro": { + "source": MINIMAL_DOT, + "files": {}, + }, + }, + })) + .unwrap(), )) .unwrap(); @@ -3536,6 +3552,62 @@ mod tests { ); } + #[tokio::test] + async fn render_graph_from_manifest_returns_svg() { + let app = test_app_with(); + + let req = Request::builder() + .method("POST") + .uri(api("/graph/render")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "manifest": { + "version": 1, + "cwd": "/tmp", + "target": { + "identifier": "workflow.fabro", + "path": "workflow.fabro", + }, + "workflows": { + "workflow.fabro": { + "source": MINIMAL_DOT, + "files": {}, + }, + }, + }, + "format": "svg", + })) + .unwrap(), + )) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + + if response.status() == StatusCode::BAD_GATEWAY { + return; + } + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("content-type") + .expect("content-type header should be present") + .to_str() + .unwrap(), + "image/svg+xml" + ); + + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let svg = String::from_utf8_lossy(&bytes); + assert!( + svg.contains(" serde_json::Value { /// Create a run via POST /runs, then start it via POST /runs/{id}/start. /// Returns the run_id string. pub(crate) async fn create_and_start_run(app: &axum::Router, dot_source: &str) -> String { + create_and_start_run_from_manifest(app, minimal_manifest_json(dot_source)).await +} + +pub(crate) async fn create_and_start_run_from_manifest( + app: &axum::Router, + manifest: serde_json::Value, +) -> String { let req = Request::builder() .method("POST") .uri(api("/runs")) .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": dot_source})).unwrap(), - )) + .body(Body::from(serde_json::to_string(&manifest).unwrap())) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; @@ -90,6 +95,29 @@ pub(crate) async fn create_and_start_run(app: &axum::Router, dot_source: &str) - run_id } +pub(crate) fn minimal_manifest_json(dot_source: &str) -> serde_json::Value { + serde_json::json!({ + "version": 1, + "cwd": "/tmp", + "target": { + "identifier": "workflow.fabro", + "path": "workflow.fabro" + }, + "workflows": { + "workflow.fabro": { + "source": dot_source, + "files": {} + } + } + }) +} + +pub(crate) fn minimal_manifest_json_with_dry_run(dot_source: &str) -> serde_json::Value { + let mut manifest = minimal_manifest_json(dot_source); + manifest["args"] = serde_json::json!({ "dry_run": true }); + manifest +} + pub(crate) async fn run_json(app: &axum::Router, run_id: &str) -> serde_json::Value { let req = Request::builder() .method("GET") diff --git a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs b/lib/crates/fabro-server/tests/it/scenario/dry_run.rs index 2807e4465..897f98aeb 100644 --- a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs +++ b/lib/crates/fabro-server/tests/it/scenario/dry_run.rs @@ -3,7 +3,8 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, create_and_start_run, dry_run_app, wait_for_run_status, + MINIMAL_DOT, api, body_json, create_and_start_run, dry_run_app, minimal_manifest_json, + wait_for_run_status, }; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -60,7 +61,7 @@ async fn dry_run_serve_rejects_invalid_dot() { .uri(api("/runs")) .header("content-type", "application/json") .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": "not valid dot"})).unwrap(), + serde_json::to_string(&minimal_manifest_json("not valid dot")).unwrap(), )) .unwrap(); diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index 360683d86..68a7ffc50 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -16,8 +16,8 @@ use tokio::time::sleep; use tower::ServiceExt; use crate::helpers::{ - POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, run_json, test_settings, wait_for_run_status, - wait_for_run_status_not_in, + POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, minimal_manifest_json, run_json, test_settings, + wait_for_run_status, wait_for_run_status_not_in, }; fn gate_registry(interviewer: Arc) -> HandlerRegistry { @@ -79,7 +79,7 @@ async fn full_http_lifecycle_approve_and_complete() { .uri(api("/runs")) .header("content-type", "application/json") .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": GATE_DOT})).unwrap(), + serde_json::to_string(&minimal_manifest_json(GATE_DOT)).unwrap(), )) .unwrap(); @@ -144,7 +144,7 @@ async fn full_http_lifecycle_cancel() { .uri(api("/runs")) .header("content-type", "application/json") .body(Body::from( - serde_json::to_string(&serde_json::json!({"dot_source": GATE_DOT})).unwrap(), + serde_json::to_string(&minimal_manifest_json(GATE_DOT)).unwrap(), )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/crates/fabro-server/tests/it/scenario/sse.rs index 170344b28..1cbb4cdd9 100644 --- a/lib/crates/fabro-server/tests/it/scenario/sse.rs +++ b/lib/crates/fabro-server/tests/it/scenario/sse.rs @@ -8,8 +8,9 @@ use tokio::time::{sleep, timeout}; use tower::ServiceExt; use crate::helpers::{ - POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run, dry_run_settings, - test_app_with_scheduler, wait_for_run_status_not_in, + POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run_from_manifest, + dry_run_settings, minimal_manifest_json_with_dry_run, test_app_with_scheduler, + wait_for_run_status_not_in, }; const SIMPLE_DOT: &str = r#"digraph SSETest { @@ -41,7 +42,9 @@ async fn sse_stream_contains_expected_event_types() { let state = create_app_state_with_options(dry_run_settings(), 5); let app = test_app_with_scheduler(state); - let run_id = create_and_start_run(&app, SIMPLE_DOT).await; + let run_id = + create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(SIMPLE_DOT)) + .await; wait_for_run_status_not_in(&app, &run_id, &["queued", "starting"]).await; diff --git a/lib/crates/fabro-workflow/src/file_resolver.rs b/lib/crates/fabro-workflow/src/file_resolver.rs new file mode 100644 index 000000000..ec6afbe21 --- /dev/null +++ b/lib/crates/fabro-workflow/src/file_resolver.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; + +pub trait FileResolver: Send + Sync { + fn resolve(&self, current_dir: &Path, reference: &str) -> Option; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedFile { + pub logical_path: PathBuf, + pub content: String, +} + +#[derive(Clone, Debug, Default)] +pub struct BundleFileResolver { + files: HashMap, +} + +impl BundleFileResolver { + #[must_use] + pub fn new(files: HashMap) -> Self { + Self { files } + } +} + +impl FileResolver for BundleFileResolver { + fn resolve(&self, current_dir: &Path, reference: &str) -> Option { + let logical_path = normalize_logical_path(current_dir, reference)?; + self.files.get(&logical_path).map(|content| ResolvedFile { + logical_path, + content: content.clone(), + }) + } +} + +pub(crate) fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option { + 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 => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(normalized) +} + +#[derive(Clone, Debug, Default)] +pub struct FilesystemFileResolver { + fallback_dir: Option, +} + +impl FilesystemFileResolver { + #[must_use] + pub fn new(fallback_dir: Option) -> Self { + Self { fallback_dir } + } +} + +impl FileResolver for FilesystemFileResolver { + fn resolve(&self, current_dir: &Path, reference: &str) -> Option { + let raw = Path::new(reference); + let is_tilde = reference.starts_with('~'); + let expanded = if is_tilde { + match dirs::home_dir() { + Some(home) => home.join(raw.strip_prefix("~").unwrap_or_else(|_| Path::new(""))), + None => current_dir.join(reference), + } + } else { + current_dir.join(reference) + }; + + let resolved_path = match expanded.canonicalize() { + Ok(path) if path.is_file() => Some(path), + _ if !is_tilde => self.fallback_dir.as_ref().and_then(|fallback_dir| { + let fallback_path = fallback_dir.join(reference); + match fallback_path.canonicalize() { + Ok(path) if path.is_file() => Some(path), + _ => None, + } + }), + _ => None, + }?; + + match std::fs::read_to_string(&resolved_path) { + Ok(content) => Some(ResolvedFile { + logical_path: resolved_path, + content, + }), + Err(error) => { + tracing::warn!( + path = %resolved_path.display(), + %error, + "Failed to read file reference" + ); + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_resolver_returns_exact_match() { + let resolver = BundleFileResolver::new(HashMap::from([( + PathBuf::from("prompts/review.md"), + "check it".to_string(), + )])); + + let resolved = resolver + .resolve(Path::new("."), "prompts/review.md") + .expect("file should resolve"); + + assert_eq!(resolved.logical_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"), + "check it".to_string(), + )])); + + let resolved = resolver + .resolve(Path::new("subflows"), "../prompts/review.md") + .expect("file should resolve"); + + assert_eq!(resolved.logical_path, PathBuf::from("prompts/review.md")); + } + + #[test] + fn bundle_resolver_returns_none_for_missing_path() { + let resolver = BundleFileResolver::new(HashMap::new()); + assert!(resolver.resolve(Path::new("."), "missing.md").is_none()); + } +} diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 9d5248832..0f45de910 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -26,6 +26,11 @@ use super::{EngineServices, Handler}; /// Orchestrates a child workflow engine, polling for completion or stop conditions. pub struct SubWorkflowHandler; +struct ParsedChildWorkflow { + graph: Graph, + workflow_path: Option, +} + /// Parse a duration string like "45s", "200ms", "5m" into a Duration. /// Falls back to 45 seconds on parse failure. fn parse_duration_str(s: &str) -> Duration { @@ -52,7 +57,10 @@ fn parse_duration_str(s: &str) -> Duration { /// (no file inlining), or file path `stack.child_workflow` / `stack.child_dotfile` /// (with file inlining). `stack.child_workflow` is preferred; `stack.child_dotfile` /// is kept for backward compatibility. -fn parse_child_graph(node: &Node) -> Result { +fn parse_child_graph( + node: &Node, + services: &EngineServices, +) -> Result { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); if let Some(dot) = node @@ -71,7 +79,10 @@ fn parse_child_graph(node: &Node) -> Result { })?; validated.raise_on_errors()?; let (graph, _, _) = validated.into_parts(); - return Ok(graph); + return Ok(ParsedChildWorkflow { + graph, + workflow_path: None, + }); } if let Some(path) = node .attrs @@ -79,15 +90,36 @@ fn parse_child_graph(node: &Node) -> Result { .or_else(|| node.attrs.get("stack.child_dotfile")) .and_then(|v| v.as_str()) { + let workflow = if let (Some(bundle), Some(current_workflow_path)) = + (&services.workflow_bundle, &services.workflow_path) + { + bundle + .resolve_child(current_workflow_path, path) + .cloned() + .map_or_else( + || WorkflowInput::Path(PathBuf::from(path)), + WorkflowInput::Bundled, + ) + } else { + 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, + }; let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(PathBuf::from(path)), + workflow, settings: Settings::default(), cwd, custom_transforms: Vec::new(), })?; validated.raise_on_errors()?; let (graph, _, _) = validated.into_parts(); - return Ok(graph); + return Ok(ParsedChildWorkflow { + graph, + workflow_path, + }); } Err(FabroError::handler("No child workflow source".to_string())) } @@ -143,7 +175,10 @@ impl Handler for SubWorkflowHandler { .unwrap_or(""); // Read and parse child workflow graph - let child_graph = match parse_child_graph(node) { + let ParsedChildWorkflow { + graph: child_graph, + workflow_path: child_workflow_path, + } = match parse_child_graph(node, services) { Ok(g) => g, Err(e) => { return Ok(Outcome::fail_classify(format!( @@ -192,6 +227,7 @@ impl Handler for SubWorkflowHandler { let hook_runner = services.hook_runner.clone(); let env = services.env.clone(); let dry_run = services.dry_run; + let workflow_bundle = services.workflow_bundle.clone(); let store = Arc::new(SlateStore::new( Arc::new(InMemory::new()), "", @@ -208,6 +244,8 @@ impl Handler for SubWorkflowHandler { graph: child_graph, source: String::new(), run_options: child_run_options, + workflow_path: child_workflow_path, + workflow_bundle, run_store, checkpoint: None, seed_context: Some(child_context), @@ -299,10 +337,14 @@ impl Handler for SubWorkflowHandler { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use super::*; use crate::handler::HandlerRegistry; use crate::handler::exit::ExitHandler; use crate::handler::start::StartHandler; + use crate::workflow_bundle::{BundledWorkflow, WorkflowBundle}; use fabro_graphviz::graph::AttrValue; fn make_services() -> EngineServices { @@ -516,6 +558,43 @@ mod tests { assert_eq!(outcome.status, StageStatus::Success); } + #[tokio::test] + async fn child_workflow_reads_from_bundle_when_present() { + let handler = SubWorkflowHandler; + let mut node = Node::new("manager"); + node.attrs.insert( + "stack.child_workflow".to_string(), + AttrValue::String("./children/review.fabro".to_string()), + ); + node.attrs + .insert("manager.max_cycles".to_string(), AttrValue::Integer(100)); + node.attrs.insert( + "manager.poll_interval".to_string(), + AttrValue::Duration(Duration::from_millis(10)), + ); + + let mut services = make_services(); + services.workflow_path = Some(PathBuf::from("workflow.fabro")); + services.workflow_bundle = Some(Arc::new(WorkflowBundle::new(HashMap::from([( + PathBuf::from("children/review.fabro"), + BundledWorkflow { + logical_path: PathBuf::from("children/review.fabro"), + source: child_dot_succeeds().to_string(), + files: HashMap::new(), + }, + )])))); + + let context = Context::new(); + let graph = Graph::new("test"); + let dir = tempfile::tempdir().unwrap(); + + let outcome = handler + .execute(&node, &context, &graph, dir.path(), &services) + .await + .unwrap(); + assert_eq!(outcome.status, StageStatus::Success); + } + #[tokio::test] async fn max_cycles_exceeded_cancels_child() { // Use a child that takes a long time (many nodes with sleep won't work, so use a diff --git a/lib/crates/fabro-workflow/src/handler/mod.rs b/lib/crates/fabro-workflow/src/handler/mod.rs index 96ea888ca..15661a928 100644 --- a/lib/crates/fabro-workflow/src/handler/mod.rs +++ b/lib/crates/fabro-workflow/src/handler/mod.rs @@ -13,7 +13,7 @@ pub mod wait; use std::any::Any; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(test)] use std::time::Duration; @@ -31,6 +31,7 @@ use crate::error::FabroError; use crate::event::Emitter; use crate::outcome::{Outcome, OutcomeExt}; use crate::sandbox_git::GitState; +use crate::workflow_bundle::WorkflowBundle; use fabro_graphviz::graph::{Graph, Node, shape_to_handler_type}; use fabro_hooks::{HookContext, HookDecision, HookRunner}; use fabro_interview::Interviewer; @@ -50,6 +51,10 @@ pub struct EngineServices { pub env: HashMap, /// 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, + /// Bundled workflows available for child-workflow resolution. + pub workflow_bundle: Option>, } impl EngineServices { @@ -106,6 +111,8 @@ impl EngineServices { hook_runner: None, env: HashMap::new(), dry_run: false, + workflow_path: None, + workflow_bundle: None, } } } diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index f4aace1a8..67b474391 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -271,6 +271,8 @@ impl Handler for ParallelHandler { let run_store = services.run_store.clone(); let env = services.env.clone(); let dry_run = services.dry_run; + let workflow_path = services.workflow_path.clone(); + let workflow_bundle = services.workflow_bundle.clone(); let graph = graph.clone(); let run_dir = run_dir.to_path_buf(); let sem = Arc::clone(&semaphore); @@ -322,6 +324,8 @@ impl Handler for ParallelHandler { hook_runner: hook_runner.clone(), env: env.clone(), dry_run, + workflow_path, + workflow_bundle, }; let handler = registry.resolve(target_node); let outcome = super::dispatch_handler( diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 351d38936..e4f76dead 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -118,6 +118,7 @@ pub mod context; pub mod devcontainer_bridge; pub mod error; pub mod event; +pub mod file_resolver; pub mod git; pub(crate) mod graph; pub mod handler; @@ -141,3 +142,4 @@ pub mod sandbox_git; pub mod test_support; #[doc(hidden)] pub mod transforms; +pub mod workflow_bundle; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index a3e11d186..7adde7562 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -7,13 +7,16 @@ use fabro_types::{RunId, Settings}; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use crate::error::FabroError; +use crate::file_resolver::FileResolver; use crate::pipeline::types::PersistOptions; use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunRecord; use crate::run_lookup::default_runs_base; use crate::transforms::{Transform, expand_vars}; +use crate::workflow_bundle::{StoredWorkflowBundle, WorkflowBundle}; use fabro_sandbox::daytona::detect_repo_info; use fabro_util::json::normalize_json_value; @@ -26,6 +29,8 @@ pub struct CreateRunInput { pub settings: Settings, pub cwd: PathBuf, pub workflow_slug: Option, + pub workflow_path: Option, + pub workflow_bundle: Option, pub run_id: Option, pub host_repo_path: Option, pub base_branch: Option, @@ -68,6 +73,8 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result Result Result Result FabroError { FabroError::engine(err.to_string()) } +fn persist_workflow_bundle( + run_dir: &Path, + workflow_path: PathBuf, + workflow_bundle: WorkflowBundle, +) -> Result<(), FabroError> { + let path = run_dir.join("workflow_bundle.json"); + let payload = + serde_json::to_string_pretty(&StoredWorkflowBundle::new(workflow_path, workflow_bundle)) + .map_err(|err| FabroError::engine(err.to_string()))?; + std::fs::write(&path, payload).map_err(|err| FabroError::Io(err.to_string())) +} + fn validate_sandbox_provider(settings: &Settings) -> Result<(), FabroError> { if let Some(provider) = settings .sandbox_settings() @@ -202,12 +226,14 @@ fn validate_sandbox_provider(settings: &Settings) -> Result<(), FabroError> { fn create_from_source( dot_source: &str, options: PersistCreateOptions, - base_dir: Option, + current_dir: Option, + file_resolver: Option>, goal_override: Option<&str>, ) -> Result { let validated = preprocess_and_validate( dot_source, - base_dir, + current_dir, + file_resolver, Vec::new(), Some(&options.settings), goal_override, @@ -224,7 +250,8 @@ fn create_from_source( pub(super) fn preprocess_and_validate( dot_source: &str, - base_dir: Option, + current_dir: Option, + file_resolver: Option>, custom_transforms: Vec>, settings: Option<&Settings>, goal_override: Option<&str>, @@ -245,7 +272,8 @@ pub(super) fn preprocess_and_validate( let transformed = pipeline::transform( parsed, &TransformOptions { - base_dir, + current_dir, + file_resolver, custom_transforms, }, ); @@ -368,6 +396,7 @@ mod tests { use std::time::Duration; use crate::operations::{ValidateInput, validate}; + use crate::workflow_bundle::BundledWorkflow; fn memory_store() -> StoreHandle { Arc::new(SlateStore::new( Arc::new(InMemory::new()), @@ -579,6 +608,49 @@ mod tests { assert_eq!(validated.graph().goal(), "ship it"); } + #[test] + 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 { + graph [goal="Ship"] + start [shape=Mdiamond] + validate [import="./child/validate.fabro"] + exit [shape=Msquare] + start -> validate -> exit + }"# + .to_string(), + files: HashMap::from([ + ( + PathBuf::from("child/validate.fabro"), + r#"digraph Validate { + start [shape=Mdiamond] + lint [prompt="@../prompts/lint.md"] + exit [shape=Msquare] + start -> lint -> exit + }"# + .to_string(), + ), + (PathBuf::from("prompts/lint.md"), "Lint $goal".to_string()), + ]), + }), + settings: Settings::default(), + cwd: PathBuf::from("."), + custom_transforms: Vec::new(), + }) + .unwrap(); + + validated.raise_on_errors().unwrap(); + assert_eq!( + validated.graph().nodes["validate.lint"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str), + Some("Lint Ship") + ); + } + #[tokio::test] async fn create_returns_validation_failed_with_diagnostics() { let dot = r#"digraph Test { @@ -597,6 +669,8 @@ mod tests { settings: Settings::default(), cwd: dir.path().to_path_buf(), workflow_slug: None, + workflow_path: None, + workflow_bundle: None, run_id: None, host_repo_path: None, base_branch: None, @@ -641,6 +715,8 @@ mod tests { }, cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, run_id: Some(fixtures::RUN_1), host_repo_path: Some(dir.path().display().to_string()), base_branch: Some("main".to_string()), @@ -717,6 +793,8 @@ mod tests { }, cwd: dir.path().to_path_buf(), workflow_slug: None, + workflow_path: None, + workflow_bundle: None, run_id: Some(fixtures::RUN_2), host_repo_path: None, base_branch: None, @@ -765,6 +843,8 @@ mod tests { }, cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, run_id: Some(fixtures::RUN_3), host_repo_path: None, base_branch: None, diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index 567138ead..22560b891 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -1,10 +1,14 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; use anyhow::Context; use fabro_config::project as project_config; use fabro_types::Settings; use fabro_util::path::expand_tilde; +use crate::file_resolver::{FileResolver, FilesystemFileResolver}; +use crate::workflow_bundle::BundledWorkflow; + #[derive(Clone, Debug)] pub enum WorkflowInput { Path(PathBuf), @@ -12,6 +16,7 @@ pub enum WorkflowInput { source: String, base_dir: Option, }, + Bundled(BundledWorkflow), } #[derive(Clone, Debug)] @@ -21,14 +26,15 @@ pub(crate) struct ResolveWorkflowInput { pub cwd: PathBuf, } -#[derive(Clone, Debug)] +#[derive(Clone)] pub(crate) struct ResolvedWorkflow { pub raw_source: String, pub settings: Settings, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, - pub base_dir: Option, + pub current_dir: Option, + pub file_resolver: Option>, pub goal_override: Option, pub working_directory: PathBuf, } @@ -52,6 +58,24 @@ fn resolve_goal_file( Ok(Some(content)) } +fn workflow_slug_from_path(workflow_path: &Path) -> Option { + let file_name = workflow_path.file_name()?.to_string_lossy(); + if workflow_path.extension().is_none() { + return Some(file_name.into_owned()); + } + + let file_stem = workflow_path.file_stem()?.to_string_lossy(); + if file_stem == "workflow" { + return workflow_path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .or_else(|| Some(file_stem.into_owned())); + } + + Some(file_stem.into_owned()) +} + pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result { match request.workflow { WorkflowInput::Path(workflow_path) => { @@ -65,6 +89,11 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< settings.goal_file.as_deref(), &working_directory, )?); + let current_dir = resolution + .dot_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); Ok(ResolvedWorkflow { raw_source, @@ -72,13 +101,10 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< workflow_slug: resolution.workflow_slug, workflow_toml_path: resolution.workflow_toml_path, dot_path: Some(resolution.dot_path.clone()), - base_dir: Some( - resolution - .dot_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf(), - ), + current_dir: Some(current_dir), + file_resolver: Some(Arc::new(FilesystemFileResolver::new( + dirs::home_dir().map(|home| home.join(".fabro")), + ))), goal_override, working_directory, }) @@ -91,17 +117,40 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< settings.goal_file.as_deref(), &working_directory, )?); + let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { raw_source: source, settings, workflow_slug: None, workflow_toml_path: None, dot_path: None, - base_dir, + current_dir: base_dir, + file_resolver: has_base_dir.then(|| { + Arc::new(FilesystemFileResolver::new( + dirs::home_dir().map(|home| home.join(".fabro")), + )) as Arc + }), goal_override, working_directory, }) } + WorkflowInput::Bundled(workflow) => { + let settings = request.settings; + let working_directory = + project_config::resolve_working_directory(&settings, &request.cwd); + + Ok(ResolvedWorkflow { + raw_source: workflow.source.clone(), + settings: settings.clone(), + workflow_slug: workflow_slug_from_path(&workflow.logical_path), + workflow_toml_path: None, + dot_path: Some(workflow.logical_path.clone()), + current_dir: Some(workflow.current_dir()), + file_resolver: Some(workflow.file_resolver()), + goal_override: settings.goal.clone(), + working_directory, + }) + } } } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index aa0ecddc3..ef9b1134f 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -29,6 +29,7 @@ use crate::pipeline::{ use crate::records::Checkpoint; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::run_status::{RunStatus, StatusReason}; +use crate::workflow_bundle::{StoredWorkflowBundle, WorkflowBundle}; use fabro_config::run::PullRequestSettings; use fabro_retro::retro::Retro; use fabro_sandbox::daytona::DaytonaConfig; @@ -58,6 +59,8 @@ struct RunSession { pr_github_app: Option, pr_origin_url: Option, pr_model: String, + workflow_path: Option, + workflow_bundle: Option>, } pub struct StartServices { @@ -255,6 +258,12 @@ impl RunSession { meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())), }) }); + let stored_workflow_bundle = StoredWorkflowBundle::load_from_run_dir(persisted.run_dir())?; + let workflow_path = stored_workflow_bundle + .as_ref() + .map(|bundle| bundle.workflow_path.clone()); + let workflow_bundle = + stored_workflow_bundle.map(|bundle| Arc::new(bundle.workflow_bundle())); if let Some(env) = settings .sandbox @@ -379,6 +388,8 @@ impl RunSession { pr_github_app: services.github_app, pr_origin_url: origin_url, pr_model: model, + workflow_path, + workflow_bundle, }) } } @@ -489,6 +500,8 @@ impl RunSession { interviewer: self.interviewer, lifecycle: self.lifecycle, run_options, + workflow_path: self.workflow_path, + workflow_bundle: self.workflow_bundle, hooks: self.hooks, sandbox_env: self.sandbox_env, devcontainer: self.devcontainer, @@ -851,6 +864,8 @@ mod tests { .unwrap_or_else(|| Path::new(".")) .to_path_buf(), workflow_slug: Some("test".to_string()), + workflow_path: None, + workflow_bundle: None, run_id: Some(fixtures::RUN_1), host_repo_path: None, base_branch: None, diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index a4b90561f..46c9f9e82 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/crates/fabro-workflow/src/operations/validate.rs @@ -30,7 +30,8 @@ pub fn validate(input: ValidateInput) -> Result { preprocess_and_validate( &resolved.raw_source, - resolved.base_dir, + resolved.current_dir, + resolved.file_resolver, input.custom_transforms, Some(&resolved.settings), resolved.goal_override.as_deref(), diff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs index 771cb5bd6..e45dbace4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs @@ -38,6 +38,8 @@ pub async fn execute(init: Initialized) -> Executed { graph, source: _, run_options, + workflow_path, + workflow_bundle, run_store, checkpoint, seed_context, @@ -78,6 +80,8 @@ pub async fn execute(init: Initialized) -> Executed { hook_runner: hook_runner.clone(), env, dry_run, + workflow_path, + workflow_bundle, }); let handler = Arc::new(WorkflowNodeHandler { diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index d0ed3d401..3d6a24a1b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -203,6 +203,8 @@ async fn execute_test_run_with_options( devcontainer_phases: vec![], }, run_options, + workflow_path: None, + workflow_bundle: None, hooks: HookSettings { hooks: vec![] }, sandbox_env: SandboxEnvSpec { devcontainer_env: HashMap::new(), @@ -256,6 +258,8 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { devcontainer_phases: vec![], }, run_options: test_run_options(&run_dir, "run-test"), + workflow_path: None, + workflow_bundle: None, hooks: HookSettings { hooks: vec![] }, sandbox_env: SandboxEnvSpec { devcontainer_env: HashMap::new(), @@ -319,6 +323,8 @@ async fn run_with_lifecycle( interviewer: Arc::new(AutoApproveInterviewer), lifecycle, run_options, + workflow_path: None, + workflow_bundle: None, hooks: HookSettings { hooks: vec![] }, sandbox_env: SandboxEnvSpec { devcontainer_env: HashMap::new(), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 36b96dd75..ad9db21fc 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -641,6 +641,8 @@ pub async fn initialize( graph, source, run_options: options.run_options, + workflow_path: options.workflow_path, + workflow_bundle: options.workflow_bundle, run_store: options.run_store, checkpoint: options.checkpoint, seed_context: options.seed_context, @@ -784,6 +786,8 @@ mod tests { devcontainer_phases: vec![], }, run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, hooks: fabro_hooks::HookSettings { hooks: vec![] }, sandbox_env: SandboxEnvSpec { devcontainer_env: HashMap::new(), @@ -857,6 +861,8 @@ mod tests { devcontainer_phases: vec![], }, run_options: test_settings(&run_dir), + workflow_path: None, + workflow_bundle: None, hooks: fabro_hooks::HookSettings { hooks: vec![] }, sandbox_env: SandboxEnvSpec { devcontainer_env: HashMap::new(), diff --git a/lib/crates/fabro-workflow/src/pipeline/transform.rs b/lib/crates/fabro-workflow/src/pipeline/transform.rs index a017e8cf6..ffe8e4add 100644 --- a/lib/crates/fabro-workflow/src/pipeline/transform.rs +++ b/lib/crates/fabro-workflow/src/pipeline/transform.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::transforms::{ FileInliningTransform, ImportTransform, ModelResolutionTransform, StylesheetApplicationTransform, Transform, VariableExpansionTransform, @@ -13,16 +15,18 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Transformed { let Parsed { graph, source } = parsed; // Built-in transforms (PreambleTransform moved to engine execution time) - let graph = if let Some(ref dir) = options.base_dir { - let fallback = dirs::home_dir().map(|home| home.join(".fabro")); - ImportTransform::new(dir.clone(), fallback).apply(graph) + let graph = if let (Some(current_dir), Some(file_resolver)) = + (&options.current_dir, &options.file_resolver) + { + ImportTransform::new(current_dir.clone(), Arc::clone(file_resolver)).apply(graph) } else { graph }; - let graph = if let Some(ref dir) = options.base_dir { - let fallback = dirs::home_dir().map(|home| home.join(".fabro")); - FileInliningTransform::new(dir.clone(), fallback).apply(graph) + let graph = if let (Some(current_dir), Some(file_resolver)) = + (&options.current_dir, &options.file_resolver) + { + FileInliningTransform::new(current_dir.clone(), Arc::clone(file_resolver)).apply(graph) } else { graph }; @@ -43,8 +47,10 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Transformed { #[cfg(test)] mod tests { use std::path::Path; + use std::sync::Arc; use super::*; + use crate::file_resolver::FilesystemFileResolver; use crate::pipeline::parse::parse; use fabro_graphviz::graph::AttrValue; @@ -68,7 +74,8 @@ mod tests { let transformed = transform( parsed, &TransformOptions { - base_dir: None, + current_dir: None, + file_resolver: None, custom_transforms: vec![], }, ); @@ -93,7 +100,8 @@ mod tests { let transformed = transform( parsed, &TransformOptions { - base_dir: None, + current_dir: None, + file_resolver: None, custom_transforms: vec![], }, ); @@ -121,7 +129,8 @@ mod tests { let transformed = transform( parsed, &TransformOptions { - base_dir: Some(dir.path().to_path_buf()), + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), custom_transforms: vec![], }, ); @@ -162,7 +171,8 @@ mod tests { let transformed = transform( parsed, &TransformOptions { - base_dir: Some(dir.path().to_path_buf()), + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))), custom_transforms: vec![], }, ); diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index ec3d05644..518af8401 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -18,11 +18,13 @@ use fabro_validate::Diagnostic; use crate::context::Context; use crate::error::FabroError; use crate::event::Emitter; +use crate::file_resolver::FileResolver; use crate::handler::HandlerRegistry; use crate::outcome::Outcome; use crate::records::{Checkpoint, Conclusion, RunRecord}; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::transforms::Transform; +use crate::workflow_bundle::WorkflowBundle; use fabro_config::run::PullRequestSettings; use fabro_llm::client::Client; use fabro_retro::retro::Retro; @@ -235,6 +237,8 @@ pub struct InitOptions { pub interviewer: Arc, pub lifecycle: LifecycleOptions, pub run_options: RunOptions, + pub workflow_path: Option, + pub workflow_bundle: Option>, pub hooks: fabro_hooks::HookSettings, pub sandbox_env: SandboxEnvSpec, pub devcontainer: Option, @@ -251,6 +255,8 @@ pub struct Initialized { pub graph: Graph, pub source: String, pub run_options: RunOptions, + pub workflow_path: Option, + pub workflow_bundle: Option>, pub run_store: SlateRunStore, pub(crate) checkpoint: Option, pub(crate) seed_context: Option, @@ -321,7 +327,8 @@ pub struct Finalized { /// Options for the TRANSFORM phase. pub struct TransformOptions { - pub base_dir: Option, + pub current_dir: Option, + pub file_resolver: Option>, pub custom_transforms: Vec>, } diff --git a/lib/crates/fabro-workflow/src/pipeline/validate.rs b/lib/crates/fabro-workflow/src/pipeline/validate.rs index 2f68d63f1..ed084ec0d 100644 --- a/lib/crates/fabro-workflow/src/pipeline/validate.rs +++ b/lib/crates/fabro-workflow/src/pipeline/validate.rs @@ -24,7 +24,8 @@ mod tests { let transformed = transform::transform( parsed, &TransformOptions { - base_dir: None, + current_dir: None, + file_resolver: None, custom_transforms: vec![], }, ); diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 2a8f83364..6545bfd45 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -102,6 +102,8 @@ async fn initialized( graph: graph.clone(), source: String::new(), run_options: run_options.clone(), + workflow_path: None, + workflow_bundle: None, run_store, checkpoint: options.checkpoint, seed_context: None, diff --git a/lib/crates/fabro-workflow/src/transforms/file_inlining.rs b/lib/crates/fabro-workflow/src/transforms/file_inlining.rs index e2518eca7..530fe17b2 100644 --- a/lib/crates/fabro-workflow/src/transforms/file_inlining.rs +++ b/lib/crates/fabro-workflow/src/transforms/file_inlining.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; +use crate::file_resolver::FileResolver; + use super::Transform; /// Resolve a potential `@path` file reference. @@ -9,64 +12,27 @@ use super::Transform; /// If `value` starts with `@` and the referenced file exists locally, the file /// contents are returned (inlined). Otherwise the original value is returned /// unchanged. -pub fn resolve_file_ref(value: &str, base_dir: &Path, fallback_dir: Option<&Path>) -> String { +pub fn resolve_file_ref(value: &str, current_dir: &Path, resolver: &dyn FileResolver) -> String { let Some(path_str) = value.strip_prefix('@') else { return value.to_string(); }; - - // Build the raw path: expand ~ then resolve relative to base_dir - let raw = Path::new(path_str); - let is_tilde = raw.starts_with("~"); - let expanded = if is_tilde { - match dirs::home_dir() { - Some(home) => home.join(raw.strip_prefix("~").unwrap()), - None => base_dir.join(path_str), - } - } else { - base_dir.join(path_str) - }; - - // Canonicalize resolves `.`, `..`, symlinks, and checks existence - let file_path = match expanded.canonicalize() { - Ok(p) if p.is_file() => Some(p), - _ if !is_tilde => { - // Try fallback_dir for relative (non-tilde) paths - fallback_dir.and_then(|fb| { - let fallback_path = fb.join(path_str); - match fallback_path.canonicalize() { - Ok(p) if p.is_file() => Some(p), - _ => None, - } - }) - } - _ => None, - }; - - let Some(file_path) = file_path else { - return value.to_string(); - }; - - match std::fs::read_to_string(&file_path) { - Ok(contents) => contents, - Err(e) => { - tracing::warn!(path = %file_path.display(), error = %e, "Failed to read @file reference"); - value.to_string() - } - } + resolver + .resolve(current_dir, path_str) + .map_or_else(|| value.to_string(), |resolved| resolved.content) } /// Inlines `@file` references in node prompts and the graph-level goal. pub struct FileInliningTransform { - base_dir: PathBuf, - fallback_dir: Option, + current_dir: PathBuf, + resolver: Arc, } impl FileInliningTransform { #[must_use] - pub fn new(base_dir: PathBuf, fallback_dir: Option) -> Self { + pub fn new(current_dir: PathBuf, resolver: Arc) -> Self { Self { - base_dir, - fallback_dir, + current_dir, + resolver, } } } @@ -74,12 +40,11 @@ impl FileInliningTransform { impl Transform for FileInliningTransform { fn apply(&self, graph: Graph) -> Graph { let mut graph = graph; - let fallback = self.fallback_dir.as_deref(); // Inline @file refs in node prompts for node in graph.nodes.values_mut() { if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") { - let resolved = resolve_file_ref(prompt, &self.base_dir, fallback); + let resolved = resolve_file_ref(prompt, &self.current_dir, self.resolver.as_ref()); if resolved != *prompt { node.attrs .insert("prompt".to_string(), AttrValue::String(resolved)); @@ -89,7 +54,7 @@ impl Transform for FileInliningTransform { // Inline @file refs in graph-level goal if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") { - let resolved = resolve_file_ref(goal, &self.base_dir, fallback); + let resolved = resolve_file_ref(goal, &self.current_dir, self.resolver.as_ref()); if resolved != *goal { graph .attrs @@ -103,15 +68,22 @@ impl Transform for FileInliningTransform { #[cfg(test)] mod tests { + use std::sync::Arc; + use fabro_graphviz::graph::{AttrValue, Graph, Node}; use super::*; + use crate::file_resolver::FilesystemFileResolver; #[test] fn resolve_file_ref_passthrough_non_at() { let dir = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("hello world", dir.path(), None), + resolve_file_ref( + "hello world", + dir.path(), + &FilesystemFileResolver::new(None), + ), "hello world" ); } @@ -120,7 +92,11 @@ mod tests { fn resolve_file_ref_passthrough_missing_file() { let dir = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("@nonexistent.md", dir.path(), None), + resolve_file_ref( + "@nonexistent.md", + dir.path(), + &FilesystemFileResolver::new(None), + ), "@nonexistent.md" ); } @@ -131,7 +107,7 @@ mod tests { std::fs::write(dir.path().join("prompt.md"), "inlined content").unwrap(); assert_eq!( - resolve_file_ref("@prompt.md", dir.path(), None), + resolve_file_ref("@prompt.md", dir.path(), &FilesystemFileResolver::new(None)), "inlined content" ); } @@ -175,7 +151,10 @@ mod tests { ); graph.nodes.insert("work".to_string(), node); - let transform = FileInliningTransform::new(dir.path().to_path_buf(), None); + let transform = FileInliningTransform::new( + dir.path().to_path_buf(), + Arc::new(FilesystemFileResolver::new(None)), + ); let graph = transform.apply(graph); assert_eq!( @@ -203,7 +182,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("@~/.fabro_test_tilde_tmp", dir.path(), None), + resolve_file_ref( + "@~/.fabro_test_tilde_tmp", + dir.path(), + &FilesystemFileResolver::new(None), + ), "tilde content" ); } @@ -215,7 +198,11 @@ mod tests { std::fs::create_dir(dir.path().join("subdir")).unwrap(); assert_eq!( - resolve_file_ref("@subdir/../file.md", dir.path(), None), + resolve_file_ref( + "@subdir/../file.md", + dir.path(), + &FilesystemFileResolver::new(None), + ), "dotdot content" ); } @@ -227,7 +214,11 @@ mod tests { std::fs::write(fallback.path().join("shared.md"), "shared content").unwrap(); assert_eq!( - resolve_file_ref("@shared.md", base.path(), Some(fallback.path())), + resolve_file_ref( + "@shared.md", + base.path(), + &FilesystemFileResolver::new(Some(fallback.path().to_path_buf())), + ), "shared content" ); } @@ -240,7 +231,11 @@ mod tests { std::fs::write(fallback.path().join("prompt.md"), "fallback content").unwrap(); assert_eq!( - resolve_file_ref("@prompt.md", base.path(), Some(fallback.path())), + resolve_file_ref( + "@prompt.md", + base.path(), + &FilesystemFileResolver::new(Some(fallback.path().to_path_buf())), + ), "base content" ); } @@ -255,7 +250,7 @@ mod tests { let result = resolve_file_ref( "@~/nonexistent_fabro_test.md", base.path(), - Some(fallback.path()), + &FilesystemFileResolver::new(Some(fallback.path().to_path_buf())), ); assert_eq!(result, "@~/nonexistent_fabro_test.md"); } @@ -264,7 +259,11 @@ mod tests { fn resolve_file_ref_fallback_none_behaves_as_before() { let base = tempfile::tempdir().unwrap(); assert_eq!( - resolve_file_ref("@missing.md", base.path(), None), + resolve_file_ref( + "@missing.md", + base.path(), + &FilesystemFileResolver::new(None) + ), "@missing.md" ); } @@ -285,7 +284,9 @@ mod tests { let transform = FileInliningTransform::new( base.path().to_path_buf(), - Some(fallback.path().to_path_buf()), + Arc::new(FilesystemFileResolver::new(Some( + fallback.path().to_path_buf(), + ))), ); let graph = transform.apply(graph); diff --git a/lib/crates/fabro-workflow/src/transforms/import.rs b/lib/crates/fabro-workflow/src/transforms/import.rs index c1144003b..34190f0d8 100644 --- a/lib/crates/fabro-workflow/src/transforms/import.rs +++ b/lib/crates/fabro-workflow/src/transforms/import.rs @@ -1,14 +1,17 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser; +use crate::file_resolver::{FileResolver, ResolvedFile}; + use super::{FileInliningTransform, Transform}; pub struct ImportTransform { - base_dir: PathBuf, - fallback_dir: Option, + current_dir: PathBuf, + resolver: Arc, } struct PlaceholderOptions { @@ -27,39 +30,10 @@ struct PreparedImport { impl ImportTransform { #[must_use] - pub fn new(base_dir: PathBuf, fallback_dir: Option) -> Self { + pub fn new(current_dir: PathBuf, resolver: Arc) -> Self { Self { - base_dir, - fallback_dir, - } - } - - fn resolve_import_path( - import_path: &str, - base_dir: &Path, - fallback_dir: Option<&Path>, - ) -> Option { - let raw = Path::new(import_path); - let is_tilde = raw.starts_with("~"); - let expanded = if is_tilde { - match dirs::home_dir() { - Some(home) => home.join(raw.strip_prefix("~").unwrap()), - None => base_dir.join(import_path), - } - } else { - base_dir.join(import_path) - }; - - match expanded.canonicalize() { - Ok(path) if path.is_file() => Some(path), - _ if !is_tilde => fallback_dir.and_then(|fallback| { - let fallback_path = fallback.join(import_path); - match fallback_path.canonicalize() { - Ok(path) if path.is_file() => Some(path), - _ => None, - } - }), - _ => None, + current_dir, + resolver, } } @@ -109,9 +83,7 @@ impl ImportTransform { } }; - let Some(resolved_path) = - Self::resolve_import_path(import_path, current_base_dir, self.fallback_dir.as_deref()) - else { + let Some(resolved_file) = self.resolver.resolve(current_base_dir, import_path) else { Self::poison_placeholder( graph, placeholder_id, @@ -120,10 +92,10 @@ impl ImportTransform { return; }; - if import_stack.contains(&resolved_path) { + if import_stack.contains(&resolved_file.logical_path) { let cycle = import_stack .iter() - .chain(std::iter::once(&resolved_path)) + .chain(std::iter::once(&resolved_file.logical_path)) .map(|path| path.display().to_string()) .collect::>() .join(" -> "); @@ -135,7 +107,7 @@ impl ImportTransform { return; } - let prepared = match self.prepare_import(&resolved_path, import_stack) { + let prepared = match self.prepare_import(&resolved_file, import_stack) { Ok(prepared) => prepared, Err(message) => { Self::poison_placeholder(graph, placeholder_id, &message); @@ -146,7 +118,7 @@ impl ImportTransform { if let Err(message) = Self::splice_import( graph, placeholder_id, - &resolved_path, + &resolved_file.logical_path, &placeholder, prepared, ) { @@ -156,38 +128,46 @@ impl ImportTransform { fn prepare_import( &self, - resolved_path: &Path, + resolved_file: &ResolvedFile, import_stack: &mut Vec, ) -> Result { - Self::with_import_stack(import_stack, resolved_path.to_path_buf(), |import_stack| { - let source = std::fs::read_to_string(resolved_path) - .map_err(|error| format!("failed to read {}: {error}", resolved_path.display()))?; - let mut graph = parser::parse(&source) - .map_err(|error| format!("failed to parse {}: {error}", resolved_path.display()))?; + Self::with_import_stack( + import_stack, + resolved_file.logical_path.clone(), + |import_stack| { + let mut graph = parser::parse(&resolved_file.content).map_err(|error| { + format!( + "failed to parse {}: {error}", + resolved_file.logical_path.display() + ) + })?; - let import_base_dir = resolved_path - .parent() - .map_or_else(|| PathBuf::from("."), Path::to_path_buf); - graph = FileInliningTransform::new(import_base_dir.clone(), self.fallback_dir.clone()) - .apply(graph); + 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); - if let Some(message) = Self::unresolved_imported_prompt_error(&graph) { - return Err(message); - } + if let Some(message) = Self::unresolved_imported_prompt_error(&graph) { + return Err(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) - }) + Self::validate_imported_graph(graph) + }, + ) } fn splice_import( @@ -595,7 +575,7 @@ impl Transform for ImportTransform { &mut graph, &placeholder_id, &import_path, - &self.base_dir, + &self.current_dir, &mut import_stack, ); } @@ -607,11 +587,13 @@ impl Transform for ImportTransform { #[cfg(test)] mod tests { use std::path::Path; + use std::sync::Arc; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use super::*; + use crate::file_resolver::FilesystemFileResolver; fn parse_graph(source: &str) -> Graph { parser::parse(source).unwrap() @@ -626,8 +608,13 @@ mod tests { fn apply_import(dot: &str, base_dir: &Path, fallback_dir: Option<&Path>) -> Graph { let graph = parse_graph(dot); - ImportTransform::new(base_dir.to_path_buf(), fallback_dir.map(Path::to_path_buf)) - .apply(graph) + ImportTransform::new( + base_dir.to_path_buf(), + Arc::new(FilesystemFileResolver::new( + fallback_dir.map(Path::to_path_buf), + )), + ) + .apply(graph) } fn basic_import_source() -> &'static str { @@ -1329,7 +1316,11 @@ mod tests { graph .nodes .insert("validate.lint".to_string(), colliding_node); - let graph = ImportTransform::new(dir.path().to_path_buf(), None).apply(graph); + let graph = ImportTransform::new( + dir.path().to_path_buf(), + Arc::new(FilesystemFileResolver::new(None)), + ) + .apply(graph); assert_eq!( graph.nodes["validate"] diff --git a/lib/crates/fabro-workflow/src/workflow_bundle.rs b/lib/crates/fabro-workflow/src/workflow_bundle.rs new file mode 100644 index 000000000..5601e4b73 --- /dev/null +++ b/lib/crates/fabro-workflow/src/workflow_bundle.rs @@ -0,0 +1,96 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::error::FabroError; +use crate::file_resolver::{BundleFileResolver, FileResolver, normalize_logical_path}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct BundledWorkflow { + pub logical_path: PathBuf, + pub source: String, + pub files: HashMap, +} + +impl BundledWorkflow { + #[must_use] + pub fn file_resolver(&self) -> Arc { + Arc::new(BundleFileResolver::new(self.files.clone())) + } + + #[must_use] + pub fn current_dir(&self) -> PathBuf { + self.logical_path + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct WorkflowBundle { + workflows: HashMap, +} + +impl WorkflowBundle { + #[must_use] + pub fn new(workflows: HashMap) -> Self { + Self { workflows } + } + + pub fn workflow(&self, logical_path: &Path) -> Option<&BundledWorkflow> { + self.workflows.get(logical_path) + } + + pub fn resolve_child( + &self, + current_workflow_path: &Path, + 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) + } + + #[must_use] + pub fn workflows(&self) -> &HashMap { + &self.workflows + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StoredWorkflowBundle { + pub workflow_path: PathBuf, + pub workflows: HashMap, +} + +impl StoredWorkflowBundle { + #[must_use] + pub fn new(workflow_path: PathBuf, bundle: WorkflowBundle) -> Self { + Self { + workflow_path, + workflows: bundle.workflows, + } + } + + #[must_use] + pub fn workflow_bundle(&self) -> WorkflowBundle { + WorkflowBundle::new(self.workflows.clone()) + } + + pub fn load_from_run_dir(run_dir: &Path) -> Result, FabroError> { + let path = run_dir.join("workflow_bundle.json"); + if !path.exists() { + return Ok(None); + } + + let payload = + std::fs::read_to_string(&path).map_err(|err| FabroError::Io(err.to_string()))?; + serde_json::from_str(&payload) + .map(Some) + .map_err(|err| FabroError::Parse(err.to_string())) + } +} diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 86aca156c..ef9313bd4 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -4003,7 +4003,10 @@ async fn import_e2e_through_engine() { let transformed = transform( parsed, &TransformOptions { - base_dir: Some(dir.path().to_path_buf()), + current_dir: Some(dir.path().to_path_buf()), + file_resolver: Some(std::sync::Arc::new( + fabro_workflow::file_resolver::FilesystemFileResolver::new(None), + )), custom_transforms: vec![], }, ); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 716911b73..94ac7ef7d 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -47,7 +47,6 @@ models/control-info.ts models/control-performance.ts models/control-reference.ts models/create-completion-request.ts -models/create-run-request.ts models/create-session-request.ts models/create-session-response.ts models/create-signoff-request.ts @@ -87,6 +86,15 @@ models/learning.ts models/llm-settings.ts models/local-sandbox-settings.ts models/log-settings.ts +models/manifest-args.ts +models/manifest-config.ts +models/manifest-file-entry.ts +models/manifest-file-ref.ts +models/manifest-git.ts +models/manifest-goal.ts +models/manifest-target.ts +models/manifest-workflow-config.ts +models/manifest-workflow.ts models/mcp-server-entry.ts models/model-costs.ts models/model-features.ts @@ -116,11 +124,20 @@ models/paginated-verification-control-list.ts models/paginated-verification-criterion-list.ts models/paginated-workflow-list.ts models/pagination-meta.ts +models/preflight-check-detail.ts +models/preflight-check-report.ts +models/preflight-check-result.ts +models/preflight-check-section.ts +models/preflight-response.ts +models/preflight-workflow-summary.ts models/preview-url-request.ts models/preview-url-response.ts models/pull-request-settings.ts models/question-type.ts models/recent-control-result.ts +models/render-workflow-graph-direction.ts +models/render-workflow-graph-format.ts +models/render-workflow-graph-request.ts models/repo-check-response-permissions.ts models/repo-check-response.ts models/repository-reference.ts @@ -133,6 +150,7 @@ models/run-checkpoint.ts models/run-error.ts models/run-event.ts models/run-list-item.ts +models/run-manifest.ts models/run-projection-checkpoints-inner-inner.ts models/run-projection.ts models/run-pull-request.ts @@ -197,6 +215,7 @@ models/verification-type.ts models/web-settings.ts models/webhook-settings.ts models/workflow-detail.ts +models/workflow-diagnostic.ts models/workflow-last-run.ts models/workflow-list-item.ts models/workflow-reference.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index f01db1e58..7aa4f2a50 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -22,12 +22,16 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore -import type { CreateRunRequest } from '../models'; -// @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore import type { PaginatedRunList } from '../models'; // @ts-ignore +import type { PreflightResponse } from '../models'; +// @ts-ignore +import type { RenderWorkflowGraphRequest } from '../models'; +// @ts-ignore +import type { RunManifest } from '../models'; +// @ts-ignore import type { RunStatusResponse } from '../models'; // @ts-ignore import type { StartRunRequest } from '../models'; @@ -80,15 +84,15 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution. + * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {CreateRunRequest} createRunRequest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createRun: async (createRunRequest: CreateRunRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'createRunRequest' is not null or undefined - assertParamExists('createRun', 'createRunRequest', createRunRequest) + createRun: async (runManifest: RunManifest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'runManifest' is not null or undefined + assertParamExists('createRun', 'runManifest', runManifest) const localVarPath = `/api/v1/runs`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -114,7 +118,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(createRunRequest, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(runManifest, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -287,6 +291,48 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Validates and renders a workflow manifest as SVG or PNG without creating a run. + * @summary Render Workflow Graph + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + renderWorkflowGraph: async (renderWorkflowGraphRequest: RenderWorkflowGraphRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'renderWorkflowGraphRequest' is not null or undefined + assertParamExists('renderWorkflowGraph', 'renderWorkflowGraphRequest', renderWorkflowGraphRequest) + const localVarPath = `/api/v1/graph/render`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'image/svg+xml,image/png,application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(renderWorkflowGraphRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns the durable run summary for a run. * @summary Retrieve Run @@ -369,6 +415,48 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Validates a workflow manifest without creating a run. + * @summary Validate Workflow Manifest + * @param {RunManifest} runManifest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runPreflight: async (runManifest: RunManifest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'runManifest' is not null or undefined + assertParamExists('runPreflight', 'runManifest', runManifest) + const localVarPath = `/api/v1/preflight`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(runManifest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run @@ -477,14 +565,14 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution. + * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {CreateRunRequest} createRunRequest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(createRunRequest, options); + async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(runManifest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.createRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -541,6 +629,19 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.pauseRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Validates and renders a workflow manifest as SVG or PNG without creating a run. + * @summary Render Workflow Graph + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async renderWorkflowGraph(renderWorkflowGraphRequest: RenderWorkflowGraphRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.renderWorkflowGraph(renderWorkflowGraphRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.renderWorkflowGraph']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns the durable run summary for a run. * @summary Retrieve Run @@ -567,6 +668,19 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraph']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Validates a workflow manifest without creating a run. + * @summary Validate Workflow Manifest + * @param {RunManifest} runManifest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async runPreflight(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.runPreflight(runManifest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.runPreflight']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run @@ -614,14 +728,14 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.cancelRun(id, options).then((request) => request(axios, basePath)); }, /** - * Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution. + * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {CreateRunRequest} createRunRequest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.createRun(createRunRequest, options).then((request) => request(axios, basePath)); + createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createRun(runManifest, options).then((request) => request(axios, basePath)); }, /** * Deletes durable store state for a run. This does not remove any local run directory. @@ -663,6 +777,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? pauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.pauseRun(id, options).then((request) => request(axios, basePath)); }, + /** + * Validates and renders a workflow manifest as SVG or PNG without creating a run. + * @summary Render Workflow Graph + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + renderWorkflowGraph(renderWorkflowGraphRequest: RenderWorkflowGraphRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.renderWorkflowGraph(renderWorkflowGraphRequest, options).then((request) => request(axios, basePath)); + }, /** * Returns the durable run summary for a run. * @summary Retrieve Run @@ -683,6 +807,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? retrieveRunGraph(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath)); }, + /** + * Validates a workflow manifest without creating a run. + * @summary Validate Workflow Manifest + * @param {RunManifest} runManifest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runPreflight(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.runPreflight(runManifest, options).then((request) => request(axios, basePath)); + }, /** * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run @@ -723,14 +857,14 @@ export class RunsApi extends BaseAPI { } /** - * Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution. + * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {CreateRunRequest} createRunRequest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public createRun(createRunRequest: CreateRunRequest, options?: RawAxiosRequestConfig) { - return RunsApiFp(this.configuration).createRun(createRunRequest, options).then((request) => request(this.axios, this.basePath)); + public createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).createRun(runManifest, options).then((request) => request(this.axios, this.basePath)); } /** @@ -777,6 +911,17 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).pauseRun(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Validates and renders a workflow manifest as SVG or PNG without creating a run. + * @summary Render Workflow Graph + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public renderWorkflowGraph(renderWorkflowGraphRequest: RenderWorkflowGraphRequest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).renderWorkflowGraph(renderWorkflowGraphRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns the durable run summary for a run. * @summary Retrieve Run @@ -799,6 +944,17 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).retrieveRunGraph(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Validates a workflow manifest without creating a run. + * @summary Validate Workflow Manifest + * @param {RunManifest} runManifest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public runPreflight(runManifest: RunManifest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).runPreflight(runManifest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 97c8ce226..1b8d472f3 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -26,7 +26,6 @@ export * from './control-info'; export * from './control-performance'; export * from './control-reference'; export * from './create-completion-request'; -export * from './create-run-request'; export * from './create-session-request'; export * from './create-session-response'; export * from './create-signoff-request'; @@ -65,6 +64,15 @@ export * from './learning-category'; export * from './llm-settings'; export * from './local-sandbox-settings'; export * from './log-settings'; +export * from './manifest-args'; +export * from './manifest-config'; +export * from './manifest-file-entry'; +export * from './manifest-file-ref'; +export * from './manifest-git'; +export * from './manifest-goal'; +export * from './manifest-target'; +export * from './manifest-workflow'; +export * from './manifest-workflow-config'; export * from './mcp-server-entry'; export * from './model'; export * from './model-costs'; @@ -94,11 +102,20 @@ export * from './paginated-verification-control-list'; export * from './paginated-verification-criterion-list'; export * from './paginated-workflow-list'; export * from './pagination-meta'; +export * from './preflight-check-detail'; +export * from './preflight-check-report'; +export * from './preflight-check-result'; +export * from './preflight-check-section'; +export * from './preflight-response'; +export * from './preflight-workflow-summary'; export * from './preview-url-request'; export * from './preview-url-response'; export * from './pull-request-settings'; export * from './question-type'; export * from './recent-control-result'; +export * from './render-workflow-graph-direction'; +export * from './render-workflow-graph-format'; +export * from './render-workflow-graph-request'; export * from './repo-check-response'; export * from './repo-check-response-permissions'; export * from './repository-reference'; @@ -111,6 +128,7 @@ export * from './run-checkpoint'; export * from './run-error'; export * from './run-event'; export * from './run-list-item'; +export * from './run-manifest'; export * from './run-projection'; export * from './run-projection-checkpoints-inner-inner'; export * from './run-pull-request'; @@ -175,6 +193,7 @@ export * from './verification-type'; export * from './web-settings'; export * from './webhook-settings'; export * from './workflow-detail'; +export * from './workflow-diagnostic'; export * from './workflow-last-run'; export * from './workflow-list-item'; export * from './workflow-reference'; diff --git a/lib/packages/fabro-api-client/src/models/manifest-args.ts b/lib/packages/fabro-api-client/src/models/manifest-args.ts new file mode 100644 index 000000000..23f69b151 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-args.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Sparse command-local args that affect run settings. + */ +export interface ManifestArgs { + 'model'?: string; + 'provider'?: string; + 'sandbox'?: string; + 'verbose'?: boolean; + 'dry_run'?: boolean; + 'auto_approve'?: boolean; + 'no_retro'?: boolean; + 'preserve_sandbox'?: boolean; + 'label'?: Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/manifest-config.ts b/lib/packages/fabro-api-client/src/models/manifest-config.ts new file mode 100644 index 000000000..91585688c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-config.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ManifestConfig { + 'type': ManifestConfigTypeEnum; + 'path'?: string; + 'source'?: string; +} + +export const ManifestConfigTypeEnum = { + PROJECT: 'project', + USER: 'user' +} as const; + +export type ManifestConfigTypeEnum = typeof ManifestConfigTypeEnum[keyof typeof ManifestConfigTypeEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/manifest-file-entry.ts b/lib/packages/fabro-api-client/src/models/manifest-file-entry.ts new file mode 100644 index 000000000..55d0abd8e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-file-entry.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestFileRef } from './manifest-file-ref'; + +/** + * A bundled file with discovery metadata. + */ +export interface ManifestFileEntry { + 'content': string; + 'ref': ManifestFileRef; +} + diff --git a/lib/packages/fabro-api-client/src/models/manifest-file-ref.ts b/lib/packages/fabro-api-client/src/models/manifest-file-ref.ts new file mode 100644 index 000000000..9ff779047 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-file-ref.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ManifestFileRef { + 'type': ManifestFileRefTypeEnum; + 'original': string; + 'from'?: string; +} + +export const ManifestFileRefTypeEnum = { + FILE_INLINE: 'file_inline', + IMPORT: 'import', + DOCKERFILE: 'dockerfile' +} as const; + +export type ManifestFileRefTypeEnum = typeof ManifestFileRefTypeEnum[keyof typeof ManifestFileRefTypeEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/manifest-git.ts b/lib/packages/fabro-api-client/src/models/manifest-git.ts new file mode 100644 index 000000000..c800b7fb7 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-git.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Observable git state from the CLI working directory. + */ +export interface ManifestGit { + /** + * Remote origin URL with any embedded credentials removed. + */ + 'origin_url': string; + /** + * Current branch name. + */ + 'branch': string; + /** + * Current commit SHA. + */ + 'sha': string; + /** + * Whether the working tree has uncommitted changes. + */ + 'clean': boolean; +} + diff --git a/lib/packages/fabro-api-client/src/models/manifest-goal.ts b/lib/packages/fabro-api-client/src/models/manifest-goal.ts new file mode 100644 index 000000000..397ef8317 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-goal.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Resolved goal with provenance. + */ +export interface ManifestGoal { + 'type': ManifestGoalTypeEnum; + /** + * Resolved goal content. + */ + 'text': string; + /** + * Original goal file path when the goal came from a file. + */ + 'path'?: string; +} + +export const ManifestGoalTypeEnum = { + VALUE: 'value', + FILE: 'file', + GRAPH: 'graph' +} as const; + +export type ManifestGoalTypeEnum = typeof ManifestGoalTypeEnum[keyof typeof ManifestGoalTypeEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/manifest-target.ts b/lib/packages/fabro-api-client/src/models/manifest-target.ts new file mode 100644 index 000000000..166d597e6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-target.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ManifestTarget { + /** + * What the user typed. + */ + 'identifier': string; + /** + * Resolved path that keys into the workflows map. + */ + 'path': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/manifest-workflow-config.ts b/lib/packages/fabro-api-client/src/models/manifest-workflow-config.ts new file mode 100644 index 000000000..55d18bdfe --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-workflow-config.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ManifestWorkflowConfig { + 'path': string; + 'source': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/manifest-workflow.ts b/lib/packages/fabro-api-client/src/models/manifest-workflow.ts new file mode 100644 index 000000000..92edc9b2d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-workflow.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestFileEntry } from './manifest-file-entry'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestWorkflowConfig } from './manifest-workflow-config'; + +export interface ManifestWorkflow { + 'source': string; + 'config'?: ManifestWorkflowConfig; + 'files'?: { [key: string]: ManifestFileEntry; }; +} + diff --git a/lib/packages/fabro-api-client/src/models/preflight-check-detail.ts b/lib/packages/fabro-api-client/src/models/preflight-check-detail.ts new file mode 100644 index 000000000..721d38c42 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-check-detail.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PreflightCheckDetail { + 'text': string; + 'warn': boolean; +} + diff --git a/lib/packages/fabro-api-client/src/models/preflight-check-report.ts b/lib/packages/fabro-api-client/src/models/preflight-check-report.ts new file mode 100644 index 000000000..ed8ce1781 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-check-report.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PreflightCheckSection } from './preflight-check-section'; + +export interface PreflightCheckReport { + 'title': string; + 'sections': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/preflight-check-result.ts b/lib/packages/fabro-api-client/src/models/preflight-check-result.ts new file mode 100644 index 000000000..2faf5771e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-check-result.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PreflightCheckDetail } from './preflight-check-detail'; + +export interface PreflightCheckResult { + 'name': string; + 'status': PreflightCheckResultStatusEnum; + 'summary': string; + 'details': Array; + 'remediation'?: string; +} + +export const PreflightCheckResultStatusEnum = { + PASS: 'pass', + WARNING: 'warning', + ERROR: 'error' +} as const; + +export type PreflightCheckResultStatusEnum = typeof PreflightCheckResultStatusEnum[keyof typeof PreflightCheckResultStatusEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/preflight-check-section.ts b/lib/packages/fabro-api-client/src/models/preflight-check-section.ts new file mode 100644 index 000000000..c83b985ff --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-check-section.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PreflightCheckResult } from './preflight-check-result'; + +export interface PreflightCheckSection { + 'title': string; + 'checks': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/preflight-response.ts b/lib/packages/fabro-api-client/src/models/preflight-response.ts new file mode 100644 index 000000000..2cf34058a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-response.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PreflightCheckReport } from './preflight-check-report'; +// May contain unused imports in some cases +// @ts-ignore +import type { PreflightWorkflowSummary } from './preflight-workflow-summary'; + +export interface PreflightResponse { + /** + * Whether preflight passed using the CLI-compatible success rule. + */ + 'ok': boolean; + 'workflow': PreflightWorkflowSummary; + 'checks': PreflightCheckReport; +} + diff --git a/lib/packages/fabro-api-client/src/models/preflight-workflow-summary.ts b/lib/packages/fabro-api-client/src/models/preflight-workflow-summary.ts new file mode 100644 index 000000000..e5ecdba75 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/preflight-workflow-summary.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { WorkflowDiagnostic } from './workflow-diagnostic'; + +export interface PreflightWorkflowSummary { + 'name': string; + 'graph_path'?: string; + 'nodes': number; + 'edges': number; + 'goal': string; + 'diagnostics': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/render-workflow-graph-direction.ts b/lib/packages/fabro-api-client/src/models/render-workflow-graph-direction.ts new file mode 100644 index 000000000..a466e72d6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/render-workflow-graph-direction.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const RenderWorkflowGraphDirection = { + LR: 'lr', + TB: 'tb' +} as const; + +export type RenderWorkflowGraphDirection = typeof RenderWorkflowGraphDirection[keyof typeof RenderWorkflowGraphDirection]; + + + diff --git a/lib/packages/fabro-api-client/src/models/render-workflow-graph-format.ts b/lib/packages/fabro-api-client/src/models/render-workflow-graph-format.ts new file mode 100644 index 000000000..be948c821 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/render-workflow-graph-format.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const RenderWorkflowGraphFormat = { + SVG: 'svg', + PNG: 'png' +} as const; + +export type RenderWorkflowGraphFormat = typeof RenderWorkflowGraphFormat[keyof typeof RenderWorkflowGraphFormat]; + + + diff --git a/lib/packages/fabro-api-client/src/models/render-workflow-graph-request.ts b/lib/packages/fabro-api-client/src/models/render-workflow-graph-request.ts new file mode 100644 index 000000000..4585ccbce --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/render-workflow-graph-request.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { RenderWorkflowGraphDirection } from './render-workflow-graph-direction'; +// May contain unused imports in some cases +// @ts-ignore +import type { RenderWorkflowGraphFormat } from './render-workflow-graph-format'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunManifest } from './run-manifest'; + +export interface RenderWorkflowGraphRequest { + 'manifest': RunManifest; + 'format'?: RenderWorkflowGraphFormat; + 'direction'?: RenderWorkflowGraphDirection; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/run-manifest.ts b/lib/packages/fabro-api-client/src/models/run-manifest.ts new file mode 100644 index 000000000..cfac73005 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-manifest.ts @@ -0,0 +1,58 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestArgs } from './manifest-args'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestConfig } from './manifest-config'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestGit } from './manifest-git'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestGoal } from './manifest-goal'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestTarget } from './manifest-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { ManifestWorkflow } from './manifest-workflow'; + +/** + * Self-contained workflow run manifest. + */ +export interface RunManifest { + /** + * Manifest schema version. + */ + 'version': number; + /** + * Optional pre-generated run ID to use instead of allocating a new ULID. + */ + 'run_id'?: string; + /** + * CLI working directory at invocation time. + */ + 'cwd': string; + 'git'?: ManifestGit; + 'goal'?: ManifestGoal; + 'args'?: ManifestArgs; + 'target': ManifestTarget; + 'configs'?: Array; + 'workflows': { [key: string]: ManifestWorkflow; }; +} + diff --git a/lib/packages/fabro-api-client/src/models/workflow-diagnostic.ts b/lib/packages/fabro-api-client/src/models/workflow-diagnostic.ts new file mode 100644 index 000000000..cb15e046f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/workflow-diagnostic.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface WorkflowDiagnostic { + 'rule': string; + 'severity': WorkflowDiagnosticSeverityEnum; + 'message': string; + 'node_id'?: string; + 'edge'?: Array; + 'fix'?: string; +} + +export const WorkflowDiagnosticSeverityEnum = { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info' +} as const; + +export type WorkflowDiagnosticSeverityEnum = typeof WorkflowDiagnosticSeverityEnum[keyof typeof WorkflowDiagnosticSeverityEnum]; + +