Select CLI workflow sources and run targets independently

This commit is contained in:
Scott Werner 2026-09-08 12:05:01 -04:00
parent 290e0d7e69
commit 81bb5bee82
13 changed files with 2252 additions and 128 deletions

View file

@ -140,3 +140,71 @@ In the web UI, the Workflows page lists all available workflows. Click into a wo
</Frame>
See the [Quick Start](/getting-started/quick-start) to try it out, or browse the [example workflows](/examples/repl-handoff) for real-world patterns.
## Select workflow source and run target
`fabro run` and `fabro create` accept the same source and target options. `create`
registers the workflow and leaves a submitted run for you to start with
`fabro start RUN`. `run` also starts it, then attaches unless you pass `--detach`.
The required positional argument selects the workflow. With no source flags,
names are found in the current checkout, then a marked project, then installed
user workflows. Explicit local paths retain their usual package roots.
```sh
fabro run review
fabro create ./review.toml --target-path ../app
fabro run review --workflow-git acme/workflows --workflow-ref v1.2 \
--target-git acme/app --target-branch release
```
In the last example, workflow instructions come from `acme/workflows`, and the
run works on `acme/app`. Neither selection changes the other. Local workflow paths,
`--goal-file`, and other caller inputs still resolve from the invocation context.
Without target flags, Fabro keeps its existing cwd/environment-based target
inference.
`--workflow-git OWNER/REPO` acquires source using native Git on your machine. A
workflow name selects `.fabro/workflows/NAME/workflow.toml` in that repository;
you can also supply an explicit repository-relative `.toml` or `.fabro` file.
Absolute paths, traversal, and directory selectors are rejected. A missing remote
workflow never falls back to a local or installed workflow.
`--workflow-ref` requires `--workflow-git`. Omit it, or use `HEAD`, to select the
remote default branch. You can select a branch, tag, or full 40-hex commit SHA.
If a branch and tag share a name, qualify it with `refs/heads/` or `refs/tags/`.
Fabro resolves the revision once, fetches that exact commit into a temporary
checkout, and registers its workflow-version closure. A moved or unavailable
commit never causes a fallback to a newer revision. Checkout hooks, content
filters, and implicit Git LFS expansion are disabled; submodules are not fetched.
Temporary files are removed after collection or failure. Interrupting acquisition
stops owned Git processes before cleanup; collection already in progress must
finish before its files can be removed.
Target selection depends on the environment:
| Selection | Local environment | Docker/Daytona environment |
| --- | --- | --- |
| Default cwd or `--target-path PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
| `--target-git OWNER/REPO` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
For clone-based execution, a target path selects a repository, not a subdirectory
working-directory override. Local target files are not uploaded. Existing target
observation may push committed local changes to origin; dirty changes are
excluded from clone targets and produce a warning. Detached or unavailable exact
commits fail. Folder targets require the directory to be accessible to the
server and its Local execution environment; passing a caller-local path does not
transfer it to a remote server.
`--target-branch` requires `--target-git` and accepts a working branch name, not a
tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI
only looks up target metadata; the execution sandbox clones the target.
`--target-path` and `--target-git` conflict.
Local Git credential helpers, SSH-agent access through configured URL rewrites,
and user network configuration govern source acquisition and remote target
lookup. Fabro server login does not grant local Git access. The execution
sandbox still needs its own target-clone credentials.
`--dry-run` simulates execution; it can still fetch and upload workflow source,
and existing local target observation can still publish committed changes.

View file

@ -70,7 +70,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro attach` | Attach to a running or finished workflow run |
| `fabro auth` | Manage CLI authentication state |
| `fabro completion` | Generate shell completions |
| `fabro create` | Register a local workflow version and create a submitted run |
| `fabro create` | Register a workflow version and create a submitted run |
| `fabro deny` | Deny pending workflow runs |
| `fabro discord` | Open the Discord community in the browser |
| `fabro docs` | Open the docs website in the browser |
@ -92,7 +92,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro resume` | Resume an interrupted workflow run |
| `fabro rewind` | Rewind a workflow run to an earlier checkpoint |
| `fabro rm` | Remove one or more workflow runs |
| `fabro run` | Register a local workflow version, create a run, and start it |
| `fabro run` | Register a workflow version, create a run, and start it |
| `fabro sandbox` | Sandbox operations (cp, ssh, preview) |
| `fabro secret` | Manage server-owned secrets |
| `fabro server` | Server operations |
@ -332,7 +332,7 @@ fabro completion [OPTIONS] <SHELL>
### `fabro create`
Register a local workflow version and create a submitted run
Register a workflow version and create a submitted run
```bash
fabro create [OPTIONS] <WORKFLOW>
@ -342,7 +342,7 @@ fabro create [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
#### Options
@ -350,7 +350,7 @@ fabro create [OPTIONS] <WORKFLOW>
| --- | --- |
| `--auto-approve` | Auto-approve all human gates |
| `-d, --detach` | Run the workflow in the background and print the run ID |
| `--dry-run` | Execute with simulated LLM backend |
| `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
@ -360,8 +360,13 @@ fabro create [OPTIONS] <WORKFLOW>
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
### `fabro deny`
@ -1061,7 +1066,7 @@ fabro rm [OPTIONS] <RUNS>...
### `fabro run`
Register a local workflow version, create a run, and start it
Register a workflow version, create a run, and start it
```bash
fabro run [OPTIONS] <WORKFLOW>
@ -1071,7 +1076,7 @@ fabro run [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
| `WORKFLOW` | Workflow name or path (repository-relative with --workflow-git) |
#### Options
@ -1079,7 +1084,7 @@ fabro run [OPTIONS] <WORKFLOW>
| --- | --- |
| `--auto-approve` | Auto-approve all human gates |
| `-d, --detach` | Run the workflow in the background and print the run ID |
| `--dry-run` | Execute with simulated LLM backend |
| `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
@ -1089,8 +1094,13 @@ fabro run [OPTIONS] <WORKFLOW>
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-git <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target-path <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-git <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
### `fabro sandbox`

View file

@ -99,7 +99,7 @@ object_store.workspace = true
bytes.workspace = true
tokio-util.workspace = true
libc = "0.2"
nix = { version = "0.30", features = ["fs"] }
nix = { version = "0.30", features = ["fs", "signal", "process"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "0.9", optional = true }

View file

@ -7,6 +7,7 @@ use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_server::serve::DEFAULT_TCP_PORT;
use fabro_static::EnvVars;
use fabro_types::GitHubRepositorySlug;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::run::MergeStrategy;
use fabro_util::printer::Printer;
@ -233,11 +234,36 @@ pub(crate) struct RunArgs {
#[command(flatten)]
pub(crate) inputs: InputOverrideArgs,
/// Local workflow name, checkout path, .fabro file, or workflow TOML
/// Workflow name or path (repository-relative with --workflow-git)
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
/// Execute with simulated LLM backend
/// Acquire workflow source locally from a GitHub OWNER/REPO using native
/// Git credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) workflow_git: Option<GitHubRepositorySlug>,
/// Workflow branch, tag, HEAD (default), or full commit SHA; qualify
/// ambiguous names
#[arg(long, requires = "workflow_git", value_name = "REF")]
pub(crate) workflow_ref: Option<String>,
/// Observe this target directory instead of cwd; Folder targets require
/// server filesystem access
#[arg(long, conflicts_with = "target_git", value_name = "PATH")]
pub(crate) target_path: Option<PathBuf>,
/// Target GitHub OWNER/REPO; the execution sandbox still needs its own
/// clone credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) target_git: Option<GitHubRepositorySlug>,
/// Target working branch (default: remote default branch), pinned to its
/// observed commit
#[arg(long, requires = "target_git", value_name = "BRANCH")]
pub(crate) target_branch: Option<String>,
/// Simulate execution; workflow source may still be fetched and uploaded
#[arg(long)]
pub(crate) dry_run: bool,
@ -1138,10 +1164,10 @@ pub(crate) struct UpgradeArgs {
#[derive(Subcommand)]
pub(crate) enum RunCommands {
/// Register a local workflow version, create a run, and start it
Run(RunArgs),
/// Register a local workflow version and create a submitted run
Create(RunArgs),
/// Register a workflow version, create a run, and start it
Run(Box<RunArgs>),
/// Register a workflow version and create a submitted run
Create(Box<RunArgs>),
/// Start a created workflow run on the server
Start(StartArgs),
/// Attach to a running or finished workflow run
@ -1869,3 +1895,56 @@ fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
)
})
}
#[cfg(test)]
mod run_selection_grammar_tests {
use clap::Parser as _;
use super::RunArgs;
#[derive(clap::Parser)]
struct Command {
#[command(flatten)]
run: RunArgs,
}
#[test]
fn run_selection_accepts_independent_resource_flags() {
for flags in [
vec![
"fabro",
"review",
"--workflow-git",
"acme/workflows",
"--workflow-ref",
"refs/tags/v1",
"--target-git",
"acme/app",
"--target-branch",
"release/topic",
],
vec!["fabro", "./review.toml", "--target-path", "../app"],
] {
assert!(Command::try_parse_from(flags).is_ok());
}
}
#[test]
fn run_selection_requires_modifier_owners_and_exclusive_targets() {
for flags in [
vec!["fabro", "review", "--workflow-ref", "v1"],
vec!["fabro", "review", "--target-branch", "release"],
vec![
"fabro",
"review",
"--target-path",
".",
"--target-git",
"acme/app",
],
vec!["fabro", "--workflow-git", "acme/workflows"],
] {
assert!(Command::try_parse_from(flags).is_err());
}
}
}

View file

@ -1,13 +1,15 @@
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::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget};
use fabro_types::{RunId, RunIntent};
use fabro_util::terminal::Styles;
use super::overrides::prepare_intent_overrides;
use super::resolution::ResolvedWorkflow;
use super::selection::WorkflowSelection;
use super::{resolution, selection};
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::commands::resolve_run_id;
@ -17,7 +19,7 @@ pub(crate) struct CreatedRun {
pub(crate) run_id: RunId,
}
/// Register the local workflow version closure with the server and create a
/// Register the workflow version closure with the server and create a
/// run from an immutable workflow intent, leaving it in the submitted state.
///
/// This does NOT start the workflow — starting is a separate request.
@ -26,10 +28,7 @@ pub(crate) async fn create_run(
args: &RunArgs,
styles: &Styles,
) -> anyhow::Result<CreatedRun> {
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let (workflow_selection, target_selection) = selection::parse(args)?;
let canonical_cwd = ctx.cwd().canonicalize().with_context(|| {
format!(
"failed to canonicalize caller working directory {}",
@ -37,11 +36,20 @@ pub(crate) async fn create_run(
)
})?;
let user_workflows_root = fabro_util::Home::from_env().workflows_dir();
let package = fabro_manifest::resolve_local_workflow_package(
workflow_path,
&canonical_cwd,
Some(&user_workflows_root),
)?;
// Preserve local lookup diagnostics before contacting the server. Remote
// acquisition waits until parent, environment, and target are validated.
let local_package = if matches!(workflow_selection, WorkflowSelection::Local(_)) {
Some(
resolution::workflow(
&workflow_selection,
&canonical_cwd,
Some(&user_workflows_root),
)
.await?,
)
} else {
None
};
let prepared = prepare_intent_overrides(args, &canonical_cwd).await?;
warn_untransmitted_settings(
@ -50,7 +58,12 @@ pub(crate) async fn create_run(
ctx.base_config_path(),
*ctx.run_settings_key_presence(),
);
let project_config = project::discover_project_config(&package.workflow_location().dir)?;
let project_config = match &local_package {
Some(ResolvedWorkflow::Local(package)) => {
project::discover_project_config(&package.workflow_location().dir)?
}
_ => project::discover_project_config(&canonical_cwd)?,
};
if let Some(path) = project_config.as_deref() {
warn_untransmitted_settings(
ctx,
@ -72,15 +85,30 @@ 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 (target, dirty_worktree) = resolution::target(
&target_selection,
environment.settings.provider,
&canonical_cwd,
)
.await?;
if dirty_worktree {
fabro_util::printerr!(
ctx.printer(),
"{} the caller Git working tree is dirty; uncommitted changes are not included in the run target.",
"{} the selected target Git working tree is dirty; uncommitted changes are not included in the run target.",
styles.yellow.apply_to("Warning:"),
);
}
let package = match local_package {
Some(package) => package,
None => {
resolution::workflow(
&workflow_selection,
&canonical_cwd,
Some(&user_workflows_root),
)
.await?
}
};
let workflow_version_id = package.closure().root_id();
client
.register_workflow_versions(
@ -164,86 +192,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: EnvironmentProvider,
canonical_cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
if !provider.is_clone_based() {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"caller working directory is not valid UTF-8: {}",
canonical_cwd.display()
)
})?;
return Ok((
RunTarget::Folder {
path: path.to_string(),
},
false,
));
}
let Some(observation) = fabro_manifest::observe_git_run_target(canonical_cwd, None) else {
return Ok((none_target_for_unversioned_directory(canonical_cwd)?, false));
};
let dirty = observation.legacy_git_context.dirty == DirtyStatus::Dirty;
let target = observation.run_target.ok_or_else(|| {
anyhow!("the caller Git checkout cannot be represented as a canonical GitHub run target")
})?;
if target.sha.is_none() {
bail!(
"the exact local Git commit could not be made available from the canonical GitHub origin; push the commit and try again"
);
}
Ok((RunTarget::Git(target), dirty))
}
fn none_target_for_unversioned_directory(canonical_cwd: &Path) -> anyhow::Result<RunTarget> {
let repository = match git2::Repository::discover(canonical_cwd) {
Ok(repository) => repository,
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(RunTarget::None {}),
Err(source) => {
return Err(anyhow::Error::new(source)).with_context(|| {
format!(
"failed to inspect caller working directory {} for Git metadata",
canonical_cwd.display()
)
});
}
};
if repository.is_bare() {
bail!(
"the caller directory resolves to a bare Git repository; clone-based runs require a non-bare checkout with an attached branch"
);
}
match repository.head() {
Err(source)
if matches!(
source.code(),
git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
) =>
{
bail!(
"the caller Git checkout has no commits; create a commit before using a clone-based environment"
);
}
Err(source) => {
return Err(anyhow::Error::new(source))
.context("failed to inspect the caller Git checkout HEAD");
}
Ok(head) if !head.is_branch() => {
bail!(
"the caller Git checkout has a detached HEAD; check out a branch before using a clone-based environment"
);
}
Ok(_) => {}
}
bail!(
"the caller Git checkout does not have a usable attached branch for a clone-based run target"
)
}

View file

@ -21,10 +21,13 @@ pub(crate) mod logs;
pub(crate) mod output;
pub(crate) mod overrides;
pub(crate) mod preview;
mod remote_workflow;
mod resolution;
pub(crate) mod resume;
pub(crate) mod rewind;
pub(crate) mod run_progress;
pub(crate) mod runner;
mod selection;
pub(crate) mod ssh;
pub(crate) mod start;
pub(crate) mod steer;
@ -38,7 +41,7 @@ pub(crate) async fn dispatch(
let printer = base_ctx.printer();
match cmd {
RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await,
RunCommands::Run(args) => Box::pin(command::execute(*args, base_ctx)).await,
RunCommands::Create(args) => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let ctx = base_ctx.with_target(&args.target)?;

View file

@ -168,6 +168,11 @@ mod tests {
target: ServerTargetArgs::default(),
inputs: InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
workflow_git: None,
workflow_ref: None,
target_path: None,
target_git: None,
target_branch: None,
dry_run: false,
auto_approve: false,
goal: None,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,358 @@
use std::path::{Path, PathBuf};
use anyhow::{Context as _, anyhow, bail};
use fabro_manifest::{CollectedWorkflowClosure, ResolvedLocalWorkflowPackage};
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunTarget};
use tokio::task;
use super::remote_workflow::{self, NativeGit};
use super::selection::{TargetSelection, WorkflowSelection};
/// Owns the canonical collector result without copying its contents. Local
/// location metadata remains available solely for existing settings warnings.
pub(super) enum ResolvedWorkflow {
Local(ResolvedLocalWorkflowPackage),
Git(CollectedWorkflowClosure),
}
impl ResolvedWorkflow {
pub(super) fn closure(&self) -> &CollectedWorkflowClosure {
match self {
Self::Local(package) => package.closure(),
Self::Git(closure) => closure,
}
}
}
pub(super) async fn workflow(
selection: &WorkflowSelection,
cwd: &Path,
user_workflows: Option<&Path>,
) -> anyhow::Result<ResolvedWorkflow> {
match selection {
WorkflowSelection::Local(path) => {
let (path, cwd, user_workflows) = (
path.clone(),
cwd.to_path_buf(),
user_workflows.map(Path::to_path_buf),
);
let package = task::spawn_blocking(move || {
fabro_manifest::resolve_local_workflow_package(
&path,
&cwd,
user_workflows.as_deref(),
)
.map_err(anyhow::Error::new)
})
.await
.context("local workflow collection task failed")??;
Ok(ResolvedWorkflow::Local(package))
}
WorkflowSelection::Git {
repository,
selector,
revision,
} => {
let git = NativeGit::new(cwd.to_path_buf());
let (repository, selector, revision) =
(repository.clone(), selector.clone(), revision.clone());
let closure = remote_workflow::owned(move |cancel| async move {
git.collect(repository, selector, revision, cancel).await
})
.await?;
Ok(ResolvedWorkflow::Git(closure))
}
}
}
pub(super) async fn target(
selection: &TargetSelection,
provider: EnvironmentProvider,
cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
match selection {
TargetSelection::CurrentDirectory => observe_directory(provider, cwd.to_path_buf()).await,
TargetSelection::Path(path) => {
let path = cwd
.join(path)
.canonicalize()
.context("failed to canonicalize target directory")?;
if !path.is_dir() {
bail!("target path must be a directory");
}
observe_directory(provider, path).await
}
TargetSelection::Git { repository, branch } => {
if !provider.is_clone_based() {
bail!("Git targets require a clone-enabled Docker or Daytona environment");
}
let git = NativeGit::new(cwd.to_path_buf());
let (repository, branch) = (repository.clone(), branch.clone());
let target = remote_workflow::owned(move |cancel| async move {
git.resolve_target(repository, branch, &cancel).await
})
.await?;
// Canonical admission retains ownership of provider capabilities.
Ok((RunTarget::Git(target), false))
}
}
}
async fn observe_directory(
provider: EnvironmentProvider,
path: PathBuf,
) -> anyhow::Result<(RunTarget, bool)> {
// The existing observer can push/query Git synchronously. Preserve its
// behavior without blocking a Tokio worker or promising a new timeout.
task::spawn_blocking(move || run_target_for_environment(provider, &path))
.await
.context("target observation task failed")?
}
/// Derives the run target from the selected 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: EnvironmentProvider,
canonical_cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
if !provider.is_clone_based() {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"target 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 target Git checkout cannot be represented as a canonical GitHub run target")
})?;
if target.sha.is_none() {
bail!(
"the exact local Git commit could not be made available from the canonical GitHub origin; push the commit and try again"
);
}
Ok((RunTarget::Git(target), dirty))
}
fn none_target_for_unversioned_directory(canonical_cwd: &Path) -> anyhow::Result<RunTarget> {
let repository = match git2::Repository::discover(canonical_cwd) {
Ok(repository) => repository,
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(RunTarget::None {}),
Err(source) => {
return Err(anyhow::Error::new(source)).with_context(|| {
format!(
"failed to inspect target directory {} for Git metadata",
canonical_cwd.display()
)
});
}
};
if repository.is_bare() {
bail!(
"the target 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 target 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 target Git checkout HEAD");
}
Ok(head) if !head.is_branch() => {
bail!(
"the target Git checkout has a detached HEAD; check out a branch before using a clone-based environment"
);
}
Ok(_) => {}
}
bail!(
"the target Git checkout does not have a usable attached branch for a clone-based run target"
)
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "resolver tests construct small local workflow fixtures"
)]
mod tests {
use clap::Parser as _;
use super::super::selection;
use super::*;
use crate::args::RunArgs;
fn write_workflow(root: &Path, name: &str) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("workflow.toml"),
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
std::fs::write(
dir.join("workflow.fabro"),
"digraph Test { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
)
.unwrap();
}
#[derive(clap::Parser)]
struct Command {
#[command(flatten)]
args: RunArgs,
}
#[tokio::test]
async fn run_selection_direct_and_parsed_resolve_identically() {
let caller = tempfile::tempdir().unwrap();
let root = caller.path().canonicalize().unwrap();
write_workflow(&root, ".fabro/workflows/review");
std::fs::create_dir(root.join("target")).unwrap();
let args = Command::try_parse_from(["cmd", "review", "--target-path", "target"]).unwrap();
let (parsed_workflow, parsed_target) = selection::parse(&args.args).unwrap();
let direct_workflow = WorkflowSelection::Local("review".into());
let direct_target = TargetSelection::Path("target".into());
assert_eq!(
workflow(&parsed_workflow, &root, None)
.await
.unwrap()
.closure()
.root_id(),
workflow(&direct_workflow, &root, None)
.await
.unwrap()
.closure()
.root_id()
);
for provider in [
EnvironmentProvider::Local,
EnvironmentProvider::Docker,
EnvironmentProvider::Daytona,
] {
assert_eq!(
target(&parsed_target, provider, &root).await.unwrap(),
target(&direct_target, provider, &root).await.unwrap()
);
}
assert_eq!(
target(&direct_target, EnvironmentProvider::Local, &root)
.await
.unwrap()
.0,
RunTarget::Folder {
path: root.join("target").to_str().unwrap().into(),
}
);
assert_eq!(
target(&direct_target, EnvironmentProvider::Docker, &root)
.await
.unwrap()
.0,
RunTarget::None {}
);
assert_eq!(
target(
&TargetSelection::CurrentDirectory,
EnvironmentProvider::Local,
&root
)
.await
.unwrap()
.0,
RunTarget::Folder {
path: root.to_str().unwrap().into(),
}
);
assert!(
target(
&TargetSelection::Git {
repository: "acme/app".parse().unwrap(),
branch: None,
},
EnvironmentProvider::Local,
&root
)
.await
.unwrap_err()
.to_string()
.contains("Docker or Daytona")
);
assert!(
target(
&TargetSelection::Path("missing".into()),
EnvironmentProvider::Local,
&root
)
.await
.is_err()
);
assert!(
target(
&TargetSelection::Path(".fabro/workflows/review/workflow.toml".into()),
EnvironmentProvider::Local,
&root
)
.await
.is_err()
);
}
#[tokio::test]
async fn run_selection_local_lookup_preserves_precedence_and_explicit_failure() {
let root = tempfile::tempdir().unwrap();
let user = root.path().join("user");
let project = root.path().join("project");
let checkout = root.path().join("project/checkout");
write_workflow(&user, "review");
write_workflow(&project, ".fabro/workflows/review");
write_workflow(&checkout, ".fabro/workflows/review");
std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap();
git2::Repository::init(&checkout).unwrap();
let selected = WorkflowSelection::Local("review".into());
for (cwd, expected_root) in [
(checkout.as_path(), checkout.as_path()),
(project.as_path(), project.as_path()),
(root.path(), user.as_path()),
] {
let ResolvedWorkflow::Local(package) =
workflow(&selected, cwd, Some(&user)).await.unwrap()
else {
panic!("local package");
};
assert_eq!(package.source_root(), expected_root.canonicalize().unwrap());
}
assert!(
workflow(
&WorkflowSelection::Local("missing.toml".into()),
&checkout,
Some(&user)
)
.await
.is_err()
);
}
}

View file

@ -0,0 +1,288 @@
//! CLI syntax ends here. Resolvers receive selections and explicit caller
//! context.
use std::path::{Component, Path, PathBuf};
use anyhow::{Context as _, bail};
use fabro_types::{GitHubRepositorySlug, repository};
use crate::args::RunArgs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WorkflowSelection {
Local(PathBuf),
Git {
repository: GitHubRepositorySlug,
selector: PathBuf,
revision: RemoteWorkflowRevision,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum TargetSelection {
CurrentDirectory,
Path(PathBuf),
Git {
repository: GitHubRepositorySlug,
branch: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RemoteWorkflowRevision {
DefaultBranch,
Ref(String),
Commit(String),
}
impl RemoteWorkflowRevision {
pub(super) fn parse(value: Option<&str>) -> anyhow::Result<Self> {
match value {
None | Some("HEAD") => Ok(Self::DefaultBranch),
Some(value) => {
if let Some(sha) = repository::normalize_git_commit_sha(value) {
return Ok(Self::Commit(sha));
}
if !repository::is_valid_github_ref_selector(value)
|| (value.starts_with("refs/")
&& !value.starts_with("refs/heads/")
&& !value.starts_with("refs/tags/"))
{
bail!("workflow ref must be a branch, tag, HEAD, or full 40-hex commit SHA");
}
Ok(Self::Ref(value.to_owned()))
}
}
}
}
pub(super) fn validate_remote_selector(path: &Path) -> anyhow::Result<()> {
let value = path
.to_str()
.context("remote workflow selector must be valid UTF-8")?;
if value.is_empty()
|| value.contains('\\')
|| value.chars().any(char::is_control)
|| path
.components()
.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
|| value.split('/').any(|part| part == "..")
{
bail!(
"remote workflow must be a name or repository-relative .fabro/.toml file without traversal"
);
}
match path.extension().and_then(|ext| ext.to_str()) {
Some("toml" | "fabro") => {}
None if path
.file_name()
.is_some_and(|name| path.as_os_str() == name)
&& value != "."
&& !value.starts_with('-') => {}
_ => bail!(
"remote workflow must be a name or explicit .fabro/.toml file; directories are ambiguous"
),
}
Ok(())
}
pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, TargetSelection)> {
let workflow = args.workflow.as_ref().context("workflow is required")?;
if args.workflow_ref.is_some() && args.workflow_git.is_none() {
bail!("--workflow-ref requires --workflow-git");
}
if args.target_branch.is_some() && args.target_git.is_none() {
bail!("--target-branch requires --target-git");
}
if args.target_path.is_some() && args.target_git.is_some() {
bail!("--target-path conflicts with --target-git");
}
let workflow = match &args.workflow_git {
None => WorkflowSelection::Local(workflow.clone()),
Some(repository) => {
validate_remote_selector(workflow)?;
WorkflowSelection::Git {
repository: repository.clone(),
selector: workflow.clone(),
revision: RemoteWorkflowRevision::parse(args.workflow_ref.as_deref())?,
}
}
};
let target = match (&args.target_path, &args.target_git) {
(Some(path), _) => TargetSelection::Path(path.clone()),
(_, Some(repository)) => {
if args
.target_branch
.as_deref()
.is_some_and(|branch| !repository::is_valid_git_branch_name(branch))
{
bail!(
"target branch must be a working branch name, not a tag, SHA, or qualified ref"
);
}
TargetSelection::Git {
repository: repository.clone(),
branch: args.target_branch.clone(),
}
}
_ => TargetSelection::CurrentDirectory,
};
Ok((workflow, target))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_selection_remote_grammar_is_pure_and_rejects_unsafe_selectors() {
for path in [
"review",
"./review.toml",
".fabro/workflows/review/workflow.toml",
"dir/graph.fabro",
] {
validate_remote_selector(Path::new(path)).unwrap();
}
for path in [
"",
".",
"..",
"/tmp/workflow.toml",
"../review.toml",
"a/../review.toml",
"dir/review",
"dir/",
"a\\review.toml",
] {
assert!(validate_remote_selector(Path::new(path)).is_err(), "{path}");
}
for value in [
"topic/slash",
"refs/heads/release",
"refs/tags/v1",
"HEAD",
"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
] {
RemoteWorkflowRevision::parse(Some(value)).unwrap();
}
for value in [
"--upload-pack=x",
"topic*",
"HEAD~1",
"main..next",
"refs/pull/1/head",
"a.lock",
"main@{1}",
] {
assert!(
RemoteWorkflowRevision::parse(Some(value)).is_err(),
"{value}"
);
}
}
}
#[cfg(test)]
mod adapter_tests {
use clap::Parser as _;
use super::*;
use crate::args::{Cli, Commands, RunCommands};
#[derive(clap::Parser)]
struct Command {
#[command(flatten)]
args: RunArgs,
}
#[test]
fn run_selection_both_commands_share_the_adapter() {
for command in ["run", "create"] {
let cli = Cli::try_parse_from([
"fabro",
command,
"review",
"--workflow-git",
"acme/workflows",
"--workflow-ref",
"v1",
"--target-git",
"acme/app",
"--target-branch",
"release",
])
.unwrap();
let Commands::RunCmd(RunCommands::Run(args) | RunCommands::Create(args)) =
*cli.command.unwrap()
else {
panic!("expected shared run arguments");
};
assert_eq!(
parse(&args).unwrap(),
(
WorkflowSelection::Git {
repository: "acme/workflows".parse().unwrap(),
selector: "review".into(),
revision: RemoteWorkflowRevision::Ref("v1".into()),
},
TargetSelection::Git {
repository: "acme/app".parse().unwrap(),
branch: Some("release".into()),
},
)
);
}
let cli = Cli::try_parse_from(["fabro", "run", "create"]).unwrap();
assert!(
matches!(*cli.command.unwrap(), Commands::RunCmd(RunCommands::Run(args)) if args.workflow.as_deref() == Some(Path::new("create")))
);
}
#[test]
fn run_selection_adapter_rejects_invalid_inputs_without_acquisition() {
for flags in [
vec![
"cmd",
"review",
"--workflow-git",
"https://github.com/acme/workflows",
],
vec!["cmd", "review", "--target-git", "acme/app/extra"],
vec!["cmd", "../review.toml", "--workflow-git", "acme/workflows"],
vec![
"cmd",
"/tmp/review.toml",
"--workflow-git",
"acme/workflows",
],
vec![
"cmd",
"review",
"--workflow-git",
"acme/workflows",
"--workflow-ref",
"HEAD~1",
],
vec![
"cmd",
"review",
"--target-git",
"acme/app",
"--target-branch",
"refs/tags/v1",
],
vec![
"cmd",
"review",
"--target-git",
"acme/app",
"--target-branch",
"1234567890123456789012345678901234567890",
],
] {
if let Ok(command) = Command::try_parse_from(flags) {
assert!(parse(&command.args).is_err());
}
}
}
}

View file

@ -87,22 +87,27 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Register a local workflow version and create a submitted run
Register a workflow version and create a submitted run
Usage: fabro create [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--dry-run Execute with simulated LLM backend
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--auto-approve Auto-approve all human gates
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
@ -909,9 +914,9 @@ fn create_rejects_unusable_git_checkouts_instead_of_sending_an_empty_target() {
for (working_directory, expected_error) in [
(
detached.path(),
"the caller Git checkout has a detached HEAD",
"the target Git checkout has a detached HEAD",
),
(unborn.path(), "the caller Git checkout has no commits"),
(unborn.path(), "the target Git checkout has no commits"),
] {
let output = context
.create_cmd()
@ -1633,3 +1638,349 @@ draft = false
assert!(pull_request.enabled);
assert!(!pull_request.draft);
}
#[test]
fn run_selection_target_path_keeps_caller_workflow_and_goal() {
let context = test_context!();
let server = MockServer::start();
let environment = mock_environment(&server, "local", "local");
let versions = mock_workflow_version_registrations(&server);
let requests = Arc::new(Mutex::new(Vec::new()));
let create = mock_intent_create(&server, &unique_run_id(), Arc::clone(&requests));
let caller = tempfile::tempdir().unwrap();
let target = caller.path().join("target");
write_workflow(caller.path(), ".fabro/workflows/review", "Caller");
write_workflow(&target, ".fabro/workflows/review", "Target");
std::fs::write(caller.path().join("goal.txt"), "Caller goal").unwrap();
std::fs::write(target.join("goal.txt"), "Target goal").unwrap();
let expected = fabro_manifest::resolve_local_workflow_package(
std::path::Path::new("review"),
caller.path(),
None,
)
.unwrap()
.closure()
.root_id();
let output = context
.create_cmd()
.current_dir(caller.path())
.args([
"review",
"--target-path",
"target",
"--goal-file",
"goal.txt",
"--environment",
"local",
"--server",
&format!("{}/api/v1", server.base_url()),
])
.output()
.unwrap();
assert!(output.status.success(), "{}", output_stderr(&output));
environment.assert();
versions.assert();
create.assert();
let requests = requests.lock().unwrap();
assert_eq!(requests[0]["workflow_version_id"], expected.to_string());
assert_eq!(
requests[0]["target"],
json!({"kind": "folder", "path": target.canonicalize().unwrap()})
);
assert_eq!(requests[0]["goal"], "Caller goal");
}
fn init_remote_fixture(path: &std::path::Path, branch: &str) -> String {
let repo = git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().initial_head(branch),
)
.expect("fixture repository should initialize");
let mut index = repo.index().expect("fixture index should open");
index
.add_all(["."], git2::IndexAddOption::DEFAULT, None)
.expect("fixture files should stage");
let tree_id = index.write_tree().expect("fixture tree should write");
let tree = repo.find_tree(tree_id).expect("fixture tree should exist");
let signature = git2::Signature::now("Fixture", "fixture@example.test")
.expect("fixture signature should be valid");
repo.commit(Some("HEAD"), &signature, &signature, "fixture", &tree, &[])
.expect("fixture commit should succeed")
.to_string()
}
#[test]
fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_independent() {
let context = test_context!();
let server = MockServer::start();
let local_env = mock_environment(&server, "local", "local");
let docker_env = mock_environment(&server, "docker", "docker");
let versions = mock_workflow_version_registrations(&server);
let requests = Arc::new(Mutex::new(Vec::new()));
let create = mock_intent_create(&server, &unique_run_id(), Arc::clone(&requests));
let root = tempfile::tempdir().unwrap();
let caller = root.path().join("caller");
let source = root.path().join("source");
let target = root.path().join("target");
write_workflow(&caller, ".fabro/workflows/review", "Caller");
write_workflow(&source, ".fabro/workflows/review", "Remote");
write_workflow(&target, ".fabro/workflows/review", "Target");
std::fs::write(caller.join("goal.txt"), "Caller goal").unwrap();
std::fs::write(target.join("goal.txt"), "Target goal").unwrap();
init_remote_fixture(&source, "trunk");
let target_sha = init_remote_fixture(&target, "release");
let config = root.path().join("gitconfig");
std::fs::write(&config, format!("[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n[url \"file://{}\"]\n insteadOf = https://github.com/acme/app\n",source.display(),target.display())).unwrap();
let local_id = fabro_manifest::resolve_local_workflow_package(
std::path::Path::new("review"),
&caller,
None,
)
.unwrap()
.closure()
.root_id();
let remote_id =
fabro_manifest::collect_workflow_versions(std::path::Path::new("review"), &source)
.unwrap()
.root_id();
let file_id = fabro_manifest::resolve_local_workflow_package(
std::path::Path::new(".fabro/workflows/review/workflow.toml"),
&caller,
None,
)
.unwrap()
.closure()
.root_id();
assert_ne!(local_id, remote_id);
for source_kind in ["name", "file", "git"] {
for target_kind in ["inferred", "path", "git"] {
let mut command = context.create_cmd();
command
.current_dir(&caller)
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0");
command.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--goal-file",
"goal.txt",
]);
command.arg(if source_kind == "file" {
".fabro/workflows/review/workflow.toml"
} else {
"review"
});
if source_kind == "git" {
command.args([
"--workflow-git",
"acme/workflows",
"--workflow-ref",
"trunk",
]);
}
match target_kind {
"path" => {
command.args(["--target-path", "../target", "--environment", "local"]);
}
"git" => {
command.args([
"--target-git",
"acme/app",
"--target-branch",
"release",
"--environment",
"docker",
]);
}
_ => {
command.args(["--environment", "docker"]);
}
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{source_kind}/{target_kind}: {}",
output_stderr(&output)
);
let requests = requests.lock().unwrap();
let intent = requests.last().unwrap();
assert_eq!(
intent["workflow_version_id"],
match source_kind {
"git" => remote_id,
"file" => file_id,
_ => local_id,
}
.to_string()
);
assert_eq!(intent["goal"], "Caller goal");
assert_eq!(intent["target"], match target_kind {
"path" => json!({"kind":"folder","path":target.canonicalize().unwrap()}),
"git" =>
json!({"kind":"git","repo":"acme/app","branch":"release","sha":target_sha}),
_ => json!({"kind":"none"}),
});
}
}
local_env.assert_calls(3);
docker_env.assert_calls(6);
versions.assert_calls(9);
create.assert_calls(9);
}
#[test]
fn remote_workflow_run_starts_once_create_leaves_submitted_and_failures_do_not_refetch() {
let context = test_context!();
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/review", "Remote");
init_remote_fixture(&source, "trunk");
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n",
source.display()
),
)
.unwrap();
for failure in ["none", "run", "upload", "create", "start"] {
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = if failure == "upload" {
server.mock(|when, then| {
when.method("POST").path("/api/v1/workflow-versions");
then.status(422).body("fixture registration rejection");
})
} else {
mock_workflow_version_registrations(&server)
};
let run_id = unique_run_id();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
if failure == "create" {
then.status(422).body("fixture create rejection");
} else {
then.status(201)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let start = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
if failure == "start" {
then.status(422).body("fixture start rejection");
} else {
then.status(200)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let trace = root.path().join(format!("trace-{failure}"));
let mut command = if failure == "none" {
context.create_cmd()
} else {
context.run_cmd()
};
command
.current_dir(root.path())
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.env("GIT_TRACE", &trace)
.args([
"review",
"--workflow-git",
"acme/workflows",
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--detach",
"--json",
]);
let output = command.output().unwrap();
assert_eq!(
output.status.success(),
matches!(failure, "none" | "run"),
"{}",
output_stderr(&output)
);
environment.assert();
version.assert();
create.assert_calls(usize::from(failure != "upload"));
start.assert_calls(usize::from(matches!(failure, "run" | "start")));
if failure == "none" {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
json!({"run_id":run_id})
);
}
let trace = std::fs::read_to_string(&trace).unwrap();
assert_eq!(
trace
.lines()
.filter(|line| line.contains("built-in: git fetch "))
.count(),
1,
"source fetched more than once"
);
}
}
#[test]
fn remote_workflow_explicit_acquisition_failure_has_no_fallback_or_server_mutations() {
let context = test_context!();
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = mock_workflow_version_registrations(&server);
let create = mock_intent_create(&server, &unique_run_id(), Arc::new(Mutex::new(Vec::new())));
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/other", "Other");
init_remote_fixture(&source, "trunk");
write_workflow(root.path(), ".fabro/workflows/review", "Caller");
write_workflow(
&context.home_dir.join(".fabro/workflows"),
"review",
"Installed",
);
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n",
source.display()
),
)
.unwrap();
for reference in [
"trunk",
"missing",
"1111111111111111111111111111111111111111",
] {
let output = context
.create_cmd()
.current_dir(root.path())
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.args([
"review",
"--workflow-git",
"acme/workflows",
"--workflow-ref",
reference,
"--server",
&format!("{}/api/v1", server.base_url()),
])
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
environment.assert_calls(3);
version.assert_calls(0);
create.assert_calls(0);
}

View file

@ -12,8 +12,8 @@ fn help() {
Usage: fabro [OPTIONS] [COMMAND]
Commands:
run Register a local workflow version, create a run, and start it
create Register a local workflow version and create a submitted run
run Register a workflow version, create a run, and start it
create Register a workflow version and create a submitted run
start Start a created workflow run on the server
attach Attach to a running or finished workflow run
events View the event log of a workflow run

View file

@ -119,22 +119,27 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Register a local workflow version, create a run, and start it
Register a workflow version, create a run, and start it
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
<WORKFLOW> Workflow name or path (repository-relative with --workflow-git)
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--dry-run Execute with simulated LLM backend
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--auto-approve Auto-approve all human gates
--workflow-git <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-path <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target-git <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model