diff --git a/docs/public/agents/mcp.mdx b/docs/public/agents/mcp.mdx index 6aac52f62..f1f5ce95c 100644 --- a/docs/public/agents/mcp.mdx +++ b/docs/public/agents/mcp.mdx @@ -153,7 +153,7 @@ You can also reuse an exact immutable workflow version without uploading content } ``` -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. 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 derives an attached GitHub checkout only when it can prove that the exact local HEAD is available from the canonical origin; 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. +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. 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. 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. diff --git a/lib/apps/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs index 3b4db80a2..1724c7e32 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,14 @@ 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 fabro_manifest::DerivedRunTarget { + target, + dirty_worktree, + } = fabro_manifest::derive_run_target_for_provider( + environment.settings.provider, + &canonical_cwd, + None, + )?; if dirty_worktree { fabro_util::printerr!( ctx.printer(), @@ -163,86 +169,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/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs index 8f774b7e9..49f511e4c 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs @@ -1992,9 +1992,15 @@ async fn mcp_create_string_shorthand_deserializes_before_auth() { ); 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"), - "the shorthand request should reach workflow-version authentication" + "an unauthenticated shorthand request must not attempt registration" ); assert!( !harness.workflow_version_exists(workflow_version_id).await, diff --git a/lib/apps/fabro-server/src/run_tool_create.rs b/lib/apps/fabro-server/src/run_tool_create.rs index 8b329a709..987f7608c 100644 --- a/lib/apps/fabro-server/src/run_tool_create.rs +++ b/lib/apps/fabro-server/src/run_tool_create.rs @@ -2,15 +2,17 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use async_trait::async_trait; +use fabro_environment::DEFAULT_ENVIRONMENT_ID; use fabro_manifest::{ - CollectedWorkflowClosure, ResolvedLocalWorkflowPackage, collect_inline_workflow_versions, - observe_git_run_target, resolve_local_workflow_package, + CollectedWorkflowClosure, DerivedRunTarget, ResolvedLocalWorkflowPackage, + collect_inline_workflow_versions, derive_run_target_for_provider, + resolve_local_workflow_package, }; use fabro_tool::{ CreateRunWorkflowSource, PreparedRunCreate, RunCreateAdapter, ValidatedCreateRunSpec, }; +use fabro_types::RunTarget; use fabro_types::settings::run::EnvironmentProvider; -use fabro_types::{DirtyStatus, RunTarget}; use tokio::{fs, task}; #[derive(Clone, Debug)] @@ -99,6 +101,7 @@ impl ServerRunCreateAdapter { async fn resolve_target( &self, + client: &fabro_client::Client, spec: &ValidatedCreateRunSpec, cwd: &Path, ) -> Result { @@ -129,44 +132,42 @@ impl ServerRunCreateAdapter { "the parent run has no canonical target; send an explicit target for this child run" ), RunCreateMode::Standalone { .. } => { - let observation_cwd = cwd.to_path_buf(); - let observation = task::spawn_blocking(move || { - observe_git_run_target(&observation_cwd, None) + // Standalone callers derive the target the same way `fabro run` + // does: from the selected environment's provider. Local + // environments run against the caller folder; clone-based + // environments need a provably published GitHub checkout. + let environment_id = spec + .environment + .as_deref() + .unwrap_or(DEFAULT_ENVIRONMENT_ID); + let environment = client + .retrieve_environment(environment_id) + .await + .with_context(|| { + format!( + "could not retrieve environment `{environment_id}` to derive the run target" + ) + })?; + let provider = environment.settings.provider; + let canonical_cwd = fs::canonicalize(cwd).await.with_context(|| { + format!("failed to canonicalize run directory {}", cwd.display()) + })?; + let DerivedRunTarget { + target, + dirty_worktree, + } = task::spawn_blocking(move || { + derive_run_target_for_provider(provider, &canonical_cwd, None) }) .await - .context("git target observation task failed")? - .ok_or_else(|| { - anyhow::anyhow!( - "target is required outside an attached local GitHub checkout with a branch" - ) - })?; - let target = observation.run_target.ok_or_else(|| { - anyhow::anyhow!( - "target is required because the local checkout cannot be represented as a GitHub run target" - ) - })?; - if observation - .legacy_git_context - .sha - .as_deref() - .is_some_and(|sha| !sha.is_empty()) - && 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" - ); - } + .context("run target derivation task failed")??; let mut warnings = Vec::new(); - if observation.legacy_git_context.dirty == DirtyStatus::Dirty { + if dirty_worktree { warnings.push( "the local checkout has uncommitted changes; those changes are excluded from the run target" .to_string(), ); } - Ok(ResolvedTarget { - target: RunTarget::Git(target), - warnings, - }) + Ok(ResolvedTarget { target, warnings }) } } } @@ -207,7 +208,7 @@ impl RunCreateAdapter for ServerRunCreateAdapter { CreateRunWorkflowSource::Stored { workflow_version_id, } => { - let resolved_target = self.resolve_target(spec, cwd).await?; + let resolved_target = self.resolve_target(client, spec, cwd).await?; return Ok(PreparedRunCreate { workflow_version_id: *workflow_version_id, target: resolved_target.target, @@ -220,7 +221,7 @@ impl RunCreateAdapter for ServerRunCreateAdapter { } CreateRunWorkflowSource::Inline(source) => collect_inline_workflow(source).await?, }; - let resolved_target = self.resolve_target(spec, cwd).await?; + let resolved_target = self.resolve_target(client, spec, cwd).await?; let versions = closure .versions() .map(|(_, version)| version.version()) @@ -264,12 +265,48 @@ mod tests { use fabro_tool::fabro_client::ClientBackend; use fabro_tool::{FabroRunCreateParams, FabroToolBackend as _, ValidatedCreateRuns}; use fabro_types::{GitRunTarget, WorkflowVersion, WorkflowVersionId}; - use httpmock::Method::POST; + use httpmock::Method::{GET, POST}; use httpmock::{HttpMockRequest, HttpMockResponse, MockServer}; use serde_json::json; use super::*; + /// Canonical `GET /api/v1/environments/{id}` body for mock servers. + fn environment_json(id: &str, provider: &str) -> serde_json::Value { + json!({ + "id": id, + "revision": "0".repeat(64), + "provider": provider, + "image": { "docker": null, "dockerfile": null }, + "resources": { "cpu": null, "memory": null, "disk": null }, + "network": { "mode": "allow_all", "allow": [] }, + "lifecycle": { + "preserve": false, + "stop_on_terminal": true, + "auto_stop": null + }, + "labels": {}, + "env": {} + }) + } + + async fn mock_environment<'a>( + server: &'a MockServer, + id: &str, + provider: &str, + ) -> httpmock::Mock<'a> { + let path = format!("/api/v1/environments/{id}"); + let body = environment_json(id, provider); + server + .mock_async(move |when, then| { + when.method(GET).path(path); + then.status(200) + .header("content-type", "application/json") + .json_body(body); + }) + .await + } + fn validated_spec(value: &serde_json::Value) -> ValidatedCreateRunSpec { let params: FabroRunCreateParams = serde_json::from_value(json!({ "runs": [value] })) .expect("create input should deserialize"); @@ -746,6 +783,7 @@ mod tests { } })); let server = MockServer::start_async().await; + mock_environment(&server, "default", "docker").await; let registered = Arc::new(Mutex::new(Vec::new())); let registration = dynamic_version_registration_mock(&server, Arc::clone(®istered)).await; @@ -820,13 +858,17 @@ mod tests { "workflow": { "kind": "stored", "workflow_version_id": workflow_version_id - } + }, + "environment": "sandbox" })); - let client = no_proxy_client("http://127.0.0.1:9"); + let server = MockServer::start_async().await; + let environment = mock_environment(&server, "sandbox", "daytona").await; + let client = no_proxy_client(&server.url("")); let adapter = ServerRunCreateAdapter::standalone(None); let prepared = adapter.prepare(&client, &spec, &workspace).await.unwrap(); + environment.assert_calls_async(1).await; let RunTarget::Git(target) = prepared.target else { panic!("standalone attached Git checkout should derive a Git target"); }; @@ -840,4 +882,53 @@ mod tests { .any(|warning| warning.contains("uncommitted changes")) ); } + + #[tokio::test] + async fn workflow_version_standalone_local_environment_targets_the_caller_folder() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("plain"); + fs::create_dir(&workspace).await.unwrap(); + let workflow_version_id: WorkflowVersionId = fabro_types::BlobHash::new(b"stored").into(); + let spec = validated_spec(&json!({ + "workflow": { + "kind": "stored", + "workflow_version_id": workflow_version_id + }, + "environment": "local" + })); + let server = MockServer::start_async().await; + mock_environment(&server, "local", "local").await; + let client = no_proxy_client(&server.url("")); + let adapter = ServerRunCreateAdapter::standalone(None); + + let prepared = adapter.prepare(&client, &spec, &workspace).await.unwrap(); + + let expected = workspace.canonicalize().unwrap(); + assert_eq!(prepared.target, RunTarget::Folder { + path: expected.to_str().unwrap().to_string(), + }); + assert!(prepared.warnings.is_empty()); + } + + #[tokio::test] + async fn workflow_version_standalone_clone_environment_without_git_metadata_runs_empty() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("plain"); + fs::create_dir(&workspace).await.unwrap(); + let workflow_version_id: WorkflowVersionId = fabro_types::BlobHash::new(b"stored").into(); + let spec = validated_spec(&json!({ + "workflow": { + "kind": "stored", + "workflow_version_id": workflow_version_id + } + })); + let server = MockServer::start_async().await; + mock_environment(&server, "default", "docker").await; + let client = no_proxy_client(&server.url("")); + let adapter = ServerRunCreateAdapter::standalone(None); + + let prepared = adapter.prepare(&client, &spec, &workspace).await.unwrap(); + + assert_eq!(prepared.target, RunTarget::None {}); + } } diff --git a/lib/components/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs index 9f78aa015..876ea5b89 100644 --- a/lib/components/fabro-manifest/src/lib.rs +++ b/lib/components/fabro-manifest/src/lib.rs @@ -28,7 +28,9 @@ use fabro_graphviz::parser; use fabro_template::validate_static_reference; use fabro_types::graph::ReferenceKind; use fabro_types::settings::interp::InterpString; -use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode}; +use fabro_types::settings::run::{ + ApprovalMode, EnvironmentProvider, ResolvedGoalSource, ResolvedRunGoal, RunMode, +}; use fabro_types::{ DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, ManifestPath, RunTarget, WorkflowSettings, @@ -324,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 @@ -344,6 +366,7 @@ pub fn observe_git_run_target( let legacy_git_context = local.legacy_git_context; 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, @@ -351,20 +374,184 @@ 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 or pass an explicit target" + )] + 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 caller of the run tool or CLI. +pub fn derive_run_target_for_provider( + provider: EnvironmentProvider, + canonical_cwd: &Path, + configured_repo_origin_url: Option<&str>, +) -> std::result::Result { + if !provider.is_clone_based() { + 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 workflow's configured `run.scm` GitHub repository as a normalized +/// origin URL, read from `workflow.toml` source text. `None` when the config +/// names no GitHub repository. +/// +/// # Errors +/// +/// Returns an error when the source cannot be parsed or resolved. +pub fn configured_repo_origin_url_from_workflow_toml(source: &str) -> Result> { + let settings = WorkflowSettingsBuilder::from_toml(source) + .context("failed to resolve workflow settings from workflow.toml")?; + Ok(configured_repo_origin_url(&settings)) +} + struct LocalGitObservation { push_origin_url: Option, legacy_git_context: GitContext, @@ -503,6 +690,9 @@ enum BranchPublishStatus { TrackingRefMatches, Pushed, Unavailable, + /// The configured repository is not the checkout's origin, so the push + /// is skipped entirely. + OriginMismatch, } /// Best-effort publication of the local branch so clone-based execution can @@ -525,7 +715,7 @@ fn publish_manifest_branch_best_effort( { let remote = fabro_github::normalize_repo_origin_url(origin_url); if remote != repo_origin_url { - return BranchPublishStatus::Unavailable; + return BranchPublishStatus::OriginMismatch; } } @@ -545,18 +735,25 @@ fn remotely_available_sha( branch: &str, local_sha: Option<&str>, publish_status: BranchPublishStatus, -) -> Option { - let local_sha = local_sha?; +) -> (Option, ExactCommitStatus) { + let Some(local_sha) = local_sha else { + return (None, ExactCommitStatus::Unpublished); + }; match publish_status { - BranchPublishStatus::Pushed => Some(local_sha.to_owned()), + BranchPublishStatus::Pushed => (Some(local_sha.to_owned()), ExactCommitStatus::Available), BranchPublishStatus::TrackingRefMatches => { - git::remote_branch_sha_noninteractive(repo_path, "origin", branch) - .ok() - .flatten() - .filter(|remote_sha| remote_sha == local_sha) - .map(|_| local_sha.to_owned()) + match git::remote_branch_sha_noninteractive(repo_path, "origin", branch) { + Ok(Some(remote_sha)) if remote_sha == local_sha => { + (Some(local_sha.to_owned()), ExactCommitStatus::Available) + } + Ok(_) => (None, ExactCommitStatus::Unpublished), + // The failure may carry raw Git stderr, so it is neither + // returned nor logged; the status tells callers what to say. + Err(_) => (None, ExactCommitStatus::Unverified), + } } - BranchPublishStatus::Unavailable => None, + BranchPublishStatus::Unavailable => (None, ExactCommitStatus::Unpublished), + BranchPublishStatus::OriginMismatch => (None, ExactCommitStatus::ConfiguredOriginMismatch), } } diff --git a/lib/components/fabro-tool/src/create.rs b/lib/components/fabro-tool/src/create.rs index 80e922901..bee90d63a 100644 --- a/lib/components/fabro-tool/src/create.rs +++ b/lib/components/fabro-tool/src/create.rs @@ -131,7 +131,7 @@ impl JsonSchema for CreateRunWorkflowSource { /// spelled out here and pinned by the serde parity test below. fn run_target_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ - "description": "Canonical run workspace target. Worker calls inherit the parent target when omitted; standalone calls require an attached GitHub checkout whose exact local HEAD is available from the canonical origin. Folder targets require a standalone or Local-worker filesystem context.", + "description": "Canonical run workspace target. Worker calls inherit the parent target when omitted; standalone calls derive it from the selected environment: Local environments target the working directory folder, and clone-based environments require an attached GitHub checkout whose exact local HEAD is available from the canonical origin. Folder targets require a standalone or Local-worker filesystem context.", "anyOf": [ { "type": "null" }, {