返回 CodeWhale
dispatch.rs
根目录 / crates / tui / src / commands / groups / utility / dispatch.rs
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
256 lines RUST