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 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 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 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 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 let lsp_count = self.lsp_server_count();
206
207 let symbol_cache_stats = self.symbol_cache_stats();
209
210 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 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 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 "cache_role": self.cache_role(),
295 "artifact_owner": artifact_owner,
296 "degraded": degraded,
297 "degraded_reasons": degraded_reasons,
298 "features": {
299 "format_on_edit": config.format_on_edit,
300 "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
301 "restrict_to_project_root": config.restrict_to_project_root,
302 "search_index": config.search_index,
303 "semantic_search": config.semantic_search,
304 "callgraph_store": config.callgraph_store,
305 "backup": backups_enabled,
306 },
307 "search_index": search_index_info,
308 "semantic_index": semantic_index_info,
309 "status_bar": status_bar,
310 "disk": disk_info,
311 "lsp_servers": lsp_count,
312 "symbol_cache": symbol_cache_stats,
313 "memory": memory,
314 "runtime": runtime,
315 "compression": compression,
316 "storage_dir": storage_dir,
317 "checkpoints_total": checkpoint_total,
319 "session": {
321 "id": session_id,
322 "tracked_files": session_tracked_files,
323 "checkpoints": session_checkpoints,
324 },
325 })
326 }
327
328 fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
329 let mut compression = CompressionStats::default();
330 let Some(project_root) = self.config().project_root.clone() else {
331 return compression;
332 };
333 let Some(db) = self.db() else {
334 return compression;
335 };
336 let Ok(conn) = db.lock() else {
337 return compression;
338 };
339
340 let harness = self.harness().storage_segment();
341 let project_key = crate::path_identity::project_scope_key(&project_root);
342 if let Ok((project, session)) = self.compression_aggregate_cache().aggregates_for_session(
343 &conn,
344 &harness,
345 &project_key,
346 session_id,
347 ) {
348 compression.project = project.into();
349 compression.session = session.into();
350 }
351
352 compression
353 }
354}
355
356fn dir_size(path: &std::path::Path) -> u64 {
358 if !path.exists() {
359 return 0;
360 }
361 dir_size_recursive(path)
362}
363
364fn dir_size_recursive(path: &std::path::Path) -> u64 {
365 let mut total = 0u64;
366 let entries = match std::fs::read_dir(path) {
367 Ok(e) => e,
368 Err(_) => return 0,
369 };
370 for entry in entries.flatten() {
371 let ft = match entry.file_type() {
372 Ok(ft) => ft,
373 Err(_) => continue,
374 };
375 if ft.is_file() {
376 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
377 } else if ft.is_dir() {
378 total += dir_size_recursive(&entry.path());
379 }
380 }
381 total
382}
383
384#[cfg(test)]
385mod tests {
386 use super::handle_status;
387 use crate::config::Config;
388 use crate::context::AppContext;
389 use crate::parser::TreeSitterProvider;
390 use crate::protocol::RawRequest;
391 use serde_json::json;
392
393 fn request() -> RawRequest {
394 RawRequest {
395 id: "status".to_string(),
396 command: "status".to_string(),
397 lsp_hints: None,
398 session_id: None,
399 params: json!({}),
400 }
401 }
402
403 #[test]
404 fn status_exposes_cache_role_and_canonical_root() {
405 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
406 let response = handle_status(&request(), &ctx);
407 assert_eq!(response.data["cache_role"], "not_initialized");
408 assert!(response.data["canonical_root"].is_null());
409 assert!(response.data["runtime"]["callgraph_commits_60s_total"].is_u64());
410 assert!(response.data["runtime"]["callgraph_pages_or_bytes_written_60s_total"].is_u64());
411
412 let temp = tempfile::tempdir().unwrap();
413 ctx.update_config(|config| {
414 config.project_root = Some(temp.path().to_path_buf());
415 });
416 ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
417 ctx.set_cache_role(false, None);
418 let response = handle_status(&request(), &ctx);
419 assert_eq!(response.data["cache_role"], "main");
420 assert!(response.data["canonical_root"].as_str().is_some());
421
422 ctx.set_cache_role(true, None);
423 let response = handle_status(&request(), &ctx);
424 assert_eq!(response.data["cache_role"], "worktree");
425 }
426
427 #[test]
428 fn memory_snapshot_reports_contended_subsystem_as_busy() {
429 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
430 let _semantic_writer = ctx.semantic_index().write().unwrap();
431 let status = ctx.build_status_snapshot();
432 assert_eq!(status["semantic_index"]["status"], "busy");
433 assert_eq!(
434 status["memory"]["roots"]["<unconfigured>"]["semantic"]["status"],
435 "busy"
436 );
437 assert_eq!(status["memory"]["process"]["sqlite"]["status"], "measured");
438 assert!(status["memory"]["process"]["allocator"]["status"].is_string());
439 assert!(status["memory"]["process"]["allocator"]
440 .get("retained_slack_bytes")
441 .is_some());
442 }
443
444 #[test]
445 fn status_status_bar_is_null_until_tier2_populated() {
446 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
447 let response = handle_status(&request(), &ctx);
448 assert!(response.data.get("status_bar").is_some());
452 assert!(response.data["status_bar"].is_null());
453
454 ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
456 let response = handle_status(&request(), &ctx);
457 assert_eq!(response.data["status_bar"]["dead_code"], 3);
458 assert_eq!(response.data["status_bar"]["unused_exports"], 2);
459 assert_eq!(response.data["status_bar"]["duplicates"], 1);
460 assert_eq!(response.data["status_bar"]["tier2_stale"], false);
461 }
462}