Diagnostics (#5323)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-10-22 14:25:49 -04:00
committed by GitHub
parent fdbd5281e2
commit 755e9f893d
16 changed files with 450 additions and 34 deletions
+15 -1
View File
@@ -135,7 +135,7 @@ enum SessionCommand {
#[arg(short, long, help = "Regex for removing matched sessions (optional)")]
regex: Option<String>,
},
#[command(about = "Export a session to Markdown format")]
#[command(about = "Export a session")]
Export {
#[command(flatten)]
identifier: Option<Identifier>,
@@ -156,6 +156,16 @@ enum SessionCommand {
)]
format: String,
},
#[command(name = "diagnostics")]
Diagnostics {
/// Session ID to generate diagnostics for
#[arg(short, long)]
session_id: String,
/// Output path for the diagnostics zip file (optional, defaults to current directory)
#[arg(short, long)]
output: Option<PathBuf>,
},
}
#[derive(Subcommand, Debug)]
@@ -847,6 +857,10 @@ pub async fn cli() -> Result<()> {
.await?;
Ok(())
}
Some(SessionCommand::Diagnostics { session_id, output }) => {
crate::commands::session::handle_diagnostics(&session_id, output).await?;
Ok(())
}
None => {
let session_start = std::time::Instant::now();
let session_type = if resume { "resumed" } else { "new" };
+35 -5
View File
@@ -2,10 +2,11 @@ use crate::session::message_to_markdown;
use anyhow::{Context, Result};
use cliclack::{confirm, multiselect, select};
use goose::session::{Session, SessionManager};
use goose::session::{generate_diagnostics, Session, SessionManager};
use goose::utils::safe_truncate;
use regex::Regex;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
const TRUNCATED_DESC_LENGTH: usize = 60;
@@ -204,10 +205,39 @@ pub async fn handle_session_export(
Ok(())
}
/// Convert a list of messages to markdown format for session export
///
/// This function handles the formatting of a complete session including headers,
/// message organization, and proper tool request/response pairing.
pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
println!(
"Generating diagnostics bundle for session '{}'...",
session_id
);
let diagnostics_data = generate_diagnostics(session_id).await.with_context(|| {
format!(
"Failed to write to generate diagnostics bundle for session '{}'",
session_id
)
})?;
let output_file = if let Some(path) = output_path {
path.clone()
} else {
PathBuf::from(format!("diagnostics_{}.zip", session_id))
};
let mut file = fs::File::create(&output_file).context(format!(
"Failed to create output file: {}",
output_file.display()
))?;
file.write_all(&diagnostics_data)
.context("Failed to write diagnostics data")?;
println!("Diagnostics bundle saved to: {}", output_file.display());
Ok(())
}
fn export_session_to_markdown(
messages: Vec<goose::conversation::message::Message>,
session_name: &String,
-1
View File
@@ -33,7 +33,6 @@ base64 = "0.21"
config = { version = "0.14.1", features = ["toml"] }
thiserror = "1.0"
clap = { version = "4.4", features = ["derive"] }
etcetera = "0.8.0"
serde_yaml = "0.9.34"
utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "multipart"], default-features = false }
+2 -1
View File
@@ -322,7 +322,8 @@ derive_utoipa!(Icon as IconSchema);
#[derive(OpenApi)]
#[openapi(
paths(
super::routes::health::status,
super::routes::status::status,
super::routes::status::diagnostics,
super::routes::config_management::backup_config,
super::routes::config_management::recover_config,
super::routes::config_management::validate_config,
-14
View File
@@ -1,14 +0,0 @@
use axum::{routing::get, Router};
#[utoipa::path(get, path = "/status",
responses(
(status = 200, description = "ok", body = String),
)
)]
async fn status() -> String {
"ok".to_string()
}
pub fn routes() -> Router {
Router::new().route("/status", get(status))
}
+2 -2
View File
@@ -4,13 +4,13 @@ pub mod config_management;
pub mod context;
pub mod errors;
pub mod extension;
pub mod health;
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
pub mod schedule;
pub mod session;
pub mod setup;
pub mod status;
pub mod utils;
use std::sync::Arc;
@@ -19,7 +19,7 @@ use axum::Router;
// Function to configure all routes
pub fn configure(state: Arc<crate::state::AppState>) -> Router {
Router::new()
.merge(health::routes())
.merge(status::routes())
.merge(reply::routes(state.clone()))
.merge(agent::routes(state.clone()))
.merge(audio::routes(state.clone()))
+46
View File
@@ -0,0 +1,46 @@
use axum::body::Body;
use axum::http::HeaderValue;
use axum::response::IntoResponse;
use axum::{extract::Path, http::StatusCode, routing::get, Router};
use goose::session::generate_diagnostics;
#[utoipa::path(get, path = "/status",
responses(
(status = 200, description = "ok", body = String),
)
)]
async fn status() -> String {
"ok".to_string()
}
#[utoipa::path(get, path = "/diagnostics/{session_id}",
responses(
(status = 200, description = "Diagnostics zip file", content_type = "application/zip", body = Vec<u8>),
(status = 500, description = "Failed to generate diagnostics"),
)
)]
async fn diagnostics(Path(session_id): Path<String>) -> impl IntoResponse {
match generate_diagnostics(&session_id).await {
Ok(zip_data) => {
let filename = format!("attachment; filename=\"diagnostics_{}.zip\"", session_id);
let headers = [
(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/zip"),
),
(
http::header::CONTENT_DISPOSITION,
HeaderValue::from_str(&filename).map_err(|_e| StatusCode::BAD_REQUEST)?,
),
];
Ok((headers, Body::from(zip_data)))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub fn routes() -> Router {
Router::new()
.route("/status", get(status))
.route("/diagnostics/{session_id}", get(diagnostics))
}
+3
View File
@@ -99,11 +99,14 @@ dashmap = "6.1"
ahash = "0.8"
tokio-util = "0.7.15"
unicode-normalization = "0.1"
zip = "0.6"
sys-info = "0.9"
oauth2 = "5.0.0"
schemars = { version = "1.0.4", default-features = false, features = ["derive"] }
insta = "1.43.2"
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
+62
View File
@@ -0,0 +1,62 @@
use crate::config::paths::Paths;
use crate::session::SessionManager;
use std::fs::{self};
use std::io::Cursor;
use std::io::Write;
use zip::write::FileOptions;
use zip::ZipWriter;
pub async fn generate_diagnostics(session_id: &str) -> anyhow::Result<Vec<u8>> {
let logs_dir = Paths::in_state_dir("logs");
let config_dir = Paths::config_dir();
let config_path = config_dir.join("config.yaml");
let system_info = format!(
"App Version: {}\n\
OS: {}\n\
OS Version: {}\n\
Architecture: {}\n\
Timestamp: {}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
sys_info::os_release().unwrap_or_else(|_| "unknown".to_string()),
std::env::consts::ARCH,
chrono::Utc::now().to_rfc3339()
);
let mut buffer = Vec::new();
{
let mut zip = ZipWriter::new(Cursor::new(&mut buffer));
let options = FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut log_files: Vec<_> = fs::read_dir(&logs_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
.collect();
log_files.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
for entry in log_files.iter().rev().take(3) {
let path = entry.path();
let name = path.file_name().unwrap().to_str().unwrap();
zip.start_file(format!("logs/{}", name), options)?;
zip.write_all(&fs::read(&path)?)?;
}
let session_data = SessionManager::export_session(session_id).await?;
zip.start_file("session.json", options)?;
zip.write_all(session_data.as_bytes())?;
if config_path.exists() {
zip.start_file("config.yaml", options)?;
zip.write_all(&fs::read(&config_path)?)?;
}
zip.start_file("system.txt", options)?;
zip.write_all(system_info.as_bytes())?;
zip.finish()?;
}
Ok(buffer)
}
+2
View File
@@ -1,6 +1,8 @@
mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod session_manager;
pub use diagnostics::generate_diagnostics;
pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState};
pub use session_manager::{Session, SessionInsights, SessionManager};