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}
282
283/// A refusal, carrying the reason the writer gave in its body.
284fn refused(url: &str, e: Box<ureq::Error>) -> Error {
285    match *e {
286        ureq::Error::Status(code, response) => {
287            let text = response.into_string().unwrap_or_default();
288            let reason = serde_json::from_str::<serde_json::Value>(&text)
289                .ok()
290                .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
291                .unwrap_or(text);
292            let reason = reason.trim();
293            if code == 401 {
294                return Error::Bad(format!(
295                    "{url}: 401: {reason}; this seat's token does not match the writer's, \
296                     so it is another user's writer or PACKSET_HOME names another pack"
297                ));
298            }
299            if reason.is_empty() {
300                Error::Bad(format!("{url}: status code {code}"))
301            } else {
302                Error::Bad(format!("{url}: {code}: {reason}"))
303            }
304        }
305        other => Error::Http(Box::new(other)),
306    }
307}
308
309impl PacksetClient {
310    pub fn new(base: impl Into<String>) -> Self {
311        let mut base = base.into();
312        while base.ends_with('/') {
313            base.pop();
314        }
315        Self {
316            base,
317            workspace: None,
318        }
319    }
320
321    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
322    /// and the working directory. A seat that is one memory across every
323    /// repository it works in sets this once.
324    #[must_use]
325    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
326        let workspace = workspace.into();
327        self.workspace = (!workspace.is_empty()).then_some(workspace);
328        self
329    }
330
331    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
332    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
333    /// line starts a writer on, `PACKSET_PORT` (`GROK_MEM_PORT`) or 8761.
334    /// `PACKSET_URL=off` is the one way to have no pack.
335    pub fn from_env() -> Result<Self, Error> {
336        load_seat_env();
337        let url = env::var("PACKSET_URL")
338            .or_else(|_| env::var("INSIDE_MEMORY_URL"))
339            .ok()
340            .filter(|url| !url.is_empty());
341        match url {
342            Some(url) if url == "off" => Err(Error::NoUrl),
343            Some(url) => Ok(Self::new(url)),
344            None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
345        }
346    }
347
348    pub fn base(&self) -> &str {
349        &self.base
350    }
351
352    pub fn workspace(&self) -> String {
353        if let Some(w) = &self.workspace {
354            return w.clone();
355        }
356        if let Ok(w) = env::var("PACKSET_WORKSPACE") {
357            if !w.is_empty() {
358                return w;
359            }
360        }
361        let cwd = env::var("GROKOS_WORKSPACE")
362            .ok()
363            .map(|s| s.trim().to_string())
364            .filter(|s| !s.is_empty())
365            .map(std::path::PathBuf::from)
366            .or_else(|| env::current_dir().ok())
367            .unwrap_or_else(|| std::path::PathBuf::from("."));
368        self.workspace_for_cwd(&cwd)
369    }
370
371    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
372    pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
373        let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
374        let url = format!("{}/v1/identity", self.base);
375        let body = send(
376            authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
377            None,
378            timeout(),
379        )
380        .ok()
381        .and_then(|r| r.into_string().ok());
382        if let Some(body) = body {
383            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
384                if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
385                    if !ws.is_empty() {
386                        return ws.to_string();
387                    }
388                }
389            }
390        }
391        format!("dir:{}", abs.display())
392    }
393
394    pub fn health(&self) -> Result<String, Error> {
395        let url = format!("{}/health", self.base);
396        let body = send(authorize(ureq::get(&url)), None, timeout())
397            .map_err(|e| refused(&url, e))?
398            .into_string()?;
399        Ok(body)
400    }
401
402    pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
403        let encoded = path_seg(id);
404        let url = format!("{}/v1/atoms/{encoded}", self.base);
405        let resp = match send(
406            authorize(ureq::get(&url)).query("workspace", workspace),
407            None,
408            timeout(),
409        ) {
410            Ok(resp) => resp,
411            Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
412                return Err(Error::Bad(format!("no atom {id}")));
413            }
414            Err(e) => return Err(Error::Http(e)),
415        };
416        Ok(resp.into_json()?)
417    }
418
419    pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
420        self.atoms_as_of(workspace, None)
421    }
422
423    /// Live-now atoms, or the ones that were live at `as_of`.
424    ///
425    /// # Errors
426    ///
427    /// The request's, or a body that is not JSON.
428    pub fn atoms_as_of(
429        &self,
430        workspace: &str,
431        as_of: Option<&str>,
432    ) -> Result<Vec<serde_json::Value>, Error> {
433        let url = format!("{}/v1/atoms", self.base);
434        let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
435        if let Some(at) = as_of {
436            req = req.query("as_of", at);
437        }
438        let mut body: serde_json::Value = send(req, None, timeout())
439            .map_err(|e| refused(&url, e))?
440            .into_json()?;
441        let atoms = body
442            .get_mut("atoms")
443            .map(serde_json::Value::take)
444            .unwrap_or(serde_json::Value::Array(vec![]));
445        Ok(serde_json::from_value(atoms)?)
446    }
447
448    /// [`Self::atoms_as_of`] without the dense vectors, for a reader of
449    /// texts, review clocks or rules: the vectors are most of the bytes. A
450    /// writer older than the `embedding=omit` query sends them anyway.
451    ///
452    /// # Errors
453    ///
454    /// The pack not answering, or an answer that is not atoms.
455    pub fn atoms_without_vectors(
456        &self,
457        workspace: &str,
458        as_of: Option<&str>,
459    ) -> Result<Vec<serde_json::Value>, Error> {
460        let url = format!("{}/v1/atoms", self.base);
461        let mut req = authorize(ureq::get(&url))
462            .query("workspace", workspace)
463            .query("embedding", "omit");
464        if let Some(at) = as_of {
465            req = req.query("as_of", at);
466        }
467        let mut body: serde_json::Value = send(req, None, timeout())
468            .map_err(|e| refused(&url, e))?
469            .into_json()?;
470        let atoms = body
471            .get_mut("atoms")
472            .map(serde_json::Value::take)
473            .unwrap_or(serde_json::Value::Array(vec![]));
474        Ok(serde_json::from_value(atoms)?)
475    }
476
477    pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
478        self.search_as_of(workspace, q, limit, None)
479    }
480
481    /// Ranked hits, optionally over the atoms that were live at `as_of`.
482    ///
483    /// # Errors
484    ///
485    /// The request's, or a body that is not JSON.
486    pub fn search_as_of(
487        &self,
488        workspace: &str,
489        q: &str,
490        limit: u32,
491        as_of: Option<&str>,
492    ) -> Result<Vec<Hit>, Error> {
493        self.search_opts(workspace, q, limit, as_of, false)
494    }
495
496    /// Ranked hits, optionally dated and optionally through the measured
497    /// cross-encoder stage.
498    ///
499    /// Off by default. On, the writer spends a forward pass per candidate and
500    /// the request waits for that rather than the usual five-second budget.
501    ///
502    /// # Errors
503    ///
504    /// The request's, or a body that is not a hit list.
505    pub fn search_opts(
506        &self,
507        workspace: &str,
508        q: &str,
509        limit: u32,
510        as_of: Option<&str>,
511        rerank: bool,
512    ) -> Result<Vec<Hit>, Error> {
513        let url = format!("{}/v1/search", self.base);
514        let budget = if rerank {
515            Duration::from_secs(60).max(timeout())
516        } else {
517            timeout()
518        };
519        let mut req = authorize(ureq::get(&url))
520            .query("workspace", workspace)
521            .query("q", q)
522            .query("limit", &limit.to_string());
523        if let Some(at) = as_of {
524            req = req.query("as_of", at);
525        }
526        if rerank {
527            req = req.query("rerank", "1");
528        }
529        let body: serde_json::Value = send(req, None, budget)
530            .map_err(|e| refused(&url, e))?
531            .into_json()?;
532        let hits = body
533            .get("hits")
534            .cloned()
535            .unwrap_or(serde_json::Value::Array(vec![]));
536        Ok(serde_json::from_value(hits)?)
537    }
538
539    /// Seat home, atom counts by kind, pin, index and embedder.
540    ///
541    /// # Errors
542    ///
543    /// The request's, or a body that is not JSON.
544    pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
545        let url = format!("{}/v1/status", self.base);
546        let mut req = authorize(ureq::get(&url));
547        if let Some(workspace) = workspace {
548            req = req.query("workspace", workspace);
549        }
550        Ok(send(req, None, timeout())
551            .map_err(|e| refused(&url, e))?
552            .into_json()?)
553    }
554
555    /// The set a workspace is pinned to.
556    ///
557    /// # Errors
558    ///
559    /// The request's, or a body that is not JSON.
560    pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
561        let url = format!("{}/v1/pin", self.base);
562        Ok(send(
563            authorize(ureq::get(&url)).query("workspace", workspace),
564            None,
565            timeout(),
566        )
567        .map_err(|e| refused(&url, e))?
568        .into_json()?)
569    }
570
571    /// Pin a workspace to a set.
572    ///
573    /// # Errors
574    ///
575    /// The request's, or a body that is not JSON.
576    pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
577        let url = format!("{}/v1/pin", self.base);
578        Ok(send(
579            authorize(ureq::put(&url)),
580            Some(&serde_json::json!({ "workspace": workspace, "name": name })),
581            timeout(),
582        )
583        .map_err(|e| refused(&url, e))?
584        .into_json()?)
585    }
586
587    /// The deed accessions a workspace's live atoms cite, sorted.
588    ///
589    /// The accession is the only identifier crossing the tracker, the pack and
590    /// the deed store, so this is what `deedar evidence -` reads.
591    ///
592    /// # Errors
593    ///
594    /// The request's, or a body that is not JSON.
595    pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
596        let url = format!("{}/v1/accessions", self.base);
597        let body: serde_json::Value = send(
598            authorize(ureq::get(&url)).query("workspace", workspace),
599            None,
600            timeout(),
601        )
602        .map_err(|e| refused(&url, e))?
603        .into_json()?;
604        let found = body
605            .get("accessions")
606            .cloned()
607            .unwrap_or(serde_json::Value::Array(vec![]));
608        Ok(serde_json::from_value(found)?)
609    }
610    /// Every live atom in a workspace.
611    ///
612    /// The bodies, not the join keys: this is what a handover carries when
613    /// somebody is given what the seat learned rather than only what it cites.
614    ///
615    /// # Errors
616    ///
617    /// The request's, or a body that is not JSON.
618    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
619        self.atoms_as_of(workspace, None)
620    }
621
622    /// The live atoms of one kind in a workspace: personas, trust rows,
623    /// habits, without the rest of the pack.
624    ///
625    /// # Errors
626    ///
627    /// The request's, or a body that is not JSON.
628    pub fn atoms_of_kind(
629        &self,
630        workspace: &str,
631        kind: &str,
632    ) -> Result<Vec<serde_json::Value>, Error> {
633        let url = format!("{}/v1/atoms", self.base);
634        let body: serde_json::Value = send(
635            authorize(ureq::get(&url))
636                .query("workspace", workspace)
637                .query("kind", kind),
638            None,
639            timeout(),
640        )
641        .map_err(|e| refused(&url, e))?
642        .into_json()?;
643        Ok(body
644            .get("atoms")
645            .and_then(serde_json::Value::as_array)
646            .cloned()
647            .unwrap_or_default())
648    }
649
650    /// The live atoms in a workspace that cite one deed accession.
651    ///
652    /// # Errors
653    ///
654    /// The request's, or a body that is not JSON.
655    pub fn citers(
656        &self,
657        workspace: &str,
658        accession: &str,
659    ) -> Result<Vec<serde_json::Value>, Error> {
660        let url = format!("{}/v1/citers", self.base);
661        let body: serde_json::Value = send(
662            authorize(ureq::get(&url))
663                .query("workspace", workspace)
664                .query("accession", accession),
665            None,
666            timeout(),
667        )
668        .map_err(|e| refused(&url, e))?
669        .into_json()?;
670        let found = body
671            .get("atoms")
672            .cloned()
673            .unwrap_or(serde_json::Value::Array(vec![]));
674        Ok(serde_json::from_value(found)?)
675    }
676
677    /// Tombstone one atom. The daemon keeps the record and drops the index
678    /// entry, so a forgotten atom stops being recalled without the pack losing
679    /// the fact that it once held it.
680    ///
681    /// `why` is the deed accession that withdrew the claim, and the daemon
682    /// refuses one that is not an accession. It rides onto the tombstone beside
683    /// the text, so the retraction and what it retracted read back together.
684    ///
685    /// # Errors
686    ///
687    /// [`Error::Bad`] when the workspace does not hold that atom, else the
688    /// request's or a body that is not JSON.
689    pub fn delete_atom(
690        &self,
691        workspace: &str,
692        id: &str,
693        why: Option<&str>,
694    ) -> Result<serde_json::Value, Error> {
695        let url = format!("{}/v1/atoms/delete", self.base);
696        let mut body = serde_json::json!({
697            "workspace": workspace,
698            "id": id,
699        });
700        if let Some(accession) = why {
701            body["why"] = serde_json::Value::String(accession.to_string());
702        }
703        let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
704            Ok(resp) => resp,
705            Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
706                return Err(Error::Bad(format!("no atom {id}")));
707            }
708            Err(e) => return Err(refused(&url, e)),
709        };
710        Ok(resp.into_json()?)
711    }
712
713    /// Move one atom along the review clock: recalled, or lapsed.
714    pub fn grade(
715        &self,
716        workspace: &str,
717        id: &str,
718        recalled: bool,
719    ) -> Result<serde_json::Value, Error> {
720        let url = format!("{}/v1/grade", self.base);
721        let body: serde_json::Value = send(
722            authorize(ureq::post(&url)),
723            Some(&serde_json::json!({
724                "workspace": workspace,
725                "id": id,
726                "recalled": recalled,
727            })),
728            timeout(),
729        )
730        .map_err(|e| refused(&url, e))?
731        .into_json()?;
732        Ok(body)
733    }
734
735    /// The link graph's communities, largest first.
736    /// The claims the link graph turns on, highest first.
737    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
738        let url = format!("{}/v1/hubs", self.base);
739        let body: serde_json::Value = send(
740            authorize(ureq::get(&url))
741                .query("workspace", workspace)
742                .query("limit", &limit.to_string()),
743            None,
744            timeout(),
745        )
746        .map_err(|e| refused(&url, e))?
747        .into_json()?;
748        Ok(body)
749    }
750
751    pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
752        let url = format!("{}/v1/islands", self.base);
753        let body: serde_json::Value = send(
754            authorize(ureq::get(&url)).query("workspace", workspace),
755            None,
756            timeout(),
757        )
758        .map_err(|e| refused(&url, e))?
759        .into_json()?;
760        Ok(body)
761    }
762
763    /// Claims that fired together: their links gain weight.
764    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
765        self.fire_as(workspace, ids, None)
766    }
767
768    /// [`Self::fire`] through a persona's lens: the weights move under its
769    /// name and the seat's stand.
770    ///
771    /// # Errors
772    ///
773    /// The request's, or a body that is not JSON.
774    pub fn fire_as(
775        &self,
776        workspace: &str,
777        ids: &[String],
778        lens: Option<&str>,
779    ) -> Result<serde_json::Value, Error> {
780        let url = format!("{}/v1/fire", self.base);
781        let body: serde_json::Value = send(
782            authorize(ureq::post(&url)),
783            Some(
784                &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
785            ),
786            timeout(),
787        )
788        .map_err(|e| refused(&url, e))?
789        .into_json()?;
790        Ok(body)
791    }
792
793    /// Consolidate the workspace: every claim that replaces an earlier one
794    /// closes it (the write-time rule, run over what is held). `apply`
795    /// false reports the pairs and writes nothing.
796    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
797        let url = format!("{}/v1/consolidate", self.base);
798        let body: serde_json::Value = send(
799            authorize(ureq::post(&url)),
800            Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
801            timeout(),
802        )
803        .map_err(|e| refused(&url, e))?
804        .into_json()?;
805        Ok(body)
806    }
807
808    /// Sweep a workspace for neglect: reviews left due past twice their
809    /// interval lapse, and the third miss forgets a never-recalled lesson.
810    ///
811    /// # Errors
812    ///
813    /// The request's, or a body that is not JSON.
814    pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
815        let url = format!("{}/v1/sweep", self.base);
816        let body: serde_json::Value = send(
817            authorize(ureq::post(&url)),
818            Some(&serde_json::json!({"workspace": workspace})),
819            timeout(),
820        )
821        .map_err(|e| refused(&url, e))?
822        .into_json()?;
823        Ok(body)
824    }
825
826    /// Drop a scratch workspace's atoms whole, no tombstones and no deed.
827    /// Retraction stays [`Self::delete_atom`]; this is what a per-run
828    /// scratch workspace calls on its way out.
829    ///
830    /// # Errors
831    ///
832    /// The request's, or a body that is not JSON.
833    pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
834        let url = format!("{}/v1/forget", self.base);
835        let body: serde_json::Value = send(
836            authorize(ureq::post(&url)),
837            Some(&serde_json::json!({"workspace": workspace})),
838            timeout(),
839        )
840        .map_err(|e| refused(&url, e))?
841        .into_json()?;
842        Ok(body)
843    }
844
845    /// The memories a cue activates, strongest first; with `fire`, the top
846    /// of them fire together.
847    pub fn activate(
848        &self,
849        workspace: &str,
850        q: &str,
851        limit: u32,
852        fire: bool,
853    ) -> Result<serde_json::Value, Error> {
854        self.activate_as(workspace, q, limit, fire, None)
855    }
856
857    /// [`Self::activate`] through a persona's lens: the spread follows the
858    /// weights that persona wrote, and a fire writes them.
859    ///
860    /// # Errors
861    ///
862    /// The request's, or a body that is not JSON.
863    pub fn activate_as(
864        &self,
865        workspace: &str,
866        q: &str,
867        limit: u32,
868        fire: bool,
869        lens: Option<&str>,
870    ) -> Result<serde_json::Value, Error> {
871        let url = format!("{}/v1/activate", self.base);
872        let mut req = authorize(ureq::get(&url))
873            .query("workspace", workspace)
874            .query("q", q)
875            .query("limit", &limit.to_string())
876            .query("fire", if fire { "1" } else { "0" });
877        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
878            req = req.query("as", name);
879        }
880        let body: serde_json::Value = send(req, None, timeout())
881            .map_err(|e| refused(&url, e))?
882            .into_json()?;
883        Ok(body)
884    }
885
886    /// The live atoms of one named set in a workspace: a persona's own
887    /// conclusions, a pinned slice.
888    ///
889    /// # Errors
890    ///
891    /// The request's, or a body that is not JSON.
892    pub fn atoms_in_set(
893        &self,
894        workspace: &str,
895        set: &str,
896    ) -> Result<Vec<serde_json::Value>, Error> {
897        let url = format!("{}/v1/pack", self.base);
898        let body: serde_json::Value = send(
899            authorize(ureq::get(&url))
900                .query("workspace", workspace)
901                .query("set", set),
902            None,
903            timeout(),
904        )
905        .map_err(|e| refused(&url, e))?
906        .into_json()?;
907        Ok(body
908            .get("atoms")
909            .and_then(serde_json::Value::as_array)
910            .cloned()
911            .unwrap_or_default())
912    }
913
914    pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
915        let url = format!("{}/v1/atoms", self.base);
916        let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
917            .map_err(|e| refused(&url, e))?
918            .into_json()?;
919        Ok(body)
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    // Both workspace tests below rewrite process-global environment, so
928    // they hold this lock: without it the runner's parallel threads
929    // interleave one test's set-and-restore with the other's read, and
930    // the suite fails one run in five on a polluted read.
931    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
932
933    #[test]
934    fn only_a_loopback_url_has_a_port_to_check() {
935        assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
936        assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
937        assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
938        assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
939        assert_eq!(loopback_port("http://example.org:8761/"), None);
940        assert_eq!(loopback_port("http://[::1]/"), Some(80));
941    }
942
943    #[test]
944    fn the_owner_of_a_listening_port_is_read_off_the_table() {
945        let table = "  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
946   0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1001        0 4242 1 0 100 0 0 10 0
947   1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000  1000        0 4343 1 0 20 4 30 10 -1
948   2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 4444 1 0 100 0 0 10 0
949";
950        assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
951        assert_eq!(listen_uids(table, 8080), vec![1000]);
952        assert!(listen_uids(table, 9).is_empty());
953    }
954
955    #[cfg(target_os = "linux")]
956    #[test]
957    fn a_port_this_user_holds_is_not_foreign() {
958        let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
959        let port = held.local_addr().unwrap().port();
960        assert!(!foreign_listener(&format!(
961            "http://127.0.0.1:{port}/health"
962        )));
963        drop(held);
964        assert!(
965            !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
966            "nobody listening is not foreign"
967        );
968    }
969
970    #[test]
971    fn resolved_workspace_reads_ljos_env_not_default() {
972        let _env = ENV_LOCK.lock().unwrap();
973        let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
974        std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
975        std::fs::write(
976            dir.join(".config/ljos/env"),
977            "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
978        )
979        .unwrap();
980        let old_home = env::var("HOME").ok();
981        let old_ws = env::var("PACKSET_WORKSPACE").ok();
982        unsafe {
983            env::remove_var("PACKSET_WORKSPACE");
984            env::set_var("HOME", &dir);
985        }
986        let got = resolved_workspace();
987        unsafe {
988            match old_home {
989                Some(h) => env::set_var("HOME", h),
990                None => env::remove_var("HOME"),
991            }
992            match old_ws {
993                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
994                None => env::remove_var("PACKSET_WORKSPACE"),
995            }
996        }
997        assert_eq!(got, "git:example.com/seat/notes");
998    }
999
1000    #[test]
1001    fn resolved_workspace_without_env_is_seat_not_default() {
1002        let _env = ENV_LOCK.lock().unwrap();
1003        let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1004        std::fs::create_dir_all(&dir).unwrap();
1005        let old_home = env::var("HOME").ok();
1006        let old_ws = env::var("PACKSET_WORKSPACE").ok();
1007        unsafe {
1008            env::remove_var("PACKSET_WORKSPACE");
1009            env::set_var("HOME", &dir);
1010        }
1011        let got = resolved_workspace();
1012        unsafe {
1013            match old_home {
1014                Some(h) => env::set_var("HOME", h),
1015                None => env::remove_var("HOME"),
1016            }
1017            match old_ws {
1018                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1019                None => env::remove_var("PACKSET_WORKSPACE"),
1020            }
1021        }
1022        assert_eq!(got, "seat");
1023        assert_ne!(got, "default");
1024    }
1025
1026    /// A writer that answers busy to its first `busy` requests and counts every
1027    /// request.
1028    fn busy_for(
1029        busy: usize,
1030    ) -> (
1031        PacksetClient,
1032        std::sync::Arc<std::sync::atomic::AtomicUsize>,
1033    ) {
1034        use std::io::{BufRead, BufReader, Read, Write};
1035        use std::sync::atomic::Ordering;
1036        let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1037        let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1038        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1039        let counted = std::sync::Arc::clone(&seen);
1040        std::thread::spawn(move || {
1041            for stream in listener.incoming().flatten() {
1042                let mut reader = BufReader::new(&stream);
1043                let mut length = 0;
1044                let mut line = String::new();
1045                while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1046                    if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1047                        length = v.trim().parse().unwrap_or(0);
1048                    }
1049                    line.clear();
1050                }
1051                let mut body = vec![0u8; length];
1052                let _ = reader.read_exact(&mut body);
1053                let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1054                    ("503 Service Unavailable", r#"{"error":"busy"}"#)
1055                } else {
1056                    ("200 OK", r#"{"ok":true}"#)
1057                };
1058                let _ = (&stream).write_all(
1059                    format!(
1060                        "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1061                        text.len()
1062                    )
1063                    .as_bytes(),
1064                );
1065            }
1066        });
1067        (client, seen)
1068    }
1069
1070    #[test]
1071    fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1072        use std::sync::atomic::Ordering;
1073        let _env = ENV_LOCK.lock().unwrap();
1074        let (client, seen) = busy_for(2);
1075        assert_eq!(client.status(None).unwrap()["ok"], true);
1076        assert_eq!(seen.load(Ordering::SeqCst), 3);
1077        let (client, seen) = busy_for(1);
1078        let stored = client
1079            .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1080            .unwrap();
1081        assert_eq!(stored["ok"], true);
1082        assert_eq!(
1083            seen.load(Ordering::SeqCst),
1084            2,
1085            "the write was not sent again"
1086        );
1087    }
1088
1089    #[test]
1090    fn a_writer_busy_past_three_retries_is_a_refusal() {
1091        use std::sync::atomic::Ordering;
1092        let _env = ENV_LOCK.lock().unwrap();
1093        let (client, seen) = busy_for(usize::MAX);
1094        let err = client.status(None).unwrap_err().to_string();
1095        assert!(err.contains("503") && err.contains("busy"), "{err}");
1096        assert_eq!(seen.load(Ordering::SeqCst), 4);
1097    }
1098
1099    /// A 100 ms budget leaves no room for the 150 ms wait.
1100    #[test]
1101    fn the_retries_stop_where_the_budget_does() {
1102        use std::sync::atomic::Ordering;
1103        let _env = ENV_LOCK.lock().unwrap();
1104        let old = env::var_os("PACKSET_TIMEOUT_MS");
1105        unsafe { env::set_var("PACKSET_TIMEOUT_MS", "100") };
1106        let (client, seen) = busy_for(usize::MAX);
1107        let started = Instant::now();
1108        let answered = client.status(None);
1109        let took = started.elapsed();
1110        unsafe {
1111            match old {
1112                Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1113                None => env::remove_var("PACKSET_TIMEOUT_MS"),
1114            }
1115        }
1116        assert!(answered.is_err());
1117        assert_eq!(seen.load(Ordering::SeqCst), 2);
1118        assert!(took < Duration::from_millis(300), "{took:?}");
1119    }
1120}