huskies: merge 1209 story Gateway lifecycle & telemetry MCP: gateway_info, restart_gateway, gateway_logs, start_story, chat_telemetry

This commit is contained in:
Huskies Agent
2026-07-18 02:21:05 +00:00
parent 3b10b29ef5
commit 8a7bff71aa
14 changed files with 584 additions and 4 deletions
+211
View File
@@ -33,6 +33,12 @@ const GATEWAY_TOOLS: &[&str] = &[
"fleet_resources",
// Read sled identity pins vs. live signed identity, and TOFU re-pin.
"fleet_identity",
// Gateway process pid/build/version/uptime/configured model (story 1209).
"gateway_info",
// Bounce the gateway process itself, never touching project containers (story 1209).
"restart_gateway",
// Tail/grep the gateway's own in-process log, distinct from sled get_server_logs (story 1209).
"gateway_logs",
];
/// Gateway tool definitions.
@@ -201,6 +207,39 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
}
}
}),
json!({
"name": "gateway_info",
"description": "Return the running gateway process's pid, build hash, version, uptime in seconds, and configured Claude Code model (from the gateway's own bot.toml).",
"inputSchema": {
"type": "object",
"properties": {}
}
}),
json!({
"name": "restart_gateway",
"description": "Safely bounce the gateway process itself (flush persisted state, then exit so Docker's restart policy relaunches the container). Never touches or removes any registered project's container — use project_rebuild for that.",
"inputSchema": {
"type": "object",
"properties": {}
}
}),
json!({
"name": "gateway_logs",
"description": "Tail/grep the gateway process's own in-process log (matrix bot, sled-uplink, poller activity) — distinct from get_server_logs, which (when proxied through the gateway) returns the active project's own log instead.",
"inputSchema": {
"type": "object",
"properties": {
"lines": {
"type": "integer",
"description": "Number of lines to return (default 100, max 1000)."
},
"filter": {
"type": "string",
"description": "Optional substring filter applied to each log line (e.g. 'matrix', 'uplink', 'poller')."
}
}
}
}),
]
}
@@ -536,6 +575,9 @@ async fn handle_gateway_tool(
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
"fleet_resources" => handle_fleet_resources_tool(params, state, id).await,
"fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
"gateway_info" => handle_gateway_info_tool(state, id).await,
"restart_gateway" => handle_restart_gateway_tool(state, id).await,
"gateway_logs" => handle_gateway_logs_tool(params, id),
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
}
}
@@ -1158,6 +1200,92 @@ async fn handle_fleet_identity_tool(
}
}
/// Handle the `gateway_info` gateway tool (story 1209).
///
/// Returns the running gateway process's pid, build hash, version, uptime,
/// and configured Claude Code model — read from the gateway's own `bot.toml`,
/// not any registered project's.
async fn handle_gateway_info_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
let build_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
let fields = gateway::io::read_bot_config_raw(&state.config_dir);
let info = json!({
"pid": std::process::id(),
"version": env!("CARGO_PKG_VERSION"),
"build_hash": build_hash,
"uptime_secs": gateway::gateway_uptime_secs(),
"configured_model": fields.model,
});
JsonRpcResponse::success(
id,
json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&info).unwrap_or_default()
}],
"info": info,
}),
)
}
/// Handle the `restart_gateway` gateway tool (story 1209).
///
/// Notifies any bot channels that the gateway is going offline for a
/// restart, then bounces the gateway process itself: flushes persisted
/// state and exits so Docker's restart policy relaunches the container.
/// Deliberately touches only the gateway's own state — `state.projects`
/// (and therefore any project container) is never read or written here, so
/// no project container is ever removed or swapped by this tool.
///
/// The actual exit is spawned in the background so the JSON-RPC response
/// reaches the caller first; consequently, like `upgrade_and_reexec`, the
/// exit itself is not unit-tested (`std::process::exit` would kill the test
/// process) — only the notify step and response shape are covered.
async fn handle_restart_gateway_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
if let Some(tx) = state.bot_shutdown_tx.lock().await.as_ref() {
let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
}
let config_dir = state.config_dir.clone();
tokio::spawn(async move {
// Give the bot task a moment to post its "going offline" message
// before the process flushes state and exits.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
crate::rebuild::drain_and_exit(&config_dir, "restart_gateway").await
});
JsonRpcResponse::success(
id,
json!({
"content": [{
"type": "text",
"text": "Gateway restart triggered. The gateway container will restart momentarily; no project containers are affected."
}]
}),
)
}
/// Handle the `gateway_logs` gateway tool (story 1209).
///
/// Reads directly from the gateway process's own in-process log ring buffer
/// (`log_buffer::global()`) — the same buffer the gateway's own `slog!`
/// calls (matrix bot, sled-uplink, poller) write to. This is distinct from
/// `get_server_logs`, which is not a gateway tool and therefore proxies to
/// the active project's own log buffer instead.
fn handle_gateway_logs_tool(params: &Value, id: Option<Value>) -> JsonRpcResponse {
let args = params.get("arguments").unwrap_or(params);
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 recent = crate::log_buffer::global().get_recent(lines_count, filter, None);
let text = recent.join("\n");
JsonRpcResponse::success(id, json!({ "content": [{ "type": "text", "text": text }] }))
}
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
/// shape expected by the gateway web UI:
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
@@ -1403,6 +1531,89 @@ mod tests {
assert!(GATEWAY_TOOLS.contains(&"fleet_identity"));
}
// ── gateway_info / restart_gateway / gateway_logs (story 1209) ──────────
#[tokio::test]
async fn gateway_info_tool_returns_pid_version_and_uptime() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state(dir.path());
let resp = handle_gateway_info_tool(&state, Some(json!(1))).await;
assert!(resp.error.is_none(), "expected success: {:?}", resp.error);
let info = &resp.result.unwrap()["info"];
assert_eq!(info["pid"], std::process::id());
assert_eq!(info["version"], env!("CARGO_PKG_VERSION"));
assert!(info["uptime_secs"].is_u64());
assert!(info["build_hash"].is_string());
}
#[tokio::test]
async fn gateway_info_tool_reports_configured_model_from_bot_toml() {
let dir = tempfile::tempdir().unwrap();
let huskies_dir = dir.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
std::fs::write(
huskies_dir.join("bot.toml"),
"model = \"claude-opus-4-8\"\n",
)
.unwrap();
let state = make_test_state(dir.path());
let resp = handle_gateway_info_tool(&state, Some(json!(1))).await;
let info = &resp.result.unwrap()["info"];
assert_eq!(info["configured_model"], "claude-opus-4-8");
}
#[test]
fn restart_gateway_is_registered_as_a_gateway_tool() {
// The handler triggers `std::process::exit` on a background task after
// a short delay — it is deliberately never invoked from a test (doing
// so risks terminating the entire test binary if the spawned task
// outlives the test's runtime). Only registration/schema wiring is
// verified here, matching the existing lack of coverage for
// `upgrade_and_reexec` (same shape, same reason).
assert!(GATEWAY_TOOLS.contains(&"restart_gateway"));
let defs = gateway_tool_definitions();
assert!(
defs.iter().any(|d| d["name"] == "restart_gateway"),
"restart_gateway must appear in gateway_tool_definitions()"
);
}
#[test]
fn gateway_logs_is_in_gateway_tools() {
assert!(GATEWAY_TOOLS.contains(&"gateway_logs"));
}
#[test]
fn gateway_logs_tool_returns_recent_lines() {
crate::slog!("gateway_logs_tool_marker_alpha");
let resp = handle_gateway_logs_tool(&json!({"arguments": {"lines": 500}}), Some(json!(1)));
assert!(resp.error.is_none(), "expected success: {:?}", resp.error);
let text = resp.result.unwrap()["content"][0]["text"]
.as_str()
.unwrap()
.to_string();
assert!(
text.contains("gateway_logs_tool_marker_alpha"),
"expected marker line in output: {text}"
);
}
#[test]
fn gateway_logs_tool_applies_filter() {
crate::slog!("gateway_logs_tool_marker_beta");
crate::slog!("unrelated_other_line");
let resp = handle_gateway_logs_tool(
&json!({"arguments": {"lines": 500, "filter": "gateway_logs_tool_marker_beta"}}),
Some(json!(1)),
);
let text = resp.result.unwrap()["content"][0]["text"]
.as_str()
.unwrap()
.to_string();
assert!(text.contains("gateway_logs_tool_marker_beta"));
assert!(!text.contains("unrelated_other_line"));
}
// ── explicit per-call `project` targeting (story 1208 AC 1) ─────────────
#[tokio::test]