| 1 | //! `/dispatch` — first-class Codewhale cloud-agent offload. |
| 2 | |
| 3 | use codewhale_command_contract::facets::CommandWorkspaceContext; |
| 4 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 5 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 6 | |
| 7 | use crate::cloud_dispatch::{ |
| 8 | CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job, |
| 9 | discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job, |
| 10 | format_job_list, format_status, plan_dispatch, |
| 11 | }; |
| 12 | use crate::commands::CommandResult; |
| 13 | use crate::dispatch_runner::spawn_confirmed_runner; |
| 14 | |
| 15 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 16 | name: "dispatch", |
| 17 | aliases: &["cloud-agent", "cloud-dispatch"], |
| 18 | usage: "/dispatch [list|show <id>|confirm <id>|cancel <id>|<prompt> [--remote github|cnb|gitee]]", |
| 19 | description_key: "cmd_dispatch_description", |
| 20 | }; |
| 21 | |
| 22 | pub(in crate::commands) struct DispatchCmd; |
| 23 | |
| 24 | impl RegisterCommand<CommandResult> for DispatchCmd { |
| 25 | fn info() -> &'static CommandInfo { |
| 26 | &COMMAND_INFO |
| 27 | } |
| 28 | |
| 29 | fn handler() -> CommandHandler<CommandResult> { |
| 30 | CommandHandler::Contextual { |
| 31 | capabilities: codewhale_command_contract::handler::CommandCapabilities::WORKSPACE, |
| 32 | handler: dispatch_contextual, |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 38 | let mut parts = contexts.into_parts(); |
| 39 | let Some(workspace) = parts.workspace.as_deref_mut() else { |
| 40 | return CommandResult::error("Command capability unavailable: workspace"); |
| 41 | }; |
| 42 | dispatch(workspace, arg) |
| 43 | } |
| 44 | |
| 45 | fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) -> CommandResult { |
| 46 | let raw = args.unwrap_or("").trim(); |
| 47 | let store = match CloudJobStore::from_env() { |
| 48 | Ok(store) => store, |
| 49 | Err(error) => return CommandResult::error(error.to_string()), |
| 50 | }; |
| 51 | if raw.is_empty() { |
| 52 | let recent = store.list().unwrap_or_default(); |
| 53 | let recent: Vec<_> = recent.into_iter().take(5).collect(); |
| 54 | return CommandResult::message(format_status( |
| 55 | &discover_remotes(&workspace.workspace()), |
| 56 | &discover_credentials(), |
| 57 | &recent, |
| 58 | )); |
| 59 | } |
| 60 | |
| 61 | let mut parts = raw.splitn(2, char::is_whitespace); |
| 62 | let verb = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 63 | let rest = parts.next().map(str::trim).unwrap_or(""); |
| 64 | |
| 65 | match verb.as_str() { |
| 66 | "list" => match store.list() { |
| 67 | Ok(jobs) => CommandResult::message(format_job_list(&jobs)), |
| 68 | Err(error) => CommandResult::error(error.to_string()), |
| 69 | }, |
| 70 | "show" | "inspect" => { |
| 71 | if rest.is_empty() { |
| 72 | return CommandResult::error("Usage: /dispatch show <id>"); |
| 73 | } |
| 74 | match store.load(rest) { |
| 75 | Ok(job) => CommandResult::message(format_job(&job)), |
| 76 | Err(error) => CommandResult::error(error.to_string()), |
| 77 | } |
| 78 | } |
| 79 | "confirm" => { |
| 80 | if rest.is_empty() { |
| 81 | return CommandResult::error("Usage: /dispatch confirm <id>"); |
| 82 | } |
| 83 | match confirm_job( |
| 84 | &store, |
| 85 | rest, |
| 86 | &discover_credentials(), |
| 87 | &discover_machine_token(), |
| 88 | ) { |
| 89 | Ok(outcome) => { |
| 90 | if let DispatchOutcome::Accepted(job) = &outcome { |
| 91 | // Detached: the TUI stays responsive and the job |
| 92 | // record streams progress; `/dispatch cancel` tears |
| 93 | // the sandbox down at any time. |
| 94 | spawn_confirmed_runner(store.clone(), job.id.clone()); |
| 95 | } |
| 96 | CommandResult::message(outcome_message(&outcome)) |
| 97 | } |
| 98 | Err(error) => CommandResult::error(error.to_string()), |
| 99 | } |
| 100 | } |
| 101 | "cancel" | "kill" | "stop" => { |
| 102 | if rest.is_empty() { |
| 103 | return CommandResult::error("Usage: /dispatch cancel <id>"); |
| 104 | } |
| 105 | match cancel_job(&store, rest, &LiveDaytonaLauncher) { |
| 106 | Ok(job) => CommandResult::message(format_job(&job)), |
| 107 | Err(error) => CommandResult::error(error.to_string()), |
| 108 | } |
| 109 | } |
| 110 | _ => propose_or_run(workspace, &store, raw, false), |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | fn propose_or_run( |
| 115 | workspace: &mut dyn CommandWorkspaceContext, |
| 116 | store: &CloudJobStore, |
| 117 | raw: &str, |
| 118 | confirm: bool, |
| 119 | ) -> CommandResult { |
| 120 | let (prompt, requested) = match split_prompt_and_remote(raw) { |
| 121 | Ok(parsed) => parsed, |
| 122 | Err(error) => return CommandResult::error(error), |
| 123 | }; |
| 124 | let remotes = discover_remotes(&workspace.workspace()); |
| 125 | let plan = match plan_dispatch(&remotes, &prompt, requested, None) { |
| 126 | Ok(plan) => plan, |
| 127 | Err(error) => return CommandResult::error(error.to_string()), |
| 128 | }; |
| 129 | match execute_dispatch( |
| 130 | store, |
| 131 | plan, |
| 132 | confirm, |
| 133 | &discover_credentials(), |
| 134 | &discover_machine_token(), |
| 135 | ) { |
| 136 | Ok(outcome) => { |
| 137 | if let DispatchOutcome::Accepted(job) = &outcome { |
| 138 | spawn_confirmed_runner(store.clone(), job.id.clone()); |
| 139 | } |
| 140 | CommandResult::message(outcome_message(&outcome)) |
| 141 | } |
| 142 | Err(error) => CommandResult::error(error.to_string()), |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | fn split_prompt_and_remote(raw: &str) -> Result<(String, Option<Forge>), String> { |
| 147 | let mut remote = None; |
| 148 | let mut prompt_parts = Vec::new(); |
| 149 | let mut tokens = raw.split_whitespace().peekable(); |
| 150 | while let Some(token) = tokens.next() { |
| 151 | if token == "--remote" { |
| 152 | let Some(value) = tokens.next() else { |
| 153 | return Err("Usage: /dispatch <prompt> --remote github|cnb|gitee".to_string()); |
| 154 | }; |
| 155 | remote = Some( |
| 156 | Forge::parse(value) |
| 157 | .ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?, |
| 158 | ); |
| 159 | continue; |
| 160 | } |
| 161 | if let Some(value) = token.strip_prefix("--remote=") { |
| 162 | remote = Some( |
| 163 | Forge::parse(value) |
| 164 | .ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?, |
| 165 | ); |
| 166 | continue; |
| 167 | } |
| 168 | prompt_parts.push(token); |
| 169 | } |
| 170 | let prompt = prompt_parts.join(" "); |
| 171 | if prompt.is_empty() { |
| 172 | return Err("Usage: /dispatch <prompt> [--remote github|cnb|gitee]".to_string()); |
| 173 | } |
| 174 | Ok((prompt, remote)) |
| 175 | } |
| 176 | |
| 177 | fn outcome_message(outcome: &DispatchOutcome) -> String { |
| 178 | match outcome { |
| 179 | DispatchOutcome::Proposal(job) |
| 180 | | DispatchOutcome::Refused(job) |
| 181 | | DispatchOutcome::Accepted(job) => format_job(job), |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | #[cfg(test)] |
| 186 | mod tests { |
| 187 | use super::*; |
| 188 | use std::path::PathBuf; |
| 189 | |
| 190 | struct FakeWorkspace(PathBuf); |
| 191 | impl CommandWorkspaceContext for FakeWorkspace { |
| 192 | fn workspace(&self) -> PathBuf { |
| 193 | self.0.clone() |
| 194 | } |
| 195 | fn work_state_snapshot(&self) -> Result<Option<String>, String> { |
| 196 | Ok(None) |
| 197 | } |
| 198 | fn operation_digest(&mut self) -> Result<String, String> { |
| 199 | Ok("digest".to_string()) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn parses_prompt_and_explicit_remote() { |
| 205 | let (prompt, remote) = split_prompt_and_remote("fix the flake --remote cnb").unwrap(); |
| 206 | assert_eq!(prompt, "fix the flake"); |
| 207 | assert_eq!(remote, Some(Forge::Cnb)); |
| 208 | assert!(split_prompt_and_remote("--remote gitee").is_err()); |
| 209 | assert_eq!( |
| 210 | split_prompt_and_remote("open pr --remote=github") |
| 211 | .unwrap() |
| 212 | .1, |
| 213 | Some(Forge::Github) |
| 214 | ); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn handler_is_contextual_and_argument_aware() { |
| 219 | assert!(matches!( |
| 220 | DispatchCmd::handler(), |
| 221 | CommandHandler::Contextual { .. } |
| 222 | )); |
| 223 | assert_eq!( |
| 224 | DispatchCmd::info().description_key, |
| 225 | "cmd_dispatch_description" |
| 226 | ); |
| 227 | assert_eq!( |
| 228 | DispatchCmd::info().aliases, |
| 229 | &["cloud-agent", "cloud-dispatch"] |
| 230 | ); |
| 231 | assert!(DispatchCmd::info().usage.starts_with("/dispatch")); |
| 232 | } |
| 233 | |
| 234 | #[test] |
| 235 | fn missing_workspace_facet_fails_safely() { |
| 236 | let result = dispatch_contextual(CommandContexts::empty(), None); |
| 237 | assert!(result.is_error, "{result:?}"); |
| 238 | assert_eq!( |
| 239 | result.message.as_deref(), |
| 240 | Some("Error: Command capability unavailable: workspace") |
| 241 | ); |
| 242 | assert!(result.action.is_none()); |
| 243 | } |
| 244 | |
| 245 | #[test] |
| 246 | fn bare_dispatch_is_a_status_card_not_a_silent_launch() { |
| 247 | let mut workspace = FakeWorkspace(PathBuf::from(".")); |
| 248 | let result = dispatch(&mut workspace, None); |
| 249 | assert!(!result.is_error, "{result:?}"); |
| 250 | let message = result.message.expect("status message"); |
| 251 | assert!(message.contains("Cloud agents")); |
| 252 | assert!(message.contains("fails closed") || message.contains("ready")); |
| 253 | assert!(result.action.is_none()); |
| 254 | } |
| 255 | } |
| 256 |