Skip to main content

construct/gateway/
kumiho_client.rs

1//! HTTP client for the Kumiho FastAPI REST API.
2//!
3//! Wraps `reqwest` calls to the Kumiho service, providing typed methods for
4//! item CRUD, revisions, search, and space management.  Used by the agent
5//! management API routes (`/api/agents`) and skill management routes
6//! (`/api/skills`).
7
8use crate::config::Config;
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Build a `KumihoClient` from the top-level `Config`.
14///
15/// Reads `kumiho.api_url` for the base URL and `KUMIHO_SERVICE_TOKEN` env var
16/// for the service token. Used by CLI commands (`construct memory`,
17/// `construct migrate openclaw`) that need a Kumiho client without an
18/// `AppState`.
19pub fn build_client_from_config(config: &Config) -> KumihoClient {
20    let base_url = config.kumiho.api_url.clone();
21    let service_token = std::env::var("KUMIHO_SERVICE_TOKEN").unwrap_or_default();
22    KumihoClient::new(base_url, service_token)
23}
24
25/// Convert a human-readable name to a kref-safe slug (lowercase, hyphens, no spaces).
26pub fn slugify(name: &str) -> String {
27    name.trim()
28        .to_lowercase()
29        .chars()
30        .map(|c| {
31            if c.is_alphanumeric() || c == '-' {
32                c
33            } else {
34                '-'
35            }
36        })
37        .collect::<String>()
38        .split('-')
39        .filter(|s| !s.is_empty())
40        .collect::<Vec<_>>()
41        .join("-")
42}
43
44/// Kumiho FastAPI client.
45#[derive(Clone)]
46pub struct KumihoClient {
47    client: Client,
48    base_url: String,
49    service_token: String,
50}
51
52// ── Response types (match Kumiho FastAPI JSON) ──────────────────────────
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ItemResponse {
56    pub kref: String,
57    pub name: String,
58    pub item_name: String,
59    pub kind: String,
60    #[serde(default)]
61    pub deprecated: bool,
62    pub created_at: Option<String>,
63    #[serde(default)]
64    pub metadata: HashMap<String, String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RevisionResponse {
69    pub kref: String,
70    pub item_kref: String,
71    pub number: i32,
72    #[serde(default)]
73    pub latest: bool,
74    #[serde(default)]
75    pub tags: Vec<String>,
76    #[serde(default)]
77    pub metadata: HashMap<String, String>,
78    #[serde(default)]
79    pub deprecated: bool,
80    pub created_at: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct BatchRevisionsResponse {
85    pub revisions: Vec<RevisionResponse>,
86    pub not_found: Vec<String>,
87    pub requested_count: i32,
88    pub found_count: i32,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SearchResult {
93    pub item: ItemResponse,
94    #[serde(default)]
95    pub score: f64,
96}
97
98// ── Bundle response types ────────────────────────────────────────────────
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct BundleMemberInfo {
102    pub item_kref: String,
103    pub added_at: Option<String>,
104    pub added_by: Option<String>,
105    pub added_in_revision: Option<i32>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct BundleMembersResponse {
110    pub members: Vec<BundleMemberInfo>,
111    pub total_count: Option<i32>,
112}
113
114// ── Artifact response types ────────────────────────────────────────────
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ArtifactResponse {
118    pub kref: String,
119    pub name: String,
120    pub location: String,
121    pub revision_kref: String,
122    pub item_kref: Option<String>,
123    #[serde(default)]
124    pub deprecated: bool,
125    pub created_at: Option<String>,
126    #[serde(default)]
127    pub metadata: HashMap<String, String>,
128}
129
130// ── Edge response types ─────────────────────────────────────────────────
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct EdgeResponse {
134    pub source_kref: String,
135    pub target_kref: String,
136    pub edge_type: String,
137    pub created_at: Option<String>,
138    #[serde(default)]
139    pub metadata: Option<HashMap<String, String>>,
140}
141
142// ── Space response types ───────────────────────────────────────────────
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SpaceResponse {
146    pub path: String,
147    pub name: String,
148    pub parent_path: Option<String>,
149    pub created_at: Option<String>,
150}
151
152// ── Error type ──────────────────────────────────────────────────────────
153
154#[derive(Debug, thiserror::Error)]
155pub enum KumihoError {
156    #[error("Kumiho service unreachable: {0}")]
157    Unreachable(#[from] reqwest::Error),
158
159    #[error("Kumiho returned {status}: {body}")]
160    Api { status: u16, body: String },
161
162    #[error("Unexpected response: {0}")]
163    Decode(String),
164}
165
166pub type Result<T> = std::result::Result<T, KumihoError>;
167
168// ── Request body types ──────────────────────────────────────────────────
169
170#[derive(Serialize)]
171struct CreateProjectBody {
172    name: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    description: Option<String>,
175}
176
177#[derive(Serialize)]
178struct CreateSpaceBody {
179    parent_path: String,
180    name: String,
181}
182
183#[derive(Serialize)]
184struct CreateItemBody {
185    space_path: String,
186    item_name: String,
187    kind: String,
188    #[serde(skip_serializing_if = "HashMap::is_empty")]
189    metadata: HashMap<String, String>,
190}
191
192#[derive(Serialize)]
193struct CreateRevisionBody {
194    item_kref: String,
195    #[serde(skip_serializing_if = "HashMap::is_empty")]
196    metadata: HashMap<String, String>,
197}
198
199#[derive(Serialize)]
200struct CreateBundleBody {
201    space_path: String,
202    bundle_name: String,
203    #[serde(skip_serializing_if = "HashMap::is_empty")]
204    metadata: HashMap<String, String>,
205}
206
207#[derive(Serialize)]
208struct BundleMemberBody {
209    bundle_kref: String,
210    item_kref: String,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    metadata: Option<HashMap<String, String>>,
213}
214
215#[derive(Serialize)]
216struct RemoveBundleMemberBody {
217    bundle_kref: String,
218    item_kref: String,
219}
220
221#[derive(Serialize)]
222struct CreateEdgeBody {
223    source_revision_kref: String,
224    target_revision_kref: String,
225    edge_type: String,
226    #[serde(skip_serializing_if = "HashMap::is_empty")]
227    metadata: HashMap<String, String>,
228}
229
230#[derive(Serialize)]
231struct CreateArtifactBody {
232    revision_kref: String,
233    name: String,
234    location: String,
235    #[serde(skip_serializing_if = "HashMap::is_empty")]
236    metadata: HashMap<String, String>,
237}
238
239impl KumihoClient {
240    /// Create a new Kumiho client.
241    ///
242    /// `service_token` is sent as `X-Kumiho-Token` on every request.
243    pub fn new(base_url: String, service_token: String) -> Self {
244        let client = Client::builder()
245            .timeout(std::time::Duration::from_secs(20))
246            .connect_timeout(std::time::Duration::from_secs(5))
247            .pool_max_idle_per_host(32)
248            .build()
249            .unwrap_or_else(|_| Client::new());
250        Self {
251            client,
252            base_url: base_url.trim_end_matches('/').to_string(),
253            service_token,
254        }
255    }
256
257    /// Access the inner HTTP client (for proxy use).
258    pub fn client(&self) -> &Client {
259        &self.client
260    }
261
262    // ── Helpers ─────────────────────────────────────────────────────
263
264    fn url(&self, path: &str) -> String {
265        format!("{}/api/v1{}", self.base_url, path)
266    }
267
268    async fn check_response(&self, resp: reqwest::Response) -> Result<reqwest::Response> {
269        let status = resp.status();
270        if status.is_success() {
271            Ok(resp)
272        } else {
273            let code = status.as_u16();
274            let body = resp.text().await.unwrap_or_default();
275            Err(KumihoError::Api { status: code, body })
276        }
277    }
278
279    // ── Project management ─────────────────────────────────────────
280
281    /// Ensure a project exists (idempotent).  Ignores 409 Conflict (already exists).
282    pub async fn ensure_project(&self, project_name: &str) -> Result<()> {
283        let body = CreateProjectBody {
284            name: project_name.to_string(),
285            description: None,
286        };
287
288        let resp = self
289            .client
290            .post(self.url("/projects"))
291            .header("X-Kumiho-Token", &self.service_token)
292            .json(&body)
293            .send()
294            .await?;
295
296        let status = resp.status().as_u16();
297        if resp.status().is_success() || status == 409 {
298            Ok(())
299        } else {
300            let text = resp.text().await.unwrap_or_default();
301            Err(KumihoError::Api { status, body: text })
302        }
303    }
304
305    // ── Space management ────────────────────────────────────────────
306
307    /// Ensure a space exists (idempotent).  Ignores 409 Conflict (already exists).
308    pub async fn ensure_space(&self, project: &str, space_name: &str) -> Result<()> {
309        let body = CreateSpaceBody {
310            parent_path: format!("/{project}"),
311            name: space_name.to_string(),
312        };
313
314        let resp = self
315            .client
316            .post(self.url("/spaces"))
317            .header("X-Kumiho-Token", &self.service_token)
318            .json(&body)
319            .send()
320            .await?;
321
322        let status = resp.status().as_u16();
323        // 409 = already exists — that's fine
324        if resp.status().is_success() || status == 409 {
325            Ok(())
326        } else {
327            let text = resp.text().await.unwrap_or_default();
328            Err(KumihoError::Api { status, body: text })
329        }
330    }
331
332    /// Ensure a nested space exists under a parent (idempotent).
333    pub async fn ensure_child_space(
334        &self,
335        _project: &str,
336        parent_path: &str,
337        space_name: &str,
338    ) -> Result<()> {
339        let body = CreateSpaceBody {
340            parent_path: parent_path.to_string(),
341            name: space_name.to_string(),
342        };
343
344        let resp = self
345            .client
346            .post(self.url("/spaces"))
347            .header("X-Kumiho-Token", &self.service_token)
348            .json(&body)
349            .send()
350            .await?;
351
352        let status = resp.status().as_u16();
353        if resp.status().is_success() || status == 409 {
354            Ok(())
355        } else {
356            let text = resp.text().await.unwrap_or_default();
357            Err(KumihoError::Api { status, body: text })
358        }
359    }
360
361    /// List spaces under a parent path (optionally recursive).
362    pub async fn list_spaces(
363        &self,
364        parent_path: &str,
365        recursive: bool,
366    ) -> Result<Vec<SpaceResponse>> {
367        let resp = self
368            .client
369            .get(self.url("/spaces"))
370            .header("X-Kumiho-Token", &self.service_token)
371            .query(&[
372                ("parent_path", parent_path),
373                ("recursive", if recursive { "true" } else { "false" }),
374            ])
375            .send()
376            .await?;
377
378        let resp = self.check_response(resp).await?;
379        resp.json::<Vec<SpaceResponse>>()
380            .await
381            .map_err(|e| KumihoError::Decode(e.to_string()))
382    }
383
384    // ── Item CRUD ───────────────────────────────────────────────────
385
386    /// List items in a space.
387    pub async fn list_items(
388        &self,
389        space_path: &str,
390        include_deprecated: bool,
391    ) -> Result<Vec<ItemResponse>> {
392        self.list_items_paged(space_path, include_deprecated, 100, 0)
393            .await
394    }
395
396    /// List items with explicit pagination.
397    pub async fn list_items_paged(
398        &self,
399        space_path: &str,
400        include_deprecated: bool,
401        limit: u32,
402        offset: u32,
403    ) -> Result<Vec<ItemResponse>> {
404        let resp = self
405            .client
406            .get(self.url("/items"))
407            .header("X-Kumiho-Token", &self.service_token)
408            .query(&[
409                ("space_path", space_path),
410                (
411                    "include_deprecated",
412                    if include_deprecated { "true" } else { "false" },
413                ),
414                ("limit", &limit.to_string()),
415                ("offset", &offset.to_string()),
416            ])
417            .send()
418            .await?;
419
420        let resp = self.check_response(resp).await?;
421        resp.json::<Vec<ItemResponse>>()
422            .await
423            .map_err(|e| KumihoError::Decode(e.to_string()))
424    }
425
426    /// List items in a space filtered by name substring.
427    ///
428    /// Uses the `name_filter` query parameter to reduce result size,
429    /// staying under Kumiho's gRPC message limit for large spaces.
430    pub async fn list_items_filtered(
431        &self,
432        space_path: &str,
433        name_filter: &str,
434        include_deprecated: bool,
435    ) -> Result<Vec<ItemResponse>> {
436        let resp = self
437            .client
438            .get(self.url("/items"))
439            .header("X-Kumiho-Token", &self.service_token)
440            .query(&[
441                ("space_path", space_path),
442                ("name_filter", name_filter),
443                (
444                    "include_deprecated",
445                    if include_deprecated { "true" } else { "false" },
446                ),
447            ])
448            .send()
449            .await?;
450
451        let resp = self.check_response(resp).await?;
452        resp.json::<Vec<ItemResponse>>()
453            .await
454            .map_err(|e| KumihoError::Decode(e.to_string()))
455    }
456
457    /// Create an item.
458    pub async fn create_item(
459        &self,
460        space_path: &str,
461        item_name: &str,
462        kind: &str,
463        metadata: HashMap<String, String>,
464    ) -> Result<ItemResponse> {
465        let body = CreateItemBody {
466            space_path: space_path.to_string(),
467            item_name: item_name.to_string(),
468            kind: kind.to_string(),
469            metadata,
470        };
471
472        let resp = self
473            .client
474            .post(self.url("/items"))
475            .header("X-Kumiho-Token", &self.service_token)
476            .json(&body)
477            .send()
478            .await?;
479
480        let resp = self.check_response(resp).await?;
481        resp.json::<ItemResponse>()
482            .await
483            .map_err(|e| KumihoError::Decode(e.to_string()))
484    }
485
486    /// Deprecate or restore an item.
487    pub async fn deprecate_item(&self, kref: &str, deprecated: bool) -> Result<ItemResponse> {
488        let resp = self
489            .client
490            .post(self.url("/items/deprecate"))
491            .header("X-Kumiho-Token", &self.service_token)
492            .query(&[
493                ("kref", kref),
494                ("deprecated", if deprecated { "true" } else { "false" }),
495            ])
496            .send()
497            .await?;
498
499        let resp = self.check_response(resp).await?;
500        resp.json::<ItemResponse>()
501            .await
502            .map_err(|e| KumihoError::Decode(e.to_string()))
503    }
504
505    /// Delete an item (force).
506    pub async fn delete_item(&self, kref: &str) -> Result<()> {
507        let resp = self
508            .client
509            .delete(self.url("/items/by-kref"))
510            .header("X-Kumiho-Token", &self.service_token)
511            .query(&[("kref", kref), ("force", "true")])
512            .send()
513            .await?;
514
515        let _ = self.check_response(resp).await?;
516        Ok(())
517    }
518
519    /// Full-text search across items.
520    pub async fn search_items(
521        &self,
522        query: &str,
523        context: &str,
524        kind: &str,
525        include_deprecated: bool,
526    ) -> Result<Vec<SearchResult>> {
527        let resp = self
528            .client
529            .get(self.url("/items/fulltext-search"))
530            .header("X-Kumiho-Token", &self.service_token)
531            .query(&[
532                ("query", query),
533                ("context", context),
534                ("kind", kind),
535                (
536                    "include_deprecated",
537                    if include_deprecated { "true" } else { "false" },
538                ),
539            ])
540            .send()
541            .await?;
542
543        let resp = self.check_response(resp).await?;
544        resp.json::<Vec<SearchResult>>()
545            .await
546            .map_err(|e| KumihoError::Decode(e.to_string()))
547    }
548
549    // ── Revisions ───────────────────────────────────────────────────
550
551    /// Create a new revision on an item.
552    pub async fn create_revision(
553        &self,
554        item_kref: &str,
555        metadata: HashMap<String, String>,
556    ) -> Result<RevisionResponse> {
557        let body = CreateRevisionBody {
558            item_kref: item_kref.to_string(),
559            metadata,
560        };
561
562        let resp = self
563            .client
564            .post(self.url("/revisions"))
565            .header("X-Kumiho-Token", &self.service_token)
566            .json(&body)
567            .send()
568            .await?;
569
570        let resp = self.check_response(resp).await?;
571        resp.json::<RevisionResponse>()
572            .await
573            .map_err(|e| KumihoError::Decode(e.to_string()))
574    }
575
576    /// Tag a revision (e.g. "published").
577    pub async fn tag_revision(&self, revision_kref: &str, tag: &str) -> Result<()> {
578        let resp = self
579            .client
580            .post(self.url("/revisions/tags"))
581            .header("X-Kumiho-Token", &self.service_token)
582            .query(&[("kref", revision_kref)])
583            .json(&serde_json::json!({ "tag": tag }))
584            .send()
585            .await?;
586
587        let _ = self.check_response(resp).await?;
588        Ok(())
589    }
590
591    /// Get a revision by tag (e.g. "published").
592    pub async fn get_revision_by_tag(
593        &self,
594        item_kref: &str,
595        tag: &str,
596    ) -> Result<RevisionResponse> {
597        let resp = self
598            .client
599            .get(self.url("/revisions/by-kref"))
600            .header("X-Kumiho-Token", &self.service_token)
601            .query(&[("kref", item_kref), ("t", tag)])
602            .send()
603            .await?;
604
605        let resp = self.check_response(resp).await?;
606        resp.json::<RevisionResponse>()
607            .await
608            .map_err(|e| KumihoError::Decode(e.to_string()))
609    }
610
611    /// Get a specific revision by its own revision_kref (e.g. "…?r=5").
612    /// The Kumiho server's `/revisions/by-kref` endpoint parses the `?r=N`
613    /// suffix out of the kref and returns that exact revision's metadata.
614    pub async fn get_revision(&self, revision_kref: &str) -> Result<RevisionResponse> {
615        let resp = self
616            .client
617            .get(self.url("/revisions/by-kref"))
618            .header("X-Kumiho-Token", &self.service_token)
619            .query(&[("kref", revision_kref)])
620            .send()
621            .await?;
622
623        let resp = self.check_response(resp).await?;
624        resp.json::<RevisionResponse>()
625            .await
626            .map_err(|e| KumihoError::Decode(e.to_string()))
627    }
628
629    /// Get the latest revision for an item.
630    pub async fn get_latest_revision(&self, item_kref: &str) -> Result<RevisionResponse> {
631        let resp = self
632            .client
633            .get(self.url("/revisions/latest"))
634            .header("X-Kumiho-Token", &self.service_token)
635            .query(&[("item_kref", item_kref)])
636            .send()
637            .await?;
638
639        let resp = self.check_response(resp).await?;
640        resp.json::<RevisionResponse>()
641            .await
642            .map_err(|e| KumihoError::Decode(e.to_string()))
643    }
644
645    /// Get the published revision, falling back to latest.
646    pub async fn get_published_or_latest(&self, item_kref: &str) -> Result<RevisionResponse> {
647        match self.get_revision_by_tag(item_kref, "published").await {
648            Ok(rev) => Ok(rev),
649            Err(_) => self.get_latest_revision(item_kref).await,
650        }
651    }
652
653    /// Batch fetch revisions for multiple items by tag in a single HTTP call.
654    ///
655    /// Returns a map of item_kref → RevisionResponse for items that were found.
656    pub async fn batch_get_revisions(
657        &self,
658        item_krefs: &[String],
659        tag: &str,
660    ) -> Result<HashMap<String, RevisionResponse>> {
661        if item_krefs.is_empty() {
662            return Ok(HashMap::new());
663        }
664
665        let body = serde_json::json!({
666            "item_krefs": item_krefs,
667            "tag": tag,
668            "allow_partial": true,
669        });
670
671        let resp = self
672            .client
673            .post(self.url("/revisions/batch"))
674            .header("X-Kumiho-Token", &self.service_token)
675            .json(&body)
676            .send()
677            .await?;
678
679        let resp = self.check_response(resp).await?;
680        let batch: BatchRevisionsResponse = resp
681            .json()
682            .await
683            .map_err(|e| KumihoError::Decode(e.to_string()))?;
684
685        let mut map = HashMap::with_capacity(batch.revisions.len());
686        for rev in batch.revisions {
687            map.insert(rev.item_kref.clone(), rev);
688        }
689        Ok(map)
690    }
691
692    // ── Skill convenience methods ──────────────────────────────────
693
694    /// List skills in the given project's Skills space.
695    pub async fn list_skills(
696        &self,
697        project: &str,
698        include_deprecated: bool,
699    ) -> Result<Vec<ItemResponse>> {
700        let space_path = format!("/{project}/Skills");
701        self.list_items(&space_path, include_deprecated).await
702    }
703
704    /// Search skills by query within the given project.
705    pub async fn search_skills(
706        &self,
707        query: &str,
708        project: &str,
709        include_deprecated: bool,
710    ) -> Result<Vec<SearchResult>> {
711        self.search_items(query, project, "skill", include_deprecated)
712            .await
713    }
714
715    /// Create a new skill item + first revision in the given project.
716    pub async fn create_skill(
717        &self,
718        project: &str,
719        name: &str,
720        metadata: HashMap<String, String>,
721    ) -> Result<(ItemResponse, RevisionResponse)> {
722        self.ensure_space(project, "Skills").await.ok();
723        let space_path = format!("/{project}/Skills");
724        let item = self
725            .create_item(&space_path, name, "skill", HashMap::new())
726            .await?;
727        let revision = self.create_revision(&item.kref, metadata).await?;
728        Ok((item, revision))
729    }
730
731    /// Deprecate or restore a skill.
732    pub async fn deprecate_skill(&self, kref: &str, deprecated: bool) -> Result<ItemResponse> {
733        self.deprecate_item(kref, deprecated).await
734    }
735
736    // ── Bundle methods ─────────────────────────────────────────────
737
738    /// Create a bundle.
739    pub async fn create_bundle(
740        &self,
741        space_path: &str,
742        bundle_name: &str,
743        metadata: HashMap<String, String>,
744    ) -> Result<ItemResponse> {
745        let body = CreateBundleBody {
746            space_path: space_path.to_string(),
747            bundle_name: bundle_name.to_string(),
748            metadata,
749        };
750
751        let resp = self
752            .client
753            .post(self.url("/bundles"))
754            .header("X-Kumiho-Token", &self.service_token)
755            .json(&body)
756            .send()
757            .await?;
758
759        let resp = self.check_response(resp).await?;
760        resp.json::<ItemResponse>()
761            .await
762            .map_err(|e| KumihoError::Decode(e.to_string()))
763    }
764
765    /// Get a bundle by kref.
766    pub async fn get_bundle(&self, kref: &str) -> Result<ItemResponse> {
767        let resp = self
768            .client
769            .get(self.url("/bundles/by-kref"))
770            .header("X-Kumiho-Token", &self.service_token)
771            .query(&[("kref", kref)])
772            .send()
773            .await?;
774
775        let resp = self.check_response(resp).await?;
776        resp.json::<ItemResponse>()
777            .await
778            .map_err(|e| KumihoError::Decode(e.to_string()))
779    }
780
781    /// Delete a bundle (force).
782    pub async fn delete_bundle(&self, kref: &str) -> Result<()> {
783        let resp = self
784            .client
785            .delete(self.url("/bundles/by-kref"))
786            .header("X-Kumiho-Token", &self.service_token)
787            .query(&[("kref", kref), ("force", "true")])
788            .send()
789            .await?;
790
791        let _ = self.check_response(resp).await?;
792        Ok(())
793    }
794
795    /// Add a member to a bundle.
796    pub async fn add_bundle_member(
797        &self,
798        bundle_kref: &str,
799        item_kref: &str,
800        metadata: HashMap<String, String>,
801    ) -> Result<serde_json::Value> {
802        let body = BundleMemberBody {
803            bundle_kref: bundle_kref.to_string(),
804            item_kref: item_kref.to_string(),
805            metadata: if metadata.is_empty() {
806                None
807            } else {
808                Some(metadata)
809            },
810        };
811
812        let resp = self
813            .client
814            .post(self.url("/bundles/members/add"))
815            .header("X-Kumiho-Token", &self.service_token)
816            .json(&body)
817            .send()
818            .await?;
819
820        let resp = self.check_response(resp).await?;
821        resp.json::<serde_json::Value>()
822            .await
823            .map_err(|e| KumihoError::Decode(e.to_string()))
824    }
825
826    /// Remove a member from a bundle.
827    pub async fn remove_bundle_member(
828        &self,
829        bundle_kref: &str,
830        item_kref: &str,
831    ) -> Result<serde_json::Value> {
832        let body = RemoveBundleMemberBody {
833            bundle_kref: bundle_kref.to_string(),
834            item_kref: item_kref.to_string(),
835        };
836
837        let resp = self
838            .client
839            .post(self.url("/bundles/members/remove"))
840            .header("X-Kumiho-Token", &self.service_token)
841            .json(&body)
842            .send()
843            .await?;
844
845        let resp = self.check_response(resp).await?;
846        resp.json::<serde_json::Value>()
847            .await
848            .map_err(|e| KumihoError::Decode(e.to_string()))
849    }
850
851    /// List members of a bundle.
852    pub async fn list_bundle_members(&self, bundle_kref: &str) -> Result<BundleMembersResponse> {
853        let resp = self
854            .client
855            .get(self.url("/bundles/members"))
856            .header("X-Kumiho-Token", &self.service_token)
857            .query(&[("bundle_kref", bundle_kref)])
858            .send()
859            .await?;
860
861        let resp = self.check_response(resp).await?;
862        resp.json::<BundleMembersResponse>()
863            .await
864            .map_err(|e| KumihoError::Decode(e.to_string()))
865    }
866
867    // ── Edge methods ───────────────────────────────────────────────
868
869    /// Create an edge between two revisions.
870    pub async fn create_edge(
871        &self,
872        source_kref: &str,
873        target_kref: &str,
874        edge_type: &str,
875        metadata: HashMap<String, String>,
876    ) -> Result<EdgeResponse> {
877        let body = CreateEdgeBody {
878            source_revision_kref: source_kref.to_string(),
879            target_revision_kref: target_kref.to_string(),
880            edge_type: edge_type.to_string(),
881            metadata,
882        };
883
884        let resp = self
885            .client
886            .post(self.url("/edges"))
887            .header("X-Kumiho-Token", &self.service_token)
888            .json(&body)
889            .send()
890            .await?;
891
892        let resp = self.check_response(resp).await?;
893        resp.json::<EdgeResponse>()
894            .await
895            .map_err(|e| KumihoError::Decode(e.to_string()))
896    }
897
898    /// List edges for a revision.
899    ///
900    /// `direction`: 0 = outgoing, 1 = incoming, 2 = both.
901    pub async fn list_edges(
902        &self,
903        revision_kref: &str,
904        edge_type: Option<&str>,
905        direction: Option<&str>,
906    ) -> Result<Vec<EdgeResponse>> {
907        // Map string directions to numeric values expected by Kumiho API
908        let dir_num = direction.map(|d| match d {
909            "outgoing" | "out" => "0",
910            "incoming" | "in" => "1",
911            "both" => "2",
912            other => other, // pass through if already numeric
913        });
914
915        let mut query_params: Vec<(&str, &str)> = vec![("kref", revision_kref)];
916        if let Some(et) = edge_type {
917            query_params.push(("edge_type", et));
918        }
919        if let Some(dir) = dir_num.as_deref() {
920            query_params.push(("direction", dir));
921        }
922
923        let resp = self
924            .client
925            .get(self.url("/edges"))
926            .header("X-Kumiho-Token", &self.service_token)
927            .query(&query_params)
928            .send()
929            .await?;
930
931        let resp = self.check_response(resp).await?;
932        resp.json::<Vec<EdgeResponse>>()
933            .await
934            .map_err(|e| KumihoError::Decode(e.to_string()))
935    }
936
937    /// Delete an edge.
938    pub async fn delete_edge(
939        &self,
940        source_kref: &str,
941        target_kref: &str,
942        edge_type: &str,
943    ) -> Result<()> {
944        let resp = self
945            .client
946            .delete(self.url("/edges"))
947            .header("X-Kumiho-Token", &self.service_token)
948            .query(&[
949                ("source_kref", source_kref),
950                ("target_kref", target_kref),
951                ("edge_type", edge_type),
952            ])
953            .send()
954            .await?;
955
956        let _ = self.check_response(resp).await?;
957        Ok(())
958    }
959
960    // ── Artifact methods ──────────────────────────────────────────
961
962    /// Create an artifact associated with a revision.
963    pub async fn create_artifact(
964        &self,
965        revision_kref: &str,
966        name: &str,
967        location: &str,
968        metadata: HashMap<String, String>,
969    ) -> Result<ArtifactResponse> {
970        let body = CreateArtifactBody {
971            revision_kref: revision_kref.to_string(),
972            name: name.to_string(),
973            location: location.to_string(),
974            metadata,
975        };
976
977        let resp = self
978            .client
979            .post(self.url("/artifacts"))
980            .header("X-Kumiho-Token", &self.service_token)
981            .json(&body)
982            .send()
983            .await?;
984
985        let resp = self.check_response(resp).await?;
986        resp.json::<ArtifactResponse>()
987            .await
988            .map_err(|e| KumihoError::Decode(e.to_string()))
989    }
990
991    /// List artifacts for a revision.
992    pub async fn get_artifacts(&self, revision_kref: &str) -> Result<Vec<ArtifactResponse>> {
993        let resp = self
994            .client
995            .get(self.url("/artifacts"))
996            .header("X-Kumiho-Token", &self.service_token)
997            .query(&[("revision_kref", revision_kref)])
998            .send()
999            .await?;
1000
1001        let resp = self.check_response(resp).await?;
1002        resp.json::<Vec<ArtifactResponse>>()
1003            .await
1004            .map_err(|e| KumihoError::Decode(e.to_string()))
1005    }
1006
1007    /// Get a specific artifact by revision kref and name.
1008    pub async fn get_artifact_by_name(
1009        &self,
1010        revision_kref: &str,
1011        name: &str,
1012    ) -> Result<ArtifactResponse> {
1013        let resp = self
1014            .client
1015            .get(self.url("/artifacts/by-kref"))
1016            .header("X-Kumiho-Token", &self.service_token)
1017            .query(&[("revision_kref", revision_kref), ("name", name)])
1018            .send()
1019            .await?;
1020
1021        let resp = self.check_response(resp).await?;
1022        resp.json::<ArtifactResponse>()
1023            .await
1024            .map_err(|e| KumihoError::Decode(e.to_string()))
1025    }
1026
1027    // ── Team convenience methods ───────────────────────────────────
1028
1029    /// List teams in the given `<project>/Teams` space.
1030    pub async fn list_teams_in(
1031        &self,
1032        space_path: &str,
1033        include_deprecated: bool,
1034    ) -> Result<Vec<ItemResponse>> {
1035        self.list_items(space_path, include_deprecated).await
1036    }
1037
1038    /// Deprecate or restore a team.
1039    pub async fn deprecate_team(&self, kref: &str, deprecated: bool) -> Result<()> {
1040        self.deprecate_item(kref, deprecated).await?;
1041        Ok(())
1042    }
1043}