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