Skip to main content

ironflow_sdk/
client.rs

1//! Ergonomic HTTP client for the Ironflow API.
2//!
3//! Wraps [`reqwest::Client`] with Bearer auth and automatic
4//! deserialization of the `{ data, meta }` response envelope.
5
6use std::time::Duration;
7
8use chrono::{DateTime, Utc};
9pub use ironflow_types::{ApiMeta, ApiResponse};
10use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
11use reqwest::{Client, RequestBuilder, Response};
12use serde::Serialize;
13use serde_json::{from_str, json};
14use uuid::Uuid;
15
16pub use crate::builder::{ArtifactDownload, ClientBuilder};
17use crate::error::{ApiErrorEnvelope, Error};
18use crate::rate_limit::RateLimiter;
19use crate::retry::RetryConfig;
20use crate::types;
21
22/// Configuration for the Ironflow SDK client.
23///
24/// # Examples
25///
26/// ```
27/// use ironflow_sdk::client::ClientConfig;
28/// use std::time::Duration;
29///
30/// let config = ClientConfig {
31///     base_url: "https://ironflow.example.com".to_string(),
32///     api_key: "my-api-key".to_string(),
33///     timeout: Duration::from_secs(60),
34/// };
35/// ```
36#[derive(Debug, Clone)]
37pub struct ClientConfig {
38    /// Base URL of the Ironflow API (e.g. `https://ironflow.example.com`).
39    pub base_url: String,
40    /// API key for Bearer authentication.
41    pub api_key: String,
42    /// HTTP request timeout.
43    pub timeout: Duration,
44}
45
46/// Filtering options for [`IronflowClient::list_runs_filtered`].
47///
48/// # Examples
49///
50/// ```
51/// use ironflow_sdk::client::ListRunsFilter;
52///
53/// let filter = ListRunsFilter {
54///     workflow: Some("deploy"),
55///     status: Some("completed"),
56///     page: Some(1),
57///     ..Default::default()
58/// };
59/// ```
60#[derive(Debug, Clone, Default, serde::Serialize)]
61pub struct ListRunsFilter<'a> {
62    /// Filter by workflow name.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub workflow: Option<&'a str>,
65    /// Filter by run status.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub status: Option<&'a str>,
68    /// Page number (1-based).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub page: Option<u32>,
71    /// Items per page.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub per_page: Option<u32>,
74    /// Filter runs that have/don't have steps.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub has_steps: Option<bool>,
77    /// Filter by label.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub label: Option<&'a str>,
80    /// Filter by author: the user ID that triggered the run.
81    ///
82    /// Also matches runs triggered by one of that user's API keys.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub created_by: Option<Uuid>,
85    /// Filter by concurrency group: only runs that belong to this group.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub concurrency_group: Option<&'a str>,
88    /// Filter by priority: only runs with exactly this priority.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub priority: Option<i16>,
91}
92
93/// Query options for [`IronflowClient::stats_history_with`].
94///
95/// The filters have the same semantics as [`ListRunsFilter`]: `workflow` is a
96/// case-insensitive substring match.
97///
98/// # Examples
99///
100/// ```
101/// use ironflow_sdk::client::StatsHistoryParams;
102///
103/// let params = StatsHistoryParams {
104///     period: Some("24h"),
105///     status: Some("failed"),
106///     label: Some("env:prod"),
107///     ..Default::default()
108/// };
109/// assert_eq!(params.period, Some("24h"));
110/// ```
111#[derive(Debug, Clone, Default, serde::Serialize)]
112pub struct StatsHistoryParams<'a> {
113    /// Filter by workflow name (case-insensitive substring match).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub workflow: Option<&'a str>,
116    /// Time period to query: `24h`, `7d`, `30d` or `90d`.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub period: Option<&'a str>,
119    /// Bucket granularity: `1h`, `1d` or `1w`. Derived from the period when omitted.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub granularity: Option<&'a str>,
122    /// Filter by run status.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub status: Option<&'a str>,
125    /// Filter by labels. Comma-separated `key:value` pairs.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub label: Option<&'a str>,
128    /// Filter runs that have/don't have steps.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub has_steps: Option<bool>,
131    /// Filter by author: the user ID that triggered the run.
132    ///
133    /// Also matches runs triggered by one of that user's API keys.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub created_by: Option<Uuid>,
136    /// Filter by concurrency group: only runs that belong to this group.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub concurrency_group: Option<&'a str>,
139}
140
141/// Query parameters for [`IronflowClient::list_steps`].
142///
143/// # Examples
144///
145/// ```
146/// use ironflow_sdk::client::ListStepsParams;
147///
148/// let params = ListStepsParams {
149///     agent_role: "Reviewer",
150///     page: Some(2),
151///     per_page: None,
152/// };
153/// assert_eq!(params.agent_role, "Reviewer");
154/// ```
155#[derive(Debug, Clone, Serialize)]
156pub struct ListStepsParams<'a> {
157    /// Name of the `AgentRole` whose agent steps to list. Matched exactly.
158    pub agent_role: &'a str,
159    /// Page number (1-based).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub page: Option<u32>,
162    /// Items per page (max 100).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub per_page: Option<u32>,
165}
166
167/// Filtering options for [`IronflowClient::get_run_logs`].
168///
169/// # Examples
170///
171/// ```
172/// use ironflow_sdk::client::ListRunLogsFilter;
173/// use uuid::Uuid;
174///
175/// let filter = ListRunLogsFilter {
176///     step_id: Some(Uuid::nil()),
177///     stream: Some("stderr"),
178///     ..Default::default()
179/// };
180/// ```
181#[derive(Debug, Clone, Default, serde::Serialize)]
182pub struct ListRunLogsFilter<'a> {
183    /// Filter by step ID.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub step_id: Option<Uuid>,
186    /// Filter by output stream (`stdout`, `stderr`, `system`).
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub stream: Option<&'a str>,
189    /// Cursor for pagination (last entry ID from previous page).
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub cursor: Option<Uuid>,
192    /// Number of entries to return (default: 100, max: 1000).
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub limit: Option<u32>,
195}
196
197/// Filtering options for [`IronflowClient::list_audit_logs_filtered`].
198///
199/// # Examples
200///
201/// ```
202/// use ironflow_sdk::client::ListAuditLogsFilter;
203///
204/// let filter = ListAuditLogsFilter {
205///     event_type: Some("run_created"),
206///     per_page: Some(25),
207///     ..Default::default()
208/// };
209/// ```
210#[derive(Debug, Clone, Default, serde::Serialize)]
211pub struct ListAuditLogsFilter<'a> {
212    /// Filter by event type (e.g. `run_status_changed`).
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub event_type: Option<&'a str>,
215    /// Filter by run ID.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub run_id: Option<Uuid>,
218    /// Keep entries recorded at or after this instant.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub from: Option<DateTime<Utc>>,
221    /// Keep entries recorded at or before this instant.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub to: Option<DateTime<Utc>>,
224    /// Page number (1-based).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub page: Option<u32>,
227    /// Items per page.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub per_page: Option<u32>,
230}
231
232/// Filtering options for [`IronflowClient::list_approval_delegations_filtered`].
233///
234/// The user filters are honoured for an admin only; a non-admin always gets the
235/// delegations they granted or received.
236///
237/// # Examples
238///
239/// ```
240/// use ironflow_sdk::client::ListApprovalDelegationsFilter;
241///
242/// let filter = ListApprovalDelegationsFilter {
243///     page: Some(2),
244///     per_page: Some(50),
245///     ..Default::default()
246/// };
247/// ```
248#[derive(Debug, Clone, Default, serde::Serialize)]
249pub struct ListApprovalDelegationsFilter {
250    /// Only delegations granted by this user.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub from_user_id: Option<Uuid>,
253    /// Only delegations received by this user.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub to_user_id: Option<Uuid>,
256    /// Page number (1-based).
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub page: Option<u32>,
259    /// Items per page (max 100).
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub per_page: Option<u32>,
262}
263
264/// Filtering options for [`IronflowClient::list_signals`].
265///
266/// # Examples
267///
268/// ```
269/// use ironflow_sdk::client::ListSignalsFilter;
270///
271/// let filter = ListSignalsFilter {
272///     name: Some("ci.pipeline_finished".to_string()),
273///     key: Some("4f2a9c1".to_string()),
274///     ..Default::default()
275/// };
276/// assert!(filter.page.is_none());
277/// ```
278#[derive(Debug, Clone, Default, serde::Serialize)]
279pub struct ListSignalsFilter {
280    /// Only signals with this exact name.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub name: Option<String>,
283    /// Only signals with this exact key.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub key: Option<String>,
286    /// Page number (1-based).
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub page: Option<u32>,
289    /// Items per page (max 100).
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub per_page: Option<u32>,
292}
293
294/// Type-safe client for the Ironflow REST API.
295///
296/// Handles Bearer authentication, the `{ data, meta }` response envelope,
297/// and provides methods for all public API endpoints.
298///
299/// # Examples
300///
301/// ```no_run
302/// use ironflow_sdk::IronflowClient;
303///
304/// # async fn example() -> Result<(), ironflow_sdk::Error> {
305/// let client = IronflowClient::new("https://ironflow.example.com", "my-api-key");
306///
307/// // List runs
308/// let runs = client.list_runs().await?;
309/// for run in &runs.data {
310///     # let _ = run;
311/// }
312///
313/// // Get a specific workflow
314/// let wf = client.get_workflow("deploy").await?;
315/// # Ok(())
316/// # }
317/// ```
318#[derive(Debug, Clone)]
319pub struct IronflowClient {
320    pub(crate) http: Client,
321    base_url: String,
322    pub(crate) retry_config: RetryConfig,
323    pub(crate) rate_limiter: RateLimiter,
324}
325
326impl IronflowClient {
327    /// Create a new client with the given base URL and API key.
328    ///
329    /// Uses a default timeout of 30 seconds.
330    ///
331    /// # Panics
332    ///
333    /// Panics if `api_key` contains characters invalid for HTTP headers
334    /// (e.g. newlines, NUL bytes, or non-visible ASCII).
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use ironflow_sdk::IronflowClient;
340    ///
341    /// let client = IronflowClient::new("https://ironflow.example.com", "my-api-key");
342    /// ```
343    pub fn new(base_url: &str, api_key: &str) -> Self {
344        let config = ClientConfig {
345            base_url: base_url.trim_end_matches('/').to_string(),
346            api_key: api_key.to_string(),
347            timeout: Duration::from_secs(30),
348        };
349        Self::from_config(config)
350    }
351
352    /// Create a new client from a [`ClientConfig`].
353    ///
354    /// # Panics
355    ///
356    /// Panics if `config.api_key` contains characters invalid for HTTP headers
357    /// (e.g. newlines, NUL bytes, or non-visible ASCII).
358    ///
359    /// # Examples
360    ///
361    /// ```
362    /// use ironflow_sdk::IronflowClient;
363    /// use ironflow_sdk::client::ClientConfig;
364    /// use std::time::Duration;
365    ///
366    /// let config = ClientConfig {
367    ///     base_url: "https://ironflow.example.com".to_string(),
368    ///     api_key: "my-api-key".to_string(),
369    ///     timeout: Duration::from_secs(60),
370    /// };
371    /// let client = IronflowClient::from_config(config);
372    /// ```
373    pub fn from_config(config: ClientConfig) -> Self {
374        Self::from_config_with_retry(config, RetryConfig::default(), RateLimiter::new())
375    }
376
377    /// Create a new client with custom retry and rate-limit settings.
378    ///
379    /// Prefer [`ClientBuilder`] for a more ergonomic interface.
380    ///
381    /// # Panics
382    ///
383    /// Panics if `config.api_key` contains characters invalid for HTTP headers.
384    pub fn from_config_with_retry(
385        config: ClientConfig,
386        retry_config: RetryConfig,
387        rate_limiter: RateLimiter,
388    ) -> Self {
389        let mut headers = HeaderMap::new();
390        let auth_value = format!("Bearer {}", config.api_key);
391        headers.insert(
392            AUTHORIZATION,
393            HeaderValue::from_str(&auth_value).expect("invalid API key characters"),
394        );
395
396        let http = Client::builder()
397            .timeout(config.timeout)
398            .default_headers(headers)
399            .build()
400            .expect("failed to build HTTP client");
401
402        Self {
403            http,
404            base_url: config.base_url,
405            retry_config,
406            rate_limiter,
407        }
408    }
409
410    /// Return the base URL.
411    pub fn base_url(&self) -> &str {
412        &self.base_url
413    }
414
415    pub(crate) fn url(&self, path: &str) -> String {
416        format!("{}{path}", self.base_url)
417    }
418
419    pub(crate) fn get(&self, path: &str) -> RequestBuilder {
420        self.http.get(self.url(path))
421    }
422
423    pub(crate) fn post(&self, path: &str) -> RequestBuilder {
424        self.http.post(self.url(path))
425    }
426
427    pub(crate) fn put(&self, path: &str) -> RequestBuilder {
428        self.http.put(self.url(path))
429    }
430
431    pub(crate) fn delete(&self, path: &str) -> RequestBuilder {
432        self.http.delete(self.url(path))
433    }
434
435    pub(crate) fn patch(&self, path: &str) -> RequestBuilder {
436        self.http.patch(self.url(path))
437    }
438
439    /// Parse an error response into an [`Error`].
440    pub(crate) async fn into_api_error(response: Response) -> Error {
441        let status = response.status();
442        let status_code = status.as_u16();
443        let text = response.text().await.unwrap_or_default();
444        from_str::<ApiErrorEnvelope>(&text)
445            .map(|env| Error::api(status_code, env.error.code, env.error.message))
446            .unwrap_or_else(|_| {
447                Error::api(
448                    status_code,
449                    status.canonical_reason().unwrap_or("UNKNOWN"),
450                    text,
451                )
452            })
453    }
454
455    // ── Health ──────────────────────────────────────────────────────
456
457    /// `GET /api/v1/health-check`
458    ///
459    /// # Errors
460    ///
461    /// Returns [`Error::Http`] on network failure.
462    pub async fn health_check(&self) -> Result<(), Error> {
463        self.send_no_content(self.get("/api/v1/health-check")).await
464    }
465
466    // ── Runs ───────────────────────────────────────────────────────
467
468    /// `GET /api/v1/runs` -- List runs with optional filtering and pagination.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`Error::Api`] on 401 Unauthorized.
473    pub async fn list_runs(&self) -> Result<ApiResponse<Vec<types::RunResponse>>, Error> {
474        self.list_runs_filtered(&ListRunsFilter::default()).await
475    }
476
477    /// `GET /api/v1/runs` with query parameters.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`Error::Api`] on 401 Unauthorized.
482    ///
483    /// # Examples
484    ///
485    /// ```no_run
486    /// use ironflow_sdk::IronflowClient;
487    /// use ironflow_sdk::client::ListRunsFilter;
488    ///
489    /// # async fn example() -> Result<(), ironflow_sdk::Error> {
490    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
491    /// let filter = ListRunsFilter {
492    ///     workflow: Some("deploy"),
493    ///     status: Some("completed"),
494    ///     ..Default::default()
495    /// };
496    /// let runs = client.list_runs_filtered(&filter).await?;
497    /// # Ok(())
498    /// # }
499    /// ```
500    pub async fn list_runs_filtered(
501        &self,
502        filter: &ListRunsFilter<'_>,
503    ) -> Result<ApiResponse<Vec<types::RunResponse>>, Error> {
504        self.send_envelope(self.get("/api/v1/runs").query(filter))
505            .await
506    }
507
508    /// `POST /api/v1/runs` -- Trigger a workflow.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`Error::Api`] on 400 (unknown workflow) or 401/403.
513    pub async fn create_run(
514        &self,
515        request: &types::CreateRunRequest,
516    ) -> Result<ApiResponse<types::RunResponse>, Error> {
517        self.send_envelope_once(self.post("/api/v1/runs").json(request))
518            .await
519    }
520
521    /// `POST /api/v1/runs` with an `Idempotency-Key` header -- trigger a workflow
522    /// exactly once.
523    ///
524    /// Replaying the same key returns the run it already produced instead of
525    /// creating a second one. The key stays bound for 24 hours. Use a value the
526    /// caller can reproduce across retries, such as a webhook delivery id.
527    ///
528    /// The returned envelope is identical whether the run was created or replayed;
529    /// compare `data.idempotency_key` or the run id if the distinction matters.
530    ///
531    /// # Errors
532    ///
533    /// Returns [`Error::Api`] on 400 (unknown workflow or malformed key), 401/403,
534    /// or 409 (`IDEMPOTENCY_KEY_CONFLICT`) when the key is already bound to a
535    /// different workflow or payload.
536    ///
537    /// # Examples
538    ///
539    /// ```no_run
540    /// use ironflow_sdk::{IronflowClient, types};
541    ///
542    /// # async fn example(request: &types::CreateRunRequest) -> Result<(), ironflow_sdk::Error> {
543    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
544    ///
545    /// // Derive the key from something the caller can reproduce on retry,
546    /// // such as the provider delivery id of the webhook that triggered this.
547    /// let run = client
548    ///     .create_run_idempotent(request, "github:8f4e2a10")
549    ///     .await?;
550    /// # Ok(())
551    /// # }
552    /// ```
553    pub async fn create_run_idempotent(
554        &self,
555        request: &types::CreateRunRequest,
556        idempotency_key: &str,
557    ) -> Result<ApiResponse<types::RunResponse>, Error> {
558        self.send_envelope(
559            self.post("/api/v1/runs")
560                .header("Idempotency-Key", idempotency_key)
561                .json(request),
562        )
563        .await
564    }
565
566    /// `GET /api/v1/runs/:id` -- Get run details.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`Error::Api`] on 404 (run not found) or 401.
571    pub async fn get_run(&self, id: Uuid) -> Result<ApiResponse<types::RunDetailResponse>, Error> {
572        self.send_envelope(self.get(&format!("/api/v1/runs/{id}")))
573            .await
574    }
575
576    /// Execute a lifecycle action on a run.
577    async fn run_action(
578        &self,
579        id: Uuid,
580        action: &str,
581    ) -> Result<ApiResponse<types::RunResponse>, Error> {
582        self.send_envelope(self.post(&format!("/api/v1/runs/{id}/{action}")))
583            .await
584    }
585
586    /// `POST /api/v1/runs/:id/cancel` -- Cancel a run and the sub-workflow
587    /// runs below it.
588    ///
589    /// The response carries the cancelled run's fields plus
590    /// `cancelled_descendants`, the ids of the sub-runs cancelled with it.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`Error::Api`] on 404 or 400 (invalid state transition).
595    pub async fn cancel_run(
596        &self,
597        id: Uuid,
598    ) -> Result<ApiResponse<types::CancelRunResponse>, Error> {
599        self.send_envelope(self.post(&format!("/api/v1/runs/{id}/cancel")))
600            .await
601    }
602
603    /// `POST /api/v1/runs/:id/pause` -- Pause a run and the sub-workflow runs
604    /// below it.
605    ///
606    /// A running run has its step in flight interrupted, executed again on
607    /// resume. The response carries
608    /// the paused run's fields, with `resume_status` (the state it returns
609    /// to), plus `paused_descendants`, the ids of the sub-runs paused with it.
610    ///
611    /// # Errors
612    ///
613    /// Returns [`Error::Api`] on 404, 403 (not an admin) or 400 (the run
614    /// already finished, is already paused, or is a sub-workflow run).
615    pub async fn pause_run(&self, id: Uuid) -> Result<ApiResponse<types::PauseRunResponse>, Error> {
616        self.send_envelope(self.post(&format!("/api/v1/runs/{id}/pause")))
617            .await
618    }
619
620    /// `POST /api/v1/runs/:id/resume` -- Resume a paused run and the
621    /// sub-workflow runs paused below it.
622    ///
623    /// The response carries the resumed run's fields plus
624    /// `resumed_descendants`, the ids of the sub-runs resumed with it.
625    ///
626    /// # Errors
627    ///
628    /// Returns [`Error::Api`] on 404, 403 (not an admin) or 400 (the run is
629    /// not paused, or is a sub-workflow run).
630    pub async fn resume_run(
631        &self,
632        id: Uuid,
633    ) -> Result<ApiResponse<types::ResumeRunResponse>, Error> {
634        self.send_envelope(self.post(&format!("/api/v1/runs/{id}/resume")))
635            .await
636    }
637
638    /// `POST /api/v1/runs/:id/approve` -- Approve a run waiting for approval.
639    ///
640    /// # Errors
641    ///
642    /// Returns [`Error::Api`] on 404 or 400.
643    pub async fn approve_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
644        self.run_action(id, "approve").await
645    }
646
647    /// `POST /api/v1/runs/:id/reject` -- Reject a run waiting for approval.
648    ///
649    /// # Errors
650    ///
651    /// Returns [`Error::Api`] on 404 or 400.
652    pub async fn reject_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
653        self.run_action(id, "reject").await
654    }
655
656    /// `POST /api/v1/runs/:id/steps/:step_id/input` -- Answer a human input step.
657    ///
658    /// `value` is serialized as the JSON body and validated by the server
659    /// against the schema stored on the step. On success the run resumes.
660    ///
661    /// # Errors
662    ///
663    /// Returns [`Error::Api`] on 400 (the step does not wait for input), 403
664    /// (the caller may not answer), 404 (unknown run or step), 409 (already
665    /// answered or rejected) or 422 (the value does not match the schema).
666    pub async fn submit_input<T: Serialize + ?Sized>(
667        &self,
668        run_id: Uuid,
669        step_id: Uuid,
670        value: &T,
671    ) -> Result<ApiResponse<types::RunResponse>, Error> {
672        self.send_envelope(
673            self.post(&format!("/api/v1/runs/{run_id}/steps/{step_id}/input"))
674                .json(value),
675        )
676        .await
677    }
678
679    /// `POST /api/v1/runs/:id/steps/:step_id/reject` -- Reject a human input step.
680    ///
681    /// The run resumes and the handler receives the rejection with `reason`
682    /// (or a default reason naming the caller).
683    ///
684    /// # Errors
685    ///
686    /// Returns [`Error::Api`] on 400 (the step does not wait for input), 403
687    /// (the caller may not reject it), 404 (unknown run or step) or 409
688    /// (already answered or rejected).
689    pub async fn reject_input(
690        &self,
691        run_id: Uuid,
692        step_id: Uuid,
693        reason: Option<&str>,
694    ) -> Result<ApiResponse<types::RunResponse>, Error> {
695        self.send_envelope(
696            self.post(&format!("/api/v1/runs/{run_id}/steps/{step_id}/reject"))
697                .json(&json!({ "reason": reason })),
698        )
699        .await
700    }
701
702    /// `POST /api/v1/runs/:id/retry` -- Retry a failed run (creates a new run).
703    ///
704    /// When `force` is `true`, the query parameter `?force=true` is appended
705    /// to override a handler version mismatch.
706    ///
707    /// # Errors
708    ///
709    /// Returns [`Error::Api`] on 404, 400, or 409 (version mismatch).
710    pub async fn retry_run(
711        &self,
712        id: Uuid,
713        force: bool,
714    ) -> Result<ApiResponse<types::RunResponse>, Error> {
715        let action = if force { "retry?force=true" } else { "retry" };
716        self.run_action(id, action).await
717    }
718
719    /// `POST /api/v1/runs/:id/replay` -- Replay a finished run on the current
720    /// handler version (creates a new run).
721    ///
722    /// Unlike [`retry_run`](Self::retry_run), the new run always targets the
723    /// handler's current version; there is no compatibility check to force.
724    ///
725    /// # Errors
726    ///
727    /// Returns [`Error::Api`] on 404 (run not found or workflow no longer
728    /// registered) or 400 (run still in flight).
729    pub async fn replay_run(&self, id: Uuid) -> Result<ApiResponse<types::RunResponse>, Error> {
730        self.run_action(id, "replay").await
731    }
732
733    // ── Workflows ──────────────────────────────────────────────────
734
735    /// `GET /api/v1/workflows` -- List registered workflows.
736    ///
737    /// # Errors
738    ///
739    /// Returns [`Error::Api`] on 401.
740    pub async fn list_workflows(&self) -> Result<ApiResponse<Vec<types::WorkflowSummary>>, Error> {
741        self.send_envelope(self.get("/api/v1/workflows")).await
742    }
743
744    /// `GET /api/v1/workflows/:name` -- Get workflow details.
745    ///
746    /// # Errors
747    ///
748    /// Returns [`Error::Api`] on 404 or 401.
749    pub async fn get_workflow(
750        &self,
751        name: &str,
752    ) -> Result<ApiResponse<types::WorkflowDetailResponse>, Error> {
753        self.send_envelope(self.get(&format!("/api/v1/workflows/{name}")))
754            .await
755    }
756
757    /// `POST /api/v1/workflows/:name/plan` -- Build the execution plan for a workflow.
758    ///
759    /// Planning has no side effect: no step runs, and no run is created.
760    ///
761    /// # Errors
762    ///
763    /// Returns [`Error::Api`] on 400 (invalid payload or `max_depth`), 401, or
764    /// 404 (workflow not registered).
765    pub async fn plan_workflow(
766        &self,
767        name: &str,
768        request: &types::PlanWorkflowRequest,
769    ) -> Result<ApiResponse<types::ExecutionPlanResponse>, Error> {
770        self.send_envelope(
771            self.post(&format!("/api/v1/workflows/{name}/plan"))
772                .json(request),
773        )
774        .await
775    }
776
777    /// `POST /api/v1/workflows/:name/pause` -- Pause a workflow: workers stop
778    /// picking its queued runs until it is resumed.
779    ///
780    /// # Errors
781    ///
782    /// Returns [`Error::Api`] on 404 (workflow not registered) or 403 (not an
783    /// admin).
784    pub async fn pause_workflow(
785        &self,
786        name: &str,
787    ) -> Result<ApiResponse<types::WorkflowPauseResponse>, Error> {
788        self.send_envelope(self.post(&format!("/api/v1/workflows/{name}/pause")))
789            .await
790    }
791
792    /// `POST /api/v1/workflows/:name/resume` -- Resume a paused workflow.
793    ///
794    /// # Errors
795    ///
796    /// Returns [`Error::Api`] on 404 (workflow not registered) or 403 (not an
797    /// admin).
798    pub async fn resume_workflow(
799        &self,
800        name: &str,
801    ) -> Result<ApiResponse<types::WorkflowPauseResponse>, Error> {
802        self.send_envelope(self.post(&format!("/api/v1/workflows/{name}/resume")))
803            .await
804    }
805
806    // ── Stats ──────────────────────────────────────────────────────
807
808    /// `GET /api/v1/stats` -- Aggregate statistics.
809    ///
810    /// # Errors
811    ///
812    /// Returns [`Error::Api`] on 401.
813    pub async fn get_stats(&self) -> Result<ApiResponse<types::StatsResponse>, Error> {
814        self.send_envelope(self.get("/api/v1/stats")).await
815    }
816
817    /// `GET /api/v1/stats/history` -- Time-bucketed historical statistics.
818    ///
819    /// Shorthand for [`stats_history_with`](Self::stats_history_with) without
820    /// run filters other than `workflow`.
821    ///
822    /// # Examples
823    ///
824    /// ```no_run
825    /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
826    /// let history = client.stats_history(Some("deploy"), Some("7d"), None).await?;
827    /// for bucket in &history.data.buckets {
828    ///     println!(
829    ///         "{}: {} completed, {} warning, {} running",
830    ///         bucket.time, bucket.completed, bucket.warning, bucket.running
831    ///     );
832    /// }
833    /// # Ok(())
834    /// # }
835    /// ```
836    ///
837    /// # Errors
838    ///
839    /// Returns [`Error::Api`] on 401 or 400.
840    pub async fn stats_history(
841        &self,
842        workflow: Option<&str>,
843        period: Option<&str>,
844        granularity: Option<&str>,
845    ) -> Result<ApiResponse<types::StatsHistoryResponse>, Error> {
846        self.stats_history_with(&StatsHistoryParams {
847            workflow,
848            period,
849            granularity,
850            ..StatsHistoryParams::default()
851        })
852        .await
853    }
854
855    /// `GET /api/v1/stats/history` with every query parameter.
856    ///
857    /// Returns one bucket per granularity step over the whole period
858    /// (zero-filled), each with a counter per run status and the success rate.
859    ///
860    /// # Examples
861    ///
862    /// ```no_run
863    /// use ironflow_sdk::client::StatsHistoryParams;
864    ///
865    /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
866    /// let params = StatsHistoryParams {
867    ///     period: Some("24h"),
868    ///     label: Some("env:prod"),
869    ///     ..Default::default()
870    /// };
871    /// let history = client.stats_history_with(&params).await?;
872    /// for bucket in &history.data.buckets {
873    ///     println!("{}: {:?}", bucket.time, bucket.success_rate_percent);
874    /// }
875    /// # Ok(())
876    /// # }
877    /// ```
878    ///
879    /// # Errors
880    ///
881    /// Returns [`Error::Api`] on 401 or 400.
882    pub async fn stats_history_with(
883        &self,
884        params: &StatsHistoryParams<'_>,
885    ) -> Result<ApiResponse<types::StatsHistoryResponse>, Error> {
886        self.send_envelope(self.get("/api/v1/stats/history").query(params))
887            .await
888    }
889
890    /// `GET /api/v1/stats/agent-roles` -- Cost, executions and failures per
891    /// agent role over a period, all workflows combined.
892    ///
893    /// `period` is `24h`, `7d`, `30d` or `90d`; the server defaults to `7d`.
894    ///
895    /// # Examples
896    ///
897    /// ```no_run
898    /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
899    /// let stats = client.agent_role_stats(Some("30d")).await?;
900    /// for role in &stats.data.roles {
901    ///     println!("{}: {} executions", role.agent_role, role.executions);
902    /// }
903    /// # Ok(())
904    /// # }
905    /// ```
906    ///
907    /// # Errors
908    ///
909    /// Returns [`Error::Api`] on 401, 403 or 400 (invalid period).
910    pub async fn agent_role_stats(
911        &self,
912        period: Option<&str>,
913    ) -> Result<ApiResponse<types::AgentRoleStatsResponse>, Error> {
914        let mut request = self.get("/api/v1/stats/agent-roles");
915        if let Some(period) = period {
916            request = request.query(&[("period", period)]);
917        }
918        self.send_envelope(request).await
919    }
920
921    // ── Steps ──────────────────────────────────────────────────────
922
923    /// `GET /api/v1/steps` -- List the agent steps of one agent role across
924    /// every run and workflow, newest first.
925    ///
926    /// # Examples
927    ///
928    /// ```no_run
929    /// use ironflow_sdk::client::ListStepsParams;
930    ///
931    /// # async fn example(client: &ironflow_sdk::IronflowClient) -> Result<(), ironflow_sdk::error::Error> {
932    /// let params = ListStepsParams {
933    ///     agent_role: "Reviewer",
934    ///     page: None,
935    ///     per_page: Some(50),
936    /// };
937    /// let steps = client.list_steps(&params).await?;
938    /// for step in &steps.data {
939    ///     println!("{} in run {}", step.name, step.run_id);
940    /// }
941    /// # Ok(())
942    /// # }
943    /// ```
944    ///
945    /// # Errors
946    ///
947    /// Returns [`Error::Api`] on 401, 403 or 400 (blank or too long role).
948    pub async fn list_steps(
949        &self,
950        params: &ListStepsParams<'_>,
951    ) -> Result<ApiResponse<Vec<types::StepResponse>>, Error> {
952        self.send_envelope(self.get("/api/v1/steps").query(params))
953            .await
954    }
955
956    // ── Auth ───────────────────────────────────────────────────────
957
958    /// `POST /api/v1/auth/sign-in` -- Sign in with credentials.
959    ///
960    /// Returns cookies/headers for session management.
961    ///
962    /// # Errors
963    ///
964    /// Returns [`Error::Api`] on 401 (invalid credentials).
965    pub async fn sign_in(&self, request: &types::SignInRequest) -> Result<(), Error> {
966        self.send_no_content(self.post("/api/v1/auth/sign-in").json(request))
967            .await
968    }
969
970    /// `POST /api/v1/auth/sign-out` -- Sign out.
971    ///
972    /// # Errors
973    ///
974    /// Returns [`Error::Api`] on 401.
975    pub async fn sign_out(&self) -> Result<(), Error> {
976        self.send_no_content(self.post("/api/v1/auth/sign-out"))
977            .await
978    }
979
980    /// `GET /api/v1/auth/me` -- Get current user info.
981    ///
982    /// # Errors
983    ///
984    /// Returns [`Error::Api`] on 401.
985    pub async fn me(&self) -> Result<ApiResponse<types::MeResponse>, Error> {
986        self.send_envelope(self.get("/api/v1/auth/me")).await
987    }
988
989    // ── API Keys ───────────────────────────────────────────────────
990
991    /// `GET /api/v1/api-keys` -- List API keys.
992    ///
993    /// # Errors
994    ///
995    /// Returns [`Error::Api`] on 401.
996    pub async fn list_api_keys(&self) -> Result<ApiResponse<Vec<types::ApiKeyResponse>>, Error> {
997        self.send_envelope(self.get("/api/v1/api-keys")).await
998    }
999
1000    /// `POST /api/v1/api-keys` -- Create a new API key.
1001    ///
1002    /// # Errors
1003    ///
1004    /// Returns [`Error::Api`] on 400 or 401.
1005    pub async fn create_api_key(
1006        &self,
1007        request: &types::CreateApiKeyRequest,
1008    ) -> Result<ApiResponse<types::CreateApiKeyResponse>, Error> {
1009        self.send_envelope_once(self.post("/api/v1/api-keys").json(request))
1010            .await
1011    }
1012
1013    /// `GET /api/v1/api-keys/scopes` -- List available API key scopes.
1014    ///
1015    /// # Errors
1016    ///
1017    /// Returns [`Error::Api`] on 401.
1018    pub async fn available_scopes(&self) -> Result<ApiResponse<Vec<types::ScopeEntry>>, Error> {
1019        self.send_envelope(self.get("/api/v1/api-keys/scopes"))
1020            .await
1021    }
1022
1023    /// `DELETE /api/v1/api-keys/:id` -- Delete an API key.
1024    ///
1025    /// # Errors
1026    ///
1027    /// Returns [`Error::Api`] on 404 or 401.
1028    pub async fn delete_api_key(&self, id: Uuid) -> Result<(), Error> {
1029        self.send_no_content(self.delete(&format!("/api/v1/api-keys/{id}")))
1030            .await
1031    }
1032
1033    // ── Users (admin) ──────────────────────────────────────────────
1034
1035    /// `GET /api/v1/users` -- List users (admin only).
1036    ///
1037    /// # Errors
1038    ///
1039    /// Returns [`Error::Api`] on 401 or 403.
1040    pub async fn list_users(&self) -> Result<ApiResponse<Vec<types::UserResponse>>, Error> {
1041        self.send_envelope(self.get("/api/v1/users")).await
1042    }
1043
1044    /// `POST /api/v1/users` -- Create a user (admin only).
1045    ///
1046    /// # Errors
1047    ///
1048    /// Returns [`Error::Api`] on 400, 401, 403, or 409.
1049    pub async fn create_user(
1050        &self,
1051        request: &types::CreateUserRequest,
1052    ) -> Result<ApiResponse<types::UserResponse>, Error> {
1053        self.send_envelope(self.post("/api/v1/users").json(request))
1054            .await
1055    }
1056
1057    /// `DELETE /api/v1/users/:id` -- Delete a user (admin only).
1058    ///
1059    /// # Errors
1060    ///
1061    /// Returns [`Error::Api`] on 404, 401, or 403.
1062    pub async fn delete_user(&self, id: Uuid) -> Result<(), Error> {
1063        self.send_no_content(self.delete(&format!("/api/v1/users/{id}")))
1064            .await
1065    }
1066
1067    /// `PATCH /api/v1/users/:id/role` -- Update a user's role (admin only).
1068    ///
1069    /// # Errors
1070    ///
1071    /// Returns [`Error::Api`] on 404, 401, or 403.
1072    pub async fn update_role(
1073        &self,
1074        id: Uuid,
1075        request: &types::UpdateRoleRequest,
1076    ) -> Result<ApiResponse<types::UserResponse>, Error> {
1077        self.send_envelope(
1078            self.patch(&format!("/api/v1/users/{id}/role"))
1079                .json(request),
1080        )
1081        .await
1082    }
1083
1084    /// `GET /api/v1/users/:id/groups` -- List a user's groups (admin only).
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns [`Error::Api`] on 401, 403, or 404.
1089    pub async fn get_user_groups(
1090        &self,
1091        id: Uuid,
1092    ) -> Result<ApiResponse<types::UserGroupsResponse>, Error> {
1093        self.send_envelope(self.get(&format!("/api/v1/users/{id}/groups")))
1094            .await
1095    }
1096
1097    /// `PUT /api/v1/users/:id/groups` -- Replace a user's groups (admin only).
1098    ///
1099    /// Group membership restricts who may vote on an approval gate whose rule
1100    /// lists approver groups. An empty slice removes the user from every
1101    /// group.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns [`Error::Api`] on 400 (invalid group name), 401, 403, or 404.
1106    pub async fn update_user_groups(
1107        &self,
1108        id: Uuid,
1109        groups: &[String],
1110    ) -> Result<ApiResponse<types::UserGroupsResponse>, Error> {
1111        let request = types::UpdateUserGroupsRequest {
1112            groups: groups.to_vec(),
1113        };
1114        self.send_envelope(
1115            self.put(&format!("/api/v1/users/{id}/groups"))
1116                .json(&request),
1117        )
1118        .await
1119    }
1120
1121    // ── Secrets (admin) ────────────────────────────────────────────
1122
1123    /// `GET /api/v1/secrets` -- List secrets (admin only).
1124    ///
1125    /// # Errors
1126    ///
1127    /// Returns [`Error::Api`] on 401 or 403.
1128    pub async fn list_secrets(&self) -> Result<ApiResponse<Vec<types::SecretResponse>>, Error> {
1129        self.send_envelope(self.get("/api/v1/secrets")).await
1130    }
1131
1132    /// `POST /api/v1/secrets` -- Create a secret (admin only).
1133    ///
1134    /// # Errors
1135    ///
1136    /// Returns [`Error::Api`] on 400, 401, or 403.
1137    pub async fn create_secret(
1138        &self,
1139        request: &types::SetSecretRequest,
1140    ) -> Result<ApiResponse<types::SecretResponse>, Error> {
1141        self.send_envelope(self.post("/api/v1/secrets").json(request))
1142            .await
1143    }
1144
1145    /// `PUT /api/v1/secrets/:key` -- Update a secret (admin only).
1146    ///
1147    /// # Errors
1148    ///
1149    /// Returns [`Error::Api`] on 404, 401, or 403.
1150    pub async fn update_secret(
1151        &self,
1152        key: &str,
1153        request: &types::UpdateSecretRequest,
1154    ) -> Result<ApiResponse<types::SecretResponse>, Error> {
1155        self.send_envelope(self.put(&format!("/api/v1/secrets/{key}")).json(request))
1156            .await
1157    }
1158
1159    /// `DELETE /api/v1/secrets/:key` -- Delete a secret (admin only).
1160    ///
1161    /// # Errors
1162    ///
1163    /// Returns [`Error::Api`] on 404, 401, or 403.
1164    pub async fn delete_secret(&self, key: &str) -> Result<(), Error> {
1165        self.send_no_content(self.delete(&format!("/api/v1/secrets/{key}")))
1166            .await
1167    }
1168
1169    /// `POST /api/v1/secrets/rotate` -- Re-encrypt one batch of secrets
1170    /// towards a key version (admin only).
1171    ///
1172    /// One call handles one batch. Loop, passing the returned `last_id` back
1173    /// as `after_id`, until `remaining` reaches zero or `last_id` is `None`.
1174    /// The operation is idempotent, so an interrupted rotation can simply be
1175    /// restarted.
1176    ///
1177    /// # Errors
1178    ///
1179    /// Returns [`Error::Api`] on 400 (unknown key version), 401, or 403.
1180    pub async fn rotate_secrets(
1181        &self,
1182        request: &types::RotateSecretsRequest,
1183    ) -> Result<ApiResponse<types::RotateSecretsResponse>, Error> {
1184        self.send_envelope(self.post("/api/v1/secrets/rotate").json(request))
1185            .await
1186    }
1187
1188    /// `GET /api/v1/secrets/key-versions` -- Encryption key ring status
1189    /// (admin only).
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns [`Error::Api`] on 401 or 403.
1194    pub async fn secret_key_versions(
1195        &self,
1196    ) -> Result<ApiResponse<types::KeyVersionsResponse>, Error> {
1197        self.send_envelope(self.get("/api/v1/secrets/key-versions"))
1198            .await
1199    }
1200
1201    // ── Run Logs ───────────────────────────────────────────────────
1202
1203    /// `GET /api/v1/runs/:id/logs` -- Retrieve persisted log lines for a run.
1204    ///
1205    /// Returns log entries with cursor-based pagination. Pass the
1206    /// `next_cursor` from a previous response to fetch the next page.
1207    ///
1208    /// # Errors
1209    ///
1210    /// Returns [`Error::Api`] on 404 (run not found) or 401.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```no_run
1215    /// use ironflow_sdk::IronflowClient;
1216    /// use ironflow_sdk::client::ListRunLogsFilter;
1217    /// use uuid::Uuid;
1218    ///
1219    /// # async fn example() -> Result<(), ironflow_sdk::Error> {
1220    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
1221    /// let run_id = Uuid::nil();
1222    /// let logs = client.get_run_logs(run_id, &ListRunLogsFilter::default()).await?;
1223    /// # Ok(())
1224    /// # }
1225    /// ```
1226    pub async fn get_run_logs(
1227        &self,
1228        run_id: Uuid,
1229        filter: &ListRunLogsFilter<'_>,
1230    ) -> Result<ApiResponse<Vec<types::LogEntry>>, Error> {
1231        self.send_envelope(
1232            self.get(&format!("/api/v1/runs/{run_id}/logs"))
1233                .query(filter),
1234        )
1235        .await
1236    }
1237
1238    // ── Audit Logs (admin) ─────────────────────────────────────────
1239
1240    /// `GET /api/v1/audit-logs` -- List audit log entries (admin only).
1241    ///
1242    /// # Errors
1243    ///
1244    /// Returns [`Error::Api`] on 401 or 403.
1245    pub async fn list_audit_logs(&self) -> Result<ApiResponse<Vec<types::AuditLogEntry>>, Error> {
1246        self.list_audit_logs_filtered(&ListAuditLogsFilter::default())
1247            .await
1248    }
1249
1250    /// `GET /api/v1/audit-logs` with query parameters (admin only).
1251    ///
1252    /// # Errors
1253    ///
1254    /// Returns [`Error::Api`] on 401 or 403.
1255    ///
1256    /// # Examples
1257    ///
1258    /// ```no_run
1259    /// use ironflow_sdk::IronflowClient;
1260    /// use ironflow_sdk::client::ListAuditLogsFilter;
1261    ///
1262    /// # async fn example() -> Result<(), ironflow_sdk::Error> {
1263    /// let client = IronflowClient::new("https://ironflow.example.com", "key");
1264    /// let filter = ListAuditLogsFilter {
1265    ///     event_type: Some("run_created"),
1266    ///     ..Default::default()
1267    /// };
1268    /// let entries = client.list_audit_logs_filtered(&filter).await?;
1269    /// # Ok(())
1270    /// # }
1271    /// ```
1272    pub async fn list_audit_logs_filtered(
1273        &self,
1274        filter: &ListAuditLogsFilter<'_>,
1275    ) -> Result<ApiResponse<Vec<types::AuditLogEntry>>, Error> {
1276        self.send_envelope(self.get("/api/v1/audit-logs").query(filter))
1277            .await
1278    }
1279}