Auto-PR on Successful Workflow Run (#3)

* arc(01KK6WHJ904CXGN0MCQFGWKMKS): implement (success)

Arc-Run: 01KK6WHJ904CXGN0MCQFGWKMKS
Arc-Completed: 2
Arc-Checkpoint: 4c3a9115be9074c7e23792c0ef9b1f0525ffc10d

* arc(01KK6WHJ904CXGN0MCQFGWKMKS): simplify (success)

Arc-Run: 01KK6WHJ904CXGN0MCQFGWKMKS
Arc-Completed: 3
Arc-Checkpoint: 7d8e8413e70eb198e14fcb676903dcc91bd21b76

* Fix merge conflicts: add pull_request to OpenAPI spec, fix clippy warning, cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: arc <arc@local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-08 16:32:55 -04:00 committed by GitHub
parent 7f6f356bae
commit 80fbeb5fcd
13 changed files with 769 additions and 10 deletions

View file

@ -1292,6 +1292,7 @@ mod runs {
])),
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
})
.unwrap()
}
@ -1441,6 +1442,7 @@ mod workflows {
])),
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
}),
graph: r#"digraph fix_build {
graph [
@ -1507,6 +1509,7 @@ mod workflows {
])),
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
}),
graph: r#"digraph implement {
graph [
@ -1585,6 +1588,7 @@ mod workflows {
])),
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
}),
graph: r#"digraph sync {
graph [
@ -1652,6 +1656,7 @@ mod workflows {
])),
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
}),
graph: r#"digraph expand {
graph [
@ -3091,6 +3096,7 @@ mod settings {
}),
vars: None,
checkpoint: Default::default(),
pull_request: None,
},
hook_config: arc_workflows::hook::HookConfig { hooks: vec![] },
})

View file

@ -604,6 +604,8 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: state.git_author.clone(),
base_branch: None,
pull_request_enabled: false,
};
let result = tokio::select! {

View file

@ -305,6 +305,7 @@ fn fully_populated_server_config() -> ServerConfig {
checkpoint: CheckpointConfig {
exclude_globs: vec![],
},
pull_request: Some(PullRequestConfig { enabled: true }),
},
hook_config: HookConfig {
// One hook per HookType variant so the key union covers all fields.

View file

@ -298,6 +298,10 @@ pub async fn run_command(
let preserve_sandbox =
resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults);
let original_cwd = std::env::current_dir()?;
let (origin_url, detected_base_branch) =
crate::daytona_sandbox::detect_repo_info(&original_cwd)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_clean = match sandbox_provider {
SandboxProvider::Local | SandboxProvider::Docker => {
crate::git::ensure_clean(&original_cwd).is_ok()
@ -541,20 +545,20 @@ pub async fn run_command(
});
// Set up git inside Daytona sandbox (if applicable)
let (daytona_run_id, daytona_base_sha, daytona_branch) =
let (daytona_run_id, daytona_base_sha, daytona_branch, daytona_base_branch) =
if sandbox_provider == SandboxProvider::Daytona {
match setup_daytona_git(&*sandbox).await {
Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)),
Ok((rid, base, branch, base_br)) => (Some(rid), Some(base), Some(branch), base_br),
Err(e) => {
eprintln!(
"{} Daytona git setup failed ({e}), running without git checkpoints.",
styles.yellow.apply_to("Warning:"),
);
(None, None, None)
(None, None, None, None)
}
}
} else {
(None, None, None)
(None, None, None, None)
};
// Create SSH access if requested
@ -751,6 +755,11 @@ pub async fn run_command(
checkpoint_exclude_globs,
github_app: github_app.clone(),
git_author,
base_branch: detected_base_branch.or(daytona_base_branch),
pull_request_enabled: run_cfg
.as_ref()
.and_then(|c| c.pull_request.as_ref())
.is_some_and(|p| p.enabled),
};
let run_start = Instant::now();
@ -815,6 +824,55 @@ pub async fn run_command(
.await;
}
// Auto-create PR on successful completion
if config.pull_request_enabled {
if let Ok(ref outcome) = engine_result {
if matches!(
outcome.status,
StageStatus::Success | StageStatus::PartialSuccess
) {
let diff = tokio::fs::read_to_string(logs_dir.join("final.patch"))
.await
.unwrap_or_default();
if let (
Some(ref base_branch),
Some(ref run_branch),
Some(ref creds),
Some(ref origin),
) = (
&config.base_branch,
&config.run_branch,
&github_app,
&origin_url,
) {
match crate::pull_request::maybe_open_pull_request(
creds,
origin,
base_branch,
run_branch,
graph.goal(),
&diff,
&model,
)
.await
{
Ok(Some(url)) => {
eprintln!("{} {url}", styles.bold.apply_to("Pull request:"));
}
Ok(None) => {} // empty diff, logged at DEBUG
Err(e) => {
tracing::warn!(error = %e, "Pull request creation failed");
eprintln!(
"{} PR creation failed: {e}",
styles.yellow.apply_to("Warning:")
);
}
}
}
}
}
}
let outcome = engine_result?;
// 8. Print result
@ -937,10 +995,26 @@ fn setup_worktree(
}
/// Set up git inside a Daytona sandbox for checkpoint commits.
/// Returns (run_id, base_sha, branch_name) on success.
/// Returns (run_id, base_sha, branch_name, base_branch) on success.
async fn setup_daytona_git(
sandbox: &dyn arc_agent::Sandbox,
) -> anyhow::Result<(String, String, String)> {
) -> anyhow::Result<(String, String, String, Option<String>)> {
// Get current branch name before creating the run branch
let branch_result = sandbox
.exec_command("git rev-parse --abbrev-ref HEAD", 10_000, None, None, None)
.await
.map_err(|e| anyhow::anyhow!("git rev-parse --abbrev-ref HEAD failed: {e}"))?;
let base_branch = if branch_result.exit_code == 0 {
let name = branch_result.stdout.trim().to_string();
if name.is_empty() || name == "HEAD" {
None
} else {
Some(name)
}
} else {
None
};
// Get current HEAD as base SHA
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
@ -972,7 +1046,7 @@ async fn setup_daytona_git(
);
}
Ok((run_id, base_sha, branch_name))
Ok((run_id, base_sha, branch_name, base_branch))
}
/// Resume a workflow run from a git run branch.
@ -1122,6 +1196,8 @@ async fn run_from_branch(
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author,
base_branch: None,
pull_request_enabled: false,
};
let run_start = Instant::now();
@ -1656,6 +1732,7 @@ mod tests {
vars: None,
hooks: Vec::new(),
checkpoint: Default::default(),
pull_request: None,
};
let (model, provider) = resolve_model_provider(
Some("gpt-5.2"),
@ -1697,6 +1774,7 @@ mod tests {
vars: None,
hooks: Vec::new(),
checkpoint: Default::default(),
pull_request: None,
};
let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph);
assert_eq!(model, "toml-model");
@ -1773,6 +1851,7 @@ mod tests {
vars: None,
hooks: Vec::new(),
checkpoint: Default::default(),
pull_request: None,
};
let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph);
assert_eq!(model, "toml-model");
@ -1798,6 +1877,7 @@ mod tests {
vars: None,
hooks: Vec::new(),
checkpoint: Default::default(),
pull_request: None,
};
let defaults = RunDefaults::default();
assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults));
@ -1822,6 +1902,7 @@ mod tests {
vars: None,
hooks: Vec::new(),
checkpoint: Default::default(),
pull_request: None,
};
let defaults = RunDefaults {
sandbox: Some(run_config::SandboxConfig {

View file

@ -16,6 +16,12 @@ pub struct CheckpointConfig {
pub exclude_globs: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct PullRequestConfig {
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowRunConfig {
@ -31,6 +37,7 @@ pub struct WorkflowRunConfig {
pub hooks: Vec<crate::hook::HookDefinition>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@ -68,6 +75,7 @@ pub struct RunDefaults {
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
}
impl WorkflowRunConfig {
@ -164,6 +172,10 @@ impl WorkflowRunConfig {
merged.dedup();
self.checkpoint.exclude_globs = merged;
}
if self.pull_request.is_none() {
self.pull_request = defaults.pull_request.clone();
}
}
}
@ -1703,4 +1715,79 @@ MISSING = "${env.ARC_TEST_DEFINITELY_NOT_SET_67890}"
"unexpected error: {err}"
);
}
#[test]
fn parse_toml_with_pull_request() {
let toml = r#"
version = 1
goal = "test"
graph = "w.dot"
[pull_request]
enabled = true
"#;
let config = parse_run_config(toml).unwrap();
let pr = config.pull_request.unwrap();
assert!(pr.enabled);
}
#[test]
fn parse_toml_without_pull_request_defaults_none() {
let toml = r#"
version = 1
goal = "test"
graph = "w.dot"
"#;
let config = parse_run_config(toml).unwrap();
assert!(config.pull_request.is_none());
}
#[test]
fn apply_defaults_pull_request_task_wins() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
[pull_request]
enabled = true
"#,
)
.unwrap();
let defaults = RunDefaults {
pull_request: Some(PullRequestConfig { enabled: false }),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
assert!(cfg.pull_request.unwrap().enabled);
}
#[test]
fn apply_defaults_pull_request_inherited() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
"#,
)
.unwrap();
let defaults = RunDefaults {
pull_request: Some(PullRequestConfig { enabled: true }),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
assert!(cfg.pull_request.unwrap().enabled);
}
#[test]
fn parse_run_defaults_with_pull_request() {
let toml = r#"
[pull_request]
enabled = true
"#;
let defaults: RunDefaults = toml::from_str(toml).unwrap();
assert!(defaults.pull_request.unwrap().enabled);
}
}

View file

@ -842,6 +842,10 @@ pub struct RunConfig {
pub github_app: Option<crate::github_app::GitHubAppCredentials>,
/// Git author identity for checkpoint commits.
pub git_author: crate::git::GitAuthor,
/// Name of the branch the run was started from (for PR base).
pub base_branch: Option<String>,
/// Whether to auto-create a PR on successful completion.
pub pull_request_enabled: bool,
}
/// The workflow run execution engine.
@ -2841,6 +2845,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -2865,6 +2871,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
let checkpoint_path = dir.path().join("checkpoint.json");
@ -2897,6 +2905,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -2925,6 +2935,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -2949,6 +2961,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -2986,6 +3000,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -3047,6 +3063,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3135,6 +3153,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3166,6 +3186,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3192,6 +3214,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3218,6 +3242,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3248,6 +3274,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3406,6 +3434,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3447,6 +3477,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();
@ -3506,6 +3538,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
@ -3568,6 +3602,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
@ -3634,6 +3670,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_ok());
@ -3689,6 +3727,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -3745,6 +3785,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
@ -3776,6 +3818,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -3803,6 +3847,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -3829,6 +3875,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -3868,6 +3916,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// Set cancel after a short delay (while the slow handler is running)
@ -3944,6 +3994,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -3973,6 +4025,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4004,6 +4058,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4040,6 +4096,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4074,6 +4132,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4105,6 +4165,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4197,6 +4259,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// The engine returns Err because the Fail outcome has no outgoing fail edge,
@ -4404,6 +4468,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4438,6 +4504,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4479,6 +4547,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4560,6 +4630,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4652,6 +4724,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&g, &config).await;
assert!(result.is_err());
@ -4721,6 +4795,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -4777,6 +4853,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&g, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
@ -4834,6 +4912,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let _outcome = engine.run(&g, &config).await.unwrap();
@ -4918,6 +4998,8 @@ mod tests {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: crate::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&g, &config).await.unwrap();

View file

@ -68,13 +68,14 @@ pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result<String, Strin
/// Request a scoped Installation Access Token for a specific repository.
///
/// Uses the App JWT to find the installation for `owner/repo`, then requests
/// a token scoped to `contents: read` on that single repository.
pub async fn create_installation_access_token(
/// a token scoped to the given `permissions` on that single repository.
async fn create_installation_access_token_with_permissions(
client: &reqwest::Client,
jwt: &str,
owner: &str,
repo: &str,
base_url: &str,
permissions: serde_json::Value,
) -> Result<String, String> {
#[derive(Deserialize)]
struct Installation {
@ -135,7 +136,7 @@ pub async fn create_installation_access_token(
);
let body = serde_json::json!({
"repositories": [repo],
"permissions": { "contents": "write" }
"permissions": permissions,
});
let token_resp = client
@ -177,6 +178,122 @@ pub async fn create_installation_access_token(
Ok(access_token.token)
}
/// Request a scoped Installation Access Token with `contents: write`.
pub async fn create_installation_access_token(
client: &reqwest::Client,
jwt: &str,
owner: &str,
repo: &str,
base_url: &str,
) -> Result<String, String> {
create_installation_access_token_with_permissions(
client,
jwt,
owner,
repo,
base_url,
serde_json::json!({ "contents": "write" }),
)
.await
}
/// Request a scoped Installation Access Token with `contents: write`
/// and `pull_requests: write`. Used for creating pull requests.
pub async fn create_installation_access_token_for_pr(
client: &reqwest::Client,
jwt: &str,
owner: &str,
repo: &str,
base_url: &str,
) -> Result<String, String> {
create_installation_access_token_with_permissions(
client,
jwt,
owner,
repo,
base_url,
serde_json::json!({ "contents": "write", "pull_requests": "write" }),
)
.await
}
/// Create a pull request on GitHub.
///
/// Signs a JWT, obtains a PR-scoped installation token, and POSTs to the
/// GitHub pulls API. Returns `(html_url, pr_number)` on success.
pub async fn create_pull_request(
creds: &GitHubAppCredentials,
owner: &str,
repo: &str,
base: &str,
head: &str,
title: &str,
body: &str,
) -> Result<(String, u64), String> {
let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?;
let client = reqwest::Client::new();
let token =
create_installation_access_token_for_pr(&client, &jwt, owner, repo, GITHUB_API_BASE_URL)
.await?;
tracing::debug!(title = %title, head = %head, base = %base, "Creating pull request");
let pr_body = serde_json::json!({
"title": title,
"head": head,
"base": base,
"body": body,
});
let url = format!("{GITHUB_API_BASE_URL}/repos/{owner}/{repo}/pulls");
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "arc")
.json(&pr_body)
.send()
.await
.map_err(|e| format!("Failed to create pull request: {e}"))?;
let status = resp.status();
match status.as_u16() {
201 => {}
422 => {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!(
"Pull request could not be created (422): {body_text}"
));
}
401 | 403 => {
return Err(format!(
"Authentication failed creating pull request ({})",
status
));
}
_ => {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!(
"Unexpected status {status} creating pull request: {body_text}"
));
}
}
#[derive(Deserialize)]
struct PullRequestResponse {
html_url: String,
number: u64,
}
let pr: PullRequestResponse = resp
.json()
.await
.map_err(|e| format!("Failed to parse pull request response: {e}"))?;
Ok((pr.html_url, pr.number))
}
/// Convert a Git SSH URL to HTTPS format for token-based authentication.
///
/// SSH URLs like `git@github.com:owner/repo.git` become
@ -474,4 +591,46 @@ mod tests {
.unwrap_err();
assert!(err.contains("authentication failed"), "got: {err}");
}
// -----------------------------------------------------------------------
// create_installation_access_token_for_pr
// -----------------------------------------------------------------------
#[tokio::test]
async fn create_iat_for_pr_requests_pr_permissions() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/repos/owner/repo/installation")
.match_header("Authorization", "Bearer test-jwt")
.with_status(200)
.with_body(r#"{"id": 456}"#)
.create_async()
.await;
let token_mock = server
.mock("POST", "/app/installations/456/access_tokens")
.match_header("Authorization", "Bearer test-jwt")
.match_body(mockito::Matcher::JsonString(
r#"{"repositories":["repo"],"permissions":{"contents":"write","pull_requests":"write"}}"#.to_string(),
))
.with_status(201)
.with_body(r#"{"token": "ghs_pr_token"}"#)
.create_async()
.await;
let client = reqwest::Client::new();
let token = create_installation_access_token_for_pr(
&client,
"test-jwt",
"owner",
"repo",
&server.url(),
)
.await
.unwrap();
assert_eq!(token, "ghs_pr_token");
token_mock.assert_async().await;
}
}

View file

@ -151,6 +151,8 @@ impl Handler for SubWorkflowHandler {
.as_ref()
.map(|gs| gs.git_author.clone())
.unwrap_or_default(),
base_branch: None,
pull_request_enabled: false,
};
// Clone parent context for child; inject parent preamble

View file

@ -46,6 +46,7 @@ pub mod manifest;
pub mod outcome;
pub mod parser;
pub mod preamble;
pub mod pull_request;
pub mod retro;
pub mod retro_agent;
pub mod stylesheet;

View file

@ -0,0 +1,92 @@
use tracing::{debug, info};
use crate::github_app::{self, ssh_url_to_https, GitHubAppCredentials};
/// Generate a PR body from the diff and goal using an LLM.
pub async fn generate_pr_body(diff: &str, goal: &str, model: &str) -> Result<String, String> {
let system = "Write a concise PR description summarizing the changes.".to_string();
// Truncate diff to fit context windows (~50k chars)
let max_diff_len = 50_000;
let truncated_diff = if diff.len() > max_diff_len {
&diff[..max_diff_len]
} else {
diff
};
let prompt = format!("Goal: {goal}\n\nDiff:\n```\n{truncated_diff}\n```");
let params = arc_llm::generate::GenerateParams::new(model)
.system(system)
.prompt(prompt);
let result = arc_llm::generate::generate(params)
.await
.map_err(|e| format!("LLM generation failed: {e}"))?;
Ok(result.response.text())
}
/// Optionally open a pull request after a successful workflow run.
///
/// Returns `Ok(Some(html_url))` if a PR was created, `Ok(None)` if the diff
/// was empty, or `Err` on failure.
pub async fn maybe_open_pull_request(
creds: &GitHubAppCredentials,
origin_url: &str,
base_branch: &str,
head_branch: &str,
goal: &str,
diff: &str,
model: &str,
) -> Result<Option<String>, String> {
if diff.is_empty() {
debug!("Empty diff, skipping pull request creation");
return Ok(None);
}
let https_url = ssh_url_to_https(origin_url);
let (owner, repo) = github_app::parse_github_owner_repo(&https_url)?;
let body = generate_pr_body(diff, goal, model).await?;
let (url, pr_number) = github_app::create_pull_request(
creds,
&owner,
&repo,
base_branch,
head_branch,
goal,
&body,
)
.await?;
info!(pr_url = %url, pr_number, "Pull request created");
Ok(Some(url))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn empty_diff_returns_none() {
let creds = GitHubAppCredentials {
app_id: "123".to_string(),
private_key_pem: "unused".to_string(),
};
let result = maybe_open_pull_request(
&creds,
"https://github.com/owner/repo.git",
"main",
"arc/run/123",
"Fix bug",
"",
"claude-sonnet-4-20250514",
)
.await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}

View file

@ -396,6 +396,8 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -590,6 +592,8 @@ async fn daytona_git_checkpoint_remote_emits_events() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -773,6 +777,8 @@ async fn daytona_parallel_git_branching_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1101,6 +1107,8 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1241,6 +1249,8 @@ async fn daytona_asset_collection() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1473,6 +1483,8 @@ async fn daytona_git_push_run_branch_to_origin() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine

View file

@ -202,6 +202,8 @@ async fn end_to_end_linear_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -342,6 +344,8 @@ async fn end_to_end_branching_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -464,6 +468,8 @@ async fn end_to_end_human_gate_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -576,6 +582,8 @@ async fn goal_gate_routes_to_retry_target_on_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -698,6 +706,8 @@ async fn goal_gate_routes_to_retry_target_when_present() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1007,6 +1017,8 @@ async fn retry_on_failure_then_succeed() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1083,6 +1095,8 @@ async fn pipeline_with_many_nodes() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1407,6 +1421,8 @@ async fn smoke_test_with_mock_codergen_backend() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1509,6 +1525,8 @@ async fn end_to_end_parallel_fan_out_fan_in() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1622,6 +1640,8 @@ async fn resume_from_checkpoint_completes_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -1721,6 +1741,8 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// This should succeed because goal gate for gated_work is satisfied
@ -1765,6 +1787,8 @@ async fn graph_goal_in_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -1801,6 +1825,8 @@ async fn event_streaming_lifecycle() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -1881,6 +1907,8 @@ async fn context_flow_between_stages() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -1934,6 +1962,8 @@ async fn tool_handler_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2004,6 +2034,8 @@ async fn auto_approve_interviewer_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2041,6 +2073,8 @@ async fn codergen_without_backend_simulated() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -2146,6 +2180,8 @@ async fn branching_loop_back_on_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2231,6 +2267,8 @@ async fn human_gate_loops_back() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2289,6 +2327,8 @@ async fn scenario_ship_a_feature() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2375,6 +2415,8 @@ async fn scenario_parallel_expert_review() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2455,6 +2497,8 @@ async fn scenario_node_retries_on_retry_status() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2517,6 +2561,8 @@ async fn scenario_loop_restart_resets_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2585,6 +2631,8 @@ async fn scenario_bug_triage_router() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -2643,6 +2691,8 @@ async fn scenario_crash_recovery() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
@ -2752,6 +2802,8 @@ async fn manager_loop_stop_condition_satisfied_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
@ -2829,6 +2881,8 @@ async fn manager_loop_max_cycles_exceeded_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
@ -2965,6 +3019,8 @@ async fn conditional_branching_success_fail_paths() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -3018,6 +3074,8 @@ async fn edge_selection_condition_match_wins_over_weight() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3065,6 +3123,8 @@ async fn edge_selection_weight_breaks_ties() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3104,6 +3164,8 @@ async fn edge_selection_lexical_tiebreak() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3162,6 +3224,8 @@ async fn context_updates_visible_across_nodes() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3206,6 +3270,8 @@ async fn stylesheet_applies_model_override() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -3262,6 +3328,8 @@ async fn custom_handler_registration_and_execution() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3333,6 +3401,8 @@ async fn integration_smoke_plan_implement_review_done() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
@ -3437,6 +3507,8 @@ async fn manager_loop_runs_child_engine_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -3572,6 +3644,8 @@ async fn manager_loop_context_flows_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
@ -3646,6 +3720,8 @@ async fn manager_loop_child_dotfile_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
@ -3760,6 +3836,8 @@ async fn graph_merge_e2e_through_engine() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -3911,6 +3989,8 @@ async fn fidelity_default_is_compact() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -3968,6 +4048,8 @@ async fn fidelity_graph_default_applied() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4021,6 +4103,8 @@ async fn fidelity_node_overrides_graph_default() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4080,6 +4164,8 @@ async fn fidelity_edge_overrides_node_and_graph() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4129,6 +4215,8 @@ async fn fidelity_full_produces_empty_preamble() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4188,6 +4276,8 @@ async fn fidelity_truncate_preamble_minimal() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4260,6 +4350,8 @@ async fn fidelity_summary_low_mode() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4327,6 +4419,8 @@ async fn fidelity_summary_medium_mode() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4394,6 +4488,8 @@ async fn fidelity_summary_high_mode() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4454,6 +4550,8 @@ async fn fidelity_full_sets_thread_id_in_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4525,6 +4623,8 @@ async fn fidelity_full_nodes_share_thread_id() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4605,6 +4705,8 @@ async fn fidelity_resume_degrades_full_to_summary_high() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
@ -4701,6 +4803,8 @@ async fn fidelity_resume_degrade_only_affects_first_hop() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
@ -4784,6 +4888,8 @@ async fn fidelity_resume_no_degrade_when_not_full() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
@ -4826,6 +4932,8 @@ async fn fidelity_stored_in_checkpoint_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4912,6 +5020,8 @@ async fn fidelity_precedence_multi_node_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -4980,6 +5090,8 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5056,6 +5168,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine_low
.run(&graph_low, &config_low)
@ -5124,6 +5238,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine_med
.run(&graph_med, &config_med)
@ -5195,6 +5311,8 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5249,6 +5367,8 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5306,6 +5426,8 @@ async fn fidelity_edge_thread_id_override_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5364,6 +5486,8 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5432,6 +5556,8 @@ async fn fidelity_from_parsed_dot_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5480,6 +5606,8 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5550,6 +5678,8 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine.run(&graph, &config).await.expect("run");
@ -5636,6 +5766,8 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
@ -5830,6 +5962,8 @@ mod real_llm {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = tokio::time::timeout(
@ -5945,6 +6079,8 @@ mod real_llm {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = tokio::time::timeout(
@ -6085,6 +6221,8 @@ mod real_llm {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = tokio::time::timeout(
@ -6193,6 +6331,8 @@ mod real_llm {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = tokio::time::timeout(
@ -6290,6 +6430,8 @@ async fn human_gate_freeform_only_routes_text() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -6422,6 +6564,8 @@ async fn human_gate_freeform_with_fixed_choice_match() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -6538,6 +6682,8 @@ async fn human_gate_freeform_fallback_on_unmatched_text() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -6668,6 +6814,8 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -6778,6 +6926,8 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -7039,6 +7189,8 @@ fn make_run_config(dir: &std::path::Path) -> RunConfig {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
}
}
@ -8176,6 +8328,8 @@ async fn arc_e2e_with_real_llm() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -8305,6 +8459,8 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
engine
@ -8505,6 +8661,8 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -8718,6 +8876,8 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -8849,6 +9009,8 @@ async fn node_dir_uses_visit_count_on_revisit() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -9768,6 +9930,8 @@ async fn full_pipeline_with_cli_backend_node() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -9898,6 +10062,8 @@ async fn stylesheet_backend_property_routes_to_cli() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -10180,6 +10346,8 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// 5. Run pipeline
@ -10370,6 +10538,8 @@ async fn git_checkpoint_host_writes_shadow_branch() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// 5. Run pipeline
@ -10565,6 +10735,8 @@ async fn parallel_git_branching_host_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
// 5. Run pipeline
@ -10828,6 +11000,8 @@ async fn git_checkpoint_host_skips_empty_diff_patch() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -11211,6 +11385,8 @@ async fn e2e_circuit_breaker_deterministic_self_loop() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11259,6 +11435,8 @@ async fn e2e_circuit_breaker_custom_limit() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11300,6 +11478,8 @@ async fn e2e_circuit_breaker_ignores_transient_failures() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11348,6 +11528,8 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11389,6 +11571,8 @@ async fn e2e_circuit_breaker_loop_restart() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11452,6 +11636,8 @@ async fn e2e_failure_signature_persisted_in_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.unwrap();
@ -11517,6 +11703,8 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let _outcome = engine.run(&graph, &config).await.unwrap();
@ -11574,6 +11762,8 @@ async fn e2e_signature_maps_persist_in_checkpoint() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.unwrap();
@ -11701,6 +11891,8 @@ async fn e2e_circuit_breaker_emits_events_before_abort() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11769,6 +11961,8 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.unwrap();
@ -11866,6 +12060,8 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -11963,6 +12159,8 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12004,6 +12202,8 @@ async fn e2e_loop_restart_blocked_for_structural_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12045,6 +12245,8 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12086,6 +12288,8 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12124,6 +12328,8 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12166,6 +12372,8 @@ async fn e2e_loop_restart_allowed_for_transient_infra() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12271,6 +12479,8 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let result = engine.run(&graph, &config).await;
@ -12328,6 +12538,8 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -12375,6 +12587,8 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -12441,6 +12655,8 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let start = std::time::Instant::now();
@ -12572,6 +12788,8 @@ async fn asset_collection_local_sandbox_success() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -12681,6 +12899,8 @@ async fn asset_collection_local_sandbox_on_failure() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -12773,6 +12993,8 @@ async fn asset_collection_docker_sandbox() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine
@ -12843,6 +13065,8 @@ async fn wait_timer_e2e() {
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: arc_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);

View file

@ -3957,6 +3957,8 @@ components:
description: Default variable map.
checkpoint:
$ref: "#/components/schemas/CheckpointConfiguration"
pull_request:
$ref: "#/components/schemas/PullRequestConfiguration"
hooks:
type: array
items:
@ -3980,6 +3982,14 @@ components:
type: string
description: Glob patterns to exclude from checkpoints.
PullRequestConfiguration:
description: Pull request creation configuration.
type: object
properties:
enabled:
type: boolean
description: Whether to create a pull request after a successful run.
WebConfiguration:
description: Web UI configuration.
type: object