What Whiskers may spend, kept in two small files beside the other hub state: the thinking ledger
(tokens.json) and the day's voice spend (speak.json, or WHISKERS_STATE_FILE). The rules (the
rolling window, the day turning, reserve and refund) are whiskers-ports'; this keeps the structs
behind one mutex, so every call is atomic with respect to every other, and writes them down.
The thinking ledger is saved when a question is charged. The voice spend is saved when a line is settled, not when it is reserved: a reservation is a promise about a line that has not been spoken yet, and a crash between the two forgets it (the spend is then a line short, never a line over).
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,
Why the last attempt to speak failed, until one succeeds. Not kept across a restart.
Reads a small JSON state file: missing or unreadable starts empty (the allowance is then fresh, and 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}
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 {
The state files under $HOME/.local/share/whiskers; the voice spend is where
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 }
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 }
The same files again, as after a restart.
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}