Skip to main content

ljos_cli/
admit.rs

1//! Origin-bound admission.
2//!
3//! An agent lesson is filed by posting the atom. A writer that admits by
4//! origin answers 400 `held as proposal <id>` and keeps that proposal,
5//! including the atom. `POST /v1/proposals/accept` with `{"workspace","id"}`
6//! writes the atom and records the acceptance. The same text held again is
7//! that proposal, so a retry does not leave another one.
8//!
9//! `POST /v1/proposals` is the miner. This path does not call it.
10//!
11//! A 400 that names `origin` as unknown, or a 404, means this writer does
12//! not hold. The proposal stays in `$XDG_STATE_HOME/ljos/proposals.jsonl`
13//! and `ljos accept` posts the atom. A writer that does not answer keeps
14//! the proposal local for this call.
15//!
16//! `origin` on a live atom is `user-declared`, `agent-derived` or `peer`.
17//! A 400 that names `origin` as unknown is retried once without the field.
18
19use std::collections::BTreeMap;
20use std::path::PathBuf;
21use std::sync::Mutex;
22
23use anyhow::{bail, Context, Result};
24use packset_client::PacksetClient;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28pub const ORIGIN_USER: &str = "user-declared";
29pub const ORIGIN_AGENT: &str = "agent-derived";
30pub const ORIGIN_PEER: &str = "peer";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Filed {
34    pub id: String,
35    pub remote: bool,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct Record {
40    id: String,
41    status: String,
42    remote: bool,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    remote_id: Option<String>,
45    text: String,
46    kind: String,
47    atom: Value,
48}
49
50fn gate() -> std::sync::MutexGuard<'static, ()> {
51    static GATE: Mutex<()> = Mutex::new(());
52    GATE.lock().unwrap_or_else(|e| e.into_inner())
53}
54
55fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
56    static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
57    KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
58}
59
60fn proposal_cache() -> &'static Mutex<BTreeMap<String, bool>> {
61    static MODE: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
62    MODE.get_or_init(|| Mutex::new(BTreeMap::new()))
63}
64
65fn proposals_path() -> PathBuf {
66    std::env::var_os("XDG_STATE_HOME")
67        .map(PathBuf::from)
68        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
69        .unwrap_or_else(|| PathBuf::from(".local/state"))
70        .join("ljos")
71        .join("proposals.jsonl")
72}
73
74fn http_timeout() -> std::time::Duration {
75    std::env::var("PACKSET_TIMEOUT_MS")
76        .ok()
77        .and_then(|v| v.trim().parse::<u64>().ok())
78        .filter(|ms| *ms > 0)
79        .map_or(
80            std::time::Duration::from_secs(30),
81            std::time::Duration::from_millis,
82        )
83}
84
85pub fn stamp_origin(atom: &mut Value, origin: &str) {
86    if let Some(map) = atom.as_object_mut() {
87        map.insert("origin".into(), Value::String(origin.to_string()));
88    }
89}
90
91fn strip_origin(atom: &mut Value) {
92    if let Some(map) = atom.as_object_mut() {
93        map.remove("origin");
94    }
95}
96
97fn origin_rejected(text: &str) -> bool {
98    let lower = text.to_lowercase();
99    lower.contains("origin")
100        && (lower.contains("unknown")
101            || lower.contains("unexpected")
102            || lower.contains("not a field")
103            || lower.contains("unrecognized"))
104}
105
106/// POST an atom. When this writer refuses `origin`, the field is dropped
107/// and the post is tried once more. The answer is remembered per base URL.
108pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
109    let base = client.base().to_string();
110    let mut body = atom.clone();
111    let known = origin_cache()
112        .lock()
113        .unwrap_or_else(|e| e.into_inner())
114        .get(&base)
115        .copied();
116    if known == Some(false) {
117        strip_origin(&mut body);
118    }
119    match client.post_atom(&body) {
120        Ok(v) => {
121            if body.get("origin").is_some() {
122                origin_cache()
123                    .lock()
124                    .unwrap_or_else(|e| e.into_inner())
125                    .insert(base, true);
126            }
127            Ok(v)
128        }
129        Err(e) => {
130            let text = e.to_string();
131            if body.get("origin").is_some() && origin_rejected(&text) {
132                origin_cache()
133                    .lock()
134                    .unwrap_or_else(|err| err.into_inner())
135                    .insert(base, false);
136                strip_origin(&mut body);
137                return client.post_atom(&body).map_err(anyhow::Error::from);
138            }
139            Err(anyhow::Error::from(e))
140        }
141    }
142}
143
144fn read_latest() -> BTreeMap<String, Record> {
145    let text = std::fs::read_to_string(proposals_path()).unwrap_or_default();
146    let mut by_id = BTreeMap::new();
147    for line in text.lines() {
148        if let Ok(rec) = serde_json::from_str::<Record>(line) {
149            by_id.insert(rec.id.clone(), rec);
150        }
151    }
152    by_id
153}
154
155fn append(rec: &Record) -> Result<()> {
156    let path = proposals_path();
157    if let Some(dir) = path.parent() {
158        std::fs::create_dir_all(dir)?;
159    }
160    let mut line = serde_json::to_string(rec)?;
161    line.push('\n');
162    use std::io::Write;
163    let mut file = std::fs::OpenOptions::new()
164        .create(true)
165        .append(true)
166        .open(path)?;
167    file.write_all(line.as_bytes())?;
168    Ok(())
169}
170
171fn proposal_is_local(base: &str) -> bool {
172    proposal_cache()
173        .lock()
174        .unwrap_or_else(|e| e.into_inner())
175        .get(base)
176        .copied()
177        == Some(false)
178}
179
180fn remember_proposal_mode(base: &str, remote: bool) {
181    proposal_cache()
182        .lock()
183        .unwrap_or_else(|e| e.into_inner())
184        .insert(base.to_string(), remote);
185}
186
187/// The id in `held as proposal <id>`.
188fn held_proposal_id(text: &str) -> Option<String> {
189    let marker = "held as proposal ";
190    let rest = text.get(text.find(marker)? + marker.len()..)?;
191    let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
192    (id.len() == 32).then_some(id)
193}
194
195/// A writer that will not hold the atom, and did not store it.
196fn filing_is_local(text: &str) -> bool {
197    let lower = text.to_lowercase();
198    origin_rejected(text)
199        || lower.contains(": 404:")
200        || lower.contains("status code 404")
201        || (lower.contains("403") && lower.contains("not allowed"))
202}
203
204/// `Ok(None)` is a writer that does not hold. `Ok(Some(id))` is its proposal id.
205fn post_direct(client: &PacksetClient, atom: &Value) -> Result<Option<String>> {
206    let base = client.base();
207    if proposal_is_local(base) {
208        return Ok(None);
209    }
210    match client.post_atom(atom) {
211        Ok(_) => {
212            // This writer stored the atom. It does not hold proposals.
213            remember_proposal_mode(base, false);
214            Ok(None)
215        }
216        Err(e) => {
217            let text = e.to_string();
218            if let Some(id) = held_proposal_id(&text) {
219                remember_proposal_mode(base, true);
220                return Ok(Some(id));
221            }
222            if filing_is_local(&text) {
223                remember_proposal_mode(base, false);
224                return Ok(None);
225            }
226            // A writer that did not answer keeps this proposal local. The
227            // next filing tries the writer again.
228            if !text.contains("bad response") {
229                return Ok(None);
230            }
231            bail!("propose: POST /v1/atoms failed: {text}");
232        }
233    }
234}
235
236/// Write `atom` as an agent's own memory, live at once.
237///
238/// It carries origin `agent-derived`. A writer from before agent writes
239/// were live answers `held as proposal ID`; that proposal is accepted on
240/// the spot, so the memory is live there too. Nothing waits for a person.
241///
242/// # Errors
243///
244/// An empty text, or the pack refusing the claim for a reason other than
245/// holding it (a secret, the size cap).
246pub fn write_agent(client: &PacksetClient, mut atom: Value) -> Result<Value> {
247    if atom["text"].as_str().unwrap_or("").trim().is_empty() {
248        bail!("remember: empty text is not a claim");
249    }
250    stamp_origin(&mut atom, ORIGIN_AGENT);
251    match post_kept(client, &atom) {
252        Ok(posted) => Ok(posted),
253        Err(e) => {
254            let text = format!("{e:#}");
255            let Some(id) = held_proposal_id(&text) else {
256                return Err(e);
257            };
258            let workspace = atom
259                .get("workspace")
260                .and_then(Value::as_str)
261                .filter(|w| !w.is_empty())
262                .map_or_else(|| client.workspace(), str::to_string);
263            let accepted = accept_on_writer(client, &workspace, &id)?;
264            // Accept stores the atom as user-declared, and `ljos memories
265            // list` then hides it. Put the origin back so the review list
266            // the CLI reads is the store this write just landed in.
267            relabel_agent(client, &workspace, accepted)
268        }
269    }
270}
271
272/// File `atom` as an agent proposal by posting it.
273///
274/// A held answer is the proposal. The id is stable for one kind and one
275/// text, so the same lesson is one open proposal. An open, accepted or
276/// satisfied row is returned as it stands.
277pub fn propose_atom(client: &PacksetClient, mut atom: Value) -> Result<Filed> {
278    let _g = gate();
279    let text = atom["text"].as_str().unwrap_or("").trim().to_string();
280    let kind = atom["kind"].as_str().unwrap_or("lesson").to_string();
281    if text.is_empty() {
282        bail!("propose: empty text is not a proposal");
283    }
284    stamp_origin(&mut atom, ORIGIN_AGENT);
285    let id = crate::work_id(&format!("{kind}\n{text}"));
286    if let Some(existing) = read_latest().get(&id) {
287        if matches!(existing.status.as_str(), "open" | "accepted" | "satisfied") {
288            return Ok(Filed {
289                id,
290                remote: existing.remote,
291            });
292        }
293    }
294    let remote_id = post_direct(client, &atom)?;
295    let rec = Record {
296        id: id.clone(),
297        status: "open".into(),
298        remote: remote_id.is_some(),
299        remote_id,
300        text,
301        kind,
302        atom,
303    };
304    let remote = rec.remote;
305    append(&rec)?;
306    Ok(Filed { id, remote })
307}
308
309/// The person's own text wrote the claim. An open proposal of those words
310/// is satisfied, so a later accept does not post a second atom.
311pub fn satisfy_text(text: &str) {
312    let _g = gate();
313    let text = text.trim();
314    if text.is_empty() {
315        return;
316    }
317    let open: Vec<Record> = read_latest()
318        .into_values()
319        .filter(|rec| rec.status == "open" && rec.text.trim() == text)
320        .collect();
321    for mut rec in open {
322        rec.status = "satisfied".into();
323        let _ = append(&rec);
324        close_remote(&rec);
325    }
326}
327
328fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
329    let workspace = rec
330        .atom
331        .get("workspace")
332        .and_then(Value::as_str)
333        .unwrap_or("");
334    if workspace.is_empty() {
335        client.workspace()
336    } else {
337        workspace.to_string()
338    }
339}
340
341/// Close a held proposal whose text is already a live claim. The writer's
342/// duplicate rule keeps one atom when the text and kind match.
343fn close_remote(rec: &Record) {
344    if !rec.remote {
345        return;
346    }
347    let Some(remote_id) = rec.remote_id.as_deref() else {
348        return;
349    };
350    let Ok(client) = crate::pack() else {
351        return;
352    };
353    let workspace = workspace_of(&client, rec);
354    let _ = accept_on_writer(&client, &workspace, remote_id);
355}
356
357fn accepted_line(id: &str, kind: &str, posted: &Value) -> String {
358    let origin = posted
359        .get("origin")
360        .and_then(Value::as_str)
361        .filter(|origin| !origin.is_empty())
362        .unwrap_or(ORIGIN_AGENT);
363    format!(
364        "accepted {id} as {kind} origin {origin}{}\n",
365        crate::revision_note(posted)
366    )
367}
368
369/// Accept rises the origin to `user-declared`. An agent write is reviewed
370/// afterwards, so the live atom has to stay `agent-derived` or the default
371/// memory list never shows it. One update, on an atom just stored.
372fn relabel_agent(client: &PacksetClient, workspace: &str, accepted: Value) -> Result<Value> {
373    let origin = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
374    if origin == ORIGIN_AGENT {
375        return Ok(accepted);
376    }
377    let Some(id) = accepted
378        .get("id")
379        .and_then(Value::as_str)
380        .filter(|id| !id.is_empty())
381    else {
382        bail!(
383            "accept stored the lesson without an id, so its origin could not be put back to agent-derived"
384        );
385    };
386    let url = format!("{}/v1/atoms/update", client.base());
387    let body = serde_json::json!({
388        "workspace": workspace,
389        "id": id,
390        "fields": {"origin": ORIGIN_AGENT},
391    });
392    match packset_client::authorize(ureq::post(&url))
393        .timeout(http_timeout())
394        .send_json(body)
395    {
396        Ok(resp) => resp
397            .into_json()
398            .context("update: the pack's answer was not json"),
399        Err(ureq::Error::Status(code, resp)) => {
400            let text = resp.into_string().unwrap_or_default();
401            bail!(
402                "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {code}: {text}"
403            );
404        }
405        Err(e) => bail!(
406            "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {e}"
407        ),
408    }
409}
410
411fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
412    let url = format!("{}/v1/proposals/accept", client.base());
413    let body = serde_json::json!({"workspace": workspace, "id": id});
414    match packset_client::authorize(ureq::post(&url))
415        .timeout(http_timeout())
416        .send_json(body)
417    {
418        Ok(resp) => resp
419            .into_json()
420            .context("accept: the pack's answer was not json"),
421        Err(ureq::Error::Status(code, resp)) => {
422            let text = resp.into_string().unwrap_or_default();
423            bail!("accept: POST /v1/proposals/accept failed: {code}: {text}");
424        }
425        Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
426    }
427}
428
429/// The full id of an open proposal a short id names; see
430/// [`crate::resolve_short_id`].
431///
432/// # Errors
433///
434/// The short id starts more than one open proposal.
435pub fn resolve_id(id: &str) -> Result<String> {
436    let open: Vec<String> = read_latest()
437        .into_values()
438        .filter(|r| r.status == "open")
439        .map(|r| r.id)
440        .collect();
441    crate::resolve_short_id("accept", id, &open)
442}
443
444/// The conversation that filed an open proposal: the holder its atom's
445/// source names. `None` when the id is not on file or the atom names none.
446#[must_use]
447pub fn proposed_by(id: &str) -> Option<String> {
448    let rec = read_latest().get(id.trim()).cloned()?;
449    rec.atom["source"]["session"]
450        .as_str()
451        .map(str::trim)
452        .filter(|s| !s.is_empty())
453        .map(str::to_string)
454}
455
456/// Write one open proposal into the pack. A satisfied proposal was already
457/// written by `remember` or `prefer`. An accepted one is already there.
458pub fn accept(id: &str) -> Result<String> {
459    let _g = gate();
460    let id = id.trim();
461    if id.is_empty() {
462        bail!("accept: a proposal id is required");
463    }
464    let Some(mut rec) = read_latest().get(id).cloned() else {
465        bail!("accept: no open proposal {id}");
466    };
467    match rec.status.as_str() {
468        "satisfied" => {
469            close_remote(&rec);
470            return Ok(format!("{id} was already written by remember or prefer\n"));
471        }
472        "accepted" => return Ok(format!("{id} is already in the pack\n")),
473        "open" => {}
474        _ => bail!("accept: no open proposal {id}"),
475    }
476    let client = crate::pack()?;
477    if rec.remote {
478        let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
479        let workspace = workspace_of(&client, &rec);
480        let posted = accept_on_writer(&client, &workspace, &remote_id)?;
481        rec.status = "accepted".into();
482        append(&rec)?;
483        return Ok(accepted_line(&rec.id, &rec.kind, &posted));
484    }
485    match post_kept(&client, &rec.atom) {
486        Ok(posted) => {
487            rec.status = "accepted".into();
488            append(&rec)?;
489            Ok(accepted_line(&rec.id, &rec.kind, &posted))
490        }
491        Err(e) => {
492            let text = e.to_string();
493            let Some(remote_id) = held_proposal_id(&text) else {
494                return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
495            };
496            // Filed while this writer was not holding, or the post is the
497            // filing. Accept that proposal. Do not leave it open.
498            let workspace = workspace_of(&client, &rec);
499            let posted = accept_on_writer(&client, &workspace, &remote_id)?;
500            rec.remote = true;
501            rec.remote_id = Some(remote_id);
502            rec.status = "accepted".into();
503            append(&rec)?;
504            Ok(accepted_line(&rec.id, &rec.kind, &posted))
505        }
506    }
507}