Skip to main content

wauldo/
workflows.rs

1//! Workflows API client — Wauldo Workflow Runtime (Step Functions style).
2//!
3//! State-machine workflows authored as `Task` / `Choice` / `Wait` / `Pass` /
4//! `Fail` / `Succeed` states. Runs are async: [`WorkflowsClient::start_run`]
5//! returns an `execution_id`, then poll [`WorkflowsClient::get_run`] (or use
6//! [`WorkflowsClient::wait_for_run`]) until a terminal status.
7//!
8//! # Example
9//! ```no_run
10//! use wauldo::workflows::{CreateWorkflowRequest, WorkflowsClient};
11//! use serde_json::json;
12//! use std::collections::HashMap;
13//!
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let wf = WorkflowsClient::new("https://api.wauldo.com")
16//!     .with_api_key("tig_live_...");
17//!
18//! let mut states = HashMap::new();
19//! states.insert(
20//!     "Compute".to_string(),
21//!     json!({ "type": "Task", "resource": "tool:calculator", "next": "Done" }),
22//! );
23//! states.insert("Done".to_string(), json!({ "type": "Succeed" }));
24//!
25//! let created = wf.create(CreateWorkflowRequest {
26//!     name: "triage".into(),
27//!     start_at: "Compute".into(),
28//!     states,
29//!     description: None,
30//! }).await?;
31//!
32//! let run = wf.start_run(&created.id, Some(json!({"operation": "add", "a": 21, "b": 21}))).await?;
33//! let final_exec = wf.wait_for_run(&created.id, &run.execution_id, None, None).await?;
34//! println!("status={} output={:?}", final_exec.status, final_exec.output);
35//! # Ok(())
36//! # }
37//! ```
38
39use 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
48/// Statuses that terminate a workflow run.
49pub const TERMINAL_WORKFLOW_STATUSES: &[&str] = &["succeeded", "failed", "timed_out"];
50
51/// `true` when the supplied status string is one of the terminal statuses
52/// returned by the workflow runtime.
53pub fn is_workflow_run_terminal(status: &str) -> bool {
54    TERMINAL_WORKFLOW_STATUSES.contains(&status)
55}
56
57// ─── Wire types ───────────────────────────────────────────────────────
58
59/// A workflow definition (`GET /v1/workflows/:id`).
60///
61/// `states` is kept as raw JSON values so future state types added on the
62/// server side don't require a SDK release.
63#[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/// 202 response from `POST /v1/workflows/:id/runs`.
93#[derive(Debug, Clone, Deserialize)]
94pub struct StartRunResponse {
95    pub execution_id: String,
96    pub workflow_id: String,
97    pub status: String,
98}
99
100/// A workflow execution record. `status` is one of `running`, `succeeded`,
101/// `failed`, `timed_out`. `output` is populated on success; `error` on
102/// terminal failure.
103#[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// Internal wire envelopes — the server wraps singletons in
133// `{ "workflow": ... }` / `{ "execution": ... }`.
134#[derive(Debug, Deserialize)]
135struct WorkflowEnvelope {
136    workflow: Workflow,
137}
138
139#[derive(Debug, Deserialize)]
140struct ExecutionEnvelope {
141    execution: WorkflowExecution,
142}
143
144// ─── Client ───────────────────────────────────────────────────────────
145
146pub 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    // ── CRUD ─────────────────────────────────────────────────────────
223
224    /// `POST /v1/workflows` — create a workflow definition.
225    ///
226    /// The server validates cycles, transition targets, choice operators,
227    /// and the per-tenant cap (100) before returning 201.
228    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    /// `GET /v1/workflows` — list workflows for the calling tenant.
239    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    /// `PATCH /v1/workflows/:id` — replace the workflow definition in place.
246    ///
247    /// Body shape is identical to [`create`] (`CreateWorkflowRequest`). The
248    /// server keeps the workflow id, tenant_id, and created_at ; refreshes
249    /// name/description/start_at/states ; bumps updated_at and the monotonic
250    /// version int. Same validations as create — cycles, transition targets,
251    /// choice operators. Returns 200 with the updated workflow.
252    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    /// `GET /v1/workflows/:id`
271    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    /// `DELETE /v1/workflows/:id`
286    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    // ── Runs ─────────────────────────────────────────────────────────
298
299    /// `POST /v1/workflows/:id/runs` — start an async execution.
300    ///
301    /// Returns 202 with an `execution_id` immediately. Poll [`Self::get_run`]
302    /// or use [`Self::wait_for_run`] to await completion.
303    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    /// `GET /v1/workflows/:id/runs/:execution_id` — fetch one execution.
323    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    /// Poll [`Self::get_run`] until the run reaches a terminal status.
342    ///
343    /// Returns [`AgentsError::InvalidInput`] if the run hasn't terminated
344    /// within `timeout`. The server enforces its own 60s wall-clock cap per
345    /// run, so a timeout larger than ~75s is just slack for polling overhead.
346    ///
347    /// Defaults: `timeout = 90s`, `poll_interval = 1s`.
348    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}