返回 CodeWhale
dispatch.rs
根目录 / crates / cli / src / dispatch.rs
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
327 lines RUST