1use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::Mutex;
28
29use crate::quoted_yes::Yes;
30use crate::PacksetClient;
31use anyhow::{bail, Context, Result};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35pub const ORIGIN_USER: &str = "user-declared";
36pub const ORIGIN_AGENT: &str = "agent-derived";
37pub const ORIGIN_TOOL: &str = "tool-echo";
38pub const ORIGIN_PEER: &str = "peer";
39
40pub const LEVEL_L0: &str = "derived";
42pub const LEVEL_EXPLICIT: &str = "explicit";
44
45#[must_use]
50pub fn admission_level(origin: &str) -> &'static str {
51 match origin {
52 ORIGIN_USER => LEVEL_EXPLICIT,
53 _ => LEVEL_L0,
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Filed {
59 pub id: String,
60 pub remote: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64struct Record {
65 id: String,
66 status: String,
67 remote: bool,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 remote_id: Option<String>,
70 text: String,
71 kind: String,
72 #[serde(default, skip_serializing_if = "String::is_empty")]
74 origin: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 grant: Option<Yes>,
79 atom: Value,
80}
81
82fn gate() -> std::sync::MutexGuard<'static, ()> {
83 static GATE: Mutex<()> = Mutex::new(());
84 GATE.lock().unwrap_or_else(|e| e.into_inner())
85}
86
87fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
88 static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
89 KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
90}
91
92fn proposal_cache() -> &'static Mutex<BTreeMap<String, bool>> {
93 static MODE: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
94 MODE.get_or_init(|| Mutex::new(BTreeMap::new()))
95}
96
97fn proposals_path() -> PathBuf {
98 std::env::var_os("XDG_STATE_HOME")
99 .map(PathBuf::from)
100 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
101 .unwrap_or_else(|| PathBuf::from(".local/state"))
102 .join("ljos")
103 .join("proposals.jsonl")
104}
105
106fn http_timeout() -> std::time::Duration {
107 std::env::var("PACKSET_TIMEOUT_MS")
108 .ok()
109 .and_then(|v| v.trim().parse::<u64>().ok())
110 .filter(|ms| *ms > 0)
111 .map_or(
112 std::time::Duration::from_secs(30),
113 std::time::Duration::from_millis,
114 )
115}
116
117pub fn stamp_origin(atom: &mut Value, origin: &str) {
118 if let Some(map) = atom.as_object_mut() {
119 map.insert("origin".into(), Value::String(origin.to_string()));
120 }
121}
122
123pub fn stamp_person(atom: &mut Value) {
127 stamp_origin(atom, ORIGIN_USER);
128 atom["source"]["basis"] = Value::String(crate::Basis::Person.as_str().into());
129}
130
131fn strip_origin(atom: &mut Value) {
132 if let Some(map) = atom.as_object_mut() {
133 map.remove("origin");
134 }
135}
136
137fn origin_rejected(text: &str) -> bool {
138 let lower = text.to_lowercase();
139 lower.contains("origin")
140 && (lower.contains("unknown")
141 || lower.contains("unexpected")
142 || lower.contains("not a field")
143 || lower.contains("unrecognized"))
144}
145
146pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
149 let base = client.base().to_string();
150 let mut body = atom.clone();
151 let known = origin_cache()
152 .lock()
153 .unwrap_or_else(|e| e.into_inner())
154 .get(&base)
155 .copied();
156 if known == Some(false) {
157 strip_origin(&mut body);
158 }
159 match client.post_atom(&body) {
160 Ok(v) => {
161 if body.get("origin").is_some() {
162 origin_cache()
163 .lock()
164 .unwrap_or_else(|e| e.into_inner())
165 .insert(base, true);
166 }
167 Ok(v)
168 }
169 Err(e) => {
170 let text = e.to_string();
171 if body.get("origin").is_some() && origin_rejected(&text) {
172 origin_cache()
173 .lock()
174 .unwrap_or_else(|err| err.into_inner())
175 .insert(base, false);
176 strip_origin(&mut body);
177 return client.post_atom(&body).map_err(anyhow::Error::from);
178 }
179 Err(anyhow::Error::from(e))
180 }
181 }
182}
183
184fn read_latest() -> BTreeMap<String, Record> {
185 let bytes = crate::read_state_file(&proposals_path()).unwrap_or_default();
186 crate::complete_rows::<Record>(&bytes)
187 .into_iter()
188 .map(|rec| (rec.id.clone(), rec))
189 .collect()
190}
191
192fn append(rec: &Record) -> Result<()> {
193 let path = proposals_path();
194 if let Some(dir) = path.parent() {
195 std::fs::create_dir_all(dir)?;
196 }
197 let mut line = serde_json::to_string(rec)?;
198 line.push('\n');
199 crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
200 .with_context(|| format!("admit: cannot write {}", path.display()))
201}
202
203fn proposal_is_local(base: &str) -> bool {
204 proposal_cache()
205 .lock()
206 .unwrap_or_else(|e| e.into_inner())
207 .get(base)
208 .copied()
209 == Some(false)
210}
211
212fn remember_proposal_mode(base: &str, remote: bool) {
213 proposal_cache()
214 .lock()
215 .unwrap_or_else(|e| e.into_inner())
216 .insert(base.to_string(), remote);
217}
218
219fn held_proposal_id(text: &str) -> Option<String> {
221 let marker = "held as proposal ";
222 let rest = text.get(text.find(marker)? + marker.len()..)?;
223 let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
224 (id.len() == 32).then_some(id)
225}
226
227fn filing_is_local(text: &str) -> bool {
229 let lower = text.to_lowercase();
230 origin_rejected(text)
231 || lower.contains(": 404:")
232 || lower.contains("status code 404")
233 || (lower.contains("403") && lower.contains("not allowed"))
234}
235
236fn post_direct(client: &PacksetClient, atom: &Value) -> Result<Option<String>> {
238 let base = client.base();
239 if proposal_is_local(base) {
240 return Ok(None);
241 }
242 match client.post_atom(atom) {
243 Ok(_) => {
244 remember_proposal_mode(base, false);
246 Ok(None)
247 }
248 Err(e) => {
249 let text = e.to_string();
250 if let Some(id) = held_proposal_id(&text) {
251 remember_proposal_mode(base, true);
252 return Ok(Some(id));
253 }
254 if filing_is_local(&text) {
255 remember_proposal_mode(base, false);
256 return Ok(None);
257 }
258 if !text.contains("bad response") {
261 return Ok(None);
262 }
263 bail!("propose: POST /v1/atoms failed: {text}");
264 }
265 }
266}
267
268pub fn write_agent(client: &PacksetClient, atom: Value) -> Result<Value> {
283 let text = atom["text"].as_str().unwrap_or("").trim().to_string();
284 if text.is_empty() {
285 bail!("remember: empty text is not a claim");
286 }
287 if atom["kind"].as_str() == Some("lesson") && past_hard_cap(&text) {
288 let copies = crate::pack_copy::pack_copies(&text);
289 let unchanged = copies.is_empty() || (copies.len() == 1 && copies[0] == text);
290 if !unchanged {
291 let mut last = None;
292 let mut first_err = None;
293 for copy in &copies {
294 let mut one = atom.clone();
295 one["text"] = Value::String(copy.clone());
296 match write_agent_one(client, one) {
297 Ok(posted) => last = Some(posted),
298 Err(e) if crate::note_can_wait(&e) => return Err(e),
299 Err(e) => {
300 if first_err.is_none() {
301 first_err = Some(e);
302 }
303 }
304 }
305 }
306 if let Some(posted) = last {
307 return Ok(posted);
308 }
309 return Err(
310 first_err.unwrap_or_else(|| anyhow::anyhow!("remember: empty text is not a claim"))
311 );
312 }
313 }
314 write_agent_one(client, atom)
315}
316
317fn past_hard_cap(text: &str) -> bool {
320 packset_core::prose::sentences(text).len() > packset_core::prose::MAX_ATOM_SENTENCES
321 || text.chars().count() > packset_core::record::TEXT_SOFT_CAP
322}
323
324pub(crate) fn write_agent_one(client: &PacksetClient, mut atom: Value) -> Result<Value> {
331 let text = atom["text"].as_str().unwrap_or("").trim();
332 if text.is_empty() {
333 bail!("remember: empty text is not a claim");
334 }
335 stamp_origin(&mut atom, ORIGIN_AGENT);
336 atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
337 match post_kept(client, &atom) {
338 Ok(posted) => Ok(posted),
339 Err(e) => {
340 let text = format!("{e:#}");
341 let Some(id) = held_proposal_id(&text) else {
342 return Err(e);
343 };
344 let workspace = atom
345 .get("workspace")
346 .and_then(Value::as_str)
347 .filter(|w| !w.is_empty())
348 .map_or_else(|| client.workspace(), str::to_string);
349 let accepted = accept_on_writer(client, &workspace, &id)?;
350 relabel_agent(client, &workspace, accepted)
354 }
355 }
356}
357
358pub fn propose_atom(client: &PacksetClient, atom: Value) -> Result<Filed> {
364 file_proposal(client, atom, true)
365}
366
367pub fn propose_admission(client: &PacksetClient, atom: Value) -> Result<Filed> {
375 file_proposal(client, atom, false)
376}
377
378fn file_proposal(client: &PacksetClient, mut atom: Value, post: bool) -> Result<Filed> {
379 let _g = gate();
380 let text = atom["text"].as_str().unwrap_or("").trim().to_string();
381 let kind = atom["kind"].as_str().unwrap_or("lesson").to_string();
382 if text.is_empty() {
383 bail!("propose: empty text is not a proposal");
384 }
385 if atom
386 .get("workspace")
387 .and_then(Value::as_str)
388 .unwrap_or("")
389 .is_empty()
390 {
391 if let Some(map) = atom.as_object_mut() {
392 map.insert("workspace".into(), Value::String(client.workspace()));
393 }
394 }
395 if post {
396 stamp_origin(&mut atom, ORIGIN_AGENT);
397 } else {
398 let named = atom["origin"].as_str().unwrap_or("");
399 if !packset_core::atom::known_origin(named) {
400 stamp_origin(&mut atom, ORIGIN_AGENT);
401 }
402 let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
403 if let Some(map) = atom.as_object_mut() {
404 map.insert(
405 "level".into(),
406 Value::String(admission_level(&origin).to_string()),
407 );
408 }
409 }
410 let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
411 let id = crate::work_id(&format!("{kind}\n{text}"));
412 if let Some(existing) = read_latest().get(&id) {
413 if matches!(existing.status.as_str(), "open" | "accepted" | "satisfied") {
414 return Ok(Filed {
415 id,
416 remote: existing.remote,
417 });
418 }
419 }
420 let remote_id = if post {
421 post_direct(client, &atom)?
422 } else {
423 None
424 };
425 let rec = Record {
426 id: id.clone(),
427 status: "open".into(),
428 remote: remote_id.is_some(),
429 remote_id,
430 text,
431 kind,
432 origin,
433 grant: None,
434 atom,
435 };
436 let remote = rec.remote;
437 append(&rec)?;
438 Ok(Filed { id, remote })
439}
440
441fn promote_level(atom: &mut Value) {
443 if let Some(map) = atom.as_object_mut() {
444 map.insert("level".into(), Value::String(LEVEL_EXPLICIT.to_string()));
445 }
446}
447
448pub fn satisfy_text(text: &str) {
451 let _g = gate();
452 let text = text.trim();
453 if text.is_empty() {
454 return;
455 }
456 let open: Vec<Record> = read_latest()
457 .into_values()
458 .filter(|rec| rec.status == "open" && rec.text.trim() == text)
459 .collect();
460 for mut rec in open {
461 rec.status = "satisfied".into();
462 if let Err(e) = append(&rec) {
466 eprintln!(
467 "ljos: a satisfied proposal was not recorded in {}: {e:#}",
468 proposals_path().display()
469 );
470 }
471 close_remote(&rec);
472 }
473}
474
475fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
476 let workspace = rec
477 .atom
478 .get("workspace")
479 .and_then(Value::as_str)
480 .unwrap_or("");
481 if workspace.is_empty() {
482 client.workspace()
483 } else {
484 workspace.to_string()
485 }
486}
487
488fn close_remote(rec: &Record) {
491 if !rec.remote {
492 return;
493 }
494 let Some(remote_id) = rec.remote_id.as_deref() else {
495 return;
496 };
497 let Ok(client) = crate::pack() else {
498 return;
499 };
500 let workspace = workspace_of(&client, rec);
501 let _ = accept_on_writer(&client, &workspace, remote_id);
502}
503
504fn accepted_line(rec: &Record, posted: &Value) -> String {
505 let origin = posted
506 .get("origin")
507 .and_then(Value::as_str)
508 .filter(|origin| !origin.is_empty())
509 .unwrap_or(ORIGIN_AGENT);
510 format!(
511 "accepted {} as {} origin {origin}{}\n{}",
512 rec.id,
513 rec.kind,
514 crate::revision_note(posted),
515 rec.grant.as_ref().map(Yes::line).unwrap_or_default()
516 )
517}
518
519fn relabel_agent(client: &PacksetClient, workspace: &str, accepted: Value) -> Result<Value> {
523 let origin = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
524 if origin == ORIGIN_AGENT {
525 return Ok(accepted);
526 }
527 let Some(id) = accepted
528 .get("id")
529 .and_then(Value::as_str)
530 .filter(|id| !id.is_empty())
531 else {
532 bail!(
533 "accept stored the lesson without an id, so its origin could not be put back to agent-derived"
534 );
535 };
536 let body = serde_json::json!({
537 "workspace": workspace,
538 "id": id,
539 "fields": {"origin": ORIGIN_AGENT},
540 });
541 let bytes = serde_json::to_vec(&body).unwrap_or_default();
542 match client.roundtrip(
543 "POST",
544 "/v1/atoms/update",
545 &[],
546 Some(&bytes),
547 true,
548 http_timeout(),
549 ) {
550 Ok(answer) if (200..300).contains(&answer.status) => serde_json::from_str(&answer.body)
551 .context("update: the pack's answer was not json"),
552 Ok(answer) => bail!(
553 "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {}: {}",
554 answer.status,
555 answer.body
556 ),
557 Err(e) => bail!(
558 "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {e}"
559 ),
560 }
561}
562
563fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
564 let body = serde_json::json!({"workspace": workspace, "id": id});
565 let bytes = serde_json::to_vec(&body).unwrap_or_default();
566 match client.roundtrip(
567 "POST",
568 "/v1/proposals/accept",
569 &[],
570 Some(&bytes),
571 true,
572 http_timeout(),
573 ) {
574 Ok(answer) if (200..300).contains(&answer.status) => {
575 serde_json::from_str(&answer.body).context("accept: the pack's answer was not json")
576 }
577 Ok(answer) => {
578 bail!(
579 "accept: POST /v1/proposals/accept failed: {}: {}",
580 answer.status,
581 answer.body
582 )
583 }
584 Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
585 }
586}
587
588pub fn resolve_id(id: &str) -> Result<String> {
595 let open: Vec<String> = read_latest()
596 .into_values()
597 .filter(|r| r.status == "open")
598 .map(|r| r.id)
599 .collect();
600 crate::resolve_short_id("accept", id, &open)
601}
602
603#[must_use]
607pub fn granted() -> Vec<(String, Yes)> {
608 let mut out: Vec<(String, Yes)> = read_latest()
609 .into_values()
610 .filter(|rec| rec.status == "accepted")
611 .filter_map(|rec| rec.grant.map(|yes| (rec.id, yes)))
612 .collect();
613 out.sort_by(|a, b| a.1.at.cmp(&b.1.at).then_with(|| a.0.cmp(&b.0)));
614 out
615}
616
617#[must_use]
620pub fn proposed_by(id: &str) -> Option<String> {
621 let rec = read_latest().get(id.trim()).cloned()?;
622 rec.atom["source"]["session"]
623 .as_str()
624 .map(str::trim)
625 .filter(|s| !s.is_empty())
626 .map(str::to_string)
627}
628
629pub fn accept(id: &str) -> Result<String> {
632 accept_on(id, None)
633}
634
635pub fn accept_on(id: &str, yes: Option<&crate::quoted_yes::Yes>) -> Result<String> {
642 let _g = gate();
643 let id = id.trim();
644 if id.is_empty() {
645 bail!("accept: a proposal id is required");
646 }
647 let Some(mut rec) = read_latest().get(id).cloned() else {
648 bail!("accept: no open proposal {id}");
649 };
650 rec.grant = yes.cloned();
651 match rec.status.as_str() {
652 "satisfied" => {
653 close_remote(&rec);
654 return Ok(format!("{id} was already written by remember or prefer\n"));
655 }
656 "accepted" => return Ok(format!("{id} is already in the pack\n")),
657 "open" => {}
658 _ => bail!("accept: no open proposal {id}"),
659 }
660 let client = crate::pack()?;
661 if rec.remote {
662 let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
663 let workspace = workspace_of(&client, &rec);
664 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
665 rec.status = "accepted".into();
666 append(&rec)?;
667 return Ok(accepted_line(&rec, &posted));
668 }
669 promote_level(&mut rec.atom);
673 match post_kept(&client, &rec.atom) {
674 Ok(posted) => {
675 rec.status = "accepted".into();
676 append(&rec)?;
677 Ok(accepted_line(&rec, &posted))
678 }
679 Err(e) => {
680 let text = e.to_string();
681 let Some(remote_id) = held_proposal_id(&text) else {
682 return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
683 };
684 let workspace = workspace_of(&client, &rec);
687 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
688 rec.remote = true;
689 rec.remote_id = Some(remote_id);
690 rec.status = "accepted".into();
691 append(&rec)?;
692 Ok(accepted_line(&rec, &posted))
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::os::unix::fs::PermissionsExt;
701
702 fn record(id: String) -> Record {
703 Record {
704 id,
705 status: "proposed".into(),
706 remote: false,
707 remote_id: None,
708 text: "use uv for python envs".into(),
709 kind: "lesson".into(),
710 origin: ORIGIN_AGENT.into(),
711 grant: None,
712 atom: serde_json::json!({"text": "use uv for python envs"}),
713 }
714 }
715
716 #[test]
719 fn concurrent_proposal_rows_each_parse_and_stay_private() {
720 let _g = crate::test_env_lock();
721 let dir = tempfile::tempdir().unwrap();
722 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
723 const THREADS: usize = 16;
724 const EACH: usize = 8;
725 let handles: Vec<_> = (0..THREADS)
726 .map(|thread| {
727 std::thread::spawn(move || {
728 for n in 0..EACH {
729 append(&record(format!("{thread}-{n}"))).unwrap();
730 }
731 })
732 })
733 .collect();
734 for handle in handles {
735 handle.join().expect("proposal writer");
736 }
737 let path = proposals_path();
738 let text = std::fs::read_to_string(&path).unwrap();
739 for line in text.lines() {
740 serde_json::from_str::<Record>(line)
741 .unwrap_or_else(|err| panic!("proposal row does not parse ({err}): {line}"));
742 }
743 assert_eq!(read_latest().len(), THREADS * EACH, "{text}");
744 let mode = std::fs::symlink_metadata(&path)
745 .unwrap()
746 .permissions()
747 .mode()
748 & 0o777;
749 assert_eq!(mode, 0o600, "proposals mode {mode:o}");
750 }
751
752 #[test]
755 fn a_torn_last_proposal_leaves_the_others() {
756 let _g = crate::test_env_lock();
757 let dir = tempfile::tempdir().unwrap();
758 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
759 append(&record("a".into())).unwrap();
760 let path = proposals_path();
761 let mut bytes = std::fs::read(&path).unwrap();
762 bytes.extend_from_slice(b"{\"id\":\"b\",\"text\":\"caf\xc3");
763 std::fs::write(&path, bytes).unwrap();
764 let ids: Vec<String> = read_latest().into_keys().collect();
765 assert_eq!(ids, ["a"]);
766 }
767
768 #[test]
770 fn a_symlinked_proposals_file_is_refused() {
771 let _g = crate::test_env_lock();
772 let dir = tempfile::tempdir().unwrap();
773 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
774 let path = proposals_path();
775 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
776 let outside = dir.path().join("outside");
777 std::fs::write(&outside, "kept\n").unwrap();
778 std::os::unix::fs::symlink(&outside, &path).unwrap();
779 assert!(append(&record("a".into())).is_err());
780 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
781 }
782}