mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Build the run's display graph from Petri's admission
The rest of the change whose deletions the previous commit carries (its `git add` stopped at an already-removed path): `fabro_types::RunGraph` and the `fabro-petri` builder that reads it off the admitted graph, the server's create, validate, preflight and render paths on Petri's check alone, the consumers moved to the new shape, the OpenAPI `RunGraph` schemas with their parity tests, the regenerated TS client, and the docs naming Petri's diagnostic codes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
52aed8c642
commit
bd59f52e22
64 changed files with 1590 additions and 4282 deletions
|
|
@ -114,7 +114,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
|
||||
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
|
||||
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
|
||||
- **fabro-workflow** — Fabro's workflow definitions: parses Graphviz graphs and layers settings for the read side, creates and archives runs, and holds the run tools and the pull request pipeline. Execution is Petri's, through `fabro-petri`
|
||||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-graphviz** — Graphviz DOT parser, the typed graph model, and SVG rendering
|
||||
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
|
|
@ -225,7 +225,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
|
||||
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
|
||||
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
|
||||
- **fabro-workflow** — Fabro's workflow definitions: parses Graphviz graphs and layers settings for the read side, creates and archives runs, and holds the run tools and the pull request pipeline. Execution is Petri's, through `fabro-petri`
|
||||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-graphviz** — Graphviz DOT parser, the typed graph model, and SVG rendering
|
||||
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2972,7 +2972,6 @@ dependencies = [
|
|||
"fabro-redact",
|
||||
"fabro-sandbox",
|
||||
"fabro-store",
|
||||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-tool",
|
||||
"fabro-types",
|
||||
|
|
@ -2984,7 +2983,6 @@ dependencies = [
|
|||
"hex",
|
||||
"httpmock",
|
||||
"lithos-llm",
|
||||
"miette",
|
||||
"pebble-coding-agent",
|
||||
"sandbox-driver",
|
||||
"scopeguard",
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ Before execution, `{{ goal }}` becomes `Add a /health endpoint to the API server
|
|||
| `{{ goal }}` | The graph-level `goal` attribute |
|
||||
| `{{ inputs.name }}` | A value from `[run.inputs]` |
|
||||
|
||||
Undefined prompt variables render as empty text and produce a `template_undefined_variable` diagnostic. `fabro validate` reports that diagnostic as a warning; run-style commands promote it to an error before proceeding. Environment variables are not available in prompt templates.
|
||||
A prompt variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered; run-style commands and `fabro preflight` refuse the workflow with `unsupported.template.unbound_input` before a run is created. Environment variables are not available in prompt templates.
|
||||
|
||||
Prompt and goal templates can use static MiniJinja includes to share partials:
|
||||
|
||||
|
|
|
|||
|
|
@ -1424,7 +1424,16 @@ paths:
|
|||
operationId: runPreflight
|
||||
tags: [Runs]
|
||||
summary: Validate Workflow Manifest
|
||||
description: Validates runtime readiness for a workflow manifest without creating a run.
|
||||
description: |
|
||||
Validates runtime readiness for a workflow manifest without creating
|
||||
a run. The workflow is checked as a run would be admitted: Petri
|
||||
compiles the bundle with the server's settings, run variables and
|
||||
model catalog, and every diagnostic carries Petri's code as its
|
||||
`rule` (`attractor.no_start`, `attractor.model.unknown`,
|
||||
`unsupported.template.unbound_input`), with Fabro's own
|
||||
`fabro.model.no_ready_provider` when a model node has no provider
|
||||
ready to run it. The checks then probe the sandbox, repository
|
||||
access and GitHub credentials.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -1453,7 +1462,16 @@ paths:
|
|||
operationId: validateRunManifest
|
||||
tags: [Runs]
|
||||
summary: Validate Workflow Manifest
|
||||
description: Validates workflow structure and diagnostics without runtime readiness checks.
|
||||
description: |
|
||||
Validates a workflow manifest without runtime readiness checks. The
|
||||
workflow is checked as a run would be admitted: Petri compiles the
|
||||
bundle with the server's settings, run variables and model catalog,
|
||||
and every diagnostic carries Petri's code as its `rule`
|
||||
(`attractor.no_start`, `attractor.model.unknown`,
|
||||
`unsupported.template.unbound_input`), with Fabro's own
|
||||
`fabro.model.no_ready_provider` when a model node has no provider
|
||||
ready to run it. `workflow` describes the admitted graph, or the DOT
|
||||
as written when Petri refused the workflow.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -1482,7 +1500,10 @@ paths:
|
|||
operationId: renderWorkflowGraph
|
||||
tags: [Runs]
|
||||
summary: Render Workflow Graph
|
||||
description: Validates and renders a workflow manifest as SVG without creating a run.
|
||||
description: |
|
||||
Validates and renders a workflow manifest as SVG without creating a
|
||||
run. The manifest is checked as `POST /validate` checks it; a
|
||||
workflow Petri refuses is not rendered.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -9958,6 +9979,11 @@ components:
|
|||
$ref: "#/components/schemas/WorkflowDiagnostic"
|
||||
|
||||
WorkflowDiagnostic:
|
||||
description: |
|
||||
One diagnostic about a workflow. `rule` is the stable code of the
|
||||
check that raised it: Petri's codes (`attractor.*`, `unsupported.*`,
|
||||
`deprecated.*`, `info.*`, `fabro.hooks.*`) for the compile, and
|
||||
`fabro.model.no_ready_provider` for Fabro's own provider check.
|
||||
type: object
|
||||
required:
|
||||
- rule
|
||||
|
|
@ -9966,6 +9992,7 @@ components:
|
|||
properties:
|
||||
rule:
|
||||
type: string
|
||||
description: The stable code of the check that raised the diagnostic.
|
||||
severity:
|
||||
type: string
|
||||
enum:
|
||||
|
|
@ -12644,10 +12671,13 @@ components:
|
|||
settings:
|
||||
$ref: "#/components/schemas/WorkflowSettings"
|
||||
graph:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
$ref: "#/components/schemas/RunGraph"
|
||||
description: |
|
||||
The display graph: the workflow Petri admitted, reduced to what
|
||||
the read side names. The DOT it was written in is `graph_source`.
|
||||
graph_source:
|
||||
type: ["string", "null"]
|
||||
description: The entrypoint workflow's DOT as written.
|
||||
workflow_slug:
|
||||
type: ["string", "null"]
|
||||
workflow_version_id:
|
||||
|
|
@ -12690,6 +12720,54 @@ components:
|
|||
What Petri admitted for the run at create time: the graphs it
|
||||
executes and resumes from.
|
||||
|
||||
RunGraph:
|
||||
description: |
|
||||
The display graph of a run: the admitted workflow's name, goal,
|
||||
stages and edges, read off the graph Petri admitted at create time.
|
||||
Lowering artifacts (the goal check, a synthetic fan-in) are left out;
|
||||
the stages are the nodes the workflow declares, imports and
|
||||
`[run.prepare]` steps included.
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The workflow's name, the DOT `digraph` name.
|
||||
goal:
|
||||
type: string
|
||||
description: The run's goal as the run displays it; empty when the workflow has none.
|
||||
nodes:
|
||||
type: object
|
||||
description: The stages by node id.
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/RunGraphNode"
|
||||
edges:
|
||||
type: array
|
||||
description: The routing edges as written, one per arm.
|
||||
items:
|
||||
$ref: "#/components/schemas/RunGraphEdge"
|
||||
|
||||
RunGraphNode:
|
||||
description: One stage of a run's display graph.
|
||||
type: object
|
||||
required: [label, kind]
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
description: The node's display label, its `label` attribute or its id.
|
||||
kind:
|
||||
$ref: "#/components/schemas/StageHandler"
|
||||
|
||||
RunGraphEdge:
|
||||
description: One routing edge of a run's display graph.
|
||||
type: object
|
||||
required: [from, to]
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
|
||||
PetriAdmission:
|
||||
description: |
|
||||
What Petri admitted for a run at create time: the lowered root graph
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ Fabro uses this order:
|
|||
|
||||
A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes.
|
||||
|
||||
If an input or variable is unavailable, `fabro validate` reports `template_undefined_variable`. It skips stylesheet syntax and model checks for that validation pass. Run-style commands treat the same diagnostic as an error before they create or start a run.
|
||||
If an input or variable is unavailable, `fabro validate` reports `attractor.unbound_input` as a warning and leaves the stylesheet unrendered, so its syntax and model checks wait for the values. Run-style commands refuse the workflow with `unsupported.template.unbound_input` before they create or start a run.
|
||||
|
||||
## Selectors
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ digraph Example {
|
|||
|
||||
That prompt becomes `Create a plan for: Implement the login feature`.
|
||||
|
||||
A graph goal cannot contain `{{ goal }}` because that would reference itself. `fabro validate` reports `goal_self_reference` as an error; put the reusable text in an input or server-managed variable instead.
|
||||
A graph goal cannot contain `{{ goal }}` because that would reference itself: the goal is not bound while it renders, so `fabro validate` reports `attractor.unbound_input` and run-style commands refuse the workflow with `unsupported.template.unbound_input`. Put the reusable text in an input or server-managed variable instead.
|
||||
|
||||
## Expansion timing
|
||||
|
||||
|
|
@ -185,9 +185,7 @@ Fabro renders the graph `goal` first and stores the rendered value back onto the
|
|||
|
||||
## Undefined variables
|
||||
|
||||
Fabro renders undefined workflow variables as empty text and records a `template_undefined_variable` diagnostic. `fabro validate` reports that diagnostic as a warning so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and promote any still-undefined reference to an error before proceeding.
|
||||
|
||||
In a `script`, an undefined value records the same diagnostic but leaves the token in place rather than emptying it, so validation output shows what is unbound.
|
||||
A workflow variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered, so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and refuse any still-unbound reference with `unsupported.template.unbound_input` before proceeding.
|
||||
|
||||
## Template includes
|
||||
|
||||
|
|
|
|||
|
|
@ -784,7 +784,7 @@ mod tests {
|
|||
let spec = fabro_types::RunSpec {
|
||||
run_id,
|
||||
settings: fabro_types::WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
graph: fabro_types::RunGraph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -153,13 +153,6 @@ impl CliDiagnostic {
|
|||
show_auth_hint,
|
||||
}
|
||||
}
|
||||
|
||||
fn delegated_diagnostic(&self) -> Option<&dyn miette::Diagnostic> {
|
||||
self.err.chain().find_map(|err| {
|
||||
err.downcast_ref::<fabro_workflow::Error>()
|
||||
.map(|err| err as &dyn miette::Diagnostic)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CliDiagnostic {
|
||||
|
|
@ -181,33 +174,9 @@ impl std::error::Error for CliDiagnostic {
|
|||
}
|
||||
|
||||
impl miette::Diagnostic for CliDiagnostic {
|
||||
fn code<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
|
||||
self.delegated_diagnostic()
|
||||
.and_then(miette::Diagnostic::code)
|
||||
}
|
||||
|
||||
fn help<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
|
||||
if self.show_auth_hint && exit::exit_class_for(&self.err) == Some(ExitClass::AuthRequired) {
|
||||
Some(Box::new("Run `fabro auth login` to authenticate."))
|
||||
} else {
|
||||
self.delegated_diagnostic()
|
||||
.and_then(miette::Diagnostic::help)
|
||||
}
|
||||
}
|
||||
|
||||
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
|
||||
self.delegated_diagnostic()
|
||||
.and_then(miette::Diagnostic::source_code)
|
||||
}
|
||||
|
||||
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
|
||||
self.delegated_diagnostic()
|
||||
.and_then(miette::Diagnostic::labels)
|
||||
}
|
||||
|
||||
fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
|
||||
self.delegated_diagnostic()
|
||||
.and_then(miette::Diagnostic::diagnostic_source)
|
||||
(self.show_auth_hint && exit::exit_class_for(&self.err) == Some(ExitClass::AuthRequired))
|
||||
.then(|| Box::new("Run `fabro auth login` to authenticate.") as Box<dyn Display + 'a>)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,9 +211,9 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
},
|
||||
"graph": {
|
||||
"name": "Remote Workflow",
|
||||
"goal": "",
|
||||
"nodes": {},
|
||||
"edges": [],
|
||||
"attrs": {}
|
||||
"edges": []
|
||||
},
|
||||
"workflow_slug": "remote-workflow",
|
||||
"source_directory": "/srv/repo",
|
||||
|
|
|
|||
|
|
@ -70,12 +70,8 @@ fn preflight_rejects_unbound_template_inputs() {
|
|||
----- stderr -----
|
||||
Workflow: TemplatedUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound.fabro
|
||||
Goal: Demo
|
||||
Goal: Demo {{ inputs.app_dir }}
|
||||
|
||||
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
error: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input)
|
||||
fix: pass `--input app_dir=VALUE`, or add a default under `[run.inputs]` in workflow.toml
|
||||
error: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds (unsupported.template.unbound_input)
|
||||
|
|
|
|||
|
|
@ -206,10 +206,6 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
|
|||
----- stderr -----
|
||||
Workflow: TemplatedUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound.fabro
|
||||
warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
warning: [FIXTURES]/templated_unbound.fabro:2:12: the graph `goal` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
|
||||
warning: [FIXTURES]/templated_unbound.fabro:7:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
|
||||
Validation: OK
|
||||
|
|
@ -228,8 +224,6 @@ fn unbound_model_stylesheet_input_warns_without_css_error() {
|
|||
----- stderr -----
|
||||
Workflow: ModelStylesheetUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/model_stylesheet_unbound.fabro
|
||||
warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable)
|
||||
fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=<value>`
|
||||
warning: [FIXTURES]/model_stylesheet_unbound.fabro:3:9: the `model_stylesheet` reads `{{ inputs.effort }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input effort=VALUE` to render it (attractor.unbound_input)
|
||||
Validation: OK
|
||||
");
|
||||
|
|
@ -252,8 +246,6 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
|
|||
----- stderr -----
|
||||
Workflow: TemplatedUnboundImported (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
warning: [FIXTURES]/templated_unbound_imported/workflow.fabro:5:25: node `work` `prompt` reads `{{ inputs.app_dir }}`, which no input binds; it is left unrendered because no inputs were given. Pass `--input app_dir=VALUE` to render it (attractor.unbound_input)
|
||||
Validation: OK
|
||||
");
|
||||
|
|
@ -275,8 +267,6 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
|
|||
----- stderr -----
|
||||
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
|
||||
fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=<value>`
|
||||
error: [FIXTURES]/templated_unbound_partial/workflow.fabro:3:42: node `test_imported_include` `prompt`: template render: could not render include: error in "../../../../../../../..[FIXTURES]/templated_unbound_partial/test-include.partial.md" (in ../../../../../../../..[FIXTURES]/templated_unbound_partial/__petri_root__:1) (attractor.template)
|
||||
× Validation failed
|
||||
"#);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ pub(crate) use auth_harness::{
|
|||
};
|
||||
pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt};
|
||||
use fabro_test::{EnvVars, TestContext, preserve_coverage_env};
|
||||
use fabro_types::{Graph, RunId, RunSpec, RunStreamItem, WorkflowSettings};
|
||||
use fabro_types::{RunGraph, RunId, RunSpec, RunStreamItem, WorkflowSettings};
|
||||
pub(crate) use mcp_client::McpStdioTestClient;
|
||||
|
||||
pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> {
|
||||
|
|
@ -45,7 +45,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
|
|||
let spec = RunSpec {
|
||||
run_id,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("Remote Workflow"),
|
||||
graph: RunGraph::new("Remote Workflow"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("remote-workflow".to_string()),
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -1630,7 +1630,7 @@ mod runs {
|
|||
/// agent stage carrying the coding agent's fold of `agent_events()`.
|
||||
pub(super) fn run_state() -> fabro_types::RunProjection {
|
||||
use fabro_types::{
|
||||
Graph, RunProjection, RunProvenance, RunSpec, StageTiming, WorkflowSettings,
|
||||
RunGraph, RunProjection, RunProvenance, RunSpec, StageTiming, WorkflowSettings,
|
||||
first_event_seq,
|
||||
};
|
||||
use pebble_coding_agent::projection::SessionProjection;
|
||||
|
|
@ -1639,7 +1639,7 @@ mod runs {
|
|||
let spec = RunSpec {
|
||||
run_id: demo_run_id(1),
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("drift-remediation"),
|
||||
graph: RunGraph::new("drift-remediation"),
|
||||
graph_source: Some(super::DEMO_GRAPH_DOT.to_string()),
|
||||
workflow_slug: Some("implement".to_string()),
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder};
|
||||
use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder, project};
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_petri::run_graph;
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate};
|
||||
use fabro_workflow::pipeline::TEMPLATE_UNDEFINED_VARIABLE_RULE;
|
||||
|
||||
use crate::run_manifest::{ManifestCheck, workflow_shape_of};
|
||||
use crate::{petri_check, run_intent, run_manifest};
|
||||
|
||||
/// Validate a manifest without a model catalog.
|
||||
|
|
@ -17,7 +17,9 @@ use crate::{petri_check, run_intent, run_manifest};
|
|||
/// client's catalog is its own, not the server's. Judging model and provider
|
||||
/// availability here would reject workflows the server can run, so Petri
|
||||
/// checks the bundle with no model client: the structure, the settings and
|
||||
/// the templates, with every model node left for the server on create.
|
||||
/// the templates, with every model node left for the server on create. An
|
||||
/// input nothing binds is a warning here (`attractor.unbound_input`), since
|
||||
/// the run's inputs do not exist yet.
|
||||
pub fn validate_manifest(
|
||||
manifest_run_defaults: &RunLayer,
|
||||
manifest: &types::RunManifest,
|
||||
|
|
@ -67,8 +69,8 @@ fn offline_runtime(run: Option<&RunLayer>) -> RuntimeSpec {
|
|||
/// The supplied run layer is complete, including any already resolved inline
|
||||
/// goal. Validation uses only seeded environment defaults, immutable workflow
|
||||
/// settings, and explicit inputs; it performs no store, HTTP, user-settings,
|
||||
/// project-settings, or model-catalog operation. Undefined template variables
|
||||
/// are promoted to errors before the response is returned.
|
||||
/// project-settings, or model-catalog operation. An input nothing binds is
|
||||
/// an error here (`unsupported.template.unbound_input`), as it is at create.
|
||||
pub fn validate_collected_workflow(
|
||||
closure: &CollectedWorkflowClosure,
|
||||
run_overrides: Option<&RunLayer>,
|
||||
|
|
@ -94,14 +96,6 @@ pub fn validate_collected_workflow(
|
|||
}
|
||||
let mut settings = builder.build().map_err(anyhow::Error::new)?;
|
||||
settings.run.inputs.extend(input_overrides.clone());
|
||||
let mut validated = validate(ValidateInput {
|
||||
workflow: WorkflowInput::Bundled(workflow),
|
||||
settings: settings.clone(),
|
||||
vars: HashMap::new(),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
custom_transforms: Vec::new(),
|
||||
})
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let request = petri_check::check_request(
|
||||
&lowered.workflow_bundle,
|
||||
&lowered.entrypoint,
|
||||
|
|
@ -113,26 +107,17 @@ pub fn validate_collected_workflow(
|
|||
)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let checked = petri_check::check(&request, true).map_err(anyhow::Error::new)?;
|
||||
validated.extend_diagnostics(checked.diagnostics);
|
||||
let mut response = types::ValidateResponse {
|
||||
ok: !validated.has_errors(),
|
||||
workflow: run_manifest::workflow_summary(&validated, lowered.entrypoint.as_path()),
|
||||
let check = ManifestCheck {
|
||||
graph: checked.admitted.as_ref().map(run_graph::run_graph),
|
||||
diagnostics: checked.diagnostics,
|
||||
};
|
||||
promote_template_undefined_variables_to_errors(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn promote_template_undefined_variables_to_errors(response: &mut types::ValidateResponse) {
|
||||
let mut promoted = false;
|
||||
for diagnostic in &mut response.workflow.diagnostics {
|
||||
if diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE {
|
||||
diagnostic.severity = types::WorkflowDiagnosticSeverity::Error;
|
||||
promoted = true;
|
||||
}
|
||||
}
|
||||
if promoted {
|
||||
response.ok = false;
|
||||
}
|
||||
let working_directory =
|
||||
project::resolve_working_directory_from_run(&settings.run, Path::new("/workspace"));
|
||||
let shape = workflow_shape_of(&check, &workflow.source, &settings, &working_directory);
|
||||
Ok(types::ValidateResponse {
|
||||
ok: !check.has_errors(),
|
||||
workflow: run_manifest::workflow_summary(&check, &shape, lowered.entrypoint.as_path()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -150,6 +135,10 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// Petri's code for an input a template reads that nothing binds, as
|
||||
/// the check before a run raises it.
|
||||
const UNBOUND_INPUT_RULE: &str = "unsupported.template.unbound_input";
|
||||
|
||||
fn write(root: &Path, path: &str, content: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
|
|
@ -217,7 +206,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn collected_validation_matches_legacy_response_for_equivalent_inputs() {
|
||||
fn collected_validation_matches_the_manifest_response_for_equivalent_inputs() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workflow = write_complete_fixture(temp.path());
|
||||
let run = run_overrides("inline goal");
|
||||
|
|
@ -242,14 +231,13 @@ dockerfile = { path = "Dockerfile" }
|
|||
})
|
||||
.unwrap();
|
||||
|
||||
let mut legacy = validate_manifest(&RunLayer::default(), &manifest.manifest).unwrap();
|
||||
promote_template_undefined_variables_to_errors(&mut legacy);
|
||||
let from_manifest = validate_manifest(&RunLayer::default(), &manifest.manifest).unwrap();
|
||||
let collected =
|
||||
validate_collected_workflow(package.closure(), Some(&run), &inputs).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(collected).unwrap(),
|
||||
serde_json::to_value(legacy).unwrap(),
|
||||
serde_json::to_value(from_manifest).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -267,10 +255,15 @@ dockerfile = { path = "Dockerfile" }
|
|||
let missing =
|
||||
validate_collected_workflow(package.closure(), None, &HashMap::new()).unwrap();
|
||||
assert!(!missing.ok);
|
||||
assert!(missing.workflow.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE
|
||||
&& diagnostic.severity == types::WorkflowDiagnosticSeverity::Error
|
||||
}));
|
||||
assert!(
|
||||
missing.workflow.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.rule == UNBOUND_INPUT_RULE
|
||||
&& diagnostic.severity == types::WorkflowDiagnosticSeverity::Error
|
||||
&& diagnostic.message.contains("inputs.owner")
|
||||
}),
|
||||
"{:?}",
|
||||
missing.workflow.diagnostics
|
||||
);
|
||||
|
||||
let present = validate_collected_workflow(
|
||||
package.closure(),
|
||||
|
|
@ -283,7 +276,7 @@ dockerfile = { path = "Dockerfile" }
|
|||
.workflow
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| { diagnostic.rule != TEMPLATE_UNDEFINED_VARIABLE_RULE })
|
||||
.all(|diagnostic| diagnostic.rule != UNBOUND_INPUT_RULE)
|
||||
);
|
||||
assert!(present.ok, "{:?}", present.workflow.diagnostics);
|
||||
assert_eq!(present.workflow.goal, "resolved inline goal");
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
//! 1. [`normalize_source`] — resolve the bundle entrypoint and retain the
|
||||
//! admitted workflow settings, whose dockerfile references are already
|
||||
//! inlined.
|
||||
//! 2. [`layer_settings`] + [`apply_run_variables`] + graph compilation — layer
|
||||
//! settings from every configured source, substitute the run-scoped variable
|
||||
//! snapshot, then parse/transform/validate the graph through the
|
||||
//! fabro-workflow pipeline.
|
||||
//! 3. [`compile_admitted`] — Petri compiled, linted and pinned models at its
|
||||
//! admission, so only the Fabro graph the read side displays is parsed here,
|
||||
//! and the admission is recorded on the run.
|
||||
//! 2. [`layer_settings`] + [`apply_run_variables`] — layer settings from every
|
||||
//! configured source and substitute the run-scoped variable snapshot. The
|
||||
//! prepared run then goes to Petri (`crate::server::petri_runs::admit`),
|
||||
//! which compiles, lints and pins models: its admitted graph is the graph.
|
||||
//! 3. [`materialize_admitted`] — record the admission and the display graph
|
||||
//! read off it on the run, and materialize Fabro's run-level settings (the
|
||||
//! goal, the pull request block) around them.
|
||||
//! 4. [`assemble_run`] — purely assemble the complete persistence input; no
|
||||
//! field is mutated after assembly.
|
||||
//!
|
||||
|
|
@ -35,14 +35,14 @@ use fabro_config::{
|
|||
use fabro_types::settings::interp::{InterpString, ResolveError};
|
||||
use fabro_types::settings::run::{McpServerSettings, RunGoal};
|
||||
use fabro_types::{
|
||||
AutomationRef, GitContext, ManifestPath, PetriAdmission, RunId, RunProvenance, RunTarget,
|
||||
WorkflowSettings, WorkflowVersionId,
|
||||
AutomationRef, GitContext, ManifestPath, PetriAdmission, RunGraph, RunId, RunProvenance,
|
||||
RunTarget, WorkflowSettings, WorkflowVersionId,
|
||||
};
|
||||
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
|
||||
use fabro_workflow::Error as WorkflowError;
|
||||
use fabro_workflow::operations::{
|
||||
self, CreateRunCompileInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata,
|
||||
MaterializedRun, WorkflowInput,
|
||||
self, AdmittedRunInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata,
|
||||
MaterializedRun,
|
||||
};
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle};
|
||||
use tokio::task;
|
||||
|
|
@ -125,7 +125,8 @@ pub(crate) struct LayeredRun {
|
|||
}
|
||||
|
||||
/// Variable-substituted stage output. Callers may inspect the resolved
|
||||
/// settings before policy checks, then move it into [`compile_and_pin`].
|
||||
/// settings before policy checks, then hand it to Petri's admission and move
|
||||
/// it into [`materialize_admitted`].
|
||||
pub(crate) struct PreparedRun {
|
||||
layered: LayeredRun,
|
||||
vars: HashMap<String, String>,
|
||||
|
|
@ -185,7 +186,14 @@ impl PreparedRun {
|
|||
}
|
||||
}
|
||||
|
||||
/// Model-pinned stage output ready for pure persistence-input assembly.
|
||||
/// What Petri admitted for a run: the stored graphs the run executes from,
|
||||
/// and the display graph read off them.
|
||||
pub(crate) struct AdmittedRun {
|
||||
pub(crate) admission: PetriAdmission,
|
||||
pub(crate) graph: RunGraph,
|
||||
}
|
||||
|
||||
/// Admitted stage output ready for pure persistence-input assembly.
|
||||
pub(crate) struct PinnedRun {
|
||||
materialized: MaterializedRun,
|
||||
metadata: RunMetadata,
|
||||
|
|
@ -210,9 +218,9 @@ pub(crate) enum RunCompilerError {
|
|||
#[error("Run config variable interpolation failed: {0}")]
|
||||
VariableInterpolation(#[from] VariableInterpolationError),
|
||||
|
||||
/// Graph compilation or model pinning failed in the workflow engine. The
|
||||
/// full [`WorkflowError`] is preserved so callers can distinguish
|
||||
/// validation, parse, and model-selection failures.
|
||||
/// Petri refused the workflow, or Fabro's materialization around its
|
||||
/// admission failed. The full [`WorkflowError`] is preserved so callers
|
||||
/// can distinguish validation and parse failures.
|
||||
#[error(transparent)]
|
||||
Workflow(#[from] WorkflowError),
|
||||
}
|
||||
|
|
@ -393,12 +401,12 @@ pub(crate) fn apply_run_variables(
|
|||
Ok(PreparedRun { layered, vars })
|
||||
}
|
||||
|
||||
/// Stages two and three for a run Petri admitted: parse the Fabro graph
|
||||
/// the read side displays, with no lint and no model pinning, and record
|
||||
/// the admission on the run.
|
||||
pub(crate) async fn compile_admitted(
|
||||
/// Stage three for a run Petri admitted: record the admission and its
|
||||
/// display graph on the run, and materialize Fabro's run-level settings
|
||||
/// around them. Blocking: a `[run.goal]` file is read from disk.
|
||||
pub(crate) async fn materialize_admitted(
|
||||
prepared: PreparedRun,
|
||||
admission: PetriAdmission,
|
||||
admitted: AdmittedRun,
|
||||
) -> Result<PinnedRun> {
|
||||
task::spawn_blocking(move || {
|
||||
let PreparedRun {
|
||||
|
|
@ -411,18 +419,20 @@ pub(crate) async fn compile_admitted(
|
|||
cwd,
|
||||
metadata,
|
||||
},
|
||||
vars,
|
||||
// Consumed at admission, as Petri's compile variables.
|
||||
vars: _,
|
||||
} = prepared;
|
||||
let compiled = operations::compile_admitted_run(CreateRunCompileInput {
|
||||
workflow: WorkflowInput::Bundled(workflow),
|
||||
let AdmittedRun { admission, graph } = admitted;
|
||||
let materialized = operations::materialize_admitted_run(AdmittedRunInput {
|
||||
settings,
|
||||
vars,
|
||||
cwd,
|
||||
workflow_path: Some(entrypoint),
|
||||
workflow_bundle: Some(workflow_bundle),
|
||||
graph,
|
||||
source: workflow.source,
|
||||
workflow_path: entrypoint,
|
||||
workflow_bundle,
|
||||
})?;
|
||||
Ok(PinnedRun {
|
||||
materialized: operations::materialize_admitted_run(compiled),
|
||||
materialized,
|
||||
metadata,
|
||||
admission,
|
||||
})
|
||||
|
|
@ -876,21 +886,22 @@ include = ["reports/{{ vars.path }}/*.json"]
|
|||
HashMap::from([("owner".to_string(), "payments".to_string())]),
|
||||
)
|
||||
.expect("settings should prepare");
|
||||
let pinned = compile_admitted(prepared, PetriAdmission::default())
|
||||
.await
|
||||
.expect("the admitted graph should compile");
|
||||
let mut graph = RunGraph::new("Test");
|
||||
graph.goal = "Graph goal".to_string();
|
||||
let pinned = materialize_admitted(prepared, AdmittedRun {
|
||||
admission: PetriAdmission::default(),
|
||||
graph,
|
||||
})
|
||||
.await
|
||||
.expect("the admitted run should materialize");
|
||||
let persistence = assemble_run(pinned);
|
||||
|
||||
assert_eq!(persistence.run_id(), run_id);
|
||||
assert_eq!(persistence.workflow_slug(), Some("compiler-boundary"));
|
||||
assert_eq!(persistence.workflow_version_id(), Some(workflow_version_id));
|
||||
assert_eq!(persistence.automation(), Some(&automation));
|
||||
assert_eq!(
|
||||
persistence
|
||||
.definition()
|
||||
.map(|definition| &definition.workflow_path),
|
||||
Some(&expected_entrypoint)
|
||||
);
|
||||
assert_eq!(persistence.definition().workflow_path, expected_entrypoint);
|
||||
assert_eq!(persistence.materialized().graph().goal(), "Graph goal");
|
||||
assert_eq!(
|
||||
persistence.materialized().settings().run.goal.as_ref(),
|
||||
Some(&RunGoal::Inline(InterpString::parse("Graph goal")))
|
||||
|
|
|
|||
|
|
@ -2285,7 +2285,7 @@ index 1111111..2222222 160000
|
|||
fabro_types::RunSpec {
|
||||
run_id: fabro_types::fixtures::RUN_1,
|
||||
settings: fabro_types::WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
graph: fabro_types::RunGraph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -7,17 +7,17 @@ use std::time::Duration;
|
|||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use fabro_api::types;
|
||||
use fabro_config::parse::SettingsSource;
|
||||
use fabro_config::run::resolve_run_goal_from_namespace;
|
||||
use fabro_config::{
|
||||
CliLayer, CliOutputLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer,
|
||||
WorkflowSettingsBuilder, parse_input_overrides, parse_labels, project,
|
||||
};
|
||||
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot};
|
||||
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_graphviz::render::apply_direction;
|
||||
use fabro_llm::lithos_catalog::Catalog;
|
||||
use fabro_llm::probe::{self, ModelTestStatus};
|
||||
use fabro_llm::{FabroClient, selection};
|
||||
use fabro_petri::check::Launch;
|
||||
use fabro_petri::run_graph;
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
use fabro_proc::ProcessError;
|
||||
use fabro_sandbox::{
|
||||
|
|
@ -27,34 +27,33 @@ use fabro_static::EnvVars;
|
|||
use fabro_types::diagnostic::{Diagnostic, Severity};
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{McpServerSettings, RunGoal, RunNamespace};
|
||||
use fabro_types::settings::run::{McpServerSettings, RunGoal, RunMode, RunNamespace};
|
||||
use fabro_types::{
|
||||
BundledProvider, ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings,
|
||||
BundledProvider, ManifestPath, RunGraph, RunId, SandboxProviderKind, ServerSettings,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_workflow::Error as WorkflowError;
|
||||
use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate};
|
||||
use fabro_workflow::pipeline::Validated;
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use tokio::process::Command;
|
||||
use tokio::task;
|
||||
#[cfg(test)]
|
||||
use tokio::time;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::server::{AppState, petri_runs};
|
||||
use crate::{petri_check, run_compiler};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PreparedManifest {
|
||||
pub cwd: PathBuf,
|
||||
pub git: Option<types::GitContext>,
|
||||
/// The entrypoint's DOT as written: what the render endpoint draws.
|
||||
pub root_source: String,
|
||||
pub settings: WorkflowSettings,
|
||||
pub target_path: ManifestPath,
|
||||
pub workflow_bundle: WorkflowBundle,
|
||||
pub workflow_input: BundledWorkflow,
|
||||
pub source_directory: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -163,31 +162,35 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
|
|||
|
||||
let source_directory = project::resolve_working_directory_from_run(&settings.run, &cwd);
|
||||
Ok(PreparedManifest {
|
||||
cwd,
|
||||
git: manifest.git.clone(),
|
||||
root_source,
|
||||
settings,
|
||||
target_path,
|
||||
workflow_bundle,
|
||||
workflow_input,
|
||||
source_directory,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fabro's own structural pass: parse and transform the root workflow, with
|
||||
/// the transforms' diagnostics. Enough to render a graph.
|
||||
pub(crate) fn validate_prepared_manifest_structural(
|
||||
prepared: &PreparedManifest,
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
validate(manifest_validate_input(prepared, HashMap::new()))
|
||||
/// What Petri said about a prepared manifest: the display graph when it
|
||||
/// admitted the workflow, and every diagnostic, Petri's and Fabro's.
|
||||
pub(crate) struct ManifestCheck {
|
||||
pub(crate) graph: Option<RunGraph>,
|
||||
pub(crate) diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// The structural pass, then Petri's check of the whole bundle under
|
||||
/// `launch` and `runtime`, its diagnostics after the transforms' own.
|
||||
impl ManifestCheck {
|
||||
pub(crate) fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == Severity::Error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Petri's check of the whole bundle under `launch` and `runtime`.
|
||||
/// `has_ready_provider` false adds Fabro's refusal of a model node no
|
||||
/// provider can run; `unbound_is_warning` keeps an input nothing binds a
|
||||
/// warning, for a validation before the run's inputs exist. Blocking:
|
||||
/// Petri lowers the graph synchronously.
|
||||
/// warning, for a validation before the run's inputs exist. Blocking: Petri
|
||||
/// lowers the graph synchronously.
|
||||
pub(crate) fn validate_prepared_manifest(
|
||||
prepared: &PreparedManifest,
|
||||
vars: &HashMap<String, String>,
|
||||
|
|
@ -195,8 +198,7 @@ pub(crate) fn validate_prepared_manifest(
|
|||
runtime: RuntimeSpec,
|
||||
has_ready_provider: bool,
|
||||
unbound_is_warning: bool,
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
let mut validated = validate(manifest_validate_input(prepared, vars.clone()))?;
|
||||
) -> Result<ManifestCheck, WorkflowError> {
|
||||
let request = petri_check::check_request(
|
||||
&prepared.workflow_bundle,
|
||||
&prepared.target_path,
|
||||
|
|
@ -207,90 +209,65 @@ pub(crate) fn validate_prepared_manifest(
|
|||
unbound_is_warning,
|
||||
)?;
|
||||
let checked = petri_check::check(&request, has_ready_provider)?;
|
||||
validated.extend_diagnostics(checked.diagnostics);
|
||||
Ok(validated)
|
||||
Ok(ManifestCheck {
|
||||
graph: checked.admitted.as_ref().map(run_graph::run_graph),
|
||||
diagnostics: checked.diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
/// The model and provider the run's LLM nodes default to: what the settings
|
||||
/// or the graph name, resolved against the ready providers first and the
|
||||
/// whole catalog after, as the run's launch binds them.
|
||||
fn preflight_model(
|
||||
catalog: &Catalog,
|
||||
graph: &Graph,
|
||||
settings: &WorkflowSettings,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<(String, ProviderId)> {
|
||||
let graph_attr = |name: &str| {
|
||||
graph
|
||||
.attrs
|
||||
.get(name)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let model = settings
|
||||
.run
|
||||
.model
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| graph_attr("default_model"));
|
||||
let provider = settings
|
||||
.run
|
||||
.model
|
||||
.provider
|
||||
.clone()
|
||||
.or_else(|| graph_attr("default_provider"))
|
||||
.filter(|provider| !provider.is_empty())
|
||||
.map(ProviderId::new);
|
||||
let eligible = ready_providers.iter().cloned().collect();
|
||||
let selected = selection::resolve_selection_with_catalog_fallback(
|
||||
catalog,
|
||||
model.as_deref(),
|
||||
provider.as_ref(),
|
||||
&eligible,
|
||||
)?;
|
||||
Ok((selected.model, selected.provider))
|
||||
}
|
||||
|
||||
fn manifest_validate_input(
|
||||
/// Check a prepared manifest as a run would be admitted: Petri's check with
|
||||
/// the server's runtime and the model client over the ready providers, on
|
||||
/// the blocking pool.
|
||||
pub(crate) async fn check_prepared_manifest(
|
||||
state: &Arc<AppState>,
|
||||
prepared: &PreparedManifest,
|
||||
vars: HashMap<String, String>,
|
||||
) -> ValidateInput {
|
||||
ValidateInput {
|
||||
workflow: WorkflowInput::Bundled(prepared.workflow_input.clone()),
|
||||
settings: prepared.settings.clone(),
|
||||
vars,
|
||||
cwd: prepared.cwd.clone(),
|
||||
custom_transforms: Vec::new(),
|
||||
}
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<ManifestCheck, WorkflowError> {
|
||||
let launch = petri_check::launch(
|
||||
&state.catalog(),
|
||||
&prepared.settings,
|
||||
ready_providers,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun;
|
||||
let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run);
|
||||
let has_ready_provider = !ready_providers.is_empty();
|
||||
let prepared = prepared.clone();
|
||||
task::spawn_blocking(move || {
|
||||
validate_prepared_manifest(&prepared, &vars, launch, runtime, has_ready_provider, false)
|
||||
})
|
||||
.await
|
||||
.map_err(|source| WorkflowError::engine_with_source("manifest check task failed", source))?
|
||||
}
|
||||
|
||||
pub(crate) async fn run_preflight(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
llm_result: Result<FabroClient>,
|
||||
check: &ManifestCheck,
|
||||
) -> Result<(types::PreflightResponse, bool)> {
|
||||
let (report, checks_ok) =
|
||||
build_preflight_report(state, prepared, validated, llm_result).await?;
|
||||
let preflight_ok = !validated.has_errors() && checks_ok;
|
||||
let shape = workflow_shape(check, prepared);
|
||||
let (report, checks_ok) = build_preflight_report(state, prepared, check, &shape).await?;
|
||||
let preflight_ok = !check.has_errors() && checks_ok;
|
||||
Ok((
|
||||
preflight_response(
|
||||
validated,
|
||||
prepared.target_path.as_path(),
|
||||
&report,
|
||||
preflight_ok,
|
||||
),
|
||||
types::PreflightResponse {
|
||||
ok: preflight_ok,
|
||||
checks: report_to_api(&report),
|
||||
workflow: workflow_summary(check, &shape, prepared.target_path.as_path()),
|
||||
},
|
||||
preflight_ok,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_response(
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
check: &ManifestCheck,
|
||||
) -> types::ValidateResponse {
|
||||
let shape = workflow_shape(check, prepared);
|
||||
types::ValidateResponse {
|
||||
ok: !validated.has_errors(),
|
||||
workflow: workflow_summary(validated, prepared.target_path.as_path()),
|
||||
ok: !check.has_errors(),
|
||||
workflow: workflow_summary(check, &shape, prepared.target_path.as_path()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -441,12 +418,11 @@ fn manifest_project_config_path(
|
|||
async fn build_preflight_report(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
llm_result: Result<FabroClient>,
|
||||
check: &ManifestCheck,
|
||||
shape: &WorkflowShape,
|
||||
) -> Result<(CheckReport, bool)> {
|
||||
let graph = validated.graph();
|
||||
let mut checks = base_preflight_checks(prepared, graph);
|
||||
if validated.has_errors() {
|
||||
let mut checks = base_preflight_checks(prepared, shape);
|
||||
if check.has_errors() {
|
||||
return Ok((
|
||||
CheckReport {
|
||||
title: "Run Preflight".into(),
|
||||
|
|
@ -459,19 +435,6 @@ async fn build_preflight_report(
|
|||
));
|
||||
}
|
||||
|
||||
let catalog = state.catalog();
|
||||
let ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(FabroClient::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let (run_model, run_provider) = preflight_model(
|
||||
catalog.as_ref(),
|
||||
graph,
|
||||
&prepared.settings,
|
||||
&ready_providers,
|
||||
)?;
|
||||
let run_provider = run_provider.into_string();
|
||||
let (run_model, run_provider) = (run_model.as_str(), run_provider.as_str());
|
||||
let resolved_run = prepared.settings.run.clone();
|
||||
let server_settings = state.server_settings();
|
||||
let github_integration = &server_settings.server.integrations.github;
|
||||
|
|
@ -531,19 +494,10 @@ async fn build_preflight_report(
|
|||
github_app.clone(),
|
||||
)
|
||||
.await;
|
||||
let llm_ok = run_llm_check(
|
||||
&mut checks,
|
||||
graph,
|
||||
run_model,
|
||||
run_provider,
|
||||
catalog.as_ref(),
|
||||
llm_result,
|
||||
)
|
||||
.await;
|
||||
let github_token_ok =
|
||||
run_github_token_check(&mut checks, prepared, &resolved_run, github_app).await;
|
||||
|
||||
let checks_ok = sandbox_ok && repository_access_ok && llm_ok && github_token_ok;
|
||||
let checks_ok = sandbox_ok && repository_access_ok && github_token_ok;
|
||||
|
||||
Ok((
|
||||
CheckReport {
|
||||
|
|
@ -557,7 +511,7 @@ async fn build_preflight_report(
|
|||
))
|
||||
}
|
||||
|
||||
fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<CheckResult> {
|
||||
fn base_preflight_checks(prepared: &PreparedManifest, shape: &WorkflowShape) -> Vec<CheckResult> {
|
||||
let setup_command_count = prepared.settings.run.prepare.steps.len();
|
||||
let repo_summary = prepared.git.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|
|
@ -600,11 +554,11 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<Chec
|
|||
CheckResult {
|
||||
name: "Workflow".into(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: graph.name.clone(),
|
||||
summary: shape.name.clone(),
|
||||
details: vec![
|
||||
CheckDetail::new(format!("Nodes: {}", graph.nodes.len())),
|
||||
CheckDetail::new(format!("Edges: {}", graph.edges.len())),
|
||||
CheckDetail::new(format!("Goal: {}", graph.goal())),
|
||||
CheckDetail::new(format!("Nodes: {}", shape.nodes)),
|
||||
CheckDetail::new(format!("Edges: {}", shape.edges)),
|
||||
CheckDetail::new(format!("Goal: {}", shape.goal)),
|
||||
],
|
||||
remediation: None,
|
||||
},
|
||||
|
|
@ -1011,190 +965,6 @@ async fn run_sandbox_check(
|
|||
}
|
||||
}
|
||||
|
||||
const MODEL_PREFLIGHT_PROBE_CONCURRENCY: usize = 4;
|
||||
|
||||
struct PendingModelProbe {
|
||||
index: usize,
|
||||
model_id: String,
|
||||
provider_name: String,
|
||||
}
|
||||
|
||||
async fn run_llm_check(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
graph: &Graph,
|
||||
model: &str,
|
||||
default_provider: &str,
|
||||
catalog: &Catalog,
|
||||
llm_result: Result<FabroClient>,
|
||||
) -> bool {
|
||||
let mut model_providers = std::collections::BTreeSet::new();
|
||||
let mut has_llm_nodes = false;
|
||||
// Each node's selector resolves against the ready providers first and
|
||||
// the whole catalog after, as the run's launch binds it: an alias
|
||||
// becomes the catalog model on the provider that offers it. A selector
|
||||
// the catalog cannot place stays as written; the checks below say why.
|
||||
let eligible = llm_result
|
||||
.as_ref()
|
||||
.map(FabroClient::provider_ids)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
if !is_llm_handler_type(node.handler_type()) {
|
||||
continue;
|
||||
}
|
||||
has_llm_nodes = true;
|
||||
let node_model = node.model().unwrap_or(model);
|
||||
let node_provider = node.provider().unwrap_or(default_provider);
|
||||
// Only a provider the node names itself constrains the selection:
|
||||
// an unqualified alias goes to whichever eligible provider offers it.
|
||||
let provider = node
|
||||
.provider()
|
||||
.filter(|provider| !provider.is_empty())
|
||||
.map(ProviderId::new);
|
||||
let resolved = selection::resolve_selection_with_catalog_fallback(
|
||||
catalog,
|
||||
Some(node_model),
|
||||
provider.as_ref(),
|
||||
&eligible,
|
||||
)
|
||||
.map_or_else(
|
||||
|_| (node_model.to_string(), node_provider.to_string()),
|
||||
|selected| (selected.model, selected.provider.into_string()),
|
||||
);
|
||||
model_providers.insert(resolved);
|
||||
}
|
||||
|
||||
if !has_llm_nodes {
|
||||
return true;
|
||||
}
|
||||
|
||||
match llm_result {
|
||||
Ok(result) => {
|
||||
let auth_issues = result.auth_issues;
|
||||
let registration_issues = result.build_issues;
|
||||
let client = Arc::new(result.client);
|
||||
|
||||
let mut all_ok = true;
|
||||
let mut completed_checks: Vec<(usize, CheckResult)> = Vec::new();
|
||||
let mut pending_probes = Vec::new();
|
||||
for (index, (model_id, provider_name)) in model_providers.iter().enumerate() {
|
||||
let provider_id = canonical_provider_id(catalog, provider_name);
|
||||
if let Some((_, issue)) = auth_issues
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate == &provider_id)
|
||||
{
|
||||
all_ok = false;
|
||||
completed_checks.push((index, CheckResult {
|
||||
name: "LLM".into(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: model_id.clone(),
|
||||
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||
remediation: Some(issue.to_string()),
|
||||
}));
|
||||
} else if let Some(issue) = registration_issues
|
||||
.iter()
|
||||
.find(|issue| issue.provider == provider_id)
|
||||
{
|
||||
all_ok = false;
|
||||
completed_checks.push((index, CheckResult {
|
||||
name: "LLM".into(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: model_id.clone(),
|
||||
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||
remediation: Some(issue.cause.to_string()),
|
||||
}));
|
||||
} else if !client.available_providers().contains(&provider_id) {
|
||||
all_ok = false;
|
||||
completed_checks.push((index, CheckResult {
|
||||
name: "LLM".into(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: model_id.clone(),
|
||||
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||
remediation: Some(format!(
|
||||
"Provider \"{provider_name}\" is not configured"
|
||||
)),
|
||||
}));
|
||||
} else {
|
||||
pending_probes.push(PendingModelProbe {
|
||||
index,
|
||||
model_id: model_id.clone(),
|
||||
provider_name: provider_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe_checks = stream::iter(pending_probes)
|
||||
.map(|probe| {
|
||||
let client = Arc::clone(&client);
|
||||
async move {
|
||||
let outcome = probe::run_basic_probe(
|
||||
&client,
|
||||
&format!("{}/{}", probe.provider_name, probe.model_id),
|
||||
Duration::from_secs(fabro_types::ModelTestMode::Basic.timeout_secs()),
|
||||
)
|
||||
.await;
|
||||
let (status, remediation) = if outcome.status == ModelTestStatus::Ok {
|
||||
(CheckStatus::Pass, None)
|
||||
} else {
|
||||
(
|
||||
CheckStatus::Error,
|
||||
Some(format!(
|
||||
"Model availability probe failed: {}",
|
||||
outcome
|
||||
.error_message
|
||||
.unwrap_or_else(|| "unknown error".to_string())
|
||||
)),
|
||||
)
|
||||
};
|
||||
(probe.index, CheckResult {
|
||||
name: "LLM".into(),
|
||||
status,
|
||||
summary: probe.model_id,
|
||||
details: vec![
|
||||
CheckDetail::new(format!("Provider: {}", probe.provider_name)),
|
||||
CheckDetail::new("Probe: basic generation".to_string()),
|
||||
],
|
||||
remediation,
|
||||
})
|
||||
}
|
||||
})
|
||||
.buffer_unordered(MODEL_PREFLIGHT_PROBE_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
if probe_checks
|
||||
.iter()
|
||||
.any(|(_, check)| check.status != CheckStatus::Pass)
|
||||
{
|
||||
all_ok = false;
|
||||
}
|
||||
completed_checks.append(&mut probe_checks);
|
||||
completed_checks.sort_by_key(|(index, _)| *index);
|
||||
checks.extend(completed_checks.into_iter().map(|(_, check)| check));
|
||||
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 canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
|
||||
catalog.enabled_provider(provider_name).map_or_else(
|
||||
|| ProviderId::new(provider_name),
|
||||
|provider| provider.id().clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_github_token_check(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
prepared: &PreparedManifest,
|
||||
|
|
@ -1535,32 +1305,81 @@ async fn mint_github_token(
|
|||
.await
|
||||
}
|
||||
|
||||
fn preflight_response(
|
||||
validated: &Validated,
|
||||
target_path: &Path,
|
||||
report: &CheckReport,
|
||||
ok: bool,
|
||||
) -> types::PreflightResponse {
|
||||
types::PreflightResponse {
|
||||
ok,
|
||||
checks: report_to_api(report),
|
||||
workflow: workflow_summary(validated, target_path),
|
||||
/// The workflow as the validate and preflight responses describe it: its
|
||||
/// name, its size and its goal. From the graph Petri admitted when it did;
|
||||
/// for a refused workflow, from the DOT as written, so the response still
|
||||
/// names what was checked. The goal is the run's effective goal, as create
|
||||
/// materializes it: the settings' `run.goal` when the run has one, else the
|
||||
/// workflow's own.
|
||||
pub(crate) struct WorkflowShape {
|
||||
name: String,
|
||||
nodes: usize,
|
||||
edges: usize,
|
||||
goal: String,
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_shape(check: &ManifestCheck, prepared: &PreparedManifest) -> WorkflowShape {
|
||||
workflow_shape_of(
|
||||
check,
|
||||
&prepared.root_source,
|
||||
&prepared.settings,
|
||||
&prepared.source_directory,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_shape_of(
|
||||
check: &ManifestCheck,
|
||||
root_source: &str,
|
||||
settings: &WorkflowSettings,
|
||||
working_directory: &Path,
|
||||
) -> WorkflowShape {
|
||||
let mut shape = check.graph.as_ref().map_or_else(
|
||||
|| {
|
||||
parser::parse(root_source).map_or_else(
|
||||
|_| WorkflowShape {
|
||||
name: String::new(),
|
||||
nodes: 0,
|
||||
edges: 0,
|
||||
goal: String::new(),
|
||||
},
|
||||
|graph| WorkflowShape {
|
||||
goal: graph
|
||||
.attrs
|
||||
.get("goal")
|
||||
.and_then(AttrValue::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
nodes: graph.nodes.len(),
|
||||
edges: graph.edges.len(),
|
||||
name: graph.name,
|
||||
},
|
||||
)
|
||||
},
|
||||
|graph| WorkflowShape {
|
||||
name: graph.name.clone(),
|
||||
nodes: graph.nodes.len(),
|
||||
edges: graph.edges.len(),
|
||||
goal: graph.goal().to_string(),
|
||||
},
|
||||
);
|
||||
if let Ok(Some(resolved)) = resolve_run_goal_from_namespace(&settings.run, working_directory) {
|
||||
shape.goal = resolved.text;
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_summary(
|
||||
validated: &Validated,
|
||||
check: &ManifestCheck,
|
||||
shape: &WorkflowShape,
|
||||
target_path: &Path,
|
||||
) -> types::PreflightWorkflowSummary {
|
||||
types::PreflightWorkflowSummary {
|
||||
diagnostics: diagnostics_to_api(validated.diagnostics()),
|
||||
edges: i64::try_from(validated.graph().edges.len())
|
||||
.expect("graph edge count should fit in i64"),
|
||||
goal: validated.graph().goal().to_string(),
|
||||
diagnostics: diagnostics_to_api(&check.diagnostics),
|
||||
edges: i64::try_from(shape.edges).expect("graph edge count should fit in i64"),
|
||||
goal: shape.goal.clone(),
|
||||
graph_path: Some(target_path.display().to_string()),
|
||||
name: validated.graph().name.clone(),
|
||||
nodes: i64::try_from(validated.graph().nodes.len())
|
||||
.expect("graph node count should fit in i64"),
|
||||
name: shape.name.clone(),
|
||||
nodes: i64::try_from(shape.nodes).expect("graph node count should fit in i64"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1647,13 +1466,13 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// Validate as the endpoints do: the structural pass, then Petri's check
|
||||
/// with the state's model client over `ready_providers`.
|
||||
/// Validate as the endpoints do: Petri's check with the state's model
|
||||
/// client over `ready_providers`.
|
||||
fn validate_for_test(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
) -> Result<ManifestCheck, WorkflowError> {
|
||||
let launch = petri_check::launch(
|
||||
&state.catalog(),
|
||||
&prepared.settings,
|
||||
|
|
@ -1677,7 +1496,7 @@ mod tests {
|
|||
fn validate_with_catalog(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
) -> Result<ManifestCheck, WorkflowError> {
|
||||
let providers = state
|
||||
.catalog()
|
||||
.enabled_provider_ids()
|
||||
|
|
@ -1798,21 +1617,6 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
fn openai_compatible_completion(model: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": "chatcmpl_preflight",
|
||||
"object": "chat.completion",
|
||||
"created": 1_700_000_000,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
})
|
||||
}
|
||||
|
||||
fn ready_moonshot_and_openrouter_state(
|
||||
server: &httpmock::MockServer,
|
||||
) -> Arc<crate::server::AppState> {
|
||||
|
|
@ -1837,55 +1641,12 @@ enabled = true
|
|||
.build()
|
||||
}
|
||||
|
||||
async fn preflight_for_model(
|
||||
state: &Arc<crate::server::AppState>,
|
||||
model: &str,
|
||||
) -> (types::PreflightResponse, bool) {
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let mut ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(FabroClient::provider_ids)
|
||||
.unwrap_or_default();
|
||||
ready_providers.sort();
|
||||
assert_eq!(ready_providers, vec![
|
||||
ProviderId::new("moonshot"),
|
||||
ProviderId::new("openrouter")
|
||||
]);
|
||||
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().source = format!(
|
||||
r#"
|
||||
digraph Demo {{
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
work [prompt="Do work", model="{model}"]
|
||||
start -> work -> exit
|
||||
}}
|
||||
"#
|
||||
);
|
||||
let prepared = prepare_manifest(
|
||||
&manifest_run_defaults(Some(&default_settings_fixture())),
|
||||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_for_test(state, &prepared, &ready_providers).unwrap();
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
|
||||
|
||||
run_preflight(state.as_ref(), &prepared, &validated, llm_result)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Preflight for a workflow naming `model`, which Petri refuses.
|
||||
async fn preflight_for_refused_model(
|
||||
state: &Arc<crate::server::AppState>,
|
||||
model: &str,
|
||||
) -> (types::PreflightResponse, bool) {
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(FabroClient::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().source = format!(
|
||||
r#"
|
||||
|
|
@ -1903,9 +1664,9 @@ digraph Demo {{
|
|||
)
|
||||
.unwrap();
|
||||
let validated = validate_for_test(state, &prepared, &ready_providers).unwrap();
|
||||
assert!(validated.has_errors(), "{:?}", validated.diagnostics());
|
||||
assert!(validated.has_errors(), "{:?}", validated.diagnostics);
|
||||
|
||||
run_preflight(state.as_ref(), &prepared, &validated, llm_result)
|
||||
run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
|
@ -1913,10 +1674,9 @@ digraph Demo {{
|
|||
async fn resolve_and_run_preflight(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
validated: &Validated,
|
||||
check: &ManifestCheck,
|
||||
) -> Result<(types::PreflightResponse, bool)> {
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
run_preflight(state, prepared, validated, llm_result).await
|
||||
run_preflight(state, prepared, check).await
|
||||
}
|
||||
|
||||
fn manifest_workflow() -> types::ManifestWorkflow {
|
||||
|
|
@ -2569,7 +2329,7 @@ issues = "read"
|
|||
)
|
||||
.unwrap();
|
||||
let validated = validate_with_catalog(&state, &prepared).unwrap();
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics);
|
||||
|
||||
let (response, _ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
|
|
@ -2614,7 +2374,7 @@ issues = "{{ env.GITHUB_ISSUES_PERMISSION }}"
|
|||
)
|
||||
.unwrap();
|
||||
let validated = validate_with_catalog(&state, &prepared).unwrap();
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics);
|
||||
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
|
|
@ -2669,7 +2429,7 @@ id = "local"
|
|||
.unwrap();
|
||||
let validated = validate_with_catalog(&state, &prepared).unwrap();
|
||||
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics());
|
||||
assert!(!validated.has_errors(), "{:?}", validated.diagnostics);
|
||||
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
|
|
@ -2792,139 +2552,18 @@ id = "daytona"
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_probes_configured_llm_model_availability() {
|
||||
async fn preflight_refuses_an_unknown_unqualified_model_before_any_check() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let response_mock = server
|
||||
let any_provider_call = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/v1/responses")
|
||||
.header("authorization", "Bearer test-openai-key");
|
||||
then.status(429)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"error": {
|
||||
"message": "quota limited",
|
||||
"type": "rate_limit_error"
|
||||
}
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let state = crate::test_support::TestAppStateBuilder::new()
|
||||
.runtime_settings(
|
||||
crate::test_support::default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
)
|
||||
.max_concurrent_runs(5)
|
||||
.provider_base_url("openai", server.url("/v1"))
|
||||
.build();
|
||||
state
|
||||
.stores
|
||||
.vault
|
||||
.set(
|
||||
"OPENAI_API_KEY",
|
||||
"test-openai-key",
|
||||
fabro_vault::SecretType::Token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#"
|
||||
digraph Demo {
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
work [prompt="Do work", model="gpt-54"]
|
||||
start -> work -> exit
|
||||
}
|
||||
"#
|
||||
.to_string();
|
||||
let prepared = prepare_manifest(
|
||||
&manifest_run_defaults(Some(&default_settings_fixture())),
|
||||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let validated = validate_with_catalog(&state, &prepared).unwrap();
|
||||
|
||||
let (response, ok) = resolve_and_run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!ok);
|
||||
let llm_check = response.checks.sections[0]
|
||||
.checks
|
||||
.iter()
|
||||
.find(|check| check.name == "LLM" && check.summary == "gpt-5.4")
|
||||
.expect("preflight should include the configured LLM model");
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Error);
|
||||
assert!(
|
||||
llm_check
|
||||
.remediation
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("quota limited")
|
||||
);
|
||||
assert!(response_mock.calls_async().await >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_uses_ready_providers_for_known_shared_alias() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let openrouter_probe = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/openrouter/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-openrouter-key")
|
||||
.json_body_includes(r#"{"model":"anthropic/claude-fable-5"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(openai_compatible_completion("anthropic/claude-fable-5"));
|
||||
})
|
||||
.await;
|
||||
let state = ready_moonshot_and_openrouter_state(&server);
|
||||
|
||||
let (response, _ok) = preflight_for_model(&state, "claude-fable").await;
|
||||
|
||||
let llm_check = response.checks.sections[0]
|
||||
.checks
|
||||
.iter()
|
||||
.find(|check| check.name == "LLM" && check.summary == "claude-fable-5")
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"preflight should include Claude Fable: {:?}",
|
||||
response.checks.sections
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
llm_check
|
||||
.details
|
||||
.iter()
|
||||
.map(|detail| detail.text.as_str())
|
||||
.find(|detail| detail.starts_with("Provider: ")),
|
||||
Some("Provider: openrouter")
|
||||
);
|
||||
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
|
||||
openrouter_probe.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_uses_ready_providers_for_unknown_unqualified_model() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let moonshot_probe = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/moonshot/v1/chat/completions")
|
||||
.header("authorization", "Bearer test-moonshot-key")
|
||||
.json_body_includes(r#"{"model":"provider-private-preview"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(openai_compatible_completion("provider-private-preview"));
|
||||
when.any_request();
|
||||
then.status(500);
|
||||
})
|
||||
.await;
|
||||
let state = ready_moonshot_and_openrouter_state(&server);
|
||||
|
||||
// Petri admits no model its catalog lacks, so the workflow is refused
|
||||
// before any probe runs: no passthrough of an unknown model.
|
||||
// at its check: no passthrough of an unknown model to a provider.
|
||||
let (response, ok) = preflight_for_refused_model(&state, "provider-private-preview").await;
|
||||
|
||||
assert!(!ok);
|
||||
|
|
@ -2939,7 +2578,7 @@ digraph Demo {
|
|||
.all(|check| check.name != "LLM"),
|
||||
"no LLM check runs on a refused workflow"
|
||||
);
|
||||
assert_eq!(moonshot_probe.calls_async().await, 0);
|
||||
assert_eq!(any_provider_call.calls_async().await, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2964,7 +2603,7 @@ digraph Demo {
|
|||
|
||||
// Petri refuses the model node: the provider is not in the catalog.
|
||||
let refusal = validated
|
||||
.diagnostics()
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.severity == Severity::Error)
|
||||
.expect("unknown provider should fail static validation");
|
||||
|
|
@ -3010,11 +2649,7 @@ digraph Demo {
|
|||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
let llm_result = state.resolve_llm_client().await;
|
||||
let ready_providers = llm_result
|
||||
.as_ref()
|
||||
.map(FabroClient::provider_ids)
|
||||
.unwrap_or_default();
|
||||
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
assert!(ready_providers.is_empty());
|
||||
|
||||
// With no provider ready there is no model client to resolve the
|
||||
|
|
@ -3024,14 +2659,14 @@ digraph Demo {
|
|||
assert!(validated.has_errors());
|
||||
assert!(
|
||||
validated
|
||||
.diagnostics()
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.rule == petri_check::NO_READY_PROVIDER_RULE),
|
||||
"{:?}",
|
||||
validated.diagnostics()
|
||||
validated.diagnostics
|
||||
);
|
||||
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated, llm_result)
|
||||
let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ok);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::time::Duration;
|
|||
|
||||
use fabro_llm::{Client, Request};
|
||||
use fabro_template::{TemplateContext, TemplateError};
|
||||
use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, RunId};
|
||||
use fabro_types::{MAX_RUN_TITLE_CHARS, RunGraph, RunId};
|
||||
use fabro_util::error;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use serde::Serialize;
|
||||
|
|
@ -149,20 +149,19 @@ pub(crate) struct WorkflowSummary {
|
|||
pub(crate) struct StageSummary {
|
||||
id: String,
|
||||
label: String,
|
||||
handler_type: Option<String>,
|
||||
handler_type: String,
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_summary(graph: &Graph) -> WorkflowSummary {
|
||||
let mut stages = graph
|
||||
pub(crate) fn workflow_summary(graph: &RunGraph) -> WorkflowSummary {
|
||||
let stages = graph
|
||||
.nodes
|
||||
.values()
|
||||
.map(|node| StageSummary {
|
||||
id: node.id.clone(),
|
||||
label: node.label().to_string(),
|
||||
handler_type: node.handler_type().map(str::to_string),
|
||||
.iter()
|
||||
.map(|(id, node)| StageSummary {
|
||||
id: id.clone(),
|
||||
label: node.label.clone(),
|
||||
handler_type: node.kind.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
stages.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
|
||||
WorkflowSummary {
|
||||
graph_name: graph.name.clone(),
|
||||
|
|
@ -192,28 +191,36 @@ mod tests {
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_llm::adapter::{ProviderAdapter, ResolvedCall};
|
||||
use fabro_llm::lithos_catalog::AdapterId;
|
||||
use fabro_llm::{Error as LlmError, Response, ResponseStream};
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::{RunGraphEdge, RunGraphNode, RunId, StageHandler};
|
||||
use lithos_llm::catalog::builtin;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn title_test_graph() -> fabro_types::Graph {
|
||||
parser::parse(
|
||||
r#"digraph Ship {
|
||||
graph [goal="Deploy API token SECRET_123 to production"]
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
plan [shape=box, label="Plan rollout"]
|
||||
deploy [shape=parallelogram, label="Deploy"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
start -> plan -> deploy -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
fn title_test_graph() -> RunGraph {
|
||||
let mut graph = RunGraph::new("Ship");
|
||||
graph.goal = "Deploy API token SECRET_123 to production".to_string();
|
||||
for (id, label, kind) in [
|
||||
("start", "Start", StageHandler::Start),
|
||||
("plan", "Plan rollout", StageHandler::Agent),
|
||||
("deploy", "Deploy", StageHandler::Command),
|
||||
("exit", "Exit", StageHandler::Exit),
|
||||
] {
|
||||
graph.nodes.insert(id.to_string(), RunGraphNode {
|
||||
label: label.to_string(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
for (from, to) in [("start", "plan"), ("plan", "deploy"), ("deploy", "exit")] {
|
||||
graph.edges.push(RunGraphEdge {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
});
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// Strict rendering already fails on a variable the template asks for and
|
||||
|
|
|
|||
|
|
@ -52,11 +52,26 @@ async fn render_graph_from_manifest(
|
|||
Ok(prepared) => prepared,
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
let validated = match run_manifest::validate_prepared_manifest_structural(&prepared) {
|
||||
Ok(validated) => validated,
|
||||
let vars = match state.stores.variables.value_map().await {
|
||||
Ok(vars) => vars,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let check = match run_manifest::check_prepared_manifest(
|
||||
&state,
|
||||
&prepared,
|
||||
vars,
|
||||
&ready_providers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(check) => check,
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
if validated.has_errors() {
|
||||
if check.has_errors() {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ use fabro_store::{
|
|||
RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility,
|
||||
};
|
||||
use fabro_types::diagnostic::Severity;
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::{
|
||||
AutomationRef, ContextWindowStaleness, ManifestPath, Principal, Run, RunClientProvenance,
|
||||
RunId, RunProvenance, RunServerProvenance, RunStatus, RunStatusKind, RunTarget,
|
||||
|
|
@ -38,12 +37,11 @@ use fabro_types::{
|
|||
};
|
||||
use fabro_util::error as error_util;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use fabro_workflow::pipeline::Validated;
|
||||
use fabro_workflow::{Error as WorkflowError, operations};
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use serde::de::IgnoredAny;
|
||||
use strum::VariantArray as _;
|
||||
use tokio::{fs, task};
|
||||
use tokio::fs;
|
||||
use tracing::info;
|
||||
|
||||
use super::super::{
|
||||
|
|
@ -64,11 +62,11 @@ use crate::run_intent::{
|
|||
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
|
||||
lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target,
|
||||
};
|
||||
use crate::run_manifest;
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
use crate::run_title_generation::{self, GenerateTitleInput, TitlePromptInput, WorkflowSummary};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use crate::test_support as server_test_support;
|
||||
use crate::{petri_check, run_manifest};
|
||||
|
||||
pub(super) fn manifest_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
|
|
@ -753,7 +751,7 @@ async fn finalize_created_run(
|
|||
// the run executes. Fabro's own settings resolution ran above.
|
||||
let pinned = match petri_runs::admit(&state, &prepared, &run_materialization_provider_ids).await
|
||||
{
|
||||
Ok(admission) => run_compiler::compile_admitted(prepared, admission).await,
|
||||
Ok(admitted) => run_compiler::materialize_admitted(prepared, admitted).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let pinned = match pinned {
|
||||
|
|
@ -810,7 +808,7 @@ async fn finalize_created_run(
|
|||
runs.insert(
|
||||
created.run_id,
|
||||
managed_run(
|
||||
created.persisted.source().to_string(),
|
||||
created.source.clone(),
|
||||
RunStatus::Submitted,
|
||||
created_at,
|
||||
created.run_dir,
|
||||
|
|
@ -820,7 +818,7 @@ async fn finalize_created_run(
|
|||
}
|
||||
if !explicit_title_supplied && !ready_provider_ids.is_empty() {
|
||||
if let Some(llm_result) = llm_client_for_title {
|
||||
let run_spec = created.persisted.run_spec();
|
||||
let run_spec = &created.spec;
|
||||
let workflow = run_title_generation::workflow_summary(&run_spec.graph);
|
||||
let run_inputs = run_spec.settings.run.inputs.clone();
|
||||
let title_catalog = state.catalog();
|
||||
|
|
@ -1213,24 +1211,28 @@ async fn run_preflight(
|
|||
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
|
||||
.into_response();
|
||||
}
|
||||
let (llm_result, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let mut validated =
|
||||
match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await {
|
||||
Ok(validated) => validated,
|
||||
Err(WorkflowError::Parse(_)) => {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
validated.promote_template_undefined_variables_to_errors();
|
||||
let response =
|
||||
match run_manifest::run_preflight(&state, &prepared, &validated, llm_result).await {
|
||||
Ok((response, _ok)) => response,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let check = match run_manifest::check_prepared_manifest(
|
||||
&state,
|
||||
&prepared,
|
||||
vars,
|
||||
&ready_providers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(check) => check,
|
||||
Err(WorkflowError::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, &check).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()
|
||||
}
|
||||
|
||||
|
|
@ -1263,55 +1265,27 @@ async fn validate_run_manifest(
|
|||
.into_response();
|
||||
}
|
||||
let (_, ready_providers) = state.resolve_llm_client_with_ready_ids().await;
|
||||
let validated =
|
||||
match validate_manifest_on_petri(&state, &prepared, vars, &ready_providers).await {
|
||||
Ok(validated) => validated,
|
||||
Err(WorkflowError::Parse(_)) => {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
let check = match run_manifest::check_prepared_manifest(
|
||||
&state,
|
||||
&prepared,
|
||||
vars,
|
||||
&ready_providers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(check) => check,
|
||||
Err(WorkflowError::Parse(_)) => {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(run_manifest::validate_response(&prepared, &validated)),
|
||||
Json(run_manifest::validate_response(&prepared, &check)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Validate a prepared manifest as a run would be admitted: Fabro's
|
||||
/// structural pass, then Petri's check with the model client over the ready
|
||||
/// providers, on the blocking pool.
|
||||
async fn validate_manifest_on_petri(
|
||||
state: &Arc<AppState>,
|
||||
prepared: &run_manifest::PreparedManifest,
|
||||
vars: HashMap<String, String>,
|
||||
ready_providers: &[ProviderId],
|
||||
) -> Result<Validated, WorkflowError> {
|
||||
let launch = petri_check::launch(
|
||||
&state.catalog(),
|
||||
&prepared.settings,
|
||||
ready_providers,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun;
|
||||
let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run);
|
||||
let has_ready_provider = !ready_providers.is_empty();
|
||||
let prepared = prepared.clone();
|
||||
task::spawn_blocking(move || {
|
||||
run_manifest::validate_prepared_manifest(
|
||||
&prepared,
|
||||
&vars,
|
||||
launch,
|
||||
runtime,
|
||||
has_ready_provider,
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|source| WorkflowError::engine_with_source("manifest check task failed", source))?
|
||||
}
|
||||
|
||||
async fn snapshot_run_variables(
|
||||
state: &AppState,
|
||||
) -> Result<HashMap<String, String>, VariableError> {
|
||||
|
|
|
|||
|
|
@ -1062,17 +1062,13 @@ fn build_ask_fabro_run_snapshot(projection: &fabro_types::RunProjection, run_id:
|
|||
|
||||
let total_non_meta = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| !is_ask_fabro_meta_node(node))
|
||||
.keys()
|
||||
.filter(|node_id| !graph.is_boundary(node_id))
|
||||
.count();
|
||||
let completed_non_meta = projection
|
||||
.iter_stages()
|
||||
.filter(|(stage_id, stage)| {
|
||||
graph
|
||||
.nodes
|
||||
.get(stage_id.node_id())
|
||||
.is_none_or(|node| !is_ask_fabro_meta_node(node))
|
||||
&& stage.effective_state().is_terminal()
|
||||
!graph.is_boundary(stage_id.node_id()) && stage.effective_state().is_terminal()
|
||||
})
|
||||
.count();
|
||||
lines.push(format!(
|
||||
|
|
@ -1081,12 +1077,7 @@ fn build_ask_fabro_run_snapshot(projection: &fabro_types::RunProjection, run_id:
|
|||
|
||||
let recent_stages = projection
|
||||
.iter_stages()
|
||||
.filter(|(stage_id, _)| {
|
||||
graph
|
||||
.nodes
|
||||
.get(stage_id.node_id())
|
||||
.is_none_or(|node| !is_ask_fabro_meta_node(node))
|
||||
})
|
||||
.filter(|(stage_id, _)| !graph.is_boundary(stage_id.node_id()))
|
||||
.collect::<Vec<_>>();
|
||||
let recent_stages = recent_stages
|
||||
.iter()
|
||||
|
|
@ -1121,10 +1112,6 @@ fn build_ask_fabro_run_snapshot(projection: &fabro_types::RunProjection, run_id:
|
|||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn is_ask_fabro_meta_node(node: &fabro_types::Node) -> bool {
|
||||
matches!(node.handler_type(), Some("start" | "exit"))
|
||||
}
|
||||
|
||||
fn ask_fabro_stage_summary(
|
||||
stage_id: &fabro_types::StageId,
|
||||
stage: &fabro_types::StageProjection,
|
||||
|
|
@ -1792,24 +1779,21 @@ enabled = true
|
|||
fn ask_fabro_run_snapshot_summarizes_goal_progress_and_recent_stages() {
|
||||
let run_id = RunId::new();
|
||||
let now = Utc::now();
|
||||
let mut graph = fabro_types::Graph::new("test");
|
||||
graph.attrs.insert(
|
||||
"goal".to_string(),
|
||||
fabro_types::AttrValue::String("Ship the feature".to_string()),
|
||||
);
|
||||
let mut graph = fabro_types::RunGraph::new("test");
|
||||
graph.goal = "Ship the feature".to_string();
|
||||
for node_id in ["start", "plan", "code", "test", "review", "deploy", "exit"] {
|
||||
let mut node = fabro_types::Node::new(node_id);
|
||||
let shape = match node_id {
|
||||
"start" => "Mdiamond",
|
||||
"exit" => "Msquare",
|
||||
"test" => "parallelogram",
|
||||
_ => "box",
|
||||
let kind = match node_id {
|
||||
"start" => fabro_types::StageHandler::Start,
|
||||
"exit" => fabro_types::StageHandler::Exit,
|
||||
"test" => fabro_types::StageHandler::Command,
|
||||
_ => fabro_types::StageHandler::Agent,
|
||||
};
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
fabro_types::AttrValue::String(shape.to_string()),
|
||||
);
|
||||
graph.nodes.insert(node_id.to_string(), node);
|
||||
graph
|
||||
.nodes
|
||||
.insert(node_id.to_string(), fabro_types::RunGraphNode {
|
||||
label: node_id.to_string(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
let spec = fabro_types::RunSpec {
|
||||
run_id,
|
||||
|
|
@ -1834,13 +1818,7 @@ enabled = true
|
|||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let handler = projection
|
||||
.spec
|
||||
.graph
|
||||
.nodes
|
||||
.get(*node_id)
|
||||
.and_then(fabro_types::Node::handler_type)
|
||||
.and_then(|handler| handler.parse().ok());
|
||||
let handler = projection.spec.graph.node(node_id).map(|node| node.kind);
|
||||
let stage = projection.stage_entry(
|
||||
node_id,
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::usage_rollup::usage_rollup_from_projection;
|
||||
use fabro_types::{
|
||||
Graph, RunProjection, StageHandler, StageId, StageProjection, StageState, StageTiming,
|
||||
RunGraph, RunProjection, StageHandler, StageId, StageProjection, StageState, StageTiming,
|
||||
usage_is_empty,
|
||||
};
|
||||
|
||||
|
|
@ -23,16 +23,13 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
fn run_stage_from_projection(
|
||||
stage_id: &StageId,
|
||||
stage: &StageProjection,
|
||||
graph: &Graph,
|
||||
graph: &RunGraph,
|
||||
now: DateTime<Utc>,
|
||||
) -> RunStage {
|
||||
let handler = stage.handler.unwrap_or_else(|| {
|
||||
StageHandler::from_handler_type(
|
||||
graph
|
||||
.nodes
|
||||
.get(stage_id.node_id())
|
||||
.and_then(|node| node.handler_type()),
|
||||
)
|
||||
graph
|
||||
.node(stage_id.node_id())
|
||||
.map_or(StageHandler::Agent, |node| node.kind)
|
||||
});
|
||||
let (parallel_group_id, parallel_branch_index) = stage
|
||||
.parallel_branch_id
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
//! and the launch to Petri's `Runtime::check` through `fabro_petri::check`,
|
||||
//! maps Petri's diagnostics onto Fabro's, and stores the admitted graphs in
|
||||
//! the blob store so the run executes and resumes from what was admitted.
|
||||
//! Petri compiled, linted and pinned models; the legacy compile is skipped.
|
||||
//! Petri compiled, linted and pinned models; the run's display graph is read
|
||||
//! off its admitted graph (`fabro_petri::run_graph`).
|
||||
//!
|
||||
//! At execution, a Petri run takes the same path a legacy run does: the
|
||||
//! scheduler launches `fabro run __run-worker` with the worker's token, and
|
||||
|
|
@ -45,13 +46,11 @@ use fabro_petri::platform_records::SqlitePlatformRecords;
|
|||
use fabro_petri::recovery::{self, Recovery, RecoveryRequest};
|
||||
use fabro_petri::runtime::{self, RuntimeSpec};
|
||||
use fabro_petri::secrets::VaultSecrets;
|
||||
use fabro_petri::{SqliteRunStore, admission};
|
||||
use fabro_petri::{SqliteRunStore, admission, run_graph};
|
||||
use fabro_store::platform_records::{RunLifecycleKind, RunLifecycleRecord};
|
||||
use fabro_types::settings::McpTransport;
|
||||
use fabro_types::settings::run::{ApprovalMode, McpServerSettings, RunMode};
|
||||
use fabro_types::{
|
||||
FailureReason, PetriAdmission, RunId, RunRunnableSource, RunStatus, RunTarget, SuccessReason,
|
||||
};
|
||||
use fabro_types::{FailureReason, RunId, RunRunnableSource, RunStatus, RunTarget, SuccessReason};
|
||||
use fabro_util::error as error_util;
|
||||
use fabro_workflow::Error as WorkflowError;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
|
@ -65,7 +64,7 @@ use super::{
|
|||
};
|
||||
use crate::petri_check;
|
||||
use crate::petri_runs::PetriRuns;
|
||||
use crate::run_compiler::{PreparedRun, RunCompilerError};
|
||||
use crate::run_compiler::{AdmittedRun, PreparedRun, RunCompilerError};
|
||||
|
||||
/// The runtime Petri gets, at create and at execution: the server's run
|
||||
/// defaults and environment catalog as the settings layer, the MCP
|
||||
|
|
@ -245,14 +244,14 @@ fn mcp_catalog_entry(server: &McpServerSettings) -> toml::Table {
|
|||
entry
|
||||
}
|
||||
|
||||
/// Petri compiles the run: check the bundle, map the diagnostics, and
|
||||
/// persist the admitted graphs. A refusal is the same validation error the
|
||||
/// legacy compiler raised, carrying Petri's diagnostics.
|
||||
/// Petri compiles the run: check the bundle, map the diagnostics, persist
|
||||
/// the admitted graphs, and read the display graph off them. A refusal is a
|
||||
/// validation error carrying Petri's diagnostics.
|
||||
pub(crate) async fn admit(
|
||||
state: &AppState,
|
||||
prepared: &PreparedRun,
|
||||
eligible: &[ProviderId],
|
||||
) -> Result<PetriAdmission, RunCompilerError> {
|
||||
) -> Result<AdmittedRun, RunCompilerError> {
|
||||
let settings = prepared.settings();
|
||||
let repository = match prepared.target() {
|
||||
Some(RunTarget::Folder { path }) => Some(path.into()),
|
||||
|
|
@ -301,14 +300,18 @@ pub(crate) async fn admit(
|
|||
"Petri's check admitted no graph and raised no error",
|
||||
))
|
||||
})?;
|
||||
admission::persist(&state.store_ref().blobs(), &admitted)
|
||||
let admission = admission::persist(&state.store_ref().blobs(), &admitted)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
RunCompilerError::Workflow(WorkflowError::engine_with_source(
|
||||
"the admitted graphs could not be stored",
|
||||
err,
|
||||
))
|
||||
})
|
||||
})?;
|
||||
Ok(AdmittedRun {
|
||||
admission,
|
||||
graph: run_graph::run_graph(&admitted),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a Petri run in the server process, under the test override:
|
||||
|
|
|
|||
|
|
@ -4819,8 +4819,11 @@ async fn validate_endpoint_uses_app_state_catalog_for_model_diagnostics() {
|
|||
);
|
||||
}
|
||||
|
||||
/// An input a bundled prompt reads that nothing binds is Petri's
|
||||
/// `unsupported.template.unbound_input`, positioned at the node attribute
|
||||
/// that names the prompt file, in the workflow the manifest targets.
|
||||
#[tokio::test]
|
||||
async fn validate_endpoint_returns_template_source_coordinates() {
|
||||
async fn validate_endpoint_reports_an_unbound_input_with_petris_code_and_position() {
|
||||
let app = test_app_with();
|
||||
let dot = r#"digraph ValidatePlan {
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
|
|
@ -4866,18 +4869,17 @@ async fn validate_endpoint_returns_template_source_coordinates() {
|
|||
let diagnostics = body["workflow"]["diagnostics"].as_array().unwrap();
|
||||
let diagnostic = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic["rule"] == "template_undefined_variable")
|
||||
.expect("expected template diagnostic");
|
||||
.find(|diagnostic| diagnostic["rule"] == "unsupported.template.unbound_input")
|
||||
.unwrap_or_else(|| panic!("expected Petri's unbound input diagnostic: {diagnostics:?}"));
|
||||
|
||||
assert_eq!(diagnostic["source_path"], "test.md");
|
||||
assert_eq!(diagnostic["line"], 1);
|
||||
assert_eq!(diagnostic["column"], 4);
|
||||
assert!(
|
||||
diagnostic["node_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("test_imported_prompt")
|
||||
);
|
||||
assert_eq!(diagnostic["severity"], "error");
|
||||
assert_eq!(diagnostic["source_path"], "workflow.fabro");
|
||||
assert_eq!(diagnostic["line"], 4);
|
||||
assert_eq!(diagnostic["column"], 43);
|
||||
let message = diagnostic["message"].as_str().unwrap();
|
||||
assert!(message.contains("test_imported_prompt"), "{message}");
|
||||
assert!(message.contains("inputs.foo"), "{message}");
|
||||
assert_eq!(body["ok"], false);
|
||||
}
|
||||
|
||||
async fn create_run_for_target(app: &Router, target_path: &str, dot_source: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -270,12 +270,12 @@ async fn run_config_substitutes_variables_before_persisting_settings() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_create_interpolates_variables_into_node_prompts() {
|
||||
async fn run_create_interpolates_variables_into_the_admitted_graph() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
// End-to-end through the real run-create path: a server variable resolves
|
||||
// inside a node `prompt` (a DOT graph attribute the settings substitution
|
||||
// pass never touches), proving the variable store is snapshotted into the
|
||||
// template render context at create time.
|
||||
// inside the graph `goal` (a DOT graph attribute the settings substitution
|
||||
// pass never touches), proving the variable store is snapshotted into
|
||||
// Petri's compile variables at create time.
|
||||
let state = test_app_state_with_options(test_settings(), 5);
|
||||
let app = fabro_server::test_support::build_test_router(std::sync::Arc::clone(&state));
|
||||
|
||||
|
|
@ -291,7 +291,7 @@ async fn run_create_interpolates_variables_into_node_prompts() {
|
|||
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
|
||||
|
||||
let dot = r#"digraph Test {
|
||||
graph [goal="Ship it"]
|
||||
graph [goal="Ship {{ vars.SERVICE }}"]
|
||||
start [shape=Mdiamond]
|
||||
work [shape=box, prompt="Service: {{ vars.SERVICE }}"]
|
||||
exit [shape=Msquare]
|
||||
|
|
@ -315,7 +315,9 @@ async fn run_create_interpolates_variables_into_node_prompts() {
|
|||
.expect("create run response should include id");
|
||||
|
||||
// The run's stream holds its `run.created` record, whose spec carries
|
||||
// the fully-rendered graph. The view trails the record, so wait for it.
|
||||
// the display graph read off Petri's admitted graph: its goal is the
|
||||
// rendered goal, the same rendering the node prompts went through. The
|
||||
// view trails the record, so wait for it.
|
||||
state
|
||||
.test_petri_projector()
|
||||
.settle(run_id.parse().expect("run id"))
|
||||
|
|
@ -340,9 +342,12 @@ async fn run_create_interpolates_variables_into_node_prompts() {
|
|||
.find(|item| item["item"]["record"]["kind"] == "run.created")
|
||||
.expect("expected a run.created record");
|
||||
assert_eq!(
|
||||
created["item"]["record"]["spec"]["graph"]["nodes"]["work"]["attrs"]["prompt"]["String"],
|
||||
"Service: billing",
|
||||
"node prompt should interpolate the run variable; record: {created}"
|
||||
created["item"]["record"]["spec"]["graph"]["goal"], "Ship billing",
|
||||
"the admitted goal should interpolate the run variable; record: {created}"
|
||||
);
|
||||
assert_eq!(
|
||||
created["item"]["record"]["spec"]["graph"]["nodes"]["work"]["kind"], "agent",
|
||||
"record: {created}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -494,12 +494,12 @@ mod tests {
|
|||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_store::{RunProjection, StageId};
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::{
|
||||
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,
|
||||
RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, SuccessReason, first_event_seq, fixtures, test_support,
|
||||
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunGraph, RunSandbox,
|
||||
RunSandboxInstance, RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion,
|
||||
StageModelUsage, StageOutcome, StartRecord, SuccessReason, first_event_seq, fixtures,
|
||||
test_support,
|
||||
};
|
||||
use futures::executor;
|
||||
|
||||
|
|
@ -507,7 +507,7 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
graph: Graph::new("ship"),
|
||||
graph: RunGraph::new("ship"),
|
||||
graph_source: Some("digraph Ship {}".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ use thiserror::Error as ThisError;
|
|||
pub enum Error {
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
#[error("Stylesheet error: {0}")]
|
||||
Stylesheet(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@ pub mod error;
|
|||
pub mod graph;
|
||||
pub mod parser;
|
||||
pub mod render;
|
||||
pub mod stylesheet;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ mod tests {
|
|||
assert_eq!(graph.nodes.len(), 4);
|
||||
// start->run_tests, run_tests->report, report->exit
|
||||
assert_eq!(graph.edges.len(), 3);
|
||||
assert!(graph.find_start_node().is_some());
|
||||
assert!(graph.find_exit_node().is_some());
|
||||
assert!(graph.nodes.contains_key("start"));
|
||||
assert!(graph.nodes.contains_key("exit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -482,7 +482,13 @@ mod tests {
|
|||
};
|
||||
|
||||
let graph = ast_to_graph(&dot).unwrap();
|
||||
assert_eq!(graph.edges[0].weight(), 5);
|
||||
assert_eq!(
|
||||
graph.edges[0]
|
||||
.attrs
|
||||
.get("weight")
|
||||
.and_then(AttrValue::as_i64),
|
||||
Some(5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
//! its diagnostics come back in a shape Fabro maps onto its own;
|
||||
//! - [`admission`]: the admitted graphs in Fabro's blob store, named on the run
|
||||
//! spec;
|
||||
//! - [`run_graph`]: the display graph the run spec carries, read off the
|
||||
//! admitted graph's metadata;
|
||||
//! - [`engine`]: a run executed by Petri, started or resumed, in the run's
|
||||
//! worker process over the HTTP store (or in the server process under its
|
||||
//! test override), with the outcome read from its record;
|
||||
|
|
@ -73,6 +75,7 @@ pub mod platform_records;
|
|||
pub mod projection;
|
||||
pub mod projector;
|
||||
pub mod recovery;
|
||||
pub mod run_graph;
|
||||
pub mod run_store;
|
||||
pub mod runtime;
|
||||
pub mod secrets;
|
||||
|
|
|
|||
274
lib/components/fabro-petri/src/run_graph.rs
Normal file
274
lib/components/fabro-petri/src/run_graph.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
//! The display graph of a run, read off what Petri admitted.
|
||||
//!
|
||||
//! Petri's lowered graph carries the frontend's metadata on every node
|
||||
//! (`meta`: `label`, `kind`, `edges`, `synthetic`, see the Fabro handoff's
|
||||
//! "Identities a host can rely on") and the run's goal and workflow name as
|
||||
//! graph params. [`run_graph`] reduces that to the [`RunGraph`] the read side
|
||||
//! stores on the run spec: one node per stage the workflow declares, each
|
||||
//! with its label and handler kind, and one edge per routing arm as written.
|
||||
//!
|
||||
//! Lowering artifacts are left out: the goal check before `exit`, the
|
||||
//! synthetic fan-in before a plain join, and a duplicate branch target's
|
||||
//! extra delegate. A parallel branch target stays a stage of the graph: in
|
||||
//! the parent graph it is the branch delegate (`kind = parallel.branch`,
|
||||
//! `synthetic: true`, `branch = {fork, target}`), so its own kind is read
|
||||
//! from the child graph the branch runs, where the target keeps its
|
||||
//! metadata, and the parallel node's edge to it is the delegate's `branch`.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use fabro_types::{RunGraph, RunGraphEdge, RunGraphNode, StageHandler};
|
||||
use petri_runtime::ir::Graph;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::check::Admitted;
|
||||
|
||||
/// The graph param the Attractor lowering stores the workflow's name under.
|
||||
const WORKFLOW_PARAM: &str = "attractor.workflow";
|
||||
/// The graph param the run's goal is stored under.
|
||||
const GOAL_PARAM: &str = "goal";
|
||||
|
||||
/// The display graph of the admitted workflow.
|
||||
#[must_use]
|
||||
pub fn run_graph(admitted: &Admitted) -> RunGraph {
|
||||
let root = &admitted.graph;
|
||||
let mut graph = RunGraph::new(param_text(root, WORKFLOW_PARAM));
|
||||
graph.goal = param_text(root, GOAL_PARAM).to_string();
|
||||
for node in &root.body.nodes {
|
||||
let meta = &node.meta;
|
||||
let name = node.name.as_str();
|
||||
let kind = if is_synthetic(meta) {
|
||||
// A branch delegate stands for the parallel node's edge to its
|
||||
// target, and for the target itself when it is named after it;
|
||||
// any other synthetic node is a lowering artifact.
|
||||
let Some((fork, target)) = branch_of(meta) else {
|
||||
continue;
|
||||
};
|
||||
graph.edges.push(RunGraphEdge {
|
||||
from: fork.to_string(),
|
||||
to: target.to_string(),
|
||||
});
|
||||
if target != name {
|
||||
continue;
|
||||
}
|
||||
admitted
|
||||
.children
|
||||
.iter()
|
||||
.flat_map(|child| &child.body.nodes)
|
||||
.find(|candidate| candidate.name == target && !is_synthetic(&candidate.meta))
|
||||
.map_or(StageHandler::Agent, |candidate| kind_of(&candidate.meta))
|
||||
} else {
|
||||
kind_of(meta)
|
||||
};
|
||||
let label = meta
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|label| !label.is_empty())
|
||||
.unwrap_or(name);
|
||||
graph.nodes.insert(name.to_string(), RunGraphNode {
|
||||
label: label.to_string(),
|
||||
kind,
|
||||
});
|
||||
if let Some(edges) = meta.get("edges").and_then(Value::as_object) {
|
||||
for entry in edges.values() {
|
||||
if let Some(to) = entry.get("to").and_then(Value::as_str) {
|
||||
graph.edges.push(RunGraphEdge {
|
||||
from: name.to_string(),
|
||||
to: to.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The routing arms are keyed by engine edge id in the metadata; order
|
||||
// them by the nodes they join so the graph reads the same on every
|
||||
// build.
|
||||
graph
|
||||
.edges
|
||||
.sort_by(|left, right| (&left.from, &left.to).cmp(&(&right.from, &right.to)));
|
||||
graph
|
||||
}
|
||||
|
||||
fn param_text<'a>(graph: &'a Graph, name: &str) -> &'a str {
|
||||
graph
|
||||
.params
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_synthetic(meta: &Value) -> bool {
|
||||
meta.get("synthetic").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// The parallel node and the target a branch delegate stands for.
|
||||
fn branch_of(meta: &Value) -> Option<(&str, &str)> {
|
||||
let branch = meta.get("branch")?;
|
||||
Some((
|
||||
branch.get("fork")?.as_str()?,
|
||||
branch.get("target")?.as_str()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The node's handler kind from its `meta.kind`: the Attractor names are the
|
||||
/// stage handler names, and anything else runs as an agent, as Fabro's
|
||||
/// handler resolution has it.
|
||||
fn kind_of(meta: &Value) -> StageHandler {
|
||||
let kind = meta.get("kind").and_then(Value::as_str);
|
||||
kind.and_then(|kind| StageHandler::from_str(kind).ok())
|
||||
.unwrap_or_else(|| StageHandler::from_handler_type(kind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::check::{self, Bundle, CheckRequest};
|
||||
|
||||
fn admit(files: &[(&str, &str)]) -> Admitted {
|
||||
let request = CheckRequest {
|
||||
bundle: Bundle {
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, text)| ((*path).to_string(), (*text).to_string()))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
entrypoint: "workflow.fabro".to_string(),
|
||||
project_toml: None,
|
||||
},
|
||||
..CheckRequest::default()
|
||||
};
|
||||
check::check(&request).unwrap_or_else(|err| panic!("the workflow should admit: {err:?}"))
|
||||
}
|
||||
|
||||
fn kinds(graph: &RunGraph) -> Vec<(&str, StageHandler)> {
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, node)| (id.as_str(), node.kind))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn edges(graph: &RunGraph) -> Vec<(&str, &str)> {
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.map(|edge| (edge.from.as_str(), edge.to.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_workflow_keeps_its_name_goal_stages_and_edges() {
|
||||
let admitted = admit(&[(
|
||||
"workflow.fabro",
|
||||
r#"digraph Ship {
|
||||
graph [goal="Ship the feature"]
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
plan [label="Plan rollout", prompt="Plan"]
|
||||
deploy [shape=parallelogram, script="make deploy"]
|
||||
gate [shape=hexagon, label="Ship?"]
|
||||
exit [shape=Msquare]
|
||||
start -> plan -> deploy -> gate
|
||||
gate -> exit [label="[Y] Yes"]
|
||||
gate -> plan [label="[N] No"]
|
||||
}"#,
|
||||
)]);
|
||||
|
||||
let graph = run_graph(&admitted);
|
||||
|
||||
assert_eq!(graph.name, "Ship");
|
||||
assert_eq!(graph.goal(), "Ship the feature");
|
||||
assert_eq!(kinds(&graph), vec![
|
||||
("deploy", StageHandler::Command),
|
||||
("exit", StageHandler::Exit),
|
||||
("gate", StageHandler::Human),
|
||||
("plan", StageHandler::Agent),
|
||||
("start", StageHandler::Start),
|
||||
]);
|
||||
assert_eq!(graph.node("plan").unwrap().label, "Plan rollout");
|
||||
assert_eq!(graph.node("deploy").unwrap().label, "deploy");
|
||||
assert_eq!(edges(&graph), vec![
|
||||
("deploy", "gate"),
|
||||
("gate", "exit"),
|
||||
("gate", "plan"),
|
||||
("plan", "deploy"),
|
||||
("start", "plan"),
|
||||
]);
|
||||
assert!(graph.is_boundary("start"));
|
||||
assert!(graph.is_boundary("exit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_artifacts_are_left_out_and_branch_targets_keep_their_kind() {
|
||||
let admitted = admit(&[(
|
||||
"workflow.fabro",
|
||||
r#"digraph Parallel {
|
||||
graph [goal="Fan out"]
|
||||
start [shape=Mdiamond]
|
||||
fan_out [shape=component]
|
||||
lint [shape=parallelogram, script="make lint"]
|
||||
test [prompt="Run the tests", goal_gate=true, retry_target="lint"]
|
||||
join [shape=tripleoctagon]
|
||||
exit [shape=Msquare]
|
||||
start -> fan_out
|
||||
fan_out -> lint
|
||||
fan_out -> test
|
||||
lint -> join
|
||||
test -> join
|
||||
join -> exit
|
||||
}"#,
|
||||
)]);
|
||||
|
||||
let graph = run_graph(&admitted);
|
||||
|
||||
assert_eq!(kinds(&graph), vec![
|
||||
("exit", StageHandler::Exit),
|
||||
("fan_out", StageHandler::Parallel),
|
||||
("join", StageHandler::ParallelFanIn),
|
||||
("lint", StageHandler::Command),
|
||||
("start", StageHandler::Start),
|
||||
("test", StageHandler::Agent),
|
||||
]);
|
||||
assert_eq!(edges(&graph), vec![
|
||||
("fan_out", "lint"),
|
||||
("fan_out", "test"),
|
||||
("join", "exit"),
|
||||
("lint", "join"),
|
||||
("start", "fan_out"),
|
||||
("test", "join"),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_steps_are_stages_of_the_graph_they_run_in() {
|
||||
let admitted = admit(&[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph Prepared {
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="Work"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}"#,
|
||||
),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[run]\ngoal = \"Settings goal\"\n[[run.prepare.steps]]\nscript = \
|
||||
\"make setup\"\n",
|
||||
),
|
||||
]);
|
||||
|
||||
let graph = run_graph(&admitted);
|
||||
|
||||
assert_eq!(graph.goal(), "Settings goal");
|
||||
assert_eq!(
|
||||
graph.node("run_prepare_1").map(|node| node.kind),
|
||||
Some(StageHandler::Command)
|
||||
);
|
||||
assert_eq!(edges(&graph), vec![
|
||||
("run_prepare_1", "work"),
|
||||
("start", "run_prepare_1"),
|
||||
("work", "exit"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -644,10 +644,10 @@ mod tests {
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{
|
||||
AutomationRef, BlockedReason, Conclusion, DiffSummary, FailureReason, Graph, PendingReason,
|
||||
PetriAdmission, PullRequestCreationId, RunDiff, RunId, RunProjection, RunSize, RunSpec,
|
||||
RunStatus, RunStatusKind, RunTiming, StageOutcome, SuccessReason, WorkflowSettings,
|
||||
test_support,
|
||||
AutomationRef, BlockedReason, Conclusion, DiffSummary, FailureReason, PendingReason,
|
||||
PetriAdmission, PullRequestCreationId, RunDiff, RunGraph, RunId, RunProjection, RunSize,
|
||||
RunSpec, RunStatus, RunStatusKind, RunTiming, StageOutcome, SuccessReason,
|
||||
WorkflowSettings, test_support,
|
||||
};
|
||||
use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage};
|
||||
use strum::VariantArray as _;
|
||||
|
|
@ -678,7 +678,7 @@ mod tests {
|
|||
RunSpec {
|
||||
run_id,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph: RunGraph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test-workflow".to_string()),
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -2,19 +2,18 @@ use std::collections::{BTreeMap, HashMap};
|
|||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_store::{RunProjection, SerializableProjection, StageId};
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::{
|
||||
Checkpoint, CheckpointRecord, InterviewQuestionRecord, ModelUsage, ParallelBranchResult,
|
||||
QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime,
|
||||
RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,
|
||||
first_event_seq, fixtures, test_support,
|
||||
QuestionType, RunDiff, RunGraph, RunSandbox, RunSandboxInstance, RunSandboxPlan,
|
||||
RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
graph: Graph::new("ship"),
|
||||
graph: RunGraph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ fabro-sandbox = { path = "../fabro-sandbox" }
|
|||
sandbox-driver.workspace = true
|
||||
pebble-coding-agent.workspace = true
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-template = { path = "../../foundation/fabro-template" }
|
||||
fabro-tool = { path = "../fabro-tool" }
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
fabro-redact.workspace = true
|
||||
|
|
@ -48,7 +47,6 @@ chrono = { workspace = true, features = ["serde"] }
|
|||
dirs = "6"
|
||||
scopeguard = "1"
|
||||
hex.workspace = true
|
||||
miette.workspace = true
|
||||
git2.workspace = true
|
||||
tracing.workspace = true
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,159 +1,28 @@
|
|||
# fabro-workflow
|
||||
|
||||
A DOT-based pipeline runner for multi-stage AI workflows. Define workflows as Graphviz `digraph` files and execute them with pluggable handlers, conditional routing, human-in-the-loop gates, parallel branching, retry policies, and checkpoint-based recovery.
|
||||
Fabro's platform half of a workflow run: what Fabro does around the engine.
|
||||
|
||||
## Key Concepts
|
||||
Petri compiles and executes every run. `fabro-petri` is the one crate that
|
||||
talks to it, and this crate keeps what Fabro itself owns:
|
||||
|
||||
- **Graph** -- A directed graph parsed from DOT syntax containing nodes, edges, and attributes. The graph carries a `goal` describing the pipeline's purpose.
|
||||
- **Node** -- A workflow step. Graphviz shapes map to handler types (e.g., `Mdiamond` = start, `Msquare` = exit, `box` = agent, `tab` = prompt, `diamond` = conditional, `hexagon` = human gate, `component` = parallel).
|
||||
- **Edge** -- A connection between nodes with optional `condition`, `label`, `weight`, and `fidelity` attributes that control routing.
|
||||
- **Handler** -- An async trait implementation that executes a node and returns an `Outcome`. Built-in handlers include `StartHandler`, `ExitHandler`, `AgentHandler`, `PromptHandler`, `ConditionalHandler`, `HumanHandler`, `ParallelHandler`, `FanInHandler`, `CommandHandler`, and `SubWorkflowHandler`.
|
||||
- **Outcome** -- The result of executing a handler, carrying a `StageOutcome` (Success, Fail, PartialSuccess, Retry, Skipped), optional routing hints (`preferred_label`, `suggested_next_ids`), and context updates.
|
||||
- **Context** -- A thread-safe key-value store shared across pipeline stages, supporting snapshots and isolated cloning for parallel branches.
|
||||
- **Interviewer** -- A trait for human-in-the-loop interactions. Implementations include `AutoApproveInterviewer` and `ControlInterviewer`.
|
||||
- **Checkpoint** -- A serializable snapshot of execution state (completed nodes, context values) for crash recovery and resume.
|
||||
- **`operations`** — creating a run around Petri's admission
|
||||
(`materialize_admitted_run`, `persist_create_run`), and the other run
|
||||
operations: fork, rewind, retry, and the timeline they resolve targets on.
|
||||
The run's display graph (`fabro_types::RunGraph`) is read off the graph
|
||||
Petri admitted; the DOT the workflow was written in is persisted beside it
|
||||
as `graph_source`.
|
||||
- **`workflow_bundle`** — the bundle a run is created from: every workflow
|
||||
of the version closure with its settings file and its files, and the
|
||||
`RunDefinition` the run records.
|
||||
- **`git`**, **`sandbox_git`** — the Git helpers a run's platform effects use,
|
||||
on the host and inside a sandbox.
|
||||
- **`pull_request`** — pull request creation for a finished run.
|
||||
- **`run_tools`**, **`services`** — the run tools an agent session calls.
|
||||
- **`web_search`** — the built-in web search backend.
|
||||
- **`run_lookup`** — resolving a run selector to a run.
|
||||
|
||||
## Pipeline Definition
|
||||
|
||||
Pipelines are defined using Graphviz DOT syntax:
|
||||
|
||||
```dot
|
||||
digraph MyPipeline {
|
||||
graph [goal="Implement and validate a feature"]
|
||||
rankdir=LR
|
||||
node [shape=box, timeout="900s"]
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
plan [label="Plan", prompt="Plan the implementation"]
|
||||
implement [label="Implement", prompt="Implement the plan"]
|
||||
validate [label="Validate", prompt="Run tests"]
|
||||
gate [shape=diamond, label="Tests passing?"]
|
||||
|
||||
start -> plan -> implement -> validate -> gate
|
||||
gate -> exit [label="Yes", condition="outcome=succeeded"]
|
||||
gate -> implement [label="No", condition="outcome!=succeeded"]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Parsing and Validating a Pipeline
|
||||
|
||||
```rust
|
||||
use fabro_workflow::operations::{create, CreateOptions};
|
||||
|
||||
let dot_source = r#"digraph Simple {
|
||||
graph [goal="Run tests"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
work [shape=box, prompt="Run the test suite"]
|
||||
start -> work -> exit
|
||||
}"#;
|
||||
|
||||
let validated = create(dot_source, CreateOptions::default())
|
||||
.expect("pipeline should parse");
|
||||
validated.raise_on_errors().expect("pipeline should validate");
|
||||
let (graph, _, _) = validated.into_parts();
|
||||
assert_eq!(graph.name, "Simple");
|
||||
assert_eq!(graph.goal(), "Run tests");
|
||||
```
|
||||
|
||||
`operations::create` parses the DOT source, applies built-in transforms (variable expansion, stylesheet application, preamble injection), and returns diagnostics through `Validated`.
|
||||
|
||||
### Running a Pipeline
|
||||
|
||||
```rust
|
||||
use fabro_workflow::operations::start;
|
||||
use fabro_workflow::pipeline;
|
||||
|
||||
// Use `operations::start(...)` for the full
|
||||
// initialize -> execute -> conclude -> publish -> finalize flow.
|
||||
// Use `pipeline::initialize(...)` + `pipeline::execute(...)` when you need partial lifecycle control.
|
||||
```
|
||||
|
||||
### Custom Handlers
|
||||
|
||||
Implement the `Handler` trait to add custom node behavior:
|
||||
|
||||
```rust
|
||||
use arc_workflows::handler::Handler;
|
||||
use arc_workflows::context::Context;
|
||||
use arc_workflows::graph::{Graph, Node};
|
||||
use arc_workflows::outcome::Outcome;
|
||||
use arc_workflows::error::ArcError;
|
||||
use async_trait::async_trait;
|
||||
use std::path::Path;
|
||||
|
||||
struct MyHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for MyHandler {
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
context: &Context,
|
||||
graph: &Graph,
|
||||
run_dir: &Path,
|
||||
) -> Result<Outcome, ArcError> {
|
||||
// Custom logic here
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Stylesheets
|
||||
|
||||
CSS-like stylesheets control LLM model assignment with specificity-based cascading:
|
||||
|
||||
```dot
|
||||
digraph Styled {
|
||||
graph [
|
||||
goal="Build feature",
|
||||
model_stylesheet="
|
||||
* { model: claude-sonnet-4-5;}
|
||||
.code { model: claude-opus-4-6; }
|
||||
#critical_review { model: gpt-5.2;}
|
||||
"
|
||||
]
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Selectors by specificity: `*` (universal, 0) < `shape` (1) < `.class` (2) < `#id` (3). Explicit node attributes are never overridden.
|
||||
|
||||
### Condition Expressions
|
||||
|
||||
Edge conditions use a simple expression syntax for routing:
|
||||
|
||||
```
|
||||
outcome=succeeded
|
||||
outcome!=failed
|
||||
outcome=succeeded && context.tests_passed=true
|
||||
my_flag
|
||||
```
|
||||
|
||||
Clauses support `=`, `!=`, and bare key truthiness checks, joined with `&&`.
|
||||
|
||||
### Human-in-the-Loop Gates
|
||||
|
||||
Nodes with `shape=hexagon` or `type="human"` pause execution for human input. Outgoing edge labels become selectable options, with accelerator key parsing for patterns like `[A] Approve` and `F) Fix`.
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
Nodes with `shape=component` fan out to branches concurrently. Branches receive isolated context forks, share the same sandbox checkout, and always finish before the workflow continues. Use `max_parallel` to limit concurrency; concurrent workspace writes are user-managed.
|
||||
|
||||
### Checkpoints and Resume
|
||||
|
||||
The engine saves a checkpoint after each node. Resume from a checkpoint with `engine.run_from_checkpoint(&graph, &config, &checkpoint)`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
parser (DOT -> AST -> Graph)
|
||||
-> transform (variable expansion, stylesheet, preamble)
|
||||
-> validation (14 lint rules)
|
||||
-> engine (execution loop with retry, edge selection, goal gates)
|
||||
-> handler (pluggable node executors)
|
||||
-> interviewer (human-in-the-loop I/O)
|
||||
```
|
||||
The run records and status vocabulary are `fabro_types`'. Workflow
|
||||
diagnostics are Petri's: `fabro validate`, `fabro preflight` and the create
|
||||
handler report Petri's codes (`attractor.*`, `unsupported.*`, `deprecated.*`,
|
||||
`info.*`), plus Fabro's `fabro.model.no_ready_provider` when a model node has
|
||||
no provider ready to run it.
|
||||
|
|
|
|||
|
|
@ -1,64 +1,8 @@
|
|||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_graphviz::Error as GraphvizError;
|
||||
use fabro_template::TemplateError;
|
||||
use fabro_types::diagnostic::Diagnostic;
|
||||
use fabro_types::settings::ResolveError;
|
||||
use fabro_util::error::{SharedError, collect_chain, render_with_causes};
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// A template error shared across clones of the workflow error that carries
|
||||
/// it, so the miette diagnostic and the source chain survive cloning.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedTemplateError(Arc<TemplateError>);
|
||||
|
||||
impl SharedTemplateError {
|
||||
#[must_use]
|
||||
pub fn new(error: TemplateError) -> Self {
|
||||
Self(Arc::new(error))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn inner(&self) -> &TemplateError {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SharedTemplateError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&*self.0, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SharedTemplateError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
std::error::Error::source(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl miette::Diagnostic for SharedTemplateError {
|
||||
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
|
||||
miette::Diagnostic::code(&*self.0)
|
||||
}
|
||||
|
||||
fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
|
||||
miette::Diagnostic::help(&*self.0)
|
||||
}
|
||||
|
||||
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
|
||||
miette::Diagnostic::source_code(&*self.0)
|
||||
}
|
||||
|
||||
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
|
||||
miette::Diagnostic::labels(&*self.0)
|
||||
}
|
||||
|
||||
fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
|
||||
miette::Diagnostic::diagnostic_source(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ThisError, Debug, Clone)]
|
||||
pub enum Error {
|
||||
#[error("Parse error: {0}")]
|
||||
|
|
@ -70,21 +14,6 @@ pub enum Error {
|
|||
#[error("Validation failed")]
|
||||
ValidationFailed { diagnostics: Vec<Diagnostic> },
|
||||
|
||||
#[error("Validation error: script interpolation failed in {owner}: {source} ({fix})")]
|
||||
ScriptInterpolation {
|
||||
owner: String,
|
||||
fix: String,
|
||||
#[source]
|
||||
source: ResolveError,
|
||||
},
|
||||
|
||||
#[error("{message}")]
|
||||
Template {
|
||||
message: String,
|
||||
#[source]
|
||||
source: SharedTemplateError,
|
||||
},
|
||||
|
||||
/// Fabro's own platform work around a run failed: a store call, a
|
||||
/// serialization, a spawned task, a Git command.
|
||||
#[error("Engine error: {message}")]
|
||||
|
|
@ -94,9 +23,6 @@ pub enum Error {
|
|||
source: Option<SharedError>,
|
||||
},
|
||||
|
||||
#[error("Stylesheet error: {0}")]
|
||||
Stylesheet(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(String),
|
||||
|
||||
|
|
@ -111,13 +37,6 @@ pub enum Error {
|
|||
}
|
||||
|
||||
impl Error {
|
||||
pub fn template(message: impl Into<String>, source: TemplateError) -> Self {
|
||||
Self::Template {
|
||||
message: message.into(),
|
||||
source: SharedTemplateError::new(source),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn engine(message: impl Into<String>) -> Self {
|
||||
Self::Engine {
|
||||
message: message.into(),
|
||||
|
|
@ -145,8 +64,6 @@ impl Error {
|
|||
Self::Engine { source, .. } => source
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |source| collect_chain(source)),
|
||||
Self::Template { source, .. } => collect_chain(source),
|
||||
Self::ScriptInterpolation { source, .. } => collect_chain(source),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -157,43 +74,6 @@ impl Error {
|
|||
}
|
||||
}
|
||||
|
||||
impl miette::Diagnostic for Error {
|
||||
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
|
||||
match self {
|
||||
Self::Template { source, .. } => miette::Diagnostic::code(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
|
||||
match self {
|
||||
Self::Template { source, .. } => miette::Diagnostic::help(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
|
||||
match self {
|
||||
Self::Template { source, .. } => miette::Diagnostic::source_code(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
|
||||
match self {
|
||||
Self::Template { source, .. } => miette::Diagnostic::labels(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
|
||||
match self {
|
||||
Self::Template { source, .. } => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::Io(err.to_string())
|
||||
|
|
@ -204,7 +84,6 @@ impl From<GraphvizError> for Error {
|
|||
fn from(e: GraphvizError) -> Self {
|
||||
match e {
|
||||
GraphvizError::Parse(msg) => Self::Parse(msg),
|
||||
GraphvizError::Stylesheet(msg) => Self::Stylesheet(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,30 +152,6 @@ mod tests {
|
|||
assert_eq!(err.to_string(), "Validation failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_error_variant_preserves_source_chain() {
|
||||
let template_err = fabro_template::render_named(
|
||||
"workflow.fabro",
|
||||
"{{ inputs.missing }}",
|
||||
&fabro_template::TemplateContext::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
let err = Error::template("template expansion failed", template_err);
|
||||
let chain = collect_chain(&err);
|
||||
|
||||
assert!(
|
||||
chain
|
||||
.iter()
|
||||
.any(|part| part.contains("template expansion failed"))
|
||||
);
|
||||
assert!(
|
||||
chain
|
||||
.iter()
|
||||
.any(|part| part.contains("undefined template variable"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_error_display() {
|
||||
let err = Error::engine("no outgoing edge");
|
||||
|
|
@ -373,7 +228,6 @@ mod tests {
|
|||
},
|
||||
Error::engine("engine err"),
|
||||
Error::engine_with_source("engine err", TestCause("cause")),
|
||||
Error::Stylesheet("style err".into()),
|
||||
Error::Io("io err".into()),
|
||||
Error::Precondition("precondition".into()),
|
||||
Error::RunNotFound("run".into()),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
//! Fabro's platform half of a workflow run: what Fabro does around the
|
||||
//! engine.
|
||||
//!
|
||||
//! Petri executes every run (`fabro-petri` is the seam). This crate keeps
|
||||
//! what Fabro itself owns: the create-time compile of the Fabro graph the
|
||||
//! read side displays (`pipeline`, `transforms`, `operations`), the Git
|
||||
//! helpers a run's platform effects use (`git`, `sandbox_git`), pull
|
||||
//! request creation (`pull_request`), the run tools an agent session calls
|
||||
//! (`run_tools`, `services`), the built-in web search backend
|
||||
//! (`web_search`). The run records and status vocabulary are
|
||||
//! `fabro_types`'.
|
||||
//! Petri compiles and executes every run (`fabro-petri` is the seam). This
|
||||
//! crate keeps what Fabro itself owns: the run's creation around Petri's
|
||||
//! admission and the other run operations (`operations`), the bundle a run
|
||||
//! is created from (`workflow_bundle`), the Git helpers a run's platform
|
||||
//! effects use (`git`, `sandbox_git`), pull request creation
|
||||
//! (`pull_request`), the run tools an agent session calls (`run_tools`,
|
||||
//! `services`), the built-in web search backend (`web_search`). The run
|
||||
//! records and status vocabulary are `fabro_types`'.
|
||||
|
||||
#![cfg_attr(
|
||||
test,
|
||||
|
|
@ -28,20 +28,15 @@
|
|||
)]
|
||||
|
||||
pub mod error;
|
||||
pub mod file_resolver;
|
||||
pub mod git;
|
||||
pub mod operations;
|
||||
pub mod pipeline;
|
||||
pub mod pull_request;
|
||||
pub mod run_lookup;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_types::ManifestPath;
|
||||
pub mod run_materialization;
|
||||
pub mod run_tools;
|
||||
pub mod sandbox_git;
|
||||
pub mod services;
|
||||
#[doc(hidden)]
|
||||
pub mod transforms;
|
||||
pub mod web_search;
|
||||
pub mod workflow_bundle;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -142,7 +142,9 @@ pub async fn persist_forked_run(store: &Database, input: &ForkedRunInput<'_>) ->
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use fabro_types::{FailureReason, Graph, PetriAdmission, RunSpec, WorkflowSettings, fixtures};
|
||||
use fabro_types::{
|
||||
FailureReason, PetriAdmission, RunGraph, RunSpec, WorkflowSettings, fixtures,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -152,7 +154,7 @@ mod tests {
|
|||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("source"),
|
||||
graph: RunGraph::new("source"),
|
||||
graph_source: Some("digraph source { start -> exit }".to_string()),
|
||||
workflow_slug: Some("source".to_string()),
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ mod create;
|
|||
mod fork;
|
||||
mod retry;
|
||||
mod rewind;
|
||||
mod source;
|
||||
mod timeline;
|
||||
mod validate;
|
||||
|
||||
pub use create::{
|
||||
CompiledRun, CreateRunCompileInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata,
|
||||
CreatedRun, MaterializedRun, assemble_create_run_persistence_input, compile_admitted_run,
|
||||
make_run_dir, materialize_admitted_run, persist_create_run,
|
||||
AdmittedRunInput, CreateRunPersistenceInput, CreateRunPersistenceMetadata, CreatedRun,
|
||||
MaterializedRun, assemble_create_run_persistence_input, make_run_dir, materialize_admitted_run,
|
||||
persist_create_run,
|
||||
};
|
||||
use fabro_types::RunId;
|
||||
pub use fork::{
|
||||
|
|
@ -18,14 +16,11 @@ pub use fork::{
|
|||
};
|
||||
pub use retry::{ensure_retryable, reruns_last};
|
||||
pub use rewind::{ensure_rewindable, superseded_record};
|
||||
pub use source::WorkflowInput;
|
||||
pub use timeline::{
|
||||
ForkTarget, RunTimeline, StageLabel, StageLabels, TimelineEntry, TimelinePosition,
|
||||
};
|
||||
pub use validate::{ValidateInput, validate};
|
||||
|
||||
pub use crate::error::Error;
|
||||
pub use crate::transforms::RenderMode;
|
||||
|
||||
/// The canonical "run is archived — mutation rejected" error message. Shared
|
||||
/// by the server's HTTP guards and the CLI so the user sees the same
|
||||
|
|
|
|||
|
|
@ -666,14 +666,13 @@ mod tests {
|
|||
|
||||
use chrono::Utc;
|
||||
use fabro_auth::VaultCredentialSource;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_llm::adapter::{ProviderAdapter, ResolvedCall};
|
||||
use fabro_llm::credentials::CredentialProvider;
|
||||
use fabro_llm::lithos_catalog::AdapterId;
|
||||
use fabro_llm::{Response, ResponseStream};
|
||||
use fabro_types::{
|
||||
PetriAdmission, RunProjection, RunSpec, StageSummary, WorkflowSettings, first_event_seq,
|
||||
fixtures, test_support,
|
||||
PetriAdmission, RunGraph, RunProjection, RunSpec, StageSummary, WorkflowSettings,
|
||||
first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use httpmock::Method::{GET, POST};
|
||||
|
|
@ -781,7 +780,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
|
|||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph: RunGraph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::ManifestPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::file_resolver::{BundleFileResolver, FileResolver};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ParsedWorkflowConfig {
|
||||
pub path: ManifestPath,
|
||||
|
|
@ -21,18 +17,6 @@ pub struct BundledWorkflow {
|
|||
pub files: HashMap<ManifestPath, 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.path.parent_or_dot().to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct WorkflowBundle {
|
||||
workflows: HashMap<ManifestPath, BundledWorkflow>,
|
||||
|
|
|
|||
|
|
@ -651,6 +651,9 @@ fn main() {
|
|||
("RunStreamItemKind", "fabro_types::RunStreamItemKind", &[]),
|
||||
("PetriAdmission", "fabro_types::PetriAdmission", &[]),
|
||||
("PetriGraphRef", "fabro_types::PetriGraphRef", &[]),
|
||||
("RunGraph", "fabro_types::RunGraph", &[]),
|
||||
("RunGraphNode", "fabro_types::RunGraphNode", &[]),
|
||||
("RunGraphEdge", "fabro_types::RunGraphEdge", &[]),
|
||||
("PullRequest", "fabro_types::PullRequest", &[]),
|
||||
("PullRequestLink", "fabro_types::PullRequestLink", &[]),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -55,20 +55,20 @@ pub mod types {
|
|||
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
|
||||
PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget,
|
||||
ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunFailure,
|
||||
RunIntent, RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance,
|
||||
RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,
|
||||
RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSessionMetadata, RunSize,
|
||||
RunStreamItem, RunStreamItemKind, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta,
|
||||
SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, SandboxService,
|
||||
SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, SessionDetail,
|
||||
SessionEvent, SessionEventBody, SessionId, SessionStatus, SessionSummary, SessionTurn,
|
||||
SkillActivationSource, SkillSummary, StageCompletion, StageContextWindow,
|
||||
StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection,
|
||||
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
|
||||
SystemActorKind, SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection,
|
||||
ToolCategory, ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal,
|
||||
Variable, VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion,
|
||||
WorkflowVersionId,
|
||||
RunGraph, RunGraphEdge, RunGraphNode, RunIntent, RunIntentArgs, RunPairStatusResponse,
|
||||
RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure,
|
||||
RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance,
|
||||
RunSessionMetadata, RunSize, RunStreamItem, RunStreamItemKind, RunTarget, SandboxDetails,
|
||||
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
|
||||
SandboxProviderLookupError, SandboxService, SandboxServiceListResponse, SecretMetadata,
|
||||
SecretType, ServerSettings, SessionDetail, SessionEvent, SessionEventBody, SessionId,
|
||||
SessionStatus, SessionSummary, SessionTurn, SkillActivationSource, SkillSummary,
|
||||
StageCompletion, StageContextWindow, StageContextWindowUnavailableReason, StageHandler,
|
||||
StageId, StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection,
|
||||
StageState, StageToolBatchProjection, SystemActorKind, SystemIntegrationStatus,
|
||||
SystemIntegrationsResponse, TodoListProjection, ToolCategory, ToolSource, ToolSummary,
|
||||
TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath,
|
||||
WorkflowSettings, WorkflowVersion, WorkflowVersionId,
|
||||
};
|
||||
pub use lithos_llm::catalog::{ModelHandle, ProviderId};
|
||||
pub use lithos_llm::types::{
|
||||
|
|
|
|||
54
lib/foundation/fabro-api/tests/run_graph_round_trip.rs
Normal file
54
lib/foundation/fabro-api/tests/run_graph_round_trip.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{
|
||||
RunGraph as ApiRunGraph, RunGraphEdge as ApiRunGraphEdge, RunGraphNode as ApiRunGraphNode,
|
||||
};
|
||||
use fabro_types::{RunGraph, RunGraphEdge, RunGraphNode, StageHandler};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_run_graph_reuses_canonical_types() {
|
||||
assert_same_type::<ApiRunGraph, RunGraph>();
|
||||
assert_same_type::<ApiRunGraphNode, RunGraphNode>();
|
||||
assert_same_type::<ApiRunGraphEdge, RunGraphEdge>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_run_graph_round_trips_the_schema_shape() {
|
||||
let value = json!({
|
||||
"name": "Ship",
|
||||
"goal": "Ship the feature",
|
||||
"nodes": {
|
||||
"plan": { "label": "Plan rollout", "kind": "agent" },
|
||||
"start": { "label": "Start", "kind": "start" }
|
||||
},
|
||||
"edges": [
|
||||
{ "from": "start", "to": "plan" }
|
||||
]
|
||||
});
|
||||
let graph: RunGraph = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(graph.name, "Ship");
|
||||
assert_eq!(graph.goal(), "Ship the feature");
|
||||
assert_eq!(
|
||||
graph.node("plan").map(|node| node.kind),
|
||||
Some(StageHandler::Agent)
|
||||
);
|
||||
assert_eq!(graph.edges.len(), 1);
|
||||
assert_eq!(serde_json::to_value(&graph).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graph_with_only_a_name_is_the_schema_minimum() {
|
||||
let graph: RunGraph = serde_json::from_value(json!({ "name": "Bare" })).unwrap();
|
||||
assert_eq!(graph, RunGraph::new("Bare"));
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,7 @@ pub mod pull_request;
|
|||
pub mod repository;
|
||||
pub mod run;
|
||||
pub mod run_failure;
|
||||
pub mod run_graph;
|
||||
pub mod run_id;
|
||||
pub mod run_intent;
|
||||
pub mod run_projection;
|
||||
|
|
@ -83,10 +84,7 @@ pub use diff::{DiffStats, DiffSummary, RunDiff};
|
|||
pub use engine::{PetriAdmission, PetriGraphRef};
|
||||
pub use failure_signature::FailureSignature;
|
||||
pub use git_identity::{GitIdentity, GitIdentitySource};
|
||||
pub use graph::{
|
||||
AttrValue, AttributeScope, ContextKeyAttr, Edge, Graph, KNOWN_HANDLER_TYPES, Node, OnFailure,
|
||||
ResolvedOnFailure, is_known_handler_type, is_llm_handler_type, shape_to_handler_type,
|
||||
};
|
||||
pub use graph::{AttrValue, AttributeScope, Edge, Graph, Node, shape_to_handler_type};
|
||||
pub use input_scalar::{
|
||||
JsonScalarToTomlError, TomlScalarToJsonError, json_scalar_to_toml_value,
|
||||
toml_scalar_to_json_value,
|
||||
|
|
@ -140,6 +138,7 @@ pub use run::{
|
|||
RunServerProvenance, RunSpec,
|
||||
};
|
||||
pub use run_failure::RunFailure;
|
||||
pub use run_graph::{RunGraph, RunGraphEdge, RunGraphNode};
|
||||
pub use run_id::{RunId, fixtures};
|
||||
pub use run_intent::{
|
||||
GitCoordinateValidationError, GitRunTarget, RunIntent, RunIntentArgs, RunTarget,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||
use serde_json::Value;
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
use crate::{
|
||||
ExecOutputTail, FailureSignature, ModelUsage, OnFailure, ResolvedOnFailure, StageTiming,
|
||||
SystemActorKind,
|
||||
};
|
||||
use crate::{ExecOutputTail, FailureSignature, ModelUsage, StageTiming, SystemActorKind};
|
||||
|
||||
pub trait OutcomeMeta:
|
||||
Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
|
||||
|
|
@ -338,90 +335,13 @@ impl<M: OutcomeMeta> Outcome<M> {
|
|||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resolved failure policy to this outcome.
|
||||
///
|
||||
/// `succeed` promotes a `failed` outcome and records the policy scope.
|
||||
/// The original `failure` stays available for durable diagnostics. Other
|
||||
/// policies and statuses do not change the outcome.
|
||||
pub fn apply_on_failure(&mut self, policy: ResolvedOnFailure) -> bool {
|
||||
if policy.policy() != OnFailure::Succeed || !self.status.is_failure() {
|
||||
return false;
|
||||
}
|
||||
self.status = StageOutcome::Succeeded;
|
||||
let note = format!(
|
||||
"{} on_failure=succeed promoted a failed outcome to succeeded",
|
||||
policy.scope()
|
||||
);
|
||||
self.notes = Some(match self.notes.take() {
|
||||
Some(existing) => format!("{existing}\n{note}"),
|
||||
None => note,
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{FailureCategory, FailureDetail, Outcome, StageOutcome, StageState};
|
||||
use crate::{OnFailure, ResolvedOnFailure};
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_keeps_failure_and_records_scope() {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
assert!(outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed)));
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.message.as_str()),
|
||||
Some("boom")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.notes.as_deref(),
|
||||
Some("node on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_appends_to_existing_notes() {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
outcome.notes = Some("handler note".to_string());
|
||||
assert!(outcome.apply_on_failure(ResolvedOnFailure::graph(OnFailure::Succeed)));
|
||||
|
||||
assert_eq!(
|
||||
outcome.notes.as_deref(),
|
||||
Some("handler note\ngraph on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_ignores_non_failed_outcomes() {
|
||||
let mut partial: Outcome = Outcome::success();
|
||||
partial.status = StageOutcome::PartiallySucceeded;
|
||||
let mut skipped: Outcome = Outcome::skipped("not needed");
|
||||
|
||||
for outcome in [&mut partial, &mut skipped] {
|
||||
let before = outcome.clone();
|
||||
assert!(!outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed)));
|
||||
assert_eq!(*outcome, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_ignores_non_succeed_policies() {
|
||||
for policy in [OnFailure::Route, OnFailure::Exit] {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
let before = outcome.clone();
|
||||
|
||||
assert!(!outcome.apply_on_failure(ResolvedOnFailure::node(policy)));
|
||||
assert_eq!(outcome, before);
|
||||
}
|
||||
}
|
||||
use super::{FailureCategory, FailureDetail, StageOutcome, StageState};
|
||||
|
||||
#[test]
|
||||
fn stage_outcome_failed_serde_is_lossy_for_retry_intent() {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::WorkflowSettings;
|
||||
use crate::blob_hash::BlobHash;
|
||||
use crate::engine::PetriAdmission;
|
||||
use crate::graph::Graph;
|
||||
use crate::principal::Principal;
|
||||
use crate::run_graph::RunGraph;
|
||||
use crate::run_id::RunId;
|
||||
use crate::run_intent::RunTarget;
|
||||
use crate::run_summary::AutomationRef;
|
||||
|
|
@ -63,7 +63,9 @@ pub struct ForkSourceRef {
|
|||
pub struct RunSpec {
|
||||
pub run_id: RunId,
|
||||
pub settings: WorkflowSettings,
|
||||
pub graph: Graph,
|
||||
/// The display graph: what Petri admitted, reduced to what the read
|
||||
/// side names. The DOT it was written in is `graph_source`.
|
||||
pub graph: RunGraph,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph_source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -102,7 +104,7 @@ impl RunSpec {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn graph(&self) -> &Graph {
|
||||
pub fn graph(&self) -> &RunGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
|
|
|
|||
124
lib/foundation/fabro-types/src/run_graph.rs
Normal file
124
lib/foundation/fabro-types/src/run_graph.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! The graph a run displays: what Petri admitted, reduced to the nodes and
|
||||
//! edges the read side names.
|
||||
//!
|
||||
//! Petri lowers and admits every run's workflow at create time
|
||||
//! (`RunSpec::admission` names the lowered graphs). The read side needs far
|
||||
//! less than the lowered graph: the workflow's name and goal, each stage's
|
||||
//! label and handler kind, and the routing edges as written. `RunGraph` is
|
||||
//! that projection, built once from the admitted graph by `fabro-petri` and
|
||||
//! stored on the run spec. The DOT the workflow was written in is beside it
|
||||
//! as `RunSpec::graph_source`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::stage_handler::StageHandler;
|
||||
|
||||
/// The display graph of a run: the admitted workflow's name, goal, stages
|
||||
/// and edges.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunGraph {
|
||||
/// The workflow's name: the DOT `digraph` name.
|
||||
pub name: String,
|
||||
/// The run's goal as the run displays it.
|
||||
#[serde(default)]
|
||||
pub goal: String,
|
||||
/// The stages by node id, in id order.
|
||||
#[serde(default)]
|
||||
pub nodes: BTreeMap<String, RunGraphNode>,
|
||||
/// The routing edges as written, one per arm.
|
||||
#[serde(default)]
|
||||
pub edges: Vec<RunGraphEdge>,
|
||||
}
|
||||
|
||||
/// One stage of the display graph.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunGraphNode {
|
||||
/// The node's display label: its `label` attribute, else its id.
|
||||
pub label: String,
|
||||
/// The handler the node runs as.
|
||||
pub kind: StageHandler,
|
||||
}
|
||||
|
||||
/// One routing edge of the display graph.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunGraphEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl RunGraph {
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The run's goal; empty when the workflow has none.
|
||||
#[must_use]
|
||||
pub fn goal(&self) -> &str {
|
||||
&self.goal
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn node(&self, id: &str) -> Option<&RunGraphNode> {
|
||||
self.nodes.get(id)
|
||||
}
|
||||
|
||||
/// Whether `id` names a `start` or `exit` boundary: a node that runs no
|
||||
/// work of its own. The test is the node's kind, never its name.
|
||||
#[must_use]
|
||||
pub fn is_boundary(&self, id: &str) -> bool {
|
||||
self.node(id)
|
||||
.is_some_and(|node| matches!(node.kind, StageHandler::Start | StageHandler::Exit))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn graph() -> RunGraph {
|
||||
let mut graph = RunGraph::new("Ship");
|
||||
graph.goal = "Ship it".to_string();
|
||||
graph.nodes.insert("start".to_string(), RunGraphNode {
|
||||
label: "Start".to_string(),
|
||||
kind: StageHandler::Start,
|
||||
});
|
||||
graph.nodes.insert("plan".to_string(), RunGraphNode {
|
||||
label: "Plan".to_string(),
|
||||
kind: StageHandler::Agent,
|
||||
});
|
||||
graph.edges.push(RunGraphEdge {
|
||||
from: "start".to_string(),
|
||||
to: "plan".to_string(),
|
||||
});
|
||||
graph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_are_by_kind_not_name() {
|
||||
let mut graph = graph();
|
||||
assert!(graph.is_boundary("start"));
|
||||
assert!(!graph.is_boundary("plan"));
|
||||
assert!(!graph.is_boundary("missing"));
|
||||
graph.nodes.get_mut("start").unwrap().kind = StageHandler::Agent;
|
||||
assert!(!graph.is_boundary("start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wire_shape_round_trips_and_defaults_the_optional_parts() {
|
||||
let graph = graph();
|
||||
let json = serde_json::to_value(&graph).unwrap();
|
||||
assert_eq!(json["nodes"]["plan"]["kind"], "agent");
|
||||
assert_eq!(json["edges"][0]["to"], "plan");
|
||||
let decoded: RunGraph = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded, graph);
|
||||
|
||||
let bare: RunGraph = serde_json::from_value(serde_json::json!({ "name": "Bare" })).unwrap();
|
||||
assert_eq!(bare, RunGraph::new("Bare"));
|
||||
}
|
||||
}
|
||||
|
|
@ -826,11 +826,7 @@ impl RunProjection {
|
|||
/// is the node's handler type, not its name: a node may be named
|
||||
/// `start` and still do real work.
|
||||
pub fn is_boundary_stage(&self, node_id: &str) -> bool {
|
||||
self.spec()
|
||||
.graph()
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.is_some_and(|node| matches!(node.handler_type(), Some("start" | "exit")))
|
||||
self.spec().graph().is_boundary(node_id)
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RunStatus {
|
||||
|
|
@ -917,14 +913,12 @@ impl RunProjection {
|
|||
mod title_tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{AttrValue, Graph, RunProjection, RunSpec, test_support};
|
||||
use crate::{RunGraph, RunProjection, RunSpec, test_support};
|
||||
|
||||
fn projection_with_goal(goal: Option<&str>) -> RunProjection {
|
||||
let mut graph = Graph::new("test");
|
||||
let mut graph = RunGraph::new("test");
|
||||
if let Some(goal) = goal {
|
||||
graph
|
||||
.attrs
|
||||
.insert("goal".to_string(), AttrValue::String(goal.to_string()));
|
||||
graph.goal = goal.to_string();
|
||||
}
|
||||
|
||||
let spec = RunSpec {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use lithos_llm::catalog::{ModelId, builtin};
|
|||
use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage};
|
||||
|
||||
use crate::{
|
||||
AuthMethod, BlobHash, Graph, IdpIdentity, ModelRef, ModelUsage, PetriAdmission, PetriGraphRef,
|
||||
Principal, RunProvenance, RunSpec, WorkflowSettings, WorkflowVersionId, fixtures,
|
||||
AuthMethod, BlobHash, IdpIdentity, ModelRef, ModelUsage, PetriAdmission, PetriGraphRef,
|
||||
Principal, RunGraph, RunProvenance, RunSpec, WorkflowSettings, WorkflowVersionId, fixtures,
|
||||
};
|
||||
|
||||
/// A fully populated `ModelUsage` for tests: `input_tokens` and
|
||||
|
|
@ -65,7 +65,7 @@ pub fn test_run_spec() -> RunSpec {
|
|||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph: RunGraph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ mod tests {
|
|||
use super::usage_rollup_from_projection;
|
||||
use crate::test_support::{self, test_usage};
|
||||
use crate::{
|
||||
AttrValue, Graph, ModelRef, Node, RunProjection, RunSpec, StageCompletion, StageOutcome,
|
||||
first_event_seq,
|
||||
ModelRef, RunGraph, RunGraphNode, RunProjection, RunSpec, StageCompletion, StageHandler,
|
||||
StageOutcome, first_event_seq,
|
||||
};
|
||||
|
||||
fn test_projection() -> RunProjection {
|
||||
|
|
@ -438,22 +438,14 @@ mod tests {
|
|||
}
|
||||
|
||||
fn run_spec_with_boundary_nodes() -> RunSpec {
|
||||
let mut graph = Graph::new("test");
|
||||
graph.nodes.insert("start".to_string(), {
|
||||
let mut node = Node::new("start");
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Mdiamond".to_string()),
|
||||
);
|
||||
node
|
||||
let mut graph = RunGraph::new("test");
|
||||
graph.nodes.insert("start".to_string(), RunGraphNode {
|
||||
label: "start".to_string(),
|
||||
kind: StageHandler::Start,
|
||||
});
|
||||
graph.nodes.insert("exit".to_string(), {
|
||||
let mut node = Node::new("exit");
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("Msquare".to_string()),
|
||||
);
|
||||
node
|
||||
graph.nodes.insert("exit".to_string(), RunGraphNode {
|
||||
label: "exit".to_string(),
|
||||
kind: StageHandler::Exit,
|
||||
});
|
||||
|
||||
RunSpec {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::{DirtyStatus, GitContext, RunSpec};
|
||||
use fabro_types::settings::{ProjectNamespace, WorkflowNamespace};
|
||||
use fabro_types::test_support::test_run_spec;
|
||||
use fabro_types::{WorkflowSettings, fixtures};
|
||||
use fabro_types::{RunGraph, WorkflowSettings, fixtures};
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
let settings = WorkflowSettings {
|
||||
|
|
@ -21,7 +20,7 @@ fn sample_run_spec() -> RunSpec {
|
|||
|
||||
RunSpec {
|
||||
settings,
|
||||
graph: Graph::new("ship"),
|
||||
graph: RunGraph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/Users/client/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
|
|
@ -67,7 +66,7 @@ fn run_spec_name_getters_do_not_synthesize_from_graph_or_slug() {
|
|||
run_spec.settings.workflow.name = None;
|
||||
run_spec.settings.project.name = None;
|
||||
run_spec.workflow_slug = Some("release-flow".to_string());
|
||||
run_spec.graph = Graph::new("GraphName");
|
||||
run_spec.graph = RunGraph::new("GraphName");
|
||||
|
||||
assert_eq!(run_spec.workflow_name(), None);
|
||||
assert_eq!(run_spec.project_name(), None);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||
use fabro_types::{
|
||||
AutomationRef, GitRunTarget, PetriAdmission, ResolvedAutomationGitWorkflowSource, RunTarget,
|
||||
WorkflowSettings, fixtures,
|
||||
AutomationRef, GitRunTarget, PetriAdmission, ResolvedAutomationGitWorkflowSource, RunGraph,
|
||||
RunTarget, WorkflowSettings, fixtures,
|
||||
};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
|
|
@ -21,7 +20,7 @@ fn run_spec_round_trips_templated_settings() {
|
|||
let record = RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: templated_settings(),
|
||||
graph: Graph::new("ship"),
|
||||
graph: RunGraph::new("ship"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
workflow_version_id: Some(test_workflow_version_id()),
|
||||
|
|
|
|||
|
|
@ -397,6 +397,9 @@ models/run-git-settings.ts
|
|||
models/run-goal-file.ts
|
||||
models/run-goal-inline.ts
|
||||
models/run-goal.ts
|
||||
models/run-graph-edge.ts
|
||||
models/run-graph-node.ts
|
||||
models/run-graph.ts
|
||||
models/run-integrations-github-settings.ts
|
||||
models/run-integrations-settings.ts
|
||||
models/run-intent-args-inputs-value.ts
|
||||
|
|
|
|||
24
lib/packages/fabro-api-client/src/api/runs-api.ts
generated
24
lib/packages/fabro-api-client/src/api/runs-api.ts
generated
|
|
@ -955,7 +955,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Validates and renders a workflow manifest as SVG without creating a run.
|
||||
* Validates and renders a workflow manifest as SVG without creating a run. The manifest is checked as `POST /validate` checks it; a workflow Petri refuses is not rendered.
|
||||
* @summary Render Workflow Graph
|
||||
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -1247,7 +1247,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Validates runtime readiness for a workflow manifest without creating a run.
|
||||
* Validates runtime readiness for a workflow manifest without creating a run. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. The checks then probe the sandbox, repository access and GitHub credentials.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -1536,7 +1536,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Validates workflow structure and diagnostics without runtime readiness checks.
|
||||
* Validates a workflow manifest without runtime readiness checks. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. `workflow` describes the admitted graph, or the DOT as written when Petri refused the workflow.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -1859,7 +1859,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Validates and renders a workflow manifest as SVG without creating a run.
|
||||
* Validates and renders a workflow manifest as SVG without creating a run. The manifest is checked as `POST /validate` checks it; a workflow Petri refuses is not rendered.
|
||||
* @summary Render Workflow Graph
|
||||
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -1952,7 +1952,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Validates runtime readiness for a workflow manifest without creating a run.
|
||||
* Validates runtime readiness for a workflow manifest without creating a run. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. The checks then probe the sandbox, repository access and GitHub credentials.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2045,7 +2045,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Validates workflow structure and diagnostics without runtime readiness checks.
|
||||
* Validates a workflow manifest without runtime readiness checks. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. `workflow` describes the admitted graph, or the DOT as written when Petri refused the workflow.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2280,7 +2280,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.pauseRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Validates and renders a workflow manifest as SVG without creating a run.
|
||||
* Validates and renders a workflow manifest as SVG without creating a run. The manifest is checked as `POST /validate` checks it; a workflow Petri refuses is not rendered.
|
||||
* @summary Render Workflow Graph
|
||||
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2352,7 +2352,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.rewindRun(id, rewindRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Validates runtime readiness for a workflow manifest without creating a run.
|
||||
* Validates runtime readiness for a workflow manifest without creating a run. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. The checks then probe the sandbox, repository access and GitHub credentials.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2424,7 +2424,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.updateRun(id, updateRunRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Validates workflow structure and diagnostics without runtime readiness checks.
|
||||
* Validates a workflow manifest without runtime readiness checks. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. `workflow` describes the admitted graph, or the DOT as written when Petri refused the workflow.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2674,7 +2674,7 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validates and renders a workflow manifest as SVG without creating a run.
|
||||
* Validates and renders a workflow manifest as SVG without creating a run. The manifest is checked as `POST /validate` checks it; a workflow Petri refuses is not rendered.
|
||||
* @summary Render Workflow Graph
|
||||
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2753,7 +2753,7 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validates runtime readiness for a workflow manifest without creating a run.
|
||||
* Validates runtime readiness for a workflow manifest without creating a run. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. The checks then probe the sandbox, repository access and GitHub credentials.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -2832,7 +2832,7 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validates workflow structure and diagnostics without runtime readiness checks.
|
||||
* Validates a workflow manifest without runtime readiness checks. The workflow is checked as a run would be admitted: Petri compiles the bundle with the server\'s settings, run variables and model catalog, and every diagnostic carries Petri\'s code as its `rule` (`attractor.no_start`, `attractor.model.unknown`, `unsupported.template.unbound_input`), with Fabro\'s own `fabro.model.no_ready_provider` when a model node has no provider ready to run it. `workflow` describes the admitted graph, or the DOT as written when Petri refused the workflow.
|
||||
* @summary Validate Workflow Manifest
|
||||
* @param {RunManifest} runManifest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
|
|||
|
|
@ -368,6 +368,9 @@ export * from './run-git-settings';
|
|||
export * from './run-goal';
|
||||
export * from './run-goal-file';
|
||||
export * from './run-goal-inline';
|
||||
export * from './run-graph';
|
||||
export * from './run-graph-edge';
|
||||
export * from './run-graph-node';
|
||||
export * from './run-integrations-github-settings';
|
||||
export * from './run-integrations-settings';
|
||||
export * from './run-intent';
|
||||
|
|
|
|||
23
lib/packages/fabro-api-client/src/models/run-graph-edge.ts
generated
Normal file
23
lib/packages/fabro-api-client/src/models/run-graph-edge.ts
generated
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* One routing edge of a run\'s display graph.
|
||||
*/
|
||||
export interface RunGraphEdge {
|
||||
'from': string;
|
||||
'to': string;
|
||||
}
|
||||
29
lib/packages/fabro-api-client/src/models/run-graph-node.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/run-graph-node.ts
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.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 { StageHandler } from './stage-handler';
|
||||
|
||||
/**
|
||||
* One stage of a run\'s display graph.
|
||||
*/
|
||||
export interface RunGraphNode {
|
||||
/**
|
||||
* The node\'s display label, its `label` attribute or its id.
|
||||
*/
|
||||
'label': string;
|
||||
'kind': StageHandler;
|
||||
}
|
||||
43
lib/packages/fabro-api-client/src/models/run-graph.ts
generated
Normal file
43
lib/packages/fabro-api-client/src/models/run-graph.ts
generated
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.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 { RunGraphEdge } from './run-graph-edge';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { RunGraphNode } from './run-graph-node';
|
||||
|
||||
/**
|
||||
* The display graph of a run: the admitted workflow\'s name, goal, stages and edges, read off the graph Petri admitted at create time. Lowering artifacts (the goal check, a synthetic fan-in) are left out; the stages are the nodes the workflow declares, imports and `[run.prepare]` steps included.
|
||||
*/
|
||||
export interface RunGraph {
|
||||
/**
|
||||
* The workflow\'s name, the DOT `digraph` name.
|
||||
*/
|
||||
'name': string;
|
||||
/**
|
||||
* The run\'s goal as the run displays it; empty when the workflow has none.
|
||||
*/
|
||||
'goal'?: string;
|
||||
/**
|
||||
* The stages by node id.
|
||||
*/
|
||||
'nodes'?: { [key: string]: RunGraphNode; };
|
||||
/**
|
||||
* The routing edges as written, one per arm.
|
||||
*/
|
||||
'edges'?: Array<RunGraphEdge>;
|
||||
}
|
||||
11
lib/packages/fabro-api-client/src/models/run-spec.ts
generated
11
lib/packages/fabro-api-client/src/models/run-spec.ts
generated
|
|
@ -27,6 +27,9 @@ import type { GitContext } from './git-context';
|
|||
import type { PetriAdmission } from './petri-admission';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { RunGraph } from './run-graph';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { RunProvenance } from './run-provenance';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
|
|
@ -41,7 +44,13 @@ import type { WorkflowSettings } from './workflow-settings';
|
|||
export interface RunSpec {
|
||||
'run_id': string;
|
||||
'settings': WorkflowSettings;
|
||||
'graph': { [key: string]: any; };
|
||||
/**
|
||||
* The display graph: the workflow Petri admitted, reduced to what the read side names. The DOT it was written in is `graph_source`.
|
||||
*/
|
||||
'graph': RunGraph;
|
||||
/**
|
||||
* The entrypoint workflow\'s DOT as written.
|
||||
*/
|
||||
'graph_source'?: string | null;
|
||||
'workflow_slug'?: string | null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@
|
|||
// @ts-ignore
|
||||
import type { RelatedWorkflowDiagnostic } from './related-workflow-diagnostic';
|
||||
|
||||
/**
|
||||
* One diagnostic about a workflow. `rule` is the stable code of the check that raised it: Petri\'s codes (`attractor.*`, `unsupported.*`, `deprecated.*`, `info.*`, `fabro.hooks.*`) for the compile, and `fabro.model.no_ready_provider` for Fabro\'s own provider check.
|
||||
*/
|
||||
export interface WorkflowDiagnostic {
|
||||
/**
|
||||
* The stable code of the check that raised the diagnostic.
|
||||
*/
|
||||
'rule': string;
|
||||
'severity': WorkflowDiagnosticSeverityEnum;
|
||||
'message': string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue