mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Add content-based workflow version registration tools
This commit is contained in:
parent
a8cc458151
commit
d5dec0fffb
23 changed files with 1109 additions and 45 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -3214,6 +3214,8 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"toml 0.8.23",
|
||||
"ulid",
|
||||
"unicase",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: "Connect MCP tools to agents and expose Fabro runs to MCP clients"
|
|||
|
||||
MCP ([Model Context Protocol](https://modelcontextprotocol.io/)) lets you connect external tool servers to Fabro agents. An MCP server exposes tools over a standardized protocol — databases, APIs, file systems, custom services — and Fabro discovers and registers them automatically. Agents call MCP tools the same way they call built-in tools.
|
||||
|
||||
Fabro can also run as an MCP server. MCP clients can use Fabro's run-management tools to create, inspect, control, wait for, and read events from workflow runs through the authenticated `fabro` CLI.
|
||||
Fabro can also run as an MCP server. MCP clients can register reusable workflow versions and use Fabro's run-management tools to create, inspect, control, wait for, and read events from workflow runs through the authenticated `fabro` CLI.
|
||||
|
||||
Workflow agents can opt in to that same run-management tool catalog with `[run.agent] fabro_tools = true`. This is not the same as configuring external MCP servers for the agent. When a workflow agent calls `fabro_run_create`, created runs are always [child runs](/execution/child-runs) of the current run; an explicit `parent_id` must match the current run ID.
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ fabro mcp init claude --name fabro-testing --server https://fabro-testing.exampl
|
|||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `fabro_workflow_version_create` | Register supplied workflow contents and local dependencies as an immutable version ID, without creating a run. |
|
||||
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
|
||||
| `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. |
|
||||
| `fabro_run_get` | Read-only inspection of a run: returns its summary, projection, and pending questions without mutating state. |
|
||||
|
|
@ -47,6 +48,45 @@ fabro mcp init claude --name fabro-testing --server https://fabro-testing.exampl
|
|||
| `fabro_run_pair` | Inspect, start, message, end, or read transcript for a live run pairing session. |
|
||||
| `fabro_run_events` | List, inspect, or search stored events for a run. |
|
||||
|
||||
### Register workflow contents from a sandbox
|
||||
|
||||
Use shell and read tools in your sandbox to acquire the workflow and all its local
|
||||
config, graph, prompt, script, import, and child-workflow files. For example, clone
|
||||
a repository with your sandbox's shell tool, then read `workflow.fabro` and its
|
||||
referenced `prompt.md`. Submit the actual contents:
|
||||
|
||||
```json
|
||||
{
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }",
|
||||
"prompt.md": "Review the implementation."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `fabro_workflow_version_create` with this object and keep the returned
|
||||
`workflow_version_id`. You can reuse it in a `RunIntent` submitted through the
|
||||
[Create Run API](/api-reference/runs/create-run). Registration packages and uploads
|
||||
child workflows before their parents; callers do not calculate dependency IDs.
|
||||
|
||||
`entrypoint` is an exact supplied key, including when it has no extension. File
|
||||
values are text, never host paths or URLs to fetch. Missing references and paths
|
||||
that escape the supplied tree fail. The source tree is limited to 512 files,
|
||||
512 KiB per file, and 2 MiB of text; each resulting serialized version must also
|
||||
fit the existing 2 MiB API limit. Case-insensitive file and ancestor collisions
|
||||
are rejected before staging. Collection follows declared file references; command
|
||||
`script` values remain literal text, and paths embedded in shell commands are not
|
||||
inspected or acquired.
|
||||
|
||||
Registration resolves no runtime secrets, selects no environment, and starts no
|
||||
execution. It requires a user credential or a worker token with `agent:run_tools`;
|
||||
ordinary worker tokens and same-run Ask Fabro sessions cannot register versions.
|
||||
If an upload fails, retry the same contents: previously registered immutable
|
||||
versions remain reusable. The existing `fabro_run_create` input is unchanged.
|
||||
|
||||
### Create runs
|
||||
|
||||
For a simple create call, `fabro_run_create` accepts a workflow selector string:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,9 @@ paths:
|
|||
description: >-
|
||||
Validates and stores an immutable workflow package in content-addressed
|
||||
storage. Repeating the same canonical content returns the same identifier.
|
||||
Requires an authenticated user or a worker token with the `agent:run_tools`
|
||||
capability. Ordinary worker tokens cannot register versions. Registration
|
||||
creates no run and starts no execution.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ Configure workflow agent behavior that is not tied to a single stage.
|
|||
fabro_tools = true
|
||||
```
|
||||
|
||||
`fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to use the same Fabro run-management MCP tool catalog exposed to human MCP clients: create, search, get, interact, gather, events, and pair.
|
||||
`fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to use the same Fabro run-management MCP tool catalog exposed to human MCP clients: workflow version registration, create, search, get, interact, gather, events, and pair.
|
||||
|
||||
One workflow-agent exception is intentional: `fabro_run_create` always creates [child runs](/execution/child-runs) parented to the current run. If an agent supplies `parent_id`, it must match the current run ID.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use fabro_interview::{
|
|||
WorkerControlMessage,
|
||||
};
|
||||
use fabro_server::run_tool_manifest;
|
||||
use fabro_server::workflow_version_tool::ServerWorkflowVersionCreateAdapter;
|
||||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||
|
|
@ -236,7 +237,8 @@ fn build_fabro_run_tool_services(
|
|||
return None;
|
||||
}
|
||||
let backend = ClientBackend::new(Arc::new(client))
|
||||
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder));
|
||||
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder))
|
||||
.with_workflow_version_create_adapter(Arc::new(ServerWorkflowVersionCreateAdapter));
|
||||
Some(FabroRunToolServices {
|
||||
backend: Arc::new(backend),
|
||||
current_run_id,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const MCP_RUN_TOOL_NAMES: &[&str] = &[
|
|||
"fabro_run_interact",
|
||||
"fabro_run_pair",
|
||||
"fabro_run_search",
|
||||
"fabro_workflow_version_create",
|
||||
];
|
||||
|
||||
async fn assert_mcp_run_tool_count(client: &McpClient) {
|
||||
|
|
@ -1847,6 +1848,24 @@ async fn mcp_get_rejects_blank_run_id_before_auth_or_network() {
|
|||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_workflow_version_validation_happens_before_auth_or_network() {
|
||||
let context = test_context!();
|
||||
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
|
||||
let error = call_tool_error_text(
|
||||
&client,
|
||||
"fabro_workflow_version_create",
|
||||
serde_json::json!({"entrypoint":"workflow","files":{}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(error, "entrypoint must be an exact supplied file key");
|
||||
assert_mcp_run_tool_count(&client).await;
|
||||
client
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_create_validation_errors_happen_before_auth_or_network() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_server::workflow_version_tool::ServerWorkflowVersionCreateAdapter;
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_tool::{self as run_tools, FabroToolBackend};
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
|
|
@ -74,7 +75,7 @@ impl ServerHandler for FabroMcpServer {
|
|||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new(SERVER_NAME, FABRO_VERSION).with_title("Fabro"))
|
||||
.with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.")
|
||||
.with_instructions("Use these tools to register workflow versions and create, inspect, control, wait for, and read events from Fabro workflow runs.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +91,27 @@ impl FabroMcpServer {
|
|||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_workflow_version_create",
|
||||
description = "Register supplied workflow file contents and all local dependencies as a reusable immutable workflow version ID. Obtain files with shell/read tools first; this does not create or start a run."
|
||||
)]
|
||||
async fn fabro_workflow_version_create(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroWorkflowVersionCreateParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
if let Err(err) = params.0.validate() {
|
||||
return Ok(error_result(&err));
|
||||
}
|
||||
let backend = match self.backend().await {
|
||||
Ok(backend) => backend,
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
};
|
||||
match run_tools::create_workflow_version(backend, params.0).await {
|
||||
Ok(result) => success_result(&result, run_tools::workflow_version_create_text(&result)),
|
||||
Err(err) => Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_create",
|
||||
description = "Create one or more Fabro workflow runs, optionally under a parent run, starting them by default."
|
||||
|
|
@ -252,7 +274,10 @@ impl FabroMcpServer {
|
|||
.map(|client| {
|
||||
Arc::new(
|
||||
ClientBackend::new(Arc::new(client))
|
||||
.with_manifest_builder(Arc::new(McpRunManifestBuilder)),
|
||||
.with_manifest_builder(Arc::new(McpRunManifestBuilder))
|
||||
.with_workflow_version_create_adapter(Arc::new(
|
||||
ServerWorkflowVersionCreateAdapter,
|
||||
)),
|
||||
) as Arc<dyn FabroToolBackend>
|
||||
})
|
||||
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
|
||||
|
|
@ -283,6 +308,7 @@ fn error_result(err: &run_tools::ToolError) -> CallToolResult {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -291,6 +317,58 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::FabroMcpServerSettings;
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_mcp_surface_matches_catalog_and_returns_minimal_result() {
|
||||
let mock = httpmock::MockServer::start_async().await;
|
||||
let version = fabro_types::WorkflowVersion::new(
|
||||
"workflow".parse().unwrap(),
|
||||
BTreeMap::from([("workflow".parse().unwrap(), "digraph W {}".to_string())]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = version.id().unwrap();
|
||||
let upload = mock
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(&version);
|
||||
then.status(201)
|
||||
.json_body(serde_json::json!({"workflow_version_id":id}));
|
||||
})
|
||||
.await;
|
||||
let url = mock.url("");
|
||||
let server = FabroMcpServer::new(Arc::new(FabroMcpServerSettings {
|
||||
cwd: PathBuf::from("/does-not-exist"),
|
||||
config_path: PathBuf::from("/does-not-exist"),
|
||||
client_factory: Arc::new(move || {
|
||||
let url = url.clone();
|
||||
Box::pin(async move { fabro_client::Client::new_no_proxy(&url) })
|
||||
}),
|
||||
}));
|
||||
let tools = server.tool_router.list_all();
|
||||
let tool = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "fabro_workflow_version_create")
|
||||
.unwrap();
|
||||
let definition = run_tools::tool_definitions()
|
||||
.iter()
|
||||
.find(|tool| tool.name == "fabro_workflow_version_create")
|
||||
.unwrap();
|
||||
let mut expected = definition.parameters.clone();
|
||||
expected.as_object_mut().unwrap().remove("$schema");
|
||||
let mut actual = Value::Object(tool.input_schema.as_ref().clone());
|
||||
actual.as_object_mut().unwrap().remove("$schema");
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(tool.description.as_deref(), Some(definition.description));
|
||||
let result = server.fabro_workflow_version_create(Parameters(serde_json::from_value(serde_json::json!({"entrypoint":"workflow","files":{"workflow":"digraph W {}"}})).unwrap())).await.unwrap();
|
||||
assert_eq!(
|
||||
result.structured_content,
|
||||
Some(serde_json::json!({"workflow_version_id":id}))
|
||||
);
|
||||
assert_ne!(result.is_error, Some(true));
|
||||
upload.assert_calls_async(1).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_info_reports_fabro_version() {
|
||||
let settings = FabroMcpServerSettings {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ pub mod web_auth;
|
|||
mod worker_control;
|
||||
mod worker_runtime;
|
||||
mod worker_token;
|
||||
pub mod workflow_version_tool;
|
||||
|
||||
pub use error::{ApiError, Error, Result};
|
||||
pub use run_manifest::workflow_bundle_from_manifest;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use fabro_workflow_version::{
|
|||
};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, IntoResponse, Json, RequiredUser, Response, Router, State, StatusCode, post,
|
||||
ApiError, AppState, IntoResponse, Json, Response, Router, State, StatusCode, post,
|
||||
};
|
||||
use crate::principal_middleware::RequiredRunManagementActor;
|
||||
|
||||
const INVALID_JSON_CODE: &str = "invalid_json";
|
||||
const INVALID_VERSION_CODE: &str = "workflow_version_invalid";
|
||||
|
|
@ -26,7 +27,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
}
|
||||
|
||||
async fn create_workflow_version(
|
||||
_auth: RequiredUser,
|
||||
_auth: RequiredRunManagementActor,
|
||||
State(state): State<Arc<AppState>>,
|
||||
payload: Result<Json<WorkflowVersion>, JsonRejection>,
|
||||
) -> Result<Response, ApiError> {
|
||||
|
|
|
|||
|
|
@ -19738,3 +19738,73 @@ fn validate_github_slug_rejects_overlong() {
|
|||
let long = "a".repeat(40);
|
||||
assert!(super::validate_github_slug("owner", &long, 39).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_registration_requires_user_or_run_tools_capability() {
|
||||
let (state, app) = jwt_auth_app();
|
||||
let run_id = RunId::new();
|
||||
let body = json!({"entrypoint":"workflow.fabro","files":{"workflow.fabro":"digraph W {}"},"workflow_dependencies":{}});
|
||||
for (token, expected) in [
|
||||
(issue_test_user_jwt(), StatusCode::CREATED),
|
||||
(
|
||||
issue_test_run_tools_worker_token(&run_id),
|
||||
StatusCode::CREATED,
|
||||
),
|
||||
(issue_test_worker_token(&run_id), StatusCode::FORBIDDEN),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_bearer_request(
|
||||
Method::POST,
|
||||
"/workflow-versions",
|
||||
&token,
|
||||
&body,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
fabro_test::expect_axum_status(response, expected, "POST /workflow-versions actor matrix")
|
||||
.await;
|
||||
}
|
||||
for body in [serde_json::to_string(&body).unwrap(), "{".to_string()] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api("/workflow-versions"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fabro_test::expect_axum_status(
|
||||
response,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"anonymous POST /workflow-versions",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
"/runs",
|
||||
&issue_test_user_jwt(),
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let listed =
|
||||
fabro_test::expect_axum_json(response, StatusCode::OK, "GET /runs after registration")
|
||||
.await;
|
||||
assert_eq!(listed["data"], json!([]));
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.runs
|
||||
.load_run_projection(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
461
lib/apps/fabro-server/src/workflow_version_tool.rs
Normal file
461
lib/apps/fabro-server/src/workflow_version_tool.rs
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use async_trait::async_trait;
|
||||
use fabro_client::Client;
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_tool::{FabroWorkflowVersionCreateParams, ToolError, WorkflowVersionCreateAdapter};
|
||||
use fabro_types::WorkflowVersionId;
|
||||
use tokio::task;
|
||||
|
||||
/// Content-only registration shared by standalone MCP and capable run workers.
|
||||
pub struct ServerWorkflowVersionCreateAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowVersionCreateAdapter for ServerWorkflowVersionCreateAdapter {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
params: FabroWorkflowVersionCreateParams,
|
||||
client: &Client,
|
||||
) -> anyhow::Result<WorkflowVersionId> {
|
||||
params.validate()?;
|
||||
let closure = task::spawn_blocking(move || {
|
||||
let staging = tempfile::Builder::new().prefix("fabro-workflow-version-").tempdir()?;
|
||||
collect_supplied_workflow(¶ms, staging)
|
||||
})
|
||||
.await
|
||||
.context("workflow packaging task failed")?
|
||||
// Parser diagnostics may contain supplied source. Keep them off the
|
||||
// tool result boundary, including nested error chains.
|
||||
.map_err(|_| ToolError::message("workflow source could not be packaged; check configuration, syntax, local references, and package limits"))?;
|
||||
let versions = closure
|
||||
.versions()
|
||||
.map(|(_, version)| version.version())
|
||||
.collect::<Vec<_>>();
|
||||
client.register_workflow_versions(versions).await?;
|
||||
Ok(closure.root_id())
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "bounded file staging and collection run on spawn_blocking"
|
||||
)]
|
||||
fn collect_supplied_workflow(
|
||||
params: &FabroWorkflowVersionCreateParams,
|
||||
staging: tempfile::TempDir,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure> {
|
||||
// TempDir owns cleanup on every return path, including collection errors.
|
||||
let root = staging.path().canonicalize()?;
|
||||
for (path, contents) in ¶ms.files {
|
||||
let destination = root.join(path.as_str());
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(destination, contents)?;
|
||||
}
|
||||
let location = WorkflowLocation::from_exact_path(Path::new(params.entrypoint.as_str()), &root)?;
|
||||
let closure = fabro_manifest::collect_workflow_versions_at_location(
|
||||
&location,
|
||||
&root,
|
||||
Path::new(params.entrypoint.as_str()),
|
||||
)?;
|
||||
// Collection validates the whole closure, including serialized request budgets.
|
||||
// A case-insensitive host must not satisfy a missing exact source key.
|
||||
for (_, version) in closure.versions() {
|
||||
for (path, content) in version.version().files() {
|
||||
anyhow::ensure!(
|
||||
params.files.get(path) == Some(content),
|
||||
"collected file does not match supplied source"
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(staging);
|
||||
Ok(closure)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "hermetic temporary source fixtures"
|
||||
)]
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use fabro_types::WorkflowVersion;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn params(entrypoint: &str, files: &[(&str, &str)]) -> FabroWorkflowVersionCreateParams {
|
||||
FabroWorkflowVersionCreateParams {
|
||||
entrypoint: entrypoint.parse().unwrap(),
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, content)| (path.parse().unwrap(), (*content).to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> FabroWorkflowVersionCreateParams {
|
||||
params("workflow.toml", &[
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
),
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@prompt.md"] child [stack.child_workflow="child.fabro"] }"#,
|
||||
),
|
||||
(
|
||||
"prompt.md",
|
||||
"Keep {{ secrets.TEST }} and {{ env.TEST }} for runtime.",
|
||||
),
|
||||
("child.fabro", "digraph Child {}"),
|
||||
])
|
||||
}
|
||||
|
||||
fn collect(params: &FabroWorkflowVersionCreateParams) -> CollectedWorkflowClosure {
|
||||
params.validate().unwrap();
|
||||
collect_supplied_workflow(params, tempfile::tempdir().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_content_matches_existing_collector_and_cleans_staging() {
|
||||
for input in [
|
||||
params("workflow.fabro", &[("workflow.fabro", "digraph W {}")]),
|
||||
fixture(),
|
||||
] {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
for (path, content) in &input.files {
|
||||
std::fs::write(source.path().join(path.as_str()), content).unwrap();
|
||||
}
|
||||
let expected = fabro_manifest::collect_workflow_versions(
|
||||
Path::new(input.entrypoint.as_str()),
|
||||
source.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let staging = tempfile::tempdir().unwrap();
|
||||
let path = staging.path().to_owned();
|
||||
let actual = collect_supplied_workflow(&input, staging).unwrap();
|
||||
assert!(!path.exists());
|
||||
assert_eq!(actual.root_id(), expected.root_id());
|
||||
assert_eq!(
|
||||
actual
|
||||
.versions()
|
||||
.map(|(_, v)| v.version())
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.versions()
|
||||
.map(|(_, v)| v.version())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_exact_extensionless_entrypoint_and_child_ignore_selectors() {
|
||||
let input = params("workflow", &[
|
||||
(
|
||||
"workflow",
|
||||
r#"digraph W { child [stack.child_workflow="child"] }"#,
|
||||
),
|
||||
("child", "digraph Child {}"),
|
||||
(
|
||||
".fabro/project.toml",
|
||||
"malformed project config must not be read",
|
||||
),
|
||||
(
|
||||
".fabro/workflows/workflow/workflow.toml",
|
||||
"misleading named workflow",
|
||||
),
|
||||
]);
|
||||
let closure = collect(&input);
|
||||
let versions = closure.versions().collect::<Vec<_>>();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions[1].1.version().entrypoint().as_str(), "workflow");
|
||||
assert_eq!(versions[0].1.version().entrypoint().as_str(), "child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_rejects_missing_and_escaping_references_and_cleans_failure() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
parent.path().join("outside.md"),
|
||||
"host content must never satisfy a reference",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(parent.path().join("child.fabro"), "digraph Host {}").unwrap();
|
||||
for (index, input) in [
|
||||
params("workflow.fabro", &[
|
||||
("workflow.fabro", r#"digraph W { p [prompt="@prompt.md"] }"#),
|
||||
("Prompt.md", "wrong case"),
|
||||
]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@../outside.md"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@sub/../../outside.md"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@outside.md"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [output_schema="@../outside.md"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="../child.fabro"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="sub/../../child.fabro"] }"#,
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="missing"] }"#,
|
||||
)]),
|
||||
params("workflow.toml", &[(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"../child.fabro\"\n",
|
||||
)]),
|
||||
params("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
"invalid source PRIVATE_CONTENT",
|
||||
)]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let staging = tempfile::tempdir_in(parent.path()).unwrap();
|
||||
let path = staging.path().to_owned();
|
||||
assert!(
|
||||
collect_supplied_workflow(&input, staging).is_err(),
|
||||
"accepted invalid fixture {index}"
|
||||
);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_registration_preserves_literal_scripts_without_executing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let marker = directory.path().join("must-not-exist");
|
||||
// Script is literal command text in Fabro, not an @file import.
|
||||
let graph = format!(
|
||||
"digraph W {{ command [script=\"touch {}\"] }}",
|
||||
marker.display()
|
||||
);
|
||||
let input = params("workflow", &[("workflow", &graph)]);
|
||||
let closure = collect(&input);
|
||||
let root = closure.versions().last().unwrap().1.version();
|
||||
assert_eq!(root.files()[&"workflow".parse().unwrap()], graph);
|
||||
assert!(!marker.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_map_order_is_irrelevant_and_reachable_changes_change_ids() {
|
||||
let input = fixture();
|
||||
let first = collect(&input);
|
||||
let mut reordered = input.clone();
|
||||
reordered.files = input.files.into_iter().rev().collect();
|
||||
assert_eq!(first.root_id(), collect(&reordered).root_id());
|
||||
reordered
|
||||
.files
|
||||
.insert("prompt.md".parse().unwrap(), "changed".into());
|
||||
let changed = collect(&reordered);
|
||||
assert_ne!(first.root_id(), changed.root_id());
|
||||
assert_eq!(
|
||||
first.versions().next().unwrap().0,
|
||||
changed.versions().next().unwrap().0
|
||||
);
|
||||
reordered
|
||||
.files
|
||||
.insert("child.fabro".parse().unwrap(), "digraph Changed {}".into());
|
||||
let changed_child = collect(&reordered);
|
||||
assert_ne!(changed.root_id(), changed_child.root_id());
|
||||
assert_ne!(
|
||||
changed.versions().next().unwrap().0,
|
||||
changed_child.versions().next().unwrap().0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_uploads_dependencies_first_and_retries_immutable_content() {
|
||||
let uploads = Arc::new(Mutex::new(Vec::<WorkflowVersion>::new()));
|
||||
let seen = uploads.clone();
|
||||
let app = Router::new().route(
|
||||
"/api/v1/workflow-versions",
|
||||
post(move |Json(version): Json<WorkflowVersion>| {
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
let mut seen = seen.lock().unwrap();
|
||||
for id in version.workflow_dependencies().values() {
|
||||
assert!(
|
||||
seen.iter().any(|prior| prior.id().unwrap() == *id),
|
||||
"dependency must be registered first"
|
||||
);
|
||||
}
|
||||
let id = version.id().unwrap();
|
||||
seen.push(version);
|
||||
(StatusCode::CREATED, Json(json!({"workflow_version_id":id})))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let client =
|
||||
Client::new_no_proxy(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let expected = collect(&fixture()).root_id();
|
||||
for _ in 0..2 {
|
||||
let id = ServerWorkflowVersionCreateAdapter
|
||||
.create_workflow_version(fixture(), &client)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, expected);
|
||||
}
|
||||
assert_eq!(uploads.lock().unwrap().len(), 4);
|
||||
server.abort();
|
||||
assert!(server.await.unwrap_err().is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_invalid_closure_has_no_uploads_or_source_in_errors() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST);
|
||||
then.status(500);
|
||||
})
|
||||
.await;
|
||||
let client = Client::new_no_proxy(&server.url("")).unwrap();
|
||||
let mut invalid = fixture();
|
||||
// Child is valid, but the root fails after its dependency is assembled.
|
||||
invalid.files.insert("workflow.toml".parse().unwrap(), "_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"missing.md\"".into());
|
||||
let mut oversized = fixture();
|
||||
let huge_prompt = "\u{1}".repeat(fabro_types::MAX_WORKFLOW_VERSION_FILE_BYTES - 1);
|
||||
oversized
|
||||
.files
|
||||
.insert("prompt.md".parse().unwrap(), huge_prompt);
|
||||
for input in [
|
||||
invalid,
|
||||
oversized,
|
||||
params("workflow", &[(
|
||||
"workflow",
|
||||
"PRIVATE_CONTENT invalid source",
|
||||
)]),
|
||||
] {
|
||||
let error = ServerWorkflowVersionCreateAdapter
|
||||
.create_workflow_version(input, &client)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!format!("{error:#}").contains("PRIVATE_CONTENT"));
|
||||
}
|
||||
upload.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_root_upload_failure_leaves_child_for_safe_retry() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let closure = collect(&fixture());
|
||||
let child = closure.versions().next().unwrap().1.version();
|
||||
let root = closure.versions().last().unwrap().1.version();
|
||||
let child_upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(child);
|
||||
then.status(201)
|
||||
.json_body(json!({"workflow_version_id":child.id().unwrap()}));
|
||||
})
|
||||
.await;
|
||||
let failed_root = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(root);
|
||||
then.status(400);
|
||||
})
|
||||
.await;
|
||||
let deletion = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::DELETE);
|
||||
then.status(500);
|
||||
})
|
||||
.await;
|
||||
let client = Client::new_no_proxy(&server.url("")).unwrap();
|
||||
assert!(
|
||||
ServerWorkflowVersionCreateAdapter
|
||||
.create_workflow_version(fixture(), &client)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
child_upload.assert_calls_async(1).await;
|
||||
failed_root.assert_calls_async(1).await;
|
||||
failed_root.delete_async().await;
|
||||
let root_upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(root);
|
||||
then.status(201)
|
||||
.json_body(json!({"workflow_version_id":root.id().unwrap()}));
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
ServerWorkflowVersionCreateAdapter
|
||||
.create_workflow_version(fixture(), &client)
|
||||
.await
|
||||
.unwrap(),
|
||||
closure.root_id()
|
||||
);
|
||||
child_upload.assert_calls_async(2).await;
|
||||
root_upload.assert_calls_async(1).await;
|
||||
deletion.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_failed_upload_and_wrong_server_id_never_return_success() {
|
||||
for wrong_id in [false, true] {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let closure = collect(&fixture());
|
||||
let child = closure.versions().next().unwrap().1.version();
|
||||
let upload = server.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST).path("/api/v1/workflow-versions").json_body_obj(child);
|
||||
if wrong_id {
|
||||
then.status(201).json_body(json!({"workflow_version_id": WorkflowVersionId::from(fabro_types::BlobHash::new(b"wrong"))}));
|
||||
} else { then.status(400); }
|
||||
}).await;
|
||||
let root = closure.versions().last().unwrap().1.version();
|
||||
let root_upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(root);
|
||||
then.status(201)
|
||||
.json_body(json!({"workflow_version_id":root.id().unwrap()}));
|
||||
})
|
||||
.await;
|
||||
let client = Client::new_no_proxy(&server.url("")).unwrap();
|
||||
assert!(
|
||||
ServerWorkflowVersionCreateAdapter
|
||||
.create_workflow_version(fixture(), &client)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
upload.assert_calls_async(1).await;
|
||||
root_upload.assert_calls_async(0).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ pub use crate::local_workflow_package::{
|
|||
use crate::workflow_bundler::WorkflowBundler;
|
||||
pub use crate::workflow_version_collector::{
|
||||
CollectedWorkflowClosure, WorkflowVersionCollectError, collect_workflow_versions,
|
||||
collect_workflow_versions_at_location,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
|
|||
|
|
@ -145,17 +145,27 @@ impl<'a> WorkflowBundler<'a> {
|
|||
/// `~` rejected) before resolution, so the file read matches the manifest
|
||||
/// key. Returns the collected workflow's manifest key.
|
||||
fn collect_workflow_entry(&mut self, workflow: &Path, resolve_from: &Path) -> Result<String> {
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
})?
|
||||
let location = if self.workflow_version_projection {
|
||||
let exact = normalize_absolute_path(resolve_from, &workflow.to_string_lossy())
|
||||
.ok_or_else(|| anyhow!("unsupported workflow reference"))?;
|
||||
// Check containment before location resolution can read a config.
|
||||
manifest_path_from_absolute(&exact, self.package_root)?;
|
||||
WorkflowLocation::from_exact_path(&exact, self.package_root)?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(
|
||||
|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
},
|
||||
)?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
WorkflowLocation::resolve(&normalized_workflow, resolve_from)?
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
self.collect_workflow_location(&location)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,9 @@ pub(super) fn canonicalize_location<E>(
|
|||
})
|
||||
}
|
||||
|
||||
pub(super) fn collect_workflow_versions_at_location(
|
||||
/// Collect an already resolved exact location inside a canonical package root.
|
||||
/// Every dependency is validated before this returns; no registration occurs.
|
||||
pub fn collect_workflow_versions_at_location(
|
||||
location: &WorkflowLocation,
|
||||
package_root: &Path,
|
||||
workflow: &Path,
|
||||
|
|
@ -263,6 +265,12 @@ fn workflow_files(
|
|||
for (path, file) in workflow.files {
|
||||
insert_file(&mut files, entrypoint, workflow_path(&path)?, file.content)?;
|
||||
}
|
||||
fabro_types::validate_workflow_source_paths(files.keys()).map_err(|source| {
|
||||
WorkflowVersionCollectError::InvalidShape {
|
||||
entrypoint: entrypoint.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,13 @@ pub type ToolResult<T> = Result<T, ToolError>;
|
|||
|
||||
#[async_trait]
|
||||
pub trait FabroToolBackend: Send + Sync {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
_params: crate::FabroWorkflowVersionCreateParams,
|
||||
) -> anyhow::Result<fabro_types::WorkflowVersionId> {
|
||||
anyhow::bail!("fabro_workflow_version_create is not available")
|
||||
}
|
||||
|
||||
async fn create_run_from_spec(
|
||||
&self,
|
||||
spec: &crate::ValidatedCreateRunSpec,
|
||||
|
|
@ -170,6 +177,7 @@ pub struct ToolDefinition {
|
|||
pub parameters: Value,
|
||||
}
|
||||
|
||||
pub const FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME: &str = "fabro_workflow_version_create";
|
||||
pub const FABRO_RUN_CREATE_TOOL_NAME: &str = "fabro_run_create";
|
||||
pub const FABRO_RUN_SEARCH_TOOL_NAME: &str = "fabro_run_search";
|
||||
pub const FABRO_RUN_GET_TOOL_NAME: &str = "fabro_run_get";
|
||||
|
|
@ -180,6 +188,10 @@ pub const FABRO_RUN_PAIR_TOOL_NAME: &str = "fabro_run_pair";
|
|||
|
||||
static TOOL_DEFINITIONS: LazyLock<Vec<ToolDefinition>> = LazyLock::new(|| {
|
||||
vec![
|
||||
tool_definition::<crate::FabroWorkflowVersionCreateParams>(
|
||||
FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME,
|
||||
"Register supplied workflow file contents and all local dependencies as a reusable immutable workflow version ID. Obtain files with shell/read tools first; this does not create or start a run.",
|
||||
),
|
||||
tool_definition::<crate::FabroRunCreateParams>(
|
||||
FABRO_RUN_CREATE_TOOL_NAME,
|
||||
"Create one or more Fabro workflow runs, optionally under a parent run, starting them by default.",
|
||||
|
|
@ -323,6 +335,7 @@ mod tests {
|
|||
#[test]
|
||||
fn shared_tool_definitions_include_run_management_catalog() {
|
||||
assert_eq!(shared_tool_names(), vec![
|
||||
FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME,
|
||||
FABRO_RUN_CREATE_TOOL_NAME,
|
||||
FABRO_RUN_SEARCH_TOOL_NAME,
|
||||
FABRO_RUN_GET_TOOL_NAME,
|
||||
|
|
@ -333,6 +346,21 @@ mod tests {
|
|||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_create_has_strict_content_schema() {
|
||||
let definition = tool_definitions()
|
||||
.iter()
|
||||
.find(|definition| definition.name == "fabro_workflow_version_create")
|
||||
.expect("workflow version creation should be in the shared catalog");
|
||||
let schema = &definition.parameters;
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
assert_eq!(schema["properties"].as_object().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
serde_json::json!(["entrypoint", "files"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_tool_definition_exposes_pair_schema() {
|
||||
let definition = tool_definitions()
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ use crate::{FabroToolBackend, RunManifestBuilder, ToolError};
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientBackend {
|
||||
client: Arc<::fabro_client::Client>,
|
||||
client: Arc<::fabro_client::Client>,
|
||||
manifest_builder: Option<Arc<dyn RunManifestBuilder>>,
|
||||
run_scope: Option<RunId>,
|
||||
run_scope: Option<RunId>,
|
||||
workflow_version_create_adapter: Option<Arc<dyn crate::WorkflowVersionCreateAdapter>>,
|
||||
}
|
||||
|
||||
impl ClientBackend {
|
||||
|
|
@ -24,6 +25,7 @@ impl ClientBackend {
|
|||
client,
|
||||
manifest_builder: None,
|
||||
run_scope: None,
|
||||
workflow_version_create_adapter: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +35,15 @@ impl ClientBackend {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_workflow_version_create_adapter(
|
||||
mut self,
|
||||
adapter: Arc<dyn crate::WorkflowVersionCreateAdapter>,
|
||||
) -> Self {
|
||||
self.workflow_version_create_adapter = Some(adapter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Restrict this backend to a single run.
|
||||
///
|
||||
/// Ask Fabro sessions use this with a same-run worker token so accidental
|
||||
|
|
@ -55,6 +66,21 @@ impl ClientBackend {
|
|||
|
||||
#[async_trait]
|
||||
impl FabroToolBackend for ClientBackend {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
params: crate::FabroWorkflowVersionCreateParams,
|
||||
) -> anyhow::Result<fabro_types::WorkflowVersionId> {
|
||||
anyhow::ensure!(
|
||||
self.run_scope.is_none(),
|
||||
"workflow version creation is outside this tool session's run scope"
|
||||
);
|
||||
let adapter = self
|
||||
.workflow_version_create_adapter
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("fabro_workflow_version_create is not available"))?;
|
||||
adapter.create_workflow_version(params, &self.client).await
|
||||
}
|
||||
|
||||
async fn create_run_from_spec(
|
||||
&self,
|
||||
spec: &crate::ValidatedCreateRunSpec,
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ mod interact;
|
|||
mod manifest;
|
||||
mod pair;
|
||||
mod search;
|
||||
mod workflow_version;
|
||||
|
||||
pub use common::{
|
||||
FABRO_RUN_CREATE_TOOL_NAME, FABRO_RUN_EVENTS_TOOL_NAME, FABRO_RUN_GATHER_TOOL_NAME,
|
||||
FABRO_RUN_GET_TOOL_NAME, FABRO_RUN_INTERACT_TOOL_NAME, FABRO_RUN_PAIR_TOOL_NAME,
|
||||
FABRO_RUN_SEARCH_TOOL_NAME, FabroToolBackend, RunManifestBuilder, RunSummaryResult,
|
||||
ToolDefinition, ToolError, ToolResult, tool_definitions,
|
||||
FABRO_RUN_SEARCH_TOOL_NAME, FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME, FabroToolBackend,
|
||||
RunManifestBuilder, RunSummaryResult, ToolDefinition, ToolError, ToolResult, tool_definitions,
|
||||
};
|
||||
pub use create::{
|
||||
CreateRunOptions, CreateRunSpec, CreateRunSpecInput, CreateRunsResult, CreatedRunResult,
|
||||
|
|
@ -47,3 +48,7 @@ pub use search::{
|
|||
FabroRunSearchParams, SearchRunSummaryResult, SearchRunsResult, ValidatedSearchRuns,
|
||||
search_runs, search_runs_text,
|
||||
};
|
||||
pub use workflow_version::{
|
||||
FabroWorkflowVersionCreateParams, WorkflowVersionCreateAdapter, create_workflow_version,
|
||||
workflow_version_create_text,
|
||||
};
|
||||
|
|
|
|||
234
lib/components/fabro-tool/src/workflow_version.rs
Normal file
234
lib/components/fabro-tool/src/workflow_version.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_api::types::CreateWorkflowVersionResponse;
|
||||
use fabro_types::{
|
||||
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES,
|
||||
WorkflowPath, WorkflowVersionId,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::de::{Error as _, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{FabroToolBackend, ToolError, ToolResult};
|
||||
|
||||
/// Caller-supplied source content, before packaging resolves workflow
|
||||
/// dependencies.
|
||||
#[derive(Clone, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FabroWorkflowVersionCreateParams {
|
||||
/// Exact package-relative key of the graph or workflow configuration file.
|
||||
#[schemars(with = "String")]
|
||||
pub entrypoint: WorkflowPath,
|
||||
/// All local dependencies, keyed by package-relative path. Values are text
|
||||
/// contents.
|
||||
#[serde(deserialize_with = "deserialize_files")]
|
||||
#[schemars(with = "BTreeMap<String, String>")]
|
||||
pub files: BTreeMap<WorkflowPath, String>,
|
||||
}
|
||||
|
||||
fn deserialize_files<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<WorkflowPath, String>, D::Error> {
|
||||
struct FilesVisitor;
|
||||
impl<'de> Visitor<'de> for FilesVisitor {
|
||||
type Value = BTreeMap<WorkflowPath, String>;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("workflow files with unique path keys and text contents")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut files = BTreeMap::new();
|
||||
while let Some((path, content)) = map.next_entry()? {
|
||||
if files.insert(path, content).is_some() {
|
||||
return Err(A::Error::custom("duplicate workflow file key"));
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(FilesVisitor)
|
||||
}
|
||||
|
||||
impl FabroWorkflowVersionCreateParams {
|
||||
/// Validate the complete supplied tree before any filesystem writes.
|
||||
pub fn validate(&self) -> ToolResult<()> {
|
||||
if !self.files.contains_key(&self.entrypoint) {
|
||||
return Err(ToolError::message(
|
||||
"entrypoint must be an exact supplied file key",
|
||||
));
|
||||
}
|
||||
if self.files.len() > MAX_WORKFLOW_VERSION_FILES {
|
||||
return Err(ToolError::message("workflow source exceeds 512 files"));
|
||||
}
|
||||
let mut total = 0;
|
||||
for content in self.files.values() {
|
||||
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
|
||||
return Err(ToolError::message("workflow source file exceeds 512 KiB"));
|
||||
}
|
||||
total += content.len();
|
||||
}
|
||||
if total > MAX_WORKFLOW_VERSION_BYTES {
|
||||
return Err(ToolError::message("workflow source exceeds 2 MiB"));
|
||||
}
|
||||
fabro_types::validate_workflow_source_paths(self.files.keys())
|
||||
.map_err(|_| ToolError::message("workflow source paths collide"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Application seam for packaging and registering content without a dependency
|
||||
/// cycle. Implementations must validate before staging, confine reads to
|
||||
/// supplied files, validate the entire closure before uploading, and register
|
||||
/// dependencies first.
|
||||
#[async_trait]
|
||||
pub trait WorkflowVersionCreateAdapter: Send + Sync {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
params: FabroWorkflowVersionCreateParams,
|
||||
client: &fabro_client::Client,
|
||||
) -> anyhow::Result<WorkflowVersionId>;
|
||||
}
|
||||
|
||||
pub async fn create_workflow_version(
|
||||
backend: Arc<dyn FabroToolBackend>,
|
||||
params: FabroWorkflowVersionCreateParams,
|
||||
) -> ToolResult<CreateWorkflowVersionResponse> {
|
||||
params.validate()?;
|
||||
let workflow_version_id = backend
|
||||
.create_workflow_version(params)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
Ok(CreateWorkflowVersionResponse {
|
||||
workflow_version_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_version_create_text(result: &CreateWorkflowVersionResponse) -> String {
|
||||
serde_json::to_string(result).expect("workflow version response should serialize")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::fabro_client::ClientBackend;
|
||||
|
||||
#[test]
|
||||
fn workflow_version_request_rejects_unknown_fields_and_invalid_paths() {
|
||||
let valid = json!({"entrypoint": "workflow", "files": {"workflow": "digraph W {}"}});
|
||||
let params: FabroWorkflowVersionCreateParams =
|
||||
serde_json::from_value(valid.clone()).unwrap();
|
||||
params.validate().unwrap();
|
||||
assert!(
|
||||
serde_json::from_str::<FabroWorkflowVersionCreateParams>(
|
||||
r#"{"entrypoint":"workflow","files":{"workflow":"a","workflow":"b"}}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for field in [
|
||||
"cwd",
|
||||
"url",
|
||||
"workflow",
|
||||
"environment",
|
||||
"parent_id",
|
||||
"workflow_dependencies",
|
||||
] {
|
||||
let mut value = valid.clone();
|
||||
value[field] = json!("unexpected");
|
||||
assert!(serde_json::from_value::<FabroWorkflowVersionCreateParams>(value).is_err());
|
||||
}
|
||||
for path in [
|
||||
"../workflow",
|
||||
"/workflow",
|
||||
"a/../workflow",
|
||||
"a//b",
|
||||
"a\\b",
|
||||
"~/workflow",
|
||||
"",
|
||||
] {
|
||||
for value in [
|
||||
json!({"entrypoint":path,"files":{"workflow":"x"}}),
|
||||
json!({"entrypoint":"workflow","files":{path:"x"}}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<FabroWorkflowVersionCreateParams>(value).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_source_enforces_presence_collisions_and_budgets() {
|
||||
for files in [
|
||||
json!({}),
|
||||
json!({"A":"x","a":"y"}),
|
||||
json!({"A":"x","a/b.md":"y"}),
|
||||
json!({"a":"x","A/b.md":"y"}),
|
||||
] {
|
||||
let mut files = files.as_object().unwrap().clone();
|
||||
files.insert("workflow".into(), json!("digraph W {}"));
|
||||
let mut params: FabroWorkflowVersionCreateParams =
|
||||
serde_json::from_value(json!({"entrypoint":"workflow","files":files})).unwrap();
|
||||
if params.files.len() == 1 {
|
||||
params.entrypoint = "missing".parse().unwrap();
|
||||
}
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
let mut params = FabroWorkflowVersionCreateParams {
|
||||
entrypoint: "workflow".parse().unwrap(),
|
||||
files: BTreeMap::from([(
|
||||
"workflow".parse().unwrap(),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES + 1),
|
||||
)]),
|
||||
};
|
||||
assert!(params.validate().is_err());
|
||||
params.files = (0..MAX_WORKFLOW_VERSION_FILES)
|
||||
.map(|i| (format!("file{i}").parse().unwrap(), String::new()))
|
||||
.collect();
|
||||
params
|
||||
.files
|
||||
.insert(params.entrypoint.clone(), String::new());
|
||||
assert!(params.validate().is_err());
|
||||
params.files = (0..5)
|
||||
.map(|i| {
|
||||
(
|
||||
format!("file{i}").parse().unwrap(),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
params
|
||||
.files
|
||||
.insert(params.entrypoint.clone(), String::new());
|
||||
assert!(params.validate().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_same_run_backend_denies_before_adapter() {
|
||||
let client = fabro_client::Client::new_no_proxy("http://127.0.0.1:1").unwrap();
|
||||
let backend = ClientBackend::new(Arc::new(client))
|
||||
.with_workflow_version_create_adapter(Arc::new(UnreachableAdapter))
|
||||
.with_run_scope("01KRBZW4DW0000000000000002".parse().unwrap());
|
||||
let params = serde_json::from_value(
|
||||
json!({"entrypoint":"workflow","files":{"workflow":"digraph W {}"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let error = create_workflow_version(Arc::new(backend), params)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.as_str().contains("run scope"));
|
||||
}
|
||||
|
||||
struct UnreachableAdapter;
|
||||
#[async_trait]
|
||||
impl WorkflowVersionCreateAdapter for UnreachableAdapter {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
_: FabroWorkflowVersionCreateParams,
|
||||
_: &fabro_client::Client,
|
||||
) -> anyhow::Result<WorkflowVersionId> {
|
||||
panic!("scoped backend must not invoke the adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,21 @@ impl WorkflowLocation {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve an exact file path without workflow-name or ambient config
|
||||
/// lookup. Relative paths are interpreted against the supplied
|
||||
/// directory only.
|
||||
pub fn from_exact_path(path: &Path, directory: &Path) -> Result<Self> {
|
||||
let path = directory.join(path);
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "toml")
|
||||
{
|
||||
Self::from_toml(path)
|
||||
} else {
|
||||
Ok(Self::from_graph(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn from_toml(toml_path: PathBuf) -> Result<Self> {
|
||||
let cfg = match run::load_run_config(&toml_path) {
|
||||
Ok(cfg) => cfg,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ strum.workspace = true
|
|||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
ulid.workspace = true
|
||||
unicase = "2"
|
||||
unicode-normalization = "0.1"
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -198,5 +198,6 @@ pub use workflow_path::{
|
|||
pub use workflow_version::{
|
||||
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES, MAX_WORKFLOW_VERSION_FILE_BYTES,
|
||||
MAX_WORKFLOW_VERSION_FILES, WorkflowVersion, WorkflowVersionShapeError,
|
||||
validate_workflow_source_paths,
|
||||
};
|
||||
pub use workflow_version_id::{WorkflowVersionId, WorkflowVersionIdParseError};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use std::marker::PhantomData;
|
|||
use serde::de::{Error as _, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use thiserror::Error;
|
||||
use unicase::UniCase;
|
||||
use unicode_normalization::UnicodeNormalization as _;
|
||||
|
||||
use crate::{BlobHash, WorkflowPath, WorkflowVersionId};
|
||||
|
||||
|
|
@ -149,31 +151,52 @@ impl WorkflowVersion {
|
|||
}
|
||||
|
||||
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 by_text =
|
||||
HashMap::with_capacity(self.files.len() + self.workflow_dependencies.len());
|
||||
for path in self.files.keys().chain(self.workflow_dependencies.keys()) {
|
||||
if let Some(existing) = by_text.insert(path.as_str(), path) {
|
||||
validate_path_collisions(
|
||||
self.files.keys().chain(self.workflow_dependencies.keys()),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject file and directory aliases before materializing a portable source
|
||||
/// tree, including Unicode case folding and normalization. Canonical versions
|
||||
/// themselves retain their exact, case-sensitive
|
||||
/// semantics.
|
||||
pub fn validate_workflow_source_paths<'a>(
|
||||
paths: impl IntoIterator<Item = &'a WorkflowPath>,
|
||||
) -> Result<(), WorkflowVersionShapeError> {
|
||||
validate_path_collisions(paths, true)
|
||||
}
|
||||
|
||||
fn validate_path_collisions<'a>(
|
||||
paths: impl IntoIterator<Item = &'a WorkflowPath>,
|
||||
case_insensitive: bool,
|
||||
) -> Result<(), WorkflowVersionShapeError> {
|
||||
let mut by_text = HashMap::new();
|
||||
for path in paths {
|
||||
let text = if case_insensitive {
|
||||
UniCase::new(path.as_str()).to_folded_case().nfc().collect()
|
||||
} else {
|
||||
path.as_str().to_owned()
|
||||
};
|
||||
if let Some(existing) = by_text.insert(text, path) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: existing.clone(),
|
||||
second: path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (text, path) in &by_text {
|
||||
for (index, _) in text.match_indices('/') {
|
||||
if let Some(ancestor) = by_text.get(&text[..index]) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: existing.clone(),
|
||||
second: path.clone(),
|
||||
first: (*ancestor).clone(),
|
||||
second: (*path).clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for path in self.files.keys().chain(self.workflow_dependencies.keys()) {
|
||||
let text = path.as_str();
|
||||
for (index, _) in text.match_indices('/') {
|
||||
if let Some(ancestor) = by_text.get(&text[..index]) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: (*ancestor).clone(),
|
||||
second: path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WorkflowVersion {
|
||||
|
|
@ -509,3 +532,37 @@ mod tests {
|
|||
assert!(serde_json::from_str::<WorkflowVersion>(duplicate).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workflow_source_collisions_are_portable_in_both_orders() {
|
||||
for pair in [
|
||||
["A", "a"],
|
||||
["A", "a/b.md"],
|
||||
["a", "A/b.md"],
|
||||
["é", "É/b"],
|
||||
["ΟΣ", "οσ/b"],
|
||||
["é", "e\u{301}/b"],
|
||||
["Straße", "STRASSE/b"],
|
||||
] {
|
||||
let paths = pair.map(|path| WorkflowPath::new(path).unwrap());
|
||||
assert!(validate_workflow_source_paths(paths.iter()).is_err());
|
||||
assert!(validate_workflow_source_paths(paths.iter().rev()).is_err());
|
||||
}
|
||||
let version = WorkflowVersion::new(
|
||||
WorkflowPath::new("A").unwrap(),
|
||||
BTreeMap::from([
|
||||
(WorkflowPath::new("A").unwrap(), "x".into()),
|
||||
(WorkflowPath::new("a").unwrap(), "y".into()),
|
||||
]),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
assert!(
|
||||
version.is_ok(),
|
||||
"canonical versions keep exact path semantics"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import type { WorkflowVersion } from '../models';
|
|||
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.
|
||||
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier. Requires an authenticated user or a worker token with the `agent:run_tools` capability. Ordinary worker tokens cannot register versions. Registration creates no run and starts no execution.
|
||||
* @summary Create Workflow Version
|
||||
* @param {WorkflowVersion} workflowVersion
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -83,7 +83,7 @@ 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.
|
||||
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier. Requires an authenticated user or a worker token with the `agent:run_tools` capability. Ordinary worker tokens cannot register versions. Registration creates no run and starts no execution.
|
||||
* @summary Create Workflow Version
|
||||
* @param {WorkflowVersion} workflowVersion
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -105,7 +105,7 @@ export const WorkflowVersionsApiFactory = function (configuration?: Configuratio
|
|||
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.
|
||||
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier. Requires an authenticated user or a worker token with the `agent:run_tools` capability. Ordinary worker tokens cannot register versions. Registration creates no run and starts no execution.
|
||||
* @summary Create Workflow Version
|
||||
* @param {WorkflowVersion} workflowVersion
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -122,7 +122,7 @@ export const WorkflowVersionsApiFactory = function (configuration?: Configuratio
|
|||
*/
|
||||
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.
|
||||
* Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier. Requires an authenticated user or a worker token with the `agent:run_tools` capability. Ordinary worker tokens cannot register versions. Registration creates no run and starts no execution.
|
||||
* @summary Create Workflow Version
|
||||
* @param {WorkflowVersion} workflowVersion
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue