Skip to main content

aft/db/
removal.rs

1//! Durable state summarized when a user is considering removing AFT.
2//!
3//! These counts intentionally read the state AFT already maintains instead of
4//! adding runtime telemetry. At removal time, a user can still connect the
5//! numbers to their recent work; a delayed TTL cleanup or an orphaned task is
6//! much harder to recognize as an AFT consequence.
7
8use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rusqlite::Connection;
12use serde::Serialize;
13
14/// The usage period shown by `aft doctor` when it explains removal costs.
15pub const USAGE_WINDOW_DAYS: u8 = 7;
16const USAGE_WINDOW_MILLIS: i64 = (USAGE_WINDOW_DAYS as i64) * 24 * 60 * 60 * 1_000;
17
18/// Durable removal-time counts reported through the status payload.
19#[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    /// AFT keeps stable per-root keys, not a historical root-path ledger. The
25    /// count is therefore a durable approximation of distinct roots served.
26    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
41/// Read removal-time health from an already-open AFT database.
42///
43/// `now_millis` is an input so boundary behavior stays deterministic in tests.
44pub 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    // The two activity tables already retain both a project scope key and a
52    // timestamp. Project keys are deliberately one-way identifiers, so this
53    // reports a count rather than pretending durable state can recover paths.
54    // Keep this allowlist aligned with `BgTaskStatus::is_terminal`: new
55    // non-terminal statuses should be visible as removal risks rather than
56    // silently treated as safe. The partial index created in migration V6 keeps
57    // this part of the single query bounded to non-terminal rows.
58    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
99/// Read a storage root without creating, migrating, or writing its database.
100pub 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}