Add tag support to Git run targets

This commit is contained in:
Scott Werner 2026-08-26 11:16:37 -04:00
parent 34014e6dce
commit fbba98defd
32 changed files with 692 additions and 112 deletions

View file

@ -9318,7 +9318,10 @@ components:
folder: "#/components/schemas/FolderRunTarget"
GitRunTarget:
description: Public github.com repository target.
description: >-
Public github.com repository target. The branch names the attached
working branch. An optional tag selects a release at worker start, and
an optional exact SHA is authoritative when both are present.
type: object
additionalProperties: false
required:
@ -9335,14 +9338,23 @@ components:
example: acme/my-app
branch:
type: string
description: Required branch name, preserved exactly.
description: Required attached working branch name, preserved exactly.
example: feature/foo
tag:
type: string
minLength: 1
description: >-
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
are rejected. Without `sha`, the worker resolves this tag when the
sandbox starts and fails if it is unavailable.
example: v1.2.3
sha:
type: string
pattern: "^[0-9A-Fa-f]{40}$"
description: >-
Optional exact commit. The server lowercase-normalizes its syntax
but does not resolve it or prove branch ancestry.
but does not resolve it, prove branch ancestry, or prove that it
matches an accompanying tag. When present, this exact commit wins.
NoneRunTarget:
description: >-

View file

@ -270,7 +270,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -227,18 +227,29 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
#### Exact commits for run intents
#### Git targets for run intents
The `RunIntent` create body names a required Git branch and may also pin a full
40-character commit SHA. Creating the run validates and lowercase-normalizes
the SHA, but it does not contact GitHub, resolve the commit, or prove that the
commit belongs to the submitted branch.
The `RunIntent` create body always names a GitHub repository and a working
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
include both:
At sandbox setup, Docker fetches the submitted commit directly and Daytona
receives it as `commit_id`; the submitted branch remains the working branch.
If the exact commit is unavailable, setup fails. Fabro never substitutes the
branch's newer HEAD. When the request omits `sha`, the sandbox resolves the
branch at materialization time instead.
| Target fields | Revision selected when the worker starts |
|---|---|
| `branch` | The branch HEAD |
| `branch` + `sha` | The exact commit |
| `branch` + `tag` | The tag's peeled commit |
| `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity |
`branch` is always the attached branch inside the sandbox. `tag` is a bare tag
name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An
unpinned tag is resolved when the worker starts, so moving a tag before that
point changes the selected commit.
Creating the run validates the selectors and lowercase-normalizes `sha`, but
does not contact GitHub or prove ancestry. An exact SHA is authoritative:
Fabro does not prove it belongs to the branch or matches the accompanying tag.
If a requested tag or exact commit is unavailable, sandbox setup fails without
falling back to a same-named branch or the branch's newer HEAD.
### GITHUB_TOKEN injection

View file

@ -935,6 +935,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
@ -947,6 +948,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
api_key: daytona_api_key,
}

View file

@ -809,12 +809,12 @@ async fn batch_archive_runs(
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Archive,
)
))
.await
}
@ -823,12 +823,12 @@ async fn batch_unarchive_runs(
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Unarchive,
)
))
.await
}

View file

@ -1091,7 +1091,7 @@ async fn validate_intent_environment(
SandboxProviderKind::Daytona => image.docker.is_some(),
};
let (target_incompatible, detail) = match target {
RunTarget::Git { .. } => (
RunTarget::Git(_) => (
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
"Git targets require a compatible clone-enabled Docker or Daytona environment",
),

View file

@ -1169,7 +1169,10 @@ async fn drive_agent_session(
result = &mut process => {
while let Ok(event) = receiver.try_recv() {
record_turn_output(output, &event);
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
Box::pin(persist_agent_event(
run_store, run_id, session_id, turn_id, event, sender,
))
.await?;
}
return Ok(result);
}
@ -1177,7 +1180,10 @@ async fn drive_agent_session(
match event {
Ok(event) => {
record_turn_output(output, &event);
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
Box::pin(persist_agent_event(
run_store, run_id, session_id, turn_id, event, sender,
))
.await?;
}
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
}

View file

@ -3598,7 +3598,7 @@ async fn store_workflow_version(
}
#[tokio::test]
async fn post_runs_run_intent_creates_submitted_version_backed_git_target_without_starting() {
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(
@ -3638,6 +3638,7 @@ docker = "workflow-owned:latest"
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent",
"tag": "v1.2.3",
"sha": submitted_sha
},
"args": {
@ -3671,11 +3672,12 @@ docker = "workflow-owned:latest"
);
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::Git {
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
})
}))
);
assert_eq!(
projection

View file

@ -29,6 +29,7 @@ async fn shell_reports_real_docker_process_outcome() {
None,
None,
None,
None,
) else {
return;
};

View file

@ -8,6 +8,7 @@ pub(crate) enum CloneDecision {
GitHub {
origin_url: String,
branch: Option<String>,
tag: Option<String>,
commit_sha: Option<String>,
},
}
@ -105,6 +106,25 @@ pub(crate) fn exact_fetch_command(
)
}
/// Fetch one fully-qualified tag without consulting a same-named branch.
#[cfg(any(feature = "docker", test))]
pub(crate) fn tag_fetch_command(
checkout_path: &str,
fetch_source: &str,
tag: &str,
depth: Option<usize>,
) -> String {
let depth_arg = depth_argument(depth);
let tag_ref = format!("refs/tags/{tag}");
format!(
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
sandbox::shell_quote(checkout_path),
sandbox::shell_quote(fetch_source),
sandbox::shell_quote(&tag_ref),
git = sandbox::GIT,
)
}
/// Leading-space ` --depth N` fragment for a Git command, or empty when
/// `depth` is `None` to fetch full history.
#[cfg(any(feature = "docker", test))]
@ -155,6 +175,13 @@ pub(crate) fn exact_checkout_verify_command(
)
}
/// Peel the fetched tag to a commit, attach it to the working branch, and
/// print the resolved commit ID.
#[cfg(any(feature = "docker", test))]
pub(crate) fn tag_checkout_verify_command(checkout_path: &str, branch: &str) -> String {
exact_checkout_verify_command(checkout_path, branch, "FETCH_HEAD^{commit}")
}
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
let actual_sha = output.trim();
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
@ -168,6 +195,12 @@ pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Resu
Ok(())
}
pub(crate) fn verify_resolved_head(output: &str) -> crate::Result<String> {
normalize_exact_commit_sha(output.trim()).map_err(|err| {
crate::Error::context("Tag checkout produced an invalid HEAD commit ID", err)
})
}
fn trim_root(root: &str) -> &str {
let trimmed = root.trim_end_matches('/');
if trimmed.is_empty() { "/" } else { trimmed }
@ -194,31 +227,48 @@ pub(crate) fn decide_clone(
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
clone_tag: Option<&str>,
clone_commit_sha: Option<&str>,
) -> crate::Result<CloneDecision> {
let tag = clone_tag
.map(|tag| {
if tag.trim().is_empty() {
Err(crate::Error::message(
"Tag checkout requires a non-empty tag",
))
} else {
Ok(tag.to_string())
}
})
.transpose()?;
let commit_sha = clone_commit_sha
.map(normalize_exact_commit_sha)
.transpose()?;
if commit_sha.is_some() {
if tag.is_some() || commit_sha.is_some() {
let selector = if commit_sha.is_some() {
"Exact commit checkout"
} else {
"Tag checkout"
};
if skip_clone {
return Err(crate::Error::message(
"Exact commit checkout requires cloning to be enabled",
));
return Err(crate::Error::message(format!(
"{selector} requires cloning to be enabled"
)));
}
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository origin",
));
return Err(crate::Error::message(format!(
"{selector} requires a repository origin"
)));
}
// The branch names the checkout the run works on; it is not used to
// constrain which commits may be fetched. No layer proves branch/SHA
// ancestry, and an unavailable exact commit fails without falling back
// to branch HEAD.
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository branch",
));
return Err(crate::Error::message(format!(
"{selector} requires a repository branch"
)));
}
}
@ -246,6 +296,7 @@ pub(crate) fn decide_clone(
branch: clone_branch
.filter(|branch| !branch.trim().is_empty())
.map(str::to_string),
tag,
commit_sha,
})
}
@ -267,7 +318,7 @@ pub(crate) fn repo_cloned_for_record(
clone_origin_url: Option<&str>,
) -> Option<bool> {
Some(matches!(
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
decide_clone(skip_clone, clone_origin_url, None, None, None).ok()?,
CloneDecision::GitHub { .. }
))
}
@ -307,17 +358,8 @@ mod tests {
String::from_utf8(output.stdout).expect("git output should be UTF-8")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
)]
fn run_shell(cwd: &Path, command: &str) -> String {
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
"--noprofile",
"--norc",
"-c",
command,
]));
let output = run_shell_output(cwd, command);
assert!(
output.status.success(),
"command failed: {}",
@ -326,6 +368,19 @@ mod tests {
String::from_utf8(output.stdout).expect("command output should be UTF-8")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
)]
fn run_shell_output(cwd: &Path, command: &str) -> Output {
isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
"--noprofile",
"--norc",
"-c",
command,
]))
}
#[test]
fn skip_clone_overrides_present_origin() {
assert_eq!(
@ -334,6 +389,7 @@ mod tests {
Some("https://gitlab.com/acme/widgets.git"),
Some("main"),
None,
None,
)
.unwrap(),
CloneDecision::EmptyWorkspace {
@ -345,7 +401,7 @@ mod tests {
#[test]
fn missing_origin_creates_empty_workspace() {
assert_eq!(
decide_clone(false, None, None, None).unwrap(),
decide_clone(false, None, None, None, None).unwrap(),
CloneDecision::EmptyWorkspace {
reason: EmptyWorkspaceReason::MissingOrigin,
}
@ -360,16 +416,50 @@ mod tests {
Some("git@github.com:acme/widgets.git"),
Some("feature/work"),
None,
None,
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("feature/work".to_string()),
tag: None,
commit_sha: None,
}
);
}
#[test]
fn tag_clone_keeps_working_branch_and_bare_tag_distinct() {
assert_eq!(
decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("release"),
Some("v1.2.3"),
None,
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("release".to_string()),
tag: Some("v1.2.3".to_string()),
commit_sha: None,
}
);
}
#[test]
fn tag_fetch_and_checkout_commands_use_the_fully_qualified_tag() {
assert_eq!(
tag_fetch_command("/repos/acme/widgets", "origin", "release/v1", Some(10)),
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets fetch --depth 10 --no-tags origin -- refs/tags/release/v1"
);
assert_eq!(
tag_checkout_verify_command("/repos/acme/widgets", "release"),
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets checkout -B release FETCH_HEAD'^{commit}' && git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets rev-parse HEAD"
);
}
#[test]
fn non_github_origin_fails_without_skip_clone() {
let error = decide_clone(
@ -377,6 +467,7 @@ mod tests {
Some("https://gitlab.com/acme/widgets.git"),
None,
None,
None,
)
.expect_err("non-GitHub origins should fail");
assert!(error.to_string().contains("GitHub repository origins only"));
@ -392,12 +483,14 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
Some("moving-branch"),
Some("release"),
Some(lowercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("moving-branch".to_string()),
tag: Some("release".to_string()),
commit_sha: Some(lowercase.to_string()),
}
);
@ -406,12 +499,14 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
Some("main"),
None,
Some(uppercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("main".to_string()),
tag: None,
commit_sha: Some(uppercase.to_ascii_lowercase()),
}
);
@ -432,6 +527,7 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
None,
None,
Some(sha),
)
.expect_err("invalid exact commit SHA should fail");
@ -449,13 +545,14 @@ mod tests {
true,
Some("https://github.com/acme/widgets"),
Some("main"),
None,
Some(sha),
)
.expect_err("exact checkout with skip-clone should fail");
assert!(skip_error.to_string().contains("requires cloning"));
for origin in [None, Some(""), Some(" ")] {
let error = decide_clone(false, origin, Some("main"), Some(sha))
let error = decide_clone(false, origin, Some("main"), None, Some(sha))
.expect_err("exact checkout without an origin should fail");
assert!(error.to_string().contains("requires a repository origin"));
}
@ -465,6 +562,7 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
branch,
None,
Some(sha),
)
.expect_err("exact checkout without a branch should fail");
@ -472,6 +570,47 @@ mod tests {
}
}
#[test]
fn tag_checkout_requires_nonempty_tag_clone_origin_and_branch() {
let empty_tag = decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("main"),
Some(""),
None,
)
.expect_err("empty tags should fail");
assert!(empty_tag.to_string().contains("non-empty tag"));
let skip_error = decide_clone(
true,
Some("https://github.com/acme/widgets"),
Some("main"),
Some("v1"),
None,
)
.expect_err("tag checkout with skip-clone should fail");
assert!(skip_error.to_string().contains("requires cloning"));
for origin in [None, Some(""), Some(" ")] {
let error = decide_clone(false, origin, Some("main"), Some("v1"), None)
.expect_err("tag checkout without an origin should fail");
assert!(error.to_string().contains("requires a repository origin"));
}
for branch in [None, Some(""), Some(" ")] {
let error = decide_clone(
false,
Some("https://github.com/acme/widgets"),
branch,
Some("v1"),
None,
)
.expect_err("tag checkout without a branch should fail");
assert!(error.to_string().contains("requires a repository branch"));
}
}
#[test]
fn docker_exact_checkout_commands_quote_inputs() {
let sha = "0123456789abcdef0123456789abcdef01234567";
@ -536,7 +675,7 @@ mod tests {
clippy::disallowed_methods,
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
)]
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
fn exact_checkout_fetches_admitted_commit_after_branch_and_tag_advance() {
let temp = tempfile::tempdir().expect("tempdir");
let remote = temp.path().join("remote.git");
let source = temp.path().join("source");
@ -561,10 +700,14 @@ mod tests {
]);
run_git(&source, &["push", "-u", "origin", "main"]);
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
run_git(&source, &["tag", "release"]);
run_git(&source, &["push", "origin", "refs/tags/release"]);
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
run_git(&source, &["commit", "-am", "commit B"]);
run_git(&source, &["push", "origin", "main"]);
run_git(&source, &["tag", "-f", "release"]);
run_git(&source, &["push", "--force", "origin", "refs/tags/release"]);
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
assert_ne!(admitted_sha, advanced_sha);
@ -609,6 +752,79 @@ mod tests {
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
)]
fn tag_checkout_peels_lightweight_and_annotated_tags_without_branch_fallback() {
let temp = tempfile::tempdir().expect("tempdir");
let remote = temp.path().join("remote.git");
let source = temp.path().join("source");
fs::create_dir(&source).expect("source directory");
run_git(temp.path(), &[
"init",
"--bare",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["init"]);
fs::write(source.join("revision.txt"), "release\n").expect("write release commit");
run_git(&source, &["add", "revision.txt"]);
run_git(&source, &["commit", "-m", "release"]);
run_git(&source, &["branch", "-M", "main"]);
let release_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
run_git(&source, &["tag", "lightweight"]);
run_git(&source, &["tag", "-a", "annotated", "-m", "annotated"]);
run_git(&source, &[
"remote",
"add",
"origin",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["push", "origin", "main", "--tags"]);
let remote_path = remote.to_str().expect("UTF-8 remote path");
for tag in ["lightweight", "annotated"] {
let checkout = temp.path().join(format!("checkout-{tag}"));
let checkout_path = checkout.to_str().expect("UTF-8 checkout path");
run_shell(
temp.path(),
&exact_repository_init_command(remote_path, checkout_path),
);
run_shell(
temp.path(),
&tag_fetch_command(checkout_path, "origin", tag, Some(10)),
);
let head = run_shell(
temp.path(),
&tag_checkout_verify_command(checkout_path, "release-work"),
);
assert_eq!(verify_resolved_head(&head).unwrap(), release_sha);
assert_eq!(
run_git(&checkout, &["symbolic-ref", "HEAD"]).trim(),
"refs/heads/release-work"
);
}
let missing = temp.path().join("missing");
let missing_path = missing.to_str().expect("UTF-8 checkout path");
run_shell(
temp.path(),
&exact_repository_init_command(remote_path, missing_path),
);
let output = run_shell_output(
temp.path(),
&tag_fetch_command(missing_path, "origin", "main", Some(10)),
);
assert!(
!output.status.success(),
"a branch must not satisfy a tag fetch"
);
assert!(!missing.join("revision.txt").exists());
}
#[test]
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
let layout = github_repo_layout(

View file

@ -109,6 +109,20 @@ const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
fn git_clone_selector(
branch: Option<&str>,
tag: Option<&str>,
commit_sha: Option<&str>,
) -> Option<String> {
if commit_sha.is_some() {
branch.map(str::to_string)
} else if let Some(tag) = tag {
Some(format!("refs/tags/{tag}"))
} else {
branch.map(str::to_string)
}
}
pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool {
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
}
@ -464,6 +478,7 @@ pub struct DaytonaSandbox {
/// Explicit branch to clone. When set, overrides the branch detected by
/// the submitted run spec.
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
}
@ -478,14 +493,16 @@ impl DaytonaSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
) -> crate::Result<Self> {
if clone_commit_sha.is_some() {
if clone_tag.is_some() || clone_commit_sha.is_some() {
clone_source::decide_clone(
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
)?;
}
@ -512,6 +529,7 @@ impl DaytonaSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
})
}
@ -565,6 +583,7 @@ impl DaytonaSandbox {
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
})
}
@ -683,6 +702,33 @@ impl DaytonaSandbox {
clone_source::verify_exact_head(&head, expected_sha)
}
/// Attach a tag clone's peeled commit to the requested working branch.
async fn attach_tag_branch(
process_svc: &daytona_sdk::ProcessService,
checkout_path: &str,
branch: &str,
deadline: time::Instant,
) -> crate::Result<()> {
Self::run_required_post_clone_command(
process_svc,
&clone_source::exact_branch_checkout_command(checkout_path, branch, "HEAD^{commit}"),
"/",
"git checkout tag commit",
deadline,
)
.await?;
let head = Self::run_required_post_clone_command(
process_svc,
&clone_source::exact_head_revision_command(checkout_path),
"/",
"git rev-parse HEAD after tag checkout",
deadline,
)
.await?;
clone_source::verify_resolved_head(&head)?;
Ok(())
}
/// Execute one post-clone command under the shared setup deadline.
///
/// The SDK timeout asks Daytona to terminate the remote process. The outer
@ -1521,6 +1567,7 @@ impl Sandbox for DaytonaSandbox {
self.config.skip_clone,
self.clone_origin_url.as_deref(),
self.clone_branch.as_deref(),
self.clone_tag.as_deref(),
self.clone_commit_sha.as_deref(),
)
.map_err(|e| self.fail_init(init_start, e))?;
@ -1549,6 +1596,7 @@ impl Sandbox for DaytonaSandbox {
CloneDecision::GitHub {
origin_url,
branch,
tag,
commit_sha,
} => {
let layout =
@ -1647,6 +1695,8 @@ impl Sandbox for DaytonaSandbox {
self.fail_init(init_start, err)
})?;
let clone_selector =
git_clone_selector(branch.as_deref(), tag.as_deref(), commit_sha.as_deref());
let clone_plan = git_retry::RetryPlan::clone_default(None);
let clone_result = git_retry::retry_git_operation(
SandboxProviderKind::Daytona,
@ -1657,7 +1707,7 @@ impl Sandbox for DaytonaSandbox {
let origin = origin_url.as_str();
let target = layout.primary_repo_path.as_str();
let options = GitCloneOptions {
branch: branch.clone(),
branch: clone_selector.clone(),
commit_id: commit_sha.clone(),
username: username.clone(),
password: password.clone(),
@ -1725,6 +1775,27 @@ impl Sandbox for DaytonaSandbox {
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
.await);
}
} else if tag.is_some() {
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty())
else {
let err =
crate::Error::message("Tag checkout requires a repository branch");
return Err(self
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
.await);
};
if let Err(err) = Self::attach_tag_branch(
&process_svc,
&layout.primary_repo_path,
branch,
post_clone_deadline,
)
.await
{
return Err(self
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
.await);
}
}
let symlink_cmd = clone_source::repo_symlink_command(&layout);
@ -3193,6 +3264,23 @@ mod tests {
use super::*;
use crate::sandbox::BASH_PROBE_MARKER;
#[test]
fn daytona_clone_selector_uses_fully_qualified_tag_unless_sha_is_exact() {
assert_eq!(
git_clone_selector(Some("release-work"), Some("v1.2.3"), None).as_deref(),
Some("refs/tags/v1.2.3")
);
assert_eq!(
git_clone_selector(
Some("release-work"),
Some("v1.2.3"),
Some("0123456789abcdef0123456789abcdef01234567"),
)
.as_deref(),
Some("release-work")
);
}
#[tokio::test]
async fn invalid_exact_sha_fails_before_daytona_client_construction() {
let error = DaytonaSandbox::new(
@ -3201,6 +3289,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
Some("main".to_string()),
None,
Some("not-a-sha".to_string()),
Some("dtn_not_used".to_string()),
)
@ -3220,6 +3309,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
None,
None,
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
Some("dtn_not_used".to_string()),
)
@ -3547,6 +3637,7 @@ mod tests {
run_id: None,
clone_origin_url: None,
clone_branch: None,
clone_tag: None,
clone_commit_sha: None,
}
}
@ -3726,6 +3817,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await
@ -3765,6 +3857,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await
@ -4093,6 +4186,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await

View file

@ -156,6 +156,7 @@ pub struct DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
container_id: OnceCell<String>,
repo_cloned: OnceCell<bool>,
@ -187,13 +188,15 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
if clone_commit_sha.is_some() {
if clone_tag.is_some() || clone_commit_sha.is_some() {
clone_source::decide_clone(
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
)?;
}
@ -205,6 +208,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
)
}
@ -216,6 +220,7 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
@ -229,6 +234,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
container_id: OnceCell::new(),
repo_cloned: OnceCell::new(),
@ -256,6 +262,7 @@ impl DockerSandbox {
clone_origin_url.clone(),
clone_branch,
None,
None,
)?;
sandbox.validate_managed_container(container_id).await?;
sandbox
@ -903,6 +910,7 @@ impl DockerSandbox {
&self,
origin_url: String,
branch: Option<String>,
tag: Option<String>,
commit_sha: Option<String>,
) -> crate::Result<()> {
self.verify_git_available().await?;
@ -1030,6 +1038,64 @@ impl DockerSandbox {
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
return Err(self.report_clone_failure(&origin_url, error));
}
} else if let Some(tag) = tag.as_deref() {
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
let error = crate::Error::message("Tag checkout requires a repository branch");
return Err(self.report_clone_failure(&origin_url, error));
};
let init_command =
clone_source::exact_repository_init_command(clone_url, &layout.primary_repo_path);
if let Err(error) = self
.run_exact_local_git_command(
&init_command,
"initialize Docker tag repository checkout",
clone_deadline,
auth_url.as_ref(),
)
.await
{
return Err(self.report_clone_failure(&origin_url, error));
}
let fetch_command = clone_source::tag_fetch_command(
&layout.primary_repo_path,
"origin",
tag,
self.config.clone_depth,
);
if let Err(failure) = self
.retry_git_transfer(
&fetch_command,
"fetch",
"Docker tag fetch",
"git fetch tag",
clone_deadline,
clone_credential_context,
auth_url.as_ref(),
)
.await
{
return Err(self.report_clone_failure(&origin_url, failure.error));
}
let checkout_command =
clone_source::tag_checkout_verify_command(&layout.primary_repo_path, branch);
let head = match self
.run_exact_local_git_command(
&checkout_command,
"git checkout tag",
clone_deadline,
auth_url.as_ref(),
)
.await
{
Ok(result) => result,
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
};
if let Err(error) = clone_source::verify_resolved_head(&head.stdout) {
return Err(self.report_clone_failure(&origin_url, error));
}
} else {
let command = git_clone_command(
clone_url,
@ -1826,6 +1892,7 @@ impl Sandbox for DockerSandbox {
self.config.skip_clone,
self.clone_origin_url.as_deref(),
self.clone_branch.as_deref(),
self.clone_tag.as_deref(),
self.clone_commit_sha.as_deref(),
)
.map_err(|e| self.fail_init(init_start, e))?;
@ -1847,9 +1914,13 @@ impl Sandbox for DockerSandbox {
CloneDecision::GitHub {
origin_url,
branch,
tag,
commit_sha,
} => {
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
if let Err(e) = self
.clone_github_repo(origin_url, branch, tag, commit_sha)
.await
{
return Err(self.fail_init(init_start, e));
}
}
@ -2668,6 +2739,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
Some("main".to_string()),
None,
Some("not-a-sha".to_string()),
)
.err()
@ -2685,6 +2757,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
None,
None,
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
)
.err()
@ -3072,6 +3145,7 @@ mod tests {
None,
None,
None,
None,
)
.expect("test sandbox should build");
sandbox

View file

@ -130,6 +130,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
clone_origin_url,
clone_branch,
None,
None,
Some(api_key),
)
.await?;

View file

@ -105,6 +105,7 @@ impl SandboxProvider for DockerSandboxProvider {
clone_origin_url,
clone_branch,
None,
None,
)?;
sandbox.initialize().await?;
let container_id = sandbox.container_identifier()?.to_string();

View file

@ -32,6 +32,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
},
#[cfg(feature = "daytona")]
@ -41,6 +42,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
},
@ -204,6 +206,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = DockerSandbox::new(
@ -212,6 +215,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
)
.context("Failed to create Docker sandbox")?;
@ -227,6 +231,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
api_key,
} => {
@ -236,6 +241,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
api_key.clone(),
)
@ -282,6 +288,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();
@ -320,6 +327,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: Some("not-a-sha".to_string()),
};
@ -349,6 +357,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
clone_branch: None,
clone_tag: None,
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();

View file

@ -38,6 +38,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?,
);
@ -76,6 +77,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
@ -182,6 +184,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
@ -232,6 +235,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
@ -300,6 +304,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;

View file

@ -41,6 +41,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -114,6 +115,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -176,6 +178,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -243,6 +246,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -337,6 +341,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -422,6 +427,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox

View file

@ -478,7 +478,7 @@ pub async fn persist_create_run(
let (source_directory, git) = match target.as_ref() {
Some(RunTarget::None {}) => (None, None),
Some(RunTarget::Folder { path }) => (Some(path.clone()), git),
Some(RunTarget::Git { .. }) | None => (Some(source_directory), git),
Some(RunTarget::Git(_)) | None => (Some(source_directory), git),
};
let persisted_run_dir = run_dir.clone();
let persisted = spawn_blocking(move || {

View file

@ -407,11 +407,12 @@ mod tests {
source_directory: Some("/client/source".to_string()),
workflow_slug: Some("fork-source".to_string()),
workflow_version_id: Some(workflow_version_id),
target: Some(fabro_types::RunTarget::Git {
target: Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
repo: "example/repo".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}),
})),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -500,11 +501,12 @@ mod tests {
);
assert_eq!(
forked_state.spec.target,
Some(fabro_types::RunTarget::Git {
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
repo: "example/repo".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
})
}))
);
assert_eq!(
forked_state.spec.fork_source_ref.unwrap().source_run_id,

View file

@ -170,11 +170,12 @@ mod tests {
}
fn run_target() -> RunTarget {
RunTarget::Git {
RunTarget::Git(fabro_types::GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
}
})
}
async fn append_created(

View file

@ -16,6 +16,8 @@ use fabro_sandbox::from_environment::{
};
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
#[cfg(test)]
use fabro_types::GitRunTarget;
use fabro_types::settings::run::{
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
@ -470,7 +472,7 @@ impl RunSession {
}
let sandbox = match sandbox_provider {
SandboxProviderKind::Local => match record.target.as_ref() {
Some(target @ (RunTarget::Git { .. } | RunTarget::None {})) => {
Some(target @ (RunTarget::Git(_) | RunTarget::None {})) => {
return Err(Error::engine(format!(
"persisted {} run targets require a clone-based sandbox provider",
target.kind_name()
@ -502,6 +504,7 @@ impl RunSession {
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
}
}
@ -517,6 +520,7 @@ impl RunSession {
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
api_key,
}
@ -599,6 +603,7 @@ impl RunSession {
struct CloneSourceForRun {
origin_url: Option<String>,
branch: Option<String>,
tag: Option<String>,
commit_sha: Option<String>,
/// The target asked for an empty workspace, so the provider must not
/// clone even when it would otherwise inherit an origin.
@ -652,6 +657,7 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
return Ok(CloneSourceForRun {
origin_url: record.repo_origin_url().map(str::to_string),
branch: record.base_branch().map(str::to_string),
tag: None,
commit_sha: None,
skip_clone: false,
});
@ -668,22 +674,29 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
"persisted Git run target has an invalid repository slug"
}
TargetValidationError::Branch => "persisted Git run target has an invalid branch",
TargetValidationError::Tag => "persisted Git run target has an invalid tag",
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
})
})?;
let tag = match &validated.target {
RunTarget::Git(target) => target.tag.clone(),
RunTarget::None {} | RunTarget::Folder { .. } => None,
};
// A target with no Git projection (`none` or `folder`) supplies no clone
// source. Folder targets only reach the Local provider, where `skip_clone`
// is unused.
Ok(match validated.git {
Some(git) => CloneSourceForRun {
origin_url: Some(git.origin_url),
branch: Some(git.branch),
branch: Some(git.branch),
tag,
commit_sha: git.sha,
skip_clone: false,
},
None => CloneSourceForRun {
origin_url: None,
branch: None,
tag: None,
commit_sha: None,
skip_clone: true,
},
@ -3175,11 +3188,12 @@ reasoning = false
let mut spec = test_support::test_run_spec();
let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
let normalized_sha = "abcdef0123456789abcdef0123456789abcdef01";
spec.target = Some(RunTarget::Git {
spec.target = Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some(submitted_sha.to_string()),
});
}));
spec.git = Some(fabro_types::GitContext {
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
@ -3194,17 +3208,19 @@ reasoning = false
Some("https://github.com/fabro-sh/fabro")
);
assert_eq!(source.branch.as_deref(), Some("feature/run-intent"));
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
assert_eq!(source.commit_sha.as_deref(), Some(normalized_sha));
}
#[test]
fn clone_commit_persisted_git_target_without_sha_keeps_branch_unpinned() {
let mut spec = test_support::test_run_spec();
spec.target = Some(RunTarget::Git {
spec.target = Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: None,
sha: None,
});
}));
spec.git = Some(fabro_types::GitContext {
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
@ -3218,14 +3234,32 @@ reasoning = false
assert_eq!(source.commit_sha, None);
}
#[test]
fn clone_source_preserves_unpinned_tag_separately_from_working_branch() {
let mut spec = test_support::test_run_spec();
spec.target = Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "release-work".to_string(),
tag: Some("v1.2.3".to_string()),
sha: None,
}));
let source = clone_source_for_run(&spec).unwrap();
assert_eq!(source.branch.as_deref(), Some("release-work"));
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
assert_eq!(source.commit_sha, None);
}
#[test]
fn clone_commit_persisted_git_target_is_authoritative_over_projection() {
let mut spec = test_support::test_run_spec();
spec.target = Some(RunTarget::Git {
spec.target = Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
});
}));
// A drifted (or absent) projection never feeds the clone source: the
// validated target alone does.
spec.git = Some(fabro_types::GitContext {

View file

@ -208,6 +208,7 @@ async fn create_env_with_github_app(
None,
None,
None,
None,
)
.await
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
@ -413,7 +414,7 @@ async fn daytona_snapshot_sandbox() {
};
let creds = load_github_app_credentials();
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None)
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None, None)
.await
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
env.initialize().await.unwrap();
@ -1618,7 +1619,7 @@ async fn daytona_computer_use_browser_screenshot() {
skip_clone: true,
..DaytonaConfig::default()
};
let env = DaytonaSandbox::new(config, None, None, None, None, None, None)
let env = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
.await
.expect("DAYTONA_API_KEY must be set");
env.initialize().await.unwrap();
@ -1766,7 +1767,7 @@ async fn daytona_playwright_mcp_sandbox_transport() {
skip_clone: true,
..DaytonaConfig::default()
};
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None)
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
.await
.expect("DAYTONA_API_KEY must be set");
sandbox.initialize().await.unwrap();

View file

@ -13737,7 +13737,7 @@ async fn asset_collection_docker_sandbox() {
..Default::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
fabro_agent::DockerSandbox::new(config, None, None, None, None, None)
fabro_agent::DockerSandbox::new(config, None, None, None, None, None, None)
.expect("Docker not available"),
);
sandbox.initialize().await.expect("Docker init failed");

View file

@ -739,6 +739,7 @@ fn main() {
("RunIntent", "fabro_types::RunIntent", &[]),
("RunIntentArgs", "fabro_types::RunIntentArgs", &[]),
("RunTarget", "fabro_types::RunTarget", &[]),
("GitRunTarget", "fabro_types::GitRunTarget", &[]),
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
("BlobHash", "fabro_types::BlobHash", &[]),

View file

@ -48,7 +48,7 @@ pub mod types {
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
FailureSignature, GitContext, GitRunTarget, IdpIdentity, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
McpServerDraft as CreateMcpServerRequest, McpServerProjection,

View file

@ -2,7 +2,7 @@ use std::any::{TypeId, type_name};
use std::collections::HashMap;
use fabro_api::types::{RunIntent as ApiRunIntent, RunIntentArgs as ApiRunIntentArgs};
use fabro_types::{RunIntent, RunIntentArgs, RunTarget, test_support};
use fabro_types::{GitRunTarget, RunIntent, RunIntentArgs, RunTarget, test_support};
use serde_json::json;
#[test]
@ -10,17 +10,19 @@ fn run_intent_schemas_reuse_canonical_types() {
assert_same_type::<ApiRunIntent, RunIntent>();
assert_same_type::<ApiRunIntentArgs, RunIntentArgs>();
assert_same_type::<fabro_api::types::RunTarget, RunTarget>();
assert_same_type::<fabro_api::types::GitRunTarget, GitRunTarget>();
}
#[test]
fn run_intent_round_trips_the_openapi_shape() {
let intent = RunIntent {
workflow_version_id: test_support::test_workflow_version_id(),
target: RunTarget::Git {
target: RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
},
}),
args: RunIntentArgs {
model: Some("gpt-5.6-sol".to_string()),
provider: Some("openai".to_string()),

View file

@ -131,7 +131,7 @@ pub use run_event::{
pub use run_failure::RunFailure;
pub use run_id::{RunId, fixtures};
pub use run_intent::{
RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
};
pub use run_projection::{
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,

View file

@ -45,16 +45,25 @@ pub struct RunIntentArgs {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[strum(serialize_all = "snake_case")]
pub enum RunTarget {
Git {
repo: String,
branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
Git(GitRunTarget),
None {},
Folder {
path: String,
},
Folder { path: String },
}
/// A Git-backed run target.
///
/// `branch` is always the attached working branch. When present, `tag` names
/// the requested release identity. An exact `sha` is authoritative over both
/// selectors while preserving the tag in durable state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitRunTarget {
pub repo: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
}
impl RunTarget {
@ -71,7 +80,12 @@ impl RunTarget {
/// filesystem validation and canonicalization during provider admission.
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
match self {
Self::Git { repo, branch, sha } => {
Self::Git(GitRunTarget {
repo,
branch,
tag,
sha,
}) => {
let slug = GitHubRepositorySlug::try_new(&repo)
.ok_or(TargetValidationError::Repository)?;
// The selector grammar is checked on the bare branch name so
@ -86,6 +100,15 @@ impl RunTarget {
{
return Err(TargetValidationError::Branch);
}
if tag.as_deref().is_some_and(|tag| {
tag == "HEAD"
|| tag.starts_with("tags/")
|| tag.starts_with("refs/")
|| repository::normalize_git_commit_sha(tag).is_some()
|| !repository::is_valid_github_ref_selector(tag)
}) {
return Err(TargetValidationError::Tag);
}
let sha = sha
.map(|sha| {
repository::normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)
@ -98,7 +121,12 @@ impl RunTarget {
dirty: DirtyStatus::Clean,
};
Ok(ValidatedRunTarget {
target: Self::Git { repo, branch, sha },
target: Self::Git(GitRunTarget {
repo,
branch,
tag,
sha,
}),
git: Some(git),
})
}
@ -129,6 +157,8 @@ pub enum TargetValidationError {
Repository,
#[error("target branch must be a non-empty branch name, not a ref or commit selector")]
Branch,
#[error("target tag must be a non-empty bare tag name, not a ref or commit selector")]
Tag,
#[error("target SHA must be exactly 40 ASCII hexadecimal characters")]
Sha,
}

View file

@ -7,7 +7,9 @@ use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
use fabro_types::{AutomationRef, EventBody, RunTarget, TurnId, WorkflowSettings, fixtures};
use fabro_types::{
AutomationRef, EventBody, GitRunTarget, RunTarget, TurnId, WorkflowSettings, fixtures,
};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
@ -26,11 +28,12 @@ fn run_created_props_round_trip_templated_settings() {
source_directory: Some("/Users/client/project".to_string()),
workflow_slug: Some("demo".to_string()),
workflow_version_id: Some(test_workflow_version_id()),
target: Some(RunTarget::Git {
target: Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}),
})),
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use fabro_types::{
RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
};
use serde_json::json;
@ -14,11 +14,12 @@ fn version_id() -> WorkflowVersionId {
fn intent() -> RunIntent {
RunIntent {
workflow_version_id: version_id(),
target: RunTarget::Git {
target: RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
},
}),
args: RunIntentArgs {
model: Some("gpt-5.6".to_string()),
provider: Some("openai".to_string()),
@ -43,6 +44,7 @@ fn run_intent_round_trips_the_strict_git_shape() {
assert_eq!(value["target"]["kind"], "git");
assert_eq!(value["target"]["repo"], "fabro-sh/fabro");
assert_eq!(value["target"]["branch"], "feature/run-intent");
assert_eq!(value["target"]["tag"], "v1.2.3");
assert_eq!(
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
intent
@ -158,11 +160,12 @@ fn git_commit_sha_normalization_is_exact_and_pure() {
#[test]
fn target_validation_normalizes_sha_without_network_resolution() {
let validated = RunTarget::Git {
let validated = RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("release/v1".to_string()),
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
}
})
.validate()
.unwrap();
let git = validated
@ -174,11 +177,15 @@ fn target_validation_normalizes_sha_without_network_resolution() {
Some("abcdef0123456789abcdef0123456789abcdef01")
);
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro");
assert_eq!(validated.target, RunTarget::Git {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
});
assert_eq!(
validated.target,
RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("release/v1".to_string()),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
})
);
}
#[test]
@ -204,17 +211,18 @@ fn run_intent_folder_target_is_preserved_for_provider_admission() {
fn target_validation_rejects_invalid_grammar() {
use fabro_types::TargetValidationError;
let validate = |repo: &str, branch: &str, sha: Option<&str>| {
RunTarget::Git {
let validate = |repo: &str, branch: &str, tag: Option<&str>, sha: Option<&str>| {
RunTarget::Git(GitRunTarget {
repo: repo.to_string(),
branch: branch.to_string(),
tag: tag.map(str::to_string),
sha: sha.map(str::to_string),
}
})
.validate()
};
assert_eq!(
validate("not-a-slug", "main", None).unwrap_err(),
validate("not-a-slug", "main", None, None).unwrap_err(),
TargetValidationError::Repository
);
for branch in [
@ -229,13 +237,66 @@ fn target_validation_rejects_invalid_grammar() {
"bad..branch",
] {
assert_eq!(
validate("fabro-sh/fabro", branch, None).unwrap_err(),
validate("fabro-sh/fabro", branch, None, None).unwrap_err(),
TargetValidationError::Branch,
"{branch:?}"
);
}
for tag in [
"",
"HEAD",
"-v1",
".v1",
"tags/v1",
"refs/tags/v1",
"abcdef0123456789abcdef0123456789abcdef01",
"bad..tag",
] {
assert_eq!(
validate("fabro-sh/fabro", "main", Some(tag), None).unwrap_err(),
TargetValidationError::Tag,
"{tag:?}"
);
}
assert_eq!(
validate("fabro-sh/fabro", "main", Some("short")).unwrap_err(),
validate("fabro-sh/fabro", "main", None, Some("short")).unwrap_err(),
TargetValidationError::Sha
);
}
#[test]
fn git_target_round_trips_all_branch_tag_sha_states() {
for target in [
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
},
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
},
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "release".to_string(),
tag: Some("v1.2.3".to_string()),
sha: None,
},
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "release".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
},
] {
let run_target = RunTarget::Git(target);
let value = serde_json::to_value(&run_target).expect("target should serialize");
assert_eq!(
serde_json::from_value::<RunTarget>(value).expect("target should deserialize"),
run_target
);
}
}

View file

@ -5,7 +5,7 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
use fabro_types::{AutomationRef, RunTarget, WorkflowSettings, fixtures};
use fabro_types::{AutomationRef, GitRunTarget, RunTarget, WorkflowSettings, fixtures};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
@ -22,11 +22,12 @@ fn run_spec_round_trips_templated_settings() {
graph_source: None,
workflow_slug: Some("demo".to_string()),
workflow_version_id: Some(test_workflow_version_id()),
target: Some(RunTarget::Git {
target: Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("abc123".to_string()),
}),
})),
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),

View file

@ -15,7 +15,7 @@
/**
* Public github.com repository target.
* Public github.com repository target. The branch names the attached working branch. An optional tag selects a release at worker start, and an optional exact SHA is authoritative when both are present.
*/
export interface GitRunTarget {
'kind': GitRunTargetKindEnum;
@ -24,11 +24,15 @@ export interface GitRunTarget {
*/
'repo': string;
/**
* Required branch name, preserved exactly.
* Required attached working branch name, preserved exactly.
*/
'branch': string;
/**
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it or prove branch ancestry.
* Optional bare tag name. Prefixes such as `refs/tags/` and `tags/` are rejected. Without `sha`, the worker resolves this tag when the sandbox starts and fails if it is unavailable.
*/
'tag'?: string;
/**
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it, prove branch ancestry, or prove that it matches an accompanying tag. When present, this exact commit wins.
*/
'sha'?: string;
}