Skip to main content

ubiquity_core/
command_cloud.rs

1//! Cloudflare Workers Durable Objects command execution implementation
2
3use async_trait::async_trait;
4use futures::{Stream, StreamExt};
5use reqwest::Client;
6use serde::{Deserialize, Serialize};
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::mpsc;
11use tracing::error;
12use uuid::Uuid;
13
14use crate::command::{
15    CommandContext, CommandEvent, CommandExecutor, CommandHandle, CommandRequest, CommandResult,
16};
17use crate::error::UbiquityError;
18
19/// Cloud command executor using Cloudflare Workers Durable Objects
20pub struct CloudCommandExecutor {
21    context: Arc<CommandContext>,
22    event_buffer_size: usize,
23    client: Client,
24    worker_url: String,
25    api_token: String,
26    namespace_id: String,
27}
28
29/// Request to create a new command execution in Durable Object
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct CloudExecuteRequest {
32    pub request: CommandRequest,
33    pub namespace_id: String,
34}
35
36/// Response from cloud execution
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct CloudExecuteResponse {
39    pub durable_object_id: String,
40    pub websocket_url: String,
41}
42
43/// WebSocket message types for streaming events
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type")]
46enum CloudWebSocketMessage {
47    Subscribe {
48        command_id: Uuid,
49    },
50    Event {
51        event: CommandEvent,
52    },
53    Cancel {
54        command_id: Uuid,
55    },
56    Status {
57        command_id: Uuid,
58    },
59    StatusResponse {
60        result: Option<CommandResult>,
61    },
62    Error {
63        message: String,
64    },
65}
66
67impl CloudCommandExecutor {
68    pub fn new(worker_url: String, api_token: String, namespace_id: String) -> Self {
69        Self {
70            context: Arc::new(CommandContext::new()),
71            event_buffer_size: 1024,
72            client: Client::builder()
73                .timeout(Duration::from_secs(30))
74                .build()
75                .unwrap(),
76            worker_url,
77            api_token,
78            namespace_id,
79        }
80    }
81
82    async fn create_durable_object(
83        &self,
84        request: CommandRequest,
85    ) -> Result<CloudExecuteResponse, UbiquityError> {
86        let url = format!("{}/api/commands/execute", self.worker_url);
87        
88        let cloud_request = CloudExecuteRequest {
89            request,
90            namespace_id: self.namespace_id.clone(),
91        };
92
93        let response = self
94            .client
95            .post(&url)
96            .header("Authorization", format!("Bearer {}", self.api_token))
97            .json(&cloud_request)
98            .send()
99            .await
100            .map_err(|e| UbiquityError::Network(format!("Failed to create durable object: {}", e)))?;
101
102        if !response.status().is_success() {
103            let status = response.status();
104            let body = response.text().await.unwrap_or_default();
105            return Err(UbiquityError::CloudExecution(format!(
106                "Failed to create durable object: {} - {}",
107                status, body
108            )));
109        }
110
111        response
112            .json::<CloudExecuteResponse>()
113            .await
114            .map_err(|e| UbiquityError::Serialization(format!("Failed to parse response: {}", e)))
115    }
116
117    async fn connect_websocket(
118        &self,
119        websocket_url: &str,
120        command_id: Uuid,
121        event_tx: mpsc::Sender<CommandEvent>,
122    ) -> Result<(), UbiquityError> {
123        use tokio_tungstenite::{connect_async, tungstenite::Message};
124        
125        let (ws_stream, _) = connect_async(websocket_url)
126            .await
127            .map_err(|e| UbiquityError::Network(format!("Failed to connect WebSocket: {}", e)))?;
128
129        let (write, read) = ws_stream.split();
130        let (internal_tx, mut internal_rx) = mpsc::channel::<Message>(100);
131
132        // Send subscribe message
133        let subscribe_msg = CloudWebSocketMessage::Subscribe { command_id };
134        let msg_text = serde_json::to_string(&subscribe_msg)
135            .map_err(|e| UbiquityError::Serialization(e.to_string()))?;
136        
137        internal_tx
138            .send(Message::Text(msg_text))
139            .await
140            .map_err(|_| UbiquityError::Internal("Failed to send subscribe message".to_string()))?;
141
142        // Spawn task to handle writing
143        let write_task = tokio::spawn(async move {
144            use futures::SinkExt;
145            let mut write = write;
146            while let Some(msg) = internal_rx.recv().await {
147                if let Err(e) = write.send(msg).await {
148                    error!("WebSocket write error: {}", e);
149                    break;
150                }
151            }
152        });
153
154        // Handle reading
155        let read_task = tokio::spawn(async move {
156            use futures::StreamExt;
157            let mut read = read;
158            while let Some(result) = read.next().await {
159                match result {
160                    Ok(Message::Text(text)) => {
161                        match serde_json::from_str::<CloudWebSocketMessage>(&text) {
162                            Ok(CloudWebSocketMessage::Event { event }) => {
163                                if event_tx.send(event).await.is_err() {
164                                    break;
165                                }
166                            }
167                            Ok(CloudWebSocketMessage::Error { message }) => {
168                                error!("Cloud execution error: {}", message);
169                                let _ = event_tx
170                                    .send(CommandEvent::Failed {
171                                        command_id,
172                                        error: message,
173                                        duration_ms: 0,
174                                        timestamp: chrono::Utc::now(),
175                                    })
176                                    .await;
177                                break;
178                            }
179                            _ => {}
180                        }
181                    }
182                    Ok(Message::Close(_)) => break,
183                    Err(e) => {
184                        error!("WebSocket read error: {}", e);
185                        break;
186                    }
187                    _ => {}
188                }
189            }
190        });
191
192        // Wait for both tasks
193        tokio::select! {
194            _ = write_task => {}
195            _ = read_task => {}
196        }
197
198        Ok(())
199    }
200
201    async fn execute_cloud(
202        request: CommandRequest,
203        event_tx: mpsc::Sender<CommandEvent>,
204        executor: CloudCommandExecutor,
205    ) -> Result<(), UbiquityError> {
206        let command_id = request.id;
207
208        // Create durable object
209        let response = executor.create_durable_object(request).await?;
210
211        // Connect to WebSocket for streaming events
212        executor
213            .connect_websocket(&response.websocket_url, command_id, event_tx)
214            .await
215    }
216}
217
218#[async_trait]
219impl CommandExecutor for CloudCommandExecutor {
220    async fn execute(
221        &self,
222        request: CommandRequest,
223    ) -> Result<Pin<Box<dyn Stream<Item = CommandEvent> + Send>>, UbiquityError> {
224        let (event_tx, event_rx) = mpsc::channel(self.event_buffer_size);
225        let (cancel_tx, _cancel_rx) = mpsc::channel(1);
226        let (status_tx, _status_rx) = mpsc::channel(1);
227
228        let command_id = request.id;
229        let handle = CommandHandle::new(command_id, cancel_tx, status_tx);
230        
231        // Register the command
232        self.context.register(command_id, handle).await;
233
234        // Clone executor for the spawned task
235        let executor = self.clone();
236        let context = self.context.clone();
237
238        // Spawn the execution task
239        tokio::spawn(async move {
240            let result = Self::execute_cloud(request, event_tx, executor).await;
241            
242            // Unregister the command when done
243            context.unregister(&command_id).await;
244            
245            if let Err(e) = result {
246                error!("Cloud command execution error: {}", e);
247            }
248        });
249
250        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(event_rx)))
251    }
252
253    async fn cancel(&self, command_id: Uuid) -> Result<(), UbiquityError> {
254        // Send cancel request to durable object
255        let url = format!("{}/api/commands/{}/cancel", self.worker_url, command_id);
256        
257        let response = self
258            .client
259            .post(&url)
260            .header("Authorization", format!("Bearer {}", self.api_token))
261            .send()
262            .await
263            .map_err(|e| UbiquityError::Network(format!("Failed to cancel command: {}", e)))?;
264
265        if !response.status().is_success() {
266            let status = response.status();
267            let body = response.text().await.unwrap_or_default();
268            return Err(UbiquityError::CloudExecution(format!(
269                "Failed to cancel command: {} - {}",
270                status, body
271            )));
272        }
273
274        Ok(())
275    }
276
277    async fn status(&self, command_id: Uuid) -> Result<Option<CommandResult>, UbiquityError> {
278        // Query durable object for status
279        let url = format!("{}/api/commands/{}/status", self.worker_url, command_id);
280        
281        let response = self
282            .client
283            .get(&url)
284            .header("Authorization", format!("Bearer {}", self.api_token))
285            .send()
286            .await
287            .map_err(|e| UbiquityError::Network(format!("Failed to get command status: {}", e)))?;
288
289        if response.status() == 404 {
290            return Ok(None);
291        }
292
293        if !response.status().is_success() {
294            let status = response.status();
295            let body = response.text().await.unwrap_or_default();
296            return Err(UbiquityError::CloudExecution(format!(
297                "Failed to get command status: {} - {}",
298                status, body
299            )));
300        }
301
302        let result = response
303            .json::<CommandResult>()
304            .await
305            .map_err(|e| UbiquityError::Serialization(format!("Failed to parse status: {}", e)))?;
306
307        Ok(Some(result))
308    }
309}
310
311impl Clone for CloudCommandExecutor {
312    fn clone(&self) -> Self {
313        Self {
314            context: self.context.clone(),
315            event_buffer_size: self.event_buffer_size,
316            client: self.client.clone(),
317            worker_url: self.worker_url.clone(),
318            api_token: self.api_token.clone(),
319            namespace_id: self.namespace_id.clone(),
320        }
321    }
322}
323
324/// Cloudflare Worker implementation (to be deployed separately)
325#[cfg(feature = "cloudflare-worker")]
326pub mod worker {
327    use super::*;
328    
329    #[durable_object]
330    pub struct CommandDurableObject {
331        state: State,
332        env: Env,
333        websockets: Vec<WebSocket>,
334        command_result: Option<CommandResult>,
335        event_history: Vec<CommandEvent>,
336    }
337    
338    #[durable_object]
339    impl DurableObject for CommandDurableObject {
340        fn new(state: State, env: Env) -> Self {
341            Self {
342                state,
343                env,
344                websockets: Vec::new(),
345                command_result: None,
346                event_history: Vec::new(),
347            }
348        }
349        
350        async fn fetch(&mut self, req: Request) -> Result<Response> {
351            let path = req.path();
352            
353            match path.as_str() {
354                "/execute" => self.handle_execute(req).await,
355                "/websocket" => self.handle_websocket(req).await,
356                "/cancel" => self.handle_cancel(req).await,
357                "/status" => self.handle_status(req).await,
358                _ => Response::error("Not Found", 404),
359            }
360        }
361    }
362    
363    impl CommandDurableObject {
364        async fn handle_execute(&mut self, mut req: Request) -> Result<Response> {
365            let request: CommandRequest = req.json().await?;
366            
367            // Start command execution in the background
368            let event_tx = self.create_event_broadcaster();
369            
370            // Simulate command execution (in real implementation, this would
371            // use a sandboxed environment or container)
372            self.simulate_command_execution(request, event_tx).await;
373            
374            Response::ok("Command execution started")
375        }
376        
377        async fn handle_websocket(&mut self, req: Request) -> Result<Response> {
378            let pair = WebSocketPair::new()?;
379            let server = pair.server;
380            
381            server.accept()?;
382            self.websockets.push(server);
383            
384            Response::from_websocket(pair.client)
385        }
386        
387        async fn handle_cancel(&mut self, _req: Request) -> Result<Response> {
388            // Broadcast cancellation event
389            let event = CommandEvent::Cancelled {
390                command_id: self.get_command_id()?,
391                duration_ms: 0,
392                timestamp: chrono::Utc::now(),
393            };
394            
395            self.broadcast_event(event).await;
396            Response::ok("Command cancelled")
397        }
398        
399        async fn handle_status(&mut self, _req: Request) -> Result<Response> {
400            match &self.command_result {
401                Some(result) => Response::ok(serde_json::to_string(result)?),
402                None => Response::error("Command not found", 404),
403            }
404        }
405        
406        async fn simulate_command_execution(
407            &mut self,
408            request: CommandRequest,
409            event_tx: mpsc::Sender<CommandEvent>,
410        ) {
411            // This is a simplified simulation
412            // In production, this would execute in a secure sandbox
413            
414            let start = std::time::Instant::now();
415            let command_id = request.id;
416            
417            // Send start event
418            let _ = event_tx.send(CommandEvent::Started {
419                command_id,
420                command: request.command.clone(),
421                args: request.args.clone(),
422                timestamp: chrono::Utc::now(),
423            }).await;
424            
425            // Simulate some output
426            let _ = event_tx.send(CommandEvent::Stdout {
427                command_id,
428                data: format!("Executing: {} {}", request.command, request.args.join(" ")),
429                timestamp: chrono::Utc::now(),
430            }).await;
431            
432            // Simulate completion
433            let duration_ms = start.elapsed().as_millis() as u64;
434            let _ = event_tx.send(CommandEvent::Completed {
435                command_id,
436                exit_code: 0,
437                duration_ms,
438                timestamp: chrono::Utc::now(),
439            }).await;
440            
441            // Store result
442            self.command_result = Some(CommandResult {
443                id: command_id,
444                exit_code: Some(0),
445                stdout: format!("Executed: {} {}", request.command, request.args.join(" ")),
446                stderr: String::new(),
447                duration_ms,
448                cancelled: false,
449            });
450        }
451        
452        fn create_event_broadcaster(&self) -> mpsc::Sender<CommandEvent> {
453            let (tx, mut rx) = mpsc::channel(100);
454            
455            let websockets = self.websockets.clone();
456            wasm_bindgen_futures::spawn_local(async move {
457                while let Some(event) = rx.recv().await {
458                    let msg = CloudWebSocketMessage::Event { event };
459                    let text = serde_json::to_string(&msg).unwrap();
460                    
461                    for ws in &websockets {
462                        let _ = ws.send_with_str(&text);
463                    }
464                }
465            });
466            
467            tx
468        }
469        
470        async fn broadcast_event(&mut self, event: CommandEvent) {
471            self.event_history.push(event.clone());
472            
473            let msg = CloudWebSocketMessage::Event { event };
474            let text = serde_json::to_string(&msg).unwrap();
475            
476            self.websockets.retain(|ws| {
477                ws.send_with_str(&text).is_ok()
478            });
479        }
480        
481        fn get_command_id(&self) -> Result<Uuid> {
482            self.command_result
483                .as_ref()
484                .map(|r| r.id)
485                .ok_or_else(|| Error::RustError("No command ID found".to_string()))
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[tokio::test]
495    async fn test_cloud_executor_creation() {
496        let executor = CloudCommandExecutor::new(
497            "https://example.workers.dev".to_string(),
498            "test-token".to_string(),
499            "test-namespace".to_string(),
500        );
501
502        assert_eq!(executor.worker_url, "https://example.workers.dev");
503        assert_eq!(executor.api_token, "test-token");
504        assert_eq!(executor.namespace_id, "test-namespace");
505    }
506}