diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/crates/fabro-agent/src/agent_profile.rs index de869e34c..cc9c1e1d4 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/crates/fabro-agent/src/agent_profile.rs @@ -1,3 +1,9 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; +use fabro_model::{Catalog, Provider}; +use tokio::sync::Mutex; + use crate::profiles::EnvContext; use crate::sandbox::Sandbox; use crate::skills::Skill; @@ -6,10 +12,6 @@ use crate::subagent::{ make_spawn_agent_tool, make_wait_tool, }; use crate::tool_registry::ToolRegistry; -use fabro_llm::types::ToolDefinition; -use fabro_model::{Catalog, Provider}; -use std::sync::Arc; -use tokio::sync::Mutex; pub trait AgentProfile: Send + Sync { fn provider(&self) -> Provider; @@ -63,9 +65,10 @@ pub trait AgentProfile: Send + Sync { #[cfg(test)] mod tests { + use fabro_model::Provider; + use super::*; use crate::test_support::{MockSandbox, TestProfile}; - use fabro_model::Provider; #[test] fn profile_provider_and_model() { diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index f06d4c9cb..e134ce799 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -1,12 +1,7 @@ -use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback}; -use crate::error::InterruptReason; -use crate::tools::WebFetchSummarizer; -use crate::truncation; -use crate::{ - AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, - Sandbox, Session, SessionOptions, Turn, - subagent::{SessionFactory, SubAgentManager}, -}; +use std::io::{IsTerminal, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + use clap::{Args, Parser}; use fabro_llm::client::Client; use fabro_llm::error::SdkError; @@ -16,12 +11,18 @@ use fabro_llm::types::{Request, Response}; use fabro_mcp::config::McpServerSettings; use fabro_model::{Catalog, ModelHandle, Provider}; use fabro_util::terminal::Styles; -use std::io::{IsTerminal, Write}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; use tokio::signal; use tokio::sync::Mutex as AsyncMutex; +use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback}; +use crate::error::InterruptReason; +use crate::subagent::{SessionFactory, SubAgentManager}; +use crate::tools::WebFetchSummarizer; +use crate::{ + AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, + Sandbox, Session, SessionOptions, Turn, truncation, +}; + /// Public arguments for the agent command, usable from an external CLI. #[derive(Args)] pub struct AgentArgs { @@ -385,7 +386,8 @@ pub async fn run_with_args_and_client( llm_client: Option, mcp_servers: Vec, ) -> anyhow::Result<()> { - // Resolve color support once, leak to get 'static lifetime for use across threads + // Resolve color support once, leak to get 'static lifetime for use across + // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); // Parse provider string to enum early for compile-time safety @@ -671,10 +673,11 @@ pub async fn run() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::*; use fabro_model::Provider; use serde_json::json; + use super::*; + static NO_COLOR: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); // tool_category tests diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/crates/fabro-agent/src/compaction.rs index cf4fc3971..4b9a6de35 100644 --- a/lib/crates/fabro-agent/src/compaction.rs +++ b/lib/crates/fabro-agent/src/compaction.rs @@ -1,5 +1,9 @@ use std::fmt::Write; +use fabro_llm::client::Client; +use fabro_llm::types::{Message, Request}; +use tracing::debug; + use crate::agent_profile::AgentProfile; use crate::error::AgentError; use crate::event::Emitter; @@ -7,13 +11,10 @@ use crate::file_tracker::FileTracker; use crate::history::History; use crate::truncation; use crate::types::{AgentEvent, Turn}; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request}; -use tracing::debug; /// Check whether the context window usage exceeds the configured threshold. -/// Emits a `Warning` event with kind `"context_window"` when over the threshold. -/// Returns `true` if the threshold is exceeded. +/// Emits a `Warning` event with kind `"context_window"` when over the +/// threshold. Returns `true` if the threshold is exceeded. pub fn check_context_usage( system_prompt: &str, history: &History, @@ -27,28 +28,26 @@ pub fn check_context_usage( let threshold = context_window * threshold_percent / 100; if estimated_tokens > threshold { - emitter.emit( - session_id.to_owned(), - AgentEvent::Warning { - kind: "context_window".into(), - message: format!( - "Context window usage: {}%", - estimated_tokens * 100 / context_window - ), - details: serde_json::json!({ - "estimated_tokens": estimated_tokens, - "context_window_size": context_window, - "usage_percent": estimated_tokens * 100 / context_window, - }), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::Warning { + kind: "context_window".into(), + message: format!( + "Context window usage: {}%", + estimated_tokens * 100 / context_window + ), + details: serde_json::json!({ + "estimated_tokens": estimated_tokens, + "context_window_size": context_window, + "usage_percent": estimated_tokens * 100 / context_window, + }), + }); true } else { false } } -/// Compact the conversation history by summarizing older turns via a non-streaming LLM call. +/// Compact the conversation history by summarizing older turns via a +/// non-streaming LLM call. #[allow(clippy::too_many_arguments)] pub async fn compact_context( history: &mut History, @@ -64,13 +63,10 @@ pub async fn compact_context( let context_window = provider_profile.context_window_size(); let original_turn_count = history.turns().len(); - emitter.emit( - session_id.to_owned(), - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size: context_window, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted { + estimated_tokens, + context_window_size: context_window, + }); // Determine turns to summarize if original_turn_count <= preserve_count { @@ -106,24 +102,24 @@ function names, error messages, and exact values. Omit pleasantries and conversa ); let summary_request = Request { - model: provider_profile.model().to_string(), - messages: vec![ + model: provider_profile.model().to_string(), + messages: vec![ Message::system(summarization_prompt), Message::user(format!( "Here is the conversation to summarize:\n\n{rendered}" )), ], - provider: Some(provider_profile.provider().as_str().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(4096), - stop_sequences: None, + provider: Some(provider_profile.provider().as_str().to_string()), + tools: None, + tool_choice: None, + response_format: None, + temperature: Some(0.0), + top_p: None, + max_tokens: Some(4096), + stop_sequences: None, reasoning_effort: None, - speed: None, - metadata: None, + speed: None, + metadata: None, provider_options: None, }; @@ -145,21 +141,18 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" history.compact(preserve_count, summary_content); - emitter.emit( - session_id.to_owned(), - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count: preserve_count, - summary_token_estimate, - tracked_file_count: file_tracker.file_count(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::CompactionCompleted { + original_turn_count, + preserved_turn_count: preserve_count, + summary_token_estimate, + tracked_file_count: file_tracker.file_count(), + }); Ok(()) } -/// Estimate the total token count of the system prompt and conversation history. -/// Uses a rough heuristic of ~4 characters per token. +/// Estimate the total token count of the system prompt and conversation +/// history. Uses a rough heuristic of ~4 characters per token. pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { let mut total_chars = system_prompt.len(); @@ -194,7 +187,8 @@ pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { total_chars / 4 // rough estimate: ~4 chars per token } -/// Render conversation turns into a human-readable summary format for the compaction LLM call. +/// Render conversation turns into a human-readable summary format for the +/// compaction LLM call. pub fn render_turns_for_summary(turns: &[Turn]) -> String { let mut out = String::new(); for turn in turns { @@ -250,40 +244,42 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String { #[cfg(test)] mod tests { + use std::time::SystemTime; + + use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; + use super::*; use crate::event::Emitter; use crate::history::History; use crate::test_support::TestProfile; use crate::tool_registry::ToolRegistry; use crate::types::Turn; - use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; - use std::time::SystemTime; #[test] fn render_turns_produces_labeled_text() { let turns = vec![ Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }, Turn::Assistant { - content: "Let me check".into(), - tool_calls: vec![ToolCall::new( + content: "Let me check".into(), + tool_calls: vec![ToolCall::new( "c1", "read_file", serde_json::json!({"path": "foo.rs"}), )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }, Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!("file contents here"), - is_error: false, - image_data: None, + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!("file contents here"), + is_error: false, + image_data: None, image_media_type: None, }], timestamp: SystemTime::now(), @@ -302,11 +298,11 @@ mod tests { fn render_turns_truncates_long_tool_output() { let long_output = "x".repeat(1000); let turns = vec![Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!(long_output), - is_error: false, - image_data: None, + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!(long_output), + is_error: false, + image_data: None, image_media_type: None, }], timestamp: SystemTime::now(), @@ -321,7 +317,7 @@ mod tests { fn estimate_token_count_basic() { let mut history = History::default(); history.push(Turn::User { - content: "Hello world".into(), // 11 chars + content: "Hello world".into(), // 11 chars timestamp: SystemTime::now(), }); // system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens @@ -343,7 +339,7 @@ mod tests { let mut history = History::default(); // Push enough content to exceed a tiny context window history.push(Turn::User { - content: "x".repeat(1000), + content: "x".repeat(1000), timestamp: SystemTime::now(), }); let emitter = Emitter::new(); diff --git a/lib/crates/fabro-agent/src/config.rs b/lib/crates/fabro-agent/src/config.rs index c468b81bc..6e2f626b6 100644 --- a/lib/crates/fabro-agent/src/config.rs +++ b/lib/crates/fabro-agent/src/config.rs @@ -81,7 +81,8 @@ pub struct SessionOptions { pub enable_context_compaction: bool, pub compaction_threshold_percent: usize, pub compaction_preserve_turns: usize, - /// Skill directories. `None` = use convention defaults, `Some(dirs)` = use these instead. + /// Skill directories. `None` = use convention defaults, `Some(dirs)` = use + /// these instead. pub skill_dirs: Option>, /// MCP server configurations to connect to on session startup. pub mcp_servers: Vec, @@ -215,12 +216,9 @@ mod tests { let approval: ToolApprovalFn = Arc::new(|_name, _args| Err("denied".to_string())); let adapter = ToolApprovalAdapter(approval); let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await; - assert_eq!( - decision, - ToolHookDecision::Block { - reason: "denied".to_string() - } - ); + assert_eq!(decision, ToolHookDecision::Block { + reason: "denied".to_string(), + }); } #[tokio::test] diff --git a/lib/crates/fabro-agent/src/error.rs b/lib/crates/fabro-agent/src/error.rs index 190ff1791..6ba7228d6 100644 --- a/lib/crates/fabro-agent/src/error.rs +++ b/lib/crates/fabro-agent/src/error.rs @@ -38,14 +38,15 @@ pub enum AgentError { #[cfg(test)] mod tests { - use super::*; use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; + use super::*; + #[test] fn agent_error_from_sdk_error() { let sdk_err = SdkError::Network { message: "connection refused".into(), - source: None, + source: None, }; let agent_err = AgentError::from(sdk_err); assert!(matches!(agent_err, AgentError::Llm(_))); @@ -88,7 +89,7 @@ mod tests { fn serde_roundtrip_llm_network() { let err = AgentError::Llm(SdkError::Network { message: "connection refused".into(), - source: None, + source: None, }); let json = serde_json::to_string(&err).unwrap(); let deserialized: AgentError = serde_json::from_str(&json).unwrap(); @@ -98,14 +99,14 @@ mod tests { #[test] fn serde_roundtrip_llm_provider() { let err = AgentError::Llm(SdkError::Provider { - kind: ProviderErrorKind::RateLimit, + kind: ProviderErrorKind::RateLimit, detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), + message: "too fast".into(), + provider: "openai".into(), status_code: Some(429), - error_code: None, + error_code: None, retry_after: Some(2.0), - raw: None, + raw: None, }), }); let json = serde_json::to_string(&err).unwrap(); @@ -152,7 +153,7 @@ mod tests { let errors: Vec = vec![ AgentError::Llm(SdkError::Network { message: "refused".into(), - source: None, + source: None, }), AgentError::SessionClosed, AgentError::InvalidState("reason".into()), @@ -170,7 +171,7 @@ mod tests { fn serde_tag_format_llm() { let err = AgentError::Llm(SdkError::Network { message: "refused".into(), - source: None, + source: None, }); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); diff --git a/lib/crates/fabro-agent/src/event.rs b/lib/crates/fabro-agent/src/event.rs index 2e2cb9d27..ae6febfec 100644 --- a/lib/crates/fabro-agent/src/event.rs +++ b/lib/crates/fabro-agent/src/event.rs @@ -1,7 +1,9 @@ -use crate::types::{AgentEvent, SessionEvent}; use std::time::SystemTime; + use tokio::sync::broadcast; +use crate::types::{AgentEvent, SessionEvent}; + #[derive(Clone)] pub struct Emitter { sender: broadcast::Sender, @@ -52,22 +54,16 @@ mod tests { let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); - emitter.emit( - "sess-1".into(), - AgentEvent::SessionStarted { - provider: Some("anthropic".into()), - model: Some("claude-opus".into()), - }, - ); + emitter.emit("sess-1".into(), AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }); let event = receiver.recv().await.unwrap(); - assert!(matches!( - event.event, - AgentEvent::SessionStarted { - provider: Some(_), - model: Some(_) - } - )); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); assert_eq!(event.session_id, "sess-1"); assert_eq!(event.parent_session_id, None); } @@ -77,12 +73,9 @@ mod tests { let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); - emitter.emit( - "sess-2".into(), - AgentEvent::Error { - error: AgentError::ToolExecution("something went wrong".into()), - }, - ); + emitter.emit("sess-2".into(), AgentEvent::Error { + error: AgentError::ToolExecution("something went wrong".into()), + }); let event = receiver.recv().await.unwrap(); assert!( @@ -112,12 +105,9 @@ mod tests { #[test] fn emit_without_subscribers_does_not_panic() { let emitter = Emitter::new(); - emitter.emit( - "sess-4".into(), - AgentEvent::Error { - error: AgentError::ToolExecution("test".into()), - }, - ); + emitter.emit("sess-4".into(), AgentEvent::Error { + error: AgentError::ToolExecution("test".into()), + }); } #[test] @@ -132,24 +122,21 @@ mod tests { let mut receiver = emitter.subscribe(); emitter.forward(SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("anthropic".into()), - model: Some("claude-opus".into()), + model: Some("claude-opus".into()), }, - timestamp: SystemTime::now(), - session_id: "child".into(), + timestamp: SystemTime::now(), + session_id: "child".into(), parent_session_id: Some("parent".into()), }); let event = receiver.recv().await.unwrap(); assert_eq!(event.session_id, "child"); assert_eq!(event.parent_session_id.as_deref(), Some("parent")); - assert!(matches!( - event.event, - AgentEvent::SessionStarted { - provider: Some(_), - model: Some(_) - } - )); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); } } diff --git a/lib/crates/fabro-agent/src/file_tracker.rs b/lib/crates/fabro-agent/src/file_tracker.rs index ed8cc04fd..10d6e274b 100644 --- a/lib/crates/fabro-agent/src/file_tracker.rs +++ b/lib/crates/fabro-agent/src/file_tracker.rs @@ -5,9 +5,9 @@ use fabro_llm::types::{ToolCall, ToolResult}; #[derive(Debug, Clone, Copy, Default)] struct FileOps { - read: bool, + read: bool, written: bool, - edited: bool, + edited: bool, } #[derive(Debug, Default)] diff --git a/lib/crates/fabro-agent/src/history.rs b/lib/crates/fabro-agent/src/history.rs index 7e9e7c339..74593dbc2 100644 --- a/lib/crates/fabro-agent/src/history.rs +++ b/lib/crates/fabro-agent/src/history.rs @@ -1,6 +1,7 @@ -use crate::types::Turn; use fabro_llm::types::{ContentPart, Message, Role}; +use crate::types::Turn; + #[derive(Debug, Clone, Default)] pub struct History { turns: Vec, @@ -25,7 +26,7 @@ impl History { let extracted_user_messages = extract_recent_user_messages(discarded, COMPACTION_USER_MESSAGE_TOKEN_BUDGET); self.turns.push(Turn::System { - content: summary, + content: summary, timestamp: std::time::SystemTime::now(), }); self.turns.extend(extracted_user_messages); @@ -33,10 +34,11 @@ impl History { self.strip_opaque_provider_items(); } - /// Remove provider-specific opaque items that are no longer valid after compaction. - /// OpenAI reasoning and message items are opaque round-trip data tied to specific API - /// responses; after compaction replaces their surrounding context with a summary, they - /// serve no purpose and can violate API constraints (reasoning must be followed by its + /// Remove provider-specific opaque items that are no longer valid after + /// compaction. OpenAI reasoning and message items are opaque round-trip + /// data tied to specific API responses; after compaction replaces their + /// surrounding context with a summary, they serve no purpose and can + /// violate API constraints (reasoning must be followed by its /// output, identified by the message item's `id`). fn strip_opaque_provider_items(&mut self) { for turn in &mut self.turns { @@ -70,9 +72,9 @@ impl History { parts.push(ContentPart::ToolCall(tc.clone())); } Message { - role: Role::Assistant, - content: parts, - name: None, + role: Role::Assistant, + content: parts, + name: None, tool_call_id: None, } } @@ -92,9 +94,9 @@ impl History { } Turn::System { content, .. } => Message::system(content), Turn::Steering { content, .. } => Message { - role: Role::User, - content: vec![ContentPart::text(content)], - name: None, + role: Role::User, + content: vec![ContentPart::text(content)], + name: None, tool_call_id: None, }, }) @@ -102,7 +104,8 @@ impl History { } } -/// Maximum token budget for user messages extracted from discarded turns during compaction. +/// Maximum token budget for user messages extracted from discarded turns during +/// compaction. const COMPACTION_USER_MESSAGE_TOKEN_BUDGET: usize = 20_000; /// Walk discarded turns in reverse, collecting `Turn::User` variants up to @@ -135,16 +138,18 @@ fn extract_recent_user_messages(discarded: Vec, token_budget: usize) -> Ve #[cfg(test)] mod tests { - use super::*; - use fabro_llm::types::{ThinkingData, TokenCounts, ToolCall, ToolResult}; use std::time::SystemTime; + use fabro_llm::types::{ThinkingData, TokenCounts, ToolCall, ToolResult}; + + use super::*; + #[test] fn compact_replaces_old_turns_with_summary() { let mut history = History::default(); for i in 0..8 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -158,7 +163,7 @@ mod tests { let mut history = History::default(); for i in 0..3 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -171,7 +176,7 @@ mod tests { let mut history = History::default(); for i in 0..8 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -194,7 +199,7 @@ mod tests { let mut history = History::default(); for i in 0..6 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -215,7 +220,7 @@ mod tests { fn user_turn_maps_to_user_message() { let mut history = History::default(); history.push(Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -228,12 +233,12 @@ mod tests { fn assistant_turn_maps_to_assistant_message() { let mut history = History::default(); history.push(Turn::Assistant { - content: "Hi there".into(), - tool_calls: vec![], + content: "Hi there".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); @@ -246,12 +251,12 @@ mod tests { let mut history = History::default(); let tc = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "foo.rs"})); history.push(Turn::Assistant { - content: "Let me read that".into(), - tool_calls: vec![tc], + content: "Let me read that".into(), + tool_calls: vec![tc], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "resp_2".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_2".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages[0].role, Role::Assistant); @@ -267,17 +272,17 @@ mod tests { fn assistant_turn_with_reasoning_in_provider_parts() { let mut history = History::default(); let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think about this...".into(), + text: "Let me think about this...".into(), signature: None, - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "The answer is 42".into(), - tool_calls: vec![], + content: "The answer is 42".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), - response_id: "resp_3".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_3".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] @@ -292,17 +297,17 @@ mod tests { fn thinking_with_signature_preserved_via_provider_parts() { let mut history = History::default(); let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think...".into(), + text: "Let me think...".into(), signature: Some("sig_abc123".into()), - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "The answer".into(), - tool_calls: vec![], + content: "The answer".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), - response_id: "resp_4".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_4".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] @@ -328,12 +333,12 @@ mod tests { }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); history.push(Turn::Assistant { - content: String::new(), - tool_calls: vec![tc], + content: String::new(), + tool_calls: vec![tc], provider_parts: vec![reasoning_item], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); @@ -349,7 +354,7 @@ mod tests { let mut history = History::default(); let result = ToolResult::success("call_1", serde_json::json!("file contents here")); history.push(Turn::ToolResults { - results: vec![result], + results: vec![result], timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -362,7 +367,7 @@ mod tests { fn system_turn_maps_to_system_message() { let mut history = History::default(); history.push(Turn::System { - content: "You are a coding assistant".into(), + content: "You are a coding assistant".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -375,7 +380,7 @@ mod tests { fn steering_turn_maps_to_user_message() { let mut history = History::default(); history.push(Turn::Steering { - content: "Focus on the main task".into(), + content: "Focus on the main task".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -389,17 +394,17 @@ mod tests { let mut history = History::default(); assert_eq!(history.turns().len(), 0); history.push(Turn::User { - content: "First".into(), + content: "First".into(), timestamp: SystemTime::now(), }); assert_eq!(history.turns().len(), 1); history.push(Turn::Assistant { - content: "Second".into(), - tool_calls: vec![], + content: "Second".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); assert_eq!(history.turns().len(), 2); } @@ -408,31 +413,31 @@ mod tests { fn round_trip_preserves_content() { let mut history = History::default(); history.push(Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::Assistant { - content: "Hi".into(), - tool_calls: vec![ToolCall::new( + content: "Hi".into(), + tool_calls: vec![ToolCall::new( "c1", "shell", serde_json::json!({"cmd": "ls"}), )], provider_parts: vec![ContentPart::Thinking(ThinkingData { - text: "thinking...".into(), + text: "thinking...".into(), signature: None, - redacted: false, + redacted: false, })], - usage: Box::new(TokenCounts { + usage: Box::new(TokenCounts { input_tokens: 10, output_tokens: 5, ..Default::default() }), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.push(Turn::ToolResults { - results: vec![ToolResult::success( + results: vec![ToolResult::success( "c1", serde_json::json!("file1.rs\nfile2.rs"), )], @@ -450,11 +455,11 @@ mod tests { fn compact_strips_openai_reasoning_from_preserved_turns() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "recent msg".into(), + content: "recent msg".into(), timestamp: SystemTime::now(), }); let reasoning = ContentPart::Other { @@ -463,17 +468,18 @@ mod tests { }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); history.push(Turn::Assistant { - content: "response".into(), - tool_calls: vec![tc], + content: "response".into(), + tool_calls: vec![tc], provider_parts: vec![reasoning], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.compact(2, "Summary".into()); - // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), + // preserved Assistant let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, @@ -497,30 +503,31 @@ mod tests { fn compact_preserves_anthropic_thinking_blocks() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "recent msg".into(), + content: "recent msg".into(), timestamp: SystemTime::now(), }); let thinking = ContentPart::Thinking(ThinkingData { - text: "deep thought".into(), + text: "deep thought".into(), signature: Some("sig_xyz".into()), - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "answer".into(), - tool_calls: vec![], + content: "answer".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.compact(2, "Summary".into()); - // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), + // preserved Assistant let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, .. } = assistant_turn { assert_eq!( @@ -538,21 +545,21 @@ mod tests { fn compact_strips_reasoning_from_all_preserved_assistant_turns() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); // Two assistant turns that will both be preserved for i in 0..2 { history.push(Turn::Assistant { - content: format!("response {i}"), - tool_calls: vec![], + content: format!("response {i}"), + tool_calls: vec![], provider_parts: vec![ContentPart::Other { kind: ContentPart::OPENAI_REASONING.into(), data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), }], - usage: Box::new(TokenCounts::default()), - response_id: format!("resp_{i}"), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: format!("resp_{i}"), + timestamp: SystemTime::now(), }); } @@ -572,19 +579,19 @@ mod tests { fn extract_recent_user_messages_collects_in_chronological_order() { let turns = vec![ Turn::User { - content: "first".into(), + content: "first".into(), timestamp: SystemTime::now(), }, Turn::Assistant { - content: "reply".into(), - tool_calls: vec![], + content: "reply".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "r1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "r1".into(), + timestamp: SystemTime::now(), }, Turn::User { - content: "second".into(), + content: "second".into(), timestamp: SystemTime::now(), }, ]; @@ -598,15 +605,16 @@ mod tests { fn extract_recent_user_messages_respects_token_budget() { let turns = vec![ Turn::User { - content: "a".repeat(100), + content: "a".repeat(100), timestamp: SystemTime::now(), }, Turn::User { - content: "b".repeat(100), + content: "b".repeat(100), timestamp: SystemTime::now(), }, ]; - // Budget of 30 tokens = 120 chars; second message (100 chars) fits, first would exceed + // Budget of 30 tokens = 120 chars; second message (100 chars) fits, first would + // exceed let extracted = extract_recent_user_messages(turns, 30); assert_eq!(extracted.len(), 1); assert!(matches!(&extracted[0], Turn::User { content, .. } if content.starts_with('b'))); @@ -616,19 +624,19 @@ mod tests { fn compact_extracts_only_user_turns_from_discarded() { let mut history = History::default(); history.push(Turn::User { - content: "user msg".into(), + content: "user msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::Assistant { - content: "assistant msg".into(), - tool_calls: vec![], + content: "assistant msg".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "r1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "r1".into(), + timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "preserved".into(), + content: "preserved".into(), timestamp: SystemTime::now(), }); diff --git a/lib/crates/fabro-agent/src/loop_detection.rs b/lib/crates/fabro-agent/src/loop_detection.rs index cca080707..926bd2a3c 100644 --- a/lib/crates/fabro-agent/src/loop_detection.rs +++ b/lib/crates/fabro-agent/src/loop_detection.rs @@ -1,8 +1,9 @@ -use crate::history::History; -use crate::types::Turn; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +use crate::history::History; +use crate::types::Turn; + fn tool_call_signature(name: &str, arguments: &serde_json::Value) -> u64 { let mut hasher = DefaultHasher::new(); name.hash(&mut hasher); @@ -23,7 +24,8 @@ fn extract_signatures_from_assistant(turn: &Turn) -> Vec { #[must_use] pub fn detect_loop(history: &History, window_size: usize) -> bool { - // Extract tool call signatures from the last N assistant turns that have tool calls + // Extract tool call signatures from the last N assistant turns that have tool + // calls let turns = history.turns(); let mut signatures: Vec = Vec::new(); @@ -93,18 +95,20 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool { #[cfg(test)] mod tests { - use super::*; - use fabro_llm::types::{TokenCounts, ToolCall}; use std::time::SystemTime; + use fabro_llm::types::{TokenCounts, ToolCall}; + + use super::*; + fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn { Turn::Assistant { - content: String::new(), - tool_calls: vec![ToolCall::new("call_1", name, args)], + content: String::new(), + tool_calls: vec![ToolCall::new("call_1", name, args)], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), - response_id: "resp".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp".into(), + timestamp: SystemTime::now(), } } @@ -262,15 +266,15 @@ mod tests { fn user_turns_are_ignored() { let mut history = History::default(); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); assert!(!detect_loop(&history, 10)); diff --git a/lib/crates/fabro-agent/src/mcp_integration.rs b/lib/crates/fabro-agent/src/mcp_integration.rs index ab2cbaed2..65d8a3fef 100644 --- a/lib/crates/fabro-agent/src/mcp_integration.rs +++ b/lib/crates/fabro-agent/src/mcp_integration.rs @@ -5,7 +5,8 @@ use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string} use crate::tool_registry::RegisteredTool; -/// Create `RegisteredTool` instances for every tool exposed by connected MCP servers. +/// Create `RegisteredTool` instances for every tool exposed by connected MCP +/// servers. pub fn make_mcp_tools(manager: &Arc) -> Vec { manager .all_tools() @@ -17,11 +18,11 @@ pub fn make_mcp_tools(manager: &Arc) -> Vec) -> Vec McpServerSettings { let test_server = format!( "{}/../fabro-mcp/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR") ); McpServerSettings { - name: "test-echo".into(), - transport: McpTransport::Stdio { + name: "test-echo".into(), + transport: McpTransport::Stdio { command: vec!["python3".into(), test_server], - env: HashMap::new(), + env: HashMap::new(), }, startup_timeout_secs: 10, - tool_timeout_secs: 30, + tool_timeout_secs: 30, } } diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs index 4a46d4f44..8c5554537 100644 --- a/lib/crates/fabro-agent/src/memory.rs +++ b/lib/crates/fabro-agent/src/memory.rs @@ -1,8 +1,10 @@ -use crate::sandbox::Sandbox; -use fabro_model::Provider; use std::collections::HashSet; + +use fabro_model::Provider; use tracing::{debug, info, warn}; +use crate::sandbox::Sandbox; + const BUDGET_BYTES: usize = 32768; pub async fn discover_memory( @@ -112,11 +114,12 @@ fn truncate_to_budget(content: &str, budget: usize) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use super::*; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; - use std::collections::HashMap; - use std::sync::Arc; #[tokio::test] async fn discovers_agents_md() { diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 80995a6be..7992e6eb7 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -1,14 +1,13 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; -use fabro_model::Provider; - -use super::EnvContext; pub struct AnthropicProfile { base: BaseProfile, @@ -166,11 +165,13 @@ in the project. Keep changes minimal and focused on the task."; #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn anthropic_profile_identity() { @@ -250,12 +251,12 @@ mod tests { let profile = AnthropicProfile::new("claude-opus-4-6"); let env = MockSandbox::linux(); let ctx = EnvContext { - git_branch: Some("feature-branch".into()), - is_git_repo: true, - current_date: "2026-02-20".into(), - model: "claude-opus-4-6".into(), - knowledge_cutoff: "May 2025".into(), - git_status_short: None, + git_branch: Some("feature-branch".into()), + is_git_repo: true, + current_date: "2026-02-20".into(), + model: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, git_recent_commits: None, }; let prompt = profile.build_system_prompt(&env, &ctx, &[], None, &[]); diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index f137ee98d..85e75f1ea 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -1,7 +1,9 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; @@ -9,9 +11,6 @@ use crate::tools::{ WebFetchSummarizer, make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool, register_core_tools, }; -use fabro_model::Provider; - -use super::EnvContext; pub struct GeminiProfile { base: BaseProfile, @@ -201,11 +200,13 @@ in the project."; #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn gemini_profile_identity() { diff --git a/lib/crates/fabro-agent/src/profiles/mod.rs b/lib/crates/fabro-agent/src/profiles/mod.rs index 742099aa0..717e22914 100644 --- a/lib/crates/fabro-agent/src/profiles/mod.rs +++ b/lib/crates/fabro-agent/src/profiles/mod.rs @@ -3,40 +3,42 @@ pub mod gemini; pub mod openai; pub use anthropic::AnthropicProfile; +use fabro_model::Provider; pub use gemini::GeminiProfile; pub use openai::OpenAiProfile; use crate::sandbox::Sandbox; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::ToolRegistry; -use fabro_model::Provider; /// Common fields shared by all provider profiles. /// -/// Each concrete profile embeds this struct and delegates `provider()`, `model()`, -/// `tool_registry()`, and `tool_registry_mut()` to it. +/// Each concrete profile embeds this struct and delegates `provider()`, +/// `model()`, `tool_registry()`, and `tool_registry_mut()` to it. pub struct BaseProfile { pub provider: Provider, - pub model: String, + pub model: String, pub registry: ToolRegistry, } /// Additional context for building environment blocks #[derive(Default)] pub struct EnvContext { - pub git_branch: Option, - pub is_git_repo: bool, - pub current_date: String, - pub model: String, - pub knowledge_cutoff: String, - pub git_status_short: Option, + pub git_branch: Option, + pub is_git_repo: bool, + pub current_date: String, + pub model: String, + pub knowledge_cutoff: String, + pub git_status_short: Option, pub git_recent_commits: Option, } -/// Assembles a complete system prompt from a core prompt template and standard sections. +/// Assembles a complete system prompt from a core prompt template and standard +/// sections. /// -/// The `core_prompt` should contain `{env_block}` as a placeholder where the environment -/// context block will be inserted. Project docs and user instructions are appended at the end. +/// The `core_prompt` should contain `{env_block}` as a placeholder where the +/// environment context block will be inserted. Project docs and user +/// instructions are appended at the end. #[must_use] pub fn assemble_system_prompt( core_prompt: &str, @@ -131,12 +133,12 @@ mod tests { fn env_context_block_with_extra_context() { let env = MockSandbox::linux(); let ctx = EnvContext { - git_branch: Some("main".into()), - is_git_repo: true, - current_date: "2026-02-20".into(), - model: "claude-opus-4-6".into(), - knowledge_cutoff: "May 2025".into(), - git_status_short: None, + git_branch: Some("main".into()), + is_git_repo: true, + current_date: "2026-02-20".into(), + model: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, git_recent_commits: None, }; let block = build_env_context_block_with(&env, &ctx); diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index 27fbc286b..83e7a5949 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -1,15 +1,14 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{WebFetchSummarizer, register_core_tools}; use crate::v4a_patch::make_apply_patch_tool; -use fabro_model::Provider; - -use super::EnvContext; pub struct OpenAiProfile { base: BaseProfile, @@ -199,11 +198,13 @@ in the project."); #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn openai_profile_identity() { diff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/crates/fabro-agent/src/sandbox.rs index 234d28325..b452538c9 100644 --- a/lib/crates/fabro-agent/src/sandbox.rs +++ b/lib/crates/fabro-agent/src/sandbox.rs @@ -1,9 +1,8 @@ // Re-export all sandbox types from fabro-sandbox. +// Re-export the delegate_sandbox! macro at crate root so existing +// `crate::delegate_sandbox!` invocations continue to work. +pub use fabro_sandbox::delegate_sandbox; pub use fabro_sandbox::{ DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, format_lines_numbered, shell_quote, }; - -// Re-export the delegate_sandbox! macro at crate root so existing -// `crate::delegate_sandbox!` invocations continue to work. -pub use fabro_sandbox::delegate_sandbox; diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 869d64622..dc35a51bd 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1,3 +1,23 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use fabro_llm::client::Client; +use fabro_llm::error::{ProviderErrorKind, SdkError}; +use fabro_llm::generate::StreamAccumulator; +use fabro_llm::provider::StreamEventStream; +use fabro_llm::retry; +use fabro_llm::types::{ + ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice, +}; +use fabro_mcp::config::{McpServerSettings, McpTransport}; +use fabro_mcp::connection_manager::McpConnectionManager; +use futures::StreamExt; +use tokio::sync::{Mutex as AsyncMutex, broadcast}; +use tokio::time; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + use crate::agent_profile::AgentProfile; use crate::compaction::{check_context_usage, compact_context}; use crate::config::SessionOptions; @@ -16,44 +36,26 @@ use crate::skills::{ use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager}; use crate::tool_execution::execute_tool_calls; use crate::types::{AgentEvent, SessionEvent, SessionState, Turn}; -use fabro_llm::client::Client; -use fabro_llm::error::{ProviderErrorKind, SdkError}; -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::retry; -use fabro_llm::types::{ - ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice, -}; -use fabro_mcp::config::{McpServerSettings, McpTransport}; -use fabro_mcp::connection_manager::McpConnectionManager; -use futures::StreamExt; -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; -use tokio::sync::{Mutex as AsyncMutex, broadcast}; -use tokio::time; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; pub struct Session { - id: String, - config: SessionOptions, - history: History, - event_emitter: Emitter, - state: SessionState, - llm_client: Client, + id: String, + config: SessionOptions, + history: History, + event_emitter: Emitter, + state: SessionState, + llm_client: Client, provider_profile: Arc, - sandbox: Arc, - steering_queue: Arc>>, - followup_queue: Arc>>, - cancel_token: CancellationToken, + sandbox: Arc, + steering_queue: Arc>>, + followup_queue: Arc>>, + cancel_token: CancellationToken, interrupt_reason: Arc>>, - memory: Vec, - env_context: EnvContext, - skills: Vec, - system_prompt: String, - file_tracker: FileTracker, - tool_env: Option>, + memory: Vec, + env_context: EnvContext, + skills: Vec, + system_prompt: String, + file_tracker: FileTracker, + tool_env: Option>, subagent_manager: Option>>, } @@ -98,16 +100,14 @@ impl Session { &self.id } - /// Initialize session by discovering project docs and capturing environment context. - /// Call before `process_input`. + /// Initialize session by discovering project docs and capturing environment + /// context. Call before `process_input`. pub async fn initialize(&mut self) { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::SessionStarted { + self.event_emitter + .emit(self.id.clone(), AgentEvent::SessionStarted { provider: Some(self.provider_profile.provider().to_string()), - model: Some(self.provider_profile.model().to_string()), - }, - ); + model: Some(self.provider_profile.model().to_string()), + }); let doc_root = self .config @@ -155,22 +155,18 @@ impl Session { for (server_name, result) in &results { match result { Ok(tool_count) => { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerReady { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerReady { server_name: server_name.clone(), - tool_count: *tool_count, - }, - ); + tool_count: *tool_count, + }); } Err(e) => { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerFailed { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerFailed { server_name: server_name.clone(), - error: e.to_string(), - }, - ); + error: e.to_string(), + }); } } } @@ -202,8 +198,9 @@ impl Session { ); } - /// Resolve `McpTransport::Sandbox` configs by starting the MCP server inside the - /// sandbox and rewriting the transport to `Http` with the sandbox's preview URL. + /// Resolve `McpTransport::Sandbox` configs by starting the MCP server + /// inside the sandbox and rewriting the transport to `Http` with the + /// sandbox's preview URL. async fn resolve_sandbox_mcp_servers(&self) -> Vec { let mut resolved = Vec::with_capacity(self.config.mcp_servers.len()); @@ -219,10 +216,10 @@ impl Session { "Sandbox MCP server started, connecting via HTTP" ); resolved.push(McpServerSettings { - name: config.name.clone(), - transport: McpTransport::Http { url, headers }, + name: config.name.clone(), + transport: McpTransport::Http { url, headers }, startup_timeout_secs: config.startup_timeout_secs, - tool_timeout_secs: config.tool_timeout_secs, + tool_timeout_secs: config.tool_timeout_secs, }); } Err(e) => { @@ -231,13 +228,11 @@ impl Session { error = %e, "Failed to start sandbox MCP server" ); - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerFailed { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerFailed { server_name: config.name.clone(), - error: e, - }, - ); + error: e, + }); } } } @@ -248,7 +243,8 @@ impl Session { resolved } - /// Start an MCP server inside the sandbox and return (url, headers) for HTTP connection. + /// Start an MCP server inside the sandbox and return (url, headers) for + /// HTTP connection. async fn start_sandbox_mcp_server( &self, command: &[String], @@ -300,7 +296,8 @@ impl Session { )); } - // Get the preview URL for the port, or fall back to localhost for local sandboxes + // Get the preview URL for the port, or fall back to localhost for local + // sandboxes if let Some(url_and_headers) = sandbox.get_preview_url(port).await? { Ok(url_and_headers) } else { @@ -418,12 +415,9 @@ impl Session { } fn emit_llm_error(&mut self, err: SdkError) -> AgentError { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::Error { - error: AgentError::Llm(err.clone()), - }, - ); + self.event_emitter.emit(self.id.clone(), AgentEvent::Error { + error: AgentError::Llm(err.clone()), + }); if is_auth_error(&err) { self.transition(SessionState::Closed); } @@ -464,8 +458,8 @@ impl Session { self.cancel_token.clone() } - /// Build a callback that forwards sub-agent lifecycle and child session events - /// through this session's emitter. + /// Build a callback that forwards sub-agent lifecycle and child session + /// events through this session's emitter. #[must_use] pub fn sub_agent_event_callback(&self) -> SubAgentEventCallback { let emitter = self.event_emitter.clone(); @@ -628,33 +622,29 @@ impl Session { // Expand skill references in input let expanded = if self.skills.is_empty() { ExpandedInput { - text: input.to_string(), + text: input.to_string(), skill_name: None, } } else { expand_skill(&self.skills, input).map_err(AgentError::InvalidState)? }; if let Some(ref name) = expanded.skill_name { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::SkillExpanded { + self.event_emitter + .emit(self.id.clone(), AgentEvent::SkillExpanded { skill_name: name.clone(), - }, - ); + }); } let expanded_input = expanded.text; // Append user turn and emit event self.history.push(Turn::User { - content: expanded_input.clone(), + content: expanded_input.clone(), timestamp: SystemTime::now(), }); - self.event_emitter.emit( - self.id.clone(), - AgentEvent::UserInput { + self.event_emitter + .emit(self.id.clone(), AgentEvent::UserInput { text: expanded_input.clone(), - }, - ); + }); // Drain steering queue before first LLM call self.drain_steering(); @@ -666,23 +656,19 @@ impl Session { if self.config.max_tool_rounds_per_input > 0 && round_count >= self.config.max_tool_rounds_per_input { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TurnLimitReached { + self.event_emitter + .emit(self.id.clone(), AgentEvent::TurnLimitReached { max_turns: self.config.max_tool_rounds_per_input, - }, - ); + }); break; } // Check max_turns if self.config.max_turns > 0 && self.history.turns().len() >= self.config.max_turns { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TurnLimitReached { + self.event_emitter + .emit(self.id.clone(), AgentEvent::TurnLimitReached { max_turns: self.config.max_turns, - }, - ); + }); break; } @@ -710,16 +696,13 @@ impl Session { let retry_policy = RetryPolicy { max_retries: 3, on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| { - retry_emitter.emit( - retry_session_id.clone(), - AgentEvent::LlmRetry { - provider: retry_provider.clone(), - model: retry_model.clone(), - attempt: attempt as usize, - delay_secs: delay.as_secs_f64(), - error: err.clone(), - }, - ); + retry_emitter.emit(retry_session_id.clone(), AgentEvent::LlmRetry { + provider: retry_provider.clone(), + model: retry_model.clone(), + attempt: attempt as usize, + delay_secs: delay.as_secs_f64(), + error: err.clone(), + }); })), ..Default::default() }; @@ -799,7 +782,7 @@ impl Session { self.event_emitter.emit( self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), + text: String::new(), reasoning: None, }, ); @@ -813,7 +796,7 @@ impl Session { let Some(response) = response else { return Err(self.emit_llm_error(SdkError::Stream { message: "Stream ended without a Finish event (after retries)".into(), - source: None, + source: None, })); }; @@ -839,15 +822,13 @@ impl Session { }); // Emit AssistantMessage with enriched data from the response - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantMessage { - text: text.clone(), - model: response.model.clone(), - usage: response.usage.clone(), + self.event_emitter + .emit(self.id.clone(), AgentEvent::AssistantMessage { + text: text.clone(), + model: response.model.clone(), + usage: response.usage.clone(), tool_call_count: tool_calls.len(), - }, - ); + }); // Post-response compaction: trim context after appending assistant turn self.compact_if_needed().await; @@ -937,12 +918,9 @@ impl Session { ) .await { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::Error { - error: AgentError::InvalidState(format!("Context compaction failed: {e}")), - }, - ); + self.event_emitter.emit(self.id.clone(), AgentEvent::Error { + error: AgentError::InvalidState(format!("Context compaction failed: {e}")), + }); } } } @@ -957,7 +935,7 @@ impl Session { for msg in messages { let text = msg.clone(); self.history.push(Turn::Steering { - content: msg, + content: msg, timestamp: SystemTime::now(), }); self.event_emitter @@ -1011,19 +989,21 @@ const fn is_auth_error(err: &SdkError) -> bool { #[cfg(test)] mod tests { - use super::*; - use crate::config::ToolApprovalAdapter; - use crate::subagent::SubAgentStatus; - use crate::test_support::*; - use crate::tool_registry::{RegisteredTool, ToolRegistry}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; use fabro_llm::types::{ ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, ToolDefinition, }; use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::config::ToolApprovalAdapter; + use crate::subagent::SubAgentStatus; + use crate::test_support::*; + use crate::tool_registry::{RegisteredTool, ToolRegistry}; #[derive(Clone)] enum ScriptedStreamCall { @@ -1033,7 +1013,7 @@ mod tests { } struct ScriptedStreamProvider { - calls: Vec, + calls: Vec, call_index: AtomicUsize, } @@ -1082,7 +1062,7 @@ mod tests { async fn complete(&self, _request: &Request) -> Result { Err(SdkError::Configuration { message: "ScriptedStreamProvider does not implement complete()".into(), - source: None, + source: None, }) } @@ -1464,11 +1444,11 @@ mod tests { // Tool that cancels the token when executed let abort_tool = RegisteredTool { definition: ToolDefinition { - name: "set_abort".into(), + name: "set_abort".into(), description: "Sets interrupt flag".into(), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(move |_args, _ctx| { + executor: Arc::new(move |_args, _ctx| { let token = cancel_token_for_tool.clone(); Box::pin(async move { token.cancel(); @@ -1517,7 +1497,7 @@ mod tests { async fn auth_error_closes_session() { let error_provider = Arc::new(MockErrorProvider { error: SdkError::Provider { - kind: ProviderErrorKind::Authentication, + kind: ProviderErrorKind::Authentication, detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")), }, }); @@ -1718,9 +1698,9 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "strict_tool".into(), + name: "strict_tool".into(), description: "Tool with required params".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -1728,7 +1708,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("should not reach".to_string()) }) }), }); @@ -1759,9 +1739,9 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "strict_tool".into(), + name: "strict_tool".into(), description: "Tool with required params".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -1769,7 +1749,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("tool executed".to_string()) }) }), }); @@ -1816,7 +1796,8 @@ mod tests { session_end_count += 1; } } - // SessionStarted is emitted once during initialize(), SessionEnded once during close() + // SessionStarted is emitted once during initialize(), SessionEnded once during + // close() assert_eq!(session_start_count, 1); assert_eq!(session_end_count, 1); } @@ -2080,9 +2061,9 @@ mod tests { async fn stream_mid_stream_error() { let provider = Arc::new(MockMidStreamErrorProvider { partial_text: "partial".into(), - error: SdkError::Stream { + error: SdkError::Stream { message: "connection reset".into(), - source: None, + source: None, }, }); let client = make_client(provider as Arc).await; @@ -2168,22 +2149,19 @@ mod tests { } } - assert_eq!( - observed, - vec![ - "start".to_string(), - "delta:Hel".to_string(), - "replace::None".to_string(), - "delta:Hello".to_string(), - "message:Hello".to_string(), - ] - ); + assert_eq!(observed, vec![ + "start".to_string(), + "delta:Hel".to_string(), + "replace::None".to_string(), + "delta:Hello".to_string(), + "message:Hello".to_string(), + ]); } #[tokio::test] async fn retry_open_auth_error_emits_error_and_closes_session() { let auth_error = SdkError::Provider { - kind: ProviderErrorKind::Authentication, + kind: ProviderErrorKind::Authentication, detail: Box::new(ProviderErrorDetail { status_code: Some(401), ..ProviderErrorDetail::new("bad key", "mock") @@ -2232,22 +2210,20 @@ mod tests { } } - assert_eq!( - observed, - vec![ - "start".to_string(), - "delta:Hel".to_string(), - "replace::None".to_string(), - "error".to_string(), - ] - ); + assert_eq!(observed, vec![ + "start".to_string(), + "delta:Hel".to_string(), + "replace::None".to_string(), + "error".to_string(), + ]); assert!(found_auth_error_event, "expected auth error event"); } #[tokio::test] async fn compaction_triggered_when_over_threshold() { // Tiny context window to trigger compaction - // Responses: [0] conversation response (stream), [1] summarization (complete), [2] unused fallback + // Responses: [0] conversation response (stream), [1] summarization (complete), + // [2] unused fallback let responses = vec![ text_response("OK"), text_response("Here is the summary of the conversation so far."), @@ -2327,11 +2303,12 @@ mod tests { #[tokio::test] async fn compaction_failure_is_non_fatal() { - // Response [0] = conversation response (stream), [1] will be used for summarization (complete) but we - // need it to error. We'll use a special provider that errors on complete() but succeeds on stream(). + // Response [0] = conversation response (stream), [1] will be used for + // summarization (complete) but we need it to error. We'll use a special + // provider that errors on complete() but succeeds on stream(). struct StreamOnlyProvider { - responses: Vec, + responses: Vec, call_index: AtomicUsize, } @@ -2344,7 +2321,7 @@ mod tests { async fn complete(&self, _request: &Request) -> Result { Err(SdkError::Stream { message: "summarization failed".into(), - source: None, + source: None, }) } @@ -2418,14 +2395,15 @@ mod tests { #[tokio::test] async fn compaction_includes_structured_prompt_and_file_tracking() { - use crate::tool_registry::RegisteredTool; use fabro_llm::types::ToolDefinition; + use crate::tool_registry::RegisteredTool; + // Provider that captures complete() requests (compaction) while returning // canned responses for stream() calls. struct CompactionCapturingProvider { - stream_responses: Vec, - stream_index: AtomicUsize, + stream_responses: Vec, + stream_index: AtomicUsize, captured_complete: Mutex>, } @@ -2454,11 +2432,11 @@ mod tests { // read_file tool that always succeeds let read_tool = RegisteredTool { definition: ToolDefinition { - name: "read_file".into(), + name: "read_file".into(), description: "Read a file".into(), - parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), + parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("file contents".to_string()) }) }), }; @@ -2510,7 +2488,8 @@ mod tests { "read_file should be tracked" ); - // Second call with large input: context is well over threshold, compaction triggers + // Second call with large input: context is well over threshold, compaction + // triggers let large_input = "x".repeat(400); session.process_input(&large_input).await.unwrap(); @@ -2556,22 +2535,23 @@ mod tests { #[tokio::test] async fn mcp_end_to_end_tool_call() { - use fabro_mcp::config::{McpServerSettings, McpTransport}; use std::collections::HashMap; + use fabro_mcp::config::{McpServerSettings, McpTransport}; + let test_server = format!( "{}/../fabro-mcp/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR") ); let config = SessionOptions { mcp_servers: vec![McpServerSettings { - name: "test-echo".into(), - transport: McpTransport::Stdio { + name: "test-echo".into(), + transport: McpTransport::Stdio { command: vec!["python3".into(), test_server], - env: HashMap::new(), + env: HashMap::new(), }, startup_timeout_secs: 10, - tool_timeout_secs: 30, + tool_timeout_secs: 30, }], enable_loop_detection: false, ..Default::default() @@ -2677,11 +2657,11 @@ mod tests { // Register a tool that loops until the cancel token fires let slow_tool = RegisteredTool { definition: ToolDefinition { - name: "slow_tool".into(), + name: "slow_tool".into(), description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, ctx| { + executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; Ok("cancelled".to_string()) diff --git a/lib/crates/fabro-agent/src/skills.rs b/lib/crates/fabro-agent/src/skills.rs index 9f58a75d2..a4d00226e 100644 --- a/lib/crates/fabro-agent/src/skills.rs +++ b/lib/crates/fabro-agent/src/skills.rs @@ -1,14 +1,16 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; + use crate::sandbox::Sandbox; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; -use fabro_llm::types::ToolDefinition; -use std::sync::Arc; #[derive(Debug, Clone)] pub struct Skill { - pub name: String, + pub name: String, pub description: String, - pub template: String, + pub template: String, } pub fn parse_skill(content: &str) -> Result { @@ -46,21 +48,23 @@ pub fn parse_skill(content: &str) -> Result { }) } -/// A detected skill reference in user input: the name and byte range of the `/name` token. +/// A detected skill reference in user input: the name and byte range of the +/// `/name` token. struct SkillMatch { - name: String, + name: String, /// Byte offset of the `/` character start: usize, /// Byte offset just past the skill name - end: usize, + end: usize, } fn is_skill_name_char(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' } -/// Find all `/skill-name` tokens in input where the `/` is preceded by whitespace (or -/// start-of-string) and the name is followed by whitespace (or end-of-string). +/// Find all `/skill-name` tokens in input where the `/` is preceded by +/// whitespace (or start-of-string) and the name is followed by whitespace (or +/// end-of-string). fn find_skill_references(input: &str) -> Vec { let mut results = Vec::new(); let bytes = input.as_bytes(); @@ -93,9 +97,9 @@ fn find_skill_references(input: &str) -> Vec { let followed_by_boundary = j >= len || bytes[j].is_ascii_whitespace(); if followed_by_boundary { results.push(SkillMatch { - name: input[name_start..j].to_string(), + name: input[name_start..j].to_string(), start: i, - end: j, + end: j, }); } @@ -110,7 +114,7 @@ fn find_skill_references(input: &str) -> Vec { #[derive(Debug)] pub struct ExpandedInput { - pub text: String, + pub text: String, pub skill_name: Option, } @@ -119,7 +123,7 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result Result>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "use_skill".into(), + name: "use_skill".into(), description: "Load a skill's instructions by name. Call this when the user's \ request matches an available skill." .into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "skill_name": { @@ -170,7 +174,7 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { "required": ["skill_name"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let skills = skills.clone(); Box::pin(async move { let name = required_str(&args, "skill_name")?; @@ -247,12 +251,14 @@ pub async fn discover_skills(env: &dyn Sandbox, dirs: &[String]) -> Vec { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use tokio_util::sync::CancellationToken; + use super::*; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; - use std::collections::HashMap; - use tokio_util::sync::CancellationToken; // --- parse_skill tests --- @@ -337,14 +343,14 @@ name: trimmed fn test_skills() -> Vec { vec![ Skill { - name: "commit".into(), + name: "commit".into(), description: "Create a commit".into(), - template: "Review changes and commit.\n\n{{user_input}}".into(), + template: "Review changes and commit.\n\n{{user_input}}".into(), }, Skill { - name: "test".into(), + name: "test".into(), description: "Run tests".into(), - template: "Run the test suite.".into(), + template: "Run the test suite.".into(), }, ] } @@ -518,14 +524,11 @@ name: trimmed #[test] fn default_dirs_with_git_root() { let dirs = default_skill_dirs(Some("/home/user/.fabro/skills"), Some("/repo")); - assert_eq!( - dirs, - vec![ - "/home/user/.fabro/skills", - "/repo/.fabro/skills", - "/repo/skills", - ] - ); + assert_eq!(dirs, vec![ + "/home/user/.fabro/skills", + "/repo/.fabro/skills", + "/repo/skills", + ]); } #[test] diff --git a/lib/crates/fabro-agent/src/subagent.rs b/lib/crates/fabro-agent/src/subagent.rs index 8bc428ff6..d082b6c52 100644 --- a/lib/crates/fabro-agent/src/subagent.rs +++ b/lib/crates/fabro-agent/src/subagent.rs @@ -1,14 +1,16 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use fabro_llm::types::ToolDefinition; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + use crate::error::AgentError; use crate::session::Session; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; use crate::types::{AgentEvent, SessionEvent, Turn}; -use fabro_llm::types::ToolDefinition; -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use tokio::sync::Mutex as AsyncMutex; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; pub type SessionFactory = Arc Session + Send + Sync>; @@ -22,8 +24,8 @@ pub type SubAgentEventCallback = Arc>>, + task: Option>>, followup_queue: Arc>>, - cancel_token: CancellationToken, - depth: usize, - status: SubAgentStatus, + cancel_token: CancellationToken, + depth: usize, + status: SubAgentStatus, } pub struct SubAgentManager { - agents: HashMap, - max_depth: usize, + agents: HashMap, + max_depth: usize, event_callback: Option, } @@ -117,27 +119,24 @@ impl SubAgentManager { _ => None, }); Ok(SubAgentResult { - output: last_text.unwrap_or_default(), - success: true, + output: last_text.unwrap_or_default(), + success: true, turns_used: turns.len(), }) }); - self.agents.insert( - agent_id.clone(), - SubAgent { - task: Some(task), - followup_queue, - cancel_token, - depth: depth + 1, - status: SubAgentStatus::Running, - }, - ); + self.agents.insert(agent_id.clone(), SubAgent { + task: Some(task), + followup_queue, + cancel_token, + depth: depth + 1, + status: SubAgentStatus::Running, + }); self.emit_event(AgentEvent::SubAgentSpawned { agent_id: agent_id.clone(), - depth: depth + 1, - task: task_prompt, + depth: depth + 1, + task: task_prompt, }); Ok(agent_id) @@ -308,9 +307,9 @@ pub fn make_spawn_agent_tool( ) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "spawn_agent".into(), + name: "spawn_agent".into(), description: "Spawn a subagent to work on a delegated task".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "task": { @@ -333,7 +332,7 @@ pub fn make_spawn_agent_tool( "required": ["task"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); let session_factory = session_factory.clone(); Box::pin(async move { @@ -347,7 +346,8 @@ pub fn make_spawn_agent_tool( // Note: working_dir and model require session factory changes to wire through let mut session = session_factory(); - // Default subagent max_turns is 0 (unlimited) per spec (overridable via parameter) + // Default subagent max_turns is 0 (unlimited) per spec (overridable via + // parameter) session.set_max_turns(max_turns.unwrap_or(0)); let mut mgr = manager.lock().await; mgr.spawn(session, task.to_string(), current_depth) @@ -360,9 +360,9 @@ pub fn make_spawn_agent_tool( pub fn make_send_input_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "send_input".into(), + name: "send_input".into(), description: "Send a follow-up message to a running subagent".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -377,7 +377,7 @@ pub fn make_send_input_tool(manager: Arc>) -> Regist "required": ["agent_id", "message"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -395,9 +395,9 @@ pub fn make_send_input_tool(manager: Arc>) -> Regist pub fn make_wait_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "wait".into(), + name: "wait".into(), description: "Wait for a subagent to complete and return its result".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -408,7 +408,7 @@ pub fn make_wait_tool(manager: Arc>) -> RegisteredTo "required": ["agent_id"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -427,9 +427,9 @@ pub fn make_wait_tool(manager: Arc>) -> RegisteredTo pub fn make_close_agent_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "close_agent".into(), + name: "close_agent".into(), description: "Close a running subagent".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -440,7 +440,7 @@ pub fn make_close_agent_tool(manager: Arc>) -> Regis "required": ["agent_id"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -455,13 +455,14 @@ pub fn make_close_agent_tool(manager: Arc>) -> Regis #[cfg(test)] mod tests { - use super::*; - use crate::config::SessionOptions; - use crate::test_support::*; use fabro_llm::provider::ProviderAdapter; use fabro_llm::types::Role; use tokio::time; + use super::*; + use crate::config::SessionOptions; + use crate::test_support::*; + // --- Tests --- #[test] @@ -718,21 +719,21 @@ mod tests { let mut rx = parent.subscribe(); callback(SubAgentCallbackEvent::Forwarded(SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("anthropic".into()), - model: Some("claude-opus".into()), + model: Some("claude-opus".into()), }, - timestamp: std::time::SystemTime::now(), - session_id: "child".into(), + timestamp: std::time::SystemTime::now(), + session_id: "child".into(), parent_session_id: None, })); callback(SubAgentCallbackEvent::Forwarded(SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("anthropic".into()), - model: Some("claude-opus".into()), + model: Some("claude-opus".into()), }, - timestamp: std::time::SystemTime::now(), - session_id: "grandchild".into(), + timestamp: std::time::SystemTime::now(), + session_id: "grandchild".into(), parent_session_id: Some("child".into()), })); @@ -750,7 +751,7 @@ mod tests { let manager = SubAgentManager::new(3); manager.emit_event(AgentEvent::SubAgentClosed { agent_id: "x".into(), - depth: 0, + depth: 0, }); } diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/crates/fabro-agent/src/test_support.rs index 1092561f4..bf209df15 100644 --- a/lib/crates/fabro-agent/src/test_support.rs +++ b/lib/crates/fabro-agent/src/test_support.rs @@ -1,12 +1,7 @@ -pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; -use crate::agent_profile::AgentProfile; -use crate::config::SessionOptions; -use crate::profiles::EnvContext; -use crate::sandbox::*; -use crate::session::Session; -use crate::skills::{Skill, format_skills_prompt_section}; -use crate::tool_registry::{RegisteredTool, ToolRegistry}; use async_trait::async_trait; use fabro_llm::client::Client; use fabro_llm::error::SdkError; @@ -15,22 +10,28 @@ use fabro_llm::types::{ ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, }; use fabro_model::Provider; +pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; use futures::stream; -use std::collections::HashMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; + +use crate::agent_profile::AgentProfile; +use crate::config::SessionOptions; +use crate::profiles::EnvContext; +use crate::sandbox::*; +use crate::session::Session; +use crate::skills::{Skill, format_skills_prompt_section}; +use crate::tool_registry::{RegisteredTool, ToolRegistry}; // --- TestProfile --- pub struct TestProfile { - pub registry: ToolRegistry, + pub registry: ToolRegistry, pub context_window: usize, } impl TestProfile { pub fn new() -> Self { Self { - registry: ToolRegistry::new(), + registry: ToolRegistry::new(), context_window: 200_000, } } @@ -97,7 +98,7 @@ impl AgentProfile for TestProfile { // --- MockLlmProvider --- pub struct MockLlmProvider { - pub responses: Vec, + pub responses: Vec, pub call_index: AtomicUsize, } @@ -169,26 +170,27 @@ pub fn response_to_stream(response: Response) -> StreamEventStream { pub fn text_response(text: &str) -> Response { Response { - id: format!("resp_{text}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), + id: format!("resp_{text}"), + model: "mock-model".into(), + provider: "mock".into(), + message: Message::assistant(text), finish_reason: FinishReason::Stop, - usage: TokenCounts { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } pub async fn make_client(provider: Arc) -> Client { let mut providers = HashMap::new(); providers.insert(provider.name().to_string(), provider.clone()); - // Also register under "anthropic" so TestProfile (Provider::Anthropic) routes correctly + // Also register under "anthropic" so TestProfile (Provider::Anthropic) routes + // correctly providers.insert("anthropic".to_string(), provider); Client::new(providers, Some("mock".into()), vec![]) } @@ -236,27 +238,27 @@ pub fn tool_call_response( ) -> Response { use fabro_llm::types::{ContentPart, Role, ToolCall}; Response { - id: format!("resp_{tool_call_id}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ + id: format!("resp_{tool_call_id}"), + model: "mock-model".into(), + provider: "mock".into(), + message: Message { + role: Role::Assistant, + content: vec![ ContentPart::text("Let me use a tool."), ContentPart::ToolCall(ToolCall::new(tool_call_id, tool_name, args)), ], - name: None, + name: None, tool_call_id: None, }, finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } @@ -264,11 +266,11 @@ pub fn make_echo_tool() -> RegisteredTool { use fabro_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition { - name: "echo".into(), + name: "echo".into(), description: "Echoes the input".into(), - parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), + parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), }, - executor: Arc::new(|args, _ctx| { + executor: Arc::new(|args, _ctx| { Box::pin(async move { let text = args .get("text") @@ -284,11 +286,11 @@ pub fn make_error_tool() -> RegisteredTool { use fabro_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition { - name: "fail_tool".into(), + name: "fail_tool".into(), description: "Always fails".into(), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Err("tool execution failed".to_string()) }) }), } @@ -358,7 +360,7 @@ impl ProviderAdapter for CapturingLlmProvider { /// A mock provider that yields some text deltas then an error mid-stream. pub struct MockMidStreamErrorProvider { pub partial_text: String, - pub error: SdkError, + pub error: SdkError, } #[async_trait] @@ -391,23 +393,23 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> ))); } Response { - id: "resp_multi".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { + id: "resp_multi".into(), + model: "mock-model".into(), + provider: "mock".into(), + message: Message { role: Role::Assistant, content, name: None, tool_call_id: None, }, finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } diff --git a/lib/crates/fabro-agent/src/tool_execution.rs b/lib/crates/fabro-agent/src/tool_execution.rs index 291d89e59..31bcfbf1a 100644 --- a/lib/crates/fabro-agent/src/tool_execution.rs +++ b/lib/crates/fabro-agent/src/tool_execution.rs @@ -1,17 +1,20 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use fabro_llm::types::{ToolCall, ToolResult}; +use futures::future; +use tokio_util::sync::CancellationToken; +use tracing::debug; + use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision}; use crate::event::Emitter; use crate::sandbox::Sandbox; use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry}; use crate::truncation::truncate_tool_output; use crate::types::AgentEvent; -use fabro_llm::types::{ToolCall, ToolResult}; -use futures::future; -use std::collections::HashMap; -use std::sync::Arc; -use tokio_util::sync::CancellationToken; -use tracing::debug; -/// Execute tool calls, choosing parallel or sequential based on `parallel` flag. +/// Execute tool calls, choosing parallel or sequential based on `parallel` +/// flag. #[allow(clippy::too_many_arguments)] pub async fn execute_tool_calls( tool_calls: &[ToolCall], @@ -163,7 +166,8 @@ pub async fn execute_and_emit_one_tool( .await } -/// Execute a single tool call with event emission, using a pre-looked-up tool reference. +/// Execute a single tool call with event emission, using a pre-looked-up tool +/// reference. #[allow(clippy::too_many_arguments)] async fn execute_and_emit_one_tool_with_lookup( tc: &ToolCall, @@ -176,14 +180,11 @@ async fn execute_and_emit_one_tool_with_lookup( session_id: &str, tool_env: Option<&HashMap>, ) -> ToolResult { - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallStarted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - arguments: tc.arguments.clone(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + arguments: tc.arguments.clone(), + }); // Pre-tool-use hook if let Some(hooks) = tool_hooks { @@ -196,21 +197,15 @@ async fn execute_and_emit_one_tool_with_lookup( if let ToolHookDecision::Block { reason } = decision { let result = ToolResult::error(&tc.id, &reason); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), - }, - ); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: true, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta { + delta: result.content.to_string(), + }); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + output: result.content.clone(), + is_error: true, + }); return truncate_tool_result(&result, &tc.name, config); } @@ -218,22 +213,16 @@ async fn execute_and_emit_one_tool_with_lookup( let result = execute_one_tool(tc, registered_tool, env, cancel_token, tool_env).await; - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta { + delta: result.content.to_string(), + }); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: result.is_error, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + output: result.content.clone(), + is_error: result.is_error, + }); // Post-tool-use hooks if let Some(hooks) = tool_hooks { @@ -304,10 +293,10 @@ fn truncate_tool_result( }; ToolResult { - tool_call_id: result.tool_call_id.clone(), - content: truncated_content, - is_error: result.is_error, - image_data: result.image_data.clone(), + tool_call_id: result.tool_call_id.clone(), + content: truncated_content, + is_error: result.is_error, + image_data: result.image_data.clone(), image_media_type: result.image_media_type.clone(), } } @@ -343,6 +332,10 @@ pub fn validate_tool_args( #[cfg(test)] mod tests { + use std::sync::Mutex; + + use fabro_llm::types::{ToolCall, ToolDefinition}; + use super::*; use crate::config::{ToolHookCallback, ToolHookDecision}; use crate::event::Emitter; @@ -353,15 +346,13 @@ mod tests { use crate::tools::{ make_edit_file_tool, make_grep_tool, make_read_file_tool, make_write_file_tool, }; - use fabro_llm::types::{ToolCall, ToolDefinition}; - use std::sync::Mutex; fn make_echo_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "echo".to_string(), + name: "echo".to_string(), description: "Echo input".to_string(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -369,7 +360,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| { + executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { let text = args["text"].as_str().unwrap_or("").to_string(); Ok(format!("echo: {text}")) @@ -381,11 +372,11 @@ mod tests { fn make_fail_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "fail_tool".to_string(), + name: "fail_tool".to_string(), description: "Always fails".to_string(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| { + executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { Err("tool failed".to_string()) }) }), } @@ -393,26 +384,26 @@ mod tests { fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall { ToolCall { - id: id.to_string(), - name: name.to_string(), - tool_type: "function".to_string(), - arguments: args, - raw_arguments: None, + id: id.to_string(), + name: name.to_string(), + tool_type: "function".to_string(), + arguments: args, + raw_arguments: None, provider_metadata: None, } } struct MockHookCallback { - pre_decision: ToolHookDecision, - post_calls: Arc>>, + pre_decision: ToolHookDecision, + post_calls: Arc>>, post_failure_calls: Arc>>, } impl MockHookCallback { fn new(decision: ToolHookDecision) -> Self { Self { - pre_decision: decision, - post_calls: Arc::new(Mutex::new(Vec::new())), + pre_decision: decision, + post_calls: Arc::new(Mutex::new(Vec::new())), post_failure_calls: Arc::new(Mutex::new(Vec::new())), } } diff --git a/lib/crates/fabro-agent/src/tool_registry.rs b/lib/crates/fabro-agent/src/tool_registry.rs index 79809a406..cc0984249 100644 --- a/lib/crates/fabro-agent/src/tool_registry.rs +++ b/lib/crates/fabro-agent/src/tool_registry.rs @@ -1,14 +1,16 @@ -use crate::sandbox::Sandbox; -use fabro_llm::types::ToolDefinition; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; use tokio_util::sync::CancellationToken; +use crate::sandbox::Sandbox; + pub struct ToolContext { - pub env: Arc, - pub cancel: CancellationToken, + pub env: Arc, + pub cancel: CancellationToken, pub tool_env: Option>, } @@ -24,7 +26,7 @@ pub type ToolExecutor = Arc< #[derive(Clone)] pub struct RegisteredTool { pub definition: ToolDefinition, - pub executor: ToolExecutor, + pub executor: ToolExecutor, } pub struct ToolRegistry { @@ -78,11 +80,11 @@ mod tests { fn make_tool(name: &str) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: name.into(), + name: name.into(), description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), } } @@ -122,19 +124,19 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "tool_a".into(), + name: "tool_a".into(), description: "version 1".into(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), }); registry.register(RegisteredTool { definition: ToolDefinition { - name: "tool_a".into(), + name: "tool_a".into(), description: "version 2".into(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), }); let tool = registry.get("tool_a").unwrap(); diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index fde68b011..049f64b43 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -1,19 +1,21 @@ -use crate::config::SessionOptions; -use crate::sandbox::GrepOptions; -use crate::tool_registry::{RegisteredTool, ToolRegistry}; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, ToolDefinition}; -use fabro_model::ModelHandle; use std::borrow::Cow; use std::fmt::Write; use std::sync::Arc; +use fabro_llm::client::Client; +use fabro_llm::types::{Message, Request, ToolDefinition}; +use fabro_model::ModelHandle; + +use crate::config::SessionOptions; +use crate::sandbox::GrepOptions; +use crate::tool_registry::{RegisteredTool, ToolRegistry}; + const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; /// Configuration for the optional LLM-based summarizer used by `web_fetch`. #[derive(Clone)] pub struct WebFetchSummarizer { - pub client: Client, + pub client: Client, pub model_id: ModelHandle, } @@ -40,12 +42,12 @@ fn html_to_markdown(text: &str) -> String { converter.convert(text).unwrap_or_else(|_| text.to_string()) } -/// Registers the core tools shared by all provider profiles: `read_file`, `write_file`, -/// `shell`, `grep`, `glob`, `web_search`, and `web_fetch`. +/// Registers the core tools shared by all provider profiles: `read_file`, +/// `write_file`, `shell`, `grep`, `glob`, `web_search`, and `web_fetch`. /// -/// The shell tool uses `config` to set its default and max timeouts. Pass a custom -/// `SessionOptions` (e.g. with a longer `default_command_timeout_ms`) for providers -/// that need non-default shell behavior. +/// The shell tool uses `config` to set its default and max timeouts. Pass a +/// custom `SessionOptions` (e.g. with a longer `default_command_timeout_ms`) +/// for providers that need non-default shell behavior. pub fn register_core_tools( registry: &mut ToolRegistry, config: &SessionOptions, @@ -70,9 +72,9 @@ pub(crate) fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> Result pub fn make_read_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "read_file".into(), + name: "read_file".into(), description: "Read the contents of a file".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -82,7 +84,7 @@ pub fn make_read_file_tool() -> RegisteredTool { "required": ["file_path"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let offset = args.get("offset").and_then(serde_json::Value::as_u64); @@ -106,9 +108,9 @@ pub fn make_read_file_tool() -> RegisteredTool { pub fn make_write_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "write_file".into(), + name: "write_file".into(), description: "Write content to a file".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -117,7 +119,7 @@ pub fn make_write_file_tool() -> RegisteredTool { "required": ["file_path", "content"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let content = required_str(&args, "content")?; @@ -133,9 +135,9 @@ pub fn make_write_file_tool() -> RegisteredTool { pub fn make_edit_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "edit_file".into(), + name: "edit_file".into(), description: "Edit a file by replacing a string".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -146,7 +148,7 @@ pub fn make_edit_file_tool() -> RegisteredTool { "required": ["file_path", "old_string", "new_string"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let old_string = required_str(&args, "old_string")?; @@ -199,9 +201,9 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool { let max_timeout = config.max_command_timeout_ms; RegisteredTool { definition: ToolDefinition { - name: "shell".into(), + name: "shell".into(), description: "Execute a shell command".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "command": {"type": "string", "description": "The shell command to execute"}, @@ -211,7 +213,7 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool { "required": ["command"] }), }, - executor: Arc::new(move |args, ctx| { + executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; let timeout_ms = args @@ -257,9 +259,9 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool { pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "grep".into(), + name: "grep".into(), description: "Search file contents with a regex pattern".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern to search for"}, @@ -271,7 +273,7 @@ pub fn make_grep_tool() -> RegisteredTool { "required": ["pattern"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; let path = args @@ -314,9 +316,9 @@ pub fn make_grep_tool() -> RegisteredTool { pub fn make_glob_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "glob".into(), + name: "glob".into(), description: "Find files matching a glob pattern".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern to match files"}, @@ -325,7 +327,7 @@ pub fn make_glob_tool() -> RegisteredTool { "required": ["pattern"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; let path = args.get("path").and_then(serde_json::Value::as_str); @@ -341,9 +343,9 @@ pub fn make_glob_tool() -> RegisteredTool { pub(crate) fn make_read_many_files_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "read_many_files".into(), + name: "read_many_files".into(), description: "Read multiple files at once".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "paths": { @@ -355,7 +357,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { "required": ["paths"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let paths = args["paths"] .as_array() @@ -386,9 +388,9 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { pub(crate) fn make_list_dir_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "list_dir".into(), + name: "list_dir".into(), description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list"}, @@ -397,7 +399,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { "required": ["path"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; let depth = args @@ -469,9 +471,9 @@ fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool RegisteredTool { definition: ToolDefinition { - name: "web_search".into(), + name: "web_search".into(), description: "Search the web using Brave Search".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, @@ -480,7 +482,7 @@ fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool "required": ["query"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let api_key = api_key.clone(); Box::pin(async move { let api_key = api_key.ok_or_else(|| { @@ -616,13 +618,15 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg #[cfg(test)] mod tests { + use std::collections::HashMap; + + use fabro_llm::provider::ProviderAdapter; + use tokio_util::sync::CancellationToken; + use super::*; use crate::sandbox::*; use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; - use fabro_llm::provider::ProviderAdapter; - use std::collections::HashMap; - use tokio_util::sync::CancellationToken; #[tokio::test] async fn read_file_returns_content() { @@ -634,14 +638,11 @@ mod tests { apply_read_offset_limit: true, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"file_path": "/test.txt"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; assert_eq!(result.unwrap(), " 1 | hello\n 2 | world"); } @@ -679,8 +680,8 @@ mod tests { let result = (tool.executor)( serde_json::json!({"file_path": "/out.txt", "content": "hello"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -709,8 +710,8 @@ mod tests { "new_string": "goodbye" }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -791,8 +792,8 @@ mod tests { "replace_all": true }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -808,22 +809,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "hello".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "hello".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 10, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "echo hello"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("Exit code: 0")); @@ -838,8 +836,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -852,22 +850,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: "error".into(), - exit_code: 1, - timed_out: false, + stdout: String::new(), + stderr: "error".into(), + exit_code: 1, + timed_out: false, duration_ms: 10, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "false"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "false"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("Exit code: 1")); @@ -879,22 +874,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: -1, - timed_out: true, + stdout: String::new(), + stderr: String::new(), + exit_code: -1, + timed_out: true, duration_ms: 10000, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "sleep 100"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "sleep 100"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.starts_with("Command timed out.\n")); @@ -910,8 +902,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "echo $MY_KEY"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: Some(tool_env.clone()), }, ) @@ -925,14 +917,11 @@ mod tests { let tool = make_shell_tool(); let env = Arc::new(MockSandbox::default()); let env_clone: Arc = env.clone(); - let _result = (tool.executor)( - serde_json::json!({"command": "echo hello"}), - ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { + env: env_clone, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let captured = env.captured_env_vars.lock().unwrap().clone(); assert_eq!(captured, None); @@ -943,10 +932,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "fetched content".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "fetched content".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -957,8 +946,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: Some(tool_env.clone()), }, ) @@ -977,14 +966,11 @@ mod tests { ], ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"pattern": "fn"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs:10:fn main()")); @@ -998,14 +984,11 @@ mod tests { glob_results: vec!["src/main.rs".into(), "src/lib.rs".into()], ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"pattern": "src/**/*.rs"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs")); @@ -1016,14 +999,11 @@ mod tests { async fn web_search_missing_api_key_returns_error() { let tool = make_web_search_tool_with_api_key(None); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)( - serde_json::json!({"query": "test"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"query": "test"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let err = result.unwrap_err(); assert!( @@ -1036,14 +1016,11 @@ mod tests { async fn web_search_missing_query_returns_error() { let tool = make_web_search_tool_with_api_key(Some("fake-key".into())); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)( - serde_json::json!({}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let err = result.unwrap_err(); assert!( @@ -1080,10 +1057,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

hello

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

hello

".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1092,8 +1069,8 @@ mod tests { let result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1150,8 +1127,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1172,8 +1149,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1192,10 +1169,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: large_content, - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: large_content, + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1219,10 +1196,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: "curl: (6) Could not resolve host".into(), - exit_code: 6, - timed_out: false, + stdout: String::new(), + stderr: "curl: (6) Could not resolve host".into(), + exit_code: 6, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1259,17 +1236,18 @@ mod tests { client, model_id: ModelHandle::ByName { provider: fabro_model::Provider::Anthropic, - model: "mock-model".to_string(), + model: "mock-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Lots of content about Rust...

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

Lots of content about Rust...

" + .into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1295,11 +1273,12 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Rust is a systems programming language.

" - .into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: + "

Rust is a systems programming language.

" + .into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1326,13 +1305,14 @@ mod tests { #[tokio::test] async fn web_fetch_summarizer_routes_to_specified_provider() { - use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind, SdkError}; + use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; + // "other_provider" is the default — it rejects all requests. let default_provider: Arc = Arc::new(MockErrorProvider { error: SdkError::Provider { - kind: ProviderErrorKind::NotFound, + kind: ProviderErrorKind::NotFound, detail: Box::new(ProviderErrorDetail::new( "model not found", "other_provider", @@ -1347,7 +1327,8 @@ mod tests { let mut providers = HashMap::new(); providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes here + // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes + // here providers.insert("anthropic".to_string(), target_provider); let client = Client::new(providers, Some("other_provider".into()), vec![]); @@ -1355,17 +1336,17 @@ mod tests { client, model_id: ModelHandle::ByName { provider: fabro_model::Provider::Anthropic, - model: "target-model".to_string(), + model: "target-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Page content

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

Page content

".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1448,14 +1429,11 @@ mod tests { // read_file tool should mark the file as agent-read let tool = make_read_file_tool(); - (tool.executor)( - serde_json::json!({"file_path": "a.ts"}), - ToolContext { - env: Arc::clone(&env), - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + (tool.executor)(serde_json::json!({"file_path": "a.ts"}), ToolContext { + env: Arc::clone(&env), + cancel: CancellationToken::new(), + tool_env: None, + }) .await .unwrap(); @@ -1480,14 +1458,11 @@ mod tests { // grep tool should mark matched files as agent-read let tool = make_grep_tool(); - (tool.executor)( - serde_json::json!({"pattern": "content"}), - ToolContext { - env: Arc::clone(&env), - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + (tool.executor)(serde_json::json!({"pattern": "content"}), ToolContext { + env: Arc::clone(&env), + cancel: CancellationToken::new(), + tool_env: None, + }) .await .unwrap(); diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index 007d18cfc..40391d0b4 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -1,14 +1,17 @@ -use crate::error::AgentError; +use std::time::SystemTime; + use fabro_llm::error::SdkError; use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult}; use serde::{Deserialize, Serialize}; -use std::time::SystemTime; + +use crate::error::AgentError; mod system_time_iso8601 { + use std::time::SystemTime; + use chrono::{DateTime, SecondsFormat, Utc}; use serde::de::Error as DeError; use serde::{self, Deserialize, Deserializer, Serializer}; - use std::time::SystemTime; pub(super) fn serialize(time: &SystemTime, serializer: S) -> Result where @@ -31,40 +34,43 @@ mod system_time_iso8601 { #[derive(Debug, Clone)] pub enum Turn { User { - content: String, + content: String, timestamp: SystemTime, }, Assistant { - content: String, - tool_calls: Vec, + content: String, + tool_calls: Vec, /// Provider-specific content parts (e.g. `OpenAI` reasoning items, - /// `Anthropic` thinking blocks with signatures) preserved for round-tripping. - /// Reasoning/thinking text is stored here as `ContentPart::Thinking`. + /// `Anthropic` thinking blocks with signatures) preserved for + /// round-tripping. Reasoning/thinking text is stored here as + /// `ContentPart::Thinking`. provider_parts: Vec, - usage: Box, - response_id: String, - timestamp: SystemTime, + usage: Box, + response_id: String, + timestamp: SystemTime, }, ToolResults { - results: Vec, + results: Vec, timestamp: SystemTime, }, - /// Injected content sent as a system-role message to the LLM (maps to `Role::System`). + /// Injected content sent as a system-role message to the LLM (maps to + /// `Role::System`). System { - content: String, + content: String, timestamp: SystemTime, }, - /// Injected steering content sent as a user-role message to the LLM (maps to `Role::User`). - /// Used to guide the assistant's behavior mid-conversation without appearing as actual user input. + /// Injected steering content sent as a user-role message to the LLM (maps + /// to `Role::User`). Used to guide the assistant's behavior + /// mid-conversation without appearing as actual user input. Steering { - content: String, + content: String, timestamp: SystemTime, }, } impl Turn { - /// Extract the first non-redacted thinking/reasoning text from an `Assistant` turn's - /// `provider_parts`, if any. + /// Extract the first non-redacted thinking/reasoning text from an + /// `Assistant` turn's `provider_parts`, if any. #[must_use] pub fn reasoning_text(&self) -> Option<&str> { let Self::Assistant { provider_parts, .. } = self else { @@ -95,7 +101,7 @@ pub enum AgentEvent { #[serde(default, skip_serializing_if = "Option::is_none")] provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, + model: Option, }, SessionEnded, ProcessingEnd, @@ -105,14 +111,14 @@ pub enum AgentEvent { AssistantTextStart, /// Replaces the current in-progress assistant output buffers. AssistantOutputReplace { - text: String, + text: String, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning: Option, }, AssistantMessage { - text: String, - model: String, - usage: TokenCounts, + text: String, + model: String, + usage: TokenCounts, tool_call_count: usize, }, TextDelta { @@ -122,24 +128,24 @@ pub enum AgentEvent { delta: String, }, ToolCallStarted { - tool_name: String, + tool_name: String, tool_call_id: String, - arguments: serde_json::Value, + arguments: serde_json::Value, }, ToolCallOutputDelta { delta: String, }, ToolCallCompleted { - tool_name: String, + tool_name: String, tool_call_id: String, - output: serde_json::Value, - is_error: bool, + output: serde_json::Value, + is_error: bool, }, Error { error: AgentError, }, Warning { - kind: String, + kind: String, message: String, details: serde_json::Value, }, @@ -154,49 +160,49 @@ pub enum AgentEvent { text: String, }, CompactionStarted { - estimated_tokens: usize, + estimated_tokens: usize, context_window_size: usize, }, CompactionCompleted { - original_turn_count: usize, - preserved_turn_count: usize, + original_turn_count: usize, + preserved_turn_count: usize, summary_token_estimate: usize, - tracked_file_count: usize, + tracked_file_count: usize, }, LlmRetry { - provider: String, - model: String, - attempt: usize, + provider: String, + model: String, + attempt: usize, delay_secs: f64, - error: SdkError, + error: SdkError, }, SubAgentSpawned { agent_id: String, - depth: usize, - task: String, + depth: usize, + task: String, }, SubAgentCompleted { - agent_id: String, - depth: usize, - success: bool, + agent_id: String, + depth: usize, + success: bool, turns_used: usize, }, SubAgentFailed { agent_id: String, - depth: usize, - error: AgentError, + depth: usize, + error: AgentError, }, SubAgentClosed { agent_id: String, - depth: usize, + depth: usize, }, McpServerReady { server_name: String, - tool_count: usize, + tool_count: usize, }, McpServerFailed { server_name: String, - error: String, + error: String, }, } @@ -406,10 +412,10 @@ impl AgentEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionEvent { - pub event: AgentEvent, + pub event: AgentEvent, #[serde(with = "system_time_iso8601")] - pub timestamp: SystemTime, - pub session_id: String, + pub timestamp: SystemTime, + pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_session_id: Option, } @@ -421,21 +427,18 @@ mod tests { #[test] fn session_event_construction() { let event = SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("anthropic".into()), - model: Some("claude-opus".into()), + model: Some("claude-opus".into()), }, - timestamp: SystemTime::now(), - session_id: "sess_1".into(), + timestamp: SystemTime::now(), + session_id: "sess_1".into(), parent_session_id: None, }; - assert!(matches!( - event.event, - AgentEvent::SessionStarted { - provider: Some(_), - model: Some(_) - } - )); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); assert_eq!(event.session_id, "sess_1"); assert_eq!(event.parent_session_id, None); } @@ -443,30 +446,24 @@ mod tests { #[test] fn compaction_events_constructible() { let started = AgentEvent::CompactionStarted { - estimated_tokens: 5000, + estimated_tokens: 5000, context_window_size: 8000, }; - assert!(matches!( - started, - AgentEvent::CompactionStarted { - estimated_tokens: 5000, - .. - } - )); + assert!(matches!(started, AgentEvent::CompactionStarted { + estimated_tokens: 5000, + .. + })); let completed = AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, + original_turn_count: 20, + preserved_turn_count: 6, summary_token_estimate: 500, - tracked_file_count: 3, + tracked_file_count: 3, }; - assert!(matches!( - completed, - AgentEvent::CompactionCompleted { - original_turn_count: 20, - .. - } - )); + assert!(matches!(completed, AgentEvent::CompactionCompleted { + original_turn_count: 20, + .. + })); } #[test] @@ -483,39 +480,36 @@ mod tests { fn subagent_spawned_constructible() { let event = AgentEvent::SubAgentSpawned { agent_id: "sa-1".into(), - depth: 1, - task: "list files".into(), + depth: 1, + task: "list files".into(), }; - assert!(matches!( - event, - AgentEvent::SubAgentSpawned { depth: 1, .. } - )); + assert!(matches!(event, AgentEvent::SubAgentSpawned { + depth: 1, + .. + })); } #[test] fn subagent_completed_constructible() { let event = AgentEvent::SubAgentCompleted { - agent_id: "sa-1".into(), - depth: 1, - success: true, + agent_id: "sa-1".into(), + depth: 1, + success: true, turns_used: 5, }; - assert!(matches!( - event, - AgentEvent::SubAgentCompleted { - success: true, - turns_used: 5, - .. - } - )); + assert!(matches!(event, AgentEvent::SubAgentCompleted { + success: true, + turns_used: 5, + .. + })); } #[test] fn subagent_failed_constructible() { let event = AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("timeout".into()), + depth: 0, + error: AgentError::ToolExecution("timeout".into()), }; assert!(matches!(event, AgentEvent::SubAgentFailed { depth: 0, .. })); } @@ -524,7 +518,7 @@ mod tests { fn subagent_closed_constructible() { let event = AgentEvent::SubAgentClosed { agent_id: "sa-1".into(), - depth: 2, + depth: 2, }; assert!(matches!(event, AgentEvent::SubAgentClosed { depth: 2, .. })); } @@ -534,23 +528,23 @@ mod tests { let events = vec![ AgentEvent::SubAgentSpawned { agent_id: "sa-1".into(), - depth: 0, - task: "test".into(), + depth: 0, + task: "test".into(), }, AgentEvent::SubAgentCompleted { - agent_id: "sa-1".into(), - depth: 0, - success: true, + agent_id: "sa-1".into(), + depth: 0, + success: true, turns_used: 3, }, AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("oops".into()), + depth: 0, + error: AgentError::ToolExecution("oops".into()), }, AgentEvent::SubAgentClosed { agent_id: "sa-1".into(), - depth: 0, + depth: 0, }, ]; let json = serde_json::to_string(&events).unwrap(); @@ -561,12 +555,12 @@ mod tests { #[test] fn session_event_serde_round_trip_without_parent_session_id() { let event = SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("anthropic".into()), - model: Some("claude-opus".into()), + model: Some("claude-opus".into()), }, - timestamp: SystemTime::now(), - session_id: "sess_42".into(), + timestamp: SystemTime::now(), + session_id: "sess_42".into(), parent_session_id: None, }; let json = serde_json::to_string(&event).unwrap(); @@ -579,24 +573,21 @@ mod tests { let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.session_id, "sess_42"); assert_eq!(deserialized.parent_session_id, None); - assert!(matches!( - deserialized.event, - AgentEvent::SessionStarted { - provider: Some(_), - model: Some(_) - } - )); + assert!(matches!(deserialized.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); } #[test] fn session_event_serde_round_trip_with_parent_session_id() { let event = SessionEvent { - event: AgentEvent::SessionStarted { + event: AgentEvent::SessionStarted { provider: Some("openai".into()), - model: Some("gpt-5.4".into()), + model: Some("gpt-5.4".into()), }, - timestamp: SystemTime::now(), - session_id: "sess_child".into(), + timestamp: SystemTime::now(), + session_id: "sess_child".into(), parent_session_id: Some("sess_parent".into()), }; let json = serde_json::to_string(&event).unwrap(); @@ -615,19 +606,19 @@ mod tests { fn mcp_server_ready_constructible() { let event = AgentEvent::McpServerReady { server_name: "filesystem".into(), - tool_count: 3, + tool_count: 3, }; - assert!(matches!( - event, - AgentEvent::McpServerReady { tool_count: 3, .. } - )); + assert!(matches!(event, AgentEvent::McpServerReady { + tool_count: 3, + .. + })); } #[test] fn mcp_server_failed_constructible() { let event = AgentEvent::McpServerFailed { server_name: "broken".into(), - error: "connection refused".into(), + error: "connection refused".into(), }; assert!( matches!(event, AgentEvent::McpServerFailed { server_name, .. } if server_name == "broken") @@ -639,20 +630,20 @@ mod tests { let events = vec![ AgentEvent::McpServerReady { server_name: "fs".into(), - tool_count: 5, + tool_count: 5, }, AgentEvent::McpServerFailed { server_name: "bad".into(), - error: "timeout".into(), + error: "timeout".into(), }, ]; let json = serde_json::to_string(&events).unwrap(); let deserialized: Vec = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.len(), 2); - assert!(matches!( - &deserialized[0], - AgentEvent::McpServerReady { tool_count: 5, .. } - )); + assert!(matches!(&deserialized[0], AgentEvent::McpServerReady { + tool_count: 5, + .. + })); assert!(matches!( &deserialized[1], AgentEvent::McpServerFailed { .. } @@ -662,16 +653,16 @@ mod tests { #[test] fn agent_event_assistant_message() { let usage = TokenCounts { - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 80, + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 80, cache_write_tokens: 10, - reasoning_tokens: 20, + reasoning_tokens: 20, }; let event = AgentEvent::AssistantMessage { - text: "Hello".into(), - model: "test-model".into(), - usage: usage.clone(), + text: "Hello".into(), + model: "test-model".into(), + usage: usage.clone(), tool_call_count: 2, }; match &event { @@ -692,7 +683,7 @@ mod tests { #[test] fn agent_event_assistant_output_replace_roundtrip() { let event = AgentEvent::AssistantOutputReplace { - text: "Hello again".into(), + text: "Hello again".into(), reasoning: Some("Retrying from scratch".into()), }; let json = serde_json::to_string(&event).unwrap(); @@ -713,7 +704,7 @@ mod tests { let event = AgentEvent::Error { error: AgentError::Llm(SdkError::Network { message: "refused".into(), - source: None, + source: None, }), }; let json = serde_json::to_string(&event).unwrap(); @@ -730,19 +721,19 @@ mod tests { fn llm_retry_event_carries_sdk_error() { use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; let event = AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-4".into(), - attempt: 1, + provider: "openai".into(), + model: "gpt-4".into(), + attempt: 1, delay_secs: 2.0, - error: SdkError::Provider { - kind: ProviderErrorKind::RateLimit, + error: SdkError::Provider { + kind: ProviderErrorKind::RateLimit, detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), + message: "too fast".into(), + provider: "openai".into(), status_code: Some(429), - error_code: None, + error_code: None, retry_after: Some(2.0), - raw: None, + raw: None, }), }, }; @@ -761,8 +752,8 @@ mod tests { fn subagent_failed_carries_agent_error() { let event = AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("cmd failed".into()), + depth: 0, + error: AgentError::ToolExecution("cmd failed".into()), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); @@ -789,7 +780,7 @@ mod tests { fn mcp_server_failed_still_string() { let event = AgentEvent::McpServerFailed { server_name: "broken".into(), - error: "connection refused".into(), + error: "connection refused".into(), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); diff --git a/lib/crates/fabro-agent/src/v4a_patch.rs b/lib/crates/fabro-agent/src/v4a_patch.rs index ee772f8d2..eb45971b1 100644 --- a/lib/crates/fabro-agent/src/v4a_patch.rs +++ b/lib/crates/fabro-agent/src/v4a_patch.rs @@ -1,8 +1,10 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; + use crate::sandbox::{Sandbox, format_lines_numbered}; use crate::tool_registry::RegisteredTool; use crate::truncation::{TruncationMode, truncate_output}; -use fabro_llm::types::ToolDefinition; -use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Change { @@ -14,23 +16,23 @@ pub enum Change { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hunk { pub context_line: String, - pub changes: Vec, - pub end_of_file: bool, + pub changes: Vec, + pub end_of_file: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PatchOperation { Add { - path: String, + path: String, content: String, }, Delete { path: String, }, Update { - path: String, + path: String, new_path: Option, - hunks: Vec, + hunks: Vec, }, } @@ -400,9 +402,9 @@ fn format_patch_error(error: &str, path: &str, content: &str) -> String { pub fn make_apply_patch_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "apply_patch".into(), + name: "apply_patch".into(), description: "Apply a v4a format patch to modify files".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "patch": { @@ -413,7 +415,7 @@ pub fn make_apply_patch_tool() -> RegisteredTool { "required": ["patch"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let patch_text = args .get("patch") @@ -429,9 +431,10 @@ pub fn make_apply_patch_tool() -> RegisteredTool { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; use crate::test_support::MutableMockSandbox; - use std::collections::HashMap; #[test] fn parse_v4a_add_file() { @@ -445,13 +448,10 @@ mod tests { let ops = parse_v4a_patch(patch).unwrap(); assert_eq!(ops.len(), 1); - assert_eq!( - ops[0], - PatchOperation::Add { - path: "src/new_file.rs".into(), - content: "fn main() {\n println!(\"hello\");\n}".into(), - } - ); + assert_eq!(ops[0], PatchOperation::Add { + path: "src/new_file.rs".into(), + content: "fn main() {\n println!(\"hello\");\n}".into(), + }); } #[test] @@ -463,12 +463,9 @@ mod tests { let ops = parse_v4a_patch(patch).unwrap(); assert_eq!(ops.len(), 1); - assert_eq!( - ops[0], - PatchOperation::Delete { - path: "src/old_file.rs".into(), - } - ); + assert_eq!(ops[0], PatchOperation::Delete { + path: "src/old_file.rs".into(), + }); } #[test] @@ -585,21 +582,21 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/game.py".into(), + path: "src/game.py".into(), new_path: None, - hunks: vec![ + hunks: vec![ Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove("from src.cards import Suit".into()), Change::Add("from src.cards import Card, Suit".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" stock: list = field(default_factory=list)".into()), Change::Remove(" waste: list = field(default_factory=list)".into()), Change::Add(" stock: list[Card] = field(default_factory=list)".into()), @@ -718,12 +715,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Context("fn unchanged() {".into()), Change::Remove(" old_line();".into()), Change::Add(" new_line();".into()), @@ -749,21 +746,21 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![ + hunks: vec![ Hunk { context_line: "def setup():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_setup()".into()), Change::Add(" new_setup()".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_teardown()".into()), Change::Add(" new_teardown()".into()), ], @@ -785,7 +782,7 @@ mod tests { async fn apply_patch_add_file() { let env = MutableMockSandbox::new(HashMap::new()); let ops = vec![PatchOperation::Add { - path: "src/new.rs".into(), + path: "src/new.rs".into(), content: "fn new() {}".into(), }]; @@ -806,12 +803,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "fn hello() {".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" println!(\"old\");".into()), Change::Add(" println!(\"new\");".into()), ], @@ -859,12 +856,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/game.py".into(), + path: "src/game.py".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "def nonexistent():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_body()".into()), Change::Add(" new_body()".into()), ], @@ -885,16 +882,16 @@ mod tests { let hunks = vec![ Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 1".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 2".into()), ], @@ -979,8 +976,8 @@ mod tests { let content = "def foo():\n pass\n\ndef bar():\n pass"; let hunks = vec![Hunk { context_line: String::new(), - end_of_file: true, - changes: vec![ + end_of_file: true, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 99".into()), ], @@ -1028,12 +1025,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/old.py".into(), + path: "src/old.py".into(), new_path: Some("src/new.py".into()), - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "def hello():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 1".into()), ], @@ -1060,8 +1057,8 @@ mod tests { let content = " indented\nindented"; let hunks = vec![Hunk { context_line: "indented".into(), - end_of_file: false, - changes: vec![Change::Add("extra".into())], + end_of_file: false, + changes: vec![Change::Add("extra".into())], }]; let result = apply_hunks(content, &hunks).unwrap(); // Should match line 1 (exact), so "extra" inserted after "indented" (line 1) @@ -1073,8 +1070,8 @@ mod tests { let content = "print(\u{201C}hello\u{201D})"; let hunks = vec![Hunk { context_line: "print(\"hello\")".into(), - end_of_file: false, - changes: vec![Change::Add("print(\"world\")".into())], + end_of_file: false, + changes: vec![Change::Add("print(\"world\")".into())], }]; let result = apply_hunks(content, &hunks).unwrap(); // Original line preserved, new line added after diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index 0126339aa..51d3b7311 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -18,7 +18,7 @@ use tokio::sync::Mutex as AsyncMutex; #[derive(Clone)] struct OpenAiTwinOptions { base_url: String, - api_key: String, + api_key: String, } fn summarizer_model_id(provider: Provider) -> ModelHandle { @@ -30,22 +30,22 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle { | Provider::Inception | Provider::OpenAiCompatible => ModelHandle::ByName { provider: Provider::OpenAi, - model: "gpt-5.4-mini".to_string(), + model: "gpt-5.4-mini".to_string(), }, Provider::Gemini => ModelHandle::ByName { provider: Provider::Gemini, - model: "gemini-3-flash-preview".to_string(), + model: "gemini-3-flash-preview".to_string(), }, Provider::Anthropic => ModelHandle::ByName { provider: Provider::Anthropic, - model: "claude-haiku-4-5".to_string(), + model: "claude-haiku-4-5".to_string(), }, } } fn build_summarizer(provider: Provider, client: &Client) -> WebFetchSummarizer { WebFetchSummarizer { - client: client.clone(), + client: client.clone(), model_id: summarizer_model_id(provider), } } @@ -76,7 +76,8 @@ async fn make_session( let mut profile = build_profile(provider, model, &client); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); - // Register subagent tools so spawn_agent / wait / send_input / close_agent are available + // Register subagent tools so spawn_agent / wait / send_input / close_agent are + // available let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(3))); let factory_client = client.clone(); let factory_model: String = model.to_string(); @@ -369,15 +370,18 @@ provider_test!( ); // Scenarios below are only generated for providers where they are supported. -// - multi_step_read_analyze_edit / provider_specific_editing: gpt-4o-mini is too -// weak to reliably apply precise file edits (uses apply_patch, not edit_file). -// - reasoning_effort: gpt-4o-mini doesn't support the reasoning.effort parameter. +// - multi_step_read_analyze_edit / provider_specific_editing: gpt-4o-mini is +// too weak to reliably apply precise file edits (uses apply_patch, not +// edit_file). +// - reasoning_effort: gpt-4o-mini doesn't support the reasoning.effort +// parameter. // - loop_detection: needs custom config, tested separately below. provider_tests!(error_recovery); openai_twin_provider_test!(error_recovery); -// gpt-5-mini is too weak to reliably apply precise file edits (uses apply_patch, not edit_file). +// gpt-5-mini is too weak to reliably apply precise file edits (uses +// apply_patch, not edit_file). macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( @@ -672,7 +676,8 @@ reasoning_effort_tests!( anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] ); -// gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI test. +// gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI +// test. reasoning_effort_tests!( Provider::Gemini, "gemini-3-flash-preview", diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 26f91fb71..c19164184 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -1,14 +1,14 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; +use std::{env, fs}; use progenitor::{GenerationSettings, Generator, InterfaceStyle}; -/// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: true`. +/// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: +/// true`. /// /// Handles two patterns: -/// - `oneOf: [{...}, {type: "null"}]` → the non-null schema with `nullable: true` +/// - `oneOf: [{...}, {type: "null"}]` → the non-null schema with `nullable: +/// true` /// - `type: [T1, ..., "null"]` → the remaining types with `nullable: true` fn patch_nullable(value: &mut serde_json::Value) { match value { @@ -67,10 +67,12 @@ fn patch_nullable(value: &mut serde_json::Value) { } } -/// Progenitor currently panics when an operation advertises more than one request-body media type. +/// Progenitor currently panics when an operation advertises more than one +/// request-body media type. /// -/// Keep the source OpenAPI spec accurate for docs, but collapse the generated-client view down to -/// a single preferred media type so code generation can proceed. +/// Keep the source OpenAPI spec accurate for docs, but collapse the +/// generated-client view down to a single preferred media type so code +/// generation can proceed. fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) { let Some(paths) = value .get_mut("paths") diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 11dfe6268..aedc1e622 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -9,5 +9,4 @@ mod generated { include!(concat!(env!("OUT_DIR"), "/codegen.rs")); } -pub use generated::Client; -pub use generated::types; +pub use generated::{Client, types}; diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index 9cd815406..d8e364c69 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -6,14 +6,14 @@ use fabro_types::settings::run::{GitAuthorLayer, GitAuthorSettings}; /// Resolved git author identity for checkpoint commits. #[derive(Debug, Clone, PartialEq)] pub struct GitAuthor { - pub name: String, + pub name: String, pub email: String, } impl Default for GitAuthor { fn default() -> Self { Self { - name: "Fabro".into(), + name: "Fabro".into(), email: "noreply@fabro.sh".into(), } } @@ -24,7 +24,7 @@ impl GitAuthor { pub fn from_options(name: Option, email: Option) -> Self { let defaults = Self::default(); Self { - name: name.unwrap_or(defaults.name), + name: name.unwrap_or(defaults.name), email: email.unwrap_or(defaults.email), } } diff --git a/lib/crates/fabro-checkpoint/src/branch.rs b/lib/crates/fabro-checkpoint/src/branch.rs index f5adc10a8..52958ee87 100644 --- a/lib/crates/fabro-checkpoint/src/branch.rs +++ b/lib/crates/fabro-checkpoint/src/branch.rs @@ -7,24 +7,26 @@ use crate::git::{FileMode, Store, TreeEntries}; /// Metadata about a commit, returned by `log`. #[derive(Debug)] pub struct CommitInfo { - pub oid: Oid, - pub message: String, - pub author_name: String, + pub oid: Oid, + pub message: String, + pub author_name: String, pub author_email: String, - pub time: git2::Time, + pub time: git2::Time, } /// Key-value storage on a single git branch. Each write creates one commit. -/// The branch's tree grows monotonically — each commit's tree is a superset of the previous. +/// The branch's tree grows monotonically — each commit's tree is a superset of +/// the previous. pub struct BranchStore<'a> { objects: &'a Store, - branch: String, - author: Signature<'static>, + branch: String, + author: Signature<'static>, } impl<'a> BranchStore<'a> { pub fn new(objects: &'a Store, branch: impl Into, author: &Signature<'_>) -> Self { - // Clone to 'static by using Signature::now (author name/email are copied into owned strings) + // Clone to 'static by using Signature::now (author name/email are copied into + // owned strings) let author_static = Signature::now( author.name().unwrap_or("unknown"), author.email().unwrap_or(""), @@ -51,7 +53,8 @@ impl<'a> BranchStore<'a> { Ok(()) } - /// Core read-modify-write: read current tree, let caller mutate, write new commit. + /// Core read-modify-write: read current tree, let caller mutate, write new + /// commit. pub fn write_with( &self, message: &str, @@ -113,7 +116,8 @@ impl<'a> BranchStore<'a> { }) } - /// Read a single file from the latest tree. Returns `None` if branch or path doesn't exist. + /// Read a single file from the latest tree. Returns `None` if branch or + /// path doesn't exist. pub fn read_entry(&self, path: &str) -> Result>> { let Some(commit_oid) = self.objects.resolve_ref(&self.branch)? else { return Ok(None); @@ -218,9 +222,10 @@ pub fn sharded_path(id: &str, prefix_len: usize) -> String { #[cfg(test)] mod tests { + use git2::Repository; + use super::*; use crate::git::FileMode; - use git2::Repository; fn temp_repo() -> (tempfile::TempDir, Store) { let dir = tempfile::TempDir::new().unwrap(); diff --git a/lib/crates/fabro-checkpoint/src/error.rs b/lib/crates/fabro-checkpoint/src/error.rs index fa5df4467..6d59481c7 100644 --- a/lib/crates/fabro-checkpoint/src/error.rs +++ b/lib/crates/fabro-checkpoint/src/error.rs @@ -9,7 +9,7 @@ pub enum Error { #[error("reading file {path}: {source}")] ReadFile { - path: PathBuf, + path: PathBuf, source: std::io::Error, }, diff --git a/lib/crates/fabro-checkpoint/src/git.rs b/lib/crates/fabro-checkpoint/src/git.rs index ce4d1f2cc..30f4eca69 100644 --- a/lib/crates/fabro-checkpoint/src/git.rs +++ b/lib/crates/fabro-checkpoint/src/git.rs @@ -34,14 +34,15 @@ impl FileMode { /// A single entry in a flat tree map. #[derive(Debug, Clone)] pub struct TreeEntry { - pub oid: Oid, + pub oid: Oid, pub filemode: FileMode, } /// A flat, sorted map of paths to tree entries. /// -/// Intermediate representation between reading an existing git tree and writing a new one. -/// Paths use forward slashes and are relative to the tree root (e.g. `"src/main.rs"`). +/// Intermediate representation between reading an existing git tree and writing +/// a new one. Paths use forward slashes and are relative to the tree root (e.g. +/// `"src/main.rs"`). #[derive(Debug, Clone, Default)] pub struct TreeEntries(BTreeMap); @@ -92,7 +93,8 @@ impl TreeEntries { } } -/// Wraps a `git2::Repository` with operations for creating blobs, trees, commits, and refs. +/// Wraps a `git2::Repository` with operations for creating blobs, trees, +/// commits, and refs. pub struct Store { repo: Repository, } @@ -119,10 +121,11 @@ impl Store { } /// Read a file from disk, store as a blob. - /// Returns `(oid, filemode)` where filemode detects the executable bit on unix. + /// Returns `(oid, filemode)` where filemode detects the executable bit on + /// unix. pub fn write_blob_from_file(&self, path: &Path) -> Result<(Oid, FileMode)> { let content = std::fs::read(path).map_err(|e| Error::ReadFile { - path: path.to_path_buf(), + path: path.to_path_buf(), source: e, })?; let mode = detect_filemode(path); @@ -150,8 +153,8 @@ impl Store { Ok(builder.write()?) } - /// Create a commit. Does NOT update any ref — caller does that via `update_ref`. - /// `author` is used for both author and committer fields. + /// Create a commit. Does NOT update any ref — caller does that via + /// `update_ref`. `author` is used for both author and committer fields. pub fn write_commit( &self, tree_oid: Oid, @@ -189,7 +192,8 @@ impl Store { } } - /// Read a blob from the tree of a specific commit. Returns `None` if the path doesn't exist. + /// Read a blob from the tree of a specific commit. Returns `None` if the + /// path doesn't exist. pub fn read_blob_at(&self, commit_oid: Oid, path: &str) -> Result>> { let commit = self.repo.find_commit(commit_oid)?; let tree = commit.tree()?; @@ -246,14 +250,14 @@ fn read_tree_recursive( /// Intermediate structure for building nested git trees from flat paths. struct DirNode { files: BTreeMap, - dirs: BTreeMap, + dirs: BTreeMap, } impl DirNode { fn new() -> Self { Self { files: BTreeMap::new(), - dirs: BTreeMap::new(), + dirs: BTreeMap::new(), } } } diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index 35a715081..d0afc7d15 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -15,14 +15,14 @@ use crate::git::Store; /// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone. pub struct MetadataStore { repo_path: PathBuf, - author: GitAuthor, + author: GitAuthor, } impl MetadataStore { pub fn new(repo_path: impl Into, author: &GitAuthor) -> Self { Self { repo_path: repo_path.into(), - author: author.clone(), + author: author.clone(), } } @@ -59,7 +59,8 @@ impl MetadataStore { Ok(()) } - /// Write arbitrary files to the metadata branch without overwriting checkpoint.json. + /// Write arbitrary files to the metadata branch without overwriting + /// checkpoint.json. pub fn write_files( &self, run_id: &str, @@ -92,7 +93,8 @@ impl MetadataStore { Ok(oid.to_string()) } - /// Read a single file from the metadata branch. Returns `None` if branch or path doesn't exist. + /// Read a single file from the metadata branch. Returns `None` if branch or + /// path doesn't exist. fn read_file( repo_path: &Path, run_id: &str, @@ -108,7 +110,8 @@ impl MetadataStore { Ok(branch_store.read_entry(path)?) } - /// Read a checkpoint from the metadata branch. Returns `None` if branch or file doesn't exist. + /// Read a checkpoint from the metadata branch. Returns `None` if branch or + /// file doesn't exist. pub fn read_checkpoint( repo_path: &Path, run_id: &str, @@ -126,7 +129,8 @@ impl MetadataStore { } } - /// Read the run record from the metadata branch. Returns `None` if not found. + /// Read the run record from the metadata branch. Returns `None` if not + /// found. pub fn read_run_record( repo_path: &Path, run_id: &str, @@ -144,7 +148,8 @@ impl MetadataStore { } } - /// Read the start record from the metadata branch. Returns `None` if not found. + /// Read the start record from the metadata branch. Returns `None` if not + /// found. pub fn read_start_record( repo_path: &Path, run_id: &str, @@ -176,11 +181,12 @@ impl MetadataStore { mod tests { use std::collections::HashMap; - use super::*; use chrono::{TimeZone, Utc}; use fabro_types::settings::SettingsLayer; use fabro_types::{Graph, fixtures}; + use super::*; + /// Create a temporary git repo with an initial commit. fn init_repo(dir: &Path) { std::process::Command::new("git") @@ -358,11 +364,10 @@ mod tests { let checkpoint_json = serde_json::to_vec_pretty(&test_checkpoint("node_a", Vec::new(), None)).unwrap(); store - .write_checkpoint( - &run_id, - &checkpoint_json, - &[("artifacts/response.plan.json", artifact_data.as_slice())], - ) + .write_checkpoint(&run_id, &checkpoint_json, &[( + "artifacts/response.plan.json", + artifact_data.as_slice(), + )]) .unwrap(); let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan") @@ -423,10 +428,10 @@ mod tests { let run_id = fixtures::RUN_6.to_string(); let store = MetadataStore::new(dir.path(), &GitAuthor::default()); let start_record = StartRecord { - run_id: fixtures::RUN_6, + run_id: fixtures::RUN_6, start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), run_branch: Some("fabro/run/test".to_string()), - base_sha: None, + base_sha: None, }; let bytes = serde_json::to_vec_pretty(&start_record).unwrap(); store.init_run(&run_id, &[("start.json", &bytes)]).unwrap(); diff --git a/lib/crates/fabro-checkpoint/src/trailer.rs b/lib/crates/fabro-checkpoint/src/trailer.rs index 055fd0f88..cd97085af 100644 --- a/lib/crates/fabro-checkpoint/src/trailer.rs +++ b/lib/crates/fabro-checkpoint/src/trailer.rs @@ -2,11 +2,12 @@ use std::fmt::Write; /// A git commit message trailer (key-value pair). pub struct Trailer<'a> { - pub key: &'a str, + pub key: &'a str, pub value: &'a str, } -/// Append a trailer to a commit message, inserting a blank-line separator if needed. +/// Append a trailer to a commit message, inserting a blank-line separator if +/// needed. pub fn append(message: &str, trailer: &Trailer<'_>) -> String { let trailer_line = format!("{}: {}", trailer.key, trailer.value); let trimmed = message.trim_end(); @@ -91,26 +92,20 @@ mod tests { #[test] fn append_to_simple_message() { - let result = append( - "Initial commit", - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append("Initial commit", &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!(result, "Initial commit\n\nMy-Checkpoint: abc123\n"); } #[test] fn append_to_message_with_existing_trailer() { let msg = "Initial commit\n\nSigned-off-by: Alice \n"; - let result = append( - msg, - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append(msg, &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!( result, "Initial commit\n\nSigned-off-by: Alice \nMy-Checkpoint: abc123\n" @@ -120,13 +115,10 @@ mod tests { #[test] fn append_to_message_with_body_no_trailer() { let msg = "Initial commit\n\nThis is a longer description of the change.\n"; - let result = append( - msg, - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append(msg, &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!( result, "Initial commit\n\nThis is a longer description of the change.\n\nMy-Checkpoint: abc123\n" @@ -175,20 +167,16 @@ mod tests { #[test] fn format_message_with_trailers() { - let result = format_message( - "Initial commit", - "", - &[ - Trailer { - key: "Signed-off-by", - value: "Alice", - }, - Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ], - ); + let result = format_message("Initial commit", "", &[ + Trailer { + key: "Signed-off-by", + value: "Alice", + }, + Trailer { + key: "My-Checkpoint", + value: "abc123", + }, + ]); assert_eq!( result, "Initial commit\n\nSigned-off-by: Alice\nMy-Checkpoint: abc123\n" @@ -197,14 +185,10 @@ mod tests { #[test] fn format_message_with_body_and_trailers() { - let result = format_message( - "Initial commit", - "Description here", - &[Trailer { - key: "My-Checkpoint", - value: "abc123", - }], - ); + let result = format_message("Initial commit", "Description here", &[Trailer { + key: "My-Checkpoint", + value: "abc123", + }]); assert_eq!( result, "Initial commit\n\nDescription here\n\nMy-Checkpoint: abc123\n" diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index d228c5aa6..ec09f63e1 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -261,16 +261,17 @@ pub(crate) struct LogsArgs { pub(crate) server: ServerTargetArgs, /// Run ID prefix or workflow name (most recent run) - pub(crate) run: String, + pub(crate) run: String, /// Follow log output #[arg(short, long)] pub(crate) follow: bool, - /// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z") + /// Logs since timestamp or relative (e.g. "42m", "2h", + /// "2026-01-02T13:00:00Z") #[arg(long)] - pub(crate) since: Option, + pub(crate) since: Option, /// Lines from end (default: all) #[arg(short = 'n', long)] - pub(crate) tail: Option, + pub(crate) tail: Option, /// Formatted colored output with rendered assistant text #[arg(short = 'p', long)] pub(crate) pretty: bool, @@ -331,7 +332,8 @@ pub(crate) struct GraphArgs { #[command(flatten)] pub(crate) target: ServerTargetArgs, - /// Path to the .fabro workflow file, .toml task config, or project workflow name + /// Path to the .fabro workflow file, .toml task config, or project workflow + /// name pub(crate) workflow: PathBuf, /// Output format @@ -401,9 +403,9 @@ pub(crate) struct CpArgs { pub(crate) server: ServerTargetArgs, /// Source: : or local path - pub(crate) src: String, + pub(crate) src: String, /// Destination: : or local path - pub(crate) dst: String, + pub(crate) dst: String, /// Recurse into directories #[arg(short, long)] pub(crate) recursive: bool, @@ -415,18 +417,18 @@ pub(crate) struct PreviewArgs { pub(crate) server: ServerTargetArgs, /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// Port number - pub(crate) port: u16, + pub(crate) port: u16, /// Generate a signed URL (embeds auth token, no headers needed) #[arg(long)] pub(crate) signed: bool, /// Signed URL expiry in seconds (default 3600, requires --signed) #[arg(long, default_value = "3600", requires = "signed")] - pub(crate) ttl: i32, + pub(crate) ttl: i32, /// Open URL in browser (implies --signed) #[arg(long)] - pub(crate) open: bool, + pub(crate) open: bool, } #[derive(Args)] @@ -435,10 +437,10 @@ pub(crate) struct SshArgs { pub(crate) server: ServerTargetArgs, /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// SSH access expiry in minutes (default 60) #[arg(long, default_value = "60")] - pub(crate) ttl: f64, + pub(crate) ttl: f64, /// Print the SSH command instead of connecting #[arg(long)] pub(crate) print: bool, @@ -450,7 +452,7 @@ pub(crate) struct DiffArgs { pub(crate) server: ServerTargetArgs, /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// Show diff for a specific node #[arg(long)] pub(crate) node: Option, @@ -490,7 +492,7 @@ pub(crate) struct SecretRmArgs { #[derive(Args)] pub(crate) struct SecretSetArgs { /// Name of the secret - pub(crate) key: String, + pub(crate) key: String, /// Value to store pub(crate) value: String, } @@ -536,7 +538,8 @@ pub(crate) struct ForkArgs { /// Run ID (or unambiguous prefix) pub(crate) run_id: String, - /// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest) + /// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from + /// latest) pub(crate) target: Option, /// Show the checkpoint timeline instead of forking @@ -602,7 +605,8 @@ pub(crate) struct RunsPruneArgs { #[command(flatten)] pub(crate) filter: RunFilterArgs, - /// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set. + /// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h + /// when no explicit filters are set. #[arg( long, value_name = "DURATION", @@ -657,7 +661,7 @@ pub(crate) struct PrCreateArgs { pub(crate) run_id: String, /// LLM model for generating PR description #[arg(long)] - pub(crate) model: Option, + pub(crate) model: Option, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index f93bb9d8c..465984c95 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -16,18 +16,18 @@ pub(crate) enum ServerMode { target_override: Option, }, ByStorageDir { - target_override: Option, + target_override: Option, storage_dir_override: Option, }, } pub(crate) struct CommandContext { - cwd: PathBuf, + cwd: PathBuf, base_config_path: PathBuf, machine_settings: SettingsLayer, - cli_settings: CliSettings, - server_mode: ServerMode, - server: OnceCell>, + cli_settings: CliSettings, + server_mode: ServerMode, + server: OnceCell>, } impl CommandContext { @@ -43,7 +43,7 @@ impl CommandContext { pub(crate) fn for_connection(args: &ServerConnectionArgs) -> Result { Self::new(ServerMode::ByStorageDir { - target_override: args.target.server.clone(), + target_override: args.target.server.clone(), storage_dir_override: args.storage_dir.clone_path(), }) } diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index 4cb33537e..c120f5a26 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -180,11 +180,11 @@ mod tests { #[test] fn format_candidate_includes_retry() { let entry = super::super::ArtifactEntry { - node_slug: "retry_assets".to_string(), - retry: 2, - stage_id: fabro_types::StageId::new("retry_assets", 2), + node_slug: "retry_assets".to_string(), + retry: 2, + stage_id: fabro_types::StageId::new("retry_assets", 2), relative_path: "assets/retry/report.txt".to_string(), - size: 6, + size: 6, }; assert_eq!(format_candidate(&entry), "retry_assets:retry_2"); diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index 0395903f8..f01c8aadf 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -12,11 +12,11 @@ use crate::server_runs::ServerSummaryLookup; #[derive(Clone, Debug, serde::Serialize)] pub(super) struct ArtifactEntry { #[serde(skip_serializing)] - pub(super) stage_id: StageId, - pub(super) node_slug: String, - pub(super) retry: u32, + pub(super) stage_id: StageId, + pub(super) node_slug: String, + pub(super) retry: u32, pub(super) relative_path: String, - pub(super) size: u64, + pub(super) size: u64, } pub(super) async fn resolve_artifacts( diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 6938759ee..0277ff9aa 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -1,15 +1,14 @@ use std::io::Write; use std::path::Path; +use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; +use fabro_config::{effective_settings, load_settings_project, project}; +use fabro_types::settings::SettingsLayer; + use crate::args::{GlobalArgs, SettingsArgs}; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::user_config; -use fabro_config::effective_settings; -use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; -use fabro_config::load_settings_project; -use fabro_config::project; -use fabro_types::settings::SettingsLayer; fn config_layers( ctx: &CommandContext, diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index ac74284ba..989a94f94 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -22,11 +22,11 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; pub(crate) struct DepSpec { - pub name: &'static str, - command: &'static [&'static str], - pub required: bool, + pub name: &'static str, + command: &'static [&'static str], + pub required: bool, pub min_version: Version, - pattern: &'static LazyLock, + pattern: &'static LazyLock, } #[derive(Debug, Clone, PartialEq)] @@ -43,18 +43,18 @@ static DOT_RE: LazyLock = pub(crate) const DEP_SPECS: &[DepSpec] = &[ DepSpec { - name: "openssl", - command: &["openssl", "version"], - required: true, + name: "openssl", + command: &["openssl", "version"], + required: true, min_version: Version::new(3, 0, 0), - pattern: &OPENSSL_RE, + pattern: &OPENSSL_RE, }, DepSpec { - name: "dot", - command: &["dot", "-V"], - required: false, + name: "dot", + command: &["dot", "-V"], + required: false, min_version: Version::new(2, 0, 0), - pattern: &DOT_RE, + pattern: &DOT_RE, }, ]; @@ -156,28 +156,31 @@ pub(crate) fn check_config( ) -> CheckResult { match (settings_path, legacy_paths.is_empty()) { (Some(path), true) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Pass, - summary: path.display().to_string(), - details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))], + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: path.display().to_string(), + details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))], remediation: None, }, (Some(path), false) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: path.display().to_string(), - details: std::iter::once(CheckDetail::new(format!("Loaded from {}", path.display()))) - .chain(legacy_paths.iter().map(|legacy| { - CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())) - })) - .collect(), + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: path.display().to_string(), + details: std::iter::once(CheckDetail::new(format!( + "Loaded from {}", + path.display() + ))) + .chain(legacy_paths.iter().map(|legacy| { + CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())) + })) + .collect(), remediation: Some("Delete or rename legacy config files".to_string()), }, (None, false) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: "legacy config files ignored".to_string(), - details: legacy_paths + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "legacy config files ignored".to_string(), + details: legacy_paths .iter() .map(|legacy| { CheckDetail::new(format!("Found legacy config file {}", legacy.display())) @@ -190,10 +193,10 @@ pub(crate) fn check_config( remediation: Some("Create ~/.fabro/settings.toml".to_string()), }, (None, true) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: "no settings config file found".to_string(), - details: vec![CheckDetail::new( + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "no settings config file found".to_string(), + details: vec![CheckDetail::new( "Create ~/.fabro/settings.toml to configure Fabro".to_string(), )], remediation: Some("Create ~/.fabro/settings.toml".to_string()), @@ -204,10 +207,10 @@ pub(crate) fn check_config( fn check_legacy_env(path: Option) -> CheckResult { match path { Some(path) => CheckResult { - name: "Legacy .env".to_string(), - status: CheckStatus::Warning, - summary: "legacy secrets file detected".to_string(), - details: vec![CheckDetail::new(format!( + name: "Legacy .env".to_string(), + status: CheckStatus::Warning, + summary: "legacy secrets file detected".to_string(), + details: vec![CheckDetail::new(format!( "{} is no longer read by fabro", path.display() ))], @@ -217,10 +220,10 @@ fn check_legacy_env(path: Option) -> CheckResult { ), }, None => CheckResult { - name: "Legacy .env".to_string(), - status: CheckStatus::Pass, - summary: "not present".to_string(), - details: Vec::new(), + name: "Legacy .env".to_string(), + status: CheckStatus::Pass, + summary: "not present".to_string(), + details: Vec::new(), remediation: None, }, } @@ -230,20 +233,20 @@ fn check_version_parity(server_version: &str) -> CheckResult { let cli_version = FABRO_VERSION; if server_version == cli_version { CheckResult { - name: "Version parity".to_string(), - status: CheckStatus::Pass, - summary: cli_version.to_string(), - details: vec![CheckDetail::new(format!( + name: "Version parity".to_string(), + status: CheckStatus::Pass, + summary: cli_version.to_string(), + details: vec![CheckDetail::new(format!( "CLI and server are both {cli_version}" ))], remediation: None, } } else { CheckResult { - name: "Version parity".to_string(), - status: CheckStatus::Warning, - summary: format!("CLI {cli_version}, server {server_version}"), - details: vec![CheckDetail::new(format!( + name: "Version parity".to_string(), + status: CheckStatus::Warning, + summary: format!("CLI {cli_version}, server {server_version}"), + details: vec![CheckDetail::new(format!( "CLI version {cli_version} does not match server version {server_version}" ))], remediation: Some( @@ -266,15 +269,15 @@ fn convert_diagnostics_sections(sections: Vec) -> sections .into_iter() .map(|section| CheckSection { - title: section.title, + title: section.title, checks: section .checks .into_iter() .map(|check| CheckResult { - name: check.name, - status: convert_diagnostics_status(check.status), - summary: check.summary, - details: check + name: check.name, + status: convert_diagnostics_status(check.status), + summary: check.summary, + details: check .details .into_iter() .map(|detail| CheckDetail { @@ -342,9 +345,9 @@ pub(crate) async fn run_doctor( }; let mut report = CheckReport { - title: "Fabro Doctor".to_string(), + title: "Fabro Doctor".to_string(), sections: vec![CheckSection { - title: "Local".to_string(), + title: "Local".to_string(), checks: vec![ check_config( settings_config_path @@ -361,12 +364,12 @@ pub(crate) async fn run_doctor( Ok(ctx) => ctx, Err(err) => { report.sections.push(CheckSection { - title: "Server".to_string(), + title: "Server".to_string(), checks: vec![CheckResult { - name: "Fabro server".to_string(), - status: CheckStatus::Error, - summary: "settings resolution failed".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "settings resolution failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Fix the local CLI settings or provide `--server`, then run doctor again." .to_string(), @@ -391,12 +394,12 @@ pub(crate) async fn run_doctor( Ok(server) => server, Err(err) => { report.sections.push(CheckSection { - title: "Server".to_string(), + title: "Server".to_string(), checks: vec![CheckResult { - name: "Fabro server".to_string(), - status: CheckStatus::Error, - summary: "unreachable".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "unreachable".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Start or connect to the server with `--server` and run doctor again." .to_string(), @@ -421,12 +424,12 @@ pub(crate) async fn run_doctor( Ok(response) => response.into_inner(), Err(err) => { report.sections.push(CheckSection { - title: "Server".to_string(), + title: "Server".to_string(), checks: vec![CheckResult { - name: "Fabro server".to_string(), - status: CheckStatus::Error, - summary: "health check failed".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "health check failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Check that the server is reachable and responding to /health.".to_string(), ), @@ -459,12 +462,12 @@ pub(crate) async fn run_doctor( } Err(err) => { report.sections.push(CheckSection { - title: "Server".to_string(), + title: "Server".to_string(), checks: vec![CheckResult { - name: "Diagnostics".to_string(), - status: CheckStatus::Error, - summary: "probe failed".to_string(), - details: vec![CheckDetail::new(err.to_string())], + name: "Diagnostics".to_string(), + status: CheckStatus::Error, + summary: "probe failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], remediation: Some( "Fix the server diagnostics failure and run `fabro doctor` again." .to_string(), @@ -550,14 +553,14 @@ mod tests { #[test] fn render_report_text_without_color_has_no_ansi() { let report = CheckReport { - title: "Fabro Doctor".to_string(), + title: "Fabro Doctor".to_string(), sections: vec![CheckSection { - title: "Local".to_string(), + title: "Local".to_string(), checks: vec![CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Pass, - summary: "loaded".to_string(), - details: vec![CheckDetail::new( + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: "loaded".to_string(), + details: vec![CheckDetail::new( "Loaded from ~/.fabro/settings.toml".into(), )], remediation: None, diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index a89fd5089..0e73b6a62 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::sync::Arc; + use anyhow::Result; use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; use fabro_llm::client::Client; @@ -6,8 +9,6 @@ use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_types::settings::InterpString; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::McpEntryLayer; -use std::collections::HashMap; -use std::sync::Arc; use crate::args::{ExecArgs, GlobalArgs}; use crate::user_config; @@ -37,7 +38,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings { } } McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { - url: url.as_source(), + url: url.as_source(), headers: headers .iter() .map(|(key, value)| (key.clone(), value.as_source())) @@ -145,10 +146,10 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< .mcps .values() .map(|server| McpServerSettings { - name: server.name.clone(), - transport: server.transport.clone(), + name: server.name.clone(), + transport: server.transport.clone(), startup_timeout_secs: server.startup_timeout_secs, - tool_timeout_secs: server.tool_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, }) .collect() } else if let Some(mcps) = cli_settings @@ -170,10 +171,10 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< .mcps .values() .map(|server| McpServerSettings { - name: server.name.clone(), - transport: server.transport.clone(), + name: server.name.clone(), + transport: server.transport.clone(), startup_timeout_secs: server.startup_timeout_secs, - tool_timeout_secs: server.tool_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, }) .collect() }) diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index eeef38768..d7bc13a03 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -25,12 +25,12 @@ pub(crate) async fn run( let ctx = CommandContext::for_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: ctx.cwd().to_path_buf(), - args_layer: SettingsLayer::default(), - args: None, - run_id: None, - user_layer: load_settings_user()?, + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; @@ -47,8 +47,8 @@ pub(crate) async fn run( let rendered = client .render_workflow_graph(types::RenderWorkflowGraphRequest { - manifest: built.manifest, - format: Some(match args.format { + manifest: built.manifest, + format: Some(match args.format { GraphOutputFormat::Svg => types::RenderWorkflowGraphFormat::Svg, GraphOutputFormat::Png => types::RenderWorkflowGraphFormat::Png, }), diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 88a8be5c9..b415601dd 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -13,9 +13,8 @@ use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{MultiSelect, Select}; use fabro_api::types::SetSecretRequest; -use fabro_config::Storage; -use fabro_config::legacy_env; use fabro_config::user::SETTINGS_CONFIG_FILENAME; +use fabro_config::{Storage, legacy_env}; use fabro_model::Provider; use fabro_server::secret_store::SecretStore; use fabro_util::terminal::Styles; @@ -28,11 +27,10 @@ use super::doctor; use crate::args::{DoctorArgs, GlobalArgs, InstallArgs, ServerTargetArgs}; use crate::commands::server::record; use crate::gh::GhCli; -use crate::server_client; use crate::shared::provider_auth::{ prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key, }; -use crate::user_config; +use crate::{server_client, user_config}; // --------------------------------------------------------------------------- // OpenSSL helpers @@ -954,7 +952,7 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res if run_doctor { eprintln!(); let doctor_args = DoctorArgs { - target: ServerTargetArgs::default(), + target: ServerTargetArgs::default(), verbose: true, }; let _ = doctor::run_doctor(&doctor_args, true, globals).await?; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 6d7739279..17816c297 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -21,19 +21,19 @@ enum ModelTestResultKind { #[derive(Serialize)] struct ModelTestRow { - model: String, + model: String, provider: Provider, - result: ModelTestResultKind, + result: ModelTestResultKind, #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, + detail: Option, #[serde(skip_serializing_if = "Option::is_none")] - error: Option, + error: Option, } #[derive(Serialize)] struct ModelTestOutput { - results: Vec, - total: usize, + results: Vec, + total: usize, failures: u32, } @@ -145,25 +145,25 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color) let trimmed = status.trim(); match result_color { Color::Green => ModelTestRow { - model: model.id.clone(), + model: model.id.clone(), provider: model.provider, - result: ModelTestResultKind::Pass, - detail: None, - error: None, + result: ModelTestResultKind::Pass, + detail: None, + error: None, }, Color::Yellow => ModelTestRow { - model: model.id.clone(), + model: model.id.clone(), provider: model.provider, - result: ModelTestResultKind::Skip, - detail: Some(trimmed.to_string()), - error: None, + result: ModelTestResultKind::Skip, + detail: Some(trimmed.to_string()), + error: None, }, _ => ModelTestRow { - model: model.id.clone(), + model: model.id.clone(), provider: model.provider, - result: ModelTestResultKind::Fail, - detail: None, - error: Some( + result: ModelTestResultKind::Fail, + detail: None, + error: Some( trimmed .strip_prefix("error: ") .unwrap_or(trimmed) @@ -415,9 +415,10 @@ impl Default for ModelsCommand { #[cfg(test)] mod tests { - use super::*; use fabro_model::{ModelCosts, ModelFeatures, ModelLimits}; + use super::*; + fn test_http_client() -> reqwest::Client { reqwest::Client::builder().no_proxy().build().unwrap() } @@ -434,19 +435,19 @@ mod tests { display_name: format!("{id} display"), limits: ModelLimits { context_window: 128_000, - max_output: Some(4096), + max_output: Some(4096), }, training: None, knowledge_cutoff: None, features: ModelFeatures { - tools: true, - vision: false, + tools: true, + vision: false, reasoning: false, - effort: false, + effort: false, }, costs: ModelCosts { - input_cost_per_mtok: Some(1.0), - output_cost_per_mtok: Some(2.0), + input_cost_per_mtok: Some(1.0), + output_cost_per_mtok: Some(2.0), cache_input_cost_per_mtok: None, }, estimated_output_tps: Some(100.0), diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index 2f7d15d0f..3d885807a 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -12,9 +12,9 @@ use crate::shared::print_json_pretty; struct PrRow { run_id: String, number: u64, - state: String, - title: String, - url: String, + state: String, + title: String, + url: String, } pub(super) async fn list_command( diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 65b6d00c1..2e53783d6 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -5,7 +5,6 @@ mod merge; mod view; use anyhow::{Context, Result}; - use fabro_types::PullRequestRecord; use fabro_types::settings::InterpString; diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 045c08cbb..82e80c82a 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -19,12 +19,12 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose; let manifest = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: ctx.cwd().to_path_buf(), - args_layer: preflight_args_layer(&args)?, - args: preflight_manifest_args(&args), - run_id: None, - user_layer: load_settings_user()?, + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: preflight_args_layer(&args)?, + args: preflight_manifest_args(&args), + run_id: None, + user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 5bc0c7159..372b80600 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -7,13 +7,12 @@ use std::process::ExitCode; use std::time::Duration; use anyhow::Result; -use fabro_types::{EventBody, RunEvent, RunId}; - use fabro_api::types; use fabro_interview::{AnswerValue, ConsoleInterviewer, Question, QuestionOption, QuestionType}; use fabro_store::EventEnvelope; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::run::ApprovalMode; +use fabro_types::{EventBody, RunEvent, RunId}; use fabro_util::json::normalize_json_value; use fabro_util::terminal::Styles; use fabro_workflow::outcome::StageStatus; @@ -109,10 +108,10 @@ pub(crate) async fn attach_run_with_client( } struct AttachOptions { - auto_approve: bool, - verbose: bool, + auto_approve: bool, + verbose: bool, kill_on_detach: bool, - json_output: bool, + json_output: bool, } fn replay_run_with_client( @@ -277,7 +276,7 @@ fn api_question_to_question(question: &types::ApiQuestion) -> Question { .options .iter() .map(|option| QuestionOption { - key: option.key.clone(), + key: option.key.clone(), label: option.label.clone(), }) .collect(); @@ -462,11 +461,12 @@ fn event_starts_interview(event: &EventEnvelope) -> bool { mod tests { #![allow(clippy::absolute_paths)] - use super::*; use fabro_interview::{Answer, AnswerValue}; use fabro_util::terminal::Styles; use httpmock::MockServer; + use super::*; + fn no_color_styles() -> &'static Styles { Box::leak(Box::new(Styles::new(false))) } @@ -552,14 +552,14 @@ mod tests { #[test] fn answer_requires_reattach_for_interrupted_and_skipped_answers() { let interrupted = Answer { - value: AnswerValue::Interrupted, + value: AnswerValue::Interrupted, selected_option: None, - text: None, + text: None, }; let skipped = Answer { - value: AnswerValue::Skipped, + value: AnswerValue::Skipped, selected_option: None, - text: None, + text: None, }; let answered = Answer::yes(); diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 99a64f311..52245830d 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -13,13 +13,13 @@ use crate::shared::{print_json_pretty, split_run_path}; #[derive(Debug)] enum CopyDirection { Download { - run_prefix: String, + run_prefix: String, remote_path: String, - local_path: PathBuf, + local_path: PathBuf, }, Upload { - local_path: PathBuf, - run_prefix: String, + local_path: PathBuf, + run_prefix: String, remote_path: String, }, } @@ -98,13 +98,13 @@ fn parse_direction(src: &str, dst: &str) -> Result { match (src_parts, dst_parts) { (Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download { - run_prefix: run_prefix.to_string(), + run_prefix: run_prefix.to_string(), remote_path: remote_path.to_string(), - local_path: PathBuf::from(dst), + local_path: PathBuf::from(dst), }), (None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload { - local_path: PathBuf::from(src), - run_prefix: run_prefix.to_string(), + local_path: PathBuf::from(src), + run_prefix: run_prefix.to_string(), remote_path: remote_path.to_string(), }), (Some(_), Some(_)) => { diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index f7a831937..603b4a647 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,7 +1,5 @@ use std::path::PathBuf; -use crate::args::RunArgs; -use crate::command_context::CommandContext; use fabro_config::Storage; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; @@ -11,15 +9,18 @@ use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; use super::overrides::run_args_layer; +use crate::args::RunArgs; +use crate::command_context::CommandContext; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args}; use crate::user_config::{self, ServerTarget}; pub(crate) struct CreatedRun { - pub(crate) run_id: RunId, + pub(crate) run_id: RunId, pub(crate) local_run_dir: Option, } -/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir). +/// Create a workflow run: allocate run directory, persist RunRecord, return +/// (run_id, run_dir). /// /// This does NOT execute the workflow — it only prepares the run directory. pub(crate) async fn create_run( diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 0975ea296..155ae55f7 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,5 +1,4 @@ -use anyhow::Context; -use anyhow::Result; +use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; use fabro_util::terminal::Styles; use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}; @@ -41,14 +40,11 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) .as_deref() .map(str::parse::) .transpose()?; - let new_run_id = fork( - &store, - &ForkRunInput { - source_run_id: run_id, - target, - push: !args.no_push, - }, - )?; + let new_run_id = fork(&store, &ForkRunInput { + source_run_id: run_id, + target, + push: !args.no_push, + })?; let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 43ac31d10..8eb06a944 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -67,19 +67,19 @@ pub(crate) fn print_preflight_workflow_summary( fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic { fabro_validate::Diagnostic { - rule: diagnostic.rule.clone(), + rule: diagnostic.rule.clone(), severity: match diagnostic.severity { types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error, types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning, types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info, }, - message: diagnostic.message.clone(), - node_id: diagnostic.node_id.clone(), - edge: diagnostic + message: diagnostic.message.clone(), + node_id: diagnostic.node_id.clone(), + edge: diagnostic .edge .as_ref() .map(|edge| (edge[0].clone(), edge[1].clone())), - fix: diagnostic.fix.clone(), + fix: diagnostic.fix.clone(), } } @@ -91,24 +91,24 @@ pub(crate) fn api_diagnostics_to_local( pub(crate) fn api_check_report_to_local(report: &types::PreflightCheckReport) -> CheckReport { CheckReport { - title: report.title.clone(), + title: report.title.clone(), sections: report .sections .iter() .map(|section| CheckSection { - title: section.title.clone(), + title: section.title.clone(), checks: section .checks .iter() .map(|check| CheckResult { - name: check.name.clone(), - status: match check.status { + name: check.name.clone(), + status: match check.status { types::PreflightCheckResultStatus::Pass => CheckStatus::Pass, types::PreflightCheckResultStatus::Warning => CheckStatus::Warning, types::PreflightCheckResultStatus::Error => CheckStatus::Error, }, - summary: check.summary.clone(), - details: check + summary: check.summary.clone(), + details: check .details .iter() .map(|detail| CheckDetail { diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 36daa69b2..9a8a813b4 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -30,8 +30,8 @@ fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option, } @@ -55,14 +54,11 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs let target = args.target.as_deref().unwrap().parse::()?; - rewind( - &store, - &RewindInput { - run_id, - target: target.clone(), - push: !args.no_push, - }, - )?; + rewind(&store, &RewindInput { + run_id, + target: target.clone(), + push: !args.no_push, + })?; let entry = timeline.resolve(&target)?; reset_rewound_run_state(lookup.client(), &store, &run_id, entry).await?; @@ -88,9 +84,9 @@ pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec, + pub(super) cost: Option, } impl ProgressUsage { pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option { let tokens = usage.tokens(); Some(Self { - input_tokens: u64::try_from(tokens.input_tokens).ok()?, + input_tokens: u64::try_from(tokens.input_tokens).ok()?, output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?, - cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0), + cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0), }) } @@ -35,8 +35,8 @@ impl ProgressUsage { pub(super) enum ProgressEvent { WorkflowStarted { worktree_dir: Option, - base_branch: Option, - base_sha: Option, + base_branch: Option, + base_sha: Option, }, WorkingDirectorySet { working_directory: String, @@ -45,12 +45,12 @@ pub(super) enum ProgressEvent { provider: String, }, SandboxReady { - provider: String, + provider: String, duration_ms: u64, - name: Option, - cpu: Option, - memory: Option, - url: Option, + name: Option, + cpu: Option, + memory: Option, + url: Option, }, SshAccessReady { ssh_command: String, @@ -62,98 +62,98 @@ pub(super) enum ProgressEvent { duration_ms: u64, }, SetupCommandCompleted { - command: String, + command: String, command_index: u64, - exit_code: i64, - duration_ms: u64, + exit_code: i64, + duration_ms: u64, }, CliEnsureStarted { cli_name: String, }, CliEnsureCompleted { - cli_name: String, + cli_name: String, already_installed: bool, - duration_ms: u64, + duration_ms: u64, }, CliEnsureFailed { cli_name: String, }, DevcontainerResolved { - dockerfile_lines: u64, - environment_count: u64, + dockerfile_lines: u64, + environment_count: u64, lifecycle_command_count: u64, - workspace_folder: String, + workspace_folder: String, }, DevcontainerLifecycleStarted { - phase: String, + phase: String, command_count: u64, }, DevcontainerLifecycleCompleted { - phase: String, + phase: String, duration_ms: u64, }, DevcontainerLifecycleFailed { - phase: String, - command: String, + phase: String, + command: String, exit_code: i64, - stderr: String, + stderr: String, }, DevcontainerLifecycleCommandCompleted { - command: String, + command: String, command_index: u64, - exit_code: i64, - duration_ms: u64, + exit_code: i64, + duration_ms: u64, }, StageStarted { node_id: String, - name: String, - script: Option, + name: String, + script: Option, }, StageCompleted { - node_id: String, - name: String, + node_id: String, + name: String, duration_ms: u64, - status: String, - usage: Option, + status: String, + usage: Option, }, StageFailed { node_id: String, - name: String, - error: String, + name: String, + error: String, }, StageRetrying { - name: String, - attempt: u64, + name: String, + attempt: u64, max_attempts: u64, - delay_ms: u64, + delay_ms: u64, }, ParallelStarted, ParallelBranchStarted { branch: String, }, ParallelBranchCompleted { - branch: String, + branch: String, duration_ms: u64, - status: String, + status: String, }, ParallelCompleted, AssistantMessage { stage_node_id: String, - model: String, + model: String, }, ToolCallStarted { stage_node_id: String, - tool_name: String, - tool_call_id: String, - arguments: Value, - timestamp: Option>, + tool_name: String, + tool_call_id: String, + arguments: Value, + timestamp: Option>, }, ToolCallCompleted { stage_node_id: String, - tool_call_id: String, - is_error: bool, - duration_ms: Option, - timestamp: Option>, + tool_call_id: String, + is_error: bool, + duration_ms: Option, + timestamp: Option>, }, ContextWindowWarning { stage_node_id: String, @@ -163,38 +163,38 @@ pub(super) enum ProgressEvent { stage_node_id: String, }, CompactionCompleted { - stage_node_id: String, - original_turn_count: u64, + stage_node_id: String, + original_turn_count: u64, preserved_turn_count: u64, - tracked_file_count: u64, + tracked_file_count: u64, }, LlmRetry { stage_node_id: String, - model: String, - attempt: u64, - delay_ms: u64, - error: String, + model: String, + attempt: u64, + delay_ms: u64, + error: String, }, SubagentSpawned { stage_node_id: String, - agent_id: String, - task: String, + agent_id: String, + task: String, }, SubagentCompleted { stage_node_id: String, - agent_id: String, - success: bool, - turns_used: u64, + agent_id: String, + success: bool, + turns_used: u64, }, EdgeSelected { from_node: String, - to_node: String, - label: Option, + to_node: String, + label: Option, condition: Option, }, LoopRestart { from_node: String, - to_node: String, + to_node: String, }, RetroStarted, RetroCompleted { @@ -204,13 +204,13 @@ pub(super) enum ProgressEvent { duration_ms: u64, }, RunNotice { - level: RunNoticeLevel, - code: String, + level: RunNoticeLevel, + code: String, message: String, }, PullRequestCreated { pr_url: String, - draft: bool, + draft: bool, }, PullRequestFailed { error: String, @@ -224,8 +224,8 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { match &stored.body { EventBody::RunStarted(props) => Some(ProgressEvent::WorkflowStarted { worktree_dir: props.worktree_dir.clone(), - base_branch: props.base_branch.clone(), - base_sha: props.base_sha.clone(), + base_branch: props.base_branch.clone(), + base_sha: props.base_sha.clone(), }), EventBody::SandboxInitialized(props) => Some(ProgressEvent::WorkingDirectorySet { working_directory: props.working_directory.clone(), @@ -234,12 +234,12 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { provider: props.provider.clone(), }), EventBody::SandboxReady(props) => Some(ProgressEvent::SandboxReady { - provider: props.provider.clone(), + provider: props.provider.clone(), duration_ms: props.duration_ms, - name: props.name.clone(), - cpu: props.cpu, - memory: props.memory, - url: props.url.clone(), + name: props.name.clone(), + cpu: props.cpu, + memory: props.memory, + url: props.url.clone(), }), EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady { ssh_command: props.ssh_command.clone(), @@ -251,54 +251,54 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { duration_ms: props.duration_ms, }), EventBody::SetupCommandCompleted(props) => Some(ProgressEvent::SetupCommandCompleted { - command: props.command.clone(), + command: props.command.clone(), command_index: props.index as u64, - exit_code: i64::from(props.exit_code), - duration_ms: props.duration_ms, + exit_code: i64::from(props.exit_code), + duration_ms: props.duration_ms, }), EventBody::CliEnsureStarted(props) => Some(ProgressEvent::CliEnsureStarted { cli_name: props.cli_name.clone(), }), EventBody::CliEnsureCompleted(props) => Some(ProgressEvent::CliEnsureCompleted { - cli_name: props.cli_name.clone(), + cli_name: props.cli_name.clone(), already_installed: props.already_installed, - duration_ms: props.duration_ms, + duration_ms: props.duration_ms, }), EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed { cli_name: props.cli_name.clone(), }), EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved { - dockerfile_lines: props.dockerfile_lines as u64, - environment_count: props.environment_count as u64, + dockerfile_lines: props.dockerfile_lines as u64, + environment_count: props.environment_count as u64, lifecycle_command_count: props.lifecycle_command_count as u64, - workspace_folder: props.workspace_folder.clone(), + workspace_folder: props.workspace_folder.clone(), }), EventBody::DevcontainerLifecycleStarted(props) => { Some(ProgressEvent::DevcontainerLifecycleStarted { - phase: props.phase.clone(), + phase: props.phase.clone(), command_count: props.command_count as u64, }) } EventBody::DevcontainerLifecycleCompleted(props) => { Some(ProgressEvent::DevcontainerLifecycleCompleted { - phase: props.phase.clone(), + phase: props.phase.clone(), duration_ms: props.duration_ms, }) } EventBody::DevcontainerLifecycleFailed(props) => { Some(ProgressEvent::DevcontainerLifecycleFailed { - phase: props.phase.clone(), - command: props.command.clone(), + phase: props.phase.clone(), + command: props.command.clone(), exit_code: i64::from(props.exit_code), - stderr: props.stderr.clone(), + stderr: props.stderr.clone(), }) } EventBody::DevcontainerLifecycleCommandCompleted(props) => { Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { - command: props.command.clone(), + command: props.command.clone(), command_index: props.index as u64, - exit_code: i64::from(props.exit_code), - duration_ms: props.duration_ms, + exit_code: i64::from(props.exit_code), + duration_ms: props.duration_ms, }) } EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted { @@ -325,38 +325,38 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { ), }), EventBody::StageRetrying(props) => Some(ProgressEvent::StageRetrying { - name: node_label, - attempt: props.attempt as u64, + name: node_label, + attempt: props.attempt as u64, max_attempts: props.max_attempts as u64, - delay_ms: props.delay_ms, + delay_ms: props.delay_ms, }), EventBody::ParallelStarted(_) => Some(ProgressEvent::ParallelStarted), EventBody::ParallelBranchStarted(_) => { Some(ProgressEvent::ParallelBranchStarted { branch: node_id }) } EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted { - branch: node_id, + branch: node_id, duration_ms: props.duration_ms, - status: props.status.clone(), + status: props.status.clone(), }), EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted), EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage { stage_node_id: node_id, - model: props.model.clone(), + model: props.model.clone(), }), EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted { stage_node_id: node_id, - tool_name: props.tool_name.clone(), - tool_call_id: props.tool_call_id.clone(), - arguments: props.arguments.clone(), - timestamp: Some(stored.ts), + tool_name: props.tool_name.clone(), + tool_call_id: props.tool_call_id.clone(), + arguments: props.arguments.clone(), + timestamp: Some(stored.ts), }), EventBody::AgentToolCompleted(props) => Some(ProgressEvent::ToolCallCompleted { stage_node_id: node_id, - tool_call_id: props.tool_call_id.clone(), - is_error: props.is_error, - duration_ms: None, - timestamp: Some(stored.ts), + tool_call_id: props.tool_call_id.clone(), + is_error: props.is_error, + duration_ms: None, + timestamp: Some(stored.ts), }), EventBody::AgentWarning(props) if props.kind == "context_window" => { let usage_percent = props @@ -374,10 +374,10 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { stage_node_id: node_id, }), EventBody::AgentCompactionCompleted(props) => Some(ProgressEvent::CompactionCompleted { - stage_node_id: node_id, - original_turn_count: props.original_turn_count as u64, + stage_node_id: node_id, + original_turn_count: props.original_turn_count as u64, preserved_turn_count: props.preserved_turn_count as u64, - tracked_file_count: props.tracked_file_count as u64, + tracked_file_count: props.tracked_file_count as u64, }), EventBody::AgentLlmRetry(props) => { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] @@ -392,24 +392,24 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { } EventBody::AgentSubSpawned(props) => Some(ProgressEvent::SubagentSpawned { stage_node_id: node_id, - agent_id: props.agent_id.clone(), - task: props.task.clone(), + agent_id: props.agent_id.clone(), + task: props.task.clone(), }), EventBody::AgentSubCompleted(props) => Some(ProgressEvent::SubagentCompleted { stage_node_id: node_id, - agent_id: props.agent_id.clone(), - success: props.success, - turns_used: props.turns_used as u64, + agent_id: props.agent_id.clone(), + success: props.success, + turns_used: props.turns_used as u64, }), EventBody::EdgeSelected(props) => Some(ProgressEvent::EdgeSelected { from_node: props.from_node.clone(), - to_node: props.to_node.clone(), - label: props.label.clone(), + to_node: props.to_node.clone(), + label: props.label.clone(), condition: props.condition.clone(), }), EventBody::LoopRestart(props) => Some(ProgressEvent::LoopRestart { from_node: props.from_node.clone(), - to_node: props.to_node.clone(), + to_node: props.to_node.clone(), }), EventBody::RetroStarted(_) => Some(ProgressEvent::RetroStarted), EventBody::RetroCompleted(props) => Some(ProgressEvent::RetroCompleted { @@ -419,13 +419,13 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { duration_ms: props.duration_ms, }), EventBody::RunNotice(props) => Some(ProgressEvent::RunNotice { - level: props.level, - code: props.code.clone(), + level: props.level, + code: props.code.clone(), message: props.message.clone(), }), EventBody::PullRequestCreated(props) => Some(ProgressEvent::PullRequestCreated { pr_url: props.pr_url.clone(), - draft: props.draft, + draft: props.draft, }), EventBody::PullRequestFailed(props) => Some(ProgressEvent::PullRequestFailed { error: props.error.clone(), @@ -477,20 +477,17 @@ mod tests { #[test] fn parse_edge_selected() { - let stored = to_run_event( - &fixtures::RUN_1, - &Event::EdgeSelected { - from_node: "a".into(), - to_node: "b".into(), - label: Some("yes".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, - }, - ); + let stored = to_run_event(&fixtures::RUN_1, &Event::EdgeSelected { + from_node: "a".into(), + to_node: "b".into(), + label: Some("yes".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + stage_status: "success".into(), + is_jump: false, + }); let event = from_run_event(&stored).unwrap(); assert!(matches!( @@ -545,14 +542,14 @@ mod tests { #[test] fn round_trip_agent_tool_call() { let event = Event::Agent { - stage: "code".into(), - visit: 1, - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), + stage: "code".into(), + visit: 1, + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), + arguments: serde_json::json!({"path": "src/main.rs"}), }, - session_id: None, + session_id: None, parent_session_id: None, }; @@ -634,12 +631,12 @@ mod tests { fn round_trip_sandbox_ready() { let event = Event::Sandbox { event: fabro_agent::SandboxEvent::Ready { - provider: "daytona".into(), + provider: "daytona".into(), duration_ms: 2500, - name: Some("sandbox-1".into()), - cpu: Some(4.0), - memory: Some(8.0), - url: Some("https://example.test".into()), + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".into()), }, }; @@ -659,8 +656,8 @@ mod tests { #[test] fn round_trip_run_notice() { let event = Event::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".into(), + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), message: "sandbox cleanup failed".into(), }; diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index cb30ea84f..8325e50d2 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -15,9 +15,9 @@ use stage_display::StageDisplay; pub(crate) struct ProgressUI { renderer: ProgressRenderer, - stage: StageDisplay, - setup: SetupDisplay, - info: InfoDisplay, + stage: StageDisplay, + setup: SetupDisplay, + info: InfoDisplay, } impl ProgressUI { @@ -484,25 +484,22 @@ mod tests { fn stage_started(node_id: &str, name: &str) -> Event { Event::StageStarted { - node_id: node_id.into(), - name: name.into(), - index: 0, + node_id: node_id.into(), + name: name.into(), + index: 0, handler_type: String::new(), - attempt: 1, + attempt: 1, max_attempts: 1, } } fn assistant_message(stage: &str, model: &str) -> Event { - agent_event( - stage, - AgentEvent::AssistantMessage { - text: "done".into(), - model: model.into(), - usage: TokenCounts::default(), - tool_call_count: 0, - }, - ) + agent_event(stage, AgentEvent::AssistantMessage { + text: "done".into(), + model: model.into(), + usage: TokenCounts::default(), + tool_call_count: 0, + }) } fn stage_completed(node_id: &str, name: &str) -> Event { @@ -547,26 +544,20 @@ mod tests { assert!(ui.stage.active_stages.contains_key("fork1")); assert!(ui.stage.parallel_parent.is_none()); - emit( - &mut ui, - Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 2, - join_policy: "wait_all".into(), - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 2, + join_policy: "wait_all".into(), + }); assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); - emit( - &mut ui, - Event::ParallelBranchStarted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - }, - ); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls.len(), 1); assert_eq!(stage.tool_calls[0].tool_call_id, "security"); @@ -575,18 +566,15 @@ mod tests { ToolCallStatus::Running )); - emit( - &mut ui, - Event::ParallelBranchCompleted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - head_sha: None, - }, - ); + emit(&mut ui, Event::ParallelBranchCompleted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + duration_ms: 2000, + status: "success".into(), + head_sha: None, + }); let stage = &ui.stage.active_stages["fork1"]; assert!(matches!( stage.tool_calls[0].status, @@ -599,24 +587,18 @@ mod tests { let mut ui = ProgressUI::new(true, false); emit(&mut ui, stage_started("fork1", "Fork")); - emit( - &mut ui, - Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 1, - join_policy: "wait_all".into(), - }, - ); - emit( - &mut ui, - Event::ParallelBranchStarted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, + join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); let stage = &ui.stage.active_stages["fork1"]; let message = stage.tool_calls[0].bar.message(); @@ -635,27 +617,21 @@ mod tests { emit( &mut ui, - agent_event( - "s1", - AgentEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - }, - ), + agent_event("s1", AgentEvent::CompactionStarted { + estimated_tokens: 5000, + context_window_size: 8000, + }), ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); emit( &mut ui, - agent_event( - "s1", - AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - }, - ), + agent_event("s1", AgentEvent::CompactionCompleted { + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 3, + }), ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); } @@ -674,101 +650,86 @@ mod tests { let events = vec![ stage_started("code", "Code"), Event::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - provider: "daytona".into(), - identifier: None, + working_directory: "/home/daytona/workspace".into(), + provider: "daytona".into(), + identifier: None, host_working_directory: None, - container_mount_point: None, + container_mount_point: None, }, - agent_event( - "code", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }, - ), + agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({ + "file_path": "/home/daytona/workspace/src/main.rs" + }), + }), assistant_message("code", "gpt-5-mini"), Event::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, + from_node: "code".into(), + to_node: "review".into(), + label: Some("ship".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, + stage_status: "success".into(), + is_jump: false, }, Event::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, max_attempts: 3, - delay_ms: 1500, + delay_ms: 1500, }, - agent_event( - "code", - AgentEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), + agent_event("code", AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }), + agent_event("code", AgentEvent::LlmRetry { + provider: "openai".into(), + model: "gpt-5-mini".into(), + attempt: 2, + delay_secs: 1.5, + error: fabro_llm::error::SdkError::Configuration { + message: "busy".into(), + source: None, }, - ), - agent_event( - "code", - AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Configuration { - message: "busy".into(), - source: None, - }, - }, - ), - agent_event( - "code", - AgentEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - }, - ), - agent_event( - "code", - AgentEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - success: true, - turns_used: 3, - }, - ), + }), + agent_event("code", AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }), + agent_event("code", AgentEvent::SubAgentCompleted { + agent_id: "a1".into(), + depth: 1, + success: true, + turns_used: 3, + }), Event::SetupStarted { command_count: 1 }, Event::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, + command: "bun install".into(), + index: 0, + exit_code: 0, duration_ms: 2200, }, Event::SetupCompleted { duration_ms: 2200 }, Event::DevcontainerLifecycleStarted { - phase: "postCreate".into(), + phase: "postCreate".into(), command_count: 1, }, Event::DevcontainerLifecycleCommandCompleted { - phase: "postCreate".into(), - command: "npm run setup".into(), - index: 0, - exit_code: 0, + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, duration_ms: 1400, }, Event::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), + phase: "postCreate".into(), duration_ms: 1400, }, ]; @@ -795,26 +756,20 @@ mod tests { emit(&mut ui, assistant_message("plan", "gpt-5-mini")); emit( &mut ui, - agent_event( - "plan", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - ), + agent_event("plan", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }), ); emit( &mut ui, - agent_event( - "plan", - AgentEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - is_error: false, - }, - ), + agent_event("plan", AgentEvent::ToolCallCompleted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + output: serde_json::json!({"ok": true}), + is_error: false, + }), ); emit(&mut ui, stage_completed("plan", "Plan")); @@ -825,68 +780,47 @@ mod tests { fn plain_default_setup_snapshot() { let (mut ui, buffer) = capture_ui(false); - emit( - &mut ui, - Event::Sandbox { - event: SandboxEvent::Initializing { - provider: "daytona".into(), - }, + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "daytona".into(), }, - ); - emit( - &mut ui, - Event::Sandbox { - event: SandboxEvent::Ready { - provider: "daytona".into(), - duration_ms: 2500, - name: Some("sandbox-1".into()), - cpu: Some(4.0), - memory: Some(8.0), - url: None, - }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".into(), + duration_ms: 2500, + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: None, }, - ); - emit( - &mut ui, - Event::SshAccessReady { - ssh_command: "ssh daytona@example".into(), - }, - ); + }); + emit(&mut ui, Event::SshAccessReady { + ssh_command: "ssh daytona@example".into(), + }); emit(&mut ui, Event::SetupStarted { command_count: 2 }); emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 }); - emit( - &mut ui, - Event::CliEnsureCompleted { - cli_name: "gh".into(), - provider: "github".into(), - already_installed: false, - node_installed: false, - duration_ms: 600, - }, - ); - emit( - &mut ui, - Event::DevcontainerResolved { - dockerfile_lines: 24, - environment_count: 3, - lifecycle_command_count: 2, - workspace_folder: "/workspace".into(), - }, - ); - emit( - &mut ui, - Event::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 2, - }, - ); - emit( - &mut ui, - Event::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1800, - }, - ); + emit(&mut ui, Event::CliEnsureCompleted { + cli_name: "gh".into(), + provider: "github".into(), + already_installed: false, + node_installed: false, + duration_ms: 600, + }); + emit(&mut ui, Event::DevcontainerResolved { + dockerfile_lines: 24, + environment_count: 3, + lifecycle_command_count: 2, + workspace_folder: "/workspace".into(), + }); + emit(&mut ui, Event::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 2, + }); + emit(&mut ui, Event::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1800, + }); insta::assert_snapshot!(rendered(&buffer), @r" Sandbox: daytona (ready in 2s) @@ -906,140 +840,104 @@ mod tests { let (mut ui, buffer) = capture_ui(true); emit(&mut ui, stage_started("code", "Code")); + emit(&mut ui, Event::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), + provider: "daytona".into(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + }); emit( &mut ui, - Event::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - provider: "daytona".into(), - identifier: None, - host_working_directory: None, - container_mount_point: None, - }, - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }, - ), + agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({ + "file_path": "/home/daytona/workspace/src/main.rs" + }), + }), ); emit(&mut ui, assistant_message("code", "gpt-5-mini")); + emit(&mut ui, Event::EdgeSelected { + from_node: "code".into(), + to_node: "review".into(), + label: Some("ship".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + stage_status: "success".into(), + is_jump: false, + }); + emit(&mut ui, Event::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, + max_attempts: 3, + delay_ms: 1500, + }); emit( &mut ui, - Event::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, - }, + agent_event("code", AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }), ); emit( &mut ui, - Event::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 1500, - }, - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), + agent_event("code", AgentEvent::LlmRetry { + provider: "openai".into(), + model: "gpt-5-mini".into(), + attempt: 2, + delay_secs: 1.5, + error: fabro_llm::error::SdkError::Configuration { + message: "busy".into(), + source: None, }, - ), + }), ); emit( &mut ui, - agent_event( - "code", - AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Configuration { - message: "busy".into(), - source: None, - }, - }, - ), + agent_event("code", AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }), ); emit( &mut ui, - agent_event( - "code", - AgentEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - }, - ), - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - success: true, - turns_used: 3, - }, - ), + agent_event("code", AgentEvent::SubAgentCompleted { + agent_id: "a1".into(), + depth: 1, + success: true, + turns_used: 3, + }), ); emit(&mut ui, Event::SetupStarted { command_count: 1 }); - emit( - &mut ui, - Event::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, - duration_ms: 2200, - }, - ); + emit(&mut ui, Event::SetupCommandCompleted { + command: "bun install".into(), + index: 0, + exit_code: 0, + duration_ms: 2200, + }); emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 }); - emit( - &mut ui, - Event::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 1, - }, - ); - emit( - &mut ui, - Event::DevcontainerLifecycleCommandCompleted { - phase: "postCreate".into(), - command: "npm run setup".into(), - index: 0, - exit_code: 0, - duration_ms: 1400, - }, - ); - emit( - &mut ui, - Event::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1400, - }, - ); + emit(&mut ui, Event::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 1, + }); + emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted { + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, + duration_ms: 1400, + }); + emit(&mut ui, Event::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1400, + }); emit(&mut ui, stage_completed("code", "Code")); insta::assert_snapshot!(rendered(&buffer), @r#" @@ -1062,33 +960,24 @@ mod tests { fn plain_notice_snapshot() { let (mut ui, buffer) = capture_ui(false); - emit( - &mut ui, - Event::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".into(), - message: "sandbox cleanup failed".into(), - }, - ); - emit( - &mut ui, - Event::PullRequestCreated { - pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), - pr_number: 42, - owner: "fabro-sh".into(), - repo: "fabro".into(), - base_branch: "main".into(), - head_branch: "fabro/run/42".into(), - title: "Ship the change".into(), - draft: true, - }, - ); - emit( - &mut ui, - Event::PullRequestFailed { - error: "auth token expired".into(), - }, - ); + emit(&mut ui, Event::RunNotice { + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), + message: "sandbox cleanup failed".into(), + }); + emit(&mut ui, Event::PullRequestCreated { + pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), + pr_number: 42, + owner: "fabro-sh".into(), + repo: "fabro".into(), + base_branch: "main".into(), + head_branch: "fabro/run/42".into(), + title: "Ship the change".into(), + draft: true, + }); + emit(&mut ui, Event::PullRequestFailed { + error: "auth token expired".into(), + }); insta::assert_snapshot!(rendered(&buffer), @r" Warning: sandbox cleanup failed [sandbox_cleanup_failed] @@ -1102,36 +991,27 @@ mod tests { let mut ui = ProgressUI::new(true, false); emit(&mut ui, stage_started("fork1", "Fork")); - emit( - &mut ui, - Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 1, - join_policy: "wait_all".into(), - }, - ); - emit( - &mut ui, - Event::ParallelBranchStarted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - }, - ); - emit( - &mut ui, - Event::ParallelBranchCompleted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - duration_ms: 500, - status: "success".into(), - head_sha: None, - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, + join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); + emit(&mut ui, Event::ParallelBranchCompleted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + duration_ms: 500, + status: "success".into(), + head_sha: None, + }); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); @@ -1151,11 +1031,11 @@ mod tests { let stage_started = serde_json::to_string(&to_run_event_at( &fixtures::RUN_1, &Event::StageStarted { - node_id: "code".into(), - name: "Code".into(), - index: 0, + node_id: "code".into(), + name: "Code".into(), + index: 0, handler_type: "agent".into(), - attempt: 1, + attempt: 1, max_attempts: 1, }, started_ts, @@ -1164,29 +1044,23 @@ mod tests { .unwrap(); let tool_started = serde_json::to_string(&to_run_event_at( &fixtures::RUN_1, - &agent_event( - "code", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - ), + &agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }), started_ts, None, )) .unwrap(); let tool_completed = serde_json::to_string(&to_run_event_at( &fixtures::RUN_1, - &agent_event( - "code", - AgentEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - is_error: false, - }, - ), + &agent_event("code", AgentEvent::ToolCallCompleted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + output: serde_json::json!({"ok": true}), + is_error: false, + }), completed_ts, None, )) diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs index 0f25fbf09..c522efac2 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs @@ -12,14 +12,14 @@ enum RendererInner { } pub(super) struct ProgressRenderer { - inner: RendererInner, + inner: RendererInner, styles: Styles, } impl ProgressRenderer { pub(super) fn new_tty() -> Self { Self { - inner: RendererInner::Tty { + inner: RendererInner::Tty { multi: MultiProgress::new(), }, styles: Styles::new(console::colors_enabled_stderr()), @@ -28,7 +28,7 @@ impl ProgressRenderer { pub(super) fn new_plain(out: Box, colors: bool) -> Self { Self { - inner: RendererInner::Plain { + inner: RendererInner::Plain { out: Mutex::new(out), }, styles: Styles::new(colors), diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs index 32cb3475c..e02124787 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs @@ -3,9 +3,8 @@ use std::convert::TryFrom; use std::time::Duration; use chrono::{DateTime, Utc}; -use indicatif::ProgressBar; - use fabro_workflow::outcome::{StageStatus, format_cost}; +use indicatif::ProgressBar; use super::event::ProgressUsage; use super::renderer::ProgressRenderer; @@ -25,18 +24,18 @@ pub(super) enum ToolCallStatus { pub(super) struct ToolCallEntry { pub(super) display_name: String, pub(super) tool_call_id: String, - pub(super) status: ToolCallStatus, - pub(super) bar: ProgressBar, - pub(super) is_branch: bool, - pub(super) started_at: Option>, + pub(super) status: ToolCallStatus, + pub(super) bar: ProgressBar, + pub(super) is_branch: bool, + pub(super) started_at: Option>, } #[derive(Debug)] pub(super) struct ActiveStage { - pub(super) display_name: String, - pub(super) has_model: bool, - pub(super) spinner: ProgressBar, - pub(super) tool_calls: VecDeque, + pub(super) display_name: String, + pub(super) has_model: bool, + pub(super) spinner: ProgressBar, + pub(super) tool_calls: VecDeque, pub(super) compaction_bar: Option, } @@ -49,12 +48,12 @@ impl ActiveStage { } pub(super) struct StageDisplay { - verbose: bool, - pub(super) active_stages: HashMap, - pub(super) stage_counts: HashMap, + verbose: bool, + pub(super) active_stages: HashMap, + pub(super) stage_counts: HashMap, pub(super) parallel_parent: Option, - any_stage_started: bool, - working_directory: Option, + any_stage_started: bool, + working_directory: Option, } impl StageDisplay { @@ -118,16 +117,13 @@ impl StageDisplay { if renderer.is_tty() { bar.enable_steady_tick(Duration::from_millis(100)); } - self.active_stages.insert( - node_id.to_string(), - ActiveStage { - display_name, - has_model: false, - spinner: bar, - tool_calls: VecDeque::new(), - compaction_bar: None, - }, - ); + self.active_stages.insert(node_id.to_string(), ActiveStage { + display_name, + has_model: false, + spinner: bar, + tool_calls: VecDeque::new(), + compaction_bar: None, + }); } pub(super) fn on_stage_completed( diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 7fa2debf5..735c28737 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -8,8 +8,7 @@ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; use fabro_store::{EventEnvelope, EventPayload, RunProjection}; -use fabro_types::settings::InterpString; -use fabro_types::settings::SettingsLayer; +use fabro_types::settings::{InterpString, SettingsLayer}; use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason}; use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; @@ -217,8 +216,8 @@ fn build_artifact_uploader( } struct HttpArtifactUploader { - run_id: RunId, - client: server_client::ServerStoreClient, + run_id: RunId, + client: server_client::ServerStoreClient, bearer_token: String, } @@ -283,7 +282,7 @@ impl StageArtifactUploader for MissingArtifactUploadTokenUploader { struct HttpRunStore { run_id: RunId, client: server_client::ServerStoreClient, - state: Arc>, + state: Arc>, events: Arc>>>, } @@ -540,20 +539,20 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; + use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; + use fabro_types::run_event::{ + InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, + RunFailedProps, RunStatusTransitionProps, + }; + use fabro_types::{EventBody, StatusReason, fixtures}; + use fabro_workflow::artifact_upload::StageArtifactUploader; + use super::{ MissingArtifactUploadTokenUploader, WorkerControlStreamEvent, WorkerTitlePhase, apply_worker_control_line, handle_worker_control_stream_events, initial_worker_title_phase, read_worker_control_stream_blocking, worker_title, worker_title_phase_for_event, }; use crate::args::RunWorkerMode; - use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; - use fabro_types::fixtures; - use fabro_types::run_event::{ - InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, - RunFailedProps, RunStatusTransitionProps, - }; - use fabro_types::{EventBody, StatusReason}; - use fabro_workflow::artifact_upload::StageArtifactUploader; #[test] fn worker_title_uses_short_run_id_and_phase() { @@ -594,12 +593,12 @@ mod tests { ); assert_eq!( worker_title_phase_for_event(&EventBody::InterviewStarted(InterviewStartedProps { - question_id: "q-1".to_string(), - question: "Approve?".to_string(), - stage: "gate".to_string(), - question_type: "yes_no".to_string(), - options: Vec::new(), - allow_freeform: false, + question_id: "q-1".to_string(), + question: "Approve?".to_string(), + stage: "gate".to_string(), + question_type: "yes_no".to_string(), + options: Vec::new(), + allow_freeform: false, timeout_seconds: None, context_display: None, })), @@ -608,39 +607,39 @@ mod tests { assert_eq!( worker_title_phase_for_event(&EventBody::InterviewCompleted(InterviewCompletedProps { question_id: "q-1".to_string(), - question: "Approve?".to_string(), - answer: "yes".to_string(), + question: "Approve?".to_string(), + answer: "yes".to_string(), duration_ms: 10, })), Some(WorkerTitlePhase::Running) ); assert_eq!( worker_title_phase_for_event(&EventBody::RunCompleted(RunCompletedProps { - duration_ms: 10, - artifact_count: 0, - status: "success".to_string(), - reason: None, - total_usd_micros: None, + duration_ms: 10, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, final_git_commit_sha: None, - final_patch: None, - billing: None, + final_patch: None, + billing: None, })), Some(WorkerTitlePhase::Succeeded) ); assert_eq!( worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { - error: "cancelled".to_string(), - duration_ms: 10, - reason: Some(StatusReason::Cancelled), + error: "cancelled".to_string(), + duration_ms: 10, + reason: Some(StatusReason::Cancelled), git_commit_sha: None, })), Some(WorkerTitlePhase::Cancelled) ); assert_eq!( worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { - error: "boom".to_string(), - duration_ms: 10, - reason: Some(StatusReason::Terminated), + error: "boom".to_string(), + duration_ms: 10, + reason: Some(StatusReason::Terminated), git_commit_sha: None, })), Some(WorkerTitlePhase::Failed) diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 198c0a9fe..f655645f9 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -143,13 +143,13 @@ fn print_human_output( #[cfg(test)] mod tests { - use super::*; - use fabro_types::BilledTokenCounts; - use fabro_types::fixtures; + use fabro_types::{BilledTokenCounts, fixtures}; use fabro_workflow::outcome::StageStatus; use fabro_workflow::records::Conclusion; use fabro_workflow::run_status::RunStatusRecord; + use super::*; + fn no_color_styles() -> Styles { Styles::new(false) } @@ -158,22 +158,22 @@ mod tests { fn json_output_succeeded_with_conclusion() { let run_id = fixtures::RUN_1; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Success, - duration_ms: 12345, - failure_reason: None, + timestamp: chrono::Utc::now(), + status: StageStatus::Success, + duration_ms: 12345, + failure_reason: None, final_git_commit_sha: None, - stages: vec![], - billing: Some(BilledTokenCounts { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - reasoning_tokens: 0, - cache_read_tokens: 0, + stages: vec![], + billing: Some(BilledTokenCounts { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, cache_write_tokens: 0, - total_usd_micros: Some(420_000), + total_usd_micros: Some(420_000), }), - total_retries: 0, + total_retries: 0, }; let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion)); assert_eq!(json["run_id"], run_id.to_string()); @@ -202,14 +202,14 @@ mod tests { fn json_output_no_cost_when_none() { let run_id = fixtures::RUN_4; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Fail, - duration_ms: 500, - failure_reason: Some("error".into()), + timestamp: chrono::Utc::now(), + status: StageStatus::Fail, + duration_ms: 500, + failure_reason: Some("error".into()), final_git_commit_sha: None, - stages: vec![], - billing: None, - total_retries: 0, + stages: vec![], + billing: None, + total_retries: 0, }; let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion)); assert!(json.get("total_usd_micros").is_none()); @@ -221,22 +221,22 @@ mod tests { let styles = no_color_styles(); let run_id = fixtures::RUN_5; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Success, - duration_ms: 8000, - failure_reason: None, + timestamp: chrono::Utc::now(), + status: StageStatus::Success, + duration_ms: 8000, + failure_reason: None, final_git_commit_sha: None, - stages: vec![], - billing: Some(BilledTokenCounts { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - reasoning_tokens: 0, - cache_read_tokens: 0, + stages: vec![], + billing: Some(BilledTokenCounts { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, cache_write_tokens: 0, - total_usd_micros: Some(150_000), + total_usd_micros: Some(150_000), }), - total_retries: 0, + total_retries: 0, }; // Just verify no panic; actual stderr output is hard to capture print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles); diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index c3192e8c3..3933c3fed 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -1,7 +1,6 @@ use anyhow::Result; -use serde::Serialize; - use fabro_workflow::run_status::RunStatus; +use serde::Serialize; use crate::args::{GlobalArgs, InspectArgs}; use crate::command_context::CommandContext; @@ -10,13 +9,13 @@ use crate::server_runs::{ServerRunSummaryInfo, ServerSummaryLookup}; #[derive(Debug, Serialize)] pub(crate) struct InspectOutput { - pub run_id: String, - pub status: RunStatus, - pub run_record: Option, + pub run_id: String, + pub status: RunStatus, + pub run_record: Option, pub start_record: Option, - pub conclusion: Option, - pub checkpoint: Option, - pub sandbox: Option, + pub conclusion: Option, + pub checkpoint: Option, + pub sandbox: Option, } pub(crate) async fn run(args: &InspectArgs, _globals: &GlobalArgs) -> Result<()> { @@ -33,24 +32,24 @@ pub(crate) async fn run(args: &InspectArgs, _globals: &GlobalArgs) -> Result<()> fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput { InspectOutput { - run_id: run.run_id().to_string(), - status: state + run_id: run.run_id().to_string(), + status: state .status .as_ref() .map_or(run.status(), |record| record.status), - run_record: state + run_record: state .run .and_then(|record| serde_json::to_value(record).ok()), start_record: state .start .and_then(|record| serde_json::to_value(record).ok()), - conclusion: state + conclusion: state .conclusion .and_then(|record| serde_json::to_value(record).ok()), - checkpoint: state + checkpoint: state .checkpoint .and_then(|record| serde_json::to_value(record).ok()), - sandbox: state + sandbox: state .sandbox .and_then(|record| serde_json::to_value(record).ok()), } diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index e3e3de71e..9d8168310 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -5,17 +5,15 @@ use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_util::terminal::Styles; - use fabro_util::text::strip_goal_decoration; use fabro_workflow::run_status::RunStatus; +use super::short_run_id; use crate::args::{GlobalArgs, RunsListArgs}; use crate::command_context::CommandContext; use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; use crate::shared::{color_if, format_duration_ms, tilde_path}; -use super::short_run_id; - #[allow(clippy::print_stdout)] pub(crate) async fn list_command( args: &RunsListArgs, diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 3d488e61e..065261cf1 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result, bail}; +use super::short_run_id; use crate::args::{GlobalArgs, RunsRemoveArgs}; use crate::command_context::CommandContext; use crate::server_client; @@ -8,8 +9,6 @@ use crate::server_runs::{ }; use crate::shared::print_json_pretty; -use super::short_run_id; - pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs) -> Result<()> { let ctx = CommandContext::for_target(&args.server)?; let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/crates/fabro-cli/src/commands/server/foreground.rs index a429a084c..ba469d061 100644 --- a/lib/crates/fabro-cli/src/commands/server/foreground.rs +++ b/lib/crates/fabro-cli/src/commands/server/foreground.rs @@ -48,15 +48,12 @@ pub(crate) async fn execute( styles, storage_dir, move |resolved_bind| { - record::write_server_record( - &record_path, - &record::ServerRecord { - pid, - bind: resolved_bind.clone(), - log_path: log_path.clone(), - started_at: Utc::now(), - }, - ) + record::write_server_record(&record_path, &record::ServerRecord { + pid, + bind: resolved_bind.clone(), + log_path: log_path.clone(), + started_at: Utc::now(), + }) }, )) .await diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs index 629b7083a..27129208a 100644 --- a/lib/crates/fabro-cli/src/commands/server/record.rs +++ b/lib/crates/fabro-cli/src/commands/server/record.rs @@ -9,15 +9,15 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ServerRecord { - pub pid: u32, - pub bind: Bind, - pub log_path: PathBuf, + pub pid: u32, + pub bind: Bind, + pub log_path: PathBuf, pub started_at: DateTime, } #[derive(Debug, Clone)] pub(crate) struct ActiveServerRecord { - pub record: ServerRecord, + pub record: ServerRecord, pub record_path: PathBuf, } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 00b40ac8d..c15680923 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -69,15 +69,15 @@ fn ensure_server_running_with_bind( } let serve_args = ServeArgs { - bind: None, - web: false, - no_web: false, - model: None, - provider: None, - dry_run: false, - sandbox: None, + bind: None, + web: false, + no_web: false, + model: None, + provider: None, + dry_run: false, + sandbox: None, max_concurrent_runs: server_max_concurrent_runs_override(), - config: Some(config_path.to_path_buf()), + config: Some(config_path.to_path_buf()), }; let bind_request = match &bind { @@ -148,15 +148,12 @@ async fn execute_foreground( styles, Some(storage_dir), move |resolved_bind| { - record::write_server_record( - &record_path, - &record::ServerRecord { - pid, - bind: resolved_bind.clone(), - log_path: log_path.clone(), - started_at: Utc::now(), - }, - ) + record::write_server_record(&record_path, &record::ServerRecord { + pid, + bind: resolved_bind.clone(), + log_path: log_path.clone(), + started_at: Utc::now(), + }) }, )) .await diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 9ef00d1cd..16b4020c9 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -1,3 +1,6 @@ +use std::io::ErrorKind; +use std::path::Path; + use anyhow::{Context, Result}; use bytes::Bytes; #[cfg(test)] @@ -8,8 +11,6 @@ use fabro_workflow::run_dump::RunDump; use futures::future::BoxFuture; #[cfg(test)] use serde::de::DeserializeOwned; -use std::io::ErrorKind; -use std::path::Path; use crate::args::{GlobalArgs, StoreDumpArgs}; use crate::server_client::ServerStoreClient; @@ -81,9 +82,9 @@ fn finalize_export( } struct DumpArtifact { - stage_id: StageId, + stage_id: StageId, relative_path: String, - data: Vec, + data: Vec, } trait DumpDataSource { @@ -96,9 +97,9 @@ trait DumpDataSource { #[cfg(test)] struct LocalDumpSource<'a> { - run_store: &'a RunDatabase, + run_store: &'a RunDatabase, artifact_store: &'a ArtifactStore, - run_id: RunId, + run_id: RunId, } #[cfg(test)] @@ -139,9 +140,9 @@ impl DumpDataSource for LocalDumpSource<'_> { ) })?; artifacts.push(DumpArtifact { - stage_id: asset.node, + stage_id: asset.node, relative_path: asset.filename, - data: data.to_vec(), + data: data.to_vec(), }); } Ok(artifacts) @@ -288,8 +289,6 @@ fn output_parent_dir(path: &Path) -> &Path { #[cfg(test)] mod tests { - use super::*; - use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -304,7 +303,10 @@ mod tests { StageStatus, StartRecord, StatusReason, fixtures, }; use fabro_workflow::event::{Event, append_event}; - use object_store::{ObjectStore, memory::InMemory}; + use object_store::ObjectStore; + use object_store::memory::InMemory; + + use super::*; fn dt(rfc3339: &str) -> DateTime { DateTime::parse_from_rfc3339(rfc3339) @@ -360,49 +362,52 @@ mod tests { fn sample_status() -> RunStatusRecord { RunStatusRecord { - status: RunStatus::Running, - reason: Some(StatusReason::SandboxInitializing), + status: RunStatus::Running, + reason: Some(StatusReason::SandboxInitializing), updated_at: dt("2026-03-27T12:05:00Z"), } } fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint { Checkpoint { - timestamp: dt("2026-03-27T12:10:00Z"), - current_node: current_node.to_string(), - completed_nodes: vec!["plan".to_string()], - node_retries: HashMap::from([(current_node.to_string(), visit.saturating_sub(1))]), - context_values: HashMap::from([( + timestamp: dt("2026-03-27T12:10:00Z"), + current_node: current_node.to_string(), + completed_nodes: vec!["plan".to_string()], + node_retries: HashMap::from([( + current_node.to_string(), + visit.saturating_sub(1), + )]), + context_values: HashMap::from([( "artifact".to_string(), serde_json::json!({"kind": "summary"}), )]), - node_outcomes: HashMap::new(), - next_node_id: Some("review".to_string()), - git_commit_sha: Some("def456".to_string()), - loop_failure_signatures: HashMap::new(), + node_outcomes: HashMap::new(), + next_node_id: Some("review".to_string()), + git_commit_sha: Some("def456".to_string()), + loop_failure_signatures: HashMap::new(), restart_failure_signatures: HashMap::new(), - node_visits: HashMap::from([(current_node.to_string(), visit as usize)]), + node_visits: HashMap::from([(current_node.to_string(), visit as usize)]), } } fn sample_conclusion() -> Conclusion { Conclusion { - timestamp: dt("2026-03-27T12:15:00Z"), - status: StageStatus::Success, - duration_ms: 3210, - failure_reason: None, + timestamp: dt("2026-03-27T12:15:00Z"), + status: StageStatus::Success, + duration_ms: 3210, + failure_reason: None, final_git_commit_sha: Some("feedbeef".to_string()), - stages: Vec::new(), - billing: Some(BilledTokenCounts { - input_tokens: 10, - output_tokens: 20, - total_tokens: 150, - reasoning_tokens: 50, - cache_read_tokens: 30, + stages: Vec::new(), + billing: Some(BilledTokenCounts { + input_tokens: 10, + output_tokens: 20, + total_tokens: 150, + reasoning_tokens: 50, + cache_read_tokens: 30, cache_write_tokens: 40, - total_usd_micros: Some(1_250_000), + total_usd_micros: Some(1_250_000), }), - total_retries: 2, + total_retries: 2, } } @@ -415,12 +420,12 @@ mod tests { smoothness: None, stages: Vec::new(), stats: AggregateStats { - total_duration_ms: 3210, + total_duration_ms: 3210, total_billing_usd_micros: Some(1_250_000), - total_retries: 2, - files_touched: vec!["src/lib.rs".to_string()], - stages_completed: 3, - stages_failed: 0, + total_retries: 2, + files_touched: vec!["src/lib.rs".to_string()], + stages_completed: 3, + stages_failed: 0, }, intent: Some("ship the fix".to_string()), outcome: Some("done".to_string()), @@ -432,11 +437,11 @@ mod tests { fn sample_sandbox() -> SandboxRecord { SandboxRecord { - provider: "local".to_string(), - working_directory: "/tmp/night-sky".to_string(), - identifier: Some("sandbox-1".to_string()), + provider: "local".to_string(), + working_directory: "/tmp/night-sky".to_string(), + identifier: Some("sandbox-1".to_string()), host_working_directory: Some("/tmp/night-sky".to_string()), - container_mount_point: None, + container_mount_point: None, } } @@ -473,223 +478,171 @@ mod tests { ); let node = StageId::new("code", 2); - append_event( - &run, - &run_id, - &Event::RunCreated { - run_id, - settings: serde_json::to_value(&run_record.settings).unwrap(), - graph: serde_json::to_value(&run_record.graph).unwrap(), - workflow_source: Some("digraph night_sky {}".to_string()), - workflow_config: None, - labels: run_record.labels.clone().into_iter().collect(), - run_dir: "/tmp/night-sky-run".to_string(), - working_directory: run_record.working_directory.display().to_string(), - host_repo_path: run_record.host_repo_path.clone(), - repo_origin_url: run_record.repo_origin_url.clone(), - base_branch: run_record.base_branch.clone(), - workflow_slug: run_record.workflow_slug.clone(), - db_prefix: None, - provenance: run_record.provenance.clone(), - manifest_blob: None, - }, - ) + append_event(&run, &run_id, &Event::RunCreated { + run_id, + settings: serde_json::to_value(&run_record.settings).unwrap(), + graph: serde_json::to_value(&run_record.graph).unwrap(), + workflow_source: Some("digraph night_sky {}".to_string()), + workflow_config: None, + labels: run_record.labels.clone().into_iter().collect(), + run_dir: "/tmp/night-sky-run".to_string(), + working_directory: run_record.working_directory.display().to_string(), + host_repo_path: run_record.host_repo_path.clone(), + repo_origin_url: run_record.repo_origin_url.clone(), + base_branch: run_record.base_branch.clone(), + workflow_slug: run_record.workflow_slug.clone(), + db_prefix: None, + provenance: run_record.provenance.clone(), + manifest_blob: None, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::WorkflowRunStarted { - name: "night-sky".to_string(), - run_id, - base_branch: run_record.base_branch.clone(), - base_sha: start_record.base_sha.clone(), - run_branch: start_record.run_branch.clone(), - worktree_dir: None, - goal: Some("map the constellations".to_string()), - }, - ) + append_event(&run, &run_id, &Event::WorkflowRunStarted { + name: "night-sky".to_string(), + run_id, + base_branch: run_record.base_branch.clone(), + base_sha: start_record.base_sha.clone(), + run_branch: start_record.run_branch.clone(), + worktree_dir: None, + goal: Some("map the constellations".to_string()), + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::RunRunning { - reason: status_record.reason, - }, - ) + append_event(&run, &run_id, &Event::RunRunning { + reason: status_record.reason, + }) .await .unwrap(); for checkpoint in [&first_checkpoint, &second_checkpoint] { - append_event( - &run, - &run_id, - &Event::CheckpointCompleted { - node_id: checkpoint.current_node.clone(), - status: "success".to_string(), - current_node: checkpoint.current_node.clone(), - completed_nodes: checkpoint.completed_nodes.clone(), - node_retries: checkpoint.node_retries.clone().into_iter().collect(), - context_values: checkpoint.context_values.clone().into_iter().collect(), - node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(), - next_node_id: checkpoint.next_node_id.clone(), - git_commit_sha: checkpoint.git_commit_sha.clone(), - loop_failure_signatures: checkpoint - .loop_failure_signatures - .clone() - .into_iter() - .map(|(signature, count)| (signature.to_string(), count)) - .collect(), - restart_failure_signatures: checkpoint - .restart_failure_signatures - .clone() - .into_iter() - .map(|(signature, count)| (signature.to_string(), count)) - .collect(), - node_visits: checkpoint.node_visits.clone().into_iter().collect(), - diff: None, - }, - ) + append_event(&run, &run_id, &Event::CheckpointCompleted { + node_id: checkpoint.current_node.clone(), + status: "success".to_string(), + current_node: checkpoint.current_node.clone(), + completed_nodes: checkpoint.completed_nodes.clone(), + node_retries: checkpoint.node_retries.clone().into_iter().collect(), + context_values: checkpoint.context_values.clone().into_iter().collect(), + node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(), + next_node_id: checkpoint.next_node_id.clone(), + git_commit_sha: checkpoint.git_commit_sha.clone(), + loop_failure_signatures: checkpoint + .loop_failure_signatures + .clone() + .into_iter() + .map(|(signature, count)| (signature.to_string(), count)) + .collect(), + restart_failure_signatures: checkpoint + .restart_failure_signatures + .clone() + .into_iter() + .map(|(signature, count)| (signature.to_string(), count)) + .collect(), + node_visits: checkpoint.node_visits.clone().into_iter().collect(), + diff: None, + }) .await .unwrap(); } - append_event( - &run, - &run_id, - &Event::SandboxInitialized { - working_directory: sandbox.working_directory.clone(), - provider: sandbox.provider.clone(), - identifier: sandbox.identifier.clone(), - host_working_directory: sandbox.host_working_directory.clone(), - container_mount_point: sandbox.container_mount_point.clone(), - }, - ) + append_event(&run, &run_id, &Event::SandboxInitialized { + working_directory: sandbox.working_directory.clone(), + provider: sandbox.provider.clone(), + identifier: sandbox.identifier.clone(), + host_working_directory: sandbox.host_working_directory.clone(), + container_mount_point: sandbox.container_mount_point.clone(), + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::Prompt { - stage: "code".to_string(), - visit: 2, - text: "Plan the fix".to_string(), - mode: None, - provider: None, - model: None, - }, - ) + append_event(&run, &run_id, &Event::Prompt { + stage: "code".to_string(), + visit: 2, + text: "Plan the fix".to_string(), + mode: None, + provider: None, + model: None, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::PromptCompleted { - node_id: "code".to_string(), - response: "Implemented".to_string(), - model: "gpt-5".to_string(), - provider: "openai".to_string(), - billing: None, - }, - ) + append_event(&run, &run_id, &Event::PromptCompleted { + node_id: "code".to_string(), + response: "Implemented".to_string(), + model: "gpt-5".to_string(), + provider: "openai".to_string(), + billing: None, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::StageCompleted { - node_id: "code".to_string(), - name: "Code".to_string(), - index: 1, - duration_ms: 250, - status: "partial_success".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - billing: None, - failure: None, - notes: Some("captured output".to_string()), - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: Some(std::collections::BTreeMap::from([( - "code".to_string(), - 2usize, - )])), - loop_failure_signatures: None, - restart_failure_signatures: None, - response: Some("Implemented".to_string()), - attempt: 1, - max_attempts: 1, - }, - ) + append_event(&run, &run_id, &Event::StageCompleted { + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + duration_ms: 250, + status: "partial_success".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + billing: None, + failure: None, + notes: Some("captured output".to_string()), + files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: Some(std::collections::BTreeMap::from([( + "code".to_string(), + 2usize, + )])), + loop_failure_signatures: None, + restart_failure_signatures: None, + response: Some("Implemented".to_string()), + attempt: 1, + max_attempts: 1, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::CommandStarted { - node_id: "code".to_string(), - script: "echo hi".to_string(), - command: "echo hi".to_string(), - language: "sh".to_string(), - timeout_ms: None, - }, - ) + append_event(&run, &run_id, &Event::CommandStarted { + node_id: "code".to_string(), + script: "echo hi".to_string(), + command: "echo hi".to_string(), + language: "sh".to_string(), + timeout_ms: None, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::CommandCompleted { - node_id: "code".to_string(), - stdout: "stdout line".to_string(), - stderr: String::new(), - exit_code: Some(0), - duration_ms: 100, - timed_out: false, - }, - ) + append_event(&run, &run_id, &Event::CommandCompleted { + node_id: "code".to_string(), + stdout: "stdout line".to_string(), + stderr: String::new(), + exit_code: Some(0), + duration_ms: 100, + timed_out: false, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::RetroStarted { - prompt: Some("How did it go?".to_string()), - provider: None, - model: None, - }, - ) + append_event(&run, &run_id, &Event::RetroStarted { + prompt: Some("How did it go?".to_string()), + provider: None, + model: None, + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::RetroCompleted { - duration_ms: 50, - response: Some("Smooth enough".to_string()), - retro: Some(serde_json::to_value(&retro).unwrap()), - }, - ) + append_event(&run, &run_id, &Event::RetroCompleted { + duration_ms: 50, + response: Some("Smooth enough".to_string()), + retro: Some(serde_json::to_value(&retro).unwrap()), + }) .await .unwrap(); - append_event( - &run, - &run_id, - &Event::WorkflowRunCompleted { - duration_ms: conclusion.duration_ms, - artifact_count: 0, - status: "success".to_string(), - reason: None, - total_usd_micros: conclusion - .billing - .as_ref() - .and_then(|billing| billing.total_usd_micros), - final_git_commit_sha: conclusion.final_git_commit_sha.clone(), - final_patch: None, - billing: conclusion.billing.clone(), - }, - ) + append_event(&run, &run_id, &Event::WorkflowRunCompleted { + duration_ms: conclusion.duration_ms, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: conclusion + .billing + .as_ref() + .and_then(|billing| billing.total_usd_micros), + final_git_commit_sha: conclusion.final_git_commit_sha.clone(), + final_patch: None, + billing: conclusion.billing.clone(), + }) .await .unwrap(); run.append_event( diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs index 096eddb4c..03fa6dc9f 100644 --- a/lib/crates/fabro-cli/src/commands/system/events.rs +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -3,8 +3,7 @@ use futures::StreamExt; use crate::args::{GlobalArgs, SystemEventsArgs}; use crate::command_context::CommandContext; -use crate::server_client; -use crate::sse; +use crate::{server_client, sse}; pub(super) async fn events_command(args: &SystemEventsArgs, globals: &GlobalArgs) -> Result<()> { let ctx = CommandContext::for_connection(&args.connection)?; diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs index 62c596c92..ec3c86e15 100644 --- a/lib/crates/fabro-cli/src/commands/system/mod.rs +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -4,11 +4,10 @@ mod info; mod prune; use anyhow::Result; +pub(crate) use prune::parse_duration; use crate::args::{GlobalArgs, SystemCommand, SystemNamespace}; -pub(crate) use prune::parse_duration; - pub(crate) async fn dispatch(ns: SystemNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { SystemCommand::Info(args) => info::info_command(&args, globals).await, diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 98bab80ef..df5c7e1c7 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -1,9 +1,8 @@ use std::collections::HashMap; use anyhow::{Context, Result, bail}; -use tracing::{debug, info}; - use fabro_api::types; +use tracing::{debug, info}; use crate::args::{GlobalArgs, RunsPruneArgs}; use crate::command_context::CommandContext; @@ -17,12 +16,12 @@ pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> .api() .prune_runs() .body(types::PruneRunsRequest { - before: args.filter.before.clone(), - dry_run: !args.yes, - labels: parse_label_filters(&args.filter.label), + before: args.filter.before.clone(), + dry_run: !args.yes, + labels: parse_label_filters(&args.filter.label), older_than: args.older_than.map(format_duration), - orphans: args.filter.orphans, - workflow: args.filter.workflow.clone(), + orphans: args.filter.orphans, + workflow: args.filter.workflow.clone(), }) .send() .await diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index f5ab53135..17cfd0b13 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -4,11 +4,10 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; +use fabro_util::Home; use serde::Serialize; use tracing::warn; -use fabro_util::Home; - use crate::args::{GlobalArgs, UninstallArgs}; use crate::commands::server; use crate::shared::{format_size, print_json_pretty, tilde_path}; @@ -16,13 +15,13 @@ use crate::user_config; #[derive(Debug, Serialize)] struct Inventory { - home_root: PathBuf, - storage_dir: PathBuf, - home_exists: bool, - home_size: u64, - server_running: bool, - shell_configs: Vec, - binary_path: Option, + home_root: PathBuf, + storage_dir: PathBuf, + home_exists: bool, + home_size: u64, + server_running: bool, + shell_configs: Vec, + binary_path: Option, binary_is_managed: bool, } @@ -204,12 +203,12 @@ fn print_preview(inventory: &Inventory) { #[derive(Debug, Serialize)] struct UninstallResult { - status: &'static str, - home_removed: bool, - server_stopped: bool, + status: &'static str, + home_removed: bool, + server_stopped: bool, shell_configs_cleaned: Vec, - binary_removed: bool, - binary_hint: Option, + binary_removed: bool, + binary_hint: Option, } fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> { @@ -218,12 +217,12 @@ fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> { let bold = console::Style::new().bold(); let mut critical_failure = false; let mut result = UninstallResult { - status: "completed", - home_removed: false, - server_stopped: false, + status: "completed", + home_removed: false, + server_stopped: false, shell_configs_cleaned: Vec::new(), - binary_removed: false, - binary_hint: None, + binary_removed: false, + binary_hint: None, }; // Unit 3a: Server stop diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 5c2f5e5d1..ee88accd8 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -5,10 +5,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use semver::Version; use sha2::{Digest, Sha256}; -use tracing::debug; - use tokio::process::Command as TokioCommand; use tokio::task::JoinHandle; +use tracing::debug; use crate::args::{GlobalArgs, UpgradeArgs}; use crate::shared::print_json_pretty; @@ -194,7 +193,7 @@ const LAST_CHECK_FILE: &str = "last_upgrade_check.json"; #[derive(serde::Serialize, serde::Deserialize)] struct UpgradeCheckState { - checked_at: u64, + checked_at: u64, latest_version: String, } @@ -411,7 +410,7 @@ async fn check_and_print_notice() -> Result<()> { .unwrap_or_default() .as_secs(); let state = UpgradeCheckState { - checked_at: now, + checked_at: now, latest_version: latest.to_string(), }; let _ = state.save(&state_path); @@ -504,7 +503,7 @@ mod tests { #[test] fn upgrade_check_state_roundtrip() { let state = UpgradeCheckState { - checked_at: 1_710_000_000, + checked_at: 1_710_000_000, latest_version: "0.5.0".to_string(), }; let json = serde_json::to_string(&state).unwrap(); @@ -516,7 +515,7 @@ mod tests { #[test] fn upgrade_check_state_stale() { let old = UpgradeCheckState { - checked_at: 0, // epoch — definitely stale + checked_at: 0, // epoch — definitely stale latest_version: "0.1.0".to_string(), }; assert!(old.is_stale()); @@ -529,7 +528,7 @@ mod tests { .unwrap() .as_secs(); let fresh = UpgradeCheckState { - checked_at: now, + checked_at: now, latest_version: "0.5.0".to_string(), }; assert!(!fresh.is_stale()); @@ -540,7 +539,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.json"); let state = UpgradeCheckState { - checked_at: 1_710_000_000, + checked_at: 1_710_000_000, latest_version: "0.5.0".to_string(), }; state.save(&path).unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index f35666cfb..f8a86fbaa 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -17,12 +17,12 @@ pub(crate) async fn run( ) -> anyhow::Result<()> { let ctx = CommandContext::for_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { - workflow: args.workflow.clone(), - cwd: ctx.cwd().to_path_buf(), - args_layer: SettingsLayer::default(), - args: None, - run_id: None, - user_layer: load_settings_user()?, + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: load_settings_user()?, user_settings_path: Some(active_settings_path(None)), })?; let client = ctx.server().await?; diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index 491032b6d..2d00fae84 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -1,7 +1,6 @@ use std::path::Path; use anyhow::{Context, Result, bail}; - use fabro_config::project::{discover_project_config, resolve_fabro_root}; use crate::args::{GlobalArgs, WorkflowCreateArgs}; diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index ffab8e02f..023d8e5a9 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -1,10 +1,9 @@ use anyhow::{Result, bail}; -use fabro_util::terminal::Styles; - use fabro_config::project::{ WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed, resolve_fabro_root, }; +use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, WorkflowListArgs}; use crate::shared::{print_json_pretty, relative_path}; diff --git a/lib/crates/fabro-cli/src/logging.rs b/lib/crates/fabro-cli/src/logging.rs index 8a6905d9e..e787b6b13 100644 --- a/lib/crates/fabro-cli/src/logging.rs +++ b/lib/crates/fabro-cli/src/logging.rs @@ -3,7 +3,9 @@ use std::path::Path; use anyhow::{Context, Result}; use fabro_util::run_log; use tracing_appender::rolling; -use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; const LOG_RETENTION_DAYS: u32 = 7; diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 012d757fe..a26be1ce6 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -14,6 +14,9 @@ mod sleep_inhibitor; mod sse; mod user_config; +#[cfg(test)] +use std::ffi::OsString; + use anyhow::Result; use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace}; use clap::{CommandFactory, Parser}; @@ -23,8 +26,6 @@ use fabro_types::settings::cli::OutputVerbosity; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use rustls::crypto::ring::default_provider; -#[cfg(test)] -use std::ffi::OsString; use tracing::debug; #[derive(Parser)] @@ -305,11 +306,12 @@ async fn main_inner() -> (String, Result<()>) { #[cfg(test)] mod tests { - use super::*; use args::{ Commands, ModelsCommand, ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace, }; + use super::*; + #[test] fn parse_provider_login_openai() { let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"]) diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 632397e74..6565f1908 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -19,14 +19,14 @@ use crate::args::{PreflightArgs, RunArgs}; #[derive(Debug)] pub(crate) struct ManifestBuildInput { - pub workflow: PathBuf, - pub cwd: PathBuf, - pub args_layer: SettingsLayer, - pub args: Option, - pub run_id: Option, + pub workflow: PathBuf, + pub cwd: PathBuf, + pub args_layer: SettingsLayer, + pub args: Option, + pub run_id: Option, /// User-level settings layer. Production callers load via /// `load_settings_user()`; tests pass `SettingsLayer::default()`. - pub user_layer: SettingsLayer, + pub user_layer: SettingsLayer, /// Path to the user settings file (for inclusion in /// `RunManifest.configs`). `None` skips the user config entry. pub user_settings_path: Option, @@ -34,21 +34,21 @@ pub(crate) struct ManifestBuildInput { #[derive(Debug)] pub(crate) struct BuiltManifest { - pub manifest: types::RunManifest, + pub manifest: types::RunManifest, pub target_path: PathBuf, } struct CollectContext<'a> { - cwd: &'a Path, - workflows: HashMap, + cwd: &'a Path, + workflows: HashMap, visited_workflows: HashSet, } #[derive(Clone)] struct WorkflowScanInput { absolute_dot_path: PathBuf, - logical_dot_path: PathBuf, - source: String, + logical_dot_path: PathBuf, + source: String, } pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { @@ -64,8 +64,8 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result Result Result Option { let payload = types::ManifestArgs { - auto_approve: args.auto_approve.then_some(true), - dry_run: args.dry_run.then_some(true), - label: args.label.clone(), - model: args.model.clone(), - no_retro: args.no_retro.then_some(true), + auto_approve: args.auto_approve.then_some(true), + dry_run: args.dry_run.then_some(true), + label: args.label.clone(), + model: args.model.clone(), + no_retro: args.no_retro.then_some(true), preserve_sandbox: args.preserve_sandbox.then_some(true), - provider: args.provider.clone(), - sandbox: args + provider: args.provider.clone(), + sandbox: args .sandbox .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), - verbose: args.verbose.then_some(true), + verbose: args.verbose.then_some(true), }; (!manifest_args_is_empty(&payload)).then_some(payload) } pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option { let payload = types::ManifestArgs { - auto_approve: None, - dry_run: None, - label: Vec::new(), - model: args.model.clone(), - no_retro: None, + auto_approve: None, + dry_run: None, + label: Vec::new(), + model: args.model.clone(), + no_retro: None, preserve_sandbox: None, - provider: args.provider.clone(), - sandbox: args + provider: args.provider.clone(), + sandbox: args .sandbox .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), - verbose: args.verbose.then_some(true), + verbose: args.verbose.then_some(true), }; (!manifest_args_is_empty(&payload)).then_some(payload) } @@ -191,7 +191,7 @@ fn collect_workflow_entry( .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; let config = if let Some(workflow_toml_path) = resolution.workflow_toml_path.as_ref() { Some(types::ManifestWorkflowConfig { - path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?), + path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?), source: std::fs::read_to_string(workflow_toml_path) .with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?, }) @@ -211,14 +211,13 @@ fn collect_workflow_entry( } collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?; - context.workflows.insert( - logical_dot_key, - types::ManifestWorkflow { + context + .workflows + .insert(logical_dot_key, types::ManifestWorkflow { config, files, source, - }, - ); + }); Ok(()) } @@ -289,8 +288,8 @@ fn collect_workflow_files( })?; let imported_scan = WorkflowScanInput { absolute_dot_path: imported.absolute_path, - logical_dot_path: imported.logical_path, - source: imported_source, + logical_dot_path: imported.logical_path, + source: imported_source, }; collect_workflow_files(context, &imported_scan, files, visited_imports)?; } @@ -348,7 +347,7 @@ fn collect_workflow_config_files( struct BundledFile { absolute_path: PathBuf, - logical_path: PathBuf, + logical_path: PathBuf, } fn collect_bundled_file( @@ -366,17 +365,14 @@ fn collect_bundled_file( if !files.contains_key(&key) { let content = std::fs::read_to_string(&absolute_path) .with_context(|| format!("Failed to read {}", absolute_path.display()))?; - files.insert( - key.clone(), - types::ManifestFileEntry { - content, - ref_: types::ManifestFileRef { - from: from.map(|value| logical_path_string(&value)), - original: reference.to_string(), - type_: ref_type, - }, + files.insert(key.clone(), types::ManifestFileEntry { + content, + ref_: types::ManifestFileRef { + from: from.map(|value| logical_path_string(&value)), + original: reference.to_string(), + type_: ref_type, }, - ); + }); } Ok(BundledFile { @@ -425,16 +421,16 @@ fn resolve_manifest_goal( ) .ok_or_else(|| anyhow!("unsupported manifest goal reference: {reference}"))?; return Ok(Some(types::ManifestGoal { - path: Some(reference.to_string()), - text: std::fs::read_to_string(&goal_path) + path: Some(reference.to_string()), + text: std::fs::read_to_string(&goal_path) .with_context(|| format!("Failed to read {}", goal_path.display()))?, type_: types::ManifestGoalType::Graph, })); } Ok(Some(types::ManifestGoal { - path: None, - text: goal.to_string(), + path: None, + text: goal.to_string(), type_: types::ManifestGoalType::Graph, })) } @@ -445,13 +441,13 @@ fn resolve_manifest_goal( fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal { match resolved.source { ResolvedGoalSource::Inline => types::ManifestGoal { - path: None, - text: resolved.text, + path: None, + text: resolved.text, type_: types::ManifestGoalType::Value, }, ResolvedGoalSource::File { path } => types::ManifestGoal { - path: Some(path.to_string_lossy().into_owned()), - text: resolved.text, + path: Some(path.to_string_lossy().into_owned()), + text: resolved.text, type_: types::ManifestGoalType::File, }, } @@ -604,12 +600,12 @@ mod tests { .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), - args: None, - run_id: None, - user_layer: SettingsLayer::default(), + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); @@ -680,12 +676,12 @@ file = "prompts/goal.md" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), - args: None, - run_id: None, - user_layer: SettingsLayer::default(), + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); @@ -733,12 +729,12 @@ file = "prompts/goal.md" .unwrap(); let built = build_run_manifest(ManifestBuildInput { - workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), - cwd: project.to_path_buf(), - args_layer: SettingsLayer::default(), - args: None, - run_id: None, - user_layer: SettingsLayer::default(), + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), user_settings_path: None, }) .unwrap(); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index b6d0c9b44..9cc115c46 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -22,26 +22,25 @@ use tokio_util::io::ReaderStream; use crate::args::ServerTargetArgs; use crate::commands::server::start; -use crate::sse; -use crate::user_config; use crate::user_config::cli_http_client_builder; +use crate::{sse, user_config}; #[derive(Clone)] pub(crate) struct ServerStoreClient { - client: fabro_api::Client, + client: fabro_api::Client, http_client: reqwest::Client, - base_url: String, + base_url: String, } #[derive(Debug, Clone)] struct LocalServerRuntime { active_config_path: PathBuf, - storage_dir: PathBuf, + storage_dir: PathBuf, } pub(crate) struct RunAttachEventStream { - stream: progenitor_client::ByteStream, - pending_bytes: Vec, + stream: progenitor_client::ByteStream, + pending_bytes: Vec, buffered_events: VecDeque, } @@ -106,7 +105,7 @@ pub(crate) async fn connect_server_with_settings( let target = user_config::resolve_server_target(args, settings)?; let runtime = LocalServerRuntime { active_config_path: base_config_path.to_path_buf(), - storage_dir: user_config::storage_dir(settings)?, + storage_dir: user_config::storage_dir(settings)?, }; connect_target_api_client_bundle(&target, &runtime).await } @@ -237,14 +236,14 @@ struct ArtifactBatchUploadManifest { #[derive(Debug, Serialize)] struct ArtifactBatchUploadEntry { - part: String, - path: String, + part: String, + path: String, #[serde(skip_serializing_if = "Option::is_none")] - sha256: Option, + sha256: Option, #[serde(skip_serializing_if = "Option::is_none")] expected_bytes: Option, #[serde(skip_serializing_if = "Option::is_none")] - content_type: Option, + content_type: Option, } impl ServerStoreClient { @@ -661,11 +660,11 @@ impl ServerStoreClient { .len(); manifest_entries.push(ArtifactBatchUploadEntry { - part: part_name.clone(), - path: artifact.path.clone(), - sha256: Some(artifact.content_sha256.clone()), + part: part_name.clone(), + path: artifact.path.clone(), + sha256: Some(artifact.content_sha256.clone()), expected_bytes: Some(artifact.bytes), - content_type: Some(artifact.mime.clone()), + content_type: Some(artifact.mime.clone()), }); file_parts.push(( diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index c1c67b75a..8020f7882 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -1,8 +1,7 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::collections::HashMap; - use anyhow::{Result, bail}; use chrono::{DateTime, Utc}; use fabro_store::RunSummary; @@ -12,9 +11,9 @@ use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, scratch_ba use crate::server_client::{self, ServerStoreClient}; pub(crate) struct ServerRunLookup { - client: ServerStoreClient, + client: ServerStoreClient, scratch_base: PathBuf, - summaries: Vec, + summaries: Vec, } impl ServerRunLookup { @@ -106,7 +105,7 @@ impl ServerRunSummaryInfo { pub(crate) struct ServerSummaryLookup { client: Arc, - runs: Vec, + runs: Vec, } impl ServerSummaryLookup { diff --git a/lib/crates/fabro-cli/src/shared/openai_jwt.rs b/lib/crates/fabro-cli/src/shared/openai_jwt.rs index 712fbf7fd..e15665550 100644 --- a/lib/crates/fabro-cli/src/shared/openai_jwt.rs +++ b/lib/crates/fabro-cli/src/shared/openai_jwt.rs @@ -11,9 +11,9 @@ struct JwtPayload { #[serde(default)] chatgpt_account_id: Option, #[serde(default, rename = "https://api.openai.com/auth")] - auth_claim: Option, + auth_claim: Option, #[serde(default)] - organizations: Option>, + organizations: Option>, } #[derive(Deserialize)] diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index e470600e7..50815b28b 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -6,8 +6,7 @@ use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, Password}; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate}; -use fabro_model::Catalog; -use fabro_model::Provider; +use fabro_model::{Catalog, Provider}; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use tokio::time::timeout; diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index a97fc50fb..8936a4756 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -1,6 +1,6 @@ -use std::path::Path; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::time::Duration; -use std::{io::Write, path::PathBuf}; use cli_table::Color; use fabro_util::terminal::Styles; diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs index a3f6f60c7..94112208b 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs @@ -1,4 +1,5 @@ use std::process::{Child, Command}; + use tracing::{debug, warn}; pub(crate) struct LinuxSleepInhibitor { diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 96beffe94..317a9b332 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,8 +1,7 @@ use std::path::{Path, PathBuf}; -pub(crate) use fabro_config::user::*; - use anyhow::{Result, bail}; +pub(crate) use fabro_config::user::*; use fabro_types::settings::cli::CliTargetSettings; use fabro_types::settings::{CliSettings, SettingsLayer}; use fabro_util::version::FABRO_VERSION; @@ -13,8 +12,8 @@ use tracing::debug; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub(crate) struct ClientTlsSettings { pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, } use crate::args::ServerTargetArgs; @@ -86,7 +85,7 @@ pub(crate) fn apply_storage_dir_override( pub(crate) enum ServerTarget { HttpUrl { api_url: String, - tls: Option, + tls: Option, }, UnixSocket(PathBuf), } @@ -100,8 +99,8 @@ fn cli_target_from_settings(settings: &CliSettings) -> Option<(String, Option { let tls_settings = tls.as_ref().map(|tls| ClientTlsSettings { cert: PathBuf::from(tls.cert.as_source()), - key: PathBuf::from(tls.key.as_source()), - ca: PathBuf::from(tls.ca.as_source()), + key: PathBuf::from(tls.key.as_source()), + ca: PathBuf::from(tls.ca.as_source()), }); Some((url.as_source(), tls_settings)) } @@ -231,9 +230,10 @@ pub(crate) fn build_server_client( #[cfg(test)] mod tests { + use fabro_config::parse_settings_layer; + use super::*; use crate::args::ServerTargetArgs; - use fabro_config::parse_settings_layer; fn server_target_args(value: Option<&str>) -> ServerTargetArgs { ServerTargetArgs { @@ -265,7 +265,7 @@ mod tests { .unwrap(), Some(ServerTarget::HttpUrl { api_url: "https://cli.example.com".to_string(), - tls: None, + tls: None, }) ); } @@ -311,7 +311,7 @@ url = "https://config.example.com" resolve_server_target(&server_target_args(None), &settings).unwrap(), ServerTarget::HttpUrl { api_url: "https://config.example.com".to_string(), - tls: None, + tls: None, } ); } @@ -335,7 +335,7 @@ url = "https://config.example.com" .unwrap(), ServerTarget::HttpUrl { api_url: "https://cli.example.com".to_string(), - tls: None, + tls: None, } ); } @@ -368,7 +368,7 @@ url = "https://config.example.com" .unwrap(), ServerTarget::HttpUrl { api_url: "https://cli.example.com".to_string(), - tls: None, + tls: None, } ); } @@ -377,8 +377,8 @@ url = "https://config.example.com" fn remote_target_uses_tls_from_config() { let expected_tls = ClientTlsSettings { cert: PathBuf::from("cert.pem"), - key: PathBuf::from("key.pem"), - ca: PathBuf::from("ca.pem"), + key: PathBuf::from("key.pem"), + ca: PathBuf::from("ca.pem"), }; let settings = parse_v2( r#" @@ -402,7 +402,7 @@ ca = "ca.pem" .unwrap(), Some(ServerTarget::HttpUrl { api_url: "https://cli.example.com".to_string(), - tls: Some(expected_tls), + tls: Some(expected_tls), }) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 33865ca16..ee65af4f4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -7,11 +7,10 @@ use std::time::{Duration, Instant}; use fabro_test::{apply_filters, fabro_snapshot, test_context}; use serde_json::Value; -use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id}; - use super::support::{ output_stdout, resolve_run, server_target, wait_for_status, write_gated_workflow, }; +use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id}; const SHARED_DAEMON_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 36cda86c6..b5b9b1ac4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -287,8 +287,8 @@ shared = "run" project } -/// Set up an external workflow fixture with a custom storage_dir in settings.toml. -/// Returns (project_tempdir, storage_dir_path). +/// Set up an external workflow fixture with a custom storage_dir in +/// settings.toml. Returns (project_tempdir, storage_dir_path). fn setup_external_workflow_fixture( context: &mut fabro_test::TestContext, ) -> (tempfile::TempDir, PathBuf) { @@ -436,10 +436,10 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { // checkpoint.exclude_globs is a security/policy list: replace by default. let checkpoint = run_checkpoint(&cfg); - assert_eq!( - checkpoint.exclude_globs, - vec!["run-only".to_string(), "shared".to_string()] - ); + assert_eq!(checkpoint.exclude_globs, vec![ + "run-only".to_string(), + "shared".to_string() + ]); // Hooks: id-based replacement. The "shared" hook appears in both cli and // workflow layers and resolves to the workflow entry; project and run-only @@ -529,10 +529,9 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { assert!(auto_approve_enabled(&cfg)); // v2 R30: run.prepare.steps replaces the whole ordered list across layers. // The highest-precedence layer (workflow) wins. - assert_eq!( - run_prepare_commands(&cfg), - vec!["workflow-setup".to_string()] - ); + assert_eq!(run_prepare_commands(&cfg), vec![ + "workflow-setup".to_string() + ]); assert_eq!(run_sandbox(&cfg).preserve, Some(true)); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index ff7cb4c54..7441032cc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -1,12 +1,10 @@ +use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use insta::assert_snapshot; use serde_json::json; -use fabro_test::{fabro_snapshot, test_context}; - -use crate::support::{fabro_json_snapshot, unique_run_id}; - use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state}; +use crate::support::{fabro_json_snapshot, unique_run_id}; fn resolved_run( settings: &fabro_types::settings::SettingsLayer, diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index c11080b59..162bdf6a5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -1,6 +1,5 @@ -use insta::assert_snapshot; - use fabro_test::{fabro_snapshot, run_and_format, test_context}; +use insta::assert_snapshot; use super::support::{ git_filters, git_show_json, git_stdout, metadata_run_ids, run_branch_commits, @@ -85,10 +84,10 @@ fn fork_latest_prints_new_run_and_resume_hint() { ); let new_run_id = &new_run_ids[0]; - let new_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{new_run_id}")], - ); + let new_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{new_run_id}"), + ]); let expected_head = run_branch_commits(&setup.repo_dir, &setup.run.run_id) .into_iter() .last() @@ -129,10 +128,10 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() { ); let new_run_id = &new_run_ids[0]; - let new_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{new_run_id}")], - ); + let new_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{new_run_id}"), + ]); assert_eq!(new_head.trim(), expected_head); let checkpoint = git_show_json( diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index d86eb8f09..f5c4a2860 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -1,6 +1,5 @@ -use insta::assert_snapshot; - use fabro_test::{fabro_snapshot, test_context}; +use insta::assert_snapshot; use super::support::{ compact_git_inspect, compact_inspect, run_success, setup_completed_fast_dry_run, diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 08749487e..0ec7bfe45 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -91,17 +91,14 @@ fn logs_completed_run_outputs_raw_ndjson() { let events = parse_ndjson(&output.stdout); assert_events_belong_to_run(&events, &run.run_id); - assert_event_sequence_contains( - &events, - &[ - "run.created", - "run.running", - "stage.started", - "stage.completed", - "run.completed", - "sandbox.cleanup.completed", - ], - ); + assert_event_sequence_contains(&events, &[ + "run.created", + "run.running", + "stage.started", + "stage.completed", + "run.completed", + "sandbox.cleanup.completed", + ]); } #[test] @@ -227,15 +224,12 @@ fn logs_follow_detached_run_streams_until_completion() { let events = parse_ndjson(&output.stdout); assert_events_belong_to_run(&events, &run.run_id); - assert_event_sequence_contains( - &events, - &[ - "run.created", - "run.running", - "stage.started", - "stage.completed", - "run.completed", - "sandbox.cleanup.completed", - ], - ); + assert_event_sequence_contains(&events, &[ + "run.created", + "run.running", + "stage.started", + "stage.completed", + "run.completed", + "sandbox.cleanup.completed", + ]); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index 49fe73cf4..6a6e56753 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -98,14 +98,14 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() { tool_call_id: None, actor: None, body: EventBody::PullRequestCreated(PullRequestCreatedProps { - pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), - pr_number: 123, - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), + pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), + pr_number: 123, + owner: "fabro-sh".to_string(), + repo: "fabro".to_string(), base_branch: "main".to_string(), head_branch: "fabro/run/demo".to_string(), - title: "Map the constellations".to_string(), - draft: false, + title: "Map the constellations".to_string(), + draft: false, }), }; client diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index da25a0145..9e9fd102f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -2,9 +2,8 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::Value; -use crate::support::unique_run_id; - use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run}; +use crate::support::unique_run_id; #[test] fn help() { diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index 5857e5638..0742d425f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -1,6 +1,5 @@ -use insta::assert_snapshot; - use fabro_test::{fabro_snapshot, test_context}; +use insta::assert_snapshot; #[test] fn help() { diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index aad477a14..5a1a940dc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -69,10 +69,10 @@ fn resume_rewound_run_succeeds() { String::from_utf8_lossy(&rewind.stdout), output_stderr(&rewind) ); - let rewound_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{}", setup.run.run_id)], - ); + let rewound_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{}", setup.run.run_id), + ]); let mut resume_cmd = context.command(); resume_cmd.current_dir(&setup.repo_dir); @@ -109,10 +109,10 @@ fn resume_rewound_run_succeeds() { std::fs::read_to_string(setup.repo_dir.join("story.txt")).unwrap(), "line 1\n" ); - let resumed_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{}", setup.run.run_id)], - ); + let resumed_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{}", setup.run.run_id), + ]); assert_ne!(resumed_head.trim(), rewound_head.trim()); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 54b8dc71b..3122c81e7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -1,6 +1,5 @@ -use insta::assert_snapshot; - use fabro_test::{fabro_snapshot, run_and_format, test_context}; +use insta::assert_snapshot; use super::support::{ git_filters, git_stdout, output_stderr as support_stderr, run_branch_commits_since_base, @@ -100,10 +99,10 @@ fn rewind_target_updates_metadata_and_resume_hint() { "); assert!(output.status.success(), "rewind should succeed"); - let run_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{}", setup.run.run_id)], - ); + let run_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{}", setup.run.run_id), + ]); assert_eq!(run_head.trim(), expected_run_head); let mut list_cmd = context.command(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index c502b9fd7..3b9a663d5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -2,11 +2,10 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::Value; -use crate::support::unique_run_id; - use super::support::{ setup_completed_fast_dry_run, setup_created_fast_dry_run, setup_local_sandbox_run, }; +use crate::support::unique_run_id; #[test] fn help() { diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index c4c344186..da04149fa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -1,9 +1,10 @@ -use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; -use fabro_test::{fabro_snapshot, test_context}; use std::process::Stdio; use std::sync::{Arc, Barrier}; use std::time::{Duration, Instant}; +use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; +use fabro_test::{fabro_snapshot, test_context}; + fn isolated_storage_dir() -> tempfile::TempDir { let root = tempfile::tempdir_in("/tmp").unwrap(); std::fs::create_dir_all(root.path().join("storage")).unwrap(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/crates/fabro-cli/tests/it/cmd/start.rs index a988bef12..f9c4adb4a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/start.rs @@ -1,8 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; -use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id}; - use super::support::{output_stdout, resolve_run, wait_for_status, write_gated_workflow}; +use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id}; const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs index 8fbebe8ec..ff777d16f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs @@ -1,10 +1,11 @@ -use super::support::setup_completed_dry_run; -use insta::assert_snapshot; use std::fs; use std::time::Duration; -use crate::support::unique_run_id; use fabro_test::{fabro_snapshot, test_context}; +use insta::assert_snapshot; + +use super::support::setup_completed_dry_run; +use crate::support::unique_run_id; #[test] fn help() { diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index ba07e410e..d214351c3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -9,7 +9,6 @@ use std::path::{Path, PathBuf}; use std::process::Output; use std::time::{Duration, Instant}; -use crate::support::unique_run_id; use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_store::EventEnvelope; @@ -18,6 +17,8 @@ use fabro_types::RunId; use serde_json::Value; use shlex::try_quote; +use crate::support::unique_run_id; + const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) use fabro_store::RunProjection; @@ -30,23 +31,23 @@ struct RunSummaryRecord { } pub(crate) struct RunSetup { - pub(crate) run_id: String, + pub(crate) run_id: String, pub(crate) run_dir: PathBuf, } pub(crate) struct GitRunSetup { - pub(crate) run: RunSetup, + pub(crate) run: RunSetup, pub(crate) repo_dir: PathBuf, pub(crate) base_sha: String, } pub(crate) struct ProjectFixture { pub(crate) project_dir: PathBuf, - pub(crate) fabro_root: PathBuf, + pub(crate) fabro_root: PathBuf, } pub(crate) struct WorkspaceRunSetup { - pub(crate) run: RunSetup, + pub(crate) run: RunSetup, pub(crate) workspace_dir: PathBuf, } @@ -147,10 +148,10 @@ fn run_completed_dry_run(context: &TestContext, workflow: &Path) -> RunSetup { run_dir: context.find_run_dir(&run_id), run_id, }; - wait_for_event_names( - &run_setup.run_dir, - &["run.completed", "sandbox.cleanup.completed"], - ); + wait_for_event_names(&run_setup.run_dir, &[ + "run.completed", + "sandbox.cleanup.completed", + ]); run_setup } @@ -721,10 +722,11 @@ pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet { } pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec { - git_stdout( - repo_dir, - &["rev-list", "--reverse", &format!("fabro/run/{run_id}")], - ) + git_stdout(repo_dir, &[ + "rev-list", + "--reverse", + &format!("fabro/run/{run_id}"), + ]) .lines() .map(str::trim) .filter(|line| !line.is_empty()) @@ -737,14 +739,11 @@ pub(crate) fn run_branch_commits_since_base( run_id: &str, base_sha: &str, ) -> Vec { - git_stdout( - repo_dir, - &[ - "rev-list", - "--reverse", - &format!("{base_sha}..fabro/run/{run_id}"), - ], - ) + git_stdout(repo_dir, &[ + "rev-list", + "--reverse", + &format!("{base_sha}..fabro/run/{run_id}"), + ]) .lines() .map(str::trim) .filter(|line| !line.is_empty()) @@ -932,11 +931,9 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git git_success(&repo_dir, &["config", "user.email", "test@example.com"]); write_text_file(&repo_dir.join("story.txt"), "line 1\n"); - write_text_file( - &repo_dir.join("flow.fabro"), - match workflow { - GitWorkflowKind::Changed => { - r#"digraph Flow { + write_text_file(&repo_dir.join("flow.fabro"), match workflow { + GitWorkflowKind::Changed => { + r#"digraph Flow { graph [goal="Edit a tracked file"]; start [shape=Mdiamond]; exit [shape=Msquare]; @@ -945,9 +942,9 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git start -> step_one -> step_two -> exit; } "# - } - GitWorkflowKind::Noop => { - r#"digraph Flow { + } + GitWorkflowKind::Noop => { + r#"digraph Flow { graph [goal="Leave tracked files unchanged"]; start [shape=Mdiamond]; exit [shape=Msquare]; @@ -955,9 +952,8 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git start -> check -> exit; } "# - } - }, - ); + } + }); git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]); git_success(&repo_dir, &["commit", "-qm", "init"]); diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs index 1079604b0..027fe40c1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs @@ -1,11 +1,9 @@ +use fabro_test::{fabro_snapshot, test_context}; use insta::assert_snapshot; use serde_json::Value; -use fabro_test::{fabro_snapshot, test_context}; - -use crate::support::fabro_json_snapshot; - use super::support::setup_project_fixture; +use crate::support::fabro_json_snapshot; #[test] fn help() { diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/crates/fabro-cli/tests/it/scenario/mod.rs index 611804b48..66da396dd 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/mod.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/mod.rs @@ -10,9 +10,10 @@ mod smoke; use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::cmd::support::RunProjection; use fabro_config::Storage; use fabro_server::bind::Bind; + +use crate::cmd::support::RunProjection; pub(super) fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/it/workflow/fixtures") diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 5ada38260..d81c80ee9 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -13,13 +13,14 @@ mod real_cli; use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::cmd::support::RunProjection; use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_store::EventEnvelope; use fabro_test::TestContext; use serde_json::Value; +use crate::cmd::support::RunProjection; + pub(super) fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/it/workflow/fixtures") diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 6a8136f99..b32baad84 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -1,4 +1,5 @@ -//! Effective settings resolution: combine layers into one resolved [`SettingsLayer`]. +//! Effective settings resolution: combine layers into one resolved +//! [`SettingsLayer`]. //! //! Shared layered domains (`project`, `workflow`, `run`, `features`) merge //! across all three config files (settings.toml, fabro.toml, workflow.toml). @@ -22,10 +23,10 @@ pub enum EffectiveSettingsMode { #[derive(Clone, Debug, Default)] pub struct EffectiveSettingsLayers { - pub args: SettingsLayer, + pub args: SettingsLayer, pub workflow: SettingsLayer, - pub project: SettingsLayer, - pub user: SettingsLayer, + pub project: SettingsLayer, + pub user: SettingsLayer, } impl EffectiveSettingsLayers { @@ -180,13 +181,12 @@ fn apply_local_daemon_overrides( #[cfg(test)] mod tests { - use crate::parse::parse_settings_layer; - use fabro_types::settings::InterpString; - use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::RunGoalLayer; use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer}; + use fabro_types::settings::{InterpString, SettingsLayer}; use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings}; + use crate::parse::parse_settings_layer; fn layer(source: &str) -> SettingsLayer { parse_settings_layer(source).expect("v2 fixture should parse") diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 3ec7574c8..862c363fa 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -12,6 +12,9 @@ pub mod run; pub mod storage; pub mod user; +use std::path::Path; + +use fabro_types::settings::{Settings, SettingsLayer}; pub use fabro_util::path::expand_tilde; pub use home::Home; pub use load::{ @@ -24,12 +27,8 @@ pub use resolve::{ resolve_run_from_file, resolve_server, resolve_server_from_file, resolve_workflow, resolve_workflow_from_file, }; -pub use storage::{RunScratch, ServerState, Storage}; - -use std::path::Path; - -use fabro_types::settings::{Settings, SettingsLayer}; use serde::de::DeserializeOwned; +pub use storage::{RunScratch, ServerState, Storage}; pub fn load_and_resolve( layers: effective_settings::EffectiveSettingsLayers, diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs index 89865da21..9c7dfa0ec 100644 --- a/lib/crates/fabro-config/src/load.rs +++ b/lib/crates/fabro-config/src/load.rs @@ -6,8 +6,7 @@ use fabro_types::settings::{InterpString, SettingsLayer}; use crate::merge::combine_files; use crate::parse::parse_settings_layer; -use crate::project; -use crate::user; +use crate::{project, user}; pub fn load_settings_path(path: &Path) -> anyhow::Result { let content = std::fs::read_to_string(path) diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs index 2afb632f3..9ff89feef 100644 --- a/lib/crates/fabro-config/src/merge.rs +++ b/lib/crates/fabro-config/src/merge.rs @@ -31,12 +31,12 @@ use fabro_types::settings::workflow::WorkflowLayer; #[must_use] pub fn combine_files(lower: SettingsLayer, higher: SettingsLayer) -> SettingsLayer { SettingsLayer { - version: higher.version.or(lower.version), - project: merge_option(lower.project, higher.project, combine_project), + version: higher.version.or(lower.version), + project: merge_option(lower.project, higher.project, combine_project), workflow: merge_option(lower.workflow, higher.workflow, combine_workflow), - run: merge_option(lower.run, higher.run, combine_run), - cli: merge_option(lower.cli, higher.cli, combine_cli), - server: merge_option(lower.server, higher.server, combine_server), + run: merge_option(lower.run, higher.run, combine_run), + cli: merge_option(lower.cli, higher.cli, combine_cli), + server: merge_option(lower.server, higher.server, combine_server), features: replace_if_some(lower.features, higher.features), } } @@ -75,10 +75,10 @@ fn merge_string_map_sticky( fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer { ProjectLayer { - name: higher.name.or(lower.name), + name: higher.name.or(lower.name), description: higher.description.or(lower.description), - directory: higher.directory.or(lower.directory), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), + directory: higher.directory.or(lower.directory), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), } } @@ -86,10 +86,10 @@ fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer { fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer { WorkflowLayer { - name: higher.name.or(lower.name), + name: higher.name.or(lower.name), description: higher.description.or(lower.description), - graph: higher.graph.or(lower.graph), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), + graph: higher.graph.or(lower.graph), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), } } @@ -97,30 +97,30 @@ fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLaye fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer { RunLayer { - goal: higher.goal.or(lower.goal), - working_dir: higher.working_dir.or(lower.working_dir), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), - inputs: higher.inputs.or(lower.inputs), - model: merge_option(lower.model, higher.model, combine_run_model), - git: merge_option(lower.git, higher.git, combine_run_git), - prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare), - execution: merge_option(lower.execution, higher.execution, combine_run_execution), - checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint), - sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox), + goal: higher.goal.or(lower.goal), + working_dir: higher.working_dir.or(lower.working_dir), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), + inputs: higher.inputs.or(lower.inputs), + model: merge_option(lower.model, higher.model, combine_run_model), + git: merge_option(lower.git, higher.git, combine_run_git), + prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare), + execution: merge_option(lower.execution, higher.execution, combine_run_execution), + checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint), + sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox), notifications: combine_notifications(lower.notifications, higher.notifications), - interviews: merge_option(lower.interviews, higher.interviews, combine_interviews), - agent: merge_option(lower.agent, higher.agent, combine_run_agent), - hooks: combine_hooks(lower.hooks, higher.hooks), - scm: merge_option(lower.scm, higher.scm, combine_run_scm), - pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr), - artifacts: replace_if_some(lower.artifacts, higher.artifacts), + interviews: merge_option(lower.interviews, higher.interviews, combine_interviews), + agent: merge_option(lower.agent, higher.agent, combine_run_agent), + hooks: combine_hooks(lower.hooks, higher.hooks), + scm: merge_option(lower.scm, higher.scm, combine_run_scm), + pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr), + artifacts: replace_if_some(lower.artifacts, higher.artifacts), } } fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer { RunModelLayer { - provider: higher.provider.or(lower.provider), - name: higher.name.or(lower.name), + provider: higher.provider.or(lower.provider), + name: higher.name.or(lower.name), fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks), } } @@ -162,7 +162,7 @@ fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer { fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer { GitAuthorLayer { - name: higher.name.or(lower.name), + name: higher.name.or(lower.name), email: higher.email.or(lower.email), } } @@ -174,9 +174,9 @@ fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunP fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer { RunExecutionLayer { - mode: higher.mode.or(lower.mode), + mode: higher.mode.or(lower.mode), approval: higher.approval.or(lower.approval), - retros: higher.retros.or(lower.retros), + retros: higher.retros.or(lower.retros), } } @@ -194,13 +194,13 @@ fn combine_run_checkpoint( fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer { RunSandboxLayer { - provider: higher.provider.or(lower.provider), - preserve: higher.preserve.or(lower.preserve), + provider: higher.provider.or(lower.provider), + preserve: higher.preserve.or(lower.preserve), devcontainer: higher.devcontainer.or(lower.devcontainer), // Sticky merge-by-key for run.sandbox.env per R71. - env: merge_string_map_sticky(lower.env, higher.env), - local: higher.local.or(lower.local), - daytona: merge_option(lower.daytona, higher.daytona, combine_daytona), + env: merge_string_map_sticky(lower.env, higher.env), + local: higher.local.or(lower.local), + daytona: merge_option(lower.daytona, higher.daytona, combine_daytona), } } @@ -208,10 +208,10 @@ fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> D DaytonaSandboxLayer { auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval), // Sticky merge-by-key for provider-native labels per R71. - labels: merge_string_map_sticky(lower.labels, higher.labels), - snapshot: higher.snapshot.or(lower.snapshot), - network: higher.network.or(lower.network), - skip_clone: higher.skip_clone.or(lower.skip_clone), + labels: merge_string_map_sticky(lower.labels, higher.labels), + snapshot: higher.snapshot.or(lower.snapshot), + network: higher.network.or(lower.network), + skip_clone: higher.skip_clone.or(lower.skip_clone), } } @@ -237,12 +237,12 @@ fn combine_notification_route( higher: NotificationRouteLayer, ) -> NotificationRouteLayer { NotificationRouteLayer { - enabled: higher.enabled.or(lower.enabled), + enabled: higher.enabled.or(lower.enabled), provider: higher.provider.or(lower.provider), - events: splice_events(lower.events, higher.events), - slack: higher.slack.or(lower.slack), - discord: higher.discord.or(lower.discord), - teams: higher.teams.or(lower.teams), + events: splice_events(lower.events, higher.events), + slack: higher.slack.or(lower.slack), + discord: higher.discord.or(lower.discord), + teams: higher.teams.or(lower.teams), } } @@ -275,9 +275,9 @@ fn splice_events(lower: Vec, higher: Vec) -> Vec fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer { InterviewsLayer { provider: higher.provider.or(lower.provider), - slack: higher.slack.or(lower.slack), - discord: higher.discord.or(lower.discord), - teams: higher.teams.or(lower.teams), + slack: higher.slack.or(lower.slack), + discord: higher.discord.or(lower.discord), + teams: higher.teams.or(lower.teams), } } @@ -285,7 +285,7 @@ fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLay RunAgentLayer { permissions: higher.permissions.or(lower.permissions), // MCP entries: field-merge per key. Higher replaces lower for same keys. - mcps: merge_string_map_sticky(lower.mcps, higher.mcps), + mcps: merge_string_map_sticky(lower.mcps, higher.mcps), } } @@ -320,18 +320,18 @@ fn combine_hooks(lower: Vec, higher: Vec) -> Vec RunScmLayer { RunScmLayer { - provider: higher.provider.or(lower.provider), - owner: higher.owner.or(lower.owner), + provider: higher.provider.or(lower.provider), + owner: higher.owner.or(lower.owner), repository: higher.repository.or(lower.repository), - github: higher.github.or(lower.github), + github: higher.github.or(lower.github), } } fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer { RunPullRequestLayer { - enabled: higher.enabled.or(lower.enabled), - draft: higher.draft.or(lower.draft), - auto_merge: higher.auto_merge.or(lower.auto_merge), + enabled: higher.enabled.or(lower.enabled), + draft: higher.draft.or(lower.draft), + auto_merge: higher.auto_merge.or(lower.auto_merge), merge_strategy: higher.merge_strategy.or(lower.merge_strategy), } } @@ -340,10 +340,10 @@ fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> Ru fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer { CliLayer { - target: merge_option(lower.target, higher.target, combine_cli_target), - auth: higher.auth.or(lower.auth), - exec: merge_option(lower.exec, higher.exec, combine_cli_exec), - output: higher.output.or(lower.output), + target: merge_option(lower.target, higher.target, combine_cli_target), + auth: higher.auth.or(lower.auth), + exec: merge_option(lower.exec, higher.exec, combine_cli_exec), + output: higher.output.or(lower.output), updates: higher.updates.or(lower.updates), logging: higher.logging.or(lower.logging), } @@ -357,8 +357,8 @@ fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTarg fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer { CliExecLayer { prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep), - model: merge_option(lower.model, higher.model, combine_cli_exec_model), - agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent), + model: merge_option(lower.model, higher.model, combine_cli_exec_model), + agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent), } } @@ -368,7 +368,7 @@ fn combine_cli_exec_model( ) -> CliExecModelLayer { CliExecModelLayer { provider: higher.provider.or(lower.provider), - name: higher.name.or(lower.name), + name: higher.name.or(lower.name), } } @@ -378,7 +378,7 @@ fn combine_cli_exec_agent( ) -> CliExecAgentLayer { CliExecAgentLayer { permissions: higher.permissions.or(lower.permissions), - mcps: merge_string_map_sticky(lower.mcps, higher.mcps), + mcps: merge_string_map_sticky(lower.mcps, higher.mcps), } } @@ -386,15 +386,15 @@ fn combine_cli_exec_agent( fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer { ServerLayer { - listen: merge_option(lower.listen, higher.listen, combine_listen), - api: higher.api.or(lower.api), - web: merge_option(lower.web, higher.web, combine_server_web), - auth: merge_option(lower.auth, higher.auth, combine_server_auth), - storage: merge_option(lower.storage, higher.storage, combine_server_storage), - artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts), - slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb), - scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler), - logging: higher.logging.or(lower.logging), + listen: merge_option(lower.listen, higher.listen, combine_listen), + api: higher.api.or(lower.api), + web: merge_option(lower.web, higher.web, combine_server_web), + auth: merge_option(lower.auth, higher.auth, combine_server_auth), + storage: merge_option(lower.storage, higher.storage, combine_server_storage), + artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts), + slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb), + scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler), + logging: higher.logging.or(lower.logging), integrations: merge_option( lower.integrations, higher.integrations, @@ -411,7 +411,7 @@ fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> Serve fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer { ServerWebLayer { enabled: higher.enabled.or(lower.enabled), - url: higher.url.or(lower.url), + url: higher.url.or(lower.url), } } @@ -437,9 +437,9 @@ fn combine_server_artifacts( ) -> ServerArtifactsLayer { ServerArtifactsLayer { provider: higher.provider.or(lower.provider), - prefix: higher.prefix.or(lower.prefix), - local: higher.local.or(lower.local), - s3: higher.s3.or(lower.s3), + prefix: higher.prefix.or(lower.prefix), + local: higher.local.or(lower.local), + s3: higher.s3.or(lower.s3), } } @@ -448,11 +448,11 @@ fn combine_server_slatedb( higher: ServerSlateDbLayer, ) -> ServerSlateDbLayer { ServerSlateDbLayer { - provider: higher.provider.or(lower.provider), - prefix: higher.prefix.or(lower.prefix), + provider: higher.provider.or(lower.provider), + prefix: higher.prefix.or(lower.prefix), flush_interval: higher.flush_interval.or(lower.flush_interval), - local: higher.local.or(lower.local), - s3: higher.s3.or(lower.s3), + local: higher.local.or(lower.local), + s3: higher.s3.or(lower.s3), } } @@ -470,19 +470,19 @@ fn combine_server_integrations( higher: ServerIntegrationsLayer, ) -> ServerIntegrationsLayer { ServerIntegrationsLayer { - github: higher.github.or(lower.github), - slack: higher.slack.or(lower.slack), + github: higher.github.or(lower.github), + slack: higher.slack.or(lower.slack), discord: higher.discord.or(lower.discord), - teams: higher.teams.or(lower.teams), + teams: higher.teams.or(lower.teams), } } #[cfg(test)] mod tests { - use crate::parse::parse_settings_layer; use fabro_types::settings::InterpString; use super::*; + use crate::parse::parse_settings_layer; fn parse(input: &str) -> SettingsLayer { parse_settings_layer(input).expect("fixture should parse") diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index ea975708b..219c04939 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -66,7 +66,7 @@ pub fn parse_settings_layer(input: &str) -> Result { for key in table.keys() { if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) { return Err(ParseError::UnknownTopLevelKey { - key: key.clone(), + key: key.clone(), hint: rename_hint(key), }); } diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 16963cbfc..af03ec584 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -8,22 +8,21 @@ use std::fmt::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, bail}; +use fabro_types::settings::SettingsLayer; use serde::Serialize; use crate::load::load_settings_path; use crate::parse::parse_settings_layer; -use crate::run; -use crate::{resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file}; -use fabro_types::settings::SettingsLayer; +use crate::{resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file, run}; const CONFIG_FILENAME: &str = "fabro.toml"; #[derive(Clone, Debug)] pub struct WorkflowPathResolution { pub resolved_workflow_path: PathBuf, - pub dot_path: PathBuf, - pub workflow_config: Option, - pub workflow_toml_path: Option, - pub workflow_slug: Option, + pub dot_path: PathBuf, + pub workflow_config: Option, + pub workflow_toml_path: Option, + pub workflow_slug: Option, } /// Parse a project config from a TOML string. @@ -226,8 +225,8 @@ fn user_workflows_dir() -> PathBuf { /// Metadata about a discovered workflow. #[derive(Clone, Debug, Serialize)] pub struct WorkflowInfo { - pub name: String, - pub goal: Option, + pub name: String, + pub goal: Option, pub source: WorkflowSource, } @@ -239,7 +238,8 @@ pub enum WorkflowSource { User, } -/// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`. +/// List workflow names in a single directory by scanning for subdirs containing +/// `workflow.toml`. fn list_workflows_in(workflows_dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(workflows_dir) else { return Vec::new(); @@ -257,7 +257,8 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec { .collect() } -/// Read the `run.goal` field from a `workflow.toml` without full config validation. +/// Read the `run.goal` field from a `workflow.toml` without full config +/// validation. fn read_workflow_goal(workflow_toml: &Path) -> Option { let content = std::fs::read_to_string(workflow_toml).ok()?; let table: toml::Table = content.parse().ok()?; @@ -269,7 +270,8 @@ fn read_workflow_goal(workflow_toml: &Path) -> Option { .map(String::from) } -/// List workflows with metadata by scanning project and user workflow directories. +/// List workflows with metadata by scanning project and user workflow +/// directories. pub fn list_workflows_detailed( project_workflows_dir: Option<&Path>, user_workflows_dir: Option<&Path>, @@ -328,7 +330,8 @@ pub fn list_available_workflows( names } -/// Find the closest match using normalized Levenshtein distance (threshold: 0.5). +/// Find the closest match using normalized Levenshtein distance (threshold: +/// 0.5). fn find_closest_match(input: &str, candidates: &[String]) -> Option { candidates .iter() @@ -375,10 +378,12 @@ pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf #[cfg(test)] mod tests { - use super::*; use std::fs; + use tempfile::TempDir; + use super::*; + #[test] fn parse_minimal_config() { let config = parse_project_config("_version = 1\n").unwrap(); diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/crates/fabro-config/src/resolve/cli.rs index b2ba0decf..f588c4fb3 100644 --- a/lib/crates/fabro-config/src/resolve/cli.rs +++ b/lib/crates/fabro-config/src/resolve/cli.rs @@ -8,13 +8,13 @@ use super::{ResolveError, require_interp}; pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliSettings { CliSettings { - target: resolve_target(layer.target.as_ref(), errors), - auth: CliAuthSettings { + target: resolve_target(layer.target.as_ref(), errors), + auth: CliAuthSettings { strategy: layer.auth.as_ref().and_then(|auth| auth.strategy), }, - exec: resolve_exec(layer.exec.as_ref()), - output: CliOutputSettings { - format: layer + exec: resolve_exec(layer.exec.as_ref()), + output: CliOutputSettings { + format: layer .output .as_ref() .and_then(|output| output.format) @@ -50,8 +50,8 @@ fn resolve_target( url: require_interp(url.as_ref(), "cli.target.url", errors), tls: tls.as_ref().map(|tls| CliTargetTlsSettings { cert: require_interp(tls.cert.as_ref(), "cli.target.tls.cert", errors), - key: require_interp(tls.key.as_ref(), "cli.target.tls.key", errors), - ca: require_interp(tls.ca.as_ref(), "cli.target.tls.ca", errors), + key: require_interp(tls.key.as_ref(), "cli.target.tls.key", errors), + ca: require_interp(tls.ca.as_ref(), "cli.target.tls.ca", errors), }), }), Some(CliTargetLayer::Unix { path }) => Some(CliTargetSettings::Unix { @@ -68,13 +68,13 @@ fn resolve_exec(exec: Option<&CliExecLayer>) -> CliExecSettings { CliExecSettings { prevent_idle_sleep: exec.prevent_idle_sleep.unwrap_or(false), - model: CliExecModelSettings { + model: CliExecModelSettings { provider: exec.model.as_ref().and_then(|model| model.provider.clone()), - name: exec.model.as_ref().and_then(|model| model.name.clone()), + name: exec.model.as_ref().and_then(|model| model.name.clone()), }, - agent: CliExecAgentSettings { + agent: CliExecAgentSettings { permissions: exec.agent.as_ref().and_then(|agent| agent.permissions), - mcps: exec + mcps: exec .agent .as_ref() .map(|agent| { diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index fa3587cf4..2698134b5 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -6,13 +6,12 @@ mod run; mod server; mod workflow; +pub use cli::resolve_cli; +pub use error::ResolveError; use fabro_types::settings::{ CliSettings, FeaturesSettings, InterpString, ProjectSettings, RunSettings, ServerSettings, Settings, SettingsLayer, WorkflowSettings, }; - -pub use cli::resolve_cli; -pub use error::ResolveError; pub use features::resolve_features; pub use project::resolve_project; pub use run::resolve_run; @@ -29,11 +28,11 @@ pub fn resolve(file: &SettingsLayer) -> Result> { let features_layer = file.features.clone().unwrap_or_default(); let settings = Settings { - project: resolve_project(&project_layer, &mut errors), + project: resolve_project(&project_layer, &mut errors), workflow: resolve_workflow(&workflow_layer, &mut errors), - run: resolve_run(&run_layer, &mut errors), - cli: resolve_cli(&cli_layer, &mut errors), - server: resolve_server(&server_layer, &mut errors), + run: resolve_run(&run_layer, &mut errors), + cli: resolve_cli(&cli_layer, &mut errors), + server: resolve_server(&server_layer, &mut errors), features: resolve_features(&features_layer, &mut errors), }; @@ -97,7 +96,7 @@ pub(crate) fn parse_socket_addr( Ok(address) => address, Err(err) => { errors.push(ResolveError::ParseFailure { - path: path.to_string(), + path: path.to_string(), reason: err.to_string(), }); std::net::SocketAddr::from(([127, 0, 0, 1], 0)) diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/crates/fabro-config/src/resolve/project.rs index fad600cdd..c8ab5fc23 100644 --- a/lib/crates/fabro-config/src/resolve/project.rs +++ b/lib/crates/fabro-config/src/resolve/project.rs @@ -6,12 +6,12 @@ const DEFAULT_PROJECT_DIRECTORY: &str = "fabro/"; pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> ProjectSettings { ProjectSettings { - name: layer.name.clone(), + name: layer.name.clone(), description: layer.description.clone(), - directory: layer + directory: layer .directory .clone() .unwrap_or_else(|| DEFAULT_PROJECT_DIRECTORY.to_string()), - metadata: layer.metadata.clone(), + metadata: layer.metadata.clone(), } } diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index d228b69fe..ed1c06265 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -17,32 +17,32 @@ use super::ResolveError; pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunSettings { RunSettings { - goal: resolve_goal(layer.goal.as_ref()), - working_dir: layer.working_dir.clone(), - metadata: layer.metadata.clone(), - inputs: layer.inputs.clone().unwrap_or_default(), - model: resolve_model(layer.model.as_ref()), - git: resolve_git(layer.git.as_ref()), - prepare: resolve_prepare(layer.prepare.as_ref(), errors), - execution: resolve_execution(layer.execution.as_ref()), - checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()), - sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors), + goal: resolve_goal(layer.goal.as_ref()), + working_dir: layer.working_dir.clone(), + metadata: layer.metadata.clone(), + inputs: layer.inputs.clone().unwrap_or_default(), + model: resolve_model(layer.model.as_ref()), + git: resolve_git(layer.git.as_ref()), + prepare: resolve_prepare(layer.prepare.as_ref(), errors), + execution: resolve_execution(layer.execution.as_ref()), + checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()), + sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors), notifications: layer .notifications .iter() .map(|(name, route)| (name.clone(), resolve_notification_route(route))) .collect(), - interviews: resolve_interviews(layer.interviews.as_ref()), - agent: resolve_agent(layer.agent.as_ref()), - hooks: layer + interviews: resolve_interviews(layer.interviews.as_ref()), + agent: resolve_agent(layer.agent.as_ref()), + hooks: layer .hooks .iter() .enumerate() .map(|(index, hook)| resolve_hook(hook, index, errors)) .collect(), - scm: resolve_scm(layer.scm.as_ref()), - pull_request: resolve_pull_request(layer.pull_request.as_ref()), - artifacts: resolve_artifacts(layer.artifacts.as_ref()), + scm: resolve_scm(layer.scm.as_ref()), + pull_request: resolve_pull_request(layer.pull_request.as_ref()), + artifacts: resolve_artifacts(layer.artifacts.as_ref()), } } @@ -59,8 +59,8 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings { }; RunModelSettings { - provider: model.provider.clone(), - name: model.name.clone(), + provider: model.provider.clone(), + name: model.name.clone(), fallbacks: model .fallbacks .iter() @@ -76,7 +76,7 @@ fn resolve_git(git: Option<&RunGitLayer>) -> RunGitSettings { RunGitSettings { author: git.and_then(|git| { git.author.as_ref().map(|author| GitAuthorSettings { - name: author.name.clone(), + name: author.name.clone(), email: author.email.clone(), }) }), @@ -102,7 +102,7 @@ fn resolve_prepare( .join(" "), ), (Some(_), Some(_)) | (None, None) => errors.push(ResolveError::Invalid { - path: format!("run.prepare.steps[{index}]"), + path: format!("run.prepare.steps[{index}]"), reason: "exactly one of script or command must be set".to_string(), }), } @@ -122,9 +122,9 @@ fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSetti }; RunExecutionSettings { - mode: execution.mode.unwrap_or(RunMode::Normal), + mode: execution.mode.unwrap_or(RunMode::Normal), approval: execution.approval.unwrap_or(ApprovalMode::Prompt), - retros: execution.retros.unwrap_or(true), + retros: execution.retros.unwrap_or(true), } } @@ -151,7 +151,7 @@ fn resolve_sandbox( match provider.as_str() { "local" | "docker" | "daytona" => {} other => errors.push(ResolveError::Invalid { - path: "run.sandbox.provider".to_string(), + path: "run.sandbox.provider".to_string(), reason: format!("unknown sandbox provider: {other}"), }), } @@ -179,13 +179,13 @@ fn resolve_local_sandbox(sandbox: &RunSandboxLayer) -> LocalSandboxSettings { fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings { DaytonaSettings { auto_stop_interval: daytona.auto_stop_interval, - labels: daytona.labels.clone(), - snapshot: daytona.snapshot.as_ref().and_then(|snapshot| { + labels: daytona.labels.clone(), + snapshot: daytona.snapshot.as_ref().and_then(|snapshot| { snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings { - name: name.clone(), - cpu: snapshot.cpu, - memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())), - disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())), + name: name.clone(), + cpu: snapshot.cpu, + memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())), + disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())), dockerfile: snapshot .dockerfile .as_ref() @@ -199,16 +199,16 @@ fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings { }), }) }), - network: daytona.network.clone(), - skip_clone: daytona.skip_clone.unwrap_or(false), + network: daytona.network.clone(), + skip_clone: daytona.skip_clone.unwrap_or(false), } } fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRouteSettings { NotificationRouteSettings { - enabled: route.enabled.unwrap_or(false), + enabled: route.enabled.unwrap_or(false), provider: route.provider.clone(), - events: route + events: route .events .iter() .filter_map(|event| match event { @@ -216,9 +216,9 @@ fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRou StringOrSplice::Splice => None, }) .collect(), - slack: route.slack.as_ref().map(resolve_notification_provider), - discord: route.discord.as_ref().map(resolve_notification_provider), - teams: route.teams.as_ref().map(resolve_notification_provider), + slack: route.slack.as_ref().map(resolve_notification_provider), + discord: route.discord.as_ref().map(resolve_notification_provider), + teams: route.teams.as_ref().map(resolve_notification_provider), } } @@ -237,9 +237,9 @@ fn resolve_interviews(interviews: Option<&InterviewsLayer>) -> RunInterviewsSett RunInterviewsSettings { provider: interviews.provider.clone(), - slack: interviews.slack.as_ref().map(resolve_interview_provider), - discord: interviews.discord.as_ref().map(resolve_interview_provider), - teams: interviews.teams.as_ref().map(resolve_interview_provider), + slack: interviews.slack.as_ref().map(resolve_interview_provider), + discord: interviews.discord.as_ref().map(resolve_interview_provider), + teams: interviews.teams.as_ref().map(resolve_interview_provider), } } @@ -256,7 +256,7 @@ fn resolve_agent(agent: Option<&RunAgentLayer>) -> RunAgentSettings { RunAgentSettings { permissions: agent.permissions, - mcps: agent + mcps: agent .mcps .iter() .map(|(name, entry)| (name.clone(), resolve_mcp_entry(name, entry))) @@ -273,13 +273,13 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS .. } => McpTransport::Stdio { command: resolve_mcp_command(script.as_ref(), command.as_ref()), - env: env + env: env .iter() .map(|(key, value)| (key.clone(), value.as_source())) .collect(), }, McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { - url: url.as_source(), + url: url.as_source(), headers: headers .iter() .map(|(key, value)| (key.clone(), value.as_source())) @@ -293,8 +293,8 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS .. } => McpTransport::Sandbox { command: resolve_mcp_command(script.as_ref(), command.as_ref()), - port: *port, - env: env + port: *port, + env: env .iter() .map(|(key, value)| (key.clone(), value.as_source())) .collect(), @@ -355,7 +355,7 @@ fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec) if variants != 1 { errors.push(ResolveError::Invalid { - path: format!("run.hooks[{index}]"), + path: format!("run.hooks[{index}]"), reason: "exactly one hook transport must be configured".to_string(), }); } @@ -419,19 +419,19 @@ fn resolve_hook_type(hook: &HookEntry) -> Option { if hook.agent == Some(HookAgentMarker::Enabled) { return Some(HookType::Agent { - prompt: hook + prompt: hook .prompt .as_ref() .map(InterpString::as_source) .unwrap_or_default(), - model: hook.model.as_ref().map(InterpString::as_source), + model: hook.model.as_ref().map(InterpString::as_source), max_tool_rounds: hook.max_tool_rounds, }); } hook.prompt.as_ref().map(|prompt| HookType::Prompt { prompt: prompt.as_source(), - model: hook.model.as_ref().map(InterpString::as_source), + model: hook.model.as_ref().map(InterpString::as_source), }) } @@ -441,10 +441,10 @@ fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings { }; RunScmSettings { - provider: scm.provider.clone(), - owner: scm.owner.clone(), + provider: scm.provider.clone(), + owner: scm.owner.clone(), repository: scm.repository.clone(), - github: scm.github.as_ref().map(|_| ScmGitHubSettings), + github: scm.github.as_ref().map(|_| ScmGitHubSettings), } } @@ -455,9 +455,9 @@ fn resolve_pull_request(pull_request: Option<&RunPullRequestLayer>) -> Option, layer: Option<&ServerWebLayer>) -> ServerWebSettings { ServerWebSettings { enabled: layer.and_then(|web| web.enabled).unwrap_or(true), - url: layer + url: layer .and_then(|web| web.url.clone()) .unwrap_or_else(|| InterpString::parse("http://localhost:3000")), } @@ -125,20 +125,20 @@ fn resolve_auth( let jwt = api.and_then(|api| { api.jwt.as_ref().map(|jwt| ServerAuthApiJwtSettings { - enabled: jwt.enabled.unwrap_or(true), - issuer: jwt.issuer.clone(), + enabled: jwt.enabled.unwrap_or(true), + issuer: jwt.issuer.clone(), audience: jwt.audience.clone(), }) }); let mtls = api.and_then(|api| { api.mtls.as_ref().map(|mtls| ServerAuthApiMtlsSettings { enabled: mtls.enabled.unwrap_or(true), - ca: mtls.ca.clone(), + ca: mtls.ca.clone(), }) }); if mtls.as_ref().is_some_and(|mtls| mtls.enabled) && !valid_tls { errors.push(ResolveError::Invalid { - path: "server.auth.api.mtls".to_string(), + path: "server.auth.api.mtls".to_string(), reason: "requires tcp listen with tls cert, key, and ca configured".to_string(), }); } @@ -149,7 +149,7 @@ fn resolve_auth( allowed_usernames: web .map(|web| web.allowed_usernames.clone()) .unwrap_or_default(), - providers: ServerAuthWebProvidersSettings { + providers: ServerAuthWebProvidersSettings { github: web .and_then(|web| web.providers.as_ref()) .and_then(|providers| providers.github.as_ref()) @@ -161,8 +161,8 @@ fn resolve_auth( fn resolve_web_github(layer: &ServerAuthWebGithubLayer) -> GithubOauthSettings { GithubOauthSettings { - enabled: layer.enabled.unwrap_or(true), - client_id: layer.client_id.clone(), + enabled: layer.enabled.unwrap_or(true), + client_id: layer.client_id.clone(), client_secret: layer.client_secret.clone(), } } @@ -180,7 +180,7 @@ fn resolve_artifacts( prefix: layer .and_then(|artifacts| artifacts.prefix.clone()) .unwrap_or_else(|| InterpString::parse("artifacts")), - store: resolve_object_store( + store: resolve_object_store( provider, layer.and_then(|artifacts| artifacts.local.as_ref()), layer.and_then(|artifacts| artifacts.s3.as_ref()), @@ -201,10 +201,10 @@ fn resolve_slatedb( .unwrap_or(ObjectStoreProvider::Local); ServerSlateDbSettings { - prefix: layer + prefix: layer .and_then(|slatedb| slatedb.prefix.clone()) .unwrap_or_else(|| InterpString::parse("")), - store: resolve_object_store( + store: resolve_object_store( provider, layer.and_then(|slatedb| slatedb.local.as_ref()), layer.and_then(|slatedb| slatedb.s3.as_ref()), @@ -255,15 +255,15 @@ fn resolve_object_store( fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings { ServerIntegrationsSettings { - github: layer + github: layer .and_then(|integrations| integrations.github.as_ref()) .map(|github| GithubIntegrationSettings { - enabled: github.enabled.unwrap_or(true), - app_id: github.app_id.clone(), - client_id: github.client_id.clone(), - slug: github.slug.clone(), + enabled: github.enabled.unwrap_or(true), + app_id: github.app_id.clone(), + client_id: github.client_id.clone(), + slug: github.slug.clone(), permissions: github.permissions.clone(), - webhooks: github + webhooks: github .webhooks .as_ref() .map(|webhooks| IntegrationWebhooksSettings { @@ -271,10 +271,10 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr }), }) .unwrap_or_default(), - slack: layer + slack: layer .and_then(|integrations| integrations.slack.as_ref()) .map(|slack| SlackIntegrationSettings { - enabled: slack.enabled.unwrap_or(true), + enabled: slack.enabled.unwrap_or(true), default_channel: slack.default_channel.clone(), }) .unwrap_or_default(), @@ -284,7 +284,7 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr enabled: discord.enabled.unwrap_or(true), }) .unwrap_or_default(), - teams: layer + teams: layer .and_then(|integrations| integrations.teams.as_ref()) .map(|teams| TeamsIntegrationSettings { enabled: teams.enabled.unwrap_or(true), diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/crates/fabro-config/src/resolve/workflow.rs index 18e0603b7..770c9a385 100644 --- a/lib/crates/fabro-config/src/resolve/workflow.rs +++ b/lib/crates/fabro-config/src/resolve/workflow.rs @@ -9,12 +9,12 @@ pub fn resolve_workflow( _errors: &mut Vec, ) -> WorkflowSettings { WorkflowSettings { - name: layer.name.clone(), + name: layer.name.clone(), description: layer.description.clone(), - graph: layer + graph: layer .graph .clone() .unwrap_or_else(|| DEFAULT_WORKFLOW_GRAPH.to_string()), - metadata: layer.metadata.clone(), + metadata: layer.metadata.clone(), } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 20458c462..89aea255d 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -8,11 +8,11 @@ use std::path::{Path, PathBuf}; use anyhow::Context; +use fabro_types::settings::SettingsLayer; +use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer}; use crate::load::{load_settings_path, resolve_goal_file_path}; use crate::parse::parse_settings_layer; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer}; /// Load and parse a run config from a TOML file. pub fn parse_run_config(contents: &str) -> anyhow::Result { @@ -45,7 +45,7 @@ pub enum ResolveRunGoalError { var: String, }, Io { - path: PathBuf, + path: PathBuf, source: std::io::Error, }, } @@ -83,7 +83,7 @@ pub fn resolve_run_goal( match goal { RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal { - text: text.as_source(), + text: text.as_source(), source: ResolvedGoalSource::Inline, })), RunGoalLayer::File { file } => { @@ -106,9 +106,10 @@ pub fn resolve_run_goal( #[cfg(test)] mod tests { - use super::*; use fabro_types::settings::run::RunGoalLayer; + use super::*; + #[test] fn load_run_config_rewrites_relative_goal_file_path() { let tmp = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/crates/fabro-config/src/storage.rs index a14cf8ba8..8088a4561 100644 --- a/lib/crates/fabro-config/src/storage.rs +++ b/lib/crates/fabro-config/src/storage.rs @@ -133,9 +133,9 @@ impl RunScratch { #[cfg(test)] mod tests { use chrono::Local; + use fabro_types::RunId; use super::{RunScratch, Storage}; - use fabro_types::RunId; #[test] fn storage_accessors_are_relative_to_root() { diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 4cce634c4..da15e9282 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -8,9 +8,10 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use fabro_types::settings::SettingsLayer; + use crate::home::Home; use crate::load::load_settings_path; -use fabro_types::settings::SettingsLayer; pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml"; pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml"; diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs index 6928c838a..9f4c05a65 100644 --- a/lib/crates/fabro-config/tests/resolve_cli.rs +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -1,5 +1,4 @@ -use fabro_config::parse_settings_layer; -use fabro_config::resolve_cli_from_file; +use fabro_config::{parse_settings_layer, resolve_cli_from_file}; use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::AgentPermissions; use fabro_types::settings::{InterpString, SettingsLayer}; diff --git a/lib/crates/fabro-config/tests/resolve_features.rs b/lib/crates/fabro-config/tests/resolve_features.rs index 6d7be95ee..59c6a1740 100644 --- a/lib/crates/fabro-config/tests/resolve_features.rs +++ b/lib/crates/fabro-config/tests/resolve_features.rs @@ -1,5 +1,4 @@ -use fabro_config::parse_settings_layer; -use fabro_config::resolve_features_from_file; +use fabro_config::{parse_settings_layer, resolve_features_from_file}; use fabro_types::settings::SettingsLayer; #[test] diff --git a/lib/crates/fabro-config/tests/resolve_project.rs b/lib/crates/fabro-config/tests/resolve_project.rs index 69b69ebaf..7c79646d5 100644 --- a/lib/crates/fabro-config/tests/resolve_project.rs +++ b/lib/crates/fabro-config/tests/resolve_project.rs @@ -1,5 +1,4 @@ -use fabro_config::parse_settings_layer; -use fabro_config::resolve_project_from_file; +use fabro_config::{parse_settings_layer, resolve_project_from_file}; use fabro_types::settings::SettingsLayer; #[test] diff --git a/lib/crates/fabro-config/tests/resolve_workflow.rs b/lib/crates/fabro-config/tests/resolve_workflow.rs index 272fe2f60..c5746caab 100644 --- a/lib/crates/fabro-config/tests/resolve_workflow.rs +++ b/lib/crates/fabro-config/tests/resolve_workflow.rs @@ -1,5 +1,4 @@ -use fabro_config::parse_settings_layer; -use fabro_config::resolve_workflow_from_file; +use fabro_config::{parse_settings_layer, resolve_workflow_from_file}; use fabro_types::settings::SettingsLayer; #[test] diff --git a/lib/crates/fabro-core/src/context.rs b/lib/crates/fabro-core/src/context.rs index ce75b0a90..0dc17b635 100644 --- a/lib/crates/fabro-core/src/context.rs +++ b/lib/crates/fabro-core/src/context.rs @@ -45,7 +45,8 @@ impl Context { } /// Deep copy for parallel branch isolation. - /// `.clone()` shares state (Arc clone); `.fork()` creates an independent copy. + /// `.clone()` shares state (Arc clone); `.fork()` creates an independent + /// copy. #[must_use] pub fn fork(&self) -> Self { Self { @@ -71,9 +72,10 @@ impl Context { #[cfg(test)] mod tests { - use super::*; use serde_json::json; + use super::*; + #[test] fn context_set_and_get() { let ctx = Context::new(); diff --git a/lib/crates/fabro-core/src/error.rs b/lib/crates/fabro-core/src/error.rs index 21ed4f286..c45bafc01 100644 --- a/lib/crates/fabro-core/src/error.rs +++ b/lib/crates/fabro-core/src/error.rs @@ -18,12 +18,13 @@ impl fmt::Display for VisitLimitSource { } /// Structured failure data on handler errors. Maps to FabroError's -/// is_retryable(), failure_class(), failure_signature_hint(), to_fail_outcome(). +/// is_retryable(), failure_class(), failure_signature_hint(), +/// to_fail_outcome(). #[derive(Debug, Clone)] pub struct HandlerErrorDetail { - pub message: String, + pub message: String, pub retryable: bool, - pub category: Option, + pub category: Option, pub signature: Option, } @@ -47,9 +48,9 @@ pub enum CoreError { "node \"{node_id}\" visited {visits} times ({limit_source} limit {limit}); run is stuck in a cycle" )] VisitLimitExceeded { - node_id: String, - visits: usize, - limit: usize, + node_id: String, + visits: usize, + limit: usize, limit_source: VisitLimitSource, }, #[error("stall timeout on node \"{node_id}\"")] @@ -80,8 +81,8 @@ impl CoreError { Self::Handler { detail } => Outcome { status: StageStatus::Fail, failure: Some(FailureDetail { - message: detail.message.clone(), - category: detail.category.unwrap_or(FailureCategory::Deterministic), + message: detail.message.clone(), + category: detail.category.unwrap_or(FailureCategory::Deterministic), signature: detail.signature.clone(), }), ..Outcome::default() @@ -110,16 +111,16 @@ mod tests { assert_eq!(CoreError::Cancelled.to_string(), "run cancelled"); assert_eq!( CoreError::Blocked { - message: "hook denied".into() + message: "hook denied".into(), } .to_string(), "blocked: hook denied" ); assert_eq!( CoreError::VisitLimitExceeded { - node_id: "n1".into(), - visits: 5, - limit: 3, + node_id: "n1".into(), + visits: 5, + limit: 3, limit_source: VisitLimitSource::Node, } .to_string(), @@ -127,7 +128,7 @@ mod tests { ); assert_eq!( CoreError::StallTimeout { - node_id: "work".into() + node_id: "work".into(), } .to_string(), "stall timeout on node \"work\"" @@ -141,17 +142,17 @@ mod tests { #[test] fn core_error_handler_is_retryable() { let retryable = CoreError::handler(HandlerErrorDetail { - message: "timeout".into(), + message: "timeout".into(), retryable: true, - category: None, + category: None, signature: None, }); assert!(retryable.is_retryable()); let not_retryable = CoreError::handler(HandlerErrorDetail { - message: "bad input".into(), + message: "bad input".into(), retryable: false, - category: None, + category: None, signature: None, }); assert!(!not_retryable.is_retryable()); @@ -161,9 +162,9 @@ mod tests { fn core_error_handler_to_fail_outcome() { use crate::outcome::FailureCategory; let err = CoreError::handler(HandlerErrorDetail { - message: "api down".into(), + message: "api down".into(), retryable: true, - category: Some(FailureCategory::TransientInfra), + category: Some(FailureCategory::TransientInfra), signature: Some("sig123".into()), }); let outcome: Outcome = err.to_fail_outcome(); @@ -181,7 +182,7 @@ mod tests { assert!(!CoreError::NoStartNode.is_retryable()); assert!( !CoreError::Blocked { - message: "no".into() + message: "no".into(), } .is_retryable() ); diff --git a/lib/crates/fabro-core/src/executor.rs b/lib/crates/fabro-core/src/executor.rs index 005eb4979..53e736525 100644 --- a/lib/crates/fabro-core/src/executor.rs +++ b/lib/crates/fabro-core/src/executor.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; +use tokio::time::sleep; use tokio_util::sync::CancellationToken; use crate::context::Context; @@ -14,19 +15,18 @@ use crate::lifecycle::{ }; use crate::outcome::{NodeResult, NodeResultExt, Outcome, StageStatus}; use crate::state::ExecutionState; -use tokio::time::sleep; #[derive(Default)] pub struct ExecutorOptions { - pub cancel_token: Option>, - pub stall_token: Option, + pub cancel_token: Option>, + pub stall_token: Option, pub max_node_visits: Option, } pub struct Executor { - handler: Arc>, + handler: Arc>, lifecycle: Box>, - options: ExecutorOptions, + options: ExecutorOptions, } enum NextStep { @@ -37,9 +37,9 @@ enum NextStep { } pub struct ExecutorBuilder { - handler: Arc>, + handler: Arc>, lifecycle: Option>>, - options: ExecutorOptions, + options: ExecutorOptions, } impl ExecutorBuilder { @@ -77,9 +77,9 @@ impl ExecutorBuilder { pub fn build(self) -> Executor { Executor { - handler: self.handler, + handler: self.handler, lifecycle: self.lifecycle.unwrap_or_else(|| Box::new(NoopLifecycle)), - options: self.options, + options: self.options, } } } @@ -109,7 +109,8 @@ impl Executor { id: state.current_node_id.clone(), })?; - // Terminal nodes: skip normal lifecycle, check goal gates, call on_terminal_reached + // Terminal nodes: skip normal lifecycle, check goal gates, call + // on_terminal_reached if node.is_terminal() { match graph.check_goal_gates(&state.node_outcomes) { Ok(()) => { @@ -433,6 +434,7 @@ mod tests { use std::time::Duration; use async_trait::async_trait; + use tokio::time::{self, Instant}; use super::*; use crate::context::Context; @@ -440,7 +442,6 @@ mod tests { use crate::lifecycle::RunLifecycle; use crate::retry::{BackoffPolicy, RetryPolicy}; use crate::test_fixtures::*; - use tokio::time::{self, Instant}; type NextNodeLog = Arc)>>>; @@ -969,26 +970,26 @@ mod tests { let handler = Arc::new( CountingHandler::new(vec![ Err(CoreError::handler(HandlerErrorDetail { - message: "fail1".into(), + message: "fail1".into(), retryable: true, - category: None, + category: None, signature: None, })), Err(CoreError::handler(HandlerErrorDetail { - message: "fail2".into(), + message: "fail2".into(), retryable: true, - category: None, + category: None, signature: None, })), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, }), ); @@ -1018,11 +1019,11 @@ mod tests { ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, }), ); @@ -1040,9 +1041,9 @@ mod tests { async fn executor_retry_non_retryable_error_no_retry() { let handler = Arc::new( CountingHandler::new(vec![Err(CoreError::handler(HandlerErrorDetail { - message: "fatal".into(), + message: "fatal".into(), retryable: false, - category: None, + category: None, signature: None, }))]) .with_retry_policy(RetryPolicy::with_max_attempts(3)), @@ -1052,7 +1053,8 @@ mod tests { handler.clone() as Arc>, ) .await; - // Non-retryable errors become fail outcomes, routing continues through the linear graph + // Non-retryable errors become fail outcomes, routing continues through the + // linear graph assert!(result.is_ok()); assert_eq!(handler.calls(), 1); } @@ -1062,9 +1064,9 @@ mod tests { // Default policy is RetryPolicy::none() (max_attempts=1) let handler = Arc::new(CountingHandler::new(vec![Err(CoreError::handler( HandlerErrorDetail { - message: "fail".into(), + message: "fail".into(), retryable: true, - category: None, + category: None, signature: None, }, ))])); @@ -1097,11 +1099,11 @@ mod tests { fn retry_policy(&self, _n: &TestNode, _g: &TestGraph) -> RetryPolicy { RetryPolicy { max_attempts: 2, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, } } @@ -1141,20 +1143,20 @@ mod tests { let handler = Arc::new( CountingHandler::new(vec![ Err(CoreError::handler(HandlerErrorDetail { - message: "r".into(), + message: "r".into(), retryable: true, - category: None, + category: None, signature: None, })), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, }), ); @@ -1185,20 +1187,20 @@ mod tests { let handler = Arc::new( CountingHandler::new(vec![ Err(CoreError::handler(HandlerErrorDetail { - message: "r".into(), + message: "r".into(), retryable: true, - category: None, + category: None, signature: None, })), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, }), ); @@ -1235,20 +1237,20 @@ mod tests { let handler = Arc::new( CountingHandler::new(vec![ Err(CoreError::handler(HandlerErrorDetail { - message: "r".into(), + message: "r".into(), retryable: true, - category: None, + category: None, signature: None, })), Ok(Outcome::success()), // should not be reached ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_millis(1), - factor: 1.0, - max_delay: Duration::from_millis(1), - jitter: false, + factor: 1.0, + max_delay: Duration::from_millis(1), + jitter: false, }, }), ); @@ -1276,11 +1278,11 @@ mod tests { ]) .with_retry_policy(RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_secs(5), - factor: 2.0, - max_delay: Duration::from_secs(60), - jitter: false, + factor: 2.0, + max_delay: Duration::from_secs(60), + jitter: false, }, }), ); @@ -1617,13 +1619,10 @@ mod tests { executor.run(&g, state).await.unwrap(); let checkpoints = log.lock().unwrap().clone(); // "start" checkpoints with next="work", "work" checkpoints with next="end" - assert_eq!( - checkpoints, - vec![ - ("start".to_string(), Some("work".to_string())), - ("work".to_string(), Some("end".to_string())), - ] - ); + assert_eq!(checkpoints, vec![ + ("start".to_string(), Some("work".to_string())), + ("work".to_string(), Some("end".to_string())), + ]); } #[tokio::test] @@ -1693,13 +1692,10 @@ mod tests { executor.run(&g, state).await.unwrap(); - assert_eq!( - *log.lock().unwrap(), - vec![ - "after_record:start:start:hello".to_string(), - "on_edge_selected:start:hello".to_string(), - ] - ); + assert_eq!(*log.lock().unwrap(), vec![ + "after_record:start:start:hello".to_string(), + "on_edge_selected:start:hello".to_string(), + ]); } #[tokio::test] @@ -1748,10 +1744,10 @@ mod tests { .lifecycle(Box::new(GateTracker(log2.clone()))) .build(); executor2.run(&g2, state2).await.unwrap(); - assert_eq!( - log2.lock().unwrap().clone(), - vec![("end".to_string(), false)] - ); + assert_eq!(log2.lock().unwrap().clone(), vec![( + "end".to_string(), + false + )]); } #[tokio::test] @@ -2040,9 +2036,9 @@ mod tests { // First call: fail with retryable, then cancel stall during backoff self.stall.cancel(); Err(CoreError::handler(HandlerErrorDetail { - message: "transient".into(), + message: "transient".into(), retryable: true, - category: None, + category: None, signature: None, })) } else { @@ -2052,11 +2048,11 @@ mod tests { fn retry_policy(&self, _n: &TestNode, _g: &TestGraph) -> RetryPolicy { RetryPolicy { max_attempts: 3, - backoff: BackoffPolicy { + backoff: BackoffPolicy { initial_delay: Duration::from_secs(60), - factor: 1.0, - max_delay: Duration::from_secs(60), - jitter: false, + factor: 1.0, + max_delay: Duration::from_secs(60), + jitter: false, }, } } diff --git a/lib/crates/fabro-core/src/graph.rs b/lib/crates/fabro-core/src/graph.rs index 0cb1744be..304e266a8 100644 --- a/lib/crates/fabro-core/src/graph.rs +++ b/lib/crates/fabro-core/src/graph.rs @@ -17,7 +17,7 @@ pub trait EdgeSpec: Send + Sync + Clone { } pub struct EdgeSelection { - pub edge: G::Edge, + pub edge: G::Edge, pub reason: &'static str, } diff --git a/lib/crates/fabro-core/src/lifecycle.rs b/lib/crates/fabro-core/src/lifecycle.rs index 836925e37..16d7ecf4f 100644 --- a/lib/crates/fabro-core/src/lifecycle.rs +++ b/lib/crates/fabro-core/src/lifecycle.rs @@ -22,26 +22,26 @@ pub enum EdgeDecision { } pub struct AttemptContext<'a, G: Graph> { - pub node: &'a G::Node, - pub attempt: u32, + pub node: &'a G::Node, + pub attempt: u32, pub max_attempts: u32, } pub struct AttemptResultContext<'a, G: Graph> { - pub node: &'a G::Node, - pub result: &'a NodeResult, - pub attempt: u32, - pub will_retry: bool, + pub node: &'a G::Node, + pub result: &'a NodeResult, + pub attempt: u32, + pub will_retry: bool, pub backoff_delay: Option, } pub struct EdgeContext<'a, G: Graph> { - pub from: &'a str, - pub to: &'a str, - pub edge: Option, + pub from: &'a str, + pub to: &'a str, + pub edge: Option, pub is_jump: bool, pub outcome: &'a Outcome, - pub reason: &'a str, + pub reason: &'a str, } #[async_trait] @@ -272,11 +272,11 @@ mod tests { /// A lifecycle that records which callbacks were called. struct RecordingLifecycle { - name: String, - log: Arc>>, - before_node_decision: Mutex>, + name: String, + log: Arc>>, + before_node_decision: Mutex>, before_attempt_decision: Mutex>, - edge_decision: Mutex>, + edge_decision: Mutex>, } impl RecordingLifecycle { @@ -525,8 +525,8 @@ mod tests { let state = ExecutionState::new(&g).unwrap(); let node = g.get_node("start").unwrap(); let ctx = AttemptContext { - node: &node, - attempt: 1, + node: &node, + attempt: 1, max_attempts: 1, }; let decision = lc.before_attempt(&ctx, &state).await.unwrap(); @@ -549,8 +549,8 @@ mod tests { let state = ExecutionState::new(&g).unwrap(); let node = g.get_node("start").unwrap(); let ctx = AttemptContext { - node: &node, - attempt: 1, + node: &node, + attempt: 1, max_attempts: 1, }; let decision = lc.before_attempt(&ctx, &state).await.unwrap(); @@ -569,10 +569,10 @@ mod tests { let node = g.get_node("start").unwrap(); let result = NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1); let ctx = AttemptResultContext { - node: &node, - result: &result, - attempt: 1, - will_retry: false, + node: &node, + result: &result, + attempt: 1, + will_retry: false, backoff_delay: None, }; lc.after_attempt(&ctx, &state).await.unwrap(); @@ -595,12 +595,12 @@ mod tests { let outcome = Outcome::success(); let edge = g.outgoing_edges("start").into_iter().next().unwrap(); let ctx = EdgeContext { - from: "start", - to: "end", - edge: Some(edge), + from: "start", + to: "end", + edge: Some(edge), is_jump: false, outcome: &outcome, - reason: "unconditional", + reason: "unconditional", }; let decision = lc.on_edge_selected(&ctx, &state).await.unwrap(); assert!(matches!(decision, EdgeDecision::Override(ref t) if t == "other")); @@ -622,12 +622,12 @@ mod tests { let state = ExecutionState::new(&g).unwrap(); let outcome = Outcome::success(); let ctx = EdgeContext { - from: "start", - to: "end", - edge: None, + from: "start", + to: "end", + edge: None, is_jump: false, outcome: &outcome, - reason: "unconditional", + reason: "unconditional", }; let decision = lc.on_edge_selected(&ctx, &state).await.unwrap(); assert!(matches!(decision, EdgeDecision::Block(_))); @@ -641,12 +641,12 @@ mod tests { let state = ExecutionState::new(&g).unwrap(); let outcome = Outcome::success(); let ctx = EdgeContext:: { - from: "start", - to: "target", - edge: None, + from: "start", + to: "target", + edge: None, is_jump: true, outcome: &outcome, - reason: "jump", + reason: "jump", }; let decision = lc.on_edge_selected(&ctx, &state).await.unwrap(); assert!(matches!(decision, EdgeDecision::Continue)); @@ -692,8 +692,8 @@ mod tests { let counter = Arc::new(AtomicU32::new(0)); struct OrderedLifecycle { - name: String, - log: Arc>>, + name: String, + log: Arc>>, counter: Arc, } @@ -711,18 +711,18 @@ mod tests { let lc = CompositeLifecycle::new(vec![ Box::new(OrderedLifecycle { - name: "first".into(), - log: log.clone(), + name: "first".into(), + log: log.clone(), counter: counter.clone(), }), Box::new(OrderedLifecycle { - name: "second".into(), - log: log.clone(), + name: "second".into(), + log: log.clone(), counter: counter.clone(), }), Box::new(OrderedLifecycle { - name: "third".into(), - log: log.clone(), + name: "third".into(), + log: log.clone(), counter: counter.clone(), }), ]); diff --git a/lib/crates/fabro-core/src/outcome.rs b/lib/crates/fabro-core/src/outcome.rs index e635ace7f..8664b40e0 100644 --- a/lib/crates/fabro-core/src/outcome.rs +++ b/lib/crates/fabro-core/src/outcome.rs @@ -1,10 +1,11 @@ use std::time::Duration; -use crate::error::CoreError; pub use fabro_types::outcome::{ FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus, }; +use crate::error::CoreError; + pub trait NodeResultExt { fn from_error(error: &CoreError, duration: Duration, attempts: u32, max_attempts: u32) -> Self; } diff --git a/lib/crates/fabro-core/src/retry.rs b/lib/crates/fabro-core/src/retry.rs index a13cf0082..e16b413cf 100644 --- a/lib/crates/fabro-core/src/retry.rs +++ b/lib/crates/fabro-core/src/retry.rs @@ -3,14 +3,14 @@ pub use fabro_util::backoff::BackoffPolicy; #[derive(Debug, Clone)] pub struct RetryPolicy { pub max_attempts: u32, - pub backoff: BackoffPolicy, + pub backoff: BackoffPolicy, } impl RetryPolicy { pub fn none() -> Self { Self { max_attempts: 1, - backoff: BackoffPolicy::default(), + backoff: BackoffPolicy::default(), } } diff --git a/lib/crates/fabro-core/src/stall.rs b/lib/crates/fabro-core/src/stall.rs index 981a1cc9a..8068900bc 100644 --- a/lib/crates/fabro-core/src/stall.rs +++ b/lib/crates/fabro-core/src/stall.rs @@ -16,18 +16,18 @@ pub trait ActivityMonitor: Send + Sync { /// Watches for inactivity and fires a stall timeout if no activity is /// reported within the configured duration. pub struct StallWatchdog { - timeout: Duration, + timeout: Duration, cancel_token: Arc, - activity: Arc, - shutdown: Arc, - monitor: Arc, + activity: Arc, + shutdown: Arc, + monitor: Arc, } /// Guard that resets the stall timer on activity. Drop to stop watching. pub struct StallGuard { activity: Arc, shutdown: Arc, - handle: Option>, + handle: Option>, } impl StallWatchdog { @@ -82,7 +82,7 @@ impl StallWatchdog { StallGuard { activity: self.activity, shutdown: self.shutdown, - handle: Some(handle), + handle: Some(handle), } } } @@ -106,10 +106,12 @@ impl Drop for StallGuard { #[cfg(test)] mod tests { - use super::*; use std::sync::atomic::AtomicU32; + use tokio::time::sleep; + use super::*; + struct TestMonitor { stall_count: AtomicU32, } @@ -159,7 +161,8 @@ mod tests { sleep(Duration::from_millis(50)).await; guard.report_activity(); - // After another 50ms (100ms total, but only 50ms since activity), should not have timed out + // After another 50ms (100ms total, but only 50ms since activity), should not + // have timed out sleep(Duration::from_millis(50)).await; assert!(!cancel.load(Ordering::Relaxed)); diff --git a/lib/crates/fabro-core/src/state.rs b/lib/crates/fabro-core/src/state.rs index 912bef357..24dd1a0dd 100644 --- a/lib/crates/fabro-core/src/state.rs +++ b/lib/crates/fabro-core/src/state.rs @@ -17,30 +17,30 @@ impl std::fmt::Debug for ExecutionState { } pub struct ExecutionState { - pub context: Context, - pub current_node_id: String, - pub completed_nodes: Vec, - pub node_outcomes: HashMap>, - pub node_retries: HashMap, - pub node_visits: HashMap, - pub stage_index: usize, + pub context: Context, + pub current_node_id: String, + pub completed_nodes: Vec, + pub node_outcomes: HashMap>, + pub node_retries: HashMap, + pub node_visits: HashMap, + pub stage_index: usize, pub previous_node_id: Option, - pub cancelled: bool, + pub cancelled: bool, } impl ExecutionState { pub fn new(graph: &G) -> Result { let start = graph.find_start_node()?; Ok(Self { - context: Context::new(), - current_node_id: start.id().to_string(), - completed_nodes: Vec::new(), - node_outcomes: HashMap::new(), - node_retries: HashMap::new(), - node_visits: HashMap::new(), - stage_index: 0, + context: Context::new(), + current_node_id: start.id().to_string(), + completed_nodes: Vec::new(), + node_outcomes: HashMap::new(), + node_retries: HashMap::new(), + node_visits: HashMap::new(), + stage_index: 0, previous_node_id: None, - cancelled: false, + cancelled: false, }) } @@ -71,7 +71,8 @@ impl ExecutionState { if let Some(ctx) = new_context { self.context = ctx; } - // node_visits is NOT cleared — preserves total visit counts across restarts + // node_visits is NOT cleared — preserves total visit counts across + // restarts } pub fn current_node(&self, graph: &G) -> Option { diff --git a/lib/crates/fabro-core/src/test_fixtures.rs b/lib/crates/fabro-core/src/test_fixtures.rs index dc8fcd6fa..e78bb2337 100644 --- a/lib/crates/fabro-core/src/test_fixtures.rs +++ b/lib/crates/fabro-core/src/test_fixtures.rs @@ -15,28 +15,28 @@ use crate::retry::RetryPolicy; #[derive(Debug, Clone)] pub struct TestNode { - pub id: String, - pub terminal: bool, + pub id: String, + pub terminal: bool, pub max_visits: Option, - pub goal_gate: Option<(String, StageStatus)>, + pub goal_gate: Option<(String, StageStatus)>, } impl TestNode { pub fn new(id: &str) -> Self { Self { - id: id.to_string(), - terminal: false, + id: id.to_string(), + terminal: false, max_visits: None, - goal_gate: None, + goal_gate: None, } } pub fn terminal(id: &str) -> Self { Self { - id: id.to_string(), - terminal: true, + id: id.to_string(), + terminal: true, max_visits: None, - goal_gate: None, + goal_gate: None, } } @@ -71,18 +71,18 @@ impl NodeSpec for TestNode { #[derive(Debug, Clone)] pub struct TestEdge { - pub from: String, - pub to: String, - pub label: Option, + pub from: String, + pub to: String, + pub label: Option, pub loop_restart: bool, } impl TestEdge { pub fn new(from: &str, to: &str) -> Self { Self { - from: from.to_string(), - to: to.to_string(), - label: None, + from: from.to_string(), + to: to.to_string(), + label: None, loop_restart: false, } } @@ -118,8 +118,8 @@ impl EdgeSpec for TestEdge { #[derive(Debug, Clone)] pub struct TestGraph { - pub nodes: Vec, - pub edges: Vec, + pub nodes: Vec, + pub edges: Vec, pub start_node_id: String, pub retry_targets: HashMap, } @@ -181,7 +181,7 @@ impl Graph for TestGraph { .find(|e| e.label.as_deref() == Some(label.as_str())) { return Some(EdgeSelection { - edge: e.clone(), + edge: e.clone(), reason: "preferred_label", }); } @@ -194,7 +194,7 @@ impl Graph for TestGraph { .find(|e| e.label.as_deref() == Some(status_label.as_str())) { return Some(EdgeSelection { - edge: e.clone(), + edge: e.clone(), reason: "condition", }); } @@ -203,7 +203,7 @@ impl Graph for TestGraph { for suggested in &outcome.suggested_next_ids { if let Some(e) = edges.iter().find(|e| e.to == *suggested) { return Some(EdgeSelection { - edge: e.clone(), + edge: e.clone(), reason: "suggested_next", }); } @@ -212,7 +212,7 @@ impl Graph for TestGraph { // Fourth: unconditional (no label) if let Some(e) = edges.iter().find(|e| e.label.is_none()) { return Some(EdgeSelection { - edge: e.clone(), + edge: e.clone(), reason: "unconditional", }); } @@ -287,16 +287,16 @@ impl NodeHandler for AlwaysFailHandler { } pub struct CountingHandler { - pub call_count: AtomicU32, - pub outcomes: std::sync::Mutex>>, + pub call_count: AtomicU32, + pub outcomes: std::sync::Mutex>>, pub retry_policy: RetryPolicy, } impl CountingHandler { pub fn new(outcomes: Vec>) -> Self { Self { - call_count: AtomicU32::new(0), - outcomes: std::sync::Mutex::new(outcomes), + call_count: AtomicU32::new(0), + outcomes: std::sync::Mutex::new(outcomes), retry_policy: RetryPolicy::none(), } } @@ -337,7 +337,7 @@ impl NodeHandler for CountingHandler { /// A handler that dispatches based on node ID. pub struct DispatchHandler { handlers: HashMap>>, - default: Arc>, + default: Arc>, } impl DispatchHandler { @@ -378,19 +378,20 @@ impl NodeHandler for DispatchHandler { } } -/// A handler that returns Err(CoreError::Handler) with configurable retryability. +/// A handler that returns Err(CoreError::Handler) with configurable +/// retryability. pub struct ErrorHandler { - pub detail: HandlerErrorDetail, + pub detail: HandlerErrorDetail, pub retry_policy: RetryPolicy, } impl ErrorHandler { pub fn retryable(message: &str, policy: RetryPolicy) -> Self { Self { - detail: HandlerErrorDetail { - message: message.to_string(), + detail: HandlerErrorDetail { + message: message.to_string(), retryable: true, - category: None, + category: None, signature: None, }, retry_policy: policy, @@ -399,10 +400,10 @@ impl ErrorHandler { pub fn non_retryable(message: &str) -> Self { Self { - detail: HandlerErrorDetail { - message: message.to_string(), + detail: HandlerErrorDetail { + message: message.to_string(), retryable: false, - category: None, + category: None, signature: None, }, retry_policy: RetryPolicy::none(), diff --git a/lib/crates/fabro-devcontainer/src/compose.rs b/lib/crates/fabro-devcontainer/src/compose.rs index d23115ab5..6a29213a4 100644 --- a/lib/crates/fabro-devcontainer/src/compose.rs +++ b/lib/crates/fabro-devcontainer/src/compose.rs @@ -4,17 +4,17 @@ use std::path::{Path, PathBuf}; /// Extracted configuration from a Docker Compose service. #[derive(Debug, Clone, Default)] pub(crate) struct ComposeServiceSpec { - pub image: Option, - pub build: Option, - pub ports: Vec, + pub image: Option, + pub build: Option, + pub ports: Vec, pub environment: HashMap, - pub user: Option, + pub user: Option, } /// Build configuration from a Docker Compose service. #[derive(Debug, Clone)] pub(crate) struct ComposeBuild { - pub context: String, + pub context: String, pub dockerfile: Option, } @@ -62,7 +62,7 @@ fn parse_build(service: &serde_yaml::Value) -> Option { if let Some(context) = build_val.as_str() { return Some(ComposeBuild { - context: context.to_string(), + context: context.to_string(), dockerfile: None, }); } @@ -153,8 +153,8 @@ fn parse_environment(service: &serde_yaml::Value) -> HashMap { } /// Parse multiple Docker Compose files and merge config for the named service. -/// Later files override earlier files for image/build/user; ports accumulate (deduped); -/// environment keys from later files override earlier ones. +/// Later files override earlier files for image/build/user; ports accumulate +/// (deduped); environment keys from later files override earlier ones. pub(crate) fn parse_compose_multi( compose_paths: &[PathBuf], service_name: &str, @@ -208,9 +208,10 @@ pub(crate) fn parse_compose_multi( #[cfg(test)] mod tests { - use super::*; use std::io::Write; + use super::*; + fn write_compose(content: &str) -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); f.write_all(content.as_bytes()).unwrap(); diff --git a/lib/crates/fabro-devcontainer/src/dockerfile.rs b/lib/crates/fabro-devcontainer/src/dockerfile.rs index 5f7a5725b..24e11268b 100644 --- a/lib/crates/fabro-devcontainer/src/dockerfile.rs +++ b/lib/crates/fabro-devcontainer/src/dockerfile.rs @@ -43,8 +43,8 @@ mod tests { fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer { FeatureLayer { - id: id.to_string(), - dir_name: dir_name.to_string(), + id: id.to_string(), + dir_name: dir_name.to_string(), dockerfile_snippet: snippet.to_string(), } } diff --git a/lib/crates/fabro-devcontainer/src/features.rs b/lib/crates/fabro-devcontainer/src/features.rs index 44e03fa58..6d56ce254 100644 --- a/lib/crates/fabro-devcontainer/src/features.rs +++ b/lib/crates/fabro-devcontainer/src/features.rs @@ -13,9 +13,9 @@ use crate::types::{FeatureMetadata, LifecycleCommand}; #[derive(Debug, Clone)] pub(crate) struct FeatureLayer { /// Feature identifier (e.g. "ghcr.io/devcontainers/features/node:1") - pub id: String, + pub id: String, /// Directory name for COPY - pub dir_name: String, + pub dir_name: String, /// Dockerfile snippet for this feature pub dockerfile_snippet: String, } @@ -23,11 +23,11 @@ pub(crate) struct FeatureLayer { /// All resolved feature data: layers, environment, and lifecycle hooks. #[derive(Debug, Clone, Default)] pub(crate) struct ResolvedFeatures { - pub layers: Vec, - pub container_env: HashMap, - pub on_create_commands: Vec, + pub layers: Vec, + pub container_env: HashMap, + pub on_create_commands: Vec, pub post_create_commands: Vec, - pub post_start_commands: Vec, + pub post_start_commands: Vec, } /// Extract the directory name from a feature ID. @@ -210,7 +210,8 @@ async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result ))); } - // OCI registries may name the tgz with a feature suffix (e.g. devcontainer-feature-node.tgz) + // OCI registries may name the tgz with a feature suffix (e.g. + // devcontainer-feature-node.tgz) if let Some(tgz) = find_tgz(&feature_dir).await { extract_tgz(&feature_dir, &tgz, feature_id).await?; } @@ -326,8 +327,9 @@ async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> { Ok(()) } -/// Topological sort of features based on `installsAfter` and `dependsOn` dependencies. -/// Uses Kahn's algorithm. Features without ordering constraints maintain input order. +/// Topological sort of features based on `installsAfter` and `dependsOn` +/// dependencies. Uses Kahn's algorithm. Features without ordering constraints +/// maintain input order. fn topo_sort( feature_ids: &[String], metadata_map: &HashMap, @@ -422,9 +424,9 @@ fn topo_sort( sorted } -/// Convert an option ID to an environment variable name per the dev container spec. -/// Replaces non-alphanumeric, non-underscore chars with `_`, strips leading digits/underscores, -/// and uppercases the result. +/// Convert an option ID to an environment variable name per the dev container +/// spec. Replaces non-alphanumeric, non-underscore chars with `_`, strips +/// leading digits/underscores, and uppercases the result. fn option_id_to_env_name(id: &str) -> String { let replaced: String = id .chars() @@ -626,16 +628,16 @@ pub(crate) async fn resolve_features( .get(id) .cloned() .unwrap_or_else(|| FeatureMetadata { - id: None, - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: None, + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }); // Collect feature containerEnv (later features override earlier) @@ -752,21 +754,18 @@ mod tests { let metadata: HashMap = ids .iter() .map(|id| { - ( - id.clone(), - FeatureMetadata { - id: Some(id.clone()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ) + (id.clone(), FeatureMetadata { + id: Some(id.clone()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }) }) .collect(); @@ -779,36 +778,30 @@ mod tests { // A depends on B (A installs after B), so B should come first let ids = vec!["a".to_string(), "b".to_string()]; let mut metadata: HashMap = HashMap::new(); - metadata.insert( - "a".to_string(), - FeatureMetadata { - id: Some("a".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: vec!["b".to_string()], - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "b".to_string(), - FeatureMetadata { - id: Some("b".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); + metadata.insert("a".to_string(), FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["b".to_string()], + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("b".to_string(), FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); let sorted = topo_sort(&ids, &metadata); assert_eq!(sorted, vec!["b", "a"]); @@ -817,7 +810,8 @@ mod tests { #[test] fn topo_sort_diamond() { // D depends on B and C; B and C depend on A - // Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order for ties) + // Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order + // for ties) let ids = vec![ "d".to_string(), "b".to_string(), @@ -825,66 +819,54 @@ mod tests { "a".to_string(), ]; let mut metadata: HashMap = HashMap::new(); - metadata.insert( - "a".to_string(), - FeatureMetadata { - id: Some("a".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "b".to_string(), - FeatureMetadata { - id: Some("b".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: vec!["a".to_string()], - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "c".to_string(), - FeatureMetadata { - id: Some("c".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: vec!["a".to_string()], - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "d".to_string(), - FeatureMetadata { - id: Some("d".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: vec!["b".to_string(), "c".to_string()], - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); + metadata.insert("a".to_string(), FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("b".to_string(), FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["a".to_string()], + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("c".to_string(), FeatureMetadata { + id: Some("c".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["a".to_string()], + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("d".to_string(), FeatureMetadata { + id: Some("d".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["b".to_string(), "c".to_string()], + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); let sorted = topo_sort(&ids, &metadata); // A must come before B and C; B and C must come before D @@ -902,25 +884,22 @@ mod tests { fn generate_layer_with_options() { let options = serde_json::json!({"version": "20"}); let mut meta_options = HashMap::new(); - meta_options.insert( - "version".to_string(), - FeatureOption { - option_type: Some("string".to_string()), - default: Some(serde_json::Value::String("lts".to_string())), - description: Some("Node.js version".to_string()), - }, - ); + meta_options.insert("version".to_string(), FeatureOption { + option_type: Some("string".to_string()), + default: Some(serde_json::Value::String("lts".to_string())), + description: Some("Node.js version".to_string()), + }); let metadata = FeatureMetadata { - id: Some("node".to_string()), - name: Some("Node.js".to_string()), - version: Some("1.0.0".to_string()), - options: meta_options, - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: Some("node".to_string()), + name: Some("Node.js".to_string()), + version: Some("1.0.0".to_string()), + options: meta_options, + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let snippet = generate_layer( @@ -949,25 +928,22 @@ mod tests { fn generate_layer_with_defaults() { let options = serde_json::json!({}); let mut meta_options = HashMap::new(); - meta_options.insert( - "version".to_string(), - FeatureOption { - option_type: Some("string".to_string()), - default: Some(serde_json::Value::String("lts".to_string())), - description: Some("Node.js version".to_string()), - }, - ); + meta_options.insert("version".to_string(), FeatureOption { + option_type: Some("string".to_string()), + default: Some(serde_json::Value::String("lts".to_string())), + description: Some("Node.js version".to_string()), + }); let metadata = FeatureMetadata { - id: Some("node".to_string()), - name: None, - version: None, - options: meta_options, - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: Some("node".to_string()), + name: None, + version: None, + options: meta_options, + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let snippet = generate_layer( @@ -996,16 +972,16 @@ mod tests { fn generate_layer_no_options() { let options = serde_json::json!({}); let metadata = FeatureMetadata { - id: Some("common-utils".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: Some("common-utils".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let snippet = generate_layer( @@ -1036,36 +1012,30 @@ mod tests { let mut metadata: HashMap = HashMap::new(); let mut depends = HashMap::new(); depends.insert("b".to_string(), serde_json::json!({})); - metadata.insert( - "a".to_string(), - FeatureMetadata { - id: Some("a".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: depends, - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "b".to_string(), - FeatureMetadata { - id: Some("b".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); + metadata.insert("a".to_string(), FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: depends, + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("b".to_string(), FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); let sorted = topo_sort(&ids, &metadata); assert_eq!(sorted, vec!["b", "a"]); @@ -1078,36 +1048,30 @@ mod tests { let mut metadata: HashMap = HashMap::new(); let mut depends = HashMap::new(); depends.insert("b".to_string(), serde_json::json!({})); - metadata.insert( - "a".to_string(), - FeatureMetadata { - id: Some("a".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: vec!["b".to_string()], - depends_on: depends, - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); - metadata.insert( - "b".to_string(), - FeatureMetadata { - id: Some("b".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, - post_create_command: None, - post_start_command: None, - }, - ); + metadata.insert("a".to_string(), FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["b".to_string()], + depends_on: depends, + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); + metadata.insert("b".to_string(), FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, + post_create_command: None, + post_start_command: None, + }); let sorted = topo_sort(&ids, &metadata); assert_eq!(sorted, vec!["b", "a"]); @@ -1149,41 +1113,42 @@ mod tests { #[test] fn feature_container_env_collected() { - // Simulate what resolve_features does: collect container_env from metadata in sort order + // Simulate what resolve_features does: collect container_env from metadata in + // sort order let mut resolved = ResolvedFeatures::default(); let meta_a = FeatureMetadata { - id: Some("a".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: { let mut env = HashMap::new(); env.insert("FOO".to_string(), "from_a".to_string()); env.insert("BAR".to_string(), "from_a".to_string()); env }, - on_create_command: None, + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let meta_b = FeatureMetadata { - id: Some("b".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: { let mut env = HashMap::new(); env.insert("FOO".to_string(), "from_b".to_string()); env }, - on_create_command: None, + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; // A is sorted first, then B — B's FOO overrides A's @@ -1247,25 +1212,22 @@ mod tests { fn generate_layer_shorthand_version() { let options = serde_json::json!("20"); let mut meta_options = HashMap::new(); - meta_options.insert( - "version".to_string(), - FeatureOption { - option_type: Some("string".to_string()), - default: Some(serde_json::Value::String("lts".to_string())), - description: Some("Node.js version".to_string()), - }, - ); + meta_options.insert("version".to_string(), FeatureOption { + option_type: Some("string".to_string()), + default: Some(serde_json::Value::String("lts".to_string())), + description: Some("Node.js version".to_string()), + }); let metadata = FeatureMetadata { - id: Some("node".to_string()), - name: None, - version: None, - options: meta_options, - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: Some("node".to_string()), + name: None, + version: None, + options: meta_options, + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let snippet = generate_layer( @@ -1283,16 +1245,16 @@ mod tests { fn generate_layer_install_env_vars() { let options = serde_json::json!({}); let metadata = FeatureMetadata { - id: Some("node".to_string()), - name: None, - version: None, - options: HashMap::new(), - installs_after: Vec::new(), - depends_on: HashMap::new(), - container_env: HashMap::new(), - on_create_command: None, + id: Some("node".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + depends_on: HashMap::new(), + container_env: HashMap::new(), + on_create_command: None, post_create_command: None, - post_start_command: None, + post_start_command: None, }; let snippet = generate_layer( diff --git a/lib/crates/fabro-devcontainer/src/lib.rs b/lib/crates/fabro-devcontainer/src/lib.rs index 17952f2a6..036eeea06 100644 --- a/lib/crates/fabro-devcontainer/src/lib.rs +++ b/lib/crates/fabro-devcontainer/src/lib.rs @@ -21,37 +21,38 @@ pub enum Command { Parallel(HashMap), } -/// Parsed and resolved devcontainer configuration — everything needed to create a sandbox. +/// Parsed and resolved devcontainer configuration — everything needed to create +/// a sandbox. #[derive(Debug, Clone)] pub struct DevcontainerSpec { /// Generated Dockerfile content - pub dockerfile: String, + pub dockerfile: String, /// Directory for docker build context - pub build_context: PathBuf, + pub build_context: PathBuf, /// Build arguments (docker build --build-arg) - pub build_args: HashMap, + pub build_args: HashMap, /// Multi-stage build target (docker build --target) - pub build_target: Option, + pub build_target: Option, /// Run on host before build - pub initialize_commands: Vec, + pub initialize_commands: Vec, /// Run in container after first creation (before updateContentCommand) - pub on_create_commands: Vec, + pub on_create_commands: Vec, /// Run in container after creation pub post_create_commands: Vec, /// Run in container on each start - pub post_start_commands: Vec, + pub post_start_commands: Vec, /// remoteEnv merged - pub environment: HashMap, + pub environment: HashMap, /// containerEnv — baked into Dockerfile as ENV directives - pub container_env: HashMap, - pub remote_user: Option, + pub container_env: HashMap, + pub remote_user: Option, /// default: /workspaces/{repo-name} - pub workspace_folder: String, + pub workspace_folder: String, /// first = default preview port - pub forwarded_ports: Vec, + pub forwarded_ports: Vec, /// Compose file paths (empty if not in compose mode) - pub compose_files: Vec, - pub compose_service: Option, + pub compose_files: Vec, + pub compose_service: Option, } #[derive(Debug, thiserror::Error)] @@ -64,7 +65,7 @@ pub enum DevcontainerError { #[error("reading file {path}: {source}")] ReadFile { - path: PathBuf, + path: PathBuf, source: std::io::Error, }, @@ -91,8 +92,9 @@ pub enum DevcontainerError { pub type Result = std::result::Result; -/// Check that a Dockerfile does not contain COPY or ADD instructions that reference -/// build context files. Multi-stage `COPY --from=` and `ADD http(s)://` are allowed. +/// Check that a Dockerfile does not contain COPY or ADD instructions that +/// reference build context files. Multi-stage `COPY --from=` and `ADD +/// http(s)://` are allowed. fn check_no_build_context_copies(dockerfile: &str) -> Result<()> { let mut offending = Vec::new(); let mut continuation = String::new(); @@ -366,7 +368,8 @@ impl DevcontainerResolver { let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports); - // Collect devcontainer.json lifecycle commands, then append feature lifecycle commands + // Collect devcontainer.json lifecycle commands, then append feature lifecycle + // commands let mut on_create_commands = Self::collect_commands(devcontainer.on_create_command.as_ref(), &vars); let mut post_create_commands = @@ -439,7 +442,8 @@ impl DevcontainerResolver { return Ok((path.to_path_buf(), parsed)); } - // Subdirectory format: scan .devcontainer/ for subdirs containing devcontainer.json + // Subdirectory format: scan .devcontainer/ for subdirs containing + // devcontainer.json let devcontainer_dir = path.join(".devcontainer"); if devcontainer_dir.is_dir() { let mut subdirs: Vec = std::fs::read_dir(&devcontainer_dir) @@ -474,8 +478,9 @@ impl DevcontainerResolver { } fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path { - // If json_path is inside .devcontainer//, the repo root is two levels up - // If json_path is inside .devcontainer/, the repo root is one level up + // If json_path is inside .devcontainer//, the repo root is two levels + // up If json_path is inside .devcontainer/, the repo root is one level + // up if let Some(parent) = json_path.parent() { if parent.file_name().is_some_and(|n| n == ".devcontainer") { if let Some(repo_root) = parent.parent() { diff --git a/lib/crates/fabro-devcontainer/src/types.rs b/lib/crates/fabro-devcontainer/src/types.rs index e02881780..2e97d4e43 100644 --- a/lib/crates/fabro-devcontainer/src/types.rs +++ b/lib/crates/fabro-devcontainer/src/types.rs @@ -1,6 +1,7 @@ -use serde::Deserialize; use std::collections::HashMap; +use serde::Deserialize; + /// Top-level devcontainer.json schema (subset of the spec we support). #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -95,7 +96,8 @@ impl ComposeFileRef { } } -/// A lifecycle command can be a string, array of strings, or object of named commands. +/// A lifecycle command can be a string, array of strings, or object of named +/// commands. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum LifecycleCommand { @@ -108,8 +110,8 @@ pub enum LifecycleCommand { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct FeatureMetadata { - pub id: Option, - pub name: Option, + pub id: Option, + pub name: Option, pub version: Option, #[serde(default)] @@ -119,7 +121,8 @@ pub(crate) struct FeatureMetadata { #[serde(default)] pub installs_after: Vec, - /// Hard dependencies: feature IDs that must be present (auto-installed if missing) + /// Hard dependencies: feature IDs that must be present (auto-installed if + /// missing) #[serde(default)] pub depends_on: HashMap, @@ -128,9 +131,9 @@ pub(crate) struct FeatureMetadata { pub container_env: HashMap, /// Lifecycle hooks contributed by this feature - pub on_create_command: Option, + pub on_create_command: Option, pub post_create_command: Option, - pub post_start_command: Option, + pub post_start_command: Option, } /// A single option for a devcontainer feature. @@ -138,7 +141,7 @@ pub(crate) struct FeatureMetadata { pub(crate) struct FeatureOption { #[serde(rename = "type")] pub option_type: Option, - pub default: Option, + pub default: Option, pub description: Option, } @@ -225,10 +228,9 @@ mod tests { "workspaceFolder": "/workspace" }"#; let config: DevcontainerJson = serde_json::from_str(json).unwrap(); - assert_eq!( - config.docker_compose_file.as_ref().unwrap().paths(), - vec!["docker-compose.yml"] - ); + assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![ + "docker-compose.yml" + ]); assert_eq!(config.service.as_deref(), Some("app")); assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); } @@ -240,10 +242,10 @@ mod tests { "service": "app" }"#; let config: DevcontainerJson = serde_json::from_str(json).unwrap(); - assert_eq!( - config.docker_compose_file.as_ref().unwrap().paths(), - vec!["docker-compose.yml", "docker-compose.override.yml"] - ); + assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![ + "docker-compose.yml", + "docker-compose.override.yml" + ]); } #[test] diff --git a/lib/crates/fabro-devcontainer/src/variables.rs b/lib/crates/fabro-devcontainer/src/variables.rs index 92bc72cef..0e71ab096 100644 --- a/lib/crates/fabro-devcontainer/src/variables.rs +++ b/lib/crates/fabro-devcontainer/src/variables.rs @@ -73,10 +73,12 @@ fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option { #[cfg(test)] mod tests { - use super::*; - use fabro_util::env::{SystemEnv, TestEnv}; use std::collections::HashMap; + use fabro_util::env::{SystemEnv, TestEnv}; + + use super::*; + fn test_ctx() -> VariableContext<'static> { // Tests that don't exercise localEnv don't care about the env impl. // Use SystemEnv which has no lifetime/allocation concerns. diff --git a/lib/crates/fabro-devcontainer/tests/it/e2e.rs b/lib/crates/fabro-devcontainer/tests/it/e2e.rs index 5d2cdabda..e5ad60c8d 100644 --- a/lib/crates/fabro-devcontainer/tests/it/e2e.rs +++ b/lib/crates/fabro-devcontainer/tests/it/e2e.rs @@ -1,15 +1,18 @@ -//! End-to-end tests exercising full resolver pipeline with realistic devcontainer configs. -//! These tests verify the 4 critical gaps are wired correctly through the entire stack: +//! End-to-end tests exercising full resolver pipeline with realistic +//! devcontainer configs. These tests verify the 4 critical gaps are wired +//! correctly through the entire stack: //! 1. onCreateCommand //! 2. build.args //! 3. containerEnv //! 4. dockerComposeFile array -use super::helpers::fixture_path; use fabro_devcontainer::{Command, DevcontainerResolver}; -/// Realistic Python project: Dockerfile + build.args + containerEnv + onCreateCommand + remoteEnv -/// Verifies all 4 gaps work together in a single config. +use super::helpers::fixture_path; + +/// Realistic Python project: Dockerfile + build.args + containerEnv + +/// onCreateCommand + remoteEnv Verifies all 4 gaps work together in a single +/// config. #[tokio::test] async fn realistic_python_project() { let config = DevcontainerResolver::resolve(&fixture_path("realistic-python")) @@ -33,7 +36,8 @@ async fn realistic_python_project() { Some("1") ); - // After fix: only containerEnv is baked into Dockerfile (remoteEnv is runtime-only) + // After fix: only containerEnv is baked into Dockerfile (remoteEnv is + // runtime-only) assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1")); // environment HashMap gets the remoteEnv value assert_eq!( @@ -76,8 +80,9 @@ async fn realistic_python_project() { assert!(config.compose_files.is_empty()); } -/// Realistic compose project: multi-file compose + containerEnv + onCreateCommand + remoteEnv -/// Verifies gaps 1, 3, 4 work together in compose mode. +/// Realistic compose project: multi-file compose + containerEnv + +/// onCreateCommand + remoteEnv Verifies gaps 1, 3, 4 work together in compose +/// mode. #[tokio::test] async fn realistic_compose_project() { let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose")) @@ -91,7 +96,8 @@ async fn realistic_compose_project() { // Gap 4: image from base compose file (override doesn't change image) assert!(config.dockerfile.contains("FROM node:20-bookworm")); - // Ports merged from both compose files (base: 3000, 9229; override: 4000) + forwardPorts (8080) + // Ports merged from both compose files (base: 3000, 9229; override: 4000) + + // forwardPorts (8080) assert!(config.forwarded_ports.contains(&3000)); assert!(config.forwarded_ports.contains(&9229)); assert!(config.forwarded_ports.contains(&4000)); @@ -148,7 +154,8 @@ async fn realistic_compose_project() { assert_eq!(config.workspace_folder, "/workspace"); } -/// All lifecycle commands in different forms: string, array, object, and the new onCreateCommand. +/// All lifecycle commands in different forms: string, array, object, and the +/// new onCreateCommand. #[tokio::test] async fn all_lifecycle_command_forms() { let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle")) @@ -174,7 +181,8 @@ async fn all_lifecycle_command_forms() { assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started")); } -/// Verify containerEnv doesn't pollute the environment HashMap (which is remoteEnv only). +/// Verify containerEnv doesn't pollute the environment HashMap (which is +/// remoteEnv only). #[tokio::test] async fn container_env_separate_from_environment() { let config = DevcontainerResolver::resolve(&fixture_path("realistic-python")) @@ -226,11 +234,12 @@ async fn build_target_none_in_image_and_compose_modes() { assert!(compose_config.build_target.is_none()); } -/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated Dockerfile. -/// Only containerEnv should be baked in. +/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated +/// Dockerfile. Only containerEnv should be baked in. #[tokio::test] async fn remote_env_excluded_from_dockerfile() { - // image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv: {"DEBIAN_FRONTEND": "noninteractive"} + // image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv: + // {"DEBIAN_FRONTEND": "noninteractive"} let config = DevcontainerResolver::resolve(&fixture_path("image-only")) .await .unwrap(); @@ -252,15 +261,18 @@ async fn remote_env_excluded_from_dockerfile() { ); } -/// Gap 2: forwardPorts in compose mode are merged with compose service ports, with deduplication. +/// Gap 2: forwardPorts in compose mode are merged with compose service ports, +/// with deduplication. #[tokio::test] async fn forward_ports_merged_and_deduped_in_compose() { - // compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000, 5173] + // compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000, + // 5173] let config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) .await .unwrap(); - // 3000 appears in both compose ports and forwardPorts — should NOT be duplicated + // 3000 appears in both compose ports and forwardPorts — should NOT be + // duplicated assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]); } @@ -274,7 +286,8 @@ async fn build_target_in_dockerfile_mode() { assert_eq!(config.build_target.as_deref(), Some("dev")); } -/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed correctly. +/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed +/// correctly. #[tokio::test] async fn forward_ports_string_formats() { // image-only fixture has forwardPorts: [3000, "8080:80", "9090"] @@ -326,7 +339,8 @@ async fn container_env_empty_when_not_specified() { assert!(config.container_env.is_empty()); } -// === Gap e2e tests: local features exercising dependsOn, containerEnv, lifecycle hooks === +// === Gap e2e tests: local features exercising dependsOn, containerEnv, +// lifecycle hooks === /// Gap 5: Local path feature references are resolved through the full pipeline. #[tokio::test] @@ -351,7 +365,8 @@ async fn local_feature_refs_resolved() { } /// Gap 1: dependsOn auto-injects missing features through the full pipeline. -/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json features. +/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json +/// features. #[tokio::test] async fn depends_on_auto_injects_missing_feature() { let config = DevcontainerResolver::resolve(&fixture_path("local-features")) @@ -418,7 +433,8 @@ async fn feature_container_env_merged() { ); } -/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json lifecycle commands. +/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json +/// lifecycle commands. #[tokio::test] async fn feature_lifecycle_hooks_appended() { let config = DevcontainerResolver::resolve(&fixture_path("local-features")) @@ -459,7 +475,8 @@ async fn feature_lifecycle_hooks_appended() { .collect(); assert!(feature_post_create.contains(&"echo python-post-create")); - // postStartCommand: only node-feature contributes (no devcontainer.json postStartCommand) + // postStartCommand: only node-feature contributes (no devcontainer.json + // postStartCommand) assert!(!config.post_start_commands.is_empty()); let post_start: Vec<&str> = config .post_start_commands @@ -487,7 +504,8 @@ async fn feature_shorthand_version_syntax() { ); } -/// Fix 2: Hyphenated option IDs are converted to valid env var names (node-version → NODE_VERSION). +/// Fix 2: Hyphenated option IDs are converted to valid env var names +/// (node-version → NODE_VERSION). #[tokio::test] async fn feature_option_id_hyphen_to_underscore() { let config = DevcontainerResolver::resolve(&fixture_path("feature-options")) @@ -506,7 +524,8 @@ async fn feature_option_id_hyphen_to_underscore() { ); } -/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install snippets. +/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install +/// snippets. #[tokio::test] async fn feature_install_user_env_vars() { let config = DevcontainerResolver::resolve(&fixture_path("feature-options")) @@ -556,8 +575,9 @@ async fn feature_install_user_env_vars_default_root() { ); } -/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook collection. -/// python-feature installsAfter node-feature, so node's env/hooks come first. +/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook +/// collection. python-feature installsAfter node-feature, so node's env/hooks +/// come first. #[tokio::test] async fn feature_ordering_preserved_in_env_and_hooks() { let config = DevcontainerResolver::resolve(&fixture_path("local-features")) diff --git a/lib/crates/fabro-devcontainer/tests/it/integration.rs b/lib/crates/fabro-devcontainer/tests/it/integration.rs index 4a9130ed1..8ac6cf692 100644 --- a/lib/crates/fabro-devcontainer/tests/it/integration.rs +++ b/lib/crates/fabro-devcontainer/tests/it/integration.rs @@ -1,6 +1,7 @@ -use super::helpers::fixture_path; use fabro_devcontainer::{Command, DevcontainerResolver}; +use super::helpers::fixture_path; + #[tokio::test] async fn resolve_image_only() { let config = DevcontainerResolver::resolve(&fixture_path("image-only")) @@ -179,7 +180,8 @@ async fn resolve_subdirectory_standard_wins_over_subdirs() { .await .unwrap(); - // Standard .devcontainer/devcontainer.json takes priority over subdirectory format + // Standard .devcontainer/devcontainer.json takes priority over subdirectory + // format assert!( config .dockerfile diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/crates/fabro-github/src/lib.rs index f5cd1db80..9b47fbfe6 100644 --- a/lib/crates/fabro-github/src/lib.rs +++ b/lib/crates/fabro-github/src/lib.rs @@ -4,7 +4,8 @@ use serde::{Deserialize, Serialize}; pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; -/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env var. +/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env +/// var. pub fn github_api_base_url() -> String { std::env::var("GITHUB_BASE_URL").unwrap_or_else(|_| GITHUB_API_BASE_URL.to_string()) } @@ -12,21 +13,21 @@ pub fn github_api_base_url() -> String { /// Detailed information about a pull request from the GitHub API. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PullRequestDetail { - pub number: u64, - pub title: String, - pub body: Option, - pub state: String, - pub draft: bool, - pub mergeable: Option, - pub additions: u64, - pub deletions: u64, + pub number: u64, + pub title: String, + pub body: Option, + pub state: String, + pub draft: bool, + pub mergeable: Option, + pub additions: u64, + pub deletions: u64, pub changed_files: u64, - pub html_url: String, - pub user: PullRequestUser, - pub head: PullRequestRef, - pub base: PullRequestRef, - pub created_at: String, - pub updated_at: String, + pub html_url: String, + pub user: PullRequestUser, + pub head: PullRequestRef, + pub base: PullRequestRef, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -49,14 +50,14 @@ pub struct AppOwner { /// Information about a GitHub App from the authenticated `/app` endpoint. #[derive(Debug, Clone, Deserialize)] pub struct AppInfo { - pub slug: String, + pub slug: String, pub owner: AppOwner, } /// Credentials for authenticating as a GitHub App. #[derive(Clone, Debug)] pub struct GitHubAppCredentials { - pub app_id: String, + pub app_id: String, pub private_key_pem: String, } @@ -105,7 +106,7 @@ pub enum HttpMethod { /// A minimal HTTP response for testability. pub struct HttpResponse { pub status: u16, - body: String, + body: String, } impl HttpResponse { @@ -385,8 +386,8 @@ pub async fn create_installation_access_token_for_pr( /// Result of a successful pull request creation. pub struct CreatedPullRequest { pub html_url: String, - pub number: u64, - pub node_id: String, + pub number: u64, + pub node_id: String, } /// Create a pull request on GitHub. @@ -408,8 +409,8 @@ pub async fn create_pull_request( #[derive(Deserialize)] struct PullRequestResponse { html_url: String, - number: u64, - node_id: String, + number: u64, + node_id: String, } let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; @@ -469,8 +470,8 @@ pub async fn create_pull_request( Ok(CreatedPullRequest { html_url: pr.html_url, - number: pr.number, - node_id: pr.node_id, + number: pr.number, + node_id: pr.node_id, }) } @@ -1015,9 +1016,10 @@ pub async fn create_installation_access_token_for_projects( #[cfg(test)] mod tests { - use super::*; use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use super::*; + #[test] fn decode_pem_env_accepts_raw_pem() { let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----"; @@ -1217,11 +1219,11 @@ mod tests { // ----------------------------------------------------------------------- struct MockRoute { - method: HttpMethod, - path: String, - status: u16, - response_body: String, - assert_header: Option<(String, MockHeaderCheck)>, + method: HttpMethod, + path: String, + status: u16, + response_body: String, + assert_header: Option<(String, MockHeaderCheck)>, assert_body_json: Option, } @@ -1468,7 +1470,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let result = @@ -1500,7 +1502,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let result = @@ -1532,7 +1534,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let result = branch_exists_with_client(&mock, &creds, "owner", "repo", "broken", "").await; @@ -1700,7 +1702,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let detail = get_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "") @@ -1737,7 +1739,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let err = get_pull_request_with_client(&mock, &creds, "owner", "repo", 999, "") @@ -1775,7 +1777,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "") @@ -1802,7 +1804,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let err = merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "") @@ -1830,7 +1832,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let err = merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "") @@ -1867,7 +1869,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; close_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "") @@ -1894,7 +1896,7 @@ mod tests { let pem = test_rsa_key(); let creds = GitHubAppCredentials { - app_id: "test".to_string(), + app_id: "test".to_string(), private_key_pem: pem.to_string(), }; let err = close_pull_request_with_client(&mock, &creds, "owner", "repo", 999, "") diff --git a/lib/crates/fabro-github/tests/integration.rs b/lib/crates/fabro-github/tests/integration.rs index 8410cde49..47fa65e54 100644 --- a/lib/crates/fabro-github/tests/integration.rs +++ b/lib/crates/fabro-github/tests/integration.rs @@ -9,7 +9,7 @@ const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem"); fn github_credentials() -> GitHubAppCredentials { GitHubAppCredentials { - app_id: "42".to_string(), + app_id: "42".to_string(), private_key_pem: TEST_RSA_KEY.to_string(), } } @@ -17,12 +17,12 @@ fn github_credentials() -> GitHubAppCredentials { fn standard_app_state() -> GitHubAppState { let mut state = GitHubAppState::new(); state.register_app(GitHubAppOptions { - app_id: "42".into(), - slug: "test-app".into(), - owner_login: "acme".into(), - public: true, + app_id: "42".into(), + slug: "test-app".into(), + owner_login: "acme".into(), + public: true, private_key_pem: TEST_RSA_KEY.into(), - webhook_secret: None, + webhook_secret: None, }); state.add_installation("42", "acme", vec!["widgets".into()], false); state.add_repository( diff --git a/lib/crates/fabro-graphviz/src/condition.rs b/lib/crates/fabro-graphviz/src/condition.rs index ad6de35bb..3532f7507 100644 --- a/lib/crates/fabro-graphviz/src/condition.rs +++ b/lib/crates/fabro-graphviz/src/condition.rs @@ -28,8 +28,8 @@ pub enum ConditionExpr { #[derive(Debug, Clone, PartialEq)] pub struct Clause { - pub key: String, - pub op: Op, + pub key: String, + pub op: Op, pub value: String, } @@ -195,7 +195,8 @@ fn is_op_char(c: char) -> bool { matches!(c, '=' | '!' | '>' | '<' | '&' | '|') } -/// Word operators (`contains`, `matches`) are recognized when preceded by a Word token. +/// Word operators (`contains`, `matches`) are recognized when preceded by a +/// Word token. fn is_word_operator_context(tokens: &[Token]) -> bool { matches!(tokens.last(), Some(Token::Word(_))) } @@ -206,7 +207,7 @@ fn is_word_operator_context(tokens: &[Token]) -> bool { struct Parser { tokens: Vec, - pos: usize, + pos: usize, } impl Parser { @@ -405,8 +406,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::Eq, + key: "outcome".to_string(), + op: Op::Eq, value: "success".to_string(), }) ); @@ -419,13 +420,13 @@ mod tests { expr, ConditionExpr::And(vec![ ConditionExpr::Clause(Clause { - key: "a".to_string(), - op: Op::Eq, + key: "a".to_string(), + op: Op::Eq, value: "1".to_string(), }), ConditionExpr::Clause(Clause { - key: "b".to_string(), - op: Op::Eq, + key: "b".to_string(), + op: Op::Eq, value: "2".to_string(), }), ]) @@ -438,8 +439,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "some_flag".to_string(), - op: Op::Truthy, + key: "some_flag".to_string(), + op: Op::Truthy, value: String::new(), }) ); @@ -451,8 +452,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::NotEq, + key: "outcome".to_string(), + op: Op::NotEq, value: "fail".to_string(), }) ); @@ -531,8 +532,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::Eq, + key: "outcome".to_string(), + op: Op::Eq, value: "success".to_string(), }) ); @@ -551,8 +552,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "outcome".to_string(), - op: Op::NotEq, + key: "outcome".to_string(), + op: Op::NotEq, value: "fail".to_string(), }) ); @@ -564,8 +565,8 @@ mod tests { assert_eq!( expr, ConditionExpr::Clause(Clause { - key: "context.msg".to_string(), - op: Op::Eq, + key: "context.msg".to_string(), + op: Op::Eq, value: "hello world".to_string(), }) ); diff --git a/lib/crates/fabro-graphviz/src/fidelity.rs b/lib/crates/fabro-graphviz/src/fidelity.rs index c658495c8..f2e92dc92 100644 --- a/lib/crates/fabro-graphviz/src/fidelity.rs +++ b/lib/crates/fabro-graphviz/src/fidelity.rs @@ -1,7 +1,8 @@ use std::fmt; use std::str::FromStr; -/// Fidelity mode controlling how much prior context is provided to LLM sessions. +/// Fidelity mode controlling how much prior context is provided to LLM +/// sessions. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Fidelity { /// Complete context, no summarization — sessions share a thread. diff --git a/lib/crates/fabro-graphviz/src/parser/ast.rs b/lib/crates/fabro-graphviz/src/parser/ast.rs index ecea9730c..8aa50e449 100644 --- a/lib/crates/fabro-graphviz/src/parser/ast.rs +++ b/lib/crates/fabro-graphviz/src/parser/ast.rs @@ -7,7 +7,8 @@ pub enum AstValue { Int(i64), Float(f64), Bool(bool), - /// A bare identifier used as a value (e.g., shape names, direction keywords). + /// A bare identifier used as a value (e.g., shape names, direction + /// keywords). Ident(String), } @@ -17,7 +18,7 @@ pub type AttrBlock = Vec<(String, AstValue)>; /// A node statement: `id [attrs]?`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NodeStmt { - pub id: String, + pub id: String, pub attrs: Option, } @@ -32,7 +33,7 @@ pub struct EdgeStmt { /// A subgraph statement: `subgraph name? { stmts }`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubgraphStmt { - pub name: Option, + pub name: Option, pub statements: Vec, } @@ -58,7 +59,7 @@ pub enum Statement { /// The top-level parsed DOT graph. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DotGraph { - pub name: String, + pub name: String, pub statements: Vec, } @@ -84,11 +85,11 @@ mod tests { #[test] fn dot_graph_construction() { let graph = DotGraph { - name: "test".into(), + name: "test".into(), statements: vec![ Statement::GraphAttrDecl("rankdir".into(), AstValue::Ident("LR".into())), Statement::Node(NodeStmt { - id: "start".into(), + id: "start".into(), attrs: Some(vec![("shape".into(), AstValue::Ident("Mdiamond".into()))]), }), ], @@ -109,7 +110,7 @@ mod tests { #[test] fn subgraph_stmt() { let sub = SubgraphStmt { - name: Some("cluster_loop".into()), + name: Some("cluster_loop".into()), statements: vec![Statement::NodeDefaults(vec![( "timeout".into(), AstValue::Str("900s".into()), diff --git a/lib/crates/fabro-graphviz/src/parser/grammar.rs b/lib/crates/fabro-graphviz/src/parser/grammar.rs index 73b0acf4a..8cd4d1b87 100644 --- a/lib/crates/fabro-graphviz/src/parser/grammar.rs +++ b/lib/crates/fabro-graphviz/src/parser/grammar.rs @@ -73,7 +73,7 @@ fn subgraph_stmt(input: &str) -> IResult<&str, Statement> { Ok(( rest, Statement::Subgraph(SubgraphStmt { - name: name.map(String::from), + name: name.map(String::from), statements: stmts, }), )) @@ -120,9 +120,10 @@ fn statement(input: &str) -> IResult<&str, Statement> { node_defaults, edge_defaults, subgraph_stmt, - // graph_attr_decl must be tried before node_or_edge because both start with an identifier. - // graph_attr_decl is `id = value` while node is `id [attrs]?` - // We try graph_attr_decl first; if it fails (no `=` after id) we fall through to node_or_edge. + // graph_attr_decl must be tried before node_or_edge because both start with an + // identifier. graph_attr_decl is `id = value` while node is `id [attrs]?` + // We try graph_attr_decl first; if it fails (no `=` after id) we fall through to + // node_or_edge. graph_attr_decl, node_or_edge_stmt, )), @@ -140,13 +141,10 @@ pub fn parse_dot_graph(input: &str) -> IResult<&str, DotGraph> { let (rest, _) = preceded(ws, char('{'))(rest)?; let (rest, stmts) = many0(statement)(rest)?; let (rest, _) = preceded(ws, char('}'))(rest)?; - Ok(( - rest, - DotGraph { - name: name.to_string(), - statements: stmts, - }, - )) + Ok((rest, DotGraph { + name: name.to_string(), + statements: stmts, + })) } // We need arrow to work with explicit error types @@ -359,7 +357,8 @@ mod tests { }"#; let (_, graph) = parse_dot_graph(input).unwrap(); assert_eq!(graph.name, "Branch"); - // graph [goal=...], rankdir=LR, node [defaults], 6 nodes, 1 chain + 2 edges = 12 + // graph [goal=...], rankdir=LR, node [defaults], 6 nodes, 1 chain + 2 edges = + // 12 assert!(graph.statements.len() >= 11); } diff --git a/lib/crates/fabro-graphviz/src/parser/lexer.rs b/lib/crates/fabro-graphviz/src/parser/lexer.rs index fe9331dd5..b121e8eab 100644 --- a/lib/crates/fabro-graphviz/src/parser/lexer.rs +++ b/lib/crates/fabro-graphviz/src/parser/lexer.rs @@ -171,7 +171,8 @@ pub mod combinators { } } - /// Parse a float: optional sign, optional integer part, `.`, fractional digits. + /// Parse a float: optional sign, optional integer part, `.`, fractional + /// digits. pub fn float_value(input: &str) -> IResult<&str, f64> { let (rest, raw) = recognize(pair( pair(opt(char('-')), take_while(|c: char| c.is_ascii_digit())), @@ -183,7 +184,8 @@ pub mod combinators { Ok((rest, val)) } - /// Parse an integer: optional sign, digits. Not followed by `.` (that's a float). + /// Parse an integer: optional sign, digits. Not followed by `.` (that's a + /// float). pub fn integer_value(input: &str) -> IResult<&str, i64> { let (rest, raw) = recognize(pair( opt(char('-')), @@ -215,7 +217,8 @@ pub mod combinators { Ok((rest, AstValue::Str(format!("{num}{unit}")))) } - /// Parse a bare string value containing hyphens and dots (e.g., `gpt-5.2-codex`). + /// Parse a bare string value containing hyphens and dots (e.g., + /// `gpt-5.2-codex`). /// /// Must start with an alpha/underscore character, then may continue with /// alphanumeric, underscore, hyphen, or dot characters. Must contain at @@ -231,8 +234,8 @@ pub mod combinators { Ok((rest, raw.to_string())) } - /// Parse an AST value: duration, float, integer, boolean, quoted string, bare identifier, - /// or bare string (e.g., `gpt-5.2-codex`). + /// Parse an AST value: duration, float, integer, boolean, quoted string, + /// bare identifier, or bare string (e.g., `gpt-5.2-codex`). pub fn value(input: &str) -> IResult<&str, AstValue> { let input = input.trim_start(); alt(( diff --git a/lib/crates/fabro-graphviz/src/parser/mod.rs b/lib/crates/fabro-graphviz/src/parser/mod.rs index 45f390d9e..d7ad10e30 100644 --- a/lib/crates/fabro-graphviz/src/parser/mod.rs +++ b/lib/crates/fabro-graphviz/src/parser/mod.rs @@ -3,11 +3,10 @@ pub mod grammar; pub mod lexer; pub mod semantic; +use self::ast::DotGraph; use crate::error::GraphvizError; use crate::graph::types::Graph; -use self::ast::DotGraph; - /// Parse a DOT source string into a raw `DotGraph` AST. /// /// Strips comments, parses the grammar, and validates there is no diff --git a/lib/crates/fabro-graphviz/src/parser/semantic.rs b/lib/crates/fabro-graphviz/src/parser/semantic.rs index e7065c243..db94d1a1e 100644 --- a/lib/crates/fabro-graphviz/src/parser/semantic.rs +++ b/lib/crates/fabro-graphviz/src/parser/semantic.rs @@ -58,7 +58,7 @@ fn derive_class_from_label(label: &str) -> String { } struct SemanticState { - graph: Graph, + graph: Graph, node_defaults: HashMap, edge_defaults: HashMap, } @@ -66,7 +66,7 @@ struct SemanticState { impl SemanticState { fn new(name: String) -> Self { Self { - graph: Graph::new(name), + graph: Graph::new(name), node_defaults: HashMap::new(), edge_defaults: HashMap::new(), } @@ -342,26 +342,26 @@ mod tests { #[test] fn ast_to_graph_simple_linear() { let dot = DotGraph { - name: "Simple".into(), + name: "Simple".into(), statements: vec![ Statement::GraphAttr(vec![("goal".into(), AstValue::Str("Run tests".into()))]), Statement::GraphAttrDecl("rankdir".into(), AstValue::Ident("LR".into())), Statement::Node(NodeStmt { - id: "start".into(), + id: "start".into(), attrs: Some(vec![ ("shape".into(), AstValue::Ident("Mdiamond".into())), ("label".into(), AstValue::Str("Start".into())), ]), }), Statement::Node(NodeStmt { - id: "exit".into(), + id: "exit".into(), attrs: Some(vec![ ("shape".into(), AstValue::Ident("Msquare".into())), ("label".into(), AstValue::Str("Exit".into())), ]), }), Statement::Node(NodeStmt { - id: "run_tests".into(), + id: "run_tests".into(), attrs: Some(vec![("label".into(), AstValue::Str("Run Tests".into()))]), }), Statement::Edge(EdgeStmt { @@ -385,18 +385,18 @@ mod tests { #[test] fn ast_to_graph_node_defaults_applied() { let dot = DotGraph { - name: "Defaults".into(), + name: "Defaults".into(), statements: vec![ Statement::NodeDefaults(vec![ ("shape".into(), AstValue::Ident("box".into())), ("timeout".into(), AstValue::Str("900s".into())), ]), Statement::Node(NodeStmt { - id: "plan".into(), + id: "plan".into(), attrs: Some(vec![("label".into(), AstValue::Str("Plan".into()))]), }), Statement::Node(NodeStmt { - id: "implement".into(), + id: "implement".into(), attrs: Some(vec![ ("label".into(), AstValue::Str("Implement".into())), ("timeout".into(), AstValue::Str("1800s".into())), @@ -429,13 +429,13 @@ mod tests { #[test] fn ast_to_graph_subgraph_class_derivation() { let dot = DotGraph { - name: "SubgraphTest".into(), + name: "SubgraphTest".into(), statements: vec![Statement::Subgraph(SubgraphStmt { - name: Some("cluster_loop".into()), + name: Some("cluster_loop".into()), statements: vec![ Statement::GraphAttrDecl("label".into(), AstValue::Str("Loop A".into())), Statement::Node(NodeStmt { - id: "plan".into(), + id: "plan".into(), attrs: None, }), ], @@ -450,16 +450,16 @@ mod tests { #[test] fn ast_to_graph_subgraph_class_from_graph_attr_block() { let dot = DotGraph { - name: "SubgraphAttrBlock".into(), + name: "SubgraphAttrBlock".into(), statements: vec![Statement::Subgraph(SubgraphStmt { - name: Some("cluster_review".into()), + name: Some("cluster_review".into()), statements: vec![ Statement::GraphAttr(vec![( "label".into(), AstValue::Str("Code Review".into()), )]), Statement::Node(NodeStmt { - id: "reviewer".into(), + id: "reviewer".into(), attrs: None, }), ], @@ -474,7 +474,7 @@ mod tests { #[test] fn ast_to_graph_edge_defaults_applied() { let dot = DotGraph { - name: "EdgeDefaults".into(), + name: "EdgeDefaults".into(), statements: vec![ Statement::EdgeDefaults(vec![("weight".into(), AstValue::Int(5))]), Statement::Edge(EdgeStmt { @@ -491,7 +491,7 @@ mod tests { #[test] fn ast_to_graph_chained_edges_with_attrs() { let dot = DotGraph { - name: "Chained".into(), + name: "Chained".into(), statements: vec![Statement::Edge(EdgeStmt { nodes: vec!["a".into(), "b".into(), "c".into()], attrs: Some(vec![("label".into(), AstValue::Str("next".into()))]), @@ -507,9 +507,9 @@ mod tests { #[test] fn ast_to_graph_class_attr_parsed() { let dot = DotGraph { - name: "ClassTest".into(), + name: "ClassTest".into(), statements: vec![Statement::Node(NodeStmt { - id: "review".into(), + id: "review".into(), attrs: Some(vec![( "class".into(), AstValue::Str("code,critical".into()), @@ -526,7 +526,7 @@ mod tests { #[test] fn ast_to_graph_implicit_nodes_from_edges() { let dot = DotGraph { - name: "Implicit".into(), + name: "Implicit".into(), statements: vec![Statement::Edge(EdgeStmt { nodes: vec!["a".into(), "b".into()], attrs: None, @@ -541,17 +541,17 @@ mod tests { #[test] fn codergen_mode_legacy_translates_to_type() { let dot = DotGraph { - name: "Legacy".into(), + name: "Legacy".into(), statements: vec![ Statement::Node(NodeStmt { - id: "classify".into(), + id: "classify".into(), attrs: Some(vec![( "codergen_mode".into(), AstValue::Str("one_shot".into()), )]), }), Statement::Node(NodeStmt { - id: "work".into(), + id: "work".into(), attrs: Some(vec![( "codergen_mode".into(), AstValue::Str("agent_loop".into()), @@ -580,9 +580,9 @@ mod tests { #[test] fn codergen_mode_does_not_override_explicit_type() { let dot = DotGraph { - name: "ExplicitType".into(), + name: "ExplicitType".into(), statements: vec![Statement::Node(NodeStmt { - id: "gate".into(), + id: "gate".into(), attrs: Some(vec![ ("type".into(), AstValue::Str("human".into())), ("codergen_mode".into(), AstValue::Str("one_shot".into())), diff --git a/lib/crates/fabro-graphviz/src/render.rs b/lib/crates/fabro-graphviz/src/render.rs index 44c0385cc..329d2c9fd 100644 --- a/lib/crates/fabro-graphviz/src/render.rs +++ b/lib/crates/fabro-graphviz/src/render.rs @@ -1,7 +1,6 @@ use std::fmt; use std::io::Write; use std::process::Command; - use std::sync::LazyLock; use anyhow::bail; @@ -25,7 +24,8 @@ impl fmt::Display for GraphFormat { } } -/// Dark mode CSS injected into SVG output (leading newline included for insertion). +/// Dark mode CSS injected into SVG output (leading newline included for +/// insertion). const DARK_MODE_STYLE: &str = r##"