Skip to main content

packset_client/
lib.rs

1//! Loopback HTTP client for packsetd.
2//!
3//! Reads `PACKSET_URL` or `INSIDE_MEMORY_URL`. search/get against packsetd; no SQLite.
4//! Does not open LMDB.
5
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::time::Duration;
9
10/// How long one request may take: `PACKSET_TIMEOUT_MS`, else thirty seconds.
11/// A write that waits behind thirty others on a busy seat is late, not failed.
12fn timeout() -> Duration {
13    std::env::var("PACKSET_TIMEOUT_MS")
14        .ok()
15        .and_then(|v| v.trim().parse::<u64>().ok())
16        .filter(|ms| *ms > 0)
17        .map_or(Duration::from_secs(30), Duration::from_millis)
18}
19
20fn path_seg(id: &str) -> String {
21    let mut out = String::with_capacity(id.len());
22    for b in id.bytes() {
23        match b {
24            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
25                out.push(b as char)
26            }
27            _ => out.push_str(&format!("%{b:02X}")),
28        }
29    }
30    out
31}
32
33/// The port a writer listens on when nothing names one. The command line,
34/// the server and this client agree on it, so a seat needs no variable set.
35pub const DEFAULT_PORT: u16 = 8761;
36
37/// `PACKSET_PORT` (`GROK_MEM_PORT` is an alias), else [`DEFAULT_PORT`].
38#[must_use]
39pub fn default_port() -> u16 {
40    env::var("PACKSET_PORT")
41        .or_else(|_| env::var("GROK_MEM_PORT"))
42        .ok()
43        .and_then(|raw| raw.trim().parse().ok())
44        .unwrap_or(DEFAULT_PORT)
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum Error {
49    #[error("packset url missing")]
50    NoUrl,
51    #[error("http: {0}")]
52    Http(#[from] Box<ureq::Error>),
53    #[error("io: {0}")]
54    Io(#[from] std::io::Error),
55    #[error("json: {0}")]
56    Json(#[from] serde_json::Error),
57    #[error("bad response: {0}")]
58    Bad(String),
59}
60
61#[derive(Debug, Clone)]
62pub struct PacksetClient {
63    base: String,
64    /// A workspace pinned by the caller; `None` reads the environment and
65    /// the working directory.
66    workspace: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Hit {
71    pub id: Option<String>,
72    pub text: String,
73    #[serde(default)]
74    pub score: f64,
75    #[serde(default)]
76    pub kind: String,
77    /// When the memory was written, the writer's clock, RFC 3339. Absent
78    /// on card paragraphs, which have no clock.
79    #[serde(default)]
80    pub ts: Option<String>,
81    /// How many of the panel's ballots named this hit, and how many ran.
82    /// Two of three is agreement; one of three is one scorer's opinion.
83    #[serde(default)]
84    pub ballots: Option<u32>,
85    #[serde(default)]
86    pub of: Option<u32>,
87}
88
89/// A refusal, carrying the reason the writer gave in its body.
90fn refused(url: &str, e: ureq::Error) -> Error {
91    match e {
92        ureq::Error::Status(code, response) => {
93            let text = response.into_string().unwrap_or_default();
94            let reason = serde_json::from_str::<serde_json::Value>(&text)
95                .ok()
96                .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
97                .unwrap_or(text);
98            let reason = reason.trim();
99            if reason.is_empty() {
100                Error::Bad(format!("{url}: status code {code}"))
101            } else {
102                Error::Bad(format!("{url}: {code}: {reason}"))
103            }
104        }
105        other => Error::Http(Box::new(other)),
106    }
107}
108
109impl PacksetClient {
110    pub fn new(base: impl Into<String>) -> Self {
111        let mut base = base.into();
112        while base.ends_with('/') {
113            base.pop();
114        }
115        Self {
116            base,
117            workspace: None,
118        }
119    }
120
121    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
122    /// and the working directory. A seat that is one memory across every
123    /// repository it works in sets this once.
124    #[must_use]
125    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
126        let workspace = workspace.into();
127        self.workspace = (!workspace.is_empty()).then_some(workspace);
128        self
129    }
130
131    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
132    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
133    /// line starts a writer on, `PACKSET_PORT` (`GROK_MEM_PORT`) or 8761.
134    /// `PACKSET_URL=off` is the one way to have no pack.
135    pub fn from_env() -> Result<Self, Error> {
136        let url = env::var("PACKSET_URL")
137            .or_else(|_| env::var("INSIDE_MEMORY_URL"))
138            .ok()
139            .filter(|url| !url.is_empty());
140        match url {
141            Some(url) if url == "off" => Err(Error::NoUrl),
142            Some(url) => Ok(Self::new(url)),
143            None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
144        }
145    }
146
147    pub fn base(&self) -> &str {
148        &self.base
149    }
150
151    pub fn workspace(&self) -> String {
152        if let Some(w) = &self.workspace {
153            return w.clone();
154        }
155        if let Ok(w) = env::var("PACKSET_WORKSPACE") {
156            if !w.is_empty() {
157                return w;
158            }
159        }
160        let cwd = env::var("GROKOS_WORKSPACE")
161            .ok()
162            .map(|s| s.trim().to_string())
163            .filter(|s| !s.is_empty())
164            .map(std::path::PathBuf::from)
165            .or_else(|| env::current_dir().ok())
166            .unwrap_or_else(|| std::path::PathBuf::from("."));
167        self.workspace_for_cwd(&cwd)
168    }
169
170    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
171    pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
172        let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
173        let url = format!("{}/v1/identity", self.base);
174        let body = ureq::get(&url)
175            .query("cwd", abs.to_string_lossy().as_ref())
176            .timeout(timeout())
177            .call()
178            .ok()
179            .and_then(|r| r.into_string().ok());
180        if let Some(body) = body {
181            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
182                if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
183                    if !ws.is_empty() {
184                        return ws.to_string();
185                    }
186                }
187            }
188        }
189        format!("dir:{}", abs.display())
190    }
191
192    pub fn health(&self) -> Result<String, Error> {
193        let url = format!("{}/health", self.base);
194        let body = ureq::get(&url)
195            .timeout(timeout())
196            .call()
197            .map_err(|e| refused(&url, e))?
198            .into_string()?;
199        Ok(body)
200    }
201
202    pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
203        let encoded = path_seg(id);
204        let url = format!("{}/v1/atoms/{encoded}", self.base);
205        let resp = match ureq::get(&url)
206            .query("workspace", workspace)
207            .timeout(timeout())
208            .call()
209        {
210            Ok(resp) => resp,
211            Err(ureq::Error::Status(404, _)) => {
212                return Err(Error::Bad(format!("no atom {id}")));
213            }
214            Err(e) => return Err(Error::Http(Box::new(e))),
215        };
216        Ok(resp.into_json()?)
217    }
218
219    pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
220        self.atoms_as_of(workspace, None)
221    }
222
223    /// Live-now atoms, or the ones that were live at `as_of`.
224    ///
225    /// # Errors
226    ///
227    /// The request's, or a body that is not JSON.
228    pub fn atoms_as_of(
229        &self,
230        workspace: &str,
231        as_of: Option<&str>,
232    ) -> Result<Vec<serde_json::Value>, Error> {
233        let url = format!("{}/v1/atoms", self.base);
234        let mut req = ureq::get(&url)
235            .query("workspace", workspace)
236            .timeout(timeout());
237        if let Some(at) = as_of {
238            req = req.query("as_of", at);
239        }
240        let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
241        let atoms = body
242            .get("atoms")
243            .cloned()
244            .unwrap_or(serde_json::Value::Array(vec![]));
245        Ok(serde_json::from_value(atoms)?)
246    }
247
248    pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
249        self.search_as_of(workspace, q, limit, None)
250    }
251
252    /// Ranked hits, optionally over the atoms that were live at `as_of`.
253    ///
254    /// # Errors
255    ///
256    /// The request's, or a body that is not JSON.
257    pub fn search_as_of(
258        &self,
259        workspace: &str,
260        q: &str,
261        limit: u32,
262        as_of: Option<&str>,
263    ) -> Result<Vec<Hit>, Error> {
264        self.search_opts(workspace, q, limit, as_of, false)
265    }
266
267    /// Ranked hits, optionally dated and optionally through the measured
268    /// cross-encoder stage.
269    ///
270    /// Off by default. On, the writer spends a forward pass per candidate and
271    /// the request waits for that rather than the usual five-second budget.
272    ///
273    /// # Errors
274    ///
275    /// The request's, or a body that is not a hit list.
276    pub fn search_opts(
277        &self,
278        workspace: &str,
279        q: &str,
280        limit: u32,
281        as_of: Option<&str>,
282        rerank: bool,
283    ) -> Result<Vec<Hit>, Error> {
284        let url = format!("{}/v1/search", self.base);
285        let budget = if rerank {
286            Duration::from_secs(60).max(timeout())
287        } else {
288            timeout()
289        };
290        let mut req = ureq::get(&url)
291            .query("workspace", workspace)
292            .query("q", q)
293            .query("limit", &limit.to_string())
294            .timeout(budget);
295        if let Some(at) = as_of {
296            req = req.query("as_of", at);
297        }
298        if rerank {
299            req = req.query("rerank", "1");
300        }
301        let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
302        let hits = body
303            .get("hits")
304            .cloned()
305            .unwrap_or(serde_json::Value::Array(vec![]));
306        Ok(serde_json::from_value(hits)?)
307    }
308
309    /// Seat home, atom counts by kind, pin, index and embedder.
310    ///
311    /// # Errors
312    ///
313    /// The request's, or a body that is not JSON.
314    pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
315        let url = format!("{}/v1/status", self.base);
316        let mut req = ureq::get(&url).timeout(timeout());
317        if let Some(workspace) = workspace {
318            req = req.query("workspace", workspace);
319        }
320        Ok(req.call().map_err(|e| refused(&url, e))?.into_json()?)
321    }
322
323    /// The set a workspace is pinned to.
324    ///
325    /// # Errors
326    ///
327    /// The request's, or a body that is not JSON.
328    pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
329        let url = format!("{}/v1/pin", self.base);
330        Ok(ureq::get(&url)
331            .query("workspace", workspace)
332            .timeout(timeout())
333            .call()
334            .map_err(|e| refused(&url, e))?
335            .into_json()?)
336    }
337
338    /// Pin a workspace to a set.
339    ///
340    /// # Errors
341    ///
342    /// The request's, or a body that is not JSON.
343    pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
344        let url = format!("{}/v1/pin", self.base);
345        Ok(ureq::put(&url)
346            .timeout(timeout())
347            .send_json(serde_json::json!({ "workspace": workspace, "name": name }))
348            .map_err(|e| refused(&url, e))?
349            .into_json()?)
350    }
351
352    /// The deed accessions a workspace's live atoms cite, sorted.
353    ///
354    /// The accession is the only identifier crossing the tracker, the pack and
355    /// the deed store, so this is what `deedar evidence -` reads.
356    ///
357    /// # Errors
358    ///
359    /// The request's, or a body that is not JSON.
360    pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
361        let url = format!("{}/v1/accessions", self.base);
362        let body: serde_json::Value = ureq::get(&url)
363            .query("workspace", workspace)
364            .timeout(timeout())
365            .call()
366            .map_err(|e| refused(&url, e))?
367            .into_json()?;
368        let found = body
369            .get("accessions")
370            .cloned()
371            .unwrap_or(serde_json::Value::Array(vec![]));
372        Ok(serde_json::from_value(found)?)
373    }
374    /// Every live atom in a workspace.
375    ///
376    /// The bodies, not the join keys: this is what a handover carries when
377    /// somebody is given what the seat learned rather than only what it cites.
378    ///
379    /// # Errors
380    ///
381    /// The request's, or a body that is not JSON.
382    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
383        self.atoms_as_of(workspace, None)
384    }
385
386    /// The live atoms in a workspace that cite one deed accession.
387    ///
388    /// # Errors
389    ///
390    /// The request's, or a body that is not JSON.
391    pub fn citers(
392        &self,
393        workspace: &str,
394        accession: &str,
395    ) -> Result<Vec<serde_json::Value>, Error> {
396        let url = format!("{}/v1/citers", self.base);
397        let body: serde_json::Value = ureq::get(&url)
398            .query("workspace", workspace)
399            .query("accession", accession)
400            .timeout(timeout())
401            .call()
402            .map_err(|e| refused(&url, e))?
403            .into_json()?;
404        let found = body
405            .get("atoms")
406            .cloned()
407            .unwrap_or(serde_json::Value::Array(vec![]));
408        Ok(serde_json::from_value(found)?)
409    }
410
411    /// Tombstone one atom. The daemon keeps the record and drops the index
412    /// entry, so a forgotten atom stops being recalled without the pack losing
413    /// the fact that it once held it.
414    ///
415    /// `why` is the deed accession that withdrew the claim, and the daemon
416    /// refuses one that is not an accession. It rides onto the tombstone beside
417    /// the text, so the retraction and what it retracted read back together.
418    ///
419    /// # Errors
420    ///
421    /// [`Error::Bad`] when the workspace does not hold that atom, else the
422    /// request's or a body that is not JSON.
423    pub fn delete_atom(
424        &self,
425        workspace: &str,
426        id: &str,
427        why: Option<&str>,
428    ) -> Result<serde_json::Value, Error> {
429        let url = format!("{}/v1/atoms/delete", self.base);
430        let mut body = serde_json::json!({
431            "workspace": workspace,
432            "id": id,
433        });
434        if let Some(accession) = why {
435            body["why"] = serde_json::Value::String(accession.to_string());
436        }
437        let resp = match ureq::post(&url).timeout(timeout()).send_json(body) {
438            Ok(resp) => resp,
439            Err(ureq::Error::Status(404, _)) => {
440                return Err(Error::Bad(format!("no atom {id}")));
441            }
442            Err(e) => return Err(refused(&url, e)),
443        };
444        Ok(resp.into_json()?)
445    }
446
447    /// Move one atom along the review clock: recalled, or lapsed.
448    pub fn grade(
449        &self,
450        workspace: &str,
451        id: &str,
452        recalled: bool,
453    ) -> Result<serde_json::Value, Error> {
454        let url = format!("{}/v1/grade", self.base);
455        let body: serde_json::Value = ureq::post(&url)
456            .timeout(timeout())
457            .send_json(serde_json::json!({
458                "workspace": workspace,
459                "id": id,
460                "recalled": recalled,
461            }))
462            .map_err(|e| refused(&url, e))?
463            .into_json()?;
464        Ok(body)
465    }
466
467    /// The link graph's communities, largest first.
468    /// The claims the link graph turns on, highest first.
469    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
470        let url = format!("{}/v1/hubs", self.base);
471        let body: serde_json::Value = ureq::get(&url)
472            .query("workspace", workspace)
473            .query("limit", &limit.to_string())
474            .timeout(timeout())
475            .call()
476            .map_err(|e| refused(&url, e))?
477            .into_json()?;
478        Ok(body)
479    }
480
481    pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
482        let url = format!("{}/v1/islands", self.base);
483        let body: serde_json::Value = ureq::get(&url)
484            .query("workspace", workspace)
485            .timeout(timeout())
486            .call()
487            .map_err(|e| refused(&url, e))?
488            .into_json()?;
489        Ok(body)
490    }
491
492    /// Claims that fired together: their links gain weight.
493    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
494        let url = format!("{}/v1/fire", self.base);
495        let body: serde_json::Value = ureq::post(&url)
496            .timeout(timeout())
497            .send_json(serde_json::json!({"workspace": workspace, "ids": ids}))
498            .map_err(|e| refused(&url, e))?
499            .into_json()?;
500        Ok(body)
501    }
502
503    /// Consolidate the workspace: every claim that replaces an earlier one
504    /// closes it (the write-time rule, run over what is held). `apply`
505    /// false reports the pairs and writes nothing.
506    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
507        let url = format!("{}/v1/consolidate", self.base);
508        let body: serde_json::Value = ureq::post(&url)
509            .timeout(timeout())
510            .send_json(serde_json::json!({"workspace": workspace, "apply": apply}))
511            .map_err(|e| refused(&url, e))?
512            .into_json()?;
513        Ok(body)
514    }
515
516    /// The memories a cue activates, strongest first; with `fire`, the top
517    /// of them fire together.
518    pub fn activate(
519        &self,
520        workspace: &str,
521        q: &str,
522        limit: u32,
523        fire: bool,
524    ) -> Result<serde_json::Value, Error> {
525        let url = format!("{}/v1/activate", self.base);
526        let body: serde_json::Value = ureq::get(&url)
527            .query("workspace", workspace)
528            .query("q", q)
529            .query("limit", &limit.to_string())
530            .query("fire", if fire { "1" } else { "0" })
531            .timeout(timeout())
532            .call()
533            .map_err(|e| refused(&url, e))?
534            .into_json()?;
535        Ok(body)
536    }
537
538    pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
539        let url = format!("{}/v1/atoms", self.base);
540        let body: serde_json::Value = ureq::post(&url)
541            .timeout(timeout())
542            .send_json(atom.clone())
543            .map_err(|e| refused(&url, e))?
544            .into_json()?;
545        Ok(body)
546    }
547}