Add immutable workflow version resource

Add the WorkflowVersion domain resource with exactly entrypoint, files,
and workflow_dependencies, plus strict WorkflowPath validation and
deterministic canonical raw JSON. Semantic validation of graph imports,
templates, file references, workflow.toml rules, Dockerfile paths, and
exact child-workflow dependency bindings lives in the new
fabro-workflow-version crate, which validates the complete stored
dependency closure through the shared blob store before writing a root.
The authenticated create-only POST /api/v1/workflow-versions endpoint
ships with its OpenAPI contract, Rust type replacements, and generated
TypeScript client.

Squashed from the resource commits of the original combined branch;
the walker unification this builds on landed separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-08-13 14:53:32 -04:00
parent 13d09f6e56
commit 178320e7a5
21 changed files with 2431 additions and 1 deletions

17
Cargo.lock generated
View file

@ -3060,6 +3060,7 @@ dependencies = [
"fabro-variable",
"fabro-vault",
"fabro-workflow",
"fabro-workflow-version",
"futures-util",
"globset",
"hex",
@ -3430,6 +3431,22 @@ dependencies = [
"walkdir",
]
[[package]]
name = "fabro-workflow-version"
version = "0.324.0-nightly.0"
dependencies = [
"fabro-config",
"fabro-graphviz",
"fabro-store",
"fabro-template",
"fabro-types",
"object_store",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "fail-parallel"
version = "0.5.1"

View file

@ -33,6 +33,8 @@ tags:
description: Internal run details (stages, turns, context, configuration)
- name: Workflows
description: Workflow definitions and execution
- name: Workflow Versions
description: Immutable, content-addressed workflow packages
- name: Billing
description: Token counts and billed totals
- name: Insights
@ -1065,6 +1067,69 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Workflow Versions ─────────────────────────────────────────────────
/api/v1/workflow-versions:
post:
operationId: createWorkflowVersion
tags: [Workflow Versions]
summary: Create Workflow Version
description: >-
Validates and stores an immutable workflow package in content-addressed
storage. Repeating the same canonical content returns the same identifier.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowVersion"
responses:
"201":
description: Workflow version stored or already present
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWorkflowVersionResponse"
"400":
description: Malformed JSON (`invalid_json`)
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"413":
description: Request body exceeds 2 MiB (`workflow_version_too_large`)
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: >-
Invalid workflow content (`workflow_version_invalid`) or an absent,
invalid, or non-canonical dependency
(`workflow_version_dependency_not_found`)
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Workflow version storage failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Runs ──────────────────────────────────────────────────────────────
/api/v1/runs:
@ -9074,6 +9139,71 @@ components:
detail:
$ref: "#/components/schemas/FailureDetail"
WorkflowPath:
description: >-
Canonical portable path inside one workflow version. Paths are UTF-8,
relative, at most 240 bytes and 16 components, and cannot contain empty,
dot, parent, backslash, control, tilde-root, or drive-letter segments.
Map keys receive stricter byte and structural validation in the domain
model than OpenAPI can express.
type: string
minLength: 1
maxLength: 240
example: graphs/main.fabro
WorkflowVersionId:
description: SHA-256 identity of validated canonical workflow-version bytes.
type: string
pattern: "^[0-9a-f]{64}$"
example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
WorkflowVersion:
description: >-
Complete immutable package for one rooted workflow. It contains at most
512 files and 512 workflow dependencies, each file is at most 512 KiB
of UTF-8 content, and its compact canonical JSON representation is at
most 2 MiB.
type: object
additionalProperties: false
required:
- entrypoint
- files
- workflow_dependencies
properties:
entrypoint:
$ref: "#/components/schemas/WorkflowPath"
files:
type: object
description: >-
Workflow-local text files keyed by canonical path. Keys receive
stricter domain validation than OpenAPI can express; each value is
limited to 512 KiB of UTF-8 bytes.
maxProperties: 512
propertyNames:
$ref: "#/components/schemas/WorkflowPath"
additionalProperties:
type: string
workflow_dependencies:
type: object
description: >-
Exact stored workflow-version IDs keyed by resolved child-workflow
path. Keys receive stricter domain validation than OpenAPI can express.
maxProperties: 512
propertyNames:
$ref: "#/components/schemas/WorkflowPath"
additionalProperties:
$ref: "#/components/schemas/WorkflowVersionId"
CreateWorkflowVersionResponse:
description: Identity of the stored immutable workflow version.
type: object
additionalProperties: false
required:
- workflow_version_id
properties:
workflow_version_id:
$ref: "#/components/schemas/WorkflowVersionId"
RunManifest:
description: Self-contained workflow run manifest.
type: object

View file

@ -32,6 +32,7 @@ fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-interview = { path = "../../components/fabro-interview" }
fabro-slack = { path = "../../components/fabro-slack" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
fabro-github = { path = "../../components/fabro-github" }

View file

@ -30,6 +30,7 @@ mod steer;
pub(in crate::server) mod system;
mod variables;
mod worker_control;
mod workflow_versions;
pub(super) use system::{health, openapi_spec};
@ -226,6 +227,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(secrets::routes())
.merge(variables::routes())
.merge(worker_control::routes())
.merge(workflow_versions::routes())
.merge(sessions::routes())
.merge(system::routes())
.merge(completions::routes())

View file

@ -0,0 +1,357 @@
use std::sync::Arc;
use axum::extract::DefaultBodyLimit;
use axum::extract::rejection::JsonRejection;
use fabro_api::types::{CreateWorkflowVersionResponse, WorkflowVersion};
use fabro_types::MAX_WORKFLOW_VERSION_BYTES;
use fabro_util::error;
use fabro_workflow_version::{
ValidatedWorkflowVersion, WorkflowVersionStore, WorkflowVersionStoreError,
};
use super::super::{
ApiError, AppState, IntoResponse, Json, RequiredUser, Response, Router, State, StatusCode, post,
};
const INVALID_JSON_CODE: &str = "invalid_json";
const INVALID_VERSION_CODE: &str = "workflow_version_invalid";
const DEPENDENCY_NOT_FOUND_CODE: &str = "workflow_version_dependency_not_found";
const VERSION_TOO_LARGE_CODE: &str = "workflow_version_too_large";
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route(
"/workflow-versions",
post(create_workflow_version).layer(DefaultBodyLimit::max(MAX_WORKFLOW_VERSION_BYTES)),
)
}
async fn create_workflow_version(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
payload: Result<Json<WorkflowVersion>, JsonRejection>,
) -> Result<Response, ApiError> {
let Json(version) = payload.map_err(json_rejection)?;
let version = ValidatedWorkflowVersion::new(version).map_err(|err| {
ApiError::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
err.to_string(),
INVALID_VERSION_CODE,
)
})?;
let blobs = state.store_ref().blobs().await.map_err(|err| {
tracing::error!(
error = %err,
error_chain = ?error::collect_chain(&err),
"Failed to open workflow version storage"
);
internal_store_error()
})?;
let store = WorkflowVersionStore::new(blobs);
let workflow_version_id = store.put(&version).await.map_err(store_error)?;
Ok((
StatusCode::CREATED,
Json(CreateWorkflowVersionResponse {
workflow_version_id,
}),
)
.into_response())
}
fn json_rejection(rejection: JsonRejection) -> ApiError {
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
return ApiError::with_code(
StatusCode::PAYLOAD_TOO_LARGE,
"workflow version request exceeds 2 MiB",
VERSION_TOO_LARGE_CODE,
);
}
match rejection {
JsonRejection::JsonDataError(err) => ApiError::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
err.body_text(),
INVALID_VERSION_CODE,
),
other => ApiError::with_code(
StatusCode::BAD_REQUEST,
other.body_text(),
INVALID_JSON_CODE,
),
}
}
fn store_error(err: WorkflowVersionStoreError) -> ApiError {
match err {
err @ WorkflowVersionStoreError::DependencyNotFound { .. } => ApiError::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
err.to_string(),
DEPENDENCY_NOT_FOUND_CODE,
),
WorkflowVersionStoreError::InvalidVersion(source) => ApiError::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
source.to_string(),
INVALID_VERSION_CODE,
),
WorkflowVersionStoreError::InvalidShape(source) => ApiError::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
source.to_string(),
INVALID_VERSION_CODE,
),
err => {
tracing::error!(
error = %err,
error_chain = ?error::collect_chain(&err),
"Workflow version store operation failed"
);
internal_store_error()
}
}
}
fn internal_store_error() -> ApiError {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"workflow version store operation failed",
)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, StatusCode, header};
use axum::response::IntoResponse;
use fabro_types::WorkflowVersionId;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::{
DEPENDENCY_NOT_FOUND_CODE, INVALID_JSON_CODE, INVALID_VERSION_CODE,
MAX_WORKFLOW_VERSION_BYTES, VERSION_TOO_LARGE_CODE, store_error,
};
use crate::server;
use crate::test_support::{self, TestAppStateBuilder};
const GRAPH: &str = "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
fn request(body: impl Into<Body>) -> Request<Body> {
Request::builder()
.method(Method::POST)
.uri("/api/v1/workflow-versions")
.header(header::CONTENT_TYPE, "application/json")
.body(body.into())
.unwrap()
}
fn version(graph: &str) -> Value {
json!({
"entrypoint": "workflow.fabro",
"files": { "workflow.fabro": graph },
"workflow_dependencies": {}
})
}
async fn response_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn error_code(body: &Value) -> &str {
body["errors"][0]["code"].as_str().unwrap()
}
#[tokio::test]
async fn create_requires_authenticated_user() {
let state = TestAppStateBuilder::new().build();
let app = server::build_router(state, test_support::test_auth_mode());
let response = app
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn valid_and_equivalent_requests_return_the_same_id() {
let state = TestAppStateBuilder::new().build();
let app = test_support::build_test_router(Arc::clone(&state));
let first = app
.clone()
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::CREATED);
let first = response_json(first).await;
assert_eq!(first.as_object().unwrap().len(), 1);
let reordered = format!(
r#"{{"workflow_dependencies":{{}},"files":{{"workflow.fabro":{}}},"entrypoint":"workflow.fabro"}}"#,
serde_json::to_string(GRAPH).unwrap()
);
let second = app.oneshot(request(reordered)).await.unwrap();
assert_eq!(second.status(), StatusCode::CREATED);
assert_eq!(response_json(second).await, first);
let id = first["workflow_version_id"]
.as_str()
.unwrap()
.parse::<WorkflowVersionId>()
.unwrap();
assert!(
state
.store_ref()
.blobs()
.await
.unwrap()
.read(&id.into())
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn invalid_json_and_domain_content_have_distinct_codes() {
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
let malformed = app.clone().oneshot(request("{")).await.unwrap();
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
assert_eq!(
error_code(&response_json(malformed).await),
INVALID_JSON_CODE
);
let unknown = json!({
"entrypoint": "workflow.fabro",
"files": { "workflow.fabro": GRAPH },
"workflow_dependencies": {},
"metadata": {}
});
let invalid = app
.oneshot(request(serde_json::to_vec(&unknown).unwrap()))
.await
.unwrap();
assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
error_code(&response_json(invalid).await),
INVALID_VERSION_CODE
);
}
#[tokio::test]
async fn unavailable_dependency_has_specific_code() {
let state = TestAppStateBuilder::new().build();
let app = test_support::build_test_router(Arc::clone(&state));
let missing_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
let root = json!({
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
},
"workflow_dependencies": { "child.fabro": missing_id }
});
let response = app
.oneshot(request(serde_json::to_vec(&root).unwrap()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
error_code(&response_json(response).await),
DEPENDENCY_NOT_FOUND_CODE
);
}
#[tokio::test]
async fn corrupt_stored_dependency_returns_curated_internal_error() {
let state = TestAppStateBuilder::new().build();
let app = test_support::build_test_router(Arc::clone(&state));
let dependency_id = WorkflowVersionId::from(
state
.store_ref()
.blobs()
.await
.unwrap()
.write(b"not a workflow version")
.await
.unwrap(),
);
let root = json!({
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
},
"workflow_dependencies": { "child.fabro": dependency_id }
});
let response = app
.oneshot(request(serde_json::to_vec(&root).unwrap()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response_json(response).await;
assert_eq!(
body["errors"][0]["detail"],
"workflow version store operation failed"
);
assert!(!body.to_string().contains("cannot be decoded"));
}
#[tokio::test]
async fn stored_child_can_be_pinned_as_a_dependency() {
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
let child = app
.clone()
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
.await
.unwrap();
assert_eq!(child.status(), StatusCode::CREATED);
let child_id = response_json(child).await["workflow_version_id"].clone();
let root = json!({
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
},
"workflow_dependencies": { "child.fabro": child_id }
});
let response = app
.oneshot(request(serde_json::to_vec(&root).unwrap()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response_json(response).await.as_object().unwrap().len(), 1);
}
#[tokio::test]
async fn body_limit_has_specific_code() {
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
let response = app
.oneshot(request(vec![b' '; MAX_WORKFLOW_VERSION_BYTES + 1]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(
error_code(&response_json(response).await),
VERSION_TOO_LARGE_CODE
);
}
#[tokio::test]
async fn storage_fault_response_is_curated() {
let response = store_error(fabro_workflow_version::WorkflowVersionStoreError::Storage {
source: fabro_store::Error::Other("private persistence detail".to_string()),
})
.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response_json(response).await;
assert_eq!(
body["errors"][0]["detail"],
"workflow version store operation failed"
);
assert!(!body.to_string().contains("private persistence detail"));
}
}

View file

@ -0,0 +1,27 @@
[package]
name = "fabro-workflow-version"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Semantic validation and storage for immutable workflow versions"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
fabro-config = { path = "../../foundation/fabro-config" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-store = { path = "../fabro-store" }
fabro-template = { path = "../../foundation/fabro-template" }
fabro-types = { path = "../../foundation/fabro-types" }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
object_store.workspace = true
tokio = { workspace = true, features = ["full"] }

View file

@ -0,0 +1,507 @@
//! Semantic validation for immutable workflow versions.
//!
//! The wire type ([`fabro_types::WorkflowVersion`]) enforces structural
//! invariants at construction. This crate owns the expensive semantic
//! validation — graph closure, config, and template checks — behind the
//! [`ValidatedWorkflowVersion`] newtype, and the content-addressed
//! [`WorkflowVersionStore`] that only accepts and returns validated versions.
use std::collections::{BTreeSet, HashMap, VecDeque};
use fabro_config::parse::{SettingsSource, validate_settings_source};
use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer};
use fabro_graphviz::parser;
use fabro_template::{
BundleTemplateStore, GraphReference, GraphReferenceError, StaticReferenceError,
TemplateDiscoveryError, TemplateSource, discover_static_dependency_closure,
validate_static_reference, visit_graph_references,
};
use fabro_types::graph::ReferenceKind;
use fabro_types::{ManifestPath, WorkflowPath, WorkflowPathParseError, WorkflowVersion};
use thiserror::Error;
mod store;
pub use store::{WorkflowVersionStore, WorkflowVersionStoreError};
#[derive(Debug, Error)]
pub enum WorkflowVersionError {
#[error("workflow graph `{path}` is invalid")]
GraphParse {
path: WorkflowPath,
#[source]
source: fabro_graphviz::Error,
},
#[error("invalid {kind} in `{path}`: `{reference}`")]
InvalidReference {
path: WorkflowPath,
kind: ReferenceKind,
reference: String,
#[source]
source: WorkflowPathParseError,
},
#[error("invalid static reference in `{path}`")]
StaticReference {
path: WorkflowPath,
#[source]
source: StaticReferenceError,
},
#[error("{kind} in `{path}` references missing file `{target}`")]
MissingFile {
path: WorkflowPath,
kind: ReferenceKind,
target: WorkflowPath,
},
#[error("template dependencies for `{path}` are invalid")]
Template {
path: WorkflowPath,
#[source]
source: Box<TemplateDiscoveryError>,
},
#[error("workflow.toml is invalid")]
Config {
#[source]
source: fabro_config::ParseError,
},
#[error(
"workflow.toml selects graph `{configured}`, but the version entrypoint is `{entrypoint}`"
)]
ConfigEntrypointMismatch {
configured: WorkflowPath,
entrypoint: WorkflowPath,
},
#[error("workflow dependencies do not match child workflow references")]
DependencyMismatch {
missing: Vec<WorkflowPath>,
unused: Vec<WorkflowPath>,
},
}
/// A workflow version whose graph, config, and template content passed
/// semantic validation.
///
/// This is the only door: functions that require a semantically valid
/// version take this type, and the only way to obtain one is [`Self::new`]
/// (or loading through [`WorkflowVersionStore`], which validates on read).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedWorkflowVersion(WorkflowVersion);
impl ValidatedWorkflowVersion {
pub fn new(version: WorkflowVersion) -> Result<Self, WorkflowVersionError> {
validate_config(&version)?;
validate_graph_closure(&version)?;
Ok(Self(version))
}
#[must_use]
pub fn version(&self) -> &WorkflowVersion {
&self.0
}
#[must_use]
pub fn into_version(self) -> WorkflowVersion {
self.0
}
}
fn validate_config(version: &WorkflowVersion) -> Result<(), WorkflowVersionError> {
let config_path =
WorkflowPath::new("workflow.toml").expect("the static workflow config path must be valid");
let Some(source) = version.files().get(&config_path) else {
return Ok(());
};
let layer = source
.parse::<SettingsLayer>()
.map_err(|source| WorkflowVersionError::Config { source })?;
validate_settings_source(&layer, SettingsSource::Workflow)
.map_err(|source| WorkflowVersionError::Config { source })?;
if let Some(configured) = layer
.workflow
.as_ref()
.and_then(|workflow| workflow.graph.as_deref())
{
let configured = resolve_reference(&config_path, ReferenceKind::FileInline, configured)?;
if configured != *version.entrypoint() {
return Err(WorkflowVersionError::ConfigEntrypointMismatch {
configured,
entrypoint: version.entrypoint().clone(),
});
}
}
for image in layer.environment_images() {
validate_dockerfile(version, &config_path, image)?;
}
Ok(())
}
fn validate_dockerfile(
version: &WorkflowVersion,
config_path: &WorkflowPath,
image: &EnvironmentImageLayer,
) -> Result<(), WorkflowVersionError> {
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
return Ok(());
};
validate_static_reference(path, ReferenceKind::Dockerfile).map_err(|source| {
WorkflowVersionError::StaticReference {
path: config_path.clone(),
source,
}
})?;
let target = resolve_reference(config_path, ReferenceKind::Dockerfile, path)?;
require_file(version, config_path, ReferenceKind::Dockerfile, target).map(|_| ())
}
fn validate_graph_closure(version: &WorkflowVersion) -> Result<(), WorkflowVersionError> {
let template_store = template_store(version);
let template_root = ManifestPath::from_wire(".")
.expect("the template package root must be a valid manifest path");
let mut queue = VecDeque::from([version.entrypoint().clone()]);
let mut visited = BTreeSet::new();
let mut child_workflows = BTreeSet::new();
while let Some(path) = queue.pop_front() {
if !visited.insert(path.clone()) {
continue;
}
let source =
version
.files()
.get(&path)
.ok_or_else(|| WorkflowVersionError::MissingFile {
path: path.clone(),
kind: ReferenceKind::Import,
target: path.clone(),
})?;
let graph = parser::parse(source).map_err(|source| WorkflowVersionError::GraphParse {
path: path.clone(),
source,
})?;
visit_graph_references(&graph, |reference| match reference {
GraphReference::GoalFile { reference } => {
let target = resolve_reference(&path, ReferenceKind::GraphGoalFile, reference)?;
let content =
require_file(version, &path, ReferenceKind::GraphGoalFile, target.clone())?;
validate_template(&target, content, &template_store, &template_root)
}
GraphReference::GoalInline { content } | GraphReference::InlinePrompt { content } => {
validate_template(&path, content, &template_store, &template_root)
}
GraphReference::Import { reference } => {
let target = resolve_reference(&path, ReferenceKind::Import, reference)?;
require_file(version, &path, ReferenceKind::Import, target.clone())?;
queue.push_back(target);
Ok(())
}
GraphReference::ChildWorkflow { reference } => {
let target = resolve_reference(&path, ReferenceKind::ChildWorkflow, reference)?;
child_workflows.insert(target);
Ok(())
}
GraphReference::FileInline { key, reference } => {
let target = resolve_reference(&path, ReferenceKind::FileInline, reference)?;
let content =
require_file(version, &path, ReferenceKind::FileInline, target.clone())?;
if key == "prompt" {
validate_template(&target, content, &template_store, &template_root)?;
}
Ok(())
}
})
.map_err(|error| match error {
GraphReferenceError::StaticReference(source) => WorkflowVersionError::StaticReference {
path: path.clone(),
source,
},
GraphReferenceError::Visit(error) => error,
})?;
}
let configured = version
.workflow_dependencies()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
if child_workflows != configured {
return Err(WorkflowVersionError::DependencyMismatch {
missing: child_workflows.difference(&configured).cloned().collect(),
unused: configured.difference(&child_workflows).cloned().collect(),
});
}
Ok(())
}
fn validate_template(
path: &WorkflowPath,
content: &str,
store: &BundleTemplateStore,
root: &ManifestPath,
) -> Result<(), WorkflowVersionError> {
let manifest_path = manifest_path(path);
discover_static_dependency_closure(
[TemplateSource::new(manifest_path, root.clone(), content)],
store,
)
.map_err(|source| WorkflowVersionError::Template {
path: path.clone(),
source: Box::new(source),
})?;
Ok(())
}
fn template_store(version: &WorkflowVersion) -> BundleTemplateStore {
BundleTemplateStore::new(
version
.files()
.iter()
.map(|(path, content)| (manifest_path(path), content.clone()))
.collect::<HashMap<_, _>>(),
)
}
fn resolve_reference(
path: &WorkflowPath,
kind: ReferenceKind,
reference: &str,
) -> Result<WorkflowPath, WorkflowVersionError> {
path.resolve_reference(reference)
.map_err(|source| WorkflowVersionError::InvalidReference {
path: path.clone(),
kind,
reference: reference.to_owned(),
source,
})
}
fn require_file<'version>(
version: &'version WorkflowVersion,
path: &WorkflowPath,
kind: ReferenceKind,
target: WorkflowPath,
) -> Result<&'version str, WorkflowVersionError> {
version
.files()
.get(&target)
.map(String::as_str)
.ok_or_else(|| WorkflowVersionError::MissingFile {
path: path.clone(),
kind,
target,
})
}
fn manifest_path(path: &WorkflowPath) -> ManifestPath {
ManifestPath::from_wire(path.as_str())
.expect("validated workflow paths must also be valid manifest paths")
}
#[cfg(test)]
mod tests {
use fabro_types::{BlobHash, WorkflowPath, WorkflowVersion, WorkflowVersionId};
use super::{ValidatedWorkflowVersion, WorkflowVersionError};
fn path(value: &str) -> WorkflowPath {
value.parse().unwrap()
}
fn dependency_id(value: &[u8]) -> WorkflowVersionId {
BlobHash::new(value).into()
}
fn version_with(
files: impl IntoIterator<Item = (&'static str, &'static str)>,
dependencies: impl IntoIterator<Item = (&'static str, WorkflowVersionId)>,
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
ValidatedWorkflowVersion::new(
WorkflowVersion::new(
path("workflow.fabro"),
files
.into_iter()
.map(|(path_value, content)| (path(path_value), content.to_owned()))
.collect(),
dependencies
.into_iter()
.map(|(path_value, id)| (path(path_value), id))
.collect(),
)
.expect("test fixtures must be structurally valid"),
)
}
#[test]
fn validates_imports_templates_file_refs_and_dependencies() {
let version = version_with(
[
(
"workflow.fabro",
r#"digraph W {
graph [goal="@prompts/goal.md"]
start [shape=Mdiamond]
imported [import="graphs/imported.fabro"]
child [stack.child_workflow="children/check.fabro"]
exit [shape=Msquare]
start -> imported -> child -> exit
}"#,
),
(
"graphs/imported.fabro",
r#"digraph I { step [prompt="{% include \"../prompts/partial.md\" %}"] }"#,
),
("prompts/goal.md", "{% include \"partial.md\" %}"),
("prompts/partial.md", "Do the work"),
],
[("children/check.fabro", dependency_id(b"child"))],
)
.unwrap();
assert_eq!(version.version().workflow_dependencies().len(), 1);
}
#[test]
fn rejects_missing_and_unused_dependencies() {
let error = version_with(
[(
"workflow.fabro",
r#"digraph W { child [stack.child_workflow="child.fabro"] }"#,
)],
[("unused.fabro", dependency_id(b"unused"))],
)
.unwrap_err();
let WorkflowVersionError::DependencyMismatch { missing, unused } = error else {
panic!("expected dependency mismatch");
};
assert_eq!(missing, vec![path("child.fabro")]);
assert_eq!(unused, vec![path("unused.fabro")]);
}
#[test]
fn rejects_config_entrypoint_and_missing_dockerfile() {
let error = version_with(
[
(
"workflow.fabro",
"digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
),
(
"workflow.toml",
"_version = 1\n[workflow]\ngraph = \"other.fabro\"\n",
),
],
[],
)
.unwrap_err();
assert!(matches!(
error,
WorkflowVersionError::ConfigEntrypointMismatch { .. }
));
let missing_dockerfile = version_with(
[
("workflow.fabro", "digraph W {}"),
(
"workflow.toml",
"_version = 1\n[run.environment.image]\ndockerfile = { path = \"docker/Dockerfile\" }\n",
),
],
[],
)
.unwrap_err();
assert!(matches!(
missing_dockerfile,
WorkflowVersionError::MissingFile { .. }
));
let invalid_config = version_with(
[
("workflow.fabro", "digraph W {}"),
("workflow.toml", "not valid toml = ["),
],
[],
)
.unwrap_err();
assert!(matches!(
invalid_config,
WorkflowVersionError::Config { .. }
));
}
#[test]
fn accepts_root_config_and_all_dockerfile_path_sources() {
let version = version_with(
[
("workflow.fabro", "digraph W {}"),
(
"workflow.toml",
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
dockerfile = { path = "docker/named.Dockerfile" }
[run.environment.image]
dockerfile = { path = "docker/run.Dockerfile" }
"#,
),
("docker/named.Dockerfile", "FROM alpine\n"),
("docker/run.Dockerfile", "FROM ubuntu\n"),
],
[],
)
.unwrap();
assert_eq!(version.version().entrypoint(), &path("workflow.fabro"));
}
#[test]
fn rejects_server_managed_environment_cwd_in_workflow_config() {
let error = version_with(
[
("workflow.fabro", "digraph W {}"),
(
"workflow.toml",
"_version = 1\n[environments.local]\nprovider = \"local\"\ncwd = \"/tmp\"\n",
),
],
[],
)
.unwrap_err();
assert!(matches!(error, WorkflowVersionError::Config { .. }));
assert!(error.to_string().contains("workflow.toml is invalid"));
}
#[test]
fn rejects_escaping_and_dynamic_template_references() {
let escaping = version_with(
[(
"workflow.fabro",
r#"digraph W { imported [import="../outside.fabro"] }"#,
)],
[],
)
.unwrap_err();
assert!(matches!(
escaping,
WorkflowVersionError::InvalidReference { .. }
));
let dynamic = version_with(
[(
"workflow.fabro",
r#"digraph W { step [prompt="{% include template_name %}"] }"#,
)],
[],
)
.unwrap_err();
assert!(matches!(dynamic, WorkflowVersionError::Template { .. }));
}
}

View file

@ -0,0 +1,306 @@
use std::collections::{BTreeMap, HashSet, VecDeque};
use std::sync::Arc;
use fabro_store::BlobStore;
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId, WorkflowVersionShapeError};
use thiserror::Error;
use crate::{ValidatedWorkflowVersion, WorkflowVersionError};
#[derive(Debug, Error)]
pub enum WorkflowVersionStoreError {
#[error(transparent)]
InvalidVersion(#[from] WorkflowVersionError),
#[error(transparent)]
InvalidShape(#[from] WorkflowVersionShapeError),
#[error("workflow-version dependency `{id}` at `{path}` is not stored")]
DependencyNotFound {
path: WorkflowPath,
id: WorkflowVersionId,
},
#[error("workflow-version dependency `{id}` at `{path}` is invalid")]
DependencyInvalid {
path: WorkflowPath,
id: WorkflowVersionId,
#[source]
source: Box<Self>,
},
#[error("workflow-version blob `{id}` cannot be decoded as a valid workflow version")]
Decode {
id: WorkflowVersionId,
#[source]
source: serde_json::Error,
},
#[error("workflow-version blob `{id}` is not canonical")]
NonCanonical { id: WorkflowVersionId },
#[error("workflow-version storage operation failed")]
Storage {
#[source]
source: fabro_store::Error,
},
}
/// Content-addressed storage for validated workflow versions.
///
/// `put` only accepts semantically validated versions; `get` re-validates
/// blobs on read because the blob namespace is shared and storage is not
/// trusted to contain only canonical versions.
#[derive(Clone, Debug)]
pub struct WorkflowVersionStore {
blobs: Arc<BlobStore>,
}
impl WorkflowVersionStore {
#[must_use]
pub fn new(blobs: Arc<BlobStore>) -> Self {
Self { blobs }
}
pub async fn put(
&self,
version: &ValidatedWorkflowVersion,
) -> Result<WorkflowVersionId, WorkflowVersionStoreError> {
let canonical = version.version().canonical_bytes()?;
self.validate_dependency_closure(version.version().workflow_dependencies())
.await?;
self.blobs
.write(&canonical)
.await
.map(WorkflowVersionId::from)
.map_err(|source| WorkflowVersionStoreError::Storage { source })
}
pub async fn get(
&self,
id: &WorkflowVersionId,
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
let Some(version) = self.load_one(id).await? else {
return Ok(None);
};
self.validate_dependency_closure(version.version().workflow_dependencies())
.await?;
Ok(Some(version))
}
async fn load_one(
&self,
id: &WorkflowVersionId,
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
let blob_id = (*id).into();
let Some(bytes) = self
.blobs
.read(&blob_id)
.await
.map_err(|source| WorkflowVersionStoreError::Storage { source })?
else {
return Ok(None);
};
let version = serde_json::from_slice::<WorkflowVersion>(&bytes)
.map_err(|source| WorkflowVersionStoreError::Decode { id: *id, source })?;
let validated = ValidatedWorkflowVersion::new(version)?;
let canonical = validated.version().canonical_bytes()?;
if canonical.as_slice() != bytes.as_ref() {
return Err(WorkflowVersionStoreError::NonCanonical { id: *id });
}
Ok(Some(validated))
}
async fn validate_dependency_closure(
&self,
dependencies: &BTreeMap<WorkflowPath, WorkflowVersionId>,
) -> Result<(), WorkflowVersionStoreError> {
let mut pending = dependencies
.iter()
.map(|(path, id)| (path.clone(), *id))
.collect::<VecDeque<_>>();
let mut visited = HashSet::new();
while let Some((path, id)) = pending.pop_front() {
if !visited.insert(id) {
continue;
}
match self.load_one(&id).await {
Ok(Some(dependency)) => {
pending.extend(
dependency
.version()
.workflow_dependencies()
.iter()
.map(|(path, id)| (path.clone(), *id)),
);
}
Ok(None) => {
return Err(WorkflowVersionStoreError::DependencyNotFound { path, id });
}
// Persistence failures are server faults, not evidence that
// the caller supplied an invalid dependency.
Err(source @ WorkflowVersionStoreError::Storage { .. }) => return Err(source),
Err(source) => {
return Err(WorkflowVersionStoreError::DependencyInvalid {
path,
id,
source: Box::new(source),
});
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use fabro_store::{BlobStore, Database};
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
use object_store::memory::InMemory;
use super::{WorkflowVersionStore, WorkflowVersionStoreError};
use crate::ValidatedWorkflowVersion;
fn path(value: &str) -> WorkflowPath {
value.parse().unwrap()
}
fn version(
graph: &str,
dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
) -> ValidatedWorkflowVersion {
ValidatedWorkflowVersion::new(
WorkflowVersion::new(
path("workflow.fabro"),
BTreeMap::from([(path("workflow.fabro"), graph.to_owned())]),
dependencies,
)
.unwrap(),
)
.unwrap()
}
async fn stores() -> (Arc<BlobStore>, WorkflowVersionStore) {
let database = Database::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
);
let blobs = database.blobs().await.unwrap();
let versions = WorkflowVersionStore::new(Arc::clone(&blobs));
(blobs, versions)
}
#[tokio::test]
async fn put_get_reuses_exact_blob_digest() {
let (blobs, store) = stores().await;
let version = version("digraph W {}", BTreeMap::new());
let expected_bytes = version.version().canonical_bytes().unwrap();
let expected_id = WorkflowVersionId::from(fabro_types::BlobHash::new(&expected_bytes));
let id = store.put(&version).await.unwrap();
assert_eq!(id, expected_id);
let blob_id = id.into();
assert_eq!(blobs.read(&blob_id).await.unwrap().unwrap(), expected_bytes);
assert_eq!(store.get(&id).await.unwrap(), Some(version));
}
#[tokio::test]
async fn identical_content_is_idempotent() {
let (_, store) = stores().await;
let original = version("digraph W {}", BTreeMap::new());
assert_eq!(
store.put(&original).await.unwrap(),
store.put(&original).await.unwrap()
);
let changed = version("digraph W { changed [label=\"yes\"] }", BTreeMap::new());
assert_ne!(
store.put(&original).await.unwrap(),
store.put(&changed).await.unwrap()
);
}
#[tokio::test]
async fn dependency_must_be_stored_first() {
let (blobs, store) = stores().await;
let child = version("digraph Child {}", BTreeMap::new());
let child_id = WorkflowVersionId::from(fabro_types::BlobHash::new(
&child.version().canonical_bytes().unwrap(),
));
let root = version(
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
BTreeMap::from([(path("child.fabro"), child_id)]),
);
let root_id = WorkflowVersionId::from(fabro_types::BlobHash::new(
&root.version().canonical_bytes().unwrap(),
));
let error = store.put(&root).await.unwrap_err();
assert!(matches!(
error,
WorkflowVersionStoreError::DependencyNotFound { .. }
));
assert!(!blobs.exists(&root_id.into()).await.unwrap());
assert_eq!(store.put(&child).await.unwrap(), child_id);
assert!(store.put(&root).await.is_ok());
}
#[tokio::test]
async fn dependency_closure_must_be_complete_before_root_write() {
let (blobs, store) = stores().await;
let missing_grandchild_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
let child = version(
r#"digraph Child { grandchild [stack.child_workflow="grandchild.fabro"] }"#,
BTreeMap::from([(path("grandchild.fabro"), missing_grandchild_id)]),
);
let child_bytes = child.version().canonical_bytes().unwrap();
let child_id = WorkflowVersionId::from(blobs.write(&child_bytes).await.unwrap());
let root = version(
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
BTreeMap::from([(path("child.fabro"), child_id)]),
);
let root_id = WorkflowVersionId::from(fabro_types::BlobHash::new(
&root.version().canonical_bytes().unwrap(),
));
assert!(matches!(
store.put(&root).await.unwrap_err(),
WorkflowVersionStoreError::DependencyNotFound { id, .. }
if id == missing_grandchild_id
));
assert!(!blobs.exists(&root_id.into()).await.unwrap());
assert!(matches!(
store.get(&child_id).await.unwrap_err(),
WorkflowVersionStoreError::DependencyNotFound { id, .. }
if id == missing_grandchild_id
));
}
#[tokio::test]
async fn get_rejects_arbitrary_and_noncanonical_blobs() {
let (blobs, store) = stores().await;
let arbitrary = WorkflowVersionId::from(blobs.write(b"not json").await.unwrap());
assert!(matches!(
store.get(&arbitrary).await.unwrap_err(),
WorkflowVersionStoreError::Decode { .. }
));
let invalid_bytes = br#"{"entrypoint":"missing.fabro","files":{"workflow.fabro":"digraph W {}"},"workflow_dependencies":{}}"#;
let invalid = WorkflowVersionId::from(blobs.write(invalid_bytes).await.unwrap());
assert!(matches!(
store.get(&invalid).await.unwrap_err(),
WorkflowVersionStoreError::Decode { .. }
));
let version = version("digraph W {}", BTreeMap::new());
let pretty = serde_json::to_vec_pretty(version.version()).unwrap();
let noncanonical = WorkflowVersionId::from(blobs.write(&pretty).await.unwrap());
assert!(matches!(
store.get(&noncanonical).await.unwrap_err(),
WorkflowVersionStoreError::NonCanonical { .. }
));
}
}

View file

@ -722,6 +722,9 @@ fn main() {
("CompletionMessage", "fabro_types::Message", &[]),
("CompletionMessageRole", "fabro_types::Role", &[]),
("CompletionContentPart", "fabro_types::ContentPart", &[]),
("WorkflowVersion", "fabro_types::WorkflowVersion", &[]),
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
("CostSource", "fabro_model::CostSource", &[]),
];
for (name, path, impls) in replacements {

View file

@ -73,7 +73,8 @@ pub mod types {
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest,
UserPrincipal, Variable, VariableListResponse, WorkflowSettings,
UserPrincipal, Variable, VariableListResponse, WorkflowPath, WorkflowSettings,
WorkflowVersion, WorkflowVersionId,
};
pub use crate::generated::types::*;

View file

@ -0,0 +1,54 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
WorkflowPath as ApiWorkflowPath, WorkflowVersion as ApiWorkflowVersion,
WorkflowVersionId as ApiWorkflowVersionId,
};
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
use serde_json::json;
#[test]
fn workflow_version_schemas_reuse_domain_types() {
assert_same_type::<ApiWorkflowPath, WorkflowPath>();
assert_same_type::<ApiWorkflowVersionId, WorkflowVersionId>();
assert_same_type::<ApiWorkflowVersion, WorkflowVersion>();
}
#[test]
fn workflow_version_round_trips_exact_wire_shape() {
let value = json!({
"entrypoint": "workflow.fabro",
"files": {
"prompts/goal.md": "Ship it",
"workflow.fabro": "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
},
"workflow_dependencies": {}
});
let version: ApiWorkflowVersion = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(version).unwrap(), value);
}
#[test]
fn workflow_version_replacement_rejects_unknown_fields() {
let value = json!({
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph W {}"
},
"workflow_dependencies": {},
"metadata": {}
});
assert!(serde_json::from_value::<ApiWorkflowVersion>(value).is_err());
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} and {} should be the same type",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -54,6 +54,9 @@ pub mod timing;
pub mod todo;
pub mod transcript;
pub mod variable;
pub mod workflow_path;
pub mod workflow_version;
pub mod workflow_version_id;
pub use artifact::ArtifactUpload;
pub use auth::{IdpIdentity, IdpIdentityError};
@ -183,3 +186,11 @@ pub use transcript::{
pub use variable::{
CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name,
};
pub use workflow_path::{
MAX_WORKFLOW_PATH_BYTES, MAX_WORKFLOW_PATH_COMPONENTS, WorkflowPath, WorkflowPathParseError,
};
pub use workflow_version::{
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES, MAX_WORKFLOW_VERSION_FILE_BYTES,
MAX_WORKFLOW_VERSION_FILES, WorkflowVersion, WorkflowVersionShapeError,
};
pub use workflow_version_id::{WorkflowVersionId, WorkflowVersionIdParseError};

View file

@ -0,0 +1,301 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MAX_WORKFLOW_PATH_BYTES: usize = 240;
pub const MAX_WORKFLOW_PATH_COMPONENTS: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("invalid workflow path `{value}`: {reason}")]
pub struct WorkflowPathParseError {
value: String,
reason: &'static str,
}
impl WorkflowPathParseError {
fn new(value: &str, reason: &'static str) -> Self {
Self {
value: value.to_owned(),
reason,
}
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub fn reason(&self) -> &'static str {
self.reason
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct WorkflowPath(String);
impl WorkflowPath {
pub fn new(value: impl Into<String>) -> Result<Self, WorkflowPathParseError> {
let value = value.into();
validate(&value)?;
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
self.0
.rsplit_once('/')
.map(|(parent, _)| Self(parent.to_owned()))
}
#[must_use]
pub fn is_ancestor_of(&self, other: &Self) -> bool {
other.0.len() > self.0.len()
&& other.0.starts_with(self.0.as_str())
&& other.0.as_bytes()[self.0.len()] == b'/'
}
pub fn resolve_reference(&self, reference: &str) -> Result<Self, WorkflowPathParseError> {
validate_reference_shape(reference)?;
let mut components = self
.0
.rsplit_once('/')
.map_or_else(Vec::new, |(parent, _)| {
parent.split('/').collect::<Vec<_>>()
});
for component in reference.split('/') {
match component {
"" | "." => {}
".." => {
if components.pop().is_none() {
return Err(WorkflowPathParseError::new(
reference,
"reference escapes the workflow root",
));
}
}
value => components.push(value),
}
}
Self::new(components.join("/"))
}
}
impl FromStr for WorkflowPath {
type Err = WorkflowPathParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<String> for WorkflowPath {
type Error = WorkflowPathParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<WorkflowPath> for String {
fn from(value: WorkflowPath) -> Self {
value.0
}
}
impl fmt::Display for WorkflowPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn validate(value: &str) -> Result<(), WorkflowPathParseError> {
validate_reference_shape(value)?;
if value
.split('/')
.any(|component| matches!(component, "." | ".."))
{
return Err(WorkflowPathParseError::new(
value,
"dot segments are not allowed in stored paths",
));
}
if value.split('/').count() > MAX_WORKFLOW_PATH_COMPONENTS {
return Err(WorkflowPathParseError::new(
value,
"path has too many components",
));
}
if value.len() > MAX_WORKFLOW_PATH_BYTES {
return Err(WorkflowPathParseError::new(value, "path is too long"));
}
Ok(())
}
fn validate_reference_shape(value: &str) -> Result<(), WorkflowPathParseError> {
if value.is_empty() {
return Err(WorkflowPathParseError::new(value, "path is empty"));
}
if value.starts_with('/') {
return Err(WorkflowPathParseError::new(
value,
"absolute paths are not allowed",
));
}
if value.starts_with('~') {
return Err(WorkflowPathParseError::new(
value,
"tilde-prefixed paths are not allowed",
));
}
if value.contains('\\') {
return Err(WorkflowPathParseError::new(
value,
"backslashes are not allowed",
));
}
if value.ends_with('/') {
return Err(WorkflowPathParseError::new(
value,
"trailing slashes are not allowed",
));
}
if value.contains("//") {
return Err(WorkflowPathParseError::new(
value,
"repeated slashes are not allowed",
));
}
let bytes = value.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(WorkflowPathParseError::new(
value,
"Windows drive paths are not allowed",
));
}
if value.bytes().any(|byte| byte.is_ascii_control()) {
return Err(WorkflowPathParseError::new(
value,
"control characters are not allowed",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use super::{MAX_WORKFLOW_PATH_BYTES, MAX_WORKFLOW_PATH_COMPONENTS, WorkflowPath};
#[test]
fn accepts_canonical_portable_paths() {
for value in ["workflow.fabro", "graphs/main.fabro", "prompts/日本語.md"] {
let path: WorkflowPath = value.parse().expect("path should parse");
assert_eq!(path.as_str(), value);
}
}
#[test]
fn rejects_non_canonical_or_unsafe_paths() {
for value in [
"", "/root", "root/", "a//b", "a\\b", "~/a", "C:/a", ".", "..", "a/./b", "a/../b",
"a\nb",
] {
assert!(value.parse::<WorkflowPath>().is_err(), "accepted {value:?}");
}
}
#[test]
fn enforces_byte_and_component_limits() {
assert!(
"a".repeat(MAX_WORKFLOW_PATH_BYTES)
.parse::<WorkflowPath>()
.is_ok()
);
assert!(
"a".repeat(MAX_WORKFLOW_PATH_BYTES + 1)
.parse::<WorkflowPath>()
.is_err()
);
assert!(
vec!["a"; MAX_WORKFLOW_PATH_COMPONENTS]
.join("/")
.parse::<WorkflowPath>()
.is_ok()
);
assert!(
vec!["a"; MAX_WORKFLOW_PATH_COMPONENTS + 1]
.join("/")
.parse::<WorkflowPath>()
.is_err()
);
}
#[test]
fn resolves_references_without_escaping_root() {
let graph: WorkflowPath = "graphs/nested/main.fabro".parse().unwrap();
assert_eq!(
graph.resolve_reference("../prompts/plan.md").unwrap(),
"graphs/prompts/plan.md".parse().unwrap()
);
assert!(graph.resolve_reference("../../../outside.md").is_err());
assert!(graph.resolve_reference("prompts//plan.md").is_err());
assert!(graph.resolve_reference("prompts/").is_err());
}
#[test]
fn ancestor_checks_component_boundaries() {
let parent: WorkflowPath = "dir/file".parse().unwrap();
assert!(parent.is_ancestor_of(&"dir/file/child".parse().unwrap()));
assert!(!parent.is_ancestor_of(&"dir/filename".parse().unwrap()));
}
#[test]
fn serde_and_ordered_map_keys_preserve_canonical_text() {
let paths = BTreeMap::from([
("z/last.md".parse::<WorkflowPath>().unwrap(), 2),
("a/first.md".parse::<WorkflowPath>().unwrap(), 1),
]);
assert_eq!(
serde_json::to_value(&paths).unwrap(),
json!({"a/first.md": 1, "z/last.md": 2})
);
assert_eq!(
serde_json::from_value::<BTreeMap<WorkflowPath, i32>>(json!({
"a/first.md": 1,
"z/last.md": 2
}))
.unwrap(),
paths
);
}
#[test]
fn byte_limit_counts_utf8_bytes() {
assert!(
"é".repeat(MAX_WORKFLOW_PATH_BYTES / 2)
.parse::<WorkflowPath>()
.is_ok()
);
assert!(
"é".repeat(MAX_WORKFLOW_PATH_BYTES / 2 + 1)
.parse::<WorkflowPath>()
.is_err()
);
assert!("notes/\u{85}.md".parse::<WorkflowPath>().is_ok());
}
}

View file

@ -0,0 +1,419 @@
use std::collections::BTreeMap;
use std::fmt;
use std::marker::PhantomData;
use serde::de::{Error as _, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use crate::{WorkflowPath, WorkflowVersionId};
pub const MAX_WORKFLOW_VERSION_FILES: usize = 512;
pub const MAX_WORKFLOW_VERSION_DEPENDENCIES: usize = 512;
pub const MAX_WORKFLOW_VERSION_FILE_BYTES: usize = 512 * 1024;
pub const MAX_WORKFLOW_VERSION_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Error)]
pub enum WorkflowVersionShapeError {
#[error("workflow version has {actual} files; maximum is {maximum}")]
TooManyFiles { actual: usize, maximum: usize },
#[error("workflow version has {actual} workflow dependencies; maximum is {maximum}")]
TooManyWorkflowDependencies { actual: usize, maximum: usize },
#[error("workflow file `{path}` is {actual} bytes; maximum is {maximum}")]
FileTooLarge {
path: WorkflowPath,
actual: usize,
maximum: usize,
},
#[error("workflow version is {actual} canonical bytes; maximum is {maximum}")]
VersionTooLarge { actual: usize, maximum: usize },
#[error("entrypoint `{path}` is not present in workflow files")]
MissingEntrypoint { path: WorkflowPath },
#[error("workflow paths collide: `{first}` and `{second}`")]
PathCollision {
first: WorkflowPath,
second: WorkflowPath,
},
#[error("failed to serialize canonical workflow version")]
Serialization {
#[source]
source: serde_json::Error,
},
}
/// Canonical wire form of an immutable workflow version.
///
/// Construction (and therefore deserialization) enforces the structural
/// invariants: file-count and byte-size limits, entrypoint presence, unique
/// map keys, and collision-free paths. Semantic validation of graph, config,
/// and template content is a separate concern owned by
/// `fabro-workflow-version`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WorkflowVersion {
entrypoint: WorkflowPath,
files: BTreeMap<WorkflowPath, String>,
workflow_dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
}
impl WorkflowVersion {
pub fn new(
entrypoint: WorkflowPath,
files: BTreeMap<WorkflowPath, String>,
workflow_dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
) -> Result<Self, WorkflowVersionShapeError> {
let version = Self {
entrypoint,
files,
workflow_dependencies,
};
version.validate_shape()?;
version.canonical_bytes()?;
Ok(version)
}
#[must_use]
pub fn entrypoint(&self) -> &WorkflowPath {
&self.entrypoint
}
#[must_use]
pub fn files(&self) -> &BTreeMap<WorkflowPath, String> {
&self.files
}
#[must_use]
pub fn workflow_dependencies(&self) -> &BTreeMap<WorkflowPath, WorkflowVersionId> {
&self.workflow_dependencies
}
/// Serialize to the canonical wire form.
///
/// Structural validity is guaranteed by construction, so this only
/// serializes and enforces the canonical size limit.
pub fn canonical_bytes(&self) -> Result<Vec<u8>, WorkflowVersionShapeError> {
let bytes = serde_json::to_vec(self)
.map_err(|source| WorkflowVersionShapeError::Serialization { source })?;
if bytes.len() > MAX_WORKFLOW_VERSION_BYTES {
return Err(WorkflowVersionShapeError::VersionTooLarge {
actual: bytes.len(),
maximum: MAX_WORKFLOW_VERSION_BYTES,
});
}
Ok(bytes)
}
fn validate_shape(&self) -> Result<(), WorkflowVersionShapeError> {
if self.files.len() > MAX_WORKFLOW_VERSION_FILES {
return Err(WorkflowVersionShapeError::TooManyFiles {
actual: self.files.len(),
maximum: MAX_WORKFLOW_VERSION_FILES,
});
}
if self.workflow_dependencies.len() > MAX_WORKFLOW_VERSION_DEPENDENCIES {
return Err(WorkflowVersionShapeError::TooManyWorkflowDependencies {
actual: self.workflow_dependencies.len(),
maximum: MAX_WORKFLOW_VERSION_DEPENDENCIES,
});
}
for (path, content) in &self.files {
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
return Err(WorkflowVersionShapeError::FileTooLarge {
path: path.clone(),
actual: content.len(),
maximum: MAX_WORKFLOW_VERSION_FILE_BYTES,
});
}
}
if !self.files.contains_key(&self.entrypoint) {
return Err(WorkflowVersionShapeError::MissingEntrypoint {
path: self.entrypoint.clone(),
});
}
self.validate_path_collisions()
}
fn validate_path_collisions(&self) -> Result<(), WorkflowVersionShapeError> {
// Keys are unique within each map, so equality can only collide
// across files and workflow dependencies.
let mut paths = self
.files
.keys()
.chain(self.workflow_dependencies.keys())
.collect::<Vec<_>>();
paths.sort_unstable();
for pair in paths.windows(2) {
let [first, second] = pair else {
unreachable!("a two-item window must contain two paths")
};
if first == second || first.is_ancestor_of(second) {
return Err(WorkflowVersionShapeError::PathCollision {
first: (*first).clone(),
second: (*second).clone(),
});
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for WorkflowVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
entrypoint: WorkflowPath,
files: UniqueBTreeMap<WorkflowPath, String>,
workflow_dependencies: UniqueBTreeMap<WorkflowPath, WorkflowVersionId>,
}
let wire = Wire::deserialize(deserializer)?;
Self::new(wire.entrypoint, wire.files.0, wire.workflow_dependencies.0)
.map_err(D::Error::custom)
}
}
struct UniqueBTreeMap<K, V>(BTreeMap<K, V>);
impl<'de, K, V> Deserialize<'de> for UniqueBTreeMap<K, V>
where
K: Deserialize<'de> + Ord + fmt::Display,
V: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct MapVisitor<K, V>(PhantomData<(K, V)>);
impl<'de, K, V> Visitor<'de> for MapVisitor<K, V>
where
K: Deserialize<'de> + Ord + fmt::Display,
V: Deserialize<'de>,
{
type Value = UniqueBTreeMap<K, V>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a map with unique keys")
}
fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut values = BTreeMap::new();
while let Some((key, value)) = access.next_entry::<K, V>()? {
if values.insert(key, value).is_some() {
return Err(A::Error::custom("duplicate workflow map key"));
}
}
Ok(UniqueBTreeMap(values))
}
}
deserializer.deserialize_map(MapVisitor(PhantomData))
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES,
MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES, WorkflowVersion,
WorkflowVersionShapeError,
};
use crate::{BlobHash, WorkflowPath, WorkflowVersionId};
fn path(value: &str) -> WorkflowPath {
value.parse().unwrap()
}
#[test]
fn canonical_bytes_have_fixed_field_and_map_order() {
let version = WorkflowVersion::new(
path("workflow.fabro"),
BTreeMap::from([
(path("z.txt"), "Z".to_string()),
(path("workflow.fabro"), "digraph W {}".to_string()),
(path("a.txt"), "A".to_string()),
]),
BTreeMap::new(),
)
.unwrap();
assert_eq!(
String::from_utf8(version.canonical_bytes().unwrap()).unwrap(),
r#"{"entrypoint":"workflow.fabro","files":{"a.txt":"A","workflow.fabro":"digraph W {}","z.txt":"Z"},"workflow_dependencies":{}}"#
);
}
#[test]
fn rejects_missing_entrypoint() {
let error = WorkflowVersion::new(
path("missing.fabro"),
BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]),
BTreeMap::new(),
)
.unwrap_err();
assert!(matches!(
error,
WorkflowVersionShapeError::MissingEntrypoint { .. }
));
}
#[test]
fn rejects_path_collisions_and_large_files() {
let collision = WorkflowVersion::new(
path("workflow.fabro"),
BTreeMap::from([
(path("workflow.fabro"), "digraph W {}".to_string()),
(path("assets"), "file".to_string()),
(path("assets/item.txt"), "nested".to_string()),
]),
BTreeMap::new(),
)
.unwrap_err();
assert!(matches!(
collision,
WorkflowVersionShapeError::PathCollision { .. }
));
let mut files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
files.insert(
path("large.txt"),
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES + 1),
);
let large =
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::new()).unwrap_err();
assert!(matches!(
large,
WorkflowVersionShapeError::FileTooLarge { .. }
));
}
#[test]
fn enforces_file_count_file_size_and_canonical_size_boundaries() {
let mut files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
for index in 0..MAX_WORKFLOW_VERSION_FILES - 1 {
files.insert(path(&format!("file-{index:03}.txt")), String::new());
}
assert!(
WorkflowVersion::new(path("workflow.fabro"), files.clone(), BTreeMap::new()).is_ok()
);
files.insert(path("too-many.txt"), String::new());
assert!(matches!(
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::new()).unwrap_err(),
WorkflowVersionShapeError::TooManyFiles { .. }
));
let exact_file = BTreeMap::from([
(path("workflow.fabro"), "digraph W {}".to_string()),
(
path("payload.txt"),
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES),
),
]);
assert!(
WorkflowVersion::new(path("workflow.fabro"), exact_file.clone(), BTreeMap::new())
.is_ok()
);
let mut oversized_file = exact_file;
oversized_file
.get_mut(&path("payload.txt"))
.unwrap()
.push('x');
assert!(matches!(
WorkflowVersion::new(path("workflow.fabro"), oversized_file, BTreeMap::new())
.unwrap_err(),
WorkflowVersionShapeError::FileTooLarge { .. }
));
let mut exact_version_files =
BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
for index in 0..4 {
exact_version_files.insert(path(&format!("payload-{index}.txt")), String::new());
}
let empty = WorkflowVersion::new(
path("workflow.fabro"),
exact_version_files.clone(),
BTreeMap::new(),
)
.unwrap();
let remaining = MAX_WORKFLOW_VERSION_BYTES - empty.canonical_bytes().unwrap().len();
let per_file = remaining / 4;
let remainder = remaining % 4;
for index in 0..4 {
let length = per_file + usize::from(index < remainder);
assert!(length <= MAX_WORKFLOW_VERSION_FILE_BYTES);
exact_version_files.insert(path(&format!("payload-{index}.txt")), "x".repeat(length));
}
let exact_version = WorkflowVersion::new(
path("workflow.fabro"),
exact_version_files.clone(),
BTreeMap::new(),
)
.unwrap();
assert_eq!(
exact_version.canonical_bytes().unwrap().len(),
MAX_WORKFLOW_VERSION_BYTES
);
exact_version_files
.get_mut(&path("payload-0.txt"))
.unwrap()
.push('x');
assert!(matches!(
WorkflowVersion::new(path("workflow.fabro"), exact_version_files, BTreeMap::new())
.unwrap_err(),
WorkflowVersionShapeError::VersionTooLarge { .. }
));
}
#[test]
fn enforces_workflow_dependency_count_boundary() {
let dependencies = (0..MAX_WORKFLOW_VERSION_DEPENDENCIES)
.map(|index| {
(
path(&format!("dependency-{index:03}.fabro")),
WorkflowVersionId::from(BlobHash::new(index.to_string().as_bytes())),
)
})
.collect::<BTreeMap<_, _>>();
let files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_owned())]);
assert!(
WorkflowVersion::new(path("workflow.fabro"), files.clone(), dependencies.clone())
.is_ok()
);
let mut oversized = dependencies;
oversized.insert(
path("too-many.fabro"),
WorkflowVersionId::from(BlobHash::new(b"too many")),
);
assert!(matches!(
WorkflowVersion::new(path("workflow.fabro"), files, oversized).unwrap_err(),
WorkflowVersionShapeError::TooManyWorkflowDependencies { .. }
));
}
#[test]
fn deserialize_rejects_unknown_fields_and_duplicate_keys() {
let unknown = r#"{
"entrypoint":"workflow.fabro",
"files":{"workflow.fabro":"digraph W {}"},
"workflow_dependencies":{},
"metadata":{}
}"#;
assert!(serde_json::from_str::<WorkflowVersion>(unknown).is_err());
let duplicate = r#"{
"entrypoint":"workflow.fabro",
"files":{"workflow.fabro":"digraph W {}","workflow.fabro":"digraph X {}"},
"workflow_dependencies":{}
}"#;
assert!(serde_json::from_str::<WorkflowVersion>(duplicate).is_err());
}
}

View file

@ -0,0 +1,96 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::BlobHash;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct WorkflowVersionId(BlobHash);
impl From<BlobHash> for WorkflowVersionId {
fn from(value: BlobHash) -> Self {
Self(value)
}
}
impl From<WorkflowVersionId> for BlobHash {
fn from(value: WorkflowVersionId) -> Self {
value.0
}
}
impl fmt::Display for WorkflowVersionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl From<WorkflowVersionId> for String {
fn from(value: WorkflowVersionId) -> Self {
value.to_string()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
#[error("workflow version ID must be exactly 64 lowercase hexadecimal characters")]
pub struct WorkflowVersionIdParseError;
impl FromStr for WorkflowVersionId {
type Err = WorkflowVersionIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
// `BlobHash` enforces length and hex charset but accepts uppercase digits;
// the canonical wire form is lowercase only.
if value.bytes().any(|byte| byte.is_ascii_uppercase()) {
return Err(WorkflowVersionIdParseError);
}
value
.parse::<BlobHash>()
.map(Self)
.map_err(|_| WorkflowVersionIdParseError)
}
}
impl TryFrom<String> for WorkflowVersionId {
type Error = WorkflowVersionIdParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
#[cfg(test)]
mod tests {
use crate::{BlobHash, WorkflowVersionId};
#[test]
fn conversion_preserves_digest_and_display() {
let blob_id = BlobHash::new(b"workflow");
let version_id = WorkflowVersionId::from(blob_id);
assert_eq!(version_id.to_string(), blob_id.to_string());
assert_eq!(BlobHash::from(version_id), blob_id);
}
#[test]
fn parse_and_serde_require_lowercase_hex() {
let value = BlobHash::new(b"workflow").to_string();
let id: WorkflowVersionId = value.parse().unwrap();
assert_eq!(serde_json::to_value(id).unwrap(), value);
assert!(value.to_uppercase().parse::<WorkflowVersionId>().is_err());
for invalid in [
String::new(),
"0".repeat(63),
"0".repeat(65),
"g".repeat(64),
] {
assert!(invalid.parse::<WorkflowVersionId>().is_err());
}
assert!(
serde_json::from_value::<WorkflowVersionId>(serde_json::json!(value.to_uppercase()))
.is_err()
);
}
}

View file

@ -22,6 +22,7 @@ api/sessions-api.ts
api/settings-api.ts
api/system-api.ts
api/variables-api.ts
api/workflow-versions-api.ts
api/workflows-api.ts
base.ts
common.ts
@ -105,6 +106,7 @@ models/create-run-pull-request-request.ts
models/create-run-session-request.ts
models/create-secret-request.ts
models/create-variable-request.ts
models/create-workflow-version-response.ts
models/delete-run-response.ts
models/delete-run-sandbox.ts
models/delete-secret-request.ts
@ -541,4 +543,5 @@ models/workflow-ref.ts
models/workflow-reference.ts
models/workflow-schedule-summary.ts
models/workflow-settings.ts
models/workflow-version.ts
models/write-blob-response.ts

View file

@ -37,4 +37,5 @@ export * from './api/sessions-api';
export * from './api/settings-api';
export * from './api/system-api';
export * from './api/variables-api';
export * from './api/workflow-versions-api';
export * from './api/workflows-api';

View file

@ -0,0 +1,134 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreateWorkflowVersionResponse } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { WorkflowVersion } from '../models';
/**
* WorkflowVersionsApi - axios parameter creator
*/
export const WorkflowVersionsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier.
* @summary Create Workflow Version
* @param {WorkflowVersion} workflowVersion
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createWorkflowVersion: async (workflowVersion: WorkflowVersion, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'workflowVersion' is not null or undefined
assertParamExists('createWorkflowVersion', 'workflowVersion', workflowVersion)
const localVarPath = `/api/v1/workflow-versions`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(workflowVersion, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* WorkflowVersionsApi - functional programming interface
*/
export const WorkflowVersionsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = WorkflowVersionsApiAxiosParamCreator(configuration)
return {
/**
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier.
* @summary Create Workflow Version
* @param {WorkflowVersion} workflowVersion
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createWorkflowVersion(workflowVersion: WorkflowVersion, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<CreateWorkflowVersionResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createWorkflowVersion(workflowVersion, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['WorkflowVersionsApi.createWorkflowVersion']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* WorkflowVersionsApi - factory interface
*/
export const WorkflowVersionsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = WorkflowVersionsApiFp(configuration)
return {
/**
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier.
* @summary Create Workflow Version
* @param {WorkflowVersion} workflowVersion
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createWorkflowVersion(workflowVersion: WorkflowVersion, options?: RawAxiosRequestConfig): AxiosPromise<CreateWorkflowVersionResponse> {
return localVarFp.createWorkflowVersion(workflowVersion, options).then((request) => request(axios, basePath));
},
};
};
/**
* WorkflowVersionsApi - object-oriented interface
*/
export class WorkflowVersionsApi extends BaseAPI {
/**
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier.
* @summary Create Workflow Version
* @param {WorkflowVersion} workflowVersion
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createWorkflowVersion(workflowVersion: WorkflowVersion, options?: RawAxiosRequestConfig) {
return WorkflowVersionsApiFp(this.configuration).createWorkflowVersion(workflowVersion, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Identity of the stored immutable workflow version.
*/
export interface CreateWorkflowVersionResponse {
/**
* SHA-256 identity of validated canonical workflow-version bytes.
*/
'workflow_version_id': string;
}

View file

@ -76,6 +76,7 @@ export * from './create-run-pull-request-request';
export * from './create-run-session-request';
export * from './create-secret-request';
export * from './create-variable-request';
export * from './create-workflow-version-response';
export * from './delete-run-response';
export * from './delete-run-sandbox';
export * from './delete-secret-request';
@ -511,4 +512,5 @@ export * from './workflow-ref';
export * from './workflow-reference';
export * from './workflow-schedule-summary';
export * from './workflow-settings';
export * from './workflow-version';
export * from './write-blob-response';

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Complete immutable package for one rooted workflow. It contains at most 512 files and 512 workflow dependencies, each file is at most 512 KiB of UTF-8 content, and its compact canonical JSON representation is at most 2 MiB.
*/
export interface WorkflowVersion {
/**
* Canonical portable path inside one workflow version. Paths are UTF-8, relative, at most 240 bytes and 16 components, and cannot contain empty, dot, parent, backslash, control, tilde-root, or drive-letter segments. Map keys receive stricter byte and structural validation in the domain model than OpenAPI can express.
*/
'entrypoint': string;
/**
* Workflow-local text files keyed by canonical path. Keys receive stricter domain validation than OpenAPI can express; each value is limited to 512 KiB of UTF-8 bytes.
*/
'files': { [key: string]: string; };
/**
* Exact stored workflow-version IDs keyed by resolved child-workflow path. Keys receive stricter domain validation than OpenAPI can express.
*/
'workflow_dependencies': { [key: string]: string; };
}