mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-04 02:33:56 +00:00
refactor(server): submit runs and preflight via manifests
This commit is contained in:
parent
9ddcc13b66
commit
bd70f805be
67 changed files with 3987 additions and 969 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1871,6 +1871,7 @@ dependencies = [
|
|||
"fabro-store",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"fabro-validate",
|
||||
"fabro-workflow",
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
644
lib/crates/fabro-cli/src/manifest_builder.rs
Normal file
644
lib/crates/fabro-cli/src/manifest_builder.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
826
lib/crates/fabro-server/src/run_manifest.rs
Normal file
826
lib/crates/fabro-server/src/run_manifest.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
148
lib/crates/fabro-workflow/src/file_resolver.rs
Normal file
148
lib/crates/fabro-workflow/src/file_resolver.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ mod tests {
|
|||
let transformed = transform::transform(
|
||||
parsed,
|
||||
&TransformOptions {
|
||||
base_dir: None,
|
||||
current_dir: None,
|
||||
file_resolver: None,
|
||||
custom_transforms: vec![],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
96
lib/crates/fabro-workflow/src/workflow_bundle.rs
Normal file
96
lib/crates/fabro-workflow/src/workflow_bundle.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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![],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
31
lib/packages/fabro-api-client/src/models/manifest-args.ts
Normal file
31
lib/packages/fabro-api-client/src/models/manifest-args.ts
Normal 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>;
|
||||
}
|
||||
|
||||
30
lib/packages/fabro-api-client/src/models/manifest-config.ts
Normal file
30
lib/packages/fabro-api-client/src/models/manifest-config.ts
Normal 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];
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
||||
38
lib/packages/fabro-api-client/src/models/manifest-git.ts
Normal file
38
lib/packages/fabro-api-client/src/models/manifest-git.ts
Normal 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;
|
||||
}
|
||||
|
||||
40
lib/packages/fabro-api-client/src/models/manifest-goal.ts
Normal file
40
lib/packages/fabro-api-client/src/models/manifest-goal.ts
Normal 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];
|
||||
|
||||
|
||||
27
lib/packages/fabro-api-client/src/models/manifest-target.ts
Normal file
27
lib/packages/fabro-api-client/src/models/manifest-target.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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; };
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
58
lib/packages/fabro-api-client/src/models/run-manifest.ts
Normal file
58
lib/packages/fabro-api-client/src/models/run-manifest.ts
Normal 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; };
|
||||
}
|
||||
|
||||
|
|
@ -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];
|
||||
|
||||
|
||||
Loading…
Add table
Reference in a new issue