mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # lib/crates/fabro-cli/src/manifest_builder.rs # lib/crates/fabro-workflow/src/run_options.rs
This commit is contained in:
commit
f02574effd
331 changed files with 13432 additions and 12599 deletions
|
|
@ -3,8 +3,8 @@ use std::path::PathBuf;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use clap::{Args, Parser};
|
||||
use fabro_llm::Error as LlmError;
|
||||
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};
|
||||
|
|
@ -303,7 +303,7 @@ struct DebugMiddleware {
|
|||
#[async_trait::async_trait]
|
||||
impl Middleware for DebugMiddleware {
|
||||
#[allow(clippy::print_stderr)]
|
||||
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, SdkError> {
|
||||
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, LlmError> {
|
||||
let s = self.styles;
|
||||
eprintln!(
|
||||
"{}",
|
||||
|
|
@ -333,7 +333,7 @@ impl Middleware for DebugMiddleware {
|
|||
&self,
|
||||
request: Request,
|
||||
next: NextStreamFn,
|
||||
) -> Result<StreamEventStream, SdkError> {
|
||||
) -> Result<StreamEventStream, LlmError> {
|
||||
next(request).await
|
||||
}
|
||||
}
|
||||
|
|
@ -346,7 +346,7 @@ struct VerboseMiddleware {
|
|||
#[async_trait::async_trait]
|
||||
impl Middleware for VerboseMiddleware {
|
||||
#[allow(clippy::print_stderr)]
|
||||
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, SdkError> {
|
||||
async fn handle_complete(&self, request: Request, next: NextFn) -> Result<Response, LlmError> {
|
||||
let s = self.styles;
|
||||
eprintln!(
|
||||
"{}\n{}",
|
||||
|
|
@ -368,7 +368,7 @@ impl Middleware for VerboseMiddleware {
|
|||
&self,
|
||||
request: Request,
|
||||
next: NextStreamFn,
|
||||
) -> Result<StreamEventStream, SdkError> {
|
||||
) -> Result<StreamEventStream, LlmError> {
|
||||
next(request).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use fabro_llm::types::{Message, Request};
|
|||
use tracing::debug;
|
||||
|
||||
use crate::agent_profile::AgentProfile;
|
||||
use crate::error::AgentError;
|
||||
use crate::error::Error;
|
||||
use crate::event::Emitter;
|
||||
use crate::file_tracker::FileTracker;
|
||||
use crate::history::History;
|
||||
|
|
@ -28,18 +28,21 @@ pub fn check_context_usage(
|
|||
let threshold = context_window * threshold_percent / 100;
|
||||
|
||||
if estimated_tokens > threshold {
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: format!(
|
||||
"Context window usage: {}%",
|
||||
estimated_tokens * 100 / context_window
|
||||
),
|
||||
details: serde_json::json!({
|
||||
"estimated_tokens": estimated_tokens,
|
||||
"context_window_size": context_window,
|
||||
"usage_percent": estimated_tokens * 100 / context_window,
|
||||
}),
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: format!(
|
||||
"Context window usage: {}%",
|
||||
estimated_tokens * 100 / context_window
|
||||
),
|
||||
details: serde_json::json!({
|
||||
"estimated_tokens": estimated_tokens,
|
||||
"context_window_size": context_window,
|
||||
"usage_percent": estimated_tokens * 100 / context_window,
|
||||
}),
|
||||
},
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
|
@ -58,15 +61,18 @@ pub async fn compact_context(
|
|||
preserve_count: usize,
|
||||
emitter: &Emitter,
|
||||
session_id: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
) -> Result<(), Error> {
|
||||
let estimated_tokens = estimate_token_count(system_prompt, history);
|
||||
let context_window = provider_profile.context_window_size();
|
||||
let original_turn_count = history.turns().len();
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted {
|
||||
estimated_tokens,
|
||||
context_window_size: context_window,
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::CompactionStarted {
|
||||
estimated_tokens,
|
||||
context_window_size: context_window,
|
||||
},
|
||||
);
|
||||
|
||||
// Determine turns to summarize
|
||||
if original_turn_count <= preserve_count {
|
||||
|
|
@ -102,31 +108,31 @@ function names, error messages, and exact values. Omit pleasantries and conversa
|
|||
);
|
||||
|
||||
let summary_request = Request {
|
||||
model: provider_profile.model().to_string(),
|
||||
messages: vec![
|
||||
model: provider_profile.model().to_string(),
|
||||
messages: vec![
|
||||
Message::system(summarization_prompt),
|
||||
Message::user(format!(
|
||||
"Here is the conversation to summarize:\n\n{rendered}"
|
||||
)),
|
||||
],
|
||||
provider: Some(provider_profile.provider().as_str().to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
response_format: None,
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
max_tokens: Some(4096),
|
||||
stop_sequences: None,
|
||||
provider: Some(provider_profile.provider().as_str().to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
response_format: None,
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
max_tokens: Some(4096),
|
||||
stop_sequences: None,
|
||||
reasoning_effort: None,
|
||||
speed: None,
|
||||
metadata: None,
|
||||
speed: None,
|
||||
metadata: None,
|
||||
provider_options: None,
|
||||
};
|
||||
|
||||
let response = llm_client
|
||||
.complete(&summary_request)
|
||||
.await
|
||||
.map_err(AgentError::Llm)?;
|
||||
.map_err(Error::Llm)?;
|
||||
|
||||
let summary_text = response.text();
|
||||
debug!(
|
||||
|
|
@ -141,12 +147,15 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}"
|
|||
|
||||
history.compact(preserve_count, summary_content);
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::CompactionCompleted {
|
||||
original_turn_count,
|
||||
preserved_turn_count: preserve_count,
|
||||
summary_token_estimate,
|
||||
tracked_file_count: file_tracker.file_count(),
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::CompactionCompleted {
|
||||
original_turn_count,
|
||||
preserved_turn_count: preserve_count,
|
||||
summary_token_estimate,
|
||||
tracked_file_count: file_tracker.file_count(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -259,27 +268,27 @@ mod tests {
|
|||
fn render_turns_produces_labeled_text() {
|
||||
let turns = vec![
|
||||
Turn::User {
|
||||
content: "Hello".into(),
|
||||
content: "Hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
Turn::Assistant {
|
||||
content: "Let me check".into(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
content: "Let me check".into(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
"c1",
|
||||
"read_file",
|
||||
serde_json::json!({"path": "foo.rs"}),
|
||||
)],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
Turn::ToolResults {
|
||||
results: vec![ToolResult {
|
||||
tool_call_id: "c1".into(),
|
||||
content: serde_json::json!("file contents here"),
|
||||
is_error: false,
|
||||
image_data: None,
|
||||
results: vec![ToolResult {
|
||||
tool_call_id: "c1".into(),
|
||||
content: serde_json::json!("file contents here"),
|
||||
is_error: false,
|
||||
image_data: None,
|
||||
image_media_type: None,
|
||||
}],
|
||||
timestamp: SystemTime::now(),
|
||||
|
|
@ -298,11 +307,11 @@ mod tests {
|
|||
fn render_turns_truncates_long_tool_output() {
|
||||
let long_output = "x".repeat(1000);
|
||||
let turns = vec![Turn::ToolResults {
|
||||
results: vec![ToolResult {
|
||||
tool_call_id: "c1".into(),
|
||||
content: serde_json::json!(long_output),
|
||||
is_error: false,
|
||||
image_data: None,
|
||||
results: vec![ToolResult {
|
||||
tool_call_id: "c1".into(),
|
||||
content: serde_json::json!(long_output),
|
||||
is_error: false,
|
||||
image_data: None,
|
||||
image_media_type: None,
|
||||
}],
|
||||
timestamp: SystemTime::now(),
|
||||
|
|
@ -317,7 +326,7 @@ mod tests {
|
|||
fn estimate_token_count_basic() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "Hello world".into(), // 11 chars
|
||||
content: "Hello world".into(), // 11 chars
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
// system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens
|
||||
|
|
@ -339,7 +348,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
// Push enough content to exceed a tiny context window
|
||||
history.push(Turn::User {
|
||||
content: "x".repeat(1000),
|
||||
content: "x".repeat(1000),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let emitter = Emitter::new();
|
||||
|
|
|
|||
|
|
@ -216,9 +216,12 @@ mod tests {
|
|||
let approval: ToolApprovalFn = Arc::new(|_name, _args| Err("denied".to_string()));
|
||||
let adapter = ToolApprovalAdapter(approval);
|
||||
let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await;
|
||||
assert_eq!(decision, ToolHookDecision::Block {
|
||||
reason: "denied".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
decision,
|
||||
ToolHookDecision::Block {
|
||||
reason: "denied".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ pub enum Error {
|
|||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub type AgentError = Error;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -49,7 +48,7 @@ mod tests {
|
|||
fn agent_error_from_sdk_error() {
|
||||
let sdk_err = LlmError::Network {
|
||||
message: "connection refused".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
};
|
||||
let agent_err = Error::from(sdk_err);
|
||||
assert!(matches!(agent_err, Error::Llm(_)));
|
||||
|
|
@ -92,7 +91,7 @@ mod tests {
|
|||
fn serde_roundtrip_llm_network() {
|
||||
let err = Error::Llm(LlmError::Network {
|
||||
message: "connection refused".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
});
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
let deserialized: Error = serde_json::from_str(&json).unwrap();
|
||||
|
|
@ -102,14 +101,14 @@ mod tests {
|
|||
#[test]
|
||||
fn serde_roundtrip_llm_provider() {
|
||||
let err = Error::Llm(LlmError::Provider {
|
||||
kind: ProviderErrorKind::RateLimit,
|
||||
kind: ProviderErrorKind::RateLimit,
|
||||
detail: Box::new(ProviderErrorDetail {
|
||||
message: "too fast".into(),
|
||||
provider: "openai".into(),
|
||||
message: "too fast".into(),
|
||||
provider: "openai".into(),
|
||||
status_code: Some(429),
|
||||
error_code: None,
|
||||
error_code: None,
|
||||
retry_after: Some(2.0),
|
||||
raw: None,
|
||||
raw: None,
|
||||
}),
|
||||
});
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
|
|
@ -156,7 +155,7 @@ mod tests {
|
|||
let errors: Vec<Error> = vec![
|
||||
Error::Llm(LlmError::Network {
|
||||
message: "refused".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
}),
|
||||
Error::SessionClosed,
|
||||
Error::InvalidState("reason".into()),
|
||||
|
|
@ -174,7 +173,7 @@ mod tests {
|
|||
fn serde_tag_format_llm() {
|
||||
let err = Error::Llm(LlmError::Network {
|
||||
message: "refused".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
});
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
|
|
|||
|
|
@ -47,23 +47,29 @@ impl Default for Emitter {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::AgentError;
|
||||
use crate::error::Error;
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_and_receive_event() {
|
||||
let emitter = Emitter::new();
|
||||
let mut receiver = emitter.subscribe();
|
||||
|
||||
emitter.emit("sess-1".into(), AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
});
|
||||
emitter.emit(
|
||||
"sess-1".into(),
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let event = receiver.recv().await.unwrap();
|
||||
assert!(matches!(event.event, AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}));
|
||||
assert!(matches!(
|
||||
event.event,
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}
|
||||
));
|
||||
assert_eq!(event.session_id, "sess-1");
|
||||
assert_eq!(event.parent_session_id, None);
|
||||
}
|
||||
|
|
@ -73,9 +79,12 @@ mod tests {
|
|||
let emitter = Emitter::new();
|
||||
let mut receiver = emitter.subscribe();
|
||||
|
||||
emitter.emit("sess-2".into(), AgentEvent::Error {
|
||||
error: AgentError::ToolExecution("something went wrong".into()),
|
||||
});
|
||||
emitter.emit(
|
||||
"sess-2".into(),
|
||||
AgentEvent::Error {
|
||||
error: Error::ToolExecution("something went wrong".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let event = receiver.recv().await.unwrap();
|
||||
assert!(
|
||||
|
|
@ -105,9 +114,12 @@ mod tests {
|
|||
#[test]
|
||||
fn emit_without_subscribers_does_not_panic() {
|
||||
let emitter = Emitter::new();
|
||||
emitter.emit("sess-4".into(), AgentEvent::Error {
|
||||
error: AgentError::ToolExecution("test".into()),
|
||||
});
|
||||
emitter.emit(
|
||||
"sess-4".into(),
|
||||
AgentEvent::Error {
|
||||
error: Error::ToolExecution("test".into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -122,21 +134,24 @@ mod tests {
|
|||
let mut receiver = emitter.subscribe();
|
||||
|
||||
emitter.forward(SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "child".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "child".into(),
|
||||
parent_session_id: Some("parent".into()),
|
||||
});
|
||||
|
||||
let event = receiver.recv().await.unwrap();
|
||||
assert_eq!(event.session_id, "child");
|
||||
assert_eq!(event.parent_session_id.as_deref(), Some("parent"));
|
||||
assert!(matches!(event.event, AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}));
|
||||
assert!(matches!(
|
||||
event.event,
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ use fabro_llm::types::{ToolCall, ToolResult};
|
|||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct FileOps {
|
||||
read: bool,
|
||||
read: bool,
|
||||
written: bool,
|
||||
edited: bool,
|
||||
edited: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ impl History {
|
|||
let extracted_user_messages =
|
||||
extract_recent_user_messages(discarded, COMPACTION_USER_MESSAGE_TOKEN_BUDGET);
|
||||
self.turns.push(Turn::System {
|
||||
content: summary,
|
||||
content: summary,
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
});
|
||||
self.turns.extend(extracted_user_messages);
|
||||
|
|
@ -72,9 +72,9 @@ impl History {
|
|||
parts.push(ContentPart::ToolCall(tc.clone()));
|
||||
}
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: parts,
|
||||
name: None,
|
||||
role: Role::Assistant,
|
||||
content: parts,
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -94,9 +94,9 @@ impl History {
|
|||
}
|
||||
Turn::System { content, .. } => Message::system(content),
|
||||
Turn::Steering { content, .. } => Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::text(content)],
|
||||
name: None,
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::text(content)],
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
})
|
||||
|
|
@ -149,7 +149,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
for i in 0..8 {
|
||||
history.push(Turn::User {
|
||||
content: format!("msg {i}"),
|
||||
content: format!("msg {i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
for i in 0..3 {
|
||||
history.push(Turn::User {
|
||||
content: format!("msg {i}"),
|
||||
content: format!("msg {i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
for i in 0..8 {
|
||||
history.push(Turn::User {
|
||||
content: format!("msg {i}"),
|
||||
content: format!("msg {i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
|
@ -199,7 +199,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
for i in 0..6 {
|
||||
history.push(Turn::User {
|
||||
content: format!("msg {i}"),
|
||||
content: format!("msg {i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
|
@ -220,7 +220,7 @@ mod tests {
|
|||
fn user_turn_maps_to_user_message() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "Hello".into(),
|
||||
content: "Hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
|
|
@ -233,12 +233,12 @@ mod tests {
|
|||
fn assistant_turn_maps_to_assistant_message() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::Assistant {
|
||||
content: "Hi there".into(),
|
||||
tool_calls: vec![],
|
||||
content: "Hi there".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
|
|
@ -251,12 +251,12 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
let tc = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "foo.rs"}));
|
||||
history.push(Turn::Assistant {
|
||||
content: "Let me read that".into(),
|
||||
tool_calls: vec![tc],
|
||||
content: "Let me read that".into(),
|
||||
tool_calls: vec![tc],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_2".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_2".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
assert_eq!(messages[0].role, Role::Assistant);
|
||||
|
|
@ -272,17 +272,17 @@ mod tests {
|
|||
fn assistant_turn_with_reasoning_in_provider_parts() {
|
||||
let mut history = History::default();
|
||||
let thinking = ContentPart::Thinking(ThinkingData {
|
||||
text: "Let me think about this...".into(),
|
||||
text: "Let me think about this...".into(),
|
||||
signature: None,
|
||||
redacted: false,
|
||||
redacted: false,
|
||||
});
|
||||
history.push(Turn::Assistant {
|
||||
content: "The answer is 42".into(),
|
||||
tool_calls: vec![],
|
||||
content: "The answer is 42".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![thinking],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_3".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_3".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
let thinking_parts: Vec<_> = messages[0]
|
||||
|
|
@ -297,17 +297,17 @@ mod tests {
|
|||
fn thinking_with_signature_preserved_via_provider_parts() {
|
||||
let mut history = History::default();
|
||||
let thinking = ContentPart::Thinking(ThinkingData {
|
||||
text: "Let me think...".into(),
|
||||
text: "Let me think...".into(),
|
||||
signature: Some("sig_abc123".into()),
|
||||
redacted: false,
|
||||
redacted: false,
|
||||
});
|
||||
history.push(Turn::Assistant {
|
||||
content: "The answer".into(),
|
||||
tool_calls: vec![],
|
||||
content: "The answer".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![thinking],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_4".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_4".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
let thinking_parts: Vec<_> = messages[0]
|
||||
|
|
@ -333,12 +333,12 @@ mod tests {
|
|||
};
|
||||
let tc = ToolCall::new("call_1", "search", serde_json::json!({}));
|
||||
history.push(Turn::Assistant {
|
||||
content: String::new(),
|
||||
tool_calls: vec![tc],
|
||||
content: String::new(),
|
||||
tool_calls: vec![tc],
|
||||
provider_parts: vec![reasoning_item],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
|
|
@ -354,7 +354,7 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
let result = ToolResult::success("call_1", serde_json::json!("file contents here"));
|
||||
history.push(Turn::ToolResults {
|
||||
results: vec![result],
|
||||
results: vec![result],
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
|
|
@ -367,7 +367,7 @@ mod tests {
|
|||
fn system_turn_maps_to_system_message() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::System {
|
||||
content: "You are a coding assistant".into(),
|
||||
content: "You are a coding assistant".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
|
|
@ -380,7 +380,7 @@ mod tests {
|
|||
fn steering_turn_maps_to_user_message() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::Steering {
|
||||
content: "Focus on the main task".into(),
|
||||
content: "Focus on the main task".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let messages = history.convert_to_messages();
|
||||
|
|
@ -394,17 +394,17 @@ mod tests {
|
|||
let mut history = History::default();
|
||||
assert_eq!(history.turns().len(), 0);
|
||||
history.push(Turn::User {
|
||||
content: "First".into(),
|
||||
content: "First".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
assert_eq!(history.turns().len(), 1);
|
||||
history.push(Turn::Assistant {
|
||||
content: "Second".into(),
|
||||
tool_calls: vec![],
|
||||
content: "Second".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
assert_eq!(history.turns().len(), 2);
|
||||
}
|
||||
|
|
@ -413,31 +413,31 @@ mod tests {
|
|||
fn round_trip_preserves_content() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "Hello".into(),
|
||||
content: "Hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::Assistant {
|
||||
content: "Hi".into(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
content: "Hi".into(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
"c1",
|
||||
"shell",
|
||||
serde_json::json!({"cmd": "ls"}),
|
||||
)],
|
||||
provider_parts: vec![ContentPart::Thinking(ThinkingData {
|
||||
text: "thinking...".into(),
|
||||
text: "thinking...".into(),
|
||||
signature: None,
|
||||
redacted: false,
|
||||
redacted: false,
|
||||
})],
|
||||
usage: Box::new(TokenCounts {
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Default::default()
|
||||
}),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::ToolResults {
|
||||
results: vec![ToolResult::success(
|
||||
results: vec![ToolResult::success(
|
||||
"c1",
|
||||
serde_json::json!("file1.rs\nfile2.rs"),
|
||||
)],
|
||||
|
|
@ -455,11 +455,11 @@ mod tests {
|
|||
fn compact_strips_openai_reasoning_from_preserved_turns() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "old msg".into(),
|
||||
content: "old msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::User {
|
||||
content: "recent msg".into(),
|
||||
content: "recent msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let reasoning = ContentPart::Other {
|
||||
|
|
@ -468,12 +468,12 @@ mod tests {
|
|||
};
|
||||
let tc = ToolCall::new("call_1", "search", serde_json::json!({}));
|
||||
history.push(Turn::Assistant {
|
||||
content: "response".into(),
|
||||
tool_calls: vec![tc],
|
||||
content: "response".into(),
|
||||
tool_calls: vec![tc],
|
||||
provider_parts: vec![reasoning],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
history.compact(2, "Summary".into());
|
||||
|
|
@ -503,25 +503,25 @@ mod tests {
|
|||
fn compact_preserves_anthropic_thinking_blocks() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "old msg".into(),
|
||||
content: "old msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::User {
|
||||
content: "recent msg".into(),
|
||||
content: "recent msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let thinking = ContentPart::Thinking(ThinkingData {
|
||||
text: "deep thought".into(),
|
||||
text: "deep thought".into(),
|
||||
signature: Some("sig_xyz".into()),
|
||||
redacted: false,
|
||||
redacted: false,
|
||||
});
|
||||
history.push(Turn::Assistant {
|
||||
content: "answer".into(),
|
||||
tool_calls: vec![],
|
||||
content: "answer".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![thinking],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
history.compact(2, "Summary".into());
|
||||
|
|
@ -545,21 +545,21 @@ mod tests {
|
|||
fn compact_strips_reasoning_from_all_preserved_assistant_turns() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "old msg".into(),
|
||||
content: "old msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
// Two assistant turns that will both be preserved
|
||||
for i in 0..2 {
|
||||
history.push(Turn::Assistant {
|
||||
content: format!("response {i}"),
|
||||
tool_calls: vec![],
|
||||
content: format!("response {i}"),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![ContentPart::Other {
|
||||
kind: ContentPart::OPENAI_REASONING.into(),
|
||||
data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
|
||||
}],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: format!("resp_{i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: format!("resp_{i}"),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -579,19 +579,19 @@ mod tests {
|
|||
fn extract_recent_user_messages_collects_in_chronological_order() {
|
||||
let turns = vec![
|
||||
Turn::User {
|
||||
content: "first".into(),
|
||||
content: "first".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
Turn::Assistant {
|
||||
content: "reply".into(),
|
||||
tool_calls: vec![],
|
||||
content: "reply".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "r1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "r1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
Turn::User {
|
||||
content: "second".into(),
|
||||
content: "second".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
];
|
||||
|
|
@ -605,11 +605,11 @@ mod tests {
|
|||
fn extract_recent_user_messages_respects_token_budget() {
|
||||
let turns = vec![
|
||||
Turn::User {
|
||||
content: "a".repeat(100),
|
||||
content: "a".repeat(100),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
Turn::User {
|
||||
content: "b".repeat(100),
|
||||
content: "b".repeat(100),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
];
|
||||
|
|
@ -624,19 +624,19 @@ mod tests {
|
|||
fn compact_extracts_only_user_turns_from_discarded() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "user msg".into(),
|
||||
content: "user msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::Assistant {
|
||||
content: "assistant msg".into(),
|
||||
tool_calls: vec![],
|
||||
content: "assistant msg".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "r1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "r1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::User {
|
||||
content: "preserved".into(),
|
||||
content: "preserved".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub use agent_profile::AgentProfile;
|
|||
pub use config::{SessionOptions, ToolApprovalAdapter, ToolHookCallback, ToolHookDecision};
|
||||
#[cfg(feature = "docker")]
|
||||
pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions};
|
||||
pub use error::{AgentError, Error, InterruptReason, Result};
|
||||
pub use error::{Error, InterruptReason, Result};
|
||||
pub use event::Emitter;
|
||||
pub use fabro_mcp::config::McpServerSettings;
|
||||
pub use history::History;
|
||||
|
|
|
|||
|
|
@ -103,12 +103,12 @@ mod tests {
|
|||
|
||||
fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn {
|
||||
Turn::Assistant {
|
||||
content: String::new(),
|
||||
tool_calls: vec![ToolCall::new("call_1", name, args)],
|
||||
content: String::new(),
|
||||
tool_calls: vec![ToolCall::new("call_1", name, args)],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,15 +266,15 @@ mod tests {
|
|||
fn user_turns_are_ignored() {
|
||||
let mut history = History::default();
|
||||
history.push(Turn::User {
|
||||
content: "hello".into(),
|
||||
content: "hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::User {
|
||||
content: "hello".into(),
|
||||
content: "hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Turn::User {
|
||||
content: "hello".into(),
|
||||
content: "hello".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
assert!(!detect_loop(&history, 10));
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ pub fn make_mcp_tools(manager: &Arc<McpConnectionManager>) -> Vec<RegisteredTool
|
|||
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: qualified_name.clone(),
|
||||
name: qualified_name.clone(),
|
||||
description: info.description.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let mgr = Arc::clone(&mgr);
|
||||
let name = name.clone();
|
||||
let timeout = tool_timeout;
|
||||
|
|
@ -57,13 +57,13 @@ mod tests {
|
|||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
McpServerSettings {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
env: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
tool_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -251,12 +251,12 @@ mod tests {
|
|||
let profile = AnthropicProfile::new("claude-opus-4-6");
|
||||
let env = MockSandbox::linux();
|
||||
let ctx = EnvContext {
|
||||
git_branch: Some("feature-branch".into()),
|
||||
is_git_repo: true,
|
||||
current_date: "2026-02-20".into(),
|
||||
model: "claude-opus-4-6".into(),
|
||||
knowledge_cutoff: "May 2025".into(),
|
||||
git_status_short: None,
|
||||
git_branch: Some("feature-branch".into()),
|
||||
is_git_repo: true,
|
||||
current_date: "2026-02-20".into(),
|
||||
model: "claude-opus-4-6".into(),
|
||||
knowledge_cutoff: "May 2025".into(),
|
||||
git_status_short: None,
|
||||
git_recent_commits: None,
|
||||
};
|
||||
let prompt = profile.build_system_prompt(&env, &ctx, &[], None, &[]);
|
||||
|
|
|
|||
|
|
@ -17,19 +17,19 @@ use crate::tool_registry::ToolRegistry;
|
|||
/// `model()`, `tool_registry()`, and `tool_registry_mut()` to it.
|
||||
pub struct BaseProfile {
|
||||
pub provider: Provider,
|
||||
pub model: String,
|
||||
pub model: String,
|
||||
pub registry: ToolRegistry,
|
||||
}
|
||||
|
||||
/// Additional context for building environment blocks
|
||||
#[derive(Default)]
|
||||
pub struct EnvContext {
|
||||
pub git_branch: Option<String>,
|
||||
pub is_git_repo: bool,
|
||||
pub current_date: String,
|
||||
pub model: String,
|
||||
pub knowledge_cutoff: String,
|
||||
pub git_status_short: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub is_git_repo: bool,
|
||||
pub current_date: String,
|
||||
pub model: String,
|
||||
pub knowledge_cutoff: String,
|
||||
pub git_status_short: Option<String>,
|
||||
pub git_recent_commits: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -133,12 +133,12 @@ mod tests {
|
|||
fn env_context_block_with_extra_context() {
|
||||
let env = MockSandbox::linux();
|
||||
let ctx = EnvContext {
|
||||
git_branch: Some("main".into()),
|
||||
is_git_repo: true,
|
||||
current_date: "2026-02-20".into(),
|
||||
model: "claude-opus-4-6".into(),
|
||||
knowledge_cutoff: "May 2025".into(),
|
||||
git_status_short: None,
|
||||
git_branch: Some("main".into()),
|
||||
is_git_repo: true,
|
||||
current_date: "2026-02-20".into(),
|
||||
model: "claude-opus-4-6".into(),
|
||||
knowledge_cutoff: "May 2025".into(),
|
||||
git_status_short: None,
|
||||
git_recent_commits: None,
|
||||
};
|
||||
let block = build_env_context_block_with(&env, &ctx);
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::SystemTime;
|
||||
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::error::{ProviderErrorKind, SdkError};
|
||||
use fabro_llm::error::ProviderErrorKind;
|
||||
use fabro_llm::generate::StreamAccumulator;
|
||||
use fabro_llm::provider::StreamEventStream;
|
||||
use fabro_llm::retry;
|
||||
use fabro_llm::types::{
|
||||
ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice,
|
||||
};
|
||||
use fabro_llm::{Error as LlmError, retry};
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_mcp::connection_manager::McpConnectionManager;
|
||||
use futures::StreamExt;
|
||||
|
|
@ -21,7 +21,7 @@ use tracing::{debug, info, warn};
|
|||
use crate::agent_profile::AgentProfile;
|
||||
use crate::compaction::{check_context_usage, compact_context};
|
||||
use crate::config::SessionOptions;
|
||||
use crate::error::{AgentError, InterruptReason};
|
||||
use crate::error::{Error, InterruptReason};
|
||||
use crate::event::Emitter;
|
||||
use crate::file_tracker::FileTracker;
|
||||
use crate::history::History;
|
||||
|
|
@ -38,24 +38,24 @@ use crate::tool_execution::execute_tool_calls;
|
|||
use crate::types::{AgentEvent, SessionEvent, SessionState, Turn};
|
||||
|
||||
pub struct Session {
|
||||
id: String,
|
||||
config: SessionOptions,
|
||||
history: History,
|
||||
event_emitter: Emitter,
|
||||
state: SessionState,
|
||||
llm_client: Client,
|
||||
id: String,
|
||||
config: SessionOptions,
|
||||
history: History,
|
||||
event_emitter: Emitter,
|
||||
state: SessionState,
|
||||
llm_client: Client,
|
||||
provider_profile: Arc<dyn AgentProfile>,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
steering_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
followup_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
cancel_token: CancellationToken,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
steering_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
followup_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
cancel_token: CancellationToken,
|
||||
interrupt_reason: Arc<Mutex<Option<InterruptReason>>>,
|
||||
memory: Vec<String>,
|
||||
env_context: EnvContext,
|
||||
skills: Vec<Skill>,
|
||||
system_prompt: String,
|
||||
file_tracker: FileTracker,
|
||||
tool_env: Option<HashMap<String, String>>,
|
||||
memory: Vec<String>,
|
||||
env_context: EnvContext,
|
||||
skills: Vec<Skill>,
|
||||
system_prompt: String,
|
||||
file_tracker: FileTracker,
|
||||
tool_env: Option<HashMap<String, String>>,
|
||||
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
|
||||
}
|
||||
|
||||
|
|
@ -103,11 +103,13 @@ impl Session {
|
|||
/// Initialize session by discovering project docs and capturing environment
|
||||
/// context. Call before `process_input`.
|
||||
pub async fn initialize(&mut self) {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::SessionStarted {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some(self.provider_profile.provider().to_string()),
|
||||
model: Some(self.provider_profile.model().to_string()),
|
||||
});
|
||||
model: Some(self.provider_profile.model().to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let doc_root = self
|
||||
.config
|
||||
|
|
@ -155,18 +157,22 @@ impl Session {
|
|||
for (server_name, result) in &results {
|
||||
match result {
|
||||
Ok(tool_count) => {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::McpServerReady {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::McpServerReady {
|
||||
server_name: server_name.clone(),
|
||||
tool_count: *tool_count,
|
||||
});
|
||||
tool_count: *tool_count,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::McpServerFailed {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::McpServerFailed {
|
||||
server_name: server_name.clone(),
|
||||
error: e.to_string(),
|
||||
});
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -216,10 +222,10 @@ impl Session {
|
|||
"Sandbox MCP server started, connecting via HTTP"
|
||||
);
|
||||
resolved.push(McpServerSettings {
|
||||
name: config.name.clone(),
|
||||
transport: McpTransport::Http { url, headers },
|
||||
name: config.name.clone(),
|
||||
transport: McpTransport::Http { url, headers },
|
||||
startup_timeout_secs: config.startup_timeout_secs,
|
||||
tool_timeout_secs: config.tool_timeout_secs,
|
||||
tool_timeout_secs: config.tool_timeout_secs,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -228,11 +234,13 @@ impl Session {
|
|||
error = %e,
|
||||
"Failed to start sandbox MCP server"
|
||||
);
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::McpServerFailed {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::McpServerFailed {
|
||||
server_name: config.name.clone(),
|
||||
error: e,
|
||||
});
|
||||
error: e,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -404,24 +412,27 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
fn interrupted_error(&self) -> AgentError {
|
||||
fn interrupted_error(&self) -> Error {
|
||||
let reason = self
|
||||
.interrupt_reason
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
.unwrap_or(InterruptReason::Cancelled);
|
||||
AgentError::Interrupted(reason)
|
||||
Error::Interrupted(reason)
|
||||
}
|
||||
|
||||
fn emit_llm_error(&mut self, err: SdkError) -> AgentError {
|
||||
self.event_emitter.emit(self.id.clone(), AgentEvent::Error {
|
||||
error: AgentError::Llm(err.clone()),
|
||||
});
|
||||
fn emit_llm_error(&mut self, err: LlmError) -> Error {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::Error {
|
||||
error: Error::Llm(err.clone()),
|
||||
},
|
||||
);
|
||||
if is_auth_error(&err) {
|
||||
self.transition(SessionState::Closed);
|
||||
}
|
||||
AgentError::Llm(err)
|
||||
Error::Llm(err)
|
||||
}
|
||||
|
||||
async fn open_stream_with_retry(
|
||||
|
|
@ -429,7 +440,7 @@ impl Session {
|
|||
client: &Client,
|
||||
request: &Request,
|
||||
retry_policy: &RetryPolicy,
|
||||
) -> Result<StreamEventStream, AgentError> {
|
||||
) -> Result<StreamEventStream, Error> {
|
||||
let stream_result = retry::retry(retry_policy, || {
|
||||
let client = client.clone();
|
||||
let request = request.clone();
|
||||
|
|
@ -556,9 +567,9 @@ impl Session {
|
|||
&self.file_tracker
|
||||
}
|
||||
|
||||
pub async fn process_input(&mut self, input: &str) -> Result<(), AgentError> {
|
||||
pub async fn process_input(&mut self, input: &str) -> Result<(), Error> {
|
||||
if self.state == SessionState::Closed {
|
||||
return Err(AgentError::SessionClosed);
|
||||
return Err(Error::SessionClosed);
|
||||
}
|
||||
|
||||
// Spawn wall-clock timeout task if configured
|
||||
|
|
@ -610,11 +621,11 @@ impl Session {
|
|||
result
|
||||
}
|
||||
|
||||
async fn run_single_input(&mut self, input: &str) -> Result<(), AgentError> {
|
||||
async fn run_single_input(&mut self, input: &str) -> Result<(), Error> {
|
||||
const STREAM_CONSUME_RETRIES: usize = 3;
|
||||
|
||||
if self.state == SessionState::Closed {
|
||||
return Err(AgentError::SessionClosed);
|
||||
return Err(Error::SessionClosed);
|
||||
}
|
||||
|
||||
self.transition(SessionState::Thinking);
|
||||
|
|
@ -622,29 +633,33 @@ impl Session {
|
|||
// Expand skill references in input
|
||||
let expanded = if self.skills.is_empty() {
|
||||
ExpandedInput {
|
||||
text: input.to_string(),
|
||||
text: input.to_string(),
|
||||
skill_name: None,
|
||||
}
|
||||
} else {
|
||||
expand_skill(&self.skills, input).map_err(AgentError::InvalidState)?
|
||||
expand_skill(&self.skills, input).map_err(Error::InvalidState)?
|
||||
};
|
||||
if let Some(ref name) = expanded.skill_name {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::SkillExpanded {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::SkillExpanded {
|
||||
skill_name: name.clone(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
let expanded_input = expanded.text;
|
||||
|
||||
// Append user turn and emit event
|
||||
self.history.push(Turn::User {
|
||||
content: expanded_input.clone(),
|
||||
content: expanded_input.clone(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::UserInput {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::UserInput {
|
||||
text: expanded_input.clone(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Drain steering queue before first LLM call
|
||||
self.drain_steering();
|
||||
|
|
@ -656,19 +671,23 @@ impl Session {
|
|||
if self.config.max_tool_rounds_per_input > 0
|
||||
&& round_count >= self.config.max_tool_rounds_per_input
|
||||
{
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::TurnLimitReached {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::TurnLimitReached {
|
||||
max_turns: self.config.max_tool_rounds_per_input,
|
||||
});
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check max_turns
|
||||
if self.config.max_turns > 0 && self.history.turns().len() >= self.config.max_turns {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::TurnLimitReached {
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::TurnLimitReached {
|
||||
max_turns: self.config.max_turns,
|
||||
});
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -696,13 +715,16 @@ impl Session {
|
|||
let retry_policy = RetryPolicy {
|
||||
max_retries: 3,
|
||||
on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| {
|
||||
retry_emitter.emit(retry_session_id.clone(), AgentEvent::LlmRetry {
|
||||
provider: retry_provider.clone(),
|
||||
model: retry_model.clone(),
|
||||
attempt: attempt as usize,
|
||||
delay_secs: delay.as_secs_f64(),
|
||||
error: err.clone(),
|
||||
});
|
||||
retry_emitter.emit(
|
||||
retry_session_id.clone(),
|
||||
AgentEvent::LlmRetry {
|
||||
provider: retry_provider.clone(),
|
||||
model: retry_model.clone(),
|
||||
attempt: attempt as usize,
|
||||
delay_secs: delay.as_secs_f64(),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -782,7 +804,7 @@ impl Session {
|
|||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::AssistantOutputReplace {
|
||||
text: String::new(),
|
||||
text: String::new(),
|
||||
reasoning: None,
|
||||
},
|
||||
);
|
||||
|
|
@ -794,9 +816,9 @@ impl Session {
|
|||
}
|
||||
|
||||
let Some(response) = response else {
|
||||
return Err(self.emit_llm_error(SdkError::Stream {
|
||||
return Err(self.emit_llm_error(LlmError::Stream {
|
||||
message: "Stream ended without a Finish event (after retries)".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -822,13 +844,15 @@ impl Session {
|
|||
});
|
||||
|
||||
// Emit AssistantMessage with enriched data from the response
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::AssistantMessage {
|
||||
text: text.clone(),
|
||||
model: response.model.clone(),
|
||||
usage: response.usage.clone(),
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::AssistantMessage {
|
||||
text: text.clone(),
|
||||
model: response.model.clone(),
|
||||
usage: response.usage.clone(),
|
||||
tool_call_count: tool_calls.len(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Post-response compaction: trim context after appending assistant turn
|
||||
self.compact_if_needed().await;
|
||||
|
|
@ -918,9 +942,12 @@ impl Session {
|
|||
)
|
||||
.await
|
||||
{
|
||||
self.event_emitter.emit(self.id.clone(), AgentEvent::Error {
|
||||
error: AgentError::InvalidState(format!("Context compaction failed: {e}")),
|
||||
});
|
||||
self.event_emitter.emit(
|
||||
self.id.clone(),
|
||||
AgentEvent::Error {
|
||||
error: Error::InvalidState(format!("Context compaction failed: {e}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -935,7 +962,7 @@ impl Session {
|
|||
for msg in messages {
|
||||
let text = msg.clone();
|
||||
self.history.push(Turn::Steering {
|
||||
content: msg,
|
||||
content: msg,
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
self.event_emitter
|
||||
|
|
@ -980,7 +1007,7 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
const fn is_auth_error(err: &SdkError) -> bool {
|
||||
const fn is_auth_error(err: &LlmError) -> bool {
|
||||
matches!(
|
||||
err.provider_kind(),
|
||||
Some(ProviderErrorKind::Authentication | ProviderErrorKind::AccessDenied)
|
||||
|
|
@ -1008,12 +1035,12 @@ mod tests {
|
|||
#[derive(Clone)]
|
||||
enum ScriptedStreamCall {
|
||||
Response(Box<Response>),
|
||||
Events(Vec<Result<StreamEvent, SdkError>>),
|
||||
Error(SdkError),
|
||||
Events(Vec<Result<StreamEvent, LlmError>>),
|
||||
Error(LlmError),
|
||||
}
|
||||
|
||||
struct ScriptedStreamProvider {
|
||||
calls: Vec<ScriptedStreamCall>,
|
||||
calls: Vec<ScriptedStreamCall>,
|
||||
call_index: AtomicUsize,
|
||||
}
|
||||
|
||||
|
|
@ -1029,7 +1056,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn events_for_response(response: Response) -> Vec<Result<StreamEvent, SdkError>> {
|
||||
fn events_for_response(response: Response) -> Vec<Result<StreamEvent, LlmError>> {
|
||||
let mut events = Vec::new();
|
||||
let text = response.text();
|
||||
if !text.is_empty() {
|
||||
|
|
@ -1059,14 +1086,14 @@ mod tests {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
|
||||
Err(SdkError::Configuration {
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
|
||||
Err(LlmError::Configuration {
|
||||
message: "ScriptedStreamProvider does not implement complete()".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
|
||||
let scripted = if idx < self.calls.len() {
|
||||
self.calls[idx].clone()
|
||||
|
|
@ -1427,7 +1454,7 @@ mod tests {
|
|||
let result = session.process_input("Do something").await;
|
||||
|
||||
// Should return Interrupted error and transition to Closed
|
||||
assert!(matches!(result, Err(AgentError::Interrupted(_))));
|
||||
assert!(matches!(result, Err(Error::Interrupted(_))));
|
||||
assert_eq!(session.state(), SessionState::Closed);
|
||||
|
||||
// Should have stopped immediately: User turn only, no LLM call
|
||||
|
|
@ -1444,11 +1471,11 @@ mod tests {
|
|||
// Tool that cancels the token when executed
|
||||
let abort_tool = RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "set_abort".into(),
|
||||
name: "set_abort".into(),
|
||||
description: "Sets interrupt flag".into(),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
executor: Arc::new(move |_args, _ctx| {
|
||||
executor: Arc::new(move |_args, _ctx| {
|
||||
let token = cancel_token_for_tool.clone();
|
||||
Box::pin(async move {
|
||||
token.cancel();
|
||||
|
|
@ -1481,7 +1508,7 @@ mod tests {
|
|||
let result = session.process_input("Do something").await;
|
||||
|
||||
// Should return Interrupted error and transition to Closed
|
||||
assert!(matches!(result, Err(AgentError::Interrupted(_))));
|
||||
assert!(matches!(result, Err(Error::Interrupted(_))));
|
||||
assert_eq!(session.state(), SessionState::Closed);
|
||||
|
||||
// Should have processed: User + Assistant(tool_call) + ToolResults = 3 turns
|
||||
|
|
@ -1496,8 +1523,8 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn auth_error_closes_session() {
|
||||
let error_provider = Arc::new(MockErrorProvider {
|
||||
error: SdkError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
error: LlmError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")),
|
||||
},
|
||||
});
|
||||
|
|
@ -1508,7 +1535,7 @@ mod tests {
|
|||
|
||||
let result = session.process_input("Hello").await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), AgentError::Llm(_)));
|
||||
assert!(matches!(result.unwrap_err(), Error::Llm(_)));
|
||||
assert_eq!(session.state(), SessionState::Closed);
|
||||
}
|
||||
|
||||
|
|
@ -1540,7 +1567,7 @@ mod tests {
|
|||
|
||||
let result = session.process_input("Hello").await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), AgentError::SessionClosed));
|
||||
assert!(matches!(result.unwrap_err(), Error::SessionClosed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1550,7 +1577,7 @@ mod tests {
|
|||
|
||||
let mut rx = session.subscribe();
|
||||
let result = session.process_input("Hello").await;
|
||||
assert!(matches!(result, Err(AgentError::SessionClosed)));
|
||||
assert!(matches!(result, Err(Error::SessionClosed)));
|
||||
|
||||
// No SessionStarted event should have been emitted
|
||||
let mut events = Vec::new();
|
||||
|
|
@ -1698,9 +1725,9 @@ mod tests {
|
|||
let mut registry = ToolRegistry::new();
|
||||
registry.register(RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "strict_tool".into(),
|
||||
name: "strict_tool".into(),
|
||||
description: "Tool with required params".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
|
|
@ -1708,7 +1735,7 @@ mod tests {
|
|||
"required": ["text"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
Box::pin(async move { Ok("should not reach".to_string()) })
|
||||
}),
|
||||
});
|
||||
|
|
@ -1739,9 +1766,9 @@ mod tests {
|
|||
let mut registry = ToolRegistry::new();
|
||||
registry.register(RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "strict_tool".into(),
|
||||
name: "strict_tool".into(),
|
||||
description: "Tool with required params".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
|
|
@ -1749,7 +1776,7 @@ mod tests {
|
|||
"required": ["text"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
Box::pin(async move { Ok("tool executed".to_string()) })
|
||||
}),
|
||||
});
|
||||
|
|
@ -2061,9 +2088,9 @@ mod tests {
|
|||
async fn stream_mid_stream_error() {
|
||||
let provider = Arc::new(MockMidStreamErrorProvider {
|
||||
partial_text: "partial".into(),
|
||||
error: SdkError::Stream {
|
||||
error: LlmError::Stream {
|
||||
message: "connection reset".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
},
|
||||
});
|
||||
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
|
||||
|
|
@ -2072,10 +2099,7 @@ mod tests {
|
|||
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
|
||||
|
||||
let result = session.process_input("Hello").await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AgentError::Llm(SdkError::Stream { .. }))
|
||||
));
|
||||
assert!(matches!(result, Err(Error::Llm(LlmError::Stream { .. }))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -2149,19 +2173,22 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
assert_eq!(observed, vec![
|
||||
"start".to_string(),
|
||||
"delta:Hel".to_string(),
|
||||
"replace::None".to_string(),
|
||||
"delta:Hello".to_string(),
|
||||
"message:Hello".to_string(),
|
||||
]);
|
||||
assert_eq!(
|
||||
observed,
|
||||
vec![
|
||||
"start".to_string(),
|
||||
"delta:Hel".to_string(),
|
||||
"replace::None".to_string(),
|
||||
"delta:Hello".to_string(),
|
||||
"message:Hello".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_open_auth_error_emits_error_and_closes_session() {
|
||||
let auth_error = SdkError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
let auth_error = LlmError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
detail: Box::new(ProviderErrorDetail {
|
||||
status_code: Some(401),
|
||||
..ProviderErrorDetail::new("bad key", "mock")
|
||||
|
|
@ -2177,7 +2204,7 @@ mod tests {
|
|||
let result = session.process_input("Hello").await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AgentError::Llm(SdkError::Provider {
|
||||
Err(Error::Llm(LlmError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
..
|
||||
}))
|
||||
|
|
@ -2199,7 +2226,7 @@ mod tests {
|
|||
observed.push("error".to_string());
|
||||
found_auth_error_event = matches!(
|
||||
error,
|
||||
AgentError::Llm(SdkError::Provider {
|
||||
Error::Llm(LlmError::Provider {
|
||||
kind: ProviderErrorKind::Authentication,
|
||||
..
|
||||
})
|
||||
|
|
@ -2210,12 +2237,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
assert_eq!(observed, vec![
|
||||
"start".to_string(),
|
||||
"delta:Hel".to_string(),
|
||||
"replace::None".to_string(),
|
||||
"error".to_string(),
|
||||
]);
|
||||
assert_eq!(
|
||||
observed,
|
||||
vec![
|
||||
"start".to_string(),
|
||||
"delta:Hel".to_string(),
|
||||
"replace::None".to_string(),
|
||||
"error".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(found_auth_error_event, "expected auth error event");
|
||||
}
|
||||
|
||||
|
|
@ -2308,7 +2338,7 @@ mod tests {
|
|||
// provider that errors on complete() but succeeds on stream().
|
||||
|
||||
struct StreamOnlyProvider {
|
||||
responses: Vec<Response>,
|
||||
responses: Vec<Response>,
|
||||
call_index: AtomicUsize,
|
||||
}
|
||||
|
||||
|
|
@ -2318,14 +2348,14 @@ mod tests {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
|
||||
Err(SdkError::Stream {
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
|
||||
Err(LlmError::Stream {
|
||||
message: "summarization failed".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if idx < self.responses.len() {
|
||||
self.responses[idx].clone()
|
||||
|
|
@ -2333,7 +2363,7 @@ mod tests {
|
|||
self.responses[self.responses.len() - 1].clone()
|
||||
};
|
||||
// Reuse response_to_stream helper from test_support
|
||||
let mut events: Vec<Result<StreamEvent, SdkError>> = Vec::new();
|
||||
let mut events: Vec<Result<StreamEvent, LlmError>> = Vec::new();
|
||||
let text = response.text();
|
||||
if !text.is_empty() {
|
||||
events.push(Ok(StreamEvent::text_delta(text, None)));
|
||||
|
|
@ -2402,8 +2432,8 @@ mod tests {
|
|||
// Provider that captures complete() requests (compaction) while returning
|
||||
// canned responses for stream() calls.
|
||||
struct CompactionCapturingProvider {
|
||||
stream_responses: Vec<Response>,
|
||||
stream_index: AtomicUsize,
|
||||
stream_responses: Vec<Response>,
|
||||
stream_index: AtomicUsize,
|
||||
captured_complete: Mutex<Option<Request>>,
|
||||
}
|
||||
|
||||
|
|
@ -2413,12 +2443,12 @@ mod tests {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
|
||||
async fn complete(&self, request: &Request) -> Result<Response, LlmError> {
|
||||
*self.captured_complete.lock().unwrap() = Some(request.clone());
|
||||
Ok(text_response("## Goal\nSummary goes here."))
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
let idx = self.stream_index.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if idx < self.stream_responses.len() {
|
||||
self.stream_responses[idx].clone()
|
||||
|
|
@ -2432,11 +2462,11 @@ mod tests {
|
|||
// read_file tool that always succeeds
|
||||
let read_tool = RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "read_file".into(),
|
||||
name: "read_file".into(),
|
||||
description: "Read a file".into(),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
Box::pin(async move { Ok("file contents".to_string()) })
|
||||
}),
|
||||
};
|
||||
|
|
@ -2545,13 +2575,13 @@ mod tests {
|
|||
);
|
||||
let config = SessionOptions {
|
||||
mcp_servers: vec![McpServerSettings {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
env: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
tool_timeout_secs: 30,
|
||||
}],
|
||||
enable_loop_detection: false,
|
||||
..Default::default()
|
||||
|
|
@ -2657,11 +2687,11 @@ mod tests {
|
|||
// Register a tool that loops until the cancel token fires
|
||||
let slow_tool = RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "slow_tool".into(),
|
||||
name: "slow_tool".into(),
|
||||
description: "Waits until cancelled".into(),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
executor: Arc::new(|_args, ctx| {
|
||||
executor: Arc::new(|_args, ctx| {
|
||||
Box::pin(async move {
|
||||
ctx.cancel.cancelled().await;
|
||||
Ok("cancelled".to_string())
|
||||
|
|
@ -2689,7 +2719,7 @@ mod tests {
|
|||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(AgentError::Interrupted(InterruptReason::WallClockTimeout))
|
||||
Err(Error::Interrupted(InterruptReason::WallClockTimeout))
|
||||
),
|
||||
"expected Interrupted(WallClockTimeout), got {result:?}"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ use crate::tools::required_str;
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub template: String,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
pub fn parse_skill(content: &str) -> Result<Skill, String> {
|
||||
|
|
@ -51,11 +51,11 @@ pub fn parse_skill(content: &str) -> Result<Skill, String> {
|
|||
/// A detected skill reference in user input: the name and byte range of the
|
||||
/// `/name` token.
|
||||
struct SkillMatch {
|
||||
name: String,
|
||||
name: String,
|
||||
/// Byte offset of the `/` character
|
||||
start: usize,
|
||||
/// Byte offset just past the skill name
|
||||
end: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn is_skill_name_char(c: char) -> bool {
|
||||
|
|
@ -97,9 +97,9 @@ fn find_skill_references(input: &str) -> Vec<SkillMatch> {
|
|||
let followed_by_boundary = j >= len || bytes[j].is_ascii_whitespace();
|
||||
if followed_by_boundary {
|
||||
results.push(SkillMatch {
|
||||
name: input[name_start..j].to_string(),
|
||||
name: input[name_start..j].to_string(),
|
||||
start: i,
|
||||
end: j,
|
||||
end: j,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ fn find_skill_references(input: &str) -> Vec<SkillMatch> {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct ExpandedInput {
|
||||
pub text: String,
|
||||
pub text: String,
|
||||
pub skill_name: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result<ExpandedInput, Stri
|
|||
|
||||
if refs.is_empty() {
|
||||
return Ok(ExpandedInput {
|
||||
text: input.to_string(),
|
||||
text: input.to_string(),
|
||||
skill_name: None,
|
||||
});
|
||||
}
|
||||
|
|
@ -159,11 +159,11 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result<ExpandedInput, Stri
|
|||
pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "use_skill".into(),
|
||||
name: "use_skill".into(),
|
||||
description: "Load a skill's instructions by name. Call this when the user's \
|
||||
request matches an available skill."
|
||||
.into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {
|
||||
|
|
@ -174,7 +174,7 @@ pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> RegisteredTool {
|
|||
"required": ["skill_name"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let skills = skills.clone();
|
||||
Box::pin(async move {
|
||||
let name = required_str(&args, "skill_name")?;
|
||||
|
|
@ -343,14 +343,14 @@ name: trimmed
|
|||
fn test_skills() -> Vec<Skill> {
|
||||
vec![
|
||||
Skill {
|
||||
name: "commit".into(),
|
||||
name: "commit".into(),
|
||||
description: "Create a commit".into(),
|
||||
template: "Review changes and commit.\n\n{{user_input}}".into(),
|
||||
template: "Review changes and commit.\n\n{{user_input}}".into(),
|
||||
},
|
||||
Skill {
|
||||
name: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "Run tests".into(),
|
||||
template: "Run the test suite.".into(),
|
||||
template: "Run the test suite.".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -524,11 +524,14 @@ name: trimmed
|
|||
#[test]
|
||||
fn default_dirs_with_git_root() {
|
||||
let dirs = default_skill_dirs(Some("/home/user/.fabro/skills"), Some("/repo"));
|
||||
assert_eq!(dirs, vec![
|
||||
"/home/user/.fabro/skills",
|
||||
"/repo/.fabro/skills",
|
||||
"/repo/skills",
|
||||
]);
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec![
|
||||
"/home/user/.fabro/skills",
|
||||
"/repo/.fabro/skills",
|
||||
"/repo/skills",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use tokio::sync::Mutex as AsyncMutex;
|
|||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::AgentError;
|
||||
use crate::error::Error;
|
||||
use crate::session::Session;
|
||||
use crate::tool_registry::RegisteredTool;
|
||||
use crate::tools::required_str;
|
||||
|
|
@ -24,29 +24,29 @@ pub type SubAgentEventCallback = Arc<dyn Fn(SubAgentCallbackEvent) + Send + Sync
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubAgentResult {
|
||||
pub output: String,
|
||||
pub success: bool,
|
||||
pub output: String,
|
||||
pub success: bool,
|
||||
pub turns_used: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubAgentStatus {
|
||||
Running,
|
||||
Finished(Result<SubAgentResult, AgentError>),
|
||||
Finished(Result<SubAgentResult, Error>),
|
||||
Closed,
|
||||
}
|
||||
|
||||
pub struct SubAgent {
|
||||
task: Option<JoinHandle<Result<SubAgentResult, AgentError>>>,
|
||||
task: Option<JoinHandle<Result<SubAgentResult, Error>>>,
|
||||
followup_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
cancel_token: CancellationToken,
|
||||
depth: usize,
|
||||
status: SubAgentStatus,
|
||||
cancel_token: CancellationToken,
|
||||
depth: usize,
|
||||
status: SubAgentStatus,
|
||||
}
|
||||
|
||||
pub struct SubAgentManager {
|
||||
agents: HashMap<String, SubAgent>,
|
||||
max_depth: usize,
|
||||
agents: HashMap<String, SubAgent>,
|
||||
max_depth: usize,
|
||||
event_callback: Option<SubAgentEventCallback>,
|
||||
}
|
||||
|
||||
|
|
@ -75,9 +75,9 @@ impl SubAgentManager {
|
|||
mut session: Session,
|
||||
task_prompt: String,
|
||||
depth: usize,
|
||||
) -> Result<String, AgentError> {
|
||||
) -> Result<String, Error> {
|
||||
if depth >= self.max_depth {
|
||||
return Err(AgentError::InvalidState(format!(
|
||||
return Err(Error::InvalidState(format!(
|
||||
"Maximum subagent depth ({}) reached",
|
||||
self.max_depth
|
||||
)));
|
||||
|
|
@ -119,32 +119,35 @@ impl SubAgentManager {
|
|||
_ => None,
|
||||
});
|
||||
Ok(SubAgentResult {
|
||||
output: last_text.unwrap_or_default(),
|
||||
success: true,
|
||||
output: last_text.unwrap_or_default(),
|
||||
success: true,
|
||||
turns_used: turns.len(),
|
||||
})
|
||||
});
|
||||
|
||||
self.agents.insert(agent_id.clone(), SubAgent {
|
||||
task: Some(task),
|
||||
followup_queue,
|
||||
cancel_token,
|
||||
depth: depth + 1,
|
||||
status: SubAgentStatus::Running,
|
||||
});
|
||||
self.agents.insert(
|
||||
agent_id.clone(),
|
||||
SubAgent {
|
||||
task: Some(task),
|
||||
followup_queue,
|
||||
cancel_token,
|
||||
depth: depth + 1,
|
||||
status: SubAgentStatus::Running,
|
||||
},
|
||||
);
|
||||
|
||||
self.emit_event(AgentEvent::SubAgentSpawned {
|
||||
agent_id: agent_id.clone(),
|
||||
depth: depth + 1,
|
||||
task: task_prompt,
|
||||
depth: depth + 1,
|
||||
task: task_prompt,
|
||||
});
|
||||
|
||||
Ok(agent_id)
|
||||
}
|
||||
|
||||
pub fn send_input(&self, agent_id: &str, message: &str) -> Result<(), AgentError> {
|
||||
pub fn send_input(&self, agent_id: &str, message: &str) -> Result<(), Error> {
|
||||
let agent = self.agents.get(agent_id).ok_or_else(|| {
|
||||
AgentError::InvalidState(format!(
|
||||
Error::InvalidState(format!(
|
||||
"No agent found with id: {agent_id} (it was never spawned)"
|
||||
))
|
||||
})?;
|
||||
|
|
@ -152,7 +155,7 @@ impl SubAgentManager {
|
|||
match agent.status {
|
||||
SubAgentStatus::Running => {}
|
||||
_ => {
|
||||
return Err(AgentError::InvalidState(format!(
|
||||
return Err(Error::InvalidState(format!(
|
||||
"Agent {agent_id} is not running"
|
||||
)));
|
||||
}
|
||||
|
|
@ -167,12 +170,12 @@ impl SubAgentManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn wait(&mut self, agent_id: &str) -> Result<SubAgentResult, AgentError> {
|
||||
pub async fn wait(&mut self, agent_id: &str) -> Result<SubAgentResult, Error> {
|
||||
// Phase 1: Check existence and current status
|
||||
let agent = self.agents.get(agent_id);
|
||||
let depth = match agent {
|
||||
None => {
|
||||
return Err(AgentError::InvalidState(format!(
|
||||
return Err(Error::InvalidState(format!(
|
||||
"No agent found with id: {agent_id} (it was never spawned)"
|
||||
)));
|
||||
}
|
||||
|
|
@ -181,7 +184,7 @@ impl SubAgentManager {
|
|||
|
||||
match &self.agents[agent_id].status {
|
||||
SubAgentStatus::Closed => {
|
||||
return Err(AgentError::InvalidState(format!(
|
||||
return Err(Error::InvalidState(format!(
|
||||
"Agent {agent_id} has been closed"
|
||||
)));
|
||||
}
|
||||
|
|
@ -198,16 +201,12 @@ impl SubAgentManager {
|
|||
.unwrap()
|
||||
.task
|
||||
.take()
|
||||
.ok_or_else(|| {
|
||||
AgentError::InvalidState(format!("Agent {agent_id} has no running task"))
|
||||
})?;
|
||||
.ok_or_else(|| Error::InvalidState(format!("Agent {agent_id} has no running task")))?;
|
||||
|
||||
// Phase 3: Await the task (no borrow held)
|
||||
let task_result = match join_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(AgentError::InvalidState(format!(
|
||||
"Agent task panicked: {e}"
|
||||
))),
|
||||
Err(e) => Err(Error::InvalidState(format!("Agent task panicked: {e}"))),
|
||||
};
|
||||
|
||||
// Phase 4: Emit event
|
||||
|
|
@ -239,16 +238,16 @@ impl SubAgentManager {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self, agent_id: &str) -> Result<(), AgentError> {
|
||||
pub fn close(&mut self, agent_id: &str) -> Result<(), Error> {
|
||||
let agent = self.agents.get_mut(agent_id).ok_or_else(|| {
|
||||
AgentError::InvalidState(format!(
|
||||
Error::InvalidState(format!(
|
||||
"No agent found with id: {agent_id} (it was never spawned)"
|
||||
))
|
||||
})?;
|
||||
|
||||
match agent.status {
|
||||
SubAgentStatus::Closed => {
|
||||
return Err(AgentError::InvalidState(format!(
|
||||
return Err(Error::InvalidState(format!(
|
||||
"Agent {agent_id} is already closed"
|
||||
)));
|
||||
}
|
||||
|
|
@ -307,9 +306,9 @@ pub fn make_spawn_agent_tool(
|
|||
) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "spawn_agent".into(),
|
||||
name: "spawn_agent".into(),
|
||||
description: "Spawn a subagent to work on a delegated task".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
|
|
@ -332,7 +331,7 @@ pub fn make_spawn_agent_tool(
|
|||
"required": ["task"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let manager = manager.clone();
|
||||
let session_factory = session_factory.clone();
|
||||
Box::pin(async move {
|
||||
|
|
@ -360,9 +359,9 @@ pub fn make_spawn_agent_tool(
|
|||
pub fn make_send_input_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "send_input".into(),
|
||||
name: "send_input".into(),
|
||||
description: "Send a follow-up message to a running subagent".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
|
|
@ -377,7 +376,7 @@ pub fn make_send_input_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> Regist
|
|||
"required": ["agent_id", "message"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let manager = manager.clone();
|
||||
Box::pin(async move {
|
||||
let agent_id = required_str(&args, "agent_id")?;
|
||||
|
|
@ -395,9 +394,9 @@ pub fn make_send_input_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> Regist
|
|||
pub fn make_wait_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "wait".into(),
|
||||
name: "wait".into(),
|
||||
description: "Wait for a subagent to complete and return its result".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
|
|
@ -408,7 +407,7 @@ pub fn make_wait_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTo
|
|||
"required": ["agent_id"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let manager = manager.clone();
|
||||
Box::pin(async move {
|
||||
let agent_id = required_str(&args, "agent_id")?;
|
||||
|
|
@ -427,9 +426,9 @@ pub fn make_wait_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTo
|
|||
pub fn make_close_agent_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "close_agent".into(),
|
||||
name: "close_agent".into(),
|
||||
description: "Close a running subagent".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
|
|
@ -440,7 +439,7 @@ pub fn make_close_agent_tool(manager: Arc<AsyncMutex<SubAgentManager>>) -> Regis
|
|||
"required": ["agent_id"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let manager = manager.clone();
|
||||
Box::pin(async move {
|
||||
let agent_id = required_str(&args, "agent_id")?;
|
||||
|
|
@ -719,21 +718,21 @@ mod tests {
|
|||
let mut rx = parent.subscribe();
|
||||
|
||||
callback(SubAgentCallbackEvent::Forwarded(SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
session_id: "child".into(),
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
session_id: "child".into(),
|
||||
parent_session_id: None,
|
||||
}));
|
||||
callback(SubAgentCallbackEvent::Forwarded(SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
session_id: "grandchild".into(),
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
session_id: "grandchild".into(),
|
||||
parent_session_id: Some("child".into()),
|
||||
}));
|
||||
|
||||
|
|
@ -751,7 +750,7 @@ mod tests {
|
|||
let manager = SubAgentManager::new(3);
|
||||
manager.emit_event(AgentEvent::SubAgentClosed {
|
||||
agent_id: "x".into(),
|
||||
depth: 0,
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_llm::Error as LlmError;
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::error::SdkError;
|
||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||
use fabro_llm::types::{
|
||||
ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts,
|
||||
|
|
@ -24,14 +24,14 @@ use crate::tool_registry::{RegisteredTool, ToolRegistry};
|
|||
// --- TestProfile ---
|
||||
|
||||
pub struct TestProfile {
|
||||
pub registry: ToolRegistry,
|
||||
pub registry: ToolRegistry,
|
||||
pub context_window: usize,
|
||||
}
|
||||
|
||||
impl TestProfile {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
registry: ToolRegistry::new(),
|
||||
registry: ToolRegistry::new(),
|
||||
context_window: 200_000,
|
||||
}
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ impl AgentProfile for TestProfile {
|
|||
// --- MockLlmProvider ---
|
||||
|
||||
pub struct MockLlmProvider {
|
||||
pub responses: Vec<Response>,
|
||||
pub responses: Vec<Response>,
|
||||
pub call_index: AtomicUsize,
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ impl ProviderAdapter for MockLlmProvider {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
|
||||
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
|
||||
if idx < self.responses.len() {
|
||||
Ok(self.responses[idx].clone())
|
||||
|
|
@ -126,7 +126,7 @@ impl ProviderAdapter for MockLlmProvider {
|
|||
}
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
let idx = self.call_index.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if idx < self.responses.len() {
|
||||
self.responses[idx].clone()
|
||||
|
|
@ -139,7 +139,7 @@ impl ProviderAdapter for MockLlmProvider {
|
|||
|
||||
/// Convert a canned `Response` into a `StreamEventStream` for mock streaming.
|
||||
pub fn response_to_stream(response: Response) -> StreamEventStream {
|
||||
let mut events: Vec<Result<StreamEvent, SdkError>> = Vec::new();
|
||||
let mut events: Vec<Result<StreamEvent, LlmError>> = Vec::new();
|
||||
|
||||
// Emit text deltas for text content
|
||||
let text = response.text();
|
||||
|
|
@ -170,19 +170,19 @@ pub fn response_to_stream(response: Response) -> StreamEventStream {
|
|||
|
||||
pub fn text_response(text: &str) -> Response {
|
||||
Response {
|
||||
id: format!("resp_{text}"),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message::assistant(text),
|
||||
id: format!("resp_{text}"),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message::assistant(text),
|
||||
finish_reason: FinishReason::Stop,
|
||||
usage: TokenCounts {
|
||||
usage: TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Default::default()
|
||||
},
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,27 +238,27 @@ pub fn tool_call_response(
|
|||
) -> Response {
|
||||
use fabro_llm::types::{ContentPart, Role, ToolCall};
|
||||
Response {
|
||||
id: format!("resp_{tool_call_id}"),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![
|
||||
id: format!("resp_{tool_call_id}"),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![
|
||||
ContentPart::text("Let me use a tool."),
|
||||
ContentPart::ToolCall(ToolCall::new(tool_call_id, tool_name, args)),
|
||||
],
|
||||
name: None,
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
finish_reason: FinishReason::ToolCalls,
|
||||
usage: TokenCounts {
|
||||
usage: TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Default::default()
|
||||
},
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,11 +266,11 @@ pub fn make_echo_tool() -> RegisteredTool {
|
|||
use fabro_llm::types::ToolDefinition;
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "echo".into(),
|
||||
name: "echo".into(),
|
||||
description: "Echoes the input".into(),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}),
|
||||
},
|
||||
executor: Arc::new(|args, _ctx| {
|
||||
executor: Arc::new(|args, _ctx| {
|
||||
Box::pin(async move {
|
||||
let text = args
|
||||
.get("text")
|
||||
|
|
@ -286,11 +286,11 @@ pub fn make_error_tool() -> RegisteredTool {
|
|||
use fabro_llm::types::ToolDefinition;
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "fail_tool".into(),
|
||||
name: "fail_tool".into(),
|
||||
description: "Always fails".into(),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
executor: Arc::new(|_args, _ctx| {
|
||||
Box::pin(async move { Err("tool execution failed".to_string()) })
|
||||
}),
|
||||
}
|
||||
|
|
@ -299,7 +299,7 @@ pub fn make_error_tool() -> RegisteredTool {
|
|||
// --- MockErrorProvider ---
|
||||
|
||||
pub struct MockErrorProvider {
|
||||
pub error: SdkError,
|
||||
pub error: LlmError,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -308,11 +308,11 @@ impl ProviderAdapter for MockErrorProvider {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
|
||||
Err(self.error.clone())
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
Err(self.error.clone())
|
||||
}
|
||||
}
|
||||
|
|
@ -338,7 +338,7 @@ impl ProviderAdapter for CapturingLlmProvider {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, request: &Request) -> Result<Response, SdkError> {
|
||||
async fn complete(&self, request: &Request) -> Result<Response, LlmError> {
|
||||
*self
|
||||
.captured_request
|
||||
.lock()
|
||||
|
|
@ -346,7 +346,7 @@ impl ProviderAdapter for CapturingLlmProvider {
|
|||
Ok(text_response("captured"))
|
||||
}
|
||||
|
||||
async fn stream(&self, request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
async fn stream(&self, request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
*self
|
||||
.captured_request
|
||||
.lock()
|
||||
|
|
@ -360,7 +360,7 @@ impl ProviderAdapter for CapturingLlmProvider {
|
|||
/// A mock provider that yields some text deltas then an error mid-stream.
|
||||
pub struct MockMidStreamErrorProvider {
|
||||
pub partial_text: String,
|
||||
pub error: SdkError,
|
||||
pub error: LlmError,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -369,12 +369,12 @@ impl ProviderAdapter for MockMidStreamErrorProvider {
|
|||
"mock"
|
||||
}
|
||||
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, SdkError> {
|
||||
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
|
||||
Err(self.error.clone())
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, SdkError> {
|
||||
let events: Vec<Result<StreamEvent, SdkError>> = vec![
|
||||
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
|
||||
let events: Vec<Result<StreamEvent, LlmError>> = vec![
|
||||
Ok(StreamEvent::text_delta(self.partial_text.clone(), None)),
|
||||
Err(self.error.clone()),
|
||||
];
|
||||
|
|
@ -393,23 +393,23 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) ->
|
|||
)));
|
||||
}
|
||||
Response {
|
||||
id: "resp_multi".into(),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message {
|
||||
id: "resp_multi".into(),
|
||||
model: "mock-model".into(),
|
||||
provider: "mock".into(),
|
||||
message: Message {
|
||||
role: Role::Assistant,
|
||||
content,
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
finish_reason: FinishReason::ToolCalls,
|
||||
usage: TokenCounts {
|
||||
usage: TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Default::default()
|
||||
},
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
raw: None,
|
||||
warnings: vec![],
|
||||
rate_limit: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,11 +180,14 @@ async fn execute_and_emit_one_tool_with_lookup(
|
|||
session_id: &str,
|
||||
tool_env: Option<&HashMap<String, String>>,
|
||||
) -> ToolResult {
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
arguments: tc.arguments.clone(),
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::ToolCallStarted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
arguments: tc.arguments.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// Pre-tool-use hook
|
||||
if let Some(hooks) = tool_hooks {
|
||||
|
|
@ -197,15 +200,21 @@ async fn execute_and_emit_one_tool_with_lookup(
|
|||
if let ToolHookDecision::Block { reason } = decision {
|
||||
let result = ToolResult::error(&tc.id, &reason);
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {
|
||||
delta: result.content.to_string(),
|
||||
});
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::ToolCallOutputDelta {
|
||||
delta: result.content.to_string(),
|
||||
},
|
||||
);
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::ToolCallCompleted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: true,
|
||||
},
|
||||
);
|
||||
|
||||
return truncate_tool_result(&result, &tc.name, config);
|
||||
}
|
||||
|
|
@ -213,16 +222,22 @@ async fn execute_and_emit_one_tool_with_lookup(
|
|||
|
||||
let result = execute_one_tool(tc, registered_tool, env, cancel_token, tool_env).await;
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {
|
||||
delta: result.content.to_string(),
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::ToolCallOutputDelta {
|
||||
delta: result.content.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: result.is_error,
|
||||
});
|
||||
emitter.emit(
|
||||
session_id.to_owned(),
|
||||
AgentEvent::ToolCallCompleted {
|
||||
tool_name: tc.name.clone(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: result.is_error,
|
||||
},
|
||||
);
|
||||
|
||||
// Post-tool-use hooks
|
||||
if let Some(hooks) = tool_hooks {
|
||||
|
|
@ -293,10 +308,10 @@ fn truncate_tool_result(
|
|||
};
|
||||
|
||||
ToolResult {
|
||||
tool_call_id: result.tool_call_id.clone(),
|
||||
content: truncated_content,
|
||||
is_error: result.is_error,
|
||||
image_data: result.image_data.clone(),
|
||||
tool_call_id: result.tool_call_id.clone(),
|
||||
content: truncated_content,
|
||||
is_error: result.is_error,
|
||||
image_data: result.image_data.clone(),
|
||||
image_media_type: result.image_media_type.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -350,9 +365,9 @@ mod tests {
|
|||
fn make_echo_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "echo".to_string(),
|
||||
name: "echo".to_string(),
|
||||
description: "Echo input".to_string(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
|
|
@ -360,7 +375,7 @@ mod tests {
|
|||
"required": ["text"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| {
|
||||
executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| {
|
||||
Box::pin(async move {
|
||||
let text = args["text"].as_str().unwrap_or("").to_string();
|
||||
Ok(format!("echo: {text}"))
|
||||
|
|
@ -372,11 +387,11 @@ mod tests {
|
|||
fn make_fail_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "fail_tool".to_string(),
|
||||
name: "fail_tool".to_string(),
|
||||
description: "Always fails".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
parameters: serde_json::json!({}),
|
||||
},
|
||||
executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| {
|
||||
executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| {
|
||||
Box::pin(async move { Err("tool failed".to_string()) })
|
||||
}),
|
||||
}
|
||||
|
|
@ -384,26 +399,26 @@ mod tests {
|
|||
|
||||
fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall {
|
||||
ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
tool_type: "function".to_string(),
|
||||
arguments: args,
|
||||
raw_arguments: None,
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
tool_type: "function".to_string(),
|
||||
arguments: args,
|
||||
raw_arguments: None,
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct MockHookCallback {
|
||||
pre_decision: ToolHookDecision,
|
||||
post_calls: Arc<Mutex<Vec<(String, String, String)>>>,
|
||||
pre_decision: ToolHookDecision,
|
||||
post_calls: Arc<Mutex<Vec<(String, String, String)>>>,
|
||||
post_failure_calls: Arc<Mutex<Vec<(String, String, String)>>>,
|
||||
}
|
||||
|
||||
impl MockHookCallback {
|
||||
fn new(decision: ToolHookDecision) -> Self {
|
||||
Self {
|
||||
pre_decision: decision,
|
||||
post_calls: Arc::new(Mutex::new(Vec::new())),
|
||||
pre_decision: decision,
|
||||
post_calls: Arc::new(Mutex::new(Vec::new())),
|
||||
post_failure_calls: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ use tokio_util::sync::CancellationToken;
|
|||
use crate::sandbox::Sandbox;
|
||||
|
||||
pub struct ToolContext {
|
||||
pub env: Arc<dyn Sandbox>,
|
||||
pub cancel: CancellationToken,
|
||||
pub env: Arc<dyn Sandbox>,
|
||||
pub cancel: CancellationToken,
|
||||
pub tool_env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ pub type ToolExecutor = Arc<
|
|||
#[derive(Clone)]
|
||||
pub struct RegisteredTool {
|
||||
pub definition: ToolDefinition,
|
||||
pub executor: ToolExecutor,
|
||||
pub executor: ToolExecutor,
|
||||
}
|
||||
|
||||
pub struct ToolRegistry {
|
||||
|
|
@ -80,11 +80,11 @@ mod tests {
|
|||
fn make_tool(name: &str) -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: name.into(),
|
||||
name: name.into(),
|
||||
description: format!("Tool {name}"),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })),
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,19 +124,19 @@ mod tests {
|
|||
let mut registry = ToolRegistry::new();
|
||||
registry.register(RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "tool_a".into(),
|
||||
name: "tool_a".into(),
|
||||
description: "version 1".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
parameters: serde_json::json!({}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })),
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })),
|
||||
});
|
||||
registry.register(RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "tool_a".into(),
|
||||
name: "tool_a".into(),
|
||||
description: "version 2".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
parameters: serde_json::json!({}),
|
||||
},
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })),
|
||||
executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })),
|
||||
});
|
||||
|
||||
let tool = registry.get("tool_a").unwrap();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const MAX_WEB_FETCH_BYTES: usize = 100 * 1024;
|
|||
/// Configuration for the optional LLM-based summarizer used by `web_fetch`.
|
||||
#[derive(Clone)]
|
||||
pub struct WebFetchSummarizer {
|
||||
pub client: Client,
|
||||
pub client: Client,
|
||||
pub model_id: ModelHandle,
|
||||
}
|
||||
|
||||
|
|
@ -72,9 +72,9 @@ pub(crate) fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> Result
|
|||
pub fn make_read_file_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "read_file".into(),
|
||||
name: "read_file".into(),
|
||||
description: "Read the contents of a file".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
||||
|
|
@ -84,7 +84,7 @@ pub fn make_read_file_tool() -> RegisteredTool {
|
|||
"required": ["file_path"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let file_path = required_str(&args, "file_path")?;
|
||||
let offset = args.get("offset").and_then(serde_json::Value::as_u64);
|
||||
|
|
@ -108,9 +108,9 @@ pub fn make_read_file_tool() -> RegisteredTool {
|
|||
pub fn make_write_file_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "write_file".into(),
|
||||
name: "write_file".into(),
|
||||
description: "Write content to a file".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
||||
|
|
@ -119,7 +119,7 @@ pub fn make_write_file_tool() -> RegisteredTool {
|
|||
"required": ["file_path", "content"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let file_path = required_str(&args, "file_path")?;
|
||||
let content = required_str(&args, "content")?;
|
||||
|
|
@ -135,9 +135,9 @@ pub fn make_write_file_tool() -> RegisteredTool {
|
|||
pub fn make_edit_file_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "edit_file".into(),
|
||||
name: "edit_file".into(),
|
||||
description: "Edit a file by replacing a string".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "Absolute path to the file"},
|
||||
|
|
@ -148,7 +148,7 @@ pub fn make_edit_file_tool() -> RegisteredTool {
|
|||
"required": ["file_path", "old_string", "new_string"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let file_path = required_str(&args, "file_path")?;
|
||||
let old_string = required_str(&args, "old_string")?;
|
||||
|
|
@ -201,9 +201,9 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
let max_timeout = config.max_command_timeout_ms;
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "shell".into(),
|
||||
name: "shell".into(),
|
||||
description: "Execute a shell command".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "The shell command to execute"},
|
||||
|
|
@ -213,7 +213,7 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
"required": ["command"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
Box::pin(async move {
|
||||
let command = required_str(&args, "command")?;
|
||||
let timeout_ms = args
|
||||
|
|
@ -259,9 +259,9 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
pub fn make_grep_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "grep".into(),
|
||||
name: "grep".into(),
|
||||
description: "Search file contents with a regex pattern".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern to search for"},
|
||||
|
|
@ -273,7 +273,7 @@ pub fn make_grep_tool() -> RegisteredTool {
|
|||
"required": ["pattern"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let pattern = required_str(&args, "pattern")?;
|
||||
let path = args
|
||||
|
|
@ -316,9 +316,9 @@ pub fn make_grep_tool() -> RegisteredTool {
|
|||
pub fn make_glob_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "glob".into(),
|
||||
name: "glob".into(),
|
||||
description: "Find files matching a glob pattern".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern to match files"},
|
||||
|
|
@ -327,7 +327,7 @@ pub fn make_glob_tool() -> RegisteredTool {
|
|||
"required": ["pattern"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let pattern = required_str(&args, "pattern")?;
|
||||
let path = args.get("path").and_then(serde_json::Value::as_str);
|
||||
|
|
@ -343,9 +343,9 @@ pub fn make_glob_tool() -> RegisteredTool {
|
|||
pub(crate) fn make_read_many_files_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "read_many_files".into(),
|
||||
name: "read_many_files".into(),
|
||||
description: "Read multiple files at once".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
|
|
@ -357,7 +357,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool {
|
|||
"required": ["paths"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let paths = args["paths"]
|
||||
.as_array()
|
||||
|
|
@ -388,9 +388,9 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool {
|
|||
pub(crate) fn make_list_dir_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "list_dir".into(),
|
||||
name: "list_dir".into(),
|
||||
description: "List directory contents with depth control".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Directory path to list"},
|
||||
|
|
@ -399,7 +399,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool {
|
|||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let path = required_str(&args, "path")?;
|
||||
let depth = args
|
||||
|
|
@ -471,9 +471,9 @@ fn make_web_search_tool_with_api_key(api_key: Option<String>) -> RegisteredTool
|
|||
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "web_search".into(),
|
||||
name: "web_search".into(),
|
||||
description: "Search the web using Brave Search".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
|
|
@ -482,7 +482,7 @@ fn make_web_search_tool_with_api_key(api_key: Option<String>) -> RegisteredTool
|
|||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
let api_key = api_key.clone();
|
||||
Box::pin(async move {
|
||||
let api_key = api_key.ok_or_else(|| {
|
||||
|
|
@ -638,11 +638,14 @@ mod tests {
|
|||
apply_read_offset_limit: true,
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"file_path": "/test.txt"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), " 1 | hello\n 2 | world");
|
||||
}
|
||||
|
|
@ -680,8 +683,8 @@ mod tests {
|
|||
let result = (tool.executor)(
|
||||
serde_json::json!({"file_path": "/out.txt", "content": "hello"}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -710,8 +713,8 @@ mod tests {
|
|||
"new_string": "goodbye"
|
||||
}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -792,8 +795,8 @@ mod tests {
|
|||
"replace_all": true
|
||||
}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -809,19 +812,22 @@ mod tests {
|
|||
let tool = make_shell_tool();
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: "hello".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "hello".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 10,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"command": "echo hello"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Exit code: 0"));
|
||||
|
|
@ -836,8 +842,8 @@ mod tests {
|
|||
let _result = (tool.executor)(
|
||||
serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -850,19 +856,22 @@ mod tests {
|
|||
let tool = make_shell_tool();
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: "error".into(),
|
||||
exit_code: 1,
|
||||
timed_out: false,
|
||||
stdout: String::new(),
|
||||
stderr: "error".into(),
|
||||
exit_code: 1,
|
||||
timed_out: false,
|
||||
duration_ms: 10,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"command": "false"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"command": "false"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Exit code: 1"));
|
||||
|
|
@ -874,19 +883,22 @@ mod tests {
|
|||
let tool = make_shell_tool();
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: -1,
|
||||
timed_out: true,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: -1,
|
||||
timed_out: true,
|
||||
duration_ms: 10000,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"command": "sleep 100"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"command": "sleep 100"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let output = result.unwrap();
|
||||
assert!(output.starts_with("Command timed out.\n"));
|
||||
|
|
@ -902,8 +914,8 @@ mod tests {
|
|||
let _result = (tool.executor)(
|
||||
serde_json::json!({"command": "echo $MY_KEY"}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: Some(tool_env.clone()),
|
||||
},
|
||||
)
|
||||
|
|
@ -917,11 +929,14 @@ mod tests {
|
|||
let tool = make_shell_tool();
|
||||
let env = Arc::new(MockSandbox::default());
|
||||
let env_clone: Arc<dyn Sandbox> = env.clone();
|
||||
let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let _result = (tool.executor)(
|
||||
serde_json::json!({"command": "echo hello"}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let captured = env.captured_env_vars.lock().unwrap().clone();
|
||||
assert_eq!(captured, None);
|
||||
|
|
@ -932,10 +947,10 @@ mod tests {
|
|||
let tool = make_web_fetch_tool(None);
|
||||
let env = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: "fetched content".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "fetched content".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -946,8 +961,8 @@ mod tests {
|
|||
let _result = (tool.executor)(
|
||||
serde_json::json!({"url": "https://example.com"}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: Some(tool_env.clone()),
|
||||
},
|
||||
)
|
||||
|
|
@ -966,11 +981,14 @@ mod tests {
|
|||
],
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"pattern": "fn"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("src/main.rs:10:fn main()"));
|
||||
|
|
@ -984,11 +1002,14 @@ mod tests {
|
|||
glob_results: vec!["src/main.rs".into(), "src/lib.rs".into()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"pattern": "src/**/*.rs"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("src/main.rs"));
|
||||
|
|
@ -999,11 +1020,14 @@ mod tests {
|
|||
async fn web_search_missing_api_key_returns_error() {
|
||||
let tool = make_web_search_tool_with_api_key(None);
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
|
||||
let result = (tool.executor)(serde_json::json!({"query": "test"}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({"query": "test"}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
|
|
@ -1016,11 +1040,14 @@ mod tests {
|
|||
async fn web_search_missing_query_returns_error() {
|
||||
let tool = make_web_search_tool_with_api_key(Some("fake-key".into()));
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
|
||||
let result = (tool.executor)(serde_json::json!({}), ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
let result = (tool.executor)(
|
||||
serde_json::json!({}),
|
||||
ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
|
|
@ -1057,10 +1084,10 @@ mod tests {
|
|||
let tool = make_web_fetch_tool(None);
|
||||
let env = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: "<html><body><h1>hello</h1></body></html>".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "<html><body><h1>hello</h1></body></html>".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1069,8 +1096,8 @@ mod tests {
|
|||
let result = (tool.executor)(
|
||||
serde_json::json!({"url": "https://example.com"}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -1127,8 +1154,8 @@ mod tests {
|
|||
let _result = (tool.executor)(
|
||||
serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -1149,8 +1176,8 @@ mod tests {
|
|||
let _result = (tool.executor)(
|
||||
serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}),
|
||||
ToolContext {
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
env: env_clone,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -1169,10 +1196,10 @@ mod tests {
|
|||
let tool = make_web_fetch_tool(None);
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: large_content,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: large_content,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1196,10 +1223,10 @@ mod tests {
|
|||
let tool = make_web_fetch_tool(None);
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: "curl: (6) Could not resolve host".into(),
|
||||
exit_code: 6,
|
||||
timed_out: false,
|
||||
stdout: String::new(),
|
||||
stderr: "curl: (6) Could not resolve host".into(),
|
||||
exit_code: 6,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1236,18 +1263,17 @@ mod tests {
|
|||
client,
|
||||
model_id: ModelHandle::ByName {
|
||||
provider: fabro_model::Provider::Anthropic,
|
||||
model: "mock-model".to_string(),
|
||||
model: "mock-model".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let tool = make_web_fetch_tool(Some(summarizer));
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: "<html><body><p>Lots of content about Rust...</p></body></html>"
|
||||
.into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "<html><body><p>Lots of content about Rust...</p></body></html>".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1273,12 +1299,11 @@ mod tests {
|
|||
let tool = make_web_fetch_tool(None);
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout:
|
||||
"<html><body><p>Rust is a systems programming language.</p></body></html>"
|
||||
.into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "<html><body><p>Rust is a systems programming language.</p></body></html>"
|
||||
.into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1305,14 +1330,15 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn web_fetch_summarizer_routes_to_specified_provider() {
|
||||
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind, SdkError};
|
||||
use fabro_llm::Error as LlmError;
|
||||
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
|
||||
|
||||
use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response};
|
||||
|
||||
// "other_provider" is the default — it rejects all requests.
|
||||
let default_provider: Arc<dyn ProviderAdapter> = Arc::new(MockErrorProvider {
|
||||
error: SdkError::Provider {
|
||||
kind: ProviderErrorKind::NotFound,
|
||||
error: LlmError::Provider {
|
||||
kind: ProviderErrorKind::NotFound,
|
||||
detail: Box::new(ProviderErrorDetail::new(
|
||||
"model not found",
|
||||
"other_provider",
|
||||
|
|
@ -1336,17 +1362,17 @@ mod tests {
|
|||
client,
|
||||
model_id: ModelHandle::ByName {
|
||||
provider: fabro_model::Provider::Anthropic,
|
||||
model: "target-model".to_string(),
|
||||
model: "target-model".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let tool = make_web_fetch_tool(Some(summarizer));
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: "<html><body><p>Page content</p></body></html>".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
stdout: "<html><body><p>Page content</p></body></html>".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
timed_out: false,
|
||||
duration_ms: 100,
|
||||
},
|
||||
..Default::default()
|
||||
|
|
@ -1429,11 +1455,14 @@ mod tests {
|
|||
|
||||
// read_file tool should mark the file as agent-read
|
||||
let tool = make_read_file_tool();
|
||||
(tool.executor)(serde_json::json!({"file_path": "a.ts"}), ToolContext {
|
||||
env: Arc::clone(&env),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
(tool.executor)(
|
||||
serde_json::json!({"file_path": "a.ts"}),
|
||||
ToolContext {
|
||||
env: Arc::clone(&env),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -1458,11 +1487,14 @@ mod tests {
|
|||
|
||||
// grep tool should mark matched files as agent-read
|
||||
let tool = make_grep_tool();
|
||||
(tool.executor)(serde_json::json!({"pattern": "content"}), ToolContext {
|
||||
env: Arc::clone(&env),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
})
|
||||
(tool.executor)(
|
||||
serde_json::json!({"pattern": "content"}),
|
||||
ToolContext {
|
||||
env: Arc::clone(&env),
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use fabro_llm::error::SdkError;
|
||||
use fabro_llm::Error as LlmError;
|
||||
use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AgentError;
|
||||
use crate::error::Error;
|
||||
|
||||
mod system_time_iso8601 {
|
||||
use std::time::SystemTime;
|
||||
|
|
@ -34,36 +34,36 @@ mod system_time_iso8601 {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum Turn {
|
||||
User {
|
||||
content: String,
|
||||
content: String,
|
||||
timestamp: SystemTime,
|
||||
},
|
||||
Assistant {
|
||||
content: String,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
content: String,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
/// Provider-specific content parts (e.g. `OpenAI` reasoning items,
|
||||
/// `Anthropic` thinking blocks with signatures) preserved for
|
||||
/// round-tripping. Reasoning/thinking text is stored here as
|
||||
/// `ContentPart::Thinking`.
|
||||
provider_parts: Vec<ContentPart>,
|
||||
usage: Box<TokenCounts>,
|
||||
response_id: String,
|
||||
timestamp: SystemTime,
|
||||
usage: Box<TokenCounts>,
|
||||
response_id: String,
|
||||
timestamp: SystemTime,
|
||||
},
|
||||
ToolResults {
|
||||
results: Vec<ToolResult>,
|
||||
results: Vec<ToolResult>,
|
||||
timestamp: SystemTime,
|
||||
},
|
||||
/// Injected content sent as a system-role message to the LLM (maps to
|
||||
/// `Role::System`).
|
||||
System {
|
||||
content: String,
|
||||
content: String,
|
||||
timestamp: SystemTime,
|
||||
},
|
||||
/// Injected steering content sent as a user-role message to the LLM (maps
|
||||
/// to `Role::User`). Used to guide the assistant's behavior
|
||||
/// mid-conversation without appearing as actual user input.
|
||||
Steering {
|
||||
content: String,
|
||||
content: String,
|
||||
timestamp: SystemTime,
|
||||
},
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ pub enum AgentEvent {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provider: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
model: Option<String>,
|
||||
},
|
||||
SessionEnded,
|
||||
ProcessingEnd,
|
||||
|
|
@ -111,14 +111,14 @@ pub enum AgentEvent {
|
|||
AssistantTextStart,
|
||||
/// Replaces the current in-progress assistant output buffers.
|
||||
AssistantOutputReplace {
|
||||
text: String,
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reasoning: Option<String>,
|
||||
},
|
||||
AssistantMessage {
|
||||
text: String,
|
||||
model: String,
|
||||
usage: TokenCounts,
|
||||
text: String,
|
||||
model: String,
|
||||
usage: TokenCounts,
|
||||
tool_call_count: usize,
|
||||
},
|
||||
TextDelta {
|
||||
|
|
@ -128,24 +128,24 @@ pub enum AgentEvent {
|
|||
delta: String,
|
||||
},
|
||||
ToolCallStarted {
|
||||
tool_name: String,
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
arguments: serde_json::Value,
|
||||
arguments: serde_json::Value,
|
||||
},
|
||||
ToolCallOutputDelta {
|
||||
delta: String,
|
||||
},
|
||||
ToolCallCompleted {
|
||||
tool_name: String,
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
output: serde_json::Value,
|
||||
is_error: bool,
|
||||
output: serde_json::Value,
|
||||
is_error: bool,
|
||||
},
|
||||
Error {
|
||||
error: AgentError,
|
||||
error: Error,
|
||||
},
|
||||
Warning {
|
||||
kind: String,
|
||||
kind: String,
|
||||
message: String,
|
||||
details: serde_json::Value,
|
||||
},
|
||||
|
|
@ -160,49 +160,49 @@ pub enum AgentEvent {
|
|||
text: String,
|
||||
},
|
||||
CompactionStarted {
|
||||
estimated_tokens: usize,
|
||||
estimated_tokens: usize,
|
||||
context_window_size: usize,
|
||||
},
|
||||
CompactionCompleted {
|
||||
original_turn_count: usize,
|
||||
preserved_turn_count: usize,
|
||||
original_turn_count: usize,
|
||||
preserved_turn_count: usize,
|
||||
summary_token_estimate: usize,
|
||||
tracked_file_count: usize,
|
||||
tracked_file_count: usize,
|
||||
},
|
||||
LlmRetry {
|
||||
provider: String,
|
||||
model: String,
|
||||
attempt: usize,
|
||||
provider: String,
|
||||
model: String,
|
||||
attempt: usize,
|
||||
delay_secs: f64,
|
||||
error: SdkError,
|
||||
error: LlmError,
|
||||
},
|
||||
SubAgentSpawned {
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
task: String,
|
||||
depth: usize,
|
||||
task: String,
|
||||
},
|
||||
SubAgentCompleted {
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
success: bool,
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
success: bool,
|
||||
turns_used: usize,
|
||||
},
|
||||
SubAgentFailed {
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
error: AgentError,
|
||||
depth: usize,
|
||||
error: Error,
|
||||
},
|
||||
SubAgentClosed {
|
||||
agent_id: String,
|
||||
depth: usize,
|
||||
depth: usize,
|
||||
},
|
||||
McpServerReady {
|
||||
server_name: String,
|
||||
tool_count: usize,
|
||||
tool_count: usize,
|
||||
},
|
||||
McpServerFailed {
|
||||
server_name: String,
|
||||
error: String,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -412,10 +412,10 @@ impl AgentEvent {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionEvent {
|
||||
pub event: AgentEvent,
|
||||
pub event: AgentEvent,
|
||||
#[serde(with = "system_time_iso8601")]
|
||||
pub timestamp: SystemTime,
|
||||
pub session_id: String,
|
||||
pub timestamp: SystemTime,
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_session_id: Option<String>,
|
||||
}
|
||||
|
|
@ -427,18 +427,21 @@ mod tests {
|
|||
#[test]
|
||||
fn session_event_construction() {
|
||||
let event = SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_1".into(),
|
||||
parent_session_id: None,
|
||||
};
|
||||
assert!(matches!(event.event, AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}));
|
||||
assert!(matches!(
|
||||
event.event,
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}
|
||||
));
|
||||
assert_eq!(event.session_id, "sess_1");
|
||||
assert_eq!(event.parent_session_id, None);
|
||||
}
|
||||
|
|
@ -446,24 +449,30 @@ mod tests {
|
|||
#[test]
|
||||
fn compaction_events_constructible() {
|
||||
let started = AgentEvent::CompactionStarted {
|
||||
estimated_tokens: 5000,
|
||||
estimated_tokens: 5000,
|
||||
context_window_size: 8000,
|
||||
};
|
||||
assert!(matches!(started, AgentEvent::CompactionStarted {
|
||||
estimated_tokens: 5000,
|
||||
..
|
||||
}));
|
||||
assert!(matches!(
|
||||
started,
|
||||
AgentEvent::CompactionStarted {
|
||||
estimated_tokens: 5000,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let completed = AgentEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
preserved_turn_count: 6,
|
||||
summary_token_estimate: 500,
|
||||
tracked_file_count: 3,
|
||||
};
|
||||
assert!(matches!(completed, AgentEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
..
|
||||
}));
|
||||
preserved_turn_count: 6,
|
||||
summary_token_estimate: 500,
|
||||
tracked_file_count: 3,
|
||||
};
|
||||
assert!(matches!(
|
||||
completed,
|
||||
AgentEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -480,36 +489,39 @@ mod tests {
|
|||
fn subagent_spawned_constructible() {
|
||||
let event = AgentEvent::SubAgentSpawned {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 1,
|
||||
task: "list files".into(),
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::SubAgentSpawned {
|
||||
depth: 1,
|
||||
..
|
||||
}));
|
||||
task: "list files".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
event,
|
||||
AgentEvent::SubAgentSpawned { depth: 1, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_completed_constructible() {
|
||||
let event = AgentEvent::SubAgentCompleted {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 5,
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::SubAgentCompleted {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 5,
|
||||
..
|
||||
}));
|
||||
};
|
||||
assert!(matches!(
|
||||
event,
|
||||
AgentEvent::SubAgentCompleted {
|
||||
success: true,
|
||||
turns_used: 5,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_failed_constructible() {
|
||||
let event = AgentEvent::SubAgentFailed {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
error: AgentError::ToolExecution("timeout".into()),
|
||||
depth: 0,
|
||||
error: Error::ToolExecution("timeout".into()),
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::SubAgentFailed { depth: 0, .. }));
|
||||
}
|
||||
|
|
@ -518,7 +530,7 @@ mod tests {
|
|||
fn subagent_closed_constructible() {
|
||||
let event = AgentEvent::SubAgentClosed {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 2,
|
||||
depth: 2,
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::SubAgentClosed { depth: 2, .. }));
|
||||
}
|
||||
|
|
@ -528,23 +540,23 @@ mod tests {
|
|||
let events = vec![
|
||||
AgentEvent::SubAgentSpawned {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
task: "test".into(),
|
||||
depth: 0,
|
||||
task: "test".into(),
|
||||
},
|
||||
AgentEvent::SubAgentCompleted {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
success: true,
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
success: true,
|
||||
turns_used: 3,
|
||||
},
|
||||
AgentEvent::SubAgentFailed {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
error: AgentError::ToolExecution("oops".into()),
|
||||
depth: 0,
|
||||
error: Error::ToolExecution("oops".into()),
|
||||
},
|
||||
AgentEvent::SubAgentClosed {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
depth: 0,
|
||||
},
|
||||
];
|
||||
let json = serde_json::to_string(&events).unwrap();
|
||||
|
|
@ -555,12 +567,12 @@ mod tests {
|
|||
#[test]
|
||||
fn session_event_serde_round_trip_without_parent_session_id() {
|
||||
let event = SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
model: Some("claude-opus".into()),
|
||||
},
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_42".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_42".into(),
|
||||
parent_session_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
|
|
@ -573,21 +585,24 @@ mod tests {
|
|||
let deserialized: SessionEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.session_id, "sess_42");
|
||||
assert_eq!(deserialized.parent_session_id, None);
|
||||
assert!(matches!(deserialized.event, AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}));
|
||||
assert!(matches!(
|
||||
deserialized.event,
|
||||
AgentEvent::SessionStarted {
|
||||
provider: Some(_),
|
||||
model: Some(_),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_serde_round_trip_with_parent_session_id() {
|
||||
let event = SessionEvent {
|
||||
event: AgentEvent::SessionStarted {
|
||||
event: AgentEvent::SessionStarted {
|
||||
provider: Some("openai".into()),
|
||||
model: Some("gpt-5.4".into()),
|
||||
model: Some("gpt-5.4".into()),
|
||||
},
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_child".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
session_id: "sess_child".into(),
|
||||
parent_session_id: Some("sess_parent".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
|
|
@ -606,19 +621,19 @@ mod tests {
|
|||
fn mcp_server_ready_constructible() {
|
||||
let event = AgentEvent::McpServerReady {
|
||||
server_name: "filesystem".into(),
|
||||
tool_count: 3,
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::McpServerReady {
|
||||
tool_count: 3,
|
||||
..
|
||||
}));
|
||||
};
|
||||
assert!(matches!(
|
||||
event,
|
||||
AgentEvent::McpServerReady { tool_count: 3, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_failed_constructible() {
|
||||
let event = AgentEvent::McpServerFailed {
|
||||
server_name: "broken".into(),
|
||||
error: "connection refused".into(),
|
||||
error: "connection refused".into(),
|
||||
};
|
||||
assert!(
|
||||
matches!(event, AgentEvent::McpServerFailed { server_name, .. } if server_name == "broken")
|
||||
|
|
@ -630,20 +645,20 @@ mod tests {
|
|||
let events = vec![
|
||||
AgentEvent::McpServerReady {
|
||||
server_name: "fs".into(),
|
||||
tool_count: 5,
|
||||
tool_count: 5,
|
||||
},
|
||||
AgentEvent::McpServerFailed {
|
||||
server_name: "bad".into(),
|
||||
error: "timeout".into(),
|
||||
error: "timeout".into(),
|
||||
},
|
||||
];
|
||||
let json = serde_json::to_string(&events).unwrap();
|
||||
let deserialized: Vec<AgentEvent> = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.len(), 2);
|
||||
assert!(matches!(&deserialized[0], AgentEvent::McpServerReady {
|
||||
tool_count: 5,
|
||||
..
|
||||
}));
|
||||
assert!(matches!(
|
||||
&deserialized[0],
|
||||
AgentEvent::McpServerReady { tool_count: 5, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&deserialized[1],
|
||||
AgentEvent::McpServerFailed { .. }
|
||||
|
|
@ -653,16 +668,16 @@ mod tests {
|
|||
#[test]
|
||||
fn agent_event_assistant_message() {
|
||||
let usage = TokenCounts {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_tokens: 80,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_tokens: 80,
|
||||
cache_write_tokens: 10,
|
||||
reasoning_tokens: 20,
|
||||
reasoning_tokens: 20,
|
||||
};
|
||||
let event = AgentEvent::AssistantMessage {
|
||||
text: "Hello".into(),
|
||||
model: "test-model".into(),
|
||||
usage: usage.clone(),
|
||||
text: "Hello".into(),
|
||||
model: "test-model".into(),
|
||||
usage: usage.clone(),
|
||||
tool_call_count: 2,
|
||||
};
|
||||
match &event {
|
||||
|
|
@ -683,7 +698,7 @@ mod tests {
|
|||
#[test]
|
||||
fn agent_event_assistant_output_replace_roundtrip() {
|
||||
let event = AgentEvent::AssistantOutputReplace {
|
||||
text: "Hello again".into(),
|
||||
text: "Hello again".into(),
|
||||
reasoning: Some("Retrying from scratch".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
|
|
@ -702,9 +717,9 @@ mod tests {
|
|||
#[test]
|
||||
fn error_event_serde_roundtrip_with_agent_error() {
|
||||
let event = AgentEvent::Error {
|
||||
error: AgentError::Llm(SdkError::Network {
|
||||
error: Error::Llm(LlmError::Network {
|
||||
message: "refused".into(),
|
||||
source: None,
|
||||
source: None,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
|
|
@ -721,19 +736,19 @@ mod tests {
|
|||
fn llm_retry_event_carries_sdk_error() {
|
||||
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
|
||||
let event = AgentEvent::LlmRetry {
|
||||
provider: "openai".into(),
|
||||
model: "gpt-4".into(),
|
||||
attempt: 1,
|
||||
provider: "openai".into(),
|
||||
model: "gpt-4".into(),
|
||||
attempt: 1,
|
||||
delay_secs: 2.0,
|
||||
error: SdkError::Provider {
|
||||
kind: ProviderErrorKind::RateLimit,
|
||||
error: LlmError::Provider {
|
||||
kind: ProviderErrorKind::RateLimit,
|
||||
detail: Box::new(ProviderErrorDetail {
|
||||
message: "too fast".into(),
|
||||
provider: "openai".into(),
|
||||
message: "too fast".into(),
|
||||
provider: "openai".into(),
|
||||
status_code: Some(429),
|
||||
error_code: None,
|
||||
error_code: None,
|
||||
retry_after: Some(2.0),
|
||||
raw: None,
|
||||
raw: None,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
@ -752,8 +767,8 @@ mod tests {
|
|||
fn subagent_failed_carries_agent_error() {
|
||||
let event = AgentEvent::SubAgentFailed {
|
||||
agent_id: "sa-1".into(),
|
||||
depth: 0,
|
||||
error: AgentError::ToolExecution("cmd failed".into()),
|
||||
depth: 0,
|
||||
error: Error::ToolExecution("cmd failed".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
|
||||
|
|
@ -768,11 +783,11 @@ mod tests {
|
|||
#[test]
|
||||
fn error_event_preserves_error_type_through_json() {
|
||||
let event = AgentEvent::Error {
|
||||
error: AgentError::ToolExecution("cmd failed".into()),
|
||||
error: Error::ToolExecution("cmd failed".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
// The error field should contain the AgentError's tagged type
|
||||
// The error field should contain the Error's tagged type
|
||||
assert_eq!(v["Error"]["error"]["type"], "tool_execution");
|
||||
}
|
||||
|
||||
|
|
@ -780,7 +795,7 @@ mod tests {
|
|||
fn mcp_server_failed_still_string() {
|
||||
let event = AgentEvent::McpServerFailed {
|
||||
server_name: "broken".into(),
|
||||
error: "connection refused".into(),
|
||||
error: "connection refused".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
|
||||
|
|
|
|||
|
|
@ -16,23 +16,23 @@ pub enum Change {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Hunk {
|
||||
pub context_line: String,
|
||||
pub changes: Vec<Change>,
|
||||
pub end_of_file: bool,
|
||||
pub changes: Vec<Change>,
|
||||
pub end_of_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PatchOperation {
|
||||
Add {
|
||||
path: String,
|
||||
path: String,
|
||||
content: String,
|
||||
},
|
||||
Delete {
|
||||
path: String,
|
||||
},
|
||||
Update {
|
||||
path: String,
|
||||
path: String,
|
||||
new_path: Option<String>,
|
||||
hunks: Vec<Hunk>,
|
||||
hunks: Vec<Hunk>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -402,9 +402,9 @@ fn format_patch_error(error: &str, path: &str, content: &str) -> String {
|
|||
pub fn make_apply_patch_tool() -> RegisteredTool {
|
||||
RegisteredTool {
|
||||
definition: ToolDefinition {
|
||||
name: "apply_patch".into(),
|
||||
name: "apply_patch".into(),
|
||||
description: "Apply a v4a format patch to modify files".into(),
|
||||
parameters: serde_json::json!({
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch": {
|
||||
|
|
@ -415,7 +415,7 @@ pub fn make_apply_patch_tool() -> RegisteredTool {
|
|||
"required": ["patch"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(|args, ctx| {
|
||||
executor: Arc::new(|args, ctx| {
|
||||
Box::pin(async move {
|
||||
let patch_text = args
|
||||
.get("patch")
|
||||
|
|
@ -448,10 +448,13 @@ mod tests {
|
|||
|
||||
let ops = parse_v4a_patch(patch).unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0], PatchOperation::Add {
|
||||
path: "src/new_file.rs".into(),
|
||||
content: "fn main() {\n println!(\"hello\");\n}".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
ops[0],
|
||||
PatchOperation::Add {
|
||||
path: "src/new_file.rs".into(),
|
||||
content: "fn main() {\n println!(\"hello\");\n}".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -463,9 +466,12 @@ mod tests {
|
|||
|
||||
let ops = parse_v4a_patch(patch).unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0], PatchOperation::Delete {
|
||||
path: "src/old_file.rs".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
ops[0],
|
||||
PatchOperation::Delete {
|
||||
path: "src/old_file.rs".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -582,21 +588,21 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/game.py".into(),
|
||||
path: "src/game.py".into(),
|
||||
new_path: None,
|
||||
hunks: vec![
|
||||
hunks: vec![
|
||||
Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove("from src.cards import Suit".into()),
|
||||
Change::Add("from src.cards import Card, Suit".into()),
|
||||
],
|
||||
},
|
||||
Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" stock: list = field(default_factory=list)".into()),
|
||||
Change::Remove(" waste: list = field(default_factory=list)".into()),
|
||||
Change::Add(" stock: list[Card] = field(default_factory=list)".into()),
|
||||
|
|
@ -715,12 +721,12 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/lib.rs".into(),
|
||||
path: "src/lib.rs".into(),
|
||||
new_path: None,
|
||||
hunks: vec![Hunk {
|
||||
hunks: vec![Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Context("fn unchanged() {".into()),
|
||||
Change::Remove(" old_line();".into()),
|
||||
Change::Add(" new_line();".into()),
|
||||
|
|
@ -746,21 +752,21 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/lib.rs".into(),
|
||||
path: "src/lib.rs".into(),
|
||||
new_path: None,
|
||||
hunks: vec![
|
||||
hunks: vec![
|
||||
Hunk {
|
||||
context_line: "def setup():".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" old_setup()".into()),
|
||||
Change::Add(" new_setup()".into()),
|
||||
],
|
||||
},
|
||||
Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" old_teardown()".into()),
|
||||
Change::Add(" new_teardown()".into()),
|
||||
],
|
||||
|
|
@ -782,7 +788,7 @@ mod tests {
|
|||
async fn apply_patch_add_file() {
|
||||
let env = MutableMockSandbox::new(HashMap::new());
|
||||
let ops = vec![PatchOperation::Add {
|
||||
path: "src/new.rs".into(),
|
||||
path: "src/new.rs".into(),
|
||||
content: "fn new() {}".into(),
|
||||
}];
|
||||
|
||||
|
|
@ -803,12 +809,12 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/lib.rs".into(),
|
||||
path: "src/lib.rs".into(),
|
||||
new_path: None,
|
||||
hunks: vec![Hunk {
|
||||
hunks: vec![Hunk {
|
||||
context_line: "fn hello() {".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" println!(\"old\");".into()),
|
||||
Change::Add(" println!(\"new\");".into()),
|
||||
],
|
||||
|
|
@ -856,12 +862,12 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/game.py".into(),
|
||||
path: "src/game.py".into(),
|
||||
new_path: None,
|
||||
hunks: vec![Hunk {
|
||||
hunks: vec![Hunk {
|
||||
context_line: "def nonexistent():".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" old_body()".into()),
|
||||
Change::Add(" new_body()".into()),
|
||||
],
|
||||
|
|
@ -882,16 +888,16 @@ mod tests {
|
|||
let hunks = vec![
|
||||
Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" pass".into()),
|
||||
Change::Add(" return 1".into()),
|
||||
],
|
||||
},
|
||||
Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" pass".into()),
|
||||
Change::Add(" return 2".into()),
|
||||
],
|
||||
|
|
@ -976,8 +982,8 @@ mod tests {
|
|||
let content = "def foo():\n pass\n\ndef bar():\n pass";
|
||||
let hunks = vec![Hunk {
|
||||
context_line: String::new(),
|
||||
end_of_file: true,
|
||||
changes: vec![
|
||||
end_of_file: true,
|
||||
changes: vec![
|
||||
Change::Remove(" pass".into()),
|
||||
Change::Add(" return 99".into()),
|
||||
],
|
||||
|
|
@ -1025,12 +1031,12 @@ mod tests {
|
|||
let env = MutableMockSandbox::new(files);
|
||||
|
||||
let ops = vec![PatchOperation::Update {
|
||||
path: "src/old.py".into(),
|
||||
path: "src/old.py".into(),
|
||||
new_path: Some("src/new.py".into()),
|
||||
hunks: vec![Hunk {
|
||||
hunks: vec![Hunk {
|
||||
context_line: "def hello():".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
end_of_file: false,
|
||||
changes: vec![
|
||||
Change::Remove(" pass".into()),
|
||||
Change::Add(" return 1".into()),
|
||||
],
|
||||
|
|
@ -1057,8 +1063,8 @@ mod tests {
|
|||
let content = " indented\nindented";
|
||||
let hunks = vec![Hunk {
|
||||
context_line: "indented".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![Change::Add("extra".into())],
|
||||
end_of_file: false,
|
||||
changes: vec![Change::Add("extra".into())],
|
||||
}];
|
||||
let result = apply_hunks(content, &hunks).unwrap();
|
||||
// Should match line 1 (exact), so "extra" inserted after "indented" (line 1)
|
||||
|
|
@ -1070,8 +1076,8 @@ mod tests {
|
|||
let content = "print(\u{201C}hello\u{201D})";
|
||||
let hunks = vec![Hunk {
|
||||
context_line: "print(\"hello\")".into(),
|
||||
end_of_file: false,
|
||||
changes: vec![Change::Add("print(\"world\")".into())],
|
||||
end_of_file: false,
|
||||
changes: vec![Change::Add("print(\"world\")".into())],
|
||||
}];
|
||||
let result = apply_hunks(content, &hunks).unwrap();
|
||||
// Original line preserved, new line added after
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use tokio::sync::Mutex as AsyncMutex;
|
|||
#[derive(Clone)]
|
||||
struct OpenAiTwinOptions {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
fn summarizer_model_id(provider: Provider) -> ModelHandle {
|
||||
|
|
@ -30,22 +30,22 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle {
|
|||
| Provider::Inception
|
||||
| Provider::OpenAiCompatible => ModelHandle::ByName {
|
||||
provider: Provider::OpenAi,
|
||||
model: "gpt-5.4-mini".to_string(),
|
||||
model: "gpt-5.4-mini".to_string(),
|
||||
},
|
||||
Provider::Gemini => ModelHandle::ByName {
|
||||
provider: Provider::Gemini,
|
||||
model: "gemini-3-flash-preview".to_string(),
|
||||
model: "gemini-3-flash-preview".to_string(),
|
||||
},
|
||||
Provider::Anthropic => ModelHandle::ByName {
|
||||
provider: Provider::Anthropic,
|
||||
model: "claude-haiku-4-5".to_string(),
|
||||
model: "claude-haiku-4-5".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_summarizer(provider: Provider, client: &Client) -> WebFetchSummarizer {
|
||||
WebFetchSummarizer {
|
||||
client: client.clone(),
|
||||
client: client.clone(),
|
||||
model_id: summarizer_model_id(provider),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ use fabro_types::settings::run::{GitAuthorLayer, GitAuthorSettings};
|
|||
/// Resolved git author identity for checkpoint commits.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GitAuthor {
|
||||
pub name: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Default for GitAuthor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Fabro".into(),
|
||||
name: "Fabro".into(),
|
||||
email: "noreply@fabro.sh".into(),
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ impl GitAuthor {
|
|||
pub fn from_options(name: Option<String>, email: Option<String>) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
name: name.unwrap_or(defaults.name),
|
||||
name: name.unwrap_or(defaults.name),
|
||||
email: email.unwrap_or(defaults.email),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ use crate::git::{FileMode, Store, TreeEntries};
|
|||
/// Metadata about a commit, returned by `log`.
|
||||
#[derive(Debug)]
|
||||
pub struct CommitInfo {
|
||||
pub oid: Oid,
|
||||
pub message: String,
|
||||
pub author_name: String,
|
||||
pub oid: Oid,
|
||||
pub message: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub time: git2::Time,
|
||||
pub time: git2::Time,
|
||||
}
|
||||
|
||||
/// Key-value storage on a single git branch. Each write creates one commit.
|
||||
|
|
@ -19,8 +19,8 @@ pub struct CommitInfo {
|
|||
/// the previous.
|
||||
pub struct BranchStore<'a> {
|
||||
objects: &'a Store,
|
||||
branch: String,
|
||||
author: Signature<'static>,
|
||||
branch: String,
|
||||
author: Signature<'static>,
|
||||
}
|
||||
|
||||
impl<'a> BranchStore<'a> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ pub enum Error {
|
|||
|
||||
#[error("reading file {path}: {source}")]
|
||||
ReadFile {
|
||||
path: PathBuf,
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ impl FileMode {
|
|||
/// A single entry in a flat tree map.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeEntry {
|
||||
pub oid: Oid,
|
||||
pub oid: Oid,
|
||||
pub filemode: FileMode,
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ impl Store {
|
|||
/// unix.
|
||||
pub fn write_blob_from_file(&self, path: &Path) -> Result<(Oid, FileMode)> {
|
||||
let content = std::fs::read(path).map_err(|e| Error::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let mode = detect_filemode(path);
|
||||
|
|
@ -250,14 +250,14 @@ fn read_tree_recursive(
|
|||
/// Intermediate structure for building nested git trees from flat paths.
|
||||
struct DirNode {
|
||||
files: BTreeMap<String, TreeEntry>,
|
||||
dirs: BTreeMap<String, Self>,
|
||||
dirs: BTreeMap<String, Self>,
|
||||
}
|
||||
|
||||
impl DirNode {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: BTreeMap::new(),
|
||||
dirs: BTreeMap::new(),
|
||||
dirs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ use crate::git::Store;
|
|||
/// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone.
|
||||
pub struct MetadataStore {
|
||||
repo_path: PathBuf,
|
||||
author: GitAuthor,
|
||||
author: GitAuthor,
|
||||
}
|
||||
|
||||
impl MetadataStore {
|
||||
pub fn new(repo_path: impl Into<PathBuf>, author: &GitAuthor) -> Self {
|
||||
Self {
|
||||
repo_path: repo_path.into(),
|
||||
author: author.clone(),
|
||||
author: author.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,10 +364,11 @@ mod tests {
|
|||
let checkpoint_json =
|
||||
serde_json::to_vec_pretty(&test_checkpoint("node_a", Vec::new(), None)).unwrap();
|
||||
store
|
||||
.write_checkpoint(&run_id, &checkpoint_json, &[(
|
||||
"artifacts/response.plan.json",
|
||||
artifact_data.as_slice(),
|
||||
)])
|
||||
.write_checkpoint(
|
||||
&run_id,
|
||||
&checkpoint_json,
|
||||
&[("artifacts/response.plan.json", artifact_data.as_slice())],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan")
|
||||
|
|
@ -428,10 +429,10 @@ mod tests {
|
|||
let run_id = fixtures::RUN_6.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let start_record = StartRecord {
|
||||
run_id: fixtures::RUN_6,
|
||||
run_id: fixtures::RUN_6,
|
||||
start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(),
|
||||
run_branch: Some("fabro/run/test".to_string()),
|
||||
base_sha: None,
|
||||
base_sha: None,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&start_record).unwrap();
|
||||
store.init_run(&run_id, &[("start.json", &bytes)]).unwrap();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::fmt::Write;
|
|||
|
||||
/// A git commit message trailer (key-value pair).
|
||||
pub struct Trailer<'a> {
|
||||
pub key: &'a str,
|
||||
pub key: &'a str,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
|
|
@ -92,20 +92,26 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn append_to_simple_message() {
|
||||
let result = append("Initial commit", &Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
});
|
||||
let result = append(
|
||||
"Initial commit",
|
||||
&Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
},
|
||||
);
|
||||
assert_eq!(result, "Initial commit\n\nMy-Checkpoint: abc123\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_to_message_with_existing_trailer() {
|
||||
let msg = "Initial commit\n\nSigned-off-by: Alice <alice@example.com>\n";
|
||||
let result = append(msg, &Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
});
|
||||
let result = append(
|
||||
msg,
|
||||
&Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Initial commit\n\nSigned-off-by: Alice <alice@example.com>\nMy-Checkpoint: abc123\n"
|
||||
|
|
@ -115,10 +121,13 @@ mod tests {
|
|||
#[test]
|
||||
fn append_to_message_with_body_no_trailer() {
|
||||
let msg = "Initial commit\n\nThis is a longer description of the change.\n";
|
||||
let result = append(msg, &Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
});
|
||||
let result = append(
|
||||
msg,
|
||||
&Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Initial commit\n\nThis is a longer description of the change.\n\nMy-Checkpoint: abc123\n"
|
||||
|
|
@ -167,16 +176,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn format_message_with_trailers() {
|
||||
let result = format_message("Initial commit", "", &[
|
||||
Trailer {
|
||||
key: "Signed-off-by",
|
||||
value: "Alice",
|
||||
},
|
||||
Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
},
|
||||
]);
|
||||
let result = format_message(
|
||||
"Initial commit",
|
||||
"",
|
||||
&[
|
||||
Trailer {
|
||||
key: "Signed-off-by",
|
||||
value: "Alice",
|
||||
},
|
||||
Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Initial commit\n\nSigned-off-by: Alice\nMy-Checkpoint: abc123\n"
|
||||
|
|
@ -185,10 +198,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn format_message_with_body_and_trailers() {
|
||||
let result = format_message("Initial commit", "Description here", &[Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
}]);
|
||||
let result = format_message(
|
||||
"Initial commit",
|
||||
"Description here",
|
||||
&[Trailer {
|
||||
key: "My-Checkpoint",
|
||||
value: "abc123",
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Initial commit\n\nDescription here\n\nMy-Checkpoint: abc123\n"
|
||||
|
|
|
|||
|
|
@ -261,17 +261,17 @@ pub(crate) struct LogsArgs {
|
|||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run ID prefix or workflow name (most recent run)
|
||||
pub(crate) run: String,
|
||||
pub(crate) run: String,
|
||||
/// Follow log output
|
||||
#[arg(short, long)]
|
||||
pub(crate) follow: bool,
|
||||
/// Logs since timestamp or relative (e.g. "42m", "2h",
|
||||
/// "2026-01-02T13:00:00Z")
|
||||
#[arg(long)]
|
||||
pub(crate) since: Option<String>,
|
||||
pub(crate) since: Option<String>,
|
||||
/// Lines from end (default: all)
|
||||
#[arg(short = 'n', long)]
|
||||
pub(crate) tail: Option<usize>,
|
||||
pub(crate) tail: Option<usize>,
|
||||
/// Formatted colored output with rendered assistant text
|
||||
#[arg(short = 'p', long)]
|
||||
pub(crate) pretty: bool,
|
||||
|
|
@ -403,9 +403,9 @@ pub(crate) struct CpArgs {
|
|||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Source: <run-id>:<path> or local path
|
||||
pub(crate) src: String,
|
||||
pub(crate) src: String,
|
||||
/// Destination: <run-id>:<path> or local path
|
||||
pub(crate) dst: String,
|
||||
pub(crate) dst: String,
|
||||
/// Recurse into directories
|
||||
#[arg(short, long)]
|
||||
pub(crate) recursive: bool,
|
||||
|
|
@ -417,18 +417,18 @@ pub(crate) struct PreviewArgs {
|
|||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
pub(crate) run: String,
|
||||
/// Port number
|
||||
pub(crate) port: u16,
|
||||
pub(crate) port: u16,
|
||||
/// Generate a signed URL (embeds auth token, no headers needed)
|
||||
#[arg(long)]
|
||||
pub(crate) signed: bool,
|
||||
/// Signed URL expiry in seconds (default 3600, requires --signed)
|
||||
#[arg(long, default_value = "3600", requires = "signed")]
|
||||
pub(crate) ttl: i32,
|
||||
pub(crate) ttl: i32,
|
||||
/// Open URL in browser (implies --signed)
|
||||
#[arg(long)]
|
||||
pub(crate) open: bool,
|
||||
pub(crate) open: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
@ -437,10 +437,10 @@ pub(crate) struct SshArgs {
|
|||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
pub(crate) run: String,
|
||||
/// SSH access expiry in minutes (default 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
pub(crate) ttl: f64,
|
||||
pub(crate) ttl: f64,
|
||||
/// Print the SSH command instead of connecting
|
||||
#[arg(long)]
|
||||
pub(crate) print: bool,
|
||||
|
|
@ -452,7 +452,7 @@ pub(crate) struct DiffArgs {
|
|||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run ID or prefix
|
||||
pub(crate) run: String,
|
||||
pub(crate) run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub(crate) node: Option<String>,
|
||||
|
|
@ -492,7 +492,7 @@ pub(crate) struct SecretRmArgs {
|
|||
#[derive(Args)]
|
||||
pub(crate) struct SecretSetArgs {
|
||||
/// Name of the secret
|
||||
pub(crate) key: String,
|
||||
pub(crate) key: String,
|
||||
/// Value to store
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
|
@ -661,7 +661,7 @@ pub(crate) struct PrCreateArgs {
|
|||
pub(crate) run_id: String,
|
||||
/// LLM model for generating PR description
|
||||
#[arg(long)]
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
|
|||
|
|
@ -16,18 +16,18 @@ pub(crate) enum ServerMode {
|
|||
target_override: Option<String>,
|
||||
},
|
||||
ByStorageDir {
|
||||
target_override: Option<String>,
|
||||
target_override: Option<String>,
|
||||
storage_dir_override: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct CommandContext {
|
||||
cwd: PathBuf,
|
||||
cwd: PathBuf,
|
||||
base_config_path: PathBuf,
|
||||
machine_settings: SettingsLayer,
|
||||
cli_settings: CliSettings,
|
||||
server_mode: ServerMode,
|
||||
server: OnceCell<Arc<ServerStoreClient>>,
|
||||
cli_settings: CliSettings,
|
||||
server_mode: ServerMode,
|
||||
server: OnceCell<Arc<ServerStoreClient>>,
|
||||
}
|
||||
|
||||
impl CommandContext {
|
||||
|
|
@ -43,7 +43,7 @@ impl CommandContext {
|
|||
|
||||
pub(crate) fn for_connection(args: &ServerConnectionArgs) -> Result<Self> {
|
||||
Self::new(ServerMode::ByStorageDir {
|
||||
target_override: args.target.server.clone(),
|
||||
target_override: args.target.server.clone(),
|
||||
storage_dir_override: args.storage_dir.clone_path(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,11 +180,11 @@ mod tests {
|
|||
#[test]
|
||||
fn format_candidate_includes_retry() {
|
||||
let entry = super::super::ArtifactEntry {
|
||||
node_slug: "retry_assets".to_string(),
|
||||
retry: 2,
|
||||
stage_id: fabro_types::StageId::new("retry_assets", 2),
|
||||
node_slug: "retry_assets".to_string(),
|
||||
retry: 2,
|
||||
stage_id: fabro_types::StageId::new("retry_assets", 2),
|
||||
relative_path: "assets/retry/report.txt".to_string(),
|
||||
size: 6,
|
||||
size: 6,
|
||||
};
|
||||
|
||||
assert_eq!(format_candidate(&entry), "retry_assets:retry_2");
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ use crate::server_runs::ServerSummaryLookup;
|
|||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub(super) struct ArtifactEntry {
|
||||
#[serde(skip_serializing)]
|
||||
pub(super) stage_id: StageId,
|
||||
pub(super) node_slug: String,
|
||||
pub(super) retry: u32,
|
||||
pub(super) stage_id: StageId,
|
||||
pub(super) node_slug: String,
|
||||
pub(super) retry: u32,
|
||||
pub(super) relative_path: String,
|
||||
pub(super) size: u64,
|
||||
pub(super) size: u64,
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_artifacts(
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ use crate::command_context::CommandContext;
|
|||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) struct DepSpec {
|
||||
pub name: &'static str,
|
||||
command: &'static [&'static str],
|
||||
pub required: bool,
|
||||
pub name: &'static str,
|
||||
command: &'static [&'static str],
|
||||
pub required: bool,
|
||||
pub min_version: Version,
|
||||
pattern: &'static LazyLock<Regex>,
|
||||
pattern: &'static LazyLock<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
@ -43,18 +43,18 @@ static DOT_RE: LazyLock<Regex> =
|
|||
|
||||
pub(crate) const DEP_SPECS: &[DepSpec] = &[
|
||||
DepSpec {
|
||||
name: "openssl",
|
||||
command: &["openssl", "version"],
|
||||
required: true,
|
||||
name: "openssl",
|
||||
command: &["openssl", "version"],
|
||||
required: true,
|
||||
min_version: Version::new(3, 0, 0),
|
||||
pattern: &OPENSSL_RE,
|
||||
pattern: &OPENSSL_RE,
|
||||
},
|
||||
DepSpec {
|
||||
name: "dot",
|
||||
command: &["dot", "-V"],
|
||||
required: false,
|
||||
name: "dot",
|
||||
command: &["dot", "-V"],
|
||||
required: false,
|
||||
min_version: Version::new(2, 0, 0),
|
||||
pattern: &DOT_RE,
|
||||
pattern: &DOT_RE,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -156,31 +156,28 @@ pub(crate) fn check_config(
|
|||
) -> CheckResult {
|
||||
match (settings_path, legacy_paths.is_empty()) {
|
||||
(Some(path), true) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: path.display().to_string(),
|
||||
details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))],
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: path.display().to_string(),
|
||||
details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))],
|
||||
remediation: None,
|
||||
},
|
||||
(Some(path), false) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: path.display().to_string(),
|
||||
details: std::iter::once(CheckDetail::new(format!(
|
||||
"Loaded from {}",
|
||||
path.display()
|
||||
)))
|
||||
.chain(legacy_paths.iter().map(|legacy| {
|
||||
CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display()))
|
||||
}))
|
||||
.collect(),
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: path.display().to_string(),
|
||||
details: std::iter::once(CheckDetail::new(format!("Loaded from {}", path.display())))
|
||||
.chain(legacy_paths.iter().map(|legacy| {
|
||||
CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display()))
|
||||
}))
|
||||
.collect(),
|
||||
remediation: Some("Delete or rename legacy config files".to_string()),
|
||||
},
|
||||
(None, false) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy config files ignored".to_string(),
|
||||
details: legacy_paths
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy config files ignored".to_string(),
|
||||
details: legacy_paths
|
||||
.iter()
|
||||
.map(|legacy| {
|
||||
CheckDetail::new(format!("Found legacy config file {}", legacy.display()))
|
||||
|
|
@ -193,10 +190,10 @@ pub(crate) fn check_config(
|
|||
remediation: Some("Create ~/.fabro/settings.toml".to_string()),
|
||||
},
|
||||
(None, true) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "no settings config file found".to_string(),
|
||||
details: vec![CheckDetail::new(
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "no settings config file found".to_string(),
|
||||
details: vec![CheckDetail::new(
|
||||
"Create ~/.fabro/settings.toml to configure Fabro".to_string(),
|
||||
)],
|
||||
remediation: Some("Create ~/.fabro/settings.toml".to_string()),
|
||||
|
|
@ -207,10 +204,10 @@ pub(crate) fn check_config(
|
|||
fn check_legacy_env(path: Option<PathBuf>) -> CheckResult {
|
||||
match path {
|
||||
Some(path) => CheckResult {
|
||||
name: "Legacy .env".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy secrets file detected".to_string(),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
name: "Legacy .env".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy secrets file detected".to_string(),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
"{} is no longer read by fabro",
|
||||
path.display()
|
||||
))],
|
||||
|
|
@ -220,10 +217,10 @@ fn check_legacy_env(path: Option<PathBuf>) -> CheckResult {
|
|||
),
|
||||
},
|
||||
None => CheckResult {
|
||||
name: "Legacy .env".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "not present".to_string(),
|
||||
details: Vec::new(),
|
||||
name: "Legacy .env".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "not present".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: None,
|
||||
},
|
||||
}
|
||||
|
|
@ -233,20 +230,20 @@ fn check_version_parity(server_version: &str) -> CheckResult {
|
|||
let cli_version = FABRO_VERSION;
|
||||
if server_version == cli_version {
|
||||
CheckResult {
|
||||
name: "Version parity".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: cli_version.to_string(),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
name: "Version parity".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: cli_version.to_string(),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
"CLI and server are both {cli_version}"
|
||||
))],
|
||||
remediation: None,
|
||||
}
|
||||
} else {
|
||||
CheckResult {
|
||||
name: "Version parity".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: format!("CLI {cli_version}, server {server_version}"),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
name: "Version parity".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: format!("CLI {cli_version}, server {server_version}"),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
"CLI version {cli_version} does not match server version {server_version}"
|
||||
))],
|
||||
remediation: Some(
|
||||
|
|
@ -269,15 +266,15 @@ fn convert_diagnostics_sections(sections: Vec<api_types::DiagnosticsSection>) ->
|
|||
sections
|
||||
.into_iter()
|
||||
.map(|section| CheckSection {
|
||||
title: section.title,
|
||||
title: section.title,
|
||||
checks: section
|
||||
.checks
|
||||
.into_iter()
|
||||
.map(|check| CheckResult {
|
||||
name: check.name,
|
||||
status: convert_diagnostics_status(check.status),
|
||||
summary: check.summary,
|
||||
details: check
|
||||
name: check.name,
|
||||
status: convert_diagnostics_status(check.status),
|
||||
summary: check.summary,
|
||||
details: check
|
||||
.details
|
||||
.into_iter()
|
||||
.map(|detail| CheckDetail {
|
||||
|
|
@ -345,9 +342,9 @@ pub(crate) async fn run_doctor(
|
|||
};
|
||||
|
||||
let mut report = CheckReport {
|
||||
title: "Fabro Doctor".to_string(),
|
||||
title: "Fabro Doctor".to_string(),
|
||||
sections: vec![CheckSection {
|
||||
title: "Local".to_string(),
|
||||
title: "Local".to_string(),
|
||||
checks: vec![
|
||||
check_config(
|
||||
settings_config_path
|
||||
|
|
@ -364,12 +361,12 @@ pub(crate) async fn run_doctor(
|
|||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
report.sections.push(CheckSection {
|
||||
title: "Server".to_string(),
|
||||
title: "Server".to_string(),
|
||||
checks: vec![CheckResult {
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "settings resolution failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "settings resolution failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
remediation: Some(
|
||||
"Fix the local CLI settings or provide `--server`, then run doctor again."
|
||||
.to_string(),
|
||||
|
|
@ -394,12 +391,12 @@ pub(crate) async fn run_doctor(
|
|||
Ok(server) => server,
|
||||
Err(err) => {
|
||||
report.sections.push(CheckSection {
|
||||
title: "Server".to_string(),
|
||||
title: "Server".to_string(),
|
||||
checks: vec![CheckResult {
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "unreachable".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "unreachable".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
remediation: Some(
|
||||
"Start or connect to the server with `--server` and run doctor again."
|
||||
.to_string(),
|
||||
|
|
@ -424,12 +421,12 @@ pub(crate) async fn run_doctor(
|
|||
Ok(response) => response.into_inner(),
|
||||
Err(err) => {
|
||||
report.sections.push(CheckSection {
|
||||
title: "Server".to_string(),
|
||||
title: "Server".to_string(),
|
||||
checks: vec![CheckResult {
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "health check failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
name: "Fabro server".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "health check failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
remediation: Some(
|
||||
"Check that the server is reachable and responding to /health.".to_string(),
|
||||
),
|
||||
|
|
@ -462,12 +459,12 @@ pub(crate) async fn run_doctor(
|
|||
}
|
||||
Err(err) => {
|
||||
report.sections.push(CheckSection {
|
||||
title: "Server".to_string(),
|
||||
title: "Server".to_string(),
|
||||
checks: vec![CheckResult {
|
||||
name: "Diagnostics".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "probe failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
name: "Diagnostics".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "probe failed".to_string(),
|
||||
details: vec![CheckDetail::new(err.to_string())],
|
||||
remediation: Some(
|
||||
"Fix the server diagnostics failure and run `fabro doctor` again."
|
||||
.to_string(),
|
||||
|
|
@ -553,14 +550,14 @@ mod tests {
|
|||
#[test]
|
||||
fn render_report_text_without_color_has_no_ansi() {
|
||||
let report = CheckReport {
|
||||
title: "Fabro Doctor".to_string(),
|
||||
title: "Fabro Doctor".to_string(),
|
||||
sections: vec![CheckSection {
|
||||
title: "Local".to_string(),
|
||||
title: "Local".to_string(),
|
||||
checks: vec![CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "loaded".to_string(),
|
||||
details: vec![CheckDetail::new(
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "loaded".to_string(),
|
||||
details: vec![CheckDetail::new(
|
||||
"Loaded from ~/.fabro/settings.toml".into(),
|
||||
)],
|
||||
remediation: None,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
|
|||
}
|
||||
}
|
||||
McpEntryLayer::Http { url, headers, .. } => McpTransport::Http {
|
||||
url: url.as_source(),
|
||||
url: url.as_source(),
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.as_source()))
|
||||
|
|
@ -146,10 +146,10 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<
|
|||
.mcps
|
||||
.values()
|
||||
.map(|server| McpServerSettings {
|
||||
name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
startup_timeout_secs: server.startup_timeout_secs,
|
||||
tool_timeout_secs: server.tool_timeout_secs,
|
||||
tool_timeout_secs: server.tool_timeout_secs,
|
||||
})
|
||||
.collect()
|
||||
} else if let Some(mcps) = cli_settings
|
||||
|
|
@ -171,10 +171,10 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<
|
|||
.mcps
|
||||
.values()
|
||||
.map(|server| McpServerSettings {
|
||||
name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
startup_timeout_secs: server.startup_timeout_secs,
|
||||
tool_timeout_secs: server.tool_timeout_secs,
|
||||
tool_timeout_secs: server.tool_timeout_secs,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ pub(crate) async fn run(
|
|||
|
||||
let ctx = CommandContext::for_target(&args.target)?;
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: SettingsLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: SettingsLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
})?;
|
||||
let client = ctx.server().await?;
|
||||
|
|
@ -47,8 +47,8 @@ pub(crate) async fn run(
|
|||
|
||||
let rendered = client
|
||||
.render_workflow_graph(types::RenderWorkflowGraphRequest {
|
||||
manifest: built.manifest,
|
||||
format: Some(match args.format {
|
||||
manifest: built.manifest,
|
||||
format: Some(match args.format {
|
||||
GraphOutputFormat::Svg => types::RenderWorkflowGraphFormat::Svg,
|
||||
GraphOutputFormat::Png => types::RenderWorkflowGraphFormat::Png,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -952,7 +952,7 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
|
|||
if run_doctor {
|
||||
eprintln!();
|
||||
let doctor_args = DoctorArgs {
|
||||
target: ServerTargetArgs::default(),
|
||||
target: ServerTargetArgs::default(),
|
||||
verbose: true,
|
||||
};
|
||||
let _ = doctor::run_doctor(&doctor_args, true, globals).await?;
|
||||
|
|
|
|||
|
|
@ -21,19 +21,19 @@ enum ModelTestResultKind {
|
|||
|
||||
#[derive(Serialize)]
|
||||
struct ModelTestRow {
|
||||
model: String,
|
||||
model: String,
|
||||
provider: Provider,
|
||||
result: ModelTestResultKind,
|
||||
result: ModelTestResultKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ModelTestOutput {
|
||||
results: Vec<ModelTestRow>,
|
||||
total: usize,
|
||||
results: Vec<ModelTestRow>,
|
||||
total: usize,
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
|
|
@ -145,25 +145,25 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color)
|
|||
let trimmed = status.trim();
|
||||
match result_color {
|
||||
Color::Green => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.clone(),
|
||||
provider: model.provider,
|
||||
result: ModelTestResultKind::Pass,
|
||||
detail: None,
|
||||
error: None,
|
||||
result: ModelTestResultKind::Pass,
|
||||
detail: None,
|
||||
error: None,
|
||||
},
|
||||
Color::Yellow => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.clone(),
|
||||
provider: model.provider,
|
||||
result: ModelTestResultKind::Skip,
|
||||
detail: Some(trimmed.to_string()),
|
||||
error: None,
|
||||
result: ModelTestResultKind::Skip,
|
||||
detail: Some(trimmed.to_string()),
|
||||
error: None,
|
||||
},
|
||||
_ => ModelTestRow {
|
||||
model: model.id.clone(),
|
||||
model: model.id.clone(),
|
||||
provider: model.provider,
|
||||
result: ModelTestResultKind::Fail,
|
||||
detail: None,
|
||||
error: Some(
|
||||
result: ModelTestResultKind::Fail,
|
||||
detail: None,
|
||||
error: Some(
|
||||
trimmed
|
||||
.strip_prefix("error: ")
|
||||
.unwrap_or(trimmed)
|
||||
|
|
@ -435,19 +435,19 @@ mod tests {
|
|||
display_name: format!("{id} display"),
|
||||
limits: ModelLimits {
|
||||
context_window: 128_000,
|
||||
max_output: Some(4096),
|
||||
max_output: Some(4096),
|
||||
},
|
||||
training: None,
|
||||
knowledge_cutoff: None,
|
||||
features: ModelFeatures {
|
||||
tools: true,
|
||||
vision: false,
|
||||
tools: true,
|
||||
vision: false,
|
||||
reasoning: false,
|
||||
effort: false,
|
||||
effort: false,
|
||||
},
|
||||
costs: ModelCosts {
|
||||
input_cost_per_mtok: Some(1.0),
|
||||
output_cost_per_mtok: Some(2.0),
|
||||
input_cost_per_mtok: Some(1.0),
|
||||
output_cost_per_mtok: Some(2.0),
|
||||
cache_input_cost_per_mtok: None,
|
||||
},
|
||||
estimated_output_tps: Some(100.0),
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ use crate::shared::print_json_pretty;
|
|||
struct PrRow {
|
||||
run_id: String,
|
||||
number: u64,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
state: String,
|
||||
title: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub(super) async fn list_command(
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an
|
|||
args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose;
|
||||
|
||||
let manifest = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: preflight_args_layer(&args)?,
|
||||
args: preflight_manifest_args(&args),
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: preflight_args_layer(&args)?,
|
||||
args: preflight_manifest_args(&args),
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
})?;
|
||||
let client = ctx.server().await?;
|
||||
|
|
|
|||
|
|
@ -108,10 +108,10 @@ pub(crate) async fn attach_run_with_client(
|
|||
}
|
||||
|
||||
struct AttachOptions {
|
||||
auto_approve: bool,
|
||||
verbose: bool,
|
||||
auto_approve: bool,
|
||||
verbose: bool,
|
||||
kill_on_detach: bool,
|
||||
json_output: bool,
|
||||
json_output: bool,
|
||||
}
|
||||
|
||||
fn replay_run_with_client(
|
||||
|
|
@ -276,7 +276,7 @@ fn api_question_to_question(question: &types::ApiQuestion) -> Question {
|
|||
.options
|
||||
.iter()
|
||||
.map(|option| QuestionOption {
|
||||
key: option.key.clone(),
|
||||
key: option.key.clone(),
|
||||
label: option.label.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -552,14 +552,14 @@ mod tests {
|
|||
#[test]
|
||||
fn answer_requires_reattach_for_interrupted_and_skipped_answers() {
|
||||
let interrupted = Answer {
|
||||
value: AnswerValue::Interrupted,
|
||||
value: AnswerValue::Interrupted,
|
||||
selected_option: None,
|
||||
text: None,
|
||||
text: None,
|
||||
};
|
||||
let skipped = Answer {
|
||||
value: AnswerValue::Skipped,
|
||||
value: AnswerValue::Skipped,
|
||||
selected_option: None,
|
||||
text: None,
|
||||
text: None,
|
||||
};
|
||||
let answered = Answer::yes();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ use crate::shared::{print_json_pretty, split_run_path};
|
|||
#[derive(Debug)]
|
||||
enum CopyDirection {
|
||||
Download {
|
||||
run_prefix: String,
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
local_path: PathBuf,
|
||||
local_path: PathBuf,
|
||||
},
|
||||
Upload {
|
||||
local_path: PathBuf,
|
||||
run_prefix: String,
|
||||
local_path: PathBuf,
|
||||
run_prefix: String,
|
||||
remote_path: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -98,13 +98,13 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
|
|||
|
||||
match (src_parts, dst_parts) {
|
||||
(Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download {
|
||||
run_prefix: run_prefix.to_string(),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
local_path: PathBuf::from(dst),
|
||||
local_path: PathBuf::from(dst),
|
||||
}),
|
||||
(None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload {
|
||||
local_path: PathBuf::from(src),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
local_path: PathBuf::from(src),
|
||||
run_prefix: run_prefix.to_string(),
|
||||
remote_path: remote_path.to_string(),
|
||||
}),
|
||||
(Some(_), Some(_)) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manife
|
|||
use crate::user_config::{self, ServerTarget};
|
||||
|
||||
pub(crate) struct CreatedRun {
|
||||
pub(crate) run_id: RunId,
|
||||
pub(crate) run_id: RunId,
|
||||
pub(crate) local_run_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,14 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs)
|
|||
.as_deref()
|
||||
.map(str::parse::<RewindTarget>)
|
||||
.transpose()?;
|
||||
let new_run_id = fork(&store, &ForkRunInput {
|
||||
source_run_id: run_id,
|
||||
target,
|
||||
push: !args.no_push,
|
||||
})?;
|
||||
let new_run_id = fork(
|
||||
&store,
|
||||
&ForkRunInput {
|
||||
source_run_id: run_id,
|
||||
target,
|
||||
push: !args.no_push,
|
||||
},
|
||||
)?;
|
||||
|
||||
let run_id_string = run_id.to_string();
|
||||
let new_run_id_string = new_run_id.to_string();
|
||||
|
|
|
|||
|
|
@ -67,19 +67,19 @@ pub(crate) fn print_preflight_workflow_summary(
|
|||
|
||||
fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic {
|
||||
fabro_validate::Diagnostic {
|
||||
rule: diagnostic.rule.clone(),
|
||||
rule: diagnostic.rule.clone(),
|
||||
severity: match diagnostic.severity {
|
||||
types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error,
|
||||
types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning,
|
||||
types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info,
|
||||
},
|
||||
message: diagnostic.message.clone(),
|
||||
node_id: diagnostic.node_id.clone(),
|
||||
edge: diagnostic
|
||||
message: diagnostic.message.clone(),
|
||||
node_id: diagnostic.node_id.clone(),
|
||||
edge: diagnostic
|
||||
.edge
|
||||
.as_ref()
|
||||
.map(|edge| (edge[0].clone(), edge[1].clone())),
|
||||
fix: diagnostic.fix.clone(),
|
||||
fix: diagnostic.fix.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,24 +91,24 @@ pub(crate) fn api_diagnostics_to_local(
|
|||
|
||||
pub(crate) fn api_check_report_to_local(report: &types::PreflightCheckReport) -> CheckReport {
|
||||
CheckReport {
|
||||
title: report.title.clone(),
|
||||
title: report.title.clone(),
|
||||
sections: report
|
||||
.sections
|
||||
.iter()
|
||||
.map(|section| CheckSection {
|
||||
title: section.title.clone(),
|
||||
title: section.title.clone(),
|
||||
checks: section
|
||||
.checks
|
||||
.iter()
|
||||
.map(|check| CheckResult {
|
||||
name: check.name.clone(),
|
||||
status: match check.status {
|
||||
name: check.name.clone(),
|
||||
status: match check.status {
|
||||
types::PreflightCheckResultStatus::Pass => CheckStatus::Pass,
|
||||
types::PreflightCheckResultStatus::Warning => CheckStatus::Warning,
|
||||
types::PreflightCheckResultStatus::Error => CheckStatus::Error,
|
||||
},
|
||||
summary: check.summary.clone(),
|
||||
details: check
|
||||
summary: check.summary.clone(),
|
||||
details: check
|
||||
.details
|
||||
.iter()
|
||||
.map(|detail| CheckDetail {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option<RunMod
|
|||
return None;
|
||||
}
|
||||
Some(RunModelLayer {
|
||||
provider: provider.map(InterpString::parse),
|
||||
name: model.map(InterpString::parse),
|
||||
provider: provider.map(InterpString::parse),
|
||||
name: model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ fn execution_layer(
|
|||
return None;
|
||||
}
|
||||
Some(RunExecutionLayer {
|
||||
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
|
|
@ -67,7 +67,7 @@ fn execution_layer(
|
|||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
retros: no_retro.map(|nr| !nr),
|
||||
retros: no_retro.map(|nr| !nr),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ use crate::shared::{color_if, print_json_pretty};
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TimelineEntryJson {
|
||||
ordinal: usize,
|
||||
node_name: String,
|
||||
visit: usize,
|
||||
ordinal: usize,
|
||||
node_name: String,
|
||||
visit: usize,
|
||||
run_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -54,11 +54,14 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs
|
|||
|
||||
let target = args.target.as_deref().unwrap().parse::<RewindTarget>()?;
|
||||
|
||||
rewind(&store, &RewindInput {
|
||||
run_id,
|
||||
target: target.clone(),
|
||||
push: !args.no_push,
|
||||
})?;
|
||||
rewind(
|
||||
&store,
|
||||
&RewindInput {
|
||||
run_id,
|
||||
target: target.clone(),
|
||||
push: !args.no_push,
|
||||
},
|
||||
)?;
|
||||
let entry = timeline.resolve(&target)?;
|
||||
reset_rewound_run_state(lookup.client(), &store, &run_id, entry).await?;
|
||||
|
||||
|
|
@ -84,9 +87,9 @@ pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec<TimelineEntry
|
|||
.entries
|
||||
.iter()
|
||||
.map(|entry| TimelineEntryJson {
|
||||
ordinal: entry.ordinal,
|
||||
node_name: entry.node_name.clone(),
|
||||
visit: entry.visit,
|
||||
ordinal: entry.ordinal,
|
||||
node_name: entry.node_name.clone(),
|
||||
visit: entry.visit,
|
||||
run_commit_sha: entry.run_commit_sha.clone(),
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -7,18 +7,18 @@ use serde_json::Value;
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ProgressUsage {
|
||||
pub(super) input_tokens: u64,
|
||||
pub(super) input_tokens: u64,
|
||||
pub(super) output_tokens: u64,
|
||||
pub(super) cost: Option<f64>,
|
||||
pub(super) cost: Option<f64>,
|
||||
}
|
||||
|
||||
impl ProgressUsage {
|
||||
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option<Self> {
|
||||
let tokens = usage.tokens();
|
||||
Some(Self {
|
||||
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
|
||||
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
|
||||
output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?,
|
||||
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
|
||||
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +35,8 @@ impl ProgressUsage {
|
|||
pub(super) enum ProgressEvent {
|
||||
WorkflowStarted {
|
||||
worktree_dir: Option<String>,
|
||||
base_branch: Option<String>,
|
||||
base_sha: Option<String>,
|
||||
base_branch: Option<String>,
|
||||
base_sha: Option<String>,
|
||||
},
|
||||
WorkingDirectorySet {
|
||||
working_directory: String,
|
||||
|
|
@ -45,12 +45,12 @@ pub(super) enum ProgressEvent {
|
|||
provider: String,
|
||||
},
|
||||
SandboxReady {
|
||||
provider: String,
|
||||
provider: String,
|
||||
duration_ms: u64,
|
||||
name: Option<String>,
|
||||
cpu: Option<f64>,
|
||||
memory: Option<f64>,
|
||||
url: Option<String>,
|
||||
name: Option<String>,
|
||||
cpu: Option<f64>,
|
||||
memory: Option<f64>,
|
||||
url: Option<String>,
|
||||
},
|
||||
SshAccessReady {
|
||||
ssh_command: String,
|
||||
|
|
@ -62,98 +62,98 @@ pub(super) enum ProgressEvent {
|
|||
duration_ms: u64,
|
||||
},
|
||||
SetupCommandCompleted {
|
||||
command: String,
|
||||
command: String,
|
||||
command_index: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
CliEnsureStarted {
|
||||
cli_name: String,
|
||||
},
|
||||
CliEnsureCompleted {
|
||||
cli_name: String,
|
||||
cli_name: String,
|
||||
already_installed: bool,
|
||||
duration_ms: u64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
CliEnsureFailed {
|
||||
cli_name: String,
|
||||
},
|
||||
DevcontainerResolved {
|
||||
dockerfile_lines: u64,
|
||||
environment_count: u64,
|
||||
dockerfile_lines: u64,
|
||||
environment_count: u64,
|
||||
lifecycle_command_count: u64,
|
||||
workspace_folder: String,
|
||||
workspace_folder: String,
|
||||
},
|
||||
DevcontainerLifecycleStarted {
|
||||
phase: String,
|
||||
phase: String,
|
||||
command_count: u64,
|
||||
},
|
||||
DevcontainerLifecycleCompleted {
|
||||
phase: String,
|
||||
phase: String,
|
||||
duration_ms: u64,
|
||||
},
|
||||
DevcontainerLifecycleFailed {
|
||||
phase: String,
|
||||
command: String,
|
||||
phase: String,
|
||||
command: String,
|
||||
exit_code: i64,
|
||||
stderr: String,
|
||||
stderr: String,
|
||||
},
|
||||
DevcontainerLifecycleCommandCompleted {
|
||||
command: String,
|
||||
command: String,
|
||||
command_index: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
StageStarted {
|
||||
node_id: String,
|
||||
name: String,
|
||||
script: Option<String>,
|
||||
name: String,
|
||||
script: Option<String>,
|
||||
},
|
||||
StageCompleted {
|
||||
node_id: String,
|
||||
name: String,
|
||||
node_id: String,
|
||||
name: String,
|
||||
duration_ms: u64,
|
||||
status: String,
|
||||
usage: Option<ProgressUsage>,
|
||||
status: String,
|
||||
usage: Option<ProgressUsage>,
|
||||
},
|
||||
StageFailed {
|
||||
node_id: String,
|
||||
name: String,
|
||||
error: String,
|
||||
name: String,
|
||||
error: String,
|
||||
},
|
||||
StageRetrying {
|
||||
name: String,
|
||||
attempt: u64,
|
||||
name: String,
|
||||
attempt: u64,
|
||||
max_attempts: u64,
|
||||
delay_ms: u64,
|
||||
delay_ms: u64,
|
||||
},
|
||||
ParallelStarted,
|
||||
ParallelBranchStarted {
|
||||
branch: String,
|
||||
},
|
||||
ParallelBranchCompleted {
|
||||
branch: String,
|
||||
branch: String,
|
||||
duration_ms: u64,
|
||||
status: String,
|
||||
status: String,
|
||||
},
|
||||
ParallelCompleted,
|
||||
AssistantMessage {
|
||||
stage_node_id: String,
|
||||
model: String,
|
||||
model: String,
|
||||
},
|
||||
ToolCallStarted {
|
||||
stage_node_id: String,
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
arguments: Value,
|
||||
timestamp: Option<DateTime<Utc>>,
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
arguments: Value,
|
||||
timestamp: Option<DateTime<Utc>>,
|
||||
},
|
||||
ToolCallCompleted {
|
||||
stage_node_id: String,
|
||||
tool_call_id: String,
|
||||
is_error: bool,
|
||||
duration_ms: Option<u64>,
|
||||
timestamp: Option<DateTime<Utc>>,
|
||||
tool_call_id: String,
|
||||
is_error: bool,
|
||||
duration_ms: Option<u64>,
|
||||
timestamp: Option<DateTime<Utc>>,
|
||||
},
|
||||
ContextWindowWarning {
|
||||
stage_node_id: String,
|
||||
|
|
@ -163,38 +163,38 @@ pub(super) enum ProgressEvent {
|
|||
stage_node_id: String,
|
||||
},
|
||||
CompactionCompleted {
|
||||
stage_node_id: String,
|
||||
original_turn_count: u64,
|
||||
stage_node_id: String,
|
||||
original_turn_count: u64,
|
||||
preserved_turn_count: u64,
|
||||
tracked_file_count: u64,
|
||||
tracked_file_count: u64,
|
||||
},
|
||||
LlmRetry {
|
||||
stage_node_id: String,
|
||||
model: String,
|
||||
attempt: u64,
|
||||
delay_ms: u64,
|
||||
error: String,
|
||||
model: String,
|
||||
attempt: u64,
|
||||
delay_ms: u64,
|
||||
error: String,
|
||||
},
|
||||
SubagentSpawned {
|
||||
stage_node_id: String,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
},
|
||||
SubagentCompleted {
|
||||
stage_node_id: String,
|
||||
agent_id: String,
|
||||
success: bool,
|
||||
turns_used: u64,
|
||||
agent_id: String,
|
||||
success: bool,
|
||||
turns_used: u64,
|
||||
},
|
||||
EdgeSelected {
|
||||
from_node: String,
|
||||
to_node: String,
|
||||
label: Option<String>,
|
||||
to_node: String,
|
||||
label: Option<String>,
|
||||
condition: Option<String>,
|
||||
},
|
||||
LoopRestart {
|
||||
from_node: String,
|
||||
to_node: String,
|
||||
to_node: String,
|
||||
},
|
||||
RetroStarted,
|
||||
RetroCompleted {
|
||||
|
|
@ -204,13 +204,13 @@ pub(super) enum ProgressEvent {
|
|||
duration_ms: u64,
|
||||
},
|
||||
RunNotice {
|
||||
level: RunNoticeLevel,
|
||||
code: String,
|
||||
level: RunNoticeLevel,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
PullRequestCreated {
|
||||
pr_url: String,
|
||||
draft: bool,
|
||||
draft: bool,
|
||||
},
|
||||
PullRequestFailed {
|
||||
error: String,
|
||||
|
|
@ -224,8 +224,8 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
match &stored.body {
|
||||
EventBody::RunStarted(props) => Some(ProgressEvent::WorkflowStarted {
|
||||
worktree_dir: props.worktree_dir.clone(),
|
||||
base_branch: props.base_branch.clone(),
|
||||
base_sha: props.base_sha.clone(),
|
||||
base_branch: props.base_branch.clone(),
|
||||
base_sha: props.base_sha.clone(),
|
||||
}),
|
||||
EventBody::SandboxInitialized(props) => Some(ProgressEvent::WorkingDirectorySet {
|
||||
working_directory: props.working_directory.clone(),
|
||||
|
|
@ -234,12 +234,12 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
provider: props.provider.clone(),
|
||||
}),
|
||||
EventBody::SandboxReady(props) => Some(ProgressEvent::SandboxReady {
|
||||
provider: props.provider.clone(),
|
||||
provider: props.provider.clone(),
|
||||
duration_ms: props.duration_ms,
|
||||
name: props.name.clone(),
|
||||
cpu: props.cpu,
|
||||
memory: props.memory,
|
||||
url: props.url.clone(),
|
||||
name: props.name.clone(),
|
||||
cpu: props.cpu,
|
||||
memory: props.memory,
|
||||
url: props.url.clone(),
|
||||
}),
|
||||
EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady {
|
||||
ssh_command: props.ssh_command.clone(),
|
||||
|
|
@ -251,54 +251,54 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
duration_ms: props.duration_ms,
|
||||
}),
|
||||
EventBody::SetupCommandCompleted(props) => Some(ProgressEvent::SetupCommandCompleted {
|
||||
command: props.command.clone(),
|
||||
command: props.command.clone(),
|
||||
command_index: props.index as u64,
|
||||
exit_code: i64::from(props.exit_code),
|
||||
duration_ms: props.duration_ms,
|
||||
exit_code: i64::from(props.exit_code),
|
||||
duration_ms: props.duration_ms,
|
||||
}),
|
||||
EventBody::CliEnsureStarted(props) => Some(ProgressEvent::CliEnsureStarted {
|
||||
cli_name: props.cli_name.clone(),
|
||||
}),
|
||||
EventBody::CliEnsureCompleted(props) => Some(ProgressEvent::CliEnsureCompleted {
|
||||
cli_name: props.cli_name.clone(),
|
||||
cli_name: props.cli_name.clone(),
|
||||
already_installed: props.already_installed,
|
||||
duration_ms: props.duration_ms,
|
||||
duration_ms: props.duration_ms,
|
||||
}),
|
||||
EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed {
|
||||
cli_name: props.cli_name.clone(),
|
||||
}),
|
||||
EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved {
|
||||
dockerfile_lines: props.dockerfile_lines as u64,
|
||||
environment_count: props.environment_count as u64,
|
||||
dockerfile_lines: props.dockerfile_lines as u64,
|
||||
environment_count: props.environment_count as u64,
|
||||
lifecycle_command_count: props.lifecycle_command_count as u64,
|
||||
workspace_folder: props.workspace_folder.clone(),
|
||||
workspace_folder: props.workspace_folder.clone(),
|
||||
}),
|
||||
EventBody::DevcontainerLifecycleStarted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleStarted {
|
||||
phase: props.phase.clone(),
|
||||
phase: props.phase.clone(),
|
||||
command_count: props.command_count as u64,
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleCompleted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleCompleted {
|
||||
phase: props.phase.clone(),
|
||||
phase: props.phase.clone(),
|
||||
duration_ms: props.duration_ms,
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleFailed(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleFailed {
|
||||
phase: props.phase.clone(),
|
||||
command: props.command.clone(),
|
||||
phase: props.phase.clone(),
|
||||
command: props.command.clone(),
|
||||
exit_code: i64::from(props.exit_code),
|
||||
stderr: props.stderr.clone(),
|
||||
stderr: props.stderr.clone(),
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleCommandCompleted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleCommandCompleted {
|
||||
command: props.command.clone(),
|
||||
command: props.command.clone(),
|
||||
command_index: props.index as u64,
|
||||
exit_code: i64::from(props.exit_code),
|
||||
duration_ms: props.duration_ms,
|
||||
exit_code: i64::from(props.exit_code),
|
||||
duration_ms: props.duration_ms,
|
||||
})
|
||||
}
|
||||
EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted {
|
||||
|
|
@ -325,38 +325,38 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
),
|
||||
}),
|
||||
EventBody::StageRetrying(props) => Some(ProgressEvent::StageRetrying {
|
||||
name: node_label,
|
||||
attempt: props.attempt as u64,
|
||||
name: node_label,
|
||||
attempt: props.attempt as u64,
|
||||
max_attempts: props.max_attempts as u64,
|
||||
delay_ms: props.delay_ms,
|
||||
delay_ms: props.delay_ms,
|
||||
}),
|
||||
EventBody::ParallelStarted(_) => Some(ProgressEvent::ParallelStarted),
|
||||
EventBody::ParallelBranchStarted(_) => {
|
||||
Some(ProgressEvent::ParallelBranchStarted { branch: node_id })
|
||||
}
|
||||
EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted {
|
||||
branch: node_id,
|
||||
branch: node_id,
|
||||
duration_ms: props.duration_ms,
|
||||
status: props.status.clone(),
|
||||
status: props.status.clone(),
|
||||
}),
|
||||
EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted),
|
||||
EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage {
|
||||
stage_node_id: node_id,
|
||||
model: props.model.clone(),
|
||||
model: props.model.clone(),
|
||||
}),
|
||||
EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted {
|
||||
stage_node_id: node_id,
|
||||
tool_name: props.tool_name.clone(),
|
||||
tool_call_id: props.tool_call_id.clone(),
|
||||
arguments: props.arguments.clone(),
|
||||
timestamp: Some(stored.ts),
|
||||
tool_name: props.tool_name.clone(),
|
||||
tool_call_id: props.tool_call_id.clone(),
|
||||
arguments: props.arguments.clone(),
|
||||
timestamp: Some(stored.ts),
|
||||
}),
|
||||
EventBody::AgentToolCompleted(props) => Some(ProgressEvent::ToolCallCompleted {
|
||||
stage_node_id: node_id,
|
||||
tool_call_id: props.tool_call_id.clone(),
|
||||
is_error: props.is_error,
|
||||
duration_ms: None,
|
||||
timestamp: Some(stored.ts),
|
||||
tool_call_id: props.tool_call_id.clone(),
|
||||
is_error: props.is_error,
|
||||
duration_ms: None,
|
||||
timestamp: Some(stored.ts),
|
||||
}),
|
||||
EventBody::AgentWarning(props) if props.kind == "context_window" => {
|
||||
let usage_percent = props
|
||||
|
|
@ -374,10 +374,10 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
stage_node_id: node_id,
|
||||
}),
|
||||
EventBody::AgentCompactionCompleted(props) => Some(ProgressEvent::CompactionCompleted {
|
||||
stage_node_id: node_id,
|
||||
original_turn_count: props.original_turn_count as u64,
|
||||
stage_node_id: node_id,
|
||||
original_turn_count: props.original_turn_count as u64,
|
||||
preserved_turn_count: props.preserved_turn_count as u64,
|
||||
tracked_file_count: props.tracked_file_count as u64,
|
||||
tracked_file_count: props.tracked_file_count as u64,
|
||||
}),
|
||||
EventBody::AgentLlmRetry(props) => {
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
|
|
@ -392,24 +392,24 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
}
|
||||
EventBody::AgentSubSpawned(props) => Some(ProgressEvent::SubagentSpawned {
|
||||
stage_node_id: node_id,
|
||||
agent_id: props.agent_id.clone(),
|
||||
task: props.task.clone(),
|
||||
agent_id: props.agent_id.clone(),
|
||||
task: props.task.clone(),
|
||||
}),
|
||||
EventBody::AgentSubCompleted(props) => Some(ProgressEvent::SubagentCompleted {
|
||||
stage_node_id: node_id,
|
||||
agent_id: props.agent_id.clone(),
|
||||
success: props.success,
|
||||
turns_used: props.turns_used as u64,
|
||||
agent_id: props.agent_id.clone(),
|
||||
success: props.success,
|
||||
turns_used: props.turns_used as u64,
|
||||
}),
|
||||
EventBody::EdgeSelected(props) => Some(ProgressEvent::EdgeSelected {
|
||||
from_node: props.from_node.clone(),
|
||||
to_node: props.to_node.clone(),
|
||||
label: props.label.clone(),
|
||||
to_node: props.to_node.clone(),
|
||||
label: props.label.clone(),
|
||||
condition: props.condition.clone(),
|
||||
}),
|
||||
EventBody::LoopRestart(props) => Some(ProgressEvent::LoopRestart {
|
||||
from_node: props.from_node.clone(),
|
||||
to_node: props.to_node.clone(),
|
||||
to_node: props.to_node.clone(),
|
||||
}),
|
||||
EventBody::RetroStarted(_) => Some(ProgressEvent::RetroStarted),
|
||||
EventBody::RetroCompleted(props) => Some(ProgressEvent::RetroCompleted {
|
||||
|
|
@ -419,13 +419,13 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
duration_ms: props.duration_ms,
|
||||
}),
|
||||
EventBody::RunNotice(props) => Some(ProgressEvent::RunNotice {
|
||||
level: props.level,
|
||||
code: props.code.clone(),
|
||||
level: props.level,
|
||||
code: props.code.clone(),
|
||||
message: props.message.clone(),
|
||||
}),
|
||||
EventBody::PullRequestCreated(props) => Some(ProgressEvent::PullRequestCreated {
|
||||
pr_url: props.pr_url.clone(),
|
||||
draft: props.draft,
|
||||
draft: props.draft,
|
||||
}),
|
||||
EventBody::PullRequestFailed(props) => Some(ProgressEvent::PullRequestFailed {
|
||||
error: props.error.clone(),
|
||||
|
|
@ -477,17 +477,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_edge_selected() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::EdgeSelected {
|
||||
from_node: "a".into(),
|
||||
to_node: "b".into(),
|
||||
label: Some("yes".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
});
|
||||
let stored = to_run_event(
|
||||
&fixtures::RUN_1,
|
||||
&Event::EdgeSelected {
|
||||
from_node: "a".into(),
|
||||
to_node: "b".into(),
|
||||
label: Some("yes".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
},
|
||||
);
|
||||
|
||||
let event = from_run_event(&stored).unwrap();
|
||||
assert!(matches!(
|
||||
|
|
@ -542,14 +545,14 @@ mod tests {
|
|||
#[test]
|
||||
fn round_trip_agent_tool_call() {
|
||||
let event = Event::Agent {
|
||||
stage: "code".into(),
|
||||
visit: 1,
|
||||
event: AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
stage: "code".into(),
|
||||
visit: 1,
|
||||
event: AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
},
|
||||
session_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
};
|
||||
|
||||
|
|
@ -631,12 +634,12 @@ mod tests {
|
|||
fn round_trip_sandbox_ready() {
|
||||
let event = Event::Sandbox {
|
||||
event: fabro_agent::SandboxEvent::Ready {
|
||||
provider: "daytona".into(),
|
||||
provider: "daytona".into(),
|
||||
duration_ms: 2500,
|
||||
name: Some("sandbox-1".into()),
|
||||
cpu: Some(4.0),
|
||||
memory: Some(8.0),
|
||||
url: Some("https://example.test".into()),
|
||||
name: Some("sandbox-1".into()),
|
||||
cpu: Some(4.0),
|
||||
memory: Some(8.0),
|
||||
url: Some("https://example.test".into()),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -656,8 +659,8 @@ mod tests {
|
|||
#[test]
|
||||
fn round_trip_run_notice() {
|
||||
let event = Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "sandbox_cleanup_failed".into(),
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "sandbox_cleanup_failed".into(),
|
||||
message: "sandbox cleanup failed".into(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ use stage_display::StageDisplay;
|
|||
|
||||
pub(crate) struct ProgressUI {
|
||||
renderer: ProgressRenderer,
|
||||
stage: StageDisplay,
|
||||
setup: SetupDisplay,
|
||||
info: InfoDisplay,
|
||||
stage: StageDisplay,
|
||||
setup: SetupDisplay,
|
||||
info: InfoDisplay,
|
||||
}
|
||||
|
||||
impl ProgressUI {
|
||||
|
|
@ -484,22 +484,25 @@ mod tests {
|
|||
|
||||
fn stage_started(node_id: &str, name: &str) -> Event {
|
||||
Event::StageStarted {
|
||||
node_id: node_id.into(),
|
||||
name: name.into(),
|
||||
index: 0,
|
||||
node_id: node_id.into(),
|
||||
name: name.into(),
|
||||
index: 0,
|
||||
handler_type: String::new(),
|
||||
attempt: 1,
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_message(stage: &str, model: &str) -> Event {
|
||||
agent_event(stage, AgentEvent::AssistantMessage {
|
||||
text: "done".into(),
|
||||
model: model.into(),
|
||||
usage: TokenCounts::default(),
|
||||
tool_call_count: 0,
|
||||
})
|
||||
agent_event(
|
||||
stage,
|
||||
AgentEvent::AssistantMessage {
|
||||
text: "done".into(),
|
||||
model: model.into(),
|
||||
usage: TokenCounts::default(),
|
||||
tool_call_count: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn stage_completed(node_id: &str, name: &str) -> Event {
|
||||
|
|
@ -544,20 +547,26 @@ mod tests {
|
|||
assert!(ui.stage.active_stages.contains_key("fork1"));
|
||||
assert!(ui.stage.parallel_parent.is_none());
|
||||
|
||||
emit(&mut ui, Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 2,
|
||||
join_policy: "wait_all".into(),
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 2,
|
||||
join_policy: "wait_all".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1"));
|
||||
|
||||
emit(&mut ui, Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
);
|
||||
let stage = &ui.stage.active_stages["fork1"];
|
||||
assert_eq!(stage.tool_calls.len(), 1);
|
||||
assert_eq!(stage.tool_calls[0].tool_call_id, "security");
|
||||
|
|
@ -566,15 +575,18 @@ mod tests {
|
|||
ToolCallStatus::Running
|
||||
));
|
||||
|
||||
emit(&mut ui, Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 2000,
|
||||
status: "success".into(),
|
||||
head_sha: None,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 2000,
|
||||
status: "success".into(),
|
||||
head_sha: None,
|
||||
},
|
||||
);
|
||||
let stage = &ui.stage.active_stages["fork1"];
|
||||
assert!(matches!(
|
||||
stage.tool_calls[0].status,
|
||||
|
|
@ -587,18 +599,24 @@ mod tests {
|
|||
let mut ui = ProgressUI::new(true, false);
|
||||
|
||||
emit(&mut ui, stage_started("fork1", "Fork"));
|
||||
emit(&mut ui, Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 1,
|
||||
join_policy: "wait_all".into(),
|
||||
});
|
||||
emit(&mut ui, Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 1,
|
||||
join_policy: "wait_all".into(),
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let stage = &ui.stage.active_stages["fork1"];
|
||||
let message = stage.tool_calls[0].bar.message();
|
||||
|
|
@ -617,21 +635,27 @@ mod tests {
|
|||
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("s1", AgentEvent::CompactionStarted {
|
||||
estimated_tokens: 5000,
|
||||
context_window_size: 8000,
|
||||
}),
|
||||
agent_event(
|
||||
"s1",
|
||||
AgentEvent::CompactionStarted {
|
||||
estimated_tokens: 5000,
|
||||
context_window_size: 8000,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());
|
||||
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("s1", AgentEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
preserved_turn_count: 6,
|
||||
summary_token_estimate: 500,
|
||||
tracked_file_count: 3,
|
||||
}),
|
||||
agent_event(
|
||||
"s1",
|
||||
AgentEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
preserved_turn_count: 6,
|
||||
summary_token_estimate: 500,
|
||||
tracked_file_count: 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert!(ui.stage.active_stages["s1"].compaction_bar.is_none());
|
||||
}
|
||||
|
|
@ -650,86 +674,101 @@ mod tests {
|
|||
let events = vec![
|
||||
stage_started("code", "Code"),
|
||||
Event::SandboxInitialized {
|
||||
working_directory: "/home/daytona/workspace".into(),
|
||||
provider: "daytona".into(),
|
||||
identifier: None,
|
||||
working_directory: "/home/daytona/workspace".into(),
|
||||
provider: "daytona".into(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
container_mount_point: None,
|
||||
},
|
||||
agent_event("code", AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({
|
||||
"file_path": "/home/daytona/workspace/src/main.rs"
|
||||
}),
|
||||
}),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({
|
||||
"file_path": "/home/daytona/workspace/src/main.rs"
|
||||
}),
|
||||
},
|
||||
),
|
||||
assistant_message("code", "gpt-5-mini"),
|
||||
Event::EdgeSelected {
|
||||
from_node: "code".into(),
|
||||
to_node: "review".into(),
|
||||
label: Some("ship".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
from_node: "code".into(),
|
||||
to_node: "review".into(),
|
||||
label: Some("ship".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
},
|
||||
Event::StageRetrying {
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
attempt: 2,
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
attempt: 2,
|
||||
max_attempts: 3,
|
||||
delay_ms: 1500,
|
||||
delay_ms: 1500,
|
||||
},
|
||||
agent_event("code", AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: "high usage".into(),
|
||||
details: serde_json::json!({"usage_percent": 92}),
|
||||
}),
|
||||
agent_event("code", AgentEvent::LlmRetry {
|
||||
provider: "openai".into(),
|
||||
model: "gpt-5-mini".into(),
|
||||
attempt: 2,
|
||||
delay_secs: 1.5,
|
||||
error: fabro_llm::error::SdkError::Configuration {
|
||||
message: "busy".into(),
|
||||
source: None,
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: "high usage".into(),
|
||||
details: serde_json::json!({"usage_percent": 92}),
|
||||
},
|
||||
}),
|
||||
agent_event("code", AgentEvent::SubAgentSpawned {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
task: "review recent changes".into(),
|
||||
}),
|
||||
agent_event("code", AgentEvent::SubAgentCompleted {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 3,
|
||||
}),
|
||||
),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::LlmRetry {
|
||||
provider: "openai".into(),
|
||||
model: "gpt-5-mini".into(),
|
||||
attempt: 2,
|
||||
delay_secs: 1.5,
|
||||
error: fabro_llm::Error::Configuration {
|
||||
message: "busy".into(),
|
||||
source: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::SubAgentSpawned {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
task: "review recent changes".into(),
|
||||
},
|
||||
),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::SubAgentCompleted {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 3,
|
||||
},
|
||||
),
|
||||
Event::SetupStarted { command_count: 1 },
|
||||
Event::SetupCommandCompleted {
|
||||
command: "bun install".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
command: "bun install".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 2200,
|
||||
},
|
||||
Event::SetupCompleted { duration_ms: 2200 },
|
||||
Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
phase: "postCreate".into(),
|
||||
command_count: 1,
|
||||
},
|
||||
Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 1400,
|
||||
},
|
||||
Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1400,
|
||||
},
|
||||
];
|
||||
|
|
@ -756,20 +795,26 @@ mod tests {
|
|||
emit(&mut ui, assistant_message("plan", "gpt-5-mini"));
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("plan", AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
}),
|
||||
agent_event(
|
||||
"plan",
|
||||
AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("plan", AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
}),
|
||||
agent_event(
|
||||
"plan",
|
||||
AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(&mut ui, stage_completed("plan", "Plan"));
|
||||
|
||||
|
|
@ -780,47 +825,68 @@ mod tests {
|
|||
fn plain_default_setup_snapshot() {
|
||||
let (mut ui, buffer) = capture_ui(false);
|
||||
|
||||
emit(&mut ui, Event::Sandbox {
|
||||
event: SandboxEvent::Initializing {
|
||||
provider: "daytona".into(),
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::Sandbox {
|
||||
event: SandboxEvent::Initializing {
|
||||
provider: "daytona".into(),
|
||||
},
|
||||
},
|
||||
});
|
||||
emit(&mut ui, Event::Sandbox {
|
||||
event: SandboxEvent::Ready {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: 2500,
|
||||
name: Some("sandbox-1".into()),
|
||||
cpu: Some(4.0),
|
||||
memory: Some(8.0),
|
||||
url: None,
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::Sandbox {
|
||||
event: SandboxEvent::Ready {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: 2500,
|
||||
name: Some("sandbox-1".into()),
|
||||
cpu: Some(4.0),
|
||||
memory: Some(8.0),
|
||||
url: None,
|
||||
},
|
||||
},
|
||||
});
|
||||
emit(&mut ui, Event::SshAccessReady {
|
||||
ssh_command: "ssh daytona@example".into(),
|
||||
});
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::SshAccessReady {
|
||||
ssh_command: "ssh daytona@example".into(),
|
||||
},
|
||||
);
|
||||
emit(&mut ui, Event::SetupStarted { command_count: 2 });
|
||||
emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 });
|
||||
emit(&mut ui, Event::CliEnsureCompleted {
|
||||
cli_name: "gh".into(),
|
||||
provider: "github".into(),
|
||||
already_installed: false,
|
||||
node_installed: false,
|
||||
duration_ms: 600,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerResolved {
|
||||
dockerfile_lines: 24,
|
||||
environment_count: 3,
|
||||
lifecycle_command_count: 2,
|
||||
workspace_folder: "/workspace".into(),
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 2,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1800,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::CliEnsureCompleted {
|
||||
cli_name: "gh".into(),
|
||||
provider: "github".into(),
|
||||
already_installed: false,
|
||||
node_installed: false,
|
||||
duration_ms: 600,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerResolved {
|
||||
dockerfile_lines: 24,
|
||||
environment_count: 3,
|
||||
lifecycle_command_count: 2,
|
||||
workspace_folder: "/workspace".into(),
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 2,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1800,
|
||||
},
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(rendered(&buffer), @r"
|
||||
Sandbox: daytona (ready in 2s)
|
||||
|
|
@ -840,104 +906,140 @@ mod tests {
|
|||
let (mut ui, buffer) = capture_ui(true);
|
||||
|
||||
emit(&mut ui, stage_started("code", "Code"));
|
||||
emit(&mut ui, Event::SandboxInitialized {
|
||||
working_directory: "/home/daytona/workspace".into(),
|
||||
provider: "daytona".into(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("code", AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({
|
||||
"file_path": "/home/daytona/workspace/src/main.rs"
|
||||
}),
|
||||
}),
|
||||
Event::SandboxInitialized {
|
||||
working_directory: "/home/daytona/workspace".into(),
|
||||
provider: "daytona".into(),
|
||||
identifier: None,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({
|
||||
"file_path": "/home/daytona/workspace/src/main.rs"
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(&mut ui, assistant_message("code", "gpt-5-mini"));
|
||||
emit(&mut ui, Event::EdgeSelected {
|
||||
from_node: "code".into(),
|
||||
to_node: "review".into(),
|
||||
label: Some("ship".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
});
|
||||
emit(&mut ui, Event::StageRetrying {
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
attempt: 2,
|
||||
max_attempts: 3,
|
||||
delay_ms: 1500,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("code", AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: "high usage".into(),
|
||||
details: serde_json::json!({"usage_percent": 92}),
|
||||
}),
|
||||
Event::EdgeSelected {
|
||||
from_node: "code".into(),
|
||||
to_node: "review".into(),
|
||||
label: Some("ship".into()),
|
||||
condition: None,
|
||||
reason: "condition".into(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
stage_status: "success".into(),
|
||||
is_jump: false,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("code", AgentEvent::LlmRetry {
|
||||
provider: "openai".into(),
|
||||
model: "gpt-5-mini".into(),
|
||||
attempt: 2,
|
||||
delay_secs: 1.5,
|
||||
error: fabro_llm::error::SdkError::Configuration {
|
||||
message: "busy".into(),
|
||||
source: None,
|
||||
Event::StageRetrying {
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
attempt: 2,
|
||||
max_attempts: 3,
|
||||
delay_ms: 1500,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: "high usage".into(),
|
||||
details: serde_json::json!({"usage_percent": 92}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("code", AgentEvent::SubAgentSpawned {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
task: "review recent changes".into(),
|
||||
}),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::LlmRetry {
|
||||
provider: "openai".into(),
|
||||
model: "gpt-5-mini".into(),
|
||||
attempt: 2,
|
||||
delay_secs: 1.5,
|
||||
error: fabro_llm::Error::Configuration {
|
||||
message: "busy".into(),
|
||||
source: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event("code", AgentEvent::SubAgentCompleted {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 3,
|
||||
}),
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::SubAgentSpawned {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
task: "review recent changes".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
agent_event(
|
||||
"code",
|
||||
AgentEvent::SubAgentCompleted {
|
||||
agent_id: "a1".into(),
|
||||
depth: 1,
|
||||
success: true,
|
||||
turns_used: 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
emit(&mut ui, Event::SetupStarted { command_count: 1 });
|
||||
emit(&mut ui, Event::SetupCommandCompleted {
|
||||
command: "bun install".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 2200,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::SetupCommandCompleted {
|
||||
command: "bun install".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 2200,
|
||||
},
|
||||
);
|
||||
emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 });
|
||||
emit(&mut ui, Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 1,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 1400,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1400,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 1,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 1400,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1400,
|
||||
},
|
||||
);
|
||||
emit(&mut ui, stage_completed("code", "Code"));
|
||||
|
||||
insta::assert_snapshot!(rendered(&buffer), @r#"
|
||||
|
|
@ -960,24 +1062,33 @@ mod tests {
|
|||
fn plain_notice_snapshot() {
|
||||
let (mut ui, buffer) = capture_ui(false);
|
||||
|
||||
emit(&mut ui, Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "sandbox_cleanup_failed".into(),
|
||||
message: "sandbox cleanup failed".into(),
|
||||
});
|
||||
emit(&mut ui, Event::PullRequestCreated {
|
||||
pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(),
|
||||
pr_number: 42,
|
||||
owner: "fabro-sh".into(),
|
||||
repo: "fabro".into(),
|
||||
base_branch: "main".into(),
|
||||
head_branch: "fabro/run/42".into(),
|
||||
title: "Ship the change".into(),
|
||||
draft: true,
|
||||
});
|
||||
emit(&mut ui, Event::PullRequestFailed {
|
||||
error: "auth token expired".into(),
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "sandbox_cleanup_failed".into(),
|
||||
message: "sandbox cleanup failed".into(),
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::PullRequestCreated {
|
||||
pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(),
|
||||
pr_number: 42,
|
||||
owner: "fabro-sh".into(),
|
||||
repo: "fabro".into(),
|
||||
base_branch: "main".into(),
|
||||
head_branch: "fabro/run/42".into(),
|
||||
title: "Ship the change".into(),
|
||||
draft: true,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::PullRequestFailed {
|
||||
error: "auth token expired".into(),
|
||||
},
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(rendered(&buffer), @r"
|
||||
Warning: sandbox cleanup failed [sandbox_cleanup_failed]
|
||||
|
|
@ -991,27 +1102,36 @@ mod tests {
|
|||
let mut ui = ProgressUI::new(true, false);
|
||||
|
||||
emit(&mut ui, stage_started("fork1", "Fork"));
|
||||
emit(&mut ui, Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 1,
|
||||
join_policy: "wait_all".into(),
|
||||
});
|
||||
emit(&mut ui, Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
});
|
||||
emit(&mut ui, Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 500,
|
||||
status: "success".into(),
|
||||
head_sha: None,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelStarted {
|
||||
node_id: "fork1".into(),
|
||||
visit: 1,
|
||||
branch_count: 1,
|
||||
join_policy: "wait_all".into(),
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
);
|
||||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 500,
|
||||
status: "success".into(),
|
||||
head_sha: None,
|
||||
},
|
||||
);
|
||||
|
||||
let stage = &ui.stage.active_stages["fork1"];
|
||||
assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms");
|
||||
|
|
@ -1031,11 +1151,11 @@ mod tests {
|
|||
let stage_started = serde_json::to_string(&to_run_event_at(
|
||||
&fixtures::RUN_1,
|
||||
&Event::StageStarted {
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
node_id: "code".into(),
|
||||
name: "Code".into(),
|
||||
index: 0,
|
||||
handler_type: "agent".into(),
|
||||
attempt: 1,
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
},
|
||||
started_ts,
|
||||
|
|
@ -1044,23 +1164,29 @@ mod tests {
|
|||
.unwrap();
|
||||
let tool_started = serde_json::to_string(&to_run_event_at(
|
||||
&fixtures::RUN_1,
|
||||
&agent_event("code", AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
}),
|
||||
&agent_event(
|
||||
"code",
|
||||
AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
arguments: serde_json::json!({"path": "src/main.rs"}),
|
||||
},
|
||||
),
|
||||
started_ts,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let tool_completed = serde_json::to_string(&to_run_event_at(
|
||||
&fixtures::RUN_1,
|
||||
&agent_event("code", AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
}),
|
||||
&agent_event(
|
||||
"code",
|
||||
AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
},
|
||||
),
|
||||
completed_ts,
|
||||
None,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ enum RendererInner {
|
|||
}
|
||||
|
||||
pub(super) struct ProgressRenderer {
|
||||
inner: RendererInner,
|
||||
inner: RendererInner,
|
||||
styles: Styles,
|
||||
}
|
||||
|
||||
impl ProgressRenderer {
|
||||
pub(super) fn new_tty() -> Self {
|
||||
Self {
|
||||
inner: RendererInner::Tty {
|
||||
inner: RendererInner::Tty {
|
||||
multi: MultiProgress::new(),
|
||||
},
|
||||
styles: Styles::new(console::colors_enabled_stderr()),
|
||||
|
|
@ -28,7 +28,7 @@ impl ProgressRenderer {
|
|||
|
||||
pub(super) fn new_plain(out: Box<dyn Write + Send>, colors: bool) -> Self {
|
||||
Self {
|
||||
inner: RendererInner::Plain {
|
||||
inner: RendererInner::Plain {
|
||||
out: Mutex::new(out),
|
||||
},
|
||||
styles: Styles::new(colors),
|
||||
|
|
|
|||
|
|
@ -24,18 +24,18 @@ pub(super) enum ToolCallStatus {
|
|||
pub(super) struct ToolCallEntry {
|
||||
pub(super) display_name: String,
|
||||
pub(super) tool_call_id: String,
|
||||
pub(super) status: ToolCallStatus,
|
||||
pub(super) bar: ProgressBar,
|
||||
pub(super) is_branch: bool,
|
||||
pub(super) started_at: Option<DateTime<Utc>>,
|
||||
pub(super) status: ToolCallStatus,
|
||||
pub(super) bar: ProgressBar,
|
||||
pub(super) is_branch: bool,
|
||||
pub(super) started_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ActiveStage {
|
||||
pub(super) display_name: String,
|
||||
pub(super) has_model: bool,
|
||||
pub(super) spinner: ProgressBar,
|
||||
pub(super) tool_calls: VecDeque<ToolCallEntry>,
|
||||
pub(super) display_name: String,
|
||||
pub(super) has_model: bool,
|
||||
pub(super) spinner: ProgressBar,
|
||||
pub(super) tool_calls: VecDeque<ToolCallEntry>,
|
||||
pub(super) compaction_bar: Option<ProgressBar>,
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +48,12 @@ impl ActiveStage {
|
|||
}
|
||||
|
||||
pub(super) struct StageDisplay {
|
||||
verbose: bool,
|
||||
pub(super) active_stages: HashMap<String, ActiveStage>,
|
||||
pub(super) stage_counts: HashMap<String, (u64, u64)>,
|
||||
verbose: bool,
|
||||
pub(super) active_stages: HashMap<String, ActiveStage>,
|
||||
pub(super) stage_counts: HashMap<String, (u64, u64)>,
|
||||
pub(super) parallel_parent: Option<String>,
|
||||
any_stage_started: bool,
|
||||
working_directory: Option<String>,
|
||||
any_stage_started: bool,
|
||||
working_directory: Option<String>,
|
||||
}
|
||||
|
||||
impl StageDisplay {
|
||||
|
|
@ -117,13 +117,16 @@ impl StageDisplay {
|
|||
if renderer.is_tty() {
|
||||
bar.enable_steady_tick(Duration::from_millis(100));
|
||||
}
|
||||
self.active_stages.insert(node_id.to_string(), ActiveStage {
|
||||
display_name,
|
||||
has_model: false,
|
||||
spinner: bar,
|
||||
tool_calls: VecDeque::new(),
|
||||
compaction_bar: None,
|
||||
});
|
||||
self.active_stages.insert(
|
||||
node_id.to_string(),
|
||||
ActiveStage {
|
||||
display_name,
|
||||
has_model: false,
|
||||
spinner: bar,
|
||||
tool_calls: VecDeque::new(),
|
||||
compaction_bar: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn on_stage_completed(
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ fn build_artifact_uploader(
|
|||
}
|
||||
|
||||
struct HttpArtifactUploader {
|
||||
run_id: RunId,
|
||||
client: server_client::ServerStoreClient,
|
||||
run_id: RunId,
|
||||
client: server_client::ServerStoreClient,
|
||||
bearer_token: String,
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ impl StageArtifactUploader for MissingArtifactUploadTokenUploader {
|
|||
struct HttpRunStore {
|
||||
run_id: RunId,
|
||||
client: server_client::ServerStoreClient,
|
||||
state: Arc<Mutex<RunProjection>>,
|
||||
state: Arc<Mutex<RunProjection>>,
|
||||
events: Arc<Mutex<Option<Vec<EventEnvelope>>>>,
|
||||
}
|
||||
|
||||
|
|
@ -593,12 +593,12 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
worker_title_phase_for_event(&EventBody::InterviewStarted(InterviewStartedProps {
|
||||
question_id: "q-1".to_string(),
|
||||
question: "Approve?".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: "yes_no".to_string(),
|
||||
options: Vec::new(),
|
||||
allow_freeform: false,
|
||||
question_id: "q-1".to_string(),
|
||||
question: "Approve?".to_string(),
|
||||
stage: "gate".to_string(),
|
||||
question_type: "yes_no".to_string(),
|
||||
options: Vec::new(),
|
||||
allow_freeform: false,
|
||||
timeout_seconds: None,
|
||||
context_display: None,
|
||||
})),
|
||||
|
|
@ -607,39 +607,39 @@ mod tests {
|
|||
assert_eq!(
|
||||
worker_title_phase_for_event(&EventBody::InterviewCompleted(InterviewCompletedProps {
|
||||
question_id: "q-1".to_string(),
|
||||
question: "Approve?".to_string(),
|
||||
answer: "yes".to_string(),
|
||||
question: "Approve?".to_string(),
|
||||
answer: "yes".to_string(),
|
||||
duration_ms: 10,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Running)
|
||||
);
|
||||
assert_eq!(
|
||||
worker_title_phase_for_event(&EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Succeeded)
|
||||
);
|
||||
assert_eq!(
|
||||
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
|
||||
error: "cancelled".to_string(),
|
||||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Cancelled),
|
||||
error: "cancelled".to_string(),
|
||||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Cancelled),
|
||||
git_commit_sha: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Cancelled)
|
||||
);
|
||||
assert_eq!(
|
||||
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
|
||||
error: "boom".to_string(),
|
||||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Terminated),
|
||||
error: "boom".to_string(),
|
||||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Terminated),
|
||||
git_commit_sha: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Failed)
|
||||
|
|
|
|||
|
|
@ -158,22 +158,22 @@ mod tests {
|
|||
fn json_output_succeeded_with_conclusion() {
|
||||
let run_id = fixtures::RUN_1;
|
||||
let conclusion = Conclusion {
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 12345,
|
||||
failure_reason: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 12345,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: None,
|
||||
stages: vec![],
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
stages: vec![],
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
total_usd_micros: Some(420_000),
|
||||
total_usd_micros: Some(420_000),
|
||||
}),
|
||||
total_retries: 0,
|
||||
total_retries: 0,
|
||||
};
|
||||
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
|
||||
assert_eq!(json["run_id"], run_id.to_string());
|
||||
|
|
@ -202,14 +202,14 @@ mod tests {
|
|||
fn json_output_no_cost_when_none() {
|
||||
let run_id = fixtures::RUN_4;
|
||||
let conclusion = Conclusion {
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Fail,
|
||||
duration_ms: 500,
|
||||
failure_reason: Some("error".into()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Fail,
|
||||
duration_ms: 500,
|
||||
failure_reason: Some("error".into()),
|
||||
final_git_commit_sha: None,
|
||||
stages: vec![],
|
||||
billing: None,
|
||||
total_retries: 0,
|
||||
stages: vec![],
|
||||
billing: None,
|
||||
total_retries: 0,
|
||||
};
|
||||
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
|
||||
assert!(json.get("total_usd_micros").is_none());
|
||||
|
|
@ -221,22 +221,22 @@ mod tests {
|
|||
let styles = no_color_styles();
|
||||
let run_id = fixtures::RUN_5;
|
||||
let conclusion = Conclusion {
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 8000,
|
||||
failure_reason: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 8000,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: None,
|
||||
stages: vec![],
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
stages: vec![],
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
total_usd_micros: Some(150_000),
|
||||
total_usd_micros: Some(150_000),
|
||||
}),
|
||||
total_retries: 0,
|
||||
total_retries: 0,
|
||||
};
|
||||
// Just verify no panic; actual stderr output is hard to capture
|
||||
print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ use crate::server_runs::{ServerRunSummaryInfo, ServerSummaryLookup};
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct InspectOutput {
|
||||
pub run_id: String,
|
||||
pub status: RunStatus,
|
||||
pub run_record: Option<serde_json::Value>,
|
||||
pub run_id: String,
|
||||
pub status: RunStatus,
|
||||
pub run_record: Option<serde_json::Value>,
|
||||
pub start_record: Option<serde_json::Value>,
|
||||
pub conclusion: Option<serde_json::Value>,
|
||||
pub checkpoint: Option<serde_json::Value>,
|
||||
pub sandbox: Option<serde_json::Value>,
|
||||
pub conclusion: Option<serde_json::Value>,
|
||||
pub checkpoint: Option<serde_json::Value>,
|
||||
pub sandbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run(args: &InspectArgs, _globals: &GlobalArgs) -> Result<()> {
|
||||
|
|
@ -32,24 +32,24 @@ pub(crate) async fn run(args: &InspectArgs, _globals: &GlobalArgs) -> Result<()>
|
|||
|
||||
fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput {
|
||||
InspectOutput {
|
||||
run_id: run.run_id().to_string(),
|
||||
status: state
|
||||
run_id: run.run_id().to_string(),
|
||||
status: state
|
||||
.status
|
||||
.as_ref()
|
||||
.map_or(run.status(), |record| record.status),
|
||||
run_record: state
|
||||
run_record: state
|
||||
.run
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
start_record: state
|
||||
.start
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
conclusion: state
|
||||
conclusion: state
|
||||
.conclusion
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
checkpoint: state
|
||||
checkpoint: state
|
||||
.checkpoint
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
sandbox: state
|
||||
sandbox: state
|
||||
.sandbox
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,12 +48,15 @@ pub(crate) async fn execute(
|
|||
styles,
|
||||
storage_dir,
|
||||
move |resolved_bind| {
|
||||
record::write_server_record(&record_path, &record::ServerRecord {
|
||||
pid,
|
||||
bind: resolved_bind.clone(),
|
||||
log_path: log_path.clone(),
|
||||
started_at: Utc::now(),
|
||||
})
|
||||
record::write_server_record(
|
||||
&record_path,
|
||||
&record::ServerRecord {
|
||||
pid,
|
||||
bind: resolved_bind.clone(),
|
||||
log_path: log_path.clone(),
|
||||
started_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
},
|
||||
))
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -9,15 +9,15 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ServerRecord {
|
||||
pub pid: u32,
|
||||
pub bind: Bind,
|
||||
pub log_path: PathBuf,
|
||||
pub pid: u32,
|
||||
pub bind: Bind,
|
||||
pub log_path: PathBuf,
|
||||
pub started_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ActiveServerRecord {
|
||||
pub record: ServerRecord,
|
||||
pub record: ServerRecord,
|
||||
pub record_path: PathBuf,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,15 +69,15 @@ fn ensure_server_running_with_bind(
|
|||
}
|
||||
|
||||
let serve_args = ServeArgs {
|
||||
bind: None,
|
||||
web: false,
|
||||
no_web: false,
|
||||
model: None,
|
||||
provider: None,
|
||||
dry_run: false,
|
||||
sandbox: None,
|
||||
bind: None,
|
||||
web: false,
|
||||
no_web: false,
|
||||
model: None,
|
||||
provider: None,
|
||||
dry_run: false,
|
||||
sandbox: None,
|
||||
max_concurrent_runs: server_max_concurrent_runs_override(),
|
||||
config: Some(config_path.to_path_buf()),
|
||||
config: Some(config_path.to_path_buf()),
|
||||
};
|
||||
|
||||
let bind_request = match &bind {
|
||||
|
|
@ -148,12 +148,15 @@ async fn execute_foreground(
|
|||
styles,
|
||||
Some(storage_dir),
|
||||
move |resolved_bind| {
|
||||
record::write_server_record(&record_path, &record::ServerRecord {
|
||||
pid,
|
||||
bind: resolved_bind.clone(),
|
||||
log_path: log_path.clone(),
|
||||
started_at: Utc::now(),
|
||||
})
|
||||
record::write_server_record(
|
||||
&record_path,
|
||||
&record::ServerRecord {
|
||||
pid,
|
||||
bind: resolved_bind.clone(),
|
||||
log_path: log_path.clone(),
|
||||
started_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
},
|
||||
))
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ fn finalize_export(
|
|||
}
|
||||
|
||||
struct DumpArtifact {
|
||||
stage_id: StageId,
|
||||
stage_id: StageId,
|
||||
relative_path: String,
|
||||
data: Vec<u8>,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
trait DumpDataSource {
|
||||
|
|
@ -97,9 +97,9 @@ trait DumpDataSource {
|
|||
|
||||
#[cfg(test)]
|
||||
struct LocalDumpSource<'a> {
|
||||
run_store: &'a RunDatabase,
|
||||
run_store: &'a RunDatabase,
|
||||
artifact_store: &'a ArtifactStore,
|
||||
run_id: RunId,
|
||||
run_id: RunId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -140,9 +140,9 @@ impl DumpDataSource for LocalDumpSource<'_> {
|
|||
)
|
||||
})?;
|
||||
artifacts.push(DumpArtifact {
|
||||
stage_id: asset.node,
|
||||
stage_id: asset.node,
|
||||
relative_path: asset.filename,
|
||||
data: data.to_vec(),
|
||||
data: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Ok(artifacts)
|
||||
|
|
@ -362,52 +362,49 @@ mod tests {
|
|||
|
||||
fn sample_status() -> RunStatusRecord {
|
||||
RunStatusRecord {
|
||||
status: RunStatus::Running,
|
||||
reason: Some(StatusReason::SandboxInitializing),
|
||||
status: RunStatus::Running,
|
||||
reason: Some(StatusReason::SandboxInitializing),
|
||||
updated_at: dt("2026-03-27T12:05:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint {
|
||||
Checkpoint {
|
||||
timestamp: dt("2026-03-27T12:10:00Z"),
|
||||
current_node: current_node.to_string(),
|
||||
completed_nodes: vec!["plan".to_string()],
|
||||
node_retries: HashMap::from([(
|
||||
current_node.to_string(),
|
||||
visit.saturating_sub(1),
|
||||
)]),
|
||||
context_values: HashMap::from([(
|
||||
timestamp: dt("2026-03-27T12:10:00Z"),
|
||||
current_node: current_node.to_string(),
|
||||
completed_nodes: vec!["plan".to_string()],
|
||||
node_retries: HashMap::from([(current_node.to_string(), visit.saturating_sub(1))]),
|
||||
context_values: HashMap::from([(
|
||||
"artifact".to_string(),
|
||||
serde_json::json!({"kind": "summary"}),
|
||||
)]),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: Some("review".to_string()),
|
||||
git_commit_sha: Some("def456".to_string()),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: Some("review".to_string()),
|
||||
git_commit_sha: Some("def456".to_string()),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits: HashMap::from([(current_node.to_string(), visit as usize)]),
|
||||
node_visits: HashMap::from([(current_node.to_string(), visit as usize)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_conclusion() -> Conclusion {
|
||||
Conclusion {
|
||||
timestamp: dt("2026-03-27T12:15:00Z"),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 3210,
|
||||
failure_reason: None,
|
||||
timestamp: dt("2026-03-27T12:15:00Z"),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 3210,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: Some("feedbeef".to_string()),
|
||||
stages: Vec::new(),
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
total_tokens: 150,
|
||||
reasoning_tokens: 50,
|
||||
cache_read_tokens: 30,
|
||||
stages: Vec::new(),
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
total_tokens: 150,
|
||||
reasoning_tokens: 50,
|
||||
cache_read_tokens: 30,
|
||||
cache_write_tokens: 40,
|
||||
total_usd_micros: Some(1_250_000),
|
||||
total_usd_micros: Some(1_250_000),
|
||||
}),
|
||||
total_retries: 2,
|
||||
total_retries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -420,12 +417,12 @@ mod tests {
|
|||
smoothness: None,
|
||||
stages: Vec::new(),
|
||||
stats: AggregateStats {
|
||||
total_duration_ms: 3210,
|
||||
total_duration_ms: 3210,
|
||||
total_billing_usd_micros: Some(1_250_000),
|
||||
total_retries: 2,
|
||||
files_touched: vec!["src/lib.rs".to_string()],
|
||||
stages_completed: 3,
|
||||
stages_failed: 0,
|
||||
total_retries: 2,
|
||||
files_touched: vec!["src/lib.rs".to_string()],
|
||||
stages_completed: 3,
|
||||
stages_failed: 0,
|
||||
},
|
||||
intent: Some("ship the fix".to_string()),
|
||||
outcome: Some("done".to_string()),
|
||||
|
|
@ -437,11 +434,11 @@ mod tests {
|
|||
|
||||
fn sample_sandbox() -> SandboxRecord {
|
||||
SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/night-sky".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/night-sky".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
host_working_directory: Some("/tmp/night-sky".to_string()),
|
||||
container_mount_point: None,
|
||||
container_mount_point: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,171 +475,223 @@ mod tests {
|
|||
);
|
||||
|
||||
let node = StageId::new("code", 2);
|
||||
append_event(&run, &run_id, &Event::RunCreated {
|
||||
run_id,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
workflow_source: Some("digraph night_sky {}".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
run_dir: "/tmp/night-sky-run".to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::RunCreated {
|
||||
run_id,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
workflow_source: Some("digraph night_sky {}".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
run_dir: "/tmp/night-sky-run".to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::WorkflowRunStarted {
|
||||
name: "night-sky".to_string(),
|
||||
run_id,
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
base_sha: start_record.base_sha.clone(),
|
||||
run_branch: start_record.run_branch.clone(),
|
||||
worktree_dir: None,
|
||||
goal: Some("map the constellations".to_string()),
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::WorkflowRunStarted {
|
||||
name: "night-sky".to_string(),
|
||||
run_id,
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
base_sha: start_record.base_sha.clone(),
|
||||
run_branch: start_record.run_branch.clone(),
|
||||
worktree_dir: None,
|
||||
goal: Some("map the constellations".to_string()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RunRunning {
|
||||
reason: status_record.reason,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::RunRunning {
|
||||
reason: status_record.reason,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for checkpoint in [&first_checkpoint, &second_checkpoint] {
|
||||
append_event(&run, &run_id, &Event::CheckpointCompleted {
|
||||
node_id: checkpoint.current_node.clone(),
|
||||
status: "success".to_string(),
|
||||
current_node: checkpoint.current_node.clone(),
|
||||
completed_nodes: checkpoint.completed_nodes.clone(),
|
||||
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
|
||||
context_values: checkpoint.context_values.clone().into_iter().collect(),
|
||||
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
|
||||
next_node_id: checkpoint.next_node_id.clone(),
|
||||
git_commit_sha: checkpoint.git_commit_sha.clone(),
|
||||
loop_failure_signatures: checkpoint
|
||||
.loop_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
restart_failure_signatures: checkpoint
|
||||
.restart_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
|
||||
diff: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::CheckpointCompleted {
|
||||
node_id: checkpoint.current_node.clone(),
|
||||
status: "success".to_string(),
|
||||
current_node: checkpoint.current_node.clone(),
|
||||
completed_nodes: checkpoint.completed_nodes.clone(),
|
||||
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
|
||||
context_values: checkpoint.context_values.clone().into_iter().collect(),
|
||||
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
|
||||
next_node_id: checkpoint.next_node_id.clone(),
|
||||
git_commit_sha: checkpoint.git_commit_sha.clone(),
|
||||
loop_failure_signatures: checkpoint
|
||||
.loop_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
restart_failure_signatures: checkpoint
|
||||
.restart_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
|
||||
diff: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
append_event(&run, &run_id, &Event::SandboxInitialized {
|
||||
working_directory: sandbox.working_directory.clone(),
|
||||
provider: sandbox.provider.clone(),
|
||||
identifier: sandbox.identifier.clone(),
|
||||
host_working_directory: sandbox.host_working_directory.clone(),
|
||||
container_mount_point: sandbox.container_mount_point.clone(),
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::SandboxInitialized {
|
||||
working_directory: sandbox.working_directory.clone(),
|
||||
provider: sandbox.provider.clone(),
|
||||
identifier: sandbox.identifier.clone(),
|
||||
host_working_directory: sandbox.host_working_directory.clone(),
|
||||
container_mount_point: sandbox.container_mount_point.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::Prompt {
|
||||
stage: "code".to_string(),
|
||||
visit: 2,
|
||||
text: "Plan the fix".to_string(),
|
||||
mode: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::Prompt {
|
||||
stage: "code".to_string(),
|
||||
visit: 2,
|
||||
text: "Plan the fix".to_string(),
|
||||
mode: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::PromptCompleted {
|
||||
node_id: "code".to_string(),
|
||||
response: "Implemented".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
provider: "openai".to_string(),
|
||||
billing: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::PromptCompleted {
|
||||
node_id: "code".to_string(),
|
||||
response: "Implemented".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
provider: "openai".to_string(),
|
||||
billing: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::StageCompleted {
|
||||
node_id: "code".to_string(),
|
||||
name: "Code".to_string(),
|
||||
index: 1,
|
||||
duration_ms: 250,
|
||||
status: "partial_success".to_string(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
billing: None,
|
||||
failure: None,
|
||||
notes: Some("captured output".to_string()),
|
||||
files_touched: Vec::new(),
|
||||
context_updates: None,
|
||||
jump_to_node: None,
|
||||
context_values: None,
|
||||
node_visits: Some(std::collections::BTreeMap::from([(
|
||||
"code".to_string(),
|
||||
2usize,
|
||||
)])),
|
||||
loop_failure_signatures: None,
|
||||
restart_failure_signatures: None,
|
||||
response: Some("Implemented".to_string()),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::StageCompleted {
|
||||
node_id: "code".to_string(),
|
||||
name: "Code".to_string(),
|
||||
index: 1,
|
||||
duration_ms: 250,
|
||||
status: "partial_success".to_string(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
billing: None,
|
||||
failure: None,
|
||||
notes: Some("captured output".to_string()),
|
||||
files_touched: Vec::new(),
|
||||
context_updates: None,
|
||||
jump_to_node: None,
|
||||
context_values: None,
|
||||
node_visits: Some(std::collections::BTreeMap::from([(
|
||||
"code".to_string(),
|
||||
2usize,
|
||||
)])),
|
||||
loop_failure_signatures: None,
|
||||
restart_failure_signatures: None,
|
||||
response: Some("Implemented".to_string()),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::CommandStarted {
|
||||
node_id: "code".to_string(),
|
||||
script: "echo hi".to_string(),
|
||||
command: "echo hi".to_string(),
|
||||
language: "sh".to_string(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::CommandStarted {
|
||||
node_id: "code".to_string(),
|
||||
script: "echo hi".to_string(),
|
||||
command: "echo hi".to_string(),
|
||||
language: "sh".to_string(),
|
||||
timeout_ms: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::CommandCompleted {
|
||||
node_id: "code".to_string(),
|
||||
stdout: "stdout line".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: Some(0),
|
||||
duration_ms: 100,
|
||||
timed_out: false,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::CommandCompleted {
|
||||
node_id: "code".to_string(),
|
||||
stdout: "stdout line".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: Some(0),
|
||||
duration_ms: 100,
|
||||
timed_out: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RetroStarted {
|
||||
prompt: Some("How did it go?".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::RetroStarted {
|
||||
prompt: Some("How did it go?".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RetroCompleted {
|
||||
duration_ms: 50,
|
||||
response: Some("Smooth enough".to_string()),
|
||||
retro: Some(serde_json::to_value(&retro).unwrap()),
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::RetroCompleted {
|
||||
duration_ms: 50,
|
||||
response: Some("Smooth enough".to_string()),
|
||||
retro: Some(serde_json::to_value(&retro).unwrap()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::WorkflowRunCompleted {
|
||||
duration_ms: conclusion.duration_ms,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: conclusion
|
||||
.billing
|
||||
.as_ref()
|
||||
.and_then(|billing| billing.total_usd_micros),
|
||||
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
|
||||
final_patch: None,
|
||||
billing: conclusion.billing.clone(),
|
||||
})
|
||||
append_event(
|
||||
&run,
|
||||
&run_id,
|
||||
&Event::WorkflowRunCompleted {
|
||||
duration_ms: conclusion.duration_ms,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: conclusion
|
||||
.billing
|
||||
.as_ref()
|
||||
.and_then(|billing| billing.total_usd_micros),
|
||||
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
|
||||
final_patch: None,
|
||||
billing: conclusion.billing.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
run.append_event(
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) ->
|
|||
.api()
|
||||
.prune_runs()
|
||||
.body(types::PruneRunsRequest {
|
||||
before: args.filter.before.clone(),
|
||||
dry_run: !args.yes,
|
||||
labels: parse_label_filters(&args.filter.label),
|
||||
before: args.filter.before.clone(),
|
||||
dry_run: !args.yes,
|
||||
labels: parse_label_filters(&args.filter.label),
|
||||
older_than: args.older_than.map(format_duration),
|
||||
orphans: args.filter.orphans,
|
||||
workflow: args.filter.workflow.clone(),
|
||||
orphans: args.filter.orphans,
|
||||
workflow: args.filter.workflow.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ use crate::user_config;
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Inventory {
|
||||
home_root: PathBuf,
|
||||
storage_dir: PathBuf,
|
||||
home_exists: bool,
|
||||
home_size: u64,
|
||||
server_running: bool,
|
||||
shell_configs: Vec<PathBuf>,
|
||||
binary_path: Option<PathBuf>,
|
||||
home_root: PathBuf,
|
||||
storage_dir: PathBuf,
|
||||
home_exists: bool,
|
||||
home_size: u64,
|
||||
server_running: bool,
|
||||
shell_configs: Vec<PathBuf>,
|
||||
binary_path: Option<PathBuf>,
|
||||
binary_is_managed: bool,
|
||||
}
|
||||
|
||||
|
|
@ -203,12 +203,12 @@ fn print_preview(inventory: &Inventory) {
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UninstallResult {
|
||||
status: &'static str,
|
||||
home_removed: bool,
|
||||
server_stopped: bool,
|
||||
status: &'static str,
|
||||
home_removed: bool,
|
||||
server_stopped: bool,
|
||||
shell_configs_cleaned: Vec<PathBuf>,
|
||||
binary_removed: bool,
|
||||
binary_hint: Option<String>,
|
||||
binary_removed: bool,
|
||||
binary_hint: Option<String>,
|
||||
}
|
||||
|
||||
fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> {
|
||||
|
|
@ -217,12 +217,12 @@ fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> {
|
|||
let bold = console::Style::new().bold();
|
||||
let mut critical_failure = false;
|
||||
let mut result = UninstallResult {
|
||||
status: "completed",
|
||||
home_removed: false,
|
||||
server_stopped: false,
|
||||
status: "completed",
|
||||
home_removed: false,
|
||||
server_stopped: false,
|
||||
shell_configs_cleaned: Vec::new(),
|
||||
binary_removed: false,
|
||||
binary_hint: None,
|
||||
binary_removed: false,
|
||||
binary_hint: None,
|
||||
};
|
||||
|
||||
// Unit 3a: Server stop
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ const LAST_CHECK_FILE: &str = "last_upgrade_check.json";
|
|||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct UpgradeCheckState {
|
||||
checked_at: u64,
|
||||
checked_at: u64,
|
||||
latest_version: String,
|
||||
}
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ async fn check_and_print_notice() -> Result<()> {
|
|||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let state = UpgradeCheckState {
|
||||
checked_at: now,
|
||||
checked_at: now,
|
||||
latest_version: latest.to_string(),
|
||||
};
|
||||
let _ = state.save(&state_path);
|
||||
|
|
@ -503,7 +503,7 @@ mod tests {
|
|||
#[test]
|
||||
fn upgrade_check_state_roundtrip() {
|
||||
let state = UpgradeCheckState {
|
||||
checked_at: 1_710_000_000,
|
||||
checked_at: 1_710_000_000,
|
||||
latest_version: "0.5.0".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
|
|
@ -515,7 +515,7 @@ mod tests {
|
|||
#[test]
|
||||
fn upgrade_check_state_stale() {
|
||||
let old = UpgradeCheckState {
|
||||
checked_at: 0, // epoch — definitely stale
|
||||
checked_at: 0, // epoch — definitely stale
|
||||
latest_version: "0.1.0".to_string(),
|
||||
};
|
||||
assert!(old.is_stale());
|
||||
|
|
@ -528,7 +528,7 @@ mod tests {
|
|||
.unwrap()
|
||||
.as_secs();
|
||||
let fresh = UpgradeCheckState {
|
||||
checked_at: now,
|
||||
checked_at: now,
|
||||
latest_version: "0.5.0".to_string(),
|
||||
};
|
||||
assert!(!fresh.is_stale());
|
||||
|
|
@ -539,7 +539,7 @@ mod tests {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.json");
|
||||
let state = UpgradeCheckState {
|
||||
checked_at: 1_710_000_000,
|
||||
checked_at: 1_710_000_000,
|
||||
latest_version: "0.5.0".to_string(),
|
||||
};
|
||||
state.save(&path).unwrap();
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ pub(crate) async fn run(
|
|||
) -> anyhow::Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.target)?;
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: SettingsLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
workflow: args.workflow.clone(),
|
||||
cwd: ctx.cwd().to_path_buf(),
|
||||
args_layer: SettingsLayer::default(),
|
||||
args: None,
|
||||
run_id: None,
|
||||
user_layer: load_settings_user()?,
|
||||
user_settings_path: Some(active_settings_path(None)),
|
||||
})?;
|
||||
let client = ctx.server().await?;
|
||||
|
|
|
|||
|
|
@ -27,20 +27,20 @@ use crate::{sse, user_config};
|
|||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ServerStoreClient {
|
||||
client: fabro_api::Client,
|
||||
client: fabro_api::Client,
|
||||
http_client: reqwest::Client,
|
||||
base_url: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LocalServerRuntime {
|
||||
active_config_path: PathBuf,
|
||||
storage_dir: PathBuf,
|
||||
storage_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) struct RunAttachEventStream {
|
||||
stream: progenitor_client::ByteStream,
|
||||
pending_bytes: Vec<u8>,
|
||||
stream: progenitor_client::ByteStream,
|
||||
pending_bytes: Vec<u8>,
|
||||
buffered_events: VecDeque<EventEnvelope>,
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ pub(crate) async fn connect_server_with_settings(
|
|||
let target = user_config::resolve_server_target(args, settings)?;
|
||||
let runtime = LocalServerRuntime {
|
||||
active_config_path: base_config_path.to_path_buf(),
|
||||
storage_dir: user_config::storage_dir(settings)?,
|
||||
storage_dir: user_config::storage_dir(settings)?,
|
||||
};
|
||||
connect_target_api_client_bundle(&target, &runtime).await
|
||||
}
|
||||
|
|
@ -236,14 +236,14 @@ struct ArtifactBatchUploadManifest {
|
|||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ArtifactBatchUploadEntry {
|
||||
part: String,
|
||||
path: String,
|
||||
part: String,
|
||||
path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sha256: Option<String>,
|
||||
sha256: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
expected_bytes: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_type: Option<String>,
|
||||
content_type: Option<String>,
|
||||
}
|
||||
|
||||
impl ServerStoreClient {
|
||||
|
|
@ -660,11 +660,11 @@ impl ServerStoreClient {
|
|||
.len();
|
||||
|
||||
manifest_entries.push(ArtifactBatchUploadEntry {
|
||||
part: part_name.clone(),
|
||||
path: artifact.path.clone(),
|
||||
sha256: Some(artifact.content_sha256.clone()),
|
||||
part: part_name.clone(),
|
||||
path: artifact.path.clone(),
|
||||
sha256: Some(artifact.content_sha256.clone()),
|
||||
expected_bytes: Some(artifact.bytes),
|
||||
content_type: Some(artifact.mime.clone()),
|
||||
content_type: Some(artifact.mime.clone()),
|
||||
});
|
||||
|
||||
file_parts.push((
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, scratch_ba
|
|||
use crate::server_client::{self, ServerStoreClient};
|
||||
|
||||
pub(crate) struct ServerRunLookup {
|
||||
client: ServerStoreClient,
|
||||
client: ServerStoreClient,
|
||||
scratch_base: PathBuf,
|
||||
summaries: Vec<RunSummary>,
|
||||
summaries: Vec<RunSummary>,
|
||||
}
|
||||
|
||||
impl ServerRunLookup {
|
||||
|
|
@ -105,7 +105,7 @@ impl ServerRunSummaryInfo {
|
|||
|
||||
pub(crate) struct ServerSummaryLookup {
|
||||
client: Arc<ServerStoreClient>,
|
||||
runs: Vec<ServerRunSummaryInfo>,
|
||||
runs: Vec<ServerRunSummaryInfo>,
|
||||
}
|
||||
|
||||
impl ServerSummaryLookup {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ struct JwtPayload {
|
|||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default, rename = "https://api.openai.com/auth")]
|
||||
auth_claim: Option<AuthClaim>,
|
||||
auth_claim: Option<AuthClaim>,
|
||||
#[serde(default)]
|
||||
organizations: Option<Vec<Organization>>,
|
||||
organizations: Option<Vec<Organization>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use tracing::debug;
|
|||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub(crate) struct ClientTlsSettings {
|
||||
pub cert: PathBuf,
|
||||
pub key: PathBuf,
|
||||
pub ca: PathBuf,
|
||||
pub key: PathBuf,
|
||||
pub ca: PathBuf,
|
||||
}
|
||||
|
||||
use crate::args::ServerTargetArgs;
|
||||
|
|
@ -85,7 +85,7 @@ pub(crate) fn apply_storage_dir_override(
|
|||
pub(crate) enum ServerTarget {
|
||||
HttpUrl {
|
||||
api_url: String,
|
||||
tls: Option<ClientTlsSettings>,
|
||||
tls: Option<ClientTlsSettings>,
|
||||
},
|
||||
UnixSocket(PathBuf),
|
||||
}
|
||||
|
|
@ -99,8 +99,8 @@ fn cli_target_from_settings(settings: &CliSettings) -> Option<(String, Option<Cl
|
|||
CliTargetSettings::Http { url, tls } => {
|
||||
let tls_settings = tls.as_ref().map(|tls| ClientTlsSettings {
|
||||
cert: PathBuf::from(tls.cert.as_source()),
|
||||
key: PathBuf::from(tls.key.as_source()),
|
||||
ca: PathBuf::from(tls.ca.as_source()),
|
||||
key: PathBuf::from(tls.key.as_source()),
|
||||
ca: PathBuf::from(tls.ca.as_source()),
|
||||
});
|
||||
Some((url.as_source(), tls_settings))
|
||||
}
|
||||
|
|
@ -265,7 +265,7 @@ mod tests {
|
|||
.unwrap(),
|
||||
Some(ServerTarget::HttpUrl {
|
||||
api_url: "https://cli.example.com".to_string(),
|
||||
tls: None,
|
||||
tls: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -311,7 +311,7 @@ url = "https://config.example.com"
|
|||
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
||||
ServerTarget::HttpUrl {
|
||||
api_url: "https://config.example.com".to_string(),
|
||||
tls: None,
|
||||
tls: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -335,7 +335,7 @@ url = "https://config.example.com"
|
|||
.unwrap(),
|
||||
ServerTarget::HttpUrl {
|
||||
api_url: "https://cli.example.com".to_string(),
|
||||
tls: None,
|
||||
tls: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -368,7 +368,7 @@ url = "https://config.example.com"
|
|||
.unwrap(),
|
||||
ServerTarget::HttpUrl {
|
||||
api_url: "https://cli.example.com".to_string(),
|
||||
tls: None,
|
||||
tls: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -377,8 +377,8 @@ url = "https://config.example.com"
|
|||
fn remote_target_uses_tls_from_config() {
|
||||
let expected_tls = ClientTlsSettings {
|
||||
cert: PathBuf::from("cert.pem"),
|
||||
key: PathBuf::from("key.pem"),
|
||||
ca: PathBuf::from("ca.pem"),
|
||||
key: PathBuf::from("key.pem"),
|
||||
ca: PathBuf::from("ca.pem"),
|
||||
};
|
||||
let settings = parse_v2(
|
||||
r#"
|
||||
|
|
@ -402,7 +402,7 @@ ca = "ca.pem"
|
|||
.unwrap(),
|
||||
Some(ServerTarget::HttpUrl {
|
||||
api_url: "https://cli.example.com".to_string(),
|
||||
tls: Some(expected_tls),
|
||||
tls: Some(expected_tls),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,10 +435,10 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
|||
|
||||
// checkpoint.exclude_globs is a security/policy list: replace by default.
|
||||
let checkpoint = run_checkpoint(&cfg);
|
||||
assert_eq!(checkpoint.exclude_globs, vec![
|
||||
"run-only".to_string(),
|
||||
"shared".to_string()
|
||||
]);
|
||||
assert_eq!(
|
||||
checkpoint.exclude_globs,
|
||||
vec!["run-only".to_string(), "shared".to_string()]
|
||||
);
|
||||
|
||||
// Hooks: id-based replacement. The "shared" hook appears in both cli and
|
||||
// workflow layers and resolves to the workflow entry; project and run-only
|
||||
|
|
@ -528,9 +528,10 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
|
|||
assert!(auto_approve_enabled(&cfg));
|
||||
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
||||
// The highest-precedence layer (workflow) wins.
|
||||
assert_eq!(run_prepare_commands(&cfg), vec![
|
||||
"workflow-setup".to_string()
|
||||
]);
|
||||
assert_eq!(
|
||||
run_prepare_commands(&cfg),
|
||||
vec!["workflow-setup".to_string()]
|
||||
);
|
||||
assert_eq!(run_sandbox(&cfg).preserve, Some(true));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,10 +84,10 @@ fn fork_latest_prints_new_run_and_resume_hint() {
|
|||
);
|
||||
let new_run_id = &new_run_ids[0];
|
||||
|
||||
let new_head = git_stdout(&setup.repo_dir, &[
|
||||
"rev-parse",
|
||||
&format!("fabro/run/{new_run_id}"),
|
||||
]);
|
||||
let new_head = git_stdout(
|
||||
&setup.repo_dir,
|
||||
&["rev-parse", &format!("fabro/run/{new_run_id}")],
|
||||
);
|
||||
let expected_head = run_branch_commits(&setup.repo_dir, &setup.run.run_id)
|
||||
.into_iter()
|
||||
.last()
|
||||
|
|
@ -128,10 +128,10 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() {
|
|||
);
|
||||
let new_run_id = &new_run_ids[0];
|
||||
|
||||
let new_head = git_stdout(&setup.repo_dir, &[
|
||||
"rev-parse",
|
||||
&format!("fabro/run/{new_run_id}"),
|
||||
]);
|
||||
let new_head = git_stdout(
|
||||
&setup.repo_dir,
|
||||
&["rev-parse", &format!("fabro/run/{new_run_id}")],
|
||||
);
|
||||
assert_eq!(new_head.trim(), expected_head);
|
||||
|
||||
let checkpoint = git_show_json(
|
||||
|
|
|
|||
|
|
@ -91,14 +91,17 @@ fn logs_completed_run_outputs_raw_ndjson() {
|
|||
|
||||
let events = parse_ndjson(&output.stdout);
|
||||
assert_events_belong_to_run(&events, &run.run_id);
|
||||
assert_event_sequence_contains(&events, &[
|
||||
"run.created",
|
||||
"run.running",
|
||||
"stage.started",
|
||||
"stage.completed",
|
||||
"run.completed",
|
||||
"sandbox.cleanup.completed",
|
||||
]);
|
||||
assert_event_sequence_contains(
|
||||
&events,
|
||||
&[
|
||||
"run.created",
|
||||
"run.running",
|
||||
"stage.started",
|
||||
"stage.completed",
|
||||
"run.completed",
|
||||
"sandbox.cleanup.completed",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -224,12 +227,15 @@ fn logs_follow_detached_run_streams_until_completion() {
|
|||
|
||||
let events = parse_ndjson(&output.stdout);
|
||||
assert_events_belong_to_run(&events, &run.run_id);
|
||||
assert_event_sequence_contains(&events, &[
|
||||
"run.created",
|
||||
"run.running",
|
||||
"stage.started",
|
||||
"stage.completed",
|
||||
"run.completed",
|
||||
"sandbox.cleanup.completed",
|
||||
]);
|
||||
assert_event_sequence_contains(
|
||||
&events,
|
||||
&[
|
||||
"run.created",
|
||||
"run.running",
|
||||
"stage.started",
|
||||
"stage.completed",
|
||||
"run.completed",
|
||||
"sandbox.cleanup.completed",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,14 +98,14 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
|
|||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::PullRequestCreated(PullRequestCreatedProps {
|
||||
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
|
||||
pr_number: 123,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
|
||||
pr_number: 123,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
head_branch: "fabro/run/demo".to_string(),
|
||||
title: "Map the constellations".to_string(),
|
||||
draft: false,
|
||||
title: "Map the constellations".to_string(),
|
||||
draft: false,
|
||||
}),
|
||||
};
|
||||
client
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ fn resume_rewound_run_succeeds() {
|
|||
String::from_utf8_lossy(&rewind.stdout),
|
||||
output_stderr(&rewind)
|
||||
);
|
||||
let rewound_head = git_stdout(&setup.repo_dir, &[
|
||||
"rev-parse",
|
||||
&format!("fabro/run/{}", setup.run.run_id),
|
||||
]);
|
||||
let rewound_head = git_stdout(
|
||||
&setup.repo_dir,
|
||||
&["rev-parse", &format!("fabro/run/{}", setup.run.run_id)],
|
||||
);
|
||||
|
||||
let mut resume_cmd = context.command();
|
||||
resume_cmd.current_dir(&setup.repo_dir);
|
||||
|
|
@ -109,10 +109,10 @@ fn resume_rewound_run_succeeds() {
|
|||
std::fs::read_to_string(setup.repo_dir.join("story.txt")).unwrap(),
|
||||
"line 1\n"
|
||||
);
|
||||
let resumed_head = git_stdout(&setup.repo_dir, &[
|
||||
"rev-parse",
|
||||
&format!("fabro/run/{}", setup.run.run_id),
|
||||
]);
|
||||
let resumed_head = git_stdout(
|
||||
&setup.repo_dir,
|
||||
&["rev-parse", &format!("fabro/run/{}", setup.run.run_id)],
|
||||
);
|
||||
assert_ne!(resumed_head.trim(), rewound_head.trim());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,10 +99,10 @@ fn rewind_target_updates_metadata_and_resume_hint() {
|
|||
");
|
||||
assert!(output.status.success(), "rewind should succeed");
|
||||
|
||||
let run_head = git_stdout(&setup.repo_dir, &[
|
||||
"rev-parse",
|
||||
&format!("fabro/run/{}", setup.run.run_id),
|
||||
]);
|
||||
let run_head = git_stdout(
|
||||
&setup.repo_dir,
|
||||
&["rev-parse", &format!("fabro/run/{}", setup.run.run_id)],
|
||||
);
|
||||
assert_eq!(run_head.trim(), expected_run_head);
|
||||
|
||||
let mut list_cmd = context.command();
|
||||
|
|
|
|||
|
|
@ -31,23 +31,23 @@ struct RunSummaryRecord {
|
|||
}
|
||||
|
||||
pub(crate) struct RunSetup {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) run_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) struct GitRunSetup {
|
||||
pub(crate) run: RunSetup,
|
||||
pub(crate) run: RunSetup,
|
||||
pub(crate) repo_dir: PathBuf,
|
||||
pub(crate) base_sha: String,
|
||||
}
|
||||
|
||||
pub(crate) struct ProjectFixture {
|
||||
pub(crate) project_dir: PathBuf,
|
||||
pub(crate) fabro_root: PathBuf,
|
||||
pub(crate) fabro_root: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) struct WorkspaceRunSetup {
|
||||
pub(crate) run: RunSetup,
|
||||
pub(crate) run: RunSetup,
|
||||
pub(crate) workspace_dir: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -148,10 +148,10 @@ fn run_completed_dry_run(context: &TestContext, workflow: &Path) -> RunSetup {
|
|||
run_dir: context.find_run_dir(&run_id),
|
||||
run_id,
|
||||
};
|
||||
wait_for_event_names(&run_setup.run_dir, &[
|
||||
"run.completed",
|
||||
"sandbox.cleanup.completed",
|
||||
]);
|
||||
wait_for_event_names(
|
||||
&run_setup.run_dir,
|
||||
&["run.completed", "sandbox.cleanup.completed"],
|
||||
);
|
||||
run_setup
|
||||
}
|
||||
|
||||
|
|
@ -719,11 +719,10 @@ pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
|
|||
}
|
||||
|
||||
pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec<String> {
|
||||
git_stdout(repo_dir, &[
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
&format!("fabro/run/{run_id}"),
|
||||
])
|
||||
git_stdout(
|
||||
repo_dir,
|
||||
&["rev-list", "--reverse", &format!("fabro/run/{run_id}")],
|
||||
)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
|
|
@ -736,11 +735,14 @@ pub(crate) fn run_branch_commits_since_base(
|
|||
run_id: &str,
|
||||
base_sha: &str,
|
||||
) -> Vec<String> {
|
||||
git_stdout(repo_dir, &[
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
&format!("{base_sha}..fabro/run/{run_id}"),
|
||||
])
|
||||
git_stdout(
|
||||
repo_dir,
|
||||
&[
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
&format!("{base_sha}..fabro/run/{run_id}"),
|
||||
],
|
||||
)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
|
|
@ -928,9 +930,11 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
|
|||
git_success(&repo_dir, &["config", "user.email", "test@example.com"]);
|
||||
|
||||
write_text_file(&repo_dir.join("story.txt"), "line 1\n");
|
||||
write_text_file(&repo_dir.join("flow.fabro"), match workflow {
|
||||
GitWorkflowKind::Changed => {
|
||||
r#"digraph Flow {
|
||||
write_text_file(
|
||||
&repo_dir.join("flow.fabro"),
|
||||
match workflow {
|
||||
GitWorkflowKind::Changed => {
|
||||
r#"digraph Flow {
|
||||
graph [goal="Edit a tracked file"];
|
||||
start [shape=Mdiamond];
|
||||
exit [shape=Msquare];
|
||||
|
|
@ -939,9 +943,9 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
|
|||
start -> step_one -> step_two -> exit;
|
||||
}
|
||||
"#
|
||||
}
|
||||
GitWorkflowKind::Noop => {
|
||||
r#"digraph Flow {
|
||||
}
|
||||
GitWorkflowKind::Noop => {
|
||||
r#"digraph Flow {
|
||||
graph [goal="Leave tracked files unchanged"];
|
||||
start [shape=Mdiamond];
|
||||
exit [shape=Msquare];
|
||||
|
|
@ -949,8 +953,9 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
|
|||
start -> check -> exit;
|
||||
}
|
||||
"#
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]);
|
||||
git_success(&repo_dir, &["commit", "-qm", "init"]);
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ pub enum EffectiveSettingsMode {
|
|||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EffectiveSettingsLayers {
|
||||
pub args: SettingsLayer,
|
||||
pub args: SettingsLayer,
|
||||
pub workflow: SettingsLayer,
|
||||
pub project: SettingsLayer,
|
||||
pub user: SettingsLayer,
|
||||
pub project: SettingsLayer,
|
||||
pub user: SettingsLayer,
|
||||
}
|
||||
|
||||
impl EffectiveSettingsLayers {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ fn format_path_suffix(path: Option<&PathBuf>) -> String {
|
|||
pub enum Error {
|
||||
#[error("reading config file {path}: {source}")]
|
||||
ReadFile {
|
||||
path: PathBuf,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
|
@ -32,14 +32,14 @@ pub enum Error {
|
|||
#[error("{context}{}: {source}", format_path_suffix(.path.as_ref()))]
|
||||
ParseSettings {
|
||||
context: &'static str,
|
||||
path: Option<PathBuf>,
|
||||
path: Option<PathBuf>,
|
||||
#[source]
|
||||
source: ParseError,
|
||||
source: ParseError,
|
||||
},
|
||||
|
||||
#[error("parsing TOML config at {path}: {source}")]
|
||||
TomlParse {
|
||||
path: PathBuf,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: TomlError,
|
||||
},
|
||||
|
|
@ -47,13 +47,13 @@ pub enum Error {
|
|||
#[error("{context}:\n{}", format_resolve_errors(.errors))]
|
||||
Resolve {
|
||||
context: &'static str,
|
||||
errors: Vec<ResolveError>,
|
||||
errors: Vec<ResolveError>,
|
||||
},
|
||||
|
||||
#[error("missing required environment variable {var} for {field}")]
|
||||
MissingEnvVar {
|
||||
field: String,
|
||||
var: String,
|
||||
field: String,
|
||||
var: String,
|
||||
#[source]
|
||||
source: std::env::VarError,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ use fabro_types::settings::workflow::WorkflowLayer;
|
|||
#[must_use]
|
||||
pub fn combine_files(lower: SettingsLayer, higher: SettingsLayer) -> SettingsLayer {
|
||||
SettingsLayer {
|
||||
version: higher.version.or(lower.version),
|
||||
project: merge_option(lower.project, higher.project, combine_project),
|
||||
version: higher.version.or(lower.version),
|
||||
project: merge_option(lower.project, higher.project, combine_project),
|
||||
workflow: merge_option(lower.workflow, higher.workflow, combine_workflow),
|
||||
run: merge_option(lower.run, higher.run, combine_run),
|
||||
cli: merge_option(lower.cli, higher.cli, combine_cli),
|
||||
server: merge_option(lower.server, higher.server, combine_server),
|
||||
run: merge_option(lower.run, higher.run, combine_run),
|
||||
cli: merge_option(lower.cli, higher.cli, combine_cli),
|
||||
server: merge_option(lower.server, higher.server, combine_server),
|
||||
features: replace_if_some(lower.features, higher.features),
|
||||
}
|
||||
}
|
||||
|
|
@ -75,10 +75,10 @@ fn merge_string_map_sticky<T>(
|
|||
|
||||
fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer {
|
||||
ProjectLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
name: higher.name.or(lower.name),
|
||||
description: higher.description.or(lower.description),
|
||||
directory: higher.directory.or(lower.directory),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
directory: higher.directory.or(lower.directory),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,10 +86,10 @@ fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer {
|
|||
|
||||
fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer {
|
||||
WorkflowLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
name: higher.name.or(lower.name),
|
||||
description: higher.description.or(lower.description),
|
||||
graph: higher.graph.or(lower.graph),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
graph: higher.graph.or(lower.graph),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,30 +97,30 @@ fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLaye
|
|||
|
||||
fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer {
|
||||
RunLayer {
|
||||
goal: higher.goal.or(lower.goal),
|
||||
working_dir: higher.working_dir.or(lower.working_dir),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
inputs: higher.inputs.or(lower.inputs),
|
||||
model: merge_option(lower.model, higher.model, combine_run_model),
|
||||
git: merge_option(lower.git, higher.git, combine_run_git),
|
||||
prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare),
|
||||
execution: merge_option(lower.execution, higher.execution, combine_run_execution),
|
||||
checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint),
|
||||
sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox),
|
||||
goal: higher.goal.or(lower.goal),
|
||||
working_dir: higher.working_dir.or(lower.working_dir),
|
||||
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
|
||||
inputs: higher.inputs.or(lower.inputs),
|
||||
model: merge_option(lower.model, higher.model, combine_run_model),
|
||||
git: merge_option(lower.git, higher.git, combine_run_git),
|
||||
prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare),
|
||||
execution: merge_option(lower.execution, higher.execution, combine_run_execution),
|
||||
checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint),
|
||||
sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox),
|
||||
notifications: combine_notifications(lower.notifications, higher.notifications),
|
||||
interviews: merge_option(lower.interviews, higher.interviews, combine_interviews),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_run_agent),
|
||||
hooks: combine_hooks(lower.hooks, higher.hooks),
|
||||
scm: merge_option(lower.scm, higher.scm, combine_run_scm),
|
||||
pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr),
|
||||
artifacts: replace_if_some(lower.artifacts, higher.artifacts),
|
||||
interviews: merge_option(lower.interviews, higher.interviews, combine_interviews),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_run_agent),
|
||||
hooks: combine_hooks(lower.hooks, higher.hooks),
|
||||
scm: merge_option(lower.scm, higher.scm, combine_run_scm),
|
||||
pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr),
|
||||
artifacts: replace_if_some(lower.artifacts, higher.artifacts),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer {
|
||||
RunModelLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
name: higher.name.or(lower.name),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
name: higher.name.or(lower.name),
|
||||
fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks),
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer {
|
|||
|
||||
fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer {
|
||||
GitAuthorLayer {
|
||||
name: higher.name.or(lower.name),
|
||||
name: higher.name.or(lower.name),
|
||||
email: higher.email.or(lower.email),
|
||||
}
|
||||
}
|
||||
|
|
@ -174,9 +174,9 @@ fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunP
|
|||
|
||||
fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer {
|
||||
RunExecutionLayer {
|
||||
mode: higher.mode.or(lower.mode),
|
||||
mode: higher.mode.or(lower.mode),
|
||||
approval: higher.approval.or(lower.approval),
|
||||
retros: higher.retros.or(lower.retros),
|
||||
retros: higher.retros.or(lower.retros),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,13 +194,13 @@ fn combine_run_checkpoint(
|
|||
|
||||
fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer {
|
||||
RunSandboxLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
preserve: higher.preserve.or(lower.preserve),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
preserve: higher.preserve.or(lower.preserve),
|
||||
devcontainer: higher.devcontainer.or(lower.devcontainer),
|
||||
// Sticky merge-by-key for run.sandbox.env per R71.
|
||||
env: merge_string_map_sticky(lower.env, higher.env),
|
||||
local: higher.local.or(lower.local),
|
||||
daytona: merge_option(lower.daytona, higher.daytona, combine_daytona),
|
||||
env: merge_string_map_sticky(lower.env, higher.env),
|
||||
local: higher.local.or(lower.local),
|
||||
daytona: merge_option(lower.daytona, higher.daytona, combine_daytona),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,10 +208,10 @@ fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> D
|
|||
DaytonaSandboxLayer {
|
||||
auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval),
|
||||
// Sticky merge-by-key for provider-native labels per R71.
|
||||
labels: merge_string_map_sticky(lower.labels, higher.labels),
|
||||
snapshot: higher.snapshot.or(lower.snapshot),
|
||||
network: higher.network.or(lower.network),
|
||||
skip_clone: higher.skip_clone.or(lower.skip_clone),
|
||||
labels: merge_string_map_sticky(lower.labels, higher.labels),
|
||||
snapshot: higher.snapshot.or(lower.snapshot),
|
||||
network: higher.network.or(lower.network),
|
||||
skip_clone: higher.skip_clone.or(lower.skip_clone),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,12 +237,12 @@ fn combine_notification_route(
|
|||
higher: NotificationRouteLayer,
|
||||
) -> NotificationRouteLayer {
|
||||
NotificationRouteLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
events: splice_events(lower.events, higher.events),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
events: splice_events(lower.events, higher.events),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,9 +275,9 @@ fn splice_events(lower: Vec<StringOrSplice>, higher: Vec<StringOrSplice>) -> Vec
|
|||
fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer {
|
||||
InterviewsLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +285,7 @@ fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLay
|
|||
RunAgentLayer {
|
||||
permissions: higher.permissions.or(lower.permissions),
|
||||
// MCP entries: field-merge per key. Higher replaces lower for same keys.
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,18 +320,18 @@ fn combine_hooks(lower: Vec<HookEntry>, higher: Vec<HookEntry>) -> Vec<HookEntry
|
|||
|
||||
fn combine_run_scm(lower: RunScmLayer, higher: RunScmLayer) -> RunScmLayer {
|
||||
RunScmLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
owner: higher.owner.or(lower.owner),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
owner: higher.owner.or(lower.owner),
|
||||
repository: higher.repository.or(lower.repository),
|
||||
github: higher.github.or(lower.github),
|
||||
github: higher.github.or(lower.github),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer {
|
||||
RunPullRequestLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
draft: higher.draft.or(lower.draft),
|
||||
auto_merge: higher.auto_merge.or(lower.auto_merge),
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
draft: higher.draft.or(lower.draft),
|
||||
auto_merge: higher.auto_merge.or(lower.auto_merge),
|
||||
merge_strategy: higher.merge_strategy.or(lower.merge_strategy),
|
||||
}
|
||||
}
|
||||
|
|
@ -340,10 +340,10 @@ fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> Ru
|
|||
|
||||
fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer {
|
||||
CliLayer {
|
||||
target: merge_option(lower.target, higher.target, combine_cli_target),
|
||||
auth: higher.auth.or(lower.auth),
|
||||
exec: merge_option(lower.exec, higher.exec, combine_cli_exec),
|
||||
output: higher.output.or(lower.output),
|
||||
target: merge_option(lower.target, higher.target, combine_cli_target),
|
||||
auth: higher.auth.or(lower.auth),
|
||||
exec: merge_option(lower.exec, higher.exec, combine_cli_exec),
|
||||
output: higher.output.or(lower.output),
|
||||
updates: higher.updates.or(lower.updates),
|
||||
logging: higher.logging.or(lower.logging),
|
||||
}
|
||||
|
|
@ -357,8 +357,8 @@ fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTarg
|
|||
fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer {
|
||||
CliExecLayer {
|
||||
prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep),
|
||||
model: merge_option(lower.model, higher.model, combine_cli_exec_model),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent),
|
||||
model: merge_option(lower.model, higher.model, combine_cli_exec_model),
|
||||
agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +368,7 @@ fn combine_cli_exec_model(
|
|||
) -> CliExecModelLayer {
|
||||
CliExecModelLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
name: higher.name.or(lower.name),
|
||||
name: higher.name.or(lower.name),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +378,7 @@ fn combine_cli_exec_agent(
|
|||
) -> CliExecAgentLayer {
|
||||
CliExecAgentLayer {
|
||||
permissions: higher.permissions.or(lower.permissions),
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -386,15 +386,15 @@ fn combine_cli_exec_agent(
|
|||
|
||||
fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer {
|
||||
ServerLayer {
|
||||
listen: merge_option(lower.listen, higher.listen, combine_listen),
|
||||
api: higher.api.or(lower.api),
|
||||
web: merge_option(lower.web, higher.web, combine_server_web),
|
||||
auth: merge_option(lower.auth, higher.auth, combine_server_auth),
|
||||
storage: merge_option(lower.storage, higher.storage, combine_server_storage),
|
||||
artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts),
|
||||
slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb),
|
||||
scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler),
|
||||
logging: higher.logging.or(lower.logging),
|
||||
listen: merge_option(lower.listen, higher.listen, combine_listen),
|
||||
api: higher.api.or(lower.api),
|
||||
web: merge_option(lower.web, higher.web, combine_server_web),
|
||||
auth: merge_option(lower.auth, higher.auth, combine_server_auth),
|
||||
storage: merge_option(lower.storage, higher.storage, combine_server_storage),
|
||||
artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts),
|
||||
slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb),
|
||||
scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler),
|
||||
logging: higher.logging.or(lower.logging),
|
||||
integrations: merge_option(
|
||||
lower.integrations,
|
||||
higher.integrations,
|
||||
|
|
@ -411,7 +411,7 @@ fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> Serve
|
|||
fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer {
|
||||
ServerWebLayer {
|
||||
enabled: higher.enabled.or(lower.enabled),
|
||||
url: higher.url.or(lower.url),
|
||||
url: higher.url.or(lower.url),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,9 +437,9 @@ fn combine_server_artifacts(
|
|||
) -> ServerArtifactsLayer {
|
||||
ServerArtifactsLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -448,11 +448,11 @@ fn combine_server_slatedb(
|
|||
higher: ServerSlateDbLayer,
|
||||
) -> ServerSlateDbLayer {
|
||||
ServerSlateDbLayer {
|
||||
provider: higher.provider.or(lower.provider),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
provider: higher.provider.or(lower.provider),
|
||||
prefix: higher.prefix.or(lower.prefix),
|
||||
flush_interval: higher.flush_interval.or(lower.flush_interval),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
local: higher.local.or(lower.local),
|
||||
s3: higher.s3.or(lower.s3),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,10 +470,10 @@ fn combine_server_integrations(
|
|||
higher: ServerIntegrationsLayer,
|
||||
) -> ServerIntegrationsLayer {
|
||||
ServerIntegrationsLayer {
|
||||
github: higher.github.or(lower.github),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
github: higher.github.or(lower.github),
|
||||
slack: higher.slack.or(lower.slack),
|
||||
discord: higher.discord.or(lower.discord),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
teams: higher.teams.or(lower.teams),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ pub fn parse_settings_layer(input: &str) -> Result<SettingsLayer, ParseError> {
|
|||
for key in table.keys() {
|
||||
if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) {
|
||||
return Err(ParseError::UnknownTopLevelKey {
|
||||
key: key.clone(),
|
||||
key: key.clone(),
|
||||
hint: rename_hint(key),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ const CONFIG_FILENAME: &str = ".fabro/project.toml";
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct WorkflowPathResolution {
|
||||
pub resolved_workflow_path: PathBuf,
|
||||
pub dot_path: PathBuf,
|
||||
pub workflow_config: Option<SettingsLayer>,
|
||||
pub workflow_toml_path: Option<PathBuf>,
|
||||
pub workflow_slug: Option<String>,
|
||||
pub dot_path: PathBuf,
|
||||
pub workflow_config: Option<SettingsLayer>,
|
||||
pub workflow_toml_path: Option<PathBuf>,
|
||||
pub workflow_slug: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a project config from a TOML string.
|
||||
|
|
@ -222,8 +222,8 @@ fn user_workflows_dir() -> PathBuf {
|
|||
/// Metadata about a discovered workflow.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WorkflowInfo {
|
||||
pub name: String,
|
||||
pub goal: Option<String>,
|
||||
pub name: String,
|
||||
pub goal: Option<String>,
|
||||
pub source: WorkflowSource,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ use super::{ResolveError, require_interp};
|
|||
|
||||
pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec<ResolveError>) -> CliSettings {
|
||||
CliSettings {
|
||||
target: resolve_target(layer.target.as_ref(), errors),
|
||||
auth: CliAuthSettings {
|
||||
target: resolve_target(layer.target.as_ref(), errors),
|
||||
auth: CliAuthSettings {
|
||||
strategy: layer.auth.as_ref().and_then(|auth| auth.strategy),
|
||||
},
|
||||
exec: resolve_exec(layer.exec.as_ref()),
|
||||
output: CliOutputSettings {
|
||||
format: layer
|
||||
exec: resolve_exec(layer.exec.as_ref()),
|
||||
output: CliOutputSettings {
|
||||
format: layer
|
||||
.output
|
||||
.as_ref()
|
||||
.and_then(|output| output.format)
|
||||
|
|
@ -50,8 +50,8 @@ fn resolve_target(
|
|||
url: require_interp(url.as_ref(), "cli.target.url", errors),
|
||||
tls: tls.as_ref().map(|tls| CliTargetTlsSettings {
|
||||
cert: require_interp(tls.cert.as_ref(), "cli.target.tls.cert", errors),
|
||||
key: require_interp(tls.key.as_ref(), "cli.target.tls.key", errors),
|
||||
ca: require_interp(tls.ca.as_ref(), "cli.target.tls.ca", errors),
|
||||
key: require_interp(tls.key.as_ref(), "cli.target.tls.key", errors),
|
||||
ca: require_interp(tls.ca.as_ref(), "cli.target.tls.ca", errors),
|
||||
}),
|
||||
}),
|
||||
Some(CliTargetLayer::Unix { path }) => Some(CliTargetSettings::Unix {
|
||||
|
|
@ -68,13 +68,13 @@ fn resolve_exec(exec: Option<&CliExecLayer>) -> CliExecSettings {
|
|||
|
||||
CliExecSettings {
|
||||
prevent_idle_sleep: exec.prevent_idle_sleep.unwrap_or(false),
|
||||
model: CliExecModelSettings {
|
||||
model: CliExecModelSettings {
|
||||
provider: exec.model.as_ref().and_then(|model| model.provider.clone()),
|
||||
name: exec.model.as_ref().and_then(|model| model.name.clone()),
|
||||
name: exec.model.as_ref().and_then(|model| model.name.clone()),
|
||||
},
|
||||
agent: CliExecAgentSettings {
|
||||
agent: CliExecAgentSettings {
|
||||
permissions: exec.agent.as_ref().and_then(|agent| agent.permissions),
|
||||
mcps: exec
|
||||
mcps: exec
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|agent| {
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ pub fn resolve(file: &SettingsLayer) -> Result<Settings, Vec<ResolveError>> {
|
|||
let features_layer = file.features.clone().unwrap_or_default();
|
||||
|
||||
let settings = Settings {
|
||||
project: resolve_project(&project_layer, &mut errors),
|
||||
project: resolve_project(&project_layer, &mut errors),
|
||||
workflow: resolve_workflow(&workflow_layer, &mut errors),
|
||||
run: resolve_run(&run_layer, &mut errors),
|
||||
cli: resolve_cli(&cli_layer, &mut errors),
|
||||
server: resolve_server(&server_layer, &mut errors),
|
||||
run: resolve_run(&run_layer, &mut errors),
|
||||
cli: resolve_cli(&cli_layer, &mut errors),
|
||||
server: resolve_server(&server_layer, &mut errors),
|
||||
features: resolve_features(&features_layer, &mut errors),
|
||||
};
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ pub(crate) fn parse_socket_addr(
|
|||
Ok(address) => address,
|
||||
Err(err) => {
|
||||
errors.push(ResolveError::ParseFailure {
|
||||
path: path.to_string(),
|
||||
path: path.to_string(),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
std::net::SocketAddr::from(([127, 0, 0, 1], 0))
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ const DEFAULT_PROJECT_DIRECTORY: &str = ".";
|
|||
|
||||
pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec<ResolveError>) -> ProjectSettings {
|
||||
ProjectSettings {
|
||||
name: layer.name.clone(),
|
||||
name: layer.name.clone(),
|
||||
description: layer.description.clone(),
|
||||
directory: layer
|
||||
directory: layer
|
||||
.directory
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_PROJECT_DIRECTORY.to_string()),
|
||||
metadata: layer.metadata.clone(),
|
||||
metadata: layer.metadata.clone(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,32 +17,32 @@ use super::ResolveError;
|
|||
|
||||
pub fn resolve_run(layer: &RunLayer, errors: &mut Vec<ResolveError>) -> RunSettings {
|
||||
RunSettings {
|
||||
goal: resolve_goal(layer.goal.as_ref()),
|
||||
working_dir: layer.working_dir.clone(),
|
||||
metadata: layer.metadata.clone(),
|
||||
inputs: layer.inputs.clone().unwrap_or_default(),
|
||||
model: resolve_model(layer.model.as_ref()),
|
||||
git: resolve_git(layer.git.as_ref()),
|
||||
prepare: resolve_prepare(layer.prepare.as_ref(), errors),
|
||||
execution: resolve_execution(layer.execution.as_ref()),
|
||||
checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()),
|
||||
sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors),
|
||||
goal: resolve_goal(layer.goal.as_ref()),
|
||||
working_dir: layer.working_dir.clone(),
|
||||
metadata: layer.metadata.clone(),
|
||||
inputs: layer.inputs.clone().unwrap_or_default(),
|
||||
model: resolve_model(layer.model.as_ref()),
|
||||
git: resolve_git(layer.git.as_ref()),
|
||||
prepare: resolve_prepare(layer.prepare.as_ref(), errors),
|
||||
execution: resolve_execution(layer.execution.as_ref()),
|
||||
checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()),
|
||||
sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors),
|
||||
notifications: layer
|
||||
.notifications
|
||||
.iter()
|
||||
.map(|(name, route)| (name.clone(), resolve_notification_route(route)))
|
||||
.collect(),
|
||||
interviews: resolve_interviews(layer.interviews.as_ref()),
|
||||
agent: resolve_agent(layer.agent.as_ref()),
|
||||
hooks: layer
|
||||
interviews: resolve_interviews(layer.interviews.as_ref()),
|
||||
agent: resolve_agent(layer.agent.as_ref()),
|
||||
hooks: layer
|
||||
.hooks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, hook)| resolve_hook(hook, index, errors))
|
||||
.collect(),
|
||||
scm: resolve_scm(layer.scm.as_ref()),
|
||||
pull_request: resolve_pull_request(layer.pull_request.as_ref()),
|
||||
artifacts: resolve_artifacts(layer.artifacts.as_ref()),
|
||||
scm: resolve_scm(layer.scm.as_ref()),
|
||||
pull_request: resolve_pull_request(layer.pull_request.as_ref()),
|
||||
artifacts: resolve_artifacts(layer.artifacts.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,8 +59,8 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings {
|
|||
};
|
||||
|
||||
RunModelSettings {
|
||||
provider: model.provider.clone(),
|
||||
name: model.name.clone(),
|
||||
provider: model.provider.clone(),
|
||||
name: model.name.clone(),
|
||||
fallbacks: model
|
||||
.fallbacks
|
||||
.iter()
|
||||
|
|
@ -76,7 +76,7 @@ fn resolve_git(git: Option<&RunGitLayer>) -> RunGitSettings {
|
|||
RunGitSettings {
|
||||
author: git.and_then(|git| {
|
||||
git.author.as_ref().map(|author| GitAuthorSettings {
|
||||
name: author.name.clone(),
|
||||
name: author.name.clone(),
|
||||
email: author.email.clone(),
|
||||
})
|
||||
}),
|
||||
|
|
@ -102,7 +102,7 @@ fn resolve_prepare(
|
|||
.join(" "),
|
||||
),
|
||||
(Some(_), Some(_)) | (None, None) => errors.push(ResolveError::Invalid {
|
||||
path: format!("run.prepare.steps[{index}]"),
|
||||
path: format!("run.prepare.steps[{index}]"),
|
||||
reason: "exactly one of script or command must be set".to_string(),
|
||||
}),
|
||||
}
|
||||
|
|
@ -122,9 +122,9 @@ fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSetti
|
|||
};
|
||||
|
||||
RunExecutionSettings {
|
||||
mode: execution.mode.unwrap_or(RunMode::Normal),
|
||||
mode: execution.mode.unwrap_or(RunMode::Normal),
|
||||
approval: execution.approval.unwrap_or(ApprovalMode::Prompt),
|
||||
retros: execution.retros.unwrap_or(true),
|
||||
retros: execution.retros.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ fn resolve_sandbox(
|
|||
match provider.as_str() {
|
||||
"local" | "docker" | "daytona" => {}
|
||||
other => errors.push(ResolveError::Invalid {
|
||||
path: "run.sandbox.provider".to_string(),
|
||||
path: "run.sandbox.provider".to_string(),
|
||||
reason: format!("unknown sandbox provider: {other}"),
|
||||
}),
|
||||
}
|
||||
|
|
@ -179,13 +179,13 @@ fn resolve_local_sandbox(sandbox: &RunSandboxLayer) -> LocalSandboxSettings {
|
|||
fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings {
|
||||
DaytonaSettings {
|
||||
auto_stop_interval: daytona.auto_stop_interval,
|
||||
labels: daytona.labels.clone(),
|
||||
snapshot: daytona.snapshot.as_ref().and_then(|snapshot| {
|
||||
labels: daytona.labels.clone(),
|
||||
snapshot: daytona.snapshot.as_ref().and_then(|snapshot| {
|
||||
snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings {
|
||||
name: name.clone(),
|
||||
cpu: snapshot.cpu,
|
||||
memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
name: name.clone(),
|
||||
cpu: snapshot.cpu,
|
||||
memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
dockerfile: snapshot
|
||||
.dockerfile
|
||||
.as_ref()
|
||||
|
|
@ -199,16 +199,16 @@ fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings {
|
|||
}),
|
||||
})
|
||||
}),
|
||||
network: daytona.network.clone(),
|
||||
skip_clone: daytona.skip_clone.unwrap_or(false),
|
||||
network: daytona.network.clone(),
|
||||
skip_clone: daytona.skip_clone.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRouteSettings {
|
||||
NotificationRouteSettings {
|
||||
enabled: route.enabled.unwrap_or(false),
|
||||
enabled: route.enabled.unwrap_or(false),
|
||||
provider: route.provider.clone(),
|
||||
events: route
|
||||
events: route
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
|
|
@ -216,9 +216,9 @@ fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRou
|
|||
StringOrSplice::Splice => None,
|
||||
})
|
||||
.collect(),
|
||||
slack: route.slack.as_ref().map(resolve_notification_provider),
|
||||
discord: route.discord.as_ref().map(resolve_notification_provider),
|
||||
teams: route.teams.as_ref().map(resolve_notification_provider),
|
||||
slack: route.slack.as_ref().map(resolve_notification_provider),
|
||||
discord: route.discord.as_ref().map(resolve_notification_provider),
|
||||
teams: route.teams.as_ref().map(resolve_notification_provider),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +237,9 @@ fn resolve_interviews(interviews: Option<&InterviewsLayer>) -> RunInterviewsSett
|
|||
|
||||
RunInterviewsSettings {
|
||||
provider: interviews.provider.clone(),
|
||||
slack: interviews.slack.as_ref().map(resolve_interview_provider),
|
||||
discord: interviews.discord.as_ref().map(resolve_interview_provider),
|
||||
teams: interviews.teams.as_ref().map(resolve_interview_provider),
|
||||
slack: interviews.slack.as_ref().map(resolve_interview_provider),
|
||||
discord: interviews.discord.as_ref().map(resolve_interview_provider),
|
||||
teams: interviews.teams.as_ref().map(resolve_interview_provider),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +256,7 @@ fn resolve_agent(agent: Option<&RunAgentLayer>) -> RunAgentSettings {
|
|||
|
||||
RunAgentSettings {
|
||||
permissions: agent.permissions,
|
||||
mcps: agent
|
||||
mcps: agent
|
||||
.mcps
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.clone(), resolve_mcp_entry(name, entry)))
|
||||
|
|
@ -273,13 +273,13 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS
|
|||
..
|
||||
} => McpTransport::Stdio {
|
||||
command: resolve_mcp_command(script.as_ref(), command.as_ref()),
|
||||
env: env
|
||||
env: env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.as_source()))
|
||||
.collect(),
|
||||
},
|
||||
McpEntryLayer::Http { url, headers, .. } => McpTransport::Http {
|
||||
url: url.as_source(),
|
||||
url: url.as_source(),
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.as_source()))
|
||||
|
|
@ -293,8 +293,8 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS
|
|||
..
|
||||
} => McpTransport::Sandbox {
|
||||
command: resolve_mcp_command(script.as_ref(), command.as_ref()),
|
||||
port: *port,
|
||||
env: env
|
||||
port: *port,
|
||||
env: env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.as_source()))
|
||||
.collect(),
|
||||
|
|
@ -355,7 +355,7 @@ fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>)
|
|||
|
||||
if variants != 1 {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("run.hooks[{index}]"),
|
||||
path: format!("run.hooks[{index}]"),
|
||||
reason: "exactly one hook transport must be configured".to_string(),
|
||||
});
|
||||
}
|
||||
|
|
@ -419,19 +419,19 @@ fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> {
|
|||
|
||||
if hook.agent == Some(HookAgentMarker::Enabled) {
|
||||
return Some(HookType::Agent {
|
||||
prompt: hook
|
||||
prompt: hook
|
||||
.prompt
|
||||
.as_ref()
|
||||
.map(InterpString::as_source)
|
||||
.unwrap_or_default(),
|
||||
model: hook.model.as_ref().map(InterpString::as_source),
|
||||
model: hook.model.as_ref().map(InterpString::as_source),
|
||||
max_tool_rounds: hook.max_tool_rounds,
|
||||
});
|
||||
}
|
||||
|
||||
hook.prompt.as_ref().map(|prompt| HookType::Prompt {
|
||||
prompt: prompt.as_source(),
|
||||
model: hook.model.as_ref().map(InterpString::as_source),
|
||||
model: hook.model.as_ref().map(InterpString::as_source),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -441,10 +441,10 @@ fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings {
|
|||
};
|
||||
|
||||
RunScmSettings {
|
||||
provider: scm.provider.clone(),
|
||||
owner: scm.owner.clone(),
|
||||
provider: scm.provider.clone(),
|
||||
owner: scm.owner.clone(),
|
||||
repository: scm.repository.clone(),
|
||||
github: scm.github.as_ref().map(|_| ScmGitHubSettings),
|
||||
github: scm.github.as_ref().map(|_| ScmGitHubSettings),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,9 +455,9 @@ fn resolve_pull_request(pull_request: Option<&RunPullRequestLayer>) -> Option<Pu
|
|||
}
|
||||
|
||||
Some(PullRequestSettings {
|
||||
enabled: true,
|
||||
draft: pull_request.draft.unwrap_or(true),
|
||||
auto_merge: pull_request.auto_merge.unwrap_or(false),
|
||||
enabled: true,
|
||||
draft: pull_request.draft.unwrap_or(true),
|
||||
auto_merge: pull_request.auto_merge.unwrap_or(false),
|
||||
merge_strategy: pull_request.merge_strategy.unwrap_or(MergeStrategy::Squash),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ fn resolve_tls(
|
|||
fn resolve_web(_api: Option<&ServerApiLayer>, layer: Option<&ServerWebLayer>) -> ServerWebSettings {
|
||||
ServerWebSettings {
|
||||
enabled: layer.and_then(|web| web.enabled).unwrap_or(true),
|
||||
url: layer
|
||||
url: layer
|
||||
.and_then(|web| web.url.clone())
|
||||
.unwrap_or_else(|| InterpString::parse("http://localhost:3000")),
|
||||
}
|
||||
|
|
@ -125,20 +125,20 @@ fn resolve_auth(
|
|||
|
||||
let jwt = api.and_then(|api| {
|
||||
api.jwt.as_ref().map(|jwt| ServerAuthApiJwtSettings {
|
||||
enabled: jwt.enabled.unwrap_or(true),
|
||||
issuer: jwt.issuer.clone(),
|
||||
enabled: jwt.enabled.unwrap_or(true),
|
||||
issuer: jwt.issuer.clone(),
|
||||
audience: jwt.audience.clone(),
|
||||
})
|
||||
});
|
||||
let mtls = api.and_then(|api| {
|
||||
api.mtls.as_ref().map(|mtls| ServerAuthApiMtlsSettings {
|
||||
enabled: mtls.enabled.unwrap_or(true),
|
||||
ca: mtls.ca.clone(),
|
||||
ca: mtls.ca.clone(),
|
||||
})
|
||||
});
|
||||
if mtls.as_ref().is_some_and(|mtls| mtls.enabled) && !valid_tls {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: "server.auth.api.mtls".to_string(),
|
||||
path: "server.auth.api.mtls".to_string(),
|
||||
reason: "requires tcp listen with tls cert, key, and ca configured".to_string(),
|
||||
});
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ fn resolve_auth(
|
|||
allowed_usernames: web
|
||||
.map(|web| web.allowed_usernames.clone())
|
||||
.unwrap_or_default(),
|
||||
providers: ServerAuthWebProvidersSettings {
|
||||
providers: ServerAuthWebProvidersSettings {
|
||||
github: web
|
||||
.and_then(|web| web.providers.as_ref())
|
||||
.and_then(|providers| providers.github.as_ref())
|
||||
|
|
@ -161,8 +161,8 @@ fn resolve_auth(
|
|||
|
||||
fn resolve_web_github(layer: &ServerAuthWebGithubLayer) -> GithubOauthSettings {
|
||||
GithubOauthSettings {
|
||||
enabled: layer.enabled.unwrap_or(true),
|
||||
client_id: layer.client_id.clone(),
|
||||
enabled: layer.enabled.unwrap_or(true),
|
||||
client_id: layer.client_id.clone(),
|
||||
client_secret: layer.client_secret.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -180,7 +180,7 @@ fn resolve_artifacts(
|
|||
prefix: layer
|
||||
.and_then(|artifacts| artifacts.prefix.clone())
|
||||
.unwrap_or_else(|| InterpString::parse("artifacts")),
|
||||
store: resolve_object_store(
|
||||
store: resolve_object_store(
|
||||
provider,
|
||||
layer.and_then(|artifacts| artifacts.local.as_ref()),
|
||||
layer.and_then(|artifacts| artifacts.s3.as_ref()),
|
||||
|
|
@ -201,10 +201,10 @@ fn resolve_slatedb(
|
|||
.unwrap_or(ObjectStoreProvider::Local);
|
||||
|
||||
ServerSlateDbSettings {
|
||||
prefix: layer
|
||||
prefix: layer
|
||||
.and_then(|slatedb| slatedb.prefix.clone())
|
||||
.unwrap_or_else(|| InterpString::parse("")),
|
||||
store: resolve_object_store(
|
||||
store: resolve_object_store(
|
||||
provider,
|
||||
layer.and_then(|slatedb| slatedb.local.as_ref()),
|
||||
layer.and_then(|slatedb| slatedb.s3.as_ref()),
|
||||
|
|
@ -255,15 +255,15 @@ fn resolve_object_store(
|
|||
|
||||
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
|
||||
ServerIntegrationsSettings {
|
||||
github: layer
|
||||
github: layer
|
||||
.and_then(|integrations| integrations.github.as_ref())
|
||||
.map(|github| GithubIntegrationSettings {
|
||||
enabled: github.enabled.unwrap_or(true),
|
||||
app_id: github.app_id.clone(),
|
||||
client_id: github.client_id.clone(),
|
||||
slug: github.slug.clone(),
|
||||
enabled: github.enabled.unwrap_or(true),
|
||||
app_id: github.app_id.clone(),
|
||||
client_id: github.client_id.clone(),
|
||||
slug: github.slug.clone(),
|
||||
permissions: github.permissions.clone(),
|
||||
webhooks: github
|
||||
webhooks: github
|
||||
.webhooks
|
||||
.as_ref()
|
||||
.map(|webhooks| IntegrationWebhooksSettings {
|
||||
|
|
@ -271,10 +271,10 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
|
|||
}),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
slack: layer
|
||||
slack: layer
|
||||
.and_then(|integrations| integrations.slack.as_ref())
|
||||
.map(|slack| SlackIntegrationSettings {
|
||||
enabled: slack.enabled.unwrap_or(true),
|
||||
enabled: slack.enabled.unwrap_or(true),
|
||||
default_channel: slack.default_channel.clone(),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
|
|
@ -284,7 +284,7 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
|
|||
enabled: discord.enabled.unwrap_or(true),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
teams: layer
|
||||
teams: layer
|
||||
.and_then(|integrations| integrations.teams.as_ref())
|
||||
.map(|teams| TeamsIntegrationSettings {
|
||||
enabled: teams.enabled.unwrap_or(true),
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ pub fn resolve_workflow(
|
|||
_errors: &mut Vec<ResolveError>,
|
||||
) -> WorkflowSettings {
|
||||
WorkflowSettings {
|
||||
name: layer.name.clone(),
|
||||
name: layer.name.clone(),
|
||||
description: layer.description.clone(),
|
||||
graph: layer
|
||||
graph: layer
|
||||
.graph
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_WORKFLOW_GRAPH.to_string()),
|
||||
metadata: layer.metadata.clone(),
|
||||
metadata: layer.metadata.clone(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub enum ResolveRunGoalError {
|
|||
var: String,
|
||||
},
|
||||
Io {
|
||||
path: PathBuf,
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ pub fn resolve_run_goal(
|
|||
|
||||
match goal {
|
||||
RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal {
|
||||
text: text.as_source(),
|
||||
text: text.as_source(),
|
||||
source: ResolvedGoalSource::Inline,
|
||||
})),
|
||||
RunGoalLayer::File { file } => {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ impl fmt::Display for VisitLimitSource {
|
|||
/// to_fail_outcome().
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HandlerErrorDetail {
|
||||
pub message: String,
|
||||
pub message: String,
|
||||
pub retryable: bool,
|
||||
pub category: Option<FailureCategory>,
|
||||
pub category: Option<FailureCategory>,
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -48,9 +48,9 @@ pub enum Error {
|
|||
"node \"{node_id}\" visited {visits} times ({limit_source} limit {limit}); run is stuck in a cycle"
|
||||
)]
|
||||
VisitLimitExceeded {
|
||||
node_id: String,
|
||||
visits: usize,
|
||||
limit: usize,
|
||||
node_id: String,
|
||||
visits: usize,
|
||||
limit: usize,
|
||||
limit_source: VisitLimitSource,
|
||||
},
|
||||
#[error("stall timeout on node \"{node_id}\"")]
|
||||
|
|
@ -81,8 +81,8 @@ impl Error {
|
|||
Self::Handler { detail } => Outcome {
|
||||
status: StageStatus::Fail,
|
||||
failure: Some(FailureDetail {
|
||||
message: detail.message.clone(),
|
||||
category: detail.category.unwrap_or(FailureCategory::Deterministic),
|
||||
message: detail.message.clone(),
|
||||
category: detail.category.unwrap_or(FailureCategory::Deterministic),
|
||||
signature: detail.signature.clone(),
|
||||
}),
|
||||
..Outcome::default()
|
||||
|
|
@ -93,7 +93,6 @@ impl Error {
|
|||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub type CoreError = Error;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -119,9 +118,9 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
Error::VisitLimitExceeded {
|
||||
node_id: "n1".into(),
|
||||
visits: 5,
|
||||
limit: 3,
|
||||
node_id: "n1".into(),
|
||||
visits: 5,
|
||||
limit: 3,
|
||||
limit_source: VisitLimitSource::Node,
|
||||
}
|
||||
.to_string(),
|
||||
|
|
@ -143,17 +142,17 @@ mod tests {
|
|||
#[test]
|
||||
fn core_error_handler_is_retryable() {
|
||||
let retryable = Error::handler(HandlerErrorDetail {
|
||||
message: "timeout".into(),
|
||||
message: "timeout".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
});
|
||||
assert!(retryable.is_retryable());
|
||||
|
||||
let not_retryable = Error::handler(HandlerErrorDetail {
|
||||
message: "bad input".into(),
|
||||
message: "bad input".into(),
|
||||
retryable: false,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
});
|
||||
assert!(!not_retryable.is_retryable());
|
||||
|
|
@ -163,9 +162,9 @@ mod tests {
|
|||
fn core_error_handler_to_fail_outcome() {
|
||||
use crate::outcome::FailureCategory;
|
||||
let err = Error::handler(HandlerErrorDetail {
|
||||
message: "api down".into(),
|
||||
message: "api down".into(),
|
||||
retryable: true,
|
||||
category: Some(FailureCategory::TransientInfra),
|
||||
category: Some(FailureCategory::TransientInfra),
|
||||
signature: Some("sig123".into()),
|
||||
});
|
||||
let outcome: Outcome = err.to_fail_outcome();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use tokio::time::sleep;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::error::{CoreError, Result, VisitLimitSource};
|
||||
use crate::error::{Error, Result, VisitLimitSource};
|
||||
use crate::graph::{EdgeSpec, Graph, NodeSpec};
|
||||
use crate::handler::NodeHandler;
|
||||
use crate::lifecycle::{
|
||||
|
|
@ -18,15 +18,15 @@ use crate::state::ExecutionState;
|
|||
|
||||
#[derive(Default)]
|
||||
pub struct ExecutorOptions {
|
||||
pub cancel_token: Option<Arc<AtomicBool>>,
|
||||
pub stall_token: Option<CancellationToken>,
|
||||
pub cancel_token: Option<Arc<AtomicBool>>,
|
||||
pub stall_token: Option<CancellationToken>,
|
||||
pub max_node_visits: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct Executor<G: Graph> {
|
||||
handler: Arc<dyn NodeHandler<G>>,
|
||||
handler: Arc<dyn NodeHandler<G>>,
|
||||
lifecycle: Box<dyn RunLifecycle<G>>,
|
||||
options: ExecutorOptions,
|
||||
options: ExecutorOptions,
|
||||
}
|
||||
|
||||
enum NextStep {
|
||||
|
|
@ -37,9 +37,9 @@ enum NextStep {
|
|||
}
|
||||
|
||||
pub struct ExecutorBuilder<G: Graph> {
|
||||
handler: Arc<dyn NodeHandler<G>>,
|
||||
handler: Arc<dyn NodeHandler<G>>,
|
||||
lifecycle: Option<Box<dyn RunLifecycle<G>>>,
|
||||
options: ExecutorOptions,
|
||||
options: ExecutorOptions,
|
||||
}
|
||||
|
||||
impl<G: Graph + 'static> ExecutorBuilder<G> {
|
||||
|
|
@ -77,9 +77,9 @@ impl<G: Graph + 'static> ExecutorBuilder<G> {
|
|||
|
||||
pub fn build(self) -> Executor<G> {
|
||||
Executor {
|
||||
handler: self.handler,
|
||||
handler: self.handler,
|
||||
lifecycle: self.lifecycle.unwrap_or_else(|| Box::new(NoopLifecycle)),
|
||||
options: self.options,
|
||||
options: self.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,13 +99,13 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
state.cancelled = true;
|
||||
let outcome = Outcome::fail("run cancelled");
|
||||
self.lifecycle.on_run_end(&outcome, &state).await;
|
||||
return Err(CoreError::Cancelled);
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
let node = state
|
||||
.current_node(graph)
|
||||
.ok_or_else(|| CoreError::NodeNotFound {
|
||||
.ok_or_else(|| Error::NodeNotFound {
|
||||
id: state.current_node_id.clone(),
|
||||
})?;
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
let visits = state.increment_visits(node.id());
|
||||
if let Some(max) = node.max_visits() {
|
||||
if visits >= max {
|
||||
return Err(CoreError::VisitLimitExceeded {
|
||||
return Err(Error::VisitLimitExceeded {
|
||||
node_id: node.id().to_string(),
|
||||
visits,
|
||||
limit: max,
|
||||
|
|
@ -164,7 +164,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
}
|
||||
if let Some(global_max) = self.options.max_node_visits {
|
||||
if visits >= global_max {
|
||||
return Err(CoreError::VisitLimitExceeded {
|
||||
return Err(Error::VisitLimitExceeded {
|
||||
node_id: node.id().to_string(),
|
||||
visits,
|
||||
limit: global_max,
|
||||
|
|
@ -183,7 +183,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
result
|
||||
}
|
||||
NodeDecision::Block(msg) => {
|
||||
return Err(CoreError::blocked(msg));
|
||||
return Err(Error::blocked(msg));
|
||||
}
|
||||
NodeDecision::Continue => {
|
||||
// Execute with retry, racing against stall token
|
||||
|
|
@ -191,7 +191,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
tokio::select! {
|
||||
r = self.execute_with_retry(&node, &state, graph) => r,
|
||||
() = stall.cancelled() => {
|
||||
return Err(CoreError::StallTimeout {
|
||||
return Err(Error::StallTimeout {
|
||||
node_id: node.id().to_string(),
|
||||
});
|
||||
}
|
||||
|
|
@ -201,11 +201,11 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
};
|
||||
let mut result = match execution_result {
|
||||
Ok(result) => result,
|
||||
Err(CoreError::Cancelled) => {
|
||||
Err(Error::Cancelled) => {
|
||||
state.cancelled = true;
|
||||
let outcome = Outcome::fail("run cancelled");
|
||||
self.lifecycle.on_run_end(&outcome, &state).await;
|
||||
return Err(CoreError::Cancelled);
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
|
@ -278,7 +278,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
};
|
||||
match self.lifecycle.before_attempt(&attempt_ctx, state).await? {
|
||||
NodeDecision::Skip(o) => return Ok(NodeResult::from_skip(*o)),
|
||||
NodeDecision::Block(msg) => return Err(CoreError::blocked(msg)),
|
||||
NodeDecision::Block(msg) => return Err(Error::blocked(msg)),
|
||||
NodeDecision::Continue => {}
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
self.lifecycle.after_attempt(&ctx, state).await?;
|
||||
sleep(delay).await;
|
||||
}
|
||||
Err(e @ CoreError::Handler { .. }) => {
|
||||
Err(e @ Error::Handler { .. }) => {
|
||||
// Convert handler failures to fail outcomes so routing continues.
|
||||
let outcome = e.to_fail_outcome();
|
||||
let result =
|
||||
|
|
@ -385,7 +385,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
match self.lifecycle.on_edge_selected(&ctx, state).await? {
|
||||
EdgeDecision::Continue => return Ok(NextStep::Jump(target.clone())),
|
||||
EdgeDecision::Override(new_target) => return Ok(NextStep::Edge(new_target)),
|
||||
EdgeDecision::Block(msg) => return Err(CoreError::blocked(msg)),
|
||||
EdgeDecision::Block(msg) => return Err(Error::blocked(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -411,7 +411,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
}
|
||||
}
|
||||
EdgeDecision::Override(new_target) => Ok(NextStep::Edge(new_target)),
|
||||
EdgeDecision::Block(msg) => Err(CoreError::blocked(msg)),
|
||||
EdgeDecision::Block(msg) => Err(Error::blocked(msg)),
|
||||
}
|
||||
} else {
|
||||
// No edge found
|
||||
|
|
@ -500,7 +500,7 @@ mod tests {
|
|||
.cancel_token(token)
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::Cancelled)));
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
// ---- Step 9: Terminal nodes, goal gates, visit limits ----
|
||||
|
|
@ -676,7 +676,7 @@ mod tests {
|
|||
ExecutorBuilder::new(Arc::new(AlwaysSucceedHandler) as Arc<dyn NodeHandler<TestGraph>>)
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::VisitLimitExceeded { .. })));
|
||||
assert!(matches!(result, Err(Error::VisitLimitExceeded { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -696,7 +696,7 @@ mod tests {
|
|||
.max_node_visits(3)
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::VisitLimitExceeded { .. })));
|
||||
assert!(matches!(result, Err(Error::VisitLimitExceeded { .. })));
|
||||
}
|
||||
|
||||
// ---- Step 10: Edge selection, jumps, loop restarts ----
|
||||
|
|
@ -922,19 +922,19 @@ mod tests {
|
|||
.cancel_token(token)
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::Cancelled)));
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_preserves_handler_returned_cancellation() {
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(CoreError::Cancelled)]));
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(Error::Cancelled)]));
|
||||
let g = linear_graph(&["start", "end"]);
|
||||
let state = ExecutionState::new(&g).unwrap();
|
||||
let executor = ExecutorBuilder::new(handler as Arc<dyn NodeHandler<TestGraph>>).build();
|
||||
|
||||
let result = executor.run(&g, state).await;
|
||||
|
||||
assert!(matches!(result, Err(CoreError::Cancelled)));
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -950,7 +950,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(CoreError::Cancelled)]));
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(Error::Cancelled)]));
|
||||
let g = linear_graph(&["start", "end"]);
|
||||
let state = ExecutionState::new(&g).unwrap();
|
||||
let executor = ExecutorBuilder::new(handler as Arc<dyn NodeHandler<TestGraph>>)
|
||||
|
|
@ -959,7 +959,7 @@ mod tests {
|
|||
|
||||
let result = executor.run(&g, state).await;
|
||||
|
||||
assert!(matches!(result, Err(CoreError::Cancelled)));
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
assert_eq!(log.lock().unwrap().as_slice(), &[true]);
|
||||
}
|
||||
|
||||
|
|
@ -969,27 +969,27 @@ mod tests {
|
|||
async fn executor_retry_on_retryable_error() {
|
||||
let handler = Arc::new(
|
||||
CountingHandler::new(vec![
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "fail1".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "fail1".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
})),
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "fail2".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "fail2".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
})),
|
||||
Ok(Outcome::success()),
|
||||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1019,11 +1019,11 @@ mod tests {
|
|||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1040,10 +1040,10 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn executor_retry_non_retryable_error_no_retry() {
|
||||
let handler = Arc::new(
|
||||
CountingHandler::new(vec![Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "fatal".into(),
|
||||
CountingHandler::new(vec![Err(Error::handler(HandlerErrorDetail {
|
||||
message: "fatal".into(),
|
||||
retryable: false,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
}))])
|
||||
.with_retry_policy(RetryPolicy::with_max_attempts(3)),
|
||||
|
|
@ -1062,11 +1062,11 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn executor_retry_no_retry_by_default() {
|
||||
// Default policy is RetryPolicy::none() (max_attempts=1)
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(CoreError::handler(
|
||||
let handler = Arc::new(CountingHandler::new(vec![Err(Error::handler(
|
||||
HandlerErrorDetail {
|
||||
message: "fail".into(),
|
||||
message: "fail".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
},
|
||||
))]));
|
||||
|
|
@ -1099,11 +1099,11 @@ mod tests {
|
|||
fn retry_policy(&self, _n: &TestNode, _g: &TestGraph) -> RetryPolicy {
|
||||
RetryPolicy {
|
||||
max_attempts: 2,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1142,21 +1142,21 @@ mod tests {
|
|||
}
|
||||
let handler = Arc::new(
|
||||
CountingHandler::new(vec![
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
})),
|
||||
Ok(Outcome::success()),
|
||||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1186,21 +1186,21 @@ mod tests {
|
|||
}
|
||||
let handler = Arc::new(
|
||||
CountingHandler::new(vec![
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
})),
|
||||
Ok(Outcome::success()),
|
||||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1236,21 +1236,21 @@ mod tests {
|
|||
}
|
||||
let handler = Arc::new(
|
||||
CountingHandler::new(vec![
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "r".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
})),
|
||||
Ok(Outcome::success()), // should not be reached
|
||||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_millis(1),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_millis(1),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1278,11 +1278,11 @@ mod tests {
|
|||
])
|
||||
.with_retry_policy(RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_secs(5),
|
||||
factor: 2.0,
|
||||
max_delay: Duration::from_secs(60),
|
||||
jitter: false,
|
||||
factor: 2.0,
|
||||
max_delay: Duration::from_secs(60),
|
||||
jitter: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -1350,7 +1350,7 @@ mod tests {
|
|||
.lifecycle(Box::new(Blocker))
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::Blocked { .. })));
|
||||
assert!(matches!(result, Err(Error::Blocked { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1429,7 +1429,7 @@ mod tests {
|
|||
.lifecycle(Box::new(EdgeBlocker))
|
||||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(CoreError::Blocked { .. })));
|
||||
assert!(matches!(result, Err(Error::Blocked { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1619,10 +1619,13 @@ mod tests {
|
|||
executor.run(&g, state).await.unwrap();
|
||||
let checkpoints = log.lock().unwrap().clone();
|
||||
// "start" checkpoints with next="work", "work" checkpoints with next="end"
|
||||
assert_eq!(checkpoints, vec![
|
||||
("start".to_string(), Some("work".to_string())),
|
||||
("work".to_string(), Some("end".to_string())),
|
||||
]);
|
||||
assert_eq!(
|
||||
checkpoints,
|
||||
vec![
|
||||
("start".to_string(), Some("work".to_string())),
|
||||
("work".to_string(), Some("end".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1692,10 +1695,13 @@ mod tests {
|
|||
|
||||
executor.run(&g, state).await.unwrap();
|
||||
|
||||
assert_eq!(*log.lock().unwrap(), vec![
|
||||
"after_record:start:start:hello".to_string(),
|
||||
"on_edge_selected:start:hello".to_string(),
|
||||
]);
|
||||
assert_eq!(
|
||||
*log.lock().unwrap(),
|
||||
vec![
|
||||
"after_record:start:start:hello".to_string(),
|
||||
"on_edge_selected:start:hello".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1744,10 +1750,10 @@ mod tests {
|
|||
.lifecycle(Box::new(GateTracker(log2.clone())))
|
||||
.build();
|
||||
executor2.run(&g2, state2).await.unwrap();
|
||||
assert_eq!(log2.lock().unwrap().clone(), vec![(
|
||||
"end".to_string(),
|
||||
false
|
||||
)]);
|
||||
assert_eq!(
|
||||
log2.lock().unwrap().clone(),
|
||||
vec![("end".to_string(), false)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -2006,7 +2012,7 @@ mod tests {
|
|||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
match result {
|
||||
Err(CoreError::StallTimeout { ref node_id }) => {
|
||||
Err(Error::StallTimeout { ref node_id }) => {
|
||||
assert_eq!(node_id, "start");
|
||||
}
|
||||
other => panic!("expected StallTimeout, got {other:?}"),
|
||||
|
|
@ -2035,10 +2041,10 @@ mod tests {
|
|||
if c == 0 {
|
||||
// First call: fail with retryable, then cancel stall during backoff
|
||||
self.stall.cancel();
|
||||
Err(CoreError::handler(HandlerErrorDetail {
|
||||
message: "transient".into(),
|
||||
Err(Error::handler(HandlerErrorDetail {
|
||||
message: "transient".into(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
}))
|
||||
} else {
|
||||
|
|
@ -2048,11 +2054,11 @@ mod tests {
|
|||
fn retry_policy(&self, _n: &TestNode, _g: &TestGraph) -> RetryPolicy {
|
||||
RetryPolicy {
|
||||
max_attempts: 3,
|
||||
backoff: BackoffPolicy {
|
||||
backoff: BackoffPolicy {
|
||||
initial_delay: Duration::from_secs(60),
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_secs(60),
|
||||
jitter: false,
|
||||
factor: 1.0,
|
||||
max_delay: Duration::from_secs(60),
|
||||
jitter: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2068,7 +2074,7 @@ mod tests {
|
|||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(
|
||||
matches!(result, Err(CoreError::StallTimeout { .. })),
|
||||
matches!(result, Err(Error::StallTimeout { .. })),
|
||||
"expected StallTimeout, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -2102,7 +2108,7 @@ mod tests {
|
|||
.build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(
|
||||
matches!(result, Err(CoreError::StallTimeout { .. })),
|
||||
matches!(result, Err(Error::StallTimeout { .. })),
|
||||
"expected StallTimeout, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pub trait EdgeSpec: Send + Sync + Clone {
|
|||
}
|
||||
|
||||
pub struct EdgeSelection<G: Graph + ?Sized> {
|
||||
pub edge: G::Edge,
|
||||
pub edge: G::Edge,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub mod state;
|
|||
pub mod test_fixtures;
|
||||
|
||||
pub use context::Context;
|
||||
pub use error::{CoreError, Error, HandlerErrorDetail, Result, VisitLimitSource};
|
||||
pub use error::{Error, HandlerErrorDetail, Result, VisitLimitSource};
|
||||
pub use executor::{Executor, ExecutorBuilder, ExecutorOptions};
|
||||
pub use graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
pub use handler::NodeHandler;
|
||||
|
|
|
|||
|
|
@ -22,26 +22,26 @@ pub enum EdgeDecision {
|
|||
}
|
||||
|
||||
pub struct AttemptContext<'a, G: Graph> {
|
||||
pub node: &'a G::Node,
|
||||
pub attempt: u32,
|
||||
pub node: &'a G::Node,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
}
|
||||
|
||||
pub struct AttemptResultContext<'a, G: Graph> {
|
||||
pub node: &'a G::Node,
|
||||
pub result: &'a NodeResult<G::Meta>,
|
||||
pub attempt: u32,
|
||||
pub will_retry: bool,
|
||||
pub node: &'a G::Node,
|
||||
pub result: &'a NodeResult<G::Meta>,
|
||||
pub attempt: u32,
|
||||
pub will_retry: bool,
|
||||
pub backoff_delay: Option<Duration>,
|
||||
}
|
||||
|
||||
pub struct EdgeContext<'a, G: Graph> {
|
||||
pub from: &'a str,
|
||||
pub to: &'a str,
|
||||
pub edge: Option<G::Edge>,
|
||||
pub from: &'a str,
|
||||
pub to: &'a str,
|
||||
pub edge: Option<G::Edge>,
|
||||
pub is_jump: bool,
|
||||
pub outcome: &'a Outcome<G::Meta>,
|
||||
pub reason: &'a str,
|
||||
pub reason: &'a str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -272,11 +272,11 @@ mod tests {
|
|||
|
||||
/// A lifecycle that records which callbacks were called.
|
||||
struct RecordingLifecycle {
|
||||
name: String,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
before_node_decision: Mutex<Option<NodeDecision>>,
|
||||
name: String,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
before_node_decision: Mutex<Option<NodeDecision>>,
|
||||
before_attempt_decision: Mutex<Option<NodeDecision>>,
|
||||
edge_decision: Mutex<Option<EdgeDecision>>,
|
||||
edge_decision: Mutex<Option<EdgeDecision>>,
|
||||
}
|
||||
|
||||
impl RecordingLifecycle {
|
||||
|
|
@ -525,8 +525,8 @@ mod tests {
|
|||
let state = ExecutionState::new(&g).unwrap();
|
||||
let node = g.get_node("start").unwrap();
|
||||
let ctx = AttemptContext {
|
||||
node: &node,
|
||||
attempt: 1,
|
||||
node: &node,
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
};
|
||||
let decision = lc.before_attempt(&ctx, &state).await.unwrap();
|
||||
|
|
@ -549,8 +549,8 @@ mod tests {
|
|||
let state = ExecutionState::new(&g).unwrap();
|
||||
let node = g.get_node("start").unwrap();
|
||||
let ctx = AttemptContext {
|
||||
node: &node,
|
||||
attempt: 1,
|
||||
node: &node,
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
};
|
||||
let decision = lc.before_attempt(&ctx, &state).await.unwrap();
|
||||
|
|
@ -569,10 +569,10 @@ mod tests {
|
|||
let node = g.get_node("start").unwrap();
|
||||
let result = NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1);
|
||||
let ctx = AttemptResultContext {
|
||||
node: &node,
|
||||
result: &result,
|
||||
attempt: 1,
|
||||
will_retry: false,
|
||||
node: &node,
|
||||
result: &result,
|
||||
attempt: 1,
|
||||
will_retry: false,
|
||||
backoff_delay: None,
|
||||
};
|
||||
lc.after_attempt(&ctx, &state).await.unwrap();
|
||||
|
|
@ -595,12 +595,12 @@ mod tests {
|
|||
let outcome = Outcome::success();
|
||||
let edge = g.outgoing_edges("start").into_iter().next().unwrap();
|
||||
let ctx = EdgeContext {
|
||||
from: "start",
|
||||
to: "end",
|
||||
edge: Some(edge),
|
||||
from: "start",
|
||||
to: "end",
|
||||
edge: Some(edge),
|
||||
is_jump: false,
|
||||
outcome: &outcome,
|
||||
reason: "unconditional",
|
||||
reason: "unconditional",
|
||||
};
|
||||
let decision = lc.on_edge_selected(&ctx, &state).await.unwrap();
|
||||
assert!(matches!(decision, EdgeDecision::Override(ref t) if t == "other"));
|
||||
|
|
@ -622,12 +622,12 @@ mod tests {
|
|||
let state = ExecutionState::new(&g).unwrap();
|
||||
let outcome = Outcome::success();
|
||||
let ctx = EdgeContext {
|
||||
from: "start",
|
||||
to: "end",
|
||||
edge: None,
|
||||
from: "start",
|
||||
to: "end",
|
||||
edge: None,
|
||||
is_jump: false,
|
||||
outcome: &outcome,
|
||||
reason: "unconditional",
|
||||
reason: "unconditional",
|
||||
};
|
||||
let decision = lc.on_edge_selected(&ctx, &state).await.unwrap();
|
||||
assert!(matches!(decision, EdgeDecision::Block(_)));
|
||||
|
|
@ -641,12 +641,12 @@ mod tests {
|
|||
let state = ExecutionState::new(&g).unwrap();
|
||||
let outcome = Outcome::success();
|
||||
let ctx = EdgeContext::<TestGraph> {
|
||||
from: "start",
|
||||
to: "target",
|
||||
edge: None,
|
||||
from: "start",
|
||||
to: "target",
|
||||
edge: None,
|
||||
is_jump: true,
|
||||
outcome: &outcome,
|
||||
reason: "jump",
|
||||
reason: "jump",
|
||||
};
|
||||
let decision = lc.on_edge_selected(&ctx, &state).await.unwrap();
|
||||
assert!(matches!(decision, EdgeDecision::Continue));
|
||||
|
|
@ -692,8 +692,8 @@ mod tests {
|
|||
let counter = Arc::new(AtomicU32::new(0));
|
||||
|
||||
struct OrderedLifecycle {
|
||||
name: String,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
name: String,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
counter: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
|
|
@ -711,18 +711,18 @@ mod tests {
|
|||
|
||||
let lc = CompositeLifecycle::new(vec![
|
||||
Box::new(OrderedLifecycle {
|
||||
name: "first".into(),
|
||||
log: log.clone(),
|
||||
name: "first".into(),
|
||||
log: log.clone(),
|
||||
counter: counter.clone(),
|
||||
}),
|
||||
Box::new(OrderedLifecycle {
|
||||
name: "second".into(),
|
||||
log: log.clone(),
|
||||
name: "second".into(),
|
||||
log: log.clone(),
|
||||
counter: counter.clone(),
|
||||
}),
|
||||
Box::new(OrderedLifecycle {
|
||||
name: "third".into(),
|
||||
log: log.clone(),
|
||||
name: "third".into(),
|
||||
log: log.clone(),
|
||||
counter: counter.clone(),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ pub use fabro_types::outcome::{
|
|||
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus,
|
||||
};
|
||||
|
||||
use crate::error::CoreError;
|
||||
use crate::error::Error;
|
||||
|
||||
pub trait NodeResultExt<M: OutcomeMeta = ()> {
|
||||
fn from_error(error: &CoreError, duration: Duration, attempts: u32, max_attempts: u32) -> Self;
|
||||
fn from_error(error: &Error, duration: Duration, attempts: u32, max_attempts: u32) -> Self;
|
||||
}
|
||||
|
||||
impl<M: OutcomeMeta> NodeResultExt<M> for NodeResult<M> {
|
||||
fn from_error(error: &CoreError, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
|
||||
fn from_error(error: &Error, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
|
||||
Self {
|
||||
outcome: error.to_fail_outcome(),
|
||||
duration,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ pub use fabro_util::backoff::BackoffPolicy;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u32,
|
||||
pub backoff: BackoffPolicy,
|
||||
pub backoff: BackoffPolicy,
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
pub fn none() -> Self {
|
||||
Self {
|
||||
max_attempts: 1,
|
||||
backoff: BackoffPolicy::default(),
|
||||
backoff: BackoffPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,18 +16,18 @@ pub trait ActivityMonitor: Send + Sync {
|
|||
/// Watches for inactivity and fires a stall timeout if no activity is
|
||||
/// reported within the configured duration.
|
||||
pub struct StallWatchdog {
|
||||
timeout: Duration,
|
||||
timeout: Duration,
|
||||
cancel_token: Arc<AtomicBool>,
|
||||
activity: Arc<Notify>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
monitor: Arc<dyn ActivityMonitor>,
|
||||
activity: Arc<Notify>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
monitor: Arc<dyn ActivityMonitor>,
|
||||
}
|
||||
|
||||
/// Guard that resets the stall timer on activity. Drop to stop watching.
|
||||
pub struct StallGuard {
|
||||
activity: Arc<Notify>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl StallWatchdog {
|
||||
|
|
@ -82,7 +82,7 @@ impl StallWatchdog {
|
|||
StallGuard {
|
||||
activity: self.activity,
|
||||
shutdown: self.shutdown,
|
||||
handle: Some(handle),
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,30 +17,30 @@ impl<M: OutcomeMeta> std::fmt::Debug for ExecutionState<M> {
|
|||
}
|
||||
|
||||
pub struct ExecutionState<M: OutcomeMeta = ()> {
|
||||
pub context: Context,
|
||||
pub current_node_id: String,
|
||||
pub completed_nodes: Vec<String>,
|
||||
pub node_outcomes: HashMap<String, Outcome<M>>,
|
||||
pub node_retries: HashMap<String, u32>,
|
||||
pub node_visits: HashMap<String, usize>,
|
||||
pub stage_index: usize,
|
||||
pub context: Context,
|
||||
pub current_node_id: String,
|
||||
pub completed_nodes: Vec<String>,
|
||||
pub node_outcomes: HashMap<String, Outcome<M>>,
|
||||
pub node_retries: HashMap<String, u32>,
|
||||
pub node_visits: HashMap<String, usize>,
|
||||
pub stage_index: usize,
|
||||
pub previous_node_id: Option<String>,
|
||||
pub cancelled: bool,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl<M: OutcomeMeta> ExecutionState<M> {
|
||||
pub fn new<G: Graph>(graph: &G) -> Result<Self> {
|
||||
let start = graph.find_start_node()?;
|
||||
Ok(Self {
|
||||
context: Context::new(),
|
||||
current_node_id: start.id().to_string(),
|
||||
completed_nodes: Vec::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
node_retries: HashMap::new(),
|
||||
node_visits: HashMap::new(),
|
||||
stage_index: 0,
|
||||
context: Context::new(),
|
||||
current_node_id: start.id().to_string(),
|
||||
completed_nodes: Vec::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
node_retries: HashMap::new(),
|
||||
node_visits: HashMap::new(),
|
||||
stage_index: 0,
|
||||
previous_node_id: None,
|
||||
cancelled: false,
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::error::{CoreError, HandlerErrorDetail, Result};
|
||||
use crate::error::{Error, HandlerErrorDetail, Result};
|
||||
use crate::graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
use crate::handler::NodeHandler;
|
||||
use crate::outcome::{Outcome, StageStatus};
|
||||
|
|
@ -15,28 +15,28 @@ use crate::retry::RetryPolicy;
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestNode {
|
||||
pub id: String,
|
||||
pub terminal: bool,
|
||||
pub id: String,
|
||||
pub terminal: bool,
|
||||
pub max_visits: Option<usize>,
|
||||
pub goal_gate: Option<(String, StageStatus)>,
|
||||
pub goal_gate: Option<(String, StageStatus)>,
|
||||
}
|
||||
|
||||
impl TestNode {
|
||||
pub fn new(id: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
terminal: false,
|
||||
id: id.to_string(),
|
||||
terminal: false,
|
||||
max_visits: None,
|
||||
goal_gate: None,
|
||||
goal_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminal(id: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
terminal: true,
|
||||
id: id.to_string(),
|
||||
terminal: true,
|
||||
max_visits: None,
|
||||
goal_gate: None,
|
||||
goal_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,18 +71,18 @@ impl NodeSpec for TestNode {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub label: Option<String>,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub label: Option<String>,
|
||||
pub loop_restart: bool,
|
||||
}
|
||||
|
||||
impl TestEdge {
|
||||
pub fn new(from: &str, to: &str) -> Self {
|
||||
Self {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
label: None,
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
label: None,
|
||||
loop_restart: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -118,8 +118,8 @@ impl EdgeSpec for TestEdge {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestGraph {
|
||||
pub nodes: Vec<TestNode>,
|
||||
pub edges: Vec<TestEdge>,
|
||||
pub nodes: Vec<TestNode>,
|
||||
pub edges: Vec<TestEdge>,
|
||||
pub start_node_id: String,
|
||||
pub retry_targets: HashMap<String, String>,
|
||||
}
|
||||
|
|
@ -151,8 +151,7 @@ impl Graph for TestGraph {
|
|||
}
|
||||
|
||||
fn find_start_node(&self) -> Result<Self::Node> {
|
||||
self.get_node(&self.start_node_id)
|
||||
.ok_or(CoreError::NoStartNode)
|
||||
self.get_node(&self.start_node_id).ok_or(Error::NoStartNode)
|
||||
}
|
||||
|
||||
fn outgoing_edges(&self, node_id: &str) -> Vec<Self::Edge> {
|
||||
|
|
@ -181,7 +180,7 @@ impl Graph for TestGraph {
|
|||
.find(|e| e.label.as_deref() == Some(label.as_str()))
|
||||
{
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
edge: e.clone(),
|
||||
reason: "preferred_label",
|
||||
});
|
||||
}
|
||||
|
|
@ -194,7 +193,7 @@ impl Graph for TestGraph {
|
|||
.find(|e| e.label.as_deref() == Some(status_label.as_str()))
|
||||
{
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
edge: e.clone(),
|
||||
reason: "condition",
|
||||
});
|
||||
}
|
||||
|
|
@ -203,7 +202,7 @@ impl Graph for TestGraph {
|
|||
for suggested in &outcome.suggested_next_ids {
|
||||
if let Some(e) = edges.iter().find(|e| e.to == *suggested) {
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
edge: e.clone(),
|
||||
reason: "suggested_next",
|
||||
});
|
||||
}
|
||||
|
|
@ -212,7 +211,7 @@ impl Graph for TestGraph {
|
|||
// Fourth: unconditional (no label)
|
||||
if let Some(e) = edges.iter().find(|e| e.label.is_none()) {
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
edge: e.clone(),
|
||||
reason: "unconditional",
|
||||
});
|
||||
}
|
||||
|
|
@ -287,16 +286,16 @@ impl NodeHandler<TestGraph> for AlwaysFailHandler {
|
|||
}
|
||||
|
||||
pub struct CountingHandler {
|
||||
pub call_count: AtomicU32,
|
||||
pub outcomes: std::sync::Mutex<Vec<std::result::Result<Outcome, CoreError>>>,
|
||||
pub call_count: AtomicU32,
|
||||
pub outcomes: std::sync::Mutex<Vec<std::result::Result<Outcome, Error>>>,
|
||||
pub retry_policy: RetryPolicy,
|
||||
}
|
||||
|
||||
impl CountingHandler {
|
||||
pub fn new(outcomes: Vec<std::result::Result<Outcome, CoreError>>) -> Self {
|
||||
pub fn new(outcomes: Vec<std::result::Result<Outcome, Error>>) -> Self {
|
||||
Self {
|
||||
call_count: AtomicU32::new(0),
|
||||
outcomes: std::sync::Mutex::new(outcomes),
|
||||
call_count: AtomicU32::new(0),
|
||||
outcomes: std::sync::Mutex::new(outcomes),
|
||||
retry_policy: RetryPolicy::none(),
|
||||
}
|
||||
}
|
||||
|
|
@ -337,7 +336,7 @@ impl NodeHandler<TestGraph> for CountingHandler {
|
|||
/// A handler that dispatches based on node ID.
|
||||
pub struct DispatchHandler {
|
||||
handlers: HashMap<String, Arc<dyn NodeHandler<TestGraph>>>,
|
||||
default: Arc<dyn NodeHandler<TestGraph>>,
|
||||
default: Arc<dyn NodeHandler<TestGraph>>,
|
||||
}
|
||||
|
||||
impl DispatchHandler {
|
||||
|
|
@ -378,20 +377,20 @@ impl NodeHandler<TestGraph> for DispatchHandler {
|
|||
}
|
||||
}
|
||||
|
||||
/// A handler that returns Err(CoreError::Handler) with configurable
|
||||
/// A handler that returns Err(Error::Handler) with configurable
|
||||
/// retryability.
|
||||
pub struct ErrorHandler {
|
||||
pub detail: HandlerErrorDetail,
|
||||
pub detail: HandlerErrorDetail,
|
||||
pub retry_policy: RetryPolicy,
|
||||
}
|
||||
|
||||
impl ErrorHandler {
|
||||
pub fn retryable(message: &str, policy: RetryPolicy) -> Self {
|
||||
Self {
|
||||
detail: HandlerErrorDetail {
|
||||
message: message.to_string(),
|
||||
detail: HandlerErrorDetail {
|
||||
message: message.to_string(),
|
||||
retryable: true,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
},
|
||||
retry_policy: policy,
|
||||
|
|
@ -400,10 +399,10 @@ impl ErrorHandler {
|
|||
|
||||
pub fn non_retryable(message: &str) -> Self {
|
||||
Self {
|
||||
detail: HandlerErrorDetail {
|
||||
message: message.to_string(),
|
||||
detail: HandlerErrorDetail {
|
||||
message: message.to_string(),
|
||||
retryable: false,
|
||||
category: None,
|
||||
category: None,
|
||||
signature: None,
|
||||
},
|
||||
retry_policy: RetryPolicy::none(),
|
||||
|
|
@ -419,7 +418,7 @@ impl NodeHandler<TestGraph> for ErrorHandler {
|
|||
_context: &Context,
|
||||
_graph: &TestGraph,
|
||||
) -> Result<Outcome> {
|
||||
Err(CoreError::handler(self.detail.clone()))
|
||||
Err(Error::handler(self.detail.clone()))
|
||||
}
|
||||
|
||||
fn retry_policy(&self, _node: &TestNode, _graph: &TestGraph) -> RetryPolicy {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ use std::path::{Path, PathBuf};
|
|||
/// Extracted configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ComposeServiceSpec {
|
||||
pub image: Option<String>,
|
||||
pub build: Option<ComposeBuild>,
|
||||
pub ports: Vec<u16>,
|
||||
pub image: Option<String>,
|
||||
pub build: Option<ComposeBuild>,
|
||||
pub ports: Vec<u16>,
|
||||
pub environment: HashMap<String, String>,
|
||||
pub user: Option<String>,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Build configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ComposeBuild {
|
||||
pub context: String,
|
||||
pub context: String,
|
||||
pub dockerfile: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ fn parse_build(service: &serde_yaml::Value) -> Option<ComposeBuild> {
|
|||
|
||||
if let Some(context) = build_val.as_str() {
|
||||
return Some(ComposeBuild {
|
||||
context: context.to_string(),
|
||||
context: context.to_string(),
|
||||
dockerfile: None,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ mod tests {
|
|||
|
||||
fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer {
|
||||
FeatureLayer {
|
||||
id: id.to_string(),
|
||||
dir_name: dir_name.to_string(),
|
||||
id: id.to_string(),
|
||||
dir_name: dir_name.to_string(),
|
||||
dockerfile_snippet: snippet.to_string(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ use crate::types::{FeatureMetadata, LifecycleCommand};
|
|||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct FeatureLayer {
|
||||
/// Feature identifier (e.g. "ghcr.io/devcontainers/features/node:1")
|
||||
pub id: String,
|
||||
pub id: String,
|
||||
/// Directory name for COPY
|
||||
pub dir_name: String,
|
||||
pub dir_name: String,
|
||||
/// Dockerfile snippet for this feature
|
||||
pub dockerfile_snippet: String,
|
||||
}
|
||||
|
|
@ -23,11 +23,11 @@ pub(crate) struct FeatureLayer {
|
|||
/// All resolved feature data: layers, environment, and lifecycle hooks.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ResolvedFeatures {
|
||||
pub layers: Vec<FeatureLayer>,
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub on_create_commands: Vec<LifecycleCommand>,
|
||||
pub layers: Vec<FeatureLayer>,
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub on_create_commands: Vec<LifecycleCommand>,
|
||||
pub post_create_commands: Vec<LifecycleCommand>,
|
||||
pub post_start_commands: Vec<LifecycleCommand>,
|
||||
pub post_start_commands: Vec<LifecycleCommand>,
|
||||
}
|
||||
|
||||
/// Extract the directory name from a feature ID.
|
||||
|
|
@ -628,16 +628,16 @@ pub(crate) async fn resolve_features(
|
|||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| FeatureMetadata {
|
||||
id: None,
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: None,
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
|
||||
// Collect feature containerEnv (later features override earlier)
|
||||
|
|
@ -754,18 +754,21 @@ mod tests {
|
|||
let metadata: HashMap<String, FeatureMetadata> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
(id.clone(), FeatureMetadata {
|
||||
id: Some(id.clone()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
})
|
||||
(
|
||||
id.clone(),
|
||||
FeatureMetadata {
|
||||
id: Some(id.clone()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -778,30 +781,36 @@ mod tests {
|
|||
// A depends on B (A installs after B), so B should come first
|
||||
let ids = vec!["a".to_string(), "b".to_string()];
|
||||
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
|
||||
metadata.insert("a".to_string(), FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("b".to_string(), FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert(
|
||||
"a".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"b".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
|
||||
let sorted = topo_sort(&ids, &metadata);
|
||||
assert_eq!(sorted, vec!["b", "a"]);
|
||||
|
|
@ -819,54 +828,66 @@ mod tests {
|
|||
"a".to_string(),
|
||||
];
|
||||
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
|
||||
metadata.insert("a".to_string(), FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("b".to_string(), FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["a".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("c".to_string(), FeatureMetadata {
|
||||
id: Some("c".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["a".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("d".to_string(), FeatureMetadata {
|
||||
id: Some("d".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string(), "c".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert(
|
||||
"a".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"b".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["a".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"c".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("c".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["a".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"d".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("d".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string(), "c".to_string()],
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
|
||||
let sorted = topo_sort(&ids, &metadata);
|
||||
// A must come before B and C; B and C must come before D
|
||||
|
|
@ -884,22 +905,25 @@ mod tests {
|
|||
fn generate_layer_with_options() {
|
||||
let options = serde_json::json!({"version": "20"});
|
||||
let mut meta_options = HashMap::new();
|
||||
meta_options.insert("version".to_string(), FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
});
|
||||
meta_options.insert(
|
||||
"version".to_string(),
|
||||
FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
},
|
||||
);
|
||||
let metadata = FeatureMetadata {
|
||||
id: Some("node".to_string()),
|
||||
name: Some("Node.js".to_string()),
|
||||
version: Some("1.0.0".to_string()),
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: Some("node".to_string()),
|
||||
name: Some("Node.js".to_string()),
|
||||
version: Some("1.0.0".to_string()),
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let snippet = generate_layer(
|
||||
|
|
@ -928,22 +952,25 @@ mod tests {
|
|||
fn generate_layer_with_defaults() {
|
||||
let options = serde_json::json!({});
|
||||
let mut meta_options = HashMap::new();
|
||||
meta_options.insert("version".to_string(), FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
});
|
||||
meta_options.insert(
|
||||
"version".to_string(),
|
||||
FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
},
|
||||
);
|
||||
let metadata = FeatureMetadata {
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let snippet = generate_layer(
|
||||
|
|
@ -972,16 +999,16 @@ mod tests {
|
|||
fn generate_layer_no_options() {
|
||||
let options = serde_json::json!({});
|
||||
let metadata = FeatureMetadata {
|
||||
id: Some("common-utils".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: Some("common-utils".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let snippet = generate_layer(
|
||||
|
|
@ -1012,30 +1039,36 @@ mod tests {
|
|||
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
|
||||
let mut depends = HashMap::new();
|
||||
depends.insert("b".to_string(), serde_json::json!({}));
|
||||
metadata.insert("a".to_string(), FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: depends,
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("b".to_string(), FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert(
|
||||
"a".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: depends,
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"b".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
|
||||
let sorted = topo_sort(&ids, &metadata);
|
||||
assert_eq!(sorted, vec!["b", "a"]);
|
||||
|
|
@ -1048,30 +1081,36 @@ mod tests {
|
|||
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
|
||||
let mut depends = HashMap::new();
|
||||
depends.insert("b".to_string(), serde_json::json!({}));
|
||||
metadata.insert("a".to_string(), FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string()],
|
||||
depends_on: depends,
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert("b".to_string(), FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
});
|
||||
metadata.insert(
|
||||
"a".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: vec!["b".to_string()],
|
||||
depends_on: depends,
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
metadata.insert(
|
||||
"b".to_string(),
|
||||
FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
},
|
||||
);
|
||||
|
||||
let sorted = topo_sort(&ids, &metadata);
|
||||
assert_eq!(sorted, vec!["b", "a"]);
|
||||
|
|
@ -1118,37 +1157,37 @@ mod tests {
|
|||
let mut resolved = ResolvedFeatures::default();
|
||||
|
||||
let meta_a = FeatureMetadata {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: {
|
||||
id: Some("a".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("FOO".to_string(), "from_a".to_string());
|
||||
env.insert("BAR".to_string(), "from_a".to_string());
|
||||
env
|
||||
},
|
||||
on_create_command: None,
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
let meta_b = FeatureMetadata {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: {
|
||||
id: Some("b".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("FOO".to_string(), "from_b".to_string());
|
||||
env
|
||||
},
|
||||
on_create_command: None,
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
// A is sorted first, then B — B's FOO overrides A's
|
||||
|
|
@ -1212,22 +1251,25 @@ mod tests {
|
|||
fn generate_layer_shorthand_version() {
|
||||
let options = serde_json::json!("20");
|
||||
let mut meta_options = HashMap::new();
|
||||
meta_options.insert("version".to_string(), FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
});
|
||||
meta_options.insert(
|
||||
"version".to_string(),
|
||||
FeatureOption {
|
||||
option_type: Some("string".to_string()),
|
||||
default: Some(serde_json::Value::String("lts".to_string())),
|
||||
description: Some("Node.js version".to_string()),
|
||||
},
|
||||
);
|
||||
let metadata = FeatureMetadata {
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: meta_options,
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let snippet = generate_layer(
|
||||
|
|
@ -1245,16 +1287,16 @@ mod tests {
|
|||
fn generate_layer_install_env_vars() {
|
||||
let options = serde_json::json!({});
|
||||
let metadata = FeatureMetadata {
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
id: Some("node".to_string()),
|
||||
name: None,
|
||||
version: None,
|
||||
options: HashMap::new(),
|
||||
installs_after: Vec::new(),
|
||||
depends_on: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
on_create_command: None,
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let snippet = generate_layer(
|
||||
|
|
|
|||
|
|
@ -26,33 +26,33 @@ pub enum Command {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct DevcontainerSpec {
|
||||
/// Generated Dockerfile content
|
||||
pub dockerfile: String,
|
||||
pub dockerfile: String,
|
||||
/// Directory for docker build context
|
||||
pub build_context: PathBuf,
|
||||
pub build_context: PathBuf,
|
||||
/// Build arguments (docker build --build-arg)
|
||||
pub build_args: HashMap<String, String>,
|
||||
pub build_args: HashMap<String, String>,
|
||||
/// Multi-stage build target (docker build --target)
|
||||
pub build_target: Option<String>,
|
||||
pub build_target: Option<String>,
|
||||
/// Run on host before build
|
||||
pub initialize_commands: Vec<Command>,
|
||||
pub initialize_commands: Vec<Command>,
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
pub on_create_commands: Vec<Command>,
|
||||
pub on_create_commands: Vec<Command>,
|
||||
/// Run in container after creation
|
||||
pub post_create_commands: Vec<Command>,
|
||||
/// Run in container on each start
|
||||
pub post_start_commands: Vec<Command>,
|
||||
pub post_start_commands: Vec<Command>,
|
||||
/// remoteEnv merged
|
||||
pub environment: HashMap<String, String>,
|
||||
pub environment: HashMap<String, String>,
|
||||
/// containerEnv — baked into Dockerfile as ENV directives
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub remote_user: Option<String>,
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub remote_user: Option<String>,
|
||||
/// default: /workspaces/{repo-name}
|
||||
pub workspace_folder: String,
|
||||
pub workspace_folder: String,
|
||||
/// first = default preview port
|
||||
pub forwarded_ports: Vec<u16>,
|
||||
pub forwarded_ports: Vec<u16>,
|
||||
/// Compose file paths (empty if not in compose mode)
|
||||
pub compose_files: Vec<PathBuf>,
|
||||
pub compose_service: Option<String>,
|
||||
pub compose_files: Vec<PathBuf>,
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -65,7 +65,7 @@ pub enum DevcontainerError {
|
|||
|
||||
#[error("reading file {path}: {source}")]
|
||||
ReadFile {
|
||||
path: PathBuf,
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ pub enum LifecycleCommand {
|
|||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FeatureMetadata {
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
|
|
@ -131,9 +131,9 @@ pub(crate) struct FeatureMetadata {
|
|||
pub container_env: HashMap<String, String>,
|
||||
|
||||
/// Lifecycle hooks contributed by this feature
|
||||
pub on_create_command: Option<LifecycleCommand>,
|
||||
pub on_create_command: Option<LifecycleCommand>,
|
||||
pub post_create_command: Option<LifecycleCommand>,
|
||||
pub post_start_command: Option<LifecycleCommand>,
|
||||
pub post_start_command: Option<LifecycleCommand>,
|
||||
}
|
||||
|
||||
/// A single option for a devcontainer feature.
|
||||
|
|
@ -141,7 +141,7 @@ pub(crate) struct FeatureMetadata {
|
|||
pub(crate) struct FeatureOption {
|
||||
#[serde(rename = "type")]
|
||||
pub option_type: Option<String>,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -228,9 +228,10 @@ mod tests {
|
|||
"workspaceFolder": "/workspace"
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![
|
||||
"docker-compose.yml"
|
||||
]);
|
||||
assert_eq!(
|
||||
config.docker_compose_file.as_ref().unwrap().paths(),
|
||||
vec!["docker-compose.yml"]
|
||||
);
|
||||
assert_eq!(config.service.as_deref(), Some("app"));
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
}
|
||||
|
|
@ -242,10 +243,10 @@ mod tests {
|
|||
"service": "app"
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![
|
||||
"docker-compose.yml",
|
||||
"docker-compose.override.yml"
|
||||
]);
|
||||
assert_eq!(
|
||||
config.docker_compose_file.as_ref().unwrap().paths(),
|
||||
vec!["docker-compose.yml", "docker-compose.override.yml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -13,21 +13,21 @@ pub fn github_api_base_url() -> String {
|
|||
/// Detailed information about a pull request from the GitHub API.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PullRequestDetail {
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub body: Option<String>,
|
||||
pub state: String,
|
||||
pub draft: bool,
|
||||
pub mergeable: Option<bool>,
|
||||
pub additions: u64,
|
||||
pub deletions: u64,
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub body: Option<String>,
|
||||
pub state: String,
|
||||
pub draft: bool,
|
||||
pub mergeable: Option<bool>,
|
||||
pub additions: u64,
|
||||
pub deletions: u64,
|
||||
pub changed_files: u64,
|
||||
pub html_url: String,
|
||||
pub user: PullRequestUser,
|
||||
pub head: PullRequestRef,
|
||||
pub base: PullRequestRef,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub html_url: String,
|
||||
pub user: PullRequestUser,
|
||||
pub head: PullRequestRef,
|
||||
pub base: PullRequestRef,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
|
@ -50,14 +50,14 @@ pub struct AppOwner {
|
|||
/// Information about a GitHub App from the authenticated `/app` endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppInfo {
|
||||
pub slug: String,
|
||||
pub slug: String,
|
||||
pub owner: AppOwner,
|
||||
}
|
||||
|
||||
/// Credentials for authenticating as a GitHub App.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitHubAppCredentials {
|
||||
pub app_id: String,
|
||||
pub app_id: String,
|
||||
pub private_key_pem: String,
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ pub enum HttpMethod {
|
|||
/// A minimal HTTP response for testability.
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
body: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl HttpResponse {
|
||||
|
|
@ -386,8 +386,8 @@ pub async fn create_installation_access_token_for_pr(
|
|||
/// Result of a successful pull request creation.
|
||||
pub struct CreatedPullRequest {
|
||||
pub html_url: String,
|
||||
pub number: u64,
|
||||
pub node_id: String,
|
||||
pub number: u64,
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
/// Create a pull request on GitHub.
|
||||
|
|
@ -409,8 +409,8 @@ pub async fn create_pull_request(
|
|||
#[derive(Deserialize)]
|
||||
struct PullRequestResponse {
|
||||
html_url: String,
|
||||
number: u64,
|
||||
node_id: String,
|
||||
number: u64,
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?;
|
||||
|
|
@ -470,8 +470,8 @@ pub async fn create_pull_request(
|
|||
|
||||
Ok(CreatedPullRequest {
|
||||
html_url: pr.html_url,
|
||||
number: pr.number,
|
||||
node_id: pr.node_id,
|
||||
number: pr.number,
|
||||
node_id: pr.node_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1219,11 +1219,11 @@ mod tests {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
struct MockRoute {
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
status: u16,
|
||||
response_body: String,
|
||||
assert_header: Option<(String, MockHeaderCheck)>,
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
status: u16,
|
||||
response_body: String,
|
||||
assert_header: Option<(String, MockHeaderCheck)>,
|
||||
assert_body_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
|
@ -1470,7 +1470,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let result =
|
||||
|
|
@ -1502,7 +1502,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let result =
|
||||
|
|
@ -1534,7 +1534,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let result = branch_exists_with_client(&mock, &creds, "owner", "repo", "broken", "").await;
|
||||
|
|
@ -1702,7 +1702,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let detail = get_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "")
|
||||
|
|
@ -1739,7 +1739,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let err = get_pull_request_with_client(&mock, &creds, "owner", "repo", 999, "")
|
||||
|
|
@ -1777,7 +1777,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "")
|
||||
|
|
@ -1804,7 +1804,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let err = merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "")
|
||||
|
|
@ -1832,7 +1832,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let err = merge_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "squash", "")
|
||||
|
|
@ -1869,7 +1869,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
close_pull_request_with_client(&mock, &creds, "owner", "repo", 42, "")
|
||||
|
|
@ -1896,7 +1896,7 @@ mod tests {
|
|||
|
||||
let pem = test_rsa_key();
|
||||
let creds = GitHubAppCredentials {
|
||||
app_id: "test".to_string(),
|
||||
app_id: "test".to_string(),
|
||||
private_key_pem: pem.to_string(),
|
||||
};
|
||||
let err = close_pull_request_with_client(&mock, &creds, "owner", "repo", 999, "")
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue