refactor: split http/mcp/diagnostics.rs (861) into mod + permission + usage

The 861-line diagnostics.rs is split:

- permission.rs: tool_prompt_permission + helpers + their tests (584 lines)
- usage.rs: tool_get_token_usage + tests (122 lines)
- mod.rs: server_logs, rebuild, version, loc_file, dump_crdt, move_story + tests (185 lines)

Tests stay co-located. The bigger sub-modules (permission at 584 with tests
mostly under 800; usage at 122) are well within the 800-line guide.

Also added #[allow(unused_imports)] to two now-pedantic re-exports in
service/diagnostics/mod.rs that the split made flag.

All 2636 tests pass; clippy clean.
This commit is contained in:
dave
2026-04-27 01:51:36 +00:00
parent 9fbbfcd585
commit a8ead9cd10
4 changed files with 321 additions and 293 deletions
+179
View File
@@ -0,0 +1,179 @@
//! MCP diagnostic tools — server logs, CRDT dump, version, line counting, story movement.
use crate::agents::move_story_to_stage;
use crate::http::context::AppContext;
use crate::log_buffer;
use crate::slog;
use serde_json::{Value, json};
mod permission;
mod usage;
pub(crate) use permission::tool_prompt_permission;
pub(crate) use usage::tool_get_token_usage;
pub(crate) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
let lines_count = args
.get("lines")
.and_then(|v| v.as_u64())
.map(|n| n.min(1000) as usize)
.unwrap_or(100);
let filter = args.get("filter").and_then(|v| v.as_str());
let severity = args
.get("severity")
.and_then(|v| v.as_str())
.and_then(log_buffer::LogLevel::from_str_ci);
let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
let joined = recent.join("\n");
// Clamp to lines_count actual lines in case any entry contains embedded newlines.
let all_lines: Vec<&str> = joined.lines().collect();
let start = all_lines.len().saturating_sub(lines_count);
Ok(all_lines[start..].join("\n"))
}
/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
pub(crate) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
slog!("[rebuild] Rebuild and restart requested via MCP tool");
// Signal the Matrix bot (if active) so it can send its own shutdown
// announcement before the process is replaced. Best-effort: we wait up
// to 1.5 s for the message to be delivered.
if let Some(ref tx) = ctx.matrix_shutdown_tx {
let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
let project_root = ctx.state.get_project_root().unwrap_or_default();
let notifier = ctx.bot_shutdown.as_deref();
crate::rebuild::rebuild_and_restart(&ctx.services.agents, &project_root, notifier).await
}
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
///
/// Forwards the permission request through the shared channel to the active
/// WebSocket session, which presents a dialog to the user. Blocks until the
/// user approves or denies (with a 5-minute timeout).
pub(crate) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let target_stage = args
.get("target_stage")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: target_stage")?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"from_stage": from_stage,
"to_stage": to_stage,
"message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: dump the raw in-memory CRDT state for debugging.
///
/// **Debug tool only** — for normal pipeline introspection use `get_pipeline_status`.
pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
let story_id_filter = args.get("story_id").and_then(|v| v.as_str());
let dump = crate::crdt_state::dump_crdt_state(story_id_filter);
let items: Vec<Value> = dump
.items
.into_iter()
.map(|item| {
json!({
"story_id": item.story_id,
"stage": item.stage,
"name": item.name,
"agent": item.agent,
"retry_count": item.retry_count,
"blocked": item.blocked,
"depends_on": item.depends_on,
"claimed_by": item.claimed_by,
"claimed_at": item.claimed_at,
"content_index": item.content_index,
"is_deleted": item.is_deleted,
})
})
.collect();
serde_json::to_string_pretty(&json!({
"metadata": {
"in_memory_state_loaded": dump.in_memory_state_loaded,
"total_items": dump.total_items,
"total_ops_in_list": dump.total_ops_in_list,
"max_seq_in_list": dump.max_seq_in_list,
"persisted_ops_count": dump.persisted_ops_count,
"pending_persist_ops_count": null,
},
"items": items,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: return the server version, build hash, and running port.
pub(crate) fn tool_get_version(ctx: &AppContext) -> Result<String, String> {
let build_hash =
std::fs::read_to_string(".huskies/build_hash").unwrap_or_else(|_| "unknown".to_string());
serde_json::to_string_pretty(&json!({
"version": env!("CARGO_PKG_VERSION"),
"build_hash": build_hash.trim(),
"port": ctx.services.agents.port(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: count lines in a specific file relative to the project root.
pub(crate) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
let file_path = args
.get("file_path")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing required argument: file_path".to_string())?;
let project_root = ctx.state.get_project_root()?;
Ok(crate::chat::commands::loc::loc_single_file(
&project_root,
file_path,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_get_server_logs_no_args_returns_string() {
let result = tool_get_server_logs(&json!({})).unwrap();
// Returns recent log lines (possibly empty in tests) — just verify no panic
let _ = result;
}
#[test]
fn tool_get_server_logs_with_filter_returns_matching_lines() {
let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
assert_eq!(
result, "",
"filter with no matches should return empty string"
);
}
#[test]
fn tool_get_server_logs_with_line_limit() {
let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
assert!(result.lines().count() <= 5);
}
#[test]
fn tool_get_server_logs_max_cap_is_1000() {
// Lines > 1000 are capped — just verify it returns without error
let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
let _ = result;
}
}