Merge pull request #832 from fabro-sh/codex/run-intent-run-create

Migrate fabro_run_create to the RunIntent API
This commit is contained in:
Scott Werner 2026-09-12 13:38:01 -04:00 committed by GitHub
commit 2c81e81e5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1515 additions and 1622 deletions

1
Cargo.lock generated
View file

@ -3176,6 +3176,7 @@ dependencies = [
"fabro-workflow-version",
"futures",
"httpmock",
"jsonschema",
"schemars 1.2.1",
"serde",
"serde_json",

View file

@ -1,5 +1,11 @@
# Fabro MCP Server — QA Test Plan
> Historical manual QA results. The run-create source selectors and flat settings
> below predate the registered-version contract. Current calls register file contents
> with `fabro_workflow_version_create`, then pass `workflow_version_id`, an explicit
> standalone `target`, and nested `args` to `fabro_run_create`. See
> [the current MCP guide](../public/agents/mcp.mdx) for the supported contract.
One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/apps/fabro-mcp-server/src/run_tools/`.
This plan is **not** a template for adding automated test coverage — it exists to drive a single hands-on sweep against a real running server. Tick boxes as scenarios pass; add notes inline for failures or surprising behavior. Open bugs/PRs for issues found; do not port these scenarios into the Rust test suite.

View file

@ -86,34 +86,58 @@ 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.
versions remain reusable. Use the returned ID with `fabro_run_create`.
### Create runs
For a simple create call, `fabro_run_create` accepts a workflow selector string:
```json
{ "runs": ["sleeper"] }
```
Use the object form when you need create options:
Call `fabro_run_create` with a registered workflow version ID and an independent
workspace target. You can reuse the same ID for multiple runs without uploading
again:
```json
{
"runs": [
{
"workflow": "sleeper",
"auto_approve": true,
"dry_run": true,
"goal_file": "plans/ship-it.md",
"runs": [{
"workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"target": { "kind": "git", "repo": "acme/widgets", "branch": "main" },
"environment_id": "production",
"goal": "Review the checkout implementation.",
"args": {
"inputs": { "component": "checkout" },
"labels": { "source": "mcp" },
"start": true
}
]
"auto_approve": false
},
"start": false
}]
}
```
Use `goal` for inline goal text or `goal_file` to read the run goal from a file. They are mutually exclusive. Relative `goal_file` paths resolve from the run's `cwd`, or from the MCP server working directory when `cwd` is omitted.
Standalone MCP calls require an explicit `target`: Git coordinates, `kind: none`
for an empty workspace, or a server-local folder for a compatible Local environment.
A folder path refers to the server's filesystem, not the MCP client's filesystem.
Workflow content does not determine the target. Native workflow-agent calls may
omit `target` to inherit the parent's execution target; see [Child Runs](/execution/child-runs).
Supplying `parent_id` in standalone MCP does not enable that inheritance.
`args` uses the same fields as RunIntent: `inputs`, `labels`, `model`, `provider`,
`dry_run`, `auto_approve`, and `preserve_sandbox`. Omitted boolean overrides stay
omitted; explicit `false` is preserved. `environment_id` selects a server environment;
omission uses the server default. `title`, literal `goal`, and an exact `parent_id`
are optional. Caller/project/machine run settings are not read by the tool.
Workflow-owned settings remain in the registered `workflow.toml`.
<Note>
Run creation accepts registered IDs only. Replace workflow strings and inline
sources with a preceding `fabro_workflow_version_create` call. Move flat run
options into `args`, use `environment_id` instead of `environment`, and send goal
text instead of `goal_file`. Obtain local or remote files using the caller's own
shell/read tools. Neither Fabro tool fetches remote sources or reads a caller path.
</Note>
Creation persists a submitted run. A separate start request follows by default;
set `start: false` to start later. Batches contain 150 items and stop at the first
failure. If creation succeeded before a start, summary lookup, or later item
failed, the error includes the already-created run IDs. Inspect those runs before
retrying to avoid creating duplicates.
Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent. See [Child Runs](/execution/child-runs) for the orchestration model.

View file

@ -32,6 +32,7 @@ This exposes the same Fabro run tools available through [MCP](/agents/mcp):
| Tool | Purpose |
|---|---|
| `fabro_workflow_version_create` | Register supplied workflow files and return an immutable version ID |
| `fabro_run_create` | Create one or more child runs, starting them by default |
| `fabro_run_search` | Search runs, including direct children by `parent_id` |
| `fabro_run_get` | Inspect a run without mutating it |
@ -46,57 +47,69 @@ When a workflow agent calls `fabro_run_create`, Fabro always parents the created
## Create child runs
The simplest `fabro_run_create` call names a workflow:
Acquire workflow files with the agent's shell/read tools, then call
`fabro_workflow_version_create` with their contents:
```json
{
"runs": ["implement-and-test"]
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph Child { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }",
"prompt.md": "Implement the requested change and run the relevant tests."
}
}
```
Use the object form to pass run options:
Use the returned `workflow_version_id` in `fabro_run_create`:
```json
{
"runs": [
{
"workflow": "implement-and-test",
"goal": "Implement the checkout page refactor and run the test suite.",
"labels": {
"lane": "checkout",
"source": "parent-run"
},
"start": true
}
]
"runs": [{
"workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"goal": "Implement the checkout page refactor and run its tests.",
"args": { "labels": { "lane": "checkout", "source": "parent-run" } },
"start": true
}]
}
```
The parent can create several children in one call:
A version can be reused for several children, each with its own goal, target, and
`args`. When the version already exists, skip registration. Read goal files with
the agent's read tool and pass literal `goal` text. Workflow names, paths, inline
source objects, and `goal_file` are no longer run-create inputs.
```json
{
"runs": [
{
"workflow": "implementation",
"goal_file": "plans/api.md",
"labels": { "lane": "api" }
},
{
"workflow": "implementation",
"goal_file": "plans/web.md",
"labels": { "lane": "web" }
},
{
"workflow": "review",
"goal": "Review the current branch for security and data-integrity risks.",
"labels": { "lane": "review" }
}
]
}
```
This flow works in Local, Docker, and Daytona environments. The agent can clone a
remote workflow in its sandbox and submit the resulting contents. Fabro's native
tool handler executes outside the sandbox, so registration treats file-map keys
as virtual relative paths and never reads sandbox paths from the worker host.
Include the workflow's referenced configuration, prompts, and child workflows in
the supplied tree. See [MCP](/agents/mcp) for package limits.
By default, `fabro_run_create` requests start for each created run. Set `"start": false` when the parent should create the child now and start it later.
Workflow content and workspace target are independent. If `target` is omitted,
a native child inherits the parent's canonical target: `none` or folder as-is,
and a Git target's repository and current execution branch (normally
`fabro/run/<parent-id>`). Push parent changes before creating the child; a child
clone sees that branch's remote HEAD. The parent's original pinned SHA/tag is
not inherited. If run branches are disabled, the original input branch is used;
if an enabled execution branch is unavailable, send an explicit target.
An explicit Git, `none`, or folder target overrides inheritance while the current
run remains the forced parent. Set `sha` on an explicit Git target to pin a child.
Server admission enforces folder access: Docker/Daytona parents cannot select a
server-host folder, including by requesting a Local child environment.
Standalone MCP always requires an explicit target, even with `parent_id`.
Use `environment_id` to choose a server environment; omission uses the server
default, not the parent's environment. Run overrides belong in canonical `args`:
`inputs`, `labels`, `model`, `provider`, `auto_approve`, `dry_run`, and
`preserve_sandbox`. Omission preserves workflow/server defaults; explicit `false`
is not omitted. The tool does not apply caller, project, or machine run settings.
Keep workflow-owned configuration in the registered `workflow.toml`.
Creation and start remain separate operations. Set `start: false` to leave a
child submitted. A batch stops on its first failure; if any runs have already
been created, their IDs appear in the error so the parent can inspect them
instead of recreating them blindly.
## Start and approval

View file

@ -34,7 +34,6 @@ fn server_settings(
let user_settings = connection_ctx.user_settings().clone();
let storage_dir = connection_ctx.storage_dir().to_path_buf();
let base_config_path = connection_ctx.base_config_path().to_path_buf();
let config_path = base_config_path.clone();
let client_factory: fabro_mcp_server::FabroClientFactory = std::sync::Arc::new(move || {
let target = target.clone();
let user_settings = user_settings.clone();
@ -51,11 +50,7 @@ fn server_settings(
});
future
});
Ok(fabro_mcp_server::FabroMcpServerSettings {
client_factory,
config_path,
cwd: base_ctx.cwd().to_path_buf(),
})
Ok(fabro_mcp_server::FabroMcpServerSettings { client_factory })
}
fn init_settings(args: &McpInitArgs) -> Result<fabro_mcp_server::McpInitSettings> {

View file

@ -1,9 +1,9 @@
use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use anyhow::{Context as _, anyhow};
use fabro_config::project;
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, Environment};
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget, SandboxProviderKind};
use fabro_types::{RunId, RunIntent};
use fabro_util::terminal::Styles;
use super::overrides::prepare_intent_overrides;
@ -71,8 +71,16 @@ pub(crate) async fn create_run(
},
resolve_run_environment(client.as_ref(), args.environment.as_deref()),
)?;
let (target, dirty_worktree) =
run_target_for_environment(&environment.settings.provider, &canonical_cwd)?;
let configured_repo_origin_url =
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?;
let fabro_manifest::DerivedRunTarget {
target,
dirty_worktree,
} = fabro_manifest::derive_run_target_for_provider(
&environment.settings.provider,
&canonical_cwd,
configured_repo_origin_url.as_deref(),
)?;
if dirty_worktree {
fabro_util::printerr!(
ctx.printer(),
@ -163,86 +171,3 @@ fn warn_untransmitted_settings(
keys.join(", "),
);
}
/// Derives the run target from the caller directory for the environment's
/// provider. Returns the target plus whether a clone-based observation found a
/// dirty Git worktree, so the caller can warn about it.
fn run_target_for_environment(
provider: &SandboxProviderKind,
canonical_cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
if !provider.clones_workspace() {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"caller working directory is not valid UTF-8: {}",
canonical_cwd.display()
)
})?;
return Ok((
RunTarget::Folder {
path: path.to_string(),
},
false,
));
}
let Some(observation) = fabro_manifest::observe_git_run_target(canonical_cwd, None) else {
return Ok((none_target_for_unversioned_directory(canonical_cwd)?, false));
};
let dirty = observation.legacy_git_context.dirty == DirtyStatus::Dirty;
let target = observation.run_target.ok_or_else(|| {
anyhow!("the caller Git checkout cannot be represented as a canonical GitHub run target")
})?;
if target.sha.is_none() {
bail!(
"the exact local Git commit could not be made available from the canonical GitHub origin; push the commit and try again"
);
}
Ok((RunTarget::Git(target), dirty))
}
fn none_target_for_unversioned_directory(canonical_cwd: &Path) -> anyhow::Result<RunTarget> {
let repository = match git2::Repository::discover(canonical_cwd) {
Ok(repository) => repository,
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(RunTarget::None {}),
Err(source) => {
return Err(anyhow::Error::new(source)).with_context(|| {
format!(
"failed to inspect caller working directory {} for Git metadata",
canonical_cwd.display()
)
});
}
};
if repository.is_bare() {
bail!(
"the caller directory resolves to a bare Git repository; clone-based runs require a non-bare checkout with an attached branch"
);
}
match repository.head() {
Err(source)
if matches!(
source.code(),
git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
) =>
{
bail!(
"the caller Git checkout has no commits; create a commit before using a clone-based environment"
);
}
Err(source) => {
return Err(anyhow::Error::new(source))
.context("failed to inspect the caller Git checkout HEAD");
}
Ok(head) if !head.is_branch() => {
bail!(
"the caller Git checkout has a detached HEAD; check out a branch before using a clone-based environment"
);
}
Ok(_) => {}
}
bail!(
"the caller Git checkout does not have a usable attached branch for a clone-based run target"
)
}

View file

@ -5,9 +5,7 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_client::ServerTarget;
use fabro_config::user::active_settings_path;
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WORKER_CONTROL_INVALID_CURSOR_REASON,
@ -16,7 +14,6 @@ use fabro_interview::{
WorkerControlMessage,
};
use fabro_manifest::SuppliedWorkflowVersionPackager;
use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::run::{RunMode, RunNamespace};
@ -98,13 +95,7 @@ pub(crate) async fn execute(
worker_token.to_owned(),
)));
let fabro_run_tools = if fabro_run_tools_enabled_from_worker_token(worker_token) {
build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
)
build_fabro_run_tool_services(worker_token, client.clone_for_reuse(), run_id)
} else {
None
};
@ -230,36 +221,18 @@ fn build_fabro_run_tool_services(
worker_token: &str,
client: fabro_client::Client,
current_run_id: RunId,
source_directory: Option<&str>,
run_dir: &Path,
) -> Option<FabroRunToolServices> {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder))
.with_workflow_version_packager(Arc::new(SuppliedWorkflowVersionPackager));
Some(FabroRunToolServices {
backend: Arc::new(backend),
current_run_id,
base_cwd: source_directory.map_or_else(|| run_dir.to_path_buf(), PathBuf::from),
user_settings_path: active_settings_path(None),
})
}
struct WorkerRunManifestBuilder;
impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &fabro_tool::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> fabro_tool::ToolResult<RunManifest> {
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}
/// Load the worker's secret vault from the run's storage root.
///
/// A worker always receives the server storage root so it can load the same

View file

@ -645,7 +645,7 @@ async fn stdio_server_initializes_and_lists_run_tools() {
.find(|(name, _, _)| name == "fabro_run_create")
.map(|(_, _, schema)| schema)
.expect("fabro_run_create tool should be listed");
assert_create_schema_accepts_string_and_object_specs(create_schema);
assert_create_schema_requires_version_ids(create_schema);
client
.shutdown()
.await
@ -737,15 +737,15 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let workflow_version_id = register_mcp_workflow(&client, &workflow).await;
let create = call_tool_json(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-test" }
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }}
}]
}),
)
@ -781,7 +781,7 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
"labels": {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"source_directory": null,
"repo_origin_url": null,
"goal_preview": "Run tests and report results",
"goal_truncated": false
@ -804,15 +804,15 @@ async fn mcp_run_tools_use_default_local_server_without_server_flag() {
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &[]).await;
let workflow_version_id = register_mcp_workflow(&client, &workflow).await;
let create = call_tool_json(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-default-server-test" },
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-default-server-test" }},
"start": false
}]
}),
@ -1388,7 +1388,7 @@ async fn mcp_lifecycle_tools_manage_real_run() {
"labels": {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"source_directory": null,
"repo_origin_url": null,
"goal": "Run tests and report results"
}
@ -1874,7 +1874,9 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
let context = test_context!();
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let too_many = (0..51)
.map(|index| serde_json::json!({ "workflow": format!("wf-{index}.fabro") }))
.map(
|_| serde_json::json!({"workflow_version_id":"a".repeat(64), "target":{"kind":"none"}}),
)
.collect::<Vec<_>>();
let empty = call_tool_error_text(
@ -1894,13 +1896,14 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": "simple.fabro",
"inputs": { "decision": null }
"workflow_version_id": "a".repeat(64),
"target": {"kind":"none"},
"args": {"inputs": { "decision": null }}
}]
}),
)
.await;
let conflicting_goal_sources = call_tool_error_text(
let conflicting_goal_sources = call_tool_parameter_error(
&client,
"fabro_run_create",
serde_json::json!({
@ -1917,7 +1920,7 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
assert!(many.contains("runs"), "{many}");
assert!(null.contains("decision"), "{null}");
assert!(
conflicting_goal_sources.contains("goal and goal_file are mutually exclusive"),
conflicting_goal_sources.contains("fabro_workflow_version_create"),
"{conflicting_goal_sources}"
);
assert_mcp_run_tool_count(&client).await;
@ -1928,41 +1931,24 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_create_string_shorthand_deserializes_before_auth() {
async fn mcp_create_requires_explicit_target_and_rejects_old_shorthand_before_auth() {
let context = test_context!();
let harness =
RealAuthHarness::start_with_dev_token(fabro_test::GitHubAppState::default()).await;
let target_url = harness.api_target();
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let result = client
.call_tool(
"fabro_run_create",
serde_json::json!({ "runs": [workflow] }),
std::time::Duration::from_secs(30),
)
.await
.expect("string shorthand should deserialize and return a tool-level auth error");
assert_eq!(result.is_error, Some(true), "tool should return error");
let error = result
.content
.first()
.and_then(|content| serde_json::to_value(content).ok())
.and_then(|content| content["text"].as_str().map(ToOwned::to_owned))
.expect("tool error should include text");
assert!(!error.contains("CreateRunSpec"), "{error}");
assert!(
error.contains("Run `fabro auth login` to authenticate."),
"{error}"
);
assert_mcp_run_tool_count(&client).await;
client
.shutdown()
.await
.expect("MCP client should shut down");
harness.shutdown().await;
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let error = call_tool_parameter_error(
&client,
"fabro_run_create",
serde_json::json!({"runs":["simple.fabro"]}),
)
.await;
assert!(error.contains("fabro_workflow_version_create"), "{error}");
let error = call_tool_error_text(
&client,
"fabro_run_create",
serde_json::json!({"runs":[{"workflow_version_id":"a".repeat(64)}]}),
)
.await;
assert!(error.contains("explicit target"), "{error}");
client.shutdown().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
@ -2770,6 +2756,18 @@ async fn call_tool_json(
.expect("tool result should include structured content")
}
async fn call_tool_parameter_error(
client: &McpClient,
name: &str,
arguments: serde_json::Value,
) -> String {
let error = client
.call_tool(name, arguments, std::time::Duration::from_secs(30))
.await
.expect_err("invalid parameters should produce an MCP protocol error");
error.to_string()
}
async fn call_tool_error_text(
client: &McpClient,
name: &str,
@ -2788,49 +2786,62 @@ async fn call_tool_error_text(
.expect("tool error should include text")
}
fn assert_create_schema_accepts_string_and_object_specs(schema: &serde_json::Value) {
let variants = schema
.pointer("/properties/runs/items/anyOf")
.and_then(serde_json::Value::as_array)
.expect("fabro_run_create runs items should use anyOf");
assert!(
variants.iter().any(|variant| variant["type"] == "string"),
"fabro_run_create should advertise workflow string shorthand: {schema}"
);
let object_variant = variants
.iter()
.find(|variant| variant["type"] == "object")
.unwrap_or_else(|| {
panic!("fabro_run_create should advertise object create specs: {schema}")
fn assert_create_schema_requires_version_ids(schema: &serde_json::Value) {
let item = &schema["properties"]["runs"]["items"];
let item = item
.get("$ref")
.and_then(serde_json::Value::as_str)
.map_or(item, |reference| {
schema
.pointer(
reference
.strip_prefix('#')
.expect("schema reference should be local"),
)
.expect("schema reference should resolve")
});
assert_eq!(item["type"], "object");
assert_eq!(item["additionalProperties"], false);
assert!(
object_variant.pointer("/properties/workflow").is_some(),
"object create spec should include workflow property: {schema}"
);
assert!(
object_variant.pointer("/properties/goal_file").is_some(),
"object create spec should include goal_file property: {schema}"
);
assert!(
object_variant
.get("required")
.and_then(serde_json::Value::as_array)
.is_some_and(|required| required.iter().any(|field| field == "workflow")),
"object create spec should require workflow: {schema}"
item["required"]
.as_array()
.expect("create schema should require fields")
.iter()
.any(|field| field == "workflow_version_id")
);
assert!(item["properties"].get("args").is_some());
assert!(item["properties"].get("workflow").is_none());
assert!(item["properties"].get("goal_file").is_none());
}
async fn register_mcp_workflow(client: &McpClient, workflow: &Path) -> serde_json::Value {
let entrypoint = workflow
.file_name()
.expect("fixture should have a filename")
.to_str()
.expect("fixture filename should be UTF-8");
let content = fs::read_to_string(workflow).expect("workflow fixture should be readable");
let result = call_tool_json(
client,
"fabro_workflow_version_create",
serde_json::json!({
"entrypoint": entrypoint, "files": {entrypoint: content}
}),
)
.await;
result["workflow_version_id"].clone()
}
async fn create_mcp_run(client: &McpClient, workflow: PathBuf, start: bool) -> String {
let workflow_version_id = register_mcp_workflow(client, &workflow).await;
let create = call_tool_json(
client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-test" },
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }},
"start": start
}]
}),

View file

@ -1,6 +1,5 @@
mod config;
mod executable_monitor;
mod manifest_builder;
mod server;
use std::future::Future;
@ -24,8 +23,6 @@ pub type FabroClientFactory = Arc<dyn Fn() -> FabroClientFuture + Send + Sync>;
#[derive(Clone)]
pub struct FabroMcpServerSettings {
pub client_factory: FabroClientFactory,
pub config_path: PathBuf,
pub cwd: PathBuf,
}
impl std::fmt::Debug for FabroMcpServerSettings {
@ -33,8 +30,6 @@ impl std::fmt::Debug for FabroMcpServerSettings {
formatter
.debug_struct("FabroMcpServerSettings")
.field("client_factory", &"<factory>")
.field("config_path", &self.config_path)
.field("cwd", &self.cwd)
.finish()
}
}

View file

@ -1,19 +0,0 @@
use std::path::Path;
use fabro_api::types;
use fabro_server::run_tool_manifest;
use fabro_tool::{RunManifestBuilder, ToolResult, ValidatedCreateRunSpec};
#[derive(Default)]
pub(crate) struct McpRunManifestBuilder;
impl RunManifestBuilder for McpRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}

View file

@ -1,4 +1,3 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -18,14 +17,12 @@ use tokio::time;
use tracing::warn;
use crate::executable_monitor::ExecutableMonitor;
use crate::manifest_builder::McpRunManifestBuilder;
use crate::{FabroMcpServerSettings, SERVER_NAME};
#[derive(Clone)]
pub(crate) struct FabroMcpServer {
settings: Arc<FabroMcpServerSettings>,
backend: Arc<OnceCell<Arc<dyn FabroToolBackend>>>,
cwd: PathBuf,
tool_router: ToolRouter<Self>,
}
@ -82,11 +79,9 @@ impl ServerHandler for FabroMcpServer {
#[tool_router(router = tool_router)]
impl FabroMcpServer {
pub(crate) fn new(settings: Arc<FabroMcpServerSettings>) -> Self {
let cwd = settings.cwd.clone();
Self {
settings,
backend: Arc::new(OnceCell::new()),
cwd,
tool_router: Self::tool_router(),
}
}
@ -115,21 +110,21 @@ impl FabroMcpServer {
#[tool(
name = "fabro_run_create",
description = "Create one or more Fabro workflow runs, optionally under a parent run, starting them by default."
description = "Create runs from registered workflow_version_id values and canonical RunIntent settings. Register contents with fabro_workflow_version_create first. Standalone calls require an explicit target; native workers may inherit the parent target. Starts runs by default."
)]
async fn fabro_run_create(
&self,
params: Parameters<run_tools::FabroRunCreateParams>,
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedCreateRuns::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(error_result(&err)),
};
let params = params.0;
if let Err(err) = params.validate(run_tools::CreateRunOptions::default()) {
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_runs(backend, &self.cwd, &self.settings.config_path, params).await {
match run_tools::create_runs(backend, params).await {
Ok(result) => success_result(&result, run_tools::create_runs_text(&result)),
Err(err) => Ok(error_result(&err)),
}
@ -274,11 +269,9 @@ impl FabroMcpServer {
.await
.map(|client| {
Arc::new(
ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(McpRunManifestBuilder))
.with_workflow_version_packager(Arc::new(
SuppliedWorkflowVersionPackager,
)),
ClientBackend::new(Arc::new(client)).with_workflow_version_packager(
Arc::new(SuppliedWorkflowVersionPackager),
),
) as Arc<dyn FabroToolBackend>
})
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
@ -310,7 +303,6 @@ fn error_result(err: &run_tools::ToolError) -> CallToolResult {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::Value;
@ -339,8 +331,6 @@ mod tests {
.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) })
@ -378,8 +368,6 @@ mod tests {
#[test]
fn server_info_reports_fabro_version() {
let settings = FabroMcpServerSettings {
cwd: PathBuf::from("."),
config_path: PathBuf::from("fabro.toml"),
client_factory: Arc::new(|| {
Box::pin(async { panic!("client should not be constructed while reading info") })
}),
@ -394,8 +382,6 @@ mod tests {
#[test]
fn fabro_run_pair_tool_is_registered_with_stage_based_schema() {
let settings = FabroMcpServerSettings {
cwd: PathBuf::from("."),
config_path: PathBuf::from("fabro.toml"),
client_factory: Arc::new(|| {
Box::pin(async { panic!("client should not be constructed while listing tools") })
}),
@ -421,10 +407,8 @@ mod tests {
}
#[test]
fn fabro_run_create_tool_advertises_string_and_object_run_specs() {
fn fabro_run_create_tool_advertises_canonical_version_contract() {
let settings = FabroMcpServerSettings {
cwd: PathBuf::from("."),
config_path: PathBuf::from("fabro.toml"),
client_factory: Arc::new(|| {
Box::pin(async { panic!("client should not be constructed while listing tools") })
}),
@ -436,35 +420,15 @@ mod tests {
.find(|tool| tool.name.as_ref() == "fabro_run_create")
.expect("fabro_run_create should be registered");
let schema = Value::Object(tool.input_schema.as_ref().clone());
let variants = schema
.pointer("/properties/runs/items/anyOf")
.and_then(Value::as_array)
.expect("runs items should advertise string and object variants");
assert!(
variants.iter().any(|variant| variant["type"] == "string"),
"runs items should include workflow string shorthand: {schema}"
);
let object_variant = variants
let definition = run_tools::tool_definitions()
.iter()
.find(|variant| variant["type"] == "object")
.unwrap_or_else(|| {
panic!("runs items should include object create spec variant: {schema}")
});
assert!(
object_variant.pointer("/properties/workflow").is_some(),
"object create spec should expose workflow property: {schema}"
);
assert!(
object_variant.pointer("/properties/goal_file").is_some(),
"object create spec should expose goal_file property: {schema}"
);
assert!(
object_variant
.get("required")
.and_then(Value::as_array)
.is_some_and(|required| required.iter().any(|name| name == "workflow")),
"object create spec should require workflow: {schema}"
);
.find(|definition| definition.name == "fabro_run_create")
.unwrap();
let mut expected = definition.parameters.clone();
expected.as_object_mut().unwrap().remove("$schema");
let mut actual = schema;
actual.as_object_mut().unwrap().remove("$schema");
assert_eq!(actual, expected);
assert_eq!(tool.description.as_deref(), Some(definition.description));
}
}

View file

@ -41,7 +41,8 @@ mod run_intent;
mod run_manifest;
mod run_selector;
mod run_title_generation;
pub mod run_tool_manifest;
#[cfg(test)]
mod run_tool_create;
pub mod security_headers;
pub mod serve;
pub mod server;

View file

@ -7,8 +7,8 @@ use fabro_environment::{EnvironmentId, EnvironmentValidationError};
use fabro_manifest::CollectedWorkflowClosure;
use fabro_types::settings::InterpString;
use fabro_types::{
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
WorkflowVersion, WorkflowVersionId,
GitContext, ManifestPath, RunId, RunTarget, SandboxProviderKind, TargetValidationError,
WorkflowPath, WorkflowVersion, WorkflowVersionId,
};
use fabro_workflow::git;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
@ -40,6 +40,14 @@ pub(crate) enum RunIntentAdmissionError {
#[source]
source: fabro_variable::Error,
},
#[error("originating worker run `{run_id}` could not be loaded")]
WorkerRun {
run_id: RunId,
#[source]
source: fabro_store::Error,
},
#[error("originating worker run `{run_id}` was not found")]
WorkerRunNotFound { run_id: RunId },
}
#[derive(Debug, Error)]

View file

@ -0,0 +1,229 @@
// Integration regression for the native run tool using production Git setup.
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use chrono::{TimeZone, Utc};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::{
GitRunTarget, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
RunTarget, RunTimestamps, WorkflowRef, WorkflowVersionId, test_support,
};
use httpmock::Method::{GET, POST};
use httpmock::{HttpMockRequest, HttpMockResponse, MockServer};
use serde_json::json;
use tokio::fs;
#[expect(
clippy::disallowed_methods,
reason = "test fixture setup uses the Git CLI against an isolated temporary repository"
)]
fn run_git(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git command should run");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn test_parent(target: Option<RunTarget>) -> RunProjection {
let mut spec = fabro_types::test_support::test_run_spec();
spec.target = target;
RunProjection::new(String::new(), spec, chrono::Utc::now())
}
async fn mock_parent<'a>(server: &'a MockServer, parent: &RunProjection) -> httpmock::Mock<'a> {
let path = format!("/api/v1/runs/{}/state", parent.spec.id());
let body = serde_json::to_value(parent).unwrap();
server
.mock_async(move |when, then| {
when.method(GET).path(path);
then.status(200).json_body(body);
})
.await
}
#[tokio::test]
async fn run_create_child_checkout_contains_the_parents_pushed_work() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("parent");
let origin = temp.path().join("origin.git");
fs::create_dir(&workspace).await.unwrap();
run_git(temp.path(), &[
"init",
"--bare",
"--quiet",
origin.to_str().unwrap(),
]);
run_git(&workspace, &["init", "--quiet", "--initial-branch", "main"]);
run_git(&workspace, &["config", "user.name", "Fabro Test"]);
run_git(&workspace, &["config", "user.email", "fabro@example.com"]);
fs::write(workspace.join("result.txt"), "original")
.await
.unwrap();
run_git(&workspace, &["add", "."]);
run_git(&workspace, &["commit", "--quiet", "-m", "initial"]);
run_git(&workspace, &[
"remote",
"add",
"origin",
origin.to_str().unwrap(),
]);
run_git(&workspace, &["push", "--quiet", "origin", "main"]);
let base_sha = fabro_workflow::git::head_sha(&workspace).unwrap();
let mut parent = test_parent(Some(RunTarget::Git(GitRunTarget {
repo: "acme/widgets".to_owned(),
branch: "main".to_owned(),
tag: Some("v1.0.0".to_owned()),
sha: Some(base_sha),
})));
let sandbox = fabro_sandbox::local_sandbox(&workspace).await.unwrap();
// Docker and Daytona use this same setup operation to create the run branch.
let git = fabro_sandbox::setup_git(&sandbox, &fabro_sandbox::GitSetupIntent::NewRun {
run_id: parent.spec.id().to_string(),
})
.await
.unwrap();
parent.start = Some(fabro_types::StartRecord {
start_time: chrono::Utc::now(),
run_branch: Some(git.run_branch.clone()),
base_sha: Some(git.base_sha),
});
fs::write(workspace.join("result.txt"), "parent implementation")
.await
.unwrap();
run_git(&workspace, &["add", "."]);
run_git(&workspace, &["commit", "--quiet", "-m", "implement"]);
run_git(&workspace, &["push", "--quiet", "origin", &git.run_branch]);
let server = MockServer::start_async().await;
let state_request = mock_parent(&server, &parent).await;
let client = fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
let workflow_version_id: WorkflowVersionId = fabro_types::BlobHash::new(b"stored").into();
let child_id = RunId::new();
let submitted = Arc::new(Mutex::new(Vec::<fabro_types::RunIntent>::new()));
let captured = Arc::clone(&submitted);
server
.mock_async(move |when, then| {
when.method(POST).path("/api/v1/runs");
then.respond_with(move |request: &HttpMockRequest| {
captured
.lock()
.unwrap()
.push(serde_json::from_str(&request.body_string()).unwrap());
HttpMockResponse::builder()
.status(201)
.header("content-type", "application/json")
.body(serde_json::to_string(&run(child_id, None, 0)).unwrap())
.build()
});
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path(format!("/api/v1/runs/{child_id}"));
then.status(200)
.json_body_obj(&run(child_id, Some(parent.spec.id()), 0));
})
.await;
let params = serde_json::from_value(
json!({"runs":[{"workflow_version_id":workflow_version_id,"start":false}]}),
)
.unwrap();
fabro_tool::create_runs_with_options(
Arc::new(ClientBackend::new(Arc::new(client))),
params,
fabro_tool::CreateRunOptions {
forced_parent_id: Some(parent.spec.id()),
},
)
.await
.unwrap();
state_request.assert_calls_async(1).await;
let intents = submitted.lock().unwrap().clone();
assert_eq!(intents.len(), 1);
assert_eq!(intents[0].workflow_version_id, workflow_version_id);
assert_eq!(intents[0].parent_id, Some(parent.spec.id()));
let RunTarget::Git(target) = &intents[0].target else {
panic!("child should have a Git target")
};
assert_eq!(target.repo, "acme/widgets");
assert_eq!(target.sha, None);
assert_eq!(target.tag, None);
let child = temp.path().join("child");
run_git(temp.path(), &[
"clone",
"--quiet",
"--branch",
&target.branch,
origin.to_str().unwrap(),
child.to_str().unwrap(),
]);
assert_eq!(
fs::read_to_string(child.join("result.txt")).await.unwrap(),
"parent implementation"
);
}
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
run_with_status(run_id, parent_id, children_count, RunStatus::Submitted)
}
fn run_with_status(
run_id: RunId,
parent_id: Option<RunId>,
children_count: u64,
status: RunStatus,
) -> Run {
Run {
id: run_id,
parent_id,
children_count,
title: "Test run".to_string(),
goal: "Test run".to_string(),
workflow: WorkflowRef {
slug: Some("simple".to_string()),
name: Some("Simple".to_string()),
graph_name: None,
node_count: 0,
edge_count: 0,
},
automation: None,
repository: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {
status,
approval: None,
pending_control: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
},
sandbox: None,
models: Vec::new(),
source_directory: Some("/srv/repo".to_string()),
timestamps: RunTimestamps {
created_at: Utc.with_ymd_and_hms(2026, 4, 5, 12, 0, 0).unwrap(),
started_at: None,
last_event_at: None,
completed_at: None,
},
timing: None,
billing: None,
size: fabro_types::RunSize::default(),
ask_fabro: fabro_types::AskFabro::default(),
diff: None,
pull_request: None,
current_question: None,
superseded_by: None,
retried_from: None,
links: RunLinks { web: None },
}
}

View file

@ -1,218 +0,0 @@
use std::path::{Path, PathBuf};
use fabro_api::types;
use fabro_config::{CliLayer, RunGoalLayer, RunLayer};
use fabro_manifest::{ManifestBuildInput, RunOverrideInput};
use fabro_tool::{ToolError, ToolResult, ValidatedCreateRunSpec};
use fabro_types::settings::interp::InterpString;
use crate::manifest_validation;
/// Build and validate a run manifest for the `fabro_run_create` tool.
///
/// Validation is structural. The caller is a client — an MCP server or a run
/// worker — whose catalog is its own, not the server's, so judging model and
/// provider availability here would reject workflows the server can run.
pub fn build_run_tool_manifest(
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(&spec.workflow),
cwd: cwd.to_path_buf(),
run_overrides: run_tool_run_overrides(spec),
cli_overrides: Some(CliLayer::default()),
input_overrides: spec.inputs.clone(),
args: run_tool_manifest_args(spec),
environment_defaults: fabro_environment::seeded_catalog_layer(),
user_settings_path: Some(user_settings_path.to_path_buf()),
})
.map_err(|err| ToolError::from_anyhow(&err))?;
let mut validation =
manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)
.map_err(|err| ToolError::from_anyhow(&err))?;
manifest_validation::promote_template_undefined_variables_to_errors(&mut validation);
if !validation.ok {
return Err(ToolError::message("workflow manifest validation failed"));
}
Ok(built.manifest)
}
pub fn run_tool_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::ManifestArgs> {
let mut input = spec
.inputs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
input.sort();
let mut label = spec
.labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
label.sort();
let payload = types::ManifestArgs {
auto_approve: spec.auto_approve.filter(|value| *value),
dry_run: spec.dry_run.filter(|value| *value),
input,
label,
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
provider: spec.provider.clone(),
environment: spec.environment.clone(),
verbose: None,
};
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
}
pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer> {
let mut run = fabro_manifest::build_run_overrides(RunOverrideInput {
goal: spec.goal.as_deref(),
model: spec.model.as_deref(),
provider: spec.provider.as_deref(),
environment: spec.environment.as_deref(),
preserve_sandbox: spec.preserve_sandbox,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,
labels: spec.labels.clone(),
});
if let Some(goal_file) = spec.goal_file.as_ref() {
run.goal = Some(RunGoalLayer::File {
file: InterpString::parse(&goal_file.to_string_lossy()),
});
}
(run.goal.is_some()
|| !run.metadata.is_empty()
|| run.model.is_some()
|| run.environment.is_some()
|| run.execution.is_some())
.then_some(run)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_tool::CreateRunSpec;
use serde_json::json;
use super::*;
fn create_run_spec(workflow: &str) -> ValidatedCreateRunSpec {
ValidatedCreateRunSpec::try_from(CreateRunSpec {
workflow: workflow.to_string(),
parent_id: None,
cwd: None,
goal: None,
goal_file: None,
inputs: HashMap::new(),
labels: HashMap::new(),
model: None,
provider: None,
environment: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
start: None,
})
.expect("create spec should validate")
}
/// The tool runs on a client, whose catalog is not the server's, so a
/// server-owned model must reach the server rather than fail here.
#[expect(
clippy::disallowed_methods,
reason = "sync test writes one workflow fixture before building the manifest"
)]
#[test]
fn server_owned_provider_is_not_rejected_by_tool_manifest_validation() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let workflow = dir.path().join("server-model.fabro");
std::fs::write(
&workflow,
r#"digraph ServerModel {
graph [goal="Use a server-owned model"]
start [shape=Mdiamond]
work [prompt="Do work", model="private-model", provider="server-only"]
exit [shape=Msquare]
start -> work -> exit
}"#,
)
.expect("workflow fixture should be written");
let manifest = build_run_tool_manifest(
&create_run_spec(&workflow.to_string_lossy()),
dir.path(),
&dir.path().join("settings.toml"),
)
.expect("tool validation should leave provider availability to the server");
let encoded = serde_json::to_string(&manifest).expect("manifest should serialize");
assert!(
encoded.contains("server-only"),
"the authored provider should survive into the manifest: {encoded}"
);
}
#[test]
fn manifest_args_preserve_input_provenance() {
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
workflow: "simple".to_string(),
parent_id: None,
cwd: None,
goal: None,
goal_file: None,
inputs: HashMap::from([
("count".to_string(), json!(3).into()),
("decision".to_string(), json!("approve").into()),
]),
labels: HashMap::new(),
model: None,
provider: None,
environment: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
start: None,
})
.expect("create spec should validate");
let args = run_tool_manifest_args(&spec).expect("input args should be present");
assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]);
}
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test]
fn run_overrides_preserve_goal_file_as_file_goal() {
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
workflow: "implement-plan".to_string(),
parent_id: None,
cwd: None,
goal: None,
goal_file: Some(PathBuf::from("plans/ship-it.md")),
inputs: HashMap::new(),
labels: HashMap::new(),
model: None,
provider: None,
environment: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
start: None,
})
.expect("create spec with goal_file should validate");
let run = run_tool_run_overrides(&spec).expect("goal_file should produce run overrides");
let Some(fabro_config::RunGoalLayer::File { file }) = run.goal else {
panic!("goal_file should become a file goal override");
};
assert_eq!(file.as_source(), "plans/ship-it.md");
}
}

View file

@ -641,6 +641,9 @@ pub(crate) async fn create_run_from_intent(
Ok(id) => id,
Err(error) => return run_intent_admission_error(error.into()),
};
if let Err(error) = validate_intent_actor_target(&state, &actor, &target).await {
return run_intent_admission_error(error);
}
let blobs = state.store_ref().blobs();
let version_store = fabro_workflow_version::WorkflowVersionStore::new(blobs);
let closure = match version_store.get_closure(&intent.workflow_version_id).await {
@ -985,6 +988,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
match &error {
RunIntentAdmissionError::VersionStore { .. }
| RunIntentAdmissionError::VariableSnapshot { .. }
| RunIntentAdmissionError::WorkerRun { .. }
| RunIntentAdmissionError::Environment(EnvironmentSelectionError::CredentialStore {
..
}) => {
@ -1003,6 +1007,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
}
RunIntentAdmissionError::Target(_)
| RunIntentAdmissionError::FolderTarget(_)
| RunIntentAdmissionError::WorkerRunNotFound { .. }
| RunIntentAdmissionError::Environment(_) => {}
}
@ -1076,9 +1081,46 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
"failed to load run variables",
"variable_store_error",
),
RunIntentAdmissionError::WorkerRun { .. } => intent_error(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to inspect originating worker run",
"worker_run_store_error",
),
RunIntentAdmissionError::WorkerRunNotFound { .. } => intent_error(
StatusCode::NOT_FOUND,
"originating worker run not found",
"worker_run_not_found",
),
}
}
async fn validate_intent_actor_target(
state: &AppState,
actor: &Principal,
target: &RunTarget,
) -> Result<(), RunIntentAdmissionError> {
let (Principal::Worker { run_id }, RunTarget::Folder { .. }) = (actor, target) else {
return Ok(());
};
let projection = state
.stores
.runs
.load_run_projection(run_id)
.await
.map_err(|source| RunIntentAdmissionError::WorkerRun {
run_id: *run_id,
source,
})?
.ok_or(RunIntentAdmissionError::WorkerRunNotFound { run_id: *run_id })?;
if !projection.spec.settings.run.environment.provider.is_local() {
return Err(EnvironmentSelectionError::TargetUnsupported {
detail: "folder targets created by a worker require a Local parent environment",
}
.into());
}
Ok(())
}
fn select_intent_environment_id(
state: &AppState,
value: &str,

View file

@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
@ -749,10 +748,8 @@ async fn build_agent(
.map_err(AskFabroBuildError::Agent)?;
let backend = ClientBackend::new(Arc::new(api_client)).with_run_scope(run_id);
let services = FabroRunToolServices {
backend: Arc::new(backend),
current_run_id: run_id,
base_cwd: PathBuf::new(),
user_settings_path: PathBuf::new(),
backend: Arc::new(backend),
current_run_id: run_id,
};
let run_tools = register_named_fabro_run_tools(&services, ASK_FABRO_RUN_TOOL_NAMES);
let selector = format!("{provider_id}/{model}");

View file

@ -4494,6 +4494,121 @@ enabled = false
);
}
#[tokio::test]
async fn run_tools_worker_cannot_select_server_folder_from_clone_based_parent() {
let dir = tempfile::tempdir().unwrap();
let missing_target = dir.path().join("missing");
let (state, app) = jwt_auth_app();
let user_token = issue_test_user_jwt();
let parent_run_id = create_run_with_bearer(&app, &user_token).await;
let worker_token = issue_test_run_tools_worker_token(&parent_run_id);
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let mut intent = folder_intent(workflow_version_id, missing_target.to_string_lossy());
intent["environment_id"] = json!("local");
intent["parent_id"] = json!(parent_run_id);
let response = app
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&worker_token,
&intent,
))
.await
.unwrap();
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
assert_eq!(
body["errors"][0]["detail"],
"folder targets created by a worker require a Local parent environment"
);
assert_eq!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.len(),
1,
"the rejected child must not be persisted"
);
}
#[tokio::test]
async fn run_tools_worker_folder_target_from_missing_parent_run_is_not_found() {
let dir = tempfile::tempdir().unwrap();
let (state, app) = jwt_auth_app();
let worker_token = issue_test_run_tools_worker_token(&RunId::new());
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let mut intent = folder_intent(workflow_version_id, dir.path().to_string_lossy());
intent["environment_id"] = json!("local");
let response = app
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&worker_token,
&intent,
))
.await
.unwrap();
let body = response_json!(response, StatusCode::NOT_FOUND).await;
assert_eq!(body["errors"][0]["code"], "worker_run_not_found");
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn run_tools_worker_can_select_server_folder_from_local_parent() {
let dir = tempfile::tempdir().unwrap();
let (state, app) = jwt_auth_app();
let user_token = issue_test_user_jwt();
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let mut parent_intent = folder_intent(workflow_version_id, dir.path().to_string_lossy());
parent_intent["environment_id"] = json!("local");
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&user_token,
&parent_intent,
))
.await
.unwrap();
let parent = response_json!(response, StatusCode::CREATED).await;
let parent_run_id = parent["id"].as_str().unwrap().parse::<RunId>().unwrap();
let worker_token = issue_test_run_tools_worker_token(&parent_run_id);
let mut child_intent = folder_intent(workflow_version_id, dir.path().to_string_lossy());
child_intent["environment_id"] = json!("local");
child_intent["parent_id"] = json!(parent_run_id);
let response = app
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&worker_token,
&child_intent,
))
.await
.unwrap();
let child = response_json!(response, StatusCode::CREATED).await;
assert_eq!(child["parent_id"], parent_run_id.to_string());
assert_eq!(child["lifecycle"]["status"]["kind"], "submitted");
}
#[tokio::test]
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
let state = TestAppStateBuilder::new()
@ -19812,3 +19927,57 @@ async fn workflow_version_registration_requires_user_or_run_tools_capability() {
.is_none()
);
}
#[tokio::test]
async fn run_tools_worker_registers_contents_then_creates_by_version_id() {
let (state, app) = jwt_auth_app();
let parent_id = create_run_with_bearer(&app, &issue_test_user_jwt()).await;
let token = issue_test_run_tools_worker_token(&parent_id);
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
"/workflow-versions",
&token,
&json!({
"entrypoint": "child.fabro",
"files": {"child.fabro": MINIMAL_DOT},
"workflow_dependencies": {}
}),
))
.await
.unwrap();
let registered = response_json!(response, StatusCode::CREATED).await;
let response = app
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&token,
&json!({
"workflow_version_id": registered["workflow_version_id"],
"target": {"kind": "none"},
"args": {"dry_run": true},
"parent_id": parent_id,
"goal": "A child created from sandbox-supplied contents"
}),
))
.await
.unwrap();
let child = response_json!(response, StatusCode::CREATED).await;
assert_eq!(child["parent_id"], parent_id.to_string());
assert_eq!(child["lifecycle"]["status"]["kind"], "submitted");
let child_id = child["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.load_run_projection(&child_id)
.await
.unwrap()
.unwrap();
assert_eq!(
serde_json::to_value(projection.spec.workflow_version_id).unwrap(),
registered["workflow_version_id"]
);
assert_eq!(projection.spec.target, Some(RunTarget::None {}));
assert!(projection.start.is_none());
}

View file

@ -17,10 +17,13 @@ use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
use fabro_config::project::{self, WorkflowLocation, discover_project_config};
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
use fabro_config::run::{
parse_run_layer_from_settings_toml, resolve_run_goal_from_layer,
resolve_run_goal_from_namespace,
};
use fabro_config::{
CliLayer, EnvironmentLayer, EnvironmentLifecycleLayer, MergeMap, ReplaceMap,
RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunScmLayer,
WorkflowSettingsBuilder,
};
use fabro_graphviz::graph::AttrValue;
@ -31,7 +34,7 @@ use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
use fabro_types::{
DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, ManifestPath, RunTarget,
WorkflowSettings,
SandboxProviderKind, WorkflowSettings,
};
use fabro_workflow::git::{self, GitSyncStatus};
pub use fabro_workflow_version::CollectedWorkflowClosure;
@ -323,9 +326,29 @@ fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
#[derive(Clone, Debug)]
pub struct GitRunTargetObservation {
pub run_target: Option<GitRunTarget>,
/// Whether the target's exact commit was proven available, and if not, why.
pub exact_commit: ExactCommitStatus,
pub legacy_git_context: GitContext,
}
/// Outcome of proving that the local HEAD commit is available from the
/// canonical origin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExactCommitStatus {
/// A successful push or a direct remote query proved the commit is
/// available; the target carries its SHA.
Available,
/// The local branch has commits the origin does not, and the best-effort
/// push did not publish them.
Unpublished,
/// The local tracking ref matches HEAD, but the origin could not be
/// queried to confirm the commit is there.
Unverified,
/// The workflow configures a `run.scm` repository that is not the
/// checkout's origin, so nothing can be proven about that repository.
ConfiguredOriginMismatch,
}
/// Observe Git facts without choosing an environment or a non-Git target.
///
/// Outer `None` means `repo_path` is not a usable attached checkout. A
@ -341,11 +364,9 @@ pub fn observe_git_run_target(
) -> Option<GitRunTargetObservation> {
let local = inspect_local_git(repo_path, configured_repo_origin_url)?;
let legacy_git_context = local.legacy_git_context;
let mut run_target = github_run_target(
&legacy_git_context.origin_url,
&legacy_git_context.branch,
None,
);
let mut run_target =
github_run_target(&legacy_git_context.origin_url, &legacy_git_context.branch);
let mut exact_commit = ExactCommitStatus::Unpublished;
if let Some(target) = run_target.as_mut() {
let publish_status = publish_manifest_branch_best_effort(
repo_path,
@ -353,20 +374,202 @@ pub fn observe_git_run_target(
local.push_origin_url.as_deref(),
configured_repo_origin_url,
);
target.sha = remotely_available_sha(
let (sha, status) = remotely_available_sha(
repo_path,
&legacy_git_context.branch,
legacy_git_context.sha.as_deref(),
publish_status,
);
target.sha = sha;
exact_commit = status;
}
Some(GitRunTargetObservation {
run_target,
exact_commit,
legacy_git_context,
})
}
/// A canonical run target derived from a caller directory, plus whether a
/// clone-based observation found uncommitted changes the target excludes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DerivedRunTarget {
pub target: RunTarget,
pub dirty_worktree: bool,
}
/// Why a caller directory could not be turned into a canonical run target.
#[derive(Debug, thiserror::Error)]
pub enum RunTargetDerivationError {
#[error("caller working directory is not valid UTF-8: {}", path.display())]
NonUtf8Path { path: PathBuf },
#[error("the caller Git checkout cannot be represented as a canonical GitHub run target")]
Unrepresentable,
#[error(
"the exact local Git commit could not be made available from the canonical GitHub origin; push the commit and try again"
)]
Unpublished,
#[error(
"the canonical GitHub origin could not be queried to confirm the exact local Git commit is available; check network access and credentials for the origin and try again"
)]
Unverified,
#[error(
"the workflow configures a run.scm repository that is not the local checkout's origin; run from a checkout of the configured repository"
)]
ConfiguredOriginMismatch,
#[error("failed to inspect caller working directory {} for Git metadata", path.display())]
Inspect {
path: PathBuf,
#[source]
source: git2::Error,
},
#[error(
"the caller directory resolves to a bare Git repository; clone-based runs require a non-bare checkout with an attached branch"
)]
BareRepository,
#[error(
"the caller Git checkout has no commits; create a commit before using a clone-based environment"
)]
NoCommits,
#[error("failed to inspect the caller Git checkout HEAD")]
Head {
#[source]
source: git2::Error,
},
#[error(
"the caller Git checkout has a detached HEAD; check out a branch before using a clone-based environment"
)]
DetachedHead,
#[error(
"the caller Git checkout does not have a usable attached branch for a clone-based run target"
)]
NoAttachedBranch,
}
/// Derive the canonical run target for `canonical_cwd` under `provider`, the
/// way `fabro run` and `fabro create` do.
///
/// Non-clone providers run against the caller folder. Clone-based providers
/// need an attached GitHub checkout whose exact HEAD is available from the
/// origin, or a directory with no Git metadata at all, which becomes a `none`
/// target. `configured_repo_origin_url` is the workflow's configured `run.scm`
/// repository, when any, and takes precedence over the checkout's own origin.
///
/// # Errors
///
/// Returns the reason a clone-based target could not be derived; every
/// variant's message is written for the CLI caller.
pub fn derive_run_target_for_provider(
provider: &SandboxProviderKind,
canonical_cwd: &Path,
configured_repo_origin_url: Option<&str>,
) -> std::result::Result<DerivedRunTarget, RunTargetDerivationError> {
if !provider.clones_workspace() {
let path = canonical_cwd
.to_str()
.ok_or_else(|| RunTargetDerivationError::NonUtf8Path {
path: canonical_cwd.to_path_buf(),
})?;
return Ok(DerivedRunTarget {
target: RunTarget::Folder {
path: path.to_string(),
},
dirty_worktree: false,
});
}
let Some(observation) = observe_git_run_target(canonical_cwd, configured_repo_origin_url)
else {
return Ok(DerivedRunTarget {
target: none_target_for_unversioned_directory(canonical_cwd)?,
dirty_worktree: false,
});
};
let dirty_worktree = observation.legacy_git_context.dirty == DirtyStatus::Dirty;
let target = observation
.run_target
.ok_or(RunTargetDerivationError::Unrepresentable)?;
if target.sha.is_none() {
return Err(match observation.exact_commit {
ExactCommitStatus::Unverified => RunTargetDerivationError::Unverified,
ExactCommitStatus::ConfiguredOriginMismatch => {
RunTargetDerivationError::ConfiguredOriginMismatch
}
ExactCommitStatus::Available | ExactCommitStatus::Unpublished => {
RunTargetDerivationError::Unpublished
}
});
}
Ok(DerivedRunTarget {
target: RunTarget::Git(target),
dirty_worktree,
})
}
fn none_target_for_unversioned_directory(
canonical_cwd: &Path,
) -> std::result::Result<RunTarget, RunTargetDerivationError> {
let repository = match git2::Repository::discover(canonical_cwd) {
Ok(repository) => repository,
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(RunTarget::None {}),
Err(source) => {
return Err(RunTargetDerivationError::Inspect {
path: canonical_cwd.to_path_buf(),
source,
});
}
};
if repository.is_bare() {
return Err(RunTargetDerivationError::BareRepository);
}
let outcome = match repository.head() {
Err(source)
if matches!(
source.code(),
git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
) =>
{
Err(RunTargetDerivationError::NoCommits)
}
Err(source) => Err(RunTargetDerivationError::Head { source }),
Ok(head) if !head.is_branch() => Err(RunTargetDerivationError::DetachedHead),
Ok(_) => Err(RunTargetDerivationError::NoAttachedBranch),
};
outcome
}
/// The configured `run.scm` GitHub repository for a resolved local workflow.
/// `workflow.toml` values take precedence over the discovered
/// `.fabro/project.toml`, field by field, matching run settings layering.
/// `None` when neither names a repository.
///
/// # Errors
///
/// Returns an error when either config exists but cannot be read or parsed.
pub fn configured_repo_origin_url_for_location(
location: &WorkflowLocation,
) -> Result<Option<String>> {
let workflow = location
.toml
.as_deref()
.map(read_scm_layer)
.transpose()?
.unwrap_or_default();
let project = discover_project_config(&location.dir)?
.as_deref()
.map(read_scm_layer)
.transpose()?
.unwrap_or_default();
let scm = RunScmLayer {
provider: workflow.provider.or(project.provider),
owner: workflow.owner.or(project.owner),
repository: workflow.repository.or(project.repository),
github: workflow.github.or(project.github),
};
Ok(configured_repo_origin_url_from_scm_layer(&scm))
}
struct LocalGitObservation {
push_origin_url: Option<String>,
legacy_git_context: GitContext,
@ -422,14 +625,14 @@ fn build_legacy_git_context(
Some(local.legacy_git_context)
}
fn github_run_target(origin_url: &str, branch: &str, sha: Option<String>) -> Option<GitRunTarget> {
fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
let (owner, repository) = fabro_github::parse_github_owner_repo(origin_url).ok()?;
let slug = GitHubRepositorySlug::try_new(&format!("{owner}/{repository}"))?;
let validated = RunTarget::Git(GitRunTarget {
repo: slug.to_string(),
repo: slug.to_string(),
branch: branch.to_owned(),
tag: None,
sha,
tag: None,
sha: None,
})
.validate()
.ok()?;
@ -441,15 +644,23 @@ fn github_run_target(origin_url: &str, branch: &str, sha: Option<String>) -> Opt
fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
let scm = &settings.run.scm;
if !scm
.provider
.as_deref()
.is_none_or(|provider| provider.eq_ignore_ascii_case("github"))
{
configured_repo_origin_url_from_scm(
scm.provider.as_deref(),
scm.owner.as_deref(),
scm.repository.as_deref(),
)
}
fn configured_repo_origin_url_from_scm(
provider: Option<&str>,
owner: Option<&str>,
repository: Option<&str>,
) -> Option<String> {
if !provider.is_none_or(|provider| provider.eq_ignore_ascii_case("github")) {
return None;
}
let owner = scm.owner.as_deref()?;
let repository = scm.repository.as_deref()?;
let owner = owner?;
let repository = repository?;
if owner.trim().is_empty() || repository.trim().is_empty() {
return None;
}
@ -458,6 +669,22 @@ fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
(!normalized.is_empty()).then_some(normalized)
}
fn configured_repo_origin_url_from_scm_layer(scm: &RunScmLayer) -> Option<String> {
configured_repo_origin_url_from_scm(
scm.provider.as_deref(),
scm.owner.as_deref(),
scm.repository.as_deref(),
)
}
fn read_scm_layer(path: &Path) -> Result<RunScmLayer> {
let source = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let run = parse_run_layer_from_settings_toml(&source)
.with_context(|| format!("failed to parse run settings from {}", path.display()))?;
Ok(run.scm.unwrap_or_default())
}
struct ManifestRepoInfo {
/// The `origin` URL as libgit2 reports it (after `insteadOf` rewrites).
origin_url: Option<String>,
@ -505,6 +732,9 @@ enum BranchPublishStatus {
TrackingRefMatches,
Pushed,
Unavailable,
/// The configured repository is not the checkout's origin, so the push
/// is skipped entirely.
OriginMismatch,
}
/// Best-effort publication of the local branch so clone-based execution can
@ -527,7 +757,7 @@ fn publish_manifest_branch_best_effort(
{
let remote = fabro_github::normalize_repo_origin_url(origin_url);
if remote != repo_origin_url {
return BranchPublishStatus::Unavailable;
return BranchPublishStatus::OriginMismatch;
}
}
@ -547,18 +777,25 @@ fn remotely_available_sha(
branch: &str,
local_sha: Option<&str>,
publish_status: BranchPublishStatus,
) -> Option<String> {
let local_sha = local_sha?;
) -> (Option<String>, ExactCommitStatus) {
let Some(local_sha) = local_sha else {
return (None, ExactCommitStatus::Unpublished);
};
match publish_status {
BranchPublishStatus::Pushed => Some(local_sha.to_owned()),
BranchPublishStatus::Pushed => (Some(local_sha.to_owned()), ExactCommitStatus::Available),
BranchPublishStatus::TrackingRefMatches => {
git::remote_branch_sha_noninteractive(repo_path, "origin", branch)
.ok()
.flatten()
.filter(|remote_sha| remote_sha == local_sha)
.map(|_| local_sha.to_owned())
match git::remote_branch_sha_noninteractive(repo_path, "origin", branch) {
Ok(Some(remote_sha)) if remote_sha == local_sha => {
(Some(local_sha.to_owned()), ExactCommitStatus::Available)
}
Ok(_) => (None, ExactCommitStatus::Unpublished),
// The failure may carry raw Git stderr, so it is neither
// returned nor logged; the status tells callers what to say.
Err(_) => (None, ExactCommitStatus::Unverified),
}
}
BranchPublishStatus::Unavailable => None,
BranchPublishStatus::Unavailable => (None, ExactCommitStatus::Unpublished),
BranchPublishStatus::OriginMismatch => (None, ExactCommitStatus::ConfiguredOriginMismatch),
}
}

View file

@ -32,4 +32,5 @@ toml.workspace = true
[dev-dependencies]
fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] }
httpmock = "0.8"
jsonschema.workspace = true
tempfile = "3"

View file

@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use async_trait::async_trait;
@ -55,13 +54,8 @@ pub trait FabroToolBackend: Send + Sync {
Err(workflow_version_tool_unavailable_error())
}
async fn create_run_from_spec(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId>;
async fn create_run_from_intent(&self, intent: fabro_types::RunIntent)
-> anyhow::Result<RunId>;
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run>;
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
@ -149,15 +143,6 @@ pub(crate) fn workflow_version_tool_unavailable_error() -> anyhow::Error {
.into()
}
pub trait RunManifestBuilder: Send + Sync {
fn build_run_manifest(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest>;
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct RunSummaryResult {
pub run_id: String,
@ -201,7 +186,7 @@ static TOOL_DEFINITIONS: LazyLock<Vec<ToolDefinition>> = LazyLock::new(|| {
),
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.",
"Create runs from registered workflow_version_id values and canonical RunIntent settings. Register contents with fabro_workflow_version_create first. Standalone calls require an explicit target; native workers may inherit the parent target. Starts runs by default.",
),
tool_definition::<crate::FabroRunSearchParams>(
FABRO_RUN_SEARCH_TOOL_NAME,

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,17 @@
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_api::types;
use fabro_types::{
EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord,
PairTranscriptResponse, Run, RunId, RunPairStatusResponse, RunProjection, StageId,
PairTranscriptResponse, Run, RunId, RunIntent, RunPairStatusResponse, RunProjection, StageId,
};
use crate::{FabroToolBackend, RunManifestBuilder, ToolError, common};
use crate::{FabroToolBackend, common};
#[derive(Clone)]
pub struct ClientBackend {
client: Arc<::fabro_client::Client>,
manifest_builder: Option<Arc<dyn RunManifestBuilder>>,
run_scope: Option<RunId>,
workflow_version_packager: Option<Arc<dyn crate::WorkflowVersionPackager>>,
}
@ -23,18 +21,11 @@ impl ClientBackend {
pub fn new(client: Arc<::fabro_client::Client>) -> Self {
Self {
client,
manifest_builder: None,
run_scope: None,
workflow_version_packager: None,
}
}
#[must_use]
pub fn with_manifest_builder(mut self, builder: Arc<dyn RunManifestBuilder>) -> Self {
self.manifest_builder = Some(builder);
self
}
#[must_use]
pub fn with_workflow_version_packager(
mut self,
@ -90,28 +81,12 @@ impl FabroToolBackend for ClientBackend {
Ok(packaged.root_id())
}
async fn create_run_from_spec(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId> {
if let Some(parent_id) = parent_id.as_ref() {
self.ensure_run_scope(parent_id)?;
}
let Some(builder) = self.manifest_builder.as_ref() else {
return Err(ToolError::message(format!(
"{} is not available",
crate::FABRO_RUN_CREATE_TOOL_NAME
))
.into());
};
let mut manifest = builder
.build_run_manifest(spec, cwd, user_settings_path)
.map_err(anyhow::Error::new)?;
manifest.parent_id = parent_id.map(|run_id| run_id.to_string());
self.client.create_run_from_manifest(manifest).await
async fn create_run_from_intent(&self, intent: RunIntent) -> anyhow::Result<RunId> {
anyhow::ensure!(
self.run_scope.is_none(),
"run creation is outside this tool session's run scope"
);
self.client.create_run_from_intent(intent).await
}
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run> {

View file

@ -446,7 +446,6 @@ fn normalize_optional_text(value: Option<&str>) -> Option<String> {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
@ -742,12 +741,9 @@ mod tests {
#[async_trait]
impl FabroToolBackend for MockInteractBackend {
async fn create_run_from_spec(
async fn create_run_from_intent(
&self,
_spec: &crate::ValidatedCreateRunSpec,
_cwd: &Path,
_user_settings_path: &Path,
_parent_id: Option<RunId>,
_intent: fabro_types::RunIntent,
) -> anyhow::Result<RunId> {
unreachable!()
}

View file

@ -20,12 +20,11 @@ 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, FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME, FabroToolBackend,
RunManifestBuilder, RunSummaryResult, ToolDefinition, ToolError, ToolResult, tool_definitions,
RunSummaryResult, ToolDefinition, ToolError, ToolResult, tool_definitions,
};
pub use create::{
CreateRunOptions, CreateRunSpec, CreateRunSpecInput, CreateRunsResult, CreatedRunResult,
FabroRunCreateParams, RunInputValue, ValidatedCreateRunSpec, ValidatedCreateRuns, create_runs,
create_runs_text, create_runs_with_options,
CreateRunOptions, CreateRunSpec, CreateRunsResult, CreatedRunResult, FabroRunCreateParams,
create_runs, create_runs_text, create_runs_with_options,
};
pub use events::{
FabroRunEventsParams, RunEventResult, RunEventsAction, RunEventsResult, ValidatedRunEvents,

View file

@ -3,7 +3,6 @@
use std::sync::Arc;
use fabro_types::RunId;
use pebble_coding_agent::tools::{RegisteredTool, ToolError, ToolSource};
use serde::de::DeserializeOwned;
@ -77,13 +76,9 @@ pub(crate) async fn execute_fabro_run_tool(
}
fabro_tool::FABRO_RUN_CREATE_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunCreateParams>(name, args)?;
ensure_current_run_parent(&params, services.current_run_id)?;
let validated = fabro_tool::ValidatedCreateRuns::try_from(params)?;
let result = fabro_tool::create_runs_with_options(
Arc::clone(&services.backend),
&services.base_cwd,
&services.user_settings_path,
validated,
params,
fabro_tool::CreateRunOptions {
forced_parent_id: Some(services.current_run_id),
},
@ -168,34 +163,6 @@ where
.map_err(|err| fabro_tool::ToolError::message(format!("invalid {name} arguments: {err}")))
}
fn ensure_current_run_parent(
params: &fabro_tool::FabroRunCreateParams,
current_run_id: RunId,
) -> fabro_tool::ToolResult<()> {
let current_parent = current_run_id.to_string();
for run in &params.runs {
let parent_id = match run {
fabro_tool::CreateRunSpecInput::Workflow(_) => None,
fabro_tool::CreateRunSpecInput::Spec(spec) => spec.parent_id.as_deref().map(str::trim),
};
match parent_id {
None => {}
Some("") => {
return Err(fabro_tool::ToolError::message(
"parent_id must be omitted or match the current run; blank parent_id is invalid",
));
}
Some(parent_id) if parent_id == current_parent => {}
Some(parent_id) => {
return Err(fabro_tool::ToolError::message(format!(
"parent_id must be omitted or match the current run {current_parent}; got {parent_id}"
)));
}
}
}
Ok(())
}
fn render_fabro_tool_result<T>(summary: &str, result: &T) -> fabro_tool::ToolResult<String>
where
T: serde::Serialize,
@ -219,6 +186,57 @@ mod tests {
use super::*;
#[tokio::test]
async fn native_run_create_submits_intent_and_enforces_current_parent() {
let server = httpmock::MockServer::start_async().await;
let parent_id = fabro_types::RunId::new();
let version_id: fabro_types::WorkflowVersionId =
fabro_types::BlobHash::new(b"registered workflow").into();
let create = server
.mock_async(|when, then| {
when.method(httpmock::Method::POST)
.path("/api/v1/runs")
.json_body(json!({
"workflow_version_id": version_id,
"target": {"kind":"none"},
"parent_id": parent_id,
"args": {"auto_approve":false}
}));
// Admission rejection proves the native dispatcher reached the
// canonical API without registering or looking up a workflow.
then.status(422).body("native admission rejection");
})
.await;
let state = server
.mock_async(|when, then| {
when.path(format!("/api/v1/runs/{parent_id}/state"));
then.status(500);
})
.await;
let client = fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
let services = FabroRunToolServices {
backend: Arc::new(ClientBackend::new(Arc::new(client))),
current_run_id: parent_id,
};
let name = fabro_tool::FABRO_RUN_CREATE_TOOL_NAME;
let mut args = json!({"runs":[{
"workflow_version_id":version_id,
"target":{"kind":"none"},
"args":{"auto_approve":false}
}]});
let error = execute_fabro_run_tool(name, args.clone(), &services)
.await
.unwrap_err();
assert!(error.to_string().contains("native admission rejection"));
args["runs"][0]["parent_id"] = json!(fabro_types::RunId::new());
let error = execute_fabro_run_tool(name, args, &services)
.await
.unwrap_err();
assert!(error.to_string().contains("match the current run"));
create.assert_calls_async(1).await;
state.assert_calls_async(0).await;
}
struct SingleGraphPackager;
#[async_trait]
@ -257,13 +275,11 @@ mod tests {
.await;
let client = fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
let services = FabroRunToolServices {
backend: Arc::new(
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(),
current_run_id: "01KRBZW4DW0000000000000002".parse().unwrap(),
};
let name = fabro_tool::FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME;
assert_eq!(register_named_fabro_run_tools(&services, &[name]).len(), 1);

View file

@ -77,10 +77,8 @@ impl RunLocations {
#[derive(Clone)]
pub struct FabroRunToolServices {
pub backend: Arc<dyn fabro_tool::FabroToolBackend>,
pub current_run_id: RunId,
pub base_cwd: PathBuf,
pub user_settings_path: PathBuf,
pub backend: Arc<dyn fabro_tool::FabroToolBackend>,
pub current_run_id: RunId,
}
/// Services shared across workflow phases.