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        let (backup_skipped_too_large_total, backup_skipped_temp_path_total) =
289            crate::backup::backup_skipped_totals();
290
291        // Degraded-mode reasons recorded by `handle_configure` when the
292        // project root doesn't look like a real project (`home_root`). Heavy
293        // subsystems are auto-disabled in that mode; the plugin / TUI sidebar
294        // surface the reason so users know why and can decide whether to open a
295        // project subdirectory. Empty list = full-featured mode.
296        let degraded_reasons = self.degraded_reasons();
297        let degraded = !degraded_reasons.is_empty();
298        let artifact_owner = self
299            .artifact_owner_status()
300            .map(|status| serde_json::to_value(status).unwrap_or(serde_json::Value::Null))
301            .unwrap_or(serde_json::Value::Null);
302
303        // Agent status-bar counts (the `[AFT E· W· | D· U· C· | T·]` glance).
304        // Surfaced for the TUI sidebar so users see the same code-health view
305        // agents get. `None` until the Tier-2 cache is populated at least once
306        // (so we never render fabricated zeros) — emitted as JSON null then,
307        // and the sidebar hides the section.
308        let status_bar = match self.status_bar_counts() {
309            Some(counts) => serde_json::json!({
310                "errors": counts.errors,
311                "warnings": counts.warnings,
312                "dead_code": counts.dead_code,
313                "unused_exports": counts.unused_exports,
314                "duplicates": counts.duplicates,
315                "todos": counts.todos,
316                "tier2_stale": counts.tier2_stale,
317            }),
318            None => serde_json::Value::Null,
319        };
320        let memory_root = self
321            .canonical_cache_root_opt()
322            .or_else(|| config.project_root.clone());
323        let callgraph_write_metrics = memory_root
324            .as_deref()
325            .and_then(|root| self.cached_artifact_cache_key(root))
326            .map(|project_key| {
327                crate::callgraph_store::callgraph_write_metrics_for_project(&project_key)
328            })
329            .unwrap_or_default();
330        let callgraph_write_metrics_total = crate::callgraph_store::callgraph_write_metrics_total();
331        // `MemorySnapshot::new` uses the process-wide allocator observation so
332        // status never walks allocator zones on a request worker.
333        let memory = serde_json::to_value(self.memory_snapshot(memory_root.as_deref()))
334            .unwrap_or(serde_json::Value::Null);
335        let watcher = serde_json::to_value(self.watcher_counters().snapshot())
336            .unwrap_or(serde_json::Value::Null);
337        // The control-path status response reads the health worker's published
338        // lifecycle snapshot; it never probes processes or opens a database.
339        let lifecycle = self.app().lifecycle_census_snapshot();
340        let mut runtime = serde_json::json!({
341            "live_watchers": self.app().watcher_count(),
342            "live_actor_roots": self.app().actor_root_count(),
343            "open_routes": self.app().open_route_count(),
344            "callgraph_commits_60s_total": callgraph_write_metrics_total.commits_60s,
345            "callgraph_pages_or_bytes_written_60s_total": callgraph_write_metrics_total
346                .pages_or_bytes_written_60s,
347        });
348        if callgraph_write_metrics.commits_60s > 0 {
349            runtime["callgraph_commits_60s"] =
350                serde_json::json!(callgraph_write_metrics.commits_60s);
351        }
352        if callgraph_write_metrics.pages_or_bytes_written_60s > 0 {
353            runtime["callgraph_pages_or_bytes_written_60s"] =
354                serde_json::json!(callgraph_write_metrics.pages_or_bytes_written_60s);
355        }
356
357        let mut payload = serde_json::json!({
358            "version": env!("CARGO_PKG_VERSION"),
359            "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
360            "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
361            // Machine field. Human renderers must treat worktree/read_only as a
362            // shared-index borrow, never as a degraded_reasons entry.
363            "cache_role": self.cache_role(),
364            "artifact_owner": artifact_owner,
365            "degraded": degraded,
366            "degraded_reasons": degraded_reasons,
367            "features": {
368                "format_on_edit": config.format_on_edit,
369                "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
370                "restrict_to_project_root": config.restrict_to_project_root,
371                "search_index": config.search_index,
372                "semantic_search": config.semantic_search,
373                "callgraph_store": config.callgraph_store,
374                "backup": backups_enabled,
375            },
376            "search_index": search_index_info,
377            "semantic_index": semantic_index_info,
378            "status_bar": status_bar,
379            "disk": disk_info,
380            "lsp_servers": lsp_count,
381            "symbol_cache": symbol_cache_stats,
382            "memory": memory,
383            "watcher": watcher,
384            "lsp": lifecycle.lsp,
385            "threads": lifecycle.threads,
386            "sqlite": lifecycle.sqlite,
387            "children": lifecycle.children,
388            "fds": lifecycle.fds,
389            "runtime": runtime,
390            "compression": compression,
391            "storage_dir": storage_dir,
392            // Project-wide (all sessions): total in-memory checkpoint count.
393            "checkpoints_total": checkpoint_total,
394            "backup_skipped_too_large_total": backup_skipped_too_large_total,
395            "backup_skipped_temp_path_total": backup_skipped_temp_path_total,
396            // Current session slice: only when the caller passed `session_id`.
397            "session": {
398                "id": session_id,
399                "tracked_files": session_tracked_files,
400                "checkpoints": session_checkpoints,
401            },
402        });
403        if config.views.enabled {
404            payload["views"] = serde_json::to_value(self.view_health_snapshot())
405                .unwrap_or(serde_json::Value::Null);
406        }
407        payload
408    }
409
410    fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
411        let mut compression = CompressionStats::default();
412        let Some(project_root) = self.config().project_root.clone() else {
413            return compression;
414        };
415        let Some(db) = self.db() else {
416            return compression;
417        };
418        let Ok(conn) = db.lock() else {
419            return compression;
420        };
421
422        let harness = self.harness().storage_segment();
423        let project_key = crate::path_identity::project_scope_key(&project_root);
424        if let Ok((project, session)) = self.compression_aggregate_cache().aggregates_for_session(
425            &conn,
426            &harness,
427            &project_key,
428            session_id,
429        ) {
430            compression.project = project.into();
431            compression.session = session.into();
432        }
433
434        compression
435    }
436}
437
438/// Recursively compute the total size of a directory.
439fn dir_size(path: &std::path::Path) -> u64 {
440    if !path.exists() {
441        return 0;
442    }
443    dir_size_recursive(path)
444}
445
446fn dir_size_recursive(path: &std::path::Path) -> u64 {
447    let mut total = 0u64;
448    let entries = match std::fs::read_dir(path) {
449        Ok(e) => e,
450        Err(_) => return 0,
451    };
452    for entry in entries.flatten() {
453        let ft = match entry.file_type() {
454            Ok(ft) => ft,
455            Err(_) => continue,
456        };
457        if ft.is_file() {
458            total += entry.metadata().map(|m| m.len()).unwrap_or(0);
459        } else if ft.is_dir() {
460            total += dir_size_recursive(&entry.path());
461        }
462    }
463    total
464}
465
466#[cfg(test)]
467mod tests {
468    use super::handle_status;
469    use crate::config::Config;
470    use crate::context::AppContext;
471    use crate::parser::TreeSitterProvider;
472    use crate::protocol::RawRequest;
473    use serde_json::json;
474
475    fn request() -> RawRequest {
476        RawRequest {
477            id: "status".to_string(),
478            command: "status".to_string(),
479            lsp_hints: None,
480            session_id: None,
481            params: json!({}),
482        }
483    }
484
485    #[test]
486    fn removal_status_reports_an_empty_storage_root_as_zero_state() {
487        let storage = tempfile::tempdir().expect("create storage root");
488        let request = RawRequest {
489            params: json!({ "removal_storage_dir": storage.path() }),
490            ..request()
491        };
492
493        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
494        let response = handle_status(&request, &ctx);
495
496        assert_eq!(response.data["removal"]["available"], true);
497        assert_eq!(response.data["removal"]["project_roots_served"], 0);
498        assert_eq!(response.data["removal"]["sessions_served"], 0);
499        assert_eq!(response.data["removal"]["running_background_tasks"], 0);
500        assert_eq!(response.data["removal"]["undo_history_sessions"], 0);
501    }
502
503    #[test]
504    fn status_exposes_cache_role_and_canonical_root() {
505        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
506        let response = handle_status(&request(), &ctx);
507        assert_eq!(response.data["cache_role"], "not_initialized");
508        assert!(response.data["canonical_root"].is_null());
509        assert!(response.data["runtime"]["callgraph_commits_60s_total"].is_u64());
510        assert!(response.data["runtime"]["callgraph_pages_or_bytes_written_60s_total"].is_u64());
511        assert_eq!(response.data["watcher"]["raw_events_total"], 0);
512        assert_eq!(response.data["watcher"]["raw_events_since_last_rescan"], 0);
513        assert_eq!(response.data["watcher"]["rescans_kernel_dropped_total"], 0);
514        assert!(response.data["backup_skipped_too_large_total"].is_u64());
515        assert!(response.data["backup_skipped_temp_path_total"].is_u64());
516
517        let temp = tempfile::tempdir().unwrap();
518        ctx.update_config(|config| {
519            config.project_root = Some(temp.path().to_path_buf());
520        });
521        ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
522        ctx.set_cache_role(false, None);
523        let response = handle_status(&request(), &ctx);
524        assert_eq!(response.data["cache_role"], "main");
525        assert!(response.data["canonical_root"].as_str().is_some());
526
527        ctx.set_cache_role(true, None);
528        let response = handle_status(&request(), &ctx);
529        assert_eq!(response.data["cache_role"], "worktree");
530    }
531
532    #[cfg(any(target_os = "macos", target_os = "linux"))]
533    #[test]
534    fn status_reuses_cached_allocator_observation_for_repeated_requests() {
535        let _allocator_test_lock = crate::memory::allocator_observation_test_lock();
536        crate::memory::reset_allocator_observation_for_test();
537        let _ =
538            crate::memory::MemorySnapshot::new_uncapped("ready", std::collections::BTreeMap::new());
539
540        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
541        let before = crate::memory::allocator_snapshot_calls_for_test();
542        for _ in 0..50 {
543            let response = handle_status(&request(), &ctx);
544            assert!(response.data["memory"]["process"]["allocator_slack_measured"].is_boolean());
545        }
546        let memory = handle_status(&request(), &ctx).data["memory"]["process"].clone();
547        assert_eq!(crate::memory::allocator_snapshot_calls_for_test(), before);
548        assert!(memory["allocator_observation_age_ms"].is_u64());
549    }
550
551    #[test]
552    fn status_reports_cold_allocator_observation_without_measuring() {
553        let _allocator_test_lock = crate::memory::allocator_observation_test_lock();
554        crate::memory::reset_allocator_observation_for_test();
555        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
556        let before = crate::memory::allocator_snapshot_calls_for_test();
557        let memory = handle_status(&request(), &ctx).data["memory"]["process"].clone();
558
559        assert_eq!(memory["allocator_slack_measured"], false);
560        assert!(memory["allocator_observation_age_ms"].is_null());
561        assert_eq!(crate::memory::allocator_snapshot_calls_for_test(), before);
562    }
563
564    #[test]
565    fn memory_snapshot_reports_contended_subsystem_as_busy() {
566        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
567        let _semantic_writer = ctx.semantic_index().write().unwrap();
568        let status = ctx.build_status_snapshot();
569        assert_eq!(status["semantic_index"]["status"], "busy");
570        assert_eq!(
571            status["memory"]["roots"]["<unconfigured>"]["semantic"]["status"],
572            "busy"
573        );
574        assert_eq!(status["memory"]["process"]["sqlite"]["status"], "measured");
575        assert!(status["memory"]["process"]["allocator"]["status"].is_string());
576        assert!(status["memory"]["process"]["allocator"]
577            .get("retained_slack_bytes")
578            .is_some());
579    }
580
581    #[test]
582    fn status_exposes_live_semantic_build_progress_only_while_building() {
583        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
584        let progress = crate::context::SemanticBuildProgress::default();
585        progress.report(3, 10, 2);
586        ctx.set_semantic_build_progress(Some(progress));
587        *ctx.semantic_index_status().write().unwrap() =
588            crate::context::SemanticIndexStatus::Building {
589                stage: "embedding_symbols".to_string(),
590                files: Some(1),
591                entries_done: Some(3),
592                entries_total: Some(10),
593            };
594
595        let building = ctx.build_status_snapshot();
596        let semantic = &building["semantic_index"];
597        assert_eq!(semantic["stage"], "embedding_symbols");
598        assert_eq!(semantic["embedded_chunks"], 3);
599        assert_eq!(semantic["total_chunks"], 10);
600        assert_eq!(semantic["current_batch"], 2);
601        assert_eq!(semantic["total_batches"], 5);
602
603        ctx.set_semantic_build_progress(None);
604        *ctx.semantic_index_status().write().unwrap() =
605            crate::context::SemanticIndexStatus::ready();
606        let ready = ctx.build_status_snapshot();
607        assert!(ready["semantic_index"].get("embedded_chunks").is_none());
608        assert!(ready["semantic_index"].get("total_chunks").is_none());
609    }
610
611    #[test]
612    fn status_status_bar_is_null_until_every_independent_producer_is_populated() {
613        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
614        let response = handle_status(&request(), &ctx);
615        // No Tier-2 scan has run yet, so the status-bar glance must be null
616        // (never fabricated zeros). The key is always present so the TS
617        // coercion can distinguish "field absent" from "not populated".
618        assert!(response.data.get("status_bar").is_some());
619        assert!(response.data["status_bar"].is_null());
620
621        // Tier-2 counts are only one input to the status bar. Keep `status_bar`
622        // null until diagnostics and all other required inputs have reported.
623        ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
624        let response = handle_status(&request(), &ctx);
625        assert!(response.data["status_bar"].is_null());
626    }
627}