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::path::PathBuf;
9use std::time::{Duration, Instant};
10
11/// How long one request may take: `PACKSET_TIMEOUT_MS`, else thirty seconds.
12/// A write that waits behind thirty others on a busy seat is late, not failed.
13fn timeout() -> Duration {
14    std::env::var("PACKSET_TIMEOUT_MS")
15        .ok()
16        .and_then(|v| v.trim().parse::<u64>().ok())
17        .filter(|ms| *ms > 0)
18        .map_or(Duration::from_secs(30), Duration::from_millis)
19}
20
21/// The wait before a busy writer is asked again, tripled each time.
22const BUSY_WAIT: Duration = Duration::from_millis(50);
23/// How many times to ask again.
24const BUSY_RETRIES: u32 = 3;
25
26/// Send `req`, with `body` when there is one, inside `budget`, and send it
27/// again when the writer answers busy. The writer answers 503 only when its
28/// queue or its lane is full, before any of the request runs, so a write
29/// sent again is not stored twice.
30fn send(
31    req: ureq::Request,
32    body: Option<&serde_json::Value>,
33    budget: Duration,
34) -> Result<ureq::Response, Box<ureq::Error>> {
35    let started = Instant::now();
36    let mut wait = BUSY_WAIT;
37    let mut retries = 0;
38    loop {
39        let left = budget
40            .saturating_sub(started.elapsed())
41            .max(Duration::from_millis(1));
42        let attempt = req.clone().timeout(left);
43        let answered = match body {
44            Some(json) => attempt.send_json(json),
45            None => attempt.call(),
46        };
47        match answered {
48            Err(ureq::Error::Status(503, _))
49                if retries < BUSY_RETRIES && started.elapsed() + wait < budget =>
50            {
51                std::thread::sleep(wait);
52                wait *= 3;
53                retries += 1;
54            }
55            other => return other.map_err(Box::new),
56        }
57    }
58}
59
60fn path_seg(id: &str) -> String {
61    let mut out = String::with_capacity(id.len());
62    for b in id.bytes() {
63        match b {
64            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
65                out.push(b as char)
66            }
67            _ => out.push_str(&format!("%{b:02X}")),
68        }
69    }
70    out
71}
72
73/// The port a writer listens on when nothing names one. The command line,
74/// the server and this client agree on it, so a seat needs no variable set.
75pub const DEFAULT_PORT: u16 = 8761;
76
77/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
78/// those keys. `ljos`, `packset`, and `packset-mcp` then share one pack.
79pub fn load_seat_env() {
80    let Some(home) = env::var_os("HOME") else {
81        return;
82    };
83    let path = PathBuf::from(home).join(".config/ljos/env");
84    let Ok(text) = std::fs::read_to_string(path) else {
85        return;
86    };
87    for line in text.lines() {
88        let line = line.trim();
89        if line.is_empty() || line.starts_with('#') {
90            continue;
91        }
92        let Some((k, v)) = line.split_once('=') else {
93            continue;
94        };
95        let k = k.trim();
96        if k.is_empty() || env::var_os(k).is_some() {
97            continue;
98        }
99        env::set_var(k, v.trim());
100    }
101}
102
103/// The workspace the seat's memory lives in: `PACKSET_WORKSPACE` after
104/// loading the seat env file, else `seat`. Not `default`, and not the
105/// working directory's git remote: those two are how one harness's
106/// remember missed the other's sitting.
107#[must_use]
108pub fn resolved_workspace() -> String {
109    load_seat_env();
110    env::var("PACKSET_WORKSPACE")
111        .ok()
112        .map(|w| w.trim().to_string())
113        .filter(|w| !w.is_empty())
114        .unwrap_or_else(|| "seat".to_string())
115}
116
117/// `PACKSET_PORT` (`GROK_MEM_PORT` is an alias), else [`DEFAULT_PORT`].
118#[must_use]
119pub fn default_port() -> u16 {
120    env::var("PACKSET_PORT")
121        .or_else(|_| env::var("GROK_MEM_PORT"))
122        .ok()
123        .and_then(|raw| raw.trim().parse().ok())
124        .unwrap_or(DEFAULT_PORT)
125}
126
127/// The token file a writer leaves in its pack home: `PACKSET_TOKEN_FILE`,
128/// else `token` under `PACKSET_HOME` (`GROKINSIDE_HOME`,
129/// `GROK_INSIDE_MEMORY_HOME`), else under the default home
130/// ([`packset_core::home::resolve`]). packsetd resolves its home the same
131/// way.
132#[must_use]
133pub fn token_path() -> Option<PathBuf> {
134    if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
135        return Some(PathBuf::from(file));
136    }
137    packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
138}
139
140/// The name of the token file in a pack home.
141pub const TOKEN_FILE: &str = "token";
142
143/// The token this seat shows the writer: `PACKSET_TOKEN`, else the first
144/// line of [`token_path`]. `None` when there is neither, and the writer then
145/// answers 401 to everything but `/health`.
146#[must_use]
147pub fn token() -> Option<String> {
148    if let Ok(t) = env::var("PACKSET_TOKEN") {
149        let t = t.trim().to_string();
150        if !t.is_empty() {
151            return Some(t);
152        }
153    }
154    let text = std::fs::read_to_string(token_path()?).ok()?;
155    let t = text.lines().next()?.trim().to_string();
156    (!t.is_empty()).then_some(t)
157}
158
159/// `request` with this seat's token as a bearer header. A caller that speaks
160/// to the writer outside [`PacksetClient`] wraps its request in this.
161///
162/// The token stays home when another user's process holds the writer's
163/// loopback port: anyone can answer `/health` with `packsetd ok`, and a
164/// listener that got there first would otherwise collect the token.
165pub fn authorize(request: ureq::Request) -> ureq::Request {
166    if foreign_listener(request.url()) {
167        return request;
168    }
169    match token() {
170        Some(t) => request.set("Authorization", &format!("Bearer {t}")),
171        None => request,
172    }
173}
174
175/// The port of a loopback `url`, `None` for any other host.
176fn loopback_port(url: &str) -> Option<u16> {
177    let rest = url
178        .strip_prefix("http://")
179        .or_else(|| url.strip_prefix("https://"))?;
180    let authority = rest.split(['/', '?', '#']).next()?;
181    let (host, port) = match authority.rsplit_once(':') {
182        Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
183        _ => (authority, 80),
184    };
185    matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
186}
187
188/// The owners of the sockets listening on `port`, from one `/proc/net/tcp`
189/// or `tcp6` table: local address is the second column, state the fourth,
190/// uid the eighth.
191fn listen_uids(table: &str, port: u16) -> Vec<u32> {
192    table
193        .lines()
194        .skip(1)
195        .filter_map(|line| {
196            let fields: Vec<&str> = line.split_whitespace().collect();
197            let local = fields.get(1)?;
198            if fields.get(3) != Some(&"0A") {
199                return None;
200            }
201            let hex = local.rsplit(':').next()?;
202            if u16::from_str_radix(hex, 16).ok()? != port {
203                return None;
204            }
205            fields.get(7)?.parse().ok()
206        })
207        .collect()
208}
209
210/// Whether every socket listening on the loopback port of `url` belongs to
211/// a user other than this one. Unknown is not foreign: off Linux, or when
212/// the tables cannot be read, the token goes as before.
213fn foreign_listener(url: &str) -> bool {
214    #[cfg(target_os = "linux")]
215    {
216        use std::os::unix::fs::MetadataExt;
217        let Some(port) = loopback_port(url) else {
218            return false;
219        };
220        let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
221            return false;
222        };
223        let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
224            .iter()
225            .filter_map(|t| std::fs::read_to_string(t).ok())
226            .flat_map(|text| listen_uids(&text, port))
227            .collect();
228        !owners.is_empty() && owners.iter().all(|uid| *uid != me)
229    }
230    #[cfg(not(target_os = "linux"))]
231    {
232        let _ = url;
233        false
234    }
235}
236
237#[derive(Debug, thiserror::Error)]
238pub enum Error {
239    #[error("packset url missing")]
240    NoUrl,
241    #[error("http: {0}")]
242    Http(#[from] Box<ureq::Error>),
243    #[error("io: {0}")]
244    Io(#[from] std::io::Error),
245    #[error("json: {0}")]
246    Json(#[from] serde_json::Error),
247    #[error("bad response: {0}")]
248    Bad(String),
249}
250
251#[derive(Debug, Clone)]
252pub struct PacksetClient {
253    base: String,
254    /// A workspace pinned by the caller; `None` reads the environment and
255    /// the working directory.
256    workspace: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Hit {
261    pub id: Option<String>,
262    pub text: String,
263    #[serde(default)]
264    pub score: f64,
265    #[serde(default)]
266    pub kind: String,
267    /// When the memory was written, the writer's clock, RFC 3339. Absent
268    /// on card paragraphs, which have no clock.
269    #[serde(default)]
270    pub ts: Option<String>,
271    /// How many of the panel's ballots named this hit, and how many ran.
272    /// Two of three is agreement; one of three is one scorer's opinion.
273    /// The atom's entities: `seat:<name>` names the seat that wrote it,
274    /// `persona:<name>` a persona's own claim, `habit:<name>` a reading.
275    #[serde(default)]
276    pub entities: Vec<String>,
277    #[serde(default)]
278    pub ballots: Option<u32>,
279    #[serde(default)]
280    pub of: Option<u32>,
281    /// The cross-encoder score when the second stage scored this hit.
282    /// Absent, or null, when it did not.
283    #[serde(default)]
284    pub rerank_score: Option<f64>,
285}
286
287/// A refusal, carrying the reason the writer gave in its body.
288fn refused(url: &str, e: Box<ureq::Error>) -> Error {
289    match *e {
290        ureq::Error::Status(code, response) => {
291            let text = response.into_string().unwrap_or_default();
292            let reason = serde_json::from_str::<serde_json::Value>(&text)
293                .ok()
294                .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
295                .unwrap_or(text);
296            let reason = reason.trim();
297            if code == 401 {
298                return Error::Bad(format!(
299                    "{url}: 401: {reason}; this seat's token does not match the writer's, \
300                     so it is another user's writer or PACKSET_HOME names another pack"
301                ));
302            }
303            if reason.is_empty() {
304                Error::Bad(format!("{url}: status code {code}"))
305            } else {
306                Error::Bad(format!("{url}: {code}: {reason}"))
307            }
308        }
309        other => Error::Http(Box::new(other)),
310    }
311}
312
313impl PacksetClient {
314    pub fn new(base: impl Into<String>) -> Self {
315        let mut base = base.into();
316        while base.ends_with('/') {
317            base.pop();
318        }
319        Self {
320            base,
321            workspace: None,
322        }
323    }
324
325    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
326    /// and the working directory. A seat that is one memory across every
327    /// repository it works in sets this once.
328    #[must_use]
329    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
330        let workspace = workspace.into();
331        self.workspace = (!workspace.is_empty()).then_some(workspace);
332        self
333    }
334
335    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
336    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
337    /// line starts a writer on, `PACKSET_PORT` (`GROK_MEM_PORT`) or 8761.
338    /// `PACKSET_URL=off` is the one way to have no pack.
339    pub fn from_env() -> Result<Self, Error> {
340        load_seat_env();
341        let url = env::var("PACKSET_URL")
342            .or_else(|_| env::var("INSIDE_MEMORY_URL"))
343            .ok()
344            .filter(|url| !url.is_empty());
345        match url {
346            Some(url) if url == "off" => Err(Error::NoUrl),
347            Some(url) => Ok(Self::new(url)),
348            None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
349        }
350    }
351
352    pub fn base(&self) -> &str {
353        &self.base
354    }
355
356    pub fn workspace(&self) -> String {
357        if let Some(w) = &self.workspace {
358            return w.clone();
359        }
360        if let Ok(w) = env::var("PACKSET_WORKSPACE") {
361            if !w.is_empty() {
362                return w;
363            }
364        }
365        let cwd = env::var("GROKOS_WORKSPACE")
366            .ok()
367            .map(|s| s.trim().to_string())
368            .filter(|s| !s.is_empty())
369            .map(std::path::PathBuf::from)
370            .or_else(|| env::current_dir().ok())
371            .unwrap_or_else(|| std::path::PathBuf::from("."));
372        self.workspace_for_cwd(&cwd)
373    }
374
375    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
376    pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
377        let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
378        let url = format!("{}/v1/identity", self.base);
379        let body = send(
380            authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
381            None,
382            timeout(),
383        )
384        .ok()
385        .and_then(|r| r.into_string().ok());
386        if let Some(body) = body {
387            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
388                if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
389                    if !ws.is_empty() {
390                        return ws.to_string();
391                    }
392                }
393            }
394        }
395        format!("dir:{}", abs.display())
396    }
397
398    pub fn health(&self) -> Result<String, Error> {
399        let url = format!("{}/health", self.base);
400        let body = send(authorize(ureq::get(&url)), None, timeout())
401            .map_err(|e| refused(&url, e))?
402            .into_string()?;
403        Ok(body)
404    }
405
406    pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
407        let encoded = path_seg(id);
408        let url = format!("{}/v1/atoms/{encoded}", self.base);
409        let resp = match send(
410            authorize(ureq::get(&url)).query("workspace", workspace),
411            None,
412            timeout(),
413        ) {
414            Ok(resp) => resp,
415            Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
416                return Err(Error::Bad(format!("no atom {id}")));
417            }
418            Err(e) => return Err(Error::Http(e)),
419        };
420        Ok(resp.into_json()?)
421    }
422
423    pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
424        self.atoms_as_of(workspace, None)
425    }
426
427    /// Live-now atoms, or the ones that were live at `as_of`.
428    ///
429    /// # Errors
430    ///
431    /// The request's, or a body that is not JSON.
432    pub fn atoms_as_of(
433        &self,
434        workspace: &str,
435        as_of: Option<&str>,
436    ) -> Result<Vec<serde_json::Value>, Error> {
437        let url = format!("{}/v1/atoms", self.base);
438        let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
439        if let Some(at) = as_of {
440            req = req.query("as_of", at);
441        }
442        let mut body: serde_json::Value = send(req, None, timeout())
443            .map_err(|e| refused(&url, e))?
444            .into_json()?;
445        let atoms = body
446            .get_mut("atoms")
447            .map(serde_json::Value::take)
448            .unwrap_or(serde_json::Value::Array(vec![]));
449        Ok(serde_json::from_value(atoms)?)
450    }
451
452    /// [`Self::atoms_as_of`] without the dense vectors, for a reader of
453    /// texts, review clocks or rules: the vectors are most of the bytes. A
454    /// writer older than the `embedding=omit` query sends them anyway.
455    ///
456    /// # Errors
457    ///
458    /// The pack not answering, or an answer that is not atoms.
459    pub fn atoms_without_vectors(
460        &self,
461        workspace: &str,
462        as_of: Option<&str>,
463    ) -> Result<Vec<serde_json::Value>, Error> {
464        let url = format!("{}/v1/atoms", self.base);
465        let mut req = authorize(ureq::get(&url))
466            .query("workspace", workspace)
467            .query("embedding", "omit");
468        if let Some(at) = as_of {
469            req = req.query("as_of", at);
470        }
471        let mut body: serde_json::Value = send(req, None, timeout())
472            .map_err(|e| refused(&url, e))?
473            .into_json()?;
474        let atoms = body
475            .get_mut("atoms")
476            .map(serde_json::Value::take)
477            .unwrap_or(serde_json::Value::Array(vec![]));
478        Ok(serde_json::from_value(atoms)?)
479    }
480
481    pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
482        self.search_as_of(workspace, q, limit, None)
483    }
484
485    /// Ranked hits, optionally over the atoms that were live at `as_of`.
486    ///
487    /// # Errors
488    ///
489    /// The request's, or a body that is not JSON.
490    pub fn search_as_of(
491        &self,
492        workspace: &str,
493        q: &str,
494        limit: u32,
495        as_of: Option<&str>,
496    ) -> Result<Vec<Hit>, Error> {
497        self.search_opts(workspace, q, limit, as_of, false)
498    }
499
500    /// Ranked hits, optionally dated and optionally through the measured
501    /// cross-encoder stage.
502    ///
503    /// Off by default. On, the writer spends a forward pass per candidate and
504    /// the request waits for that rather than the usual five-second budget.
505    ///
506    /// # Errors
507    ///
508    /// The request's, or a body that is not a hit list.
509    pub fn search_opts(
510        &self,
511        workspace: &str,
512        q: &str,
513        limit: u32,
514        as_of: Option<&str>,
515        rerank: bool,
516    ) -> Result<Vec<Hit>, Error> {
517        self.search_ranked(workspace, q, limit, as_of, rerank, None)
518    }
519
520    /// [`Self::search_opts`] with a rank. `Some("relevance")` asks for the
521    /// top k by lexical, BM25 and cosine, with no half-life and no
522    /// retrievability. `None` leaves the shipped order. An older writer
523    /// ignores the parameter; `GET /v1/status` lists `search.ranks` when
524    /// this writer honours it.
525    ///
526    /// # Errors
527    ///
528    /// The request's, including a rank the writer refuses, or a body that
529    /// is not a hit list.
530    pub fn search_ranked(
531        &self,
532        workspace: &str,
533        q: &str,
534        limit: u32,
535        as_of: Option<&str>,
536        rerank: bool,
537        rank: Option<&str>,
538    ) -> Result<Vec<Hit>, Error> {
539        let url = format!("{}/v1/search", self.base);
540        let budget = if rerank {
541            Duration::from_secs(60).max(timeout())
542        } else {
543            timeout()
544        };
545        let mut req = authorize(ureq::get(&url))
546            .query("workspace", workspace)
547            .query("q", q)
548            .query("limit", &limit.to_string());
549        if let Some(at) = as_of {
550            req = req.query("as_of", at);
551        }
552        if rerank {
553            req = req.query("rerank", "1");
554        }
555        if let Some(rank) = rank.map(str::trim).filter(|s| !s.is_empty()) {
556            req = req.query("rank", rank);
557        }
558        let body: serde_json::Value = send(req, None, budget)
559            .map_err(|e| refused(&url, e))?
560            .into_json()?;
561        let hits = body
562            .get("hits")
563            .cloned()
564            .unwrap_or(serde_json::Value::Array(vec![]));
565        Ok(serde_json::from_value(hits)?)
566    }
567
568    /// Seat home, atom counts by kind, pin, index and embedder.
569    ///
570    /// # Errors
571    ///
572    /// The request's, or a body that is not JSON.
573    pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
574        let url = format!("{}/v1/status", self.base);
575        let mut req = authorize(ureq::get(&url));
576        if let Some(workspace) = workspace {
577            req = req.query("workspace", workspace);
578        }
579        Ok(send(req, None, timeout())
580            .map_err(|e| refused(&url, e))?
581            .into_json()?)
582    }
583
584    /// The set a workspace is pinned to.
585    ///
586    /// # Errors
587    ///
588    /// The request's, or a body that is not JSON.
589    pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
590        let url = format!("{}/v1/pin", self.base);
591        Ok(send(
592            authorize(ureq::get(&url)).query("workspace", workspace),
593            None,
594            timeout(),
595        )
596        .map_err(|e| refused(&url, e))?
597        .into_json()?)
598    }
599
600    /// Pin a workspace to a set.
601    ///
602    /// # Errors
603    ///
604    /// The request's, or a body that is not JSON.
605    pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
606        let url = format!("{}/v1/pin", self.base);
607        Ok(send(
608            authorize(ureq::put(&url)),
609            Some(&serde_json::json!({ "workspace": workspace, "name": name })),
610            timeout(),
611        )
612        .map_err(|e| refused(&url, e))?
613        .into_json()?)
614    }
615
616    /// The deed accessions a workspace's live atoms cite, sorted.
617    ///
618    /// The accession is the only identifier crossing the tracker, the pack and
619    /// the deed store, so this is what `deedar evidence -` reads.
620    ///
621    /// # Errors
622    ///
623    /// The request's, or a body that is not JSON.
624    pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
625        let url = format!("{}/v1/accessions", self.base);
626        let body: serde_json::Value = send(
627            authorize(ureq::get(&url)).query("workspace", workspace),
628            None,
629            timeout(),
630        )
631        .map_err(|e| refused(&url, e))?
632        .into_json()?;
633        let found = body
634            .get("accessions")
635            .cloned()
636            .unwrap_or(serde_json::Value::Array(vec![]));
637        Ok(serde_json::from_value(found)?)
638    }
639    /// Every live atom in a workspace.
640    ///
641    /// The bodies, not the join keys: this is what a handover carries when
642    /// somebody is given what the seat learned rather than only what it cites.
643    ///
644    /// # Errors
645    ///
646    /// The request's, or a body that is not JSON.
647    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
648        self.atoms_as_of(workspace, None)
649    }
650
651    /// The live atoms of one kind in a workspace: personas, trust rows,
652    /// habits, without the rest of the pack.
653    ///
654    /// # Errors
655    ///
656    /// The request's, or a body that is not JSON.
657    pub fn atoms_of_kind(
658        &self,
659        workspace: &str,
660        kind: &str,
661    ) -> Result<Vec<serde_json::Value>, Error> {
662        let url = format!("{}/v1/atoms", self.base);
663        let body: serde_json::Value = send(
664            authorize(ureq::get(&url))
665                .query("workspace", workspace)
666                .query("kind", kind),
667            None,
668            timeout(),
669        )
670        .map_err(|e| refused(&url, e))?
671        .into_json()?;
672        Ok(body
673            .get("atoms")
674            .and_then(serde_json::Value::as_array)
675            .cloned()
676            .unwrap_or_default())
677    }
678
679    /// The live atoms in a workspace that cite one deed accession.
680    ///
681    /// # Errors
682    ///
683    /// The request's, or a body that is not JSON.
684    pub fn citers(
685        &self,
686        workspace: &str,
687        accession: &str,
688    ) -> Result<Vec<serde_json::Value>, Error> {
689        let url = format!("{}/v1/citers", self.base);
690        let body: serde_json::Value = send(
691            authorize(ureq::get(&url))
692                .query("workspace", workspace)
693                .query("accession", accession),
694            None,
695            timeout(),
696        )
697        .map_err(|e| refused(&url, e))?
698        .into_json()?;
699        let found = body
700            .get("atoms")
701            .cloned()
702            .unwrap_or(serde_json::Value::Array(vec![]));
703        Ok(serde_json::from_value(found)?)
704    }
705
706    /// Tombstone one atom. The daemon keeps the record and drops the index
707    /// entry, so a forgotten atom stops being recalled without the pack losing
708    /// the fact that it once held it.
709    ///
710    /// `why` is the deed accession that withdrew the claim, and the daemon
711    /// refuses one that is not an accession. It rides onto the tombstone beside
712    /// the text, so the retraction and what it retracted read back together.
713    ///
714    /// # Errors
715    ///
716    /// [`Error::Bad`] when the workspace does not hold that atom, else the
717    /// request's or a body that is not JSON.
718    pub fn delete_atom(
719        &self,
720        workspace: &str,
721        id: &str,
722        why: Option<&str>,
723    ) -> Result<serde_json::Value, Error> {
724        let url = format!("{}/v1/atoms/delete", self.base);
725        let mut body = serde_json::json!({
726            "workspace": workspace,
727            "id": id,
728        });
729        if let Some(accession) = why {
730            body["why"] = serde_json::Value::String(accession.to_string());
731        }
732        let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
733            Ok(resp) => resp,
734            Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
735                return Err(Error::Bad(format!("no atom {id}")));
736            }
737            Err(e) => return Err(refused(&url, e)),
738        };
739        Ok(resp.into_json()?)
740    }
741
742    /// Move one atom along the review clock: recalled, or lapsed.
743    pub fn grade(
744        &self,
745        workspace: &str,
746        id: &str,
747        recalled: bool,
748    ) -> Result<serde_json::Value, Error> {
749        let url = format!("{}/v1/grade", self.base);
750        let body: serde_json::Value = send(
751            authorize(ureq::post(&url)),
752            Some(&serde_json::json!({
753                "workspace": workspace,
754                "id": id,
755                "recalled": recalled,
756            })),
757            timeout(),
758        )
759        .map_err(|e| refused(&url, e))?
760        .into_json()?;
761        Ok(body)
762    }
763
764    /// The link graph's communities, largest first.
765    /// The claims the link graph turns on, highest first.
766    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
767        let url = format!("{}/v1/hubs", self.base);
768        let body: serde_json::Value = send(
769            authorize(ureq::get(&url))
770                .query("workspace", workspace)
771                .query("limit", &limit.to_string()),
772            None,
773            timeout(),
774        )
775        .map_err(|e| refused(&url, e))?
776        .into_json()?;
777        Ok(body)
778    }
779
780    pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
781        let url = format!("{}/v1/islands", self.base);
782        let body: serde_json::Value = send(
783            authorize(ureq::get(&url)).query("workspace", workspace),
784            None,
785            timeout(),
786        )
787        .map_err(|e| refused(&url, e))?
788        .into_json()?;
789        Ok(body)
790    }
791
792    /// Claims that fired together: their links gain weight.
793    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
794        self.fire_as(workspace, ids, None)
795    }
796
797    /// [`Self::fire`] through a persona's lens: the weights move under its
798    /// name and the seat's stand.
799    ///
800    /// # Errors
801    ///
802    /// The request's, or a body that is not JSON.
803    pub fn fire_as(
804        &self,
805        workspace: &str,
806        ids: &[String],
807        lens: Option<&str>,
808    ) -> Result<serde_json::Value, Error> {
809        let url = format!("{}/v1/fire", self.base);
810        let body: serde_json::Value = send(
811            authorize(ureq::post(&url)),
812            Some(
813                &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
814            ),
815            timeout(),
816        )
817        .map_err(|e| refused(&url, e))?
818        .into_json()?;
819        Ok(body)
820    }
821
822    /// Consolidate the workspace: every claim that replaces an earlier one
823    /// closes it (the write-time rule, run over what is held). `apply`
824    /// false reports the pairs and writes nothing.
825    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
826        let url = format!("{}/v1/consolidate", self.base);
827        let body: serde_json::Value = send(
828            authorize(ureq::post(&url)),
829            Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
830            timeout(),
831        )
832        .map_err(|e| refused(&url, e))?
833        .into_json()?;
834        Ok(body)
835    }
836
837    /// Sweep a workspace for neglect: reviews left due past twice their
838    /// interval lapse, and the third miss forgets a never-recalled lesson.
839    ///
840    /// # Errors
841    ///
842    /// The request's, or a body that is not JSON.
843    pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
844        let url = format!("{}/v1/sweep", self.base);
845        let body: serde_json::Value = send(
846            authorize(ureq::post(&url)),
847            Some(&serde_json::json!({"workspace": workspace})),
848            timeout(),
849        )
850        .map_err(|e| refused(&url, e))?
851        .into_json()?;
852        Ok(body)
853    }
854
855    /// Drop a scratch workspace's live atoms, no deed; its tombstones stay.
856    /// Retraction stays [`Self::delete_atom`]; this is what a per-run
857    /// scratch workspace calls on its way out.
858    ///
859    /// # Errors
860    ///
861    /// The request's, or a body that is not JSON.
862    pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
863        let url = format!("{}/v1/forget", self.base);
864        let body: serde_json::Value = send(
865            authorize(ureq::post(&url)),
866            Some(&serde_json::json!({"workspace": workspace})),
867            timeout(),
868        )
869        .map_err(|e| refused(&url, e))?
870        .into_json()?;
871        Ok(body)
872    }
873
874    /// The memories a cue activates, strongest first; with `fire`, the top
875    /// of them fire together.
876    pub fn activate(
877        &self,
878        workspace: &str,
879        q: &str,
880        limit: u32,
881        fire: bool,
882    ) -> Result<serde_json::Value, Error> {
883        self.activate_as(workspace, q, limit, fire, None)
884    }
885
886    /// [`Self::activate`] through a persona's lens: the spread follows the
887    /// weights that persona wrote, and a fire writes them.
888    ///
889    /// # Errors
890    ///
891    /// The request's, or a body that is not JSON.
892    pub fn activate_as(
893        &self,
894        workspace: &str,
895        q: &str,
896        limit: u32,
897        fire: bool,
898        lens: Option<&str>,
899    ) -> Result<serde_json::Value, Error> {
900        let url = format!("{}/v1/activate", self.base);
901        let mut req = authorize(ureq::get(&url))
902            .query("workspace", workspace)
903            .query("q", q)
904            .query("limit", &limit.to_string())
905            .query("fire", if fire { "1" } else { "0" });
906        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
907            req = req.query("as", name);
908        }
909        let body: serde_json::Value = send(req, None, timeout())
910            .map_err(|e| refused(&url, e))?
911            .into_json()?;
912        Ok(body)
913    }
914
915    /// The live atoms of one named set in a workspace: a persona's own
916    /// conclusions, a pinned slice.
917    ///
918    /// # Errors
919    ///
920    /// The request's, or a body that is not JSON.
921    pub fn atoms_in_set(
922        &self,
923        workspace: &str,
924        set: &str,
925    ) -> Result<Vec<serde_json::Value>, Error> {
926        let url = format!("{}/v1/pack", self.base);
927        let body: serde_json::Value = send(
928            authorize(ureq::get(&url))
929                .query("workspace", workspace)
930                .query("set", set),
931            None,
932            timeout(),
933        )
934        .map_err(|e| refused(&url, e))?
935        .into_json()?;
936        Ok(body
937            .get("atoms")
938            .and_then(serde_json::Value::as_array)
939            .cloned()
940            .unwrap_or_default())
941    }
942
943    pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
944        let url = format!("{}/v1/atoms", self.base);
945        let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
946            .map_err(|e| refused(&url, e))?
947            .into_json()?;
948        Ok(body)
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    // Both workspace tests below rewrite process-global environment, so
957    // they hold this lock: without it the runner's parallel threads
958    // interleave one test's set-and-restore with the other's read, and
959    // the suite fails one run in five on a polluted read.
960    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
961
962    #[test]
963    fn only_a_loopback_url_has_a_port_to_check() {
964        assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
965        assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
966        assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
967        assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
968        assert_eq!(loopback_port("http://example.org:8761/"), None);
969        assert_eq!(loopback_port("http://[::1]/"), Some(80));
970    }
971
972    #[test]
973    fn the_owner_of_a_listening_port_is_read_off_the_table() {
974        let table = "  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
975   0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1001        0 4242 1 0 100 0 0 10 0
976   1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000  1000        0 4343 1 0 20 4 30 10 -1
977   2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 4444 1 0 100 0 0 10 0
978";
979        assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
980        assert_eq!(listen_uids(table, 8080), vec![1000]);
981        assert!(listen_uids(table, 9).is_empty());
982    }
983
984    #[cfg(target_os = "linux")]
985    #[test]
986    fn a_port_this_user_holds_is_not_foreign() {
987        let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
988        let port = held.local_addr().unwrap().port();
989        assert!(!foreign_listener(&format!(
990            "http://127.0.0.1:{port}/health"
991        )));
992        drop(held);
993        assert!(
994            !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
995            "nobody listening is not foreign"
996        );
997    }
998
999    #[test]
1000    fn resolved_workspace_reads_ljos_env_not_default() {
1001        let _env = ENV_LOCK.lock().unwrap();
1002        let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
1003        std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
1004        std::fs::write(
1005            dir.join(".config/ljos/env"),
1006            "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
1007        )
1008        .unwrap();
1009        let old_home = env::var("HOME").ok();
1010        let old_ws = env::var("PACKSET_WORKSPACE").ok();
1011        unsafe {
1012            env::remove_var("PACKSET_WORKSPACE");
1013            env::set_var("HOME", &dir);
1014        }
1015        let got = resolved_workspace();
1016        unsafe {
1017            match old_home {
1018                Some(h) => env::set_var("HOME", h),
1019                None => env::remove_var("HOME"),
1020            }
1021            match old_ws {
1022                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1023                None => env::remove_var("PACKSET_WORKSPACE"),
1024            }
1025        }
1026        assert_eq!(got, "git:example.com/seat/notes");
1027    }
1028
1029    #[test]
1030    fn resolved_workspace_without_env_is_seat_not_default() {
1031        let _env = ENV_LOCK.lock().unwrap();
1032        let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1033        std::fs::create_dir_all(&dir).unwrap();
1034        let old_home = env::var("HOME").ok();
1035        let old_ws = env::var("PACKSET_WORKSPACE").ok();
1036        unsafe {
1037            env::remove_var("PACKSET_WORKSPACE");
1038            env::set_var("HOME", &dir);
1039        }
1040        let got = resolved_workspace();
1041        unsafe {
1042            match old_home {
1043                Some(h) => env::set_var("HOME", h),
1044                None => env::remove_var("HOME"),
1045            }
1046            match old_ws {
1047                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1048                None => env::remove_var("PACKSET_WORKSPACE"),
1049            }
1050        }
1051        assert_eq!(got, "seat");
1052        assert_ne!(got, "default");
1053    }
1054
1055    /// A writer that answers busy to its first `busy` requests and counts every
1056    /// request.
1057    fn busy_for(
1058        busy: usize,
1059    ) -> (
1060        PacksetClient,
1061        std::sync::Arc<std::sync::atomic::AtomicUsize>,
1062    ) {
1063        use std::io::{BufRead, BufReader, Read, Write};
1064        use std::sync::atomic::Ordering;
1065        let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1066        let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1067        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1068        let counted = std::sync::Arc::clone(&seen);
1069        std::thread::spawn(move || {
1070            for stream in listener.incoming().flatten() {
1071                let mut reader = BufReader::new(&stream);
1072                let mut length = 0;
1073                let mut line = String::new();
1074                while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1075                    if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1076                        length = v.trim().parse().unwrap_or(0);
1077                    }
1078                    line.clear();
1079                }
1080                let mut body = vec![0u8; length];
1081                let _ = reader.read_exact(&mut body);
1082                let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1083                    ("503 Service Unavailable", r#"{"error":"busy"}"#)
1084                } else {
1085                    ("200 OK", r#"{"ok":true}"#)
1086                };
1087                let _ = (&stream).write_all(
1088                    format!(
1089                        "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1090                        text.len()
1091                    )
1092                    .as_bytes(),
1093                );
1094            }
1095        });
1096        (client, seen)
1097    }
1098
1099    #[test]
1100    fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1101        use std::sync::atomic::Ordering;
1102        let _env = ENV_LOCK.lock().unwrap();
1103        let (client, seen) = busy_for(2);
1104        assert_eq!(client.status(None).unwrap()["ok"], true);
1105        assert_eq!(seen.load(Ordering::SeqCst), 3);
1106        let (client, seen) = busy_for(1);
1107        let stored = client
1108            .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1109            .unwrap();
1110        assert_eq!(stored["ok"], true);
1111        assert_eq!(
1112            seen.load(Ordering::SeqCst),
1113            2,
1114            "the write was not sent again"
1115        );
1116    }
1117
1118    #[test]
1119    fn a_writer_busy_past_three_retries_is_a_refusal() {
1120        use std::sync::atomic::Ordering;
1121        let _env = ENV_LOCK.lock().unwrap();
1122        let (client, seen) = busy_for(usize::MAX);
1123        let err = client.status(None).unwrap_err().to_string();
1124        assert!(err.contains("503") && err.contains("busy"), "{err}");
1125        assert_eq!(seen.load(Ordering::SeqCst), 4);
1126    }
1127
1128    /// A 100 ms budget leaves no room for the 150 ms wait.
1129    #[test]
1130    fn the_retries_stop_where_the_budget_does() {
1131        use std::sync::atomic::Ordering;
1132        let _env = ENV_LOCK.lock().unwrap();
1133        let old = env::var_os("PACKSET_TIMEOUT_MS");
1134        unsafe { env::set_var("PACKSET_TIMEOUT_MS", "100") };
1135        let (client, seen) = busy_for(usize::MAX);
1136        let started = Instant::now();
1137        let answered = client.status(None);
1138        let took = started.elapsed();
1139        unsafe {
1140            match old {
1141                Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1142                None => env::remove_var("PACKSET_TIMEOUT_MS"),
1143            }
1144        }
1145        assert!(answered.is_err());
1146        assert_eq!(seen.load(Ordering::SeqCst), 2);
1147        assert!(took < Duration::from_millis(300), "{took:?}");
1148    }
1149}