Skip to main content

writ_client/
client.rs

1//! Client construction, configuration (DESIGN.md §3) and discovery (§4), plus the
2//! shared HTTP plumbing every resource handle rides on.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
9use reqwest::{Method, Response};
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12
13use crate::discovery::{env_var, runtime_candidates};
14use crate::error::{api_error, Result, WritError};
15use crate::models::WsTicket;
16use crate::resources::{
17    Agent, Automations, Crawl, Data, Datasets, Extractors, Files, Keys, Monitors, Personas, Runs,
18    Secrets, Selectors, Vault, Workflows,
19};
20
21/// `User-Agent` sent on every request: `writ-sdk-rust/<version>`.
22pub(crate) const USER_AGENT: &str = concat!("writ-sdk-rust/", env!("CARGO_PKG_VERSION"));
23
24/// Default per-request timeout (DESIGN.md §3).
25const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// Liveness-probe budget during discovery (DESIGN.md §4).
28const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
29
30/// Per-request timeout override for plain `runs().events()` streams — an SSE stream
31/// must outlive the client's default 30 s request timeout.
32pub(crate) const SSE_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
33
34/// The async client for a local Writ agent (`writ-agentd`).
35///
36/// Construct with [`WritAgent::builder`] (explicit config, no network I/O) or
37/// [`WritAgent::discover`] (env + `runtime.json` walk with a liveness probe).
38/// Resource groups hang off accessor methods: [`WritAgent::workflows`],
39/// [`WritAgent::runs`], [`WritAgent::monitors`], …
40#[derive(Debug, Clone)]
41pub struct WritAgent {
42    inner: Arc<Inner>,
43}
44
45/// Shared HTTP state (one reqwest client, resolved base URL + bearer).
46#[derive(Debug)]
47pub(crate) struct Inner {
48    pub(crate) http: reqwest::Client,
49    pub(crate) base_url: String,
50}
51
52/// Configuration builder. `build()` performs **no network I/O** (and no filesystem
53/// discovery); the only I/O it can do is reading the CA file passed to
54/// [`WritAgentBuilder::ca_pem_file`]. `discover()` runs the full DESIGN.md §4
55/// algorithm for whichever of base URL / token was not provided.
56#[derive(Debug, Default, Clone)]
57pub struct WritAgentBuilder {
58    base_url: Option<String>,
59    token: Option<String>,
60    timeout: Option<Duration>,
61    ca_pem_file: Option<PathBuf>,
62}
63
64impl WritAgentBuilder {
65    /// Base URL of the daemon, e.g. `http://127.0.0.1:8131`. No trailing `/v1` —
66    /// the SDK appends path prefixes itself. A trailing `/` is stripped.
67    pub fn base_url(mut self, url: impl Into<String>) -> Self {
68        self.base_url = Some(url.into());
69        self
70    }
71
72    /// Bearer token (`wlt_` runtime token, `wlk_` scoped key, or `wlo_` OAuth
73    /// token) — treated as an opaque string.
74    pub fn token(mut self, token: impl Into<String>) -> Self {
75        self.token = Some(token.into());
76        self
77    }
78
79    /// Per-request timeout (default 30 s). `run_and_wait` and `events()` manage
80    /// their own deadlines.
81    pub fn timeout(mut self, timeout: Duration) -> Self {
82        self.timeout = Some(timeout);
83        self
84    }
85
86    /// PEM file of the daemon's local CA (`~/.writ/tls/ca.pem`) for the HTTPS twin
87    /// port. Read (filesystem only) at `build()`/`discover()` time.
88    pub fn ca_pem_file(mut self, path: impl Into<PathBuf>) -> Self {
89        self.ca_pem_file = Some(path.into());
90        self
91    }
92
93    /// The reqwest client for `timeout`, honoring the optional CA file.
94    fn http_client(&self, timeout: Duration, token: Option<&str>) -> Result<reqwest::Client> {
95        let mut builder = reqwest::Client::builder()
96            .timeout(timeout)
97            .user_agent(USER_AGENT);
98        if let Some(token) = token {
99            let mut headers = HeaderMap::new();
100            let mut auth = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
101                WritError::Discovery("token contains characters invalid in an HTTP header".into())
102            })?;
103            auth.set_sensitive(true);
104            headers.insert(AUTHORIZATION, auth);
105            builder = builder.default_headers(headers);
106        }
107        if let Some(path) = &self.ca_pem_file {
108            let pem = std::fs::read(path).map_err(|e| {
109                WritError::Discovery(format!("cannot read ca_pem_file {}: {e}", path.display()))
110            })?;
111            let cert = reqwest::Certificate::from_pem(&pem).map_err(|e| {
112                WritError::Discovery(format!("invalid CA pem {}: {e}", path.display()))
113            })?;
114            builder = builder.add_root_certificate(cert);
115        }
116        builder
117            .build()
118            .map_err(|e| WritError::Discovery(format!("building http client: {e}")))
119    }
120
121    /// Resolve `(base_url, token)` from explicit options, falling back to the
122    /// `WRIT_API_URL` / `WRIT_TOKEN` env overrides (§4 step 1).
123    fn resolved(&self) -> (Option<String>, Option<String>) {
124        let url = self.base_url.clone().or_else(|| env_var("WRIT_API_URL"));
125        let token = self.token.clone().or_else(|| env_var("WRIT_TOKEN"));
126        (url, token)
127    }
128
129    fn assemble(&self, base_url: &str, token: &str) -> Result<WritAgent> {
130        let http = self.http_client(self.timeout.unwrap_or(DEFAULT_TIMEOUT), Some(token))?;
131        Ok(WritAgent {
132            inner: Arc::new(Inner {
133                http,
134                base_url: base_url.trim_end_matches('/').to_string(),
135            }),
136        })
137    }
138
139    /// Build the client from explicit options (env `WRIT_API_URL`/`WRIT_TOKEN`
140    /// fill gaps; base URL defaults to `http://127.0.0.1:8131`). **No network or
141    /// filesystem discovery, no liveness probe.** Fails with a discovery error
142    /// when no token can be resolved.
143    pub fn build(self) -> Result<WritAgent> {
144        let (url, token) = self.resolved();
145        let token = token.ok_or_else(|| {
146            WritError::Discovery(
147                "no token configured — is the Writ agent running? pass .token(...) or set WRIT_TOKEN"
148                    .into(),
149            )
150        })?;
151        let url = url.unwrap_or_else(|| "http://127.0.0.1:8131".to_string());
152        self.assemble(&url, &token)
153    }
154
155    /// Full discovery (DESIGN.md §4) for whichever of base URL / token this
156    /// builder does not already have: env overrides, then the `runtime.json`
157    /// candidate walk with a 2 s `GET /v1/agent` liveness probe per candidate
158    /// (stale descriptors fall through to the next candidate).
159    pub async fn discover(self) -> Result<WritAgent> {
160        let (url_override, token_override) = self.resolved();
161
162        // §4 step 1: with both fields pinned (explicitly or via env), discovery is done.
163        if let (Some(url), Some(token)) = (&url_override, &token_override) {
164            return self.assemble(url, token);
165        }
166
167        let candidates = runtime_candidates();
168        if candidates.is_empty() {
169            return Err(WritError::Discovery(
170                "no runtime.json found under $WRIT_HOME or ~/.writ — is the Writ agent running? \
171                 pass base_url/token explicitly or set WRIT_API_URL/WRIT_TOKEN"
172                    .into(),
173            ));
174        }
175
176        let probe = self.http_client(PROBE_TIMEOUT, None)?;
177        let mut tried: Vec<String> = Vec::new();
178        for candidate in candidates {
179            let url = url_override
180                .clone()
181                .unwrap_or_else(|| candidate.base_url.clone());
182            let url = url.trim_end_matches('/').to_string();
183            let token = token_override
184                .clone()
185                .unwrap_or_else(|| candidate.token.clone());
186            let live = probe
187                .get(format!("{url}/v1/agent"))
188                .bearer_auth(&token)
189                .send()
190                .await
191                .map(|r| r.status().is_success())
192                .unwrap_or(false);
193            if live {
194                return self.assemble(&url, &token);
195            }
196            tried.push(candidate.source.display().to_string());
197        }
198        Err(WritError::Discovery(format!(
199            "no live Writ agent answered the probe (stale runtime.json candidates: {}) — \
200             is the Writ agent running? pass token=... or set WRIT_TOKEN",
201            tried.join(", ")
202        )))
203    }
204}
205
206impl WritAgent {
207    /// Start explicit configuration. `build()` performs no I/O.
208    pub fn builder() -> WritAgentBuilder {
209        WritAgentBuilder::default()
210    }
211
212    /// Discover a live local daemon (env → `runtime.json` walk → liveness probe)
213    /// and return a ready client. See DESIGN.md §4 / [`WritAgentBuilder::discover`].
214    pub async fn discover() -> Result<WritAgent> {
215        WritAgentBuilder::default().discover().await
216    }
217
218    /// The resolved base URL (no trailing slash).
219    pub fn base_url(&self) -> &str {
220        &self.inner.base_url
221    }
222
223    /// Agent status/health (`/v1/agent`, `/v1/health`).
224    pub fn agent(&self) -> Agent<'_> {
225        Agent { c: &self.inner }
226    }
227
228    /// Workflows (`/v1/workflows`).
229    pub fn workflows(&self) -> Workflows<'_> {
230        Workflows { c: &self.inner }
231    }
232
233    /// Runs — read + control (`/v1/runs`).
234    pub fn runs(&self) -> Runs<'_> {
235        Runs { c: &self.inner }
236    }
237
238    /// Monitors (`/v1/monitors`, `/v1/changes/recent`).
239    pub fn monitors(&self) -> Monitors<'_> {
240        Monitors { c: &self.inner }
241    }
242
243    /// Content selectors nested under monitors (`/v1/monitors/:id/selectors`).
244    pub fn selectors(&self) -> Selectors<'_> {
245        Selectors { c: &self.inner }
246    }
247
248    /// Field extractors (`/v1/extractors`, `/v1/selectors/:sid/extractors`).
249    pub fn extractors(&self) -> Extractors<'_> {
250        Extractors { c: &self.inner }
251    }
252
253    /// Automations (`/v1/automations`).
254    pub fn automations(&self) -> Automations<'_> {
255        Automations { c: &self.inner }
256    }
257
258    /// Personas (`/v1/personas`).
259    pub fn personas(&self) -> Personas<'_> {
260        Personas { c: &self.inner }
261    }
262
263    /// Vault secrets — metadata only, values never come back (`/v1/secrets`).
264    pub fn secrets(&self) -> Secrets<'_> {
265        Secrets { c: &self.inner }
266    }
267
268    /// Vault app-lock (`/v1/vault/*`).
269    pub fn vault(&self) -> Vault<'_> {
270        Vault { c: &self.inner }
271    }
272
273    /// Stored files (`/v1/files`).
274    pub fn files(&self) -> Files<'_> {
275        Files { c: &self.inner }
276    }
277
278    /// Extracted-data queries and exports (`/v1/data`, `/v1/workflows/:id/data*`).
279    pub fn data(&self) -> Data<'_> {
280        Data { c: &self.inner }
281    }
282
283    /// Scoped API keys (`/v1/keys`; requires the `manage`-capable `wlt_` token).
284    pub fn keys(&self) -> Keys<'_> {
285        Keys { c: &self.inner }
286    }
287
288    /// Dragnet whole-site crawls (`/v1/crawl`).
289    pub fn crawl(&self) -> Crawl<'_> {
290        Crawl { c: &self.inner }
291    }
292
293    /// Datasets — the unified crawl + workflow extracted-data index (`/v1/datasets`).
294    pub fn datasets(&self) -> Datasets<'_> {
295        Datasets { c: &self.inner }
296    }
297
298    /// Mint a single-use WebSocket connect ticket (`POST /v1/ws-ticket`).
299    /// `route` ∈ `"record" | "ai-preview"`; `ai-preview` requires a `channel`.
300    /// Opening the WebSocket itself is out of scope for v1.
301    pub async fn ws_ticket(&self, route: &str, channel: Option<&str>) -> Result<WsTicket> {
302        let mut body = serde_json::json!({ "route": route });
303        if let Some(channel) = channel {
304            body["channel"] = Value::String(channel.to_string());
305        }
306        self.inner
307            .send_json(Method::POST, "/v1/ws-ticket", &[], Some(&body))
308            .await
309    }
310}
311
312impl Inner {
313    fn url(&self, path: &str) -> String {
314        format!("{}{}", self.base_url, path)
315    }
316
317    /// Send and surface non-2xx (outside `extra_ok`) as [`WritError::Api`].
318    async fn execute(&self, rb: reqwest::RequestBuilder, extra_ok: &[u16]) -> Result<Response> {
319        let resp = rb.send().await.map_err(WritError::from)?;
320        let status = resp.status();
321        if status.is_success() || extra_ok.contains(&status.as_u16()) {
322            return Ok(resp);
323        }
324        let reason = status.canonical_reason().unwrap_or("error").to_string();
325        let text = resp.text().await.unwrap_or_default();
326        Err(api_error(status.as_u16(), &reason, &text))
327    }
328
329    async fn decode<T: DeserializeOwned>(resp: Response) -> Result<T> {
330        resp.json::<T>()
331            .await
332            .map_err(|e| WritError::Connection(format!("decoding response body: {e}")))
333    }
334
335    /// `GET path?query` → JSON.
336    pub(crate) async fn get_json<T: DeserializeOwned>(
337        &self,
338        path: &str,
339        query: &[(&str, &str)],
340    ) -> Result<T> {
341        let rb = self.http.get(self.url(path)).query(query);
342        Self::decode(self.execute(rb, &[]).await?).await
343    }
344
345    /// `method path?query` with an optional JSON body → JSON.
346    pub(crate) async fn send_json<T: DeserializeOwned>(
347        &self,
348        method: Method,
349        path: &str,
350        query: &[(&str, &str)],
351        body: Option<&Value>,
352    ) -> Result<T> {
353        self.send_json_allowing(method, path, query, body, &[])
354            .await
355    }
356
357    /// Like [`Inner::send_json`], but the listed non-2xx statuses are decoded as a
358    /// success body instead of an error (cancel's `409 not_running`).
359    pub(crate) async fn send_json_allowing<T: DeserializeOwned>(
360        &self,
361        method: Method,
362        path: &str,
363        query: &[(&str, &str)],
364        body: Option<&Value>,
365        extra_ok: &[u16],
366    ) -> Result<T> {
367        let mut rb = self.http.request(method, self.url(path)).query(query);
368        if let Some(body) = body {
369            rb = rb.json(body);
370        }
371        Self::decode(self.execute(rb, extra_ok).await?).await
372    }
373
374    /// `GET path?query` → raw text (CSV lane).
375    pub(crate) async fn get_text(&self, path: &str, query: &[(&str, &str)]) -> Result<String> {
376        let rb = self.http.get(self.url(path)).query(query);
377        self.execute(rb, &[])
378            .await?
379            .text()
380            .await
381            .map_err(|e| WritError::Connection(format!("reading response body: {e}")))
382    }
383
384    /// `GET path?query` → raw bytes (file content / exports).
385    pub(crate) async fn get_bytes(
386        &self,
387        path: &str,
388        query: &[(&str, &str)],
389    ) -> Result<bytes::Bytes> {
390        let rb = self.http.get(self.url(path)).query(query);
391        self.execute(rb, &[])
392            .await?
393            .bytes()
394            .await
395            .map_err(|e| WritError::Connection(format!("reading response body: {e}")))
396    }
397
398    /// `GET path` as a streaming response (SSE) with a per-request timeout
399    /// override — the client-wide 30 s default would sever a long stream.
400    pub(crate) async fn get_stream(&self, path: &str, timeout: Duration) -> Result<Response> {
401        let rb = self
402            .http
403            .get(self.url(path))
404            .header(reqwest::header::ACCEPT, "text/event-stream")
405            .timeout(timeout);
406        self.execute(rb, &[]).await
407    }
408
409    /// `POST path` with a multipart form → JSON.
410    pub(crate) async fn post_multipart<T: DeserializeOwned>(
411        &self,
412        path: &str,
413        form: reqwest::multipart::Form,
414    ) -> Result<T> {
415        let rb = self.http.post(self.url(path)).multipart(form);
416        Self::decode(self.execute(rb, &[]).await?).await
417    }
418}