mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-13 23:14:17 +00:00
Merge pull request #845 from fabro-sh/codex/cli-workflow-target-selection
Select CLI workflow sources and run targets independently
This commit is contained in:
commit
1aaca98ea4
17 changed files with 2925 additions and 142 deletions
|
|
@ -140,3 +140,93 @@ 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. Local 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-from ../app
|
||||
fabro run acme/workflows@v1.2:review --target acme/app@release
|
||||
# Equivalent explicit flags:
|
||||
fabro run review --workflow-repo acme/workflows --workflow-ref v1.2 \
|
||||
--target-repo 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.
|
||||
|
||||
Remote workflow shorthand is `OWNER/REPO[@REF]:WORKFLOW`. The workflow selector
|
||||
is required: `acme/workflows:review` selects a named workflow, and
|
||||
`acme/workflows@v1.2:./reviews/security.toml` selects a file. Repository default
|
||||
workflows are not supported; without `:WORKFLOW`, the positional argument retains
|
||||
local lookup behavior. Prefix local paths containing a colon with `./`, `../`,
|
||||
or `/` to avoid shorthand parsing. Shorthand cannot be combined with
|
||||
`--workflow-repo` or `--workflow-ref`. Repository slugs currently imply GitHub.com.
|
||||
|
||||
`--workflow-repo 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-repo`. 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.
|
||||
|
||||
For local workflows without target flags, the existing `run.scm` repository
|
||||
configuration in `workflow.toml` or `.fabro/project.toml` still participates in
|
||||
target inference, with workflow values overriding project values field by field.
|
||||
For clone-based environments, the configured repository must match the checkout's
|
||||
origin. Without that configuration, omission is equivalent to `--target-from .`.
|
||||
Explicit `--target-from`, `--target-repo`, and `--target` selections take precedence
|
||||
over the configured repository.
|
||||
|
||||
Target selection depends on the environment:
|
||||
|
||||
| Selection | Local environment | Clone-based environment (Docker, Daytona, or plugin) |
|
||||
| --- | --- | --- |
|
||||
| Default cwd or `--target-from 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-repo OWNER/REPO` or `--target OWNER/REPO[@BRANCH]` | 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-repo` 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.
|
||||
The shorthand `--target acme/app@release/v2` selects the working branch
|
||||
`release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes
|
||||
accept working branches, while workflow suffixes accept branches, tags, or SHAs.
|
||||
`--target`, `--target-from`, and `--target-repo` are mutually exclusive.
|
||||
`--target-branch` cannot be combined with `--target`.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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, path, or OWNER/REPO[@REF]:WORKFLOW |
|
||||
|
||||
#### 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,14 @@ 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-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
|
||||
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
|
||||
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
|
||||
### `fabro deny`
|
||||
|
||||
|
|
@ -1061,7 +1067,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 +1077,7 @@ fabro run [OPTIONS] <WORKFLOW>
|
|||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
|
||||
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
|
||||
|
||||
#### Options
|
||||
|
||||
|
|
@ -1079,7 +1085,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 +1095,14 @@ 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-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
|
||||
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
|
||||
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
|
||||
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
|
||||
| `-v, --verbose` | Enable verbose output |
|
||||
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
|
||||
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
|
||||
|
||||
### `fabro sandbox`
|
||||
|
||||
|
|
|
|||
|
|
@ -96,12 +96,11 @@ serde_yaml = "0.9"
|
|||
tempfile = "3"
|
||||
sha2.workspace = true
|
||||
shlex = "1"
|
||||
walkdir.workspace = true
|
||||
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"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = { version = "0.9", optional = true }
|
||||
|
|
@ -118,6 +117,7 @@ chrono = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
walkdir.workspace = true
|
||||
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
|
||||
fabro-mcp = { path = "../../components/fabro-mcp", features = ["test-support"] }
|
||||
fabro-build-support = { path = "../../foundation/build-support" }
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
|||
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
|
||||
use fabro_server::serve::DEFAULT_TCP_PORT;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::PermissionLevel;
|
||||
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
|
||||
use fabro_types::settings::run::MergeStrategy;
|
||||
use fabro_types::{GitHubRepositorySlug, PermissionLevel};
|
||||
use fabro_util::printer::Printer;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use lithos_llm::types::ReasoningEffort;
|
||||
|
|
@ -233,11 +233,40 @@ pub(crate) struct RunArgs {
|
|||
#[command(flatten)]
|
||||
pub(crate) inputs: InputOverrideArgs,
|
||||
|
||||
/// Local workflow name, checkout path, .fabro file, or workflow TOML
|
||||
/// Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
#[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_repo: Option<GitHubRepositorySlug>,
|
||||
|
||||
/// Workflow branch, tag, HEAD (default), or full commit SHA; qualify
|
||||
/// ambiguous names
|
||||
#[arg(long, requires = "workflow_repo", 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_all = ["target_repo", "target_repo_selector"], value_name = "PATH")]
|
||||
pub(crate) target_from: Option<PathBuf>,
|
||||
|
||||
/// Target GitHub repository and optional working branch
|
||||
#[arg(long = "target", conflicts_with_all = ["target_repo", "target_branch"], value_name = "OWNER/REPO[@BRANCH]")]
|
||||
pub(crate) target_repo_selector: Option<String>,
|
||||
|
||||
/// Target GitHub OWNER/REPO; the execution sandbox still needs its own
|
||||
/// clone credentials
|
||||
#[arg(long, value_name = "OWNER/REPO")]
|
||||
pub(crate) target_repo: Option<GitHubRepositorySlug>,
|
||||
|
||||
/// Target working branch (default: remote default branch), pinned to its
|
||||
/// observed commit
|
||||
#[arg(long, requires = "target_repo", 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,
|
||||
|
||||
|
|
@ -1243,10 +1272,12 @@ 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),
|
||||
// Boxed so `RunArgs` does not dominate the size of the flattened
|
||||
// `Commands` enum (clippy `large_enum_variant`).
|
||||
/// 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
|
||||
|
|
@ -1974,3 +2005,40 @@ fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
|
|||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod run_selection_grammar_tests {
|
||||
use crate::commands::run::test_support::parse_run_args;
|
||||
|
||||
#[test]
|
||||
fn run_selection_accepts_independent_resource_flags() {
|
||||
for flags in [
|
||||
vec![
|
||||
"review",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"refs/tags/v1",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"release/topic",
|
||||
],
|
||||
vec!["./review.toml", "--target-from", "../app"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_selection_requires_modifier_owners_and_exclusive_targets() {
|
||||
for flags in [
|
||||
vec!["review", "--workflow-ref", "v1"],
|
||||
vec!["review", "--target-branch", "release"],
|
||||
vec!["review", "--target-from", ".", "--target-repo", "acme/app"],
|
||||
vec!["--workflow-repo", "acme/workflows"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use anyhow::Result;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use super::remote_workflow::Interruption;
|
||||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
|
|
@ -15,16 +16,32 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res
|
|||
|
||||
let quiet = args.detach;
|
||||
let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep;
|
||||
let created_run = Box::pin(super::create::create_run(&ctx, &args, styles)).await?;
|
||||
// Ctrl-C stays owned here through start; `attach` installs its own listener.
|
||||
let interruption = Interruption::for_run_args(&args);
|
||||
let (created_run, client) = interruption
|
||||
.guard(async {
|
||||
let created_run = Box::pin(super::create::create_run(
|
||||
&ctx,
|
||||
&args,
|
||||
styles,
|
||||
&interruption,
|
||||
))
|
||||
.await?;
|
||||
|
||||
if !quiet {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
styles.dim.apply_to("Run:"),
|
||||
styles.dim.apply_to(&created_run.run_id),
|
||||
);
|
||||
}
|
||||
if !quiet {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} {}",
|
||||
styles.dim.apply_to("Run:"),
|
||||
styles.dim.apply_to(&created_run.run_id),
|
||||
);
|
||||
}
|
||||
|
||||
let client = ctx.server().await?;
|
||||
super::start::start_run_with_client(&client, &created_run.run_id, false).await?;
|
||||
Ok((created_run, client))
|
||||
})
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
let _sleep_guard = sleep_inhibitor::guard(prevent_idle_sleep);
|
||||
|
|
@ -32,9 +49,6 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res
|
|||
#[cfg(not(feature = "sleep_inhibitor"))]
|
||||
let _ = prevent_idle_sleep;
|
||||
|
||||
let client = ctx.server().await?;
|
||||
super::start::start_run_with_client(&client, &created_run.run_id, false).await?;
|
||||
|
||||
let json = ctx.json_output();
|
||||
if args.detach {
|
||||
if json {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ use fabro_types::{RunId, RunIntent};
|
|||
use fabro_util::terminal::Styles;
|
||||
|
||||
use super::overrides::prepare_intent_overrides;
|
||||
use super::remote_workflow::Interruption;
|
||||
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;
|
||||
|
|
@ -16,19 +20,20 @@ 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.
|
||||
///
|
||||
/// Native Git acquisition runs under `interruption`; the caller guards this
|
||||
/// call (and any later phase before `attach`) with the same handle.
|
||||
pub(crate) async fn create_run(
|
||||
ctx: &CommandContext,
|
||||
args: &RunArgs,
|
||||
styles: &Styles,
|
||||
interruption: &Interruption,
|
||||
) -> 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 {}",
|
||||
|
|
@ -36,11 +41,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),
|
||||
)?;
|
||||
let resolve_workflow = || {
|
||||
resolution::workflow(
|
||||
&workflow_selection,
|
||||
&canonical_cwd,
|
||||
Some(&user_workflows_root),
|
||||
interruption,
|
||||
)
|
||||
};
|
||||
// Preserve local lookup diagnostics before contacting the server. Remote
|
||||
// acquisition waits until the parent and environment are validated.
|
||||
let local_package = match &workflow_selection {
|
||||
WorkflowSelection::Local(_) => Some(resolve_workflow().await?),
|
||||
WorkflowSelection::Git { .. } => None,
|
||||
};
|
||||
let prepared = prepare_intent_overrides(args, &canonical_cwd).await?;
|
||||
|
||||
warn_untransmitted_settings(
|
||||
|
|
@ -49,7 +63,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,
|
||||
|
|
@ -71,20 +90,36 @@ pub(crate) async fn create_run(
|
|||
},
|
||||
resolve_run_environment(client.as_ref(), args.environment.as_deref()),
|
||||
)?;
|
||||
let configured_repo_origin_url =
|
||||
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?;
|
||||
let fabro_manifest::DerivedRunTarget {
|
||||
target,
|
||||
dirty_worktree,
|
||||
} = fabro_manifest::derive_run_target_for_provider(
|
||||
// Observing a local Git target may push its branch. Acquire the remote
|
||||
// workflow first so a bad --workflow-ref never causes that side effect.
|
||||
let package = match local_package {
|
||||
Some(package) => package,
|
||||
None => resolve_workflow().await?,
|
||||
};
|
||||
// Preserve configured repository inference for the existing local workflow
|
||||
// path. Explicit targets select their own repository independently.
|
||||
let configured_repo_origin_url = match &package {
|
||||
ResolvedWorkflow::Local(package)
|
||||
if args.target_from.is_none()
|
||||
&& args.target_repo.is_none()
|
||||
&& args.target_repo_selector.is_none() =>
|
||||
{
|
||||
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (target, dirty_worktree) = resolution::target(
|
||||
&target_selection,
|
||||
&environment.settings.provider,
|
||||
&canonical_cwd,
|
||||
configured_repo_origin_url.as_deref(),
|
||||
)?;
|
||||
interruption,
|
||||
)
|
||||
.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:"),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ 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;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
pub(crate) mod wait;
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
|
|
@ -38,11 +43,19 @@ 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)?;
|
||||
let created_run = Box::pin(create::create_run(&ctx, &args, styles)).await?;
|
||||
let interruption = remote_workflow::Interruption::for_run_args(&args);
|
||||
let created_run = interruption
|
||||
.guard(Box::pin(create::create_run(
|
||||
&ctx,
|
||||
&args,
|
||||
styles,
|
||||
&interruption,
|
||||
)))
|
||||
.await?;
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -165,21 +165,27 @@ mod tests {
|
|||
|
||||
fn run_args() -> RunArgs {
|
||||
RunArgs {
|
||||
target: ServerTargetArgs::default(),
|
||||
inputs: InputOverrideArgs::default(),
|
||||
workflow: Some(PathBuf::from("workflow.fabro")),
|
||||
dry_run: false,
|
||||
auto_approve: false,
|
||||
goal: None,
|
||||
goal_file: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
verbose: false,
|
||||
environment: None,
|
||||
label: Vec::new(),
|
||||
parent: None,
|
||||
preserve_sandbox: false,
|
||||
detach: false,
|
||||
target: ServerTargetArgs::default(),
|
||||
inputs: InputOverrideArgs::default(),
|
||||
workflow: Some(PathBuf::from("workflow.fabro")),
|
||||
workflow_repo: None,
|
||||
workflow_ref: None,
|
||||
target_from: None,
|
||||
target_repo_selector: None,
|
||||
target_repo: None,
|
||||
target_branch: None,
|
||||
dry_run: false,
|
||||
auto_approve: false,
|
||||
goal: None,
|
||||
goal_file: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
verbose: false,
|
||||
environment: None,
|
||||
label: Vec::new(),
|
||||
parent: None,
|
||||
preserve_sandbox: false,
|
||||
detach: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1188
lib/apps/fabro-cli/src/commands/run/remote_workflow.rs
Normal file
1188
lib/apps/fabro-cli/src/commands/run/remote_workflow.rs
Normal file
File diff suppressed because it is too large
Load diff
247
lib/apps/fabro-cli/src/commands/run/resolution.rs
Normal file
247
lib/apps/fabro-cli/src/commands/run/resolution.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use fabro_manifest::{CollectedWorkflowClosure, ResolvedLocalWorkflowPackage};
|
||||
use fabro_types::{RunTarget, SandboxProviderKind};
|
||||
use tokio::task;
|
||||
|
||||
use super::remote_workflow::{Interruption, NativeGit};
|
||||
use super::selection::{TargetSelection, WorkflowSelection};
|
||||
|
||||
/// Owns the canonical collector result without copying its contents. Local
|
||||
/// location metadata remains available for settings warnings and target
|
||||
/// inference.
|
||||
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>,
|
||||
interruption: &Interruption,
|
||||
) -> 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();
|
||||
let (repository, selector, revision) =
|
||||
(repository.clone(), selector.clone(), revision.clone());
|
||||
let closure = interruption
|
||||
.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: &SandboxProviderKind,
|
||||
cwd: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
interruption: &Interruption,
|
||||
) -> anyhow::Result<(RunTarget, bool)> {
|
||||
let path = match selection {
|
||||
TargetSelection::Path(path) => cwd
|
||||
.join(path)
|
||||
.canonicalize()
|
||||
.context("failed to canonicalize target directory")?,
|
||||
TargetSelection::Git { repository, branch } => {
|
||||
if !provider.clones_workspace() {
|
||||
bail!("Git targets require a clone-enabled environment");
|
||||
}
|
||||
let git = NativeGit::new();
|
||||
let (repository, branch) = (repository.clone(), branch.clone());
|
||||
let target = interruption
|
||||
.owned(move |cancel| async move {
|
||||
git.resolve_target(repository, branch, &cancel).await
|
||||
})
|
||||
.await?;
|
||||
// Canonical admission retains ownership of provider capabilities.
|
||||
return Ok((RunTarget::Git(target), false));
|
||||
}
|
||||
};
|
||||
if !path.is_dir() {
|
||||
bail!("target path must be a directory");
|
||||
}
|
||||
// The existing observer can push/query Git synchronously. Preserve its
|
||||
// behavior without blocking a Tokio worker or promising a new timeout.
|
||||
let provider = provider.clone();
|
||||
let configured_repo_origin_url = configured_repo_origin_url.map(str::to_owned);
|
||||
let derived = task::spawn_blocking(move || {
|
||||
fabro_manifest::derive_run_target_for_provider(
|
||||
&provider,
|
||||
&path,
|
||||
configured_repo_origin_url.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.context("target observation task failed")??;
|
||||
Ok((derived.target, derived.dirty_worktree))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "resolver tests construct small local workflow fixtures"
|
||||
)]
|
||||
mod tests {
|
||||
use super::super::test_support::write_workflow;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_selection_target_resolution_by_provider() {
|
||||
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 selected = TargetSelection::Path("target".into());
|
||||
let interruption = Interruption::new(false);
|
||||
assert_eq!(
|
||||
target(
|
||||
&selected,
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&root,
|
||||
None,
|
||||
&interruption
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0,
|
||||
RunTarget::Folder {
|
||||
path: root.join("target").to_str().unwrap().into(),
|
||||
}
|
||||
);
|
||||
for provider in [
|
||||
SandboxProviderKind::DOCKER,
|
||||
SandboxProviderKind::DAYTONA,
|
||||
SandboxProviderKind::try_new("host").unwrap(),
|
||||
] {
|
||||
assert_eq!(
|
||||
target(&selected, &provider, &root, None, &interruption)
|
||||
.await
|
||||
.unwrap()
|
||||
.0,
|
||||
RunTarget::None {}
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
target(
|
||||
&TargetSelection::Path(".".into()),
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&root,
|
||||
None,
|
||||
&interruption
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0,
|
||||
RunTarget::Folder {
|
||||
path: root.to_str().unwrap().into(),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
target(
|
||||
&TargetSelection::Git {
|
||||
repository: "acme/app".parse().unwrap(),
|
||||
branch: None,
|
||||
},
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&root,
|
||||
None,
|
||||
&interruption
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("clone-enabled environment")
|
||||
);
|
||||
for path in ["missing", ".fabro/workflows/review/workflow.toml"] {
|
||||
assert!(
|
||||
target(
|
||||
&TargetSelection::Path(path.into()),
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&root,
|
||||
None,
|
||||
&interruption
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
let interruption = Interruption::new(false);
|
||||
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), &interruption)
|
||||
.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),
|
||||
&interruption
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
444
lib/apps/fabro-cli/src/commands/run/selection.rs
Normal file
444
lib/apps/fabro-cli/src/commands/run/selection.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
//! CLI syntax ends here. Resolvers receive selections and explicit caller
|
||||
//! context.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use fabro_types::{GitHubRepositorySlug, WorkflowPath, 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 {
|
||||
/// Directory relative to the caller; the default is the caller directory
|
||||
/// itself.
|
||||
Path(PathBuf),
|
||||
Git {
|
||||
repository: GitHubRepositorySlug,
|
||||
branch: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A validated `--workflow-ref`, classified once so resolution never re-derives
|
||||
/// which ref namespaces a value may name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum RemoteWorkflowRevision {
|
||||
DefaultBranch,
|
||||
/// A fully qualified `refs/heads/...` branch.
|
||||
Branch(String),
|
||||
/// A fully qualified `refs/tags/...` tag.
|
||||
Tag(String),
|
||||
/// A bare name that may be a branch or a tag.
|
||||
Name(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) {
|
||||
bail!("workflow ref must be a branch, tag, HEAD, or full 40-hex commit SHA");
|
||||
}
|
||||
let reference = value.to_owned();
|
||||
if value.starts_with("refs/heads/") {
|
||||
Ok(Self::Branch(reference))
|
||||
} else if value.starts_with("refs/tags/") {
|
||||
Ok(Self::Tag(reference))
|
||||
} else if value.starts_with("refs/") {
|
||||
bail!("workflow ref must be a branch, tag, HEAD, or full 40-hex commit SHA");
|
||||
} else {
|
||||
Ok(Self::Name(reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_remote_selector(path: &Path) -> anyhow::Result<()> {
|
||||
let value = path
|
||||
.to_str()
|
||||
.context("remote workflow selector must be valid UTF-8")?;
|
||||
let value = value.strip_prefix("./").unwrap_or(value);
|
||||
if WorkflowPath::new(value).is_err() {
|
||||
bail!(
|
||||
"remote workflow must be a name or repository-relative .fabro/.toml file without traversal"
|
||||
);
|
||||
}
|
||||
let is_bare_name = !value.contains('/') && !value.starts_with('-');
|
||||
match Path::new(value).extension().and_then(|ext| ext.to_str()) {
|
||||
Some("toml" | "fabro") => Ok(()),
|
||||
None if is_bare_name => Ok(()),
|
||||
_ => bail!(
|
||||
"remote workflow must be a name or explicit .fabro/.toml file; directories are ambiguous"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit local paths escape the shorthand grammar, including colons in
|
||||
/// file names. A repository without `:WORKFLOW` retains local lookup behavior.
|
||||
pub(super) fn workflow_shorthand(path: &Path) -> Option<(&str, &str)> {
|
||||
let value = path.to_str()?;
|
||||
if path.is_absolute() || value.starts_with("./") || value.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
value.split_once(':')
|
||||
}
|
||||
|
||||
fn repository_revision(value: &str) -> anyhow::Result<(GitHubRepositorySlug, Option<&str>)> {
|
||||
let (repository, revision) = value
|
||||
.split_once('@')
|
||||
.map_or((value, None), |(repository, revision)| {
|
||||
(repository, Some(revision))
|
||||
});
|
||||
let repository = repository
|
||||
.parse()
|
||||
.context("repository must be a GitHub OWNER/REPO")?;
|
||||
if revision == Some("") {
|
||||
bail!("a revision or branch is required after '@'");
|
||||
}
|
||||
Ok((repository, revision))
|
||||
}
|
||||
|
||||
pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, TargetSelection)> {
|
||||
// Flag co-occurrence rules (`requires`/`conflicts_with`) are enforced by clap.
|
||||
let workflow = args.workflow.as_ref().context("workflow is required")?;
|
||||
let workflow = match (&args.workflow_repo, workflow_shorthand(workflow)) {
|
||||
(_, Some(_)) if args.workflow_repo.is_some() || args.workflow_ref.is_some() => {
|
||||
bail!("workflow shorthand cannot be combined with --workflow-repo or --workflow-ref");
|
||||
}
|
||||
(None, Some((source, selector))) => {
|
||||
let (repository, revision) = repository_revision(source)?;
|
||||
let selector = PathBuf::from(selector);
|
||||
validate_remote_selector(&selector)?;
|
||||
WorkflowSelection::Git {
|
||||
repository,
|
||||
selector,
|
||||
revision: RemoteWorkflowRevision::parse(revision)?,
|
||||
}
|
||||
}
|
||||
(None, 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 = if let Some(value) = &args.target_repo_selector {
|
||||
let (repository, branch) = repository_revision(value)?;
|
||||
git_target(repository, branch)?
|
||||
} else {
|
||||
match (&args.target_from, &args.target_repo) {
|
||||
(Some(path), _) => TargetSelection::Path(path.clone()),
|
||||
(_, Some(repository)) => git_target(repository.clone(), args.target_branch.as_deref())?,
|
||||
_ => TargetSelection::Path(PathBuf::from(".")),
|
||||
}
|
||||
};
|
||||
Ok((workflow, target))
|
||||
}
|
||||
|
||||
fn git_target(
|
||||
repository: GitHubRepositorySlug,
|
||||
branch: Option<&str>,
|
||||
) -> anyhow::Result<TargetSelection> {
|
||||
if branch.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");
|
||||
}
|
||||
Ok(TargetSelection::Git {
|
||||
repository,
|
||||
branch: branch.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
#[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, expected) in [
|
||||
(
|
||||
"topic/slash",
|
||||
RemoteWorkflowRevision::Name("topic/slash".into()),
|
||||
),
|
||||
(
|
||||
"refs/heads/release",
|
||||
RemoteWorkflowRevision::Branch("refs/heads/release".into()),
|
||||
),
|
||||
(
|
||||
"refs/tags/v1",
|
||||
RemoteWorkflowRevision::Tag("refs/tags/v1".into()),
|
||||
),
|
||||
("HEAD", RemoteWorkflowRevision::DefaultBranch),
|
||||
(
|
||||
"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
|
||||
RemoteWorkflowRevision::Commit("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd".into()),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
RemoteWorkflowRevision::parse(Some(value)).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
RemoteWorkflowRevision::parse(None).unwrap(),
|
||||
RemoteWorkflowRevision::DefaultBranch
|
||||
);
|
||||
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 super::super::test_support::parse_run_args;
|
||||
use super::*;
|
||||
use crate::args::{Cli, Commands, RunCommands};
|
||||
|
||||
#[test]
|
||||
fn shorthand_matches_explicit_selections_for_both_commands() {
|
||||
for command in ["run", "create"] {
|
||||
for (suffix, reference) in [
|
||||
("", None),
|
||||
("@v1.2", Some("v1.2")),
|
||||
("@release/v2", Some("release/v2")),
|
||||
("@refs/tags/v1", Some("refs/tags/v1")),
|
||||
(
|
||||
"@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
|
||||
Some("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"),
|
||||
),
|
||||
] {
|
||||
for selector in ["review", "./reviews/security.toml"] {
|
||||
for branch in [None, Some("release/v2")] {
|
||||
let workflow = format!("acme/workflows{suffix}:{selector}");
|
||||
let target = branch.map_or_else(
|
||||
|| "acme/app".to_owned(),
|
||||
|branch| format!("acme/app@{branch}"),
|
||||
);
|
||||
let short = ["fabro", command, &workflow, "--target", &target];
|
||||
let mut explicit = vec![
|
||||
"fabro",
|
||||
command,
|
||||
selector,
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
];
|
||||
if let Some(reference) = reference {
|
||||
explicit.extend(["--workflow-ref", reference]);
|
||||
}
|
||||
if let Some(branch) = branch {
|
||||
explicit.extend(["--target-branch", branch]);
|
||||
}
|
||||
let selections = |argv: &[&str]| {
|
||||
let cli = Cli::try_parse_from(argv).unwrap();
|
||||
let Commands::RunCmd(
|
||||
RunCommands::Run(args) | RunCommands::Create(args),
|
||||
) = *cli.command.unwrap()
|
||||
else {
|
||||
panic!("expected run args")
|
||||
};
|
||||
parse(&args).unwrap()
|
||||
};
|
||||
assert_eq!(selections(&short), selections(&explicit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_preserves_local_paths_and_requires_remote_workflow_selector() {
|
||||
for value in [
|
||||
"review",
|
||||
"dir/review.toml",
|
||||
"acme/workflows",
|
||||
"acme/workflows@v1",
|
||||
"./acme/workflows:review",
|
||||
"../acme/workflows:review",
|
||||
"/tmp/workflows:review",
|
||||
] {
|
||||
let args = parse_run_args([value]).unwrap();
|
||||
assert_eq!(
|
||||
parse(&args).unwrap(),
|
||||
(
|
||||
WorkflowSelection::Local(value.into()),
|
||||
TargetSelection::Path(".".into())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_rejects_malformed_or_conflicting_selections_before_acquisition() {
|
||||
for flags in [
|
||||
vec!["acme/workflows:"],
|
||||
vec!["acme/workflows@:review"],
|
||||
vec!["acme/workflows@HEAD~1:review"],
|
||||
vec!["acme/workflows:../review.toml"],
|
||||
vec!["acme/workflows:/review.toml"],
|
||||
vec!["acme/workflows/extra:review"],
|
||||
vec!["https://github.com/acme/workflows:review"],
|
||||
vec!["acme/workflows:review", "--workflow-repo", "acme/other"],
|
||||
vec!["acme/workflows:review", "--workflow-ref", "v1"],
|
||||
vec!["review", "--target", "acme/app@"],
|
||||
vec!["review", "--target", "acme/app@refs/tags/v1"],
|
||||
vec![
|
||||
"review",
|
||||
"--target",
|
||||
"acme/app@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
|
||||
],
|
||||
vec!["review", "--target", "acme/app@main..next"],
|
||||
vec!["review", "--target", "acme/app/extra"],
|
||||
vec!["review", "--target", "acme/app", "--target-from", "."],
|
||||
vec![
|
||||
"review",
|
||||
"--target",
|
||||
"acme/app",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
],
|
||||
vec!["review", "--target", "acme/app", "--target-branch", "main"],
|
||||
] {
|
||||
if let Ok(args) = parse_run_args(flags.iter().copied()) {
|
||||
assert!(parse(&args).is_err(), "{flags:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_selection_both_commands_share_the_adapter() {
|
||||
for command in ["run", "create"] {
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
command,
|
||||
"review",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"v1",
|
||||
"--target-repo",
|
||||
"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::Name("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() {
|
||||
// Malformed repository slugs never reach the adapter.
|
||||
for flags in [
|
||||
[
|
||||
"review",
|
||||
"--workflow-repo",
|
||||
"https://github.com/acme/workflows",
|
||||
],
|
||||
["review", "--target-repo", "acme/app/extra"],
|
||||
] {
|
||||
assert!(parse_run_args(flags).is_err());
|
||||
}
|
||||
for flags in [
|
||||
vec!["../review.toml", "--workflow-repo", "acme/workflows"],
|
||||
vec!["/tmp/review.toml", "--workflow-repo", "acme/workflows"],
|
||||
vec![
|
||||
"review",
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"HEAD~1",
|
||||
],
|
||||
vec![
|
||||
"review",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"refs/tags/v1",
|
||||
],
|
||||
vec![
|
||||
"review",
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"1234567890123456789012345678901234567890",
|
||||
],
|
||||
] {
|
||||
let args = parse_run_args(flags.iter().copied()).unwrap();
|
||||
assert!(parse(&args).is_err(), "{flags:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
62
lib/apps/fabro-cli/src/commands/run/test_support.rs
Normal file
62
lib/apps/fabro-cli/src/commands/run/test_support.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Fixtures shared by the run selection, resolution, and remote workflow
|
||||
//! unit tests.
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "test fixtures write small files synchronously"
|
||||
)]
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Parser as _;
|
||||
|
||||
use crate::args::RunArgs;
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct Command {
|
||||
#[command(flatten)]
|
||||
args: RunArgs,
|
||||
}
|
||||
|
||||
/// Parse `fabro run`/`fabro create` arguments exactly as clap would.
|
||||
pub(crate) fn parse_run_args<'a>(
|
||||
args: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<RunArgs, clap::Error> {
|
||||
Command::try_parse_from(std::iter::once("cmd").chain(args)).map(|command| command.args)
|
||||
}
|
||||
|
||||
/// Write a minimal two-file workflow package under `root/name`.
|
||||
pub(super) 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();
|
||||
}
|
||||
|
||||
/// Stage every file in the worktree and commit it on HEAD, returning the SHA.
|
||||
pub(super) fn commit_all(repo: &git2::Repository, message: &str) -> String {
|
||||
let mut index = repo.index().unwrap();
|
||||
index
|
||||
.add_all(["."], git2::IndexAddOption::DEFAULT, None)
|
||||
.unwrap();
|
||||
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
|
||||
let parent = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
|
||||
let parents: Vec<_> = parent.iter().collect();
|
||||
let signature = git2::Signature::now("Fixture", "fixture@example.test").unwrap();
|
||||
repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
message,
|
||||
&tree,
|
||||
&parents,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
|
@ -12,10 +12,10 @@ use insta::assert_snapshot;
|
|||
use serde_json::json;
|
||||
|
||||
use super::support::{
|
||||
created_run_id, environment_json, fixture, mock_environment,
|
||||
created_run_id, environment_json, fixture, init_remote_fixture, mock_environment,
|
||||
mock_workflow_version_registrations, mock_workflow_version_registrations_recording,
|
||||
output_stderr, output_stdout, remote_run_summary_json, resolve_run, run_count_for_test_case,
|
||||
run_git, run_state,
|
||||
run_git, run_state, write_workflow,
|
||||
};
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
|
|
@ -60,24 +60,6 @@ fn mock_intent_create<'a>(
|
|||
})
|
||||
}
|
||||
|
||||
fn write_workflow(root: &std::path::Path, directory: &str, graph_name: &str) -> std::path::PathBuf {
|
||||
let directory = root.join(directory);
|
||||
std::fs::create_dir_all(&directory).expect("workflow fixture directory should be created");
|
||||
std::fs::write(
|
||||
directory.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.expect("workflow fixture manifest should be written");
|
||||
std::fs::write(
|
||||
directory.join("workflow.fabro"),
|
||||
format!(
|
||||
"digraph {graph_name} {{ start [shape=Mdiamond] exit [shape=Msquare] start -> exit }}"
|
||||
),
|
||||
)
|
||||
.expect("workflow fixture graph should be written");
|
||||
directory.join("workflow.toml")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
|
|
@ -87,33 +69,39 @@ 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, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
|
||||
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
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--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
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
--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)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-repo <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-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
|
||||
--target-repo <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
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
|
@ -688,6 +676,112 @@ fn create_preserves_named_user_other_checkout_and_loose_file_selection() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_preserves_configured_repository_inference_but_explicit_target_path_wins() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let environment = mock_environment(&server, "default", "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 checkout = root.path().join("checkout");
|
||||
let workflow = write_workflow(&checkout, "workflow", "ConfiguredRepository");
|
||||
std::fs::write(&workflow, "_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.scm]\nowner = \"acme\"\nrepository = \"configured\"\n").unwrap();
|
||||
let sha = init_remote_fixture(&checkout, "topic");
|
||||
let origin = root.path().join("origin.git");
|
||||
let bare = git2::Repository::init_bare(&origin).unwrap();
|
||||
run_git(&checkout, &[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/acme/actual.git",
|
||||
]);
|
||||
run_git(&checkout, &[
|
||||
"remote",
|
||||
"set-url",
|
||||
"--push",
|
||||
"origin",
|
||||
&format!("file://{}", origin.display()),
|
||||
]);
|
||||
let server_url = format!("{}/api/v1", server.base_url());
|
||||
let inferred = context
|
||||
.create_cmd()
|
||||
.current_dir(&checkout)
|
||||
.args(["--server", &server_url, workflow.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!inferred.status.success());
|
||||
assert!(
|
||||
output_stderr(&inferred)
|
||||
.contains("run.scm repository that is not the local checkout's origin")
|
||||
);
|
||||
assert!(
|
||||
bare.find_reference("refs/heads/topic").is_err(),
|
||||
"rejected inference must not publish the branch"
|
||||
);
|
||||
versions.assert_calls(0);
|
||||
create.assert_calls(0);
|
||||
|
||||
let explicit = context
|
||||
.create_cmd()
|
||||
.current_dir(&checkout)
|
||||
.args([
|
||||
"--server",
|
||||
&server_url,
|
||||
workflow.to_str().unwrap(),
|
||||
"--target-from",
|
||||
".",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(explicit.status.success(), "{}", output_stderr(&explicit));
|
||||
assert_eq!(
|
||||
requests.lock().unwrap()[0]["target"],
|
||||
json!({
|
||||
"kind": "git", "repo": "acme/actual", "branch": "topic", "sha": sha
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
bare.find_reference("refs/heads/topic")
|
||||
.unwrap()
|
||||
.target()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
sha
|
||||
);
|
||||
let config = root.path().join("gitconfig");
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/actual\n",
|
||||
origin.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let shorthand = context
|
||||
.create_cmd()
|
||||
.current_dir(&checkout)
|
||||
.env("GIT_CONFIG_GLOBAL", &config)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.args([
|
||||
"--server",
|
||||
&server_url,
|
||||
workflow.to_str().unwrap(),
|
||||
"--target",
|
||||
"acme/actual@topic",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(shorthand.status.success(), "{}", output_stderr(&shorthand));
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests[0]["target"], requests[1]["target"]);
|
||||
environment.assert_calls(3);
|
||||
versions.assert_calls(2);
|
||||
create.assert_calls(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_clone_targets_require_exact_git_observations() {
|
||||
let context = test_context!();
|
||||
|
|
@ -1633,3 +1727,278 @@ draft = false
|
|||
assert!(pull_request.enabled);
|
||||
assert!(!pull_request.draft);
|
||||
}
|
||||
|
||||
#[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 plugin_env = mock_environment(&server, "plugin", "host");
|
||||
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", "shorthand"] {
|
||||
for target_kind in ["inferred", "path", "git", "git-plugin", "shorthand"] {
|
||||
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 == "shorthand" {
|
||||
"acme/workflows@trunk:review"
|
||||
} else if source_kind == "file" {
|
||||
".fabro/workflows/review/workflow.toml"
|
||||
} else {
|
||||
"review"
|
||||
});
|
||||
if source_kind == "git" {
|
||||
command.args([
|
||||
"--workflow-repo",
|
||||
"acme/workflows",
|
||||
"--workflow-ref",
|
||||
"trunk",
|
||||
]);
|
||||
}
|
||||
match target_kind {
|
||||
"shorthand" => {
|
||||
command.args(["--target", "acme/app@release", "--environment", "docker"]);
|
||||
}
|
||||
"path" => {
|
||||
command.args(["--target-from", "../target", "--environment", "local"]);
|
||||
}
|
||||
"git" | "git-plugin" => {
|
||||
command.args([
|
||||
"--target-repo",
|
||||
"acme/app",
|
||||
"--target-branch",
|
||||
"release",
|
||||
"--environment",
|
||||
if target_kind == "git-plugin" {
|
||||
"plugin"
|
||||
} else {
|
||||
"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" | "shorthand" => 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" | "git-plugin" | "shorthand" =>
|
||||
json!({"kind":"git","repo":"acme/app","branch":"release","sha":target_sha}),
|
||||
_ => json!({"kind":"none"}),
|
||||
});
|
||||
}
|
||||
}
|
||||
local_env.assert_calls(4);
|
||||
docker_env.assert_calls(12);
|
||||
plugin_env.assert_calls(4);
|
||||
versions.assert_calls(20);
|
||||
create.assert_calls(20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_leaves_remote_workflow_run_submitted_without_starting() {
|
||||
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();
|
||||
let server = MockServer::start();
|
||||
let environment = mock_environment(&server, "default", "docker");
|
||||
let version = mock_workflow_version_registrations(&server);
|
||||
let run_id = unique_run_id();
|
||||
let create = server.mock(|when, then| {
|
||||
when.method("POST").path("/api/v1/runs");
|
||||
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"));
|
||||
});
|
||||
let trace = root.path().join("trace");
|
||||
let output = context
|
||||
.create_cmd()
|
||||
.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-repo",
|
||||
"acme/workflows",
|
||||
"--server",
|
||||
&format!("{}/api/v1", server.base_url()),
|
||||
"--dry-run",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", output_stderr(&output));
|
||||
environment.assert();
|
||||
version.assert();
|
||||
create.assert();
|
||||
start.assert_calls(0);
|
||||
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");
|
||||
// The caller is a GitHub-origin checkout with an unpushed branch, which
|
||||
// Docker target observation would publish if it ran first.
|
||||
let caller = root.path().join("caller");
|
||||
let origin = root.path().join("origin.git");
|
||||
git2::Repository::init_bare(&origin).unwrap();
|
||||
write_workflow(&caller, ".fabro/workflows/review", "Caller");
|
||||
init_remote_fixture(&caller, "main");
|
||||
git2::Repository::open(&caller)
|
||||
.unwrap()
|
||||
.remote("origin", "https://github.com/acme/app")
|
||||
.unwrap();
|
||||
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[url \"file://{}\"]\n insteadOf = https://github.com/acme/app\n",
|
||||
source.display(),
|
||||
origin.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for reference in [
|
||||
"trunk",
|
||||
"missing",
|
||||
"1111111111111111111111111111111111111111",
|
||||
] {
|
||||
let output = context
|
||||
.create_cmd()
|
||||
.current_dir(&caller)
|
||||
.env("GIT_CONFIG_GLOBAL", &config)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.args([
|
||||
"review",
|
||||
"--workflow-repo",
|
||||
"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);
|
||||
// Acquisition failed before target observation, so nothing was pushed.
|
||||
assert!(
|
||||
git2::Repository::open_bare(&origin)
|
||||
.unwrap()
|
||||
.find_reference("refs/heads/main")
|
||||
.is_err(),
|
||||
"a failed remote workflow acquisition must not publish the target branch"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use httpmock::MockServer;
|
|||
use serde_json::Value;
|
||||
|
||||
use super::support::{
|
||||
created_run_id, mock_environment, mock_workflow_version_registrations, output_stderr,
|
||||
remote_run_summary_json, run_state, wait_for_event_names,
|
||||
created_run_id, init_remote_fixture, mock_environment, mock_workflow_version_registrations,
|
||||
output_stderr, remote_run_summary_json, run_state, wait_for_event_names, write_workflow,
|
||||
};
|
||||
use crate::support::{LightweightCli, run_output_filters, run_projection_json, unique_run_id};
|
||||
|
||||
|
|
@ -119,33 +119,39 @@ 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, path, or OWNER/REPO[@REF]:WORKFLOW
|
||||
|
||||
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
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--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
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
--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)
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--workflow-repo <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-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
|
||||
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
|
||||
--target-repo <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
|
||||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--environment <ENVIRONMENT> Named environment for agent tools
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--parent <RUN> Link this run to an existing orchestration parent run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
-d, --detach Run the workflow in the background and print the run ID
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
|
@ -1161,3 +1167,98 @@ fn detach_creates_run_dir_with_detach_log() {
|
|||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_starts_remote_workflow_once_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", "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 output = context
|
||||
.run_cmd()
|
||||
.current_dir(root.path())
|
||||
.env("GIT_CONFIG_GLOBAL", &config)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_COUNT", "0")
|
||||
.env("GIT_TRACE", &trace)
|
||||
.args([
|
||||
"acme/workflows:review",
|
||||
"--server",
|
||||
&format!("{}/api/v1", server.base_url()),
|
||||
"--dry-run",
|
||||
"--detach",
|
||||
"--json",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
failure == "none",
|
||||
"{failure}: {}",
|
||||
output_stderr(&output)
|
||||
);
|
||||
environment.assert();
|
||||
version.assert();
|
||||
create.assert_calls(usize::from(failure != "upload"));
|
||||
start.assert_calls(usize::from(matches!(failure, "none" | "start")));
|
||||
if failure == "none" {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
|
||||
serde_json::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,
|
||||
"{failure}: source fetched more than once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,47 @@ pub(crate) fn mock_workflow_version_registrations_recording(
|
|||
|
||||
/// Runs a `git` command in `path` for fixture setup, panicking on failure and
|
||||
/// returning trimmed stdout.
|
||||
/// Write a minimal `workflow.toml` and `workflow.fabro` pair under
|
||||
/// `root/directory`; `graph_name` distinguishes fixtures by content.
|
||||
pub(crate) fn write_workflow(root: &Path, directory: &str, graph_name: &str) -> PathBuf {
|
||||
let directory = root.join(directory);
|
||||
std::fs::create_dir_all(&directory).expect("workflow fixture directory should be created");
|
||||
std::fs::write(
|
||||
directory.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.expect("workflow fixture manifest should be written");
|
||||
std::fs::write(
|
||||
directory.join("workflow.fabro"),
|
||||
format!(
|
||||
"digraph {graph_name} {{ start [shape=Mdiamond] exit [shape=Msquare] start -> exit }}"
|
||||
),
|
||||
)
|
||||
.expect("workflow fixture graph should be written");
|
||||
directory.join("workflow.toml")
|
||||
}
|
||||
|
||||
/// Initialize `path` as a repository on `branch` with one commit of its
|
||||
/// current contents, returning the commit SHA.
|
||||
pub(crate) fn init_remote_fixture(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()
|
||||
}
|
||||
|
||||
pub(crate) fn run_git(path: &Path, args: &[&str]) -> String {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
|
|
|
|||
|
|
@ -111,17 +111,6 @@ pub fn collect_workflow_versions(
|
|||
checkout_root: &Path,
|
||||
) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
|
||||
let repository_workflow = repository_workflow_path(workflow);
|
||||
let location = crate::resolve_existing_workflow_location(&repository_workflow, checkout_root)
|
||||
.map_err(|source| match source {
|
||||
fabro_config::Error::WorkflowNotFound(_) => WorkflowVersionCollectError::WorkflowNotFound {
|
||||
path: workflow.to_path_buf(),
|
||||
},
|
||||
source => WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: source.into(),
|
||||
},
|
||||
})?;
|
||||
|
||||
let package_root =
|
||||
checkout_root
|
||||
.canonicalize()
|
||||
|
|
@ -132,6 +121,21 @@ pub fn collect_workflow_versions(
|
|||
checkout_root.display()
|
||||
)),
|
||||
})?;
|
||||
ensure_selection_contained(
|
||||
&checkout_root.join(&repository_workflow),
|
||||
&package_root,
|
||||
workflow,
|
||||
)?;
|
||||
let location = crate::resolve_existing_workflow_location(&repository_workflow, checkout_root)
|
||||
.map_err(|source| match source {
|
||||
fabro_config::Error::WorkflowNotFound(_) => WorkflowVersionCollectError::WorkflowNotFound {
|
||||
path: workflow.to_path_buf(),
|
||||
},
|
||||
source => WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: source.into(),
|
||||
},
|
||||
})?;
|
||||
let location = canonicalize_location(location, |path, source| {
|
||||
WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
|
|
@ -186,6 +190,51 @@ pub fn collect_workflow_versions_at_location(
|
|||
VersionAssembler::new(collected).assemble()
|
||||
}
|
||||
|
||||
/// Location resolution reads the selected TOML, or a selected graph's sibling
|
||||
/// `workflow.toml`, before the bundler's root-checked reads begin. Refuse a
|
||||
/// selection whose file resolves outside the package first, so a symlink in an
|
||||
/// untrusted checkout never reads host content. Missing files are left for
|
||||
/// resolution to report; symlinks elsewhere in the checkout are irrelevant
|
||||
/// because every file the bundler reads is checked when it is opened.
|
||||
fn ensure_selection_contained(
|
||||
selected: &Path,
|
||||
package_root: &Path,
|
||||
workflow: &Path,
|
||||
) -> Result<(), WorkflowVersionCollectError> {
|
||||
let mut candidates = vec![selected.to_path_buf()];
|
||||
if selected
|
||||
.extension()
|
||||
.is_none_or(|extension| extension != "toml")
|
||||
{
|
||||
candidates.push(selected.with_file_name("workflow.toml"));
|
||||
}
|
||||
for path in candidates {
|
||||
if path.symlink_metadata().is_err() {
|
||||
continue;
|
||||
}
|
||||
let canonical =
|
||||
path.canonicalize()
|
||||
.map_err(|source| WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: anyhow::Error::new(source).context(format!(
|
||||
"failed to canonicalize workflow file {}",
|
||||
path.display()
|
||||
)),
|
||||
})?;
|
||||
if !canonical.starts_with(package_root) {
|
||||
return Err(WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: anyhow::anyhow!(
|
||||
"workflow file `{}` resolves outside its source root `{}`",
|
||||
path.display(),
|
||||
package_root.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repository_workflow_path(workflow: &Path) -> PathBuf {
|
||||
if workflow.is_relative() && workflow.extension().is_none() {
|
||||
Path::new(".fabro/workflows")
|
||||
|
|
@ -425,6 +474,50 @@ dockerfile = { path = "Dockerfile" }
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_selected_files_that_resolve_outside_the_package_root() {
|
||||
let host = tempfile::tempdir().unwrap();
|
||||
write(host.path(), "workflow.toml", "_version = 1\n");
|
||||
for selector in ["root", ".fabro/workflows/root/workflow.fabro"] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
write_complete_fixture(temp.path());
|
||||
let toml = temp.path().join(".fabro/workflows/root/workflow.toml");
|
||||
fs::remove_file(&toml).unwrap();
|
||||
std::os::unix::fs::symlink(host.path().join("workflow.toml"), &toml).unwrap();
|
||||
let error = collect_workflow_versions(Path::new(selector), temp.path()).unwrap_err();
|
||||
assert!(
|
||||
format!("{error:?}").contains("outside its source root"),
|
||||
"{selector}: {error:?}"
|
||||
);
|
||||
// A dangling selection is refused rather than reported as missing.
|
||||
fs::remove_file(&toml).unwrap();
|
||||
std::os::unix::fs::symlink(host.path().join("missing.toml"), &toml).unwrap();
|
||||
let error = collect_workflow_versions(Path::new(selector), temp.path()).unwrap_err();
|
||||
assert!(
|
||||
format!("{error:?}").contains("failed to canonicalize workflow file"),
|
||||
"{selector}: {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ignores_symlinks_the_selected_workflow_never_reads() {
|
||||
let host = tempfile::tempdir().unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
write_complete_fixture(temp.path());
|
||||
std::os::unix::fs::symlink(host.path(), temp.path().join("tools")).unwrap();
|
||||
std::os::unix::fs::symlink("../missing", temp.path().join("vendor")).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
"/nonexistent/module",
|
||||
temp.path().join(".fabro/workflows/root/unrelated"),
|
||||
)
|
||||
.unwrap();
|
||||
let closure = collect_workflow_versions(Path::new("root"), temp.path()).unwrap();
|
||||
assert_eq!(closure.versions().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_named_workflow_without_project_config() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue