whiskers.git / crates / whiskersd / src / allowance.rs
1//! What Whiskers may spend, kept in two small files beside the other hub state: the thinking ledger
2//! (`tokens.json`) and the day's voice spend (`speak.json`, or `WHISKERS_STATE_FILE`). The rules (the
3//! rolling window, the day turning, reserve and refund) are `whiskers-ports`'; this keeps the structs
4//! behind one mutex, so every call is atomic with respect to every other, and writes them down.
5//!
6//! The thinking ledger is saved when a question is charged. The voice spend is saved when a line is
7//! settled, not when it is reserved: a reservation is a promise about a line that has not been spoken
8//! yet, and a crash between the two forgets it (the spend is then a line short, never a line over).
9
10use std::path::{Path, PathBuf};
11use std::sync::{Mutex, MutexGuard, PoisonError};
12
13use log::{debug, error, info, warn};
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16use whiskers_core::TokenLimit;
17use whiskers_ports::{
18    Allowance, Day, Millis, ReserveError, Settlement, SpeakError, StoreError, ThinkingLedger, ThinkingRoom, ThinkingUsage, Tokens, VoiceDay,
19    VoiceReservation, VoiceSpend,
20};
21
22struct State {
23    thinking: ThinkingLedger,
24    voice: VoiceDay,
25    /// Why the last attempt to speak failed, until one succeeds. Not kept across a restart.
26    last_failure: Option<SpeakError>,
27}
28
29pub struct FileAllowance {
30    tokens_path: Option<PathBuf>,
31    voice_path: Option<PathBuf>,
32    state: Mutex<State>,
33}
34
35/// Reads a small JSON state file: missing or unreadable starts empty (the allowance is then fresh, and
36/// the log says so).
37fn load<T: DeserializeOwned + Default>(what: &str, path: Option<&Path>) -> T {
38    let Some(p) = path else { return T::default() };
39    match std::fs::read_to_string(p) {
40        Ok(t) => serde_json::from_str(&t).unwrap_or_else(|e| {
41            warn!("{what} {} does not parse ({e}); starting empty", p.display());
42            T::default()
43        }),
44        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
45            debug!("no {what} at {}; starting empty", p.display());
46            T::default()
47        }
48        Err(e) => {
49            warn!("{what} {} unreadable ({e}); starting empty", p.display());
50            T::default()
51        }
52    }
53}
54
55fn save<T: Serialize>(what: &str, path: Option<&Path>, value: &T) -> Result<(), StoreError> {
56    let Some(p) = path else { return Ok(()) };
57    let json = serde_json::to_string(value).map_err(|e| {
58        error!("{what} does not serialise: {e}");
59        StoreError::Unavailable
60    })?;
61    if let Some(dir) = p.parent()
62        && let Err(e) = std::fs::create_dir_all(dir)
63    {
64        warn!("{what} directory {} cannot be created: {e}", dir.display());
65    }
66    std::fs::write(p, json).map_err(|e| {
67        warn!("{what} {} not saved ({e}); a restart may forget this spending", p.display());
68        StoreError::Unavailable
69    })
70}
71
72impl FileAllowance {
73    /// The state files under `$HOME/.local/share/whiskers`; the voice spend is where
74    /// `WHISKERS_STATE_FILE` says if it is set.
75    pub fn from_env() -> Self {
76        let dir = std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/whiskers"));
77        let voice = std::env::var("WHISKERS_STATE_FILE")
78            .ok()
79            .map(|v| v.trim().to_owned())
80            .filter(|v| !v.is_empty())
81            .map(PathBuf::from)
82            .or_else(|| dir.as_ref().map(|d| d.join("speak.json")));
83        Self::open(dir.map(|d| d.join("tokens.json")), voice)
84    }
85
86    pub fn open(tokens_path: Option<PathBuf>, voice_path: Option<PathBuf>) -> Self {
87        let thinking: ThinkingLedger = load("token ledger", tokens_path.as_deref());
88        let voice: VoiceDay = load("voice spend file", voice_path.as_deref());
89        debug!("token ledger opened: {} entries", thinking.entries());
90        info!("voice: {} chars already spent on day {}", voice.spent_on(voice.day()), voice.day().number());
91        Self { tokens_path, voice_path, state: Mutex::new(State { thinking, voice, last_failure: None }) }
92    }
93
94    /// The same files again, as after a restart.
95    pub fn reopened(&self) -> Self {
96        Self::open(self.tokens_path.clone(), self.voice_path.clone())
97    }
98
99    fn locked(&self) -> MutexGuard<'_, State> {
100        self.state.lock().unwrap_or_else(PoisonError::into_inner)
101    }
102}
103
104impl Allowance for FileAllowance {
105    async fn thinking_room(&self, now: Millis, limit: &TokenLimit) -> Result<ThinkingRoom, StoreError> {
106        Ok(self.locked().thinking.room(now, limit))
107    }
108
109    async fn charge_thinking(&self, now: Millis, cost: Tokens) -> Result<(), StoreError> {
110        let mut state = self.locked();
111        state.thinking.charge(now, cost);
112        save("token ledger", self.tokens_path.as_deref(), &state.thinking)
113    }
114
115    async fn thinking_usage(&self, now: Millis, limit: &TokenLimit) -> Result<ThinkingUsage, StoreError> {
116        Ok(self.locked().thinking.usage(now, limit))
117    }
118
119    async fn reserve_voice(&self, now: Millis, chars: u32, cap: u32) -> Result<VoiceReservation, ReserveError> {
120        self.locked().voice.reserve(now, chars, cap)
121    }
122
123    async fn settle_voice(&self, reservation: VoiceReservation, how: Settlement) -> Result<(), StoreError> {
124        let mut state = self.locked();
125        match how {
126            Settlement::Spoken => {
127                state.last_failure = None;
128                save("voice spend file", self.voice_path.as_deref(), &state.voice)
129            }
130            Settlement::NotSpoken(why) => {
131                state.voice.release(reservation);
132                state.last_failure = Some(why);
133                // Nothing to write: the file holds only what has been spoken. (If another line settled
134                // while this one was outstanding, the file briefly holds this one's characters too, and
135                // a restart in that moment forgets the refund: a line short, never a line over.)
136                Ok(())
137            }
138        }
139    }
140
141    async fn voice_spend(&self, now: Millis) -> Result<VoiceSpend, StoreError> {
142        let state = self.locked();
143        Ok(VoiceSpend { spent_today: state.voice.spent_on(Day::containing(now)), last_failure: state.last_failure.clone() })
144    }
145}