1use 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
41fn 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 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 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 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 let lsp_count = self.lsp_server_count();
257
258 let symbol_cache_stats = self.symbol_cache_stats();
260
261 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 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 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 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 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 "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 "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 "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
438fn 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 assert!(response.data.get("status_bar").is_some());
619 assert!(response.data["status_bar"].is_null());
620
621 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}