diff --git a/Cargo.lock b/Cargo.lock
index a29e0d55f..ef6490fd1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3176,6 +3176,7 @@ dependencies = [
"fabro-workflow-version",
"futures",
"httpmock",
+ "jsonschema",
"schemars 1.2.1",
"serde",
"serde_json",
diff --git a/docs/internal/mcp-server-qa-test-plan.md b/docs/internal/mcp-server-qa-test-plan.md
index c5d7b8e74..920ff0754 100644
--- a/docs/internal/mcp-server-qa-test-plan.md
+++ b/docs/internal/mcp-server-qa-test-plan.md
@@ -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.
diff --git a/docs/public/agents/mcp.mdx b/docs/public/agents/mcp.mdx
index e769a4bf3..98f1de65d 100644
--- a/docs/public/agents/mcp.mdx
+++ b/docs/public/agents/mcp.mdx
@@ -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`.
+
+
+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.
+
+
+Creation persists a submitted run. A separate start request follows by default;
+set `start: false` to start later. Batches contain 1–50 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.
diff --git a/docs/public/execution/child-runs.mdx b/docs/public/execution/child-runs.mdx
index 1b85039c1..95881df18 100644
--- a/docs/public/execution/child-runs.mdx
+++ b/docs/public/execution/child-runs.mdx
@@ -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/`). 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
diff --git a/lib/apps/fabro-cli/src/commands/mcp/mod.rs b/lib/apps/fabro-cli/src/commands/mcp/mod.rs
index ef5725f72..c6a63a5e1 100644
--- a/lib/apps/fabro-cli/src/commands/mcp/mod.rs
+++ b/lib/apps/fabro-cli/src/commands/mcp/mod.rs
@@ -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 {
diff --git a/lib/apps/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs
index 3b4db80a2..10365ca4a 100644
--- a/lib/apps/fabro-cli/src/commands/run/create.rs
+++ b/lib/apps/fabro-cli/src/commands/run/create.rs
@@ -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 {
- 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"
- )
-}
diff --git a/lib/apps/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs
index 506f2d83a..e96388091 100644
--- a/lib/apps/fabro-cli/src/commands/run/runner.rs
+++ b/lib/apps/fabro-cli/src/commands/run/runner.rs
@@ -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 {
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 {
- 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
diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs
index c07daff5a..246e7b99b 100644
--- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs
+++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs
@@ -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::>();
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
}]
}),
diff --git a/lib/apps/fabro-mcp-server/src/lib.rs b/lib/apps/fabro-mcp-server/src/lib.rs
index d345ec5c1..6a58b29b8 100644
--- a/lib/apps/fabro-mcp-server/src/lib.rs
+++ b/lib/apps/fabro-mcp-server/src/lib.rs
@@ -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 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", &"")
- .field("config_path", &self.config_path)
- .field("cwd", &self.cwd)
.finish()
}
}
diff --git a/lib/apps/fabro-mcp-server/src/manifest_builder.rs b/lib/apps/fabro-mcp-server/src/manifest_builder.rs
deleted file mode 100644
index b09be76de..000000000
--- a/lib/apps/fabro-mcp-server/src/manifest_builder.rs
+++ /dev/null
@@ -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 {
- run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
- }
-}
diff --git a/lib/apps/fabro-mcp-server/src/server.rs b/lib/apps/fabro-mcp-server/src/server.rs
index fba1fc5ac..ee98aa6b0 100644
--- a/lib/apps/fabro-mcp-server/src/server.rs
+++ b/lib/apps/fabro-mcp-server/src/server.rs
@@ -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,
backend: Arc>>,
- cwd: PathBuf,
tool_router: ToolRouter,
}
@@ -82,11 +79,9 @@ impl ServerHandler for FabroMcpServer {
#[tool_router(router = tool_router)]
impl FabroMcpServer {
pub(crate) fn new(settings: Arc) -> 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,
) -> Result {
- 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
})
.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));
}
}
diff --git a/lib/apps/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs
index 98f7a2c77..5b9ee5447 100644
--- a/lib/apps/fabro-server/src/lib.rs
+++ b/lib/apps/fabro-server/src/lib.rs
@@ -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;
diff --git a/lib/apps/fabro-server/src/run_intent.rs b/lib/apps/fabro-server/src/run_intent.rs
index 68aa7c7a8..32c6f4eea 100644
--- a/lib/apps/fabro-server/src/run_intent.rs
+++ b/lib/apps/fabro-server/src/run_intent.rs
@@ -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)]
diff --git a/lib/apps/fabro-server/src/run_tool_create.rs b/lib/apps/fabro-server/src/run_tool_create.rs
new file mode 100644
index 000000000..503236f61
--- /dev/null
+++ b/lib/apps/fabro-server/src/run_tool_create.rs
@@ -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) -> 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::::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, 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,
+ 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 },
+ }
+}
diff --git a/lib/apps/fabro-server/src/run_tool_manifest.rs b/lib/apps/fabro-server/src/run_tool_manifest.rs
deleted file mode 100644
index a27f92e05..000000000
--- a/lib/apps/fabro-server/src/run_tool_manifest.rs
+++ /dev/null
@@ -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 {
- 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 {
- let mut input = spec
- .inputs
- .iter()
- .map(|(key, value)| format!("{key}={value}"))
- .collect::>();
- input.sort();
- let mut label = spec
- .labels
- .iter()
- .map(|(key, value)| format!("{key}={value}"))
- .collect::>();
- 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 {
- 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");
- }
-}
diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs
index 1066b0cce..b2de8d3c6 100644
--- a/lib/apps/fabro-server/src/server/handler/runs.rs
+++ b/lib/apps/fabro-server/src/server/handler/runs.rs
@@ -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,
diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs
index 3f63337b5..9b3a01f58 100644
--- a/lib/apps/fabro-server/src/server/handler/sessions.rs
+++ b/lib/apps/fabro-server/src/server/handler/sessions.rs
@@ -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}");
diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs
index a0a4533f6..1b86f0c90 100644
--- a/lib/apps/fabro-server/src/server/tests.rs
+++ b/lib/apps/fabro-server/src/server/tests.rs
@@ -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::().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::().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());
+}
diff --git a/lib/components/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs
index f020b5828..43a028dd1 100644
--- a/lib/components/fabro-manifest/src/lib.rs
+++ b/lib/components/fabro-manifest/src/lib.rs
@@ -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,
+ /// 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 {
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 {
+ 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 {
+ 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