refactor(server): submit runs and preflight via manifests

This commit is contained in:
Bryan Helmkamp 2026-04-05 20:07:10 -04:00
parent 9ddcc13b66
commit bd70f805be
No known key found for this signature in database
67 changed files with 3987 additions and 969 deletions

1
Cargo.lock generated
View file

@ -1871,6 +1871,7 @@ dependencies = [
"fabro-store",
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-workflow",
"futures-util",
"hex",

View file

@ -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.

View file

@ -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,

View file

@ -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<regex::Regex> =
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<GraphDirection>) -> Cow<'_, str> {
match direction {
Some(dir) => {
let replacement = format!("rankdir={dir}");
RANKDIR_RE.replace(source, replacement.as_str())
}
None => Cow::Borrowed(source),
}
}

View file

@ -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<String>) {
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::<Provider>().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<Option<SandboxProvider>> {
settings
.sandbox_settings()
.and_then(|s| s.provider.as_deref())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}"))
}
fn resolve_sandbox_provider(
cli: Option<SandboxProvider>,
settings: &Settings,
) -> anyhow::Result<SandboxProvider> {
Ok(cli
.or(parse_sandbox_provider(settings)?)
.unwrap_or_default())
}
fn resolve_daytona_config(settings: &Settings) -> Option<DaytonaConfig> {
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<String, String>,
) -> anyhow::Result<String> {
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<fabro_github::GitHubAppCredentials>,
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<CheckResult> = 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<Arc<dyn Sandbox>, 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<String> = 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::<Provider>() {
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<CheckDetail> = 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))
}

View file

@ -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))

View file

@ -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(|| "<inline>".to_string());
let diagnostics = workflow
.diagnostics
.iter()
.map(api_diagnostic_to_local)
.collect::<Vec<_>>();
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(|| "<inline>".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<fabro_validate::Diagnostic> {
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(

View file

@ -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");
}

View file

@ -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)?;

View file

@ -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<types::ManifestArgs>,
pub run_id: Option<RunId>,
}
#[derive(Debug)]
pub(crate) struct BuiltManifest {
pub manifest: types::RunManifest,
pub target_path: PathBuf,
}
struct CollectContext<'a> {
cwd: &'a Path,
workflows: HashMap<String, types::ManifestWorkflow>,
visited_workflows: HashSet<String>,
}
#[derive(Clone)]
struct WorkflowScanInput {
absolute_dot_path: PathBuf,
logical_dot_path: PathBuf,
source: String,
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
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<types::ManifestArgs> {
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<types::ManifestArgs> {
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<String, types::ManifestFileEntry>,
visited_imports: &mut HashSet<String>,
) -> 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<String, types::ManifestFileEntry>,
) -> 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<String, types::ManifestFileEntry>,
base_dir: &Path,
cwd: &Path,
reference: &str,
ref_type: types::ManifestFileRefType,
from: Option<PathBuf>,
) -> Result<BundledFile> {
let absolute_path = normalize_absolute_path(base_dir, reference)
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
let logical_path = to_logical_path(&absolute_path, cwd)?;
let key = logical_path_string(&logical_path);
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<Option<types::ManifestGoal>> {
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<types::ManifestGit> {
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<PathBuf> {
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<PathBuf> {
if let Ok(stripped) = path.strip_prefix(cwd) {
return Ok(stripped.to_path_buf());
}
relative_path_from(path, cwd)
.ok_or_else(|| anyhow!("Failed to compute logical path for {}", path.display()))
}
fn relative_path_from(path: &Path, base: &Path) -> Option<PathBuf> {
let path_components = path.components().collect::<Vec<_>>();
let base_components = base.components().collect::<Vec<_>>();
if path_components.is_empty() || base_components.is_empty() {
return None;
}
let mut common = 0;
while common < path_components.len()
&& common < base_components.len()
&& path_components[common] == base_components[common]
{
common += 1;
}
let mut relative = PathBuf::new();
for component in &base_components[common..] {
if matches!(component, Component::Normal(_)) {
relative.push("..");
}
}
for component in &path_components[common..] {
match component {
Component::Normal(part) => relative.push(part),
Component::CurDir => {}
Component::ParentDir => relative.push(".."),
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(relative)
}
fn logical_path_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn manifest_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")
);
}
}

View file

@ -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<ServerStoreClie
})
}
pub(crate) async fn connect_server_backed(
args: &ServerConnectionArgs,
) -> Result<ServerStoreClient> {
Ok(ServerStoreClient {
client: connect_server_backed_api_client(args).await?,
})
}
pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result<fabro_api::Client> {
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<RunId> {
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<types::PreflightResponse> {
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<Vec<u8>> {
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()

View file

@ -20,25 +20,45 @@ fn help() {
Path to the .fabro workflow file, .toml task config, or project workflow name
Options:
--format <FORMAT>
Output format
[default: svg]
[possible values: svg, png]
--json
Output as JSON
[env: FABRO_JSON=]
--storage-dir <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 <SERVER>
Fabro server target: http(s) URL or absolute Unix socket path
[env: FABRO_SERVER=]
--format <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>
Output file path (defaults to stdout)
--quiet
Suppress non-essential output
[env: FABRO_QUIET=]
-d, --direction <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

View file

@ -23,11 +23,12 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--goal <GOAL> Override the workflow goal (exposed as $goal in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--model <MODEL> Override default LLM model
--goal-file <GOAL_FILE> Read the workflow goal from a file
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]

View file

@ -24,12 +24,14 @@ fn help() {
<WORKFLOW> 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 <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 <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 -----
");
}

View file

@ -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" }

View file

@ -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(

View file

@ -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;

View file

@ -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<types::ManifestGit>,
pub root_source: String,
pub run_id: Option<RunId>,
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<PreparedManifest> {
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::<RunId>)
.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<Validated, FabroError> {
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<String, types::ManifestWorkflow>,
) -> Result<WorkflowBundle> {
let workflows = workflows
.iter()
.map(|(path, workflow)| {
let files = workflow
.files
.iter()
.map(|(key, entry)| (PathBuf::from(key), entry.content.clone()))
.collect::<HashMap<_, _>>();
Ok::<_, anyhow::Error>((
PathBuf::from(path),
BundledWorkflow {
logical_path: PathBuf::from(path),
source: workflow.source.clone(),
files,
},
))
})
.collect::<Result<HashMap<_, _>>>()?;
Ok(WorkflowBundle::new(workflows))
}
fn root_workflow_config_layer(
manifest: &types::RunManifest,
workflow: &BundledWorkflow,
) -> Result<ConfigLayer> {
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<ConfigLayer> {
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<PathBuf, String>,
) -> 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<ConfigLayer> {
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<String, String> {
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<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
}
let mut normalized = PathBuf::new();
for component in current_dir.join(path).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
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<SandboxProvider> {
Ok(settings
.sandbox_settings()
.and_then(|sandbox| sandbox.provider.as_deref())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|err| anyhow!("Invalid sandbox provider: {err}"))?
.unwrap_or_default())
}
fn resolve_daytona_config(settings: &Settings) -> Option<DaytonaConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.daytona.clone())
}
async fn run_sandbox_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProvider,
prepared: &PreparedManifest,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> bool {
let daytona_config = resolve_daytona_config(&prepared.settings);
let sandbox_result: Result<Arc<dyn Sandbox>, 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<CheckResult>,
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::<Vec<_>>();
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::<Provider>() {
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<String>) {
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::<Provider>().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<CheckResult>,
prepared: &PreparedManifest,
settings: &Settings,
github_app: Option<fabro_github::GitHubAppCredentials>,
) {
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::<Vec<_>>();
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<String, String>,
) -> Result<String> {
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<types::WorkflowDiagnostic> {
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));
}
}

View file

@ -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"))
);
}
}

View file

@ -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<AppState>, auth_mode: AuthMode) -> Router {
fn demo_routes() -> Router<Arc<AppState>> {
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<Arc<AppState>> {
fn real_routes() -> Router<Arc<AppState>> {
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<Arc<AppState>>,
Json(req): Json<CreateRunRequest>,
Json(req): Json<RunManifest>,
) -> Response {
let run_id = match req.run_id.as_deref() {
Some(raw) => match raw.parse::<RunId>() {
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>(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<Arc<AppState>>,
Json(req): Json<RunManifest>,
) -> 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<Arc<AppState>>,
Json(req): Json<RenderWorkflowGraphRequest>,
) -> 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<Arc<AppState>>,
@ -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("<?xml") || svg.contains("<svg"),
"expected SVG content, got: {}",
&svg[..svg.len().min(200)]
);
}
#[tokio::test]
async fn get_graph_not_found() {
let app = test_app_with();
@ -3573,9 +3645,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();
@ -3607,9 +3677,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();
@ -3665,9 +3733,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();
@ -3738,9 +3804,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();
@ -3766,6 +3830,7 @@ mod tests {
.expect("run record should exist");
let mut expected_settings = settings;
expected_settings.goal = Some("Test".to_string());
expected_settings.dry_run = None;
assert_eq!(run_record.settings, expected_settings);
}
@ -3780,9 +3845,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();
@ -3983,9 +4046,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();

View file

@ -68,13 +68,18 @@ pub(crate) async fn body_json(body: Body) -> 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")

View file

@ -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();

View file

@ -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<dyn Interviewer>) -> 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();

View file

@ -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;

View file

@ -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<ResolvedFile>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedFile {
pub logical_path: PathBuf,
pub content: String,
}
#[derive(Clone, Debug, Default)]
pub struct BundleFileResolver {
files: HashMap<PathBuf, String>,
}
impl BundleFileResolver {
#[must_use]
pub fn new(files: HashMap<PathBuf, String>) -> Self {
Self { files }
}
}
impl FileResolver for BundleFileResolver {
fn resolve(&self, current_dir: &Path, reference: &str) -> Option<ResolvedFile> {
let logical_path = normalize_logical_path(current_dir, reference)?;
self.files.get(&logical_path).map(|content| ResolvedFile {
logical_path,
content: content.clone(),
})
}
}
pub(crate) fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {
return None;
}
let mut normalized = PathBuf::new();
for component in current_dir.join(path).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
normalized.pop();
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(normalized)
}
#[derive(Clone, Debug, Default)]
pub struct FilesystemFileResolver {
fallback_dir: Option<PathBuf>,
}
impl FilesystemFileResolver {
#[must_use]
pub fn new(fallback_dir: Option<PathBuf>) -> Self {
Self { fallback_dir }
}
}
impl FileResolver for FilesystemFileResolver {
fn resolve(&self, current_dir: &Path, reference: &str) -> Option<ResolvedFile> {
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());
}
}

View file

@ -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<PathBuf>,
}
/// 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<Graph, FabroError> {
fn parse_child_graph(
node: &Node,
services: &EngineServices,
) -> Result<ParsedChildWorkflow, FabroError> {
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<Graph, FabroError> {
})?;
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<Graph, FabroError> {
.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

View file

@ -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<String, String>,
/// When true, handlers should skip real execution and return simulated results.
pub dry_run: bool,
/// Logical path of the current workflow when running from a bundle.
pub workflow_path: Option<PathBuf>,
/// Bundled workflows available for child-workflow resolution.
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
}
impl EngineServices {
@ -106,6 +111,8 @@ impl EngineServices {
hook_runner: None,
env: HashMap::new(),
dry_run: false,
workflow_path: None,
workflow_bundle: None,
}
}
}

View file

@ -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(

View file

@ -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;

View file

@ -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<String>,
pub workflow_path: Option<PathBuf>,
pub workflow_bundle: Option<WorkflowBundle>,
pub run_id: Option<RunId>,
pub host_repo_path: Option<String>,
pub base_branch: Option<String>,
@ -68,6 +73,8 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
settings: _,
cwd: _,
workflow_slug,
workflow_path,
workflow_bundle,
run_id,
host_repo_path,
base_branch,
@ -87,7 +94,8 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
});
let goal_override = resolved.goal_override.clone();
let base_dir = resolved.base_dir.clone();
let current_dir = resolved.current_dir.clone();
let file_resolver = resolved.file_resolver.clone();
let persisted = create_from_source(
&resolved.raw_source,
@ -101,7 +109,8 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
working_directory,
host_repo_path,
},
base_dir,
current_dir,
file_resolver,
goal_override.as_deref(),
)?;
@ -110,6 +119,9 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok());
persist_created_run(store, &persisted, &resolved.raw_source, workflow_config).await?;
if let (Some(workflow_path), Some(workflow_bundle)) = (workflow_path, workflow_bundle) {
persist_workflow_bundle(persisted.run_dir(), workflow_path, workflow_bundle)?;
}
Ok(CreatedRun {
persisted,
@ -186,6 +198,18 @@ fn store_error(err: impl std::fmt::Display) -> 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<PathBuf>,
current_dir: Option<PathBuf>,
file_resolver: Option<Arc<dyn FileResolver>>,
goal_override: Option<&str>,
) -> Result<Persisted, FabroError> {
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<PathBuf>,
current_dir: Option<PathBuf>,
file_resolver: Option<Arc<dyn FileResolver>>,
custom_transforms: Vec<Box<dyn Transform>>,
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,

View file

@ -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<PathBuf>,
},
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<String>,
pub workflow_toml_path: Option<PathBuf>,
pub dot_path: Option<PathBuf>,
pub base_dir: Option<PathBuf>,
pub current_dir: Option<PathBuf>,
pub file_resolver: Option<Arc<dyn FileResolver>>,
pub goal_override: Option<String>,
pub working_directory: PathBuf,
}
@ -52,6 +58,24 @@ fn resolve_goal_file(
Ok(Some(content))
}
fn workflow_slug_from_path(workflow_path: &Path) -> Option<String> {
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<ResolvedWorkflow> {
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<dyn FileResolver>
}),
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,
})
}
}
}

View file

@ -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<fabro_github::GitHubAppCredentials>,
pr_origin_url: Option<String>,
pr_model: String,
workflow_path: Option<PathBuf>,
workflow_bundle: Option<Arc<WorkflowBundle>>,
}
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,

View file

@ -30,7 +30,8 @@ pub fn validate(input: ValidateInput) -> Result<Validated, FabroError> {
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(),

View file

@ -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 {

View file

@ -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(),

View file

@ -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(),

View file

@ -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![],
},
);

View file

@ -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<dyn Interviewer>,
pub lifecycle: LifecycleOptions,
pub run_options: RunOptions,
pub workflow_path: Option<PathBuf>,
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
pub hooks: fabro_hooks::HookSettings,
pub sandbox_env: SandboxEnvSpec,
pub devcontainer: Option<DevcontainerSpec>,
@ -251,6 +255,8 @@ pub struct Initialized {
pub graph: Graph,
pub source: String,
pub run_options: RunOptions,
pub workflow_path: Option<PathBuf>,
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
pub run_store: SlateRunStore,
pub(crate) checkpoint: Option<Checkpoint>,
pub(crate) seed_context: Option<Context>,
@ -321,7 +327,8 @@ pub struct Finalized {
/// Options for the TRANSFORM phase.
pub struct TransformOptions {
pub base_dir: Option<PathBuf>,
pub current_dir: Option<PathBuf>,
pub file_resolver: Option<Arc<dyn FileResolver>>,
pub custom_transforms: Vec<Box<dyn Transform>>,
}

View file

@ -24,7 +24,8 @@ mod tests {
let transformed = transform::transform(
parsed,
&TransformOptions {
base_dir: None,
current_dir: None,
file_resolver: None,
custom_transforms: vec![],
},
);

View file

@ -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,

View file

@ -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<PathBuf>,
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
}
impl FileInliningTransform {
#[must_use]
pub fn new(base_dir: PathBuf, fallback_dir: Option<PathBuf>) -> Self {
pub fn new(current_dir: PathBuf, resolver: Arc<dyn FileResolver>) -> 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);

View file

@ -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<PathBuf>,
current_dir: PathBuf,
resolver: Arc<dyn FileResolver>,
}
struct PlaceholderOptions {
@ -27,39 +30,10 @@ struct PreparedImport {
impl ImportTransform {
#[must_use]
pub fn new(base_dir: PathBuf, fallback_dir: Option<PathBuf>) -> Self {
pub fn new(current_dir: PathBuf, resolver: Arc<dyn FileResolver>) -> Self {
Self {
base_dir,
fallback_dir,
}
}
fn resolve_import_path(
import_path: &str,
base_dir: &Path,
fallback_dir: Option<&Path>,
) -> Option<PathBuf> {
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::<Vec<_>>()
.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<PathBuf>,
) -> Result<PreparedImport, String> {
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"]

View file

@ -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<PathBuf, String>,
}
impl BundledWorkflow {
#[must_use]
pub fn file_resolver(&self) -> Arc<dyn FileResolver> {
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<PathBuf, BundledWorkflow>,
}
impl WorkflowBundle {
#[must_use]
pub fn new(workflows: HashMap<PathBuf, BundledWorkflow>) -> 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<PathBuf, BundledWorkflow> {
&self.workflows
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredWorkflowBundle {
pub workflow_path: PathBuf,
pub workflows: HashMap<PathBuf, BundledWorkflow>,
}
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<Option<Self>, 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()))
}
}

View file

@ -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![],
},
);

View file

@ -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

View file

@ -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<RequestArgs> => {
// verify required parameter 'createRunRequest' is not null or undefined
assertParamExists('createRun', 'createRunRequest', createRunRequest)
createRun: async (runManifest: RunManifest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
// 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<RequestArgs> => {
// 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<RunStatusResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(createRunRequest, options);
async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
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<File>> {
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<PreflightResponse>> {
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<RunStatusResponse> {
return localVarFp.createRun(createRunRequest, options).then((request) => request(axios, basePath));
createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
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<RunStatusResponse> {
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<File> {
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<string> {
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<PreflightResponse> {
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

View file

@ -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';

View file

@ -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<string>;
}

View file

@ -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];

View file

@ -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;
}

View file

@ -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];

View file

@ -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;
}

View file

@ -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];

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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; };
}

View file

@ -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;
}

View file

@ -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<PreflightCheckSection>;
}

View file

@ -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<PreflightCheckDetail>;
'remediation'?: string;
}
export const PreflightCheckResultStatusEnum = {
PASS: 'pass',
WARNING: 'warning',
ERROR: 'error'
} as const;
export type PreflightCheckResultStatusEnum = typeof PreflightCheckResultStatusEnum[keyof typeof PreflightCheckResultStatusEnum];

View file

@ -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<PreflightCheckResult>;
}

View file

@ -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;
}

View file

@ -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<WorkflowDiagnostic>;
}

View file

@ -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];

View file

@ -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];

View file

@ -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;
}

View file

@ -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<ManifestConfig>;
'workflows': { [key: string]: ManifestWorkflow; };
}

View file

@ -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<string>;
'fix'?: string;
}
export const WorkflowDiagnosticSeverityEnum = {
ERROR: 'error',
WARNING: 'warning',
INFO: 'info'
} as const;
export type WorkflowDiagnosticSeverityEnum = typeof WorkflowDiagnosticSeverityEnum[keyof typeof WorkflowDiagnosticSeverityEnum];