Enforce no-inline-qualified-paths via clippy absolute_paths lint

Add clippy.toml with absolute-paths-max-segments = 2 (allowing std/core/alloc)
and enable the absolute_paths = "warn" lint workspace-wide. Fix all ~300
violations across the codebase: replace 3+-segment inline paths with use
statements so call sites read as operations::create() rather than
fabro_workflows::operations::create(). The demo module gets an allow
attribute since it constructs many API types by design.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-28 12:42:02 -04:00
parent 41e1809b21
commit 0b90305432
163 changed files with 1787 additions and 1644 deletions

View file

@ -67,6 +67,7 @@ object_store = "0.12.5"
[workspace.lints.clippy]
wildcard_imports = "warn"
absolute_paths = "warn"
[profile.release]
lto = "thin"

2
clippy.toml Normal file
View file

@ -0,0 +1,2 @@
absolute-paths-max-segments = 2
absolute-paths-allowed-crates = ["std", "core", "alloc"]

View file

@ -2,13 +2,14 @@ use crate::profiles::EnvContext;
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::subagent::{
make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, SessionFactory,
SubAgentManager,
make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, make_wait_tool,
SessionFactory, SubAgentManager,
};
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;
@ -43,7 +44,7 @@ pub trait AgentProfile: Send + Sync {
fn register_subagent_tools(
&mut self,
manager: Arc<tokio::sync::Mutex<SubAgentManager>>,
manager: Arc<Mutex<SubAgentManager>>,
session_factory: SessionFactory,
current_depth: usize,
) {
@ -55,7 +56,7 @@ pub trait AgentProfile: Send + Sync {
self.tool_registry_mut()
.register(make_send_input_tool(manager.clone()));
self.tool_registry_mut()
.register(crate::subagent::make_wait_tool(manager.clone()));
.register(make_wait_tool(manager.clone()));
self.tool_registry_mut()
.register(make_close_agent_tool(manager));
}

View file

@ -1,16 +1,26 @@
use crate::config::ToolApprovalFn;
use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback};
use crate::error::AbortReason;
use crate::tools::WebFetchSummarizer;
use crate::truncation;
use crate::{
subagent::{SessionFactory, SubAgentManager},
AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile,
Session, SessionConfig, Turn,
Sandbox, Session, SessionConfig, Turn,
};
use clap::{Args, Parser};
use fabro_llm::client::Client;
use fabro_llm::error::SdkError;
use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
use fabro_llm::provider::StreamEventStream;
use fabro_llm::types::{Request, Response};
use fabro_mcp::config::McpServerConfig;
use fabro_model::{Catalog, ModelRef, 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;
/// Public arguments for the agent command, usable from an external CLI.
#[derive(Args)]
@ -173,12 +183,9 @@ fn summarizer_model_id(provider: Provider) -> ModelRef {
}
}
fn build_summarizer(
provider: Provider,
llm_client: Option<Client>,
) -> Option<crate::tools::WebFetchSummarizer> {
fn build_summarizer(provider: Provider, llm_client: Option<Client>) -> Option<WebFetchSummarizer> {
let client = llm_client?;
Some(crate::tools::WebFetchSummarizer {
Some(WebFetchSummarizer {
client,
model_id: summarizer_model_id(provider),
})
@ -218,7 +225,7 @@ fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String {
serde_json::Value::String(s) => {
let s = s.strip_prefix(&cwd_prefix).unwrap_or(s);
let display = if s.len() > 80 {
format!("{}...", &s[..crate::truncation::floor_char_boundary(s, 77)])
format!("{}...", &s[..truncation::floor_char_boundary(s, 77)])
} else {
s.to_string()
};
@ -273,12 +280,8 @@ struct DebugMiddleware {
}
#[async_trait::async_trait]
impl fabro_llm::middleware::Middleware for DebugMiddleware {
async fn handle_complete(
&self,
request: fabro_llm::types::Request,
next: fabro_llm::middleware::NextFn,
) -> Result<fabro_llm::types::Response, fabro_llm::error::SdkError> {
impl Middleware for DebugMiddleware {
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, SdkError> {
let s = self.styles;
eprintln!(
"{}",
@ -306,9 +309,9 @@ impl fabro_llm::middleware::Middleware for DebugMiddleware {
async fn handle_stream(
&self,
request: fabro_llm::types::Request,
next: fabro_llm::middleware::NextStreamFn,
) -> Result<fabro_llm::provider::StreamEventStream, fabro_llm::error::SdkError> {
request: Request,
next: NextStreamFn,
) -> Result<StreamEventStream, SdkError> {
next(request).await
}
}
@ -319,12 +322,8 @@ struct VerboseMiddleware {
}
#[async_trait::async_trait]
impl fabro_llm::middleware::Middleware for VerboseMiddleware {
async fn handle_complete(
&self,
request: fabro_llm::types::Request,
next: fabro_llm::middleware::NextFn,
) -> Result<fabro_llm::types::Response, fabro_llm::error::SdkError> {
impl Middleware for VerboseMiddleware {
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, SdkError> {
let s = self.styles;
eprintln!(
"{}\n{}",
@ -344,16 +343,16 @@ impl fabro_llm::middleware::Middleware for VerboseMiddleware {
async fn handle_stream(
&self,
request: fabro_llm::types::Request,
next: fabro_llm::middleware::NextStreamFn,
) -> Result<fabro_llm::provider::StreamEventStream, fabro_llm::error::SdkError> {
request: Request,
next: NextStreamFn,
) -> Result<StreamEventStream, SdkError> {
next(request).await
}
}
pub async fn run_with_args(
args: AgentArgs,
mcp_servers: Vec<fabro_mcp::config::McpServerConfig>,
mcp_servers: Vec<McpServerConfig>,
) -> anyhow::Result<()> {
run_with_args_and_client(args, None, mcp_servers).await
}
@ -361,7 +360,7 @@ pub async fn run_with_args(
pub async fn run_with_args_and_client(
args: AgentArgs,
llm_client: Option<Client>,
mcp_servers: Vec<fabro_mcp::config::McpServerConfig>,
mcp_servers: Vec<McpServerConfig>,
) -> anyhow::Result<()> {
// Resolve color support once, leak to get 'static lifetime for use across threads
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
@ -407,7 +406,7 @@ pub async fn run_with_args_and_client(
// Build sandbox
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cwd_str = cwd.to_string_lossy().to_string();
let env: Arc<dyn crate::Sandbox> = Arc::new(crate::ReadBeforeWriteSandbox::new(Arc::new(
let env: Arc<dyn Sandbox> = Arc::new(crate::ReadBeforeWriteSandbox::new(Arc::new(
LocalSandbox::new(cwd),
)));
@ -415,8 +414,7 @@ pub async fn run_with_args_and_client(
let permissions = args.permissions.unwrap_or(PermissionLevel::ReadWrite);
let is_interactive = std::io::stdin().is_terminal() && !args.auto_approve;
let tool_approval = build_tool_approval(permissions, is_interactive, styles);
let tool_hooks: Arc<dyn crate::config::ToolHookCallback> =
Arc::new(crate::config::ToolApprovalAdapter(tool_approval));
let tool_hooks: Arc<dyn ToolHookCallback> = Arc::new(ToolApprovalAdapter(tool_approval));
let config = SessionConfig {
tool_hooks: Some(tool_hooks.clone()),
@ -426,7 +424,7 @@ pub async fn run_with_args_and_client(
};
// Register subagent tools
let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(
let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(
config.max_subagent_depth,
)));
let manager_for_callback = manager.clone();
@ -490,11 +488,11 @@ pub async fn run_with_args_and_client(
let cancel_token = session.cancel_token();
let abort_reason = session.abort_reason_handle();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
signal::ctrl_c().await.ok();
{
let mut guard = abort_reason.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = Some(crate::error::AbortReason::Cancelled);
*guard = Some(AbortReason::Cancelled);
}
}
cancel_token.cancel();
@ -562,7 +560,7 @@ pub async fn run_with_args_and_client(
..
} => {
let task_preview = if task.len() > 60 {
&task[..crate::truncation::floor_char_boundary(task, 60)]
&task[..truncation::floor_char_boundary(task, 60)]
} else {
task
};
@ -773,7 +771,7 @@ mod tests {
#[test]
fn build_profile_can_register_subagent_tools() {
let mut profile = build_profile(Provider::Anthropic, "model", None);
let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(1)));
let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(1)));
let factory: SessionFactory = Arc::new(|| {
panic!("factory should not be called in this test");
});

View file

@ -3,6 +3,7 @@ use crate::error::AgentError;
use crate::event::EventEmitter;
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};
@ -212,7 +213,7 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String {
let truncated = if args_str.len() > 500 {
format!(
"{}...",
&args_str[..crate::truncation::floor_char_boundary(&args_str, 500)]
&args_str[..truncation::floor_char_boundary(&args_str, 500)]
)
} else {
args_str
@ -226,8 +227,7 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String {
let truncated = if content_str.len() > 500 {
format!(
"{}...",
&content_str
[..crate::truncation::floor_char_boundary(&content_str, 500)]
&content_str[..truncation::floor_char_boundary(&content_str, 500)]
)
} else {
content_str

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::types::ReasoningEffort;
use fabro_mcp::config::McpServerConfig;
/// Callback invoked before each tool execution. Return `Ok(())` to allow,
@ -63,7 +64,7 @@ pub struct SessionConfig {
pub max_tool_rounds_per_input: usize,
pub default_command_timeout_ms: u64,
pub max_command_timeout_ms: u64,
pub reasoning_effort: Option<fabro_llm::types::ReasoningEffort>,
pub reasoning_effort: Option<ReasoningEffort>,
pub speed: Option<String>,
pub tool_output_limits: HashMap<String, usize>,
pub tool_line_limits: HashMap<String, usize>,
@ -187,14 +188,11 @@ mod tests {
fn config_with_custom_values() {
let config = SessionConfig {
max_turns: 50,
reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High),
reasoning_effort: Some(ReasoningEffort::High),
..Default::default()
};
assert_eq!(config.max_turns, 50);
assert_eq!(
config.reasoning_effort,
Some(fabro_llm::types::ReasoningEffort::High)
);
assert_eq!(config.reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(config.max_tool_rounds_per_input, 0);
}

View file

@ -1,27 +1,37 @@
use crate::agent_profile::AgentProfile;
use crate::compaction::{check_context_usage, compact_context};
use crate::config::SessionConfig;
use crate::error::{AbortReason, AgentError};
use crate::event::EventEmitter;
use crate::file_tracker::FileTracker;
use crate::history::History;
use crate::loop_detection::detect_loop;
use crate::mcp_integration;
use crate::memory::discover_memory;
use crate::profiles::EnvContext;
use crate::sandbox::Sandbox;
use crate::skills::{
default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, Skill,
default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, ExpandedInput, Skill,
};
use crate::types::{AgentEvent, SessionState, Turn};
use crate::subagent::{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::types::{Message, Request, StreamEvent, ToolChoice};
use fabro_mcp::config::McpServerConfig;
use fabro_llm::retry;
use fabro_llm::types::{
ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice,
};
use fabro_mcp::config::{McpServerConfig, McpTransport};
use fabro_mcp::connection_manager::McpConnectionManager;
use futures::StreamExt;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tokio::sync::{broadcast, Mutex as AsyncMutex};
use tokio::time;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
@ -43,8 +53,8 @@ pub struct Session {
skills: Vec<Skill>,
system_prompt: String,
file_tracker: FileTracker,
tool_env: Option<std::collections::HashMap<String, String>>,
subagent_manager: Option<Arc<tokio::sync::Mutex<crate::subagent::SubAgentManager>>>,
tool_env: Option<HashMap<String, String>>,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
}
impl Session {
@ -54,7 +64,7 @@ impl Session {
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
config: SessionConfig,
subagent_manager: Option<Arc<tokio::sync::Mutex<crate::subagent::SubAgentManager>>>,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
@ -79,7 +89,7 @@ impl Session {
}
}
pub fn set_tool_env(&mut self, env: std::collections::HashMap<String, String>) {
pub fn set_tool_env(&mut self, env: HashMap<String, String>) {
self.tool_env = Some(env);
}
@ -129,7 +139,7 @@ impl Session {
// then rewrite the config to Http using the sandbox's preview URL.
let mcp_servers = self.resolve_sandbox_mcp_servers().await;
let mut manager = fabro_mcp::connection_manager::McpConnectionManager::new();
let mut manager = McpConnectionManager::new();
let results = manager.start_servers(&mcp_servers).await;
for (server_name, result) in &results {
@ -156,7 +166,7 @@ impl Session {
}
let manager = Arc::new(manager);
let mcp_tools = crate::mcp_integration::make_mcp_tools(manager);
let mcp_tools = mcp_integration::make_mcp_tools(manager);
if let Some(profile) = Arc::get_mut(&mut self.provider_profile) {
for tool in mcp_tools {
profile.tool_registry_mut().register(tool);
@ -189,7 +199,7 @@ impl Session {
for config in &self.config.mcp_servers {
match &config.transport {
fabro_mcp::config::McpTransport::Sandbox { command, port, env } => {
McpTransport::Sandbox { command, port, env } => {
let port = *port;
match self.start_sandbox_mcp_server(command, port, env).await {
Ok((url, headers)) => {
@ -200,7 +210,7 @@ impl Session {
);
resolved.push(McpServerConfig {
name: config.name.clone(),
transport: fabro_mcp::config::McpTransport::Http { url, headers },
transport: McpTransport::Http { url, headers },
startup_timeout_secs: config.startup_timeout_secs,
tool_timeout_secs: config.tool_timeout_secs,
});
@ -347,7 +357,7 @@ impl Session {
}
#[must_use]
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<crate::types::SessionEvent> {
pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
self.event_emitter.subscribe()
}
@ -410,9 +420,9 @@ impl Session {
&mut self,
client: &Client,
request: &Request,
retry_policy: &fabro_llm::types::RetryPolicy,
retry_policy: &RetryPolicy,
) -> Result<StreamEventStream, AgentError> {
let stream_result = fabro_llm::retry::retry(retry_policy, || {
let stream_result = retry::retry(retry_policy, || {
let client = client.clone();
let request = request.clone();
async move { client.stream(&request).await }
@ -442,7 +452,7 @@ impl Session {
/// Build a callback that forwards `AgentEvent`s through this session's emitter.
#[must_use]
pub fn event_callback(&self) -> crate::subagent::SubAgentEventCallback {
pub fn event_callback(&self) -> SubAgentEventCallback {
let emitter = self.event_emitter.clone();
let session_id = self.id.clone();
Arc::new(move |event| {
@ -498,7 +508,7 @@ impl Session {
self.transition(SessionState::Closed);
}
pub fn set_reasoning_effort(&mut self, effort: Option<fabro_llm::types::ReasoningEffort>) {
pub fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffort>) {
self.config.reasoning_effort = effort;
}
@ -530,7 +540,7 @@ impl Session {
let token = self.cancel_token.clone();
let reason_handle = self.abort_reason.clone();
tokio::spawn(async move {
tokio::time::sleep(duration).await;
time::sleep(duration).await;
{
let mut guard = reason_handle.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
@ -581,7 +591,7 @@ impl Session {
// Expand skill references in input
let expanded = if self.skills.is_empty() {
crate::skills::ExpandedInput {
ExpandedInput {
text: input.to_string(),
skill_name: None,
}
@ -661,7 +671,7 @@ impl Session {
let retry_session_id = self.id.clone();
let retry_provider = self.provider_profile.provider().as_str().to_string();
let retry_model = self.provider_profile.model().to_string();
let retry_policy = fabro_llm::types::RetryPolicy {
let retry_policy = RetryPolicy {
max_retries: 3,
on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| {
retry_emitter.emit(
@ -782,13 +792,7 @@ impl Session {
.message
.content
.iter()
.filter(|p| {
matches!(
p,
fabro_llm::types::ContentPart::Other { .. }
| fabro_llm::types::ContentPart::Thinking(_)
)
})
.filter(|p| matches!(p, ContentPart::Other { .. } | ContentPart::Thinking(_)))
.cloned()
.collect();
let usage = response.usage.clone();
@ -824,7 +828,7 @@ impl Session {
round_count += 1;
// Execute tool calls (parallel or sequential based on provider)
let results = crate::tool_execution::execute_tool_calls(
let results = execute_tool_calls(
&tool_calls,
true,
self.provider_profile.tool_registry(),
@ -878,7 +882,7 @@ impl Session {
}
async fn compact_if_needed(&mut self) {
let over_threshold = crate::compaction::check_context_usage(
let over_threshold = check_context_usage(
&self.system_prompt,
&self.history,
self.provider_profile.as_ref(),
@ -887,7 +891,7 @@ impl Session {
&self.id,
);
if over_threshold && self.config.enable_context_compaction {
if let Err(e) = crate::compaction::compact_context(
if let Err(e) = compact_context(
&mut self.history,
&self.llm_client,
self.provider_profile.as_ref(),

View file

@ -6,6 +6,8 @@ use crate::types::{AgentEvent, 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<dyn Fn() -> Session + Send + Sync>;
@ -26,7 +28,7 @@ pub enum SubAgentStatus {
}
pub struct SubAgent {
task: Option<tokio::task::JoinHandle<Result<SubAgentResult, AgentError>>>,
task: Option<JoinHandle<Result<SubAgentResult, AgentError>>>,
followup_queue: Arc<Mutex<VecDeque<String>>>,
cancel_token: CancellationToken,
depth: usize,
@ -303,7 +305,7 @@ impl SubAgentManager {
}
pub fn make_spawn_agent_tool(
manager: Arc<tokio::sync::Mutex<SubAgentManager>>,
manager: Arc<AsyncMutex<SubAgentManager>>,
session_factory: SessionFactory,
current_depth: usize,
) -> RegisteredTool {
@ -358,7 +360,7 @@ pub fn make_spawn_agent_tool(
}
}
pub fn make_send_input_tool(manager: Arc<tokio::sync::Mutex<SubAgentManager>>) -> RegisteredTool {
pub fn make_send_input_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "send_input".into(),
@ -393,7 +395,7 @@ pub fn make_send_input_tool(manager: Arc<tokio::sync::Mutex<SubAgentManager>>) -
}
}
pub fn make_wait_tool(manager: Arc<tokio::sync::Mutex<SubAgentManager>>) -> RegisteredTool {
pub fn make_wait_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "wait".into(),
@ -425,7 +427,7 @@ pub fn make_wait_tool(manager: Arc<tokio::sync::Mutex<SubAgentManager>>) -> Regi
}
}
pub fn make_close_agent_tool(manager: Arc<tokio::sync::Mutex<SubAgentManager>>) -> RegisteredTool {
pub fn make_close_agent_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "close_agent".into(),

View file

@ -1,10 +1,11 @@
use crate::config::{SessionConfig, ToolHookCallback, ToolHookDecision};
use crate::event::EventEmitter;
use crate::sandbox::Sandbox;
use crate::tool_registry::ToolRegistry;
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};
use crate::truncation::truncate_tool_output;
use crate::types::AgentEvent;
use fabro_llm::types::ToolResult;
use fabro_llm::types::{ToolCall, ToolResult};
use futures::future;
use std::collections::HashMap;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@ -13,7 +14,7 @@ use tracing::debug;
/// Execute tool calls, choosing parallel or sequential based on `parallel` flag.
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool_calls(
tool_calls: &[fabro_llm::types::ToolCall],
tool_calls: &[ToolCall],
parallel: bool,
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
@ -55,7 +56,7 @@ pub async fn execute_tool_calls(
#[allow(clippy::too_many_arguments)]
async fn execute_tool_calls_sequential(
tool_calls: &[fabro_llm::types::ToolCall],
tool_calls: &[ToolCall],
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
@ -91,7 +92,7 @@ async fn execute_tool_calls_sequential(
#[allow(clippy::too_many_arguments)]
async fn execute_tool_calls_parallel(
tool_calls: &[fabro_llm::types::ToolCall],
tool_calls: &[ToolCall],
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
@ -132,13 +133,13 @@ async fn execute_tool_calls_parallel(
})
.collect();
futures::future::join_all(futures).await
future::join_all(futures).await
}
/// Execute a single tool call with event emission and output truncation.
#[allow(clippy::too_many_arguments)]
pub async fn execute_and_emit_one_tool(
tc: &fabro_llm::types::ToolCall,
tc: &ToolCall,
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
@ -165,8 +166,8 @@ pub async fn execute_and_emit_one_tool(
/// 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: &fabro_llm::types::ToolCall,
registered_tool: Option<&crate::tool_registry::RegisteredTool>,
tc: &ToolCall,
registered_tool: Option<&RegisteredTool>,
env: Arc<dyn Sandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: CancellationToken,
@ -262,8 +263,8 @@ async fn execute_and_emit_one_tool_with_lookup(
/// Execute a single tool call: argument validation and execution.
async fn execute_one_tool(
tc: &fabro_llm::types::ToolCall,
registered_tool: Option<&crate::tool_registry::RegisteredTool>,
tc: &ToolCall,
registered_tool: Option<&RegisteredTool>,
env: Arc<dyn Sandbox>,
cancel_token: CancellationToken,
tool_env: Option<&HashMap<String, String>>,
@ -276,7 +277,7 @@ async fn execute_one_tool(
return ToolResult::error(&tc.id, validation_error);
}
let ctx = crate::tool_registry::ToolContext {
let ctx = ToolContext {
env,
cancel: cancel_token,
tool_env: tool_env.cloned(),

View file

@ -1,6 +1,6 @@
use crate::config::SessionConfig;
use crate::sandbox::GrepOptions;
use crate::tool_registry::RegisteredTool;
use crate::tool_registry::{RegisteredTool, ToolRegistry};
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, ToolDefinition};
use fabro_model::ModelRef;
@ -47,7 +47,7 @@ fn html_to_markdown(text: &str) -> String {
/// `SessionConfig` (e.g. with a longer `default_command_timeout_ms`) for providers
/// that need non-default shell behavior.
pub fn register_core_tools(
registry: &mut crate::tool_registry::ToolRegistry,
registry: &mut ToolRegistry,
config: &SessionConfig,
summarizer: Option<WebFetchSummarizer>,
) {

View file

@ -1,9 +1,12 @@
use crate::error::AgentError;
use fabro_llm::error::SdkError;
use fabro_llm::types::{ContentPart, ThinkingData, ToolCall, ToolResult, Usage};
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
mod system_time_iso8601 {
use chrono::{DateTime, Utc};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::de::Error as DeError;
use serde::{self, Deserialize, Deserializer, Serializer};
use std::time::SystemTime;
@ -12,7 +15,7 @@ mod system_time_iso8601 {
S: Serializer,
{
let dt: DateTime<Utc> = (*time).into();
serializer.serialize_str(&dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
serializer.serialize_str(&dt.to_rfc3339_opts(SecondsFormat::Millis, true))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
@ -20,7 +23,7 @@ mod system_time_iso8601 {
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(DeError::custom)?;
Ok(dt.with_timezone(&Utc).into())
}
}
@ -128,7 +131,7 @@ pub enum AgentEvent {
is_error: bool,
},
Error {
error: crate::error::AgentError,
error: AgentError,
},
Warning {
kind: String,
@ -160,7 +163,7 @@ pub enum AgentEvent {
model: String,
attempt: usize,
delay_secs: f64,
error: fabro_llm::error::SdkError,
error: SdkError,
},
SubAgentSpawned {
agent_id: String,
@ -176,7 +179,7 @@ pub enum AgentEvent {
SubAgentFailed {
agent_id: String,
depth: usize,
error: crate::error::AgentError,
error: AgentError,
},
SubAgentClosed {
agent_id: String,
@ -481,7 +484,7 @@ mod tests {
let event = AgentEvent::SubAgentFailed {
agent_id: "sa-1".into(),
depth: 0,
error: crate::error::AgentError::ToolExecution("timeout".into()),
error: AgentError::ToolExecution("timeout".into()),
};
assert!(matches!(event, AgentEvent::SubAgentFailed { depth: 0, .. }));
}
@ -527,7 +530,7 @@ mod tests {
AgentEvent::SubAgentFailed {
agent_id: "sa-1".into(),
depth: 0,
error: crate::error::AgentError::ToolExecution("oops".into()),
error: AgentError::ToolExecution("oops".into()),
},
AgentEvent::SubAgentClosed {
agent_id: "sa-1".into(),
@ -669,7 +672,7 @@ mod tests {
#[test]
fn error_event_serde_roundtrip_with_agent_error() {
let event = AgentEvent::Error {
error: crate::error::AgentError::Llm(fabro_llm::error::SdkError::Network {
error: AgentError::Llm(fabro_llm::error::SdkError::Network {
message: "refused".into(),
source: None,
}),
@ -720,7 +723,7 @@ mod tests {
let event = AgentEvent::SubAgentFailed {
agent_id: "sa-1".into(),
depth: 0,
error: crate::error::AgentError::ToolExecution("cmd failed".into()),
error: AgentError::ToolExecution("cmd failed".into()),
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
@ -735,7 +738,7 @@ mod tests {
#[test]
fn error_event_preserves_error_type_through_json() {
let event = AgentEvent::Error {
error: crate::error::AgentError::ToolExecution("cmd failed".into()),
error: AgentError::ToolExecution("cmd failed".into()),
};
let json = serde_json::to_string(&event).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();

View file

@ -1,5 +1,6 @@
use std::{env, fs, path::Path};
use schemars::schema::Schema;
use typify::{TypeSpace, TypeSpaceSettings};
fn main() {
@ -23,10 +24,10 @@ fn main() {
.as_object()
.expect("no components/schemas in spec");
let named_schemas: Vec<(String, schemars::schema::Schema)> = schemas
let named_schemas: Vec<(String, Schema)> = schemas
.iter()
.map(|(name, value)| {
let schema: schemars::schema::Schema = serde_json::from_value(value.clone())
let schema: Schema = serde_json::from_value(value.clone())
.unwrap_or_else(|e| panic!("failed to parse schema {name}: {e}"));
(name.clone(), schema)
})

View file

@ -1,4 +1,4 @@
#[allow(clippy::derivable_impls)]
#[allow(clippy::absolute_paths, clippy::derivable_impls)]
mod generated {
include!(concat!(env!("OUT_DIR"), "/openapi_types.rs"));
}

View file

@ -6,6 +6,8 @@ use axum::Router;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::sync::oneshot;
use tracing::{debug, error, info, warn};
type HmacSha256 = Hmac<Sha256>;
@ -108,7 +110,7 @@ fn parse_event_metadata(body: &[u8]) -> (String, String) {
/// A running webhook listener that can be shut down.
pub struct WebhookListener {
port: u16,
shutdown_tx: tokio::sync::oneshot::Sender<()>,
shutdown_tx: oneshot::Sender<()>,
}
impl WebhookListener {
@ -132,7 +134,7 @@ pub async fn spawn_webhook_listener(secret: Vec<u8>) -> anyhow::Result<WebhookLi
.route("/webhooks/github", post(webhook_handler))
.with_state(state);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(listener, router)
@ -198,7 +200,7 @@ impl WebhookManager {
}
async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
let output = tokio::process::Command::new("tailscale")
let output = Command::new("tailscale")
.args(["funnel", &port.to_string()])
.output()
.await?;
@ -209,7 +211,7 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
}
// Get the funnel URL from `tailscale funnel status`
let status_output = tokio::process::Command::new("tailscale")
let status_output = Command::new("tailscale")
.args(["funnel", "status"])
.output()
.await?;
@ -233,7 +235,7 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
}
async fn disable_tailscale_funnel(port: u16) {
match tokio::process::Command::new("tailscale")
match Command::new("tailscale")
.args(["funnel", "off", &port.to_string()])
.output()
.await

View file

@ -2,12 +2,14 @@ use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use rustls_pki_types::CertificateDer;
use serde::Deserialize;
use tracing::warn;
use crate::error::ApiError;
use fabro_config::server::ApiSettings;
/// JWT claims for service-to-service authentication.
#[derive(Debug, Deserialize)]
@ -57,7 +59,7 @@ pub fn decode_pem_env(name: &str, value: &str) -> String {
if value.starts_with("-----") {
return value.to_string();
}
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
let bytes = base64::Engine::decode(&BASE64_STANDARD, value)
.unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}"));
String::from_utf8(bytes)
.unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}"))
@ -67,10 +69,7 @@ pub fn decode_pem_env(name: &str, value: &str) -> String {
///
/// Call this once at startup before serving requests. Panics if the
/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config).
pub fn resolve_auth_mode(
api_config: &fabro_config::server::ApiSettings,
allowed_usernames: Vec<String>,
) -> AuthMode {
pub fn resolve_auth_mode(api_config: &ApiSettings, allowed_usernames: Vec<String>) -> AuthMode {
use fabro_config::server::ApiAuthStrategy;
if api_config.authentication_strategies.is_empty() {

View file

@ -1,4 +1,4 @@
#[allow(clippy::wildcard_imports)]
#[allow(clippy::wildcard_imports, clippy::absolute_paths)]
mod demo;
pub mod error;
pub mod github_webhooks;

View file

@ -2,18 +2,23 @@ use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use fabro_config::server::{load_server_settings, resolve_storage_dir};
use fabro_model::{Catalog, Provider};
use fabro_util::terminal::Styles;
use fabro_workflows::git::GitAuthor;
use tokio::net::TcpListener;
use tokio::time::interval;
use tracing::{error, info, warn};
use clap::Args;
use fabro_config::FabroSettings;
use crate::jwt_auth::{AuthMode, AuthStrategy};
use crate::server::build_router;
use crate::tls::ClientAuth;
use crate::github_webhooks::WebhookManager;
use crate::jwt_auth::{decode_pem_env, resolve_auth_mode, AuthMode, AuthStrategy};
use crate::server::{build_router, create_app_state_with_options, spawn_scheduler};
use crate::tls::{build_rustls_config, serve_tls, ClientAuth};
use fabro_llm::client::Client as LlmClient;
use fabro_sandbox::SandboxProvider;
use fabro_workflows::pipeline::LlmSpec;
@ -62,7 +67,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let dry_run_mode = if args.dry_run {
true
} else {
match fabro_llm::client::Client::from_env().await {
match LlmClient::from_env().await {
Ok(c) if c.provider_names().is_empty() => {
eprintln!(
"{} No LLM providers configured. Running in dry-run mode.",
@ -83,8 +88,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
// Initialize data directory and SQLite database
let config_path = args.config;
let server_settings = fabro_config::server::load_server_settings(config_path.as_deref())?;
let data_dir = fabro_config::server::resolve_storage_dir(&server_settings);
let server_settings = load_server_settings(config_path.as_deref())?;
let data_dir = resolve_storage_dir(&server_settings);
// Shared config for live reloading
let shared_config = Arc::new(RwLock::new(server_settings));
@ -121,7 +126,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
.as_ref()
.map(|w| w.auth.allowed_usernames.clone())
.unwrap_or_default();
let auth_mode = crate::jwt_auth::resolve_auth_mode(&api, allowed_usernames);
let auth_mode = resolve_auth_mode(&api, allowed_usernames);
let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode));
let max_concurrent_runs = args
.max_concurrent_runs
@ -133,7 +138,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let git_author = {
let cfg = shared_config.read().expect("config lock poisoned");
let author = cfg.git_author();
fabro_workflows::git::GitAuthor::from_options(
GitAuthor::from_options(
author.and_then(|a| a.name.clone()),
author.and_then(|a| a.email.clone()),
)
@ -142,7 +147,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let cfg = shared_config.read().expect("config lock poisoned");
cfg.hooks.clone()
};
let state = crate::server::create_app_state_with_options(
let state = create_app_state_with_options(
db,
factory,
dry_run_mode,
@ -150,7 +155,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
git_author,
hooks,
);
crate::server::spawn_scheduler(Arc::clone(&state));
spawn_scheduler(Arc::clone(&state));
let router = build_router(state, auth_mode);
let addr = format!("{}:{}", args.host, args.port);
@ -183,13 +188,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let private_key_pem = read_github_private_key();
match (secret, private_key_pem) {
(Some(secret), Some(pem)) => {
match crate::github_webhooks::WebhookManager::start(
secret.into_bytes(),
&app_id,
&pem,
)
.await
{
match WebhookManager::start(secret.into_bytes(), &app_id, &pem).await {
Ok(manager) => Some(manager),
Err(err) => {
error!(error = %err, "Failed to start webhook listener");
@ -210,11 +209,11 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
let config_for_poll = Arc::clone(&shared_config);
let config_path_for_poll = config_path.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
let mut interval = interval(Duration::from_secs(5));
interval.tick().await; // skip first immediate tick
loop {
interval.tick().await;
match fabro_config::server::load_server_settings(config_path_for_poll.as_deref()) {
match load_server_settings(config_path_for_poll.as_deref()) {
Ok(new_config) => {
let changed = {
let cfg = config_for_poll.read().expect("config lock poisoned");
@ -243,12 +242,12 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
if let Some(ref tls_config) = tls_config {
let client_auth = client_auth.unwrap();
let rustls_config = crate::tls::build_rustls_config(tls_config, client_auth);
let rustls_config = build_rustls_config(tls_config, client_auth);
let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config);
info!("TLS enabled");
crate::tls::serve_tls(listener, tls_acceptor, router).await?;
serve_tls(listener, tls_acceptor, router).await?;
} else {
axum::serve(listener, router).await?;
}
@ -308,10 +307,7 @@ fn resolve_model_provider(
/// Read the GitHub App private key from the environment, decoding base64 if needed.
fn read_github_private_key() -> Option<String> {
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
Some(crate::jwt_auth::decode_pem_env(
"GITHUB_APP_PRIVATE_KEY",
&raw,
))
Some(decode_pem_env("GITHUB_APP_PRIVATE_KEY", &raw))
}
/// Derive client certificate verification mode from the resolved auth strategies.

View file

@ -3,21 +3,41 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::extract::{self as axum_extract, Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, Sse};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use fabro_config::sandbox::SandboxSettings;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{generate, generate_object, GenerateParams};
use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
};
use fabro_retro::retro::{derive_retro, extract_stage_durations, Retro};
use fabro_util::redact::redact_jsonl_line;
use fabro_workflows::error::FabroError;
use fabro_workflows::git::GitAuthor;
use fabro_workflows::handler::HandlerRegistry;
use futures_util::stream;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::{Notify, OnceCell};
use tokio::task::spawn_blocking;
use tokio::time::{sleep, timeout};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tower::ServiceExt;
use tower::{service_fn, ServiceExt};
use tracing::{error, info};
use crate::demo;
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser};
use crate::sessions as sessions_mod;
use crate::sessions::{new_session_store, SessionStore};
use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer};
use fabro_retro::RetroExt;
use fabro_workflows::context::Context;
@ -75,7 +95,7 @@ struct ManagedRun {
event_tx: Option<broadcast::Sender<WorkflowRunEvent>>,
context: Option<Context>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
cancel_tx: Option<oneshot::Sender<()>>,
cancel_token: Option<Arc<AtomicBool>>,
run_dir: Option<std::path::PathBuf>,
}
@ -98,8 +118,7 @@ struct AggregateUsageTotals {
}
type LlmSpecFactory = dyn Fn() -> LlmSpec + Send + Sync;
type RegistryFactoryOverride =
dyn Fn(Arc<dyn Interviewer>) -> fabro_workflows::handler::HandlerRegistry + Send + Sync;
type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync;
/// Shared application state for the server.
pub struct AppState {
@ -110,11 +129,11 @@ pub struct AppState {
pub dry_run: bool,
pub db: sqlx::SqlitePool,
max_concurrent_runs: usize,
scheduler_notify: tokio::sync::Notify,
scheduler_notify: Notify,
pub hooks: Vec<fabro_hooks::HookDefinition>,
git_author: fabro_workflows::git::GitAuthor,
pub sessions: crate::sessions::SessionStore,
llm_client: tokio::sync::OnceCell<fabro_llm::client::Client>,
git_author: GitAuthor,
pub sessions: SessionStore,
llm_client: OnceCell<LlmClient>,
}
/// Build the axum Router with all run endpoints.
@ -140,7 +159,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
.layer(axum::Extension(auth_mode))
.with_state(state);
let dispatch = tower::service_fn(move |req: axum::extract::Request| {
let dispatch = service_fn(move |req: axum_extract::Request| {
let demo = demo_router.clone();
let real = real_router.clone();
async move {
@ -157,99 +176,78 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/runs",
get(crate::demo::list_runs).post(crate::demo::start_run_stub),
)
.route("/runs/{id}", get(crate::demo::get_run_status))
.route("/runs/{id}/questions", get(crate::demo::get_questions_stub))
.route(
"/runs/{id}/questions/{qid}/answer",
post(crate::demo::answer_stub),
)
.route("/runs/{id}/events", get(crate::demo::run_events_stub))
.route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub))
.route("/runs/{id}/context", get(crate::demo::context_stub))
.route("/runs/{id}/cancel", post(crate::demo::cancel_stub))
.route("/runs/{id}/pause", post(crate::demo::pause_stub))
.route("/runs/{id}/unpause", post(crate::demo::unpause_stub))
.route("/runs/{id}/graph", get(crate::demo::get_run_graph))
.route("/runs/{id}/retro", get(crate::demo::get_run_retro))
.route("/runs/{id}/stages", get(crate::demo::get_run_stages))
.route("/runs", get(demo::list_runs).post(demo::start_run_stub))
.route("/runs/{id}", get(demo::get_run_status))
.route("/runs/{id}/questions", get(demo::get_questions_stub))
.route("/runs/{id}/questions/{qid}/answer", post(demo::answer_stub))
.route("/runs/{id}/events", get(demo::run_events_stub))
.route("/runs/{id}/checkpoint", get(demo::checkpoint_stub))
.route("/runs/{id}/context", get(demo::context_stub))
.route("/runs/{id}/cancel", post(demo::cancel_stub))
.route("/runs/{id}/pause", post(demo::pause_stub))
.route("/runs/{id}/unpause", post(demo::unpause_stub))
.route("/runs/{id}/graph", get(demo::get_run_graph))
.route("/runs/{id}/retro", get(demo::get_run_retro))
.route("/runs/{id}/stages", get(demo::get_run_stages))
.route(
"/runs/{id}/stages/{stageId}/turns",
get(crate::demo::get_stage_turns),
)
.route("/runs/{id}/files", get(crate::demo::get_run_files))
.route("/runs/{id}/usage", get(crate::demo::get_run_usage))
.route(
"/runs/{id}/verification",
get(crate::demo::get_run_verification),
)
.route("/runs/{id}/settings", get(crate::demo::get_run_settings))
.route("/runs/{id}/steer", post(crate::demo::steer_run_stub))
.route(
"/runs/{id}/preview",
post(crate::demo::generate_preview_url_stub),
)
.route("/workflows", get(crate::demo::list_workflows))
.route("/workflows/{name}", get(crate::demo::get_workflow))
.route(
"/workflows/{name}/runs",
get(crate::demo::list_workflow_runs),
get(demo::get_stage_turns),
)
.route("/runs/{id}/files", get(demo::get_run_files))
.route("/runs/{id}/usage", get(demo::get_run_usage))
.route("/runs/{id}/verification", get(demo::get_run_verification))
.route("/runs/{id}/settings", get(demo::get_run_settings))
.route("/runs/{id}/steer", post(demo::steer_run_stub))
.route("/runs/{id}/preview", post(demo::generate_preview_url_stub))
.route("/workflows", get(demo::list_workflows))
.route("/workflows/{name}", get(demo::get_workflow))
.route("/workflows/{name}/runs", get(demo::list_workflow_runs))
.route(
"/verification/criteria",
get(crate::demo::list_verification_criteria),
get(demo::list_verification_criteria),
)
.route(
"/verification/criteria/{id}",
get(crate::demo::get_verification_criterion),
get(demo::get_verification_criterion),
)
.route(
"/verification/controls",
get(crate::demo::list_verification_controls),
get(demo::list_verification_controls),
)
.route(
"/verification/controls/{id}",
get(crate::demo::get_verification_control),
get(demo::get_verification_control),
)
.route(
"/verification/signoffs",
get(crate::demo::list_signoffs).post(crate::demo::create_signoff_stub),
get(demo::list_signoffs).post(demo::create_signoff_stub),
)
.route("/verification/signoffs/{id}", get(crate::demo::get_signoff))
.route("/retros", get(crate::demo::list_retros))
.route("/verification/signoffs/{id}", get(demo::get_signoff))
.route("/retros", get(demo::list_retros))
.route(
"/sessions",
get(crate::demo::list_sessions).post(crate::demo::create_session_stub),
)
.route("/sessions/{id}", get(crate::demo::get_session))
.route(
"/sessions/{id}/messages",
post(crate::demo::send_message_stub),
)
.route(
"/sessions/{id}/events",
get(crate::demo::session_events_stub),
get(demo::list_sessions).post(demo::create_session_stub),
)
.route("/sessions/{id}", get(demo::get_session))
.route("/sessions/{id}/messages", post(demo::send_message_stub))
.route("/sessions/{id}/events", get(demo::session_events_stub))
.route(
"/insights/queries",
get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub),
get(demo::list_saved_queries).post(demo::save_query_stub),
)
.route(
"/insights/queries/{id}",
get(crate::demo::get_saved_query)
.put(crate::demo::update_query_stub)
.delete(crate::demo::delete_query_stub),
get(demo::get_saved_query)
.put(demo::update_query_stub)
.delete(demo::delete_query_stub),
)
.route("/insights/execute", post(crate::demo::execute_query_stub))
.route("/insights/history", get(crate::demo::list_query_history))
.route("/models", get(crate::demo::list_models))
.route("/insights/execute", post(demo::execute_query_stub))
.route("/insights/history", get(demo::list_query_history))
.route("/models", get(demo::list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(crate::demo::get_server_settings))
.route("/usage", get(crate::demo::get_aggregate_usage))
.route("/settings", get(demo::get_server_settings))
.route("/usage", get(demo::get_aggregate_usage))
}
fn real_routes() -> Router<Arc<AppState>> {
@ -289,16 +287,13 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/retros", get(not_implemented))
.route(
"/sessions",
get(crate::sessions::list_sessions).post(crate::sessions::create_session),
)
.route("/sessions/{id}", get(crate::sessions::retrieve_session))
.route(
"/sessions/{id}/messages",
post(crate::sessions::send_message),
get(sessions_mod::list_sessions).post(sessions_mod::create_session),
)
.route("/sessions/{id}", get(sessions_mod::retrieve_session))
.route("/sessions/{id}/messages", post(sessions_mod::send_message))
.route(
"/sessions/{id}/events",
get(crate::sessions::stream_session_events),
get(sessions_mod::stream_session_events),
)
.route(
"/insights/queries",
@ -312,7 +307,7 @@ fn real_routes() -> Router<Arc<AppState>> {
)
.route("/insights/execute", post(not_implemented))
.route("/insights/history", get(not_implemented))
.route("/models", get(crate::demo::list_models))
.route("/models", get(demo::list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(not_implemented))
@ -393,7 +388,7 @@ pub fn create_app_state(
llm_spec_factory,
false,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
)
}
@ -402,10 +397,7 @@ pub fn create_app_state(
pub fn create_app_state_with_registry_factory(
db: sqlx::SqlitePool,
llm_spec_factory: impl Fn() -> LlmSpec + Send + Sync + 'static,
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> fabro_workflows::handler::HandlerRegistry
+ Send
+ Sync
+ 'static,
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
build_app_state(
db,
@ -413,7 +405,7 @@ pub fn create_app_state_with_registry_factory(
Some(Box::new(registry_factory_override)),
false,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
)
}
@ -424,7 +416,7 @@ pub fn create_app_state_with_options(
llm_spec_factory: impl Fn() -> LlmSpec + Send + Sync + 'static,
dry_run: bool,
max_concurrent_runs: usize,
git_author: fabro_workflows::git::GitAuthor,
git_author: GitAuthor,
hooks: Vec<fabro_hooks::HookDefinition>,
) -> Arc<AppState> {
build_app_state(
@ -444,7 +436,7 @@ fn build_app_state(
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
dry_run: bool,
max_concurrent_runs: usize,
git_author: fabro_workflows::git::GitAuthor,
git_author: GitAuthor,
hooks: Vec<fabro_hooks::HookDefinition>,
) -> Arc<AppState> {
Arc::new(AppState {
@ -455,11 +447,11 @@ fn build_app_state(
dry_run,
db,
max_concurrent_runs,
scheduler_notify: tokio::sync::Notify::new(),
scheduler_notify: Notify::new(),
hooks,
git_author,
sessions: crate::sessions::new_session_store(),
llm_client: tokio::sync::OnceCell::new(),
sessions: new_session_store(),
llm_client: OnceCell::new(),
})
}
@ -524,7 +516,7 @@ async fn start_run(
let settings = fabro_config::FabroSettings {
dry_run: Some(state.dry_run),
hooks: state.hooks.clone(),
sandbox: Some(fabro_config::sandbox::SandboxSettings {
sandbox: Some(SandboxSettings {
provider: Some("local".to_string()),
..Default::default()
}),
@ -544,7 +536,7 @@ async fn start_run(
base_branch: None,
}) {
Ok(created) => created,
Err(ref err @ fabro_workflows::error::FabroError::ValidationFailed { ref diagnostics }) => {
Err(ref err @ FabroError::ValidationFailed { ref diagnostics }) => {
let message = if diagnostics.is_empty() {
err.to_string()
} else {
@ -556,7 +548,7 @@ async fn start_run(
};
return ApiError::bad_request(message).into_response();
}
Err(err @ fabro_workflows::error::FabroError::Parse(_)) => {
Err(err @ FabroError::Parse(_)) => {
return ApiError::bad_request(err.to_string()).into_response();
}
Err(err) => {
@ -619,7 +611,7 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
None => return,
};
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
let cancel_token = Arc::new(AtomicBool::new(false));
let (event_tx, _) = broadcast::channel(256);
@ -751,7 +743,7 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
},
)
.await?;
Ok::<_, fabro_workflows::error::FabroError>(pipeline::execute(initialized).await)
Ok::<_, FabroError>(pipeline::execute(initialized).await)
}
};
@ -778,8 +770,8 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
if let Some(ref cp) = checkpoint {
let failed = result.is_err();
let completed_stages = fabro_workflows::build_completed_stages(cp, failed);
let stage_durations = fabro_retro::retro::extract_stage_durations(&run_options.run_dir);
let retro = fabro_retro::retro::derive_retro(
let stage_durations = extract_stage_durations(&run_options.run_dir);
let retro = derive_retro(
&run_id,
"workflow",
"",
@ -817,7 +809,7 @@ async fn execute_run(state: Arc<AppState>, run_id: String) {
info!(run_id = %run_id, "Run completed");
managed_run.status = RunStatus::Completed;
}
Err(fabro_workflows::error::FabroError::Cancelled) => {
Err(FabroError::Cancelled) => {
info!(run_id = %run_id, "Run cancelled");
managed_run.status = RunStatus::Cancelled;
}
@ -844,7 +836,7 @@ pub fn spawn_scheduler(state: Arc<AppState>) {
loop {
tokio::select! {
_ = state.scheduler_notify.notified() => {},
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {},
_ = sleep(std::time::Duration::from_secs(1)) => {},
}
// Promote as many queued runs as capacity allows
loop {
@ -1045,7 +1037,7 @@ async fn get_events(
let stream = BroadcastStream::new(rx).filter_map(|result| match result {
Ok(event) => {
let data = serde_json::to_string(&event).unwrap_or_default();
let data = fabro_util::redact::redact_jsonl_line(&data);
let data = redact_jsonl_line(&data);
Some(Ok::<Event, std::convert::Infallible>(
Event::default().data(data),
))
@ -1196,16 +1188,12 @@ async fn test_model(
.into_response();
}
let params = fabro_llm::generate::GenerateParams::new(&info.id)
let params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt("Say OK")
.max_tokens(16);
let result = tokio::time::timeout(
Duration::from_secs(30),
fabro_llm::generate::generate(params),
)
.await;
let result = timeout(Duration::from_secs(30), generate(params)).await;
match result {
Ok(Ok(_)) => Json(serde_json::json!({
@ -1228,26 +1216,26 @@ async fn test_model(
}
}
fn finish_reason_to_api_stop_reason(reason: &fabro_llm::types::FinishReason) -> String {
fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
match reason {
fabro_llm::types::FinishReason::Stop => "end_turn".to_string(),
fabro_llm::types::FinishReason::Length => "max_tokens".to_string(),
fabro_llm::types::FinishReason::ToolCalls => "tool_calls".to_string(),
fabro_llm::types::FinishReason::ContentFilter => "content_filter".to_string(),
fabro_llm::types::FinishReason::Error => "error".to_string(),
fabro_llm::types::FinishReason::Other(s) => s.clone(),
FinishReason::Stop => "end_turn".to_string(),
FinishReason::Length => "max_tokens".to_string(),
FinishReason::ToolCalls => "tool_calls".to_string(),
FinishReason::ContentFilter => "content_filter".to_string(),
FinishReason::Error => "error".to_string(),
FinishReason::Other(s) => s.clone(),
}
}
fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> fabro_llm::types::Message {
fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> LlmMessage {
let role = match msg.role {
fabro_api_types::CompletionMessageRole::System => fabro_llm::types::Role::System,
fabro_api_types::CompletionMessageRole::User => fabro_llm::types::Role::User,
fabro_api_types::CompletionMessageRole::Assistant => fabro_llm::types::Role::Assistant,
fabro_api_types::CompletionMessageRole::Tool => fabro_llm::types::Role::Tool,
fabro_api_types::CompletionMessageRole::Developer => fabro_llm::types::Role::Developer,
fabro_api_types::CompletionMessageRole::System => Role::System,
fabro_api_types::CompletionMessageRole::User => Role::User,
fabro_api_types::CompletionMessageRole::Assistant => Role::Assistant,
fabro_api_types::CompletionMessageRole::Tool => Role::Tool,
fabro_api_types::CompletionMessageRole::Developer => Role::Developer,
};
let content: Vec<fabro_llm::types::ContentPart> = msg
let content: Vec<ContentPart> = msg
.content
.iter()
.filter_map(|part| {
@ -1255,7 +1243,7 @@ fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> fabro_llm::t
serde_json::from_value(json).ok()
})
.collect();
fabro_llm::types::Message {
LlmMessage {
role,
content,
name: msg.name.clone(),
@ -1263,13 +1251,13 @@ fn convert_api_message(msg: &fabro_api_types::CompletionMessage) -> fabro_llm::t
}
}
fn convert_llm_message(msg: &fabro_llm::types::Message) -> fabro_api_types::CompletionMessage {
fn convert_llm_message(msg: &LlmMessage) -> fabro_api_types::CompletionMessage {
let role = match msg.role {
fabro_llm::types::Role::System => fabro_api_types::CompletionMessageRole::System,
fabro_llm::types::Role::User => fabro_api_types::CompletionMessageRole::User,
fabro_llm::types::Role::Assistant => fabro_api_types::CompletionMessageRole::Assistant,
fabro_llm::types::Role::Tool => fabro_api_types::CompletionMessageRole::Tool,
fabro_llm::types::Role::Developer => fabro_api_types::CompletionMessageRole::Developer,
Role::System => fabro_api_types::CompletionMessageRole::System,
Role::User => fabro_api_types::CompletionMessageRole::User,
Role::Assistant => fabro_api_types::CompletionMessageRole::Assistant,
Role::Tool => fabro_api_types::CompletionMessageRole::Tool,
Role::Developer => fabro_api_types::CompletionMessageRole::Developer,
};
let content: Vec<fabro_api_types::CompletionContentPart> = msg
.content
@ -1310,22 +1298,22 @@ async fn create_completion(
info!(model = %model_id, provider = ?provider_name, "Completion request received");
// Build messages list
let mut messages: Vec<fabro_llm::types::Message> = Vec::new();
let mut messages: Vec<LlmMessage> = Vec::new();
if let Some(system) = req.system {
messages.push(fabro_llm::types::Message::system(system));
messages.push(LlmMessage::system(system));
}
for msg in &req.messages {
messages.push(convert_api_message(msg));
}
// Convert tools
let tools: Option<Vec<fabro_llm::types::ToolDefinition>> = if req.tools.is_empty() {
let tools: Option<Vec<ToolDefinition>> = if req.tools.is_empty() {
None
} else {
Some(
req.tools
.into_iter()
.map(|t| fabro_llm::types::ToolDefinition {
.map(|t| ToolDefinition {
name: t.name,
description: t.description,
parameters: t.parameters,
@ -1335,20 +1323,17 @@ async fn create_completion(
};
// Convert tool_choice
let tool_choice: Option<fabro_llm::types::ToolChoice> =
req.tool_choice.map(|tc| match tc.mode {
fabro_api_types::CompletionToolChoiceMode::Auto => fabro_llm::types::ToolChoice::Auto,
fabro_api_types::CompletionToolChoiceMode::None => fabro_llm::types::ToolChoice::None,
fabro_api_types::CompletionToolChoiceMode::Required => {
fabro_llm::types::ToolChoice::Required
}
fabro_api_types::CompletionToolChoiceMode::Named => {
fabro_llm::types::ToolChoice::named(tc.tool_name.unwrap_or_default())
}
});
let tool_choice: Option<ToolChoice> = req.tool_choice.map(|tc| match tc.mode {
fabro_api_types::CompletionToolChoiceMode::Auto => ToolChoice::Auto,
fabro_api_types::CompletionToolChoiceMode::None => ToolChoice::None,
fabro_api_types::CompletionToolChoiceMode::Required => ToolChoice::Required,
fabro_api_types::CompletionToolChoiceMode::Named => {
ToolChoice::named(tc.tool_name.unwrap_or_default())
}
});
// Build the LLM request
let request = fabro_llm::types::Request {
let request = LlmRequest {
model: model_id.clone(),
messages,
provider: provider_name,
@ -1376,23 +1361,23 @@ async fn create_completion(
if state.dry_run {
let msg_id = ulid::Ulid::new().to_string();
if use_stream {
let finish_event = fabro_llm::types::StreamEvent::finish(
fabro_llm::types::FinishReason::Stop,
fabro_llm::types::Usage::default(),
fabro_llm::types::Response {
let finish_event = StreamEvent::finish(
FinishReason::Stop,
Usage::default(),
LlmResponse {
id: msg_id.clone(),
model: model_id.clone(),
provider: String::new(),
message: fabro_llm::types::Message::assistant(""),
finish_reason: fabro_llm::types::FinishReason::Stop,
usage: fabro_llm::types::Usage::default(),
message: LlmMessage::assistant(""),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
raw: None,
warnings: vec![],
rate_limit: None,
},
);
let json = serde_json::to_string(&finish_event).unwrap_or_default();
let sse_stream = futures_util::stream::iter(vec![Ok::<_, std::convert::Infallible>(
let sse_stream = stream::iter(vec![Ok::<_, std::convert::Infallible>(
Event::default().event("stream_event").data(json),
)]);
return Sse::new(sse_stream).into_response();
@ -1418,11 +1403,7 @@ async fn create_completion(
}
// Get or create LLM client (cached in AppState)
let client = match state
.llm_client
.get_or_try_init(fabro_llm::client::Client::from_env)
.await
{
let client = match state.llm_client.get_or_try_init(LlmClient::from_env).await {
Ok(c) => c,
Err(e) => {
return ApiError::new(
@ -1469,13 +1450,11 @@ async fn create_completion(
Sse::new(sse_stream)
.keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(15))
.event(
Event::default()
.event("ping")
.data(serde_json::json!({"type": "ping"}).to_string()),
),
KeepAlive::new().interval(Duration::from_secs(15)).event(
Event::default()
.event("ping")
.data(serde_json::json!({"type": "ping"}).to_string()),
),
)
.into_response()
} else {
@ -1484,7 +1463,7 @@ async fn create_completion(
if let Some(schema) = req.schema {
// Structured output uses generate_object for JSON parsing logic
let mut params = fabro_llm::generate::GenerateParams::new(&request.model)
let mut params = GenerateParams::new(&request.model)
.messages(request.messages)
.client(std::sync::Arc::new(client.clone()));
if let Some(ref p) = request.provider {
@ -1499,7 +1478,7 @@ async fn create_completion(
if let Some(top_p) = request.top_p {
params = params.top_p(top_p);
}
match fabro_llm::generate::generate_object(params, schema).await {
match generate_object(params, schema).await {
Ok(result) => Json(fabro_api_types::CompletionResponse {
id: msg_id,
model: model_id,
@ -1553,7 +1532,7 @@ async fn get_retro(
return (StatusCode::OK, Json(serde_json::json!(null))).into_response();
};
match fabro_retro::retro::Retro::load(&run_dir) {
match Retro::load(&run_dir) {
Ok(retro) => (StatusCode::OK, Json(retro)).into_response(),
Err(_) => (StatusCode::OK, Json(serde_json::json!(null))).into_response(),
}
@ -1564,7 +1543,7 @@ pub(crate) async fn render_dot_svg(dot_source: &str) -> Response {
use fabro_graphviz::render::{render_dot, GraphFormat};
let source = dot_source.to_owned();
match tokio::task::spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await {
match spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await {
Ok(Ok(bytes)) => {
(StatusCode::OK, [("content-type", "image/svg+xml")], bytes).into_response()
}
@ -1675,7 +1654,7 @@ mod tests {
test_llm_spec,
true,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
);
let app = build_router(state, AuthMode::Disabled);
@ -1702,7 +1681,7 @@ mod tests {
test_llm_spec,
true,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
);
let app = build_router(state, AuthMode::Disabled);
@ -2393,7 +2372,7 @@ mod tests {
test_llm_spec,
false,
1,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
);
let app = test_app_with_scheduler(state);
@ -2481,7 +2460,7 @@ mod tests {
test_llm_spec,
true,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
);
let app = build_router(state, AuthMode::Disabled);
@ -2518,7 +2497,7 @@ mod tests {
test_llm_spec,
true,
5,
fabro_workflows::git::GitAuthor::default(),
GitAuthor::default(),
Vec::new(),
);
let app = build_router(state, AuthMode::Disabled);

View file

@ -7,11 +7,14 @@ use axum::http::StatusCode;
use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use axum::Json;
use fabro_llm::generate::{stream as llm_stream, GenerateParams};
use fabro_llm::types::{Message as LlmMessage, StreamEvent};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use crate::error::ApiError;
use crate::jwt_auth::AuthenticatedService;
use crate::server::PaginationParams;
use crate::server::{AppState, PaginationParams};
pub type SessionStore = Arc<RwLock<HashMap<uuid::Uuid, SessionState>>>;
@ -72,15 +75,13 @@ fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
}
}
fn turns_to_messages(turns: &[fabro_api_types::SessionTurn]) -> Vec<fabro_llm::types::Message> {
fn turns_to_messages(turns: &[fabro_api_types::SessionTurn]) -> Vec<LlmMessage> {
turns
.iter()
.filter_map(|turn| match turn {
fabro_api_types::SessionTurn::UserTurn(t) => {
Some(fabro_llm::types::Message::user(&t.content))
}
fabro_api_types::SessionTurn::UserTurn(t) => Some(LlmMessage::user(&t.content)),
fabro_api_types::SessionTurn::AssistantTurn(t) => {
Some(fabro_llm::types::Message::assistant(&t.content))
Some(LlmMessage::assistant(&t.content))
}
fabro_api_types::SessionTurn::ToolTurn(_) => None,
})
@ -136,7 +137,7 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
return;
}
let mut params = fabro_llm::generate::GenerateParams::new(&model_id)
let mut params = GenerateParams::new(&model_id)
.messages(messages)
.max_tokens(4096);
if let Some(ref provider) = model_provider {
@ -146,7 +147,7 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
params = params.system(system);
}
let stream_result = match fabro_llm::generate::stream(params).await {
let stream_result = match llm_stream(params).await {
Ok(s) => s,
Err(e) => {
let _ = event_tx.send(SessionEvent::Error {
@ -165,7 +166,7 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
return;
}
match event {
Ok(fabro_llm::types::StreamEvent::TextDelta { delta, .. }) => {
Ok(StreamEvent::TextDelta { delta, .. }) => {
full_text.push_str(&delta);
let _ = event_tx.send(SessionEvent::TextDelta { delta });
}
@ -207,7 +208,7 @@ fn spawn_generation(store: SessionStore, session_id: uuid::Uuid, dry_run: bool,
pub async fn create_session(
_auth: AuthenticatedService,
State(state): State<Arc<crate::server::AppState>>,
State(state): State<Arc<AppState>>,
Json(req): Json<fabro_api_types::CreateSessionRequest>,
) -> Response {
let (model_id, model_provider) = resolve_model(req.model);
@ -259,7 +260,7 @@ pub async fn create_session(
pub async fn retrieve_session(
_auth: AuthenticatedService,
State(state): State<Arc<crate::server::AppState>>,
State(state): State<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> Response {
let store = state.sessions.read().expect("session store lock poisoned");
@ -284,7 +285,7 @@ pub async fn retrieve_session(
pub async fn send_message(
_auth: AuthenticatedService,
State(state): State<Arc<crate::server::AppState>>,
State(state): State<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<fabro_api_types::SendMessageRequest>,
) -> Response {
@ -318,7 +319,7 @@ pub async fn send_message(
pub async fn stream_session_events(
_auth: AuthenticatedService,
State(state): State<Arc<crate::server::AppState>>,
State(state): State<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> Response {
let rx = {
@ -331,46 +332,45 @@ pub async fn stream_session_events(
use tokio_stream::StreamExt;
let stream =
tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|result| match result {
Ok(event) => {
let sse: Option<Event> = match event {
SessionEvent::TextDelta { delta } => Some(
Event::default()
.event("content_delta")
.data(serde_json::json!({"delta": delta}).to_string()),
let stream = BroadcastStream::new(rx).filter_map(|result| match result {
Ok(event) => {
let sse: Option<Event> = match event {
SessionEvent::TextDelta { delta } => Some(
Event::default()
.event("content_delta")
.data(serde_json::json!({"delta": delta}).to_string()),
),
SessionEvent::AssistantTurnComplete {
content,
created_at,
} => Some(
Event::default().event("assistant_turn").data(
serde_json::json!({
"kind": "assistant",
"content": content,
"created_at": created_at,
})
.to_string(),
),
SessionEvent::AssistantTurnComplete {
content,
created_at,
} => Some(
Event::default().event("assistant_turn").data(
serde_json::json!({
"kind": "assistant",
"content": content,
"created_at": created_at,
})
.to_string(),
),
),
SessionEvent::Done => Some(Event::default().event("done").data("{}")),
SessionEvent::Error { message } => Some(
Event::default()
.event("error")
.data(serde_json::json!({"message": message}).to_string()),
),
};
sse.map(Ok::<_, std::convert::Infallible>)
}
Err(_) => None,
});
),
SessionEvent::Done => Some(Event::default().event("done").data("{}")),
SessionEvent::Error { message } => Some(
Event::default()
.event("error")
.data(serde_json::json!({"message": message}).to_string()),
),
};
sse.map(Ok::<_, std::convert::Infallible>)
}
Err(_) => None,
});
Sse::new(stream).into_response()
}
pub async fn list_sessions(
_auth: AuthenticatedService,
State(state): State<Arc<crate::server::AppState>>,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let store = state.sessions.read().expect("session store lock poisoned");

View file

@ -65,6 +65,8 @@ pub async fn serve_tls(
tls_acceptor: tokio_rustls::TlsAcceptor,
router: axum::Router,
) -> anyhow::Result<()> {
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use tower_service::Service;
@ -94,13 +96,11 @@ pub async fn serve_tls(
let io = TokioIo::new(tls_stream);
let service = hyper::service::service_fn(
move |mut req: hyper::Request<hyper::body::Incoming>| {
req.extensions_mut().insert(peer_certs.clone());
let mut router = router.clone();
async move { router.call(req).await }
},
);
let service = service_fn(move |mut req: hyper::Request<Incoming>| {
req.extensions_mut().insert(peer_certs.clone());
let mut router = router.clone();
async move { router.call(req).await }
});
if let Err(e) = builder.serve_connection(io, service).await {
error!(%remote_addr, "connection error: {e}");

View file

@ -2,6 +2,9 @@ use std::fmt;
use std::path::PathBuf;
use clap::{Args, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_graphviz::render::GraphFormat;
use fabro_llm::cli::{ChatArgs, ModelsCommand, PromptArgs};
#[cfg(feature = "server")]
use crate::cli_config;
@ -273,7 +276,7 @@ pub(crate) enum GraphOutputFormat {
Png,
}
impl From<GraphOutputFormat> for fabro_graphviz::render::GraphFormat {
impl From<GraphOutputFormat> for GraphFormat {
fn from(value: GraphOutputFormat) -> Self {
match value {
GraphOutputFormat::Svg => Self::Svg,
@ -726,7 +729,7 @@ pub(crate) enum Commands {
Llm(LlmNamespace),
/// Run an agentic coding session
#[command(hide = true)]
Exec(fabro_agent::cli::AgentArgs),
Exec(AgentArgs),
#[command(flatten)]
RunCmd(RunCommands),
/// Validate run configuration without executing
@ -745,7 +748,7 @@ pub(crate) enum Commands {
/// List and test LLM models
Model {
#[command(subcommand)]
command: Option<fabro_llm::cli::ModelsCommand>,
command: Option<ModelsCommand>,
},
/// Start the HTTP API server
#[cfg(feature = "server")]
@ -825,8 +828,8 @@ impl Commands {
Self::Parse(_) => "parse",
Self::RunsCmd(cmd) => cmd.name(),
Self::Model { command } => match command {
Some(fabro_llm::cli::ModelsCommand::List { .. }) => "model list",
Some(fabro_llm::cli::ModelsCommand::Test { .. }) => "model test",
Some(ModelsCommand::List { .. }) => "model list",
Some(ModelsCommand::Test { .. }) => "model test",
None => "model",
},
#[cfg(feature = "server")]
@ -1009,9 +1012,9 @@ pub(crate) struct LlmNamespace {
#[derive(Subcommand)]
pub(crate) enum LlmCommand {
/// Execute a prompt
Prompt(fabro_llm::cli::PromptArgs),
Prompt(PromptArgs),
/// Interactive multi-turn chat
Chat(fabro_llm::cli::ChatArgs),
Chat(ChatArgs),
}
#[derive(Args)]

View file

@ -3,13 +3,14 @@ pub use fabro_config::cli::*;
use std::path::Path;
use fabro_config::cli::load_cli_config;
use fabro_config::FabroSettings;
#[cfg(feature = "server")]
use tracing::debug;
pub fn load_cli_settings(path: Option<&Path>) -> anyhow::Result<FabroSettings> {
fabro_config::cli::load_cli_config(path)?.try_into()
load_cli_config(path)?.try_into()
}
#[cfg(feature = "server")]

View file

@ -3,18 +3,20 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_store::RuntimeState;
use fabro_workflows::assets::{scan_assets, AssetEntry};
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use crate::args::AssetCpArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::split_run_path;
pub fn cp_command(args: &AssetCpArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let (run_id, asset_path) = parse_source(&args.source);
let run = fabro_workflows::run_lookup::resolve_run(&base, run_id)?;
let run = resolve_run(&base, run_id)?;
let runtime_state = RuntimeState::new(&run.path);
let entries =
fabro_workflows::assets::scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?;
let entries = scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?;
if entries.is_empty() {
bail!("No assets found for this run");
@ -80,8 +82,7 @@ pub fn cp_command(args: &AssetCpArgs) -> Result<()> {
})?;
}
} else {
let mut by_filename: Vec<(String, &fabro_workflows::assets::AssetEntry)> =
Vec::with_capacity(entries.len());
let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len());
for entry in &entries {
let filename = Path::new(&entry.relative_path)
.file_name()

View file

@ -1,17 +1,19 @@
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use fabro_store::RuntimeState;
use fabro_workflows::assets::scan_assets;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use crate::args::AssetListArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub fn list_command(args: &AssetListArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run = resolve_run(&base, &args.run_id)?;
let runtime_state = RuntimeState::new(&run.path);
let entries =
fabro_workflows::assets::scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?;
let entries = scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?;
if args.json {
println!("{}", serde_json::to_string_pretty(&entries)?);

View file

@ -2,7 +2,8 @@ use std::io::Write;
use std::path::Path;
use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs};
use fabro_config::project::ResolveSettingsInput;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{discover_project_config, resolve_settings, ResolveSettingsInput};
use fabro_config::{FabroConfig, FabroSettings};
pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
@ -13,9 +14,9 @@ pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroSettings> {
if let Some(workflow) = workflow {
let cli_config = fabro_config::cli::load_cli_config(None)?;
let cli_config = load_cli_config(None)?;
let cwd = std::env::current_dir()?;
return fabro_config::project::resolve_settings(ResolveSettingsInput {
return resolve_settings(ResolveSettingsInput {
workflow_path: workflow.to_path_buf(),
cwd,
defaults: cli_config,
@ -25,10 +26,10 @@ fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroSettings> {
}
let cwd = std::env::current_dir()?;
let project_config = fabro_config::project::discover_project_config(&cwd)?
let project_config = discover_project_config(&cwd)?
.map(|(_, config)| config)
.unwrap_or_default();
let cli_config = fabro_config::cli::load_cli_config(None)?;
let cli_config = load_cli_config(None)?;
FabroConfig::combine(project_config, cli_config).try_into()
}

View file

@ -4,18 +4,25 @@ use std::process::Command;
#[cfg(feature = "server")]
use std::sync::LazyLock;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
#[cfg(feature = "server")]
use fabro_config::server::{ApiAuthStrategy, AuthProvider};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::types::{Message, Request};
use fabro_model::{Catalog, Provider};
pub use fabro_util::check_report::{
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
};
use fabro_util::terminal::Styles;
use futures::future::join_all;
#[cfg(feature = "server")]
use regex::Regex;
#[cfg(feature = "server")]
use semver::Version;
use crate::cli_config::load_cli_settings;
// ---------------------------------------------------------------------------
// System dependency types and parsers (server mode only)
// ---------------------------------------------------------------------------
@ -863,12 +870,12 @@ pub(crate) fn probe_model(provider: Provider) -> String {
}
async fn probe_llm_provider(
client: &fabro_llm::client::Client,
client: &LlmClient,
provider: Provider,
) -> (Provider, Result<(), String>) {
let request = fabro_llm::types::Request {
let request = Request {
model: probe_model(provider),
messages: vec![fabro_llm::types::Message::user("hi")],
messages: vec![Message::user("hi")],
provider: Some(provider.as_str().to_string()),
tools: None,
tool_choice: None,
@ -928,7 +935,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
// Gather state
let cli_config = crate::cli_config::load_cli_settings(None).unwrap_or_default();
let cli_config = load_cli_settings(None).unwrap_or_default();
let config_path = dirs::home_dir().map(|h| h.join(".fabro").join("cli.toml"));
let config_exists = config_path.as_ref().is_some_and(|p| p.exists());
@ -980,7 +987,8 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let pem = if raw.starts_with("-----") {
Ok(raw.clone())
} else {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, raw)
BASE64_STANDARD
.decode(raw)
.map_err(|e| format!("base64 decode failed: {e}"))
.and_then(|bytes| {
String::from_utf8(bytes)
@ -1058,7 +1066,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let http = reqwest::Client::new();
// Build LLM client — may fail if no keys are set
let llm_client = fabro_llm::client::Client::from_env().await.ok();
let llm_client = LlmClient::from_env().await.ok();
let configured_providers: Vec<Provider> = llm_statuses
.iter()
@ -1072,7 +1080,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
.iter()
.map(|p| probe_llm_provider(client, *p))
.collect();
Some(futures::future::join_all(futures).await)
Some(join_all(futures).await)
} else {
None
}

View file

@ -1,9 +1,14 @@
use anyhow::Result;
#[cfg(feature = "server")]
use fabro_agent::cli::run_with_args_and_client;
use fabro_agent::cli::{run_with_args, AgentArgs};
use fabro_config::mcp::McpServerEntry;
use fabro_mcp::config::McpServerConfig;
use crate::args::GlobalArgs;
use crate::cli_config;
pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs) -> Result<()> {
pub async fn execute(mut args: AgentArgs, globals: &GlobalArgs) -> Result<()> {
let cli_config = cli_config::load_cli_settings(None)?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(cli_config.prevent_idle_sleep_enabled());
@ -20,10 +25,10 @@ pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs
globals.server_url.as_deref(),
&cli_config,
);
let mcp_servers: Vec<fabro_mcp::config::McpServerConfig> = cli_config
let mcp_servers: Vec<McpServerConfig> = cli_config
.mcp_servers
.into_iter()
.map(|(name, entry): (String, fabro_config::mcp::McpServerEntry)| entry.into_config(name))
.map(|(name, entry): (String, McpServerEntry)| entry.into_config(name))
.collect();
#[cfg(feature = "server")]
{
@ -46,11 +51,11 @@ pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs
.register_provider(adapter)
.await
.map_err(|e| anyhow::anyhow!("Failed to register fabro server adapter: {e}"))?;
fabro_agent::cli::run_with_args_and_client(args, Some(client), mcp_servers).await?
run_with_args_and_client(args, Some(client), mcp_servers).await?
}
cli_config::ExecutionMode::Standalone => {
tracing::info!(mode = "standalone", "Agent session starting");
fabro_agent::cli::run_with_args(args, mcp_servers).await?
run_with_args(args, mcp_servers).await?
}
}
}
@ -58,7 +63,7 @@ pub async fn execute(mut args: fabro_agent::cli::AgentArgs, globals: &GlobalArgs
{
let _ = globals;
tracing::info!(mode = "standalone", "Agent session starting");
fabro_agent::cli::run_with_args(args, mcp_servers).await?
run_with_args(args, mcp_servers).await?
}
Ok(())

View file

@ -3,9 +3,12 @@ use std::io::Write;
use std::sync::LazyLock;
use anyhow::bail;
use fabro_config::project::ResolveSettingsInput;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{resolve_settings, resolve_workflow_path, ResolveSettingsInput};
use fabro_graphviz::render::render_dot;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
use fabro_workflows::operations::{validate, ValidateInput, WorkflowInput};
use tracing::debug;
use crate::args::{GraphArgs, GraphDirection};
@ -16,22 +19,21 @@ static RANKDIR_RE: LazyLock<regex::Regex> =
pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
let settings = fabro_config::project::resolve_settings(ResolveSettingsInput {
let cli_defaults = load_cli_config(None)?;
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: fabro_config::FabroConfig::default(),
apply_project_config: true,
})?;
let resolution = fabro_config::project::resolve_workflow_path(&args.workflow, &cwd)?;
let validated =
fabro_workflows::operations::validate(fabro_workflows::operations::ValidateInput {
workflow: fabro_workflows::operations::WorkflowInput::Path(args.workflow.clone()),
settings,
cwd,
custom_transforms: Vec::new(),
})?;
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(args.workflow.clone()),
settings,
cwd,
custom_transforms: Vec::new(),
})?;
let diagnostics = validated.diagnostics();
print_diagnostics(diagnostics, styles);
@ -42,7 +44,7 @@ pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
let source = read_workflow_file(&resolution.dot_path)?;
let source = apply_direction(&source, args.direction);
let rendered = fabro_graphviz::render::render_dot(&source, args.format.into())?;
let rendered = render_dot(&source, args.format.into())?;
if let Some(ref output_path) = args.output {
std::fs::write(output_path, &rendered)?;

View file

@ -8,12 +8,17 @@ use anyhow::{bail, Context, Result};
use axum::extract::Query;
use axum::response::Html;
use axum::routing::get;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use rand::Rng;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::spawn_blocking;
use super::doctor;
use crate::shared::provider_auth::{
@ -224,29 +229,23 @@ fn detect_binary_on_path(binary: &str) -> bool {
#[cfg(feature = "server")]
fn prompt_input(prompt: &str) -> Result<String> {
Ok(
dialoguer::Input::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&dialoguer::console::Term::stderr())?,
)
Ok(dialoguer::Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&Term::stderr())?)
}
fn prompt_select(prompt: &str, items: &[String]) -> Result<usize> {
Ok(
Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&dialoguer::console::Term::stderr())?,
)
Ok(Select::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&Term::stderr())?)
}
fn prompt_multiselect(prompt: &str, items: &[String]) -> Result<Vec<usize>> {
Ok(
MultiSelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&dialoguer::console::Term::stderr())?,
)
Ok(MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.interact_on(&Term::stderr())?)
}
// ---------------------------------------------------------------------------
@ -468,8 +467,7 @@ async fn setup_github_app(
);
// Return secrets as env pairs
let pem_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, pem.as_bytes());
let pem_b64 = BASE64_STANDARD.encode(pem.as_bytes());
let mut env_pairs = vec![
("GITHUB_APP_PRIVATE_KEY".to_string(), pem_b64),
@ -524,7 +522,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
let dot_idx = doctor::DEP_SPECS.iter().position(|s| s.name == "dot");
if let Some(idx) = dot_idx {
if matches!(dep_outcomes[idx], doctor::ProbeOutcome::NotFound) {
let install = tokio::task::spawn_blocking(|| {
let install = spawn_blocking(|| {
prompt_confirm("Graphviz (dot) not found. Install via Homebrew?", true)
})
.await??;
@ -560,7 +558,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
if codex_detected {
tracing::debug!("Codex binary detected on PATH");
let use_oauth = tokio::task::spawn_blocking(|| {
let use_oauth = spawn_blocking(|| {
prompt_confirm(
"OpenAI (Codex) detected. Set up OpenAI via browser login?",
true,
@ -584,7 +582,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
.map(|p| provider_display_name(*p).to_string())
.collect();
let primary_idx: usize = tokio::task::spawn_blocking({
let primary_idx: usize = spawn_blocking({
let labels = primary_labels.clone();
move || prompt_select("Choose your first LLM provider", &labels)
})
@ -601,8 +599,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
// Additional providers
eprintln!();
let add_more =
tokio::task::spawn_blocking(|| prompt_confirm("Set up additional LLM providers?", false))
.await??;
spawn_blocking(|| prompt_confirm("Set up additional LLM providers?", false)).await??;
if add_more {
let remaining_labels: Vec<String> = Provider::ALL
@ -619,7 +616,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
.copied()
.collect();
let selected_indices: Vec<usize> = tokio::task::spawn_blocking({
let selected_indices: Vec<usize> = spawn_blocking({
let labels = remaining_labels.clone();
move || prompt_multiselect("Which additional LLM providers?", &labels)
})
@ -644,10 +641,8 @@ pub async fn run_install(web_url: &str) -> Result<()> {
eprintln!();
{
let setup_github = tokio::task::spawn_blocking(|| {
prompt_confirm("Set up a GitHub App? (Recommended)", true)
})
.await??;
let setup_github =
spawn_blocking(|| prompt_confirm("Set up a GitHub App? (Recommended)", true)).await??;
if setup_github {
let github_env_pairs = setup_github_app(&arc_dir, &s, web_url).await?;
@ -686,7 +681,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
let config_path = arc_dir.join("server.toml");
let write_config = if config_path.exists() {
tokio::task::spawn_blocking(|| {
spawn_blocking(|| {
prompt_confirm("~/.fabro/server.toml already exists. Overwrite?", false)
})
.await??
@ -696,8 +691,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
if write_config {
let username: String =
tokio::task::spawn_blocking(|| prompt_input("GitHub username for allowed access"))
.await??;
spawn_blocking(|| prompt_input("GitHub username for allowed access")).await??;
let toml_content = format_config_toml(&username);
std::fs::write(&config_path, &toml_content)?;
@ -732,14 +726,8 @@ pub async fn run_install(web_url: &str) -> Result<()> {
s.green.apply_to("")
);
let jwt_private_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
jwt_private_pem.as_bytes(),
);
let jwt_public_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
jwt_public_pem.as_bytes(),
);
let jwt_private_b64 = BASE64_STANDARD.encode(jwt_private_pem.as_bytes());
let jwt_public_b64 = BASE64_STANDARD.encode(jwt_public_pem.as_bytes());
let server_env_pairs = vec![
("FABRO_JWT_PRIVATE_KEY".to_string(), jwt_private_b64),
@ -759,8 +747,7 @@ pub async fn run_install(web_url: &str) -> Result<()> {
// Verify setup
let env_path = arc_dir.join(".env");
let run_doctor =
tokio::task::spawn_blocking(|| prompt_confirm("Run fabro doctor to verify?", true))
.await??;
spawn_blocking(|| prompt_confirm("Run fabro doctor to verify?", true)).await??;
if run_doctor {
// Reload .env so doctor sees the values we just wrote

View file

@ -1,10 +1,13 @@
use anyhow::Result;
use fabro_config::FabroSettings;
use fabro_llm::cli::{run_chat, ChatArgs};
#[cfg(feature = "server")]
use fabro_llm::cli::{run_chat_via_server, ServerConnection};
use crate::args::GlobalArgs;
pub async fn execute(
mut args: fabro_llm::cli::ChatArgs,
mut args: ChatArgs,
cli_config: &FabroSettings,
globals: &GlobalArgs,
) -> Result<()> {
@ -23,14 +26,14 @@ pub async fn execute(
match resolved.mode {
crate::cli_config::ExecutionMode::Server => {
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
let server = fabro_llm::cli::ServerConnection {
let server = ServerConnection {
client,
base_url: resolved.server_base_url,
};
fabro_llm::cli::run_chat_via_server(args, &server).await?;
run_chat_via_server(args, &server).await?;
}
crate::cli_config::ExecutionMode::Standalone => {
fabro_llm::cli::run_chat(args).await?;
run_chat(args).await?;
}
}
}
@ -38,7 +41,7 @@ pub async fn execute(
#[cfg(not(feature = "server"))]
{
let _ = globals;
fabro_llm::cli::run_chat(args).await?;
run_chat(args).await?;
}
Ok(())

View file

@ -4,9 +4,10 @@ mod prompt;
use anyhow::Result;
use crate::args::{GlobalArgs, LlmCommand, LlmNamespace};
use crate::cli_config::load_cli_settings;
pub async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let cli_config = load_cli_settings(None)?;
match ns.command {
LlmCommand::Prompt(args) => prompt::execute(args, &cli_config, globals).await,

View file

@ -1,10 +1,13 @@
use anyhow::Result;
use fabro_config::FabroSettings;
use fabro_llm::cli::{run_prompt, PromptArgs};
#[cfg(feature = "server")]
use fabro_llm::cli::{run_prompt_via_server, ServerConnection};
use crate::args::GlobalArgs;
pub async fn execute(
mut args: fabro_llm::cli::PromptArgs,
mut args: PromptArgs,
cli_config: &FabroSettings,
globals: &GlobalArgs,
) -> Result<()> {
@ -23,14 +26,14 @@ pub async fn execute(
match resolved.mode {
crate::cli_config::ExecutionMode::Server => {
let client = crate::cli_config::build_server_client(resolved.tls.as_ref())?;
let server = fabro_llm::cli::ServerConnection {
let server = ServerConnection {
client,
base_url: resolved.server_base_url,
};
fabro_llm::cli::run_prompt_via_server(args, &server).await?;
run_prompt_via_server(args, &server).await?;
}
crate::cli_config::ExecutionMode::Standalone => {
fabro_llm::cli::run_prompt(args).await?;
run_prompt(args).await?;
}
}
}
@ -38,7 +41,7 @@ pub async fn execute(
#[cfg(not(feature = "server"))]
{
let _ = globals;
fabro_llm::cli::run_prompt(args).await?;
run_prompt(args).await?;
}
Ok(())

View file

@ -1,13 +1,13 @@
use anyhow::Result;
#[cfg(feature = "server")]
use fabro_llm::cli::ServerConnection;
use fabro_llm::cli::{run_models, ModelsCommand};
use crate::args::GlobalArgs;
#[cfg(feature = "server")]
use crate::cli_config;
pub async fn execute(
command: Option<fabro_llm::cli::ModelsCommand>,
globals: &GlobalArgs,
) -> Result<()> {
pub async fn execute(command: Option<ModelsCommand>, globals: &GlobalArgs) -> Result<()> {
let server = {
#[cfg(feature = "server")]
{
@ -20,7 +20,7 @@ pub async fn execute(
match resolved.mode {
cli_config::ExecutionMode::Server => {
let client = cli_config::build_server_client(resolved.tls.as_ref())?;
Some(fabro_llm::cli::ServerConnection {
Some(ServerConnection {
client,
base_url: resolved.server_base_url,
})
@ -35,5 +35,5 @@ pub async fn execute(
}
};
fabro_llm::cli::run_models(command, server).await
run_models(command, server).await
}

View file

@ -1,6 +1,10 @@
use std::io::Write;
use fabro_config::project::resolve_workflow;
use fabro_graphviz::parser::parse_ast;
use crate::args::ParseArgs;
use crate::shared::read_workflow_file;
use std::io::Write;
pub fn run(args: &ParseArgs) -> anyhow::Result<()> {
let stdout = std::io::stdout();
@ -8,9 +12,9 @@ pub fn run(args: &ParseArgs) -> anyhow::Result<()> {
}
fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
let (dot_path, _cfg) = resolve_workflow(&args.workflow)?;
let source = read_workflow_file(&dot_path)?;
let ast = fabro_graphviz::parser::parse_ast(&source)?;
let ast = parse_ast(&source)?;
serde_json::to_writer_pretty(&mut out, &ast)?;
writeln!(out)?;
Ok(())

View file

@ -2,16 +2,18 @@ use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_workflows::run_lookup::runs_base;
use tracing::info;
use crate::args::PrCloseArgs;
use crate::cli_config::load_cli_settings;
pub async fn close_command(
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
close_from(&base, args, github_app).await
}

View file

@ -3,17 +3,24 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_model::Catalog;
use fabro_workflows::records::{ConclusionExt, RunRecordExt, StartRecordExt};
use fabro_sandbox::daytona::detect_repo_info;
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::pull_request::maybe_open_pull_request;
use fabro_workflows::records::{
Conclusion, ConclusionExt, RunRecord, RunRecordExt, StartRecord, StartRecordExt,
};
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use tracing::info;
use crate::args::PrCreateArgs;
use crate::cli_config::load_cli_settings;
pub async fn create_command(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
create_from(&base, args, github_app).await
}
@ -22,20 +29,17 @@ async fn create_from(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
let run_dir = resolve_run(base, &args.run_id)?.path;
let record =
fabro_workflows::records::RunRecord::load(&run_dir).context("Failed to load run.json")?;
let record = RunRecord::load(&run_dir).context("Failed to load run.json")?;
let start = fabro_workflows::records::StartRecord::load(&run_dir)
.context("Failed to load start.json")?;
let start = StartRecord::load(&run_dir).context("Failed to load start.json")?;
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
let conclusion = Conclusion::load(&run_dir.join("conclusion.json"))
.context("Failed to load conclusion.json — is the run finished?")?;
match conclusion.status {
fabro_workflows::outcome::StageStatus::Success
| fabro_workflows::outcome::StageStatus::PartialSuccess => {}
StageStatus::Success | StageStatus::PartialSuccess => {}
status => bail!("Run status is '{status}', expected success or partial_success"),
}
@ -52,7 +56,7 @@ async fn create_from(
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let (origin_url, detected_branch) =
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
let base_branch = record
.base_branch
@ -89,7 +93,7 @@ async fn create_from(
.model
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let record = fabro_workflows::pull_request::maybe_open_pull_request(
let record = maybe_open_pull_request(
&creds,
&origin_url,
base_branch,

View file

@ -2,16 +2,20 @@ use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_workflows::pull_request::PullRequestRecord;
use fabro_workflows::run_lookup::{runs_base, scan_runs};
use futures::future::join_all;
use tracing::info;
use crate::args::PrListArgs;
use crate::cli_config::load_cli_settings;
pub async fn list_command(
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
list_from(&base, args, github_app).await
}
@ -24,15 +28,13 @@ async fn list_from(
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
)?;
let runs = fabro_workflows::run_lookup::scan_runs(base).context("Failed to scan runs")?;
let runs = scan_runs(base).context("Failed to scan runs")?;
let mut entries: Vec<(String, fabro_workflows::pull_request::PullRequestRecord)> = Vec::new();
let mut entries: Vec<(String, PullRequestRecord)> = Vec::new();
for run in &runs {
let pr_path = run.path.join("pull_request.json");
if let Ok(content) = std::fs::read_to_string(&pr_path) {
if let Ok(record) =
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
{
if let Ok(record) = serde_json::from_str::<PullRequestRecord>(&content) {
entries.push((run.run_id.clone(), record));
}
}
@ -93,7 +95,7 @@ async fn list_from(
})
.collect();
let all_rows = futures::future::join_all(futures).await;
let all_rows = join_all(futures).await;
let rows: Vec<_> = if args.all {
all_rows
} else {

View file

@ -4,14 +4,17 @@ use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use fabro_workflows::run_lookup::runs_base;
use crate::args::PrMergeArgs;
use crate::cli_config::load_cli_settings;
pub async fn merge_command(
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
merge_from(&base, args, github_app).await
}

View file

@ -8,11 +8,16 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fabro_workflows::pull_request::PullRequestRecord;
use fabro_workflows::run_lookup::resolve_run;
use crate::args::{PrCommand, PrNamespace};
use crate::cli_config::load_cli_settings;
use crate::shared::github::build_github_app_credentials;
pub async fn dispatch(ns: PrNamespace) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
let cli_config = load_cli_settings(None)?;
let github_app = build_github_app_credentials(cli_config.app_id());
match ns.command {
PrCommand::Create(args) => create::create_command(args, github_app).await,
@ -23,11 +28,8 @@ pub async fn dispatch(ns: PrNamespace) -> Result<()> {
}
}
pub(crate) fn load_pr_record(
base: &Path,
run_id: &str,
) -> Result<(fabro_workflows::pull_request::PullRequestRecord, PathBuf)> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_id)?.path;
pub(crate) fn load_pr_record(base: &Path, run_id: &str) -> Result<(PullRequestRecord, PathBuf)> {
let run_dir = resolve_run(base, run_id)?.path;
let pr_path = run_dir.join("pull_request.json");
let content = std::fs::read_to_string(&pr_path).with_context(|| {
format!(
@ -35,7 +37,7 @@ pub(crate) fn load_pr_record(
Create one first with: fabro pr create {run_id}"
)
})?;
let record: fabro_workflows::pull_request::PullRequestRecord =
let record: PullRequestRecord =
serde_json::from_str(&content).context("Failed to parse pull_request.json")?;
Ok((record, run_dir))
}

View file

@ -4,14 +4,17 @@ use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use fabro_workflows::run_lookup::runs_base;
use crate::args::PrViewArgs;
use crate::cli_config::load_cli_settings;
pub async fn view_command(
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
view_from(&base, args, github_app).await
}

View file

@ -3,39 +3,47 @@ use std::sync::Arc;
use anyhow::bail;
use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
use fabro_config::project::ResolveSettingsInput;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{
resolve_settings, resolve_workflow_path, resolve_working_directory, ResolveSettingsInput,
};
use fabro_config::{FabroConfig, FabroSettings};
use fabro_graphviz::graph::{is_llm_handler_type, Graph};
use fabro_llm::client::Client as LlmClient;
use fabro_model::{Catalog, Provider};
use fabro_sandbox::daytona::{detect_repo_info, DaytonaConfig, DaytonaSandbox};
use fabro_sandbox::ssh::{GitCloneParams as SshGitCloneParams, SshConfig, SshSandbox};
use fabro_sandbox::SandboxProvider;
use fabro_util::terminal::Styles;
use fabro_workflows::git::GitSyncStatus;
use fabro_workflows::git::{sync_status, GitSyncStatus};
use fabro_workflows::operations::{validate, ValidateInput, WorkflowInput};
use crate::args::PreflightArgs;
use crate::shared::github::build_github_app_credentials;
pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
let cli_defaults = load_cli_config(None)?;
let cli_config: FabroSettings = cli_defaults.clone().try_into()?;
args.verbose = args.verbose || cli_config.verbose_enabled();
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
let github_app = build_github_app_credentials(cli_config.app_id());
let cli_args_config = FabroConfig::try_from(&args)?;
let cwd = std::env::current_dir()?;
let settings = fabro_config::project::resolve_settings(ResolveSettingsInput {
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: cli_args_config,
apply_project_config: true,
})?;
let resolution = fabro_config::project::resolve_workflow_path(&args.workflow, &cwd)?;
let working_directory = fabro_config::project::resolve_working_directory(&settings, &cwd);
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let working_directory = resolve_working_directory(&settings, &cwd);
let (origin_url, detected_base_branch) =
fabro_sandbox::daytona::detect_repo_info(&working_directory)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_status = fabro_workflows::git::sync_status(
let (origin_url, detected_base_branch) = detect_repo_info(&working_directory)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_status = sync_status(
&working_directory,
"origin",
detected_base_branch.as_deref(),
@ -43,13 +51,12 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
let sandbox_provider = resolve_sandbox_provider(args.sandbox.map(Into::into), &settings)?;
let validated =
fabro_workflows::operations::validate(fabro_workflows::operations::ValidateInput {
workflow: fabro_workflows::operations::WorkflowInput::Path(args.workflow.clone()),
settings: settings.clone(),
cwd,
custom_transforms: Vec::new(),
})?;
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(args.workflow.clone()),
settings: settings.clone(),
cwd,
custom_transforms: Vec::new(),
})?;
super::run::output::print_workflow_report(&validated, Some(&resolution.dot_path), styles);
if validated.has_errors() {
bail!("Validation failed");
@ -74,7 +81,7 @@ fn resolve_model_provider(
cli_model: Option<&str>,
cli_provider: Option<&str>,
settings: &FabroSettings,
graph: &fabro_graphviz::graph::Graph,
graph: &Graph,
) -> (String, Option<String>) {
let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref());
let configured_provider = settings
@ -128,9 +135,7 @@ fn resolve_sandbox_provider(
.unwrap_or_default())
}
fn resolve_daytona_config(
settings: &FabroSettings,
) -> Option<fabro_sandbox::daytona::DaytonaConfig> {
fn resolve_daytona_config(settings: &FabroSettings) -> Option<DaytonaConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.daytona.clone())
@ -145,7 +150,7 @@ fn resolve_exe_config(settings: &FabroSettings) -> Option<fabro_sandbox::exe::Ex
#[cfg(feature = "exedev")]
fn resolve_exe_clone_params(cwd: &Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
let (detected_url, branch) = match detect_repo_info(cwd) {
Ok(info) => info,
Err(err) => {
tracing::warn!("No git repo detected for exe.dev clone: {err}");
@ -156,14 +161,14 @@ fn resolve_exe_clone_params(cwd: &Path) -> Option<fabro_sandbox::exe::GitClonePa
Some(fabro_sandbox::exe::GitCloneParams { url, branch })
}
fn resolve_ssh_config(settings: &FabroSettings) -> Option<fabro_sandbox::ssh::SshConfig> {
fn resolve_ssh_config(settings: &FabroSettings) -> Option<SshConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.ssh.clone())
}
fn resolve_ssh_clone_params(cwd: &Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
fn resolve_ssh_clone_params(cwd: &Path) -> Option<SshGitCloneParams> {
let (detected_url, branch) = match detect_repo_info(cwd) {
Ok(info) => info,
Err(err) => {
tracing::warn!("No git repo detected for SSH clone: {err}");
@ -171,7 +176,7 @@ fn resolve_ssh_clone_params(cwd: &Path) -> Option<fabro_sandbox::ssh::GitClonePa
}
};
let url = fabro_github::ssh_url_to_https(&detected_url);
Some(fabro_sandbox::ssh::GitCloneParams { url, branch })
Some(SshGitCloneParams { url, branch })
}
async fn mint_github_token(
@ -201,7 +206,7 @@ async fn mint_github_token(
#[allow(clippy::too_many_arguments)]
async fn run_preflight(
graph: &fabro_graphviz::graph::Graph,
graph: &Graph,
settings: &FabroSettings,
cli_model: Option<&str>,
cli_provider: Option<&str>,
@ -281,14 +286,7 @@ async fn run_preflight(
}
SandboxProvider::Daytona => {
let config = daytona_config.unwrap_or_default();
match fabro_sandbox::daytona::DaytonaSandbox::new(
config,
github_app.clone(),
None,
None,
)
.await
{
match DaytonaSandbox::new(config, github_app.clone(), None, None).await {
Ok(env) => Ok(Arc::new(env) as Arc<dyn Sandbox>),
Err(e) => Err(format!("Daytona sandbox creation failed: {e}")),
}
@ -316,7 +314,7 @@ async fn run_preflight(
SandboxProvider::Ssh => match ssh_config {
Some(config) => {
let clone_params = resolve_ssh_clone_params(working_directory);
let env = fabro_sandbox::ssh::SshSandbox::new(config, clone_params, None, None);
let env = SshSandbox::new(config, clone_params, None, None);
Ok(Arc::new(env) as Arc<dyn Sandbox>)
}
None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()),
@ -367,14 +365,14 @@ async fn run_preflight(
}
let default_provider = provider.as_deref().unwrap_or("anthropic");
let llm_ok = match fabro_llm::client::Client::from_env().await {
let llm_ok = match LlmClient::from_env().await {
Ok(c) => {
let configured: Vec<String> =
c.provider_names().iter().map(|s| s.to_string()).collect();
let mut model_providers = std::collections::BTreeSet::new();
for node in graph.nodes.values() {
if !fabro_graphviz::graph::is_llm_handler_type(node.handler_type()) {
if !is_llm_handler_type(node.handler_type()) {
continue;
}
let node_model = node.model().unwrap_or(&model);

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use tokio::task::spawn_blocking;
use crate::args::ProviderLoginArgs;
use crate::shared::provider_auth;
@ -13,10 +14,8 @@ pub async fn login_command(args: ProviderLoginArgs) -> Result<()> {
std::fs::create_dir_all(&arc_dir)?;
let use_oauth = args.provider == Provider::OpenAi
&& tokio::task::spawn_blocking(|| {
provider_auth::prompt_confirm("Log in via browser (OAuth)?", true)
})
.await??;
&& spawn_blocking(|| provider_auth::prompt_confirm("Log in via browser (OAuth)?", true))
.await??;
let env_pairs = if use_oauth {
provider_auth::run_openai_oauth_or_api_key(&s).await?

View file

@ -1,6 +1,10 @@
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use tokio::task::spawn_blocking;
use crate::cli_config::load_cli_settings;
use crate::shared::github::build_github_app_credentials;
pub(super) fn git_repo_root() -> Result<PathBuf> {
let output = std::process::Command::new("git")
@ -150,7 +154,7 @@ async fn check_github_app_installation() {
};
// Load CLI config to get app_id and slug
let cli_config = match crate::cli_config::load_cli_settings(None) {
let cli_config = match load_cli_settings(None) {
Ok(c) => c,
Err(_) => return,
};
@ -172,7 +176,7 @@ async fn check_github_app_installation() {
let slug = cli_config.slug().map(String::from);
// Build GitHub App credentials
let creds = match crate::shared::github::build_github_app_credentials(Some(&app_id)) {
let creds = match build_github_app_credentials(Some(&app_id)) {
Some(c) => c,
None => {
eprintln!(
@ -264,7 +268,7 @@ async fn check_github_app_installation() {
// Only prompt if stdin is a terminal
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!(" Press Enter to continue after installing...");
let _ = tokio::task::spawn_blocking(|| {
let _ = spawn_blocking(|| {
let mut buf = String::new();
let _ = std::io::stdin().read_line(&mut buf);
})

View file

@ -10,8 +10,11 @@ use anyhow::{bail, Result};
use fabro_interview::{AnswerValue, ConsoleInterviewer};
use fabro_store::RuntimeState;
use fabro_util::terminal::Styles;
use fabro_workflows::records::{ConclusionExt, RunRecordExt};
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::records::{Conclusion, ConclusionExt, RunRecord, RunRecordExt};
use fabro_workflows::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt};
use tokio::signal::ctrl_c;
use tokio::time::sleep;
use super::run_progress;
@ -41,7 +44,7 @@ pub async fn attach_run(
let mut engine_guard = engine_child.map(EngineChildGuard::new);
let is_tty = std::io::stderr().is_terminal();
let verbose = fabro_workflows::records::RunRecord::load(run_dir)
let verbose = RunRecord::load(run_dir)
.map(|record| record.settings.verbose_enabled())
.unwrap_or(false);
let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose);
@ -51,7 +54,7 @@ pub async fn attach_run(
{
let cancelled = Arc::clone(&cancelled);
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
let _ = ctrl_c().await;
cancelled.store(true, Ordering::Relaxed);
});
}
@ -62,7 +65,7 @@ pub async fn attach_run(
// engine death so we surface the real failure instead of timing out.
let mut wait_count = 0;
while !progress_path.exists() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
sleep(std::time::Duration::from_millis(100)).await;
wait_count += 1;
// Check if engine died before writing any progress
@ -140,7 +143,7 @@ pub async fn attach_run(
{
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
sleep(Duration::from_millis(100)).await;
}
} else {
if let Some(guard) = engine_guard.as_mut() {
@ -246,7 +249,7 @@ pub async fn attach_run(
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
sleep(Duration::from_millis(100)).await;
}
// Finish progress bars
@ -438,11 +441,10 @@ fn write_interview_response_atomically(
fn determine_exit_code(conclusion_path: &Path, status_record: Option<RunStatusRecord>) -> ExitCode {
if conclusion_path.exists() {
if let Ok(conclusion) = fabro_workflows::records::Conclusion::load(conclusion_path) {
if let Ok(conclusion) = Conclusion::load(conclusion_path) {
let success = matches!(
conclusion.status,
fabro_workflows::outcome::StageStatus::Success
| fabro_workflows::outcome::StageStatus::PartialSuccess
StageStatus::Success | StageStatus::PartialSuccess
);
return if success {
ExitCode::from(0)

View file

@ -1,11 +1,12 @@
use anyhow::Result;
use fabro_config::cli::load_cli_config;
use fabro_util::terminal::Styles;
use crate::args::{GlobalArgs, RunArgs};
pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> Result<()> {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = load_cli_config(None)?;
let cli_config: fabro_config::FabroSettings = cli_defaults.clone().try_into()?;
args.verbose = args.verbose || cli_config.verbose_enabled();

View file

@ -1,11 +1,16 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use fabro_agent::sandbox::Sandbox;
use fabro_config::FabroSettingsExt;
use fabro_sandbox::reconnect::reconnect;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use tokio::fs;
use tracing::{debug, info};
use crate::args::CpArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::split_run_path;
enum CopyDirection {
@ -23,8 +28,8 @@ enum CopyDirection {
pub async fn cp_command(args: CpArgs) -> Result<()> {
let direction = parse_direction(&args.src, &args.dst)?;
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
match direction {
CopyDirection::Download {
@ -90,11 +95,8 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
}
}
async fn load_sandbox(
base: &Path,
run_prefix: &str,
) -> Result<Box<dyn fabro_agent::sandbox::Sandbox>> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, run_prefix)?.path;
async fn load_sandbox(base: &Path, run_prefix: &str) -> Result<Box<dyn Sandbox>> {
let run_dir = resolve_run(base, run_prefix)?.path;
let sandbox_json = run_dir.join("sandbox.json");
debug!(path = %sandbox_json.display(), "Loading sandbox record");
let record = fabro_sandbox::SandboxRecord::load(&sandbox_json).context(
@ -102,11 +104,11 @@ async fn load_sandbox(
)?;
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
fabro_sandbox::reconnect::reconnect(&record).await
reconnect(&record).await
}
async fn download_recursive(
sandbox: &dyn fabro_agent::sandbox::Sandbox,
sandbox: &dyn Sandbox,
remote_path: &str,
local_path: &Path,
) -> Result<()> {
@ -123,7 +125,7 @@ async fn download_recursive(
let remote_file = format!("{remote_path}/{}", entry.name);
let local_file = local_path.join(&entry.name);
if let Some(parent) = local_file.parent() {
tokio::fs::create_dir_all(parent)
fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
@ -139,7 +141,7 @@ async fn download_recursive(
}
async fn upload_recursive(
sandbox: &dyn fabro_agent::sandbox::Sandbox,
sandbox: &dyn Sandbox,
local_path: &Path,
remote_path: &str,
) -> Result<()> {
@ -147,7 +149,7 @@ async fn upload_recursive(
let mut stack = vec![(local_path.to_path_buf(), remote_path.to_string())];
while let Some((dir_path, dir_remote)) = stack.pop() {
let mut entries = tokio::fs::read_dir(&dir_path)
let mut entries = fs::read_dir(&dir_path)
.await
.with_context(|| format!("Failed to read directory {}", dir_path.display()))?;

View file

@ -1,10 +1,11 @@
use std::path::PathBuf;
use crate::args::RunArgs;
use fabro_config::project::ResolveSettingsInput;
use fabro_config::project::{resolve_settings, ResolveSettingsInput};
use fabro_config::{FabroConfig, FabroSettings};
use fabro_util::terminal::Styles;
use fabro_workflows::error::FabroError;
use fabro_workflows::operations::{create, CreateRunInput, WorkflowInput};
use super::output::{print_diagnostics_from_error, print_workflow_report_from_persisted};
@ -23,7 +24,7 @@ pub async fn create_run(
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let cli_args_config = FabroConfig::try_from(args)?;
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let settings: FabroSettings = fabro_config::project::resolve_settings(ResolveSettingsInput {
let settings: FabroSettings = resolve_settings(ResolveSettingsInput {
workflow_path: workflow_path.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
@ -31,26 +32,25 @@ pub async fn create_run(
apply_project_config: true,
})?;
let created =
match fabro_workflows::operations::create(fabro_workflows::operations::CreateRunInput {
workflow: fabro_workflows::operations::WorkflowInput::Path(workflow_path.clone()),
settings,
cwd,
workflow_slug: None,
run_dir: None,
run_id: args.run_id.clone(),
base_branch: None,
host_repo_path: None,
}) {
Ok(created) => created,
Err(fabro_workflows::error::FabroError::ValidationFailed { diagnostics }) => {
if !quiet {
print_diagnostics_from_error(&diagnostics, styles);
}
anyhow::bail!("Validation failed");
let created = match create(CreateRunInput {
workflow: WorkflowInput::Path(workflow_path.clone()),
settings,
cwd,
workflow_slug: None,
run_dir: None,
run_id: args.run_id.clone(),
base_branch: None,
host_repo_path: None,
}) {
Ok(created) => created,
Err(FabroError::ValidationFailed { diagnostics }) => {
if !quiet {
print_diagnostics_from_error(&diagnostics, styles);
}
Err(err) => return Err(err.into()),
};
anyhow::bail!("Validation failed");
}
Err(err) => return Err(err.into()),
};
if !quiet {
print_workflow_report_from_persisted(

View file

@ -5,6 +5,8 @@ use anyhow::Result;
use fabro_interview::FileInterviewer;
use fabro_store::RuntimeState;
use fabro_workflows::event::EventEmitter;
use fabro_workflows::git::GitAuthor;
use fabro_workflows::operations::{resume as resume_run, start as start_run, StartServices};
use crate::cli_config;
use crate::shared;
@ -12,7 +14,7 @@ use crate::shared;
pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) -> Result<()> {
let cli_config = cli_config::load_cli_settings(None)?;
let github_app = shared::github::build_github_app_credentials(cli_config.app_id());
let git_author = fabro_workflows::git::GitAuthor::from_options(
let git_author = GitAuthor::from_options(
cli_config.git_author().and_then(|a| a.name.clone()),
cli_config.git_author().and_then(|a| a.email.clone()),
);
@ -22,7 +24,7 @@ pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) ->
});
let runtime_state = RuntimeState::new(&run_dir);
let services = fabro_workflows::operations::StartServices {
let services = StartServices {
cancel_token: None,
emitter: Arc::new(EventEmitter::new()),
interviewer: Arc::new(FileInterviewer::new(
@ -36,9 +38,9 @@ pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) ->
};
if resume {
let _ = fabro_workflows::operations::resume(&run_dir, services).await?;
let _ = resume_run(&run_dir, services).await?;
} else {
let _ = fabro_workflows::operations::start(&run_dir, services).await?;
let _ = start_run(&run_dir, services).await?;
}
Ok(())

View file

@ -3,17 +3,21 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::reconnect::reconnect;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::records::StartRecordExt;
use fabro_workflows::records::{StartRecord, StartRecordExt};
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use fabro_workflows::sandbox_git::GIT_REMOTE;
use tracing::{debug, info};
use crate::args::DiffArgs;
use crate::cli_config::load_cli_settings;
pub async fn run(args: DiffArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_dir = resolve_run(&base, &args.run)?.path;
let patch = resolve_diff(&run_dir, &args).await?;
@ -38,8 +42,7 @@ async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
});
}
let start = fabro_workflows::records::StartRecord::load(run_dir)
.context("Failed to load start.json")?;
let start = StartRecord::load(run_dir).context("Failed to load start.json")?;
let base_sha = start
.base_sha
@ -66,7 +69,7 @@ async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
)?;
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
let sandbox = fabro_sandbox::reconnect::reconnect(&record).await?;
let sandbox = reconnect(&record).await?;
let cmd = build_live_diff_cmd(base_sha, args.stat, args.shortstat);
debug!(cmd, "Running git diff in sandbox");
@ -98,8 +101,7 @@ fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String {
);
format!(
"{} add -N . && {} diff{flags} {quoted_sha}",
fabro_workflows::sandbox_git::GIT_REMOTE,
fabro_workflows::sandbox_git::GIT_REMOTE
GIT_REMOTE, GIT_REMOTE
)
}

View file

@ -2,16 +2,19 @@ use anyhow::Context;
use anyhow::Result;
use fabro_git_storage::gitobj::Store;
use fabro_util::terminal::Styles;
use fabro_workflows::operations::{
build_timeline, find_run_id_by_prefix, fork, ForkRunInput, RewindTarget,
};
use git2::Repository;
use crate::args::ForkArgs;
pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let run_id = fabro_workflows::operations::find_run_id_by_prefix(&repo, &args.run_id)?;
let run_id = find_run_id_by_prefix(&repo, &args.run_id)?;
let store = Store::new(repo);
let timeline = fabro_workflows::operations::build_timeline(&store, &run_id)?;
let timeline = build_timeline(&store, &run_id)?;
if args.list {
super::rewind::print_timeline(&timeline, styles);
@ -21,11 +24,11 @@ pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
let target = args
.target
.as_deref()
.map(str::parse::<fabro_workflows::operations::RewindTarget>)
.map(str::parse::<RewindTarget>)
.transpose()?;
let new_run_id = fabro_workflows::operations::fork(
let new_run_id = fork(
&store,
fabro_workflows::operations::ForkRunInput {
ForkRunInput {
source_run_id: run_id.clone(),
target,
push: !args.no_push,

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::FabroSettingsExt;
use fabro_workflows::records::RunRecordExt;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -46,7 +46,7 @@ pub(crate) fn remove_launcher_record(path: &Path) {
}
pub(crate) fn active_launcher_record_for_run(run_dir: &Path) -> Option<LauncherRecord> {
let run_record = fabro_workflows::records::RunRecord::load(run_dir).ok()?;
let run_record = RunRecord::load(run_dir).ok()?;
let path = launcher_record_path(&run_record.settings.storage_dir(), &run_record.run_id);
let launcher = read_launcher_record(&path)?;
if launcher_record_is_running(&launcher) {

View file

@ -5,14 +5,16 @@ use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use tracing::{debug, info};
use crate::args::LogsArgs;
use crate::cli_config::load_cli_settings;
pub fn run(args: LogsArgs, styles: &Styles) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run = resolve_run(&base, &args.run)?;
info!(run_id = %run.run_id, "Showing logs");

View file

@ -1,7 +1,11 @@
use anyhow::Result;
use fabro_config::cli::load_cli_config;
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use crate::args::{GlobalArgs, RunCommands};
use crate::cli_config::load_cli_settings;
pub(crate) mod attach;
pub(crate) mod command;
@ -26,27 +30,25 @@ pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> {
match cmd {
RunCommands::Run(args) => command::execute(args, globals).await,
RunCommands::Create(args) => {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = load_cli_config(None)?;
let (run_id, _run_dir) = create::create_run(&args, cli_defaults, styles, true).await?;
println!("{run_id}");
Ok(())
}
RunCommands::Start { run } => {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_info = fabro_workflows::run_lookup::resolve_run(&base, &run)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_info = resolve_run(&base, &run)?;
let child = start::start_run(&run_info.path, false)?;
eprintln!("Started engine process (PID {})", child.id());
Ok(())
}
RunCommands::Attach { run } => {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_info = fabro_workflows::run_lookup::resolve_run(&base, &run)?;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_info = resolve_run(&base, &run)?;
let exit_code = attach::attach_run(&run_info.path, false, styles, None).await?;
if exit_code != std::process::ExitCode::SUCCESS {
std::process::exit(1);
@ -63,29 +65,28 @@ pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> {
RunCommands::Ssh(args) => ssh::run(args).await,
RunCommands::Diff(args) => diff::run(args).await,
RunCommands::Logs(args) => {
let styles = fabro_util::terminal::Styles::detect_stdout();
let styles = Styles::detect_stdout();
logs::run(args, &styles)
}
RunCommands::Resume(args) => {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let cli_config = load_cli_settings(None)?;
crate::sleep_inhibitor::guard(cli_config.prevent_idle_sleep_enabled())
};
resume::resume_command(args, styles).await
}
RunCommands::Rewind(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
let styles = Styles::detect_stderr();
rewind::run(&args, &styles)
}
RunCommands::Fork(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
let styles = Styles::detect_stderr();
fork::run(&args, &styles)
}
RunCommands::Wait(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
let styles = Styles::detect_stderr();
wait::run(args, &styles)
}
}

View file

@ -1,17 +1,21 @@
use std::path::Path;
use std::time::Duration;
use fabro_graphviz::graph::Graph;
use fabro_store::RuntimeState;
use fabro_util::terminal::Styles;
use fabro_util::text::strip_goal_decoration;
use fabro_workflows::asset_snapshot::collect_asset_paths;
use fabro_workflows::outcome::{format_cost, StageStatus};
use fabro_workflows::pipeline::{Persisted, Validated};
use fabro_workflows::records::{Checkpoint, CheckpointExt, ConclusionExt};
use fabro_workflows::pull_request::PullRequestRecord;
use fabro_workflows::records::{Checkpoint, CheckpointExt, Conclusion, ConclusionExt};
use indicatif::HumanDuration;
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};
fn print_workflow_header(
graph: &fabro_graphviz::graph::Graph,
graph: &Graph,
diagnostics: &[fabro_validate::Diagnostic],
dot_path: Option<&Path>,
styles: &Styles,
@ -37,7 +41,7 @@ fn print_workflow_header(
let goal = graph.goal();
if !goal.is_empty() {
let stripped = fabro_util::text::strip_goal_decoration(goal);
let stripped = strip_goal_decoration(goal);
eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:"));
}
@ -69,14 +73,14 @@ pub(crate) fn print_diagnostics_from_error(
pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
let conclusion_path = run_dir.join("conclusion.json");
let Ok(conclusion) = fabro_workflows::records::Conclusion::load(&conclusion_path) else {
let Ok(conclusion) = Conclusion::load(&conclusion_path) else {
return;
};
let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json"))
.ok()
.and_then(|content| {
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
serde_json::from_str::<PullRequestRecord>(&content)
.ok()
.map(|record| record.html_url)
});
@ -94,7 +98,7 @@ pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
}
pub(crate) fn print_run_conclusion(
conclusion: &fabro_workflows::records::Conclusion,
conclusion: &Conclusion,
run_id: &str,
run_dir: &Path,
pushed_branch: Option<&str>,
@ -201,7 +205,7 @@ pub(crate) fn print_final_output(run_dir: &Path, styles: &Styles) {
pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) {
let runtime_state = RuntimeState::new(run_dir);
let paths = fabro_workflows::asset_snapshot::collect_asset_paths(&runtime_state.assets_dir());
let paths = collect_asset_paths(&runtime_state.assets_dir());
if paths.is_empty() {
return;
}

View file

@ -1,6 +1,7 @@
use std::collections::HashMap;
use anyhow::Result;
use fabro_config::run::LlmConfig;
use fabro_config::{sandbox as sandbox_config, FabroConfig};
use fabro_sandbox::SandboxProvider;
@ -23,7 +24,7 @@ impl TryFrom<&RunArgs> for FabroConfig {
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
let llm = if args.model.is_some() || args.provider.is_some() {
Some(fabro_config::run::LlmConfig {
Some(LlmConfig {
model: args.model.clone(),
provider: args.provider.clone(),
fallbacks: None,
@ -65,7 +66,7 @@ impl TryFrom<&PreflightArgs> for FabroConfig {
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {
let llm = if args.model.is_some() || args.provider.is_some() {
Some(fabro_config::run::LlmConfig {
Some(LlmConfig {
model: args.model.clone(),
provider: args.provider.clone(),
fallbacks: None,

View file

@ -1,15 +1,18 @@
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::daytona::DaytonaSandbox;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use tracing::info;
use crate::args::PreviewArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::validate_daytona_provider;
pub async fn run(args: PreviewArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_dir = resolve_run(&base, &args.run)?.path;
let sandbox_json = run_dir.join("sandbox.json");
let record = fabro_sandbox::SandboxRecord::load(&sandbox_json).context(
"Failed to load sandbox.json — was this run started with a recent version of arc?",
@ -24,7 +27,7 @@ pub async fn run(args: PreviewArgs) -> Result<()> {
info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL");
let daytona = fabro_sandbox::daytona::DaytonaSandbox::reconnect(name)
let daytona = DaytonaSandbox::reconnect(name)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;

View file

@ -2,8 +2,10 @@ use anyhow::bail;
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use fabro_workflows::run_lookup::{find_run_by_prefix, runs_base};
use crate::args::ResumeArgs;
use crate::cli_config::load_cli_settings;
/// Resume an interrupted workflow run.
///
@ -11,9 +13,9 @@ use crate::args::ResumeArgs;
/// artifacts from the previous execution, then spawns an engine subprocess
/// (identical to `fabro run`'s create→start→attach flow).
pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow::Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_dir = fabro_workflows::run_lookup::find_run_by_prefix(&base, &args.run)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_dir = find_run_by_prefix(&base, &args.run)?;
// find_run_by_prefix can match orphan directories (no run.json).
if !run_dir.join("run.json").exists() {

View file

@ -4,6 +4,9 @@ use cli_table::format::{Border, Separator};
use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table};
use fabro_git_storage::gitobj::Store;
use fabro_util::terminal::Styles;
use fabro_workflows::operations::{
build_timeline, find_run_id_by_prefix, rewind, RewindInput, RewindTarget, RunTimeline,
};
use git2::Repository;
use crate::args::RewindArgs;
@ -11,25 +14,21 @@ use crate::shared::color_if;
pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let run_id = fabro_workflows::operations::find_run_id_by_prefix(&repo, &args.run_id)?;
let run_id = find_run_id_by_prefix(&repo, &args.run_id)?;
let store = Store::new(repo);
let timeline = fabro_workflows::operations::build_timeline(&store, &run_id)?;
let timeline = build_timeline(&store, &run_id)?;
if args.list || args.target.is_none() {
print_timeline(&timeline, styles);
return Ok(());
}
let target = args
.target
.as_deref()
.unwrap()
.parse::<fabro_workflows::operations::RewindTarget>()?;
let target = args.target.as_deref().unwrap().parse::<RewindTarget>()?;
fabro_workflows::operations::rewind(
rewind(
&store,
fabro_workflows::operations::RewindInput {
RewindInput {
run_id: run_id.clone(),
target,
push: !args.no_push,
@ -44,7 +43,7 @@ pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
Ok(())
}
pub(crate) fn print_timeline(timeline: &fabro_workflows::operations::RunTimeline, styles: &Styles) {
pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles) {
if timeline.entries.is_empty() {
eprintln!("No checkpoints found.");
return;

View file

@ -9,6 +9,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use fabro_agent::AgentEvent;
use fabro_interview::{Answer, ConsoleInterviewer, Interviewer, Question};
use fabro_util::version::FABRO_VERSION;
use fabro_workflows::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use fabro_workflows::outcome::StageStatus;
@ -1227,7 +1228,7 @@ impl ProgressUI {
}
pub fn show_version(&mut self) {
let version = fabro_util::version::FABRO_VERSION;
let version = FABRO_VERSION;
match &self.renderer {
ProgressRenderer::Tty(tty) => {
let bar = tty.multi.add(ProgressBar::new_spinner());

View file

@ -1,15 +1,18 @@
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::daytona::DaytonaSandbox;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use tracing::info;
use crate::args::SshArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::validate_daytona_provider;
pub async fn run(args: SshArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_dir = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?.path;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_dir = resolve_run(&base, &args.run)?.path;
let sandbox_json = run_dir.join("sandbox.json");
let record = fabro_sandbox::SandboxRecord::load(&sandbox_json).context(
"Failed to load sandbox.json — was this run started with a recent version of arc?",
@ -24,7 +27,7 @@ pub async fn run(args: SshArgs) -> Result<()> {
info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access");
let daytona = fabro_sandbox::daytona::DaytonaSandbox::reconnect(name)
let daytona = DaytonaSandbox::reconnect(name)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;

View file

@ -3,7 +3,7 @@ use std::path::Path;
use anyhow::{anyhow, Result};
use chrono::Utc;
use fabro_config::FabroSettingsExt;
use fabro_workflows::records::RunRecordExt;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use super::launcher::{
launcher_log_path, launcher_record_path, remove_launcher_record, write_launcher_record,
@ -15,7 +15,7 @@ use super::launcher::{
/// The engine process reads `run.json` from the run directory and executes the
/// workflow. Returns the child process handle (use `.id()` for the PID).
pub fn start_run(run_dir: &Path, resume: bool) -> Result<std::process::Child> {
let record = fabro_workflows::records::RunRecord::load(run_dir)
let record = RunRecord::load(run_dir)
.map_err(|e| anyhow!("Cannot start run: failed to load run.json: {e}"))?;
let storage_dir = record.settings.storage_dir();

View file

@ -3,17 +3,19 @@ use std::io::Write;
use anyhow::{bail, Result};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::records::ConclusionExt;
use fabro_workflows::records::{Conclusion, ConclusionExt};
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use fabro_workflows::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt};
use tracing::info;
use crate::args::WaitArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::format_duration_ms;
pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run_info = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run_info = resolve_run(&base, &args.run)?;
info!(run_id = %run_info.run_id, "Waiting for run to complete");
@ -49,7 +51,7 @@ pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
};
let conclusion_path = run_info.path.join("conclusion.json");
let conclusion = fabro_workflows::records::Conclusion::load(&conclusion_path).ok();
let conclusion = Conclusion::load(&conclusion_path).ok();
if args.json {
let json_value = build_json_output(final_status, &run_info.run_id, conclusion.as_ref());
@ -70,7 +72,7 @@ pub fn run(args: WaitArgs, styles: &Styles) -> Result<()> {
fn build_json_output(
status: RunStatus,
run_id: &str,
conclusion: Option<&fabro_workflows::records::Conclusion>,
conclusion: Option<&Conclusion>,
) -> serde_json::Value {
let mut value = serde_json::json!({
"run_id": run_id,
@ -88,7 +90,7 @@ fn build_json_output(
fn print_human_output(
status: RunStatus,
run_id: &str,
conclusion: Option<&fabro_workflows::records::Conclusion>,
conclusion: Option<&Conclusion>,
styles: &Styles,
) {
let (style, label) = match status {

View file

@ -6,13 +6,18 @@ use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::records::{CheckpointExt, ConclusionExt, RunRecordExt, StartRecordExt};
use serde::Serialize;
use fabro_workflows::records::{Checkpoint, Conclusion, RunRecord, StartRecord};
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use fabro_workflows::run_status::RunStatus;
use crate::args::InspectArgs;
use crate::cli_config::load_cli_settings;
#[derive(Debug, Serialize)]
pub struct InspectOutput {
pub run_id: String,
pub run_dir: PathBuf,
pub status: fabro_workflows::run_status::RunStatus,
pub status: RunStatus,
pub run_record: Option<serde_json::Value>,
pub start_record: Option<serde_json::Value>,
pub conclusion: Option<serde_json::Value>,
@ -21,30 +26,26 @@ pub struct InspectOutput {
}
pub fn run(args: &InspectArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let run = resolve_run(&base, &args.run)?;
let output = inspect_run_dir(&run.run_id, &run.path, run.status)?;
let json = serde_json::to_string_pretty(&[output])?;
println!("{json}");
Ok(())
}
fn inspect_run_dir(
run_id: &str,
run_dir: &Path,
status: fabro_workflows::run_status::RunStatus,
) -> Result<InspectOutput> {
let run_record = fabro_workflows::records::RunRecord::load(run_dir)
fn inspect_run_dir(run_id: &str, run_dir: &Path, status: RunStatus) -> Result<InspectOutput> {
let run_record = RunRecord::load(run_dir)
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let start_record = fabro_workflows::records::StartRecord::load(run_dir)
let start_record = StartRecord::load(run_dir)
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let conclusion = fabro_workflows::records::Conclusion::load(&run_dir.join("conclusion.json"))
let conclusion = Conclusion::load(&run_dir.join("conclusion.json"))
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let checkpoint = fabro_workflows::records::Checkpoint::load(&run_dir.join("checkpoint.json"))
let checkpoint = Checkpoint::load(&run_dir.join("checkpoint.json"))
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let sandbox = fabro_sandbox::SandboxRecord::load(&run_dir.join("sandbox.json"))

View file

@ -7,26 +7,31 @@ use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_util::text::strip_goal_decoration;
use fabro_workflows::run_lookup::{filter_runs, runs_base, scan_runs, StatusFilter};
use fabro_workflows::run_status::RunStatus;
use crate::args::RunsListArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::{color_if, format_duration_ms, tilde_path};
use super::short_run_id;
pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let runs = fabro_workflows::run_lookup::scan_runs(&base)?;
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
let runs = scan_runs(&base)?;
let label_filters = parse_label_filters(&args.filter.label);
let filtered = fabro_workflows::run_lookup::filter_runs(
let filtered = filter_runs(
&runs,
args.filter.before.as_deref(),
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
if args.all {
fabro_workflows::run_lookup::StatusFilter::All
StatusFilter::All
} else {
fabro_workflows::run_lookup::StatusFilter::RunningOnly
StatusFilter::RunningOnly
},
);
@ -110,17 +115,15 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
Ok(())
}
fn status_cell(status: fabro_workflows::run_status::RunStatus, use_color: bool) -> CellStruct {
fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
let text = status.to_string();
let color = match status {
fabro_workflows::run_status::RunStatus::Succeeded => Some(Color::Green),
fabro_workflows::run_status::RunStatus::Failed => Some(Color::Red),
fabro_workflows::run_status::RunStatus::Running
| fabro_workflows::run_status::RunStatus::Starting
| fabro_workflows::run_status::RunStatus::Submitted => Some(Color::Cyan),
fabro_workflows::run_status::RunStatus::Removing => Some(Color::Yellow),
fabro_workflows::run_status::RunStatus::Paused => Some(Color::Magenta),
fabro_workflows::run_status::RunStatus::Dead => Some(Color::Ansi256(8)),
RunStatus::Succeeded => Some(Color::Green),
RunStatus::Failed => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused => Some(Color::Magenta),
RunStatus::Dead => Some(Color::Ansi256(8)),
};
text.cell()
.bold(use_color && color != Some(Color::Ansi256(8)))
@ -136,7 +139,7 @@ fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
}
fn truncate_goal(goal: &str, max_len: usize) -> String {
truncate_str(fabro_util::text::strip_goal_decoration(goal), max_len)
truncate_str(strip_goal_decoration(goal), max_len)
}
fn truncate_str(s: &str, max_len: usize) -> String {

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use fabro_util::terminal::Styles;
use crate::args::RunsCommands;
@ -9,7 +10,7 @@ pub(crate) mod rm;
pub async fn dispatch(cmd: RunsCommands) -> Result<()> {
match cmd {
RunsCommands::Ps(args) => {
let styles = fabro_util::terminal::Styles::detect_stdout();
let styles = Styles::detect_stdout();
list::list_command(&args, &styles)
}
RunsCommands::Rm(args) => rm::remove_command(&args).await,

View file

@ -5,13 +5,18 @@ use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use tracing::warn;
use fabro_sandbox::reconnect::reconnect as reconnect_sandbox;
use fabro_workflows::run_lookup::{resolve_run, runs_base};
use fabro_workflows::run_status::{write_run_status, RunStatus};
use crate::args::RunsRemoveArgs;
use crate::cli_config::load_cli_settings;
use super::short_run_id;
pub async fn remove_command(args: &RunsRemoveArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
remove_from(args, &base).await
}
@ -19,7 +24,7 @@ async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
let mut had_errors = false;
for identifier in &args.runs {
let run = match fabro_workflows::run_lookup::resolve_run(base, identifier) {
let run = match resolve_run(base, identifier) {
Ok(run) => run,
Err(err) => {
eprintln!("error: {identifier}: {err}");
@ -38,16 +43,12 @@ async fn remove_from(args: &RunsRemoveArgs, base: &Path) -> Result<()> {
continue;
}
fabro_workflows::run_status::write_run_status(
&run.path,
fabro_workflows::run_status::RunStatus::Removing,
None,
);
write_run_status(&run.path, RunStatus::Removing, None);
let sandbox_path = run.path.join("sandbox.json");
if let Ok(record) = fabro_sandbox::SandboxRecord::load(&sandbox_path) {
if record.provider != "local" {
match fabro_sandbox::reconnect::reconnect(&record).await {
match reconnect_sandbox(&record).await {
Ok(sandbox) => {
if let Err(err) = sandbox.cleanup().await {
warn!(run_id = %run.run_id, error = %err, "sandbox cleanup failed");

View file

@ -6,19 +6,23 @@ use cli_table::format::{Border, Justify, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Style, Table};
use fabro_config::FabroSettingsExt;
use fabro_workflows::run_lookup::{logs_base, runs_base, scan_runs};
use fabro_workflows::run_status::RunStatus;
use crate::args::DfArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub fn df_command(args: &DfArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let cli_config = load_cli_settings(None)?;
let data_dir = cli_config.storage_dir();
let runs_base = fabro_workflows::run_lookup::runs_base(&data_dir);
let logs_base = fabro_workflows::run_lookup::logs_base(&data_dir);
df_from(args, &data_dir, &runs_base, &logs_base)
let runs_base_dir = runs_base(&data_dir);
let logs_base_dir = logs_base(&data_dir);
df_from(args, &data_dir, &runs_base_dir, &logs_base_dir)
}
fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(runs_base)?;
let runs = scan_runs(runs_base)?;
let mut active_count = 0u64;
let mut total_run_size = 0u64;
let mut reclaimable_run_size = 0u64;
@ -26,7 +30,7 @@ fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Path) -
struct RunSizeInfo {
run_id: String,
workflow_name: String,
status: fabro_workflows::run_status::RunStatus,
status: RunStatus,
start_time_dt: Option<DateTime<Utc>>,
size: u64,
}

View file

@ -5,12 +5,15 @@ use chrono::Utc;
use fabro_config::FabroSettingsExt;
use tracing::{debug, info};
use fabro_workflows::run_lookup::{filter_runs, runs_base, scan_runs, StatusFilter};
use crate::args::RunsPruneArgs;
use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub fn prune_command(args: &RunsPruneArgs) -> Result<()> {
let cli_config = crate::cli_config::load_cli_settings(None)?;
let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir());
let cli_config = load_cli_settings(None)?;
let base = runs_base(&cli_config.storage_dir());
prune_from(args, &base)
}
@ -31,15 +34,15 @@ pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
}
fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> {
let runs = fabro_workflows::run_lookup::scan_runs(base)?;
let runs = scan_runs(base)?;
let label_filters = parse_label_filters(&args.filter.label);
let mut filtered = fabro_workflows::run_lookup::filter_runs(
let mut filtered = filter_runs(
&runs,
args.filter.before.as_deref(),
args.filter.workflow.as_deref(),
&label_filters,
args.filter.orphans,
fabro_workflows::run_lookup::StatusFilter::All,
StatusFilter::All,
);
let has_explicit_filters =

View file

@ -7,6 +7,9 @@ use semver::Version;
use sha2::{Digest, Sha256};
use tracing::debug;
use tokio::process::Command as TokioCommand;
use tokio::task::JoinHandle;
use crate::args::UpgradeArgs;
// ── Download backend abstraction ───────────────────────────────────────────
@ -29,7 +32,7 @@ impl Backend {
async fn fetch_latest_release_tag(&self) -> Result<String> {
match self {
Backend::Gh => {
let output = tokio::process::Command::new("gh")
let output = TokioCommand::new("gh")
.args([
"release",
"view",
@ -75,7 +78,7 @@ impl Backend {
let dest = dest_dir.join(asset);
match self {
Backend::Gh => {
let status = tokio::process::Command::new("gh")
let status = TokioCommand::new("gh")
.args([
"release",
"download",
@ -117,10 +120,7 @@ impl Backend {
async fn select_backend() -> Backend {
// Check if gh is available
let gh_version = tokio::process::Command::new("gh")
.arg("--version")
.output()
.await;
let gh_version = TokioCommand::new("gh").arg("--version").output().await;
let Ok(output) = gh_version else {
debug!("gh CLI not found, using HTTP backend");
return Backend::Http(http_client().expect("failed to build HTTP client"));
@ -131,7 +131,7 @@ async fn select_backend() -> Backend {
}
// Check if gh is authenticated
let auth_status = tokio::process::Command::new("gh")
let auth_status = TokioCommand::new("gh")
.args(["auth", "status"])
.output()
.await;
@ -349,7 +349,7 @@ pub async fn run_upgrade(args: UpgradeArgs) -> Result<()> {
pub fn spawn_upgrade_check(
no_upgrade_check: bool,
upgrade_check_enabled: bool,
) -> Option<tokio::task::JoinHandle<()>> {
) -> Option<JoinHandle<()>> {
if no_upgrade_check || !upgrade_check_enabled {
return None;
}

View file

@ -1,29 +1,31 @@
use anyhow::bail;
use fabro_config::project::ResolveSettingsInput;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{resolve_settings, resolve_workflow_path, ResolveSettingsInput};
use fabro_config::FabroConfig;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
use fabro_workflows::operations::{validate, ValidateInput, WorkflowInput};
use crate::args::ValidateArgs;
use crate::shared::{print_diagnostics, relative_path};
pub fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
let settings = fabro_config::project::resolve_settings(ResolveSettingsInput {
let cli_defaults = load_cli_config(None)?;
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: fabro_config::FabroConfig::default(),
overrides: FabroConfig::default(),
apply_project_config: true,
})?;
let resolution = fabro_config::project::resolve_workflow_path(&args.workflow, &cwd)?;
let validated =
fabro_workflows::operations::validate(fabro_workflows::operations::ValidateInput {
workflow: fabro_workflows::operations::WorkflowInput::Path(args.workflow.clone()),
settings,
cwd,
custom_transforms: Vec::new(),
})?;
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(args.workflow.clone()),
settings,
cwd,
custom_transforms: Vec::new(),
})?;
let graph = validated.graph();
let diagnostics = validated.diagnostics();

View file

@ -2,13 +2,15 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::project::{discover_project_config, resolve_fabro_root};
use crate::args::WorkflowCreateArgs;
use crate::shared::relative_path;
pub fn create_command(args: &WorkflowCreateArgs) -> Result<()> {
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
let (config_path, config) = match discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
@ -16,7 +18,7 @@ pub fn create_command(args: &WorkflowCreateArgs) -> Result<()> {
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path, &config);
write_workflow_scaffold(args, &fabro_root)?;
let workflows_dir = fabro_root.join("workflows").join(&args.name);

View file

@ -1,6 +1,11 @@
use anyhow::{bail, Result};
use fabro_util::terminal::Styles;
use fabro_config::project::{
discover_project_config, list_workflows_detailed, resolve_fabro_root, WorkflowInfo,
WorkflowSource,
};
use crate::args::WorkflowListArgs;
use crate::shared::relative_path;
@ -10,7 +15,7 @@ pub fn list_command(_args: &WorkflowListArgs) -> Result<()> {
let styles = Styles::detect_stderr();
let cwd = std::env::current_dir()?;
let (config_path, config) = match fabro_config::project::discover_project_config(&cwd)? {
let (config_path, config) = match discover_project_config(&cwd)? {
Some(found) => found,
None => bail!(
"No fabro.toml found in {cwd} or any parent directory",
@ -18,22 +23,19 @@ pub fn list_command(_args: &WorkflowListArgs) -> Result<()> {
),
};
let fabro_root = fabro_config::project::resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path, &config);
let project_wf_dir = fabro_root.join("workflows");
let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows"));
let workflows = fabro_config::project::list_workflows_detailed(
Some(&project_wf_dir),
user_wf_dir.as_deref(),
);
let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref());
let project: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::Project)
.filter(|w| w.source == WorkflowSource::Project)
.collect();
let user: Vec<_> = workflows
.iter()
.filter(|w| w.source == fabro_config::project::WorkflowSource::User)
.filter(|w| w.source == WorkflowSource::User)
.collect();
let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0);
@ -65,7 +67,7 @@ pub fn list_command(_args: &WorkflowListArgs) -> Result<()> {
fn print_section(
title: &str,
path: &str,
workflows: &[&fabro_config::project::WorkflowInfo],
workflows: &[&WorkflowInfo],
name_width: usize,
styles: &Styles,
) {

View file

@ -1,4 +1,6 @@
use anyhow::{Context, Result};
use fabro_util::run_log;
use tracing_appender::rolling;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init_tracing(debug: bool, config_log_level: Option<&str>, log_prefix: &str) -> Result<()> {
@ -20,9 +22,9 @@ pub fn init_tracing(debug: bool, config_log_level: Option<&str>, log_prefix: &st
let filename = chrono::Local::now()
.format(&format!("{log_prefix}-%Y-%m-%d.log"))
.to_string();
let file_appender = tracing_appender::rolling::never(&log_dir, &filename);
let file_appender = rolling::never(&log_dir, &filename);
let run_log_writer = fabro_util::run_log::init();
let run_log_writer = run_log::init();
tracing_subscriber::registry()
.with(filter)

View file

@ -9,6 +9,9 @@ mod sleep_inhibitor;
use anyhow::Result;
use args::{Commands, GlobalArgs, RunCommands, LONG_VERSION};
use clap::Parser;
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
use fabro_util::terminal::Styles;
use rustls::crypto::ring::default_provider;
use tracing::debug;
#[derive(Parser)]
@ -23,7 +26,7 @@ struct Cli {
#[tokio::main]
async fn main() {
fabro_telemetry::panic::install_panic_hook();
tel_panic::install_panic_hook();
fabro_telemetry::init_cli();
let start = std::time::Instant::now();
@ -33,8 +36,8 @@ async fn main() {
let duration_ms = start.elapsed().as_millis() as u64;
let is_error = result.is_err();
let command = fabro_telemetry::sanitize::sanitize_command(&raw_args, &command_name);
let repository = fabro_telemetry::git::repository_identifier();
let command = sanitize::sanitize_command(&raw_args, &command_name);
let repository = git::repository_identifier();
let ci = std::env::var("CI").is_ok();
if is_error {
fabro_telemetry::track!("CLI Errored", {
@ -82,7 +85,7 @@ async fn main() {
}
async fn main_inner() -> (String, Result<()>) {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = default_provider().install_default();
let cli = Cli::parse();
if let Some(home) = dirs::home_dir() {
@ -107,7 +110,7 @@ async fn main_inner() -> (String, Result<()>) {
Err(err) => return (command_name, Err(err)),
}
} else {
match crate::cli_config::load_cli_settings(None) {
match cli_config::load_cli_settings(None) {
Ok(cli_config) => (
cli_config.log.as_ref().and_then(|l| l.level.clone()),
cli_config.upgrade_check_enabled(),
@ -118,7 +121,7 @@ async fn main_inner() -> (String, Result<()>) {
}
#[cfg(not(feature = "server"))]
{
match crate::cli_config::load_cli_settings(None) {
match cli_config::load_cli_settings(None) {
Ok(cli_config) => (
cli_config.log.as_ref().and_then(|l| l.level.clone()),
cli_config.upgrade_check_enabled(),
@ -160,11 +163,11 @@ async fn main_inner() -> (String, Result<()>) {
Commands::RunCmd(cmd) => commands::run::dispatch(cmd, &globals).await?,
Commands::Preflight(args) => commands::preflight::execute(args).await?,
Commands::Validate(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
let styles = Styles::detect_stderr();
commands::validate::run(&args, &styles)?;
}
Commands::Graph(args) => {
let styles = fabro_util::terminal::Styles::detect_stderr();
let styles = Styles::detect_stderr();
commands::graph::run(&args, &styles)?;
}
Commands::Parse(args) => {
@ -175,8 +178,7 @@ async fn main_inner() -> (String, Result<()>) {
Commands::Model { command } => commands::model::execute(command, &globals).await?,
#[cfg(feature = "server")]
Commands::Serve(args) => {
let styles: &'static fabro_util::terminal::Styles =
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
fabro_api::serve::serve_command(args, styles).await?;
}
Commands::Doctor { verbose, dry_run } => {
@ -213,12 +215,12 @@ async fn main_inner() -> (String, Result<()>) {
Commands::Provider(ns) => commands::provider::dispatch(ns).await?,
Commands::System(ns) => commands::system::dispatch(ns)?,
Commands::SendAnalytics { path } => {
let result = fabro_telemetry::sender::upload(&path).await;
let result = sender::upload(&path).await;
let _ = std::fs::remove_file(&path);
result?;
}
Commands::SendPanic { path } => {
let result = fabro_telemetry::panic::capture(&path).await;
let result = tel_panic::capture(&path).await;
let _ = std::fs::remove_file(&path);
result?;
}

View file

@ -1,16 +1,17 @@
pub(crate) fn build_github_app_credentials(
app_id: Option<&str>,
) -> Option<fabro_github::GitHubAppCredentials> {
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
use fabro_github::GitHubAppCredentials;
pub(crate) fn build_github_app_credentials(app_id: Option<&str>) -> Option<GitHubAppCredentials> {
let app_id = app_id?;
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
let private_key_pem = if raw.starts_with("-----") {
raw
} else {
let pem_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw).ok()?;
let pem_bytes = BASE64_STANDARD.decode(&raw).ok()?;
String::from_utf8(pem_bytes).ok()?
};
Some(fabro_github::GitHubAppCredentials {
Some(GitHubAppCredentials {
app_id: app_id.to_string(),
private_key_pem,
})

View file

@ -1,9 +1,16 @@
use std::path::Path;
use anyhow::Result;
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_config::dotenv::{merge_env, write_env_file as write_env};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{generate, GenerateParams};
use fabro_model::Provider;
use fabro_util::terminal::Styles;
use tokio::task::spawn_blocking;
use tokio::time::timeout;
use crate::commands::doctor;
@ -111,20 +118,16 @@ pub(crate) async fn run_openai_oauth_or_api_key(s: &Styles) -> Result<Vec<(Strin
// ---------------------------------------------------------------------------
pub(crate) fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
Ok(
Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.default(default)
.interact_on(&dialoguer::console::Term::stderr())?,
)
Ok(Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.default(default)
.interact_on(&Term::stderr())?)
}
pub(crate) fn prompt_password(prompt: &str) -> Result<String> {
Ok(
Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&dialoguer::console::Term::stderr())?,
)
Ok(Password::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.interact_on(&Term::stderr())?)
}
// ---------------------------------------------------------------------------
@ -142,8 +145,8 @@ pub(crate) fn write_env_file(
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let merged = fabro_config::dotenv::merge_env(&existing, &refs);
fabro_config::dotenv::write_env_file(&env_path, &merged)?;
let merged = merge_env(&existing, &refs);
write_env(&env_path, &merged)?;
eprintln!(
" {}",
s.dim.apply_to(format!("Wrote {}", env_path.display()))
@ -160,24 +163,19 @@ pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Resul
let env_var = provider.api_key_env_vars()[0];
std::env::set_var(env_var, api_key);
let client = fabro_llm::client::Client::from_env()
.await
.map_err(|e| e.to_string())?;
let client = LlmClient::from_env().await.map_err(|e| e.to_string())?;
let params = fabro_llm::generate::GenerateParams::new(doctor::probe_model(provider))
let params = GenerateParams::new(doctor::probe_model(provider))
.provider(provider.as_str())
.prompt("Say OK")
.max_tokens(16)
.client(std::sync::Arc::new(client));
tokio::time::timeout(
std::time::Duration::from_secs(30),
fabro_llm::generate::generate(params),
)
.await
.map_err(|_| "timeout (30s)".to_string())?
.map(|_| ())
.map_err(|e| e.to_string())
timeout(std::time::Duration::from_secs(30), generate(params))
.await
.map_err(|_| "timeout (30s)".to_string())?
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) async fn prompt_and_validate_key(
@ -193,7 +191,7 @@ pub(crate) async fn prompt_and_validate_key(
loop {
let prompt = env_var.to_string();
let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??;
let key: String = spawn_blocking(move || prompt_password(&prompt)).await??;
eprintln!(" {}", s.dim.apply_to("Validating API key..."));
match validate_api_key(provider, &key).await {
@ -203,10 +201,9 @@ pub(crate) async fn prompt_and_validate_key(
}
Err(e) => {
eprintln!(" [error] API key validation failed: {e}");
let retry = tokio::task::spawn_blocking(|| {
prompt_confirm("Try again with a different key?", true)
})
.await??;
let retry =
spawn_blocking(|| prompt_confirm("Try again with a different key?", true))
.await??;
if !retry {
return Ok((env_var.to_string(), key));
}

View file

@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::FabroConfig;
pub use fabro_types::settings::cli::{
ClientTlsSettings, ExecSettings, ExecutionMode, OutputFormat, PermissionLevel, ServerSettings,
};
@ -70,6 +72,6 @@ impl From<ExecConfig> for ExecSettings {
/// Load CLI config from an explicit path or `~/.fabro/cli.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<crate::config::FabroConfig> {
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<FabroConfig> {
crate::load_config_file(path, "cli.toml")
}

View file

@ -19,13 +19,15 @@ pub use settings::{FabroSettings, FabroSettingsExt};
use std::path::Path;
use serde::de::DeserializeOwned;
/// Load a TOML config from an explicit path or `~/.fabro/{filename}`.
///
/// Returns `T::default()` when no explicit path is given and the default file
/// doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_config_file<T>(path: Option<&Path>, filename: &str) -> anyhow::Result<T>
where
T: Default + serde::de::DeserializeOwned,
T: Default + DeserializeOwned,
{
if let Some(explicit) = path {
tracing::debug!(path = %explicit.display(), "Loading config from explicit path");

View file

@ -7,6 +7,7 @@ use tracing::debug;
use crate::combine::Combine;
use crate::config::FabroConfig;
use crate::sandbox::DockerfileSource;
pub use fabro_types::settings::run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
@ -179,12 +180,12 @@ fn resolve_dockerfile(config: &mut FabroConfig, config_dir: &Path) -> anyhow::Re
.and_then(|d| d.snapshot.as_mut())
.and_then(|snap| snap.dockerfile.as_mut());
if let Some(crate::sandbox::DockerfileSource::Path { path: ref rel }) = source {
if let Some(DockerfileSource::Path { path: ref rel }) = source {
let path = config_dir.join(rel);
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read dockerfile at {}", path.display()))?;
debug!(path = %path.display(), "Resolved dockerfile from path");
*source.unwrap() = crate::sandbox::DockerfileSource::Inline(contents);
*source.unwrap() = DockerfileSource::Inline(contents);
}
Ok(())

View file

@ -5,7 +5,7 @@ use std::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::context::Context;
use crate::error::{CoreError, Result};
use crate::error::{CoreError, Result, VisitLimitSource};
use crate::graph::{EdgeSpec, Graph, NodeSpec};
use crate::handler::NodeHandler;
use crate::lifecycle::{
@ -14,6 +14,7 @@ use crate::lifecycle::{
};
use crate::outcome::{NodeResult, NodeResultExt, Outcome, StageStatus};
use crate::state::RunState;
use tokio::time::sleep;
#[derive(Default)]
pub struct ExecutorOptions {
@ -147,7 +148,7 @@ impl<G: Graph + 'static> Executor<G> {
node_id: node.id().to_string(),
visits,
limit: max,
limit_source: crate::error::VisitLimitSource::Node,
limit_source: VisitLimitSource::Node,
});
}
}
@ -157,7 +158,7 @@ impl<G: Graph + 'static> Executor<G> {
node_id: node.id().to_string(),
visits,
limit: global_max,
limit_source: crate::error::VisitLimitSource::Graph,
limit_source: VisitLimitSource::Graph,
});
}
}
@ -276,7 +277,7 @@ impl<G: Graph + 'static> Executor<G> {
backoff_delay: Some(delay),
};
self.lifecycle.after_attempt(&ctx, state).await?;
tokio::time::sleep(delay).await;
sleep(delay).await;
}
Ok(outcome) if outcome.status == StageStatus::Retry => {
let final_outcome = self.handler.on_retries_exhausted(node, outcome);
@ -321,7 +322,7 @@ impl<G: Graph + 'static> Executor<G> {
backoff_delay: Some(delay),
};
self.lifecycle.after_attempt(&ctx, state).await?;
tokio::time::sleep(delay).await;
sleep(delay).await;
}
Err(e) => {
// Convert handler error to fail outcome so routing continues
@ -1876,7 +1877,7 @@ mod tests {
// Cancel stall token while "running"
self.0.cancel();
// Simulate long work
tokio::time::sleep(Duration::from_secs(10)).await;
sleep(Duration::from_secs(10)).await;
Ok(Outcome::success())
}
}
@ -1973,7 +1974,7 @@ mod tests {
_s: &RunState,
) -> Result<NodeDecision> {
self.0.cancel();
tokio::time::sleep(Duration::from_secs(10)).await;
sleep(Duration::from_secs(10)).await;
Ok(NodeDecision::Continue)
}
}

View file

@ -1,25 +1,16 @@
use std::time::Duration;
use crate::error::CoreError;
pub use fabro_types::outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus,
};
pub trait NodeResultExt<M: OutcomeMeta = ()> {
fn from_error(
error: &crate::error::CoreError,
duration: Duration,
attempts: u32,
max_attempts: u32,
) -> Self;
fn from_error(error: &CoreError, duration: Duration, attempts: u32, max_attempts: u32) -> Self;
}
impl<M: OutcomeMeta> NodeResultExt<M> for NodeResult<M> {
fn from_error(
error: &crate::error::CoreError,
duration: Duration,
attempts: u32,
max_attempts: u32,
) -> Self {
fn from_error(error: &CoreError, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
Self {
outcome: error.to_fail_outcome(),
duration,

View file

@ -3,6 +3,8 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use tokio::task::JoinHandle;
use tokio::time::sleep;
/// Trait for receiving stall timeout notifications.
pub trait ActivityMonitor: Send + Sync {
@ -25,7 +27,7 @@ pub struct StallWatchdog {
pub struct StallGuard {
activity: Arc<Notify>,
shutdown: Arc<AtomicBool>,
handle: Option<tokio::task::JoinHandle<()>>,
handle: Option<JoinHandle<()>>,
}
impl StallWatchdog {
@ -55,7 +57,7 @@ impl StallWatchdog {
let handle = tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(timeout) => {
_ = sleep(timeout) => {
if shutdown.load(Ordering::Relaxed) {
return;
}

View file

@ -1,6 +1,8 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use tokio::fs;
use tokio::process::Command;
use tracing::info;
use crate::types::{FeatureMetadata, LifecycleCommand};
@ -61,7 +63,7 @@ fn dir_name_from_id(feature_id: &str) -> String {
/// Ensure `oras` CLI is available, installing it if necessary.
async fn ensure_oras() -> crate::Result<()> {
let check = tokio::process::Command::new("which")
let check = Command::new("which")
.arg("oras")
.output()
.await
@ -74,7 +76,7 @@ async fn ensure_oras() -> crate::Result<()> {
info!("oras not found, attempting to install");
if cfg!(target_os = "macos") {
let status = tokio::process::Command::new("brew")
let status = Command::new("brew")
.args(["install", "oras"])
.status()
.await
@ -93,7 +95,7 @@ async fn ensure_oras() -> crate::Result<()> {
.map_err(|_| DevcontainerError::OrasInstall("HOME not set".to_string()))?;
let bin_dir = format!("{home}/.local/bin");
tokio::fs::create_dir_all(&bin_dir).await.map_err(|e| {
fs::create_dir_all(&bin_dir).await.map_err(|e| {
DevcontainerError::OrasInstall(format!("failed to create {bin_dir}: {e}"))
})?;
@ -107,7 +109,7 @@ async fn ensure_oras() -> crate::Result<()> {
"https://github.com/oras-project/oras/releases/download/v{version}/oras_{version}_linux_{arch}.tar.gz"
);
let status = tokio::process::Command::new("sh")
let status = Command::new("sh")
.args([
"-c",
&format!("curl -fsSL '{url}' | tar xzf - -C '{bin_dir}' oras"),
@ -128,7 +130,7 @@ async fn ensure_oras() -> crate::Result<()> {
/// Find the first `.tgz` file in a directory.
async fn find_tgz(dir: &Path) -> Option<String> {
let mut entries = tokio::fs::read_dir(dir).await.ok()?;
let mut entries = fs::read_dir(dir).await.ok()?;
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".tgz") {
@ -141,7 +143,7 @@ async fn find_tgz(dir: &Path) -> Option<String> {
/// Extract a tgz archive in the given directory.
async fn extract_tgz(feature_dir: &Path, tgz_name: &str, feature_id: &str) -> crate::Result<()> {
let status = tokio::process::Command::new("tar")
let status = Command::new("tar")
.args(["xzf", tgz_name])
.current_dir(feature_dir)
.status()
@ -159,11 +161,9 @@ async fn extract_tgz(feature_dir: &Path, tgz_name: &str, feature_id: &str) -> cr
/// Read and parse devcontainer-feature.json from a feature directory.
async fn read_feature_metadata(feature_dir: &Path) -> crate::Result<FeatureMetadata> {
let metadata_path = feature_dir.join("devcontainer-feature.json");
let metadata_str = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(|e| {
DevcontainerError::Feature(format!("failed to read {}: {e}", metadata_path.display()))
})?;
let metadata_str = fs::read_to_string(&metadata_path).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to read {}: {e}", metadata_path.display()))
})?;
serde_json::from_str(&metadata_str).map_err(|e| {
DevcontainerError::Feature(format!("failed to parse {}: {e}", metadata_path.display()))
@ -177,7 +177,7 @@ async fn create_feature_dir(
) -> crate::Result<std::path::PathBuf> {
let dir_name = dir_name_from_id(feature_id);
let feature_dir = output_dir.join(&dir_name);
tokio::fs::create_dir_all(&feature_dir).await.map_err(|e| {
fs::create_dir_all(&feature_dir).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to create dir {}: {e}",
feature_dir.display()
@ -192,7 +192,7 @@ async fn fetch_feature_oci(feature_id: &str, output_dir: &Path) -> crate::Result
info!(feature_id, "pulling feature with oras");
let output = tokio::process::Command::new("oras")
let output = Command::new("oras")
.args(["pull", feature_id, "-o"])
.arg(&feature_dir)
.output()
@ -259,7 +259,7 @@ async fn fetch_feature_https(
})?;
let tgz_path = feature_dir.join("devcontainer-feature.tgz");
tokio::fs::write(&tgz_path, &bytes).await.map_err(|e| {
fs::write(&tgz_path, &bytes).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to write {}: {e}", tgz_path.display()))
})?;
@ -290,11 +290,11 @@ async fn fetch_feature_dispatch(
/// Recursively copy a directory.
async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> {
tokio::fs::create_dir_all(dst).await.map_err(|e| {
fs::create_dir_all(dst).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to create dir {}: {e}", dst.display()))
})?;
let mut entries = tokio::fs::read_dir(src).await.map_err(|e| {
let mut entries = fs::read_dir(src).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to read dir {}: {e}", src.display()))
})?;
@ -309,15 +309,13 @@ async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> {
if entry_path.is_dir() {
Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?;
} else {
tokio::fs::copy(&entry_path, &dest_path)
.await
.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to copy {} to {}: {e}",
entry_path.display(),
dest_path.display()
))
})?;
fs::copy(&entry_path, &dest_path).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to copy {} to {}: {e}",
entry_path.display(),
dest_path.display()
))
})?;
}
}
@ -548,7 +546,7 @@ pub async fn resolve_features(
.as_nanos()
);
let tmp_dir = std::env::temp_dir().join(unique_id);
tokio::fs::create_dir_all(&tmp_dir)
fs::create_dir_all(&tmp_dir)
.await
.map_err(|e| DevcontainerError::Feature(format!("failed to create temp dir: {e}")))?;

View file

@ -10,6 +10,7 @@ mod variables;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_util::env::SystemEnv;
pub use types::DevcontainerJson;
/// Lifecycle command — string, array, or object (parallel) form.
@ -174,7 +175,7 @@ impl DevcontainerResolver {
.clone()
.unwrap_or_else(|| format!("/workspaces/{repo_name}"));
let system_env = fabro_util::env::SystemEnv;
let system_env = SystemEnv;
let preliminary_vars = variables::VariableContext {
local_workspace_folder: repo_root.to_string_lossy().to_string(),
local_workspace_folder_basename: repo_name.clone(),

View file

@ -1,9 +1,11 @@
use fabro_util::env::Env;
/// Context for variable substitution.
pub struct VariableContext<'a> {
pub local_workspace_folder: String,
pub local_workspace_folder_basename: String,
pub container_workspace_folder: String,
pub env: &'a dyn fabro_util::env::Env,
pub env: &'a dyn Env,
}
/// Replace devcontainer variables in a string value.

View file

@ -1,6 +1,8 @@
use nom::branch::alt;
use nom::character::complete::char;
use nom::bytes::complete::tag;
use nom::character::complete::{char, multispace0};
use nom::combinator::opt;
use nom::error::{Error, ParseError};
use nom::multi::{many0, separated_list0};
use nom::sequence::{delimited, preceded, tuple};
use nom::IResult;
@ -83,11 +85,11 @@ fn node_or_edge_stmt(input: &str) -> IResult<&str, Statement> {
let (rest, first_id) = preceded(ws, identifier)(input)?;
// Try to parse as edge: first_id (-> id)+ [attrs]? ;?
if let Ok((rest2, _)) = arrow::<nom::error::Error<&str>>(rest) {
if let Ok((rest2, _)) = arrow::<Error<&str>>(rest) {
let (rest2, second_id) = preceded(ws, identifier)(rest2)?;
let mut nodes = vec![first_id.to_string(), second_id.to_string()];
let mut remaining = rest2;
while let Ok((r, _)) = arrow::<nom::error::Error<&str>>(remaining) {
while let Ok((r, _)) = arrow::<Error<&str>>(remaining) {
let (r, next_id) = preceded(ws, identifier)(r)?;
nodes.push(next_id.to_string());
remaining = r;
@ -148,11 +150,8 @@ pub fn parse_dot_graph(input: &str) -> IResult<&str, DotGraph> {
}
// We need arrow to work with explicit error types
fn arrow<'a, E: nom::error::ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, &'a str, E> {
preceded(
nom::character::complete::multispace0,
nom::bytes::complete::tag("->"),
)(input)
fn arrow<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, &'a str, E> {
preceded(multispace0, tag("->"))(input)
}
#[cfg(test)]

View file

@ -56,8 +56,9 @@ pub mod combinators {
use nom::bytes::complete::{tag, take_while, take_while1};
use nom::character::complete::{char, multispace0};
use nom::combinator::{map, opt, recognize};
use nom::error::{Error, ErrorKind};
use nom::sequence::{delimited, pair, preceded};
use nom::IResult;
use nom::{Err, IResult};
use crate::parser::ast::AstValue;
@ -85,7 +86,7 @@ pub mod combinators {
let mut result = first.to_string();
let mut remaining = rest;
let mut found_dot = false;
while let Ok((r, _)) = char::<&str, nom::error::Error<&str>>('.')(remaining) {
while let Ok((r, _)) = char::<&str, Error<&str>>('.')(remaining) {
if let Ok((r2, segment)) = identifier(r) {
result.push('.');
result.push_str(segment);
@ -98,10 +99,7 @@ pub mod combinators {
if found_dot {
Ok((remaining, result))
} else {
Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
)))
Err(Err::Error(Error::new(input, ErrorKind::Tag)))
}
}
@ -148,10 +146,7 @@ pub mod combinators {
consumed += c.len_utf8();
}
None => {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Char,
)));
return Err(Err::Error(Error::new(input, ErrorKind::Char)));
}
}
}
@ -160,10 +155,7 @@ pub mod combinators {
consumed += c.len_utf8();
}
None => {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Char,
)));
return Err(Err::Error(Error::new(input, ErrorKind::Char)));
}
}
}
@ -175,10 +167,7 @@ pub mod combinators {
match word {
"true" => Ok((rest, true)),
"false" => Ok((rest, false)),
_ => Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
))),
_ => Err(Err::Error(Error::new(input, ErrorKind::Tag))),
}
}
@ -188,9 +177,9 @@ pub mod combinators {
pair(opt(char('-')), take_while(|c: char| c.is_ascii_digit())),
pair(char('.'), take_while1(|c: char| c.is_ascii_digit())),
))(input)?;
let val: f64 = raw.parse().map_err(|_| {
nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Float))
})?;
let val: f64 = raw
.parse()
.map_err(|_| Err::Error(Error::new(input, ErrorKind::Float)))?;
Ok((rest, val))
}
@ -201,14 +190,11 @@ pub mod combinators {
take_while1(|c: char| c.is_ascii_digit()),
))(input)?;
if rest.starts_with('.') {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Digit,
)));
return Err(Err::Error(Error::new(input, ErrorKind::Digit)));
}
let val: i64 = raw.parse().map_err(|_| {
nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Digit))
})?;
let val: i64 = raw
.parse()
.map_err(|_| Err::Error(Error::new(input, ErrorKind::Digit)))?;
Ok((rest, val))
}
@ -224,10 +210,7 @@ pub mod combinators {
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
)));
return Err(Err::Error(Error::new(input, ErrorKind::Tag)));
}
Ok((rest, AstValue::Str(format!("{num}{unit}"))))
}
@ -243,10 +226,7 @@ pub mod combinators {
take_while(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'),
))(input)?;
if !raw.contains('-') && !raw.contains('.') {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
)));
return Err(Err::Error(Error::new(input, ErrorKind::Verify)));
}
Ok((rest, raw.to_string()))
}

View file

@ -3,7 +3,7 @@ use std::time::Duration;
use crate::error::GraphvizError;
use crate::graph::types::{AttrValue, Edge, Graph, Node};
use crate::parser::ast::{AstValue, AttrBlock, DotGraph, Statement};
use crate::parser::ast::{AstValue, AttrBlock, DotGraph, EdgeStmt, NodeStmt, Statement};
/// Convert an AST `AstValue` to a semantic `AttrValue`.
fn convert_value(ast_val: &AstValue) -> AttrValue {
@ -89,11 +89,7 @@ impl SemanticState {
}
}
fn process_node(
&mut self,
node_stmt: &crate::parser::ast::NodeStmt,
subgraph_class: Option<&str>,
) {
fn process_node(&mut self, node_stmt: &NodeStmt, subgraph_class: Option<&str>) {
self.ensure_node(&node_stmt.id);
let node = self
.graph
@ -141,11 +137,7 @@ impl SemanticState {
}
}
fn process_edge(
&mut self,
edge_stmt: &crate::parser::ast::EdgeStmt,
subgraph_class: Option<&str>,
) {
fn process_edge(&mut self, edge_stmt: &EdgeStmt, subgraph_class: Option<&str>) {
for id in &edge_stmt.nodes {
self.ensure_node(id);
if let Some(cls) = subgraph_class {

View file

@ -5,8 +5,15 @@ use std::sync::{Arc, LazyLock};
use std::time::Instant;
use async_trait::async_trait;
use fabro_agent::tool_registry::ToolContext;
use fabro_agent::Sandbox;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{generate_object, GenerateParams};
use fabro_llm::types::{Message, Request, ToolResult};
use fabro_util::env::{Env, SystemEnv};
use tokio::process::Command as TokioCommand;
use tokio::time::timeout as tokio_timeout;
use tokio_util::sync::CancellationToken;
use crate::config::{HookDefinition, HookType, TlsMode};
use crate::types::{HookContext, HookDecision, HookResult, PromptHookResponse};
@ -40,11 +47,7 @@ pub trait HookExecutor: Send + Sync {
/// Interpolate `$VAR` and `${VAR}` references in `value` using environment
/// variables, but only when the variable name appears in `allowed_vars`.
/// Unlisted or missing vars are replaced with the empty string.
pub fn interpolate_env_vars(
value: &str,
allowed_vars: &[String],
env: &dyn fabro_util::env::Env,
) -> String {
pub fn interpolate_env_vars(value: &str, allowed_vars: &[String], env: &dyn Env) -> String {
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
@ -149,7 +152,7 @@ impl HookExecutorImpl {
},
}
} else {
let mut cmd = tokio::process::Command::new("sh");
let mut cmd = TokioCommand::new("sh");
cmd.arg("-c").arg(command);
if let Some(wd) = work_dir {
cmd.current_dir(wd);
@ -238,7 +241,7 @@ impl HookExecutorImpl {
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = HookDecision>,
{
match tokio::time::timeout(timeout, f()).await {
match tokio_timeout(timeout, f()).await {
Ok(decision) => decision,
Err(_) => {
tracing::warn!("{hook_kind} hook timed out, proceeding");
@ -258,12 +261,12 @@ impl HookExecutorImpl {
let user_msg = Self::build_hook_user_message(prompt, context);
Self::execute_llm_with_timeout(timeout, "prompt", || async move {
let params = fabro_llm::generate::GenerateParams::new(&resolved_model)
let params = GenerateParams::new(&resolved_model)
.system(HOOK_EVALUATOR_SYSTEM_PROMPT)
.prompt(user_msg)
.max_tokens(1024);
match fabro_llm::generate::generate_object(params, HOOK_RESPONSE_SCHEMA.clone()).await {
match generate_object(params, HOOK_RESPONSE_SCHEMA.clone()).await {
Ok(result) => match result.output {
Some(obj) => match serde_json::from_value::<PromptHookResponse>(obj) {
Ok(resp) if resp.ok => HookDecision::Proceed,
@ -306,7 +309,7 @@ impl HookExecutorImpl {
let user_msg = Self::build_hook_user_message(prompt, context);
Self::execute_llm_with_timeout(timeout, "agent", || async move {
let client = match fabro_llm::client::Client::from_env().await {
let client = match LlmClient::from_env().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "agent hook client creation failed, proceeding");
@ -320,15 +323,15 @@ impl HookExecutorImpl {
let tool_defs = registry.definitions();
let mut messages = vec![
fabro_llm::types::Message::system(HOOK_EVALUATOR_SYSTEM_PROMPT),
fabro_llm::types::Message::user(user_msg),
Message::system(HOOK_EVALUATOR_SYSTEM_PROMPT),
Message::user(user_msg),
];
let rounds = max_tool_rounds.unwrap_or(50);
let cancel = tokio_util::sync::CancellationToken::new();
let cancel = CancellationToken::new();
for _ in 0..rounds {
let request = fabro_llm::types::Request {
let request = Request {
model: resolved_model.clone(),
messages: messages.clone(),
provider: None,
@ -362,25 +365,23 @@ impl HookExecutorImpl {
for tc in &tool_calls {
let tool = registry.get(&tc.name).cloned();
let ctx = fabro_agent::tool_registry::ToolContext {
let ctx = ToolContext {
env: sandbox.clone(),
cancel: cancel.child_token(),
tool_env: None,
};
let result = match tool {
Some(t) => match (t.executor)(tc.arguments.clone(), ctx).await {
Ok(output) => fabro_llm::types::ToolResult::success(
tc.id.clone(),
serde_json::json!(output),
),
Err(err) => fabro_llm::types::ToolResult::error(tc.id.clone(), err),
Ok(output) => {
ToolResult::success(tc.id.clone(), serde_json::json!(output))
}
Err(err) => ToolResult::error(tc.id.clone(), err),
},
None => fabro_llm::types::ToolResult::error(
tc.id.clone(),
format!("Unknown tool: {}", tc.name),
),
None => {
ToolResult::error(tc.id.clone(), format!("Unknown tool: {}", tc.name))
}
};
messages.push(fabro_llm::types::Message::tool_result(
messages.push(Message::tool_result(
result.tool_call_id,
result.content,
result.is_error,
@ -414,7 +415,7 @@ impl HookExecutorImpl {
tls: &TlsMode,
context: &HookContext,
timeout: std::time::Duration,
env: &dyn fabro_util::env::Env,
env: &dyn Env,
) -> HookDecision {
// Enforce URL scheme based on TLS mode
match tls {
@ -551,7 +552,7 @@ impl HookExecutor for HookExecutorImpl {
tls,
context,
definition.timeout(),
&fabro_util::env::SystemEnv,
&SystemEnv,
)
.await
}

View file

@ -4,7 +4,8 @@ use async_trait::async_trait;
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use fabro_util::terminal::Styles;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{self, AsyncBufReadExt, BufReader};
use tokio::task;
use crate::{Answer, AnswerValue, Interviewer, Question, QuestionOption, QuestionType};
@ -55,7 +56,7 @@ fn find_matching_option(response: &str, options: &[QuestionOption]) -> Option<An
async fn read_line(prompt: &str) -> PromptRead {
// Print the prompt to stderr so it doesn't interfere with piped stdout
eprint!("{prompt}");
let stdin = tokio::io::stdin();
let stdin = io::stdin();
let mut reader = BufReader::new(stdin);
let mut line = String::new();
match reader.read_line(&mut line).await {
@ -219,7 +220,7 @@ impl Interviewer for ConsoleInterviewer {
eprint!("{rendered}");
}
let q = question;
return tokio::task::spawn_blocking(move || match q.question_type {
return task::spawn_blocking(move || match q.question_type {
QuestionType::MultipleChoice => ask_select_interactive(&q),
QuestionType::MultiSelect => ask_multi_select_interactive(&q),
QuestionType::YesNo | QuestionType::Confirmation => ask_confirm_interactive(&q),

View file

@ -2,6 +2,8 @@ use std::path::PathBuf;
use std::time::Duration;
use async_trait::async_trait;
use tokio::fs;
use tokio::time;
use crate::{Answer, Interviewer, Question};
@ -49,17 +51,17 @@ impl FileInterviewer {
let json = serde_json::to_string_pretty(question).expect("Question serialization failed");
let request_path = self.request_path();
if let Some(parent) = request_path.parent() {
tokio::fs::create_dir_all(parent).await?;
fs::create_dir_all(parent).await?;
}
let temp_path = request_path.with_extension("json.tmp");
tokio::fs::write(&temp_path, json).await?;
tokio::fs::rename(temp_path, request_path).await
fs::write(&temp_path, json).await?;
fs::rename(temp_path, request_path).await
}
async fn cleanup_ipc_files(&self) {
let _ = tokio::fs::remove_file(self.request_path()).await;
let _ = tokio::fs::remove_file(self.response_path()).await;
let _ = tokio::fs::remove_file(self.claim_path()).await;
let _ = fs::remove_file(self.request_path()).await;
let _ = fs::remove_file(self.response_path()).await;
let _ = fs::remove_file(self.claim_path()).await;
}
}
@ -81,9 +83,9 @@ impl Interviewer for FileInterviewer {
let response_path = self.response_path();
let claim_path = self.claim_path();
let mut claim_was_seen = false;
let mut reattach_deadline: Option<tokio::time::Instant> = None;
let mut reattach_deadline: Option<time::Instant> = None;
loop {
match tokio::fs::read_to_string(&response_path).await {
match fs::read_to_string(&response_path).await {
Ok(data) => match serde_json::from_str::<Answer>(&data) {
Ok(answer) => {
self.cleanup_ipc_files().await;
@ -107,23 +109,23 @@ impl Interviewer for FileInterviewer {
claim_was_seen = true;
reattach_deadline = None;
} else if claim_was_seen && reattach_deadline.is_none() {
reattach_deadline = Some(tokio::time::Instant::now() + REATTACH_WINDOW);
reattach_deadline = Some(time::Instant::now() + REATTACH_WINDOW);
}
if let Some(deadline) = reattach_deadline {
if tokio::time::Instant::now() >= deadline {
if time::Instant::now() >= deadline {
self.cleanup_ipc_files().await;
return default_for_claim_timeout.unwrap_or_else(Answer::timeout);
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
time::sleep(Duration::from_millis(100)).await;
}
};
if let Some(secs) = timeout_secs {
let duration = std::time::Duration::from_secs_f64(secs);
match tokio::time::timeout(duration, poll).await {
match time::timeout(duration, poll).await {
Ok(answer) => answer,
Err(_) => {
self.cleanup_ipc_files().await;

View file

@ -11,6 +11,7 @@ use std::collections::HashMap;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::time;
/// The type of question being asked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -174,7 +175,7 @@ pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question)
if let Some(secs) = timeout_secs {
let duration = std::time::Duration::from_secs_f64(secs);
match tokio::time::timeout(duration, interviewer.ask(question)).await {
match time::timeout(duration, interviewer.ask(question)).await {
Ok(answer) => answer,
Err(_elapsed) => default_answer.unwrap_or_else(Answer::timeout),
}

View file

@ -8,8 +8,10 @@ use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
use futures::StreamExt;
use futures::{stream, StreamExt};
use serde::Deserialize;
use tokio::task;
use tokio::time;
use fabro_util::terminal::Styles;
@ -17,7 +19,7 @@ use fabro_model::{Catalog, Model, Provider};
use crate::generate::{self, GenerateParams};
use crate::tools::Tool;
use crate::types::{ContentPart, Message};
use crate::types::{ContentPart, GenerateResult, Message, ReasoningEffort, StreamEvent, Usage};
pub struct ServerConnection {
pub client: reqwest::Client,
@ -161,7 +163,7 @@ fn models_title() -> Vec<CellStruct> {
]
}
fn print_models_table(models: &[crate::types::Model], s: &Styles) {
fn print_models_table(models: &[Model], s: &Styles) {
let use_color = s.use_color;
let rows: Vec<Vec<CellStruct>> = models.iter().map(|m| model_row(m, use_color)).collect();
let table = rows
@ -251,7 +253,7 @@ fn apply_options(
Ok(params)
}
fn print_usage(usage: &crate::types::Usage) {
fn print_usage(usage: &Usage) {
eprintln!(
"Tokens: {} input, {} output, {} total",
usage.input_tokens, usage.output_tokens, usage.total_tokens
@ -278,7 +280,7 @@ pub async fn run_chat(args: ChatArgs) -> Result<()> {
loop {
let line = if is_tty {
let result = tokio::task::spawn_blocking(|| {
let result = task::spawn_blocking(|| {
dialoguer::Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(">")
.interact_on(&Term::stderr())
@ -318,7 +320,7 @@ pub async fn run_chat(args: ChatArgs) -> Result<()> {
let mut stream_result = generate::stream(params).await?;
let mut full_text = String::new();
while let Some(event) = stream_result.next().await {
if let crate::types::StreamEvent::TextDelta { delta, .. } = event? {
if let StreamEvent::TextDelta { delta, .. } = event? {
print!("{delta}");
full_text.push_str(&delta);
}
@ -380,7 +382,7 @@ pub async fn run_prompt(args: PromptArgs) -> Result<()> {
(false, None) => {
let mut stream_result = generate::stream(params).await?;
while let Some(event) = stream_result.next().await {
if let crate::types::StreamEvent::TextDelta { delta, .. } = event? {
if let StreamEvent::TextDelta { delta, .. } = event? {
print!("{delta}");
}
}
@ -480,22 +482,22 @@ pub async fn run_prompt_via_server(args: PromptArgs, server: &ServerConnection)
}
let show_usage = args.usage;
let mut output_usage: Option<crate::types::Usage> = None;
let mut output_usage: Option<Usage> = None;
parse_sse_frames(response, |event_type, data| {
if event_type == "stream_event" {
if let Ok(event) = serde_json::from_str::<crate::types::StreamEvent>(data) {
if let Ok(event) = serde_json::from_str::<StreamEvent>(data) {
match event {
crate::types::StreamEvent::TextDelta { delta, .. } => {
StreamEvent::TextDelta { delta, .. } => {
print!("{delta}");
let _ = io::stdout().flush();
}
crate::types::StreamEvent::Finish { usage, .. } => {
StreamEvent::Finish { usage, .. } => {
if show_usage {
output_usage = Some(usage);
}
}
crate::types::StreamEvent::Error { error, .. } => {
StreamEvent::Error { error, .. } => {
bail!("Server error: {error}");
}
_ => {}
@ -646,7 +648,7 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R
loop {
let line = if is_tty {
let result = tokio::task::spawn_blocking(|| {
let result = task::spawn_blocking(|| {
dialoguer::Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(">")
.interact_on(&Term::stderr())
@ -869,16 +871,13 @@ fn build_deep_test_params(info: &Model) -> Option<GenerateParams> {
.max_tokens(1024);
if info.features.reasoning {
params = params.reasoning_effort(crate::types::ReasoningEffort::High);
params = params.reasoning_effort(ReasoningEffort::High);
}
Some(params)
}
fn validate_deep_result(
result: &crate::types::GenerateResult,
info: &Model,
) -> (cli_table::Color, String) {
fn validate_deep_result(result: &GenerateResult, info: &Model) -> (cli_table::Color, String) {
// Check tool use: need at least 2 steps (tool call + follow-up)
if result.steps.len() < 2 {
return (
@ -1048,7 +1047,7 @@ async fn test_one_model(info: &Model, deep: bool) -> (Color, String) {
None => (Color::Yellow, "deep: skipped (no tool support)".to_string()),
Some(params) => {
let result =
tokio::time::timeout(Duration::from_secs(90), generate::generate(params)).await;
time::timeout(Duration::from_secs(90), generate::generate(params)).await;
match result {
Ok(Ok(ref gen_result)) => validate_deep_result(gen_result, info),
Ok(Err(e)) => (Color::Red, format!("deep: error: {e}")),
@ -1062,8 +1061,7 @@ async fn test_one_model(info: &Model, deep: bool) -> (Color, String) {
.prompt("Say OK")
.max_tokens(16);
let result =
tokio::time::timeout(Duration::from_secs(30), generate::generate(params)).await;
let result = time::timeout(Duration::from_secs(30), generate::generate(params)).await;
match result {
Ok(Ok(_)) => (Color::Green, "ok".to_string()),
Ok(Err(e)) => (Color::Red, format!("error: {e}")),
@ -1109,7 +1107,7 @@ async fn test_models(
indexed.shuffle(&mut rand::thread_rng());
// Run tests concurrently, 6 at a time
let results: Vec<(usize, Color, String)> = futures::stream::iter(indexed)
let results: Vec<(usize, Color, String)> = stream::iter(indexed)
.map(|(idx, info)| {
let pb = pb.clone();
async move {

View file

@ -8,11 +8,14 @@ use crate::types::{
ResponseFormat, ResponseFormatType, RetryPolicy, StepResult, StreamEvent, TimeoutConfig,
ToolCall, ToolChoice, ToolDefinition, Usage,
};
use futures::{Stream, StreamExt};
use fabro_util::backoff::BackoffPolicy;
use futures::{future, stream, Stream, StreamExt};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::sync::OnceCell;
use tokio::sync::{mpsc, OnceCell};
use tokio::time;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
@ -109,7 +112,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
};
let retry_policy = RetryPolicy {
max_retries: params.max_retries,
backoff: fabro_util::backoff::BackoffPolicy {
backoff: BackoffPolicy {
initial_delay: std::time::Duration::from_micros(1),
jitter: false,
..Default::default()
@ -156,7 +159,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
let response = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step)
{
let duration = std::time::Duration::from_secs_f64(per_step);
tokio::time::timeout(
time::timeout(
duration,
retry(&retry_policy, || {
let c = client_ref.clone();
@ -262,7 +265,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) {
let duration = std::time::Duration::from_secs_f64(total);
tokio::time::timeout(duration, generate_future)
time::timeout(duration, generate_future)
.await
.map_err(|_| {
warn!(timeout_secs = total, "Total generation timeout exceeded");
@ -583,7 +586,7 @@ impl StreamResult {
#[must_use]
pub fn text_stream(self) -> Pin<Box<dyn Stream<Item = Result<String, SdkError>> + Send>> {
Box::pin(self.filter_map(|result| {
futures::future::ready(match result {
future::ready(match result {
Ok(StreamEvent::TextDelta { delta, .. }) => Some(Ok(delta)),
Err(e) => Some(Err(e)),
_ => None,
@ -661,12 +664,12 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
}
// Tool loop: collect events from each round, execute tools, continue
let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent, SdkError>>(64);
let (tx, rx) = mpsc::channel::<Result<StreamEvent, SdkError>>(64);
let tools = params.tools.clone();
let retry_policy = RetryPolicy {
max_retries: params.max_retries,
backoff: fabro_util::backoff::BackoffPolicy {
backoff: BackoffPolicy {
initial_delay: std::time::Duration::from_micros(1),
jitter: false,
..Default::default()
@ -703,7 +706,7 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
let stream_result =
if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) {
let duration = std::time::Duration::from_secs_f64(per_step);
tokio::time::timeout(duration, stream_connect)
time::timeout(duration, stream_connect)
.await
.unwrap_or_else(|_| {
Err(SdkError::RequestTimeout {
@ -832,10 +835,7 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
// Apply total timeout if configured (Section 4.7)
if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) {
let duration = std::time::Duration::from_secs_f64(total);
if tokio::time::timeout(duration, tool_loop_future)
.await
.is_err()
{
if time::timeout(duration, tool_loop_future).await.is_err() {
let _ = tx
.send(Err(SdkError::RequestTimeout {
message: format!("Total timeout of {total}s exceeded"),
@ -848,7 +848,7 @@ async fn stream_with_tool_loop(params: GenerateParams) -> Result<StreamEventStre
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
Ok(Box::pin(ReceiverStream::new(rx)))
}
/// Internal single-round streaming (no tool loop). Used by `stream_object()`.
@ -863,7 +863,7 @@ async fn stream_generate_raw(
// Apply per_step timeout to the initial connection (Section 4.7)
let inner_stream = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) {
let duration = std::time::Duration::from_secs_f64(per_step);
tokio::time::timeout(duration, client.stream(&request))
time::timeout(duration, client.stream(&request))
.await
.map_err(|_| SdkError::RequestTimeout {
message: format!("Per-step timeout of {per_step}s exceeded"),
@ -892,25 +892,24 @@ async fn stream_generate_raw(
// Apply total timeout to the stream (Section 4.7)
if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) {
let duration = std::time::Duration::from_secs_f64(total);
let deadline = tokio::time::Instant::now() + duration;
let deadline = time::Instant::now() + duration;
let total_copy = total;
let timed_stream =
futures::stream::unfold((stream, false), move |(mut stream, done)| async move {
if done {
return None;
}
match tokio::time::timeout_at(deadline, stream.next()).await {
Ok(Some(item)) => Some((item, (stream, false))),
Ok(None) => None, // stream completed naturally
Err(_) => Some((
Err(SdkError::RequestTimeout {
message: format!("Total timeout of {total_copy}s exceeded"),
source: None,
}),
(stream, true),
)),
}
});
let timed_stream = stream::unfold((stream, false), move |(mut stream, done)| async move {
if done {
return None;
}
match time::timeout_at(deadline, stream.next()).await {
Ok(Some(item)) => Some((item, (stream, false))),
Ok(None) => None, // stream completed naturally
Err(_) => Some((
Err(SdkError::RequestTimeout {
message: format!("Total timeout of {total_copy}s exceeded"),
source: None,
}),
(stream, true),
)),
}
});
Ok(Box::pin(timed_stream))
} else {
Ok(stream)
@ -1099,7 +1098,7 @@ pub async fn stream_object(
}
}
futures::future::ready(Some(futures::stream::iter(events)))
future::ready(Some(stream::iter(events)))
},
);
@ -1672,7 +1671,7 @@ mod tests {
.unwrap();
let events: Vec<ObjectStreamEvent> = obj_stream
.filter_map(|r| futures::future::ready(r.ok()))
.filter_map(|r| future::ready(r.ok()))
.collect()
.await;
@ -1710,7 +1709,7 @@ mod tests {
.unwrap();
let events: Vec<ObjectStreamEvent> = obj_stream
.filter_map(|r| futures::future::ready(r.ok()))
.filter_map(|r| future::ready(r.ok()))
.collect()
.await;
@ -1967,7 +1966,7 @@ mod tests {
let texts: Vec<String> = result
.text_stream()
.filter_map(|r| futures::future::ready(r.ok()))
.filter_map(|r| future::ready(r.ok()))
.collect()
.await;

View file

@ -1,14 +1,16 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
use futures::stream;
use crate::error::SdkError;
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::error::{error_from_status_code, SdkError};
use crate::provider::{validate_tool_choice, ProviderAdapter, StreamEventStream};
use crate::providers::common::{
extract_system_prompt, parse_error_body, parse_rate_limit_headers, parse_retry_after,
send_and_read_response,
self as common, extract_system_prompt, parse_error_body, parse_rate_limit_headers,
parse_retry_after, send_and_read_response,
};
use crate::types::{
ContentPart, FinishReason, Message, Request, Response, ResponseFormatType, Role, StreamEvent,
ThinkingData, ToolCall, ToolChoice, ToolDefinition, Usage,
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition,
Usage,
};
/// Provider adapter for the Anthropic Messages API.
@ -47,7 +49,7 @@ impl Adapter {
}
#[must_use]
pub fn with_timeout(self, timeout: crate::types::AdapterTimeout) -> Self {
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
..self
@ -266,8 +268,8 @@ fn content_part_to_api(part: &ContentPart) -> Option<serde_json::Value> {
}
ContentPart::Image(img) => {
if let Some(url) = &img.url {
if crate::providers::common::is_file_path(url) {
return match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
return match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({
"type": "image",
"source": {"type": "base64", "media_type": mime, "data": b64}
@ -286,8 +288,8 @@ fn content_part_to_api(part: &ContentPart) -> Option<serde_json::Value> {
}
ContentPart::Document(doc) => {
if let Some(url) = &doc.url {
if crate::providers::common::is_file_path(url) {
return match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
return match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({
"type": "document",
"source": {"type": "base64", "media_type": mime, "data": b64}
@ -669,11 +671,11 @@ struct StreamAccumulator {
/// Accumulated raw JSON arguments for the current `tool_use` block.
current_tool_args: String,
/// Rate limit info parsed from the initial HTTP response headers.
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
}
impl StreamAccumulator {
fn new(rate_limit: Option<crate::types::RateLimitInfo>) -> Self {
fn new(rate_limit: Option<RateLimitInfo>) -> Self {
Self {
id: String::new(),
model: String::new(),
@ -974,7 +976,7 @@ struct SseReaderState {
impl SseReaderState {
fn new(
http_resp: reqwest::Response,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
json_schema_mode: bool,
stream_read_timeout: Option<std::time::Duration>,
) -> Self {
@ -1214,7 +1216,7 @@ impl ProviderAdapter for Adapter {
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
// Non-Anthropic providers (e.g. Kimi) require stream=true even for
@ -1290,7 +1292,7 @@ impl ProviderAdapter for Adapter {
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let (_api_request, req_builder) = build_api_request(self, request, true);
@ -1307,7 +1309,7 @@ impl ProviderAdapter for Adapter {
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "type");
return Err(crate::error::error_from_status_code(
return Err(error_from_status_code(
status.as_u16(),
msg,
self.provider_name.clone(),
@ -1321,7 +1323,7 @@ impl ProviderAdapter for Adapter {
let json_schema_mode = uses_json_schema_format(request);
let stream_read_timeout = self.http.stream_read_timeout;
let stream = futures::stream::unfold(
let stream = stream::unfold(
SseReaderState::new(http_resp, rate_limit, json_schema_mode, stream_read_timeout),
|mut state| async move {
loop {

View file

@ -2,6 +2,8 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
use crate::error::{error_from_status_code, SdkError};
use crate::types::{Message, RateLimitInfo, Role};
use reqwest::header::HeaderMap;
use tokio::time;
use tracing::warn;
/// Parse an error response body, extracting the message and error code.
@ -104,7 +106,7 @@ pub fn load_file_as_base64(path: &str) -> Result<(String, String), std::io::Erro
/// Extract the `Retry-After` header value from an HTTP response as seconds.
#[must_use]
pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
pub fn parse_retry_after(headers: &HeaderMap) -> Option<f64> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
@ -115,15 +117,15 @@ pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
///
/// Returns `None` if no rate limit headers are present.
#[must_use]
pub fn parse_rate_limit_headers(headers: &reqwest::header::HeaderMap) -> Option<RateLimitInfo> {
fn header_i64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<i64> {
pub fn parse_rate_limit_headers(headers: &HeaderMap) -> Option<RateLimitInfo> {
fn header_i64(headers: &HeaderMap, name: &str) -> Option<i64> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
}
fn header_str(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
@ -166,7 +168,7 @@ pub async fn send_and_read_response(
request: reqwest::RequestBuilder,
provider: &str,
error_code_field: &str,
) -> Result<(String, reqwest::header::HeaderMap), SdkError> {
) -> Result<(String, HeaderMap), SdkError> {
let http_resp = request.send().await.map_err(|e| {
if e.is_timeout() {
warn!(provider = %provider, error = %e, "Provider request timed out");
@ -239,7 +241,7 @@ impl LineReader {
}
let chunk_result = match self.stream_read_timeout {
Some(timeout) => tokio::time::timeout(timeout, self.response.chunk()).await,
Some(timeout) => time::timeout(timeout, self.response.chunk()).await,
None => Ok(self.response.chunk().await),
};
match chunk_result {
@ -317,7 +319,7 @@ mod tests {
#[test]
fn parse_rate_limit_headers_all_present() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-remaining-requests", "99".parse().unwrap());
headers.insert("x-ratelimit-limit-requests", "100".parse().unwrap());
headers.insert("x-ratelimit-remaining-tokens", "9000".parse().unwrap());
@ -337,13 +339,13 @@ mod tests {
#[test]
fn parse_rate_limit_headers_none_present() {
let headers = reqwest::header::HeaderMap::new();
let headers = HeaderMap::new();
assert!(parse_rate_limit_headers(&headers).is_none());
}
#[test]
fn parse_rate_limit_headers_partial() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-remaining-requests", "50".parse().unwrap());
let info = parse_rate_limit_headers(&headers).unwrap();
@ -356,7 +358,7 @@ mod tests {
#[test]
fn parse_rate_limit_headers_reset_tokens_fallback() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-limit-tokens", "5000".parse().unwrap());
headers.insert(
"x-ratelimit-reset-tokens",
@ -370,7 +372,7 @@ mod tests {
#[test]
fn parse_rate_limit_headers_invalid_values_ignored() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert(
"x-ratelimit-remaining-requests",
"not-a-number".parse().unwrap(),
@ -499,27 +501,27 @@ mod tests {
#[test]
fn parse_retry_after_valid() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("retry-after", "2.5".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Some(2.5));
}
#[test]
fn parse_retry_after_missing() {
let headers = reqwest::header::HeaderMap::new();
let headers = HeaderMap::new();
assert_eq!(parse_retry_after(&headers), None);
}
#[test]
fn parse_retry_after_invalid() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("retry-after", "not-a-number".parse().unwrap());
assert_eq!(parse_retry_after(&headers), None);
}
#[test]
fn parse_retry_after_integer() {
let mut headers = reqwest::header::HeaderMap::new();
let mut headers = HeaderMap::new();
headers.insert("retry-after", "5".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Some(5.0));
}

View file

@ -2,6 +2,7 @@ use crate::error::{error_from_status_code, SdkError};
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::providers::common::LineReader;
use crate::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
use futures::stream;
use tracing::{debug, error};
/// Provider adapter that routes LLM requests through an fabro server's
@ -161,35 +162,34 @@ impl ProviderAdapter for Adapter {
let body = build_body(request, true)?;
let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?;
let stream =
futures::stream::unfold(LineReader::new(http_resp, None), |mut reader| async move {
loop {
match reader.read_next_chunk("\n\n").await {
Ok(Some(block)) => {
if let Some((event_type, data)) = parse_sse_block(&block) {
if event_type == "stream_event" {
match serde_json::from_str::<StreamEvent>(&data) {
Ok(event) => return Some((Ok(event), reader)),
Err(e) => {
return Some((
Err(SdkError::stream_error(
format!("failed to parse stream event: {e}"),
e,
)),
reader,
));
}
let stream = stream::unfold(LineReader::new(http_resp, None), |mut reader| async move {
loop {
match reader.read_next_chunk("\n\n").await {
Ok(Some(block)) => {
if let Some((event_type, data)) = parse_sse_block(&block) {
if event_type == "stream_event" {
match serde_json::from_str::<StreamEvent>(&data) {
Ok(event) => return Some((Ok(event), reader)),
Err(e) => {
return Some((
Err(SdkError::stream_error(
format!("failed to parse stream event: {e}"),
e,
)),
reader,
));
}
}
// Skip non-stream_event SSE events
}
// Empty or unparsable block — keep reading.
// Skip non-stream_event SSE events
}
Ok(None) => return None,
Err(e) => return Some((Err(e), reader)),
// Empty or unparsable block — keep reading.
}
Ok(None) => return None,
Err(e) => return Some((Err(e), reader)),
}
});
}
});
Ok(Box::pin(stream))
}

View file

@ -5,14 +5,17 @@ use crate::error::{
error_from_grpc_status, error_from_status_code, ProviderErrorDetail, ProviderErrorKind,
SdkError,
};
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::provider::{validate_tool_choice, ProviderAdapter, StreamEventStream};
use crate::providers::common::{
extract_system_prompt, parse_error_body, parse_rate_limit_headers, parse_retry_after,
self as common, extract_system_prompt, parse_error_body, parse_rate_limit_headers,
parse_retry_after,
};
use crate::types::{
ContentPart, FinishReason, Message, Request, Response, ResponseFormat, ResponseFormatType,
Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition, Usage,
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
ToolDefinition, Usage,
};
use reqwest::header::HeaderMap;
const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
@ -43,7 +46,7 @@ impl Adapter {
}
#[must_use]
pub fn with_timeout(self, timeout: crate::types::AdapterTimeout) -> Self {
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
}
@ -251,8 +254,8 @@ fn translate_messages(messages: &[&Message]) -> Vec<Content> {
})
},
|url| {
if crate::providers::common::is_file_path(url) {
match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({"inlineData": {"mimeType": mime, "data": b64}})),
Err(_) => None,
}
@ -273,8 +276,8 @@ fn translate_messages(messages: &[&Message]) -> Vec<Content> {
})
},
|url| {
if crate::providers::common::is_file_path(url) {
match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({"inlineData": {"mimeType": mime, "data": b64}})),
Err(_) => None,
}
@ -295,8 +298,8 @@ fn translate_messages(messages: &[&Message]) -> Vec<Content> {
})
},
|url| {
if crate::providers::common::is_file_path(url) {
match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({"inlineData": {"mimeType": mime, "data": b64}})),
Err(_) => None,
}
@ -505,7 +508,7 @@ fn parse_usage(metadata: Option<&UsageMetadata>) -> Usage {
/// Like `send_and_read_response` but uses gRPC status code mapping when available.
async fn send_gemini_response(
request: reqwest::RequestBuilder,
) -> Result<(String, reqwest::header::HeaderMap), SdkError> {
) -> Result<(String, HeaderMap), SdkError> {
let http_resp = request.send().await.map_err(|e| {
if e.is_timeout() {
SdkError::request_timeout(format!("gemini: {e}"), e)
@ -589,7 +592,7 @@ async fn send_streaming_request(
fn process_sse_stream(
http_resp: reqwest::Response,
model: String,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
stream_read_timeout: Option<std::time::Duration>,
) -> StreamEventStream {
Box::pin(stream::unfold(
@ -698,14 +701,14 @@ struct SseStreamState {
/// Whether we have emitted the `Finish` event.
finished: bool,
/// Rate limit info parsed from HTTP response headers.
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
}
impl SseStreamState {
fn new(
http_resp: reqwest::Response,
model: String,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
stream_read_timeout: Option<std::time::Duration>,
) -> Self {
Self {
@ -884,7 +887,7 @@ impl ProviderAdapter for Adapter {
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request);
@ -950,7 +953,7 @@ impl ProviderAdapter for Adapter {
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request);

View file

@ -1,14 +1,16 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
use futures::StreamExt;
use futures::{stream, StreamExt};
use crate::error::SdkError;
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::error::{error_from_status_code, SdkError};
use crate::provider::{validate_tool_choice, ProviderAdapter, StreamEventStream};
use crate::providers::common::{
parse_error_body, parse_rate_limit_headers, parse_retry_after, send_and_read_response,
self as common, parse_error_body, parse_rate_limit_headers, parse_retry_after,
send_and_read_response,
};
use crate::types::{
ContentPart, FinishReason, Message, Request, Response, ResponseFormat, ResponseFormatType,
Role, StreamEvent, ToolCall, ToolChoice, ToolDefinition, Usage,
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ToolCall, ToolChoice, ToolDefinition,
Usage,
};
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
@ -69,7 +71,7 @@ impl Adapter {
}
#[must_use]
pub fn with_timeout(self, timeout: crate::types::AdapterTimeout) -> Self {
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
..self
@ -216,8 +218,8 @@ fn translate_input(messages: &[Message]) -> (Option<String>, Vec<serde_json::Val
})
},
|url| {
if crate::providers::common::is_file_path(url) {
match crate::providers::common::load_file_as_base64(url) {
if common::is_file_path(url) {
match common::load_file_as_base64(url) {
Ok((b64, mime)) => Some(serde_json::json!({"type": "input_image", "image_url": format!("data:{mime};base64,{b64}")})),
Err(_) => None,
}
@ -550,7 +552,7 @@ struct SseStreamState {
emitted_text_start: bool,
emitted_reasoning_start: bool,
raw_response: Option<serde_json::Value>,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
}
/// Parse a single SSE message block into an (`event_type`, `data`) pair.
@ -944,7 +946,7 @@ impl ProviderAdapter for Adapter {
}
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let request_body = build_request_body(request, false, false);
let url = format!("{}/responses", self.http.base_url);
@ -999,7 +1001,7 @@ impl ProviderAdapter for Adapter {
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let request_body = build_request_body(request, true, self.codex_mode);
let url = format!("{}/responses", self.http.base_url);
@ -1019,7 +1021,7 @@ impl ProviderAdapter for Adapter {
.await
.map_err(|e| SdkError::network(e.to_string(), e))?;
let (msg, code, raw) = parse_error_body(&body, "type");
return Err(crate::error::error_from_status_code(
return Err(error_from_status_code(
status.as_u16(),
msg,
"openai".to_string(),
@ -1051,14 +1053,14 @@ impl ProviderAdapter for Adapter {
rate_limit,
};
let stream = futures::stream::unfold(state, |mut state| async move {
let stream = stream::unfold(state, |mut state| async move {
let events = process_next_sse_events(&mut state).await;
let items: Vec<Result<StreamEvent, SdkError>> = match events {
Ok(events) if events.is_empty() => return None,
Ok(events) => events.into_iter().map(Ok).collect(),
Err(e) => vec![Err(e)],
};
Some((futures::stream::iter(items), state))
Some((stream::iter(items), state))
})
.flatten();

View file

@ -1,13 +1,14 @@
use futures::StreamExt;
use futures::{stream, StreamExt};
use crate::error::{error_from_status_code, ProviderErrorDetail, ProviderErrorKind, SdkError};
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::provider::{validate_tool_choice, ProviderAdapter, StreamEventStream};
use crate::providers::common::{
parse_error_body, parse_rate_limit_headers, parse_retry_after, send_and_read_response,
};
use crate::types::{
ContentPart, FinishReason, Message, Request, Response, ResponseFormat, ResponseFormatType,
Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition, Usage,
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
ToolDefinition, Usage,
};
/// `OpenAI`-compatible Chat Completions adapter (Section 7.10).
@ -46,7 +47,7 @@ impl Adapter {
}
#[must_use]
pub fn with_timeout(self, timeout: crate::types::AdapterTimeout) -> Self {
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
..self
@ -451,7 +452,7 @@ impl ProviderAdapter for Adapter {
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request, None, &self.provider_name);
let url = format!("{}/chat/completions", self.http.base_url);
@ -530,7 +531,7 @@ impl ProviderAdapter for Adapter {
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
if let Some(tc) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tc)?;
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request, Some(true), &self.provider_name);
let url = format!("{}/chat/completions", self.http.base_url);
@ -565,7 +566,7 @@ impl ProviderAdapter for Adapter {
let rate_limit = parse_rate_limit_headers(http_resp.headers());
let stream_read_timeout = self.http.stream_read_timeout;
let stream = futures::stream::unfold(
let stream = stream::unfold(
StreamState::new(
http_resp,
provider_name,
@ -629,7 +630,7 @@ impl ProviderAdapter for Adapter {
);
// Flatten batched events into individual stream events.
let flat_stream = futures::stream::unfold(
let flat_stream = stream::unfold(
FlattenState {
inner: Box::pin(stream),
pending: Vec::new(),
@ -680,7 +681,7 @@ struct StreamState {
done: bool,
/// True after `finish_events()` has been called (guards against duplicates).
finished: bool,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
}
impl StreamState {
@ -688,7 +689,7 @@ impl StreamState {
response: reqwest::Response,
provider_name: String,
model: String,
rate_limit: Option<crate::types::RateLimitInfo>,
rate_limit: Option<RateLimitInfo>,
stream_read_timeout: Option<std::time::Duration>,
) -> Self {
Self {

Some files were not shown because too many files have changed in this diff Show more