mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Merge pull request #851 from fabro-sh/codex/workflow-version-create-tool
Add fabro_workflow_version_create to agent tools and MCP
This commit is contained in:
commit
bfcf82e367
31 changed files with 1989 additions and 106 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -2754,12 +2754,14 @@ name = "fabro-manifest"
|
|||
version = "0.354.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"fabro-api",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-tool",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"fabro-workflow",
|
||||
|
|
@ -2770,7 +2772,10 @@ dependencies = [
|
|||
"temp-env",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3168,7 +3173,9 @@ dependencies = [
|
|||
"fabro-client",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"fabro-workflow-version",
|
||||
"futures",
|
||||
"httpmock",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -3214,6 +3221,8 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"toml 0.8.23",
|
||||
"ulid",
|
||||
"unicase",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
@ -3304,6 +3313,7 @@ dependencies = [
|
|||
"fabro-api",
|
||||
"fabro-auth",
|
||||
"fabro-checkpoint",
|
||||
"fabro-client",
|
||||
"fabro-config",
|
||||
"fabro-core",
|
||||
"fabro-dump",
|
||||
|
|
@ -3328,6 +3338,7 @@ dependencies = [
|
|||
"fabro-validate",
|
||||
"fabro-vault",
|
||||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures",
|
||||
"git2",
|
||||
"hex",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ console = "0.15"
|
|||
dialoguer = "0.12"
|
||||
git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2", "vendored-openssl", "https"] }
|
||||
tracing = "0.1"
|
||||
unicase = "2"
|
||||
unicode-normalization = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
rmcp = { version = "1.4", default-features = false }
|
||||
|
|
|
|||
|
|
@ -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,48 @@ 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. Graph nesting through child workflows and imports
|
||||
is limited to 64 levels, including the entrypoint. A supplied sibling
|
||||
`workflow.toml` must be valid even when a graph is the entrypoint; a valid config
|
||||
that selects another graph is omitted. 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use fabro_interview::{
|
|||
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlDeliveryFrame, WorkerControlEnvelope,
|
||||
WorkerControlMessage,
|
||||
};
|
||||
use fabro_manifest::SuppliedWorkflowVersionPackager;
|
||||
use fabro_server::run_tool_manifest;
|
||||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
|
|
@ -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_packager(Arc::new(SuppliedWorkflowVersionPackager));
|
||||
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,27 @@ 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 `workflow` is not present in workflow files"
|
||||
);
|
||||
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_manifest::SuppliedWorkflowVersionPackager;
|
||||
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,28 @@ 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> {
|
||||
let source = match run_tools::ValidatedWorkflowVersionCreate::try_from(params.0) {
|
||||
Ok(source) => source,
|
||||
Err(err) => 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, source).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 +275,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_packager(Arc::new(
|
||||
SuppliedWorkflowVersionPackager,
|
||||
)),
|
||||
) as Arc<dyn FabroToolBackend>
|
||||
})
|
||||
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
|
||||
|
|
@ -283,6 +309,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 +318,63 @@ 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 params =
|
||||
serde_json::json!({"entrypoint":"workflow","files":{"workflow":"digraph W {}"}});
|
||||
let result = server
|
||||
.fabro_workflow_version_create(Parameters(serde_json::from_value(params).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 {
|
||||
|
|
|
|||
|
|
@ -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,77 @@ 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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,22 +14,27 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
fabro-api = { path = "../../foundation/fabro-api" }
|
||||
fabro-config = { path = "../../foundation/fabro-config" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-template = { path = "../../foundation/fabro-template" }
|
||||
fabro-tool = { path = "../fabro-tool" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
fabro-workflow = { path = "../fabro-workflow" }
|
||||
fabro-workflow-version = { path = "../fabro-workflow-version" }
|
||||
git2.workspace = true
|
||||
tempfile = "3"
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fabro-test.workspace = true
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
tracing-subscriber.workspace = true
|
||||
insta.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
temp-env = "0.3"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@
|
|||
)]
|
||||
|
||||
mod local_workflow_package;
|
||||
mod supplied_workflow;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod workflow_bundler;
|
||||
mod workflow_version_collector;
|
||||
mod workflow_version_packager;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
|
@ -30,14 +34,18 @@ use fabro_types::{
|
|||
WorkflowSettings,
|
||||
};
|
||||
use fabro_workflow::git::{self, GitSyncStatus};
|
||||
pub use fabro_workflow_version::CollectedWorkflowClosure;
|
||||
|
||||
pub use crate::local_workflow_package::{
|
||||
LocalWorkflowPackageError, ResolvedLocalWorkflowPackage, resolve_local_workflow_package,
|
||||
};
|
||||
pub use crate::supplied_workflow::collect_supplied_workflow_versions;
|
||||
use crate::workflow_bundler::WorkflowBundler;
|
||||
pub use crate::workflow_version_collector::{
|
||||
CollectedWorkflowClosure, WorkflowVersionCollectError, collect_workflow_versions,
|
||||
MAX_WORKFLOW_VERSION_DEPTH, WorkflowVersionCollectError, collect_workflow_versions,
|
||||
collect_workflow_versions_at_location,
|
||||
};
|
||||
pub use crate::workflow_version_packager::SuppliedWorkflowVersionPackager;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManifestBuildInput {
|
||||
|
|
|
|||
439
lib/components/fabro-manifest/src/supplied_workflow.rs
Normal file
439
lib/components/fabro-manifest/src/supplied_workflow.rs
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
//! Package workflow versions from caller-supplied file contents instead of a
|
||||
//! checkout on disk.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_types::WorkflowPath;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::{CollectedWorkflowClosure, WorkflowVersionCollectError};
|
||||
|
||||
type Result<T> = std::result::Result<T, WorkflowVersionCollectError>;
|
||||
|
||||
/// Stage `files` in a private temporary directory and collect the workflow
|
||||
/// closure rooted at `entrypoint` with the same collector used for checkouts.
|
||||
/// Only supplied files can satisfy references; the staging directory is
|
||||
/// removed on every return path. Every dependency is validated before this
|
||||
/// returns and nothing is registered.
|
||||
pub fn collect_supplied_workflow_versions(
|
||||
entrypoint: &WorkflowPath,
|
||||
files: &BTreeMap<WorkflowPath, String>,
|
||||
) -> Result<CollectedWorkflowClosure> {
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("fabro-workflow-version-")
|
||||
.tempdir()
|
||||
.map_err(stage_error)?;
|
||||
collect_in_staging(entrypoint, files, &staging)
|
||||
}
|
||||
|
||||
fn stage_error(source: std::io::Error) -> WorkflowVersionCollectError {
|
||||
WorkflowVersionCollectError::Stage { source }
|
||||
}
|
||||
|
||||
fn collect_in_staging(
|
||||
entrypoint: &WorkflowPath,
|
||||
files: &BTreeMap<WorkflowPath, String>,
|
||||
staging: &TempDir,
|
||||
) -> Result<CollectedWorkflowClosure> {
|
||||
let root = staging.path().canonicalize().map_err(stage_error)?;
|
||||
for (path, contents) in files {
|
||||
let destination = root.join(path.as_str());
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(stage_error)?;
|
||||
}
|
||||
std::fs::write(destination, contents).map_err(stage_error)?;
|
||||
}
|
||||
let entrypoint = Path::new(entrypoint.as_str());
|
||||
let location =
|
||||
WorkflowLocation::from_exact_path(entrypoint, &root).map_err(|source| match source {
|
||||
fabro_config::Error::WorkflowNotFound(_) => {
|
||||
WorkflowVersionCollectError::WorkflowNotFound {
|
||||
path: entrypoint.to_path_buf(),
|
||||
}
|
||||
}
|
||||
source => WorkflowVersionCollectError::Collect {
|
||||
path: entrypoint.to_path_buf(),
|
||||
source: source.into(),
|
||||
},
|
||||
})?;
|
||||
let closure = crate::collect_workflow_versions_at_location(&location, &root, entrypoint)?;
|
||||
for (_, version) in closure.versions() {
|
||||
confine_to_supplied(version.version(), files, &root)?;
|
||||
}
|
||||
Ok(closure)
|
||||
}
|
||||
|
||||
/// The collector resolves references against the staged tree, so its result
|
||||
/// can depend on the host filesystem's case and normalization rules. Reject
|
||||
/// every version whose collected files are not exactly the supplied keys, and
|
||||
/// pin the one implicit probe (the sibling `workflow.toml`) to the supplied
|
||||
/// map so the same request packages identically on every host.
|
||||
fn confine_to_supplied(
|
||||
version: &fabro_types::WorkflowVersion,
|
||||
files: &BTreeMap<WorkflowPath, String>,
|
||||
root: &Path,
|
||||
) -> Result<()> {
|
||||
// A supplied sibling config attaches only when its `[workflow].graph`
|
||||
// selects this entrypoint, exactly as for a checkout; several graphs may
|
||||
// share one directory. When no exact sibling was supplied, the probe must
|
||||
// not find one either.
|
||||
let config_path = version.config_path();
|
||||
if !files.contains_key(&config_path) {
|
||||
let alias = files.keys().find(|path| {
|
||||
fabro_types::validate_workflow_source_paths([*path, &config_path]).is_err()
|
||||
});
|
||||
if let Some(alias) = alias {
|
||||
// A case-insensitive host would probe this key as the config and
|
||||
// a case-sensitive one would not; neither outcome is what was
|
||||
// asked for.
|
||||
return Err(WorkflowVersionCollectError::ConfigAlias {
|
||||
config_path,
|
||||
alias: alias.clone(),
|
||||
});
|
||||
}
|
||||
} else if !version.files().contains_key(&config_path) {
|
||||
// Graph discovery deliberately ignores sibling configs that it cannot
|
||||
// load. A supplied config may be omitted only if it is valid and selects
|
||||
// a different graph; malformed settings must not silently disappear.
|
||||
WorkflowLocation::from_exact_path(Path::new(config_path.as_str()), root).map_err(
|
||||
|source| WorkflowVersionCollectError::InvalidSuppliedConfig {
|
||||
path: config_path,
|
||||
source: Box::new(source),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
// A case-insensitive host must not satisfy a reference that is missing
|
||||
// from the supplied tree under its exact key.
|
||||
for path in version.files().keys() {
|
||||
if !files.contains_key(path) {
|
||||
return Err(WorkflowVersionCollectError::NotSupplied { path: path.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_tool::ValidatedWorkflowVersionCreate as Supplied;
|
||||
use fabro_util::error::collect_chain;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{fixture, source as supplied};
|
||||
|
||||
fn collect(input: &Supplied) -> CollectedWorkflowClosure {
|
||||
collect_supplied_workflow_versions(&input.entrypoint, &input.files).unwrap()
|
||||
}
|
||||
|
||||
/// Consumes `staging` so the tests can assert cleanup after return.
|
||||
fn collect_with_staging(
|
||||
input: &Supplied,
|
||||
staging: TempDir,
|
||||
) -> Result<CollectedWorkflowClosure> {
|
||||
let result = collect_in_staging(&input.entrypoint, &input.files, &staging);
|
||||
drop(staging);
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplied_content_matches_checkout_collector_and_cleans_staging() {
|
||||
for input in [
|
||||
supplied("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 = crate::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_with_staging(&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 exact_extensionless_entrypoint_and_child_ignore_selectors() {
|
||||
let input = supplied("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 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();
|
||||
// Malformed on purpose: a parser that reaches this file would quote it.
|
||||
std::fs::write(
|
||||
parent.path().join("secret.toml"),
|
||||
"HOST_SECRET = [unterminated",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
parent.path().join("workflow.toml"),
|
||||
"HOST_SECRET = [unterminated",
|
||||
)
|
||||
.unwrap();
|
||||
for (index, input) in [
|
||||
supplied("workflow.fabro", &[
|
||||
("workflow.fabro", r#"digraph W { p [prompt="@prompt.md"] }"#),
|
||||
("Prompt.md", "wrong case"),
|
||||
]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@../outside.md"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@sub/../../outside.md"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [prompt="@outside.md"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [output_schema="@../outside.md"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="../child.fabro"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="sub/../../child.fabro"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="../secret.toml"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="../graph.fabro"] }"#,
|
||||
)]),
|
||||
supplied("workflow.fabro", &[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { p [stack.child_workflow="missing"] }"#,
|
||||
)]),
|
||||
supplied("workflow.toml", &[(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"../child.fabro\"\n",
|
||||
)]),
|
||||
supplied("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();
|
||||
let error = collect_with_staging(&input, staging)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("accepted invalid fixture {index}"));
|
||||
let rendered = collect_chain(&error).join(": ");
|
||||
// Escaping references must fail before any host file is opened,
|
||||
// so no host diagnostic (parse error, exists-vs-missing) leaks.
|
||||
assert!(
|
||||
!rendered.contains("HOST_SECRET") && !rendered.contains("secret.toml:"),
|
||||
"fixture {index} read a host file: {rendered}"
|
||||
);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_entrypoint_must_be_workflow_toml_beside_its_graph() {
|
||||
let config = "_version = 1\n[workflow]\ngraph = \"g.fabro\"\n[run]\ngoal = \"hello\"\n";
|
||||
let accepted = supplied("sub/workflow.toml", &[
|
||||
("sub/workflow.toml", config),
|
||||
("sub/g.fabro", "digraph W {}"),
|
||||
]);
|
||||
let root = collect(&accepted)
|
||||
.versions()
|
||||
.last()
|
||||
.unwrap()
|
||||
.1
|
||||
.version()
|
||||
.clone();
|
||||
assert!(root.files().contains_key(&root.config_path()));
|
||||
|
||||
// Same tree under another config name: runtime would never read it.
|
||||
let renamed = supplied("sub/run.toml", &[
|
||||
("sub/run.toml", config),
|
||||
("sub/g.fabro", "digraph W {}"),
|
||||
]);
|
||||
let error =
|
||||
collect_supplied_workflow_versions(&renamed.entrypoint, &renamed.files).unwrap_err();
|
||||
let rendered = collect_chain(&error).join(": ");
|
||||
assert!(
|
||||
rendered.contains("must be `sub/workflow.toml`"),
|
||||
"{rendered}"
|
||||
);
|
||||
// A config that selects a graph in another directory is not its sibling.
|
||||
let elsewhere = supplied("sub/workflow.toml", &[
|
||||
(
|
||||
"sub/workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"../g.fabro\"\n",
|
||||
),
|
||||
("g.fabro", "digraph W {}"),
|
||||
]);
|
||||
assert!(
|
||||
collect_supplied_workflow_versions(&elsewhere.entrypoint, &elsewhere.files).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_config_resolution_does_not_depend_on_the_host_filesystem() {
|
||||
let graph = r#"digraph W { child [stack.child_workflow="sub/child.fabro"] }"#;
|
||||
let selects = |target: &str| format!("_version = 1\n[workflow]\ngraph = \"{target}\"\n");
|
||||
|
||||
// Exact sibling configs attach to the root and to the child.
|
||||
let attached = supplied("workflow.fabro", &[
|
||||
("workflow.fabro", graph),
|
||||
("workflow.toml", &selects("workflow.fabro")),
|
||||
("sub/child.fabro", "digraph Child {}"),
|
||||
("sub/workflow.toml", &selects("child.fabro")),
|
||||
]);
|
||||
for (_, version) in collect(&attached).versions() {
|
||||
let version = version.version();
|
||||
assert!(
|
||||
version.files().contains_key(&version.config_path()),
|
||||
"{} lost its config",
|
||||
version.entrypoint()
|
||||
);
|
||||
}
|
||||
|
||||
// A case variant of the implicit probe name is rejected everywhere,
|
||||
// instead of attaching on APFS and vanishing on ext4.
|
||||
for (entrypoint, files) in [
|
||||
("workflow.fabro", vec![
|
||||
("workflow.fabro", graph.to_owned()),
|
||||
("Workflow.toml", selects("workflow.fabro")),
|
||||
("sub/child.fabro", "digraph Child {}".to_owned()),
|
||||
]),
|
||||
("workflow.fabro", vec![
|
||||
("workflow.fabro", graph.to_owned()),
|
||||
("sub/child.fabro", "digraph Child {}".to_owned()),
|
||||
("sub/WORKFLOW.toml", selects("child.fabro")),
|
||||
]),
|
||||
] {
|
||||
let files = files
|
||||
.iter()
|
||||
.map(|(path, content)| (*path, content.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let input = supplied(entrypoint, &files);
|
||||
let error =
|
||||
collect_supplied_workflow_versions(&input.entrypoint, &input.files).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkflowVersionCollectError::ConfigAlias { .. }),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
// Several graphs may share a directory: a sibling config attaches only
|
||||
// to the graph it selects, as for a checkout.
|
||||
let shared_dir = supplied("workflow.fabro", &[
|
||||
("workflow.fabro", graph),
|
||||
("sub/child.fabro", "digraph Child {}"),
|
||||
("sub/other.fabro", "digraph Other {}"),
|
||||
("sub/workflow.toml", &selects("other.fabro")),
|
||||
]);
|
||||
let closure = collect(&shared_dir);
|
||||
let (_, child) = closure.versions().next().unwrap();
|
||||
assert_eq!(child.version().entrypoint().as_str(), "sub/child.fabro");
|
||||
assert!(
|
||||
!child
|
||||
.version()
|
||||
.files()
|
||||
.contains_key(&child.version().config_path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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 = supplied("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 map_order_is_irrelevant_and_reachable_changes_change_ids() {
|
||||
let input = fixture();
|
||||
let first = collect(&input);
|
||||
let mut reordered = Supplied {
|
||||
entrypoint: input.entrypoint.clone(),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/components/fabro-manifest/src/test_support.rs
Normal file
29
lib/components/fabro-manifest/src/test_support.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use fabro_tool::ValidatedWorkflowVersionCreate;
|
||||
|
||||
pub(super) fn source(entrypoint: &str, files: &[(&str, &str)]) -> ValidatedWorkflowVersionCreate {
|
||||
ValidatedWorkflowVersionCreate {
|
||||
entrypoint: entrypoint.parse().unwrap(),
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, content)| (path.parse().unwrap(), (*content).to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fixture() -> ValidatedWorkflowVersionCreate {
|
||||
source("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 {}"),
|
||||
])
|
||||
}
|
||||
|
|
@ -17,7 +17,10 @@ use fabro_template::{
|
|||
use fabro_types::ManifestPath;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
||||
use crate::{manifest_path_from_absolute, normalize_absolute_path};
|
||||
use crate::{
|
||||
WorkflowVersionCollectError, manifest_path_from_absolute, normalize_absolute_path,
|
||||
workflow_version_collector,
|
||||
};
|
||||
|
||||
pub(super) struct WorkflowBundler<'a> {
|
||||
package_root: &'a Path,
|
||||
|
|
@ -55,7 +58,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
workflow: &Path,
|
||||
project_config: Option<(&ManifestPath, &str)>,
|
||||
) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let root_key = self.collect_workflow_entry(workflow, self.package_root)?;
|
||||
let root_key = self.collect_workflow_entry(workflow, self.package_root, 1)?;
|
||||
|
||||
if let Some((config_path, source)) = project_config {
|
||||
let mut root = self
|
||||
|
|
@ -80,7 +83,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
root: &WorkflowLocation,
|
||||
) -> Result<CollectedWorkflowSources> {
|
||||
self.workflow_version_projection = true;
|
||||
let root_key = self.collect_workflow_location(root)?;
|
||||
let root_key = self.collect_workflow_location(root, 1)?;
|
||||
Ok(CollectedWorkflowSources {
|
||||
root_key,
|
||||
workflows: self.workflows,
|
||||
|
|
@ -88,18 +91,38 @@ impl<'a> WorkflowBundler<'a> {
|
|||
}
|
||||
|
||||
/// Collects the workflow at `location` and returns its manifest key.
|
||||
fn collect_workflow_location(&mut self, location: &WorkflowLocation) -> Result<String> {
|
||||
fn collect_workflow_location(
|
||||
&mut self,
|
||||
location: &WorkflowLocation,
|
||||
depth: usize,
|
||||
) -> Result<String> {
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, self.package_root)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !self.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(dot_key);
|
||||
}
|
||||
if self.workflow_version_projection {
|
||||
workflow_version_collector::check_workflow_depth(depth, &dot_key)?;
|
||||
}
|
||||
|
||||
let source = self.read_package_file(&location.graph)?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
let config_path = manifest_path_from_absolute(workflow_toml_path, self.package_root)?;
|
||||
if self.workflow_version_projection {
|
||||
// A version's config is read at run time from the fixed
|
||||
// sibling path only, so a config file under any other name
|
||||
// would be registered and then silently ignored.
|
||||
let expected = dot_path.parent_or_dot().join("workflow.toml");
|
||||
if config_path.as_path() != expected {
|
||||
bail!(
|
||||
"workflow configuration `{config_path}` must be `{}` beside its graph \
|
||||
`{dot_path}`",
|
||||
expected.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, self.package_root)?
|
||||
.to_string(),
|
||||
path: config_path.to_string(),
|
||||
source: self.read_package_file(workflow_toml_path)?,
|
||||
})
|
||||
} else {
|
||||
|
|
@ -125,6 +148,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
&mut visited_imports,
|
||||
&mut dependency_keys,
|
||||
GraphPosition::Entrypoint,
|
||||
depth,
|
||||
)?;
|
||||
|
||||
self.workflows
|
||||
|
|
@ -143,9 +167,18 @@ impl<'a> WorkflowBundler<'a> {
|
|||
/// Relative workflow references with an extension are lexically
|
||||
/// normalized (`..` segments resolved without consulting the filesystem,
|
||||
/// `~` 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() {
|
||||
/// key. Workflow-version projection normalizes every reference and
|
||||
/// resolves it as an exact path inside the package root, with no
|
||||
/// workflow-name lookup. Returns the collected workflow's manifest key.
|
||||
fn collect_workflow_entry(
|
||||
&mut self,
|
||||
workflow: &Path,
|
||||
resolve_from: &Path,
|
||||
depth: usize,
|
||||
) -> Result<String> {
|
||||
let normalize = self.workflow_version_projection
|
||||
|| (workflow.extension().is_some() && workflow.is_relative());
|
||||
let normalized = if normalize {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
|
|
@ -155,8 +188,23 @@ impl<'a> WorkflowBundler<'a> {
|
|||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
self.collect_workflow_location(&location)
|
||||
let location = if self.workflow_version_projection {
|
||||
// Location resolution probes and parses config files, so reject
|
||||
// references that leave the package root before it can touch a
|
||||
// host file. `ManifestPath::from_absolute` accepts `..`-prefixed
|
||||
// results and is not a containment check.
|
||||
if !normalized.starts_with(self.package_root) {
|
||||
bail!(
|
||||
"workflow reference `{}` escapes source root `{}`",
|
||||
workflow.display(),
|
||||
self.package_root.display()
|
||||
);
|
||||
}
|
||||
WorkflowLocation::from_exact_path(&normalized, self.package_root)?
|
||||
} else {
|
||||
WorkflowLocation::resolve(&normalized, resolve_from)?
|
||||
};
|
||||
self.collect_workflow_location(&location, depth)
|
||||
}
|
||||
|
||||
fn collect_workflow_files(
|
||||
|
|
@ -166,7 +214,14 @@ impl<'a> WorkflowBundler<'a> {
|
|||
visited_imports: &mut HashSet<String>,
|
||||
dependency_keys: &mut BTreeSet<String>,
|
||||
position: GraphPosition,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if self.workflow_version_projection {
|
||||
workflow_version_collector::check_workflow_depth(
|
||||
depth,
|
||||
&workflow.dot_path.to_string(),
|
||||
)?;
|
||||
}
|
||||
let graph = parser::parse(&workflow.source)
|
||||
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
|
||||
let workflow_base_dir = workflow
|
||||
|
|
@ -264,12 +319,13 @@ impl<'a> WorkflowBundler<'a> {
|
|||
visited_imports,
|
||||
dependency_keys,
|
||||
GraphPosition::Imported,
|
||||
depth + 1,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
let dependency_key =
|
||||
self.collect_workflow_entry(Path::new(child), workflow_base_dir)?;
|
||||
self.collect_workflow_entry(Path::new(child), workflow_base_dir, depth + 1)?;
|
||||
dependency_keys.insert(dependency_key);
|
||||
}
|
||||
|
||||
|
|
@ -480,11 +536,18 @@ impl<'a> WorkflowBundler<'a> {
|
|||
return std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()));
|
||||
}
|
||||
let canonical = path.canonicalize().with_context(|| {
|
||||
format!(
|
||||
let canonical = path.canonicalize().map_err(|source| {
|
||||
if source.kind() == std::io::ErrorKind::NotFound {
|
||||
let path = ManifestPath::from_absolute(path, self.package_root)
|
||||
.map_or_else(|| path.display().to_string(), |path| path.to_string());
|
||||
return anyhow::Error::new(WorkflowVersionCollectError::MissingPackageFile {
|
||||
path,
|
||||
});
|
||||
}
|
||||
anyhow::Error::new(source).context(format!(
|
||||
"failed to canonicalize workflow package file `{}`",
|
||||
path.display()
|
||||
)
|
||||
))
|
||||
})?;
|
||||
if !canonical.starts_with(self.package_root) {
|
||||
bail!(
|
||||
|
|
|
|||
|
|
@ -7,34 +7,34 @@ use fabro_types::{
|
|||
WorkflowPath, WorkflowPathParseError, WorkflowVersion, WorkflowVersionId,
|
||||
WorkflowVersionShapeError,
|
||||
};
|
||||
use fabro_workflow_version::{ValidatedWorkflowVersion, WorkflowVersionError};
|
||||
use fabro_workflow_version::{
|
||||
CollectedWorkflowClosure, ValidatedWorkflowVersion, WorkflowVersionError,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::workflow_bundler::{CollectedWorkflowSource, CollectedWorkflowSources, WorkflowBundler};
|
||||
|
||||
/// One locally packaged workflow-version closure in dependency-first order.
|
||||
#[derive(Debug)]
|
||||
pub struct CollectedWorkflowClosure {
|
||||
root_id: WorkflowVersionId,
|
||||
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
|
||||
}
|
||||
/// Maximum active graph nesting while collecting or assembling a version.
|
||||
/// Bounds native stack use independently of file-count and byte budgets.
|
||||
pub const MAX_WORKFLOW_VERSION_DEPTH: usize = 64;
|
||||
|
||||
impl CollectedWorkflowClosure {
|
||||
#[must_use]
|
||||
pub fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id
|
||||
}
|
||||
|
||||
/// Iterate over every unique version with dependencies before parents.
|
||||
pub fn versions(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (WorkflowVersionId, &ValidatedWorkflowVersion)> + '_ {
|
||||
self.versions.iter().map(|(id, version)| (*id, version))
|
||||
pub(super) fn check_workflow_depth(
|
||||
depth: usize,
|
||||
path: &str,
|
||||
) -> Result<(), WorkflowVersionCollectError> {
|
||||
if depth > MAX_WORKFLOW_VERSION_DEPTH {
|
||||
return Err(WorkflowVersionCollectError::DepthExceeded {
|
||||
path: path.to_owned(),
|
||||
maximum: MAX_WORKFLOW_VERSION_DEPTH,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionCollectError {
|
||||
#[error("workflow dependency nesting at `{path}` exceeds {maximum} levels")]
|
||||
DepthExceeded { path: String, maximum: usize },
|
||||
#[error("workflow `{path}` was not found")]
|
||||
WorkflowNotFound { path: PathBuf },
|
||||
#[error("failed to collect workflow `{path}`")]
|
||||
|
|
@ -70,6 +70,34 @@ pub enum WorkflowVersionCollectError {
|
|||
DependencyCycle { path: WorkflowPath },
|
||||
#[error("collected workflow dependency `{path}` is missing")]
|
||||
MissingWorkflow { path: String },
|
||||
/// A referenced file is absent from the package root. Surfaced separately
|
||||
/// from [`Self::Collect`] because the path is the whole message.
|
||||
#[error("referenced file `{path}` is missing from the workflow source")]
|
||||
MissingPackageFile { path: String },
|
||||
/// Supplied-content packaging: the collector read a file whose exact key
|
||||
/// the caller did not supply (a case-insensitive host satisfied a
|
||||
/// reference that differs from the supplied key).
|
||||
#[error("collected file `{path}` was not supplied")]
|
||||
NotSupplied { path: WorkflowPath },
|
||||
/// Supplied-content packaging: a supplied key differs from the implicit
|
||||
/// sibling config name `{config_path}` only by case or normalization, so
|
||||
/// hosts with different filesystem rules would package different trees.
|
||||
#[error("supplied file `{alias}` aliases the workflow config name `{config_path}`")]
|
||||
ConfigAlias {
|
||||
config_path: WorkflowPath,
|
||||
alias: WorkflowPath,
|
||||
},
|
||||
#[error("supplied workflow configuration `{path}` is invalid")]
|
||||
InvalidSuppliedConfig {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: Box<fabro_config::Error>,
|
||||
},
|
||||
#[error("failed to stage supplied workflow files")]
|
||||
Stage {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Package one workflow and every separately runnable dependency from a local
|
||||
|
|
@ -137,7 +165,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,
|
||||
|
|
@ -145,9 +175,13 @@ pub(super) fn collect_workflow_versions_at_location(
|
|||
let inputs = HashMap::new();
|
||||
let collected = WorkflowBundler::new(package_root, &inputs)
|
||||
.collect_versions(location)
|
||||
.map_err(|source| WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source,
|
||||
.map_err(|source| {
|
||||
source
|
||||
.downcast::<WorkflowVersionCollectError>()
|
||||
.unwrap_or_else(|source| WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
})?;
|
||||
VersionAssembler::new(collected).assemble()
|
||||
}
|
||||
|
|
@ -185,10 +219,10 @@ impl VersionAssembler {
|
|||
fn assemble(mut self) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
|
||||
let root_key = std::mem::take(&mut self.root_key);
|
||||
let root_id = self.assemble_one(&root_key)?;
|
||||
Ok(CollectedWorkflowClosure {
|
||||
Ok(CollectedWorkflowClosure::from_dependency_order(
|
||||
root_id,
|
||||
versions: self.versions,
|
||||
})
|
||||
self.versions,
|
||||
))
|
||||
}
|
||||
|
||||
fn assemble_one(
|
||||
|
|
@ -198,6 +232,7 @@ impl VersionAssembler {
|
|||
if let Some(id) = self.ids.get(key) {
|
||||
return Ok(*id);
|
||||
}
|
||||
check_workflow_depth(self.visiting.len() + 1, key)?;
|
||||
if !self.visiting.insert(key.to_owned()) {
|
||||
return Err(WorkflowVersionCollectError::DependencyCycle {
|
||||
path: workflow_path(key)?,
|
||||
|
|
@ -303,6 +338,44 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_assembly_bounds_its_own_dependency_traversal() {
|
||||
// Collection and assembly visit shared dependencies in different orders.
|
||||
// Assembly must bound its stack even if collection already cached nodes.
|
||||
for count in [MAX_WORKFLOW_VERSION_DEPTH, MAX_WORKFLOW_VERSION_DEPTH + 1] {
|
||||
let workflows = (0..count)
|
||||
.map(|index| {
|
||||
let child = (index + 1 < count).then(|| format!("f{}.fabro", index + 1));
|
||||
let source = child.as_ref().map_or_else(
|
||||
|| "digraph W {}".to_owned(),
|
||||
|child| format!("digraph W {{ child [stack.child_workflow=\"{child}\"] }}"),
|
||||
);
|
||||
(format!("f{index}.fabro"), CollectedWorkflowSource {
|
||||
workflow: types::ManifestWorkflow {
|
||||
config: None,
|
||||
files: HashMap::new(),
|
||||
source,
|
||||
},
|
||||
dependency_keys: child.into_iter().collect(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let result = VersionAssembler::new(CollectedWorkflowSources {
|
||||
root_key: "f0.fabro".to_owned(),
|
||||
workflows,
|
||||
})
|
||||
.assemble();
|
||||
if count == MAX_WORKFLOW_VERSION_DEPTH {
|
||||
assert_eq!(result.unwrap().versions().count(), count);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(WorkflowVersionCollectError::DepthExceeded { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(root: &Path, path: &str, content: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().expect("fixture path should have a parent")).unwrap();
|
||||
|
|
|
|||
349
lib/components/fabro-manifest/src/workflow_version_packager.rs
Normal file
349
lib/components/fabro-manifest/src/workflow_version_packager.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! Application adapter that packages caller-supplied workflow contents for
|
||||
//! the `fabro_workflow_version_create` tool.
|
||||
|
||||
use anyhow::Context as _;
|
||||
use async_trait::async_trait;
|
||||
use fabro_tool::{ToolError, ValidatedWorkflowVersionCreate, WorkflowVersionPackager};
|
||||
use fabro_util::error::collect_chain;
|
||||
use fabro_workflow_version::{CollectedWorkflowClosure, WorkflowVersionError};
|
||||
use tokio::task;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::WorkflowVersionCollectError;
|
||||
|
||||
/// Packages supplied workflow contents for standalone MCP and capable run
|
||||
/// workers; the backend that owns the API client performs registration.
|
||||
pub struct SuppliedWorkflowVersionPackager;
|
||||
|
||||
const PACKAGING_HINT: &str = "check configuration, syntax, local references, and package limits";
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowVersionPackager for SuppliedWorkflowVersionPackager {
|
||||
async fn package(
|
||||
&self,
|
||||
source: ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure> {
|
||||
let packaged = task::spawn_blocking(move || package_blocking(&source))
|
||||
.await
|
||||
.context("workflow packaging task failed")??;
|
||||
Ok(packaged)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage, collect, and validate on the calling thread.
|
||||
///
|
||||
/// Packaging failures are expected input errors, so they log at DEBUG. The
|
||||
/// event carries the collector error's own message only: parser and template
|
||||
/// diagnostics further down the chain quote supplied file contents, which
|
||||
/// must not reach the log at any level.
|
||||
fn package_blocking(
|
||||
source: &ValidatedWorkflowVersionCreate,
|
||||
) -> Result<CollectedWorkflowClosure, ToolError> {
|
||||
let closure = crate::collect_supplied_workflow_versions(&source.entrypoint, &source.files)
|
||||
.map_err(|err| {
|
||||
debug!(
|
||||
entrypoint = %source.entrypoint,
|
||||
file_count = source.files.len(),
|
||||
error = %err,
|
||||
"workflow version packaging failed"
|
||||
);
|
||||
ToolError::message(render_packaging_error(&err))
|
||||
})?;
|
||||
Ok(closure)
|
||||
}
|
||||
|
||||
/// Render a packaging failure for the tool caller. Every collector variant's
|
||||
/// own message names paths and counts only, so most render their full cause
|
||||
/// chain and the caller can fix the input. The graph parser, TOML parser, and
|
||||
/// template engine quote the offending source in their diagnostics, so
|
||||
/// failures that reach them stop at the last path-only level and add a hint.
|
||||
fn render_packaging_error(err: &WorkflowVersionCollectError) -> String {
|
||||
let quotes_source = match err {
|
||||
WorkflowVersionCollectError::Collect { .. }
|
||||
| WorkflowVersionCollectError::InvalidSuppliedConfig { .. } => true,
|
||||
WorkflowVersionCollectError::InvalidVersion { source, .. } => matches!(
|
||||
source,
|
||||
WorkflowVersionError::GraphParse { .. }
|
||||
| WorkflowVersionError::Template { .. }
|
||||
| WorkflowVersionError::Config { .. }
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if !quotes_source {
|
||||
return collect_chain(err).join(": ");
|
||||
}
|
||||
let summary = match err {
|
||||
// `WorkflowVersionError` names the offending path; only its source
|
||||
// quotes content.
|
||||
WorkflowVersionCollectError::InvalidVersion { source, .. } => format!("{err}: {source}"),
|
||||
_ => err.to_string(),
|
||||
};
|
||||
format!("{summary}; {PACKAGING_HINT}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "test log capture writes synchronously into memory"
|
||||
)]
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::{Level, subscriber};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLog(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl Write for CapturedLog {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CapturedLog {
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
use crate::test_support::{fixture, source};
|
||||
|
||||
async fn package_error(input: ValidatedWorkflowVersionCreate) -> String {
|
||||
let error = SuppliedWorkflowVersionPackager
|
||||
.package(input)
|
||||
.await
|
||||
.unwrap_err();
|
||||
format!("{error:#}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deeply_nested_graphs_return_errors_on_the_packaging_thread() {
|
||||
for kind in ["child", "import", "mixed"] {
|
||||
for count in [
|
||||
crate::MAX_WORKFLOW_VERSION_DEPTH,
|
||||
crate::MAX_WORKFLOW_VERSION_DEPTH + 1,
|
||||
384,
|
||||
512,
|
||||
] {
|
||||
let files: BTreeMap<_, _> = (0..count)
|
||||
.map(|index| {
|
||||
let attribute = if kind == "import" || (kind == "mixed" && index % 2 == 0) {
|
||||
"import"
|
||||
} else {
|
||||
"stack.child_workflow"
|
||||
};
|
||||
let graph = if index + 1 < count {
|
||||
format!(
|
||||
"digraph W {{ node{index} [{attribute}=\"f{}.fabro\"] }}",
|
||||
index + 1
|
||||
)
|
||||
} else {
|
||||
"digraph W {}".to_owned()
|
||||
};
|
||||
(format!("f{index}.fabro").parse().unwrap(), graph)
|
||||
})
|
||||
.collect();
|
||||
let input = ValidatedWorkflowVersionCreate::try_from(
|
||||
fabro_tool::FabroWorkflowVersionCreateParams {
|
||||
entrypoint: "f0.fabro".parse().unwrap(),
|
||||
files,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let result = SuppliedWorkflowVersionPackager.package(input).await;
|
||||
if count == crate::MAX_WORKFLOW_VERSION_DEPTH {
|
||||
assert!(result.is_ok(), "{kind} at limit: {result:?}");
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("exceeds 64 levels"),
|
||||
"{kind}/{count}: {error:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_supplied_sibling_configs_fail_without_quoting_source() {
|
||||
for config in [
|
||||
"_version = 1\nPRIVATE_CONTENT = [unterminated",
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run]\ngoal = \"PRIVATE_CONTENT\"\nunknown_setting = true\n",
|
||||
] {
|
||||
for child in [false, true] {
|
||||
let input = if child {
|
||||
source("root.fabro", &[
|
||||
(
|
||||
"root.fabro",
|
||||
r#"digraph W { child [stack.child_workflow="sub/workflow.fabro"] }"#,
|
||||
),
|
||||
("sub/workflow.fabro", "digraph Child {}"),
|
||||
("sub/workflow.toml", config),
|
||||
])
|
||||
} else {
|
||||
source("workflow.fabro", &[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
("workflow.toml", config),
|
||||
])
|
||||
};
|
||||
let error = package_error(input).await;
|
||||
let path = if child {
|
||||
"sub/workflow.toml"
|
||||
} else {
|
||||
"workflow.toml"
|
||||
};
|
||||
assert!(
|
||||
error.contains(&format!(
|
||||
"supplied workflow configuration `{path}` is invalid"
|
||||
)),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!error.contains("PRIVATE_CONTENT"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn packager_returns_dependencies_before_root() {
|
||||
let packaged = SuppliedWorkflowVersionPackager
|
||||
.package(fixture())
|
||||
.await
|
||||
.unwrap();
|
||||
let versions = packaged
|
||||
.versions()
|
||||
.map(|(_, v)| v.version())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions[0].entrypoint().as_str(), "child.fabro");
|
||||
assert_eq!(
|
||||
versions[1].id().unwrap(),
|
||||
packaged.root_id(),
|
||||
"root version must be last"
|
||||
);
|
||||
let child_id = versions[0].id().unwrap();
|
||||
assert!(
|
||||
versions[1]
|
||||
.workflow_dependencies()
|
||||
.values()
|
||||
.any(|id| *id == child_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn packaging_errors_never_quote_supplied_source() {
|
||||
let mut invalid_root = fixture();
|
||||
// Child is valid, but the root fails after its dependency is assembled.
|
||||
invalid_root.files.insert(
|
||||
"workflow.toml".parse().unwrap(),
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"missing.md\""
|
||||
.into(),
|
||||
);
|
||||
let mut invalid_config = fixture();
|
||||
invalid_config.files.insert(
|
||||
"workflow.toml".parse().unwrap(),
|
||||
"_version = 1\nPRIVATE_CONTENT = [unterminated".into(),
|
||||
);
|
||||
for input in [
|
||||
invalid_root,
|
||||
invalid_config,
|
||||
source("workflow", &[(
|
||||
"workflow",
|
||||
"PRIVATE_CONTENT invalid source",
|
||||
)]),
|
||||
] {
|
||||
let rendered = package_error(input).await;
|
||||
assert!(!rendered.contains("PRIVATE_CONTENT"), "{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaging_failure_log_never_carries_supplied_source() {
|
||||
let log = CapturedLog::default();
|
||||
let writer = log.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(Level::TRACE)
|
||||
.with_ansi(false)
|
||||
.with_writer(move || writer.clone())
|
||||
.finish();
|
||||
let inputs = [
|
||||
source("workflow", &[(
|
||||
"workflow",
|
||||
"PRIVATE_CONTENT invalid source",
|
||||
)]),
|
||||
source("workflow.toml", &[(
|
||||
"workflow.toml",
|
||||
"_version = 1\nPRIVATE_CONTENT = [unterminated",
|
||||
)]),
|
||||
source("workflow.fabro", &[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\nPRIVATE_CONTENT = [unterminated",
|
||||
),
|
||||
]),
|
||||
];
|
||||
// The guard is load-bearing: the full chain does quote the source.
|
||||
let leaky =
|
||||
crate::collect_supplied_workflow_versions(&inputs[0].entrypoint, &inputs[0].files)
|
||||
.unwrap_err();
|
||||
assert!(collect_chain(&leaky).join(": ").contains("PRIVATE_CONTENT"));
|
||||
subscriber::with_default(subscriber, || {
|
||||
for input in &inputs {
|
||||
package_blocking(input).unwrap_err();
|
||||
}
|
||||
});
|
||||
let text = log.text();
|
||||
assert!(text.contains("workflow version packaging failed"), "{text}");
|
||||
assert!(!text.contains("PRIVATE_CONTENT"), "{text}");
|
||||
assert!(text.contains("DEBUG"), "{text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_only_failures_tell_the_caller_what_to_fix() {
|
||||
let mut missing_child = fixture();
|
||||
missing_child.files.remove(&"child.fabro".parse().unwrap());
|
||||
let rendered = package_error(missing_child).await;
|
||||
assert!(
|
||||
rendered.contains("`child.fabro`") && rendered.contains("missing"),
|
||||
"{rendered}"
|
||||
);
|
||||
|
||||
let mut wrong_case = fixture();
|
||||
let prompt = wrong_case
|
||||
.files
|
||||
.remove(&"prompt.md".parse().unwrap())
|
||||
.unwrap();
|
||||
wrong_case
|
||||
.files
|
||||
.insert("Prompt.md".parse().unwrap(), prompt);
|
||||
// A case-insensitive host reads the file and reports the unsupplied
|
||||
// key; a case-sensitive host reports it missing. Either names the
|
||||
// path the graph asked for.
|
||||
let rendered = package_error(wrong_case).await;
|
||||
assert!(rendered.contains("`prompt.md`"), "{rendered}");
|
||||
|
||||
let mut oversized = fixture();
|
||||
oversized.files.insert(
|
||||
"prompt.md".parse().unwrap(),
|
||||
"\u{1}".repeat(fabro_types::MAX_WORKFLOW_VERSION_FILE_BYTES - 1),
|
||||
);
|
||||
let rendered = package_error(oversized).await;
|
||||
assert!(rendered.contains("canonical bytes"), "{rendered}");
|
||||
|
||||
let rendered = package_error(source("workflow", &[(
|
||||
"workflow",
|
||||
"PRIVATE_CONTENT invalid source",
|
||||
)]))
|
||||
.await;
|
||||
assert!(rendered.ends_with(PACKAGING_HINT), "{rendered}");
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ fabro-api = { path = "../../foundation/fabro-api" }
|
|||
fabro-client = { path = "../../foundation/fabro-client" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
fabro-workflow-version = { path = "../fabro-workflow-version" }
|
||||
futures.workspace = true
|
||||
schemars = "1.2.1"
|
||||
serde.workspace = true
|
||||
|
|
@ -30,4 +31,5 @@ toml.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] }
|
||||
httpmock = "0.8"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,13 @@ pub type ToolResult<T> = Result<T, ToolError>;
|
|||
|
||||
#[async_trait]
|
||||
pub trait FabroToolBackend: Send + Sync {
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
_source: crate::ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<fabro_types::WorkflowVersionId> {
|
||||
Err(workflow_version_tool_unavailable_error())
|
||||
}
|
||||
|
||||
async fn create_run_from_spec(
|
||||
&self,
|
||||
spec: &crate::ValidatedCreateRunSpec,
|
||||
|
|
@ -135,6 +142,13 @@ fn pair_tool_unavailable_error() -> anyhow::Error {
|
|||
ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into()
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_version_tool_unavailable_error() -> anyhow::Error {
|
||||
ToolError::message(format!(
|
||||
"{FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME} is not available"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub trait RunManifestBuilder: Send + Sync {
|
||||
fn build_run_manifest(
|
||||
&self,
|
||||
|
|
@ -170,6 +184,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 +195,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 +342,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 +353,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_TOOL_NAME)
|
||||
.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()
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ use fabro_types::{
|
|||
PairTranscriptResponse, Run, RunId, RunPairStatusResponse, RunProjection, StageId,
|
||||
};
|
||||
|
||||
use crate::{FabroToolBackend, RunManifestBuilder, ToolError};
|
||||
use crate::{FabroToolBackend, RunManifestBuilder, ToolError, common};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientBackend {
|
||||
client: Arc<::fabro_client::Client>,
|
||||
manifest_builder: Option<Arc<dyn RunManifestBuilder>>,
|
||||
run_scope: Option<RunId>,
|
||||
client: Arc<::fabro_client::Client>,
|
||||
manifest_builder: Option<Arc<dyn RunManifestBuilder>>,
|
||||
run_scope: Option<RunId>,
|
||||
workflow_version_packager: Option<Arc<dyn crate::WorkflowVersionPackager>>,
|
||||
}
|
||||
|
||||
impl ClientBackend {
|
||||
|
|
@ -24,6 +25,7 @@ impl ClientBackend {
|
|||
client,
|
||||
manifest_builder: None,
|
||||
run_scope: None,
|
||||
workflow_version_packager: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +35,15 @@ impl ClientBackend {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_workflow_version_packager(
|
||||
mut self,
|
||||
packager: Arc<dyn crate::WorkflowVersionPackager>,
|
||||
) -> Self {
|
||||
self.workflow_version_packager = Some(packager);
|
||||
self
|
||||
}
|
||||
|
||||
/// Restrict this backend to a single run.
|
||||
///
|
||||
/// Ask Fabro sessions use this with a same-run worker token so accidental
|
||||
|
|
@ -55,6 +66,30 @@ impl ClientBackend {
|
|||
|
||||
#[async_trait]
|
||||
impl FabroToolBackend for ClientBackend {
|
||||
/// Package the supplied tree, then register dependencies before parents.
|
||||
/// Versions are immutable and content-addressed, so a failed upload can be
|
||||
/// retried with the same contents without cleanup.
|
||||
async fn create_workflow_version(
|
||||
&self,
|
||||
source: crate::ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<fabro_types::WorkflowVersionId> {
|
||||
anyhow::ensure!(
|
||||
self.run_scope.is_none(),
|
||||
"workflow version creation is outside this tool session's run scope"
|
||||
);
|
||||
let packager = self
|
||||
.workflow_version_packager
|
||||
.as_ref()
|
||||
.ok_or_else(common::workflow_version_tool_unavailable_error)?;
|
||||
let packaged = packager.package(source).await?;
|
||||
let versions = packaged
|
||||
.versions()
|
||||
.map(|(_, v)| v.version())
|
||||
.collect::<Vec<_>>();
|
||||
self.client.register_workflow_versions(versions).await?;
|
||||
Ok(packaged.root_id())
|
||||
}
|
||||
|
||||
async fn create_run_from_spec(
|
||||
&self,
|
||||
spec: &crate::ValidatedCreateRunSpec,
|
||||
|
|
@ -252,3 +287,135 @@ impl FabroToolBackend for ClientBackend {
|
|||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_types::{WorkflowVersion, WorkflowVersionId};
|
||||
use fabro_workflow_version::{CollectedWorkflowClosure, ValidatedWorkflowVersion};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{ValidatedWorkflowVersionCreate, WorkflowVersionPackager};
|
||||
|
||||
struct FixedPackager(Vec<WorkflowVersion>);
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowVersionPackager for FixedPackager {
|
||||
async fn package(
|
||||
&self,
|
||||
_: ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure> {
|
||||
let versions = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|v| Ok((v.id()?, ValidatedWorkflowVersion::new(v.clone())?)))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok(CollectedWorkflowClosure::from_dependency_order(
|
||||
versions.last().unwrap().0,
|
||||
versions,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn version(
|
||||
entrypoint: &str,
|
||||
dependencies: BTreeMap<fabro_types::WorkflowPath, WorkflowVersionId>,
|
||||
) -> WorkflowVersion {
|
||||
WorkflowVersion::new(
|
||||
entrypoint.parse().unwrap(),
|
||||
BTreeMap::from([(
|
||||
entrypoint.parse().unwrap(),
|
||||
format!(
|
||||
"digraph {entrypoint} {{ {} }}",
|
||||
dependencies
|
||||
.keys()
|
||||
.map(|p| format!("child [stack.child_workflow=\"{p}\"]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
)]),
|
||||
dependencies,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source() -> ValidatedWorkflowVersionCreate {
|
||||
ValidatedWorkflowVersionCreate {
|
||||
entrypoint: "root".parse().unwrap(),
|
||||
files: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_workflow_version_registers_packaged_closure_and_retries_after_failure() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let child = version("child", BTreeMap::new());
|
||||
let child_id = child.id().unwrap();
|
||||
let root = version(
|
||||
"root",
|
||||
BTreeMap::from([("child".parse().unwrap(), child_id)]),
|
||||
);
|
||||
let root_id = root.id().unwrap();
|
||||
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}));
|
||||
})
|
||||
.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 client = ::fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
|
||||
let backend = ClientBackend::new(Arc::new(client))
|
||||
.with_workflow_version_packager(Arc::new(FixedPackager(vec![child, root.clone()])));
|
||||
|
||||
assert!(backend.create_workflow_version(source()).await.is_err());
|
||||
child_upload.assert_calls_async(1).await;
|
||||
failed_root.assert_calls_async(1).await;
|
||||
|
||||
// Immutable content: retrying re-sends the child and completes the root.
|
||||
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}));
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
backend.create_workflow_version(source()).await.unwrap(),
|
||||
root_id
|
||||
);
|
||||
child_upload.assert_calls_async(2).await;
|
||||
root_upload.assert_calls_async(1).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_workflow_version_without_packager_is_unavailable() {
|
||||
let client = ::fabro_client::Client::new_no_proxy("http://127.0.0.1:1").unwrap();
|
||||
let error = ClientBackend::new(Arc::new(client))
|
||||
.create_workflow_version(source())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"{} is not available",
|
||||
crate::FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ValidatedWorkflowVersionCreate, WorkflowVersionPackager,
|
||||
create_workflow_version, workflow_version_create_text,
|
||||
};
|
||||
|
|
|
|||
207
lib/components/fabro-tool/src/workflow_version.rs
Normal file
207
lib/components/fabro-tool/src/workflow_version.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
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, WorkflowPath};
|
||||
use fabro_workflow_version::CollectedWorkflowClosure;
|
||||
use schemars::JsonSchema;
|
||||
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. Tool arguments arrive as an already-parsed JSON value on
|
||||
/// every production route, so duplicate keys have collapsed (last wins)
|
||||
/// before this type sees them; there is no byte-level guard to add here.
|
||||
#[schemars(with = "BTreeMap<String, String>")]
|
||||
pub files: BTreeMap<WorkflowPath, String>,
|
||||
}
|
||||
|
||||
/// A supplied source tree whose entrypoint, budgets, and portable path
|
||||
/// collisions have been checked, so it is safe to stage on a filesystem.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ValidatedWorkflowVersionCreate {
|
||||
pub entrypoint: WorkflowPath,
|
||||
pub files: BTreeMap<WorkflowPath, String>,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroWorkflowVersionCreateParams> for ValidatedWorkflowVersionCreate {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroWorkflowVersionCreateParams) -> Result<Self, Self::Error> {
|
||||
let FabroWorkflowVersionCreateParams { entrypoint, files } = params;
|
||||
fabro_types::validate_workflow_files(&entrypoint, &files)
|
||||
.map_err(|err| ToolError::message(err.to_string()))?;
|
||||
let total: usize = files.values().map(String::len).sum();
|
||||
if total > MAX_WORKFLOW_VERSION_BYTES {
|
||||
return Err(ToolError::message(format!(
|
||||
"workflow source exceeds {} MiB",
|
||||
MAX_WORKFLOW_VERSION_BYTES / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
fabro_types::validate_workflow_source_paths(files.keys())
|
||||
.map_err(|_| ToolError::message("workflow source paths collide"))?;
|
||||
Ok(Self { entrypoint, files })
|
||||
}
|
||||
}
|
||||
|
||||
/// Application seam for packaging supplied content. The manifest crates that
|
||||
/// own collection depend on this crate, so the packager is injected instead.
|
||||
/// Implementations confine reads to supplied files and validate the entire
|
||||
/// closure before returning.
|
||||
#[async_trait]
|
||||
pub trait WorkflowVersionPackager: Send + Sync {
|
||||
async fn package(
|
||||
&self,
|
||||
source: ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure>;
|
||||
}
|
||||
|
||||
pub async fn create_workflow_version(
|
||||
backend: Arc<dyn FabroToolBackend>,
|
||||
source: ValidatedWorkflowVersionCreate,
|
||||
) -> ToolResult<CreateWorkflowVersionResponse> {
|
||||
let workflow_version_id = backend
|
||||
.create_workflow_version(source)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
Ok(CreateWorkflowVersionResponse {
|
||||
workflow_version_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_version_create_text(result: &CreateWorkflowVersionResponse) -> String {
|
||||
format!("Registered workflow version {}", result.workflow_version_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::{MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::fabro_client::ClientBackend;
|
||||
|
||||
fn validate(value: serde_json::Value) -> ToolResult<ValidatedWorkflowVersionCreate> {
|
||||
let params: FabroWorkflowVersionCreateParams = serde_json::from_value(value).unwrap();
|
||||
ValidatedWorkflowVersionCreate::try_from(params)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_request_rejects_unknown_fields_and_invalid_paths() {
|
||||
let valid = json!({"entrypoint": "workflow", "files": {"workflow": "digraph W {}"}});
|
||||
validate(valid.clone()).unwrap();
|
||||
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() {
|
||||
assert!(
|
||||
validate(json!({"entrypoint":"missing","files":{"workflow":"digraph W {}"}})).is_err()
|
||||
);
|
||||
for files in [
|
||||
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 {}"));
|
||||
assert!(validate(json!({"entrypoint":"workflow","files":files})).is_err());
|
||||
}
|
||||
let oversized_file = FabroWorkflowVersionCreateParams {
|
||||
entrypoint: "workflow".parse().unwrap(),
|
||||
files: BTreeMap::from([(
|
||||
"workflow".parse().unwrap(),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES + 1),
|
||||
)]),
|
||||
};
|
||||
assert!(ValidatedWorkflowVersionCreate::try_from(oversized_file).is_err());
|
||||
let mut too_many_files = FabroWorkflowVersionCreateParams {
|
||||
entrypoint: "workflow".parse().unwrap(),
|
||||
files: (0..MAX_WORKFLOW_VERSION_FILES)
|
||||
.map(|i| (format!("file{i}").parse().unwrap(), String::new()))
|
||||
.collect(),
|
||||
};
|
||||
too_many_files
|
||||
.files
|
||||
.insert(too_many_files.entrypoint.clone(), String::new());
|
||||
assert!(ValidatedWorkflowVersionCreate::try_from(too_many_files).is_err());
|
||||
let mut oversized_total = FabroWorkflowVersionCreateParams {
|
||||
entrypoint: "workflow".parse().unwrap(),
|
||||
files: (0..5)
|
||||
.map(|i| {
|
||||
(
|
||||
format!("file{i}").parse().unwrap(),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
oversized_total
|
||||
.files
|
||||
.insert(oversized_total.entrypoint.clone(), String::new());
|
||||
assert!(ValidatedWorkflowVersionCreate::try_from(oversized_total).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_same_run_backend_denies_before_packaging() {
|
||||
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_packager(Arc::new(UnreachablePackager))
|
||||
.with_run_scope("01KRBZW4DW0000000000000002".parse().unwrap());
|
||||
let source =
|
||||
validate(json!({"entrypoint":"workflow","files":{"workflow":"digraph W {}"}})).unwrap();
|
||||
let error = create_workflow_version(Arc::new(backend), source)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.as_str().contains("run scope"));
|
||||
}
|
||||
|
||||
struct UnreachablePackager;
|
||||
#[async_trait]
|
||||
impl WorkflowVersionPackager for UnreachablePackager {
|
||||
async fn package(
|
||||
&self,
|
||||
_: ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure> {
|
||||
panic!("scoped backend must not invoke the packager")
|
||||
}
|
||||
}
|
||||
}
|
||||
38
lib/components/fabro-workflow-version/src/closure.rs
Normal file
38
lib/components/fabro-workflow-version/src/closure.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use fabro_types::WorkflowVersionId;
|
||||
|
||||
use crate::ValidatedWorkflowVersion;
|
||||
|
||||
/// Validated workflow versions in dependency-first order, with the root last.
|
||||
/// Owns source contents; consumers borrow versions instead of cloning them.
|
||||
#[derive(Debug)]
|
||||
pub struct CollectedWorkflowClosure {
|
||||
root_id: WorkflowVersionId,
|
||||
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
|
||||
}
|
||||
|
||||
impl CollectedWorkflowClosure {
|
||||
/// Assemble the result of a collector that has already ordered and
|
||||
/// validated the dependency graph. The caller supplies matching IDs,
|
||||
/// unique versions, and dependencies before parents, with `root_id`
|
||||
/// identifying the last entry. This preserves the collector's ordering
|
||||
/// without traversing or hashing again.
|
||||
#[must_use]
|
||||
pub fn from_dependency_order(
|
||||
root_id: WorkflowVersionId,
|
||||
versions: Vec<(WorkflowVersionId, ValidatedWorkflowVersion)>,
|
||||
) -> Self {
|
||||
Self { root_id, versions }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id
|
||||
}
|
||||
|
||||
/// Iterate over every version with dependencies before parents.
|
||||
pub fn versions(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (WorkflowVersionId, &ValidatedWorkflowVersion)> + '_ {
|
||||
self.versions.iter().map(|(id, version)| (*id, version))
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,9 @@ use fabro_types::settings::InterpString;
|
|||
use fabro_types::{ManifestPath, WorkflowPath, WorkflowPathParseError, WorkflowVersion};
|
||||
use thiserror::Error;
|
||||
|
||||
mod closure;
|
||||
mod store;
|
||||
|
||||
pub use closure::CollectedWorkflowClosure;
|
||||
pub use store::{LoadedWorkflowVersionClosure, WorkflowVersionStore, WorkflowVersionStoreError};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ tempfile = "3"
|
|||
toml.workspace = true
|
||||
fabro-vault = { path = "../../foundation/fabro-vault" }
|
||||
[dev-dependencies]
|
||||
fabro-client = { path = "../../foundation/fabro-client" }
|
||||
fabro-workflow-version = { path = "../fabro-workflow-version" }
|
||||
fabro-llm = { path = "../fabro-llm", features = ["test-support"] }
|
||||
fabro-store = { path = "../fabro-store", features = ["test-support"] }
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! The Fabro run tools (`fabro_run_*`) as application tools a pebble coding
|
||||
//! The Fabro workflow and run tools as application tools a pebble coding
|
||||
//! agent can call.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -66,6 +66,15 @@ pub(crate) async fn execute_fabro_run_tool(
|
|||
services: &FabroRunToolServices,
|
||||
) -> fabro_tool::ToolResult<String> {
|
||||
match name {
|
||||
fabro_tool::FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME => {
|
||||
let params =
|
||||
parse_fabro_tool_args::<fabro_tool::FabroWorkflowVersionCreateParams>(name, args)?;
|
||||
let source = fabro_tool::ValidatedWorkflowVersionCreate::try_from(params)?;
|
||||
let result =
|
||||
fabro_tool::create_workflow_version(Arc::clone(&services.backend), source).await?;
|
||||
let summary = fabro_tool::workflow_version_create_text(&result);
|
||||
render_fabro_tool_result(&summary, &result)
|
||||
}
|
||||
fabro_tool::FABRO_RUN_CREATE_TOOL_NAME => {
|
||||
let params = parse_fabro_tool_args::<fabro_tool::FabroRunCreateParams>(name, args)?;
|
||||
ensure_current_run_parent(¶ms, services.current_run_id)?;
|
||||
|
|
@ -196,3 +205,89 @@ where
|
|||
})?;
|
||||
Ok(format!("{summary}\n{json}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_tool::{ValidatedWorkflowVersionCreate, WorkflowVersionPackager};
|
||||
use fabro_types::WorkflowVersion;
|
||||
use fabro_workflow_version::{CollectedWorkflowClosure, ValidatedWorkflowVersion};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct SingleGraphPackager;
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowVersionPackager for SingleGraphPackager {
|
||||
async fn package(
|
||||
&self,
|
||||
source: ValidatedWorkflowVersionCreate,
|
||||
) -> anyhow::Result<CollectedWorkflowClosure> {
|
||||
let version = WorkflowVersion::new(source.entrypoint, source.files, BTreeMap::new())?;
|
||||
let id = version.id()?;
|
||||
Ok(CollectedWorkflowClosure::from_dependency_order(id, vec![(
|
||||
id,
|
||||
ValidatedWorkflowVersion::new(version)?,
|
||||
)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_version_native_dispatch_registers_and_returns_version() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let version = WorkflowVersion::new(
|
||||
"workflow".parse().unwrap(),
|
||||
BTreeMap::from([("workflow".parse().unwrap(), "digraph W {}".into())]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = version.id().unwrap();
|
||||
let upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(httpmock::Method::POST)
|
||||
.path("/api/v1/workflow-versions")
|
||||
.json_body_obj(&version);
|
||||
then.status(201)
|
||||
.json_body(json!({"workflow_version_id": id}));
|
||||
})
|
||||
.await;
|
||||
let client = fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
|
||||
let services = FabroRunToolServices {
|
||||
backend: Arc::new(
|
||||
ClientBackend::new(Arc::new(client))
|
||||
.with_workflow_version_packager(Arc::new(SingleGraphPackager)),
|
||||
),
|
||||
current_run_id: "01KRBZW4DW0000000000000002".parse().unwrap(),
|
||||
base_cwd: "unused".into(),
|
||||
user_settings_path: "unused".into(),
|
||||
};
|
||||
let name = fabro_tool::FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME;
|
||||
assert_eq!(register_named_fabro_run_tools(&services, &[name]).len(), 1);
|
||||
let output = execute_fabro_run_tool(
|
||||
name,
|
||||
json!({"entrypoint":"workflow", "files":{"workflow":"digraph W {}"}}),
|
||||
&services,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (summary, body) = output.split_once('\n').unwrap();
|
||||
assert_eq!(summary, format!("Registered workflow version {id}"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(body).unwrap(),
|
||||
json!({"workflow_version_id": id})
|
||||
);
|
||||
let error = execute_fabro_run_tool(
|
||||
name,
|
||||
json!({"entrypoint":"missing", "files":{"workflow":"digraph W {}"}}),
|
||||
&services,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("not present"));
|
||||
upload.assert_calls_async(1).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,11 +46,22 @@ impl WorkflowLocation {
|
|||
/// graph file (e.g. `workflow.fabro`); the three forms produce the same
|
||||
/// shape.
|
||||
pub fn resolve(arg: &Path, cwd: &Path) -> Result<Self> {
|
||||
let resolved = resolve_workflow_arg_from(arg, cwd)?;
|
||||
if resolved.extension().is_some_and(|ext| ext == "toml") {
|
||||
Self::from_toml(resolved)
|
||||
Self::from_resolved_path(resolve_workflow_arg_from(arg, cwd)?)
|
||||
}
|
||||
|
||||
/// Resolve an exact file path without workflow-name lookup. Relative paths
|
||||
/// are interpreted against the supplied directory only.
|
||||
pub fn from_exact_path(path: &Path, directory: &Path) -> Result<Self> {
|
||||
Self::from_resolved_path(directory.join(path))
|
||||
}
|
||||
|
||||
/// Dispatch a resolved file path on its extension: `workflow.toml` loads
|
||||
/// run config, anything else is treated as a graph file.
|
||||
fn from_resolved_path(path: PathBuf) -> Result<Self> {
|
||||
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||
Self::from_toml(path)
|
||||
} else {
|
||||
Ok(Self::from_graph(resolved))
|
||||
Ok(Self::from_graph(path))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ strum.workspace = true
|
|||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
ulid.workspace = true
|
||||
unicase.workspace = true
|
||||
unicode-normalization.workspace = true
|
||||
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_files, validate_workflow_source_paths,
|
||||
};
|
||||
pub use workflow_version_id::{WorkflowVersionId, WorkflowVersionIdParseError};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt;
|
||||
use std::marker::PhantomData;
|
||||
|
|
@ -5,6 +6,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};
|
||||
|
||||
|
|
@ -119,61 +122,110 @@ impl WorkflowVersion {
|
|||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
validate_workflow_files(&self.entrypoint, &self.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 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()),
|
||||
Cow::Borrowed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the file limits and entrypoint shared by source trees and versions.
|
||||
/// Aggregate source bytes, canonical bytes, and path policies are checked
|
||||
/// separately.
|
||||
pub fn validate_workflow_files(
|
||||
entrypoint: &WorkflowPath,
|
||||
files: &BTreeMap<WorkflowPath, String>,
|
||||
) -> Result<(), WorkflowVersionShapeError> {
|
||||
if files.len() > MAX_WORKFLOW_VERSION_FILES {
|
||||
return Err(WorkflowVersionShapeError::TooManyFiles {
|
||||
actual: files.len(),
|
||||
maximum: MAX_WORKFLOW_VERSION_FILES,
|
||||
});
|
||||
}
|
||||
for (path, content) in 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 !files.contains_key(entrypoint) {
|
||||
return Err(WorkflowVersionShapeError::MissingEntrypoint {
|
||||
path: entrypoint.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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, |text| {
|
||||
if text.is_ascii() {
|
||||
Cow::Owned(text.to_ascii_lowercase())
|
||||
} else {
|
||||
// Normalize before folding: case folding is not closed under
|
||||
// canonical equivalence, so folding a decomposed sequence and
|
||||
// folding its precomposed form can yield different strings.
|
||||
let normalized: String = text.nfc().collect();
|
||||
Cow::Owned(
|
||||
UniCase::unicode(normalized)
|
||||
.to_folded_case()
|
||||
.nfc()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect colliding paths under a comparison key: identical keys, or a key
|
||||
/// that names an ancestor directory of another.
|
||||
fn validate_path_collisions<'a>(
|
||||
paths: impl IntoIterator<Item = &'a WorkflowPath>,
|
||||
key: impl Fn(&'a str) -> Cow<'a, str>,
|
||||
) -> Result<(), WorkflowVersionShapeError> {
|
||||
let keyed: Vec<(Cow<'a, str>, &WorkflowPath)> = paths
|
||||
.into_iter()
|
||||
.map(|path| (key(path.as_str()), path))
|
||||
.collect();
|
||||
let mut by_text = HashMap::with_capacity(keyed.len());
|
||||
for (text, path) in &keyed {
|
||||
if let Some(existing) = by_text.insert(text.as_ref(), *path) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: existing.clone(),
|
||||
second: (*path).clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Walk the input order, not the map, so the reported pair is stable when
|
||||
// more than one ancestor collision exists.
|
||||
for (text, path) in &keyed {
|
||||
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 +561,60 @@ mod tests {
|
|||
assert!(serde_json::from_str::<WorkflowVersion>(duplicate).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ancestor_collision_reports_the_first_pair_in_input_order() {
|
||||
let paths = ["assets", "assets/item.txt", "libs", "libs/child.fabro"]
|
||||
.map(|path| WorkflowPath::new(path).unwrap());
|
||||
for _ in 0..32 {
|
||||
let error = validate_workflow_source_paths(paths.iter()).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workflow paths collide: `assets` and `assets/item.txt`"
|
||||
);
|
||||
let version = WorkflowVersion::new(
|
||||
paths[1].clone(),
|
||||
paths.iter().map(|p| (p.clone(), String::new())).collect(),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(version.to_string(), error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[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"],
|
||||
// Canonically equivalent, but folding before normalizing yields
|
||||
// different keys (U+03B1 U+03AF vs U+03AC U+03B9).
|
||||
["α\u{345}\u{301}.md", "\u{1FB4}.md"],
|
||||
] {
|
||||
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