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 da356b8a5..98f1de65d 100644
--- a/docs/public/agents/mcp.mdx
+++ b/docs/public/agents/mcp.mdx
@@ -86,84 +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. Selectors are compatible only when the MCP process and Fabro operation share the native filesystem:
-
-```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
+ }]
}
```
-For agent-authored content or callers without a shared filesystem, send an inline package by value. `entrypoint` and every `files` key are portable paths inside that package:
+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.
-```json
-{
- "runs": [
- {
- "workflow": {
- "kind": "inline",
- "entrypoint": "workflows/review/workflow.fabro",
- "files": {
- "workflows/review/workflow.fabro": "digraph Review { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
- }
- },
- "target": {
- "kind": "git",
- "repo": "acme/widgets",
- "branch": "feature/checkout"
- }
- }
- ]
-}
-```
+`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`.
-You can also reuse an exact immutable workflow version without uploading content again:
+
+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.
+
-```json
-{
- "runs": [
- {
- "workflow": {
- "kind": "stored",
- "workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
- },
- "target": { "kind": "none" },
- "start": false
- }
- ]
-}
-```
-
-Workflow source and run target are independent. Object-form calls can provide an explicit Git, `none`, or folder target. A workflow agent inherits its parent run's canonical target when `target` is omitted; for a Git parent that means its repository and current execution branch (normally `fabro/run/`). The original input branch and pinned commit/tag are not inherited. Push the parent's changes before creating the child. If run branches are disabled, the child follows the original input branch; if an enabled run branch is not available yet, provide an explicit target.
-
-Folder targets are available only to standalone MCP and Local workflow agents with a shared host filesystem; Docker and Daytona parents cannot select a server-host folder, even by naming a Local child environment.
-
-Standalone MCP calls using a stored workflow ID must supply an explicit `target`: the stored repository configuration is not available for implicit derivation. For selector and inline sources, when `target` is omitted, standalone MCP derives it from the selected environment the way `fabro run` does: a Local environment targets the working directory as a folder, and a Docker or Daytona environment targets the attached GitHub checkout only when it can prove that the exact local HEAD is available from the canonical origin (a directory with no Git metadata runs with no workspace); otherwise, push the commit or provide an explicit target. Docker and Daytona agents must send inline files or a stored version ID—never send a sandbox path as a selector, because the run worker cannot use that path to read the sandbox's files.
-
-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. Like selectors, `goal_file` requires a native/shared filesystem; Docker and Daytona agents must send `goal` text by value.
-
-Creation and execution remain separate operations. `fabro_run_create` first creates a durable run, then requests one start by default. Set `start: false` to leave the new run submitted without requesting execution.
-
-`fabro_run_create` transmits only the values in the request. The MCP server's `~/.fabro/settings.toml` `[run]` defaults, and any project or machine run settings, are not applied to the created run, matching `fabro run`. Keep workflow-owned behavior in `workflow.toml`, and pass explicit `auto_approve`, `dry_run`, `model`, `provider`, `environment`, or `preserve_sandbox` values in the spec when a run needs them.
+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 752cb62e4..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,101 +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.
-Selector strings and selector paths are available only when the parent agent runs in a genuinely shared Local filesystem context. Docker and Daytona agents must send the workflow bytes inline or reuse an exact stored workflow version; a path inside their sandbox is not a usable selector on the worker host.
+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.
-Send agent-authored workflow content with a strict inline source:
+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`.
-```json
-{
- "runs": [
- {
- "workflow": {
- "kind": "inline",
- "entrypoint": "child/workflow.fabro",
- "files": {
- "child/workflow.fabro": "digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
- }
- },
- "goal": "Run the workflow content supplied by this parent."
- }
- ]
-}
-```
+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`.
-Reuse content already registered with Fabro by supplying its exact immutable ID:
-
-```json
-{
- "runs": [
- {
- "workflow": {
- "kind": "stored",
- "workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
- },
- "target": { "kind": "none" },
- "start": false
- }
- ]
-}
-```
-
-Workflow content and workspace target are separate choices. If `target` is omitted, a child inherits the parent's canonical target: a `none` or folder target as-is, and a Git target's repository and current execution branch (normally `fabro/run/`). Push the parent's changes before creating the child; the child checks out that branch's current remote HEAD. The original input branch and pinned commit/tag are not carried over. If run branches are disabled, the child follows the original input branch. If an enabled run branch is not available yet, provide an explicit target. Pass an explicit Git target with a `sha` to pin a child. An explicit Git, `none`, or folder target overrides that inheritance while the current run remains the forced parent. Folder targets require a Local parent with a shared host filesystem; Docker and Daytona parents cannot select a server-host folder, even by naming a Local child environment.
-
-`goal_file` is also a shared-filesystem feature. Local agents can read it relative to the operation `cwd`; Docker and Daytona agents must send the resolved `goal` text by value.
-
-Like `fabro run`, `fabro_run_create` transmits only what the request names. Run defaults from the caller's `~/.fabro/settings.toml` `[run]` table, and project or machine run settings, are not applied to the created run. Put workflow-owned behavior in the workflow's `workflow.toml`, and pass explicit `auto_approve`, `dry_run`, `model`, `provider`, `environment`, or `preserve_sandbox` values in the spec when a child needs them. See [Run Configuration](/execution/run-configuration) for the settings layering.
-
-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.
+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 9e600cf16..10365ca4a 100644
--- a/lib/apps/fabro-cli/src/commands/run/create.rs
+++ b/lib/apps/fabro-cli/src/commands/run/create.rs
@@ -77,7 +77,7 @@ pub(crate) async fn create_run(
target,
dirty_worktree,
} = fabro_manifest::derive_run_target_for_provider(
- environment.settings.provider,
+ &environment.settings.provider,
&canonical_cwd,
configured_repo_origin_url.as_deref(),
)?;
diff --git a/lib/apps/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs
index cd01459f2..e96388091 100644
--- a/lib/apps/fabro-cli/src/commands/run/runner.rs
+++ b/lib/apps/fabro-cli/src/commands/run/runner.rs
@@ -6,7 +6,6 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_client::ServerTarget;
-use fabro_config::user::default_workflows_dir;
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WORKER_CONTROL_INVALID_CURSOR_REASON,
@@ -15,13 +14,10 @@ use fabro_interview::{
WorkerControlMessage,
};
use fabro_manifest::SuppliedWorkflowVersionPackager;
-use fabro_server::run_tool_create::ServerRunCreateAdapter;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
-use fabro_types::settings::run::{EnvironmentProvider, RunMode, RunNamespace};
-use fabro_types::{
- ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
-};
+use fabro_types::settings::run::{RunMode, RunNamespace};
+use fabro_types::{ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId};
use fabro_vault::{SecretStore, Vault};
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@@ -99,14 +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.settings.run.environment.provider,
- run_spec.source_directory.as_deref(),
- &run_dir,
- )
+ build_fabro_run_tool_services(worker_token, client.clone_for_reuse(), run_id)
} else {
None
};
@@ -232,24 +221,15 @@ fn build_fabro_run_tool_services(
worker_token: &str,
client: fabro_client::Client,
current_run_id: RunId,
- provider: EnvironmentProvider,
- source_directory: Option<&str>,
- run_dir: &Path,
) -> Option {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
- .with_run_create_adapter(Arc::new(ServerRunCreateAdapter::worker(
- provider,
- current_run_id,
- Some(default_workflows_dir()),
- )))
.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),
})
}
@@ -1189,7 +1169,6 @@ fn install_signal_handlers(
reason = "This test module prefers explicit type paths over extra imports."
)]
mod tests {
- use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
@@ -1199,14 +1178,13 @@ mod tests {
use fabro_interview::{
AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope,
};
- use fabro_server::run_tool_create::ServerRunCreateAdapter;
use fabro_types::run_event::{
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
RunFailedProps, RunStatusTransitionProps,
};
use fabro_types::{
AuthMethod, EventBody, FailureCategory, FailureDetail, FailureReason, IdpIdentity,
- Principal, QuestionType, RunFailure, RunTarget, SuccessReason, WorkflowVersionId, fixtures,
+ Principal, QuestionType, RunFailure, SuccessReason, fixtures,
};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::event::RunEventSink;
@@ -1264,45 +1242,6 @@ mod tests {
));
}
- #[tokio::test]
- async fn fabro_run_create_worker_adapter_preserves_explicit_target_without_host_reads() {
- use fabro_tool::{FabroRunCreateParams, RunCreateAdapter, ValidatedCreateRuns};
- use fabro_types::settings::run::EnvironmentProvider;
-
- let temp = tempfile::tempdir().unwrap();
- let workflow_version_id: WorkflowVersionId =
- fabro_types::BlobHash::new(b"stored workflow").into();
- let inherited = RunTarget::None {};
- let params: FabroRunCreateParams = serde_json::from_value(serde_json::json!({
- "runs": [{
- "workflow": {
- "kind": "stored",
- "workflow_version_id": workflow_version_id
- },
- "target": inherited
- }]
- }))
- .unwrap();
- let spec = ValidatedCreateRuns::try_from(params)
- .unwrap()
- .runs
- .remove(0);
- let adapter = ServerRunCreateAdapter::worker(
- EnvironmentProvider::Docker,
- fabro_types::RunId::new(),
- Some(temp.path().join("workflows")),
- );
- let client = fabro_client::Client::new_no_proxy("http://127.0.0.1:9").unwrap();
-
- let prepared = adapter
- .prepare(&client, &spec, Path::new("/host/that-must-not-be-read"))
- .await
- .unwrap();
-
- assert_eq!(prepared.workflow_version_id, workflow_version_id);
- assert_eq!(prepared.target, inherited);
- }
-
fn worker_token_with_claims(claims: &serde_json::Value) -> String {
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs
index 49f511e4c..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,16 +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,
+ "workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
- "dry_run": true,
- "auto_approve": true,
- "labels": { "source": "mcp-test" }
+ "args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }}
}]
}),
)
@@ -805,16 +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,
+ "workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
- "dry_run": true,
- "auto_approve": true,
- "labels": { "source": "mcp-default-server-test" },
+ "args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-default-server-test" }},
"start": false
}]
}),
@@ -1876,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(
@@ -1896,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!({
@@ -1919,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;
@@ -1930,93 +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");
- context.git_init();
- run_git(&context.temp_dir, &["config", "user.name", "Fabro Test"]);
- run_git(&context.temp_dir, &[
- "config",
- "user.email",
- "fabro@example.com",
- ]);
- run_git(&context.temp_dir, &["add", "simple.fabro"]);
- run_git(&context.temp_dir, &["commit", "--quiet", "-m", "fixture"]);
- run_git(&context.temp_dir, &[
- "remote",
- "add",
- "origin",
- "https://github.com/fabro-sh/fabro.git",
- ]);
- let test_origin = context.temp_dir.join("origin.git");
- run_git(&context.temp_dir, &[
- "init",
- "--bare",
- "--quiet",
- test_origin
- .to_str()
- .expect("test origin path should be UTF-8"),
- ]);
- let push_url = format!("file://{}", test_origin.display());
- run_git(&context.temp_dir, &[
- "remote", "set-url", "--push", "origin", &push_url,
- ]);
- let workflow_version_id =
- fabro_manifest::collect_workflow_versions(&workflow, &context.temp_dir)
- .expect("workflow fixture should package")
- .root_id();
- 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!(
- harness
- .api_requests
- .contains("GET /api/v1/environments/default"),
- "the shorthand request should reach environment lookup authentication"
- );
- assert!(
- !harness
- .api_requests
- .contains("POST /api/v1/workflow-versions"),
- "an unauthenticated shorthand request must not attempt registration"
- );
- assert!(
- !harness.workflow_version_exists(workflow_version_id).await,
- "an unauthenticated shorthand request must not register a workflow version"
- );
- assert!(
- !harness.api_requests.contains("POST /api/v1/runs"),
- "an unauthenticated shorthand request must not reach run creation"
- );
- 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")]
@@ -2824,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,
@@ -2842,50 +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,
+ "workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
- "dry_run": true,
- "auto_approve": true,
- "labels": { "source": "mcp-test" },
+ "args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }},
"start": start
}]
}),
@@ -2897,19 +2853,6 @@ async fn create_mcp_run(client: &McpClient, workflow: PathBuf, start: bool) -> S
.to_string()
}
-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 seed_oauth_auth(
home_dir: &Path,
target: &fabro_client::ServerTarget,
diff --git a/lib/apps/fabro-cli/tests/it/support/auth_harness.rs b/lib/apps/fabro-cli/tests/it/support/auth_harness.rs
index d9fb34389..315465096 100644
--- a/lib/apps/fabro-cli/tests/it/support/auth_harness.rs
+++ b/lib/apps/fabro-cli/tests/it/support/auth_harness.rs
@@ -25,9 +25,7 @@ use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{RouterOptions, build_router_with_options};
use fabro_server::test_support::TestAppStateBuilder;
use fabro_static::EnvVars;
-use fabro_store::Database;
use fabro_test::{GitHubAppState, TestContext, apply_test_isolation};
-use fabro_types::WorkflowVersionId;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
@@ -44,7 +42,6 @@ pub(crate) const TEST_DEV_TOKEN: &str =
pub(crate) struct RealAuthHarness {
pub(crate) api_base_url: String,
api_server: RunningHttpServer,
- store: Arc,
twin: fabro_test::TwinGitHub,
pub(crate) api_requests: ListenerRequestLog,
}
@@ -87,13 +84,11 @@ impl RealAuthHarness {
if let Some(token) = dev_token.clone() {
secrets.insert("FABRO_DEV_TOKEN".to_string(), token);
}
- let (store, artifact_store) = fabro_server::test_support::test_store_bundle();
let state = TestAppStateBuilder::new()
.runtime_settings(settings, RunLayer::default())
.max_concurrent_runs(5)
.env_lookup(|_| None)
.server_secret_env(secrets)
- .store_bundle(Arc::clone(&store), artifact_store)
.vault_entries([
("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str()),
(EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
@@ -118,7 +113,6 @@ impl RealAuthHarness {
Self {
api_base_url,
api_server,
- store,
twin,
api_requests,
}
@@ -128,15 +122,6 @@ impl RealAuthHarness {
format!("{}/api/v1", self.api_base_url)
}
- pub(crate) async fn workflow_version_exists(&self, id: WorkflowVersionId) -> bool {
- let blob_hash = id.into();
- self.store
- .blobs()
- .exists(&blob_hash)
- .await
- .expect("workflow-version blob lookup should succeed")
- }
-
pub(crate) async fn shutdown(self) {
self.api_server.shutdown().await;
self.twin.shutdown().await;
diff --git a/lib/apps/fabro-mcp-server/src/lib.rs b/lib/apps/fabro-mcp-server/src/lib.rs
index ff555322c..6a58b29b8 100644
--- a/lib/apps/fabro-mcp-server/src/lib.rs
+++ b/lib/apps/fabro-mcp-server/src/lib.rs
@@ -23,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 {
@@ -32,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/server.rs b/lib/apps/fabro-mcp-server/src/server.rs
index b20fd4bb4..ee98aa6b0 100644
--- a/lib/apps/fabro-mcp-server/src/server.rs
+++ b/lib/apps/fabro-mcp-server/src/server.rs
@@ -1,11 +1,8 @@
-use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
-use fabro_config::user;
use fabro_manifest::SuppliedWorkflowVersionPackager;
-use fabro_server::run_tool_create::ServerRunCreateAdapter;
use fabro_tool::fabro_client::ClientBackend;
use fabro_tool::{self as run_tools, FabroToolBackend};
use fabro_util::version::FABRO_VERSION;
@@ -26,7 +23,6 @@ use crate::{FabroMcpServerSettings, SERVER_NAME};
pub(crate) struct FabroMcpServer {
settings: Arc,
backend: Arc>>,
- cwd: PathBuf,
tool_router: ToolRouter,
}
@@ -83,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(),
}
}
@@ -116,21 +110,21 @@ impl FabroMcpServer {
#[tool(
name = "fabro_run_create",
- description = "Create one or more Fabro workflow runs from a native selector, inline files, or an exact stored workflow version, optionally under a parent run and 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, 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)),
}
@@ -275,15 +269,9 @@ impl FabroMcpServer {
.await
.map(|client| {
Arc::new(
- ClientBackend::new(Arc::new(client))
- .with_run_create_adapter(Arc::new(
- ServerRunCreateAdapter::standalone(Some(
- user::default_workflows_dir(),
- )),
- ))
- .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))
@@ -315,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;
@@ -344,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) })
@@ -383,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") })
}),
@@ -399,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") })
}),
@@ -426,10 +407,8 @@ mod tests {
}
#[test]
- fn fabro_run_create_tool_advertises_complete_workflow_source_and_target_grammar() {
+ 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") })
}),
@@ -441,59 +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_eq!(object_variant["additionalProperties"], false);
- 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}"
- );
- let workflow_variants = object_variant
- .pointer("/properties/workflow/anyOf")
- .and_then(Value::as_array)
- .expect("workflow should advertise selector, inline, and stored sources");
- assert!(
- workflow_variants
- .iter()
- .any(|variant| variant["type"] == "string")
- );
- for kind in ["inline", "stored"] {
- assert!(workflow_variants.iter().any(|variant| {
- variant.pointer("/properties/kind/const") == Some(&Value::from(kind))
- }));
- }
- let target_variants = object_variant
- .pointer("/properties/target/anyOf")
- .and_then(Value::as_array)
- .expect("target should advertise the canonical target union");
- for kind in ["git", "none", "folder"] {
- assert!(target_variants.iter().any(|variant| {
- variant.pointer("/properties/kind/const") == Some(&Value::from(kind))
- }));
- }
+ .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 14d9addc1..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_create;
+#[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_tool_create.rs b/lib/apps/fabro-server/src/run_tool_create.rs
index a59229866..503236f61 100644
--- a/lib/apps/fabro-server/src/run_tool_create.rs
+++ b/lib/apps/fabro-server/src/run_tool_create.rs
@@ -1,1228 +1,229 @@
-use std::path::{Path, PathBuf};
+// 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 anyhow::{Context, Result, bail};
-use async_trait::async_trait;
-use fabro_environment::DEFAULT_ENVIRONMENT_ID;
-use fabro_manifest::{
- CollectedWorkflowClosure, DerivedRunTarget, collect_inline_workflow_versions,
- configured_repo_origin_url_for_location, configured_repo_origin_url_from_workflow_toml,
- derive_run_target_for_provider, resolve_local_workflow_package,
+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 fabro_tool::{
- CreateRunWorkflowSource, PreparedRunCreate, RunCreateAdapter, ValidatedCreateRunSpec,
-};
-use fabro_types::settings::run::EnvironmentProvider;
-use fabro_types::{RunId, RunProjection, RunTarget};
-use tokio::{fs, task};
-
-#[derive(Clone, Debug)]
-pub struct ServerRunCreateAdapter {
- mode: RunCreateMode,
+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)
+ );
}
-#[derive(Clone, Debug)]
-enum RunCreateMode {
- Standalone {
- user_workflows_root: Option,
- },
- Worker {
- provider: EnvironmentProvider,
- parent_run_id: RunId,
- user_workflows_root: Option,
- },
+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())
}
-impl ServerRunCreateAdapter {
- #[must_use]
- pub fn standalone(user_workflows_root: Option) -> Self {
- Self {
- mode: RunCreateMode::Standalone {
- user_workflows_root,
- },
- }
- }
-
- #[must_use]
- pub fn worker(
- provider: EnvironmentProvider,
- parent_run_id: RunId,
- user_workflows_root: Option,
- ) -> Self {
- Self {
- mode: RunCreateMode::Worker {
- provider,
- parent_run_id,
- user_workflows_root,
- },
- }
- }
-
- fn has_shared_filesystem(&self) -> bool {
- match self.mode {
- RunCreateMode::Standalone { .. } => true,
- RunCreateMode::Worker { provider, .. } => provider.is_local(),
- }
- }
-
- fn user_workflows_root(&self) -> Option<&Path> {
- match &self.mode {
- RunCreateMode::Standalone {
- user_workflows_root,
- }
- | RunCreateMode::Worker {
- user_workflows_root,
- ..
- } => user_workflows_root.as_deref(),
- }
- }
-
- async fn resolve_goal(
- &self,
- spec: &ValidatedCreateRunSpec,
- cwd: &Path,
- ) -> Result