diff --git a/WARP.md b/WARP.md index 518605e8..8f080bed 100644 --- a/WARP.md +++ b/WARP.md @@ -36,6 +36,11 @@ Environment variables: - `./script/run-clang-format.py -r --extensions 'c,h,cpp,m' ./crates/warpui/src/ ./app/src/` - Format C/C++/Obj-C code - `find . -name "*.wgsl" -exec wgslfmt --check {} +` - Check WGSL shader formatting +### Bedrock Diagnostics +- Bedrock request or stream failures automatically write `Error_.txt` to the repository root. +- The error snapshot file includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails. +- Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to additionally write per-event Bedrock diagnostic logs to `bedrock-diagnostics.log` in the active Warp log directory. + ### Platform Setup - `./script/bootstrap` - Platform-specific setup (calls platform-specific bootstrap scripts) - `./script/install_cargo_build_deps` - Install Cargo build dependencies diff --git a/app/src/ai/agent/api/impl.rs b/app/src/ai/agent/api/impl.rs index 43a1ba9b..e83a71ef 100644 --- a/app/src/ai/agent/api/impl.rs +++ b/app/src/ai/agent/api/impl.rs @@ -260,7 +260,7 @@ pub async fn generate_multi_agent_output( format!("MultiPart[{}]", part_descs.join(", ")) } }; - log::debug!( + log::info!( "[bedrock] msg[{}]: role={:?}, content={}", i, msg.role, diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index d231d901..50a44124 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -14,7 +14,7 @@ use crate::terminal::model::block::{ }; use crate::ai::agent::api::convert_conversation::{ - ConvertToExchanges, compute_time_to_first_token_ms_from_messages, + compute_time_to_first_token_ms_from_messages, ConvertToExchanges, }; use ai::document::AIDocumentId; use chrono::{DateTime, Local, TimeZone}; @@ -29,8 +29,8 @@ use galaxy_core::execution_mode::AppExecutionMode; use galaxy_core::features::FeatureFlag; use galaxy_core::send_telemetry_from_ctx; use galaxy_core::ui::appearance::Appearance; -use galaxy_core::ui::theme::WarpTheme; use galaxy_core::ui::theme::color::internal_colors; +use galaxy_core::ui::theme::WarpTheme; use galaxyui::color::ColorU; use galaxyui::{EntityId, ModelContext, SingletonEntity}; use uuid::Uuid; @@ -40,35 +40,36 @@ use warp_multi_agent_api::{self as api, response_event::stream_finished::TokenUs use crate::ai::agent::{AIIdentifiers, CancellationReason}; use crate::{ - BlocklistAIHistoryModel, GlobalResourceHandlesProvider, ai::{ agent::{ - AIAgentOutputMessage, AIAgentOutputMessageType, MessageToAIAgentOutputMessageError, icons::{ failed_icon, gray_stop_icon, in_progress_icon, succeeded_icon, yellow_stop_icon, }, todos::AIAgentTodoList, + AIAgentOutputMessage, AIAgentOutputMessageType, MessageToAIAgentOutputMessageError, }, blocklist::BlocklistAIHistoryEvent, }, persistence::{ - ModelEvent, model::{AgentConversationData, PersistedAutoexecuteMode}, + ModelEvent, }, ui_components::icons::Icon, + BlocklistAIHistoryModel, GlobalResourceHandlesProvider, }; use super::task::{ExtractMessagesError, UpdateTaskError, UpgradeOptimisticTaskError}; use super::{ + api::ServerConversationToken, + task::{ + derive_todo_lists_from_root_task, + helper::*, + transaction::{SavedTask, Transaction}, + Task, TaskId, + }, AIAgentAction, AIAgentActionId, AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, AIAgentTodo, AIAgentTodoId, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost, - api::ServerConversationToken, - task::{ - Task, TaskId, derive_todo_lists_from_root_task, - helper::*, - transaction::{SavedTask, Transaction}, - }, }; use super::{ AIAgentOutput, OutputModelInfo, ServerOutputId, Shared, SuggestedLoggingId, Suggestions, diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index f31fcaa8..8c41e788 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use super::{ - AIConversation, AIConversationAutoexecuteMode, AIConversationId, artifact_from_fork_proto, + artifact_from_fork_proto, AIConversation, AIConversationAutoexecuteMode, AIConversationId, }; use crate::ai::artifacts::Artifact; use crate::persistence::model::AgentConversationData; diff --git a/app/src/ai/bedrock/client.rs b/app/src/ai/bedrock/client.rs index 93f91265..5816f5d1 100644 --- a/app/src/ai/bedrock/client.rs +++ b/app/src/ai/bedrock/client.rs @@ -163,16 +163,22 @@ impl BedrockClient { let output = request.send().await.map_err(|e| { let debug_msg = format!("{:?}", e); - let display_msg = format!("{}", e); + let display_msg = format!("{e}"); log::error!("[bedrock] API error (display): {display_msg}"); log::error!("[bedrock] API error (debug): {debug_msg}"); let msg = if debug_msg.len() > display_msg.len() { - debug_msg + debug_msg.clone() } else { - display_msg + display_msg.clone() }; if let Some(ref logger) = diagnostic_logger { logger.log_result_fail(&msg); + if let Some(path) = logger.dump_error_snapshot(&display_msg, &debug_msg) { + log::error!( + "[bedrock] Wrote Bedrock failure snapshot to {}", + path.display() + ); + } } if msg.contains("AccessDenied") || msg.contains("access denied") { BedrockError::AccessDenied(msg) diff --git a/app/src/ai/bedrock/convert_request.rs b/app/src/ai/bedrock/convert_request.rs index 6f2a289a..e615b8b1 100644 --- a/app/src/ai/bedrock/convert_request.rs +++ b/app/src/ai/bedrock/convert_request.rs @@ -577,10 +577,17 @@ fn ensure_tool_results_paired(messages: &mut Vec) { let insert_idx = i + 1; // If the next message is already a user message, merge synthetic results into it. + // IMPORTANT: ToolResult blocks must come BEFORE text content in a user message + // that follows an assistant tool_use. The Bedrock/Anthropic API validates this + // ordering and rejects requests where text precedes tool_result. if insert_idx < messages.len() && messages[insert_idx].role == MessageRole::User { match &mut messages[insert_idx].content { MessageContent::MultiPart(parts) => { + // Prepend synthetic results before existing parts so + // tool_result blocks appear first in the content. + let existing = std::mem::take(parts); parts.extend(synthetic_results); + parts.extend(existing); } existing => { // Convert existing single content + synthetic results into MultiPart. @@ -607,11 +614,18 @@ fn ensure_tool_results_paired(messages: &mut Vec) { }, MessageContent::MultiPart(_) => unreachable!(), }; - let mut parts = vec![existing_part]; - parts.extend(synthetic_results); + // Synthetic tool_result blocks come first, then the + // original content (text), matching the Bedrock API + // requirement that tool_result precedes other content. + let mut parts = synthetic_results; + parts.push(existing_part); *existing = MessageContent::MultiPart(parts); } } + log::info!( + "[bedrock] Merged synthetic tool_result(s) into existing user message at index {}", + insert_idx + ); } else { // No user message follows — insert a new one. let content = if synthetic_results.len() == 1 { @@ -1272,3 +1286,7 @@ fn format_tool_call_result(result: &api::message::ToolCallResult) -> String { "Tool completed.".to_string() } } + +#[cfg(test)] +#[path = "convert_request_tests.rs"] +mod tests; diff --git a/app/src/ai/bedrock/convert_request_tests.rs b/app/src/ai/bedrock/convert_request_tests.rs new file mode 100644 index 00000000..e72d7de0 --- /dev/null +++ b/app/src/ai/bedrock/convert_request_tests.rs @@ -0,0 +1,47 @@ +use serde_json::json; + +use super::super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; +use super::sanitize_messages_for_bedrock; + +#[test] +fn test_sanitize_messages_prepends_synthetic_tool_result_before_existing_user_text() { + let tool_use_id = "tooluse_Pzmn1QfoWgJsA8sb4RHTM3".to_string(); + let existing_user_text = "What happened?".to_string(); + + let mut messages = vec![ + ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Run a command.".to_string()), + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::ToolUse { + tool_use_id: tool_use_id.clone(), + name: "run_shell_command".to_string(), + input: json!({ "command": "ls" }), + }, + }, + ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text(existing_user_text.clone()), + }, + ]; + + sanitize_messages_for_bedrock(&mut messages); + + assert_eq!(messages.len(), 3); + + let parts = match &messages[2].content { + MessageContent::MultiPart(parts) => parts, + other => panic!("Expected MultiPart content, got: {:?}", other), + }; + + assert_eq!(parts.len(), 2); + assert!( + matches!(&parts[0], ContentPart::ToolResult { tool_use_id: id, .. } if id == &tool_use_id) + ); + assert!(matches!( + &parts[1], + ContentPart::Text(text) if text == &existing_user_text + )); +} diff --git a/app/src/ai/bedrock/diagnostic.rs b/app/src/ai/bedrock/diagnostic.rs index c284883f..1356b8dc 100644 --- a/app/src/ai/bedrock/diagnostic.rs +++ b/app/src/ai/bedrock/diagnostic.rs @@ -1,16 +1,18 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{BufWriter, Write}; -use std::path::PathBuf; -use std::sync::Mutex; - -use chrono::Utc; +use chrono::{Local, Utc}; use serde_json::Value as JsonValue; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; use super::convert::{ConversationMessage, MessageContent, MessageRole, ToolDefinition}; const ENV_VAR: &str = "GALAXY_BEDROCK_DIAGNOSTICS"; const LOG_FILENAME: &str = "bedrock-diagnostics.log"; const MAX_ROTATIONS: usize = 5; +const ERROR_DUMP_PREFIX: &str = "Error_"; +const MAX_CAPTURED_LINES: usize = 2_000; +const LOG_TAIL_BYTES: u64 = 200 * 1024; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Layer { @@ -62,11 +64,15 @@ impl std::fmt::Display for Status { } pub struct BedrockDiagnosticLogger { - writer: Mutex>, + writer: Option>>, + log_path: Option, model_id: String, conversation_id: Mutex, request_id: Mutex, task_id: String, + protobuf_input: Mutex>, + bedrock_input: Mutex>, + captured_lines: Mutex>, } impl BedrockDiagnosticLogger { @@ -76,47 +82,42 @@ impl BedrockDiagnosticLogger { request_id: &str, task_id: &str, ) -> Option { - if !is_enabled() { - return None; - } + let mut writer = None; + let mut log_path = None; - let log_path = match log_file_path() { - Some(path) => path, - None => { + if is_enabled() { + if let Some(path) = diagnostic_log_file_path() { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + rotate_if_needed(&path); + + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(file) => { + writer = Some(Mutex::new(BufWriter::new(file))); + log_path = Some(path.clone()); + log::info!("[bedrock-diag] Diagnostic logging enabled -> {path:?}"); + } + Err(e) => { + log::warn!("[bedrock-diag] Failed to open log file {path:?}: {e}"); + } + } + } else { log::warn!("[bedrock-diag] Could not determine log directory"); - return None; } - }; - - if let Some(parent) = log_path.parent() { - let _ = fs::create_dir_all(parent); } - rotate_if_needed(&log_path); - - let file = match OpenOptions::new().create(true).append(true).open(&log_path) { - Ok(f) => f, - Err(e) => { - log::warn!( - "[bedrock-diag] Failed to open log file {:?}: {}", - log_path, - e - ); - return None; - } - }; - - log::info!( - "[bedrock-diag] Diagnostic logging enabled -> {:?}", - log_path - ); - Some(Self { - writer: Mutex::new(BufWriter::new(file)), + writer, + log_path, model_id: model_id.to_string(), conversation_id: Mutex::new(conversation_id.to_string()), request_id: Mutex::new(request_id.to_string()), task_id: task_id.to_string(), + protobuf_input: Mutex::new(None), + bedrock_input: Mutex::new(None), + captured_lines: Mutex::new(Vec::new()), }) } @@ -131,6 +132,9 @@ impl BedrockDiagnosticLogger { pub fn log_protobuf_input(&self, request: &warp_multi_agent_api::Request) { let payload = format!("{:?}", request); + if let Ok(mut protobuf_input) = self.protobuf_input.lock() { + *protobuf_input = Some(payload.clone()); + } self.write_line(Layer::Protobuf, Direction::Input, Status::Pending, &payload); } @@ -155,6 +159,9 @@ impl BedrockDiagnosticLogger { "messages": messages_json, "tools": tools_json, }); + if let Ok(mut bedrock_input) = self.bedrock_input.lock() { + *bedrock_input = Some(payload.clone()); + } self.write_line( Layer::Bedrock, @@ -203,6 +210,111 @@ impl BedrockDiagnosticLogger { ); } + pub fn dump_error_snapshot(&self, error: &str, debug_error: &str) -> Option { + let conversation_id = self + .conversation_id + .lock() + .map(|value| value.clone()) + .unwrap_or_default(); + let request_id = self + .request_id + .lock() + .map(|value| value.clone()) + .unwrap_or_default(); + let protobuf_input = self + .protobuf_input + .lock() + .ok() + .and_then(|value| value.clone()); + let bedrock_input = self + .bedrock_input + .lock() + .ok() + .and_then(|value| value.clone()); + let captured_lines = self + .captured_lines + .lock() + .map(|lines| lines.clone()) + .unwrap_or_default(); + + let current_log_path = galaxy_logging::log_file_path().ok(); + let current_log_tail = current_log_path + .as_ref() + .and_then(|path| read_file_tail(path, LOG_TAIL_BYTES)); + let diagnostics_log_tail = self + .log_path + .as_ref() + .and_then(|path| read_file_tail(path, LOG_TAIL_BYTES)); + let context_window = bedrock_input + .as_ref() + .and_then(|value| value.get("messages").cloned()) + .unwrap_or(JsonValue::Null); + let tools = bedrock_input + .as_ref() + .and_then(|value| value.get("tools").cloned()) + .unwrap_or(JsonValue::Null); + + let payload = serde_json::json!({ + "timestamp_local": Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "timestamp_utc": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "error": { + "display": error, + "debug": debug_error, + }, + "bedrock": { + "model_id": &self.model_id, + "task_id": &self.task_id, + "conversation_id": conversation_id, + "request_id": request_id, + "input": bedrock_input, + "context_window": context_window, + "tools": tools, + }, + "protobuf_request_debug": protobuf_input, + "captured_bedrock_lines": captured_lines, + "logs": { + "warp_log_path": current_log_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "warp_log_tail": current_log_tail, + "bedrock_diagnostics_log_path": self + .log_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "bedrock_diagnostics_log_tail": diagnostics_log_tail, + } + }); + + let file_name = format!( + "{ERROR_DUMP_PREFIX}{}.txt", + Local::now().format("%Y%m%d_%H%M%S_%3f") + ); + let serialized_payload = match serde_json::to_string_pretty(&payload) { + Ok(value) => value, + Err(e) => { + log::error!("[bedrock] Failed to serialize Bedrock error snapshot: {e}"); + return None; + } + }; + + let mut attempted_paths = Vec::new(); + for dump_path in error_dump_paths(&file_name) { + match fs::write(&dump_path, &serialized_payload) { + Ok(()) => return Some(dump_path), + Err(e) => { + attempted_paths.push(format!("{} ({e})", dump_path.display())); + } + } + } + log::error!( + "[bedrock] Failed to write Bedrock error snapshot. Attempted paths: {}", + attempted_paths.join(", ") + ); + None + } + fn write_line(&self, layer: Layer, direction: Direction, status: Status, payload: &str) { let timestamp = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let conversation_id = self @@ -228,9 +340,19 @@ impl BedrockDiagnosticLogger { payload, ); - if let Ok(mut writer) = self.writer.lock() { - let _ = writer.write_all(line.as_bytes()); - let _ = writer.flush(); + if let Ok(mut captured_lines) = self.captured_lines.lock() { + captured_lines.push(line.trim_end().to_string()); + if captured_lines.len() > MAX_CAPTURED_LINES { + let overflow = captured_lines.len() - MAX_CAPTURED_LINES; + captured_lines.drain(0..overflow); + } + } + + if let Some(writer) = &self.writer { + if let Ok(mut writer) = writer.lock() { + let _ = writer.write_all(line.as_bytes()); + let _ = writer.flush(); + } } } } @@ -241,13 +363,45 @@ pub fn is_enabled() -> bool { .unwrap_or(false) } -fn log_file_path() -> Option { +fn diagnostic_log_file_path() -> Option { galaxy_logging::log_directory() .ok() .map(|dir| dir.join(LOG_FILENAME)) } -fn rotate_if_needed(path: &PathBuf) { +fn error_dump_directory() -> PathBuf { + let source_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) + .to_path_buf(); + + if source_root.is_dir() { + source_root + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } +} + +fn error_dump_paths(file_name: &str) -> Vec { + let mut directories = Vec::new(); + push_unique_directory(&mut directories, error_dump_directory()); + if let Ok(current_dir) = std::env::current_dir() { + push_unique_directory(&mut directories, current_dir); + } + push_unique_directory(&mut directories, std::env::temp_dir()); + + directories + .into_iter() + .map(|directory| directory.join(file_name)) + .collect() +} + +fn push_unique_directory(directories: &mut Vec, directory: PathBuf) { + if directory.is_dir() && !directories.iter().any(|existing| existing == &directory) { + directories.push(directory); + } +} +fn rotate_if_needed(path: &Path) { let metadata = match fs::metadata(path) { Ok(m) => m, Err(_) => return, @@ -260,7 +414,7 @@ fn rotate_if_needed(path: &PathBuf) { for i in (0..MAX_ROTATIONS - 1).rev() { let from = if i == 0 { - path.clone() + path.to_path_buf() } else { path.with_extension(format!("log.{}", i)) }; @@ -272,6 +426,25 @@ fn rotate_if_needed(path: &PathBuf) { let _ = fs::rename(path, &first_rotation); } +fn read_file_tail(path: &Path, max_bytes: u64) -> Option { + let mut file = File::open(path).ok()?; + let file_len = file.metadata().ok()?.len(); + let start = file_len.saturating_sub(max_bytes); + if file.seek(SeekFrom::Start(start)).is_err() { + return None; + } + let mut bytes = Vec::new(); + if file.read_to_end(&mut bytes).is_err() { + return None; + } + + let mut tail = String::from_utf8_lossy(&bytes).to_string(); + if start > 0 { + tail = format!("... log tail truncated to last {max_bytes} bytes ...\n{tail}"); + } + Some(tail) +} + fn serialize_messages(messages: &[ConversationMessage]) -> JsonValue { let entries: Vec = messages .iter() diff --git a/app/src/ai/bedrock/e2e_tests.rs b/app/src/ai/bedrock/e2e_tests.rs index da6f7126..9609a282 100644 --- a/app/src/ai/bedrock/e2e_tests.rs +++ b/app/src/ai/bedrock/e2e_tests.rs @@ -555,6 +555,7 @@ impl AgentSimulation { 8192, None, false, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1140,6 +1141,7 @@ async fn test_reasoning_model_produces_substantial_output() { 8192, None, false, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1261,6 +1263,7 @@ async fn test_event_sequence_matches_controller_expectations() { 100, None, false, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1374,6 +1377,7 @@ async fn test_followup_turn_does_not_send_create_task() { 100, None, false, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1438,7 +1442,11 @@ async fn run_slash_command_test( let task_id = uuid::Uuid::new_v4().to_string(); println!( "\n{}\n[SLASH CMD TEST] {}\n[SLASH CMD TEST] Model: {}\n[SLASH CMD TEST] Message: {:.100}\n{}", - "=".repeat(80), test_name, model, user_message, "=".repeat(80) + "=".repeat(80), + test_name, + model, + user_message, + "=".repeat(80) ); let stream = client @@ -1452,6 +1460,7 @@ async fn run_slash_command_test( 4096, None, true, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1678,6 +1687,7 @@ async fn test_slash_resume_conversation() { 256, None, true, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1788,6 +1798,7 @@ async fn test_empty_messages_safety_check() { 100, None, true, + None, Arc::new(Mutex::new(Vec::new())), ) .await @@ -1829,17 +1840,26 @@ async fn test_full_proto_round_trip_with_tool_history() { let request = make_request( &task_id, vec![ - make_user_query_message("msg-1", &task_id, "List all files in the current directory."), + make_user_query_message( + "msg-1", + &task_id, + "List all files in the current directory.", + ), make_tool_call_run_shell(tool_call_id_1, &task_id, tool_call_id_1, "ls -la"), make_tool_call_read_files(tool_call_id_2, &task_id, tool_call_id_2, "README.md"), make_tool_result_shell( - "msg-result-1", &task_id, tool_call_id_1, + "msg-result-1", + &task_id, + tool_call_id_1, "total 16\ndrwxr-xr-x 5 user staff 160 May 6 10:00 .\n-rw-r--r-- 1 user staff 100 May 6 10:00 README.md\n-rw-r--r-- 1 user staff 200 May 6 10:00 Cargo.toml", 0, ), make_tool_result_read_files( - "msg-result-2", &task_id, tool_call_id_2, - "README.md", "# Sample Project\nA test project.", + "msg-result-2", + &task_id, + tool_call_id_2, + "README.md", + "# Sample Project\nA test project.", ), ], make_user_inputs_input("Now summarize what you found in one sentence."), @@ -1938,6 +1958,7 @@ async fn test_full_proto_round_trip_with_tool_history() { 1024, None, true, + None, Arc::new(Mutex::new(Vec::new())), ) .await; diff --git a/app/src/ai/bedrock/integration_tests.rs b/app/src/ai/bedrock/integration_tests.rs index 32a5d77a..400b0fe5 100644 --- a/app/src/ai/bedrock/integration_tests.rs +++ b/app/src/ai/bedrock/integration_tests.rs @@ -64,6 +64,7 @@ async fn collect_stream_output( 8192, None, false, + None, Arc::new(Mutex::new(Vec::new())), ) .await diff --git a/app/src/ai/bedrock/stream.rs b/app/src/ai/bedrock/stream.rs index 1b9d7159..dd5dd2b4 100644 --- a/app/src/ai/bedrock/stream.rs +++ b/app/src/ai/bedrock/stream.rs @@ -218,7 +218,16 @@ pub fn bedrock_stream_to_response_events( Err(e) => { log::error!("[bedrock-debug] Stream error after {event_count} events: {e}"); if let Some(ref logger) = diagnostic_logger { - logger.log_stream_error(&format!("{e}")); + let error_msg = format!("{e}"); + let debug_error = format!("{e:?}"); + logger.log_stream_error(&error_msg); + logger.log_result_fail(&error_msg); + if let Some(path) = logger.dump_error_snapshot(&error_msg, &debug_error) { + log::error!( + "[bedrock] Wrote Bedrock failure snapshot to {}", + path.display() + ); + } } if !buffered_text.is_empty() { let msg_id = current_text_message_id diff --git a/app/src/drive/index.rs b/app/src/drive/index.rs index 17102f72..fc1122f3 100644 --- a/app/src/drive/index.rs +++ b/app/src/drive/index.rs @@ -651,7 +651,7 @@ impl DriveIndex { .as_ref(ctx) .num_trashed_cloud_objects_per_space(spaces.iter(), ctx), }; - let mut sections = spaces + let sections = spaces .iter() .map(|space| DriveIndexSection::Space(*space)) .collect::>(); @@ -1254,7 +1254,9 @@ impl DriveIndex { { self.expand_section_for_object(&id.uid().clone(), ctx); } else { - log::warn!("unknown GenericStringObject type found while trying to manually expand drive section. {object_id:?}"); + log::warn!( + "unknown GenericStringObject type found while trying to manually expand drive section. {object_id:?}" + ); } } }; @@ -5399,7 +5401,9 @@ impl TypedActionView for DriveIndex { log::error!("Creation of EnvVarCollections is not yet supported") } DriveObjectType::AIFact | DriveObjectType::AIFactCollection => { - log::error!("Use DriveIndexAction::OpenAIFactCollection to open the pane view instead"); + log::error!( + "Use DriveIndexAction::OpenAIFactCollection to open the pane view instead" + ); } DriveObjectType::MCPServer | DriveObjectType::MCPServerCollection => { log::error!( diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 014447d7..2928013b 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -303,6 +303,13 @@ impl AIApiError { } true } + // Don't retry Bedrock validation errors — they are deterministic + // and will always fail with the same request payload. + AIApiError::Stream { source, .. } + if source.to_string().contains("Validation error") => + { + false + } // By default, retry on error. _ => true, } diff --git a/app/src/settings_view/about_page.rs b/app/src/settings_view/about_page.rs index fb17dfb0..26dc3d43 100644 --- a/app/src/settings_view/about_page.rs +++ b/app/src/settings_view/about_page.rs @@ -60,7 +60,6 @@ impl SettingsWidget for AboutPageWidget { appearance: &Appearance, _app: &AppContext, ) -> Box { - let theme = appearance.theme(); let ui_builder = appearance.ui_builder(); let image_path = "bundled/svg/galaxy-logo.svg"; diff --git a/app/src/settings_view/mod_test.rs b/app/src/settings_view/mod_test.rs index 24f47847..1aa5dbf9 100644 --- a/app/src/settings_view/mod_test.rs +++ b/app/src/settings_view/mod_test.rs @@ -26,12 +26,12 @@ fn code_subpages_are_identified() { } #[test] -fn cloud_platform_subpages_are_identified() { - assert!(SettingsSection::CloudEnvironments.is_cloud_platform_subpage()); - assert!(SettingsSection::OzCloudAPIKeys.is_cloud_platform_subpage()); +fn legacy_cloud_platform_sections_are_not_subpages() { + assert!(!SettingsSection::CloudEnvironments.is_subpage()); + assert!(!SettingsSection::OzCloudAPIKeys.is_subpage()); - assert!(!SettingsSection::Account.is_cloud_platform_subpage()); - assert!(!SettingsSection::WarpAgent.is_cloud_platform_subpage()); + assert!(!SettingsSection::Account.is_subpage()); + assert!(!SettingsSection::WarpAgent.is_subpage()); } #[test] @@ -42,8 +42,8 @@ fn is_subpage_covers_all_umbrella_types() { } assert!(SettingsSection::CodeIndexing.is_subpage()); assert!(SettingsSection::EditorAndCodeReview.is_subpage()); - assert!(SettingsSection::CloudEnvironments.is_subpage()); - assert!(SettingsSection::OzCloudAPIKeys.is_subpage()); + assert!(!SettingsSection::CloudEnvironments.is_subpage()); + assert!(!SettingsSection::OzCloudAPIKeys.is_subpage()); // Top-level pages should not be subpages. assert!(!SettingsSection::Account.is_subpage()); @@ -647,7 +647,10 @@ fn realistic_nav_items() -> Vec { )), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Cloud platform", - SettingsSection::cloud_platform_subpages().to_vec(), + vec![ + SettingsSection::CloudEnvironments, + SettingsSection::OzCloudAPIKeys, + ], )), SettingsNavItem::Page(SettingsSection::Teams), ] diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 30136981..3a9b503b 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -1,6 +1,5 @@ -use crate::BlocklistAIHistoryModel; -use crate::ai::blocklist::agent_view::{AgentViewState, agent_view_bg_fill}; -use crate::ai::blocklist::{ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, ai_brand_color}; +use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState}; +use crate::ai::blocklist::{ai_brand_color, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT}; use crate::ai_assistant::{AI_ASSISTANT_SVG_PATH, ASK_AI_ASSISTANT_TEXT}; use crate::appearance::Appearance; use crate::drive::settings::WarpDriveSettings; @@ -20,10 +19,11 @@ use crate::terminal::model::index::Point as IndexPoint; use crate::terminal::model::selection::{SelectAction, SelectionPoint}; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::view::TerminalAction; -use crate::terminal::{SizeInfo, grid_renderer}; +use crate::terminal::{grid_renderer, SizeInfo}; use crate::themes::theme::{Fill, WarpTheme}; use crate::ui_components::{self, icons as UIIcon}; use crate::util::color::Opacity; +use crate::BlocklistAIHistoryModel; use enum_iterator::Sequence; use galaxy_core::semantic_selection::SemanticSelection; use galaxy_core::ui::builder::UiBuilder; @@ -44,15 +44,15 @@ use galaxyui::elements::{ use galaxyui::event::{KeyState, ModifiersState}; use galaxyui::fonts::{FamilyId, Properties, Weight}; use galaxyui::geometry::rect::RectF; -use galaxyui::geometry::vector::{Vector2F, vec2f}; +use galaxyui::geometry::vector::{vec2f, Vector2F}; use galaxyui::platform::keyboard::KeyCode; use galaxyui::ui_components::components::UiComponent; use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels}; +use galaxyui::{elements::Icon, ClipBounds}; use galaxyui::{ - AfterLayoutContext, AppContext, Element, Event, EventContext, LayoutContext, PaintContext, - SizeConstraint, elements::SavePosition, event::DispatchedEvent, + elements::SavePosition, event::DispatchedEvent, AfterLayoutContext, AppContext, Element, Event, + EventContext, LayoutContext, PaintContext, SizeConstraint, }; -use galaxyui::{ClipBounds, elements::Icon}; use galaxyui::{EntityId, ModelHandle, SingletonEntity as _}; use pathfinder_color::ColorU; use session_sharing_protocol::common::{ParticipantId, Selection}; @@ -69,9 +69,7 @@ use super::blockgrid_renderer::GridRenderParams; use super::find::{BlockListFindRun, BlockListMatch, TerminalFindModel}; use super::grid_renderer::CellGlyphCache; -use super::TerminalModel; use super::meta_shortcuts::handle_keystroke_despite_composing; -use super::model::SecretHandle; use super::model::block::BlockId; use super::model::blocks::{RichContentItem, SelectionRange}; use super::model::grid::grid_handler::{Link, TermMode}; @@ -79,22 +77,24 @@ use super::model::image_map::StoredImageMetadata; use super::model::mouse::{MouseAction, MouseButton, MouseState}; use super::model::session::SessionId; use super::model::terminal_model::{SelectedBlocks, WithinBlock, WithinModel}; +use super::model::SecretHandle; use super::shared_session::presence_manager::{ - MUTED_PARTICIPANT_COLOR, PresenceManager, text_selection_color, + text_selection_color, PresenceManager, MUTED_PARTICIPANT_COLOR, }; use super::shared_session::render_util::SHARED_SESSION_AVATAR_DIAMETER; use super::view::{ - BLOCK_BANNER_HEIGHT, BlocklistAIRenderContext, InlineBannerId, RichContentMetadata, - SeparatorId, SharedSessionBanners, TerminalEditor, TerminalViewRenderContext, + BlocklistAIRenderContext, InlineBannerId, RichContentMetadata, SeparatorId, + SharedSessionBanners, TerminalEditor, TerminalViewRenderContext, BLOCK_BANNER_HEIGHT, }; use super::warpify::render::{draw_flag_pole, render_subshell_flag}; -use super::{HEIGHT_FUDGE_FACTOR_LINES, heights_approx_eq}; +use super::TerminalModel; +use super::{heights_approx_eq, HEIGHT_FUDGE_FACTOR_LINES}; use crate::terminal::blockgrid_renderer::BlockGridParams; use crate::terminal::model::terminal_model::BlockIndex; use crate::terminal::warpify::SubshellSource; use crate::terminal::model::escape_sequences::{ - KeystrokeWithDetails, ToEscapeSequence, maybe_kitty_keyboard_escape_sequence, + maybe_kitty_keyboard_escape_sequence, KeystrokeWithDetails, ToEscapeSequence, }; /// The number of pixels at the bottom of padding where selection scrolling is performed. diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 729d4278..b1601c3d 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -568,7 +568,6 @@ const VERTICAL_TABS_PANEL_POSITION_ID: &str = "workspace_view:vertical_tabs_pane const TAB_CONTENT_POSITION_ID: &str = "workspace_view:tab_content"; const WELCOME_TIPS_POSITION_ID: &str = "welcome_tips_pill"; -const ELLIPSE_SVG_PATH: &str = "bundled/svg/ellipse.svg"; const AI_ASSISTANT_BUTTON_ID: &str = "workspace_view:ai_assistant_button"; @@ -17720,54 +17719,6 @@ impl Workspace { SavePosition::new(Align::new(button).finish(), USER_AVATAR_BUTTON_POSITION_ID).finish() } - fn render_resource_center_button( - &self, - appearance: &Appearance, - ctx: &AppContext, - ) -> Box { - // only show the unread indicator if the tips are NOT completed - let should_show_unread_indicator = !self.tips_completed.as_ref(ctx).skipped_or_completed; - let mut button = self - .render_tab_bar_icon_button( - appearance, - icons::Icon::Lightbulb, - &self.mouse_states.resource_center_icon, - WorkspaceAction::ToggleResourceCenter, - "Warp Essentials".to_string(), - self.cached_keybindings[TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME].clone(), - false, - false, - ) - .finish(); - - if should_show_unread_indicator { - const INDICATOR_DIAMETER: f32 = 6.; - let indicator = Container::new( - ConstrainedBox::new( - WarpUiIcon::new(ELLIPSE_SVG_PATH, appearance.theme().accent()).finish(), - ) - .with_height(INDICATOR_DIAMETER) - .with_width(INDICATOR_DIAMETER) - .finish(), - ) - .finish(); - let mut stack = Stack::new(); - stack.add_child(button); - stack.add_positioned_child( - indicator, - OffsetPositioning::offset_from_parent( - Vector2F::zero(), - ParentOffsetBounds::WindowByPosition, - ParentAnchor::TopRight, - ChildAnchor::TopRight, - ), - ); - button = stack.finish(); - } - - Align::new(button).finish() - } - fn render_settings_button(&self, appearance: &Appearance) -> Box { Align::new( self.render_tab_bar_icon_button( diff --git a/app/src/workspace/view/right_panel.rs b/app/src/workspace/view/right_panel.rs index a989e189..152b1a1d 100644 --- a/app/src/workspace/view/right_panel.rs +++ b/app/src/workspace/view/right_panel.rs @@ -1440,7 +1440,10 @@ impl RightPanelView { terminal_status.is_available(), Self::format_optional_path(terminal_status.active_session_path.as_deref()), Self::format_optional_path(terminal_status.current_repo_path.as_deref()), - terminal_status.active_cli_agent.as_deref().unwrap_or(""), + terminal_status + .active_cli_agent + .as_deref() + .unwrap_or(""), terminal_status.is_executing, terminal_status.is_input_box_visible, unavailable_reasons, diff --git a/crates/galaxy_files/test_data/test_write/missing-directory/test_save_missing_directory.rs b/crates/galaxy_files/test_data/test_write/missing-directory/test_save_missing_directory.rs new file mode 100644 index 00000000..e6815c8a --- /dev/null +++ b/crates/galaxy_files/test_data/test_write/missing-directory/test_save_missing_directory.rs @@ -0,0 +1 @@ +Overwrite content \ No newline at end of file diff --git a/crates/galaxy_files/test_data/test_write/test_save_file.rs b/crates/galaxy_files/test_data/test_write/test_save_file.rs new file mode 100644 index 00000000..e6815c8a --- /dev/null +++ b/crates/galaxy_files/test_data/test_write/test_save_file.rs @@ -0,0 +1 @@ +Overwrite content \ No newline at end of file diff --git a/script/install-galaxy.sh b/script/install-galaxy.sh new file mode 100755 index 00000000..395ef862 --- /dev/null +++ b/script/install-galaxy.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# install-galaxy.sh — Clone, build, and install Galaxy.app on macOS. +# +# Usage: +# curl -fsSL https://mng-web-sharing.mini-games.tv/wst-data/ryan-share/galaxy/install-galaxy.sh | bash +# — or — +# ./script/install-galaxy.sh +# + +set -euo pipefail + +REPO_URL="git@gitlab.com:samnasbo/shared/galaxy.git" +CLONE_DIR="$HOME/.galaxy/source" +APP_NAME="Galaxy.app" +INSTALL_DIR="/Applications" +BUNDLE_BIN="galaxy-oss" +BUNDLE_PKG="galaxy" + +# ---------- helpers ---------- +info() { printf "\033[1;34m==>\033[0m %s\n" "$1"; } +warn() { printf "\033[1;33m==> WARNING:\033[0m %s\n" "$1"; } +fail() { printf "\033[1;31m==> ERROR:\033[0m %s\n" "$1"; exit 1; } + +# ---------- 1. Xcode / CLI tools ---------- +info "Checking Xcode and Command Line Tools..." + +if ! xcode-select -p &>/dev/null; then + fail "Xcode Command Line Tools are not installed. Run: xcode-select --install" +fi + +if ! xcrun --show-sdk-path &>/dev/null; then + fail "Xcode SDK not found. Ensure Xcode or Command Line Tools are properly installed." +fi + +if ! xcrun -f metal &>/dev/null; then + warn "Metal compiler not found. Attempting to download Metal toolchain..." + xcodebuild -downloadComponent MetalToolchain || warn "Could not download Metal toolchain — build may fail." +fi + +info "Xcode prerequisites look good." + +# ---------- 2. Homebrew ---------- +if ! command -v brew &>/dev/null; then + info "Installing Homebrew..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + # Add brew to PATH for Apple Silicon + if [[ -f /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + fi +fi + +if ! command -v pkgconf &>/dev/null && ! command -v pkg-config &>/dev/null; then + info "Installing pkgconf via Homebrew..." + brew install pkgconf +fi + +# ---------- 3. Rust toolchain ---------- +if ! command -v rustup &>/dev/null; then + info "Installing Rust via rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" +fi + +info "Syncing Rust toolchain (rust-toolchain.toml will pin the exact version)..." +rustup show active-toolchain &>/dev/null || rustup default stable + +# aarch64-apple-darwin target for Apple Silicon +rustup target add aarch64-apple-darwin 2>/dev/null || true + +# cargo-bundle for producing the .app +if ! cargo bundle --help &>/dev/null 2>&1; then + info "Installing cargo-bundle..." + cargo install cargo-bundle \ + --git=https://github.com/burtonageo/cargo-bundle \ + --rev ae4c76e92c08774bf54ff077b1c52e3d1cd6c16d +fi + +# ---------- 4. Clone the repo ---------- +if [[ -d "$CLONE_DIR/.git" ]]; then + info "Repository already exists at $CLONE_DIR — pulling latest..." + git -C "$CLONE_DIR" pull --ff-only || warn "Pull failed; building with current checkout." +else + info "Cloning Galaxy into $CLONE_DIR..." + mkdir -p "$(dirname "$CLONE_DIR")" + git clone "$REPO_URL" "$CLONE_DIR" +fi + +# ---------- 5. Build ---------- +info "Building $BUNDLE_BIN (release)..." +cargo build --release --bin "$BUNDLE_BIN" --package "$BUNDLE_PKG" \ + --manifest-path "$CLONE_DIR/Cargo.toml" + +info "Bundling $APP_NAME..." +# cargo-bundle does not support --manifest-path; run from repo root +pushd "$CLONE_DIR" > /dev/null +cargo bundle --release --bin "$BUNDLE_BIN" --package "$BUNDLE_PKG" +popd > /dev/null + +BUILT_APP="$CLONE_DIR/target/release/bundle/osx/$APP_NAME" +if [[ ! -d "$BUILT_APP" ]]; then + fail "Bundle not found at $BUILT_APP — build may have failed." +fi + +# ---------- 6. Kill, remove, install, launch ---------- +info "Stopping any running Galaxy processes..." +pkill -x "Galaxy" 2>/dev/null && sleep 1 || true +pkill -9 -x "Galaxy" 2>/dev/null || true + +INSTALLED_APP="$INSTALL_DIR/$APP_NAME" +if [[ -d "$INSTALLED_APP" ]]; then + info "Removing existing $INSTALLED_APP..." + rm -rf "$INSTALLED_APP" +fi + +info "Copying $APP_NAME to $INSTALL_DIR..." +cp -R "$BUILT_APP" "$INSTALL_DIR/" + +info "Launching Galaxy..." +open "$INSTALLED_APP" + +info "Done! Galaxy is running."