Skip to main content

fabric_sdk/
lib.rs

1//! Rust client SDK for the Fabric platform.
2//!
3//! This is a thin wrapper around the Fabric REST API. Every method
4//! corresponds to an endpoint under `/v1/...` on a running Fabric API.
5//! For the higher-level resource-oriented design, see the TypeScript SDK
6//! at `sdks/typescript/` — this crate is intentionally minimal and
7//! returns `serde_json::Value` rather than typed models. A typed rewrite
8//! is tracked in `specs/plans/031-rust-sdk-rewrite.md` and the Rust SDK
9//! backlog in `specs/SPRINT.md`.
10
11use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use std::time::Duration;
15
16#[cfg(not(target_arch = "wasm32"))]
17use std::time::Instant;
18#[cfg(target_arch = "wasm32")]
19use web_time::Instant;
20
21pub mod sse;
22pub use sse::SseEvent;
23
24/// Portable async sleep that works on both native (via tokio) and wasm32
25/// (via gloo-timers / setTimeout).
26async fn sleep(d: Duration) {
27    #[cfg(not(target_arch = "wasm32"))]
28    {
29        tokio::time::sleep(d).await;
30    }
31    #[cfg(target_arch = "wasm32")]
32    {
33        gloo_timers::future::TimeoutFuture::new(d.as_millis() as u32).await;
34    }
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum FabricError {
39    #[error("HTTP error: {0}")]
40    Http(#[from] reqwest::Error),
41    #[error("API error ({code}): {message}")]
42    Api { code: String, message: String },
43    #[error("{0}")]
44    Other(String),
45}
46
47pub type Result<T> = std::result::Result<T, FabricError>;
48
49// ── Workflow run output models ───────────────────────────────────────
50//
51// The SDK is otherwise serde_json::Value-typed, but variants is a core
52// feature of the platform — consumers iterate per-variant outputs and
53// artifacts on every run, so the run-output endpoint exposes typed
54// structs for ergonomics. Other endpoints stay untyped pending the
55// broader SDK rewrite tracked in plan 031.
56
57/// Artifact produced by a workflow run.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RunArtifact {
60    pub id: String,
61    pub run_id: String,
62    pub asset_id: Option<String>,
63    pub task_id: String,
64    pub filename: String,
65    pub content_type: Option<String>,
66    pub produced_by: Option<String>,
67    pub consumed_from: Option<Vec<String>>,
68    pub metadata: Option<serde_json::Value>,
69    pub artifact_type: String,
70    /// Variant (0-indexed) that produced this artifact.
71    #[serde(default)]
72    pub variant_index: i16,
73    pub created_at: Option<String>,
74    pub download_url: Option<String>,
75    pub download_url_expires_at: Option<String>,
76}
77
78/// Card layout shape for a variant — declared by the workflow's
79/// `WorkflowOutput.kind`. Consumer UIs branch on this to pick the
80/// right rendering. `None` means the workflow didn't declare a kind
81/// and consumers should fall back to deriving from artifact MIME types.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "lowercase")]
84pub enum VariantKind {
85    Video,
86    Carousel,
87    Image,
88    Text,
89}
90
91/// A single variant's output and artifacts within a run.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct VariantOutput {
94    pub variant_index: i16,
95    /// Sub-workflow that produced this entry. For bundle runs each
96    /// entry has its own; for non-bundle runs every entry carries
97    /// the run's single workflow_name. `None` on legacy data.
98    #[serde(default)]
99    pub workflow_name: Option<String>,
100    /// Card shape for this variant, lifted from the workflow's
101    /// `WorkflowOutput.kind`. `None` when the workflow didn't declare
102    /// a kind.
103    #[serde(default)]
104    pub kind: Option<VariantKind>,
105    pub output: Option<serde_json::Value>,
106    #[serde(default)]
107    pub artifacts: Vec<RunArtifact>,
108}
109
110/// One slot in a heterogeneous bundle submission.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct BundleEntry {
113    /// Sub-workflow name (must resolve via the registry).
114    pub workflow: String,
115    /// Per-sub input. Platform-level `_fabric_*` keys are injected by
116    /// the engine; only domain inputs go here.
117    pub input: serde_json::Value,
118}
119
120/// Creative direction for a Regenerate run.
121///
122/// Surfaced on the Regenerate modal as radio buttons. Workflows that
123/// honor this hint read it off `input.regenerate.direction` and
124/// modulate their prompts (e.g. `Punchier` = shorter, tighter hooks).
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "kebab-case")]
127pub enum RegenerateDirection {
128    Punchier,
129    Deeper,
130    Contrarian,
131    Visual,
132    DataFirst,
133    Surprise,
134}
135
136/// Aspect of the original output to preserve during regeneration.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum RegenerateKeepFlag {
140    Platform,
141    Format,
142    CoreTopic,
143    ToneOfVoice,
144}
145
146/// Hints for a regeneration run.
147///
148/// Set on a submit request to mark a run as a regeneration of an
149/// earlier run/variant. The server persists `parent_run_id` and
150/// `parent_variant_index` as run lineage; workflows that care read
151/// `direction` / `keep` / `extra_instructions` to modulate prompts.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Regenerate {
154    pub direction: RegenerateDirection,
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub keep: Vec<RegenerateKeepFlag>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub extra_instructions: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub parent_run_id: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub parent_variant_index: Option<u16>,
163}
164
165/// Result of `GET /v1/workflows/runs/{id}/output`.
166///
167/// Always exposes a uniform `outputs` array with one entry per variant
168/// (default 1). Iterate `outputs` rather than branching on `variants`.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct RunOutput {
171    pub run_id: String,
172    pub status: String,
173    pub error: Option<String>,
174    #[serde(default)]
175    pub nodes: Vec<serde_json::Value>,
176    #[serde(default = "default_variants")]
177    pub variants: i16,
178    #[serde(default)]
179    pub outputs: Vec<VariantOutput>,
180}
181
182fn default_variants() -> i16 {
183    1
184}
185
186impl RunOutput {
187    /// Convenience for single-variant runs: ``outputs[0].output``.
188    pub fn first_output(&self) -> Option<&serde_json::Value> {
189        self.outputs.first().and_then(|v| v.output.as_ref())
190    }
191
192    /// Flat iterator over artifacts across every variant.
193    pub fn all_artifacts(&self) -> impl Iterator<Item = &RunArtifact> {
194        self.outputs.iter().flat_map(|v| v.artifacts.iter())
195    }
196}
197
198/// Synchronous-style Fabric API client.
199///
200/// Configure scoping defaults (`organization_id`, `team_id`, `user_id`)
201/// via [`set_organization_id`](Self::set_organization_id),
202/// [`set_team_id`](Self::set_team_id), and
203/// [`set_user_id`](Self::set_user_id). Methods like
204/// [`list_runs`](Self::list_runs) auto-inject these as query params, and
205/// per-call arguments override them.
206pub struct FabricClient {
207    client: reqwest::Client,
208    base_url: String,
209    organization_id: String,
210    team_id: Option<String>,
211    user_id: Option<String>,
212}
213
214impl FabricClient {
215    /// Create a new client authenticated with an API key.
216    pub fn new(base_url: &str, api_key: &str) -> Result<Self> {
217        let mut headers = HeaderMap::new();
218        headers.insert(
219            AUTHORIZATION,
220            HeaderValue::from_str(&format!("Bearer {api_key}"))
221                .map_err(|e| FabricError::Other(e.to_string()))?,
222        );
223        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
224
225        let client = reqwest::Client::builder()
226            .default_headers(headers)
227            .build()?;
228
229        Ok(Self {
230            client,
231            base_url: base_url.trim_end_matches('/').to_string(),
232            organization_id: String::new(),
233            team_id: None,
234            user_id: None,
235        })
236    }
237
238    /// Create a new client authenticated with a principal ID header.
239    pub fn with_principal(base_url: &str, principal_id: &str) -> Result<Self> {
240        let mut headers = HeaderMap::new();
241        headers.insert(
242            "X-Principal-Id",
243            HeaderValue::from_str(principal_id).map_err(|e| FabricError::Other(e.to_string()))?,
244        );
245        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
246
247        let client = reqwest::Client::builder()
248            .default_headers(headers)
249            .build()?;
250
251        Ok(Self {
252            client,
253            base_url: base_url.trim_end_matches('/').to_string(),
254            organization_id: String::new(),
255            team_id: None,
256            user_id: None,
257        })
258    }
259
260    /// Set the organization ID used for scoped requests (e.g. workflow runs).
261    pub fn set_organization_id(&mut self, org_id: &str) {
262        self.organization_id = org_id.to_string();
263    }
264
265    /// Set the default team ID used for scoped run queries.
266    pub fn set_team_id(&mut self, team_id: Option<&str>) {
267        self.team_id = team_id.map(str::to_string);
268    }
269
270    /// Set the default user ID (Fabric principal UUID matching
271    /// `fabric_workflow_runs.created_by`) used for scoped run queries.
272    pub fn set_user_id(&mut self, user_id: Option<&str>) {
273        self.user_id = user_id.map(str::to_string);
274    }
275
276    /// Resolve an org ID from an explicit value or the client default.
277    fn resolve_org_id(&self, org_id: Option<&str>) -> Result<String> {
278        org_id
279            .map(str::to_string)
280            .or_else(|| {
281                if self.organization_id.is_empty() {
282                    None
283                } else {
284                    Some(self.organization_id.clone())
285                }
286            })
287            .ok_or_else(|| {
288                FabricError::Other(
289                    "Organization ID required — pass it or set it on the client".to_string(),
290                )
291            })
292    }
293
294    // ── Private helpers ──────────────────────────────────────────────
295
296    async fn request<T: serde::de::DeserializeOwned>(
297        &self,
298        method: reqwest::Method,
299        path: &str,
300        body: Option<serde_json::Value>,
301    ) -> Result<T> {
302        let url = format!("{}{path}", self.base_url);
303        let mut req = self.client.request(method, &url);
304        if let Some(b) = body {
305            req = req.json(&b);
306        }
307        let resp = req.send().await?;
308        let status = resp.status();
309        let json: serde_json::Value = resp.json().await?;
310
311        if let Some(err) = json.get("error") {
312            return Err(FabricError::Api {
313                code: status.as_u16().to_string(),
314                message: err
315                    .as_str()
316                    .map(|s| s.to_string())
317                    .unwrap_or_else(|| err.to_string()),
318            });
319        }
320
321        if let Some(data) = json.get("data") {
322            serde_json::from_value(data.clone())
323                .map_err(|e| FabricError::Other(format!("Failed to deserialize data: {e}")))
324        } else {
325            serde_json::from_value(json)
326                .map_err(|e| FabricError::Other(format!("Failed to deserialize response: {e}")))
327        }
328    }
329
330    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
331        self.request(reqwest::Method::GET, path, None).await
332    }
333
334    async fn post<T: serde::de::DeserializeOwned>(
335        &self,
336        path: &str,
337        body: serde_json::Value,
338    ) -> Result<T> {
339        self.request(reqwest::Method::POST, path, Some(body)).await
340    }
341
342    async fn post_empty(&self, path: &str) -> Result<()> {
343        let url = format!("{}{path}", self.base_url);
344        let resp = self.client.post(&url).send().await?;
345        let status = resp.status();
346        let json: serde_json::Value = resp.json().await?;
347
348        if let Some(err) = json.get("error") {
349            return Err(FabricError::Api {
350                code: status.as_u16().to_string(),
351                message: err
352                    .as_str()
353                    .map(|s| s.to_string())
354                    .unwrap_or_else(|| err.to_string()),
355            });
356        }
357        Ok(())
358    }
359
360    async fn delete_req(&self, path: &str) -> Result<()> {
361        let url = format!("{}{path}", self.base_url);
362        let resp = self.client.delete(&url).send().await?;
363        let status = resp.status();
364        let json: serde_json::Value = resp.json().await?;
365
366        if let Some(err) = json.get("error") {
367            return Err(FabricError::Api {
368                code: status.as_u16().to_string(),
369                message: err
370                    .as_str()
371                    .map(|s| s.to_string())
372                    .unwrap_or_else(|| err.to_string()),
373            });
374        }
375        Ok(())
376    }
377
378    // ── System ───────────────────────────────────────────────────────
379
380    pub async fn health_check(&self) -> Result<serde_json::Value> {
381        self.get("/health").await
382    }
383
384    pub async fn system_status(&self) -> Result<serde_json::Value> {
385        self.get("/v1/system/status").await
386    }
387
388    // ── Identity ─────────────────────────────────────────────────────
389
390    pub async fn get_me(&self) -> Result<serde_json::Value> {
391        self.get("/v1/me").await
392    }
393
394    pub async fn get_my_organizations(&self) -> Result<Vec<serde_json::Value>> {
395        self.get("/v1/me/organizations").await
396    }
397
398    pub async fn get_my_teams(&self) -> Result<Vec<serde_json::Value>> {
399        self.get("/v1/me/teams").await
400    }
401
402    pub async fn get_my_permissions(&self) -> Result<Vec<serde_json::Value>> {
403        self.get("/v1/me/permissions").await
404    }
405
406    // ── Organizations ────────────────────────────────────────────────
407
408    pub async fn create_organization(&self, slug: &str, name: &str) -> Result<serde_json::Value> {
409        self.post("/v1/organizations", json!({ "slug": slug, "name": name }))
410            .await
411    }
412
413    pub async fn list_organizations(&self) -> Result<Vec<serde_json::Value>> {
414        self.get("/v1/organizations").await
415    }
416
417    /// Get an organization by ID. Falls back to the client's organization ID when `None`.
418    pub async fn get_organization(&self, org_id: Option<&str>) -> Result<serde_json::Value> {
419        let id = self.resolve_org_id(org_id)?;
420        self.get(&format!("/v1/organizations/{id}")).await
421    }
422
423    /// List teams in an organization. Falls back to the client's organization ID when `None`.
424    pub async fn list_org_teams(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
425        let id = self.resolve_org_id(org_id)?;
426        self.get(&format!("/v1/organizations/{id}/teams")).await
427    }
428
429    /// List members in an organization. Falls back to the client's organization ID when `None`.
430    pub async fn list_org_members(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
431        let id = self.resolve_org_id(org_id)?;
432        self.get(&format!("/v1/organizations/{id}/members")).await
433    }
434
435    // ── Teams ────────────────────────────────────────────────────────
436
437    /// Create a team. Falls back to the client's organization ID when `None`.
438    pub async fn create_team(
439        &self,
440        org_id: Option<&str>,
441        slug: &str,
442        name: &str,
443    ) -> Result<serde_json::Value> {
444        let id = self.resolve_org_id(org_id)?;
445        self.post(
446            &format!("/v1/organizations/{id}/teams"),
447            json!({ "slug": slug, "name": name }),
448        )
449        .await
450    }
451
452    pub async fn get_team(&self, team_id: &str) -> Result<serde_json::Value> {
453        self.get(&format!("/v1/teams/{team_id}")).await
454    }
455
456    // ── Invitations ──────────────────────────────────────────────────
457
458    /// Create an invitation. Falls back to the client's organization ID when `None`.
459    pub async fn create_invitation(
460        &self,
461        org_id: Option<&str>,
462        email: &str,
463        role: &str,
464    ) -> Result<serde_json::Value> {
465        let id = self.resolve_org_id(org_id)?;
466        self.post(
467            &format!("/v1/organizations/{id}/invitations"),
468            json!({ "email": email, "role": role }),
469        )
470        .await
471    }
472
473    pub async fn accept_invitation(&self, invitation_id: &str) -> Result<()> {
474        self.post_empty(&format!("/v1/invitations/{invitation_id}/accept"))
475            .await
476    }
477
478    /// Revoke an outstanding invitation. (POST to `/revoke` — not DELETE.)
479    pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<()> {
480        self.post_empty(&format!("/v1/invitations/{invitation_id}/revoke"))
481            .await
482    }
483
484    // ── Authorization ────────────────────────────────────────────────
485
486    pub async fn check_permission(&self, action: &str, resource: Option<&str>) -> Result<bool> {
487        let mut body = json!({ "action": action });
488        if let Some(r) = resource {
489            body["resource"] = serde_json::Value::String(r.to_string());
490        }
491        let resp: serde_json::Value = self.post("/v1/authz/check", body).await?;
492        Ok(resp
493            .get("allowed")
494            .and_then(|v| v.as_bool())
495            .unwrap_or(false))
496    }
497
498    pub async fn check_permissions(
499        &self,
500        checks: Vec<serde_json::Value>,
501    ) -> Result<Vec<serde_json::Value>> {
502        self.post("/v1/authz/check-batch", json!({ "checks": checks }))
503            .await
504    }
505
506    // ── API Keys ─────────────────────────────────────────────────────
507
508    /// Create an API key. Falls back to the client's organization ID when `None`.
509    pub async fn create_api_key(
510        &self,
511        name: &str,
512        org_id: Option<&str>,
513        scopes: Option<Vec<&str>>,
514    ) -> Result<serde_json::Value> {
515        let id = self.resolve_org_id(org_id)?;
516        let mut body = json!({ "name": name, "organization_id": id });
517        if let Some(s) = scopes {
518            body["scopes"] = serde_json::Value::from(s);
519        }
520        self.post("/v1/api-keys", body).await
521    }
522
523    pub async fn list_api_keys(&self) -> Result<Vec<serde_json::Value>> {
524        self.get("/v1/api-keys").await
525    }
526
527    pub async fn get_api_key(&self, key_id: &str) -> Result<serde_json::Value> {
528        self.get(&format!("/v1/api-keys/{key_id}")).await
529    }
530
531    pub async fn delete_api_key(&self, key_id: &str) -> Result<()> {
532        self.delete_req(&format!("/v1/api-keys/{key_id}")).await
533    }
534
535    pub async fn disable_api_key(&self, key_id: &str) -> Result<()> {
536        self.post_empty(&format!("/v1/api-keys/{key_id}/disable"))
537            .await
538    }
539
540    pub async fn rotate_api_key(&self, key_id: &str) -> Result<serde_json::Value> {
541        self.post(&format!("/v1/api-keys/{key_id}/rotate"), json!({}))
542            .await
543    }
544
545    // ── Workflows (registry + runs) ──────────────────────────────────
546
547    /// Create or update a workflow in the registry.
548    ///
549    /// Posts to `/v1/workflow-registry`. The `body` should match
550    /// `CreateRegistryRequest` on the API side (name, language, source,
551    /// entry_point, etc.).
552    pub async fn upsert_workflow(&self, name: &str, body: serde_json::Value) -> Result<String> {
553        let mut payload = body;
554        payload["name"] = serde_json::Value::String(name.to_string());
555        let resp: serde_json::Value = self.post("/v1/workflow-registry", payload).await?;
556        resp.get("id")
557            .and_then(|v| v.as_str())
558            .map(|s| s.to_string())
559            .ok_or_else(|| FabricError::Other("Missing workflow id in response".to_string()))
560    }
561
562    /// List workflows in the registry (hierarchical: global > org > team).
563    ///
564    /// Always passes `limit=500` because the API's default of 50
565    /// silently truncates the dropdown for any installation with more
566    /// than ~50 registered workflows. The API caps the value at 200
567    /// internally as of writing — passing 500 just means "give me as
568    /// many as you can". A pagination-aware variant can be added if
569    /// anyone needs to scroll past page one, but no current consumer
570    /// does.
571    pub async fn list_workflows(&self) -> Result<Vec<serde_json::Value>> {
572        let mut params = vec![("limit", "500".to_string())];
573        if !self.organization_id.is_empty() {
574            params.push(("organization_id", self.organization_id.clone()));
575            if let Some(team) = &self.team_id {
576                params.push(("team_id", team.clone()));
577            }
578        }
579        let qs = params
580            .iter()
581            .map(|(k, v)| format!("{k}={v}"))
582            .collect::<Vec<_>>()
583            .join("&");
584        self.get(&format!("/v1/workflow-registry?{qs}")).await
585    }
586
587    /// Submit a workflow run by name.
588    ///
589    /// Posts to `POST /v1/workflows/run?name=<workflow_name>` and
590    /// returns the resulting run ID immediately.
591    pub async fn run_workflow(
592        &self,
593        workflow_name: &str,
594        input: serde_json::Value,
595    ) -> Result<String> {
596        self.run_workflow_opts(workflow_name, input, false).await
597    }
598
599    /// Like [`run_workflow`](Self::run_workflow) with server-side input
600    /// validation against the workflow's registered `input_schema`
601    /// (plan 038 §5). Returns 400 with per-field errors if the payload
602    /// doesn't conform. Workflows without a schema are unaffected.
603    pub async fn run_workflow_validated(
604        &self,
605        workflow_name: &str,
606        input: serde_json::Value,
607    ) -> Result<String> {
608        self.run_workflow_opts(workflow_name, input, true).await
609    }
610
611    /// Submit a workflow with `variants` parallel executions (1–10).
612    ///
613    /// `variants` is part of the workflow input contract — it's set as
614    /// `input.variants` on the wire. This helper inserts the value
615    /// into a clone of `input` for callers that prefer the named arg.
616    /// The engine fans out N parallel runs and the run-output API
617    /// always returns an `outputs: [{variant_index, output, artifacts}]`
618    /// array, with one entry per variant.
619    pub async fn run_workflow_with_variants(
620        &self,
621        workflow_name: &str,
622        input: serde_json::Value,
623        variants: u16,
624    ) -> Result<String> {
625        let mut input = input;
626        if let Some(obj) = input.as_object_mut() {
627            obj.entry("variants").or_insert_with(|| json!(variants));
628        }
629        self.run_workflow_opts(workflow_name, input, false).await
630    }
631
632    /// Submit a regeneration of an earlier run/variant.
633    ///
634    /// Lifts the [`Regenerate`] hints into `input.regenerate` and the
635    /// optional `variants` count into `input.variants`. The server
636    /// validates the parent reference (same org, in-range variant
637    /// index) and persists `parent_run_id` / `parent_variant_index` as
638    /// run lineage.
639    pub async fn run_regenerate(
640        &self,
641        workflow_name: &str,
642        input: serde_json::Value,
643        regenerate: Regenerate,
644        variants: Option<u16>,
645    ) -> Result<String> {
646        let mut input = input;
647        if let Some(obj) = input.as_object_mut() {
648            if let Some(v) = variants {
649                obj.entry("variants").or_insert_with(|| json!(v));
650            }
651            obj.insert(
652                "regenerate".to_string(),
653                serde_json::to_value(&regenerate)
654                    .map_err(|e| FabricError::Other(format!("serialize regenerate: {e}")))?,
655            );
656        }
657        self.run_workflow_opts(workflow_name, input, false).await
658    }
659
660    /// Submit a heterogeneous **bundle** — N different workflows
661    /// running in parallel — and return the run id.
662    ///
663    /// Each [`BundleEntry`] runs as its own subprocess; the engine
664    /// aggregates per-entry outputs into the run's `outputs` array.
665    /// Use [`get_run_output`](Self::get_run_output) to fetch the typed
666    /// [`RunOutput`] when the run completes; each `VariantOutput`
667    /// carries the producing sub-`workflow_name` and (when declared)
668    /// `kind`. For one creative brief that should produce multiple
669    /// kinds of artifact (video + carousel + thread), this is the
670    /// primitive — `run_workflow_with_variants` is N copies of one
671    /// workflow, this is N different workflows.
672    pub async fn run_bundle(&self, bundle: Vec<BundleEntry>) -> Result<String> {
673        let input = json!({ "bundle": bundle });
674        self.run_workflow_opts("bundle/ad-hoc", input, false).await
675    }
676
677    async fn run_workflow_opts(
678        &self,
679        workflow_name: &str,
680        input: serde_json::Value,
681        validate: bool,
682    ) -> Result<String> {
683        let mut path = format!("/v1/workflows/run?name={}", urlencode(workflow_name));
684        if !self.organization_id.is_empty() {
685            path.push_str(&format!("&organization_id={}", self.organization_id));
686        }
687        if let Some(team) = &self.team_id {
688            path.push_str(&format!("&team_id={team}"));
689        }
690        if validate {
691            path.push_str("&validate=true");
692        }
693        let resp: serde_json::Value = self.post(&path, json!({ "input": input })).await?;
694        resp.get("id")
695            .and_then(|v| v.as_str())
696            .map(|s| s.to_string())
697            .ok_or_else(|| FabricError::Other("Missing run id in response".to_string()))
698    }
699
700    pub async fn get_run(&self, run_id: &str) -> Result<serde_json::Value> {
701        self.get(&format!("/v1/workflows/runs/{run_id}")).await
702    }
703
704    /// Fetch a run's final output, per-task timeline, and artifacts.
705    ///
706    /// Hits `GET /v1/workflows/runs/{id}/output`. Unlike `get_run`,
707    /// which returns the bare `fabric_workflow_runs` row, this endpoint
708    /// returns the workflow's terminal output (sourced from the canonical
709    /// store, falling back to Sayiir's snapshot when the eager finalizer
710    /// hasn't run yet) along with any artifacts the workflow registered.
711    ///
712    /// Returns a typed [`RunOutput`] whose `outputs` array always has
713    /// `variants` entries (default 1). Each [`VariantOutput`] carries
714    /// its `output` and the [`RunArtifact`]s produced by that variant,
715    /// each with a signed `download_url`.
716    ///
717    /// `expires_in_secs` controls the signed URL TTL (server clamps to
718    /// `[1, 86_400]`, defaults to 3600). `u32` is used so the generated
719    /// wasm-bindgen type comes out as `number | null` in TypeScript
720    /// rather than `bigint | null`.
721    pub async fn get_run_output(
722        &self,
723        run_id: &str,
724        expires_in_secs: Option<u32>,
725    ) -> Result<RunOutput> {
726        let path = match expires_in_secs {
727            Some(ttl) => format!("/v1/workflows/runs/{run_id}/output?expires_in={ttl}"),
728            None => format!("/v1/workflows/runs/{run_id}/output"),
729        };
730        self.get(&path).await
731    }
732
733    /// Fetch the input/output/task schemas registered for a workflow.
734    ///
735    /// Hits the dedicated `GET /v1/workflow-schemas/{name}` endpoint
736    /// (plan 038 §3e) rather than pulling the whole registry entry.
737    /// Returns `{ name, input_schema, output_schema, task_schemas,
738    /// warnings }` — the same shape the TypeScript SDK's
739    /// `workflows.registry.getSchemas()` returns.
740    ///
741    /// Workflows that don't declare Pydantic types come back with all
742    /// three schema fields set to `null`. The call still succeeds, the
743    /// consumer just learns there's no machine-readable contract.
744    pub async fn get_workflow_schemas(&self, name: &str) -> Result<serde_json::Value> {
745        let mut path = format!("/v1/workflow-schemas/{}", urlencode(name));
746        if !self.organization_id.is_empty() {
747            path.push_str(&format!("?organization_id={}", self.organization_id));
748        }
749        self.get(&path).await
750    }
751
752    pub async fn cancel_run(&self, run_id: &str) -> Result<()> {
753        self.post_empty(&format!("/v1/workflows/runs/{run_id}/cancel"))
754            .await
755    }
756
757    pub async fn pause_run(&self, run_id: &str) -> Result<()> {
758        self.post_empty(&format!("/v1/workflows/runs/{run_id}/pause"))
759            .await
760    }
761
762    pub async fn resume_run(&self, run_id: &str) -> Result<()> {
763        self.post_empty(&format!("/v1/workflows/runs/{run_id}/resume"))
764            .await
765    }
766
767    pub async fn wait_for_run(&self, run_id: &str) -> Result<serde_json::Value> {
768        let timeout = Duration::from_secs(300);
769        let poll_interval = Duration::from_secs(2);
770        let start = Instant::now();
771
772        loop {
773            let run = self.get_run(run_id).await?;
774            if let Some("completed" | "failed" | "cancelled") =
775                run.get("status").and_then(|v| v.as_str())
776            {
777                return Ok(run);
778            }
779            if start.elapsed() >= timeout {
780                return Err(FabricError::Other(format!(
781                    "Timed out waiting for run {run_id} after {timeout:?}"
782                )));
783            }
784            sleep(poll_interval).await;
785        }
786    }
787
788    /// Submit a workflow, wait for it to finish, and return the full
789    /// output with artifacts that have signed download URLs.
790    ///
791    /// Combines `run_workflow` + `wait_for_run` + `get_run_output` so
792    /// callers get downloadable artifact URLs in a single call.
793    ///
794    /// `expires_in_secs` controls the signed URL TTL (default 3600,
795    /// max 86400).
796    pub async fn run_workflow_and_get_output(
797        &self,
798        workflow_name: &str,
799        input: serde_json::Value,
800        expires_in_secs: Option<u32>,
801    ) -> Result<RunOutput> {
802        let run_id = self.run_workflow(workflow_name, input).await?;
803        self.wait_for_run(&run_id).await?;
804        self.get_run_output(&run_id, expires_in_secs).await
805    }
806
807    /// Submit a bundle, wait for every sub-workflow to finish, and
808    /// return the typed [`RunOutput`] with one entry per bundle entry.
809    pub async fn run_bundle_and_get_output(
810        &self,
811        bundle: Vec<BundleEntry>,
812        expires_in_secs: Option<u32>,
813    ) -> Result<RunOutput> {
814        let run_id = self.run_bundle(bundle).await?;
815        self.wait_for_run(&run_id).await?;
816        self.get_run_output(&run_id, expires_in_secs).await
817    }
818
819    /// Download an artifact's binary content using its signed download URL.
820    ///
821    /// Pass a `download_url` from the artifacts returned by `get_run_output`.
822    /// Returns `None` if the URL is empty. The signed URL requires no auth
823    /// headers — it is self-contained.
824    pub async fn download_artifact(&self, download_url: &str) -> Result<Vec<u8>> {
825        let resp = self
826            .client
827            .get(download_url)
828            .send()
829            .await
830            .map_err(|e| FabricError::Other(format!("download request failed: {e}")))?;
831        if !resp.status().is_success() {
832            return Err(FabricError::Api {
833                code: resp.status().as_u16().to_string(),
834                message: format!("Failed to download artifact: {}", resp.status()),
835            });
836        }
837        resp.bytes()
838            .await
839            .map(|b| b.to_vec())
840            .map_err(|e| FabricError::Other(format!("download body read failed: {e}")))
841    }
842
843    /// List workflow runs scoped by org / team / creator.
844    ///
845    /// Each of `organization_id`, `team_id`, and `created_by` falls back
846    /// to the client's configured defaults when `None`. To list across
847    /// all users of a team without the client's default user filter,
848    /// clear the default via [`set_user_id(None)`](Self::set_user_id)
849    /// first.
850    ///
851    /// `created_by` must be the Fabric principal UUID stored in
852    /// `fabric_workflow_runs.created_by` — the same UUID that the
853    /// submitter's principal had at run submit time.
854    pub async fn list_runs(
855        &self,
856        organization_id: Option<&str>,
857        team_id: Option<&str>,
858        created_by: Option<&str>,
859        limit: Option<i64>,
860        offset: Option<i64>,
861    ) -> Result<Vec<serde_json::Value>> {
862        let org = self.resolve_org_id(organization_id)?;
863
864        let team = team_id.map(str::to_string).or_else(|| self.team_id.clone());
865        let user = created_by
866            .map(str::to_string)
867            .or_else(|| self.user_id.clone());
868
869        let mut path = format!("/v1/workflows/runs?organization_id={}", urlencode(&org));
870        if let Some(t) = team.as_deref() {
871            path.push_str(&format!("&team_id={}", urlencode(t)));
872        }
873        if let Some(u) = user.as_deref() {
874            path.push_str(&format!("&created_by={}", urlencode(u)));
875        }
876        if let Some(l) = limit {
877            path.push_str(&format!("&limit={l}"));
878        }
879        if let Some(o) = offset {
880            path.push_str(&format!("&offset={o}"));
881        }
882        self.get(&path).await
883    }
884
885    /// List workflow runs waiting for a signal (pending approvals).
886    pub async fn list_waiting_runs(
887        &self,
888        organization_id: Option<&str>,
889        team_id: Option<&str>,
890        created_by: Option<&str>,
891    ) -> Result<Vec<serde_json::Value>> {
892        let org = self.resolve_org_id(organization_id)?;
893
894        let team = team_id.map(str::to_string).or_else(|| self.team_id.clone());
895        let user = created_by
896            .map(str::to_string)
897            .or_else(|| self.user_id.clone());
898
899        let mut path = format!(
900            "/v1/workflows/runs/waiting?organization_id={}",
901            urlencode(&org)
902        );
903        if let Some(t) = team.as_deref() {
904            path.push_str(&format!("&team_id={}", urlencode(t)));
905        }
906        if let Some(u) = user.as_deref() {
907            path.push_str(&format!("&created_by={}", urlencode(u)));
908        }
909        self.get(&path).await
910    }
911
912    /// Cancel a workflow run with an optional reason.
913    pub async fn cancel_run_with_reason(&self, run_id: &str, reason: Option<&str>) -> Result<()> {
914        let body = match reason {
915            Some(r) => json!({ "reason": r }),
916            None => json!({}),
917        };
918        let _: serde_json::Value = self
919            .post(&format!("/v1/workflows/runs/{run_id}/cancel"), body)
920            .await?;
921        Ok(())
922    }
923
924    // ── Face Swap & Motion Transfer ─────────────────────────────────
925
926    /// Run the `video/face-swap` workflow.
927    ///
928    /// Swaps a persona face onto a source image/video. Provide either
929    /// `target_url` (direct face URL) or `persona_gallery_id` (to pull
930    /// from an org gallery).
931    pub async fn face_swap(
932        &self,
933        source_url: &str,
934        target_url: Option<&str>,
935        persona_gallery_id: Option<&str>,
936    ) -> Result<serde_json::Value> {
937        let mut input = json!({ "source_url": source_url });
938        if let Some(url) = target_url {
939            input["target_url"] = serde_json::Value::String(url.to_string());
940        }
941        if let Some(gid) = persona_gallery_id {
942            input["persona_gallery_id"] = serde_json::Value::String(gid.to_string());
943        }
944        let run_id = self.run_workflow("video/face-swap", input).await?;
945        self.wait_for_run(&run_id).await
946    }
947
948    /// Run the `video/motion-transfer` workflow.
949    ///
950    /// Animates a persona image using a reference video's motion (dance,
951    /// gestures, expressions).
952    pub async fn motion_transfer(
953        &self,
954        driving_video_url: &str,
955        source_image_url: Option<&str>,
956        persona_gallery_id: Option<&str>,
957        motion_model: Option<&str>,
958    ) -> Result<serde_json::Value> {
959        let mut input = json!({ "driving_video_url": driving_video_url });
960        if let Some(url) = source_image_url {
961            input["source_image_url"] = serde_json::Value::String(url.to_string());
962        }
963        if let Some(gid) = persona_gallery_id {
964            input["persona_gallery_id"] = serde_json::Value::String(gid.to_string());
965        }
966        if let Some(model) = motion_model {
967            input["motion_model"] = serde_json::Value::String(model.to_string());
968        }
969        let run_id = self.run_workflow("video/motion-transfer", input).await?;
970        self.wait_for_run(&run_id).await
971    }
972
973    // ── Providers ────────────────────────────────────────────────────
974
975    pub async fn list_providers(&self) -> Result<Vec<serde_json::Value>> {
976        self.get("/v1/providers").await
977    }
978
979    pub async fn execute_provider(&self, body: serde_json::Value) -> Result<serde_json::Value> {
980        self.post("/v1/providers/execute", body).await
981    }
982
983    pub async fn estimate_cost(&self, body: serde_json::Value) -> Result<serde_json::Value> {
984        self.post("/v1/providers/estimate", body).await
985    }
986
987    // ── Usage & Audit ────────────────────────────────────────────────
988
989    /// Get usage summary. Falls back to the client's organization ID when `None`.
990    pub async fn get_org_usage(&self, org_id: Option<&str>) -> Result<serde_json::Value> {
991        let id = self.resolve_org_id(org_id)?;
992        self.get(&format!("/v1/organizations/{id}/usage")).await
993    }
994
995    /// Get daily usage rollup. Falls back to the client's organization ID when `None`.
996    pub async fn get_org_usage_daily(
997        &self,
998        org_id: Option<&str>,
999    ) -> Result<Vec<serde_json::Value>> {
1000        let id = self.resolve_org_id(org_id)?;
1001        self.get(&format!("/v1/organizations/{id}/usage/daily"))
1002            .await
1003    }
1004
1005    /// Get audit logs. Falls back to the client's organization ID when `None`.
1006    pub async fn get_org_audit_logs(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
1007        let id = self.resolve_org_id(org_id)?;
1008        self.get(&format!("/v1/organizations/{id}/audit-logs"))
1009            .await
1010    }
1011
1012    pub async fn get_audit_logs(&self) -> Result<Vec<serde_json::Value>> {
1013        self.get("/v1/audit-logs").await
1014    }
1015
1016    // ── Webhooks ─────────────────────────────────────────────────────
1017
1018    /// Create a webhook subscription. Falls back to the client's organization ID when `None`.
1019    ///
1020    /// The signing secret is generated server-side and returned once in the
1021    /// response as `data.secret`. Store it securely — it cannot be retrieved again.
1022    pub async fn create_webhook(
1023        &self,
1024        org_id: Option<&str>,
1025        url: &str,
1026        events: Vec<&str>,
1027    ) -> Result<serde_json::Value> {
1028        let id = self.resolve_org_id(org_id)?;
1029        let body = json!({
1030            "url": url,
1031            "event_filter": events,
1032        });
1033        self.post(&format!("/v1/organizations/{id}/webhooks"), body)
1034            .await
1035    }
1036
1037    /// List webhooks. Falls back to the client's organization ID when `None`.
1038    pub async fn list_webhooks(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
1039        let id = self.resolve_org_id(org_id)?;
1040        self.get(&format!("/v1/organizations/{id}/webhooks")).await
1041    }
1042
1043    pub async fn get_webhook(&self, webhook_id: &str) -> Result<serde_json::Value> {
1044        self.get(&format!("/v1/webhooks/{webhook_id}")).await
1045    }
1046
1047    pub async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
1048        self.delete_req(&format!("/v1/webhooks/{webhook_id}")).await
1049    }
1050
1051    // ── Secrets (org-scoped) ─────────────────────────────────────────
1052
1053    /// Set a secret. Falls back to the client's organization ID when `None`.
1054    pub async fn set_secret(&self, org_id: Option<&str>, name: &str, value: &str) -> Result<()> {
1055        let id = self.resolve_org_id(org_id)?;
1056        let _: serde_json::Value = self
1057            .post(
1058                &format!("/v1/organizations/{id}/secrets"),
1059                json!({ "name": name, "value": value }),
1060            )
1061            .await?;
1062        Ok(())
1063    }
1064
1065    /// List secrets. Falls back to the client's organization ID when `None`.
1066    pub async fn list_secrets(&self, org_id: Option<&str>) -> Result<Vec<String>> {
1067        let id = self.resolve_org_id(org_id)?;
1068        self.get(&format!("/v1/organizations/{id}/secrets")).await
1069    }
1070
1071    /// Delete a secret. Falls back to the client's organization ID when `None`.
1072    pub async fn delete_secret(&self, org_id: Option<&str>, name: &str) -> Result<()> {
1073        let id = self.resolve_org_id(org_id)?;
1074        self.delete_req(&format!("/v1/organizations/{id}/secrets/{name}"))
1075            .await
1076    }
1077
1078    // ── Schedules ────────────────────────────────────────────────────
1079
1080    /// Create a schedule for a workflow definition.
1081    ///
1082    /// Note: schedules are still mounted at `/v1/workflow-definitions/{id}/schedules`
1083    /// in the current API, not `/v1/workflows/{id}/schedules`.
1084    pub async fn create_schedule(
1085        &self,
1086        workflow_definition_id: &str,
1087        cron: &str,
1088        input_context: Option<serde_json::Value>,
1089    ) -> Result<serde_json::Value> {
1090        let mut body = json!({ "cron_expression": cron });
1091        if let Some(ctx) = input_context {
1092            body["input_context"] = ctx;
1093        }
1094        self.post(
1095            &format!("/v1/workflow-definitions/{workflow_definition_id}/schedules"),
1096            body,
1097        )
1098        .await
1099    }
1100
1101    pub async fn list_schedules(
1102        &self,
1103        workflow_definition_id: &str,
1104    ) -> Result<Vec<serde_json::Value>> {
1105        self.get(&format!(
1106            "/v1/workflow-definitions/{workflow_definition_id}/schedules"
1107        ))
1108        .await
1109    }
1110
1111    pub async fn delete_schedule(&self, schedule_id: &str) -> Result<()> {
1112        self.delete_req(&format!("/v1/schedules/{schedule_id}"))
1113            .await
1114    }
1115}
1116
1117/// Minimal URL-component encoder for query values.
1118///
1119/// Handles the characters that actually appear in Fabric IDs and names:
1120/// spaces, `&`, `=`, `?`, `#`, `+`, `/`, and a few others. This is not
1121/// a full percent-encoder — a proper implementation is tracked in the
1122/// Rust SDK backlog.
1123fn urlencode(s: &str) -> String {
1124    let mut out = String::with_capacity(s.len());
1125    for b in s.bytes() {
1126        match b {
1127            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1128                out.push(b as char);
1129            }
1130            _ => {
1131                out.push('%');
1132                out.push_str(&format!("{b:02X}"));
1133            }
1134        }
1135    }
1136    out
1137}