1use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rusqlite::Connection;
12use serde::Serialize;
13
14pub const USAGE_WINDOW_DAYS: u8 = 7;
16const USAGE_WINDOW_MILLIS: i64 = (USAGE_WINDOW_DAYS as i64) * 24 * 60 * 60 * 1_000;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
20pub struct RemovalHealth {
21 pub usage_window_days: u8,
22 pub project_roots_served: u64,
23 pub sessions_served: u64,
24 pub project_roots_source: &'static str,
27 pub running_background_tasks: u64,
28 pub undo_history_sessions: u64,
29}
30
31impl RemovalHealth {
32 fn empty() -> Self {
33 Self {
34 usage_window_days: USAGE_WINDOW_DAYS,
35 project_roots_source: "durable_project_keys_approximation",
36 ..Self::default()
37 }
38 }
39}
40
41pub fn removal_health_from_connection(
45 conn: &Connection,
46 now_millis: i64,
47) -> rusqlite::Result<RemovalHealth> {
48 let mut health = RemovalHealth::empty();
49 let since_millis = now_millis.saturating_sub(USAGE_WINDOW_MILLIS);
50
51 let (project_roots_served, sessions_served, undo_history_sessions, running_background_tasks) =
59 conn.query_row(
60 "WITH activity AS (
61 SELECT project_key, harness, session_id
62 FROM bash_tasks
63 WHERE started_at >= ?1
64 UNION ALL
65 SELECT project_key, harness, session_id
66 FROM backups
67 WHERE created_at >= ?1
68 )
69 SELECT
70 (SELECT COUNT(*) FROM (
71 SELECT project_key FROM activity GROUP BY project_key
72 )),
73 (SELECT COUNT(*) FROM (
74 SELECT harness, session_id FROM activity GROUP BY harness, session_id
75 )),
76 (SELECT COUNT(*) FROM (
77 SELECT harness, session_id FROM backups GROUP BY harness, session_id
78 )),
79 (SELECT COUNT(*) FROM bash_tasks
80 WHERE status NOT IN ('completed', 'failed', 'killed', 'timed_out', 'fate_unknown'))",
81 [since_millis],
82 |row| {
83 Ok((
84 row.get::<_, u64>(0)?,
85 row.get::<_, u64>(1)?,
86 row.get::<_, u64>(2)?,
87 row.get::<_, u64>(3)?,
88 ))
89 },
90 )?;
91 health.project_roots_served = project_roots_served;
92 health.sessions_served = sessions_served;
93 health.undo_history_sessions = undo_history_sessions;
94 health.running_background_tasks = running_background_tasks;
95
96 Ok(health)
97}
98
99pub fn removal_health_from_storage_root(storage_root: &Path) -> Result<RemovalHealth, String> {
101 let db_path = storage_root.join("aft.db");
102 if !db_path.is_file() {
103 return Ok(RemovalHealth::empty());
104 }
105
106 let conn = crate::db::open_readonly(&db_path)
107 .map_err(|error| format!("could not open {} read-only: {error}", db_path.display()))?;
108 removal_health_from_connection(&conn, unix_millis())
109 .map_err(|error| format!("could not read {}: {error}", db_path.display()))
110}
111
112fn unix_millis() -> i64 {
113 SystemTime::now()
114 .duration_since(UNIX_EPOCH)
115 .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
116 .unwrap_or_default()
117}
118
119#[cfg(test)]
120mod tests {
121 use super::{removal_health_from_connection, USAGE_WINDOW_MILLIS};
122 use crate::db::backups::{insert_backup, BackupRow};
123 use crate::db::bash_tasks::{upsert_bash_task, BashTaskRow};
124
125 fn fixture_db() -> (tempfile::TempDir, crate::db::TrackedConnection) {
126 let dir = tempfile::tempdir().expect("create fixture directory");
127 let connection = crate::db::open(&dir.path().join("aft.db")).expect("open fixture db");
128 (dir, connection)
129 }
130
131 fn task(
132 task_id: &str,
133 project_key: &str,
134 session_id: &str,
135 status: &str,
136 started_at: i64,
137 ) -> BashTaskRow {
138 BashTaskRow {
139 harness: "opencode".to_string(),
140 session_id: session_id.to_string(),
141 task_id: task_id.to_string(),
142 project_key: project_key.to_string(),
143 command: "sleep 1".to_string(),
144 cwd: "/project".to_string(),
145 status: status.to_string(),
146 exit_code: None,
147 pid: None,
148 pgid: None,
149 started_at,
150 completed_at: None,
151 stdout_path: None,
152 stderr_path: None,
153 compressed: true,
154 timeout_ms: None,
155 completion_delivered: false,
156 output_bytes: None,
157 metadata: String::new(),
158 }
159 }
160
161 fn backup(
162 backup_id: &str,
163 harness: &str,
164 project_key: &str,
165 session_id: &str,
166 created_at: i64,
167 order: u128,
168 ) -> BackupRow {
169 BackupRow {
170 backup_id: backup_id.to_string(),
171 harness: harness.to_string(),
172 session_id: session_id.to_string(),
173 project_key: project_key.to_string(),
174 op_id: None,
175 order,
176 file_path: format!("/project/{backup_id}.txt"),
177 path_hash: format!("path-{backup_id}"),
178 backup_path: Some(format!("/backups/{backup_id}")),
179 kind: "snapshot".to_string(),
180 description: "fixture".to_string(),
181 created_at,
182 is_tombstone: false,
183 restore_meta: None,
184 }
185 }
186
187 #[test]
188 fn usage_counts_rows_inside_the_seven_day_window_and_excludes_rows_outside_it() {
189 let (_dir, conn) = fixture_db();
190 let now = USAGE_WINDOW_MILLIS * 10;
191 upsert_bash_task(
192 &conn,
193 &task(
194 "inside-task",
195 "project-task",
196 "session-task",
197 "completed",
198 now - USAGE_WINDOW_MILLIS + 1,
199 ),
200 )
201 .expect("seed inside task");
202 upsert_bash_task(
203 &conn,
204 &task(
205 "outside-task",
206 "project-old",
207 "session-old",
208 "completed",
209 now - USAGE_WINDOW_MILLIS - 1,
210 ),
211 )
212 .expect("seed outside task");
213 insert_backup(
214 &conn,
215 &backup(
216 "inside-backup",
217 "opencode",
218 "project-backup",
219 "session-backup",
220 now - USAGE_WINDOW_MILLIS + 1,
221 1,
222 ),
223 )
224 .expect("seed inside backup");
225 insert_backup(
226 &conn,
227 &backup(
228 "outside-backup",
229 "opencode",
230 "project-old-backup",
231 "session-old-backup",
232 now - USAGE_WINDOW_MILLIS - 1,
233 2,
234 ),
235 )
236 .expect("seed outside backup");
237
238 let health = removal_health_from_connection(&conn, now).expect("read removal health");
239
240 assert_eq!(health.project_roots_served, 2);
241 assert_eq!(health.sessions_served, 2);
242 }
243
244 #[test]
245 fn running_task_count_excludes_every_terminal_status_from_the_shared_allowlist() {
246 let (_dir, conn) = fixture_db();
247 let now = USAGE_WINDOW_MILLIS * 10;
248 for (index, status) in ["completed", "failed", "killed", "timed_out", "fate_unknown"]
249 .iter()
250 .enumerate()
251 {
252 upsert_bash_task(
253 &conn,
254 &task(
255 &format!("terminal-{index}"),
256 "project",
257 "session",
258 status,
259 now,
260 ),
261 )
262 .expect("seed terminal task");
263 }
264 upsert_bash_task(
265 &conn,
266 &task("running", "project", "session", "running", now),
267 )
268 .expect("seed running task");
269 upsert_bash_task(
270 &conn,
271 &task("future-state", "project", "session", "pausing", now),
272 )
273 .expect("seed future non-terminal task");
274
275 let health = removal_health_from_connection(&conn, now).expect("read removal health");
276
277 assert_eq!(health.running_background_tasks, 2);
278 }
279
280 #[test]
281 fn undo_history_counts_distinct_harness_and_session_pairs() {
282 let (_dir, conn) = fixture_db();
283 let now = USAGE_WINDOW_MILLIS * 10;
284 insert_backup(
285 &conn,
286 &backup("first", "opencode", "project", "same-id", now, 1),
287 )
288 .expect("seed first backup");
289 insert_backup(
290 &conn,
291 &backup("second", "opencode", "project", "same-id", now, 2),
292 )
293 .expect("seed second backup");
294 insert_backup(&conn, &backup("third", "pi", "project", "same-id", now, 3))
295 .expect("seed third backup");
296
297 let health = removal_health_from_connection(&conn, now).expect("read removal health");
298
299 assert_eq!(health.undo_history_sessions, 2);
300 }
301}