| 1 | //! Cloud-dispatch remote runner: confirmed job → sandbox → forge PR. |
| 2 | //! |
| 3 | //! This module is the engine behind `confirm_job` / |
| 4 | //! [`crate::cloud_dispatch::execute_dispatch`] with `confirm`: it drives one |
| 5 | //! cloud job from `launching` through a Codewhale-operated sandbox to a pull |
| 6 | //! request on the target forge (`github` | `cnb` | `gitee`), then tears the |
| 7 | //! sandbox down on completion, failure, or cancellation. The persisted |
| 8 | //! contract, credential discovery, and fail-closed membership gate stay in |
| 9 | //! [`crate::cloud_dispatch`]; nothing here re-implements them. |
| 10 | //! |
| 11 | //! Invariants: |
| 12 | //! |
| 13 | //! - one harness: the sandbox runs the same `codewhale exec --auto` |
| 14 | //! one-shot entry every local non-interactive caller uses (one |
| 15 | //! `Engine::run_turn` inside); the runner itself runs no model turn. |
| 16 | //! - credentials never widen: the sandbox credential stays inside |
| 17 | //! [`crate::cloud_dispatch::LiveDaytonaLauncher`]; forge tokens are read |
| 18 | //! from Codewhale service slots only at PR-open time and are never |
| 19 | //! printed, logged, or persisted into job records. |
| 20 | //! - fail closed: every phase that cannot honestly complete records a |
| 21 | //! `failed` (or keeps `canceled`) job with a truthful note; a PR URL is |
| 22 | //! never invented. |
| 23 | //! - teardown always runs: cancel, failure, and success all attempt sandbox |
| 24 | //! teardown; the job note records whether it succeeded. |
| 25 | //! - orphans reconcile: the TUI detaches this runner, so quitting the TUI |
| 26 | //! can orphan a live sandbox. The job record persists a create intent |
| 27 | //! before the POST, every sandbox is labeled with its job id, and |
| 28 | //! [`startup_reconcile`] fails stale active jobs and deletes labeled |
| 29 | //! sandboxes whose job no longer needs them. |
| 30 | |
| 31 | use std::path::Path; |
| 32 | use std::process::Command; |
| 33 | use std::sync::Mutex; |
| 34 | |
| 35 | use anyhow::{Context, Result, anyhow, bail}; |
| 36 | |
| 37 | use crate::cloud_dispatch::{ |
| 38 | self, CloudJob, CloudJobStatus, CloudJobStore, DaytonaLauncher, Forge, HarnessCommand, |
| 39 | PatchReceipt, SANDBOX_WORKSPACE, SandboxReceipt, sanitize_error, unix_timestamp, |
| 40 | validate_outbound_origin, |
| 41 | }; |
| 42 | use crate::dependencies::ExternalTool; |
| 43 | |
| 44 | /// Ceiling for PR titles (forges truncate longer titles). |
| 45 | const MAX_TITLE_CHARS: usize = 96; |
| 46 | /// Ceiling for the PR body, kept well under forge limits. |
| 47 | const MAX_BODY_CHARS: usize = 6_000; |
| 48 | /// Harness timeout for one cloud-agent turn. |
| 49 | const HARNESS_TIMEOUT_SECS: u32 = 3_600; |
| 50 | |
| 51 | /// The pull request the forge opened (or that `gh` reported). |
| 52 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 53 | pub struct PrOpened { |
| 54 | pub url: String, |
| 55 | /// SHA actually applied and pushed — not the sandbox `patch.head_sha`. |
| 56 | pub head_sha: String, |
| 57 | } |
| 58 | |
| 59 | /// Forge seam: raise the agent's branch and open the PR. Tests inject a |
| 60 | /// recorder; production uses [`LiveForgePr`]. |
| 61 | pub trait ForgePr { |
| 62 | fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened>; |
| 63 | } |
| 64 | |
| 65 | /// Run one confirmed job end to end. |
| 66 | /// |
| 67 | /// Requires the job to be `launching` (or `running`, for a resumed runner): |
| 68 | /// a `proposed` job is refused — confirmation is the caller's explicit act, |
| 69 | /// never the runner's. Every phase persists its transition so `/dispatch |
| 70 | /// show` and `/jobs` stream real progress, and a `canceled` record at any |
| 71 | /// checkpoint stops the run and tears the sandbox down. |
| 72 | pub fn run_confirmed_job( |
| 73 | store: &CloudJobStore, |
| 74 | id: &str, |
| 75 | launcher: &dyn DaytonaLauncher, |
| 76 | forge: &dyn ForgePr, |
| 77 | ) -> Result<CloudJob> { |
| 78 | let mut job = store.load(id)?; |
| 79 | if !matches!( |
| 80 | job.status, |
| 81 | CloudJobStatus::Launching | CloudJobStatus::Running |
| 82 | ) { |
| 83 | bail!( |
| 84 | "Cloud job {id} is {} and cannot be run; confirm it first with `/dispatch confirm {id}`.", |
| 85 | status_word(job.status) |
| 86 | ); |
| 87 | } |
| 88 | match drive(store, &mut job, launcher, forge) { |
| 89 | Ok(()) => store.load(id), |
| 90 | Err(error) => { |
| 91 | let message = sanitize_error(&error.to_string()); |
| 92 | // Re-load before writing any failure: a job the user canceled |
| 93 | // stays canceled — a later run error must not overwrite the |
| 94 | // user's terminal word with `failed`. The error is appended to |
| 95 | // the note instead. And if a cancel lands inside the load→save |
| 96 | // span of the failure write, the disk record (already |
| 97 | // `canceled`, with the user's note) wins and is left alone. |
| 98 | let current = match store.load(id) { |
| 99 | Ok(mut record) if record.status == CloudJobStatus::Canceled => { |
| 100 | record.finished_unix = |
| 101 | Some(record.finished_unix.unwrap_or_else(unix_timestamp)); |
| 102 | record.note = format!("{}. Run error after cancel: {message}", record.note); |
| 103 | let _ = store.save(&record); |
| 104 | record |
| 105 | } |
| 106 | loaded => { |
| 107 | let mut failed = loaded.unwrap_or_else(|_| job.clone()); |
| 108 | failed.status = CloudJobStatus::Failed; |
| 109 | failed.refusal = Some(message.clone()); |
| 110 | failed.finished_unix = Some(unix_timestamp()); |
| 111 | failed.note = format!("Cloud agent run failed closed. {message}"); |
| 112 | match store.save_unless_canceled(&failed) { |
| 113 | Ok(true) => failed, |
| 114 | _ => store.load(id).unwrap_or(failed), |
| 115 | } |
| 116 | } |
| 117 | }; |
| 118 | teardown_best_effort(launcher, ¤t); |
| 119 | Err(error) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// Background runner used by the CLI and TUI confirm paths. The store is |
| 125 | /// the source of truth; the thread result is intentionally dropped |
| 126 | /// (failures are recorded inside the job record). The CLI joins the handle |
| 127 | /// before exiting so a confirmed run is never orphaned mid-flight; the TUI |
| 128 | /// detaches it (the job record keeps the truth across restarts, and |
| 129 | /// `/dispatch cancel` tears a live sandbox down at any time). |
| 130 | pub fn spawn_confirmed_runner( |
| 131 | store: CloudJobStore, |
| 132 | id: String, |
| 133 | ) -> Option<std::thread::JoinHandle<()>> { |
| 134 | std::thread::Builder::new() |
| 135 | .name(format!("cw-dispatch-{id}")) |
| 136 | .spawn(move || { |
| 137 | let launcher = cloud_dispatch::LiveDaytonaLauncher; |
| 138 | let forge = LiveForgePr; |
| 139 | let _ = run_confirmed_job(&store, &id, &launcher, &forge); |
| 140 | }) |
| 141 | .ok() |
| 142 | } |
| 143 | |
| 144 | /// Best-effort startup reconciliation for the detached runner. |
| 145 | /// |
| 146 | /// The TUI spawns [`spawn_confirmed_runner`] detached, so quitting the TUI |
| 147 | /// or crashing mid-run orphans the record (and possibly a billing sandbox) |
| 148 | /// with nothing to reconcile it. Two passes, in order: |
| 149 | /// |
| 150 | /// 1. [`cloud_dispatch::sweep_stale_jobs`] — active records older than the |
| 151 | /// declared harness budget plus slack are failed and their recorded |
| 152 | /// sandboxes torn down; |
| 153 | /// 2. [`cloud_dispatch::reconcile_sandboxes`] — any dispatch-labeled |
| 154 | /// sandbox whose job is terminal or absent from the store is deleted by |
| 155 | /// label, covering creates whose id was never recorded. |
| 156 | /// |
| 157 | /// Never fatal and never blocks the caller's critical path beyond the |
| 158 | /// launcher's own bounded HTTP budget; returns a human receipt for the log |
| 159 | /// (empty when there was nothing to do). |
| 160 | pub fn startup_reconcile(store: &CloudJobStore, launcher: &dyn DaytonaLauncher) -> String { |
| 161 | let swept = cloud_dispatch::sweep_stale_jobs(store, launcher, cloud_dispatch::unix_timestamp()); |
| 162 | let mut lines = Vec::new(); |
| 163 | for job in &swept { |
| 164 | lines.push(format!( |
| 165 | "cloud dispatch startup sweep: job {} marked stale (failed) and its sandbox teardown attempted", |
| 166 | job.id |
| 167 | )); |
| 168 | } |
| 169 | match cloud_dispatch::reconcile_sandboxes(store, launcher) { |
| 170 | Ok(report) if !report.deleted.is_empty() => { |
| 171 | lines.push(format!( |
| 172 | "cloud dispatch label reconcile: deleted orphaned sandbox(es) {}", |
| 173 | report.deleted.join(", ") |
| 174 | )); |
| 175 | } |
| 176 | Ok(_) => {} |
| 177 | Err(error) => lines.push(format!( |
| 178 | "cloud dispatch label reconcile skipped: {}", |
| 179 | sanitize_error(&error.to_string()) |
| 180 | )), |
| 181 | } |
| 182 | lines.join("\n") |
| 183 | } |
| 184 | |
| 185 | fn drive( |
| 186 | store: &CloudJobStore, |
| 187 | job: &mut CloudJob, |
| 188 | launcher: &dyn DaytonaLauncher, |
| 189 | forge: &dyn ForgePr, |
| 190 | ) -> Result<()> { |
| 191 | // Launching → Running: create the sandbox. The intent record goes down |
| 192 | // BEFORE the POST: if the create response is slow and the client gives |
| 193 | // up (or the process dies), the sandbox may still come into being with |
| 194 | // no recorded id — `sandbox_pending` is what cancel and the label |
| 195 | // reconciler use to find and delete it by label. |
| 196 | // |
| 197 | // Every phase save below is cancel-authoritative |
| 198 | // (`save_unless_canceled`): a cancel that lands while a phase is in |
| 199 | // flight wins over the runner's read-modify-write, so a canceled job |
| 200 | // can never be resurrected into a later phase — above all never into |
| 201 | // the branch push / PR open. |
| 202 | job.sandbox_pending = true; |
| 203 | store.save(job)?; |
| 204 | let receipt = launcher.create_sandbox(job)?; |
| 205 | job.status = CloudJobStatus::Running; |
| 206 | job.sandbox_pending = false; |
| 207 | job.sandbox_id = Some(receipt.sandbox_id.clone()); |
| 208 | job.note = format!( |
| 209 | "Sandbox {} created; the Codewhale cloud agent turn is running.", |
| 210 | receipt.sandbox_id |
| 211 | ); |
| 212 | if !store.save_unless_canceled(job)? { |
| 213 | return finish_canceled(store, job, launcher, &receipt); |
| 214 | } |
| 215 | |
| 216 | launcher.wait_ready(&receipt)?; |
| 217 | let clone_url = cloud_dispatch::validate_git_remote_url(&job.remote_url)?; |
| 218 | launcher.clone_repository(&receipt, &clone_url, SANDBOX_WORKSPACE)?; |
| 219 | if cancel_requested(store, job)? { |
| 220 | return finish_canceled(store, job, launcher, &receipt); |
| 221 | } |
| 222 | |
| 223 | // One agent turn through the standard one-shot harness entry. |
| 224 | let output = launcher.run_harness(&receipt, &harness_command(job))?; |
| 225 | job.agent_summary = Some(summary_line(&output)); |
| 226 | if cancel_requested(store, job)? { |
| 227 | return finish_canceled(store, job, launcher, &receipt); |
| 228 | } |
| 229 | |
| 230 | // Running → OpeningPr: collect the agent's work product. |
| 231 | let patch = launcher.collect_patch(&receipt)?; |
| 232 | job.status = CloudJobStatus::OpeningPr; |
| 233 | job.base_branch = Some(patch.base_branch.clone()); |
| 234 | job.head_sha = Some(patch.head_sha.clone()); // replaced with the pushed sha after `forge.open` |
| 235 | job.note = format!( |
| 236 | "Agent turn complete ({}); raising branch {} and opening the PR on {}.", |
| 237 | patch.summary, |
| 238 | job.branch, |
| 239 | job.forge.as_str() |
| 240 | ); |
| 241 | if !store.save_unless_canceled(job)? { |
| 242 | return finish_canceled(store, job, launcher, &receipt); |
| 243 | } |
| 244 | |
| 245 | // OpeningPr → Done: push the branch and open the PR. The cancel check |
| 246 | // is the last gate before money-adjacent side effects on the forge. |
| 247 | if cancel_requested(store, job)? { |
| 248 | return finish_canceled(store, job, launcher, &receipt); |
| 249 | } |
| 250 | let opened = forge.open(job, &patch)?; |
| 251 | job.status = CloudJobStatus::Done; |
| 252 | job.pr_url = Some(opened.url.clone()); |
| 253 | job.head_sha = Some(opened.head_sha.clone()); |
| 254 | job.finished_unix = Some(unix_timestamp()); |
| 255 | job.note = format!( |
| 256 | "Cloud agent finished; PR opened at {}. {}", |
| 257 | opened.url, |
| 258 | teardown_note(launcher, &receipt) |
| 259 | ); |
| 260 | if !store.save_unless_canceled(job)? { |
| 261 | // A cancel landed while the PR was opening. The PR may well exist — |
| 262 | // keep its URL and say exactly that rather than claiming success or |
| 263 | // silently dropping the receipt. |
| 264 | let mut canceled = store.load(&job.id)?; |
| 265 | canceled.pr_url = job.pr_url.clone(); |
| 266 | canceled.agent_summary = job.agent_summary.clone(); |
| 267 | canceled.finished_unix = Some(canceled.finished_unix.unwrap_or_else(unix_timestamp)); |
| 268 | canceled.note = format!( |
| 269 | "Canceled as the PR was opening; it may still have landed at {}. {}", |
| 270 | opened.url, |
| 271 | teardown_note(launcher, &receipt) |
| 272 | ); |
| 273 | *job = canceled.clone(); |
| 274 | return store.save(&canceled).map(|_| ()); |
| 275 | } |
| 276 | Ok(()) |
| 277 | } |
| 278 | |
| 279 | /// True when the user canceled the job mid-run. |
| 280 | fn cancel_requested(store: &CloudJobStore, job: &CloudJob) -> Result<bool> { |
| 281 | Ok(store.load(&job.id)?.status == CloudJobStatus::Canceled) |
| 282 | } |
| 283 | |
| 284 | fn finish_canceled( |
| 285 | store: &CloudJobStore, |
| 286 | job: &mut CloudJob, |
| 287 | launcher: &dyn DaytonaLauncher, |
| 288 | receipt: &SandboxReceipt, |
| 289 | ) -> Result<()> { |
| 290 | let mut canceled = store.load(&job.id)?; |
| 291 | canceled.agent_summary = job.agent_summary.clone(); |
| 292 | canceled.finished_unix = Some(canceled.finished_unix.unwrap_or_else(unix_timestamp)); |
| 293 | canceled.note = format!("Canceled mid-run. {}", teardown_note(launcher, receipt)); |
| 294 | *job = canceled.clone(); |
| 295 | store.save(&canceled) |
| 296 | } |
| 297 | |
| 298 | fn teardown_best_effort(launcher: &dyn DaytonaLauncher, job: &CloudJob) { |
| 299 | if let Some(sandbox_id) = job.sandbox_id.clone() { |
| 300 | let receipt = SandboxReceipt { |
| 301 | sandbox_id, |
| 302 | toolbox_url: None, |
| 303 | }; |
| 304 | let _ = launcher.teardown(&receipt); |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | fn teardown_note(launcher: &dyn DaytonaLauncher, receipt: &SandboxReceipt) -> String { |
| 309 | match launcher.teardown(receipt) { |
| 310 | Ok(()) => "The sandbox was torn down.".to_string(), |
| 311 | Err(error) => format!( |
| 312 | "Sandbox teardown failed and may need a retry: {}", |
| 313 | sanitize_error(&error.to_string()) |
| 314 | ), |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | /// The exact harness invocation the sandbox runs: the one-shot |
| 319 | /// `codewhale exec --auto` entry — the same single-`Engine::run_turn` path |
| 320 | /// local non-interactive callers use, never a second engine. |
| 321 | pub fn harness_command(job: &CloudJob) -> HarnessCommand { |
| 322 | HarnessCommand { |
| 323 | argv: vec![ |
| 324 | "codewhale".to_string(), |
| 325 | "exec".to_string(), |
| 326 | "--auto".to_string(), |
| 327 | job.prompt.clone(), |
| 328 | ], |
| 329 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 330 | timeout_secs: HARNESS_TIMEOUT_SECS, |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | /// First non-empty line of harness output, bounded for notes and PR bodies. |
| 335 | pub fn summary_line(output: &str) -> String { |
| 336 | // Redacted first: the sandbox env carries the account machine token, and |
| 337 | // harness output must not be able to echo it into the job record. |
| 338 | crate::cloud_dispatch::redact_machine_tokens(output) |
| 339 | .lines() |
| 340 | .map(str::trim) |
| 341 | .find(|line| !line.is_empty()) |
| 342 | .map(|line| line.chars().take(200).collect()) |
| 343 | .unwrap_or_else(|| "cloud agent turn completed".to_string()) |
| 344 | } |
| 345 | |
| 346 | /// Owner/repo slug for a forge remote URL, or `None` when the URL does not |
| 347 | /// match the job's forge. Both https and `git@host:owner/repo.git` shapes |
| 348 | /// are accepted; a trailing `.git` is stripped. |
| 349 | pub fn forge_slug(forge: Forge, remote_url: &str) -> Option<String> { |
| 350 | if cloud_dispatch::classify_url(remote_url) != Some(forge) { |
| 351 | return None; |
| 352 | } |
| 353 | // `https://host/owner/repo.git` or `git@host:owner/repo.git` |
| 354 | let repo_path = if let Some((_, rest)) = remote_url.split_once("://") { |
| 355 | // Strip the host: the slug is the two path segments after it. |
| 356 | rest.split_once('/')?.1 |
| 357 | } else { |
| 358 | remote_url.split_once(':')?.1 |
| 359 | }; |
| 360 | let segments: Vec<&str> = repo_path.split('/').collect(); |
| 361 | if segments.len() < 2 { |
| 362 | return None; |
| 363 | } |
| 364 | let repo = segments[segments.len() - 1].trim_end_matches(".git"); |
| 365 | let owner = segments[segments.len() - 2]; |
| 366 | if owner.is_empty() || repo.is_empty() || repo == owner { |
| 367 | return None; |
| 368 | } |
| 369 | Some(format!("{owner}/{repo}")) |
| 370 | } |
| 371 | |
| 372 | /// Truthful PR title: names the agent and its own summary. |
| 373 | pub fn compose_pr_title(job: &CloudJob, patch: &PatchReceipt) -> String { |
| 374 | let summary = if patch.summary.trim().is_empty() { |
| 375 | one_line(&job.prompt, 72) |
| 376 | } else { |
| 377 | one_line(&patch.summary, 72) |
| 378 | }; |
| 379 | one_line(&format!("codewhale cloud: {summary}"), MAX_TITLE_CHARS) |
| 380 | } |
| 381 | |
| 382 | /// Truthful PR body: what the agent did, the receipts Codewhale has, and an |
| 383 | /// explicit No-Issue line (no tracked issue; the cloud job is the record). |
| 384 | /// Sandbox-provider names never appear — the operator is Codewhale. |
| 385 | pub fn compose_pr_body(job: &CloudJob, patch: &PatchReceipt) -> String { |
| 386 | compose_pr_body_for_head(job, patch, &patch.head_sha) |
| 387 | } |
| 388 | |
| 389 | /// PR body whose `Head:` is the sha actually applied/pushed. |
| 390 | pub fn compose_pr_body_for_head(job: &CloudJob, patch: &PatchReceipt, head_sha: &str) -> String { |
| 391 | let summary = if patch.summary.trim().is_empty() { |
| 392 | "(the agent's commit list is the record)".to_string() |
| 393 | } else { |
| 394 | patch.summary.trim().to_string() |
| 395 | }; |
| 396 | let body = format!( |
| 397 | "Automated change by a Codewhale cloud agent.\n\n\ |
| 398 | ## What the agent did\n{summary}\n\n\ |
| 399 | ## Task\n{}\n\n\ |
| 400 | ## Receipts\n\ |
| 401 | - Cloud job: {}\n\ |
| 402 | - Sandbox: {}\n\ |
| 403 | - Branch: `{}` (base `{}`)\n\ |
| 404 | - Head: `{}`\n\n\ |
| 405 | No-Issue: cloud dispatch {} (no tracked issue; receipts above)", |
| 406 | job.prompt, |
| 407 | job.id, |
| 408 | job.sandbox_id.as_deref().unwrap_or("(pending)"), |
| 409 | job.branch, |
| 410 | patch.base_branch, |
| 411 | head_sha, |
| 412 | job.id, |
| 413 | ); |
| 414 | one_line(&body, MAX_BODY_CHARS) |
| 415 | } |
| 416 | |
| 417 | /// `gh pr create` argv for the GitHub path. The body rides in a file so the |
| 418 | /// prompt text never appears in `ps` output. |
| 419 | pub fn gh_pr_create_argv( |
| 420 | slug: &str, |
| 421 | base: &str, |
| 422 | head: &str, |
| 423 | title: &str, |
| 424 | body_file: &str, |
| 425 | ) -> Vec<String> { |
| 426 | vec![ |
| 427 | "pr".to_string(), |
| 428 | "create".to_string(), |
| 429 | "--repo".to_string(), |
| 430 | slug.to_string(), |
| 431 | "--base".to_string(), |
| 432 | base.to_string(), |
| 433 | "--head".to_string(), |
| 434 | head.to_string(), |
| 435 | "--title".to_string(), |
| 436 | title.to_string(), |
| 437 | "--body-file".to_string(), |
| 438 | body_file.to_string(), |
| 439 | ] |
| 440 | } |
| 441 | |
| 442 | /// Gitee v5 pull-request endpoint for a slug. |
| 443 | pub fn gitee_pr_url(slug: &str) -> String { |
| 444 | format!("https://gitee.com/api/v5/repos/{slug}/pulls") |
| 445 | } |
| 446 | |
| 447 | /// CNB pull-request endpoint for a slug. |
| 448 | pub fn cnb_pr_url(slug: &str) -> String { |
| 449 | format!("https://api.cnb.cool/{slug}/-/pulls") |
| 450 | } |
| 451 | |
| 452 | /// Production forge opener: local git for the branch push, then the forge's |
| 453 | /// own API surface for the PR (gh for GitHub; REST for CNB and Gitee with |
| 454 | /// service-slot tokens). Fails closed on missing tooling or tokens — the |
| 455 | /// branch push is not rolled back, and no PR URL is ever invented. |
| 456 | pub struct LiveForgePr; |
| 457 | |
| 458 | impl ForgePr for LiveForgePr { |
| 459 | fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened> { |
| 460 | let slug = forge_slug(job.forge, &job.remote_url).ok_or_else(|| { |
| 461 | anyhow!( |
| 462 | "the {} remote {} does not resolve to an owner/repo slug", |
| 463 | job.forge.as_str(), |
| 464 | job.remote_url |
| 465 | ) |
| 466 | })?; |
| 467 | let title = compose_pr_title(job, patch); |
| 468 | let dir = tempfile::tempdir().context("could not stage the cloud agent branch")?; |
| 469 | let head_sha = prepare_branch(job, patch, dir.path())?; |
| 470 | push_branch(&dir.path().join("repo"), &job.remote_url, &job.branch)?; |
| 471 | let body = compose_pr_body_for_head(job, patch, &head_sha); |
| 472 | let url = match job.forge { |
| 473 | Forge::Github => open_pr_github(&slug, job, patch, &title, &body)?, |
| 474 | Forge::Gitee => open_pr_gitee(&slug, job, patch, &title, &body)?, |
| 475 | Forge::Cnb => open_pr_cnb(&slug, job, patch, &title, &body)?, |
| 476 | }; |
| 477 | Ok(PrOpened { url, head_sha }) |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | /// Shallow-clone the target repository, apply the agent's patch on a branch, |
| 482 | /// and return the head sha. Plain git subprocess work; never forceful. |
| 483 | fn prepare_branch(job: &CloudJob, patch: &PatchReceipt, dir: &Path) -> Result<String> { |
| 484 | let remote_url = cloud_dispatch::validate_git_remote_url(&job.remote_url)?; |
| 485 | git( |
| 486 | None, |
| 487 | &[ |
| 488 | "clone", |
| 489 | "--quiet", |
| 490 | "--depth", |
| 491 | "50", |
| 492 | "--", |
| 493 | &remote_url, |
| 494 | &dir.join("repo").to_string_lossy(), |
| 495 | ], |
| 496 | ) |
| 497 | .context("could not clone the target repository for the cloud agent branch")?; |
| 498 | let repo = dir.join("repo"); |
| 499 | let patch_path = dir.join("agent.patch"); |
| 500 | std::fs::write(&patch_path, &patch.patch).context("could not stage the agent patch")?; |
| 501 | git( |
| 502 | Some(&repo), |
| 503 | &["config", "user.name", "Codewhale Cloud Agent"], |
| 504 | ) |
| 505 | .context("could not set the agent identity")?; |
| 506 | git( |
| 507 | Some(&repo), |
| 508 | &["config", "user.email", "cloud-agent@codewhale.invalid"], |
| 509 | ) |
| 510 | .context("could not set the agent identity")?; |
| 511 | git(Some(&repo), &["checkout", "--quiet", "-b", &job.branch]) |
| 512 | .context("could not create the cloud agent branch")?; |
| 513 | git( |
| 514 | Some(&repo), |
| 515 | &["am", "--quiet", "--3way", &patch_path.to_string_lossy()], |
| 516 | ) |
| 517 | .context("the agent patch did not apply cleanly onto the target branch")?; |
| 518 | git(Some(&repo), &["rev-parse", "HEAD"]).map(|out| out.trim().to_string()) |
| 519 | } |
| 520 | |
| 521 | /// Push the prepared branch. Plain push only — `--force` is never passed, so |
| 522 | /// an existing branch that is not a fast-forward fails closed instead of |
| 523 | /// rewriting the target's history. |
| 524 | fn push_branch(repo: &Path, remote_url: &str, branch: &str) -> Result<()> { |
| 525 | let remote_url = cloud_dispatch::validate_git_remote_url(remote_url)?; |
| 526 | if cloud_dispatch::is_forge_default_branch(branch) { |
| 527 | bail!("refusing to push onto the forge default branch {branch}"); |
| 528 | } |
| 529 | if looks_like_network_remote(&remote_url) && cloud_dispatch::classify_url(&remote_url).is_none() |
| 530 | { |
| 531 | bail!("refusing to push to a non-forge remote"); |
| 532 | } |
| 533 | if remote_branch_exists(&remote_url, branch)? { |
| 534 | bail!("refusing to update existing remote branch {branch}"); |
| 535 | } |
| 536 | git( |
| 537 | Some(repo), |
| 538 | &[ |
| 539 | "push", |
| 540 | "--quiet", |
| 541 | "--", |
| 542 | &remote_url, |
| 543 | &format!("HEAD:refs/heads/{branch}"), |
| 544 | ], |
| 545 | ) |
| 546 | .map(|_| ()) |
| 547 | .context( |
| 548 | "could not push the cloud agent branch (it may need credentials or the branch may have moved)", |
| 549 | ) |
| 550 | } |
| 551 | |
| 552 | fn looks_like_network_remote(url: &str) -> bool { |
| 553 | url.contains("://") || url.contains('@') |
| 554 | } |
| 555 | |
| 556 | fn remote_branch_exists(remote_url: &str, branch: &str) -> Result<bool> { |
| 557 | let listing = |
| 558 | git(None, &["ls-remote", "--heads", "--", remote_url, branch]).unwrap_or_default(); |
| 559 | Ok(listing |
| 560 | .lines() |
| 561 | .any(|line| line.contains(&format!("refs/heads/{branch}")))) |
| 562 | } |
| 563 | |
| 564 | fn git(cwd: Option<&Path>, args: &[&str]) -> Result<String> { |
| 565 | let mut command = Command::new("git"); |
| 566 | if let Some(cwd) = cwd { |
| 567 | command.current_dir(cwd); |
| 568 | } |
| 569 | let output = command |
| 570 | .args(args) |
| 571 | .output() |
| 572 | .context("failed to start git for the cloud agent branch")?; |
| 573 | if !output.status.success() { |
| 574 | bail!( |
| 575 | "git {} failed: {}", |
| 576 | args.first().unwrap_or(&""), |
| 577 | sanitize_error(&String::from_utf8_lossy(&output.stderr)) |
| 578 | ); |
| 579 | } |
| 580 | Ok(String::from_utf8_lossy(&output.stdout).to_string()) |
| 581 | } |
| 582 | |
| 583 | fn open_pr_github( |
| 584 | slug: &str, |
| 585 | job: &CloudJob, |
| 586 | patch: &PatchReceipt, |
| 587 | title: &str, |
| 588 | body: &str, |
| 589 | ) -> Result<String> { |
| 590 | let body_dir = tempfile::tempdir().context("could not stage the PR body")?; |
| 591 | let body_file = body_dir.path().join("body.md"); |
| 592 | std::fs::write(&body_file, body).context("could not write the PR body")?; |
| 593 | let mut command = crate::dependencies::Gh::command() |
| 594 | .ok_or_else(|| anyhow!("the GitHub pull request needs the gh CLI on PATH"))?; |
| 595 | command.args(gh_pr_create_argv( |
| 596 | slug, |
| 597 | &patch.base_branch, |
| 598 | &job.branch, |
| 599 | title, |
| 600 | &body_file.to_string_lossy(), |
| 601 | )); |
| 602 | let output = command |
| 603 | .output() |
| 604 | .context("failed to start gh for the pull request")?; |
| 605 | if !output.status.success() { |
| 606 | bail!( |
| 607 | "gh pr create failed: {}", |
| 608 | sanitize_error(&String::from_utf8_lossy(&output.stderr)) |
| 609 | ); |
| 610 | } |
| 611 | let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 612 | if !url.starts_with("https://") { |
| 613 | bail!("gh did not report a pull request URL; refusing to invent one."); |
| 614 | } |
| 615 | Ok(url) |
| 616 | } |
| 617 | |
| 618 | fn open_pr_gitee( |
| 619 | slug: &str, |
| 620 | job: &CloudJob, |
| 621 | patch: &PatchReceipt, |
| 622 | title: &str, |
| 623 | body: &str, |
| 624 | ) -> Result<String> { |
| 625 | let token = read_service_token("gitee").ok_or_else(|| { |
| 626 | anyhow!("a Gitee access token is not configured in the Codewhale service slot; the branch was pushed but no pull request was opened") |
| 627 | })?; |
| 628 | let url = validate_outbound_origin(&gitee_pr_url(slug))?; |
| 629 | let response = crate::tls::reqwest_blocking_client_builder() |
| 630 | .connect_timeout(std::time::Duration::from_secs(8)) |
| 631 | .timeout(std::time::Duration::from_secs(30)) |
| 632 | .redirect(reqwest::redirect::Policy::none()) |
| 633 | .build() |
| 634 | .context("could not initialize the Gitee client")? |
| 635 | .post(url) |
| 636 | .form(&[ |
| 637 | ("access_token", token.as_str()), |
| 638 | ("title", title), |
| 639 | ("head", job.branch.as_str()), |
| 640 | ("base", patch.base_branch.as_str()), |
| 641 | ("body", body), |
| 642 | ]) |
| 643 | .send() |
| 644 | .context("could not reach Gitee")?; |
| 645 | let status = response.status(); |
| 646 | let text = response.text().unwrap_or_default(); |
| 647 | if !status.is_success() { |
| 648 | bail!("Gitee pull request create failed (HTTP {status})."); |
| 649 | } |
| 650 | let parsed: serde_json::Value = |
| 651 | serde_json::from_str(&text).context("Gitee returned invalid JSON")?; |
| 652 | parsed |
| 653 | .get("html_url") |
| 654 | .and_then(serde_json::Value::as_str) |
| 655 | .map(str::trim) |
| 656 | .filter(|url| url.starts_with("https://")) |
| 657 | .map(|url| url.to_string()) |
| 658 | .ok_or_else(|| anyhow!("Gitee did not report a pull request URL; refusing to invent one.")) |
| 659 | } |
| 660 | |
| 661 | fn open_pr_cnb( |
| 662 | slug: &str, |
| 663 | job: &CloudJob, |
| 664 | patch: &PatchReceipt, |
| 665 | title: &str, |
| 666 | body: &str, |
| 667 | ) -> Result<String> { |
| 668 | let token = read_service_token("cnb").ok_or_else(|| { |
| 669 | anyhow!("a CNB access token is not configured in the Codewhale service slot; the branch was pushed but no pull request was opened") |
| 670 | })?; |
| 671 | let url = validate_outbound_origin(&cnb_pr_url(slug))?; |
| 672 | let response = crate::tls::reqwest_blocking_client_builder() |
| 673 | .connect_timeout(std::time::Duration::from_secs(8)) |
| 674 | .timeout(std::time::Duration::from_secs(30)) |
| 675 | .redirect(reqwest::redirect::Policy::none()) |
| 676 | .build() |
| 677 | .context("could not initialize the CNB client")? |
| 678 | .post(url) |
| 679 | .bearer_auth(&token) |
| 680 | .json(&serde_json::json!({ |
| 681 | "title": title, |
| 682 | "head": job.branch, |
| 683 | "base": patch.base_branch, |
| 684 | "body": body, |
| 685 | })) |
| 686 | .send() |
| 687 | .context("could not reach CNB")?; |
| 688 | let status = response.status(); |
| 689 | let text = response.text().unwrap_or_default(); |
| 690 | if !status.is_success() { |
| 691 | bail!("CNB pull request create failed (HTTP {status})."); |
| 692 | } |
| 693 | let parsed: serde_json::Value = |
| 694 | serde_json::from_str(&text).context("CNB returned invalid JSON")?; |
| 695 | let number = parsed |
| 696 | .get("number") |
| 697 | .and_then(serde_json::Value::as_i64) |
| 698 | .filter(|number| *number > 0) |
| 699 | .ok_or_else(|| { |
| 700 | anyhow!("CNB did not report a pull request number; refusing to invent a URL.") |
| 701 | })?; |
| 702 | Ok(format!("https://cnb.cool/{slug}/-/pulls/{number}")) |
| 703 | } |
| 704 | |
| 705 | /// Read a forge token from the Codewhale service slot. Never logged. |
| 706 | fn read_service_token(slot: &str) -> Option<String> { |
| 707 | codewhale_secrets::Secrets::auto_detect() |
| 708 | .get(slot) |
| 709 | .ok() |
| 710 | .flatten() |
| 711 | .map(|value| value.trim().to_string()) |
| 712 | .filter(|value| !value.is_empty()) |
| 713 | } |
| 714 | |
| 715 | fn one_line(value: &str, max: usize) -> String { |
| 716 | let flat: String = value |
| 717 | .chars() |
| 718 | .map(|ch| { |
| 719 | if ch.is_control() && ch != '\n' { |
| 720 | ' ' |
| 721 | } else { |
| 722 | ch |
| 723 | } |
| 724 | }) |
| 725 | .collect(); |
| 726 | if flat.chars().count() <= max { |
| 727 | flat |
| 728 | } else { |
| 729 | let mut out: String = flat.chars().take(max.saturating_sub(1)).collect(); |
| 730 | out.push('…'); |
| 731 | out |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | fn status_word(status: CloudJobStatus) -> &'static str { |
| 736 | match status { |
| 737 | CloudJobStatus::Proposed => "proposed", |
| 738 | CloudJobStatus::Refused => "refused", |
| 739 | CloudJobStatus::Launching => "launching", |
| 740 | CloudJobStatus::Running => "running", |
| 741 | CloudJobStatus::OpeningPr => "openingpr", |
| 742 | CloudJobStatus::Done => "done", |
| 743 | CloudJobStatus::Failed => "failed", |
| 744 | CloudJobStatus::Canceled => "canceled", |
| 745 | } |
| 746 | } |
| 747 | |
| 748 | /// Callback fired at each launcher phase boundary (used by tests to |
| 749 | /// simulate mid-run cancellation). |
| 750 | pub type PhaseHook = Box<dyn Fn(&str) + Send + Sync>; |
| 751 | |
| 752 | /// Recording launcher for offline tests. Records every phase by name and |
| 753 | /// replays canned results; `hook` fires at each phase boundary so tests can |
| 754 | /// simulate mid-run cancellation. |
| 755 | pub struct RecordingLauncher { |
| 756 | sandbox_id: String, |
| 757 | patch: PatchReceipt, |
| 758 | calls: Mutex<Vec<String>>, |
| 759 | pub hook: Option<PhaseHook>, |
| 760 | /// Sandboxes reported by `list_job_sandboxes` (the reconciler seam). |
| 761 | pub listed: Mutex<Vec<crate::cloud_dispatch::LabeledSandbox>>, |
| 762 | } |
| 763 | |
| 764 | impl RecordingLauncher { |
| 765 | pub fn new(sandbox_id: &str, patch: PatchReceipt) -> Self { |
| 766 | Self { |
| 767 | sandbox_id: sandbox_id.to_string(), |
| 768 | patch, |
| 769 | calls: Mutex::new(Vec::new()), |
| 770 | hook: None, |
| 771 | listed: Mutex::new(Vec::new()), |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | pub fn calls(&self) -> Vec<String> { |
| 776 | self.calls |
| 777 | .lock() |
| 778 | .map(|calls| calls.clone()) |
| 779 | .unwrap_or_default() |
| 780 | } |
| 781 | |
| 782 | fn record(&self, phase: &str) { |
| 783 | if let Ok(mut calls) = self.calls.lock() { |
| 784 | calls.push(phase.to_string()); |
| 785 | } |
| 786 | if let Some(hook) = self.hook.as_ref() { |
| 787 | hook(phase); |
| 788 | } |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | impl DaytonaLauncher for RecordingLauncher { |
| 793 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 794 | self.record("create"); |
| 795 | Ok(SandboxReceipt { |
| 796 | sandbox_id: self.sandbox_id.clone(), |
| 797 | toolbox_url: Some("https://toolbox.example.test".to_string()), |
| 798 | }) |
| 799 | } |
| 800 | |
| 801 | fn wait_ready(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 802 | self.record("wait_ready"); |
| 803 | Ok(()) |
| 804 | } |
| 805 | |
| 806 | fn clone_repository(&self, _receipt: &SandboxReceipt, url: &str, path: &str) -> Result<()> { |
| 807 | self.record("clone"); |
| 808 | assert_eq!(path, SANDBOX_WORKSPACE); |
| 809 | assert!( |
| 810 | url.starts_with("https://"), |
| 811 | "live clone must be an https URL" |
| 812 | ); |
| 813 | Ok(()) |
| 814 | } |
| 815 | |
| 816 | fn run_harness(&self, _receipt: &SandboxReceipt, _command: &HarnessCommand) -> Result<String> { |
| 817 | self.record("harness"); |
| 818 | Ok("Fixed the flaky test and re-ran the suite.\nall green".to_string()) |
| 819 | } |
| 820 | |
| 821 | fn collect_patch(&self, _receipt: &SandboxReceipt) -> Result<PatchReceipt> { |
| 822 | self.record("collect"); |
| 823 | Ok(self.patch.clone()) |
| 824 | } |
| 825 | |
| 826 | fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 827 | self.record("teardown"); |
| 828 | Ok(()) |
| 829 | } |
| 830 | |
| 831 | fn list_job_sandboxes(&self) -> Result<Vec<crate::cloud_dispatch::LabeledSandbox>> { |
| 832 | self.record("list"); |
| 833 | Ok(self |
| 834 | .listed |
| 835 | .lock() |
| 836 | .map(|listed| listed.clone()) |
| 837 | .unwrap_or_default()) |
| 838 | } |
| 839 | } |
| 840 | |
| 841 | /// Recording forge opener for offline tests. |
| 842 | pub struct RecordingForgePr { |
| 843 | pub url: String, |
| 844 | opened: Mutex<Vec<String>>, |
| 845 | } |
| 846 | |
| 847 | impl RecordingForgePr { |
| 848 | pub fn new(url: &str) -> Self { |
| 849 | Self { |
| 850 | url: url.to_string(), |
| 851 | opened: Mutex::new(Vec::new()), |
| 852 | } |
| 853 | } |
| 854 | |
| 855 | pub fn opened(&self) -> Vec<String> { |
| 856 | self.opened |
| 857 | .lock() |
| 858 | .map(|opened| opened.clone()) |
| 859 | .unwrap_or_default() |
| 860 | } |
| 861 | } |
| 862 | |
| 863 | impl ForgePr for RecordingForgePr { |
| 864 | fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened> { |
| 865 | if let Ok(mut opened) = self.opened.lock() { |
| 866 | opened.push(format!("{}:{}", job.id, patch.head_sha)); |
| 867 | } |
| 868 | Ok(PrOpened { |
| 869 | url: self.url.clone(), |
| 870 | head_sha: patch.head_sha.clone(), |
| 871 | }) |
| 872 | } |
| 873 | } |
| 874 | |
| 875 | #[cfg(test)] |
| 876 | mod tests { |
| 877 | use super::*; |
| 878 | use crate::cloud_dispatch::{ |
| 879 | CloudJobStatus, CredentialSource, CredentialState, DispatchOutcome, GitRemote, |
| 880 | MachineTokenState, execute_dispatch, plan_dispatch, |
| 881 | }; |
| 882 | use std::sync::Arc; |
| 883 | |
| 884 | fn fixture_patch() -> PatchReceipt { |
| 885 | PatchReceipt { |
| 886 | base_branch: "main".to_string(), |
| 887 | head_sha: "abc123def4567".to_string(), |
| 888 | summary: "Fix the flaky dispatch test".to_string(), |
| 889 | patch: "From abc123 Mon Sep 17 00:00:00 2001\nSubject: [PATCH] Fix the flake\n" |
| 890 | .to_string(), |
| 891 | } |
| 892 | } |
| 893 | |
| 894 | fn confirmed_job(store: &CloudJobStore) -> CloudJob { |
| 895 | let plan = plan_dispatch( |
| 896 | &[GitRemote { |
| 897 | name: "github".to_string(), |
| 898 | url: "https://github.com/org/repo.git".to_string(), |
| 899 | }], |
| 900 | "open a PR that fixes the flake", |
| 901 | Some(Forge::Github), |
| 902 | Some("codewhale/cloud-runner-test"), |
| 903 | ) |
| 904 | .unwrap(); |
| 905 | match execute_dispatch( |
| 906 | store, |
| 907 | plan, |
| 908 | true, |
| 909 | &CredentialState::Present { |
| 910 | source: CredentialSource::Env, |
| 911 | }, |
| 912 | &MachineTokenState::Present, |
| 913 | ) |
| 914 | .unwrap() |
| 915 | { |
| 916 | DispatchOutcome::Accepted(job) => job, |
| 917 | other => panic!("expected accept, got {other:?}"), |
| 918 | } |
| 919 | } |
| 920 | |
| 921 | #[test] |
| 922 | fn recording_lifecycle_reaches_done_with_receipts_and_teardown() { |
| 923 | let temp = tempfile::tempdir().unwrap(); |
| 924 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 925 | let job = confirmed_job(&store); |
| 926 | let launcher = RecordingLauncher::new("sandbox_runner_1", fixture_patch()); |
| 927 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 928 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 929 | |
| 930 | assert_eq!(finished.status, CloudJobStatus::Done); |
| 931 | assert_eq!( |
| 932 | finished.pr_url.as_deref(), |
| 933 | Some("https://github.com/org/repo/pull/9") |
| 934 | ); |
| 935 | assert_eq!(finished.sandbox_id.as_deref(), Some("sandbox_runner_1")); |
| 936 | assert_eq!(finished.base_branch.as_deref(), Some("main")); |
| 937 | assert_eq!(finished.head_sha.as_deref(), Some("abc123def4567")); |
| 938 | assert_eq!( |
| 939 | finished.agent_summary.as_deref(), |
| 940 | Some("Fixed the flaky test and re-ran the suite.") |
| 941 | ); |
| 942 | assert!(finished.finished_unix.is_some()); |
| 943 | assert!(finished.note.contains("PR opened at")); |
| 944 | assert!(finished.note.contains("torn down")); |
| 945 | // Full protocol order, teardown last. |
| 946 | assert_eq!( |
| 947 | launcher.calls(), |
| 948 | vec![ |
| 949 | "create", |
| 950 | "wait_ready", |
| 951 | "clone", |
| 952 | "harness", |
| 953 | "collect", |
| 954 | "teardown" |
| 955 | ] |
| 956 | ); |
| 957 | assert_eq!(forge.opened(), vec![format!("{}:abc123def4567", job.id)]); |
| 958 | // The persisted record streams the same truth. |
| 959 | assert_eq!(store.load(&job.id).unwrap().status, CloudJobStatus::Done); |
| 960 | } |
| 961 | |
| 962 | #[test] |
| 963 | fn cancel_during_running_tears_down_and_never_opens_the_pr() { |
| 964 | let temp = tempfile::tempdir().unwrap(); |
| 965 | let root = temp.path().join("jobs"); |
| 966 | let store = CloudJobStore::from_path(root.clone()); |
| 967 | let job = confirmed_job(&store); |
| 968 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 969 | // Cancel lands while the harness turn is in flight. |
| 970 | let cancel_root = root.clone(); |
| 971 | let cancel_id = job.id.clone(); |
| 972 | let canceled_seen = Arc::new(std::sync::atomic::AtomicBool::new(false)); |
| 973 | let seen = canceled_seen.clone(); |
| 974 | let mut launcher = RecordingLauncher::new("sandbox_runner_2", fixture_patch()); |
| 975 | launcher.hook = Some(Box::new(move |phase| { |
| 976 | if phase == "harness" && !seen.swap(true, std::sync::atomic::Ordering::SeqCst) { |
| 977 | let store = CloudJobStore::from_path(cancel_root.clone()); |
| 978 | let mut current = store.load(&cancel_id).unwrap(); |
| 979 | current.status = CloudJobStatus::Canceled; |
| 980 | store.save(¤t).unwrap(); |
| 981 | } |
| 982 | })); |
| 983 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 984 | |
| 985 | assert_eq!(finished.status, CloudJobStatus::Canceled); |
| 986 | assert!(finished.pr_url.is_none()); |
| 987 | assert!(finished.note.contains("Canceled mid-run")); |
| 988 | assert!(finished.note.contains("torn down")); |
| 989 | // The run stopped before collect and the forge never fired; teardown ran. |
| 990 | assert_eq!( |
| 991 | launcher.calls(), |
| 992 | vec!["create", "wait_ready", "clone", "harness", "teardown"] |
| 993 | ); |
| 994 | assert!(forge.opened().is_empty()); |
| 995 | assert!(canceled_seen.load(std::sync::atomic::Ordering::SeqCst)); |
| 996 | } |
| 997 | |
| 998 | #[test] |
| 999 | fn cancel_between_clone_and_harness_never_starts_the_turn() { |
| 1000 | let temp = tempfile::tempdir().unwrap(); |
| 1001 | let root = temp.path().join("jobs"); |
| 1002 | let store = CloudJobStore::from_path(root.clone()); |
| 1003 | let job = confirmed_job(&store); |
| 1004 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 1005 | let mut launcher = RecordingLauncher::new("sandbox_runner_clone_cancel", fixture_patch()); |
| 1006 | launcher.hook = Some(Box::new({ |
| 1007 | let cancel = raw_cancel_from_hook(root, job.id.clone()); |
| 1008 | move |phase| { |
| 1009 | if phase == "clone" { |
| 1010 | cancel(phase); |
| 1011 | } |
| 1012 | } |
| 1013 | })); |
| 1014 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 1015 | assert_eq!(finished.status, CloudJobStatus::Canceled); |
| 1016 | assert!(finished.pr_url.is_none()); |
| 1017 | let calls = launcher.calls(); |
| 1018 | assert!( |
| 1019 | calls.contains(&"clone".to_string()), |
| 1020 | "clone must have run: {calls:?}" |
| 1021 | ); |
| 1022 | assert!( |
| 1023 | !calls.contains(&"harness".to_string()), |
| 1024 | "harness must not run after a post-clone cancel: {calls:?}" |
| 1025 | ); |
| 1026 | assert!(forge.opened().is_empty()); |
| 1027 | } |
| 1028 | |
| 1029 | /// Cancels the job from inside a phase hook with a raw status flip (no) |
| 1030 | /// `finished_unix`), mirroring the reviewer's reproduction: cancel_job |
| 1031 | /// saves `canceled` while a phase is in flight. |
| 1032 | fn raw_cancel_from_hook(root: std::path::PathBuf, id: String) -> impl Fn(&str) + Send + Sync { |
| 1033 | move |_phase: &str| { |
| 1034 | let store = CloudJobStore::from_path(root.clone()); |
| 1035 | let mut current = store.load(&id).unwrap(); |
| 1036 | current.status = CloudJobStatus::Canceled; |
| 1037 | store.save(¤t).unwrap(); |
| 1038 | } |
| 1039 | } |
| 1040 | |
| 1041 | #[test] |
| 1042 | fn cancel_during_create_wins_over_the_post_create_save() { |
| 1043 | let temp = tempfile::tempdir().unwrap(); |
| 1044 | let root = temp.path().join("jobs"); |
| 1045 | let store = CloudJobStore::from_path(root.clone()); |
| 1046 | let job = confirmed_job(&store); |
| 1047 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 1048 | // The cancel lands while the create POST is in flight — before the |
| 1049 | // runner ever holds a receipt. The post-create phase save must |
| 1050 | // refuse to resurrect the run. |
| 1051 | let mut launcher = RecordingLauncher::new("sandbox_cancel_1", fixture_patch()); |
| 1052 | launcher.hook = Some(Box::new({ |
| 1053 | let cancel = raw_cancel_from_hook(root.clone(), job.id.clone()); |
| 1054 | move |phase| { |
| 1055 | if phase == "create" { |
| 1056 | cancel(phase); |
| 1057 | } |
| 1058 | } |
| 1059 | })); |
| 1060 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 1061 | |
| 1062 | assert_eq!(finished.status, CloudJobStatus::Canceled); |
| 1063 | assert!(finished.pr_url.is_none()); |
| 1064 | assert!(finished.finished_unix.is_some()); |
| 1065 | assert!(finished.note.contains("Canceled mid-run")); |
| 1066 | assert!(finished.note.contains("torn down")); |
| 1067 | // The run never reached readiness, the forge never fired, teardown ran. |
| 1068 | assert_eq!(launcher.calls(), vec!["create", "teardown"]); |
| 1069 | assert!(forge.opened().is_empty()); |
| 1070 | let persisted = store.load(&job.id).unwrap(); |
| 1071 | assert_eq!(persisted.status, CloudJobStatus::Canceled); |
| 1072 | assert!(persisted.finished_unix.is_some()); |
| 1073 | } |
| 1074 | |
| 1075 | #[test] |
| 1076 | fn cancel_during_collect_blocks_the_branch_raise_and_the_pr() { |
| 1077 | let temp = tempfile::tempdir().unwrap(); |
| 1078 | let root = temp.path().join("jobs"); |
| 1079 | let store = CloudJobStore::from_path(root.clone()); |
| 1080 | let job = confirmed_job(&store); |
| 1081 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 1082 | // The cancel lands while the patch is being collected; the |
| 1083 | // OpeningPr phase save must refuse, so the branch is never raised |
| 1084 | // and the PR never opened. |
| 1085 | let mut launcher = RecordingLauncher::new("sandbox_cancel_2", fixture_patch()); |
| 1086 | launcher.hook = Some(Box::new({ |
| 1087 | let cancel = raw_cancel_from_hook(root.clone(), job.id.clone()); |
| 1088 | move |phase| { |
| 1089 | if phase == "collect" { |
| 1090 | cancel(phase); |
| 1091 | } |
| 1092 | } |
| 1093 | })); |
| 1094 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 1095 | |
| 1096 | assert_eq!(finished.status, CloudJobStatus::Canceled); |
| 1097 | assert!(finished.pr_url.is_none()); |
| 1098 | assert!(finished.base_branch.is_none()); |
| 1099 | assert!(finished.finished_unix.is_some()); |
| 1100 | assert!(finished.note.contains("Canceled mid-run")); |
| 1101 | assert_eq!( |
| 1102 | launcher.calls(), |
| 1103 | vec![ |
| 1104 | "create", |
| 1105 | "wait_ready", |
| 1106 | "clone", |
| 1107 | "harness", |
| 1108 | "collect", |
| 1109 | "teardown" |
| 1110 | ] |
| 1111 | ); |
| 1112 | assert!(forge.opened().is_empty()); |
| 1113 | } |
| 1114 | |
| 1115 | /// Launcher whose harness step fails; earlier phases record normally. |
| 1116 | struct HarnessFailsLauncher { |
| 1117 | calls: Mutex<Vec<String>>, |
| 1118 | hook: Option<PhaseHook>, |
| 1119 | } |
| 1120 | |
| 1121 | impl HarnessFailsLauncher { |
| 1122 | fn note(&self, phase: &str) { |
| 1123 | if let Ok(mut calls) = self.calls.lock() { |
| 1124 | calls.push(phase.to_string()); |
| 1125 | } |
| 1126 | if let Some(hook) = self.hook.as_ref() { |
| 1127 | hook(phase); |
| 1128 | } |
| 1129 | } |
| 1130 | } |
| 1131 | |
| 1132 | impl DaytonaLauncher for HarnessFailsLauncher { |
| 1133 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 1134 | self.note("create"); |
| 1135 | Ok(SandboxReceipt { |
| 1136 | sandbox_id: "sandbox_err_1".to_string(), |
| 1137 | toolbox_url: None, |
| 1138 | }) |
| 1139 | } |
| 1140 | fn wait_ready(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 1141 | self.note("wait_ready"); |
| 1142 | Ok(()) |
| 1143 | } |
| 1144 | fn clone_repository(&self, _receipt: &SandboxReceipt, url: &str, path: &str) -> Result<()> { |
| 1145 | self.note("clone"); |
| 1146 | assert_eq!(path, SANDBOX_WORKSPACE); |
| 1147 | assert!(url.starts_with("https://")); |
| 1148 | Ok(()) |
| 1149 | } |
| 1150 | fn run_harness( |
| 1151 | &self, |
| 1152 | _receipt: &SandboxReceipt, |
| 1153 | _command: &HarnessCommand, |
| 1154 | ) -> Result<String> { |
| 1155 | self.note("harness"); |
| 1156 | bail!("harness exploded") |
| 1157 | } |
| 1158 | fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 1159 | self.note("teardown"); |
| 1160 | Ok(()) |
| 1161 | } |
| 1162 | } |
| 1163 | |
| 1164 | #[test] |
| 1165 | fn run_error_after_cancel_keeps_the_canceled_record() { |
| 1166 | let temp = tempfile::tempdir().unwrap(); |
| 1167 | let root = temp.path().join("jobs"); |
| 1168 | let store = CloudJobStore::from_path(root.clone()); |
| 1169 | let job = confirmed_job(&store); |
| 1170 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9"); |
| 1171 | // The cancel lands as the harness explodes: the failure arm must not |
| 1172 | // overwrite the user's `canceled` with `failed` — the error rides |
| 1173 | // along in the note. |
| 1174 | let mut launcher = HarnessFailsLauncher { |
| 1175 | calls: Mutex::new(Vec::new()), |
| 1176 | hook: None, |
| 1177 | }; |
| 1178 | launcher.hook = Some(Box::new({ |
| 1179 | let cancel = raw_cancel_from_hook(root.clone(), job.id.clone()); |
| 1180 | move |phase| { |
| 1181 | if phase == "harness" { |
| 1182 | cancel(phase); |
| 1183 | } |
| 1184 | } |
| 1185 | })); |
| 1186 | let error = run_confirmed_job(&store, &job.id, &launcher, &forge) |
| 1187 | .unwrap_err() |
| 1188 | .to_string(); |
| 1189 | assert!(error.contains("harness exploded"), "{error}"); |
| 1190 | |
| 1191 | let persisted = store.load(&job.id).unwrap(); |
| 1192 | assert_eq!( |
| 1193 | persisted.status, |
| 1194 | CloudJobStatus::Canceled, |
| 1195 | "a user cancel survives a later run error" |
| 1196 | ); |
| 1197 | assert!(persisted.note.contains("Run error after cancel")); |
| 1198 | assert!(persisted.note.contains("harness exploded")); |
| 1199 | assert!(persisted.refusal.is_none()); |
| 1200 | assert!(persisted.pr_url.is_none()); |
| 1201 | assert!(persisted.finished_unix.is_some()); |
| 1202 | assert!(forge.opened().is_empty()); |
| 1203 | let calls = launcher.calls.lock().unwrap().clone(); |
| 1204 | assert_eq!( |
| 1205 | calls, |
| 1206 | vec!["create", "wait_ready", "clone", "harness", "teardown"] |
| 1207 | ); |
| 1208 | } |
| 1209 | |
| 1210 | #[test] |
| 1211 | fn the_declared_harness_budget_fits_the_harness_client_budget() { |
| 1212 | let temp = tempfile::tempdir().unwrap(); |
| 1213 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1214 | let job = confirmed_job(&store); |
| 1215 | let command = harness_command(&job); |
| 1216 | assert_eq!(command.timeout_secs, HARNESS_TIMEOUT_SECS); |
| 1217 | assert!( |
| 1218 | cloud_dispatch::LiveDaytonaLauncher::harness_client_budget_secs(&command) |
| 1219 | >= u64::from(HARNESS_TIMEOUT_SECS), |
| 1220 | "the client that carries the harness turn must cover the declared hour" |
| 1221 | ); |
| 1222 | } |
| 1223 | |
| 1224 | #[test] |
| 1225 | fn sandbox_intent_is_persisted_before_the_create_post() { |
| 1226 | let temp = tempfile::tempdir().unwrap(); |
| 1227 | let root = temp.path().join("jobs"); |
| 1228 | let store = CloudJobStore::from_path(root.clone()); |
| 1229 | let job = confirmed_job(&store); |
| 1230 | // Observed from inside the create phase: the intent must already be |
| 1231 | // on disk, so a create whose response never arrives is still |
| 1232 | // reconcilable by label. |
| 1233 | let intent_root = root.clone(); |
| 1234 | let intent_id = job.id.clone(); |
| 1235 | let seen_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); |
| 1236 | let seen = seen_pending.clone(); |
| 1237 | let mut launcher = RecordingLauncher::new("sandbox_intent_1", fixture_patch()); |
| 1238 | launcher.hook = Some(Box::new(move |phase| { |
| 1239 | if phase == "create" { |
| 1240 | let store = CloudJobStore::from_path(intent_root.clone()); |
| 1241 | let current = store.load(&intent_id).unwrap(); |
| 1242 | assert!(current.sandbox_pending, "intent must precede the POST"); |
| 1243 | seen.store(true, std::sync::atomic::Ordering::SeqCst); |
| 1244 | } |
| 1245 | })); |
| 1246 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/11"); |
| 1247 | let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap(); |
| 1248 | |
| 1249 | assert!(seen_pending.load(std::sync::atomic::Ordering::SeqCst)); |
| 1250 | assert_eq!(finished.status, CloudJobStatus::Done); |
| 1251 | assert!(!finished.sandbox_pending, "intent clears once the id lands"); |
| 1252 | let persisted = store.load(&job.id).unwrap(); |
| 1253 | assert!(!persisted.sandbox_pending); |
| 1254 | assert_eq!(persisted.sandbox_id.as_deref(), Some("sandbox_intent_1")); |
| 1255 | } |
| 1256 | |
| 1257 | #[test] |
| 1258 | fn startup_reconcile_sweeps_stale_jobs_and_deletes_orphan_sandboxes() { |
| 1259 | let temp = tempfile::tempdir().unwrap(); |
| 1260 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1261 | let job = confirmed_job(&store); |
| 1262 | // Age the record past the stale threshold and park it mid-run, the |
| 1263 | // state a quit/crash would leave behind. |
| 1264 | let mut stale = store.load(&job.id).unwrap(); |
| 1265 | stale.status = CloudJobStatus::Running; |
| 1266 | stale.sandbox_id = Some("sandbox_orphan".to_string()); |
| 1267 | stale.created_unix = stale |
| 1268 | .created_unix |
| 1269 | .saturating_sub(crate::cloud_dispatch::STALE_ACTIVE_JOB_SECS + 120); |
| 1270 | store.save(&stale).unwrap(); |
| 1271 | // A sandbox labeled for a job that is not in the store at all. |
| 1272 | let launcher = RecordingLauncher::new("unused", fixture_patch()); |
| 1273 | *launcher.listed.lock().unwrap() = vec![crate::cloud_dispatch::LabeledSandbox { |
| 1274 | sandbox_id: "sandbox_ghost".to_string(), |
| 1275 | job_id: Some("cloud_0000000000000bad".to_string()), |
| 1276 | }]; |
| 1277 | |
| 1278 | let receipt = startup_reconcile(&store, &launcher); |
| 1279 | assert!( |
| 1280 | receipt.contains(&job.id), |
| 1281 | "receipt names the swept job: {receipt}" |
| 1282 | ); |
| 1283 | assert!( |
| 1284 | receipt.contains("sandbox_ghost"), |
| 1285 | "receipt names deletions: {receipt}" |
| 1286 | ); |
| 1287 | assert_eq!(store.load(&job.id).unwrap().status, CloudJobStatus::Failed); |
| 1288 | assert!(launcher.calls().contains(&"teardown".to_string())); |
| 1289 | assert!(launcher.calls().contains(&"list".to_string())); |
| 1290 | } |
| 1291 | |
| 1292 | #[test] |
| 1293 | fn only_confirmed_jobs_can_run() { |
| 1294 | let temp = tempfile::tempdir().unwrap(); |
| 1295 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1296 | let plan = plan_dispatch( |
| 1297 | &[GitRemote { |
| 1298 | name: "github".to_string(), |
| 1299 | url: "https://github.com/org/repo.git".to_string(), |
| 1300 | }], |
| 1301 | "unconfirmed work", |
| 1302 | Some(Forge::Github), |
| 1303 | Some("codewhale/cloud-unconfirmed"), |
| 1304 | ) |
| 1305 | .unwrap(); |
| 1306 | match execute_dispatch( |
| 1307 | &store, |
| 1308 | plan, |
| 1309 | false, |
| 1310 | &CredentialState::Present { |
| 1311 | source: CredentialSource::Env, |
| 1312 | }, |
| 1313 | &MachineTokenState::Present, |
| 1314 | ) |
| 1315 | .unwrap() |
| 1316 | { |
| 1317 | DispatchOutcome::Proposal(job) => { |
| 1318 | let launcher = RecordingLauncher::new("never", fixture_patch()); |
| 1319 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/1"); |
| 1320 | let error = run_confirmed_job(&store, &job.id, &launcher, &forge) |
| 1321 | .unwrap_err() |
| 1322 | .to_string(); |
| 1323 | assert!(error.contains("confirm it first"), "{error}"); |
| 1324 | assert!(launcher.calls().is_empty()); |
| 1325 | } |
| 1326 | other => panic!("expected proposal, got {other:?}"), |
| 1327 | } |
| 1328 | } |
| 1329 | |
| 1330 | #[test] |
| 1331 | fn launch_failure_fails_closed_with_sanitized_note() { |
| 1332 | let temp = tempfile::tempdir().unwrap(); |
| 1333 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1334 | let job = confirmed_job(&store); |
| 1335 | struct FailingLauncher; |
| 1336 | impl DaytonaLauncher for FailingLauncher { |
| 1337 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 1338 | bail!("create exploded\u{1}") |
| 1339 | } |
| 1340 | } |
| 1341 | let forge = RecordingForgePr::new("https://github.com/org/repo/pull/2"); |
| 1342 | let error = run_confirmed_job(&store, &job.id, &FailingLauncher, &forge).unwrap_err(); |
| 1343 | assert!(error.to_string().contains("create exploded")); |
| 1344 | let failed = store.load(&job.id).unwrap(); |
| 1345 | assert_eq!(failed.status, CloudJobStatus::Failed); |
| 1346 | assert!(failed.pr_url.is_none()); |
| 1347 | assert!(failed.note.contains("failed closed")); |
| 1348 | assert!(!failed.note.contains('\u{1}')); |
| 1349 | assert!(forge.opened().is_empty()); |
| 1350 | } |
| 1351 | |
| 1352 | #[test] |
| 1353 | fn pr_title_and_body_are_truthful_unbranded_and_carry_receipts() { |
| 1354 | let temp = tempfile::tempdir().unwrap(); |
| 1355 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1356 | let job = confirmed_job(&store); |
| 1357 | let patch = fixture_patch(); |
| 1358 | let title = compose_pr_title(&job, &patch); |
| 1359 | assert_eq!(title, "codewhale cloud: Fix the flaky dispatch test"); |
| 1360 | let body = compose_pr_body(&job, &patch); |
| 1361 | assert!(body.contains("Codewhale cloud agent")); |
| 1362 | assert!(body.contains("What the agent did")); |
| 1363 | assert!(body.contains("Fix the flaky dispatch test")); |
| 1364 | assert!(body.contains("open a PR that fixes the flake")); |
| 1365 | assert!(body.contains(&job.id)); |
| 1366 | assert!(body.contains("(pending)")); |
| 1367 | assert!(body.contains("codewhale/cloud-runner-test")); |
| 1368 | assert!(body.contains("`main`")); |
| 1369 | assert!(body.contains("abc123def4567")); |
| 1370 | assert!(body.contains("No-Issue: cloud dispatch")); |
| 1371 | for banned in ["Daytona", "daytona"] { |
| 1372 | assert!( |
| 1373 | !body.contains(banned), |
| 1374 | "body must not brand the sandbox: {banned}" |
| 1375 | ); |
| 1376 | } |
| 1377 | } |
| 1378 | |
| 1379 | #[test] |
| 1380 | fn harness_command_is_the_standard_one_shot_entry() { |
| 1381 | let temp = tempfile::tempdir().unwrap(); |
| 1382 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1383 | let job = confirmed_job(&store); |
| 1384 | let command = harness_command(&job); |
| 1385 | assert_eq!( |
| 1386 | command.argv, |
| 1387 | vec![ |
| 1388 | "codewhale".to_string(), |
| 1389 | "exec".to_string(), |
| 1390 | "--auto".to_string(), |
| 1391 | job.prompt.clone(), |
| 1392 | ] |
| 1393 | ); |
| 1394 | assert_eq!(command.cwd, SANDBOX_WORKSPACE); |
| 1395 | } |
| 1396 | |
| 1397 | #[test] |
| 1398 | fn gh_argv_and_forge_endpoints_pin_the_pr_shapes() { |
| 1399 | let argv = gh_pr_create_argv( |
| 1400 | "org/repo", |
| 1401 | "main", |
| 1402 | "codewhale/cloud-1", |
| 1403 | "title", |
| 1404 | "/tmp/body.md", |
| 1405 | ); |
| 1406 | assert_eq!( |
| 1407 | argv, |
| 1408 | vec![ |
| 1409 | "pr", |
| 1410 | "create", |
| 1411 | "--repo", |
| 1412 | "org/repo", |
| 1413 | "--base", |
| 1414 | "main", |
| 1415 | "--head", |
| 1416 | "codewhale/cloud-1", |
| 1417 | "--title", |
| 1418 | "title", |
| 1419 | "--body-file", |
| 1420 | "/tmp/body.md", |
| 1421 | ] |
| 1422 | ); |
| 1423 | assert_eq!( |
| 1424 | gitee_pr_url("org/repo"), |
| 1425 | "https://gitee.com/api/v5/repos/org/repo/pulls" |
| 1426 | ); |
| 1427 | assert_eq!( |
| 1428 | cnb_pr_url("org/repo"), |
| 1429 | "https://api.cnb.cool/org/repo/-/pulls" |
| 1430 | ); |
| 1431 | } |
| 1432 | |
| 1433 | #[test] |
| 1434 | fn forge_slug_parses_https_and_ssh_and_rejects_foreign_hosts() { |
| 1435 | assert_eq!( |
| 1436 | forge_slug(Forge::Github, "https://github.com/Hmbown/CodeWhale.git"), |
| 1437 | Some("Hmbown/CodeWhale".to_string()) |
| 1438 | ); |
| 1439 | assert_eq!( |
| 1440 | forge_slug(Forge::Github, "git@github.com:Hmbown/CodeWhale.git"), |
| 1441 | Some("Hmbown/CodeWhale".to_string()) |
| 1442 | ); |
| 1443 | assert_eq!( |
| 1444 | forge_slug(Forge::Cnb, "https://cnb.cool/codewhale.net/codewhale.git"), |
| 1445 | Some("codewhale.net/codewhale".to_string()) |
| 1446 | ); |
| 1447 | assert_eq!( |
| 1448 | forge_slug(Forge::Gitee, "https://gitee.com/org/repo.git"), |
| 1449 | Some("org/repo".to_string()) |
| 1450 | ); |
| 1451 | assert_eq!( |
| 1452 | forge_slug(Forge::Github, "https://gitee.com/org/repo.git"), |
| 1453 | None |
| 1454 | ); |
| 1455 | assert_eq!( |
| 1456 | forge_slug(Forge::Cnb, "https://example.test/org/repo.git"), |
| 1457 | None |
| 1458 | ); |
| 1459 | assert_eq!( |
| 1460 | forge_slug(Forge::Github, "https://github.com/only-repo"), |
| 1461 | None |
| 1462 | ); |
| 1463 | } |
| 1464 | |
| 1465 | #[test] |
| 1466 | fn push_branch_refuses_non_forge_remotes() { |
| 1467 | let error = |
| 1468 | push_branch(Path::new("."), "https://example.test/org/repo.git", "b").unwrap_err(); |
| 1469 | let text = error.to_string(); |
| 1470 | assert!( |
| 1471 | text.contains("non-forge") || text.contains("not a supported forge"), |
| 1472 | "{text}" |
| 1473 | ); |
| 1474 | } |
| 1475 | |
| 1476 | #[test] |
| 1477 | fn push_branch_refuses_forge_default_branch_names() { |
| 1478 | let error = |
| 1479 | push_branch(Path::new("."), "https://github.com/org/repo.git", "main").unwrap_err(); |
| 1480 | assert!(error.to_string().contains("default branch"), "{}", error); |
| 1481 | } |
| 1482 | |
| 1483 | #[test] |
| 1484 | fn prepare_branch_rejects_leading_dash_remote_before_clone() { |
| 1485 | let temp = tempfile::tempdir().unwrap(); |
| 1486 | let mut job = confirmed_job(&CloudJobStore::from_path(temp.path().join("jobs"))); |
| 1487 | job.remote_url = "--upload-pack=evil".to_string(); |
| 1488 | let error = prepare_branch(&job, &fixture_patch(), temp.path()).unwrap_err(); |
| 1489 | assert!( |
| 1490 | error.to_string().contains("must not start with '-'"), |
| 1491 | "{error}" |
| 1492 | ); |
| 1493 | } |
| 1494 | |
| 1495 | #[test] |
| 1496 | fn compose_pr_body_head_is_the_pushed_sha_not_the_sandbox_sha() { |
| 1497 | let job = CloudJob { |
| 1498 | id: "cloud_00000000000000cc".to_string(), |
| 1499 | kind: "cloud".to_string(), |
| 1500 | status: CloudJobStatus::OpeningPr, |
| 1501 | prompt: "fix".to_string(), |
| 1502 | forge: Forge::Github, |
| 1503 | remote_name: "github".to_string(), |
| 1504 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 1505 | branch: "codewhale/cloud-b".to_string(), |
| 1506 | confirmed: true, |
| 1507 | sandbox_id: Some("sandbox".to_string()), |
| 1508 | pr_url: None, |
| 1509 | refusal: None, |
| 1510 | note: "n".to_string(), |
| 1511 | created_unix: 1, |
| 1512 | base_branch: None, |
| 1513 | head_sha: None, |
| 1514 | agent_summary: None, |
| 1515 | finished_unix: None, |
| 1516 | sandbox_pending: false, |
| 1517 | }; |
| 1518 | let patch = fixture_patch(); |
| 1519 | let body = compose_pr_body_for_head(&job, &patch, "pushedsha0000000000000000000000000001"); |
| 1520 | assert!(body.contains("Head: `pushedsha0000000000000000000000000001`")); |
| 1521 | assert!( |
| 1522 | !body.contains(&format!("Head: `{}`", patch.head_sha)), |
| 1523 | "sandbox sha must not be the receipt Head" |
| 1524 | ); |
| 1525 | } |
| 1526 | |
| 1527 | /// Local-fixture integration: real git, no network — branch preparation |
| 1528 | /// applies a real format-patch, and a diverged push fails without force. |
| 1529 | #[test] |
| 1530 | fn prepare_branch_applies_a_real_patch_locally() { |
| 1531 | let temp = tempfile::tempdir().unwrap(); |
| 1532 | let origin = temp.path().join("origin.git"); |
| 1533 | git( |
| 1534 | None, |
| 1535 | &[ |
| 1536 | "init", |
| 1537 | "--bare", |
| 1538 | "--quiet", |
| 1539 | "--initial-branch=main", |
| 1540 | &origin.to_string_lossy(), |
| 1541 | ], |
| 1542 | ) |
| 1543 | .unwrap(); |
| 1544 | let seed = temp.path().join("seed"); |
| 1545 | git( |
| 1546 | None, |
| 1547 | &[ |
| 1548 | "clone", |
| 1549 | "--quiet", |
| 1550 | &origin.to_string_lossy(), |
| 1551 | &seed.to_string_lossy(), |
| 1552 | ], |
| 1553 | ) |
| 1554 | .unwrap(); |
| 1555 | git(Some(&seed), &["config", "user.name", "Seeder"]).unwrap(); |
| 1556 | git(Some(&seed), &["config", "user.email", "seed@example.test"]).unwrap(); |
| 1557 | std::fs::write(seed.join("file.txt"), "base\n").unwrap(); |
| 1558 | git(Some(&seed), &["add", "."]).unwrap(); |
| 1559 | git(Some(&seed), &["commit", "--quiet", "-m", "base"]).unwrap(); |
| 1560 | git( |
| 1561 | Some(&seed), |
| 1562 | &[ |
| 1563 | "push", |
| 1564 | "--quiet", |
| 1565 | &origin.to_string_lossy(), |
| 1566 | "HEAD:refs/heads/main", |
| 1567 | ], |
| 1568 | ) |
| 1569 | .unwrap(); |
| 1570 | |
| 1571 | // The agent's work product: a real patch. |
| 1572 | std::fs::write(seed.join("file.txt"), "base\nagent change\n").unwrap(); |
| 1573 | git(Some(&seed), &["add", "."]).unwrap(); |
| 1574 | git(Some(&seed), &["commit", "--quiet", "-m", "agent work"]).unwrap(); |
| 1575 | let patch_text = git(Some(&seed), &["format-patch", "HEAD~1", "--stdout"]).unwrap(); |
| 1576 | |
| 1577 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 1578 | let mut job = confirmed_job(&store); |
| 1579 | // Point at the local fixture so preparation is offline. |
| 1580 | job.remote_url = origin.to_string_lossy().to_string(); |
| 1581 | let patch = PatchReceipt { |
| 1582 | base_branch: "main".to_string(), |
| 1583 | head_sha: "fixture".to_string(), |
| 1584 | summary: "agent work".to_string(), |
| 1585 | patch: patch_text, |
| 1586 | }; |
| 1587 | let dir = temp.path().join("agent"); |
| 1588 | std::fs::create_dir_all(&dir).unwrap(); |
| 1589 | let head = prepare_branch(&job, &patch, &dir).unwrap(); |
| 1590 | assert_eq!( |
| 1591 | head.len(), |
| 1592 | 40, |
| 1593 | "prepare_branch returns the applied head sha" |
| 1594 | ); |
| 1595 | |
| 1596 | // A diverged push onto the same branch must fail: no force, ever. |
| 1597 | let repo = dir.join("repo"); |
| 1598 | std::fs::write(repo.join("other.txt"), "y\n").unwrap(); |
| 1599 | git(Some(&repo), &["add", "."]).unwrap(); |
| 1600 | git(Some(&repo), &["commit", "--quiet", "-m", "diverges"]).unwrap(); |
| 1601 | // Seed the remote branch at the "diverges" commit. |
| 1602 | git( |
| 1603 | Some(&repo), |
| 1604 | &[ |
| 1605 | "push", |
| 1606 | "--quiet", |
| 1607 | &origin.to_string_lossy(), |
| 1608 | "HEAD:refs/heads/codewhale/cloud-runner-test", |
| 1609 | ], |
| 1610 | ) |
| 1611 | .unwrap(); |
| 1612 | // Rewind and build a sibling commit: same parent, different content. |
| 1613 | git(Some(&repo), &["reset", "--quiet", "--hard", "HEAD~1"]).unwrap(); |
| 1614 | std::fs::write(repo.join("other2.txt"), "z\n").unwrap(); |
| 1615 | git(Some(&repo), &["add", "."]).unwrap(); |
| 1616 | git( |
| 1617 | Some(&repo), |
| 1618 | &["commit", "--quiet", "-m", "diverges differently"], |
| 1619 | ) |
| 1620 | .unwrap(); |
| 1621 | let diverged = git( |
| 1622 | Some(&repo), |
| 1623 | &[ |
| 1624 | "push", |
| 1625 | "--quiet", |
| 1626 | &origin.to_string_lossy(), |
| 1627 | "HEAD:refs/heads/codewhale/cloud-runner-test", |
| 1628 | ], |
| 1629 | ); |
| 1630 | assert!(diverged.is_err(), "a diverged push must fail without force"); |
| 1631 | } |
| 1632 | |
| 1633 | #[test] |
| 1634 | fn compose_pr_body_bounds_oversized_prompts() { |
| 1635 | let job = CloudJob { |
| 1636 | id: "cloud_00000000000000bb".to_string(), |
| 1637 | kind: "cloud".to_string(), |
| 1638 | status: CloudJobStatus::OpeningPr, |
| 1639 | prompt: "p".repeat(9_000), |
| 1640 | forge: Forge::Github, |
| 1641 | remote_name: "github".to_string(), |
| 1642 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 1643 | branch: "codewhale/cloud-b".to_string(), |
| 1644 | confirmed: true, |
| 1645 | sandbox_id: Some("sandbox".to_string()), |
| 1646 | pr_url: None, |
| 1647 | refusal: None, |
| 1648 | note: "n".to_string(), |
| 1649 | created_unix: 1, |
| 1650 | base_branch: None, |
| 1651 | head_sha: None, |
| 1652 | agent_summary: None, |
| 1653 | finished_unix: None, |
| 1654 | sandbox_pending: false, |
| 1655 | }; |
| 1656 | let body = compose_pr_body(&job, &fixture_patch()); |
| 1657 | assert!(body.chars().count() <= MAX_BODY_CHARS); |
| 1658 | } |
| 1659 | |
| 1660 | #[test] |
| 1661 | fn one_line_flattens_control_characters() { |
| 1662 | assert_eq!(one_line("a\nb", 10), "a\nb"); |
| 1663 | assert_eq!(one_line("a\u{1}b", 10), "a b"); |
| 1664 | assert_eq!(one_line("abcdef", 3), "ab…"); |
| 1665 | } |
| 1666 | } |
| 1667 |