1use std::collections::HashMap;
40use std::time::Duration;
41
42use reqwest::{header::HeaderMap, Client, Method, StatusCode};
43use serde::{Deserialize, Serialize};
44use tokio::time::{sleep, Instant};
45
46use crate::agents::{bounded_read, AgentsError, AgentsResult, MAX_RESPONSE_SIZE};
47
48pub const TERMINAL_WORKFLOW_STATUSES: &[&str] = &["succeeded", "failed", "timed_out"];
50
51pub fn is_workflow_run_terminal(status: &str) -> bool {
54 TERMINAL_WORKFLOW_STATUSES.contains(&status)
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Workflow {
65 pub id: String,
66 pub tenant_id: String,
67 pub name: String,
68 #[serde(default)]
69 pub description: Option<String>,
70 pub start_at: String,
71 pub states: HashMap<String, serde_json::Value>,
72 pub version: String,
73 pub created_at: u64,
74 pub updated_at: u64,
75}
76
77#[derive(Debug, Clone, Serialize, Default)]
78pub struct CreateWorkflowRequest {
79 pub name: String,
80 pub start_at: String,
81 pub states: HashMap<String, serde_json::Value>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84}
85
86#[derive(Debug, Clone, Deserialize)]
87pub struct WorkflowListResponse {
88 #[serde(default)]
89 pub workflows: Vec<Workflow>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
94pub struct StartRunResponse {
95 pub execution_id: String,
96 pub workflow_id: String,
97 pub status: String,
98}
99
100#[derive(Debug, Clone, Deserialize)]
104pub struct WorkflowExecution {
105 pub id: String,
106 pub workflow_id: String,
107 pub tenant_id: String,
108 pub status: String,
109 #[serde(default)]
110 pub current_state: Option<String>,
111 #[serde(default)]
112 pub input: serde_json::Value,
113 #[serde(default)]
114 pub output: Option<serde_json::Value>,
115 pub started_at: u64,
116 #[serde(default)]
117 pub ended_at: Option<u64>,
118 #[serde(default)]
119 pub error: Option<String>,
120}
121
122impl WorkflowExecution {
123 pub fn is_terminal(&self) -> bool {
124 is_workflow_run_terminal(&self.status)
125 }
126
127 pub fn succeeded(&self) -> bool {
128 self.status == "succeeded"
129 }
130}
131
132#[derive(Debug, Deserialize)]
135struct WorkflowEnvelope {
136 workflow: Workflow,
137}
138
139#[derive(Debug, Deserialize)]
140struct ExecutionEnvelope {
141 execution: WorkflowExecution,
142}
143
144pub struct WorkflowsClient {
147 base_url: String,
148 api_key: Option<String>,
149 tenant: Option<String>,
150 client: Client,
151}
152
153impl WorkflowsClient {
154 pub fn new(base_url: impl Into<String>) -> Self {
155 Self {
156 base_url: base_url.into().trim_end_matches('/').to_string(),
157 api_key: None,
158 tenant: None,
159 client: Client::builder()
160 .timeout(Duration::from_secs(120))
161 .build()
162 .expect("reqwest client"),
163 }
164 }
165
166 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
167 self.api_key = Some(key.into());
168 self
169 }
170
171 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
172 self.tenant = Some(tenant.into());
173 self
174 }
175
176 fn headers(&self) -> HeaderMap {
177 let mut h = HeaderMap::new();
178 h.insert("Content-Type", "application/json".parse().unwrap());
179 if let Some(key) = &self.api_key {
180 if let Ok(val) = format!("Bearer {key}").parse() {
181 h.insert("Authorization", val);
182 }
183 }
184 if let Some(t) = &self.tenant {
185 if let Ok(val) = t.parse() {
186 h.insert("x-rapidapi-user", val);
187 }
188 }
189 h
190 }
191
192 async fn request<T: for<'de> Deserialize<'de>>(
193 &self,
194 method: Method,
195 path: &str,
196 body: Option<&impl Serialize>,
197 ) -> AgentsResult<Option<T>> {
198 let url = format!("{}{}", self.base_url, path);
199 let mut req = self.client.request(method, &url).headers(self.headers());
200 if let Some(b) = body {
201 req = req.json(b);
202 }
203 let resp = req.send().await?;
204 let status = resp.status();
205 if status == StatusCode::NO_CONTENT {
206 return Ok(None);
207 }
208 let bytes = bounded_read(resp, MAX_RESPONSE_SIZE).await?;
209 if !status.is_success() {
210 let body = String::from_utf8_lossy(&bytes).into_owned();
211 return Err(AgentsError::Status {
212 status: status.as_u16(),
213 body,
214 });
215 }
216 if bytes.is_empty() {
217 return Ok(None);
218 }
219 Ok(Some(serde_json::from_slice(&bytes)?))
220 }
221
222 pub async fn create(&self, req: CreateWorkflowRequest) -> AgentsResult<Workflow> {
229 let env: WorkflowEnvelope = self
230 .request::<WorkflowEnvelope>(Method::POST, "/v1/workflows", Some(&req))
231 .await?
232 .ok_or_else(|| {
233 AgentsError::InvalidInput("server returned empty body for create".into())
234 })?;
235 Ok(env.workflow)
236 }
237
238 pub async fn list(&self) -> AgentsResult<WorkflowListResponse> {
240 self.request::<WorkflowListResponse>(Method::GET, "/v1/workflows", Option::<&()>::None)
241 .await
242 .map(|o| o.unwrap_or(WorkflowListResponse { workflows: vec![] }))
243 }
244
245 pub async fn update(
253 &self,
254 workflow_id: &str,
255 req: CreateWorkflowRequest,
256 ) -> AgentsResult<Workflow> {
257 let env: WorkflowEnvelope = self
258 .request::<WorkflowEnvelope>(
259 Method::PATCH,
260 &format!("/v1/workflows/{workflow_id}"),
261 Some(&req),
262 )
263 .await?
264 .ok_or_else(|| {
265 AgentsError::InvalidInput("server returned empty body for update".into())
266 })?;
267 Ok(env.workflow)
268 }
269
270 pub async fn get(&self, workflow_id: &str) -> AgentsResult<Workflow> {
272 let env: WorkflowEnvelope = self
273 .request::<WorkflowEnvelope>(
274 Method::GET,
275 &format!("/v1/workflows/{workflow_id}"),
276 Option::<&()>::None,
277 )
278 .await?
279 .ok_or_else(|| {
280 AgentsError::InvalidInput("server returned empty body for get".into())
281 })?;
282 Ok(env.workflow)
283 }
284
285 pub async fn delete(&self, workflow_id: &str) -> AgentsResult<()> {
287 let _: Option<serde_json::Value> = self
288 .request(
289 Method::DELETE,
290 &format!("/v1/workflows/{workflow_id}"),
291 Option::<&()>::None,
292 )
293 .await?;
294 Ok(())
295 }
296
297 pub async fn start_run(
304 &self,
305 workflow_id: &str,
306 input: Option<serde_json::Value>,
307 ) -> AgentsResult<StartRunResponse> {
308 #[derive(Serialize)]
309 struct Body {
310 #[serde(skip_serializing_if = "Option::is_none")]
311 input: Option<serde_json::Value>,
312 }
313 self.request::<StartRunResponse>(
314 Method::POST,
315 &format!("/v1/workflows/{workflow_id}/runs"),
316 Some(&Body { input }),
317 )
318 .await?
319 .ok_or_else(|| AgentsError::InvalidInput("server returned empty body for start_run".into()))
320 }
321
322 pub async fn get_run(
324 &self,
325 workflow_id: &str,
326 execution_id: &str,
327 ) -> AgentsResult<WorkflowExecution> {
328 let env: ExecutionEnvelope = self
329 .request::<ExecutionEnvelope>(
330 Method::GET,
331 &format!("/v1/workflows/{workflow_id}/runs/{execution_id}"),
332 Option::<&()>::None,
333 )
334 .await?
335 .ok_or_else(|| {
336 AgentsError::InvalidInput("server returned empty body for get_run".into())
337 })?;
338 Ok(env.execution)
339 }
340
341 pub async fn wait_for_run(
349 &self,
350 workflow_id: &str,
351 execution_id: &str,
352 timeout: Option<Duration>,
353 poll_interval: Option<Duration>,
354 ) -> AgentsResult<WorkflowExecution> {
355 let timeout = timeout.unwrap_or_else(|| Duration::from_secs(90));
356 let poll_interval = poll_interval.unwrap_or_else(|| Duration::from_secs(1));
357 let deadline = Instant::now() + timeout;
358 loop {
359 let execution = self.get_run(workflow_id, execution_id).await?;
360 if execution.is_terminal() {
361 return Ok(execution);
362 }
363 if Instant::now() >= deadline {
364 return Err(AgentsError::InvalidInput(format!(
365 "workflow run {execution_id} did not terminate within {timeout:?} \
366 (last status: {})",
367 execution.status
368 )));
369 }
370 sleep(poll_interval).await;
371 }
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_is_terminal() {
381 assert!(is_workflow_run_terminal("succeeded"));
382 assert!(is_workflow_run_terminal("failed"));
383 assert!(is_workflow_run_terminal("timed_out"));
384 assert!(!is_workflow_run_terminal("running"));
385 assert!(!is_workflow_run_terminal("queued"));
386 }
387
388 #[test]
389 fn test_execution_terminal_helpers() {
390 let succeeded = WorkflowExecution {
391 id: "wfr_1".into(),
392 workflow_id: "wf_1".into(),
393 tenant_id: "t1".into(),
394 status: "succeeded".into(),
395 current_state: None,
396 input: serde_json::json!(null),
397 output: Some(serde_json::json!({"ok": true})),
398 started_at: 100,
399 ended_at: Some(110),
400 error: None,
401 };
402 assert!(succeeded.is_terminal());
403 assert!(succeeded.succeeded());
404
405 let running = WorkflowExecution {
406 id: "wfr_2".into(),
407 workflow_id: "wf_1".into(),
408 tenant_id: "t1".into(),
409 status: "running".into(),
410 current_state: Some("Compute".into()),
411 input: serde_json::json!(null),
412 output: None,
413 started_at: 100,
414 ended_at: None,
415 error: None,
416 };
417 assert!(!running.is_terminal());
418 assert!(!running.succeeded());
419 }
420
421 #[test]
422 fn test_workflow_deserialize() {
423 let json = serde_json::json!({
424 "id": "wf_1",
425 "tenant_id": "t1",
426 "name": "triage",
427 "start_at": "Compute",
428 "states": { "Compute": { "type": "Succeed" } },
429 "version": "1.0",
430 "created_at": 100,
431 "updated_at": 200
432 });
433 let wf: Workflow = serde_json::from_value(json).unwrap();
434 assert_eq!(wf.id, "wf_1");
435 assert_eq!(wf.start_at, "Compute");
436 assert!(wf.states.contains_key("Compute"));
437 assert!(wf.description.is_none());
438 }
439
440 #[test]
441 fn test_envelope_unwrap() {
442 let json = serde_json::json!({
443 "workflow": {
444 "id": "wf_1",
445 "tenant_id": "t1",
446 "name": "triage",
447 "start_at": "S",
448 "states": { "S": { "type": "Succeed" } },
449 "version": "1.0",
450 "created_at": 1,
451 "updated_at": 2,
452 }
453 });
454 let env: WorkflowEnvelope = serde_json::from_value(json).unwrap();
455 assert_eq!(env.workflow.id, "wf_1");
456 }
457
458 #[test]
459 fn test_client_construction() {
460 let c = WorkflowsClient::new("http://localhost:3000/")
461 .with_api_key("k")
462 .with_tenant("t");
463 let h = c.headers();
464 assert_eq!(h.get("Authorization").unwrap(), "Bearer k");
465 assert_eq!(h.get("x-rapidapi-user").unwrap(), "t");
466 assert_eq!(c.base_url, "http://localhost:3000");
467 }
468}