fabro(01KP91ZK13BQG7VN5CYZVH2XBK): implement (success)

Fabro-Run: 01KP91ZK13BQG7VN5CYZVH2XBK
Fabro-Completed: 5
Fabro-Checkpoint: 406ca29bdb

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-04-15 19:06:55 +00:00
parent 7864f1dc41
commit 1febf2c684
352 changed files with 13654 additions and 12362 deletions

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { mapRunSummaryToRunItem } from "./runs";
import { mapRunSummaryToRunItem, isRunStatus } from "./runs";
describe("mapRunSummaryToRunItem", () => {
test("maps store run summary to RunItem", () => {
@ -15,6 +15,7 @@ describe("mapRunSummaryToRunItem", () => {
labels: {},
start_time: "2026-04-08T12:00:00Z",
status_reason: null,
blocked_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);
@ -38,6 +39,7 @@ describe("mapRunSummaryToRunItem", () => {
labels: {},
start_time: null,
status_reason: null,
blocked_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);
@ -46,4 +48,18 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.workflow).toBe("unknown");
expect(item.repo).toBe("unknown");
});
});
test("summary mapping accepts blocked, paused, completed, and cancelled statuses", () => {
for (const status of ["blocked", "paused", "completed", "cancelled"]) {
expect(isRunStatus(status)).toBe(true);
}
});
test("no UI code depends on waiting status", () => {
expect(isRunStatus("waiting")).toBe(false);
});
test("no UI code depends on dead status", () => {
expect(isRunStatus("dead")).toBe(false);
});
});

View file

@ -29,17 +29,13 @@ export interface RunItem {
sandboxId?: string;
}
export type ColumnStatus = "working" | "initializing" | "review" | "merge" | "running" | "waiting" | "succeeded" | "failed";
export type ColumnStatus = "working" | "blocked" | "review" | "merge";
export const columnNames: Record<ColumnStatus, string> = {
working: "Working",
initializing: "Initializing",
blocked: "Blocked",
review: "Verify",
merge: "Merge",
running: "Running",
waiting: "Waiting",
succeeded: "Succeeded",
failed: "Failed",
};
export interface RunWithStatus extends RunItem {
@ -83,6 +79,7 @@ export interface RunSummaryResponse {
host_repo_path: string | null;
status: string | null;
status_reason: string | null;
blocked_reason: string | null;
pending_control: string | null;
duration_ms: number | null;
total_usd_micros: number | null;
@ -113,34 +110,34 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus {
export const statusColors: Record<ColumnStatus, { dot: string; text: string }> = {
working: { dot: "bg-teal-500", text: "text-teal-500" },
initializing: { dot: "bg-amber", text: "text-amber" },
blocked: { dot: "bg-amber", text: "text-amber" },
review: { dot: "bg-mint", text: "text-mint" },
merge: { dot: "bg-teal-300", text: "text-teal-300" },
running: { dot: "bg-teal-500", text: "text-teal-500" },
waiting: { dot: "bg-amber", text: "text-amber" },
succeeded: { dot: "bg-teal-300", text: "text-teal-300" },
failed: { dot: "bg-coral", text: "text-coral" },
};
export type RunStatus =
| "submitted"
| "queued"
| "starting"
| "running"
| "blocked"
| "paused"
| "removing"
| "succeeded"
| "completed"
| "failed"
| "dead";
| "cancelled";
export const runStatusDisplay: Record<RunStatus, { label: string; dot: string; text: string }> = {
submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" },
queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" },
starting: { label: "Starting", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
paused: { label: "Paused", dot: "bg-amber", text: "text-amber" },
removing: { label: "Removing", dot: "bg-fg-muted", text: "text-fg-muted" },
succeeded: { label: "Succeeded", dot: "bg-mint", text: "text-mint" },
completed: { label: "Completed", dot: "bg-mint", text: "text-mint" },
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
dead: { label: "Dead", dot: "bg-coral", text: "text-coral" },
cancelled: { label: "Cancelled", dot: "bg-coral", text: "text-coral" },
};
const knownRunStatuses = new Set<string>(Object.keys(runStatusDisplay));
@ -160,4 +157,4 @@ export const ciConfig: Record<CiStatus, { label: string; dot: string; text: stri
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};
};

View file

@ -36,13 +36,9 @@ interface ColumnStyle {
const columnStyles: Record<string, ColumnStyle> = {
working: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] },
blocked: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] },
review: { accent: "bg-mint", iconColor: "text-mint", iconType: "pr", actions: [] },
merge: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] },
succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] },
failed: { accent: "bg-coral", iconColor: "text-coral", iconType: "branch", actions: [] },
};
const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "text-fg-muted", iconType: "branch", actions: [] };
@ -528,6 +524,8 @@ export default function Runs({ loaderData }: any) {
const STATUS_EVENTS = new Set([
"run.submitted", "run.starting", "run.running",
"run.paused", "run.completed", "run.failed",
"interview.started", "interview.completed",
"interview.timeout", "interview.interrupted",
]);
useEffect(() => {
@ -690,4 +688,4 @@ export default function Runs({ loaderData }: any) {
</div>
</DndContext>
);
}
}

View file

@ -2089,10 +2089,11 @@ components:
- queued
- starting
- running
- blocked
- paused
- completed
- failed
- cancelled
- paused
RunManifest:
description: Self-contained workflow run manifest.
@ -2481,6 +2482,10 @@ components:
allOf:
- $ref: "#/components/schemas/StatusReason"
nullable: true
blocked_reason:
allOf:
- $ref: "#/components/schemas/BlockedReason"
nullable: true
pending_control:
allOf:
- $ref: "#/components/schemas/RunControlAction"
@ -2876,13 +2881,21 @@ components:
type: string
enum:
- submitted
- queued
- starting
- running
- blocked
- paused
- removing
- succeeded
- completed
- failed
- dead
- cancelled
BlockedReason:
description: Reason why a run is blocked.
type: string
enum:
- human_input_required
StatusReason:
description: Optional reason attached to a run status transition.
@ -2921,6 +2934,10 @@ components:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
updated_at:
type: string
format: date-time
@ -3088,6 +3105,9 @@ components:
status_reason:
type: string
nullable: true
blocked_reason:
type: string
nullable: true
pending_control:
allOf:
- $ref: "#/components/schemas/RunControlAction"
@ -3109,7 +3129,7 @@ components:
type: string
enum:
- working
- initializing
- blocked
- review
- merge
@ -4525,4 +4545,4 @@ components:
login:
type: string
description: User's login identifier (e.g. GitHub username).
example: octocat
example: octocat

View file

@ -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
@ -63,10 +66,13 @@ pub async fn compact_context(
let context_window = provider_profile.context_window_size();
let original_turn_count = history.turns().len();
emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted {
estimated_tokens,
context_window_size: context_window,
});
emitter.emit(
session_id.to_owned(),
AgentEvent::CompactionStarted {
estimated_tokens,
context_window_size: context_window,
},
);
// Determine turns to summarize
if original_turn_count <= preserve_count {
@ -102,24 +108,24 @@ function names, error messages, and exact values. Omit pleasantries and conversa
);
let summary_request = Request {
model: provider_profile.model().to_string(),
messages: vec![
model: provider_profile.model().to_string(),
messages: vec![
Message::system(summarization_prompt),
Message::user(format!(
"Here is the conversation to summarize:\n\n{rendered}"
)),
],
provider: Some(provider_profile.provider().as_str().to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: Some(0.0),
top_p: None,
max_tokens: Some(4096),
stop_sequences: None,
provider: Some(provider_profile.provider().as_str().to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: Some(0.0),
top_p: None,
max_tokens: Some(4096),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
speed: None,
metadata: None,
provider_options: None,
};
@ -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();

View file

@ -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]

View file

@ -48,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(_)));
@ -91,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();
@ -101,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();
@ -155,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()),
@ -173,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();

View file

@ -54,16 +54,22 @@ mod tests {
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
emitter.emit("sess-1".into(), AgentEvent::SessionStarted {
provider: Some("anthropic".into()),
model: Some("claude-opus".into()),
});
emitter.emit(
"sess-1".into(),
AgentEvent::SessionStarted {
provider: Some("anthropic".into()),
model: Some("claude-opus".into()),
},
);
let event = receiver.recv().await.unwrap();
assert!(matches!(event.event, AgentEvent::SessionStarted {
provider: Some(_),
model: Some(_),
}));
assert!(matches!(
event.event,
AgentEvent::SessionStarted {
provider: Some(_),
model: Some(_),
}
));
assert_eq!(event.session_id, "sess-1");
assert_eq!(event.parent_session_id, None);
}
@ -73,9 +79,12 @@ mod tests {
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
emitter.emit("sess-2".into(), AgentEvent::Error {
error: Error::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: Error::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(_),
}
));
}
}

View file

@ -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)]

View file

@ -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(),
});

View file

@ -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));

View file

@ -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,
}
}

View file

@ -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, &[]);

View file

@ -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);

View file

@ -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,
},
);
}
}
}
@ -415,9 +423,12 @@ impl Session {
}
fn emit_llm_error(&mut self, err: LlmError) -> Error {
self.event_emitter.emit(self.id.clone(), AgentEvent::Error {
error: Error::Llm(err.clone()),
});
self.event_emitter.emit(
self.id.clone(),
AgentEvent::Error {
error: Error::Llm(err.clone()),
},
);
if is_auth_error(&err) {
self.transition(SessionState::Closed);
}
@ -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(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,
},
);
@ -796,7 +818,7 @@ impl Session {
let Some(response) = response else {
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: Error::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
@ -1013,7 +1040,7 @@ mod tests {
}
struct ScriptedStreamProvider {
calls: Vec<ScriptedStreamCall>,
calls: Vec<ScriptedStreamCall>,
call_index: AtomicUsize,
}
@ -1062,7 +1089,7 @@ mod tests {
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
Err(LlmError::Configuration {
message: "ScriptedStreamProvider does not implement complete()".into(),
source: None,
source: None,
})
}
@ -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();
@ -1497,7 +1524,7 @@ mod tests {
async fn auth_error_closes_session() {
let error_provider = Arc::new(MockErrorProvider {
error: LlmError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")),
},
});
@ -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: LlmError::Stream {
error: LlmError::Stream {
message: "connection reset".into(),
source: None,
source: None,
},
});
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
@ -2146,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 = LlmError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail {
status_code: Some(401),
..ProviderErrorDetail::new("bad key", "mock")
@ -2207,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");
}
@ -2305,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,7 +2351,7 @@ mod tests {
async fn complete(&self, _request: &Request) -> Result<Response, LlmError> {
Err(LlmError::Stream {
message: "summarization failed".into(),
source: None,
source: None,
})
}
@ -2399,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>>,
}
@ -2429,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()) })
}),
};
@ -2542,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()
@ -2654,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())

View file

@ -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]

View file

@ -24,8 +24,8 @@ 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,
}
@ -37,16 +37,16 @@ pub enum SubAgentStatus {
}
pub struct SubAgent {
task: Option<JoinHandle<Result<SubAgentResult, Error>>>,
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>,
}
@ -119,24 +119,27 @@ 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)
@ -303,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": {
@ -328,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 {
@ -356,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": {
@ -373,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")?;
@ -391,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": {
@ -404,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")?;
@ -423,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": {
@ -436,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")?;
@ -715,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()),
}));
@ -747,7 +750,7 @@ mod tests {
let manager = SubAgentManager::new(3);
manager.emit_event(AgentEvent::SubAgentClosed {
agent_id: "x".into(),
depth: 0,
depth: 0,
});
}

View file

@ -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,
}
@ -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()) })
}),
}
@ -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: LlmError,
pub error: LlmError,
}
#[async_trait]
@ -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,
}
}

View file

@ -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())),
}
}

View file

@ -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();

View file

@ -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(|| {
@ -642,11 +642,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");
}
@ -684,8 +687,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,
},
)
@ -714,8 +717,8 @@ mod tests {
"new_string": "goodbye"
}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
},
)
@ -796,8 +799,8 @@ mod tests {
"replace_all": true
}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
},
)
@ -813,19 +816,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"));
@ -840,8 +846,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,
},
)
@ -854,19 +860,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"));
@ -878,19 +887,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"));
@ -906,8 +918,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()),
},
)
@ -921,11 +933,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);
@ -936,10 +951,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()
@ -950,8 +965,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()),
},
)
@ -970,11 +985,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()"));
@ -988,11 +1006,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"));
@ -1003,11 +1024,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!(
@ -1020,11 +1044,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!(
@ -1061,10 +1088,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()
@ -1073,8 +1100,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,
},
)
@ -1131,8 +1158,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,
},
)
@ -1153,8 +1180,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,
},
)
@ -1173,10 +1200,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()
@ -1200,10 +1227,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()
@ -1240,18 +1267,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()
@ -1277,12 +1303,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()
@ -1317,7 +1342,7 @@ mod tests {
// "other_provider" is the default — it rejects all requests.
let default_provider: Arc<dyn ProviderAdapter> = Arc::new(MockErrorProvider {
error: LlmError::Provider {
kind: ProviderErrorKind::NotFound,
kind: ProviderErrorKind::NotFound,
detail: Box::new(ProviderErrorDetail::new(
"model not found",
"other_provider",
@ -1341,17 +1366,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()
@ -1434,11 +1459,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();
@ -1463,11 +1491,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();

View file

@ -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: 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: LlmError,
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: Error,
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: Error::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: Error::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();
@ -704,7 +719,7 @@ mod tests {
let event = AgentEvent::Error {
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: LlmError::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: Error::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();
@ -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();

View file

@ -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

View file

@ -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),
}
}

View file

@ -3,13 +3,13 @@ use fabro_model::Provider;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthContextRequest {
ApiKey {
provider: Provider,
provider: Provider,
env_var_names: Vec<String>,
},
DeviceCode {
user_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
expires_in: u64,
},
}

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
pub struct AuthCredential {
pub provider: Provider,
#[serde(flatten)]
pub details: AuthDetails,
pub details: AuthDetails,
}
impl AuthCredential {
@ -28,8 +28,8 @@ pub enum AuthDetails {
key: String,
},
CodexOAuth {
tokens: OAuthTokens,
config: OAuthConfig,
tokens: OAuthTokens,
config: OAuthConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
account_id: Option<String>,
},
@ -37,9 +37,9 @@ pub enum AuthDetails {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OAuthTokens {
pub access_token: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
pub(crate) fn expires_at_from_now(expires_in: Option<u64>) -> DateTime<Utc> {
@ -49,12 +49,12 @@ pub(crate) fn expires_at_from_now(expires_in: Option<u64>) -> DateTime<Utc> {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OAuthConfig {
pub auth_url: String,
pub token_url: String,
pub client_id: String,
pub scopes: Vec<String>,
pub auth_url: String,
pub token_url: String,
pub client_id: String,
pub scopes: Vec<String>,
pub redirect_uri: Option<String>,
pub use_pkce: bool,
pub use_pkce: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -94,19 +94,19 @@ mod tests {
fn oauth_credential(expires_at: DateTime<Utc>) -> AuthCredential {
AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at,
},
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "https://auth.openai.com/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "https://auth.openai.com/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
use_pkce: true,
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
},
@ -137,7 +137,7 @@ mod tests {
fn credential_id_for_openai_api_key() {
let credential = AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::ApiKey {
details: AuthDetails::ApiKey {
key: "sk-test".to_string(),
},
};

View file

@ -25,15 +25,15 @@ pub async fn refresh_oauth_credential(
.map_err(anyhow::Error::msg)?;
Ok(AuthCredential {
provider: credential.provider,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: response.access_token,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: response.access_token,
refresh_token: response
.refresh_token
.or_else(|| tokens.refresh_token.clone()),
expires_at: expires_at_from_now(response.expires_in),
expires_at: expires_at_from_now(response.expires_in),
},
config: config.clone(),
config: config.clone(),
account_id: account_id.clone(),
},
})

View file

@ -28,18 +28,18 @@ pub enum CredentialUsage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiCredential {
pub provider: Provider,
pub auth_header: ApiKeyHeader,
pub provider: Provider,
pub auth_header: ApiKeyHeader,
pub extra_headers: HashMap<String, String>,
pub base_url: Option<String>,
pub codex_mode: bool,
pub org_id: Option<String>,
pub project_id: Option<String>,
pub base_url: Option<String>,
pub codex_mode: bool,
pub org_id: Option<String>,
pub project_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliCredential {
pub env_vars: HashMap<String, String>,
pub env_vars: HashMap<String, String>,
pub login_command: Option<String>,
}
@ -57,7 +57,7 @@ pub enum ResolveError {
RefreshFailed {
provider: Provider,
#[source]
source: anyhow::Error,
source: anyhow::Error,
},
#[error("{0} requires re-authentication: missing refresh token")]
RefreshTokenMissing(Provider),
@ -65,7 +65,7 @@ pub enum ResolveError {
#[derive(Clone)]
pub struct CredentialResolver {
vault: Arc<AsyncRwLock<Vault>>,
vault: Arc<AsyncRwLock<Vault>>,
env_lookup: EnvLookup,
}
@ -200,7 +200,7 @@ impl CredentialResolver {
provider: credential.provider,
auth_header: match credential.provider {
Provider::Anthropic => ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
name: "x-api-key".to_string(),
value: key.clone(),
},
_ => ApiKeyHeader::Bearer(key.clone()),
@ -349,13 +349,13 @@ mod tests {
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> AuthCredential {
AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at,
},
config: OAuthConfig {
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url,
client_id: "test-client".to_string(),
@ -469,10 +469,13 @@ mod tests {
panic!("expected api credential");
};
assert_eq!(api.auth_header, ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
});
assert_eq!(
api.auth_header,
ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
}
);
}
#[tokio::test]
@ -653,9 +656,10 @@ mod tests {
let resolver = test_resolver(vault, Arc::new(|_| None));
let vault = resolver.vault.read().await;
assert_eq!(resolver.configured_providers(&vault), vec![
Provider::OpenAi
]);
assert_eq!(
resolver.configured_providers(&vault),
vec![Provider::OpenAi]
);
}
#[tokio::test]
@ -668,9 +672,10 @@ mod tests {
);
let vault = resolver.vault.read().await;
assert_eq!(resolver.configured_providers(&vault), vec![
Provider::OpenAi
]);
assert_eq!(
resolver.configured_providers(&vault),
vec![Provider::OpenAi]
);
}
#[tokio::test]

View file

@ -20,7 +20,7 @@ impl ApiKeyStrategy {
impl AuthStrategy for ApiKeyStrategy {
async fn init(&mut self) -> anyhow::Result<AuthContextRequest> {
Ok(AuthContextRequest::ApiKey {
provider: self.provider,
provider: self.provider,
env_var_names: self
.provider
.api_key_env_vars()
@ -34,7 +34,7 @@ impl AuthStrategy for ApiKeyStrategy {
match response {
AuthContextResponse::ApiKey { key } => Ok(AuthCredential {
provider: self.provider,
details: AuthDetails::ApiKey { key },
details: AuthDetails::ApiKey { key },
}),
AuthContextResponse::DeviceCodeConfirmed => {
Err(anyhow::anyhow!("expected API key response"))

View file

@ -38,9 +38,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(Debug, Deserialize)]
@ -100,48 +100,48 @@ impl U64OrString {
#[derive(Debug, Deserialize)]
struct DeviceCodeInitResponse {
device_auth_id: String,
user_code: String,
user_code: String,
#[serde(default)]
interval: Option<U64OrString>,
interval: Option<U64OrString>,
#[serde(default)]
expires_in: Option<U64OrString>,
expires_in: Option<U64OrString>,
#[serde(default)]
expires_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
struct DeviceCodePollResponse {
#[serde(default)]
status: Option<String>,
status: Option<String>,
#[serde(default)]
authorization_code: Option<String>,
#[serde(default)]
code_verifier: Option<String>,
code_verifier: Option<String>,
}
#[derive(Debug, Serialize)]
struct DeviceCodeInitRequest<'a> {
client_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
scope: Option<String>,
}
#[derive(Debug)]
struct PendingDeviceAuth {
device_auth_id: String,
user_code: String,
poll_interval: Duration,
deadline: Instant,
user_code: String,
poll_interval: Duration,
deadline: Instant,
}
#[derive(Debug)]
struct DeviceAuthorization {
authorization_code: String,
code_verifier: String,
code_verifier: String,
}
pub struct CodexDeviceStrategy {
config: OAuthConfig,
config: OAuthConfig,
pending: Option<PendingDeviceAuth>,
}
@ -247,7 +247,7 @@ impl AuthStrategy for CodexDeviceStrategy {
.header("originator", "fabro")
.json(&DeviceCodeInitRequest {
client_id: &self.config.client_id,
scope: (!self.config.scopes.is_empty()).then(|| self.config.scopes.join(" ")),
scope: (!self.config.scopes.is_empty()).then(|| self.config.scopes.join(" ")),
})
.send()
.await?;
@ -301,13 +301,13 @@ impl AuthStrategy for CodexDeviceStrategy {
Ok(AuthCredential {
provider: fabro_model::Provider::OpenAi,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: token_response.access_token,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: token_response.access_token,
refresh_token: token_response.refresh_token,
expires_at: expires_at_from_now(token_response.expires_in),
expires_at: expires_at_from_now(token_response.expires_in),
},
config: self.config.clone(),
config: self.config.clone(),
account_id: token_response
.id_token
.as_deref()
@ -329,17 +329,17 @@ mod tests {
fn test_config(server: &MockServer) -> OAuthConfig {
OAuthConfig {
auth_url: server.url(""),
token_url: server.url("/oauth/token"),
client_id: "test-client".to_string(),
scopes: vec![
auth_url: server.url(""),
token_url: server.url("/oauth/token"),
client_id: "test-client".to_string(),
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
"offline_access".to_string(),
],
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
use_pkce: false,
use_pkce: false,
}
}
@ -415,11 +415,14 @@ mod tests {
let request = strategy.init().await.unwrap();
assert_eq!(request, AuthContextRequest::DeviceCode {
user_code: "ABCD-EFGH".to_string(),
verification_uri: "https://auth.openai.com/codex/device".to_string(),
expires_in: 300,
});
assert_eq!(
request,
AuthContextRequest::DeviceCode {
user_code: "ABCD-EFGH".to_string(),
verification_uri: "https://auth.openai.com/codex/device".to_string(),
expires_in: 300,
}
);
init_mock.assert_async().await;
}

View file

@ -25,17 +25,17 @@ pub enum AuthMethod {
#[must_use]
pub fn codex_oauth_config() -> OAuthConfig {
OAuthConfig {
auth_url: CODEX_AUTH_URL.to_string(),
token_url: CODEX_TOKEN_URL.to_string(),
client_id: CODEX_CLIENT_ID.to_string(),
scopes: vec![
auth_url: CODEX_AUTH_URL.to_string(),
token_url: CODEX_TOKEN_URL.to_string(),
client_id: CODEX_CLIENT_ID.to_string(),
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
"offline_access".to_string(),
],
redirect_uri: Some(format!("{CODEX_AUTH_URL}/deviceauth/callback")),
use_pkce: false,
use_pkce: false,
}
}
@ -73,9 +73,12 @@ mod tests {
async fn api_key_strategy_uses_provider_env_names() {
let mut strategy = ApiKeyStrategy::new(Provider::Anthropic);
let request = strategy.init().await.unwrap();
assert_eq!(request, AuthContextRequest::ApiKey {
provider: Provider::Anthropic,
env_var_names: vec!["ANTHROPIC_API_KEY".to_string()],
});
assert_eq!(
request,
AuthContextRequest::ApiKey {
provider: Provider::Anthropic,
env_var_names: vec!["ANTHROPIC_API_KEY".to_string()],
}
);
}
}

View file

@ -48,19 +48,19 @@ mod tests {
fn oauth_credential() -> AuthCredential {
AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: "access".to_string(),
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Utc::now() + Duration::hours(1),
expires_at: Utc::now() + Duration::hours(1),
},
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "https://auth.openai.com/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "https://auth.openai.com/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
use_pkce: true,
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
},
@ -86,12 +86,16 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(&mut vault, "openai_codex", &oauth_credential()).unwrap();
vault_set_credential(&mut vault, "anthropic", &AuthCredential {
provider: Provider::Anthropic,
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
vault_set_credential(
&mut vault,
"anthropic",
&AuthCredential {
provider: Provider::Anthropic,
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
},
})
)
.unwrap();
let credentials = vault_credentials_for_provider(&vault, Provider::OpenAi);

View file

@ -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),
}
}

View file

@ -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> {

View file

@ -9,7 +9,7 @@ pub enum Error {
#[error("reading file {path}: {source}")]
ReadFile {
path: PathBuf,
path: PathBuf,
source: std::io::Error,
},

View file

@ -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(),
}
}
}

View file

@ -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(),
}
}
@ -369,10 +369,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")
@ -433,10 +434,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();

View file

@ -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"

View file

@ -297,17 +297,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,
@ -428,9 +428,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,
@ -442,18 +442,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)]
@ -462,10 +462,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,
@ -477,7 +477,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>,
@ -523,14 +523,14 @@ pub(crate) enum SecretTypeArg {
#[derive(Args)]
pub(crate) struct SecretSetArgs {
/// Name of the secret
pub(crate) key: String,
pub(crate) key: String,
/// Value to store (omit to enter interactively)
pub(crate) value: Option<String>,
pub(crate) value: Option<String>,
/// Read the secret value from stdin
#[arg(long, conflicts_with = "value")]
pub(crate) value_stdin: bool,
#[arg(long, value_enum, default_value = "environment")]
pub(crate) r#type: SecretTypeArg,
pub(crate) r#type: SecretTypeArg,
#[arg(long)]
pub(crate) description: Option<String>,
}
@ -709,10 +709,10 @@ 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>,
/// Create PR even if the run status is not success/partial_success
#[arg(short, long)]
pub(crate) force: bool,
pub(crate) force: bool,
}
#[derive(Args)]
@ -994,7 +994,7 @@ pub(crate) enum Commands {
/// Set up the Fabro environment (LLMs, certs, GitHub)
Install {
#[command(flatten)]
args: InstallArgs,
args: InstallArgs,
#[command(subcommand)]
command: Option<InstallCommand>,
},

View file

@ -19,20 +19,20 @@ 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 {
#[allow(dead_code)]
printer: Printer,
cwd: PathBuf,
printer: Printer,
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 {
@ -69,7 +69,7 @@ impl CommandContext {
Self::new(
printer,
ServerMode::ByStorageDir {
target_override: args.target.server.clone(),
target_override: args.target.server.clone(),
storage_dir_override: args.storage_dir.clone_path(),
},
cli_settings,
@ -92,10 +92,13 @@ impl CommandContext {
..
} => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?,
};
let machine_settings = combine_files(disk_settings, SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
});
let machine_settings = combine_files(
disk_settings,
SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
},
);
Ok(Self {
printer,

View file

@ -197,11 +197,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");

View file

@ -15,11 +15,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(

View file

@ -30,10 +30,10 @@ pub(crate) fn check_config(
(Some(path), true) => {
let display = contract_tilde(&path);
CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Pass,
summary: display.display().to_string(),
details: vec![CheckDetail::new(format!(
name: "Configuration".to_string(),
status: CheckStatus::Pass,
summary: display.display().to_string(),
details: vec![CheckDetail::new(format!(
"Loaded from {}",
display.display()
))],
@ -43,10 +43,10 @@ pub(crate) fn check_config(
(Some(path), false) => {
let display = contract_tilde(&path);
CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: display.display().to_string(),
details: std::iter::once(CheckDetail::new(format!(
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: display.display().to_string(),
details: std::iter::once(CheckDetail::new(format!(
"Loaded from {}",
display.display()
)))
@ -62,10 +62,10 @@ pub(crate) fn check_config(
}
}
(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()))
@ -78,10 +78,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()),
@ -93,10 +93,10 @@ fn check_legacy_env(path: Option<PathBuf>) -> Option<CheckResult> {
path.map(|path| {
let display = contract_tilde(&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",
display.display()
))],
@ -107,8 +107,8 @@ fn check_legacy_env(path: Option<PathBuf>) -> Option<CheckResult> {
#[derive(Debug, Clone, PartialEq, Eq)]
struct StorageDirStatus {
path: PathBuf,
exists: bool,
path: PathBuf,
exists: bool,
readable: bool,
writable: bool,
}
@ -168,20 +168,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(
@ -194,10 +194,10 @@ fn check_version_parity(server_version: &str) -> CheckResult {
fn skipped_version_parity(reason: &str) -> CheckResult {
CheckResult {
name: "Version parity".to_string(),
status: CheckStatus::Warning,
summary: "skipped".to_string(),
details: vec![CheckDetail::new(format!(
name: "Version parity".to_string(),
status: CheckStatus::Warning,
summary: "skipped".to_string(),
details: vec![CheckDetail::new(format!(
"Could not retrieve server version: {reason}"
))],
remediation: None,
@ -216,15 +216,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 {
@ -317,9 +317,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: local_checks,
}],
};
@ -328,12 +328,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(),
@ -358,12 +358,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(),
@ -386,12 +386,12 @@ pub(crate) async fn run_doctor(
if let Err(err) = server.api().get_health().send().await {
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(),
),
@ -411,12 +411,12 @@ pub(crate) async fn run_doctor(
}
report.sections.push(CheckSection {
title: "Server".to_string(),
title: "Server".to_string(),
checks: vec![CheckResult {
name: "Location".to_string(),
status: CheckStatus::Pass,
summary: server.base_url().to_string(),
details: vec![],
name: "Location".to_string(),
status: CheckStatus::Pass,
summary: server.base_url().to_string(),
details: vec![],
remediation: None,
}],
});
@ -436,12 +436,12 @@ pub(crate) async fn run_doctor(
.checks
.push(skipped_version_parity(&err.to_string()));
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(),
@ -516,12 +516,15 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let status = probe_storage_dir(dir.path());
assert_eq!(status, StorageDirStatus {
path: dir.path().to_path_buf(),
exists: true,
readable: true,
writable: true,
});
assert_eq!(
status,
StorageDirStatus {
path: dir.path().to_path_buf(),
exists: true,
readable: true,
writable: true,
}
);
}
#[test]
@ -530,19 +533,22 @@ mod tests {
let path = dir.path().join("missing");
let status = probe_storage_dir(&path);
assert_eq!(status, StorageDirStatus {
path,
exists: false,
readable: false,
writable: false,
});
assert_eq!(
status,
StorageDirStatus {
path,
exists: false,
readable: false,
writable: false,
}
);
}
#[test]
fn check_storage_dir_pass() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/home/user/.fabro"),
exists: true,
path: PathBuf::from("/home/user/.fabro"),
exists: true,
readable: true,
writable: true,
});
@ -556,8 +562,8 @@ mod tests {
#[test]
fn check_storage_dir_not_exists() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/tmp/nonexistent-fabro-doctor-test-xyz"),
exists: false,
path: PathBuf::from("/tmp/nonexistent-fabro-doctor-test-xyz"),
exists: false,
readable: false,
writable: false,
});
@ -570,8 +576,8 @@ mod tests {
#[test]
fn check_storage_dir_not_writable() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/home/user/.fabro"),
exists: true,
path: PathBuf::from("/home/user/.fabro"),
exists: true,
readable: true,
writable: false,
});
@ -605,14 +611,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,

View file

@ -39,7 +39,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()))
@ -141,10 +141,10 @@ pub(crate) async fn execute(
.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) = raw_settings
@ -166,10 +166,10 @@ pub(crate) async fn execute(
.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()
})

View file

@ -30,12 +30,12 @@ pub(crate) async fn run(
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
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?;
@ -52,8 +52,8 @@ pub(crate) async fn run(
let rendered = client
.render_workflow_graph(types::RenderWorkflowGraphRequest {
manifest: built.manifest,
format: Some(types::RenderWorkflowGraphFormat::Svg),
manifest: built.manifest,
format: Some(types::RenderWorkflowGraphFormat::Svg),
direction: args.direction.map(|direction| match direction {
GraphDirection::Lr => types::RenderWorkflowGraphDirection::Lr,
GraphDirection::Tb => types::RenderWorkflowGraphDirection::Tb,

View file

@ -430,7 +430,7 @@ enum GitHubInstallSelection {
token: String,
},
App {
owner: GitHubAppOwner,
owner: GitHubAppOwner,
username: Option<String>,
},
}
@ -782,11 +782,9 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
Ok(GitHubInstallSelection::Token { token })
}
Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App {
owner: GitHubAppOwner::parse_scripted(
self.args.github_owner.as_deref().context(
"non-interactive install requires --github-owner for --github-strategy app",
)?,
)?,
owner: GitHubAppOwner::parse_scripted(self.args.github_owner.as_deref().context(
"non-interactive install requires --github-owner for --github-strategy app",
)?)?,
username: best_effort_github_username().await,
}),
None => bail!("non-interactive install requires --github-strategy"),
@ -867,7 +865,7 @@ async fn choose_install_github_selection(
Ok(GitHubInstallSelection::Token { token })
}
Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App {
owner: GitHubAppOwner::parse_scripted(github_args.owner.as_deref().context(
owner: GitHubAppOwner::parse_scripted(github_args.owner.as_deref().context(
"install github --non-interactive requires --owner for --strategy app",
)?)?,
username: best_effort_github_username().await,
@ -1030,8 +1028,8 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_
/// Run the GitHub App manifest registration flow via a temporary local server.
/// Returns the app metadata and secret pairs to persist for the local server.
struct GitHubAppRegistration {
app_id: String,
slug: String,
app_id: String,
slug: String,
client_id: String,
env_pairs: Vec<(String, String)>,
}
@ -1039,17 +1037,17 @@ struct GitHubAppRegistration {
enum PendingGitHubSettings {
Token,
App {
app_id: String,
slug: String,
client_id: String,
app_id: String,
slug: String,
client_id: String,
allowed_usernames: Vec<String>,
},
}
#[derive(Clone, Copy)]
struct PendingSettingsWrite<'a> {
path: &'a Path,
contents: &'a str,
path: &'a Path,
contents: &'a str,
previous_contents: Option<&'a str>,
}
@ -1265,9 +1263,9 @@ async fn persist_vault_secrets_via_server(
client
.create_secret()
.body(CreateSecretRequest {
name: secret.name.clone(),
value: secret.value.clone(),
type_: secret.type_,
name: secret.name.clone(),
value: secret.value.clone(),
type_: secret.type_,
description: secret.description.clone(),
})
.send()
@ -1306,9 +1304,9 @@ async fn persist_vault_secrets_with(
fn credential_secret_request(credential: &AuthCredential) -> Result<CreateSecretRequest> {
Ok(CreateSecretRequest {
name: credential_id_for(credential).map_err(anyhow::Error::msg)?,
value: serde_json::to_string(credential)?,
type_: ApiSecretType::Credential,
name: credential_id_for(credential).map_err(anyhow::Error::msg)?,
value: serde_json::to_string(credential)?,
type_: ApiSecretType::Credential,
description: None,
})
}
@ -1345,11 +1343,11 @@ async fn persist_install_outputs(
}
struct PendingGitHubInstallWrite<'a> {
settings_write: PendingSettingsWrite<'a>,
server_env_set: Vec<(String, String)>,
settings_write: PendingSettingsWrite<'a>,
server_env_set: Vec<(String, String)>,
server_env_remove: Vec<&'static str>,
vault_set: Vec<(String, String)>,
vault_remove: Vec<&'static str>,
vault_set: Vec<(String, String)>,
vault_remove: Vec<&'static str>,
}
fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
@ -1693,17 +1691,20 @@ async fn run_install_github_inner(
}
let settings_toml = toml::to_string_pretty(&doc)?;
persist_github_install_changes(&storage_dir, &PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &config_path,
contents: settings_toml.as_str(),
previous_contents: Some(existing_config_contents.as_str()),
persist_github_install_changes(
&storage_dir,
&PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &config_path,
contents: settings_toml.as_str(),
previous_contents: Some(existing_config_contents.as_str()),
},
server_env_set,
server_env_remove,
vault_set,
vault_remove,
},
server_env_set,
server_env_remove,
vault_set,
vault_remove,
})?;
)?;
if let Some(restart_outcome) =
maybe_restart_server_after_github_install(&storage_dir, &config_path, server_was_running)
@ -1856,9 +1857,9 @@ async fn run_install_inner(
s.green.apply_to("")
);
vault_secrets.push(CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: token,
type_: ApiSecretType::Environment,
name: "GITHUB_TOKEN".to_string(),
value: token,
type_: ApiSecretType::Environment,
description: None,
});
Some(PendingGitHubSettings::Token)
@ -1889,9 +1890,9 @@ async fn run_install_inner(
);
server_env_pairs.extend(registration.env_pairs.iter().cloned());
Some(PendingGitHubSettings::App {
app_id: registration.app_id,
slug: registration.slug,
client_id: registration.client_id,
app_id: registration.app_id,
slug: registration.slug,
client_id: registration.client_id,
allowed_usernames: vec![allowed_username],
})
}
@ -1996,8 +1997,8 @@ async fn run_install_inner(
&server_env_pairs,
&vault_secrets,
Some(PendingSettingsWrite {
path: &config_path,
contents: settings_toml.as_str(),
path: &config_path,
contents: settings_toml.as_str(),
previous_contents: existing_config_contents.as_deref(),
}),
server_was_running,
@ -2079,7 +2080,7 @@ async fn run_install_inner(
|| async {
fabro_util::printerr!(printer, "");
let doctor_args = DoctorArgs {
target: ServerTargetArgs::default(),
target: ServerTargetArgs::default(),
verbose: false,
};
doctor::run_doctor(&doctor_args, false, cli, cli_layer, printer).await
@ -2211,9 +2212,10 @@ mod tests {
.and_then(|s| s.auth.as_ref())
.and_then(|a| a.methods.clone())
.expect("server.auth.methods should be set");
assert_eq!(methods, vec![
fabro_types::settings::ServerAuthMethod::DevToken
]);
assert_eq!(
methods,
vec![fabro_types::settings::ServerAuthMethod::DevToken]
);
}
#[test]
@ -2412,9 +2414,13 @@ client_id = "client-id"
let mut doc = toml::Value::Table(toml::Table::default());
merge_server_settings(&mut doc).unwrap();
write_github_app_settings(&mut doc, "123", "fabro-app", "client-id", &[
"brynary".to_string()
])
write_github_app_settings(
&mut doc,
"123",
"fabro-app",
"client-id",
&["brynary".to_string()],
)
.unwrap();
let github = doc
@ -2602,14 +2608,14 @@ client_id = "client-id"
];
let vault_secrets = vec![
CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
description: None,
},
credential_secret_request(&AuthCredential {
provider: Provider::Anthropic,
details: fabro_auth::AuthDetails::ApiKey {
details: fabro_auth::AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
@ -2673,9 +2679,9 @@ client_id = "client-id"
async fn persist_vault_secrets_with_leaves_running_server_up() {
let dir = tempfile::tempdir().unwrap();
let vault_secrets = vec![CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
description: None,
}];
let server = MockServer::start_async().await;
@ -2887,9 +2893,9 @@ client_id = "client-id"
let dir = tempfile::tempdir().unwrap();
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = vec![CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
description: None,
}];
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
@ -2900,8 +2906,8 @@ client_id = "client-id"
&server_env_pairs,
&vault_secrets,
Some(PendingSettingsWrite {
path: &settings_path,
contents: "_version = 1\n",
path: &settings_path,
contents: "_version = 1\n",
previous_contents: None,
}),
false,
@ -2930,9 +2936,9 @@ client_id = "client-id"
let dir = tempfile::tempdir().unwrap();
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = vec![CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
description: None,
}];
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
@ -2943,8 +2949,8 @@ client_id = "client-id"
&server_env_pairs,
&vault_secrets,
Some(PendingSettingsWrite {
path: &settings_path,
contents: "_version = 1\n[server]\nfoo = \"bar\"\n",
path: &settings_path,
contents: "_version = 1\n[server]\nfoo = \"bar\"\n",
previous_contents: Some("_version = 1\n[server]\n"),
}),
false,
@ -2988,21 +2994,24 @@ client_id = "client-id"
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
std::fs::write(&settings_path, "before").unwrap();
persist_github_install_changes(dir.path(), &PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &settings_path,
contents: "after",
previous_contents: Some("before"),
persist_github_install_changes(
dir.path(),
&PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &settings_path,
contents: "after",
previous_contents: Some("before"),
},
server_env_set: Vec::new(),
server_env_remove: vec![
GITHUB_APP_PRIVATE_KEY_KEY,
GITHUB_APP_CLIENT_SECRET_KEY,
GITHUB_APP_WEBHOOK_SECRET_KEY,
],
vault_set: vec![(GITHUB_TOKEN_SECRET_KEY.to_string(), "token".to_string())],
vault_remove: Vec::new(),
},
server_env_set: Vec::new(),
server_env_remove: vec![
GITHUB_APP_PRIVATE_KEY_KEY,
GITHUB_APP_CLIENT_SECRET_KEY,
GITHUB_APP_WEBHOOK_SECRET_KEY,
],
vault_set: vec![(GITHUB_TOKEN_SECRET_KEY.to_string(), "token".to_string())],
vault_remove: Vec::new(),
})
)
.unwrap();
let server_env = envfile::read_env_file(&server_env_path).unwrap();
@ -3046,30 +3055,33 @@ client_id = "client-id"
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
std::fs::write(&settings_path, "before").unwrap();
persist_github_install_changes(dir.path(), &PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &settings_path,
contents: "after",
previous_contents: Some("before"),
persist_github_install_changes(
dir.path(),
&PendingGitHubInstallWrite {
settings_write: PendingSettingsWrite {
path: &settings_path,
contents: "after",
previous_contents: Some("before"),
},
server_env_set: vec![
(
GITHUB_APP_PRIVATE_KEY_KEY.to_string(),
"private".to_string(),
),
(
GITHUB_APP_CLIENT_SECRET_KEY.to_string(),
"client".to_string(),
),
],
server_env_remove: vec![
GITHUB_APP_PRIVATE_KEY_KEY,
GITHUB_APP_CLIENT_SECRET_KEY,
GITHUB_APP_WEBHOOK_SECRET_KEY,
],
vault_set: Vec::new(),
vault_remove: vec![GITHUB_TOKEN_SECRET_KEY],
},
server_env_set: vec![
(
GITHUB_APP_PRIVATE_KEY_KEY.to_string(),
"private".to_string(),
),
(
GITHUB_APP_CLIENT_SECRET_KEY.to_string(),
"client".to_string(),
),
],
server_env_remove: vec![
GITHUB_APP_PRIVATE_KEY_KEY,
GITHUB_APP_CLIENT_SECRET_KEY,
GITHUB_APP_WEBHOOK_SECRET_KEY,
],
vault_set: Vec::new(),
vault_remove: vec![GITHUB_TOKEN_SECRET_KEY],
})
)
.unwrap();
let server_env = envfile::read_env_file(&server_env_path).unwrap();
@ -3137,10 +3149,13 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_hidden_args_without_switch() {
let args = install_args(false, InstallNonInteractiveArgs {
llm_provider: Some(Provider::Anthropic),
..InstallNonInteractiveArgs::default()
});
let args = install_args(
false,
InstallNonInteractiveArgs {
llm_provider: Some(Provider::Anthropic),
..InstallNonInteractiveArgs::default()
},
);
let err = NonInteractiveInstallInputSource::new(&args).unwrap_err();
assert!(
err.to_string()
@ -3150,14 +3165,17 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_conflicting_api_key_inputs() {
let args = install_args(true, InstallNonInteractiveArgs {
llm_provider: Some(Provider::Anthropic),
llm_api_key_stdin: true,
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
});
let args = install_args(
true,
InstallNonInteractiveArgs {
llm_provider: Some(Provider::Anthropic),
llm_api_key_stdin: true,
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
},
);
let err = NonInteractiveInstallInputSource::new(&args).unwrap_err();
assert!(
err.to_string()
@ -3330,7 +3348,7 @@ root = "{}"
let err = validate_install_github_non_interactive(
&InstallGithubArgs {
strategy: Some(InstallGitHubStrategyArg::Token),
owner: Some("personal".to_string()),
owner: Some("personal".to_string()),
},
true,
)
@ -3347,7 +3365,7 @@ root = "{}"
let err = validate_install_github_non_interactive(
&InstallGithubArgs {
strategy: Some(InstallGitHubStrategyArg::App),
owner: None,
owner: None,
},
true,
)

View file

@ -24,21 +24,21 @@ 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,
skipped: u32,
skipped: u32,
}
pub(crate) async fn execute(
@ -159,25 +159,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)
@ -477,19 +477,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),

View file

@ -18,9 +18,9 @@ use crate::shared::{color_if, 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(

View file

@ -26,12 +26,12 @@ pub(crate) async fn execute(
args.verbose = args.verbose || cli.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?;

View file

@ -119,10 +119,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(
@ -295,7 +295,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();
@ -448,7 +448,7 @@ fn state_exit_code(state: &server_client::RunProjection) -> Option<ExitCode> {
}
match state.status.as_ref() {
Some(record) if record.status == RunStatus::Succeeded => Some(ExitCode::from(0)),
Some(record) if record.status == RunStatus::Completed => Some(ExitCode::from(0)),
Some(record) if record.status.is_terminal() => Some(ExitCode::from(1)),
Some(_) | None => None,
}
@ -571,14 +571,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();

View file

@ -16,13 +16,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,
},
}
@ -110,13 +110,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(_)) => {

View file

@ -16,7 +16,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>,
}

View file

@ -49,11 +49,14 @@ pub(crate) async fn run(
.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();

View file

@ -73,19 +73,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(),
}
}
@ -97,24 +97,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 {

View file

@ -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),
})
}

View file

@ -25,9 +25,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>,
}
@ -63,11 +63,14 @@ pub(crate) async fn run(
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?;
@ -94,9 +97,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()

View file

@ -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(),
};

View file

@ -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::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::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,
))

View file

@ -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),

View file

@ -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(

View file

@ -247,8 +247,8 @@ fn build_artifact_uploader(
}
struct HttpArtifactUploader {
run_id: RunId,
client: server_client::ServerStoreClient,
run_id: RunId,
client: server_client::ServerStoreClient,
bearer_token: String,
}
@ -313,7 +313,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>>>>,
}
@ -464,7 +464,7 @@ fn worker_title(run_id: &RunId, phase: WorkerTitlePhase) -> String {
WorkerTitlePhase::Running => "running",
WorkerTitlePhase::Waiting => "waiting",
WorkerTitlePhase::Paused => "paused",
WorkerTitlePhase::Succeeded => "succeeded",
WorkerTitlePhase::Succeeded => "completed",
WorkerTitlePhase::Failed => "failed",
WorkerTitlePhase::Cancelled => "cancelled",
};
@ -607,7 +607,7 @@ mod tests {
);
assert_eq!(
worker_title(&fixtures::RUN_1, WorkerTitlePhase::Succeeded),
format!("fabro {short_id} succeeded")
format!("fabro {short_id} completed")
);
}
@ -637,12 +637,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,
})),
@ -651,39 +651,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)
@ -815,7 +815,7 @@ mod tests {
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: Provider::Anthropic,
details: AuthDetails::ApiKey {
details: AuthDetails::ApiKey {
key: "vault-key".to_string(),
},
})

View file

@ -52,7 +52,7 @@ pub(crate) async fn run(
if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE {
RunStatus::Submitted
} else {
RunStatus::Dead
RunStatus::Failed
}
});
@ -86,7 +86,7 @@ pub(crate) async fn run(
print_human_output(final_status, &run_id, conclusion.as_ref(), styles, printer);
}
if final_status == RunStatus::Succeeded {
if final_status == RunStatus::Completed {
Ok(())
} else {
std::process::exit(1);
@ -123,10 +123,10 @@ fn print_human_output(
printer: Printer,
) {
let (style, label) = match status {
RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
RunStatus::Completed => (&styles.bold_green, "Completed"),
RunStatus::Failed => (&styles.bold_red, "Failed"),
RunStatus::Dead => (&styles.bold_red, "Dead"),
// Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead
RunStatus::Cancelled => (&styles.bold_red, "Cancelled"),
// Poll loop only breaks on is_terminal() which is Completed | Failed | Cancelled
_ => unreachable!(),
};
let status_display = style.apply_to(label);
@ -167,29 +167,29 @@ mod tests {
}
#[test]
fn json_output_succeeded_with_conclusion() {
fn json_output_completed_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));
let json = build_json_output(RunStatus::Completed, &run_id, Some(&conclusion));
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "succeeded");
assert_eq!(json["status"], "completed");
assert_eq!(json["duration_ms"], 12345);
assert_eq!(json["total_usd_micros"], 420_000);
}
@ -205,23 +205,23 @@ mod tests {
}
#[test]
fn json_output_dead_status() {
let json = build_json_output(RunStatus::Dead, &fixtures::RUN_3, None);
assert_eq!(json["status"], "dead");
fn json_output_cancelled_status() {
let json = build_json_output(RunStatus::Cancelled, &fixtures::RUN_3, None);
assert_eq!(json["status"], "cancelled");
}
#[test]
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());
@ -229,30 +229,30 @@ mod tests {
}
#[test]
fn human_output_succeeded() {
fn human_output_completed() {
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,
RunStatus::Completed,
&run_id,
Some(&conclusion),
&styles,
@ -276,7 +276,7 @@ mod tests {
fn poll_terminal_immediately() {
let dir = tempfile::tempdir().unwrap();
let status_path = dir.path().join("status.json");
let record = RunStatusRecord::new(RunStatus::Succeeded, None);
let record = RunStatusRecord::new(RunStatus::Completed, None);
std::fs::write(&status_path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
// Simulate what the poll loop does
@ -286,18 +286,18 @@ mod tests {
.unwrap()
.status;
assert!(status.is_terminal());
assert_eq!(status, RunStatus::Succeeded);
assert_eq!(status, RunStatus::Completed);
}
#[test]
fn missing_status_treated_as_dead() {
fn missing_status_treated_as_failed() {
let status = match std::fs::read_to_string(std::path::Path::new("/nonexistent/status.json"))
{
Ok(data) => serde_json::from_str::<RunStatusRecord>(&data)
.map(|record| record.status)
.unwrap_or(RunStatus::Dead),
Err(_) => RunStatus::Dead,
.unwrap_or(RunStatus::Failed),
Err(_) => RunStatus::Failed,
};
assert_eq!(status, RunStatus::Dead);
assert_eq!(status, RunStatus::Failed);
}
}

View file

@ -12,13 +12,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(
@ -40,24 +40,24 @@ pub(crate) async fn run(
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()),
}

View file

@ -146,15 +146,16 @@ pub(crate) async fn list_command(
fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
let text = status.to_string();
let color = match status {
RunStatus::Succeeded => Some(Color::Green),
RunStatus::Failed => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
RunStatus::Removing => Some(Color::Yellow),
RunStatus::Completed => Some(Color::Green),
RunStatus::Failed | RunStatus::Cancelled => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted | RunStatus::Queued => {
Some(Color::Cyan)
}
RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused => Some(Color::Magenta),
RunStatus::Dead => Some(Color::Ansi256(8)),
};
text.cell()
.bold(use_color && color != Some(Color::Ansi256(8)))
.bold(use_color)
.foreground_color(color_if(use_color, color.unwrap_or(Color::Ansi256(8))))
}

View file

@ -52,13 +52,16 @@ 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(),
dev_token_path: dev_token_path.clone(),
started_at: Utc::now(),
})
record::write_server_record(
&record_path,
&record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
dev_token_path: dev_token_path.clone(),
started_at: Utc::now(),
},
)
},
))
.await

View file

@ -9,17 +9,17 @@ 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,
#[serde(skip_serializing_if = "Option::is_none")]
pub dev_token_path: Option<PathBuf>,
pub started_at: DateTime<Utc>,
pub started_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct ActiveServerRecord {
pub record: ServerRecord,
pub record: ServerRecord,
pub record_path: PathBuf,
}

View file

@ -81,14 +81,14 @@ async fn ensure_server_running_with_bind(
}
let serve_args = ServeArgs {
bind: bind_request.as_ref().map(ToString::to_string),
web: false,
no_web: false,
model: None,
provider: None,
sandbox: None,
bind: bind_request.as_ref().map(ToString::to_string),
web: false,
no_web: false,
model: None,
provider: None,
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 = if let Some(bind_request) = bind_request {
@ -266,13 +266,16 @@ async fn execute_foreground(
Some(storage_dir),
move |resolved_bind| {
print_dev_token(printer, &home, &token);
record::write_server_record(&record_path, &record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
dev_token_path: Some(home.dev_token_path()),
started_at: Utc::now(),
})
record::write_server_record(
&record_path,
&record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
dev_token_path: Some(home.dev_token_path()),
started_at: Utc::now(),
},
)
},
))
.await

View file

@ -90,9 +90,9 @@ fn finalize_export(
}
struct DumpArtifact {
stage_id: StageId,
stage_id: StageId,
relative_path: String,
data: Vec<u8>,
data: Vec<u8>,
}
trait DumpDataSource {
@ -105,9 +105,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)]
@ -148,9 +148,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)
@ -371,52 +371,50 @@ mod tests {
fn sample_status() -> RunStatusRecord {
RunStatusRecord {
status: RunStatus::Running,
reason: Some(StatusReason::SandboxInitializing),
status: RunStatus::Running,
reason: Some(StatusReason::SandboxInitializing),
blocked_reason: None,
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,
}
}
@ -429,12 +427,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()),
@ -446,11 +444,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,
}
}
@ -487,171 +485,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(
@ -705,7 +755,7 @@ mod tests {
assert_eq!(exported_start.run_id, run_id);
let exported_status: RunStatusRecord = read_json(&output.path().join("status.json"));
assert_eq!(exported_status.status, RunStatus::Succeeded);
assert_eq!(exported_status.status, RunStatus::Completed);
let exported_checkpoint: Checkpoint = read_json(&output.path().join("checkpoint.json"));
assert_eq!(exported_checkpoint.current_node, "code");

View file

@ -24,12 +24,12 @@ pub(super) async fn prune_command(
.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

View file

@ -18,13 +18,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,
}
@ -221,12 +221,12 @@ fn print_preview(inventory: &Inventory, printer: Printer) {
#[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>,
}
async fn execute_uninstall(inventory: &Inventory, json: bool, printer: Printer) -> Result<()> {
@ -235,12 +235,12 @@ async fn execute_uninstall(inventory: &Inventory, json: bool, printer: Printer)
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

View file

@ -192,7 +192,7 @@ const LAST_CHECK_FILE: &str = "last_upgrade_check.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct UpgradeCheckState {
checked_at: u64,
checked_at: u64,
latest_version: String,
}
@ -411,7 +411,7 @@ async fn check_and_print_notice(printer: Printer) -> 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);
@ -507,7 +507,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();
@ -519,7 +519,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());
@ -532,7 +532,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());
@ -543,7 +543,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();

View file

@ -21,12 +21,12 @@ pub(crate) async fn run(
) -> anyhow::Result<()> {
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
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?;

View file

@ -31,23 +31,23 @@ pub(crate) async fn version_command(
Ok(response) => {
let response = response.into_inner();
ServerVersionInfo::Success {
address: server_address,
version: response.version,
git_sha: response.git_sha,
build_date: response.build_date,
os: response.os,
arch: response.arch,
address: server_address,
version: response.version,
git_sha: response.git_sha,
build_date: response.build_date,
os: response.os,
arch: response.arch,
uptime_secs: response.uptime_secs,
}
}
Err(err) => ServerVersionInfo::Error {
address: server_address,
error: err.to_string(),
error: err.to_string(),
},
},
Err(err) => ServerVersionInfo::Error {
address: server_address,
error: err.to_string(),
error: err.to_string(),
},
};
@ -61,36 +61,36 @@ pub(crate) async fn version_command(
}
struct ClientVersionInfo {
version: &'static str,
git_sha: &'static str,
version: &'static str,
git_sha: &'static str,
build_date: &'static str,
os: &'static str,
arch: &'static str,
os: &'static str,
arch: &'static str,
}
enum ServerVersionInfo {
Success {
address: String,
version: Option<String>,
git_sha: Option<String>,
build_date: Option<String>,
os: Option<String>,
arch: Option<String>,
address: String,
version: Option<String>,
git_sha: Option<String>,
build_date: Option<String>,
os: Option<String>,
arch: Option<String>,
uptime_secs: Option<i64>,
},
Error {
address: String,
error: String,
error: String,
},
}
fn client_info() -> ClientVersionInfo {
ClientVersionInfo {
version: env!("CARGO_PKG_VERSION"),
git_sha: env!("FABRO_GIT_SHA"),
version: env!("CARGO_PKG_VERSION"),
git_sha: env!("FABRO_GIT_SHA"),
build_date: env!("FABRO_BUILD_DATE"),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
}
}

View file

@ -140,10 +140,13 @@ async fn main_inner() -> (String, Result<()>) {
Ok(settings) => settings,
Err(err) => return (command_name, Err(err)),
};
let combined_settings = combine_files(user_settings, SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
});
let combined_settings = combine_files(
user_settings,
SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
},
);
let cli_settings = match user_config::resolve_cli_settings(&combined_settings) {
Ok(cli_settings) => cli_settings,
Err(err) => return (command_name, Err(err)),

View file

@ -19,14 +19,14 @@ use crate::args::{PreflightArgs, RunArgs};
#[derive(Debug)]
pub(crate) struct ManifestBuildInput {
pub workflow: PathBuf,
pub cwd: PathBuf,
pub args_layer: SettingsLayer,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
pub workflow: PathBuf,
pub cwd: PathBuf,
pub args_layer: SettingsLayer,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
/// User-level settings layer. Production callers load via
/// `load_settings_user()`; tests pass `SettingsLayer::default()`.
pub user_layer: SettingsLayer,
pub user_layer: SettingsLayer,
/// Path to the user settings file (for inclusion in
/// `RunManifest.configs`). `None` skips the user config entry.
pub user_settings_path: Option<PathBuf>,
@ -34,21 +34,21 @@ pub(crate) struct ManifestBuildInput {
#[derive(Debug)]
pub(crate) struct BuiltManifest {
pub manifest: types::RunManifest,
pub manifest: types::RunManifest,
pub target_path: PathBuf,
}
struct CollectContext<'a> {
cwd: &'a Path,
workflows: HashMap<String, types::ManifestWorkflow>,
cwd: &'a Path,
workflows: HashMap<String, types::ManifestWorkflow>,
visited_workflows: HashSet<String>,
}
#[derive(Clone)]
struct WorkflowScanInput {
absolute_dot_path: PathBuf,
logical_dot_path: PathBuf,
source: String,
logical_dot_path: PathBuf,
source: String,
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
@ -64,8 +64,8 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
let target_logical_path_string = logical_path_string(&target_logical_path);
let mut context = CollectContext {
cwd: &input.cwd,
workflows: HashMap::new(),
cwd: &input.cwd,
workflows: HashMap::new(),
visited_workflows: HashSet::new(),
};
collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?;
@ -86,18 +86,18 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {
path: Some(path.display().to_string()),
path: Some(path.display().to_string()),
source: Some(source),
type_: types::ManifestConfigType::Project,
type_: types::ManifestConfigType::Project,
});
}
if let Some(path) = input.user_settings_path.filter(|p| p.is_file()) {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {
path: Some(path.display().to_string()),
path: Some(path.display().to_string()),
source: Some(source),
type_: types::ManifestConfigType::User,
type_: types::ManifestConfigType::User,
});
}
@ -122,7 +122,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
run_id: input.run_id.map(|run_id| run_id.to_string()),
target: types::ManifestTarget {
identifier: input.workflow.display().to_string(),
path: target_logical_path_string,
path: target_logical_path_string,
},
version: 1,
workflows: context.workflows,
@ -133,34 +133,34 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
let payload = types::ManifestArgs {
auto_approve: args.auto_approve.then_some(true),
dry_run: args.dry_run.then_some(true),
label: args.label.clone(),
model: args.model.clone(),
no_retro: args.no_retro.then_some(true),
auto_approve: args.auto_approve.then_some(true),
dry_run: args.dry_run.then_some(true),
label: args.label.clone(),
model: args.model.clone(),
no_retro: args.no_retro.then_some(true),
preserve_sandbox: args.preserve_sandbox.then_some(true),
provider: args.provider.clone(),
sandbox: args
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
verbose: args.verbose.then_some(true),
verbose: args.verbose.then_some(true),
};
(!manifest_args_is_empty(&payload)).then_some(payload)
}
pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::ManifestArgs> {
let payload = types::ManifestArgs {
auto_approve: None,
dry_run: None,
label: Vec::new(),
model: args.model.clone(),
no_retro: None,
auto_approve: None,
dry_run: None,
label: Vec::new(),
model: args.model.clone(),
no_retro: None,
preserve_sandbox: None,
provider: args.provider.clone(),
sandbox: args
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
verbose: args.verbose.then_some(true),
verbose: args.verbose.then_some(true),
};
(!manifest_args_is_empty(&payload)).then_some(payload)
}
@ -191,7 +191,7 @@ fn collect_workflow_entry(
.with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?;
let config = if let Some(workflow_toml_path) = resolution.workflow_toml_path.as_ref() {
Some(types::ManifestWorkflowConfig {
path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?),
path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?),
source: std::fs::read_to_string(workflow_toml_path)
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
})
@ -211,13 +211,14 @@ fn collect_workflow_entry(
}
collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?;
context
.workflows
.insert(logical_dot_key, types::ManifestWorkflow {
context.workflows.insert(
logical_dot_key,
types::ManifestWorkflow {
config,
files,
source,
});
},
);
Ok(())
}
@ -288,8 +289,8 @@ fn collect_workflow_files(
})?;
let imported_scan = WorkflowScanInput {
absolute_dot_path: imported.absolute_path,
logical_dot_path: imported.logical_path,
source: imported_source,
logical_dot_path: imported.logical_path,
source: imported_source,
};
collect_workflow_files(context, &imported_scan, files, visited_imports)?;
}
@ -347,7 +348,7 @@ fn collect_workflow_config_files(
struct BundledFile {
absolute_path: PathBuf,
logical_path: PathBuf,
logical_path: PathBuf,
}
fn collect_bundled_file(
@ -365,14 +366,17 @@ fn collect_bundled_file(
if !files.contains_key(&key) {
let content = std::fs::read_to_string(&absolute_path)
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
files.insert(key.clone(), types::ManifestFileEntry {
content,
ref_: types::ManifestFileRef {
from: from.map(|value| logical_path_string(&value)),
original: reference.to_string(),
type_: ref_type,
files.insert(
key.clone(),
types::ManifestFileEntry {
content,
ref_: types::ManifestFileRef {
from: from.map(|value| logical_path_string(&value)),
original: reference.to_string(),
type_: ref_type,
},
},
});
);
}
Ok(BundledFile {
@ -421,16 +425,16 @@ fn resolve_manifest_goal(
)
.ok_or_else(|| anyhow!("unsupported manifest goal reference: {reference}"))?;
return Ok(Some(types::ManifestGoal {
path: Some(reference.to_string()),
text: std::fs::read_to_string(&goal_path)
path: Some(reference.to_string()),
text: std::fs::read_to_string(&goal_path)
.with_context(|| format!("Failed to read {}", goal_path.display()))?,
type_: types::ManifestGoalType::Graph,
}));
}
Ok(Some(types::ManifestGoal {
path: None,
text: goal.to_string(),
path: None,
text: goal.to_string(),
type_: types::ManifestGoalType::Graph,
}))
}
@ -441,13 +445,13 @@ fn resolve_manifest_goal(
fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
match resolved.source {
ResolvedGoalSource::Inline => types::ManifestGoal {
path: None,
text: resolved.text,
path: None,
text: resolved.text,
type_: types::ManifestGoalType::Value,
},
ResolvedGoalSource::File { path } => types::ManifestGoal {
path: Some(path.to_string_lossy().into_owned()),
text: resolved.text,
path: Some(path.to_string_lossy().into_owned()),
text: resolved.text,
type_: types::ManifestGoalType::File,
},
}
@ -600,12 +604,12 @@ mod tests {
.unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -680,12 +684,12 @@ file = "prompts/goal.md"
.unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -733,12 +737,12 @@ file = "prompts/goal.md"
.unwrap();
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();

View file

@ -31,20 +31,20 @@ use crate::{sse, user_config};
#[derive(Clone)]
pub(crate) struct ServerStoreClient {
client: fabro_api::Client,
client: fabro_api::Client,
http_client: fabro_http::HttpClient,
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>,
}
@ -109,7 +109,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
}
@ -436,14 +436,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 {
@ -887,11 +887,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((
@ -1168,13 +1168,16 @@ mod tests {
let record_path = fabro_config::Storage::new(storage.path())
.server_state()
.record_path();
record::write_server_record(&record_path, &record::ServerRecord {
pid: std::process::id(),
bind: Bind::Unix(temp_home.path().join("fabro.sock")),
log_path: storage.path().join("server.log"),
dev_token_path: Some(token_path),
started_at: chrono::Utc::now(),
})
record::write_server_record(
&record_path,
&record::ServerRecord {
pid: std::process::id(),
bind: Bind::Unix(temp_home.path().join("fabro.sock")),
log_path: storage.path().join("server.log"),
dev_token_path: Some(token_path),
started_at: chrono::Utc::now(),
},
)
.unwrap();
std::env::remove_var("FABRO_DEV_TOKEN");

View file

@ -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 {
@ -63,7 +63,7 @@ impl ServerRunSummaryInfo {
}
pub(crate) fn status(&self) -> RunStatus {
self.summary.status.unwrap_or(RunStatus::Dead)
self.summary.status.unwrap_or(RunStatus::Failed)
}
pub(crate) fn status_reason(&self) -> Option<StatusReason> {
@ -105,7 +105,7 @@ impl ServerRunSummaryInfo {
pub(crate) struct ServerSummaryLookup {
client: Arc<ServerStoreClient>,
runs: Vec<ServerRunSummaryInfo>,
runs: Vec<ServerRunSummaryInfo>,
}
impl ServerSummaryLookup {

View file

@ -71,7 +71,7 @@ pub(crate) enum ApiKeySource {
pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
let auth_header = if provider == Provider::Anthropic {
ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
name: "x-api-key".to_string(),
value: api_key.to_string(),
}
} else {

View file

@ -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))
}
@ -268,7 +268,7 @@ mod tests {
.unwrap(),
Some(ServerTarget::HttpUrl {
api_url: "https://cli.example.com".to_string(),
tls: None,
tls: None,
})
);
}
@ -314,7 +314,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,
}
);
}
@ -338,7 +338,7 @@ url = "https://config.example.com"
.unwrap(),
ServerTarget::HttpUrl {
api_url: "https://cli.example.com".to_string(),
tls: None,
tls: None,
}
);
}
@ -371,7 +371,7 @@ url = "https://config.example.com"
.unwrap(),
ServerTarget::HttpUrl {
api_url: "https://cli.example.com".to_string(),
tls: None,
tls: None,
}
);
}
@ -380,8 +380,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#"
@ -405,7 +405,7 @@ ca = "ca.pem"
.unwrap(),
Some(ServerTarget::HttpUrl {
api_url: "https://cli.example.com".to_string(),
tls: Some(expected_tls),
tls: Some(expected_tls),
})
);
}

View file

@ -257,7 +257,7 @@ fn attach_before_completion_streams_to_finished_state() {
stderr: stderr_reader.join().expect("stderr reader should join"),
};
let snapshot = format_output_snapshot(&output, &filters);
wait_for_status(&run.run_dir, &["succeeded"]);
wait_for_status(&run.run_dir, &["completed"]);
insta::assert_snapshot!(snapshot, @"
success: true
@ -848,5 +848,5 @@ fn attach_json_errors_without_prompting_for_human_input() {
.expect("answer submission should succeed");
assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT);
});
wait_for_status(&run.run_dir, &["succeeded"]);
wait_for_status(&run.run_dir, &["completed"]);
}

View file

@ -516,9 +516,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"].as_bool(), Some(true));
}

View file

@ -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(

View file

@ -77,7 +77,7 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": "completed",
"run_record": {
"goal": "Run tests and report results",
"workflow_name": "Simple",
@ -143,7 +143,7 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": "completed",
"run_record": {
"goal": "Run tests and report results",
"workflow_name": "Simple",
@ -194,7 +194,7 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": "completed",
"run_record": {
"goal": "Edit a tracked file",
"workflow_name": "Flow",

View file

@ -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",
],
);
}

View file

@ -74,14 +74,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

View file

@ -136,7 +136,7 @@ fn ps_all_json_lists_created_and_completed_runs() {
"ps should include the created run: {runs:#?}"
);
assert!(
runs.iter().any(|run| run["status"] == "succeeded"),
runs.iter().any(|run| run["status"] == "completed"),
"ps should include the completed run: {runs:#?}"
);
}
@ -247,7 +247,7 @@ fn ps_filters_by_workflow_and_label() {
);
let run = &runs[0];
assert_eq!(run["workflow_name"], "Simple");
assert_eq!(run["status"], "succeeded");
assert_eq!(run["status"], "completed");
assert_eq!(run["labels"]["suite"], "alpha");
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
@ -274,7 +274,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
},
"host_repo_path": "/srv/repo",
"start_time": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status": "completed",
"status_reason": null,
"duration_ms": 123,
"total_usd_micros": null

View file

@ -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);
@ -98,7 +98,7 @@ fn resume_rewound_run_succeeds() {
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
Completed [ULID] [DURATION]
");
assert_eq!(
@ -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());
}

View file

@ -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();

View file

@ -279,7 +279,7 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
"labels": {},
"host_repo_path": null,
"start_time": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status": "completed",
"status_reason": null,
"duration_ms": 123,
"total_usd_micros": null

View file

@ -97,7 +97,7 @@ fn seed_anthropic_vault(storage_dir: &std::path::Path, base_url: &str) {
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: Provider::Anthropic,
details: AuthDetails::ApiKey {
details: AuthDetails::ApiKey {
key: "vault-anthropic-key".to_string(),
},
})

View file

@ -78,7 +78,7 @@ fn start_by_run_id_starts_created_run() {
}),
@r#"
{
"status": "succeeded"
"status": "completed"
}
"#
);
@ -123,7 +123,7 @@ fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
}),
@r#"
{
"status": "succeeded"
"status": "completed"
}
"#
);
@ -173,7 +173,7 @@ fn start_rejects_already_active_or_completed_run() {
");
gate.release();
wait_for_status(&run.run_dir, &["succeeded"]);
wait_for_status(&run.run_dir, &["completed"]);
let mut completed_cmd = context.command();
completed_cmd.args(["start", &run_id]);
@ -182,7 +182,7 @@ fn start_rejects_already_active_or_completed_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: cannot start run: status is Succeeded, expected submitted
error: cannot start run: status is Completed, expected submitted
");
}
@ -237,5 +237,5 @@ fn start_runs_under_server_ownership_without_launcher_record() {
);
gate.release();
wait_for_status(&run.run_dir, &["succeeded"]);
wait_for_status(&run.run_dir, &["completed"]);
}

View file

@ -35,23 +35,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,
}
@ -157,10 +157,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
}
@ -659,7 +659,7 @@ fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
#[derive(Debug, serde::Deserialize)]
struct TestServerRecord {
bind: Bind,
bind: Bind,
#[serde(default)]
dev_token_path: Option<PathBuf>,
}
@ -829,11 +829,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())
@ -846,11 +845,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())
@ -1038,9 +1040,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];
@ -1049,9 +1053,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];
@ -1059,8 +1063,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"]);

View file

@ -84,7 +84,7 @@ fn system_df_verbose_lists_runs_with_reclaimable_marker() {
"verbose system df should include the workflow name: {stdout}"
);
assert!(
stdout.contains("succeeded"),
stdout.contains("completed"),
"verbose system df should include the run status: {stdout}"
);
assert!(

View file

@ -49,7 +49,7 @@ fn wait_completed_run_prints_success_summary() {
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
Completed [ULID] [DURATION]
");
}
@ -70,7 +70,7 @@ fn wait_completed_run_reads_store_without_status_or_conclusion_files() {
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
Completed [ULID] [DURATION]
");
}
@ -92,7 +92,7 @@ fn wait_completed_run_json_outputs_status_and_duration() {
----- stdout -----
{
"run_id": "[ULID]",
"status": "succeeded",
"status": "completed",
"duration_ms": [DURATION_MS]
}
----- stderr -----

View file

@ -144,7 +144,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
}),
@r#"
{
"status": "succeeded",
"status": "completed",
"has_conclusion": true
}
"#

View file

@ -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 {

View file

@ -137,10 +137,13 @@ mod tests {
let path = dir.path().join("server.env");
std::fs::write(&path, "EXISTING=value\n").unwrap();
let entries = merge_env_file(&path, [
("SESSION_SECRET", "secret"),
("FABRO_JWT_PUBLIC_KEY", "jwt"),
])
let entries = merge_env_file(
&path,
[
("SESSION_SECRET", "secret"),
("FABRO_JWT_PUBLIC_KEY", "jwt"),
],
)
.unwrap();
assert_eq!(entries.get("EXISTING").map(String::as_str), Some("value"));

View file

@ -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,
},

View file

@ -32,12 +32,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),
}
}
@ -76,10 +76,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),
}
}
@ -87,10 +87,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),
}
}
@ -98,30 +98,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),
}
}
@ -163,7 +163,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),
}
}
@ -175,9 +175,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),
}
}
@ -195,13 +195,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),
}
}
@ -209,10 +209,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),
}
}
@ -238,12 +238,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),
}
}
@ -276,9 +276,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),
}
}
@ -286,7 +286,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),
}
}
@ -321,18 +321,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),
}
}
@ -341,10 +341,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: merge_option(lower.output, higher.output, combine_cli_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: merge_option(lower.output, higher.output, combine_cli_output),
updates: merge_option(lower.updates, higher.updates, combine_cli_updates),
logging: higher.logging.or(lower.logging),
}
@ -358,8 +358,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),
}
}
@ -369,7 +369,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),
}
}
@ -379,13 +379,13 @@ 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),
}
}
fn combine_cli_output(lower: CliOutputLayer, higher: CliOutputLayer) -> CliOutputLayer {
CliOutputLayer {
format: higher.format.or(lower.format),
format: higher.format.or(lower.format),
verbosity: higher.verbosity.or(lower.verbosity),
}
}
@ -400,15 +400,15 @@ fn combine_cli_updates(lower: CliUpdatesLayer, higher: CliUpdatesLayer) -> CliUp
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,
@ -425,14 +425,14 @@ 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),
}
}
fn combine_server_auth(lower: ServerAuthLayer, higher: ServerAuthLayer) -> ServerAuthLayer {
ServerAuthLayer {
methods: higher.methods.or(lower.methods),
github: higher.github.or(lower.github),
github: higher.github.or(lower.github),
}
}
@ -451,9 +451,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),
}
}
@ -462,12 +462,12 @@ 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),
disk_cache: higher.disk_cache.or(lower.disk_cache),
local: higher.local.or(lower.local),
s3: higher.s3.or(lower.s3),
disk_cache: higher.disk_cache.or(lower.disk_cache),
}
}
@ -485,10 +485,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),
}
}

View file

@ -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),
});
}

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