Skip to main content

honcho_ai/
client.rs

1//! High-level Honcho SDK client.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex, PoisonError};
5use std::time::Duration;
6
7use reqwest::header::HeaderMap;
8use serde_json::Value;
9use tokio::sync::OnceCell;
10use url::Url;
11
12use crate::error::{HonchoError, Result};
13use crate::http::client::{DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient, normalize_base_url};
14use crate::http::routes;
15use crate::peer::Peer;
16use crate::session::{PeerSpec, Session};
17use crate::types::dream::QueueStatus;
18use crate::types::message::MessageResponse;
19use crate::types::peer::Peer as PeerResponse;
20use crate::types::session::SessionResponse;
21use crate::types::workspace::{Workspace, WorkspaceConfiguration};
22
23/// Default `limit` for workspace search when the builder caller leaves it unset.
24///
25/// Single source of truth shared by the async [`Honcho::search`] builder and its
26/// blocking mirror (`crate::blocking::Honcho::search`) so both surfaces stay in
27/// lockstep instead of carrying independent magic literals.
28pub(crate) const DEFAULT_SEARCH_LIMIT: u32 = 10;
29
30/// API environment.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[non_exhaustive]
33pub enum Environment {
34    /// Local development server.
35    Local,
36    /// Production API at <https://api.honcho.dev>.
37    #[default]
38    Production,
39}
40
41impl Environment {
42    fn base_url(self) -> &'static str {
43        match self {
44            Self::Local => "http://localhost:8000",
45            Self::Production => "https://api.honcho.dev",
46        }
47    }
48}
49
50struct Inner {
51    http: HttpClient,
52    workspace_id: Arc<str>,
53    base_url: Url,
54    /// Single-flight "workspace exists" cache.
55    ///
56    /// The inner [`OnceCell`] provides single-flight initialization; wrapping it
57    /// in a `Mutex<Arc<…>>` makes the cache *invalidatable* behind a shared
58    /// reference (`&self`): [`Inner::reset_ensure`] swaps in a fresh cell so the
59    /// next [`Honcho::ensure_workspace`] re-issues the request. The `Mutex`
60    /// critical section only ever clones/replaces an `Arc`, so it is never held
61    /// across an `.await`.
62    ensure_workspace_once: Mutex<Arc<OnceCell<()>>>,
63}
64
65impl Inner {
66    /// Snapshot the current ensure cell (cheap `Arc` clone under a short lock).
67    fn ensure_cell(&self) -> Arc<OnceCell<()>> {
68        self.ensure_workspace_once
69            .lock()
70            .unwrap_or_else(PoisonError::into_inner)
71            .clone()
72    }
73
74    /// Invalidate the ensure cache so the next `ensure_workspace` re-issues
75    /// the create request (e.g. after a server-side delete).
76    fn reset_ensure(&self) {
77        *self
78            .ensure_workspace_once
79            .lock()
80            .unwrap_or_else(PoisonError::into_inner) = Arc::new(OnceCell::new());
81    }
82}
83
84/// Entry point for the Honcho SDK.
85///
86/// Construct via [`Honcho::builder()`] followed by [`Honcho::from_params()`].
87#[derive(Clone)]
88pub struct Honcho {
89    inner: Arc<Inner>,
90}
91
92/// Parameters for constructing a [`Honcho`] client.
93///
94/// Resolution order: explicit argument -> environment variable -> default.
95#[derive(bon::Builder)]
96#[builder(on(String, into))]
97#[builder(finish_fn = build)]
98pub struct HonchoParams {
99    /// API key. Falls back to `HONCHO_API_KEY` env var.
100    api_key: Option<String>,
101    /// Base URL. Falls back to `HONCHO_URL` env var, then `HONCHO_API_URL`, then [`Environment::base_url`].
102    base_url: Option<String>,
103    /// API environment. Defaults to [`Environment::Production`].
104    #[builder(default)]
105    environment: Environment,
106    /// Workspace ID. Falls back to `HONCHO_WORKSPACE_ID` env var, then "default".
107    workspace_id: Option<String>,
108    /// Custom `reqwest::Client`.
109    http_client: Option<reqwest::Client>,
110    /// Request timeout. Falls back to `HttpClient` default (60s).
111    timeout: Option<Duration>,
112    /// Max retries for transient errors. Falls back to `HttpClient` default (2).
113    max_retries: Option<u32>,
114    /// Extra default headers sent with every request.
115    default_headers: Option<HeaderMap>,
116    /// Extra default query parameters appended to every request.
117    default_query: Option<Vec<(String, String)>>,
118}
119
120#[bon::bon]
121impl Honcho {
122    /// Quick constructor pointing at `base_url` for `workspace_id`.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "my-workspace")?;
128    /// # Ok::<(), honcho_ai::error::HonchoError>(())
129    /// ```
130    pub fn new(base_url: &str, workspace_id: &str) -> Result<Self> {
131        validate_workspace_id(workspace_id)?;
132        let url = normalize_base_url(base_url)?;
133        let api_key = std::env::var("HONCHO_API_KEY")
134            .ok()
135            .filter(|s| !s.is_empty());
136        let http = HttpClient::from_params_with_base_url(
137            HttpClient::builder()
138                .base_url(base_url.to_owned())
139                .maybe_api_key(api_key)
140                .build(),
141            url.clone(),
142        )?;
143        Ok(Self {
144            inner: Arc::new(Inner {
145                http,
146                workspace_id: Arc::from(workspace_id),
147                base_url: url,
148                ensure_workspace_once: Mutex::new(Arc::new(OnceCell::new())),
149            }),
150        })
151    }
152
153    /// Returns a builder for [`HonchoParams`].
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// let params = honcho_ai::Honcho::builder()
159    ///     .base_url("http://localhost:8000".to_owned())
160    ///     .workspace_id("my-workspace".to_owned())
161    ///     .build();
162    /// let client = honcho_ai::Honcho::from_params(params)?;
163    /// # Ok::<(), honcho_ai::error::HonchoError>(())
164    /// ```
165    pub fn builder() -> HonchoParamsBuilder {
166        HonchoParams::builder()
167    }
168
169    /// Constructs a [`Honcho`] from params.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// let params = honcho_ai::Honcho::builder()
175    ///     .base_url("http://localhost:8000".to_owned())
176    ///     .build();
177    /// let client = honcho_ai::Honcho::from_params(params)?;
178    /// # Ok::<(), honcho_ai::error::HonchoError>(())
179    /// ```
180    pub fn from_params(params: HonchoParams) -> Result<Self> {
181        let resolved_base_url = params
182            .base_url
183            .or_else(|| std::env::var("HONCHO_URL").ok().filter(|s| !s.is_empty()))
184            .or_else(|| {
185                std::env::var("HONCHO_API_URL")
186                    .ok()
187                    .filter(|s| !s.is_empty())
188            })
189            .unwrap_or_else(|| params.environment.base_url().to_owned());
190
191        let resolved_api_key = params.api_key.or_else(|| {
192            std::env::var("HONCHO_API_KEY")
193                .ok()
194                .filter(|s| !s.is_empty())
195        });
196
197        let resolved_workspace_id = params
198            .workspace_id
199            .or_else(|| {
200                std::env::var("HONCHO_WORKSPACE_ID")
201                    .ok()
202                    .filter(|s| !s.is_empty())
203            })
204            .unwrap_or_else(|| "default".to_owned());
205
206        validate_workspace_id(&resolved_workspace_id)?;
207        let base_url = normalize_base_url(&resolved_base_url)?;
208
209        let http = HttpClient::from_params_with_base_url(
210            HttpClient::builder()
211                .base_url(resolved_base_url)
212                .maybe_api_key(resolved_api_key)
213                .maybe_http_client(params.http_client)
214                .timeout(params.timeout.unwrap_or(DEFAULT_TIMEOUT))
215                .max_retries(params.max_retries.unwrap_or(DEFAULT_MAX_RETRIES))
216                .default_headers(params.default_headers.unwrap_or_default())
217                .default_query(params.default_query.unwrap_or_default())
218                .build(),
219            base_url.clone(),
220        )?;
221
222        Ok(Self {
223            inner: Arc::new(Inner {
224                http,
225                workspace_id: Arc::from(resolved_workspace_id),
226                base_url,
227                ensure_workspace_once: Mutex::new(Arc::new(OnceCell::new())),
228            }),
229        })
230    }
231
232    /// Ensure the workspace exists on the server (`POST /v3/workspaces`).
233    pub(crate) async fn ensure_workspace(&self) -> Result<()> {
234        let cell = self.inner.ensure_cell();
235        cell.get_or_try_init(|| async {
236            let body = self.workspace_get_or_create_body();
237            match self
238                .inner
239                .http
240                .post::<_, Workspace>(&routes::workspaces(), Some(&body), &[])
241                .await
242            {
243                Ok(_) => Ok(()),
244                Err(e) if e.status_code() == Some(409) => Ok(()),
245                Err(e) => Err(e),
246            }
247        })
248        .await
249        .map(drop)
250    }
251
252    /// Bypasses the workspace-ensure cache and always issues a
253    /// `POST /v3/workspaces`, even if a prior ensure already succeeded.
254    ///
255    /// Use this to recover after a server-side workspace deletion. Repeated
256    /// calls each hit the server.
257    ///
258    /// # Examples
259    ///
260    /// ```no_run
261    /// # async fn example() -> honcho_ai::error::Result<()> {
262    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
263    /// client.force_ensure().await?;
264    /// # Ok(())
265    /// # }
266    /// ```
267    pub async fn force_ensure(&self) -> Result<()> {
268        // Bypass the cached `OnceCell`: invalidate it first so this call always
269        // re-issues the ensure request, even when a prior lazy ensure already
270        // succeeded or the workspace was deleted server-side.
271        self.inner.reset_ensure();
272        self.ensure_workspace().await
273    }
274
275    /// Returns the workspace ID this client is scoped to.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
281    /// assert_eq!(client.workspace_id(), "ws-1");
282    /// # Ok::<(), honcho_ai::error::HonchoError>(())
283    /// ```
284    #[must_use]
285    pub fn workspace_id(&self) -> &str {
286        &self.inner.workspace_id
287    }
288
289    /// Returns the resolved base URL.
290    #[must_use]
291    pub fn base_url(&self) -> &Url {
292        &self.inner.base_url
293    }
294
295    /// Returns the underlying HTTP client.
296    pub(crate) fn http(&self) -> &HttpClient {
297        &self.inner.http
298    }
299
300    /// Build the get-or-create body for this client's workspace.
301    ///
302    /// Reads go through `POST /v3/workspaces` because the server exposes no
303    /// `GET /workspaces/{id}` (it answers `405`) — only `PUT`/`DELETE` are. The
304    /// `WorkspaceCreate` skips its `None` fields, so the body is just `{"id": …}`
305    /// and the call returns the workspace without mutating it.
306    fn workspace_get_or_create_body(&self) -> crate::types::workspace::WorkspaceCreate {
307        crate::types::workspace::WorkspaceCreate {
308            id: self.inner.workspace_id.to_string(),
309            metadata: None,
310            configuration: None,
311        }
312    }
313
314    /// POST the get-or-create workspace endpoint, returning the response as `T`
315    /// (typed [`Workspace`] for reads, raw [`Value`] when unknown fields must be
316    /// preserved).
317    ///
318    /// On success the workspace is guaranteed to exist — exactly what the lazy
319    /// ensure-cache asserts — so the cache is populated here, letting a later
320    /// `peer()`/`session()`/`search()` skip its otherwise-redundant ensure POST.
321    async fn get_or_create_workspace<T: serde::de::DeserializeOwned + 'static>(&self) -> Result<T> {
322        let body = self.workspace_get_or_create_body();
323        let resp: T = self
324            .inner
325            .http
326            .post(&routes::workspaces(), Some(&body), &[])
327            .await?;
328        let _ = self.inner.ensure_cell().set(());
329        Ok(resp)
330    }
331
332    /// Fetch the workspace via the get-or-create `POST /v3/workspaces` endpoint.
333    /// Doubles as the lazy workspace-ensure (see [`get_or_create_workspace`]).
334    async fn fetch_workspace(&self) -> Result<Workspace> {
335        self.get_or_create_workspace().await
336    }
337
338    /// Fetch workspace metadata from the server.
339    pub async fn get_metadata(&self) -> Result<HashMap<String, Value>> {
340        Ok(self.fetch_workspace().await?.metadata)
341    }
342
343    /// Set workspace metadata on the server.
344    ///
345    /// Unlike the read operations (e.g. [`get_metadata`](Self::get_metadata)),
346    /// which lazily ensure the workspace exists, this write assumes the
347    /// workspace already exists and fails with a 404 if it was deleted
348    /// server-side.
349    pub async fn set_metadata(&self, metadata: HashMap<String, Value>) -> Result<()> {
350        let body = crate::types::workspace::WorkspaceMetadataSet { metadata };
351        let _: Workspace = self
352            .inner
353            .http
354            .put(&routes::workspace(self.workspace_id())?, Some(&body), &[])
355            .await?;
356        Ok(())
357    }
358
359    /// Fetch workspace configuration as a typed [`WorkspaceConfiguration`].
360    ///
361    /// # Example
362    ///
363    /// ```ignore
364    /// let config = client.get_configuration().await?;
365    /// if let Some(reasoning) = &config.reasoning {
366    ///     println!("reasoning enabled: {:?}", reasoning.enabled);
367    /// }
368    /// ```
369    pub async fn get_configuration(&self) -> Result<WorkspaceConfiguration> {
370        Ok(self.fetch_workspace().await?.configuration)
371    }
372
373    /// Set workspace configuration from a typed [`WorkspaceConfiguration`].
374    ///
375    /// # Example
376    ///
377    /// ```no_run
378    /// # async fn example() -> honcho_ai::error::Result<()> {
379    /// use honcho_ai::types::common::ReasoningConfiguration;
380    /// use honcho_ai::types::workspace::WorkspaceConfiguration;
381    ///
382    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
383    ///
384    /// // Both types are `#[non_exhaustive]`, so build from `Default` and set
385    /// // fields instead of using a struct literal / functional-update syntax.
386    /// let mut reasoning = ReasoningConfiguration::default();
387    /// reasoning.enabled = Some(true);
388    ///
389    /// let mut config = WorkspaceConfiguration::default();
390    /// config.reasoning = Some(reasoning);
391    ///
392    /// client.set_configuration(&config).await?;
393    /// # Ok(())
394    /// # }
395    /// ```
396    ///
397    /// Unlike the read operations (e.g.
398    /// [`get_configuration`](Self::get_configuration)), which lazily ensure the
399    /// workspace exists, this write assumes the workspace already exists and
400    /// fails with a 404 if it was deleted server-side.
401    pub async fn set_configuration(&self, config: &WorkspaceConfiguration) -> Result<()> {
402        let body = crate::types::workspace::WorkspaceConfigurationSet {
403            configuration: serde_json::to_value(config).map_err(|e| {
404                HonchoError::Serialization {
405                    path: "WorkspaceConfiguration".into(),
406                    source: e,
407                }
408            })?,
409        };
410        let _: Workspace = self
411            .inner
412            .http
413            .put(&routes::workspace(self.workspace_id())?, Some(&body), &[])
414            .await?;
415        Ok(())
416    }
417
418    /// Fetch workspace configuration as a raw JSON map.
419    ///
420    /// Prefer [`get_configuration`](Self::get_configuration) for typed access.
421    /// Use this when the server returns fields not yet represented in
422    /// [`WorkspaceConfiguration`].
423    pub async fn get_configuration_raw(&self) -> Result<HashMap<String, Value>> {
424        let raw: serde_json::Value = self.get_or_create_workspace().await?;
425        // Take ownership of the parsed JSON and move the `configuration` object
426        // out of it — no per-entry cloning.
427        match raw {
428            serde_json::Value::Object(mut map) => match map.remove("configuration") {
429                Some(serde_json::Value::Object(configuration)) => {
430                    Ok(configuration.into_iter().collect())
431                }
432                _ => Ok(HashMap::new()),
433            },
434            _ => Ok(HashMap::new()),
435        }
436    }
437
438    /// Set workspace configuration from a raw JSON map.
439    ///
440    /// Prefer [`set_configuration`](Self::set_configuration) for typed access.
441    /// Use this when you need to send fields not yet represented in
442    /// [`WorkspaceConfiguration`].
443    ///
444    /// Unlike the read operations (e.g.
445    /// [`get_configuration_raw`](Self::get_configuration_raw)), which lazily
446    /// ensure the workspace exists, this write assumes the workspace already
447    /// exists and fails with a 404 if it was deleted server-side.
448    pub async fn set_configuration_raw(&self, configuration: HashMap<String, Value>) -> Result<()> {
449        let body = crate::types::workspace::WorkspaceConfigurationSet {
450            configuration: serde_json::Value::Object(configuration.into_iter().collect()),
451        };
452        let _: Workspace = self
453            .inner
454            .http
455            .put(&routes::workspace(self.workspace_id())?, Some(&body), &[])
456            .await?;
457        Ok(())
458    }
459
460    /// Get or create a peer by ID.
461    ///
462    /// Returns a builder; finish with `.build().await`.
463    ///
464    /// # Examples
465    ///
466    /// ```no_run
467    /// # async fn example() -> honcho_ai::error::Result<()> {
468    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
469    /// let peer = client.peer("alice").build().await?;
470    /// # Ok(())
471    /// # }
472    /// ```
473    #[builder(finish_fn = build, on(String, into))]
474    pub async fn peer(
475        &self,
476        #[builder(start_fn)] id: String,
477        metadata: Option<HashMap<String, Value>>,
478        #[builder(name = config)] configuration: Option<HashMap<String, Value>>,
479    ) -> Result<Peer> {
480        if id.is_empty() {
481            return Err(HonchoError::Configuration(
482                "peer_id must not be empty".into(),
483            ));
484        }
485        self.ensure_workspace().await?;
486        let body = crate::types::peer::PeerCreate {
487            id,
488            metadata,
489            configuration,
490        };
491        let resp: PeerResponse = self
492            .inner
493            .http
494            .post(&routes::peers(&self.inner.workspace_id)?, Some(&body), &[])
495            .await?;
496        Peer::from_response(self, resp)
497    }
498
499    /// Get or create a session by ID.
500    ///
501    /// Returns a builder; finish with `.build().await`.
502    ///
503    /// # Examples
504    ///
505    /// ```no_run
506    /// # async fn example() -> honcho_ai::error::Result<()> {
507    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
508    /// let session = client.session("s-42").build().await?;
509    /// # Ok(())
510    /// # }
511    /// ```
512    #[builder(finish_fn = build, on(String, into))]
513    pub async fn session(
514        &self,
515        #[builder(start_fn)] id: String,
516        metadata: Option<HashMap<String, Value>>,
517        peers: Option<Vec<PeerSpec>>,
518        configuration: Option<crate::SessionConfiguration>,
519    ) -> Result<Session> {
520        if id.is_empty() {
521            return Err(HonchoError::Configuration(
522                "session_id must not be empty".into(),
523            ));
524        }
525        self.ensure_workspace().await?;
526        let peers_map = peers.map(|specs| specs.into_iter().map(PeerSpec::into_parts).collect());
527        let body = crate::types::session::SessionCreate {
528            id,
529            metadata,
530            peers: peers_map,
531            configuration,
532        };
533        let resp: SessionResponse = self
534            .inner
535            .http
536            .post(
537                &routes::sessions(&self.inner.workspace_id)?,
538                Some(&body),
539                &[],
540            )
541            .await?;
542        Ok(Session::from_response(self, resp))
543    }
544
545    /// Refresh workspace state by re-fetching metadata and configuration.
546    ///
547    /// Issues a single get-or-create `POST /v3/workspaces` request (the server
548    /// exposes no `GET /workspaces/{id}`); this also populates the lazy
549    /// ensure-cache as a side effect.
550    ///
551    /// # Examples
552    ///
553    /// ```no_run
554    /// # async fn example() -> honcho_ai::error::Result<()> {
555    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
556    /// client.refresh().await?;
557    /// # Ok(())
558    /// # }
559    /// ```
560    pub async fn refresh(&self) -> Result<()> {
561        self.fetch_workspace().await?;
562        Ok(())
563    }
564
565    /// Search messages across the workspace.
566    ///
567    /// Returns a builder; finish with `.build().await`. `limit` defaults to 10.
568    ///
569    /// # Examples
570    ///
571    /// ```no_run
572    /// # async fn example() -> honcho_ai::error::Result<()> {
573    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
574    /// let results = client.search("important topic").build().await?;
575    /// # Ok(())
576    /// # }
577    /// ```
578    #[builder(finish_fn = build, on(String, into))]
579    pub async fn search(
580        &self,
581        #[builder(start_fn)] query: String,
582        #[builder(default = DEFAULT_SEARCH_LIMIT)] limit: u32,
583        filters: Option<HashMap<String, Value>>,
584    ) -> Result<Vec<crate::Message>> {
585        self.ensure_workspace().await?;
586        let body = crate::types::workspace::WorkspaceSearchRequest {
587            query,
588            limit,
589            filters,
590        };
591        let responses: Vec<MessageResponse> = self
592            .inner
593            .http
594            .post(
595                &routes::workspace_search(&self.inner.workspace_id)?,
596                Some(&body),
597                &[],
598            )
599            .await?;
600        // `Message::from_raw` no longer takes a workspace_id, so there is no
601        // per-message `String` allocation here.
602        Ok(responses
603            .into_iter()
604            .map(crate::Message::from_raw)
605            .collect())
606    }
607
608    /// Get queue processing status.
609    ///
610    /// # Examples
611    ///
612    /// ```no_run
613    /// # async fn example() -> honcho_ai::error::Result<()> {
614    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
615    /// let status = client.queue_status(None, None, None).await?;
616    /// # Ok(())
617    /// # }
618    /// ```
619    pub async fn queue_status(
620        &self,
621        observer_id: Option<&str>,
622        sender_id: Option<&str>,
623        session_id: Option<&str>,
624    ) -> Result<QueueStatus> {
625        self.ensure_workspace().await?;
626        let mut query: Vec<(&str, &str)> = Vec::new();
627        if let Some(v) = observer_id {
628            query.push(("observer_id", v));
629        }
630        if let Some(v) = sender_id {
631            query.push(("sender_id", v));
632        }
633        if let Some(v) = session_id {
634            query.push(("session_id", v));
635        }
636        self.inner
637            .http
638            .get(
639                &routes::workspace_queue_status(&self.inner.workspace_id)?,
640                &query,
641            )
642            .await
643    }
644
645    /// Schedule a dream task for memory consolidation.
646    ///
647    /// # Examples
648    ///
649    /// ```no_run
650    /// # async fn example() -> honcho_ai::error::Result<()> {
651    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
652    /// client.schedule_dream("alice", None, None).await?;
653    /// # Ok(())
654    /// # }
655    /// ```
656    pub async fn schedule_dream(
657        &self,
658        observer: &str,
659        session_id: Option<&str>,
660        observed_peer: Option<&str>,
661    ) -> Result<()> {
662        if observer.is_empty() {
663            return Err(HonchoError::Validation(
664                "observer must not be empty".to_string(),
665            ));
666        }
667        self.ensure_workspace().await?;
668        let observed_peer = observed_peer.unwrap_or(observer);
669        let body = crate::types::dream::ScheduleDreamRequest {
670            observer: observer.to_owned(),
671            dream_type: crate::types::dream::DreamType::Omni,
672            observed: Some(observed_peer.to_owned()),
673            session_id: session_id.map(std::borrow::ToOwned::to_owned),
674        };
675        self.inner
676            .http
677            .post(
678                &routes::workspace_schedule_dream(&self.inner.workspace_id)?,
679                Some(&body),
680                &[],
681            )
682            .await
683    }
684
685    /// Delete a workspace by ID.
686    ///
687    /// # Examples
688    ///
689    /// ```no_run
690    /// # async fn example() -> honcho_ai::error::Result<()> {
691    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
692    /// client.delete_workspace("old-ws").await?;
693    /// # Ok(())
694    /// # }
695    /// ```
696    pub async fn delete_workspace(&self, id: &str) -> Result<()> {
697        self.inner
698            .http
699            .delete::<()>(&routes::workspace(id)?, &[])
700            .await?;
701        // If we just deleted the workspace this client is scoped to, invalidate
702        // the ensure cache so later peer()/session() calls re-create it instead
703        // of failing with 404 against a now-missing workspace.
704        if id == self.workspace_id() {
705            self.inner.reset_ensure();
706        }
707        Ok(())
708    }
709
710    // ── Paginated list methods (F4.5) ──────────────────────────────────
711
712    /// Shared pagination plumbing for the peer/session list endpoints:
713    /// ensure the workspace exists, then POST to `route` for one page.
714    async fn list<T>(
715        &self,
716        route: &str,
717        body: Option<&Value>,
718        page: u64,
719        size: u64,
720        reverse: bool,
721    ) -> Result<crate::types::pagination::Page<T>>
722    where
723        T: serde::de::DeserializeOwned + Clone + Send + 'static,
724    {
725        self.ensure_workspace().await?;
726        crate::types::pagination::paginate_post(&self.inner.http, route, body, page, size, reverse)
727            .await
728    }
729
730    /// List peers in the workspace. Returns a paginated result.
731    ///
732    /// Defaults: page=1, size=50, reverse=false, no filters.
733    ///
734    /// # Examples
735    ///
736    /// ```no_run
737    /// # async fn example() -> honcho_ai::error::Result<()> {
738    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
739    /// let page = client.peers().await?;
740    /// for peer in page.items() {
741    ///     println!("{}", peer.id);
742    /// }
743    /// # Ok(())
744    /// # }
745    /// ```
746    pub async fn peers(&self) -> Result<crate::types::pagination::Page<crate::types::peer::Peer>> {
747        self.list(
748            &routes::peers_list(&self.inner.workspace_id)?,
749            None,
750            1,
751            50,
752            false,
753        )
754        .await
755    }
756
757    /// List peers with filters.
758    ///
759    /// `page` is 1-based. `size` must be in `1..=100`.
760    ///
761    /// # Examples
762    ///
763    /// ```no_run
764    /// # async fn example() -> honcho_ai::error::Result<()> {
765    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
766    /// let mut filters = std::collections::HashMap::new();
767    /// filters.insert("role".into(), "admin".into());
768    /// let page = client.peers_with_filters(filters, 1, 10, false).await?;
769    /// # Ok(())
770    /// # }
771    /// ```
772    pub async fn peers_with_filters(
773        &self,
774        filters: HashMap<String, Value>,
775        page: u64,
776        size: u64,
777        reverse: bool,
778    ) -> Result<crate::types::pagination::Page<crate::types::peer::Peer>> {
779        let body = crate::types::peer::PeerGet {
780            filters: Some(filters),
781        };
782        let body_val = serde_json::to_value(&body).map_err(|e| HonchoError::Serialization {
783            path: "PeerGet".into(),
784            source: e,
785        })?;
786        self.list(
787            &routes::peers_list(&self.inner.workspace_id)?,
788            Some(&body_val),
789            page,
790            size,
791            reverse,
792        )
793        .await
794    }
795
796    /// List sessions in the workspace. Returns a paginated result.
797    ///
798    /// Defaults: page=1, size=50, reverse=false, no filters.
799    ///
800    /// # Examples
801    ///
802    /// ```no_run
803    /// # async fn example() -> honcho_ai::error::Result<()> {
804    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
805    /// let page = client.sessions().await?;
806    /// for session in page.items() {
807    ///     println!("{}", session.id);
808    /// }
809    /// # Ok(())
810    /// # }
811    /// ```
812    pub async fn sessions(
813        &self,
814    ) -> Result<crate::types::pagination::Page<crate::types::session::SessionResponse>> {
815        self.list(
816            &routes::sessions_list(&self.inner.workspace_id)?,
817            None,
818            1,
819            50,
820            false,
821        )
822        .await
823    }
824
825    /// List sessions with filters.
826    ///
827    /// `page` is 1-based. `size` must be in `1..=100`.
828    ///
829    /// # Examples
830    ///
831    /// ```no_run
832    /// # async fn example() -> honcho_ai::error::Result<()> {
833    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
834    /// let mut filters = std::collections::HashMap::new();
835    /// filters.insert("is_active".into(), true.into());
836    /// let page = client.sessions_with_filters(filters, 1, 10, false).await?;
837    /// # Ok(())
838    /// # }
839    /// ```
840    pub async fn sessions_with_filters(
841        &self,
842        filters: HashMap<String, Value>,
843        page: u64,
844        size: u64,
845        reverse: bool,
846    ) -> Result<crate::types::pagination::Page<crate::types::session::SessionResponse>> {
847        let body = crate::types::session::SessionGet {
848            filters: Some(filters),
849        };
850        let body_val = serde_json::to_value(&body).map_err(|e| HonchoError::Serialization {
851            path: "SessionGet".into(),
852            source: e,
853        })?;
854        self.list(
855            &routes::sessions_list(&self.inner.workspace_id)?,
856            Some(&body_val),
857            page,
858            size,
859            reverse,
860        )
861        .await
862    }
863
864    /// List workspace IDs. Returns a paginated result of ID strings.
865    ///
866    /// Defaults: page=1, size=50, reverse=false, no filters.
867    /// No workspace scope required — queries all workspaces.
868    ///
869    /// # Examples
870    ///
871    /// ```no_run
872    /// # async fn example() -> honcho_ai::error::Result<()> {
873    /// let client = honcho_ai::Honcho::new("http://localhost:8000", "ws-1")?;
874    /// let page = client.workspaces().await?;
875    /// for id in page.items() {
876    ///     println!("{id}");
877    /// }
878    /// # Ok(())
879    /// # }
880    /// ```
881    pub async fn workspaces(&self) -> Result<crate::types::pagination::Page<Workspace, String>> {
882        let page = crate::types::pagination::paginate_post::<Workspace>(
883            &self.inner.http,
884            &routes::workspaces_list(),
885            None,
886            1,
887            50,
888            false,
889        )
890        .await?;
891        Ok(page.map(|ws| ws.id))
892    }
893}
894
895fn validate_workspace_id(workspace_id: &str) -> Result<()> {
896    if workspace_id.is_empty() {
897        return Err(HonchoError::Configuration(
898            "workspace_id must not be empty".into(),
899        ));
900    }
901
902    if workspace_id.len() > 512 {
903        return Err(HonchoError::Configuration(
904            "workspace_id must be at most 512 characters".into(),
905        ));
906    }
907
908    if !workspace_id
909        .bytes()
910        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
911    {
912        return Err(HonchoError::Configuration(
913            "workspace_id must match [a-zA-Z0-9_-]+".into(),
914        ));
915    }
916
917    Ok(())
918}
919
920#[cfg(test)]
921mod tests {
922    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
923
924    use super::*;
925    use wiremock::matchers::{header, method, path};
926    use wiremock::{Mock, MockServer, ResponseTemplate};
927
928    fn workspace_body() -> serde_json::Value {
929        serde_json::json!({ "id": "ws-1", "created_at": "2025-01-15T10:30:00Z" })
930    }
931
932    fn peer_body() -> serde_json::Value {
933        serde_json::json!({
934            "id": "p1",
935            "workspace_id": "ws-1",
936            "created_at": "2025-01-15T10:30:00Z",
937        })
938    }
939
940    /// Build a wiremock-targeting client with `HONCHO_API_KEY` pinned unset.
941    ///
942    /// `Honcho::new` reads `HONCHO_API_KEY` from the process environment at
943    /// construction time. These wiremock tests do not expect an auth header, so
944    /// ambient env — or a concurrent env-mutating test (e.g.
945    /// `new_picks_up_honcho_api_key_env`) — could otherwise leak a `Bearer`
946    /// token into them. Pinning the var via `temp_env` makes construction
947    /// deterministic regardless of ambient state, and `temp_env`'s internal lock
948    /// serializes this read against the module's other env scopes without
949    /// forcing the whole (async) test body to run serially.
950    fn client_no_env_key(base_url: &str) -> Honcho {
951        temp_env::with_var("HONCHO_API_KEY", None::<&str>, || {
952            Honcho::new(base_url, "ws-1").unwrap()
953        })
954    }
955
956    /// Count requests that hit the workspace-ensure endpoint (`POST /v3/workspaces`).
957    async fn ensure_hits(server: &MockServer) -> usize {
958        server
959            .received_requests()
960            .await
961            .unwrap()
962            .iter()
963            .filter(|r| r.url.path() == "/v3/workspaces")
964            .count()
965    }
966
967    // Bug 1: `force_ensure` must bypass the cached OnceCell and re-issue the
968    // ensure request every call, even after a prior success.
969    #[tokio::test]
970    async fn force_ensure_reissues_every_call() {
971        let server = MockServer::start().await;
972        Mock::given(method("POST"))
973            .and(path("/v3/workspaces"))
974            .respond_with(ResponseTemplate::new(200).set_body_json(workspace_body()))
975            .mount(&server)
976            .await;
977
978        let client = client_no_env_key(&server.uri());
979        client.force_ensure().await.unwrap();
980        client.force_ensure().await.unwrap();
981
982        let hits = ensure_hits(&server).await;
983        assert!(
984            hits >= 2,
985            "force_ensure should re-issue PUT/POST, got {hits}"
986        );
987    }
988
989    // Bug 2: `new` must honor `HONCHO_API_KEY` and send it as a bearer token.
990    // The mock only matches when the Authorization header is present, so a
991    // missing key would yield zero received requests.
992    #[tokio::test]
993    #[serial_test::serial]
994    async fn new_picks_up_honcho_api_key_env() {
995        let server = MockServer::start().await;
996        Mock::given(method("POST"))
997            .and(path("/v3/workspaces"))
998            .and(header("authorization", "Bearer secret-key-123"))
999            .respond_with(ResponseTemplate::new(200).set_body_json(workspace_body()))
1000            .mount(&server)
1001            .await;
1002
1003        let uri = server.uri();
1004        // `new` reads the env synchronously at construction time; build the
1005        // client inside the scoped env so the key is baked into the HttpClient.
1006        let client = temp_env::with_var("HONCHO_API_KEY", Some("secret-key-123"), || {
1007            Honcho::new(&uri, "ws-1").unwrap()
1008        });
1009
1010        client.force_ensure().await.unwrap();
1011        assert_eq!(ensure_hits(&server).await, 1, "auth header was not sent");
1012    }
1013
1014    // Bug 3: deleting the client's own workspace must invalidate the ensure
1015    // cache so a subsequent peer()/session() re-creates it (POST #2) instead of
1016    // short-circuiting and 404-ing.
1017    #[tokio::test]
1018    async fn delete_self_workspace_resets_ensure() {
1019        let server = MockServer::start().await;
1020        Mock::given(method("POST"))
1021            .and(path("/v3/workspaces"))
1022            .respond_with(ResponseTemplate::new(200).set_body_json(workspace_body()))
1023            .mount(&server)
1024            .await;
1025        Mock::given(method("DELETE"))
1026            .and(path("/v3/workspaces/ws-1"))
1027            .respond_with(ResponseTemplate::new(200))
1028            .mount(&server)
1029            .await;
1030        Mock::given(method("POST"))
1031            .and(path("/v3/workspaces/ws-1/peers"))
1032            .respond_with(ResponseTemplate::new(200).set_body_json(peer_body()))
1033            .mount(&server)
1034            .await;
1035
1036        let client = client_no_env_key(&server.uri());
1037        client.peer("alice").build().await.unwrap(); // ensure POST #1
1038        client.delete_workspace("ws-1").await.unwrap(); // resets cache
1039        client.peer("bob").build().await.unwrap(); // ensure POST #2
1040
1041        let hits = ensure_hits(&server).await;
1042        assert!(
1043            hits >= 2,
1044            "deleting own workspace must force re-ensure, got {hits}"
1045        );
1046    }
1047
1048    // Deleting a *different* workspace must NOT invalidate our ensure cache.
1049    #[tokio::test]
1050    async fn delete_other_workspace_keeps_ensure() {
1051        let server = MockServer::start().await;
1052        Mock::given(method("POST"))
1053            .and(path("/v3/workspaces"))
1054            .respond_with(ResponseTemplate::new(200).set_body_json(workspace_body()))
1055            .mount(&server)
1056            .await;
1057        Mock::given(method("DELETE"))
1058            .and(path("/v3/workspaces/other-ws"))
1059            .respond_with(ResponseTemplate::new(200))
1060            .mount(&server)
1061            .await;
1062        Mock::given(method("POST"))
1063            .and(path("/v3/workspaces/ws-1/peers"))
1064            .respond_with(ResponseTemplate::new(200).set_body_json(peer_body()))
1065            .mount(&server)
1066            .await;
1067
1068        let client = client_no_env_key(&server.uri());
1069        client.peer("alice").build().await.unwrap(); // ensure POST #1
1070        client.delete_workspace("other-ws").await.unwrap(); // unrelated, no reset
1071        client.peer("bob").build().await.unwrap(); // cache hit, no POST #2
1072
1073        assert_eq!(
1074            ensure_hits(&server).await,
1075            1,
1076            "deleting an unrelated workspace must not reset the cache"
1077        );
1078    }
1079
1080    // Bug 4: an empty `HONCHO_URL` must fall through to the next source
1081    // (here: the default environment URL), not be used as the base URL.
1082    #[test]
1083    #[serial_test::serial]
1084    fn empty_honcho_url_falls_back_to_default() {
1085        let client = temp_env::with_vars(
1086            [
1087                ("HONCHO_URL", Some("")),
1088                ("HONCHO_API_URL", None),
1089                ("HONCHO_WORKSPACE_ID", None),
1090                ("HONCHO_API_KEY", None),
1091            ],
1092            || Honcho::from_params(Honcho::builder().build()).unwrap(),
1093        );
1094
1095        assert!(
1096            client
1097                .base_url()
1098                .as_str()
1099                .starts_with("https://api.honcho.dev"),
1100            "empty HONCHO_URL should fall back to the default, got {}",
1101            client.base_url()
1102        );
1103    }
1104
1105    // The server exposes no `GET /v3/workspaces/{id}` (only PUT/DELETE), so
1106    // `refresh` reads through the get-or-create `POST /v3/workspaces` endpoint —
1107    // a single round-trip that both ensures and fetches the workspace.
1108    #[tokio::test]
1109    async fn refresh_uses_get_or_create() {
1110        let server = MockServer::start().await;
1111        Mock::given(method("POST"))
1112            .and(path("/v3/workspaces"))
1113            .respond_with(ResponseTemplate::new(200).set_body_json(workspace_body()))
1114            .mount(&server)
1115            .await;
1116
1117        let client = client_no_env_key(&server.uri());
1118        client.refresh().await.unwrap();
1119
1120        assert_eq!(
1121            ensure_hits(&server).await,
1122            1,
1123            "refresh must read the workspace via a single get-or-create POST"
1124        );
1125    }
1126}