Skip to main content

cloacina_client/
lib.rs

1/*
2 *  Copyright 2025-2026 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! Rust client for `cloacina-server` (CLOACI-I-0113 / T-0646).
18//!
19//! Extracted from `cloacinactl`'s crate-private client so external services
20//! consume the same surface the CLI does. DTOs come from
21//! [`cloacina-api-types`] — the same crate the server's handlers build their
22//! responses from, so request/response shapes cannot drift.
23//!
24//! ```no_run
25//! # async fn demo() -> Result<(), cloacina_client::ClientError> {
26//! use cloacina_client::ClientBuilder;
27//!
28//! let client = ClientBuilder::new("http://localhost:8080")
29//!     .api_key("clk_...")
30//!     .tenant("public")
31//!     .build()?;
32//!
33//! let accepted = client
34//!     .execute_workflow("my_workflow", serde_json::json!({"input": 42}))
35//!     .await?;
36//!
37//! use futures_util::StreamExt;
38//! let mut events = std::pin::pin!(client.follow_execution_events(&accepted.execution_id));
39//! while let Some(event) = events.next().await {
40//!     println!("{:?}", event?);
41//! }
42//! # Ok(())
43//! # }
44//! ```
45
46mod error;
47mod profile;
48mod ws;
49
50pub use cloacina_api_types as types;
51pub use error::ClientError;
52pub use profile::resolve_api_key_scheme;
53pub use ws::{DeliveryPush, SubscribeOptions, DELIVERY_PROTOCOL_VERSION};
54
55use std::sync::Arc;
56use std::time::Duration;
57
58use reqwest::{Method, Response};
59use serde::de::DeserializeOwned;
60use serde_json::Value;
61
62use cloacina_api_types::{
63    AccumulatorStatus, AgentInfo, CompilerStatus, CreateInstanceRequest, CreateKeyRequest,
64    CreateSecretRequest, CreateTenantRequest, DeclaredSurface, ExecuteRequest, ExecuteResponse,
65    ExecutionDetail, ExecutionEventsResponse, ExecutionSummary, ExecutionTasksResponse,
66    FireReactorRequest, FireReactorResponse, FireTriggerRequest, FireTriggerResponse, GraphStatus,
67    InjectAccumulatorRequest, InjectAccumulatorResponse, KeyCreatedResponse, KeyInfo,
68    KeyRevokedResponse, KeyRole, ListResponse, ReactorFire, ReactorFireTimeseries, ReactorStatus,
69    RotateSecretRequest, SecretDeletedResponse, SecretMetadataResponse, TenantCreatedResponse,
70    TenantListResponse, TenantRemovedResponse, TenantSummary, TriggerDetailResponse,
71    TriggerPauseResponse, TriggerScheduleSummary, WorkflowDeletedResponse, WorkflowDetail,
72    WorkflowInstanceSummary, WorkflowPauseResponse, WorkflowSourceResponse, WorkflowSummary,
73    WorkflowUploadedResponse, WsTicketResponse,
74};
75
76/// Builder for [`Client`].
77#[derive(Debug, Clone, Default)]
78pub struct ClientBuilder {
79    server: String,
80    api_key: Option<String>,
81    tenant: Option<String>,
82    connect_timeout: Option<Duration>,
83    timeout: Option<Duration>,
84}
85
86impl ClientBuilder {
87    /// Start a builder for the given server base URL
88    /// (e.g. `http://localhost:8080`).
89    pub fn new(server: impl Into<String>) -> Self {
90        Self {
91            server: server.into(),
92            ..Default::default()
93        }
94    }
95
96    /// Build from a `cloacinactl` profile in `~/.cloacina/config.toml`
97    /// (or `home`/config.toml when `home` is given). Resolves `env:` and
98    /// `file:` API-key schemes exactly like the CLI.
99    pub fn from_cloacinactl_profile(
100        home: Option<&std::path::Path>,
101        profile: Option<&str>,
102    ) -> Result<Self, ClientError> {
103        profile::builder_from_profile(home, profile)
104    }
105
106    /// API key, sent as `Authorization: Bearer <key>` on every request.
107    pub fn api_key(mut self, key: impl Into<String>) -> Self {
108        self.api_key = Some(key.into());
109        self
110    }
111
112    /// Default tenant for tenant-scoped calls (defaults to `public` —
113    /// the admin-schema tenant the server treats specially).
114    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
115        self.tenant = Some(tenant.into());
116        self
117    }
118
119    /// Connect timeout (default 5s).
120    pub fn connect_timeout(mut self, d: Duration) -> Self {
121        self.connect_timeout = Some(d);
122        self
123    }
124
125    /// Overall request timeout (default 30s).
126    pub fn timeout(mut self, d: Duration) -> Self {
127        self.timeout = Some(d);
128        self
129    }
130
131    pub fn build(self) -> Result<Client, ClientError> {
132        let api_key = self
133            .api_key
134            .ok_or_else(|| ClientError::Config("no API key configured".into()))?;
135        // reqwest's wasm builder exposes neither timeout knob — the browser
136        // owns connection/request timeouts there (CLOACI-T-0932).
137        #[cfg(not(target_arch = "wasm32"))]
138        let http = reqwest::Client::builder()
139            .connect_timeout(self.connect_timeout.unwrap_or(Duration::from_secs(5)))
140            .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
141            .build()
142            .map_err(ClientError::from_reqwest)?;
143        #[cfg(target_arch = "wasm32")]
144        let http = {
145            let _ = (&self.connect_timeout, &self.timeout);
146            reqwest::Client::builder()
147                .build()
148                .map_err(ClientError::from_reqwest)?
149        };
150        Ok(Client {
151            inner: Arc::new(ClientInner {
152                server: self.server.trim_end_matches('/').to_string(),
153                api_key,
154                tenant: self.tenant,
155                http,
156            }),
157        })
158    }
159}
160
161struct ClientInner {
162    server: String,
163    api_key: String,
164    tenant: Option<String>,
165    http: reqwest::Client,
166}
167
168/// Typed client for the cloacina-server REST API + delivery WebSocket.
169/// Cheap to clone (everything behind one `Arc`).
170#[derive(Clone)]
171pub struct Client {
172    inner: Arc<ClientInner>,
173}
174
175impl Client {
176    /// Server base URL this client talks to.
177    pub fn server(&self) -> &str {
178        &self.inner.server
179    }
180
181    /// Default tenant segment for tenant-scoped routes — `--tenant` value
182    /// or `public`.
183    pub fn tenant_segment(&self) -> &str {
184        self.inner.tenant.as_deref().unwrap_or("public")
185    }
186
187    fn url(&self, path: &str) -> String {
188        format!("{}/{}", self.inner.server, path.trim_start_matches('/'))
189    }
190
191    fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
192        // Tenant rides the URL path (`/tenants/{tenant}/...`), not a
193        // header — auth is just the bearer token.
194        self.inner
195            .http
196            .request(method, self.url(path))
197            .bearer_auth(&self.inner.api_key)
198    }
199
200    async fn parse<T: DeserializeOwned>(response: Response) -> Result<T, ClientError> {
201        let status = response.status().as_u16();
202        if response.status().is_success() {
203            return response
204                .json::<T>()
205                .await
206                .map_err(ClientError::from_reqwest);
207        }
208        let body = response.json::<Value>().await.unwrap_or(Value::Null);
209        Err(ClientError::from_status(status, body))
210    }
211
212    // ---- generic escape hatches (the surface cloacinactl's verb handlers
213    // were built on; kept public so consumers can reach undocumented or
214    // bleeding-edge routes) ----
215
216    /// Typed GET of an arbitrary path.
217    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
218        let response = self
219            .request(Method::GET, path)
220            .send()
221            .await
222            .map_err(ClientError::from_reqwest)?;
223        Self::parse(response).await
224    }
225
226    /// Typed POST (JSON body) to an arbitrary path.
227    pub async fn post_json<B: serde::Serialize + ?Sized, T: DeserializeOwned>(
228        &self,
229        path: &str,
230        body: &B,
231    ) -> Result<T, ClientError> {
232        let response = self
233            .request(Method::POST, path)
234            .json(body)
235            .send()
236            .await
237            .map_err(ClientError::from_reqwest)?;
238        Self::parse(response).await
239    }
240
241    /// Typed PUT (JSON body) to an arbitrary path.
242    pub async fn put_json<B: serde::Serialize + ?Sized, T: DeserializeOwned>(
243        &self,
244        path: &str,
245        body: &B,
246    ) -> Result<T, ClientError> {
247        let response = self
248            .request(Method::PUT, path)
249            .json(body)
250            .send()
251            .await
252            .map_err(ClientError::from_reqwest)?;
253        Self::parse(response).await
254    }
255
256    /// DELETE an arbitrary path, discarding any response body.
257    pub async fn delete_path(&self, path: &str) -> Result<(), ClientError> {
258        let response = self
259            .request(Method::DELETE, path)
260            .send()
261            .await
262            .map_err(ClientError::from_reqwest)?;
263        let status = response.status().as_u16();
264        if response.status().is_success() {
265            return Ok(());
266        }
267        let body = response.json::<Value>().await.unwrap_or(Value::Null);
268        Err(ClientError::from_status(status, body))
269    }
270
271    fn tenant_of<'a>(&'a self, tenant: Option<&'a str>) -> &'a str {
272        tenant.unwrap_or_else(|| self.tenant_segment())
273    }
274
275    // ---- operational ----
276
277    pub async fn health(&self) -> Result<Value, ClientError> {
278        self.get_json("/health").await
279    }
280
281    /// Raw readiness response — 503 is a meaningful state, not an error.
282    pub async fn ready(&self) -> Result<(u16, Value), ClientError> {
283        let response = self
284            .request(Method::GET, "/ready")
285            .send()
286            .await
287            .map_err(ClientError::from_reqwest)?;
288        let status = response.status().as_u16();
289        let body = response.json::<Value>().await.unwrap_or(Value::Null);
290        Ok((status, body))
291    }
292
293    // ---- keys ----
294
295    pub async fn create_key(
296        &self,
297        name: &str,
298        role: KeyRole,
299    ) -> Result<KeyCreatedResponse, ClientError> {
300        self.post_json(
301            "/v1/auth/keys",
302            &CreateKeyRequest {
303                name: name.to_string(),
304                role,
305            },
306        )
307        .await
308    }
309
310    pub async fn list_keys(&self) -> Result<ListResponse<KeyInfo>, ClientError> {
311        self.get_json("/v1/auth/keys").await
312    }
313
314    pub async fn revoke_key(&self, key_id: &str) -> Result<KeyRevokedResponse, ClientError> {
315        let response = self
316            .request(Method::DELETE, &format!("/v1/auth/keys/{key_id}"))
317            .send()
318            .await
319            .map_err(ClientError::from_reqwest)?;
320        Self::parse(response).await
321    }
322
323    pub async fn create_tenant_key(
324        &self,
325        name: &str,
326        role: KeyRole,
327        tenant: Option<&str>,
328    ) -> Result<KeyCreatedResponse, ClientError> {
329        let t = self.tenant_of(tenant);
330        self.post_json(
331            &format!("/v1/tenants/{t}/keys"),
332            &CreateKeyRequest {
333                name: name.to_string(),
334                role,
335            },
336        )
337        .await
338    }
339
340    /// List the keys scoped to one tenant — tenant-admin self-service
341    /// (CLOACI-T-0784).
342    pub async fn list_tenant_keys(
343        &self,
344        tenant: Option<&str>,
345    ) -> Result<ListResponse<KeyInfo>, ClientError> {
346        let t = self.tenant_of(tenant);
347        self.get_json(&format!("/v1/tenants/{t}/keys")).await
348    }
349
350    /// Revoke a key owned by one tenant — tenant-admin self-service
351    /// (CLOACI-T-0784). A cross-tenant or unknown id is reported as
352    /// not-found.
353    pub async fn revoke_tenant_key(
354        &self,
355        key_id: &str,
356        tenant: Option<&str>,
357    ) -> Result<KeyRevokedResponse, ClientError> {
358        let t = self.tenant_of(tenant);
359        let response = self
360            .request(Method::DELETE, &format!("/v1/tenants/{t}/keys/{key_id}"))
361            .send()
362            .await
363            .map_err(ClientError::from_reqwest)?;
364        Self::parse(response).await
365    }
366
367    /// Mint a single-use, short-lived WebSocket ticket.
368    pub async fn create_ws_ticket(&self) -> Result<WsTicketResponse, ClientError> {
369        self.post_json("/v1/auth/ws-ticket", &Value::Null).await
370    }
371
372    // ---- session / local auth (CLOACI-T-0794/0796/0803) ----
373    //
374    // These DTOs live in `cloacina-server` (not the shared `cloacina-api-types`
375    // contract crate), so the client returns the raw JSON `Value` — the same
376    // escape-hatch shape `get_json`/`post_json` already expose for routes whose
377    // typed DTO isn't on the wire-contract crate.
378
379    /// Username/password login — returns a minted bearer key (the JSON body
380    /// carries `key`, `tenant_id`, `role`, `expires_at`).
381    pub async fn local_login(
382        &self,
383        username: &str,
384        password: &str,
385        tenant: Option<&str>,
386    ) -> Result<Value, ClientError> {
387        let body = serde_json::json!({
388            "username": username,
389            "password": password,
390            "tenant": tenant,
391        });
392        self.post_json("/v1/auth/local/login", &body).await
393    }
394
395    /// Silently re-mint the caller's short-TTL key before it expires.
396    pub async fn refresh(&self) -> Result<Value, ClientError> {
397        self.post_json("/v1/auth/refresh", &Value::Null).await
398    }
399
400    /// Revoke the caller's key + forget any refresh session.
401    pub async fn logout(&self) -> Result<Value, ClientError> {
402        self.post_json("/v1/auth/logout", &Value::Null).await
403    }
404
405    /// The caller's own tenant + role + admin flag.
406    pub async fn whoami(&self) -> Result<Value, ClientError> {
407        self.get_json("/v1/auth/whoami").await
408    }
409
410    // ---- local accounts: tenant-admin management (CLOACI-T-0797) ----
411
412    /// List a tenant's local accounts (never the password hash).
413    pub async fn list_accounts(&self, tenant: Option<&str>) -> Result<Value, ClientError> {
414        let t = self.tenant_of(tenant);
415        self.get_json(&format!("/v1/tenants/{t}/accounts")).await
416    }
417
418    /// Create a local account in a tenant.
419    pub async fn create_account(
420        &self,
421        username: &str,
422        password: &str,
423        role: &str,
424        tenant: Option<&str>,
425    ) -> Result<Value, ClientError> {
426        let t = self.tenant_of(tenant);
427        let body = serde_json::json!({
428            "username": username,
429            "password": password,
430            "role": role,
431        });
432        self.post_json(&format!("/v1/tenants/{t}/accounts"), &body)
433            .await
434    }
435
436    /// Disable (not hard-delete) a local account, preserving history.
437    pub async fn disable_account(
438        &self,
439        account_id: &str,
440        tenant: Option<&str>,
441    ) -> Result<Value, ClientError> {
442        let t = self.tenant_of(tenant);
443        let response = self
444            .request(
445                Method::DELETE,
446                &format!("/v1/tenants/{t}/accounts/{account_id}"),
447            )
448            .send()
449            .await
450            .map_err(ClientError::from_reqwest)?;
451        Self::parse(response).await
452    }
453
454    /// Admin-reset a local account's password.
455    pub async fn reset_password(
456        &self,
457        account_id: &str,
458        password: &str,
459        tenant: Option<&str>,
460    ) -> Result<Value, ClientError> {
461        let t = self.tenant_of(tenant);
462        let body = serde_json::json!({ "password": password });
463        self.post_json(
464            &format!("/v1/tenants/{t}/accounts/{account_id}/password"),
465            &body,
466        )
467        .await
468    }
469
470    // ---- tenants ----
471
472    pub async fn create_tenant(
473        &self,
474        request: &CreateTenantRequest,
475    ) -> Result<TenantCreatedResponse, ClientError> {
476        self.post_json("/v1/tenants", request).await
477    }
478
479    pub async fn list_tenants(&self) -> Result<ListResponse<TenantSummary>, ClientError> {
480        self.get_json("/v1/tenants").await
481    }
482
483    pub async fn remove_tenant(
484        &self,
485        schema_name: &str,
486    ) -> Result<TenantRemovedResponse, ClientError> {
487        let response = self
488            .request(Method::DELETE, &format!("/v1/tenants/{schema_name}"))
489            .send()
490            .await
491            .map_err(ClientError::from_reqwest)?;
492        Self::parse(response).await
493    }
494
495    // ---- secrets (CLOACI-I-0133 / T-0862) ----
496    //
497    // Reads return metadata only — never a plaintext or ciphertext value.
498
499    /// List the tenant's secret metadata (names + timestamps, no values).
500    pub async fn list_secrets(
501        &self,
502        tenant: Option<&str>,
503    ) -> Result<ListResponse<SecretMetadataResponse>, ClientError> {
504        let t = self.tenant_of(tenant);
505        self.get_json(&format!("/v1/tenants/{t}/secrets")).await
506    }
507
508    /// Create a secret from a `{field: value}` map. Returns metadata only.
509    pub async fn create_secret(
510        &self,
511        request: &CreateSecretRequest,
512        tenant: Option<&str>,
513    ) -> Result<SecretMetadataResponse, ClientError> {
514        let t = self.tenant_of(tenant);
515        self.post_json(&format!("/v1/tenants/{t}/secrets"), request)
516            .await
517    }
518
519    /// One secret's metadata. Never returns a value.
520    pub async fn get_secret(
521        &self,
522        name: &str,
523        tenant: Option<&str>,
524    ) -> Result<SecretMetadataResponse, ClientError> {
525        let t = self.tenant_of(tenant);
526        self.get_json(&format!("/v1/tenants/{t}/secrets/{name}"))
527            .await
528    }
529
530    /// Rotate a secret's field map in place. Returns metadata only.
531    pub async fn rotate_secret(
532        &self,
533        name: &str,
534        request: &RotateSecretRequest,
535        tenant: Option<&str>,
536    ) -> Result<SecretMetadataResponse, ClientError> {
537        let t = self.tenant_of(tenant);
538        self.put_json(&format!("/v1/tenants/{t}/secrets/{name}"), request)
539            .await
540    }
541
542    /// Delete a secret.
543    pub async fn delete_secret(
544        &self,
545        name: &str,
546        tenant: Option<&str>,
547    ) -> Result<SecretDeletedResponse, ClientError> {
548        let t = self.tenant_of(tenant);
549        let response = self
550            .request(Method::DELETE, &format!("/v1/tenants/{t}/secrets/{name}"))
551            .send()
552            .await
553            .map_err(ClientError::from_reqwest)?;
554        Self::parse(response).await
555    }
556
557    // ---- workflows ----
558
559    /// Upload a `.cloacina` package (multipart).
560    pub async fn upload_workflow(
561        &self,
562        package: Vec<u8>,
563        tenant: Option<&str>,
564    ) -> Result<WorkflowUploadedResponse, ClientError> {
565        let t = self.tenant_of(tenant);
566        let part = reqwest::multipart::Part::bytes(package)
567            .file_name("package.cloacina")
568            .mime_str("application/octet-stream")
569            .map_err(ClientError::from_reqwest)?;
570        let form = reqwest::multipart::Form::new().part("file", part);
571        let response = self
572            .request(Method::POST, &format!("/v1/tenants/{t}/workflows"))
573            .multipart(form)
574            .send()
575            .await
576            .map_err(ClientError::from_reqwest)?;
577        Self::parse(response).await
578    }
579
580    pub async fn list_workflows(
581        &self,
582        tenant: Option<&str>,
583    ) -> Result<TenantListResponse<WorkflowSummary>, ClientError> {
584        let t = self.tenant_of(tenant);
585        self.get_json(&format!("/v1/tenants/{t}/workflows")).await
586    }
587
588    pub async fn get_workflow(
589        &self,
590        name: &str,
591        tenant: Option<&str>,
592    ) -> Result<WorkflowDetail, ClientError> {
593        let t = self.tenant_of(tenant);
594        self.get_json(&format!("/v1/tenants/{t}/workflows/{name}"))
595            .await
596    }
597
598    pub async fn delete_workflow(
599        &self,
600        name: &str,
601        version: &str,
602        tenant: Option<&str>,
603    ) -> Result<WorkflowDeletedResponse, ClientError> {
604        let t = self.tenant_of(tenant);
605        let response = self
606            .request(
607                Method::DELETE,
608                &format!("/v1/tenants/{t}/workflows/{name}/{version}"),
609            )
610            .send()
611            .await
612            .map_err(ClientError::from_reqwest)?;
613        Self::parse(response).await
614    }
615
616    // ---- triggers ----
617
618    pub async fn list_triggers(
619        &self,
620        limit: Option<i64>,
621        offset: Option<i64>,
622        tenant: Option<&str>,
623    ) -> Result<TenantListResponse<TriggerScheduleSummary>, ClientError> {
624        let t = self.tenant_of(tenant);
625        let mut path = format!("/v1/tenants/{t}/triggers");
626        let mut sep = '?';
627        if let Some(l) = limit {
628            path.push_str(&format!("{sep}limit={l}"));
629            sep = '&';
630        }
631        if let Some(o) = offset {
632            path.push_str(&format!("{sep}offset={o}"));
633        }
634        self.get_json(&path).await
635    }
636
637    pub async fn get_trigger(
638        &self,
639        name: &str,
640        tenant: Option<&str>,
641    ) -> Result<TriggerDetailResponse, ClientError> {
642        let t = self.tenant_of(tenant);
643        self.get_json(&format!("/v1/tenants/{t}/triggers/{name}"))
644            .await
645    }
646
647    // ---- workflow instances (CLOACI-T-0894) ----
648
649    /// Create a named, param-bound instance of a workflow. Omit `cron` on the
650    /// request to create an unscheduled binding.
651    pub async fn create_instance(
652        &self,
653        workflow: &str,
654        request: &CreateInstanceRequest,
655        tenant: Option<&str>,
656    ) -> Result<WorkflowInstanceSummary, ClientError> {
657        let t = self.tenant_of(tenant);
658        self.post_json(
659            &format!("/v1/tenants/{t}/workflows/{workflow}/instances"),
660            request,
661        )
662        .await
663    }
664
665    pub async fn list_instances(
666        &self,
667        workflow: &str,
668        limit: Option<i64>,
669        offset: Option<i64>,
670        tenant: Option<&str>,
671    ) -> Result<TenantListResponse<WorkflowInstanceSummary>, ClientError> {
672        let t = self.tenant_of(tenant);
673        let mut path = format!("/v1/tenants/{t}/workflows/{workflow}/instances");
674        let mut sep = '?';
675        if let Some(l) = limit {
676            path.push_str(&format!("{sep}limit={l}"));
677            sep = '&';
678        }
679        if let Some(o) = offset {
680            path.push_str(&format!("{sep}offset={o}"));
681        }
682        self.get_json(&path).await
683    }
684
685    pub async fn get_instance(
686        &self,
687        workflow: &str,
688        instance: &str,
689        tenant: Option<&str>,
690    ) -> Result<WorkflowInstanceSummary, ClientError> {
691        let t = self.tenant_of(tenant);
692        self.get_json(&format!(
693            "/v1/tenants/{t}/workflows/{workflow}/instances/{instance}"
694        ))
695        .await
696    }
697
698    pub async fn delete_instance(
699        &self,
700        workflow: &str,
701        instance: &str,
702        tenant: Option<&str>,
703    ) -> Result<(), ClientError> {
704        let t = self.tenant_of(tenant);
705        self.delete_path(&format!(
706            "/v1/tenants/{t}/workflows/{workflow}/instances/{instance}"
707        ))
708        .await
709    }
710
711    // ---- executions ----
712
713    pub async fn execute_workflow(
714        &self,
715        name: &str,
716        context: Value,
717    ) -> Result<ExecuteResponse, ClientError> {
718        let t = self.tenant_segment();
719        self.post_json(
720            &format!("/v1/tenants/{t}/workflows/{name}/execute"),
721            &ExecuteRequest {
722                context: Some(context),
723            },
724        )
725        .await
726    }
727
728    pub async fn list_executions(
729        &self,
730        query: &cloacina_api_types::ListExecutionsQuery,
731        tenant: Option<&str>,
732    ) -> Result<TenantListResponse<ExecutionSummary>, ClientError> {
733        let t = self.tenant_of(tenant);
734        let mut path = format!("/v1/tenants/{t}/executions");
735        let mut sep = '?';
736        let mut push = |k: &str, v: String| {
737            path.push_str(&format!("{sep}{k}={v}"));
738            sep = '&';
739        };
740        if let Some(s) = &query.status {
741            push("status", urlencoding::encode(s).into_owned());
742        }
743        if let Some(w) = &query.workflow {
744            push("workflow", urlencoding::encode(w).into_owned());
745        }
746        if let Some(l) = query.limit {
747            push("limit", l.to_string());
748        }
749        if let Some(o) = query.offset {
750            push("offset", o.to_string());
751        }
752        self.get_json(&path).await
753    }
754
755    pub async fn get_execution(
756        &self,
757        exec_id: &str,
758        tenant: Option<&str>,
759    ) -> Result<ExecutionDetail, ClientError> {
760        let t = self.tenant_of(tenant);
761        self.get_json(&format!("/v1/tenants/{t}/executions/{exec_id}"))
762            .await
763    }
764
765    pub async fn get_execution_events(
766        &self,
767        exec_id: &str,
768        tenant: Option<&str>,
769    ) -> Result<ExecutionEventsResponse, ClientError> {
770        let t = self.tenant_of(tenant);
771        self.get_json(&format!("/v1/tenants/{t}/executions/{exec_id}/events"))
772            .await
773    }
774
775    pub async fn get_execution_tasks(
776        &self,
777        tenant_id: &str,
778        exec_id: &str,
779    ) -> Result<ExecutionTasksResponse, ClientError> {
780        self.get_json(&format!(
781            "/v1/tenants/{tenant_id}/executions/{exec_id}/tasks"
782        ))
783        .await
784    }
785
786    // ---- computation-graph health ----
787
788    pub async fn list_accumulators(&self) -> Result<ListResponse<AccumulatorStatus>, ClientError> {
789        self.get_json("/v1/health/accumulators").await
790    }
791
792    pub async fn list_graphs(&self) -> Result<ListResponse<GraphStatus>, ClientError> {
793        self.get_json("/v1/health/graphs").await
794    }
795
796    pub async fn get_graph(&self, name: &str) -> Result<GraphStatus, ClientError> {
797        self.get_json(&format!("/v1/health/graphs/{name}")).await
798    }
799
800    pub async fn list_reactors(&self) -> Result<ListResponse<ReactorStatus>, ClientError> {
801        self.get_json("/v1/health/reactors").await
802    }
803
804    // ---- reactor operator controls (CLOACI-T-0772) ----
805
806    pub async fn fire_reactor(
807        &self,
808        name: &str,
809        request: &FireReactorRequest,
810    ) -> Result<FireReactorResponse, ClientError> {
811        self.post_json(&format!("/v1/health/reactors/{name}/fire"), request)
812            .await
813    }
814
815    pub async fn list_reactor_fires(
816        &self,
817        name: &str,
818    ) -> Result<ListResponse<ReactorFire>, ClientError> {
819        self.get_json(&format!("/v1/health/reactors/{name}/fires"))
820            .await
821    }
822
823    pub async fn reactor_fire_timeseries(
824        &self,
825        name: &str,
826    ) -> Result<ReactorFireTimeseries, ClientError> {
827        self.get_json(&format!("/v1/health/reactors/{name}/fires/timeseries"))
828            .await
829    }
830
831    pub async fn reactor_interface(&self, name: &str) -> Result<DeclaredSurface, ClientError> {
832        self.get_json(&format!("/v1/health/reactors/{name}/interface"))
833            .await
834    }
835
836    pub async fn accumulator_interface(&self, name: &str) -> Result<DeclaredSurface, ClientError> {
837        self.get_json(&format!("/v1/health/accumulators/{name}/interface"))
838            .await
839    }
840
841    pub async fn inject_accumulator(
842        &self,
843        name: &str,
844        request: &InjectAccumulatorRequest,
845    ) -> Result<InjectAccumulatorResponse, ClientError> {
846        self.post_json(&format!("/v1/health/accumulators/{name}/inject"), request)
847            .await
848    }
849
850    // ---- workflow & trigger pause/resume + source (CLOACI-T-0772) ----
851
852    pub async fn pause_workflow(
853        &self,
854        name: &str,
855        tenant: Option<&str>,
856    ) -> Result<WorkflowPauseResponse, ClientError> {
857        let t = self.tenant_of(tenant);
858        self.post_json(
859            &format!("/v1/tenants/{t}/workflows/{name}/pause"),
860            &Value::Null,
861        )
862        .await
863    }
864
865    pub async fn resume_workflow(
866        &self,
867        name: &str,
868        tenant: Option<&str>,
869    ) -> Result<WorkflowPauseResponse, ClientError> {
870        let t = self.tenant_of(tenant);
871        self.post_json(
872            &format!("/v1/tenants/{t}/workflows/{name}/resume"),
873            &Value::Null,
874        )
875        .await
876    }
877
878    pub async fn get_workflow_source(
879        &self,
880        name: &str,
881        tenant: Option<&str>,
882    ) -> Result<WorkflowSourceResponse, ClientError> {
883        let t = self.tenant_of(tenant);
884        self.get_json(&format!("/v1/tenants/{t}/workflows/{name}/source"))
885            .await
886    }
887
888    pub async fn pause_trigger(
889        &self,
890        name: &str,
891        tenant: Option<&str>,
892    ) -> Result<TriggerPauseResponse, ClientError> {
893        let t = self.tenant_of(tenant);
894        self.post_json(
895            &format!("/v1/tenants/{t}/triggers/{name}/pause"),
896            &Value::Null,
897        )
898        .await
899    }
900
901    /// Manually fire a trigger — fans out to every subscribed workflow
902    /// (CLOACI-T-0777).
903    pub async fn fire_trigger(
904        &self,
905        name: &str,
906        request: &FireTriggerRequest,
907        tenant: Option<&str>,
908    ) -> Result<FireTriggerResponse, ClientError> {
909        let t = self.tenant_of(tenant);
910        self.post_json(&format!("/v1/tenants/{t}/triggers/{name}/fire"), request)
911            .await
912    }
913
914    /// A trigger's declared pass-through interface — the union of its
915    /// subscribers' declared params (CLOACI-T-0777).
916    pub async fn trigger_interface(
917        &self,
918        name: &str,
919        tenant: Option<&str>,
920    ) -> Result<DeclaredSurface, ClientError> {
921        let t = self.tenant_of(tenant);
922        self.get_json(&format!("/v1/tenants/{t}/triggers/{name}/interface"))
923            .await
924    }
925
926    pub async fn resume_trigger(
927        &self,
928        name: &str,
929        tenant: Option<&str>,
930    ) -> Result<TriggerPauseResponse, ClientError> {
931        let t = self.tenant_of(tenant);
932        self.post_json(
933            &format!("/v1/tenants/{t}/triggers/{name}/resume"),
934            &Value::Null,
935        )
936        .await
937    }
938
939    // ---- fleet / compiler ----
940
941    pub async fn list_agents(&self) -> Result<ListResponse<AgentInfo>, ClientError> {
942        self.get_json("/v1/agents").await
943    }
944
945    pub async fn compiler_status(&self) -> Result<CompilerStatus, ClientError> {
946        self.get_json("/v1/compiler/status").await
947    }
948
949    // ---- WebSocket (substrate delivery) ----
950
951    /// Subscribe to the substrate delivery stream for a recipient. Yields
952    /// each push exactly once (dedup on row id), acking after yield;
953    /// reconnects with exponential backoff. See [`ws::SubscribeOptions`].
954    pub fn subscribe_delivery(
955        &self,
956        recipient: &str,
957        options: SubscribeOptions,
958    ) -> impl futures_util::Stream<Item = Result<DeliveryPush, ClientError>> + '_ {
959        ws::subscribe_delivery(self.clone(), recipient.to_string(), options)
960    }
961
962    /// Stream the JSON events of one workflow execution — recipient
963    /// convention `exec_events:<execution_id>`, the same stream
964    /// `cloacinactl execution follow` renders.
965    pub fn follow_execution_events(
966        &self,
967        execution_id: &str,
968    ) -> impl futures_util::Stream<Item = Result<Value, ClientError>> + '_ {
969        self.follow_execution_events_with(execution_id, SubscribeOptions::default())
970    }
971
972    /// [`follow_execution_events`](Self::follow_execution_events) with
973    /// explicit subscription options (reconnect policy, backoff).
974    pub fn follow_execution_events_with(
975        &self,
976        execution_id: &str,
977        options: SubscribeOptions,
978    ) -> impl futures_util::Stream<Item = Result<Value, ClientError>> + '_ {
979        ws::follow_execution_events(self.clone(), execution_id.to_string(), options)
980    }
981}