Improve conversation logs rendering (#238)

* pull all logs in one request

* render only the last 100 entries from the conversation

* cleanup

* fix diffs jumping on update

* fix SSE to not loose new logs on reconnect

* fmt

* small refactoring

* remove obsolete /normalized-logs endpoint
This commit is contained in:
Anastasiia Solop
2025-07-17 17:12:51 +02:00
committed by GitHub
parent 74482375a9
commit ddc692fa77
14 changed files with 878 additions and 732 deletions

View File

@@ -111,6 +111,7 @@ fn generate_types_content() -> String {
vibe_kanban::routes::filesystem::DirectoryEntry::decl(),
vibe_kanban::routes::filesystem::DirectoryListResponse::decl(),
vibe_kanban::routes::auth::DeviceStartResponse::decl(),
vibe_kanban::routes::task_attempts::ProcessLogsResponse::decl(),
vibe_kanban::models::task_attempt::DiffChunkType::decl(),
vibe_kanban::models::task_attempt::DiffChunk::decl(),
vibe_kanban::models::task_attempt::FileDiff::decl(),

View File

@@ -7,6 +7,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use ts_rs::TS;
use uuid::Uuid;
use crate::{
@@ -16,8 +17,9 @@ use crate::{
},
models::{
config::Config,
execution_process::{ExecutionProcess, ExecutionProcessSummary, ExecutionProcessType},
executor_session::ExecutorSession,
execution_process::{
ExecutionProcess, ExecutionProcessStatus, ExecutionProcessSummary, ExecutionProcessType,
},
task::{Task, TaskStatus},
task_attempt::{
BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt,
@@ -46,6 +48,176 @@ pub struct FollowUpResponse {
pub created_new_attempt: bool,
}
#[derive(Debug, Serialize, TS)]
#[ts(export)]
pub struct ProcessLogsResponse {
pub id: Uuid,
pub process_type: ExecutionProcessType,
pub command: String,
pub executor_type: Option<String>,
pub status: ExecutionProcessStatus,
pub normalized_conversation: NormalizedConversation,
}
// Helper to normalize logs for a process (extracted from get_execution_process_normalized_logs)
async fn normalize_process_logs(
db_pool: &SqlitePool,
process: &ExecutionProcess,
) -> NormalizedConversation {
use crate::models::{
execution_process::ExecutionProcessType, executor_session::ExecutorSession,
};
let executor_session = ExecutorSession::find_by_execution_process_id(db_pool, process.id)
.await
.ok()
.flatten();
let has_stdout = process
.stdout
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let has_stderr = process
.stderr
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !has_stdout && !has_stderr {
return NormalizedConversation {
entries: vec![],
session_id: None,
executor_type: process
.executor_type
.clone()
.unwrap_or("unknown".to_string()),
prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()),
summary: executor_session.as_ref().and_then(|s| s.summary.clone()),
};
}
// Parse stdout as JSONL using executor normalization
let mut stdout_entries = Vec::new();
if let Some(stdout) = &process.stdout {
if !stdout.trim().is_empty() {
let executor_type = process.executor_type.as_deref().unwrap_or("unknown");
let executor_config = if process.process_type == ExecutionProcessType::SetupScript {
ExecutorConfig::SetupScript {
script: executor_session
.as_ref()
.and_then(|s| s.prompt.clone())
.unwrap_or_else(|| "setup script".to_string()),
}
} else {
match executor_type.to_string().parse() {
Ok(config) => config,
Err(_) => {
return NormalizedConversation {
entries: vec![],
session_id: None,
executor_type: executor_type.to_string(),
prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()),
summary: executor_session.as_ref().and_then(|s| s.summary.clone()),
};
}
}
};
let executor = executor_config.create_executor();
let working_dir_path = match std::fs::canonicalize(&process.working_directory) {
Ok(canonical_path) => canonical_path.to_string_lossy().to_string(),
Err(_) => process.working_directory.clone(),
};
if let Ok(normalized) = executor.normalize_logs(stdout, &working_dir_path) {
stdout_entries = normalized.entries;
}
}
}
// Parse stderr chunks separated by boundary markers
let mut stderr_entries = Vec::new();
if let Some(stderr) = &process.stderr {
let trimmed = stderr.trim();
if !trimmed.is_empty() {
let chunks: Vec<&str> = trimmed.split("---STDERR_CHUNK_BOUNDARY---").collect();
for chunk in chunks {
let chunk_trimmed = chunk.trim();
if !chunk_trimmed.is_empty() {
let filtered_content = chunk_trimmed.replace("---STDERR_CHUNK_BOUNDARY---", "");
if !filtered_content.trim().is_empty() {
stderr_entries.push(NormalizedEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: NormalizedEntryType::ErrorMessage,
content: filtered_content.trim().to_string(),
metadata: None,
});
}
}
}
}
}
let mut all_entries = Vec::new();
all_entries.extend(stdout_entries);
all_entries.extend(stderr_entries);
all_entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
(Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
let executor_type = if process.process_type == ExecutionProcessType::SetupScript {
"setup-script".to_string()
} else {
process
.executor_type
.clone()
.unwrap_or("unknown".to_string())
};
NormalizedConversation {
entries: all_entries,
session_id: None,
executor_type,
prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()),
summary: executor_session.as_ref().and_then(|s| s.summary.clone()),
}
}
/// Get all normalized logs for all execution processes of a task attempt
pub async fn get_task_attempt_all_logs(
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
State(app_state): State<AppState>,
) -> Result<Json<ApiResponse<Vec<ProcessLogsResponse>>>, StatusCode> {
// Validate attempt belongs to task and project
let _ctx = match TaskAttempt::load_context(&app_state.db_pool, attempt_id, task_id, project_id)
.await
{
Ok(ctx) => ctx,
Err(_) => return Err(StatusCode::NOT_FOUND),
};
// Fetch all execution processes for this attempt
let processes =
match ExecutionProcess::find_by_task_attempt_id(&app_state.db_pool, attempt_id).await {
Ok(list) => list,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// For each process, normalize logs
let mut result = Vec::new();
for process in processes {
let normalized_conversation = normalize_process_logs(&app_state.db_pool, &process).await;
result.push(ProcessLogsResponse {
id: process.id,
process_type: process.process_type.clone(),
command: process.command.clone(),
executor_type: process.executor_type.clone(),
status: process.status.clone(),
normalized_conversation,
});
}
Ok(Json(ApiResponse {
success: true,
data: Some(result),
message: None,
}))
}
pub async fn get_task_attempts(
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
State(app_state): State<AppState>,
@@ -988,228 +1160,6 @@ pub async fn get_task_attempt_execution_state(
}
}
pub async fn get_execution_process_normalized_logs(
Path((project_id, process_id)): Path<(Uuid, Uuid)>,
State(app_state): State<AppState>,
) -> Result<ResponseJson<ApiResponse<NormalizedConversation>>, StatusCode> {
// Get the execution process and verify it belongs to the correct project
let process = match ExecutionProcess::find_by_id(&app_state.db_pool, process_id).await {
Ok(Some(process)) => process,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch execution process {}: {}", process_id, e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Verify the process belongs to a task attempt in the correct project
let attempt = match TaskAttempt::find_by_id(&app_state.db_pool, process.task_attempt_id).await {
Ok(Some(attempt)) => attempt,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch task attempt: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let _task = match Task::find_by_id(&app_state.db_pool, attempt.task_id).await {
Ok(Some(task)) if task.project_id == project_id => task,
Ok(Some(_)) => return Err(StatusCode::NOT_FOUND), // Wrong project
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch task: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
// Get executor session data for this execution process
let executor_session =
match ExecutorSession::find_by_execution_process_id(&app_state.db_pool, process_id).await {
Ok(session) => session,
Err(e) => {
tracing::error!(
"Failed to fetch executor session for process {}: {}",
process_id,
e
);
None
}
};
// Check if logs are available
let has_stdout =
process.stdout.is_some() && !process.stdout.as_ref().unwrap().trim().is_empty();
let has_stderr =
process.stderr.is_some() && !process.stderr.as_ref().unwrap().trim().is_empty();
// If no logs available, return empty conversation
if !has_stdout && !has_stderr {
let empty_conversation = NormalizedConversation {
entries: vec![],
session_id: None,
executor_type: process
.executor_type
.clone()
.unwrap_or("unknown".to_string()),
prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()),
summary: executor_session.as_ref().and_then(|s| s.summary.clone()),
};
return Ok(ResponseJson(ApiResponse {
success: true,
data: Some(empty_conversation),
message: None,
}));
}
// Parse stdout as JSONL using executor normalization
let mut stdout_entries = Vec::new();
if let Some(stdout) = &process.stdout {
if !stdout.trim().is_empty() {
// Determine executor type and create appropriate executor for normalization
let executor_type = process.executor_type.as_deref().unwrap_or("unknown");
let executor_config = if process.process_type == ExecutionProcessType::SetupScript {
// For setup scripts, use the setup script executor
ExecutorConfig::SetupScript {
script: executor_session
.as_ref()
.and_then(|s| s.prompt.clone())
.unwrap_or_else(|| "setup script".to_string()),
}
} else {
match executor_type.to_string().parse() {
Ok(config) => config,
Err(_) => {
tracing::warn!(
"Unsupported executor type: {}, cannot normalize logs properly",
executor_type
);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Unsupported executor type: {}", executor_type)),
}));
}
}
};
let executor = executor_config.create_executor();
// Use the working directory path for normalization
// Try to canonicalize if the directory exists, otherwise use the stored path as-is
let working_dir_path = match std::fs::canonicalize(&process.working_directory) {
Ok(canonical_path) => {
tracing::debug!(
"Using canonical path for normalization: {}",
canonical_path.display()
);
canonical_path.to_string_lossy().to_string()
}
Err(_) => {
tracing::debug!(
"Working directory {} no longer exists, using stored path for normalization",
process.working_directory
);
process.working_directory.clone()
}
};
// Normalize stdout logs with error handling
match executor.normalize_logs(stdout, &working_dir_path) {
Ok(normalized) => {
stdout_entries = normalized.entries;
tracing::debug!(
"Successfully normalized {} stdout entries for process {}",
stdout_entries.len(),
process_id
);
}
Err(e) => {
tracing::error!(
"Failed to normalize stdout for process {}: {}",
process_id,
e
);
return Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to normalize logs: {}", e)),
}));
}
}
}
}
// Parse stderr chunks separated by boundary markers
let mut stderr_entries = Vec::new();
if let Some(stderr) = &process.stderr {
let trimmed = stderr.trim();
if !trimmed.is_empty() {
// Split stderr by chunk boundaries and create separate error messages
let chunks: Vec<&str> = trimmed.split("---STDERR_CHUNK_BOUNDARY---").collect();
for chunk in chunks {
let chunk_trimmed = chunk.trim();
if !chunk_trimmed.is_empty() {
// Filter out any remaining boundary markers from the chunk content
let filtered_content = chunk_trimmed.replace("---STDERR_CHUNK_BOUNDARY---", "");
if !filtered_content.trim().is_empty() {
stderr_entries.push(NormalizedEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: NormalizedEntryType::ErrorMessage,
content: filtered_content.trim().to_string(),
metadata: None,
});
}
}
}
tracing::debug!(
"Processed stderr content into {} error messages for process {}",
stderr_entries.len(),
process_id
);
}
}
// Merge stdout and stderr entries chronologically
let mut all_entries = Vec::new();
all_entries.extend(stdout_entries);
all_entries.extend(stderr_entries);
// Sort by timestamp (entries without timestamps go to the end)
all_entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
(Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
// Create final normalized conversation
let executor_type = if process.process_type == ExecutionProcessType::SetupScript {
"setup-script".to_string()
} else {
process
.executor_type
.clone()
.unwrap_or("unknown".to_string())
};
let normalized_conversation = NormalizedConversation {
entries: all_entries,
session_id: None,
executor_type,
prompt: executor_session.as_ref().and_then(|s| s.prompt.clone()),
summary: executor_session.as_ref().and_then(|s| s.summary.clone()),
};
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(normalized_conversation),
message: None,
}))
}
/// Find plan content with context by searching through multiple processes in the same attempt
async fn find_plan_content_with_context(
pool: &SqlitePool,
@@ -1456,8 +1406,8 @@ pub fn task_attempts_router() -> Router<AppState> {
get(get_execution_process),
)
.route(
"/projects/:project_id/execution-processes/:process_id/normalized-logs",
get(get_execution_process_normalized_logs),
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/logs",
get(get_task_attempt_all_logs),
)
.route(
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/follow-up",