Skip to main content

aft/commands/
status.rs

1//! AFT status command — returns the current state of indexes, features, and configuration.
2
3use crate::context::AppContext;
4use crate::context::SemanticIndexStatus;
5use crate::db::compression_events::CompressionAggregate;
6use crate::protocol::{RawRequest, Response, StatusPayload, DEFAULT_SESSION_ID};
7
8#[derive(Debug, Clone, Default, serde::Serialize)]
9pub struct CompressionStats {
10    pub project: CompressionAggregateSerde,
11    pub session: CompressionAggregateSerde,
12}
13
14#[derive(Debug, Clone, Default, serde::Serialize)]
15pub struct CompressionAggregateSerde {
16    pub events: u64,
17    pub original_tokens: u64,
18    pub compressed_tokens: u64,
19    pub savings_tokens: u64,
20}
21
22impl From<CompressionAggregate> for CompressionAggregateSerde {
23    fn from(agg: CompressionAggregate) -> Self {
24        Self {
25            events: agg.events,
26            original_tokens: agg.original_tokens,
27            compressed_tokens: agg.compressed_tokens,
28            savings_tokens: agg.savings_tokens(),
29        }
30    }
31}
32
33pub fn handle_status(req: &RawRequest, ctx: &AppContext) -> Response {
34    let mut snapshot = ctx.build_status_snapshot_for_session(req.session());
35    if let Some(removal) = removal_health_for_status(req) {
36        snapshot["removal"] = removal;
37    }
38    Response::success(&req.id, snapshot)
39}
40
41/// Add removal-time state only when the management caller names a storage root.
42///
43/// Normal status refreshes are frequent and must stay independent from a global
44/// SQLite aggregation. `aft doctor` supplies this parameter while the user is
45/// deciding whether to remove AFT, and the read-only helper never creates or
46/// migrates their database.
47fn removal_health_for_status(req: &RawRequest) -> Option<serde_json::Value> {
48    let storage_root = req.params.get("removal_storage_dir")?;
49    let Some(storage_root) = storage_root.as_str() else {
50        return Some(serde_json::json!({
51            "available": false,
52            "message": "removal_storage_dir must be a string",
53        }));
54    };
55
56    Some(
57        match crate::db::removal::removal_health_from_storage_root(std::path::Path::new(
58            storage_root,
59        )) {
60            Ok(health) => serde_json::json!({
61                "available": true,
62                "usage_window_days": health.usage_window_days,
63                "project_roots_served": health.project_roots_served,
64                "sessions_served": health.sessions_served,
65                "project_roots_source": health.project_roots_source,
66                "running_background_tasks": health.running_background_tasks,
67                "undo_history_sessions": health.undo_history_sessions,
68            }),
69            Err(message) => serde_json::json!({
70                "available": false,
71                "message": message,
72            }),
73        },
74    )
75}
76
77impl AppContext {
78    pub fn build_status_snapshot(&self) -> StatusPayload {
79        self.build_status_snapshot_for_session(DEFAULT_SESSION_ID)
80    }
81
82    pub fn build_status_snapshot_for_session(&self, session_id: &str) -> StatusPayload {
83        let config = self.config();
84
85        // Search index status. Status is a control-path snapshot, so lock
86        // pressure is represented directly instead of delaying the caller.
87        let search_index_info = match self.search_index().try_read() {
88            Ok(index) => match index.as_ref() {
89                Some(idx) if idx.ready => {
90                    let file_count = idx.file_count();
91                    let trigram_count = idx.trigram_count();
92                    serde_json::json!({
93                        "status": "ready",
94                        "files": file_count,
95                        "trigrams": trigram_count,
96                    })
97                }
98                Some(_) => serde_json::json!({ "status": "building" }),
99                None => {
100                    let status = if config.search_index {
101                        "loading"
102                    } else {
103                        "disabled"
104                    };
105                    serde_json::json!({ "status": status })
106                }
107            },
108            Err(_) => serde_json::json!({ "status": "busy" }),
109        };
110
111        let semantic_status = self
112            .semantic_index_status()
113            .try_read()
114            .ok()
115            .map(|status| status.clone());
116        let semantic_index_info = match semantic_status {
117            None => serde_json::json!({ "status": "busy", "state": "busy" }),
118            Some(status) => match self.semantic_index().try_read() {
119                Err(_) => serde_json::json!({ "status": "busy", "state": "busy" }),
120                Ok(index) => {
121                    let refreshing_count = status.refreshing_count();
122                    match index.as_ref() {
123                        Some(idx) => {
124                            let status_label = match status {
125                                SemanticIndexStatus::Ready { .. } => "ready",
126                                _ => idx.status_label(),
127                            };
128                            serde_json::json!({
129                                "status": status_label,
130                                "state": status_label,
131                                "refreshing_count": refreshing_count,
132                                "entries": idx.entry_count(),
133                                "dimension": idx.dimension(),
134                                "backend": idx.backend_label().unwrap_or(config.semantic_backend_label()),
135                                "model": idx.model_label().unwrap_or(config.semantic.model.as_str()),
136                            })
137                        }
138                        None => match status {
139                            SemanticIndexStatus::Disabled => serde_json::json!({
140                                "status": "disabled",
141                                "state": "disabled",
142                                "refreshing_count": 0,
143                                "backend": config.semantic_backend_label(),
144                                "model": config.semantic.model.as_str(),
145                            }),
146                            SemanticIndexStatus::Building {
147                                stage,
148                                files,
149                                entries_done,
150                                entries_total,
151                            } => serde_json::json!({
152                                "status": "loading",
153                                "state": "loading",
154                                "refreshing_count": 0,
155                                "stage": stage,
156                                "files": files,
157                                "entries_done": entries_done,
158                                "entries_total": entries_total,
159                                "backend": config.semantic_backend_label(),
160                                "model": config.semantic.model.as_str(),
161                            }),
162                            SemanticIndexStatus::Ready { refreshing, .. } => serde_json::json!({
163                                "status": "ready",
164                                "state": "ready",
165                                "refreshing_count": refreshing.len(),
166                                "backend": config.semantic_backend_label(),
167                                "model": config.semantic.model.as_str(),
168                            }),
169                            SemanticIndexStatus::Failed(error) => serde_json::json!({
170                                "status": "failed",
171                                "state": "failed",
172                                "refreshing_count": 0,
173                                "error": error,
174                                "backend": config.semantic_backend_label(),
175                                "model": config.semantic.model.as_str(),
176                            }),
177                        },
178                    }
179                }
180            },
181        };
182
183        // Disk cache sizes — scoped to the **current project** only.
184        //
185        // Both trigram (`<storage_dir>/index/<key>/`) and semantic
186        // (`<storage_dir>/semantic/<key>/`) caches are partitioned per project by
187        // `project_cache_key(project_root)`. Earlier this function reported the
188        // recursive size of the entire `index/` and `semantic/` directories,
189        // which summed disk usage across **every** project the user had ever
190        // opened. The TUI sidebar surfaced that total as if it were the current
191        // project's footprint, which was misleading (e.g. a 4.8 MB project with
192        // 9 sibling projects appeared to use 16+ GB).
193        //
194        // We now resolve the per-project key from `config.project_root` and
195        // size only that project's slice. When the project key can't be
196        // resolved (no project_root), fall back to zeros — the cross-project
197        // total is never the right answer to display per-session.
198        let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
199        let disk_info = match (&config.storage_dir, &config.project_root) {
200            (Some(dir), Some(root)) => {
201                let key_root = self
202                    .canonical_cache_root_opt()
203                    .unwrap_or_else(|| root.clone());
204                // Passive read only: status must never trigger a key
205                // derivation (git probe). Artifact-backed features derive and
206                // memoize the key at configure; when none are enabled there is
207                // no per-project artifact slice to size.
208                match self.cached_artifact_cache_key(&key_root) {
209                    Some(key) => {
210                        let trigram_size = dir_size(&dir.join("index").join(&key));
211                        let semantic_size = dir_size(&dir.join("semantic").join(&key));
212                        serde_json::json!({
213                            "storage_dir": dir.display().to_string(),
214                            "project_cache_key": key,
215                            "trigram_disk_bytes": trigram_size,
216                            "semantic_disk_bytes": semantic_size,
217                        })
218                    }
219                    None => serde_json::json!({
220                        "storage_dir": dir.display().to_string(),
221                        "project_cache_key": null,
222                        "trigram_disk_bytes": 0,
223                        "semantic_disk_bytes": 0,
224                    }),
225                }
226            }
227            (Some(dir), None) => serde_json::json!({
228                "storage_dir": dir.display().to_string(),
229                "project_cache_key": null,
230                "trigram_disk_bytes": 0,
231                "semantic_disk_bytes": 0,
232            }),
233            _ => serde_json::json!({
234                "storage_dir": null,
235                "project_cache_key": null,
236                "trigram_disk_bytes": 0,
237                "semantic_disk_bytes": 0,
238            }),
239        };
240
241        // LSP servers
242        let lsp_count = self.lsp_server_count();
243
244        // Symbol cache stats
245        let symbol_cache_stats = self.symbol_cache_stats();
246
247        // Per-session undo/checkpoint counts (issue #14 — one shared bridge serves
248        // many sessions; surface both the global footprint and the current
249        // session's own slice so `/aft-status` can split them in the UI).
250        let backups_enabled = config.backup.enabled.unwrap_or(true);
251        let checkpoint_total = if backups_enabled {
252            self.checkpoint().lock().total_count()
253        } else {
254            0
255        };
256        let session_checkpoints = if backups_enabled {
257            self.checkpoint().lock().list(session_id).len()
258        } else {
259            0
260        };
261        let session_tracked_files = if backups_enabled {
262            self.backup().lock().tracked_files(session_id).len()
263        } else {
264            0
265        };
266        let compression = self.compression_stats_for_session(session_id);
267
268        // Degraded-mode reasons recorded by `handle_configure` when the
269        // project root doesn't look like a real project (`home_root`). Heavy
270        // subsystems are auto-disabled in that mode; the plugin / TUI sidebar
271        // surface the reason so users know why and can decide whether to open a
272        // project subdirectory. Empty list = full-featured mode.
273        let degraded_reasons = self.degraded_reasons();
274        let degraded = !degraded_reasons.is_empty();
275        let artifact_owner = self
276            .artifact_owner_status()
277            .map(|status| serde_json::to_value(status).unwrap_or(serde_json::Value::Null))
278            .unwrap_or(serde_json::Value::Null);
279
280        // Agent status-bar counts (the `[AFT E· W· | D· U· C· | T·]` glance).
281        // Surfaced for the TUI sidebar so users see the same code-health view
282        // agents get. `None` until the Tier-2 cache is populated at least once
283        // (so we never render fabricated zeros) — emitted as JSON null then,
284        // and the sidebar hides the section.
285        let status_bar = match self.status_bar_counts() {
286            Some(counts) => serde_json::json!({
287                "errors": counts.errors,
288                "warnings": counts.warnings,
289                "dead_code": counts.dead_code,
290                "unused_exports": counts.unused_exports,
291                "duplicates": counts.duplicates,
292                "todos": counts.todos,
293                "tier2_stale": counts.tier2_stale,
294            }),
295            None => serde_json::Value::Null,
296        };
297        let memory_root = self
298            .canonical_cache_root_opt()
299            .or_else(|| config.project_root.clone());
300        let callgraph_write_metrics = memory_root
301            .as_deref()
302            .and_then(|root| self.cached_artifact_cache_key(root))
303            .map(|project_key| {
304                crate::callgraph_store::callgraph_write_metrics_for_project(&project_key)
305            })
306            .unwrap_or_default();
307        let callgraph_write_metrics_total = crate::callgraph_store::callgraph_write_metrics_total();
308        let memory = serde_json::to_value(self.memory_snapshot(memory_root.as_deref()))
309            .unwrap_or(serde_json::Value::Null);
310        let mut runtime = serde_json::json!({
311            "live_watchers": self.app().watcher_count(),
312            "live_actor_roots": self.app().actor_root_count(),
313            "open_routes": self.app().open_route_count(),
314            "callgraph_commits_60s_total": callgraph_write_metrics_total.commits_60s,
315            "callgraph_pages_or_bytes_written_60s_total": callgraph_write_metrics_total
316                .pages_or_bytes_written_60s,
317        });
318        if callgraph_write_metrics.commits_60s > 0 {
319            runtime["callgraph_commits_60s"] =
320                serde_json::json!(callgraph_write_metrics.commits_60s);
321        }
322        if callgraph_write_metrics.pages_or_bytes_written_60s > 0 {
323            runtime["callgraph_pages_or_bytes_written_60s"] =
324                serde_json::json!(callgraph_write_metrics.pages_or_bytes_written_60s);
325        }
326
327        serde_json::json!({
328            "version": env!("CARGO_PKG_VERSION"),
329            "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
330            "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
331            // Machine field. Human renderers must treat worktree/read_only as a
332            // shared-index borrow, never as a degraded_reasons entry.
333            "cache_role": self.cache_role(),
334            "artifact_owner": artifact_owner,
335            "degraded": degraded,
336            "degraded_reasons": degraded_reasons,
337            "features": {
338                "format_on_edit": config.format_on_edit,
339                "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
340                "restrict_to_project_root": config.restrict_to_project_root,
341                "search_index": config.search_index,
342                "semantic_search": config.semantic_search,
343                "callgraph_store": config.callgraph_store,
344                "backup": backups_enabled,
345            },
346            "search_index": search_index_info,
347            "semantic_index": semantic_index_info,
348            "status_bar": status_bar,
349            "disk": disk_info,
350            "lsp_servers": lsp_count,
351            "symbol_cache": symbol_cache_stats,
352            "memory": memory,
353            "runtime": runtime,
354            "compression": compression,
355            "storage_dir": storage_dir,
356            // Project-wide (all sessions): total in-memory checkpoint count.
357            "checkpoints_total": checkpoint_total,
358            // Current session slice: only when the caller passed `session_id`.
359            "session": {
360                "id": session_id,
361                "tracked_files": session_tracked_files,
362                "checkpoints": session_checkpoints,
363            },
364        })
365    }
366
367    fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
368        let mut compression = CompressionStats::default();
369        let Some(project_root) = self.config().project_root.clone() else {
370            return compression;
371        };
372        let Some(db) = self.db() else {
373            return compression;
374        };
375        let Ok(conn) = db.lock() else {
376            return compression;
377        };
378
379        let harness = self.harness().storage_segment();
380        let project_key = crate::path_identity::project_scope_key(&project_root);
381        if let Ok((project, session)) = self.compression_aggregate_cache().aggregates_for_session(
382            &conn,
383            &harness,
384            &project_key,
385            session_id,
386        ) {
387            compression.project = project.into();
388            compression.session = session.into();
389        }
390
391        compression
392    }
393}
394
395/// Recursively compute the total size of a directory.
396fn dir_size(path: &std::path::Path) -> u64 {
397    if !path.exists() {
398        return 0;
399    }
400    dir_size_recursive(path)
401}
402
403fn dir_size_recursive(path: &std::path::Path) -> u64 {
404    let mut total = 0u64;
405    let entries = match std::fs::read_dir(path) {
406        Ok(e) => e,
407        Err(_) => return 0,
408    };
409    for entry in entries.flatten() {
410        let ft = match entry.file_type() {
411            Ok(ft) => ft,
412            Err(_) => continue,
413        };
414        if ft.is_file() {
415            total += entry.metadata().map(|m| m.len()).unwrap_or(0);
416        } else if ft.is_dir() {
417            total += dir_size_recursive(&entry.path());
418        }
419    }
420    total
421}
422
423#[cfg(test)]
424mod tests {
425    use super::handle_status;
426    use crate::config::Config;
427    use crate::context::AppContext;
428    use crate::parser::TreeSitterProvider;
429    use crate::protocol::RawRequest;
430    use serde_json::json;
431
432    fn request() -> RawRequest {
433        RawRequest {
434            id: "status".to_string(),
435            command: "status".to_string(),
436            lsp_hints: None,
437            session_id: None,
438            params: json!({}),
439        }
440    }
441
442    #[test]
443    fn removal_status_reports_an_empty_storage_root_as_zero_state() {
444        let storage = tempfile::tempdir().expect("create storage root");
445        let request = RawRequest {
446            params: json!({ "removal_storage_dir": storage.path() }),
447            ..request()
448        };
449
450        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
451        let response = handle_status(&request, &ctx);
452
453        assert_eq!(response.data["removal"]["available"], true);
454        assert_eq!(response.data["removal"]["project_roots_served"], 0);
455        assert_eq!(response.data["removal"]["sessions_served"], 0);
456        assert_eq!(response.data["removal"]["running_background_tasks"], 0);
457        assert_eq!(response.data["removal"]["undo_history_sessions"], 0);
458    }
459
460    #[test]
461    fn status_exposes_cache_role_and_canonical_root() {
462        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
463        let response = handle_status(&request(), &ctx);
464        assert_eq!(response.data["cache_role"], "not_initialized");
465        assert!(response.data["canonical_root"].is_null());
466        assert!(response.data["runtime"]["callgraph_commits_60s_total"].is_u64());
467        assert!(response.data["runtime"]["callgraph_pages_or_bytes_written_60s_total"].is_u64());
468
469        let temp = tempfile::tempdir().unwrap();
470        ctx.update_config(|config| {
471            config.project_root = Some(temp.path().to_path_buf());
472        });
473        ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
474        ctx.set_cache_role(false, None);
475        let response = handle_status(&request(), &ctx);
476        assert_eq!(response.data["cache_role"], "main");
477        assert!(response.data["canonical_root"].as_str().is_some());
478
479        ctx.set_cache_role(true, None);
480        let response = handle_status(&request(), &ctx);
481        assert_eq!(response.data["cache_role"], "worktree");
482    }
483
484    #[test]
485    fn memory_snapshot_reports_contended_subsystem_as_busy() {
486        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
487        let _semantic_writer = ctx.semantic_index().write().unwrap();
488        let status = ctx.build_status_snapshot();
489        assert_eq!(status["semantic_index"]["status"], "busy");
490        assert_eq!(
491            status["memory"]["roots"]["<unconfigured>"]["semantic"]["status"],
492            "busy"
493        );
494        assert_eq!(status["memory"]["process"]["sqlite"]["status"], "measured");
495        assert!(status["memory"]["process"]["allocator"]["status"].is_string());
496        assert!(status["memory"]["process"]["allocator"]
497            .get("retained_slack_bytes")
498            .is_some());
499    }
500
501    #[test]
502    fn status_status_bar_is_null_until_tier2_populated() {
503        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
504        let response = handle_status(&request(), &ctx);
505        // No Tier-2 scan has run yet, so the status-bar glance must be null
506        // (never fabricated zeros). The key is always present so the TS
507        // coercion can distinguish "field absent" from "not populated".
508        assert!(response.data.get("status_bar").is_some());
509        assert!(response.data["status_bar"].is_null());
510
511        // Once Tier-2 counts are populated, the snapshot carries the glance.
512        ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
513        let response = handle_status(&request(), &ctx);
514        assert_eq!(response.data["status_bar"]["dead_code"], 3);
515        assert_eq!(response.data["status_bar"]["unused_exports"], 2);
516        assert_eq!(response.data["status_bar"]["duplicates"], 1);
517        assert_eq!(response.data["status_bar"]["tier2_stale"], false);
518    }
519}