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}