1use std::pin::Pin;
15
16use futures_util::{Stream, StreamExt};
17use reqwest::Method;
18use reqwest_eventsource::{Event, EventSource};
19use serde::{Deserialize, Serialize};
20
21use crate::client::Client;
22use crate::error::BoxxkiteError;
23
24const DEFAULT_MAX_RUNTIME_SECONDS: i64 = 3600;
31
32#[derive(Debug, Clone)]
34pub struct StartProcessOptions {
35 description: Option<String>,
36 max_runtime_seconds: i64,
37 expose_port: Option<i32>,
38}
39
40impl Default for StartProcessOptions {
41 fn default() -> Self {
42 Self {
43 description: None,
44 max_runtime_seconds: DEFAULT_MAX_RUNTIME_SECONDS,
45 expose_port: None,
46 }
47 }
48}
49
50impl StartProcessOptions {
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn description(mut self, description: impl Into<String>) -> Self {
56 self.description = Some(description.into());
57 self
58 }
59
60 pub fn max_runtime_seconds(mut self, max_runtime_seconds: i64) -> Self {
63 self.max_runtime_seconds = max_runtime_seconds;
64 self
65 }
66
67 pub fn expose_port(mut self, expose_port: i32) -> Self {
72 self.expose_port = Some(expose_port);
73 self
74 }
75}
76
77#[derive(Serialize)]
78struct StartProcessBody<'a> {
79 command: &'a str,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 description: Option<&'a str>,
82 max_runtime_seconds: i64,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 expose_port: Option<i32>,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct ProcessStartResult {
90 pub process_id: String,
91 pub status: String,
92 pub started_at: String,
93}
94
95#[derive(Debug, Clone, Deserialize)]
98pub struct ProcessInfo {
99 pub process_id: String,
100 pub command: String,
101 pub description: Option<String>,
102 pub status: String,
103 pub started_at: String,
104 pub exit_code: Option<i32>,
105 pub expose_port: Option<i32>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110pub struct ProcessListResult {
111 pub processes: Vec<ProcessInfo>,
112}
113
114#[derive(Debug, Clone, Deserialize)]
116pub struct ProcessOutputResult {
117 pub status: String,
118 pub stdout_chunk: String,
119 pub next_offset: i64,
120 pub truncated: bool,
121 pub exit_code: Option<i32>,
122}
123
124#[derive(Debug, Clone, Deserialize)]
128#[serde(tag = "type", rename_all = "lowercase")]
129pub enum ProcessStreamEvent {
130 Output {
131 stdout_chunk: String,
132 next_offset: i64,
133 truncated: bool,
134 },
135 Exit {
136 status: String,
137 exit_code: Option<i32>,
138 },
139}
140
141#[derive(Debug, Clone, Deserialize)]
143pub struct ProcessInputResult {
144 pub bytes_written: i64,
145}
146
147#[derive(Debug, Clone, Deserialize)]
149pub struct ProcessStopResult {
150 pub status: String,
151 pub exit_code: Option<i32>,
152}
153
154impl Client {
155 pub async fn start_process(
161 &self,
162 session_id: &str,
163 command: &str,
164 options: StartProcessOptions,
165 ) -> Result<ProcessStartResult, BoxxkiteError> {
166 let body = StartProcessBody {
167 command,
168 description: options.description.as_deref(),
169 max_runtime_seconds: options.max_runtime_seconds,
170 expose_port: options.expose_port,
171 };
172 let builder = self
173 .request(
174 Method::POST,
175 &format!("/v1/sandboxes/{session_id}/processes"),
176 )
177 .json(&body);
178 self.send(builder).await
179 }
180
181 pub async fn list_processes(
185 &self,
186 session_id: &str,
187 ) -> Result<ProcessListResult, BoxxkiteError> {
188 let builder = self.request(
189 Method::GET,
190 &format!("/v1/sandboxes/{session_id}/processes"),
191 );
192 self.send(builder).await
193 }
194
195 pub async fn get_process_output(
201 &self,
202 session_id: &str,
203 process_id: &str,
204 since_offset: i64,
205 ) -> Result<ProcessOutputResult, BoxxkiteError> {
206 let builder = self
207 .request(
208 Method::GET,
209 &format!("/v1/sandboxes/{session_id}/processes/{process_id}/output"),
210 )
211 .query(&[("since_offset", since_offset.to_string())]);
212 self.send(builder).await
213 }
214
215 pub fn stream_process_output(
223 &self,
224 session_id: &str,
225 process_id: &str,
226 since_offset: i64,
227 ) -> Pin<Box<dyn Stream<Item = Result<ProcessStreamEvent, BoxxkiteError>> + Send + 'static>> {
228 let request_builder = self
229 .request(
230 Method::GET,
231 &format!("/v1/sandboxes/{session_id}/processes/{process_id}/stream"),
232 )
233 .query(&[("since_offset", since_offset.to_string())]);
234
235 Box::pin(async_stream::stream! {
236 let mut event_source = match EventSource::new(request_builder) {
237 Ok(event_source) => event_source,
238 Err(err) => {
239 yield Err(BoxxkiteError::Config(format!("failed to build stream request: {err}")));
240 return;
241 }
242 };
243
244 while let Some(event) = event_source.next().await {
245 match event {
246 Ok(Event::Open) => continue,
247 Ok(Event::Message(message)) => {
248 yield serde_json::from_str::<ProcessStreamEvent>(&message.data).map_err(BoxxkiteError::from);
249 }
250 Err(reqwest_eventsource::Error::StreamEnded) => break,
251 Err(reqwest_eventsource::Error::InvalidStatusCode(status, response)) => {
252 let bytes = response.bytes().await.unwrap_or_default();
253 yield Err(crate::error::api_error_from_bytes(status.as_u16(), &bytes));
254 break;
255 }
256 Err(err) => {
257 yield Err(BoxxkiteError::from(err));
258 break;
259 }
260 }
261 }
262 event_source.close();
263 })
264 }
265
266 pub async fn send_process_input(
270 &self,
271 session_id: &str,
272 process_id: &str,
273 data: &str,
274 ) -> Result<ProcessInputResult, BoxxkiteError> {
275 #[derive(Serialize)]
276 struct Body<'a> {
277 data: &'a str,
278 }
279 let builder = self
280 .request(
281 Method::POST,
282 &format!("/v1/sandboxes/{session_id}/processes/{process_id}/input"),
283 )
284 .json(&Body { data });
285 self.send(builder).await
286 }
287
288 pub async fn stop_process(
292 &self,
293 session_id: &str,
294 process_id: &str,
295 ) -> Result<ProcessStopResult, BoxxkiteError> {
296 let builder = self.request(
297 Method::POST,
298 &format!("/v1/sandboxes/{session_id}/processes/{process_id}/stop"),
299 );
300 self.send(builder).await
301 }
302}