1use std::collections::BTreeSet;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, Instant};
32
33use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
34use serde::Serialize;
35use serde_json::json;
36
37use super::team::TeamAppState;
38
39pub(super) const STORAGE_CACHE_TTL: Duration = Duration::from_mins(1);
41
42pub(super) const QUOTA_ENV: &str = "LEANCTX_TEAM_STORAGE_QUOTA_BYTES";
47
48pub(super) const DEFAULT_TEAM_STORAGE_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024;
55
56#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct StorageComponent {
60 pub id: String,
62 pub bytes: u64,
63}
64
65#[derive(Debug, Clone)]
67pub struct StorageReport {
68 pub used_bytes: u64,
69 pub components: Vec<StorageComponent>,
70}
71
72#[derive(Default)]
74pub struct StorageCache(Option<(Instant, StorageReport)>);
75
76#[derive(Clone)]
78pub struct StorageRoots {
79 pub data_root: PathBuf,
82 pub workspaces: Vec<(String, PathBuf)>,
85 pub quota_bytes: u64,
88}
89
90impl StorageRoots {
91 fn measure(&self) -> StorageReport {
93 let mut components = Vec::new();
94 let data_bytes = dir_allocated_bytes(&self.data_root);
95 components.push(StorageComponent {
96 id: "server-data".into(),
97 bytes: data_bytes,
98 });
99
100 for (id, state_dir) in &self.workspaces {
101 if state_dir.starts_with(&self.data_root) {
102 continue; }
104 components.push(StorageComponent {
105 id: format!("workspace:{id}"),
106 bytes: dir_allocated_bytes(state_dir),
107 });
108 }
109
110 let used_bytes = components
111 .iter()
112 .map(|c| c.bytes)
113 .fold(0u64, u64::saturating_add);
114 StorageReport {
115 used_bytes,
116 components,
117 }
118 }
119}
120
121pub async fn v1_storage(State(state): State<TeamAppState>) -> impl IntoResponse {
123 let (report, age) = cached_report(&state).await;
124 let body = json!({
125 "schemaVersion": 1,
126 "measuredAt": chrono::Utc::now().to_rfc3339(),
127 "usedBytes": report.used_bytes,
128 "quotaBytes": state.team.storage_roots.quota_bytes,
129 "components": report.components,
130 "cacheAgeSeconds": age.as_secs(),
131 });
132 (StatusCode::OK, Json(body))
133}
134
135pub async fn v1_usage(State(state): State<TeamAppState>) -> impl IntoResponse {
137 let dir = state.team.savings_store_dir.lock().await.clone();
138 let summary = tokio::task::spawn_blocking(move || super::savings_summary::aggregate(&dir))
139 .await
140 .unwrap_or_default();
141 let (report, _) = cached_report(&state).await;
142
143 let storage = json!({
144 "used_bytes": report.used_bytes,
145 "quota_bytes": state.team.storage_roots.quota_bytes,
146 });
147
148 let body = json!({
149 "schemaVersion": 1,
150 "generatedAt": chrono::Utc::now().to_rfc3339(),
151 "savings": {
152 "memberCount": summary.member_count,
153 "savedTokens": summary.totals.saved_tokens,
154 "netSavedTokens": summary.totals.net_saved_tokens,
155 "savedUsd": summary.totals.saved_usd,
156 },
157 "toolCalls": summary.totals.total_events,
160 "storage": storage,
161 });
162 (StatusCode::OK, Json(body))
163}
164
165async fn cached_report(state: &TeamAppState) -> (StorageReport, Duration) {
167 {
168 let cache = state.team.storage_cache.lock().await;
169 if let Some((at, report)) = cache.0.as_ref() {
170 let age = at.elapsed();
171 if age < STORAGE_CACHE_TTL {
172 return (report.clone(), age);
173 }
174 }
175 }
176
177 let roots = state.team.storage_roots.clone();
178 let report = tokio::task::spawn_blocking(move || roots.measure())
179 .await
180 .unwrap_or(StorageReport {
181 used_bytes: 0,
182 components: Vec::new(),
183 });
184
185 let mut cache = state.team.storage_cache.lock().await;
186 cache.0 = Some((Instant::now(), report.clone()));
187 (report, Duration::ZERO)
188}
189
190fn quota_bytes_from_env() -> Option<u64> {
191 std::env::var(QUOTA_ENV).ok()?.trim().parse::<u64>().ok()
192}
193
194pub(super) fn resolve_quota_bytes(env_override: Option<u64>, config_quota: Option<u64>) -> u64 {
198 env_override
199 .or(config_quota)
200 .unwrap_or(DEFAULT_TEAM_STORAGE_QUOTA_BYTES)
201}
202
203pub(super) fn storage_roots_from_config(
207 audit_log_path: &Path,
208 workspaces: &[(String, PathBuf)],
209 config_quota: Option<u64>,
210) -> StorageRoots {
211 let data_root = audit_log_path
212 .parent()
213 .unwrap_or_else(|| Path::new("."))
214 .to_path_buf();
215 let workspaces = workspaces
216 .iter()
217 .map(|(id, root)| (id.clone(), root.join(".lean-ctx")))
218 .collect();
219 StorageRoots {
220 data_root,
221 workspaces,
222 quota_bytes: resolve_quota_bytes(quota_bytes_from_env(), config_quota),
223 }
224}
225
226fn dir_allocated_bytes(path: &Path) -> u64 {
231 let mut seen: BTreeSet<(u64, u64)> = BTreeSet::new();
232 let mut total = 0u64;
233 let mut stack = vec![path.to_path_buf()];
234 while let Some(p) = stack.pop() {
235 let Ok(meta) = std::fs::symlink_metadata(&p) else {
236 continue;
237 };
238 if meta.is_symlink() {
239 continue;
240 }
241 if meta.is_dir() {
242 if let Ok(entries) = std::fs::read_dir(&p) {
243 stack.extend(entries.flatten().map(|e| e.path()));
244 }
245 continue;
246 }
247 #[cfg(unix)]
248 {
249 use std::os::unix::fs::MetadataExt;
250 if !seen.insert((meta.dev(), meta.ino())) {
251 continue; }
253 total = total.saturating_add(meta.blocks().saturating_mul(512));
254 }
255 #[cfg(not(unix))]
256 {
257 let _ = &mut seen;
258 total = total.saturating_add(meta.len());
259 }
260 }
261 total
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn temp_dir(tag: &str) -> PathBuf {
269 let d =
270 std::env::temp_dir().join(format!("leanctx_team_billing_{tag}_{}", std::process::id()));
271 let _ = std::fs::remove_dir_all(&d);
272 std::fs::create_dir_all(&d).unwrap();
273 d
274 }
275
276 #[test]
277 fn missing_dir_measures_zero() {
278 let missing = std::env::temp_dir().join("leanctx_team_billing_does_not_exist_xyz");
279 let _ = std::fs::remove_dir_all(&missing);
280 assert_eq!(dir_allocated_bytes(&missing), 0);
281 }
282
283 #[test]
288 fn quota_resolution_precedence() {
289 assert_eq!(resolve_quota_bytes(Some(7), Some(9)), 7);
290 assert_eq!(resolve_quota_bytes(None, Some(9)), 9);
291 assert_eq!(
292 resolve_quota_bytes(None, None),
293 DEFAULT_TEAM_STORAGE_QUOTA_BYTES
294 );
295 assert_eq!(DEFAULT_TEAM_STORAGE_QUOTA_BYTES, 5_368_709_120);
296 }
297
298 #[test]
299 fn allocated_bytes_cover_written_content() {
300 let d = temp_dir("alloc");
301 std::fs::write(d.join("a.jsonl"), vec![b'x'; 10_000]).unwrap();
302 std::fs::create_dir_all(d.join("nested")).unwrap();
303 std::fs::write(d.join("nested/b.bin"), vec![b'y'; 5_000]).unwrap();
304 let measured = dir_allocated_bytes(&d);
305 assert!(measured >= 15_000, "measured {measured} < logical 15000");
309 assert!(
310 measured < 15_000 * 64,
311 "measured {measured} implausibly large"
312 );
313 let _ = std::fs::remove_dir_all(&d);
314 }
315
316 #[cfg(unix)]
317 #[test]
318 fn hard_links_count_once_and_symlinks_do_not_escape() {
319 let d = temp_dir("links");
320 std::fs::write(d.join("real.bin"), vec![b'z'; 8_192]).unwrap();
321 std::fs::hard_link(d.join("real.bin"), d.join("hard.bin")).unwrap();
322 let outside = temp_dir("links_outside");
323 std::fs::write(outside.join("big.bin"), vec![b'w'; 100_000]).unwrap();
324 std::os::unix::fs::symlink(outside.join("big.bin"), d.join("escape.bin")).unwrap();
325
326 let measured = dir_allocated_bytes(&d);
327 let single = dir_allocated_bytes(&outside); assert!(measured < single, "symlink target must not be billed");
329 assert!(
331 (8_192..16_384).contains(&measured),
332 "hard link double-counted: {measured}"
333 );
334
335 let _ = std::fs::remove_dir_all(&d);
336 let _ = std::fs::remove_dir_all(&outside);
337 }
338
339 #[test]
340 fn workspace_state_dirs_under_data_root_are_not_double_counted() {
341 let d = temp_dir("dedupe");
342 let audit = d.join("audit.jsonl");
343 std::fs::write(&audit, "x").unwrap();
344 let ws_root = d.join("ws1");
347 std::fs::create_dir_all(ws_root.join(".lean-ctx")).unwrap();
348 std::fs::write(ws_root.join(".lean-ctx/events.jsonl"), vec![b'e'; 4_096]).unwrap();
349 let ext = temp_dir("dedupe_ext");
351 std::fs::create_dir_all(ext.join(".lean-ctx")).unwrap();
352 std::fs::write(ext.join(".lean-ctx/k.jsonl"), vec![b'k'; 4_096]).unwrap();
353
354 let roots = storage_roots_from_config(
355 &audit,
356 &[
357 ("inside".into(), ws_root.clone()),
358 ("outside".into(), ext.clone()),
359 ],
360 None,
361 );
362 let report = roots.measure();
363 let ids: Vec<&str> = report.components.iter().map(|c| c.id.as_str()).collect();
364 assert!(ids.contains(&"server-data"));
365 assert!(
366 !ids.contains(&"workspace:inside"),
367 "nested workspace double-counted"
368 );
369 assert!(ids.contains(&"workspace:outside"));
370 let sum: u64 = report.components.iter().map(|c| c.bytes).sum();
371 assert_eq!(report.used_bytes, sum);
372
373 let _ = std::fs::remove_dir_all(&d);
374 let _ = std::fs::remove_dir_all(&ext);
375 }
376}