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                            } => {
152                                let mut snapshot = serde_json::json!({
153                                    "status": "loading",
154                                    "state": "loading",
155                                    "refreshing_count": 0,
156                                    "stage": stage,
157                                    "files": files,
158                                    "entries_done": entries_done,
159                                    "entries_total": entries_total,
160                                    "backend": config.semantic_backend_label(),
161                                    "model": config.semantic.model.as_str(),
162                                });
163                                if let Some(progress) = self.semantic_build_progress() {
164                                    let progress = progress.snapshot();
165                                    snapshot["embedded_chunks"] =
166                                        serde_json::json!(progress.embedded_chunks);
167                                    snapshot["total_chunks"] =
168                                        serde_json::json!(progress.total_chunks);
169                                    snapshot["current_batch"] =
170                                        serde_json::json!(progress.current_batch);
171                                    snapshot["total_batches"] =
172                                        serde_json::json!(progress.total_batches);
173                                }
174                                snapshot
175                            }
176                            SemanticIndexStatus::Ready { refreshing, .. } => serde_json::json!({
177                                "status": "ready",
178                                "state": "ready",
179                                "refreshing_count": refreshing.len(),
180                                "backend": config.semantic_backend_label(),
181                                "model": config.semantic.model.as_str(),
182                            }),
183                            SemanticIndexStatus::Failed(error) => serde_json::json!({
184                                "status": "failed",
185                                "state": "failed",
186                                "refreshing_count": 0,
187                                "error": error,
188                                "backend": config.semantic_backend_label(),
189                                "model": config.semantic.model.as_str(),
190                            }),
191                        },
192                    }
193                }
194            },
195        };
196
197        // Disk cache sizes — scoped to the **current project** only.
198        //
199        // Both trigram (`<storage_dir>/index/<key>/`) and semantic
200        // (`<storage_dir>/semantic/<key>/`) caches are partitioned per project by
201        // `project_cache_key(project_root)`. Earlier this function reported the
202        // recursive size of the entire `index/` and `semantic/` directories,
203        // which summed disk usage across **every** project the user had ever
204        // opened. The TUI sidebar surfaced that total as if it were the current
205        // project's footprint, which was misleading (e.g. a 4.8 MB project with
206        // 9 sibling projects appeared to use 16+ GB).
207        //
208        // We now resolve the per-project key from `config.project_root` and
209        // size only that project's slice. When the project key can't be
210        // resolved (no project_root), fall back to zeros — the cross-project
211        // total is never the right answer to display per-session.
212        let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
213        let disk_info = match (&config.storage_dir, &config.project_root) {
214            (Some(dir), Some(root)) => {
215                let key_root = self
216                    .canonical_cache_root_opt()
217                    .unwrap_or_else(|| root.clone());
218                // Passive read only: status must never trigger a key
219                // derivation (git probe). Artifact-backed features derive and
220                // memoize the key at configure; when none are enabled there is
221                // no per-project artifact slice to size.
222                match self.cached_artifact_cache_key(&key_root) {
223                    Some(key) => {
224                        let trigram_size = dir_size(&dir.join("index").join(&key));
225                        let semantic_size = dir_size(&dir.join("semantic").join(&key));
226                        serde_json::json!({
227                            "storage_dir": dir.display().to_string(),
228                            "project_cache_key": key,
229                            "trigram_disk_bytes": trigram_size,
230                            "semantic_disk_bytes": semantic_size,
231                        })
232                    }
233                    None => serde_json::json!({
234                        "storage_dir": dir.display().to_string(),
235                        "project_cache_key": null,
236                        "trigram_disk_bytes": 0,
237                        "semantic_disk_bytes": 0,
238                    }),
239                }
240            }
241            (Some(dir), None) => serde_json::json!({
242                "storage_dir": dir.display().to_string(),
243                "project_cache_key": null,
244                "trigram_disk_bytes": 0,
245                "semantic_disk_bytes": 0,
246            }),
247            _ => serde_json::json!({
248                "storage_dir": null,
249                "project_cache_key": null,
250                "trigram_disk_bytes": 0,
251                "semantic_disk_bytes": 0,
252            }),
253        };
254
255        // LSP servers
256        let lsp_count = self.lsp_server_count();
257
258        // Symbol cache stats
259        let symbol_cache_stats = self.symbol_cache_stats();
260
261        // Per-session undo/checkpoint counts (issue #14 — one shared bridge serves
262        // many sessions; surface both the global footprint and the current
263        // session's own slice so `/aft-status` can split them in the UI).
264        let backups_enabled = config.backup.enabled.unwrap_or(true);
265        let checkpoint_total = if backups_enabled {
266            self.checkpoint().lock().total_count()
267        } else {
268            0
269        };
270        let session_checkpoints = if backups_enabled {
271            self.checkpoint()
272                .lock()
273                .list(session_id)
274                .map(|checkpoints| checkpoints.len())
275                .unwrap_or_else(|error| {
276                    crate::slog_warn!("status checkpoint hydration failed: {}", error);
277                    0
278                })
279        } else {
280            0
281        };
282        let session_tracked_files = if backups_enabled {
283            self.backup().lock().tracked_files(session_id).len()
284        } else {
285            0
286        };
287        let compression = self.compression_stats_for_session(session_id);
288
289        // Degraded-mode reasons recorded by `handle_configure` when the
290        // project root doesn't look like a real project (`home_root`). Heavy
291        // subsystems are auto-disabled in that mode; the plugin / TUI sidebar
292        // surface the reason so users know why and can decide whether to open a
293        // project subdirectory. Empty list = full-featured mode.
294        let degraded_reasons = self.degraded_reasons();
295        let degraded = !degraded_reasons.is_empty();
296        let artifact_owner = self
297            .artifact_owner_status()
298            .map(|status| serde_json::to_value(status).unwrap_or(serde_json::Value::Null))
299            .unwrap_or(serde_json::Value::Null);
300
301        // Agent status-bar counts (the `[AFT E· W· | D· U· C· | T·]` glance).
302        // Surfaced for the TUI sidebar so users see the same code-health view
303        // agents get. `None` until the Tier-2 cache is populated at least once
304        // (so we never render fabricated zeros) — emitted as JSON null then,
305        // and the sidebar hides the section.
306        let status_bar = match self.status_bar_counts() {
307            Some(counts) => serde_json::json!({
308                "errors": counts.errors,
309                "warnings": counts.warnings,
310                "dead_code": counts.dead_code,
311                "unused_exports": counts.unused_exports,
312                "duplicates": counts.duplicates,
313                "todos": counts.todos,
314                "tier2_stale": counts.tier2_stale,
315            }),
316            None => serde_json::Value::Null,
317        };
318        let memory_root = self
319            .canonical_cache_root_opt()
320            .or_else(|| config.project_root.clone());
321        let callgraph_write_metrics = memory_root
322            .as_deref()
323            .and_then(|root| self.cached_artifact_cache_key(root))
324            .map(|project_key| {
325                crate::callgraph_store::callgraph_write_metrics_for_project(&project_key)
326            })
327            .unwrap_or_default();
328        let callgraph_write_metrics_total = crate::callgraph_store::callgraph_write_metrics_total();
329        let memory = serde_json::to_value(self.memory_snapshot(memory_root.as_deref()))
330            .unwrap_or(serde_json::Value::Null);
331        let mut runtime = serde_json::json!({
332            "live_watchers": self.app().watcher_count(),
333            "live_actor_roots": self.app().actor_root_count(),
334            "open_routes": self.app().open_route_count(),
335            "callgraph_commits_60s_total": callgraph_write_metrics_total.commits_60s,
336            "callgraph_pages_or_bytes_written_60s_total": callgraph_write_metrics_total
337                .pages_or_bytes_written_60s,
338        });
339        if callgraph_write_metrics.commits_60s > 0 {
340            runtime["callgraph_commits_60s"] =
341                serde_json::json!(callgraph_write_metrics.commits_60s);
342        }
343        if callgraph_write_metrics.pages_or_bytes_written_60s > 0 {
344            runtime["callgraph_pages_or_bytes_written_60s"] =
345                serde_json::json!(callgraph_write_metrics.pages_or_bytes_written_60s);
346        }
347
348        serde_json::json!({
349            "version": env!("CARGO_PKG_VERSION"),
350            "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
351            "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
352            // Machine field. Human renderers must treat worktree/read_only as a
353            // shared-index borrow, never as a degraded_reasons entry.
354            "cache_role": self.cache_role(),
355            "artifact_owner": artifact_owner,
356            "degraded": degraded,
357            "degraded_reasons": degraded_reasons,
358            "features": {
359                "format_on_edit": config.format_on_edit,
360                "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
361                "restrict_to_project_root": config.restrict_to_project_root,
362                "search_index": config.search_index,
363                "semantic_search": config.semantic_search,
364                "callgraph_store": config.callgraph_store,
365                "backup": backups_enabled,
366            },
367            "search_index": search_index_info,
368            "semantic_index": semantic_index_info,
369            "status_bar": status_bar,
370            "disk": disk_info,
371            "lsp_servers": lsp_count,
372            "symbol_cache": symbol_cache_stats,
373            "memory": memory,
374            "runtime": runtime,
375            "compression": compression,
376            "storage_dir": storage_dir,
377            // Project-wide (all sessions): total in-memory checkpoint count.
378            "checkpoints_total": checkpoint_total,
379            // Current session slice: only when the caller passed `session_id`.
380            "session": {
381                "id": session_id,
382                "tracked_files": session_tracked_files,
383                "checkpoints": session_checkpoints,
384            },
385        })
386    }
387
388    fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
389        let mut compression = CompressionStats::default();
390        let Some(project_root) = self.config().project_root.clone() else {
391            return compression;
392        };
393        let Some(db) = self.db() else {
394            return compression;
395        };
396        let Ok(conn) = db.lock() else {
397            return compression;
398        };
399
400        let harness = self.harness().storage_segment();
401        let project_key = crate::path_identity::project_scope_key(&project_root);
402        if let Ok((project, session)) = self.compression_aggregate_cache().aggregates_for_session(
403            &conn,
404            &harness,
405            &project_key,
406            session_id,
407        ) {
408            compression.project = project.into();
409            compression.session = session.into();
410        }
411
412        compression
413    }
414}
415
416/// Recursively compute the total size of a directory.
417fn dir_size(path: &std::path::Path) -> u64 {
418    if !path.exists() {
419        return 0;
420    }
421    dir_size_recursive(path)
422}
423
424fn dir_size_recursive(path: &std::path::Path) -> u64 {
425    let mut total = 0u64;
426    let entries = match std::fs::read_dir(path) {
427        Ok(e) => e,
428        Err(_) => return 0,
429    };
430    for entry in entries.flatten() {
431        let ft = match entry.file_type() {
432            Ok(ft) => ft,
433            Err(_) => continue,
434        };
435        if ft.is_file() {
436            total += entry.metadata().map(|m| m.len()).unwrap_or(0);
437        } else if ft.is_dir() {
438            total += dir_size_recursive(&entry.path());
439        }
440    }
441    total
442}
443
444#[cfg(test)]
445mod tests {
446    use super::handle_status;
447    use crate::config::Config;
448    use crate::context::AppContext;
449    use crate::parser::TreeSitterProvider;
450    use crate::protocol::RawRequest;
451    use serde_json::json;
452
453    fn request() -> RawRequest {
454        RawRequest {
455            id: "status".to_string(),
456            command: "status".to_string(),
457            lsp_hints: None,
458            session_id: None,
459            params: json!({}),
460        }
461    }
462
463    #[test]
464    fn removal_status_reports_an_empty_storage_root_as_zero_state() {
465        let storage = tempfile::tempdir().expect("create storage root");
466        let request = RawRequest {
467            params: json!({ "removal_storage_dir": storage.path() }),
468            ..request()
469        };
470
471        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
472        let response = handle_status(&request, &ctx);
473
474        assert_eq!(response.data["removal"]["available"], true);
475        assert_eq!(response.data["removal"]["project_roots_served"], 0);
476        assert_eq!(response.data["removal"]["sessions_served"], 0);
477        assert_eq!(response.data["removal"]["running_background_tasks"], 0);
478        assert_eq!(response.data["removal"]["undo_history_sessions"], 0);
479    }
480
481    #[test]
482    fn status_exposes_cache_role_and_canonical_root() {
483        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
484        let response = handle_status(&request(), &ctx);
485        assert_eq!(response.data["cache_role"], "not_initialized");
486        assert!(response.data["canonical_root"].is_null());
487        assert!(response.data["runtime"]["callgraph_commits_60s_total"].is_u64());
488        assert!(response.data["runtime"]["callgraph_pages_or_bytes_written_60s_total"].is_u64());
489
490        let temp = tempfile::tempdir().unwrap();
491        ctx.update_config(|config| {
492            config.project_root = Some(temp.path().to_path_buf());
493        });
494        ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
495        ctx.set_cache_role(false, None);
496        let response = handle_status(&request(), &ctx);
497        assert_eq!(response.data["cache_role"], "main");
498        assert!(response.data["canonical_root"].as_str().is_some());
499
500        ctx.set_cache_role(true, None);
501        let response = handle_status(&request(), &ctx);
502        assert_eq!(response.data["cache_role"], "worktree");
503    }
504
505    #[test]
506    fn memory_snapshot_reports_contended_subsystem_as_busy() {
507        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
508        let _semantic_writer = ctx.semantic_index().write().unwrap();
509        let status = ctx.build_status_snapshot();
510        assert_eq!(status["semantic_index"]["status"], "busy");
511        assert_eq!(
512            status["memory"]["roots"]["<unconfigured>"]["semantic"]["status"],
513            "busy"
514        );
515        assert_eq!(status["memory"]["process"]["sqlite"]["status"], "measured");
516        assert!(status["memory"]["process"]["allocator"]["status"].is_string());
517        assert!(status["memory"]["process"]["allocator"]
518            .get("retained_slack_bytes")
519            .is_some());
520    }
521
522    #[test]
523    fn status_exposes_live_semantic_build_progress_only_while_building() {
524        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
525        let progress = crate::context::SemanticBuildProgress::default();
526        progress.report(3, 10, 2);
527        ctx.set_semantic_build_progress(Some(progress));
528        *ctx.semantic_index_status().write().unwrap() =
529            crate::context::SemanticIndexStatus::Building {
530                stage: "embedding_symbols".to_string(),
531                files: Some(1),
532                entries_done: Some(3),
533                entries_total: Some(10),
534            };
535
536        let building = ctx.build_status_snapshot();
537        let semantic = &building["semantic_index"];
538        assert_eq!(semantic["stage"], "embedding_symbols");
539        assert_eq!(semantic["embedded_chunks"], 3);
540        assert_eq!(semantic["total_chunks"], 10);
541        assert_eq!(semantic["current_batch"], 2);
542        assert_eq!(semantic["total_batches"], 5);
543
544        ctx.set_semantic_build_progress(None);
545        *ctx.semantic_index_status().write().unwrap() =
546            crate::context::SemanticIndexStatus::ready();
547        let ready = ctx.build_status_snapshot();
548        assert!(ready["semantic_index"].get("embedded_chunks").is_none());
549        assert!(ready["semantic_index"].get("total_chunks").is_none());
550    }
551
552    #[test]
553    fn status_status_bar_is_null_until_tier2_populated() {
554        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
555        let response = handle_status(&request(), &ctx);
556        // No Tier-2 scan has run yet, so the status-bar glance must be null
557        // (never fabricated zeros). The key is always present so the TS
558        // coercion can distinguish "field absent" from "not populated".
559        assert!(response.data.get("status_bar").is_some());
560        assert!(response.data["status_bar"].is_null());
561
562        // Once Tier-2 counts are populated, the snapshot carries the glance.
563        ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
564        let response = handle_status(&request(), &ctx);
565        assert_eq!(response.data["status_bar"]["dead_code"], 3);
566        assert_eq!(response.data["status_bar"]["unused_exports"], 2);
567        assert_eq!(response.data["status_bar"]["duplicates"], 1);
568        assert_eq!(response.data["status_bar"]["tier2_stale"], false);
569    }
570}