Skip to main content

writ_client/
resources.rs

1//! Resource namespaces (DESIGN.md §7) — lightweight handles returned by the
2//! accessor methods on [`crate::WritAgent`].
3//!
4//! Loosely-shaped request bodies (create/update payloads) are `serde_json::Value`
5//! (build them with `serde_json::json!`); responses are typed models with an
6//! `extra` catch-all. `*_with` variants pass arbitrary query params through as
7//! given.
8
9use std::collections::VecDeque;
10use std::time::{Duration, Instant};
11
12use futures_util::stream::BoxStream;
13use futures_util::StreamExt;
14use reqwest::Method;
15use serde_json::{json, Value};
16
17use crate::client::{Inner, SSE_TIMEOUT};
18use crate::error::{Result, WritError};
19use crate::models::{
20    AgentStatus, ApiKey, Automation, CancelOutcome, CrawlCancel, CrawlJob, CrawlList,
21    CrawlStartParams, DatasetFormat, DatasetList, DatasetMeta, DatasetSearchResult, Extractor,
22    Health, Monitor, MonitorHistory, Persona, RunCompleted, RunData, RunEvent, RunFeedItem,
23    RunOutcome, RunResults, RunStarted, SecretMeta, Selector, StoredFile, VaultStatus, Workflow,
24};
25use crate::page::Page;
26use crate::sse::SseParser;
27
28/// Default overall deadline for [`Workflows::run_and_wait`] (DESIGN.md §8).
29const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(600);
30
31/// Polling cadence when the SSE stream is unavailable (DESIGN.md §8).
32const POLL_INTERVAL: Duration = Duration::from_secs(1);
33
34/// A live stream of [`RunEvent`]s; ends after a terminal `finished`/`error` frame.
35pub type RunEventStream = BoxStream<'static, Result<RunEvent>>;
36
37/// Options for `POST /v1/workflows/:id/run` (and `run_and_wait`).
38///
39/// The wire body is `{ inputs?, persona_id?, files? }` — `form_data` is an accepted
40/// daemon alias for `inputs`, but this SDK only exposes `inputs`.
41#[derive(Debug, Clone, Default)]
42pub struct RunOptions {
43    /// Free-form run inputs `{NAME: value}` resolved over `{{NAME}}`/`{{input.NAME}}`.
44    pub inputs: Option<Value>,
45    /// Persona override for this run (wins over the workflow's pinned default).
46    pub persona_id: Option<i64>,
47    /// `{slot: file_id}` bindings of vault files to declared upload slots.
48    pub files: Option<Value>,
49    /// `run_and_wait` only: overall deadline (default 600 s). The run itself is
50    /// **never** cancelled on timeout.
51    pub wait_timeout: Option<Duration>,
52    /// `run_and_wait` only: also fetch `runs().results()` after the terminal event.
53    pub include_results: bool,
54}
55
56impl RunOptions {
57    fn body(&self, dry_run: bool) -> Value {
58        let mut body = serde_json::Map::new();
59        if let Some(inputs) = &self.inputs {
60            body.insert("inputs".into(), inputs.clone());
61        }
62        if let Some(persona_id) = self.persona_id {
63            body.insert("persona_id".into(), json!(persona_id));
64        }
65        if let Some(files) = &self.files {
66            body.insert("files".into(), files.clone());
67        }
68        if dry_run {
69            body.insert("dry_run".into(), json!(true));
70        }
71        Value::Object(body)
72    }
73}
74
75/// `/v1/agent` + `/v1/health`.
76#[derive(Debug, Clone, Copy)]
77pub struct Agent<'a> {
78    pub(crate) c: &'a Inner,
79}
80
81impl Agent<'_> {
82    /// `GET /v1/agent` — lightweight status.
83    pub async fn status(&self) -> Result<AgentStatus> {
84        self.c.get_json("/v1/agent", &[]).await
85    }
86
87    /// `GET /v1/health` — deep health.
88    pub async fn health(&self) -> Result<Health> {
89        self.c.get_json("/v1/health", &[]).await
90    }
91}
92
93/// `/v1/workflows`.
94#[derive(Debug, Clone, Copy)]
95pub struct Workflows<'a> {
96    pub(crate) c: &'a Inner,
97}
98
99impl Workflows<'_> {
100    /// `GET /v1/workflows`.
101    pub async fn list(&self) -> Result<Page<Workflow>> {
102        self.list_with(&[]).await
103    }
104
105    /// `GET /v1/workflows` with query params (`active_only`, `limit`).
106    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<Workflow>> {
107        self.c.get_json("/v1/workflows", query).await
108    }
109
110    /// `POST /v1/workflows`.
111    pub async fn create(&self, body: Value) -> Result<Workflow> {
112        self.c
113            .send_json(Method::POST, "/v1/workflows", &[], Some(&body))
114            .await
115    }
116
117    /// `GET /v1/workflows/:id`.
118    pub async fn get(&self, id: i64) -> Result<Workflow> {
119        self.c.get_json(&format!("/v1/workflows/{id}"), &[]).await
120    }
121
122    /// `PATCH /v1/workflows/:id` — sparse update.
123    pub async fn update(&self, id: i64, patch: Value) -> Result<Workflow> {
124        self.c
125            .send_json(
126                Method::PATCH,
127                &format!("/v1/workflows/{id}"),
128                &[],
129                Some(&patch),
130            )
131            .await
132    }
133
134    /// `DELETE /v1/workflows/:id` — hard delete.
135    pub async fn delete(&self, id: i64) -> Result<Value> {
136        self.c
137            .send_json(Method::DELETE, &format!("/v1/workflows/{id}"), &[], None)
138            .await
139    }
140
141    /// `POST /v1/workflows/:id/run` → `202 {run_id, status:"running"}`.
142    ///
143    /// Observe the run with [`RunsApi::events`] / [`RunsApi::get`], or use
144    /// [`Self::run_wait`] to have the daemon block and hand back the result directly.
145    pub async fn run(&self, id: i64, opts: &RunOptions) -> Result<RunStarted> {
146        self.c
147            .send_json(
148                Method::POST,
149                &format!("/v1/workflows/{id}/run"),
150                &[],
151                Some(&opts.body(false)),
152            )
153            .await
154    }
155
156    /// `POST /v1/workflows/:id/run?wait=true` — run the workflow and BLOCK on the daemon
157    /// until it reaches a terminal state, returning the run's own result. One request: no
158    /// SSE, no poll loop.
159    ///
160    /// `timeout` is how long the daemon may block (clamped server-side to `[1s, 3600s]`,
161    /// default 120 s). A run that FAILS is returned as `Ok` with `status == "failed"` —
162    /// check it. Only an expired budget is an `Err`, and it is
163    /// [`WritError::RunTimeout`] carrying the still-valid `run_id`, so you can collect the
164    /// run rather than start a second one.
165    ///
166    /// Prefer [`Self::run_and_wait`] when you want live events, the enriched run feed item,
167    /// or a deadline longer than the daemon's own ceiling.
168    pub async fn run_wait(
169        &self,
170        id: i64,
171        opts: &RunOptions,
172        timeout: Option<Duration>,
173    ) -> Result<RunCompleted> {
174        let secs = timeout.map(|d| d.as_secs().max(1).to_string());
175        let mut query: Vec<(&str, &str)> = vec![("wait", "true")];
176        if let Some(secs) = secs.as_deref() {
177            query.push(("timeout", secs));
178        }
179        // 504 is a documented, RECOVERABLE outcome of waiting, so it is decoded as a body
180        // rather than mapped to a generic API error — which would throw away the run id,
181        // the only thing that makes it recoverable.
182        let out: RunCompleted = self
183            .c
184            .send_json_allowing(
185                Method::POST,
186                &format!("/v1/workflows/{id}/run"),
187                &query,
188                Some(&opts.body(false)),
189                &[504],
190            )
191            .await?;
192        if !out.done {
193            return Err(WritError::RunTimeout {
194                run_id: out.run_id,
195                status_url: out.status_url,
196                events_url: out.events_url,
197            });
198        }
199        Ok(out)
200    }
201
202    /// `POST /v1/workflows/:id/run` with `dry_run: true` → `200` validate-only
203    /// step-plan report (nothing is executed).
204    pub async fn dry_run(&self, id: i64, opts: &RunOptions) -> Result<Value> {
205        self.c
206            .send_json(
207                Method::POST,
208                &format!("/v1/workflows/{id}/run"),
209                &[],
210                Some(&opts.body(true)),
211            )
212            .await
213    }
214
215    /// `POST /v1/workflows/:id/cancel` — cancel the newest live run of this
216    /// workflow. A `409 not_running` is a valid [`CancelOutcome`], not an error.
217    pub async fn cancel(&self, id: i64) -> Result<CancelOutcome> {
218        self.c
219            .send_json_allowing(
220                Method::POST,
221                &format!("/v1/workflows/{id}/cancel"),
222                &[],
223                None,
224                &[409],
225            )
226            .await
227    }
228
229    /// `GET /v1/workflows/:id/session` — browserless-HTTP-lane session status.
230    pub async fn session(&self, id: i64) -> Result<Value> {
231        self.c
232            .get_json(&format!("/v1/workflows/{id}/session"), &[])
233            .await
234    }
235
236    /// `DELETE /v1/workflows/:id/session` — drop the persisted session.
237    pub async fn clear_session(&self, id: i64) -> Result<Value> {
238        self.c
239            .send_json(
240                Method::DELETE,
241                &format!("/v1/workflows/{id}/session"),
242                &[],
243                None,
244            )
245            .await
246    }
247
248    /// Run the workflow and wait for the terminal state (DESIGN.md §8):
249    /// start the run, follow the SSE event stream, and on SSE failure fall back to
250    /// polling `runs().get()` every second. Overall deadline
251    /// [`RunOptions::wait_timeout`] (default **600 s**) — on timeout the run is
252    /// **NOT cancelled**; it keeps executing on the daemon. Returns the final
253    /// [`RunFeedItem`] (plus `runs().results()` when
254    /// [`RunOptions::include_results`] is set).
255    pub async fn run_and_wait(&self, id: i64, opts: &RunOptions) -> Result<RunOutcome> {
256        let started = self.run(id, opts).await?;
257        let run_id = started.run_id;
258        let wait = opts.wait_timeout.unwrap_or(DEFAULT_WAIT_TIMEOUT);
259        let deadline = Instant::now() + wait;
260        let runs = Runs { c: self.c };
261
262        let timeout_err = || {
263            WritError::Connection(format!(
264                "run_and_wait: run {run_id} not terminal after {}s — the run was NOT cancelled \
265                 and continues on the daemon",
266                wait.as_secs()
267            ))
268        };
269
270        // Phase 1: SSE. The per-request timeout is capped at the remaining
271        // deadline, so a silent stream cannot outlive the overall budget.
272        let mut saw_terminal = false;
273        let remaining = deadline.saturating_duration_since(Instant::now());
274        if !remaining.is_zero() {
275            if let Ok(mut stream) = runs.events_with_timeout(run_id, remaining).await {
276                while let Some(item) = stream.next().await {
277                    match item {
278                        Ok(ev) if ev.is_terminal() => {
279                            saw_terminal = true;
280                            break;
281                        }
282                        Ok(_) => {
283                            if Instant::now() >= deadline {
284                                return Err(timeout_err());
285                            }
286                        }
287                        // Dropped pre-terminal → polling fallback.
288                        Err(_) => break,
289                    }
290                }
291            }
292        }
293
294        // Phase 2: polling fallback (stream failed, dropped, or closed without a
295        // terminal frame).
296        if !saw_terminal {
297            loop {
298                if Instant::now() >= deadline {
299                    return Err(timeout_err());
300                }
301                let item = runs.get(run_id).await?;
302                if !item.is_running() {
303                    break;
304                }
305                let nap = POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()));
306                if nap.is_zero() {
307                    return Err(timeout_err());
308                }
309                crate::util::sleep(nap).await;
310            }
311        }
312
313        // Final snapshot (once, after the terminal event).
314        let run = runs.get(run_id).await?;
315        let results = if opts.include_results {
316            Some(runs.results(run_id).await?)
317        } else {
318            None
319        };
320        Ok(RunOutcome { run, results })
321    }
322}
323
324/// `/v1/runs` — read + control. Ids are the **numeric row id**
325/// ([`RunFeedItem::row_id`] parses it out of the composite feed id).
326#[derive(Debug, Clone, Copy)]
327pub struct Runs<'a> {
328    pub(crate) c: &'a Inner,
329}
330
331impl Runs<'_> {
332    /// `GET /v1/runs`.
333    pub async fn list(&self) -> Result<Page<RunFeedItem>> {
334        self.list_with(&[]).await
335    }
336
337    /// `GET /v1/runs` with filters (`entity_id`, `workflow_id`, `run_type`,
338    /// `status`, `limit`, `offset`).
339    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<RunFeedItem>> {
340        self.c.get_json("/v1/runs", query).await
341    }
342
343    /// `GET /v1/runs/:id`.
344    pub async fn get(&self, run_id: i64) -> Result<RunFeedItem> {
345        self.c.get_json(&format!("/v1/runs/{run_id}"), &[]).await
346    }
347
348    /// `GET /v1/runs/:id/results` → `{run_id, status, result}`.
349    pub async fn results(&self, run_id: i64) -> Result<RunResults> {
350        self.c
351            .get_json(&format!("/v1/runs/{run_id}/results"), &[])
352            .await
353    }
354
355    /// `GET /v1/runs/:id/data` — the run's extracted rows (JSON lane).
356    pub async fn data(&self, run_id: i64) -> Result<RunData> {
357        self.c
358            .get_json(&format!("/v1/runs/{run_id}/data"), &[])
359            .await
360    }
361
362    /// `GET /v1/runs/:id/data?format=csv` — the extracted rows flattened to CSV.
363    pub async fn data_csv(&self, run_id: i64) -> Result<String> {
364        self.c
365            .get_text(&format!("/v1/runs/{run_id}/data"), &[("format", "csv")])
366            .await
367    }
368
369    /// `POST /v1/runs/:id/cancel`. Both `202 cancel_requested` and
370    /// `409 not_running` are valid [`CancelOutcome`]s (the 409 is not an `Err`).
371    pub async fn cancel(&self, run_id: i64) -> Result<CancelOutcome> {
372        self.c
373            .send_json_allowing(
374                Method::POST,
375                &format!("/v1/runs/{run_id}/cancel"),
376                &[],
377                None,
378                &[409],
379            )
380            .await
381    }
382
383    /// `GET /v1/runs/:id/events` — live SSE stream of [`RunEvent`]s. The stream
384    /// ends after the terminal `finished`/`error` frame (a run that is already
385    /// finished yields exactly one terminal frame). Keep-alive comments are
386    /// consumed by the parser. No reconnect logic in v1 — a dropped stream
387    /// surfaces as an `Err` item.
388    pub async fn events(&self, run_id: i64) -> Result<RunEventStream> {
389        self.events_with_timeout(run_id, SSE_TIMEOUT).await
390    }
391
392    /// [`Runs::events`] with an explicit whole-stream timeout (used by
393    /// `run_and_wait` to cap the stream at the remaining deadline).
394    pub(crate) async fn events_with_timeout(
395        &self,
396        run_id: i64,
397        timeout: Duration,
398    ) -> Result<RunEventStream> {
399        let resp = self
400            .c
401            .get_stream(&format!("/v1/runs/{run_id}/events"), timeout)
402            .await?;
403
404        struct SseState {
405            body: BoxStream<'static, reqwest::Result<bytes::Bytes>>,
406            parser: SseParser,
407            pending: VecDeque<RunEvent>,
408            done: bool,
409        }
410
411        let state = SseState {
412            body: resp.bytes_stream().boxed(),
413            parser: SseParser::new(),
414            pending: VecDeque::new(),
415            done: false,
416        };
417
418        let stream = futures_util::stream::unfold(state, |mut st| async move {
419            loop {
420                if let Some(ev) = st.pending.pop_front() {
421                    if ev.is_terminal() {
422                        // Terminal frame: deliver it, then end the stream.
423                        st.done = true;
424                        st.pending.clear();
425                    }
426                    return Some((Ok(ev), st));
427                }
428                if st.done {
429                    return None;
430                }
431                match st.body.next().await {
432                    Some(Ok(chunk)) => {
433                        let mut payloads = Vec::new();
434                        st.parser.feed(&chunk, &mut payloads);
435                        for p in payloads {
436                            st.pending.push_back(RunEvent::parse(&p));
437                        }
438                    }
439                    Some(Err(e)) => {
440                        st.done = true;
441                        return Some((Err(WritError::from(e)), st));
442                    }
443                    None => return None,
444                }
445            }
446        })
447        .boxed();
448        Ok(stream)
449    }
450}
451
452/// `/v1/monitors` (+ `/v1/changes/recent`).
453#[derive(Debug, Clone, Copy)]
454pub struct Monitors<'a> {
455    pub(crate) c: &'a Inner,
456}
457
458impl Monitors<'_> {
459    /// `GET /v1/monitors` (bare-array endpoint, normalized to [`Page`]).
460    pub async fn list(&self) -> Result<Page<Monitor>> {
461        self.list_with(&[]).await
462    }
463
464    /// `GET /v1/monitors` with query params (`limit`, `check_type`).
465    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<Monitor>> {
466        self.c.get_json("/v1/monitors", query).await
467    }
468
469    /// `POST /v1/monitors` — requires a non-empty `url`. A `409 device_capacity`
470    /// surfaces as a normal [`WritError::Api`].
471    pub async fn create(&self, body: Value) -> Result<Monitor> {
472        self.c
473            .send_json(Method::POST, "/v1/monitors", &[], Some(&body))
474            .await
475    }
476
477    /// `GET /v1/monitors/:id`.
478    pub async fn get(&self, id: i64) -> Result<Monitor> {
479        self.c.get_json(&format!("/v1/monitors/{id}"), &[]).await
480    }
481
482    /// `PATCH /v1/monitors/:id`.
483    pub async fn update(&self, id: i64, patch: Value) -> Result<Monitor> {
484        self.c
485            .send_json(
486                Method::PATCH,
487                &format!("/v1/monitors/{id}"),
488                &[],
489                Some(&patch),
490            )
491            .await
492    }
493
494    /// `DELETE /v1/monitors/:id`.
495    pub async fn delete(&self, id: i64) -> Result<Value> {
496        self.c
497            .send_json(Method::DELETE, &format!("/v1/monitors/{id}"), &[], None)
498            .await
499    }
500
501    /// `POST /v1/monitors/:id/run` — run the check now; returns the check outcome.
502    pub async fn run(&self, id: i64) -> Result<Value> {
503        self.c
504            .send_json(Method::POST, &format!("/v1/monitors/{id}/run"), &[], None)
505            .await
506    }
507
508    /// `GET /v1/monitors/:id/changes` — change + uptime history.
509    pub async fn changes(&self, id: i64) -> Result<MonitorHistory> {
510        self.changes_with(id, &[]).await
511    }
512
513    /// `GET /v1/monitors/:id/changes` with `limit`/`offset`.
514    pub async fn changes_with(&self, id: i64, query: &[(&str, &str)]) -> Result<MonitorHistory> {
515        self.c
516            .get_json(&format!("/v1/monitors/{id}/changes"), query)
517            .await
518    }
519
520    /// `GET /v1/monitors/capacity` — the device check-capacity meter.
521    pub async fn capacity(&self) -> Result<Value> {
522        self.c.get_json("/v1/monitors/capacity", &[]).await
523    }
524
525    /// `GET /v1/changes/recent` — cross-monitor recent content changes.
526    pub async fn recent_changes(&self) -> Result<Page<Value>> {
527        self.recent_changes_with(&[]).await
528    }
529
530    /// `GET /v1/changes/recent` with `limit`.
531    pub async fn recent_changes_with(&self, query: &[(&str, &str)]) -> Result<Page<Value>> {
532        self.c.get_json("/v1/changes/recent", query).await
533    }
534}
535
536/// `/v1/monitors/:id/selectors` — selectors nested under a monitor.
537#[derive(Debug, Clone, Copy)]
538pub struct Selectors<'a> {
539    pub(crate) c: &'a Inner,
540}
541
542impl Selectors<'_> {
543    /// `GET /v1/monitors/:id/selectors` (bare array, normalized to [`Page`]).
544    pub async fn list(&self, monitor_id: i64) -> Result<Page<Selector>> {
545        self.c
546            .get_json(&format!("/v1/monitors/{monitor_id}/selectors"), &[])
547            .await
548    }
549
550    /// `POST /v1/monitors/:id/selectors` — requires a non-empty `selector`.
551    pub async fn create(&self, monitor_id: i64, body: Value) -> Result<Selector> {
552        self.c
553            .send_json(
554                Method::POST,
555                &format!("/v1/monitors/{monitor_id}/selectors"),
556                &[],
557                Some(&body),
558            )
559            .await
560    }
561
562    /// `GET /v1/monitors/:id/selectors/:sid`.
563    pub async fn get(&self, monitor_id: i64, selector_id: i64) -> Result<Selector> {
564        self.c
565            .get_json(
566                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}"),
567                &[],
568            )
569            .await
570    }
571
572    /// `PATCH /v1/monitors/:id/selectors/:sid`.
573    pub async fn update(
574        &self,
575        monitor_id: i64,
576        selector_id: i64,
577        patch: Value,
578    ) -> Result<Selector> {
579        self.c
580            .send_json(
581                Method::PATCH,
582                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}"),
583                &[],
584                Some(&patch),
585            )
586            .await
587    }
588
589    /// `DELETE /v1/monitors/:id/selectors/:sid`.
590    pub async fn delete(&self, monitor_id: i64, selector_id: i64) -> Result<Value> {
591        self.c
592            .send_json(
593                Method::DELETE,
594                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}"),
595                &[],
596                None,
597            )
598            .await
599    }
600
601    /// `POST .../selectors/:sid/toggle` — flip `enabled`.
602    pub async fn toggle(&self, monitor_id: i64, selector_id: i64) -> Result<Value> {
603        self.c
604            .send_json(
605                Method::POST,
606                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}/toggle"),
607                &[],
608                None,
609            )
610            .await
611    }
612
613    /// `POST .../selectors/:sid/test` — live one-off probe, nothing persisted.
614    pub async fn test(&self, monitor_id: i64, selector_id: i64) -> Result<Value> {
615        self.c
616            .send_json(
617                Method::POST,
618                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}/test"),
619                &[],
620                None,
621            )
622            .await
623    }
624
625    /// `POST .../selectors/:sid/set-baseline` — capture a fresh baseline.
626    pub async fn set_baseline(&self, monitor_id: i64, selector_id: i64) -> Result<Value> {
627        self.c
628            .send_json(
629                Method::POST,
630                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}/set-baseline"),
631                &[],
632                None,
633            )
634            .await
635    }
636
637    /// `POST .../selectors/:sid/clear-baseline` — drop the stored baseline.
638    pub async fn clear_baseline(&self, monitor_id: i64, selector_id: i64) -> Result<Value> {
639        self.c
640            .send_json(
641                Method::POST,
642                &format!("/v1/monitors/{monitor_id}/selectors/{selector_id}/clear-baseline"),
643                &[],
644                None,
645            )
646            .await
647    }
648}
649
650/// `/v1/extractors` (+ `/v1/selectors/:sid/extractors`).
651#[derive(Debug, Clone, Copy)]
652pub struct Extractors<'a> {
653    pub(crate) c: &'a Inner,
654}
655
656impl Extractors<'_> {
657    /// `GET /v1/selectors/:sid/extractors` (bare array, normalized to [`Page`]).
658    pub async fn list(&self, selector_id: i64) -> Result<Page<Extractor>> {
659        self.c
660            .get_json(&format!("/v1/selectors/{selector_id}/extractors"), &[])
661            .await
662    }
663
664    /// `POST /v1/extractors` — body carries `target_selector_id` + `output_name`.
665    pub async fn create(&self, body: Value) -> Result<Extractor> {
666        self.c
667            .send_json(Method::POST, "/v1/extractors", &[], Some(&body))
668            .await
669    }
670
671    /// `GET /v1/extractors/:id`.
672    pub async fn get(&self, extractor_id: i64) -> Result<Extractor> {
673        self.c
674            .get_json(&format!("/v1/extractors/{extractor_id}"), &[])
675            .await
676    }
677
678    /// `PATCH /v1/extractors/:id`.
679    pub async fn update(&self, extractor_id: i64, patch: Value) -> Result<Extractor> {
680        self.c
681            .send_json(
682                Method::PATCH,
683                &format!("/v1/extractors/{extractor_id}"),
684                &[],
685                Some(&patch),
686            )
687            .await
688    }
689
690    /// `DELETE /v1/extractors/:id`.
691    pub async fn delete(&self, extractor_id: i64) -> Result<Value> {
692        self.c
693            .send_json(
694                Method::DELETE,
695                &format!("/v1/extractors/{extractor_id}"),
696                &[],
697                None,
698            )
699            .await
700    }
701
702    /// `PATCH /v1/extractors/:id/toggle` — flip `enabled`.
703    pub async fn toggle(&self, extractor_id: i64) -> Result<Value> {
704        self.c
705            .send_json(
706                Method::PATCH,
707                &format!("/v1/extractors/{extractor_id}/toggle"),
708                &[],
709                None,
710            )
711            .await
712    }
713
714    /// `POST /v1/extractors/:id/test` — run the saved extractor over
715    /// caller-supplied content (`{content, content_type?}`), nothing persisted.
716    pub async fn test(&self, extractor_id: i64, body: Value) -> Result<Value> {
717        self.c
718            .send_json(
719                Method::POST,
720                &format!("/v1/extractors/{extractor_id}/test"),
721                &[],
722                Some(&body),
723            )
724            .await
725    }
726}
727
728/// `/v1/automations`.
729#[derive(Debug, Clone, Copy)]
730pub struct Automations<'a> {
731    pub(crate) c: &'a Inner,
732}
733
734impl Automations<'_> {
735    /// `GET /v1/automations` (bare array, normalized to [`Page`]).
736    pub async fn list(&self) -> Result<Page<Automation>> {
737        self.list_with(&[]).await
738    }
739
740    /// `GET /v1/automations` with query params (`limit`).
741    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<Automation>> {
742        self.c.get_json("/v1/automations", query).await
743    }
744
745    /// `POST /v1/automations` — requires a non-empty `name`.
746    pub async fn create(&self, body: Value) -> Result<Automation> {
747        self.c
748            .send_json(Method::POST, "/v1/automations", &[], Some(&body))
749            .await
750    }
751
752    /// `GET /v1/automations/:id`.
753    pub async fn get(&self, id: i64) -> Result<Automation> {
754        self.c.get_json(&format!("/v1/automations/{id}"), &[]).await
755    }
756
757    /// `PATCH /v1/automations/:id`.
758    pub async fn update(&self, id: i64, patch: Value) -> Result<Automation> {
759        self.c
760            .send_json(
761                Method::PATCH,
762                &format!("/v1/automations/{id}"),
763                &[],
764                Some(&patch),
765            )
766            .await
767    }
768
769    /// `DELETE /v1/automations/:id`.
770    pub async fn delete(&self, id: i64) -> Result<Value> {
771        self.c
772            .send_json(Method::DELETE, &format!("/v1/automations/{id}"), &[], None)
773            .await
774    }
775
776    /// `POST /v1/automations/:id/enable` — set the `enabled` flag; returns the
777    /// refreshed row.
778    pub async fn enable(&self, id: i64, enabled: bool) -> Result<Automation> {
779        self.c
780            .send_json(
781                Method::POST,
782                &format!("/v1/automations/{id}/enable"),
783                &[],
784                Some(&json!({ "enabled": enabled })),
785            )
786            .await
787    }
788
789    /// `POST /v1/automations/:id/run` — fire now (optional `inputs`); returns the
790    /// execution outcome.
791    pub async fn run(&self, id: i64, inputs: Option<Value>) -> Result<Value> {
792        let body = json!({ "inputs": inputs });
793        self.c
794            .send_json(
795                Method::POST,
796                &format!("/v1/automations/{id}/run"),
797                &[],
798                Some(&body),
799            )
800            .await
801    }
802}
803
804/// `/v1/personas`.
805#[derive(Debug, Clone, Copy)]
806pub struct Personas<'a> {
807    pub(crate) c: &'a Inner,
808}
809
810impl Personas<'_> {
811    /// `GET /v1/personas`.
812    pub async fn list(&self) -> Result<Page<Persona>> {
813        self.list_with(&[]).await
814    }
815
816    /// `GET /v1/personas` with query params (`limit`, `domain`).
817    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<Persona>> {
818        self.c.get_json("/v1/personas", query).await
819    }
820
821    /// `POST /v1/personas`.
822    pub async fn create(&self, body: Value) -> Result<Persona> {
823        self.c
824            .send_json(Method::POST, "/v1/personas", &[], Some(&body))
825            .await
826    }
827
828    /// `GET /v1/personas/:id`.
829    pub async fn get(&self, id: i64) -> Result<Persona> {
830        self.c.get_json(&format!("/v1/personas/{id}"), &[]).await
831    }
832
833    /// `PATCH /v1/personas/:id`.
834    pub async fn update(&self, id: i64, patch: Value) -> Result<Persona> {
835        self.c
836            .send_json(
837                Method::PATCH,
838                &format!("/v1/personas/{id}"),
839                &[],
840                Some(&patch),
841            )
842            .await
843    }
844
845    /// `DELETE /v1/personas/:id`.
846    pub async fn delete(&self, id: i64) -> Result<Value> {
847        self.c
848            .send_json(Method::DELETE, &format!("/v1/personas/{id}"), &[], None)
849            .await
850    }
851
852    /// `GET /v1/personas/:id/runs` — runs attributed to this persona.
853    pub async fn runs(&self, id: i64) -> Result<Page<Value>> {
854        self.c
855            .get_json(&format!("/v1/personas/{id}/runs"), &[])
856            .await
857    }
858
859    /// `POST /v1/personas/validate-totp` — check a base32 seed (and optionally a
860    /// live code) without persisting anything. Body:
861    /// `{totp_seed, code?, digits?, period?, algorithm?}`.
862    pub async fn validate_totp(&self, body: Value) -> Result<Value> {
863        self.c
864            .send_json(Method::POST, "/v1/personas/validate-totp", &[], Some(&body))
865            .await
866    }
867
868    /// `POST /v1/personas/:id/test-2fa` — exercise the persona's stored 2FA.
869    pub async fn test_2fa(&self, id: i64) -> Result<Value> {
870        self.c
871            .send_json(
872                Method::POST,
873                &format!("/v1/personas/{id}/test-2fa"),
874                &[],
875                None,
876            )
877            .await
878    }
879}
880
881/// `/v1/secrets` — metadata only; secret **values never come back** over this API.
882#[derive(Debug, Clone, Copy)]
883pub struct Secrets<'a> {
884    pub(crate) c: &'a Inner,
885}
886
887impl Secrets<'_> {
888    /// `GET /v1/secrets`.
889    pub async fn list(&self) -> Result<Page<SecretMeta>> {
890        self.list_with(&[]).await
891    }
892
893    /// `GET /v1/secrets` with query params (`limit`, `search`, `category`).
894    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<SecretMeta>> {
895        self.c.get_json("/v1/secrets", query).await
896    }
897
898    /// `POST /v1/secrets` — create a single-value secret. The plaintext is sealed
899    /// daemon-side; only metadata is returned.
900    pub async fn set(&self, key: &str, value: &str) -> Result<SecretMeta> {
901        self.create(json!({ "name": key, "value": value })).await
902    }
903
904    /// `POST /v1/secrets` with a full body — single-value `{name, value}`,
905    /// credential `{name, username, password}`, or card `{name, card: {...}}`
906    /// (plus optional `description`/`category`).
907    pub async fn create(&self, body: Value) -> Result<SecretMeta> {
908        self.c
909            .send_json(Method::POST, "/v1/secrets", &[], Some(&body))
910            .await
911    }
912
913    /// `GET /v1/secrets/:key` — **metadata** for one secret (never the value).
914    pub async fn get(&self, key: &str) -> Result<SecretMeta> {
915        self.c.get_json(&format!("/v1/secrets/{key}"), &[]).await
916    }
917
918    /// `DELETE /v1/secrets/:key`.
919    pub async fn delete(&self, key: &str) -> Result<Value> {
920        self.c
921            .send_json(Method::DELETE, &format!("/v1/secrets/{key}"), &[], None)
922            .await
923    }
924}
925
926/// `/v1/vault/*` — the app-lock control surface.
927#[derive(Debug, Clone, Copy)]
928pub struct Vault<'a> {
929    pub(crate) c: &'a Inner,
930}
931
932impl Vault<'_> {
933    /// `GET /v1/vault/status` → `{enabled, locked, idle_timeout_secs}`.
934    pub async fn status(&self) -> Result<VaultStatus> {
935        self.c.get_json("/v1/vault/status", &[]).await
936    }
937
938    /// `POST /v1/vault/lock` — relock now. Idempotent.
939    pub async fn lock(&self) -> Result<Value> {
940        self.c
941            .send_json(Method::POST, "/v1/vault/lock", &[], None)
942            .await
943    }
944
945    /// `POST /v1/vault/unlock` — unlock with the passphrase (consumed
946    /// daemon-side; never persisted). A wrong passphrase surfaces as a 401
947    /// [`WritError::Api`].
948    pub async fn unlock(&self, passphrase: &str) -> Result<Value> {
949        self.c
950            .send_json(
951                Method::POST,
952                "/v1/vault/unlock",
953                &[],
954                Some(&json!({ "passphrase": passphrase })),
955            )
956            .await
957    }
958}
959
960/// `/v1/files` — metadata + byte I/O.
961#[derive(Debug, Clone, Copy)]
962pub struct Files<'a> {
963    pub(crate) c: &'a Inner,
964}
965
966impl Files<'_> {
967    /// `GET /v1/files`.
968    pub async fn list(&self) -> Result<Page<StoredFile>> {
969        self.list_with(&[]).await
970    }
971
972    /// `GET /v1/files` with query params (`limit`, `source`).
973    pub async fn list_with(&self, query: &[(&str, &str)]) -> Result<Page<StoredFile>> {
974        self.c.get_json("/v1/files", query).await
975    }
976
977    /// `POST /v1/files` — multipart upload of `bytes` as the `file` part
978    /// (max 50 MiB daemon-side). `source` may be `upload` (default) | `api` |
979    /// `workflow_output`.
980    pub async fn upload(
981        &self,
982        filename: &str,
983        bytes: impl Into<Vec<u8>>,
984        content_type: Option<&str>,
985        source: Option<&str>,
986    ) -> Result<StoredFile> {
987        let mut part =
988            reqwest::multipart::Part::bytes(bytes.into()).file_name(filename.to_string());
989        if let Some(ct) = content_type {
990            part = part
991                .mime_str(ct)
992                .map_err(|e| WritError::Connection(format!("invalid content type {ct:?}: {e}")))?;
993        }
994        let mut form = reqwest::multipart::Form::new().part("file", part);
995        if let Some(source) = source {
996            form = form.text("source", source.to_string());
997        }
998        self.c.post_multipart("/v1/files", form).await
999    }
1000
1001    /// `POST /v1/files/from-data` — export a workflow's extracted data into a
1002    /// stored file. Body: `{workflow_id, format?, filters?}`.
1003    pub async fn from_data(&self, body: Value) -> Result<StoredFile> {
1004        self.c
1005            .send_json(Method::POST, "/v1/files/from-data", &[], Some(&body))
1006            .await
1007    }
1008
1009    /// `GET /v1/files/:id` — one file's metadata.
1010    pub async fn get(&self, id: &str) -> Result<StoredFile> {
1011        self.c.get_json(&format!("/v1/files/{id}"), &[]).await
1012    }
1013
1014    /// `DELETE /v1/files/:id` — soft-delete the handle.
1015    pub async fn delete(&self, id: &str) -> Result<Value> {
1016        self.c
1017            .send_json(Method::DELETE, &format!("/v1/files/{id}"), &[], None)
1018            .await
1019    }
1020
1021    /// `GET /v1/files/:id/content` — the decrypted raw bytes.
1022    pub async fn content(&self, id: &str) -> Result<bytes::Bytes> {
1023        self.c
1024            .get_bytes(&format!("/v1/files/{id}/content"), &[])
1025            .await
1026    }
1027}
1028
1029/// `/v1/data` + `/v1/workflows/:id/data*` — the extracted-data surface.
1030/// Shapes are query-engine-driven, so responses stay loosely typed.
1031#[derive(Debug, Clone, Copy)]
1032pub struct Data<'a> {
1033    pub(crate) c: &'a Inner,
1034}
1035
1036impl Data<'_> {
1037    /// `GET /v1/data` — workflows that have extracted data.
1038    pub async fn query(&self, query: &[(&str, &str)]) -> Result<Value> {
1039        self.c.get_json("/v1/data", query).await
1040    }
1041
1042    /// `GET /v1/workflows/:id/data` — the aggregated data table (filters,
1043    /// `limit`/`offset`, sort — pass query params through as given).
1044    pub async fn workflow_data(&self, workflow_id: i64, query: &[(&str, &str)]) -> Result<Value> {
1045        self.c
1046            .get_json(&format!("/v1/workflows/{workflow_id}/data"), query)
1047            .await
1048    }
1049
1050    /// `DELETE /v1/workflows/:id/data` — drop the workflow's extracted data.
1051    pub async fn delete_workflow_data(&self, workflow_id: i64) -> Result<Value> {
1052        self.c
1053            .send_json(
1054                Method::DELETE,
1055                &format!("/v1/workflows/{workflow_id}/data"),
1056                &[],
1057                None,
1058            )
1059            .await
1060    }
1061
1062    /// `GET /v1/workflows/:id/data/facets` — per-column facet values.
1063    pub async fn facets(&self, workflow_id: i64) -> Result<Value> {
1064        self.c
1065            .get_json(&format!("/v1/workflows/{workflow_id}/data/facets"), &[])
1066            .await
1067    }
1068
1069    /// `GET /v1/workflows/:id/data/export` — raw export bytes (`format=csv|json`
1070    /// via query params).
1071    pub async fn export(&self, workflow_id: i64, query: &[(&str, &str)]) -> Result<bytes::Bytes> {
1072        self.c
1073            .get_bytes(&format!("/v1/workflows/{workflow_id}/data/export"), query)
1074            .await
1075    }
1076
1077    /// `GET /v1/workflows/:id/data/runs` — the runs feeding the data table.
1078    pub async fn data_runs(&self, workflow_id: i64) -> Result<Value> {
1079        self.c
1080            .get_json(&format!("/v1/workflows/{workflow_id}/data/runs"), &[])
1081            .await
1082    }
1083}
1084
1085/// `/v1/keys` — scoped `wlk_` API keys (requires the `manage`-capable `wlt_` token).
1086#[derive(Debug, Clone, Copy)]
1087pub struct Keys<'a> {
1088    pub(crate) c: &'a Inner,
1089}
1090
1091impl Keys<'_> {
1092    /// `GET /v1/keys`.
1093    pub async fn list(&self) -> Result<Page<ApiKey>> {
1094        self.c.get_json("/v1/keys", &[]).await
1095    }
1096
1097    /// `POST /v1/keys` — mint a scoped key. The plaintext `wlk_` key appears
1098    /// **only** in this response ([`ApiKey::key`]); capture it immediately.
1099    /// `scopes` is a CSV of `read|run|admin` (daemon default: `run`).
1100    pub async fn create(&self, name: &str, scopes: Option<&str>) -> Result<ApiKey> {
1101        let mut body = json!({ "name": name });
1102        if let Some(scopes) = scopes {
1103            body["scopes"] = Value::String(scopes.to_string());
1104        }
1105        self.c
1106            .send_json(Method::POST, "/v1/keys", &[], Some(&body))
1107            .await
1108    }
1109
1110    /// `GET /v1/keys/:id`.
1111    pub async fn get(&self, id: i64) -> Result<ApiKey> {
1112        self.c.get_json(&format!("/v1/keys/{id}"), &[]).await
1113    }
1114
1115    /// `DELETE /v1/keys/:id`.
1116    pub async fn delete(&self, id: i64) -> Result<Value> {
1117        self.c
1118            .send_json(Method::DELETE, &format!("/v1/keys/{id}"), &[], None)
1119            .await
1120    }
1121}
1122
1123/// `/v1/crawl` — the **Dragnet** whole-site crawl. One crawl fans a seed URL across
1124/// a bounded in-process worker pool; extracted pages aggregate under a synthetic
1125/// per-crawl workflow ([`CrawlJob::data_workflow_id`]) read back through the Data API.
1126#[derive(Debug, Clone, Copy)]
1127pub struct Crawl<'a> {
1128    pub(crate) c: &'a Inner,
1129}
1130
1131impl Crawl<'_> {
1132    /// `GET /v1/crawl` — newest-first crawls (`limit` default 50 daemon-side, max
1133    /// 500). **Not** a [`Page`]: the daemon answers `{crawls: [...]}`, so this
1134    /// returns a [`CrawlList`] (unwrap its `crawls` field).
1135    pub async fn list(&self, limit: Option<i64>) -> Result<CrawlList> {
1136        let limit_str = limit.map(|n| n.to_string());
1137        let mut query: Vec<(&str, &str)> = Vec::new();
1138        if let Some(limit) = &limit_str {
1139            query.push(("limit", limit.as_str()));
1140        }
1141        self.c.get_json("/v1/crawl", &query).await
1142    }
1143
1144    /// `POST /v1/crawl` — validate the seed, mint the synthetic dataset workflow +
1145    /// the crawl row, and kick the crawl off. Returns the queued [`CrawlJob`] view
1146    /// (with `workflow_id`/`data_workflow_id` set). An empty `url` is a `400`
1147    /// [`WritError::Api`].
1148    pub async fn start(&self, params: CrawlStartParams) -> Result<CrawlJob> {
1149        let body = serde_json::to_value(&params)
1150            .map_err(|e| WritError::Connection(format!("serializing crawl params: {e}")))?;
1151        self.c
1152            .send_json(Method::POST, "/v1/crawl", &[], Some(&body))
1153            .await
1154    }
1155
1156    /// `GET /v1/crawl/:id` — one crawl's live status view (`404` if missing).
1157    pub async fn get(&self, id: i64) -> Result<CrawlJob> {
1158        self.c.get_json(&format!("/v1/crawl/{id}"), &[]).await
1159    }
1160
1161    /// `POST /v1/crawl/:id/cancel` — request cancellation. Returns the refreshed
1162    /// view plus [`CrawlCancel::cancel_requested_now`] (never a 409; `404` if
1163    /// missing).
1164    pub async fn cancel(&self, id: i64) -> Result<CrawlCancel> {
1165        self.c
1166            .send_json(Method::POST, &format!("/v1/crawl/{id}/cancel"), &[], None)
1167            .await
1168    }
1169}
1170
1171/// `/v1/datasets` — the unified dataset index over crawl- and workflow-sourced
1172/// extracted data. Metadata is typed ([`DatasetList`]/[`DatasetMeta`]); the
1173/// records/export shapes are query-engine-driven, so those stay loosely typed
1174/// (as with the [`Data`] surface).
1175#[derive(Debug, Clone, Copy)]
1176pub struct Datasets<'a> {
1177    pub(crate) c: &'a Inner,
1178}
1179
1180impl Datasets<'_> {
1181    /// `GET /v1/datasets` — datasets that have accumulated extracted data. **Not**
1182    /// a [`Page`]: the daemon answers `{datasets: [...]}`, so this returns a
1183    /// [`DatasetList`] (unwrap its `datasets` field).
1184    pub async fn list(&self) -> Result<DatasetList> {
1185        self.c.get_json("/v1/datasets", &[]).await
1186    }
1187
1188    /// `GET /v1/datasets/:id` — one dataset's metadata + schema (`404` if missing).
1189    pub async fn get(&self, id: i64) -> Result<DatasetMeta> {
1190        self.c.get_json(&format!("/v1/datasets/{id}"), &[]).await
1191    }
1192
1193    /// `GET /v1/datasets/:id/records` — the dataset's row table. Pass query params
1194    /// through as given (`q`, `filter`, `filters`, `sort_by`, `sort_dir`, `limit`,
1195    /// `offset`, `include_inputs`, `collection`). Query-engine-shaped, so the
1196    /// response stays a loosely-typed [`Value`].
1197    pub async fn records(&self, id: i64, query: &[(&str, &str)]) -> Result<Value> {
1198        self.c
1199            .get_json(&format!("/v1/datasets/{id}/records"), query)
1200            .await
1201    }
1202
1203    /// `GET /v1/datasets/:id/export` — raw export text. `format=json|csv|markdown|html`
1204    /// plus the same filters as [`Datasets::records`], via query params. markdown/html
1205    /// are content-aware: a crawl's pages export as readable documents, structured data
1206    /// as a table.
1207    pub async fn export(&self, id: i64, query: &[(&str, &str)]) -> Result<String> {
1208        self.c
1209            .get_text(&format!("/v1/datasets/{id}/export"), query)
1210            .await
1211    }
1212
1213    /// `GET /v1/datasets/:id/records` RENDERED as text — the `?format=` twin of
1214    /// [`Datasets::records`].
1215    ///
1216    /// A non-`json` format returns prose/CSV rather than the JSON envelope, so it
1217    /// needs its own method (Rust has no return-type overloading). Pass
1218    /// [`DatasetFormat::Markdown`] to read a crawl's pages as documents, or a
1219    /// structured dataset as a table.
1220    pub async fn records_text(
1221        &self,
1222        id: i64,
1223        format: DatasetFormat,
1224        query: &[(&str, &str)],
1225    ) -> Result<String> {
1226        let mut q: Vec<(&str, &str)> = vec![("format", format.as_str())];
1227        q.extend_from_slice(query);
1228        self.c
1229            .get_text(&format!("/v1/datasets/{id}/records"), &q)
1230            .await
1231    }
1232
1233    /// `GET /v1/datasets/search` RENDERED as text — the `?format=` twin of
1234    /// [`Datasets::search`]. The per-result dataset tag + highlight snippet exist
1235    /// only in the JSON shape.
1236    pub async fn search_text(
1237        &self,
1238        q: &str,
1239        format: DatasetFormat,
1240        params: &[(&str, &str)],
1241    ) -> Result<String> {
1242        let mut query: Vec<(&str, &str)> = vec![("q", q), ("format", format.as_str())];
1243        query.extend_from_slice(params);
1244        self.c.get_text("/v1/datasets/search", &query).await
1245    }
1246
1247    /// `GET /v1/datasets/:id/search` RENDERED as text — the `?format=` twin of
1248    /// [`Datasets::search_one`].
1249    pub async fn search_one_text(
1250        &self,
1251        id: i64,
1252        q: &str,
1253        format: DatasetFormat,
1254        params: &[(&str, &str)],
1255    ) -> Result<String> {
1256        let mut query: Vec<(&str, &str)> = vec![("q", q), ("format", format.as_str())];
1257        query.extend_from_slice(params);
1258        self.c
1259            .get_text(&format!("/v1/datasets/{id}/search"), &query)
1260            .await
1261    }
1262
1263    /// `GET /v1/datasets/search` — full-text search across **all** datasets. `q`
1264    /// is prepended to `params` (typically `limit`/`offset`) as the `q` query
1265    /// pair.
1266    pub async fn search(&self, q: &str, params: &[(&str, &str)]) -> Result<DatasetSearchResult> {
1267        let mut query: Vec<(&str, &str)> = vec![("q", q)];
1268        query.extend_from_slice(params);
1269        self.c.get_json("/v1/datasets/search", &query).await
1270    }
1271
1272    /// `GET /v1/datasets/:id/search` — full-text search within one dataset. `q`
1273    /// is prepended to `params` (typically `limit`/`offset`) as the `q` query
1274    /// pair.
1275    pub async fn search_one(
1276        &self,
1277        id: i64,
1278        q: &str,
1279        params: &[(&str, &str)],
1280    ) -> Result<DatasetSearchResult> {
1281        let mut query: Vec<(&str, &str)> = vec![("q", q)];
1282        query.extend_from_slice(params);
1283        self.c
1284            .get_json(&format!("/v1/datasets/{id}/search"), &query)
1285            .await
1286    }
1287}