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, CreateKeyRequest, CreateTenantRequest,
64    DeclaredSurface, ExecuteRequest, ExecuteResponse, ExecutionDetail, ExecutionEventsResponse,
65    ExecutionSummary, ExecutionTasksResponse, FireReactorRequest, FireReactorResponse,
66    FireTriggerRequest, FireTriggerResponse, GraphStatus, InjectAccumulatorRequest,
67    InjectAccumulatorResponse, KeyCreatedResponse, KeyInfo, KeyRevokedResponse, KeyRole,
68    ListResponse, ReactorFire, ReactorFireTimeseries, ReactorStatus, TenantCreatedResponse,
69    TenantListResponse, TenantRemovedResponse, TenantSummary, TriggerDetailResponse,
70    TriggerPauseResponse, TriggerScheduleSummary, WorkflowDeletedResponse, WorkflowDetail,
71    WorkflowPauseResponse, WorkflowSourceResponse, WorkflowSummary, WorkflowUploadedResponse,
72    WsTicketResponse,
73};
74
75/// Builder for [`Client`].
76#[derive(Debug, Clone, Default)]
77pub struct ClientBuilder {
78    server: String,
79    api_key: Option<String>,
80    tenant: Option<String>,
81    connect_timeout: Option<Duration>,
82    timeout: Option<Duration>,
83}
84
85impl ClientBuilder {
86    /// Start a builder for the given server base URL
87    /// (e.g. `http://localhost:8080`).
88    pub fn new(server: impl Into<String>) -> Self {
89        Self {
90            server: server.into(),
91            ..Default::default()
92        }
93    }
94
95    /// Build from a `cloacinactl` profile in `~/.cloacina/config.toml`
96    /// (or `home`/config.toml when `home` is given). Resolves `env:` and
97    /// `file:` API-key schemes exactly like the CLI.
98    pub fn from_cloacinactl_profile(
99        home: Option<&std::path::Path>,
100        profile: Option<&str>,
101    ) -> Result<Self, ClientError> {
102        profile::builder_from_profile(home, profile)
103    }
104
105    /// API key, sent as `Authorization: Bearer <key>` on every request.
106    pub fn api_key(mut self, key: impl Into<String>) -> Self {
107        self.api_key = Some(key.into());
108        self
109    }
110
111    /// Default tenant for tenant-scoped calls (defaults to `public` —
112    /// the admin-schema tenant the server treats specially).
113    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
114        self.tenant = Some(tenant.into());
115        self
116    }
117
118    /// Connect timeout (default 5s).
119    pub fn connect_timeout(mut self, d: Duration) -> Self {
120        self.connect_timeout = Some(d);
121        self
122    }
123
124    /// Overall request timeout (default 30s).
125    pub fn timeout(mut self, d: Duration) -> Self {
126        self.timeout = Some(d);
127        self
128    }
129
130    pub fn build(self) -> Result<Client, ClientError> {
131        let api_key = self
132            .api_key
133            .ok_or_else(|| ClientError::Config("no API key configured".into()))?;
134        let http = reqwest::Client::builder()
135            .connect_timeout(self.connect_timeout.unwrap_or(Duration::from_secs(5)))
136            .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
137            .build()
138            .map_err(ClientError::from_reqwest)?;
139        Ok(Client {
140            inner: Arc::new(ClientInner {
141                server: self.server.trim_end_matches('/').to_string(),
142                api_key,
143                tenant: self.tenant,
144                http,
145            }),
146        })
147    }
148}
149
150struct ClientInner {
151    server: String,
152    api_key: String,
153    tenant: Option<String>,
154    http: reqwest::Client,
155}
156
157/// Typed client for the cloacina-server REST API + delivery WebSocket.
158/// Cheap to clone (everything behind one `Arc`).
159#[derive(Clone)]
160pub struct Client {
161    inner: Arc<ClientInner>,
162}
163
164impl Client {
165    /// Server base URL this client talks to.
166    pub fn server(&self) -> &str {
167        &self.inner.server
168    }
169
170    /// Default tenant segment for tenant-scoped routes — `--tenant` value
171    /// or `public`.
172    pub fn tenant_segment(&self) -> &str {
173        self.inner.tenant.as_deref().unwrap_or("public")
174    }
175
176    fn url(&self, path: &str) -> String {
177        format!("{}/{}", self.inner.server, path.trim_start_matches('/'))
178    }
179
180    fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
181        // Tenant rides the URL path (`/tenants/{tenant}/...`), not a
182        // header — auth is just the bearer token.
183        self.inner
184            .http
185            .request(method, self.url(path))
186            .bearer_auth(&self.inner.api_key)
187    }
188
189    async fn parse<T: DeserializeOwned>(response: Response) -> Result<T, ClientError> {
190        let status = response.status().as_u16();
191        if response.status().is_success() {
192            return response
193                .json::<T>()
194                .await
195                .map_err(ClientError::from_reqwest);
196        }
197        let body = response.json::<Value>().await.unwrap_or(Value::Null);
198        Err(ClientError::from_status(status, body))
199    }
200
201    // ---- generic escape hatches (the surface cloacinactl's verb handlers
202    // were built on; kept public so consumers can reach undocumented or
203    // bleeding-edge routes) ----
204
205    /// Typed GET of an arbitrary path.
206    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
207        let response = self
208            .request(Method::GET, path)
209            .send()
210            .await
211            .map_err(ClientError::from_reqwest)?;
212        Self::parse(response).await
213    }
214
215    /// Typed POST (JSON body) to an arbitrary path.
216    pub async fn post_json<B: serde::Serialize + ?Sized, T: DeserializeOwned>(
217        &self,
218        path: &str,
219        body: &B,
220    ) -> Result<T, ClientError> {
221        let response = self
222            .request(Method::POST, path)
223            .json(body)
224            .send()
225            .await
226            .map_err(ClientError::from_reqwest)?;
227        Self::parse(response).await
228    }
229
230    /// DELETE an arbitrary path, discarding any response body.
231    pub async fn delete_path(&self, path: &str) -> Result<(), ClientError> {
232        let response = self
233            .request(Method::DELETE, path)
234            .send()
235            .await
236            .map_err(ClientError::from_reqwest)?;
237        let status = response.status().as_u16();
238        if response.status().is_success() {
239            return Ok(());
240        }
241        let body = response.json::<Value>().await.unwrap_or(Value::Null);
242        Err(ClientError::from_status(status, body))
243    }
244
245    fn tenant_of<'a>(&'a self, tenant: Option<&'a str>) -> &'a str {
246        tenant.unwrap_or_else(|| self.tenant_segment())
247    }
248
249    // ---- operational ----
250
251    pub async fn health(&self) -> Result<Value, ClientError> {
252        self.get_json("/health").await
253    }
254
255    /// Raw readiness response — 503 is a meaningful state, not an error.
256    pub async fn ready(&self) -> Result<(u16, Value), ClientError> {
257        let response = self
258            .request(Method::GET, "/ready")
259            .send()
260            .await
261            .map_err(ClientError::from_reqwest)?;
262        let status = response.status().as_u16();
263        let body = response.json::<Value>().await.unwrap_or(Value::Null);
264        Ok((status, body))
265    }
266
267    // ---- keys ----
268
269    pub async fn create_key(
270        &self,
271        name: &str,
272        role: KeyRole,
273    ) -> Result<KeyCreatedResponse, ClientError> {
274        self.post_json(
275            "/v1/auth/keys",
276            &CreateKeyRequest {
277                name: name.to_string(),
278                role,
279            },
280        )
281        .await
282    }
283
284    pub async fn list_keys(&self) -> Result<ListResponse<KeyInfo>, ClientError> {
285        self.get_json("/v1/auth/keys").await
286    }
287
288    pub async fn revoke_key(&self, key_id: &str) -> Result<KeyRevokedResponse, ClientError> {
289        let response = self
290            .request(Method::DELETE, &format!("/v1/auth/keys/{key_id}"))
291            .send()
292            .await
293            .map_err(ClientError::from_reqwest)?;
294        Self::parse(response).await
295    }
296
297    pub async fn create_tenant_key(
298        &self,
299        name: &str,
300        role: KeyRole,
301        tenant: Option<&str>,
302    ) -> Result<KeyCreatedResponse, ClientError> {
303        let t = self.tenant_of(tenant);
304        self.post_json(
305            &format!("/v1/tenants/{t}/keys"),
306            &CreateKeyRequest {
307                name: name.to_string(),
308                role,
309            },
310        )
311        .await
312    }
313
314    /// List the keys scoped to one tenant — tenant-admin self-service
315    /// (CLOACI-T-0784).
316    pub async fn list_tenant_keys(
317        &self,
318        tenant: Option<&str>,
319    ) -> Result<ListResponse<KeyInfo>, ClientError> {
320        let t = self.tenant_of(tenant);
321        self.get_json(&format!("/v1/tenants/{t}/keys")).await
322    }
323
324    /// Revoke a key owned by one tenant — tenant-admin self-service
325    /// (CLOACI-T-0784). A cross-tenant or unknown id is reported as
326    /// not-found.
327    pub async fn revoke_tenant_key(
328        &self,
329        key_id: &str,
330        tenant: Option<&str>,
331    ) -> Result<KeyRevokedResponse, ClientError> {
332        let t = self.tenant_of(tenant);
333        let response = self
334            .request(Method::DELETE, &format!("/v1/tenants/{t}/keys/{key_id}"))
335            .send()
336            .await
337            .map_err(ClientError::from_reqwest)?;
338        Self::parse(response).await
339    }
340
341    /// Mint a single-use, short-lived WebSocket ticket.
342    pub async fn create_ws_ticket(&self) -> Result<WsTicketResponse, ClientError> {
343        self.post_json("/v1/auth/ws-ticket", &Value::Null).await
344    }
345
346    // ---- session / local auth (CLOACI-T-0794/0796/0803) ----
347    //
348    // These DTOs live in `cloacina-server` (not the shared `cloacina-api-types`
349    // contract crate), so the client returns the raw JSON `Value` — the same
350    // escape-hatch shape `get_json`/`post_json` already expose for routes whose
351    // typed DTO isn't on the wire-contract crate.
352
353    /// Username/password login — returns a minted bearer key (the JSON body
354    /// carries `key`, `tenant_id`, `role`, `expires_at`).
355    pub async fn local_login(
356        &self,
357        username: &str,
358        password: &str,
359        tenant: Option<&str>,
360    ) -> Result<Value, ClientError> {
361        let body = serde_json::json!({
362            "username": username,
363            "password": password,
364            "tenant": tenant,
365        });
366        self.post_json("/v1/auth/local/login", &body).await
367    }
368
369    /// Silently re-mint the caller's short-TTL key before it expires.
370    pub async fn refresh(&self) -> Result<Value, ClientError> {
371        self.post_json("/v1/auth/refresh", &Value::Null).await
372    }
373
374    /// Revoke the caller's key + forget any refresh session.
375    pub async fn logout(&self) -> Result<Value, ClientError> {
376        self.post_json("/v1/auth/logout", &Value::Null).await
377    }
378
379    /// The caller's own tenant + role + admin flag.
380    pub async fn whoami(&self) -> Result<Value, ClientError> {
381        self.get_json("/v1/auth/whoami").await
382    }
383
384    // ---- local accounts: tenant-admin management (CLOACI-T-0797) ----
385
386    /// List a tenant's local accounts (never the password hash).
387    pub async fn list_accounts(&self, tenant: Option<&str>) -> Result<Value, ClientError> {
388        let t = self.tenant_of(tenant);
389        self.get_json(&format!("/v1/tenants/{t}/accounts")).await
390    }
391
392    /// Create a local account in a tenant.
393    pub async fn create_account(
394        &self,
395        username: &str,
396        password: &str,
397        role: &str,
398        tenant: Option<&str>,
399    ) -> Result<Value, ClientError> {
400        let t = self.tenant_of(tenant);
401        let body = serde_json::json!({
402            "username": username,
403            "password": password,
404            "role": role,
405        });
406        self.post_json(&format!("/v1/tenants/{t}/accounts"), &body)
407            .await
408    }
409
410    /// Disable (not hard-delete) a local account, preserving history.
411    pub async fn disable_account(
412        &self,
413        account_id: &str,
414        tenant: Option<&str>,
415    ) -> Result<Value, ClientError> {
416        let t = self.tenant_of(tenant);
417        let response = self
418            .request(
419                Method::DELETE,
420                &format!("/v1/tenants/{t}/accounts/{account_id}"),
421            )
422            .send()
423            .await
424            .map_err(ClientError::from_reqwest)?;
425        Self::parse(response).await
426    }
427
428    /// Admin-reset a local account's password.
429    pub async fn reset_password(
430        &self,
431        account_id: &str,
432        password: &str,
433        tenant: Option<&str>,
434    ) -> Result<Value, ClientError> {
435        let t = self.tenant_of(tenant);
436        let body = serde_json::json!({ "password": password });
437        self.post_json(
438            &format!("/v1/tenants/{t}/accounts/{account_id}/password"),
439            &body,
440        )
441        .await
442    }
443
444    // ---- tenants ----
445
446    pub async fn create_tenant(
447        &self,
448        request: &CreateTenantRequest,
449    ) -> Result<TenantCreatedResponse, ClientError> {
450        self.post_json("/v1/tenants", request).await
451    }
452
453    pub async fn list_tenants(&self) -> Result<ListResponse<TenantSummary>, ClientError> {
454        self.get_json("/v1/tenants").await
455    }
456
457    pub async fn remove_tenant(
458        &self,
459        schema_name: &str,
460    ) -> Result<TenantRemovedResponse, ClientError> {
461        let response = self
462            .request(Method::DELETE, &format!("/v1/tenants/{schema_name}"))
463            .send()
464            .await
465            .map_err(ClientError::from_reqwest)?;
466        Self::parse(response).await
467    }
468
469    // ---- workflows ----
470
471    /// Upload a `.cloacina` package (multipart).
472    pub async fn upload_workflow(
473        &self,
474        package: Vec<u8>,
475        tenant: Option<&str>,
476    ) -> Result<WorkflowUploadedResponse, ClientError> {
477        let t = self.tenant_of(tenant);
478        let part = reqwest::multipart::Part::bytes(package)
479            .file_name("package.cloacina")
480            .mime_str("application/octet-stream")
481            .map_err(ClientError::from_reqwest)?;
482        let form = reqwest::multipart::Form::new().part("file", part);
483        let response = self
484            .request(Method::POST, &format!("/v1/tenants/{t}/workflows"))
485            .multipart(form)
486            .send()
487            .await
488            .map_err(ClientError::from_reqwest)?;
489        Self::parse(response).await
490    }
491
492    pub async fn list_workflows(
493        &self,
494        tenant: Option<&str>,
495    ) -> Result<TenantListResponse<WorkflowSummary>, ClientError> {
496        let t = self.tenant_of(tenant);
497        self.get_json(&format!("/v1/tenants/{t}/workflows")).await
498    }
499
500    pub async fn get_workflow(
501        &self,
502        name: &str,
503        tenant: Option<&str>,
504    ) -> Result<WorkflowDetail, ClientError> {
505        let t = self.tenant_of(tenant);
506        self.get_json(&format!("/v1/tenants/{t}/workflows/{name}"))
507            .await
508    }
509
510    pub async fn delete_workflow(
511        &self,
512        name: &str,
513        version: &str,
514        tenant: Option<&str>,
515    ) -> Result<WorkflowDeletedResponse, ClientError> {
516        let t = self.tenant_of(tenant);
517        let response = self
518            .request(
519                Method::DELETE,
520                &format!("/v1/tenants/{t}/workflows/{name}/{version}"),
521            )
522            .send()
523            .await
524            .map_err(ClientError::from_reqwest)?;
525        Self::parse(response).await
526    }
527
528    // ---- triggers ----
529
530    pub async fn list_triggers(
531        &self,
532        limit: Option<i64>,
533        offset: Option<i64>,
534        tenant: Option<&str>,
535    ) -> Result<TenantListResponse<TriggerScheduleSummary>, ClientError> {
536        let t = self.tenant_of(tenant);
537        let mut path = format!("/v1/tenants/{t}/triggers");
538        let mut sep = '?';
539        if let Some(l) = limit {
540            path.push_str(&format!("{sep}limit={l}"));
541            sep = '&';
542        }
543        if let Some(o) = offset {
544            path.push_str(&format!("{sep}offset={o}"));
545        }
546        self.get_json(&path).await
547    }
548
549    pub async fn get_trigger(
550        &self,
551        name: &str,
552        tenant: Option<&str>,
553    ) -> Result<TriggerDetailResponse, ClientError> {
554        let t = self.tenant_of(tenant);
555        self.get_json(&format!("/v1/tenants/{t}/triggers/{name}"))
556            .await
557    }
558
559    // ---- executions ----
560
561    pub async fn execute_workflow(
562        &self,
563        name: &str,
564        context: Value,
565    ) -> Result<ExecuteResponse, ClientError> {
566        let t = self.tenant_segment();
567        self.post_json(
568            &format!("/v1/tenants/{t}/workflows/{name}/execute"),
569            &ExecuteRequest {
570                context: Some(context),
571            },
572        )
573        .await
574    }
575
576    pub async fn list_executions(
577        &self,
578        query: &cloacina_api_types::ListExecutionsQuery,
579        tenant: Option<&str>,
580    ) -> Result<TenantListResponse<ExecutionSummary>, ClientError> {
581        let t = self.tenant_of(tenant);
582        let mut path = format!("/v1/tenants/{t}/executions");
583        let mut sep = '?';
584        let mut push = |k: &str, v: String| {
585            path.push_str(&format!("{sep}{k}={v}"));
586            sep = '&';
587        };
588        if let Some(s) = &query.status {
589            push("status", urlencoding::encode(s).into_owned());
590        }
591        if let Some(w) = &query.workflow {
592            push("workflow", urlencoding::encode(w).into_owned());
593        }
594        if let Some(l) = query.limit {
595            push("limit", l.to_string());
596        }
597        if let Some(o) = query.offset {
598            push("offset", o.to_string());
599        }
600        self.get_json(&path).await
601    }
602
603    pub async fn get_execution(
604        &self,
605        exec_id: &str,
606        tenant: Option<&str>,
607    ) -> Result<ExecutionDetail, ClientError> {
608        let t = self.tenant_of(tenant);
609        self.get_json(&format!("/v1/tenants/{t}/executions/{exec_id}"))
610            .await
611    }
612
613    pub async fn get_execution_events(
614        &self,
615        exec_id: &str,
616        tenant: Option<&str>,
617    ) -> Result<ExecutionEventsResponse, ClientError> {
618        let t = self.tenant_of(tenant);
619        self.get_json(&format!("/v1/tenants/{t}/executions/{exec_id}/events"))
620            .await
621    }
622
623    pub async fn get_execution_tasks(
624        &self,
625        tenant_id: &str,
626        exec_id: &str,
627    ) -> Result<ExecutionTasksResponse, ClientError> {
628        self.get_json(&format!(
629            "/v1/tenants/{tenant_id}/executions/{exec_id}/tasks"
630        ))
631        .await
632    }
633
634    // ---- computation-graph health ----
635
636    pub async fn list_accumulators(&self) -> Result<ListResponse<AccumulatorStatus>, ClientError> {
637        self.get_json("/v1/health/accumulators").await
638    }
639
640    pub async fn list_graphs(&self) -> Result<ListResponse<GraphStatus>, ClientError> {
641        self.get_json("/v1/health/graphs").await
642    }
643
644    pub async fn get_graph(&self, name: &str) -> Result<GraphStatus, ClientError> {
645        self.get_json(&format!("/v1/health/graphs/{name}")).await
646    }
647
648    pub async fn list_reactors(&self) -> Result<ListResponse<ReactorStatus>, ClientError> {
649        self.get_json("/v1/health/reactors").await
650    }
651
652    // ---- reactor operator controls (CLOACI-T-0772) ----
653
654    pub async fn fire_reactor(
655        &self,
656        name: &str,
657        request: &FireReactorRequest,
658    ) -> Result<FireReactorResponse, ClientError> {
659        self.post_json(&format!("/v1/health/reactors/{name}/fire"), request)
660            .await
661    }
662
663    pub async fn list_reactor_fires(
664        &self,
665        name: &str,
666    ) -> Result<ListResponse<ReactorFire>, ClientError> {
667        self.get_json(&format!("/v1/health/reactors/{name}/fires"))
668            .await
669    }
670
671    pub async fn reactor_fire_timeseries(
672        &self,
673        name: &str,
674    ) -> Result<ReactorFireTimeseries, ClientError> {
675        self.get_json(&format!("/v1/health/reactors/{name}/fires/timeseries"))
676            .await
677    }
678
679    pub async fn reactor_interface(&self, name: &str) -> Result<DeclaredSurface, ClientError> {
680        self.get_json(&format!("/v1/health/reactors/{name}/interface"))
681            .await
682    }
683
684    pub async fn accumulator_interface(&self, name: &str) -> Result<DeclaredSurface, ClientError> {
685        self.get_json(&format!("/v1/health/accumulators/{name}/interface"))
686            .await
687    }
688
689    pub async fn inject_accumulator(
690        &self,
691        name: &str,
692        request: &InjectAccumulatorRequest,
693    ) -> Result<InjectAccumulatorResponse, ClientError> {
694        self.post_json(&format!("/v1/health/accumulators/{name}/inject"), request)
695            .await
696    }
697
698    // ---- workflow & trigger pause/resume + source (CLOACI-T-0772) ----
699
700    pub async fn pause_workflow(
701        &self,
702        name: &str,
703        tenant: Option<&str>,
704    ) -> Result<WorkflowPauseResponse, ClientError> {
705        let t = self.tenant_of(tenant);
706        self.post_json(
707            &format!("/v1/tenants/{t}/workflows/{name}/pause"),
708            &Value::Null,
709        )
710        .await
711    }
712
713    pub async fn resume_workflow(
714        &self,
715        name: &str,
716        tenant: Option<&str>,
717    ) -> Result<WorkflowPauseResponse, ClientError> {
718        let t = self.tenant_of(tenant);
719        self.post_json(
720            &format!("/v1/tenants/{t}/workflows/{name}/resume"),
721            &Value::Null,
722        )
723        .await
724    }
725
726    pub async fn get_workflow_source(
727        &self,
728        name: &str,
729        tenant: Option<&str>,
730    ) -> Result<WorkflowSourceResponse, ClientError> {
731        let t = self.tenant_of(tenant);
732        self.get_json(&format!("/v1/tenants/{t}/workflows/{name}/source"))
733            .await
734    }
735
736    pub async fn pause_trigger(
737        &self,
738        name: &str,
739        tenant: Option<&str>,
740    ) -> Result<TriggerPauseResponse, ClientError> {
741        let t = self.tenant_of(tenant);
742        self.post_json(
743            &format!("/v1/tenants/{t}/triggers/{name}/pause"),
744            &Value::Null,
745        )
746        .await
747    }
748
749    /// Manually fire a trigger — fans out to every subscribed workflow
750    /// (CLOACI-T-0777).
751    pub async fn fire_trigger(
752        &self,
753        name: &str,
754        request: &FireTriggerRequest,
755        tenant: Option<&str>,
756    ) -> Result<FireTriggerResponse, ClientError> {
757        let t = self.tenant_of(tenant);
758        self.post_json(&format!("/v1/tenants/{t}/triggers/{name}/fire"), request)
759            .await
760    }
761
762    /// A trigger's declared pass-through interface — the union of its
763    /// subscribers' declared params (CLOACI-T-0777).
764    pub async fn trigger_interface(
765        &self,
766        name: &str,
767        tenant: Option<&str>,
768    ) -> Result<DeclaredSurface, ClientError> {
769        let t = self.tenant_of(tenant);
770        self.get_json(&format!("/v1/tenants/{t}/triggers/{name}/interface"))
771            .await
772    }
773
774    pub async fn resume_trigger(
775        &self,
776        name: &str,
777        tenant: Option<&str>,
778    ) -> Result<TriggerPauseResponse, ClientError> {
779        let t = self.tenant_of(tenant);
780        self.post_json(
781            &format!("/v1/tenants/{t}/triggers/{name}/resume"),
782            &Value::Null,
783        )
784        .await
785    }
786
787    // ---- fleet / compiler ----
788
789    pub async fn list_agents(&self) -> Result<ListResponse<AgentInfo>, ClientError> {
790        self.get_json("/v1/agents").await
791    }
792
793    pub async fn compiler_status(&self) -> Result<CompilerStatus, ClientError> {
794        self.get_json("/v1/compiler/status").await
795    }
796
797    // ---- WebSocket (substrate delivery) ----
798
799    /// Subscribe to the substrate delivery stream for a recipient. Yields
800    /// each push exactly once (dedup on row id), acking after yield;
801    /// reconnects with exponential backoff. See [`ws::SubscribeOptions`].
802    pub fn subscribe_delivery(
803        &self,
804        recipient: &str,
805        options: SubscribeOptions,
806    ) -> impl futures_util::Stream<Item = Result<DeliveryPush, ClientError>> + '_ {
807        ws::subscribe_delivery(self.clone(), recipient.to_string(), options)
808    }
809
810    /// Stream the JSON events of one workflow execution — recipient
811    /// convention `exec_events:<execution_id>`, the same stream
812    /// `cloacinactl execution follow` renders.
813    pub fn follow_execution_events(
814        &self,
815        execution_id: &str,
816    ) -> impl futures_util::Stream<Item = Result<Value, ClientError>> + '_ {
817        self.follow_execution_events_with(execution_id, SubscribeOptions::default())
818    }
819
820    /// [`follow_execution_events`](Self::follow_execution_events) with
821    /// explicit subscription options (reconnect policy, backoff).
822    pub fn follow_execution_events_with(
823        &self,
824        execution_id: &str,
825        options: SubscribeOptions,
826    ) -> impl futures_util::Stream<Item = Result<Value, ClientError>> + '_ {
827        ws::follow_execution_events(self.clone(), execution_id.to_string(), options)
828    }
829}