| 1 | //! First-class Daytona cloud-agent offload: `codewhale dispatch`. |
| 2 | |
| 3 | use std::io::{self, Write}; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | use anyhow::{Result, bail}; |
| 7 | use clap::{Args, ValueEnum}; |
| 8 | use codewhale_tui::cloud_dispatch::{ |
| 9 | CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job, |
| 10 | discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job, |
| 11 | format_job_list, format_status, plan_dispatch, |
| 12 | }; |
| 13 | use codewhale_tui::dispatch_runner::spawn_confirmed_runner; |
| 14 | |
| 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] |
| 16 | enum ForgeArg { |
| 17 | Github, |
| 18 | Cnb, |
| 19 | Gitee, |
| 20 | } |
| 21 | |
| 22 | impl From<ForgeArg> for Forge { |
| 23 | fn from(value: ForgeArg) -> Self { |
| 24 | match value { |
| 25 | ForgeArg::Github => Forge::Github, |
| 26 | ForgeArg::Cnb => Forge::Cnb, |
| 27 | ForgeArg::Gitee => Forge::Gitee, |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | #[derive(Debug, Args)] |
| 33 | pub(crate) struct DispatchArgs { |
| 34 | /// Task for the remote agent. Required unless listing or inspecting a job. |
| 35 | #[arg(value_name = "PROMPT")] |
| 36 | prompt: Vec<String>, |
| 37 | /// Forge that should receive the branch and PR: github, cnb, or gitee. |
| 38 | #[arg(long, value_enum)] |
| 39 | remote: Option<ForgeArg>, |
| 40 | /// Branch the remote agent will raise (default: codewhale/cloud-<unix>). |
| 41 | #[arg(long)] |
| 42 | branch: Option<String>, |
| 43 | /// Required to create Codewhale cloud-agent spend or push. Without this, only a proposal is written. |
| 44 | #[arg(long)] |
| 45 | confirm: bool, |
| 46 | /// Show remotes and whether Codewhale cloud-agent credentials are present (never prints secrets). |
| 47 | #[arg(long)] |
| 48 | status: bool, |
| 49 | /// List first-class cloud jobs (same kind shown by `/jobs`). |
| 50 | #[arg(long)] |
| 51 | list: bool, |
| 52 | /// Inspect one cloud job. |
| 53 | #[arg(long, value_name = "ID")] |
| 54 | show: Option<String>, |
| 55 | /// Cancel one cloud job. |
| 56 | #[arg(long, value_name = "ID")] |
| 57 | cancel: Option<String>, |
| 58 | /// Workspace whose git remotes are classified (default: current directory). |
| 59 | #[arg(long)] |
| 60 | cwd: Option<PathBuf>, |
| 61 | } |
| 62 | |
| 63 | pub(crate) fn run(args: DispatchArgs) -> Result<()> { |
| 64 | let mut out = io::stdout().lock(); |
| 65 | run_with(args, &mut out) |
| 66 | } |
| 67 | |
| 68 | fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> { |
| 69 | if [ |
| 70 | args.status, |
| 71 | args.list, |
| 72 | args.show.is_some(), |
| 73 | args.cancel.is_some(), |
| 74 | !args.prompt.is_empty(), |
| 75 | ] |
| 76 | .iter() |
| 77 | .filter(|flag| **flag) |
| 78 | .count() |
| 79 | > 1 |
| 80 | { |
| 81 | bail!("Use one of: a prompt, --status, --list, --show <id>, or --cancel <id>."); |
| 82 | } |
| 83 | |
| 84 | let workspace = args |
| 85 | .cwd |
| 86 | .clone() |
| 87 | .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); |
| 88 | let remotes = discover_remotes(&workspace); |
| 89 | let credentials = discover_credentials(); |
| 90 | let store = CloudJobStore::from_env()?; |
| 91 | |
| 92 | if args.status |
| 93 | || (args.prompt.is_empty() && args.show.is_none() && args.cancel.is_none() && !args.list) |
| 94 | { |
| 95 | writeln!( |
| 96 | out, |
| 97 | "{}", |
| 98 | format_status(&remotes, &credentials, &recent_jobs(&store)) |
| 99 | )?; |
| 100 | return Ok(()); |
| 101 | } |
| 102 | if args.list { |
| 103 | writeln!(out, "{}", format_job_list(&store.list()?))?; |
| 104 | return Ok(()); |
| 105 | } |
| 106 | if let Some(id) = args.show.as_deref() { |
| 107 | writeln!(out, "{}", format_job(&store.load(id)?))?; |
| 108 | return Ok(()); |
| 109 | } |
| 110 | if let Some(id) = args.cancel.as_deref() { |
| 111 | writeln!( |
| 112 | out, |
| 113 | "{}", |
| 114 | format_job(&cancel_job(&store, id, &LiveDaytonaLauncher)?) |
| 115 | )?; |
| 116 | return Ok(()); |
| 117 | } |
| 118 | |
| 119 | let prompt = args.prompt.join(" "); |
| 120 | if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 { |
| 121 | let outcome = confirm_job( |
| 122 | &store, |
| 123 | prompt.trim(), |
| 124 | &credentials, |
| 125 | &discover_machine_token(), |
| 126 | )?; |
| 127 | let runner = spawn_accepted(&store, &outcome); |
| 128 | write_outcome(out, outcome)?; |
| 129 | return join_runner(out, &store, prompt.trim(), runner); |
| 130 | } |
| 131 | |
| 132 | let plan = plan_dispatch( |
| 133 | &remotes, |
| 134 | &prompt, |
| 135 | args.remote.map(Forge::from), |
| 136 | args.branch.as_deref(), |
| 137 | )?; |
| 138 | let outcome = execute_dispatch( |
| 139 | &store, |
| 140 | plan, |
| 141 | args.confirm, |
| 142 | &credentials, |
| 143 | &discover_machine_token(), |
| 144 | )?; |
| 145 | let runner = spawn_accepted(&store, &outcome); |
| 146 | let job_id = outcome_job_id(&outcome).unwrap_or_default(); |
| 147 | write_outcome(out, outcome)?; |
| 148 | join_runner(out, &store, &job_id, runner) |
| 149 | } |
| 150 | |
| 151 | /// The CLI stays attached to a confirmed run: the card prints immediately, |
| 152 | /// then the process waits for the runner so a paid sandbox is never |
| 153 | /// orphaned by an early exit. Ctrl-C exits the wait; the job stays recorded |
| 154 | /// and `--cancel` tears the sandbox down. |
| 155 | fn join_runner<W: Write>( |
| 156 | out: &mut W, |
| 157 | store: &CloudJobStore, |
| 158 | id: &str, |
| 159 | runner: Option<std::thread::JoinHandle<()>>, |
| 160 | ) -> Result<()> { |
| 161 | if let Some(runner) = runner { |
| 162 | runner |
| 163 | .join() |
| 164 | .map_err(|_| anyhow::anyhow!("the cloud agent runner panicked"))?; |
| 165 | if !id.is_empty() |
| 166 | && let Ok(job) = store.load(id) |
| 167 | { |
| 168 | writeln!(out, "{}", format_job(&job))?; |
| 169 | } |
| 170 | } |
| 171 | Ok(()) |
| 172 | } |
| 173 | |
| 174 | fn outcome_job_id(outcome: &DispatchOutcome) -> Option<String> { |
| 175 | match outcome { |
| 176 | DispatchOutcome::Proposal(job) |
| 177 | | DispatchOutcome::Refused(job) |
| 178 | | DispatchOutcome::Accepted(job) => Some(job.id.clone()), |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /// Newest jobs for the status card's receipts section (best effort — an |
| 183 | /// unreadable store must not hide the card). |
| 184 | fn recent_jobs(store: &CloudJobStore) -> Vec<codewhale_tui::cloud_dispatch::CloudJob> { |
| 185 | store |
| 186 | .list() |
| 187 | .unwrap_or_default() |
| 188 | .into_iter() |
| 189 | .take(5) |
| 190 | .collect() |
| 191 | } |
| 192 | |
| 193 | /// Start the background runner for a just-accepted confirm. The sandbox, |
| 194 | /// harness turn, branch push, PR open, and teardown all happen there. |
| 195 | fn spawn_accepted( |
| 196 | store: &CloudJobStore, |
| 197 | outcome: &DispatchOutcome, |
| 198 | ) -> Option<std::thread::JoinHandle<()>> { |
| 199 | match outcome { |
| 200 | DispatchOutcome::Accepted(job) => spawn_confirmed_runner(store.clone(), job.id.clone()), |
| 201 | _ => None, |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | fn write_outcome<W: Write>(out: &mut W, outcome: DispatchOutcome) -> Result<()> { |
| 206 | match outcome { |
| 207 | DispatchOutcome::Proposal(job) | DispatchOutcome::Accepted(job) => { |
| 208 | writeln!(out, "{}", format_job(&job))?; |
| 209 | Ok(()) |
| 210 | } |
| 211 | DispatchOutcome::Refused(job) => { |
| 212 | writeln!(out, "{}", format_job(&job))?; |
| 213 | bail!("{}", job.note); |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | #[cfg(test)] |
| 219 | mod tests { |
| 220 | use super::*; |
| 221 | use crate::{Cli, Commands}; |
| 222 | use clap::Parser; |
| 223 | |
| 224 | fn args(argv: &[&str]) -> DispatchArgs { |
| 225 | let cli = Cli::try_parse_from(argv).unwrap(); |
| 226 | let Some(Commands::Dispatch(args)) = cli.command else { |
| 227 | panic!("expected dispatch command"); |
| 228 | }; |
| 229 | args |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn parses_the_obvious_dispatch_command() { |
| 234 | let parsed = args(&[ |
| 235 | "codewhale", |
| 236 | "dispatch", |
| 237 | "fix", |
| 238 | "the", |
| 239 | "flake", |
| 240 | "--remote", |
| 241 | "github", |
| 242 | ]); |
| 243 | assert_eq!(parsed.prompt, ["fix", "the", "flake"]); |
| 244 | assert_eq!(parsed.remote, Some(ForgeArg::Github)); |
| 245 | assert!(!parsed.confirm); |
| 246 | assert!(args(&["codewhale", "cloud-agent", "--status"]).status); |
| 247 | assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "gitlab"]).is_err()); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn refused_confirmation_is_a_nonzero_error() { |
| 252 | use codewhale_tui::cloud_dispatch::{CloudJob, CloudJobStatus}; |
| 253 | let job = CloudJob { |
| 254 | id: "cloud_00000000000000dd".to_string(), |
| 255 | kind: "cloud".to_string(), |
| 256 | status: CloudJobStatus::Refused, |
| 257 | prompt: "fix".to_string(), |
| 258 | forge: Forge::Github, |
| 259 | remote_name: "github".to_string(), |
| 260 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 261 | branch: "codewhale/cloud-x".to_string(), |
| 262 | confirmed: true, |
| 263 | sandbox_id: None, |
| 264 | pr_url: None, |
| 265 | refusal: Some("no credentials".to_string()), |
| 266 | note: "Refused: cloud agents are not available.".to_string(), |
| 267 | created_unix: 1, |
| 268 | base_branch: None, |
| 269 | head_sha: None, |
| 270 | agent_summary: None, |
| 271 | finished_unix: Some(1), |
| 272 | sandbox_pending: false, |
| 273 | }; |
| 274 | let error = write_outcome(&mut Vec::new(), DispatchOutcome::Refused(job)).unwrap_err(); |
| 275 | assert!( |
| 276 | error.to_string().contains("Refused"), |
| 277 | "refused confirmations must not exit 0: {error}" |
| 278 | ); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn status_is_fail_closed_and_never_prints_secrets() { |
| 283 | let temp = tempfile::tempdir().unwrap(); |
| 284 | let mut output = Vec::new(); |
| 285 | run_with( |
| 286 | DispatchArgs { |
| 287 | prompt: Vec::new(), |
| 288 | remote: None, |
| 289 | branch: None, |
| 290 | confirm: false, |
| 291 | status: true, |
| 292 | list: false, |
| 293 | show: None, |
| 294 | cancel: None, |
| 295 | cwd: Some(temp.path().to_path_buf()), |
| 296 | }, |
| 297 | &mut output, |
| 298 | ) |
| 299 | .unwrap(); |
| 300 | let text = String::from_utf8(output).unwrap(); |
| 301 | assert!(text.contains("Codewhale cloud dispatch")); |
| 302 | assert!(!text.contains("Daytona")); |
| 303 | assert!(!text.contains("sk-")); |
| 304 | assert!(!text.contains("Bearer")); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn rendered_help_carries_no_provider_brand() { |
| 309 | use clap::CommandFactory; |
| 310 | let help = Cli::command() |
| 311 | .find_subcommand_mut("dispatch") |
| 312 | .expect("dispatch subcommand exists") |
| 313 | .render_help() |
| 314 | .to_string(); |
| 315 | // The reworded cloud-agent copy must actually land in --help… |
| 316 | assert!(help.contains("cloud-agent"), "{help}"); |
| 317 | assert!(help.contains("--confirm"), "{help}"); |
| 318 | // …and no provider brand may leak into it. |
| 319 | for banned in ["Daytona", "daytona"] { |
| 320 | assert!( |
| 321 | !help.contains(banned), |
| 322 | "--help must not brand the operator: {banned}" |
| 323 | ); |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 |