Create fabro-types and rewire shared types

This commit is contained in:
Bryan Helmkamp 2026-03-27 21:15:03 -04:00
parent 8b148d06e4
commit b032ae32b4
No known key found for this signature in database
105 changed files with 3000 additions and 4887 deletions

40
Cargo.lock generated
View file

@ -1276,6 +1276,7 @@ dependencies = [
"fabro-model",
"fabro-retro",
"fabro-sandbox",
"fabro-types",
"fabro-util",
"fabro-workflows",
"futures-util",
@ -1357,6 +1358,7 @@ dependencies = [
"fabro-retro",
"fabro-sandbox",
"fabro-telemetry",
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-workflows",
@ -1401,7 +1403,7 @@ dependencies = [
"anyhow",
"clap",
"dirs",
"fabro-config-derive",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
@ -1411,20 +1413,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "fabro-config-derive"
version = "0.176.2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "fabro-core"
version = "0.176.2"
dependencies = [
"async-trait",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
@ -1492,6 +1486,7 @@ name = "fabro-graphviz"
version = "0.176.2"
dependencies = [
"anyhow",
"fabro-types",
"nom",
"regex",
"serde",
@ -1507,6 +1502,7 @@ dependencies = [
"fabro-config",
"fabro-llm",
"fabro-model",
"fabro-types",
"fabro-util",
"mockito",
"regex",
@ -1570,6 +1566,7 @@ version = "0.176.2"
dependencies = [
"anyhow",
"fabro-config",
"fabro-types",
"futures",
"reqwest",
"rmcp",
@ -1614,6 +1611,7 @@ dependencies = [
"chrono",
"fabro-agent",
"fabro-llm",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
@ -1634,6 +1632,7 @@ dependencies = [
"daytona-sdk",
"fabro-config",
"fabro-github",
"fabro-types",
"futures",
"git2",
"glob",
@ -1708,6 +1707,26 @@ dependencies = [
"tracing",
]
[[package]]
name = "fabro-types"
version = "0.176.2"
dependencies = [
"chrono",
"clap",
"fabro-types-derive",
"serde",
"serde_json",
]
[[package]]
name = "fabro-types-derive"
version = "0.176.2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "fabro-util"
version = "0.176.2"
@ -1764,6 +1783,7 @@ dependencies = [
"fabro-model",
"fabro-retro",
"fabro-sandbox",
"fabro-types",
"fabro-util",
"fabro-validate",
"futures",

View file

@ -20,6 +20,7 @@ fabro-agent = { path = "../fabro-agent" }
fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../fabro-model" }
fabro-retro = { path = "../fabro-retro" }
fabro-types = { path = "../fabro-types", features = ["exedev"] }
fabro-util = { path = "../fabro-util" }
fabro-db = { path = "../fabro-db" }
fabro-api-types = { path = "../fabro-api-types" }
@ -63,4 +64,4 @@ http-body-util = "0.1"
tempfile = "3"
openapiv3 = "2"
serde_yaml = "0.9"
fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] }
fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] }

View file

@ -19,13 +19,14 @@ use tracing::{error, info};
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser};
use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer};
use fabro_retro::RetroExt;
use fabro_workflows::context::Context;
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
use fabro_workflows::operations::{self, CreateRunInput, WorkflowInput};
use fabro_workflows::pipeline::{
self, InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec,
};
use fabro_workflows::records::Checkpoint;
use fabro_workflows::records::{Checkpoint, CheckpointExt};
use fabro_workflows::run_options::LifecycleOptions;
use fabro_workflows::run_options::RunOptions;

View file

@ -12,7 +12,7 @@ path = "src/main.rs"
[features]
default = []
server = ["dep:fabro-api"]
exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-workflows/exedev"]
exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-workflows/exedev", "fabro-types/exedev"]
sleep_inhibitor = ["dep:core-foundation"]
[dependencies]
@ -34,6 +34,7 @@ fabro-validate = { path = "../fabro-validate" }
fabro-workflows = { path = "../fabro-workflows" }
fabro-api = { path = "../fabro-api", optional = true }
fabro-telemetry = { path = "../fabro-telemetry" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
clap.workspace = true
cli-table.workspace = true

View file

@ -1,6 +1,7 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use crate::args::AssetCpArgs;
use crate::shared::split_run_path;

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use crate::args::AssetListArgs;
use crate::shared::format_size;

View file

@ -1,6 +1,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use crate::args::PrCloseArgs;

View file

@ -1,7 +1,9 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_model::Catalog;
use fabro_workflows::records::{ConclusionExt, RunRecordExt, StartRecordExt};
use tracing::info;
use crate::args::PrCreateArgs;

View file

@ -1,6 +1,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use crate::args::PrListArgs;

View file

@ -1,6 +1,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use crate::args::PrMergeArgs;

View file

@ -1,6 +1,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use tracing::info;
use crate::args::PrViewArgs;

View file

@ -9,7 +9,8 @@ use anyhow::{bail, Result};
use fabro_interview::{AnswerValue, ConsoleInterviewer};
use fabro_util::terminal::Styles;
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
use fabro_workflows::records::{ConclusionExt, RunRecordExt};
use fabro_workflows::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt};
use super::run_progress;

View file

@ -1,6 +1,8 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use tracing::{debug, info};
use crate::args::CpArgs;

View file

@ -2,6 +2,9 @@ use std::io::{self, IsTerminal, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::records::StartRecordExt;
use tracing::{debug, info};
use crate::args::DiffArgs;

View file

@ -2,6 +2,8 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::FabroSettingsExt;
use fabro_workflows::records::RunRecordExt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -3,6 +3,7 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use tracing::{debug, info};

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use crate::args::{GlobalArgs, RunCommands};

View file

@ -4,7 +4,7 @@ use std::time::Duration;
use fabro_util::terminal::Styles;
use fabro_workflows::outcome::{format_cost, StageStatus};
use fabro_workflows::pipeline::{Persisted, Validated};
use fabro_workflows::records::Checkpoint;
use fabro_workflows::records::{Checkpoint, CheckpointExt, ConclusionExt};
use indicatif::HumanDuration;
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};

View file

@ -1,4 +1,6 @@
use anyhow::{Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use tracing::info;
use crate::args::PreviewArgs;

View file

@ -1,6 +1,7 @@
use anyhow::bail;
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::records::RunRecord;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use crate::args::ResumeArgs;

View file

@ -1,4 +1,6 @@
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use tracing::info;
use crate::args::SshArgs;

View file

@ -2,6 +2,8 @@ use std::path::Path;
use anyhow::{anyhow, Result};
use chrono::Utc;
use fabro_config::FabroSettingsExt;
use fabro_workflows::records::RunRecordExt;
use super::launcher::{
launcher_log_path, launcher_record_path, remove_launcher_record, write_launcher_record,

View file

@ -1,8 +1,10 @@
use std::io::Write;
use anyhow::{bail, Result};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
use fabro_workflows::records::ConclusionExt;
use fabro_workflows::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt};
use tracing::info;
use crate::args::WaitArgs;

View file

@ -1,6 +1,9 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use fabro_workflows::records::{CheckpointExt, ConclusionExt, RunRecordExt, StartRecordExt};
use serde::Serialize;
use crate::args::InspectArgs;

View file

@ -4,6 +4,7 @@ use anyhow::Result;
use chrono::Utc;
use cli_table::format::{Border, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table};
use fabro_config::FabroSettingsExt;
use fabro_util::terminal::Styles;
use crate::args::RunsListArgs;

View file

@ -1,6 +1,8 @@
use std::path::Path;
use anyhow::{bail, Context, Result};
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use tracing::warn;
use crate::args::RunsRemoveArgs;

View file

@ -4,6 +4,7 @@ use anyhow::Result;
use chrono::{DateTime, Utc};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{print_stdout, Cell, CellStruct, Style, Table};
use fabro_config::FabroSettingsExt;
use crate::args::DfArgs;
use crate::shared::format_size;

View file

@ -2,6 +2,7 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use fabro_config::FabroSettingsExt;
use tracing::{debug, info};
use crate::args::RunsPruneArgs;

View file

@ -10,13 +10,13 @@ doctest = false
[features]
default = []
exedev = []
clap = ["dep:clap"]
exedev = ["fabro-types/exedev"]
clap = ["dep:clap", "fabro-types/clap"]
[dependencies]
anyhow.workspace = true
clap = { workspace = true, optional = true }
fabro-config-derive = { path = "../fabro-config-derive" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
dirs.workspace = true
serde.workspace = true

View file

@ -3,30 +3,9 @@ use std::path::{Path, PathBuf};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "kebab-case")]
pub enum PermissionLevel {
ReadOnly,
ReadWrite,
Full,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
#[default]
Standalone,
Server,
}
pub use fabro_types::settings::cli::{
ClientTlsSettings, ExecSettings, ExecutionMode, OutputFormat, PermissionLevel, ServerSettings,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ClientTlsConfig {
@ -35,13 +14,6 @@ pub struct ClientTlsConfig {
pub ca: Option<PathBuf>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ClientTlsSettings {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
impl TryFrom<ClientTlsConfig> for ClientTlsSettings {
type Error = anyhow::Error;
@ -66,12 +38,6 @@ pub struct ServerConfig {
pub tls: Option<ClientTlsConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ServerSettings {
pub base_url: Option<String>,
pub tls: Option<ClientTlsSettings>,
}
impl TryFrom<ServerConfig> for ServerSettings {
type Error = anyhow::Error;
@ -91,14 +57,6 @@ pub struct ExecConfig {
pub output_format: Option<OutputFormat>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExecSettings {
pub provider: Option<String>,
pub model: Option<String>,
pub permissions: Option<PermissionLevel>,
pub output_format: Option<OutputFormat>,
}
impl From<ExecConfig> for ExecSettings {
fn from(value: ExecConfig) -> Self {
Self {

View file

@ -1,60 +1 @@
use std::collections::HashMap;
use std::hash::Hash;
use std::path::PathBuf;
pub trait Combine {
fn combine(self, other: Self) -> Self;
}
impl<T: Combine> Combine for Option<T> {
fn combine(self, other: Self) -> Self {
match (self, other) {
(Some(this), Some(other)) => Some(this.combine(other)),
(Some(this), None) => Some(this),
(None, Some(other)) => Some(other),
(None, None) => None,
}
}
}
impl<T> Combine for Vec<T> {
fn combine(mut self, other: Self) -> Self {
self.extend(other);
self
}
}
impl<K, V> Combine for HashMap<K, V>
where
K: Eq + Hash,
V: Combine,
{
fn combine(mut self, other: Self) -> Self {
for (key, value) in other {
match self.remove(&key) {
Some(existing) => {
self.insert(key, existing.combine(value));
}
None => {
self.insert(key, value);
}
}
}
self
}
}
macro_rules! impl_left_wins {
($($ty:ty),* $(,)?) => {
$(
impl Combine for $ty {
fn combine(self, _other: Self) -> Self {
self
}
}
)*
};
}
impl_left_wins!(bool, i32, u16, u32, u64, usize, String, PathBuf,);
pub use fabro_types::combine::*;

View file

@ -1,760 +1 @@
use std::borrow::Cow;
use serde::{Deserialize, Serialize};
/// Lifecycle events that can trigger user-defined hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
RunStart,
RunComplete,
RunFailed,
StageStart,
StageComplete,
StageFailed,
StageRetrying,
EdgeSelected,
ParallelStart,
ParallelComplete,
/// Reserved: hooks for this event are not yet invoked by the engine.
SandboxReady,
/// Reserved: hooks for this event are not yet invoked by the engine.
SandboxCleanup,
CheckpointSaved,
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
/// Whether hooks for this event block execution by default.
#[must_use]
pub fn is_blocking_by_default(self) -> bool {
matches!(
self,
Self::RunStart
| Self::StageStart
| Self::EdgeSelected
| Self::PreToolUse
| Self::SandboxReady
)
}
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::RunStart => "run_start",
Self::RunComplete => "run_complete",
Self::RunFailed => "run_failed",
Self::StageStart => "stage_start",
Self::StageComplete => "stage_complete",
Self::StageFailed => "stage_failed",
Self::StageRetrying => "stage_retrying",
Self::EdgeSelected => "edge_selected",
Self::ParallelStart => "parallel_start",
Self::ParallelComplete => "parallel_complete",
Self::SandboxReady => "sandbox_ready",
Self::SandboxCleanup => "sandbox_cleanup",
Self::CheckpointSaved => "checkpoint_saved",
Self::PreToolUse => "pre_tool_use",
Self::PostToolUse => "post_tool_use",
Self::PostToolUseFailure => "post_tool_use_failure",
})
}
}
/// TLS verification mode for HTTP hooks.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TlsMode {
/// Require `https://` and verify certificates (default).
#[default]
Verify,
/// Require `https://` but skip certificate verification.
NoVerify,
/// Allow `http://`; skip certificate verification for `https://`.
Off,
}
/// How a hook is executed.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookType {
Command {
command: String,
},
Http {
url: String,
headers: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
allowed_env_vars: Vec<String>,
#[serde(default)]
tls: TlsMode,
},
Prompt {
prompt: String,
model: Option<String>,
},
Agent {
prompt: String,
model: Option<String>,
max_tool_rounds: Option<u32>,
},
}
/// A single hook definition.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct HookDefinition {
pub name: Option<String>,
pub event: HookEvent,
/// Inline command shorthand — if set, implies `type = "command"`.
#[serde(default)]
pub command: Option<String>,
/// Explicit hook type (command or http). If omitted and `command` is set,
/// defaults to `Command`.
#[serde(flatten)]
pub hook_type: Option<HookType>,
/// Regex matched against node_id, handler_type, or event-specific fields.
pub matcher: Option<String>,
/// Override the event's default blocking behavior.
pub blocking: Option<bool>,
/// Timeout in milliseconds (default: 60_000).
pub timeout_ms: Option<u64>,
/// Run inside the sandbox (true, default) or on the host (false).
pub sandbox: Option<bool>,
}
impl HookDefinition {
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
/// shorthand, then error.
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
if let Some(ref ht) = self.hook_type {
return Some(Cow::Borrowed(ht));
}
self.command.as_ref().map(|cmd| {
Cow::Owned(HookType::Command {
command: cmd.clone(),
})
})
}
/// Whether this hook is blocking for its event.
#[must_use]
pub fn is_blocking(&self) -> bool {
self.blocking
.unwrap_or_else(|| self.event.is_blocking_by_default())
}
/// Timeout duration for this hook.
///
/// Defaults: 30s for prompt hooks, 60s for all others.
#[must_use]
pub fn timeout(&self) -> std::time::Duration {
if let Some(ms) = self.timeout_ms {
return std::time::Duration::from_millis(ms);
}
let default_ms = match self.resolved_hook_type().as_deref() {
Some(HookType::Prompt { .. }) => 30_000,
_ => 60_000,
};
std::time::Duration::from_millis(default_ms)
}
/// Whether this hook runs in the sandbox.
#[must_use]
pub fn runs_in_sandbox(&self) -> bool {
self.sandbox.unwrap_or(true)
}
/// The effective name: explicit name or a generated one.
#[must_use]
pub fn effective_name(&self) -> String {
if let Some(ref n) = self.name {
return n.clone();
}
let event_str = self.event.to_string();
match self.resolved_hook_type().as_deref() {
Some(HookType::Command { ref command }) => {
let short = &command[..command.floor_char_boundary(20)];
format!("{event_str}:{short}")
}
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
Some(HookType::Prompt { ref prompt, .. })
| Some(HookType::Agent { ref prompt, .. }) => {
let short = &prompt[..prompt.floor_char_boundary(20)];
format!("{event_str}:{short}")
}
None => event_str,
}
}
}
/// Top-level hook configuration: a list of hook definitions.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct HookConfig {
#[serde(default)]
pub hooks: Vec<HookDefinition>,
}
impl HookConfig {
/// Merge with another config. Concatenates lists; on name collisions, `other` wins.
#[must_use]
pub fn merge(self, other: Self) -> Self {
let mut by_name: std::collections::HashMap<String, HookDefinition> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for hook in self.hooks {
let name = hook.effective_name();
if !by_name.contains_key(&name) {
order.push(name.clone());
}
by_name.insert(name, hook);
}
for hook in other.hooks {
let name = hook.effective_name();
if !by_name.contains_key(&name) {
order.push(name.clone());
}
by_name.insert(name, hook);
}
let hooks = order
.into_iter()
.filter_map(|name| by_name.remove(&name))
.collect();
Self { hooks }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_event_serde_round_trip() {
let events = [
HookEvent::RunStart,
HookEvent::RunComplete,
HookEvent::RunFailed,
HookEvent::StageStart,
HookEvent::StageComplete,
HookEvent::StageFailed,
HookEvent::StageRetrying,
HookEvent::EdgeSelected,
HookEvent::ParallelStart,
HookEvent::ParallelComplete,
HookEvent::SandboxReady,
HookEvent::SandboxCleanup,
HookEvent::CheckpointSaved,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::PostToolUseFailure,
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let back: HookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
}
#[test]
fn hook_event_serializes_as_snake_case() {
assert_eq!(
serde_json::to_string(&HookEvent::RunStart).unwrap(),
"\"run_start\""
);
assert_eq!(
serde_json::to_string(&HookEvent::StageRetrying).unwrap(),
"\"stage_retrying\""
);
}
#[test]
fn hook_event_display() {
assert_eq!(HookEvent::RunStart.to_string(), "run_start");
assert_eq!(HookEvent::CheckpointSaved.to_string(), "checkpoint_saved");
}
#[test]
fn hook_event_blocking_defaults() {
assert!(HookEvent::RunStart.is_blocking_by_default());
assert!(HookEvent::StageStart.is_blocking_by_default());
assert!(HookEvent::EdgeSelected.is_blocking_by_default());
assert!(HookEvent::SandboxReady.is_blocking_by_default());
assert!(!HookEvent::SandboxCleanup.is_blocking_by_default());
assert!(!HookEvent::RunComplete.is_blocking_by_default());
assert!(!HookEvent::StageFailed.is_blocking_by_default());
assert!(!HookEvent::CheckpointSaved.is_blocking_by_default());
}
#[test]
fn pre_tool_use_serde_round_trip() {
let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
assert_eq!(json, "\"pre_tool_use\"");
let back: HookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back, HookEvent::PreToolUse);
}
#[test]
fn pre_tool_use_is_blocking_by_default() {
assert!(HookEvent::PreToolUse.is_blocking_by_default());
}
#[test]
fn post_tool_use_is_not_blocking_by_default() {
assert!(!HookEvent::PostToolUse.is_blocking_by_default());
}
#[test]
fn post_tool_use_failure_is_not_blocking_by_default() {
assert!(!HookEvent::PostToolUseFailure.is_blocking_by_default());
}
#[test]
fn parse_command_shorthand() {
let toml = r#"
[[hooks]]
event = "stage_start"
command = "./scripts/pre-check.sh"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hooks.len(), 1);
let hook = &config.hooks[0];
assert_eq!(hook.event, HookEvent::StageStart);
assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh"));
let resolved = hook.resolved_hook_type().unwrap();
assert!(
matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh")
);
}
#[test]
fn parse_explicit_command_type() {
let toml = r#"
[[hooks]]
event = "run_start"
type = "command"
command = "echo hello"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hooks.len(), 1);
let hook = &config.hooks[0];
assert_eq!(hook.event, HookEvent::RunStart);
assert!(hook.resolved_hook_type().is_some());
}
#[test]
fn parse_http_hook() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert!(matches!(
hook.resolved_hook_type().as_deref(),
Some(HookType::Http { url, .. }) if url == "https://hooks.example.com/done"
));
}
#[test]
fn parse_http_hook_with_allowed_env_vars() {
let toml = r#"
[[hooks]]
event = "run_start"
type = "http"
url = "https://hooks.example.com/start"
allowed_env_vars = ["API_KEY", "SECRET"]
[hooks.headers]
Authorization = "Bearer $API_KEY"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
match &*hook.resolved_hook_type().unwrap() {
HookType::Http {
url,
headers,
allowed_env_vars,
..
} => {
assert_eq!(url, "https://hooks.example.com/start");
assert_eq!(allowed_env_vars, &["API_KEY", "SECRET"]);
assert_eq!(
headers.as_ref().unwrap().get("Authorization").unwrap(),
"Bearer $API_KEY"
);
}
_ => panic!("expected Http hook type"),
}
}
#[test]
fn parse_http_hook_allowed_env_vars_defaults_empty() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
match &*hook.resolved_hook_type().unwrap() {
HookType::Http {
allowed_env_vars, ..
} => {
assert!(allowed_env_vars.is_empty());
}
_ => panic!("expected Http hook type"),
}
}
#[test]
fn parse_full_hook_definition() {
let toml = r#"
[[hooks]]
name = "pre-check"
event = "stage_start"
command = "./check.sh"
matcher = "agent_loop"
blocking = true
timeout_ms = 30000
sandbox = false
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert_eq!(hook.name.as_deref(), Some("pre-check"));
assert_eq!(hook.event, HookEvent::StageStart);
assert_eq!(hook.matcher.as_deref(), Some("agent_loop"));
assert!(hook.is_blocking());
assert_eq!(hook.timeout(), std::time::Duration::from_millis(30_000));
assert!(!hook.runs_in_sandbox());
}
#[test]
fn blocking_defaults_to_event() {
let blocking_def = HookDefinition {
name: None,
event: HookEvent::StageStart,
command: Some("echo".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert!(blocking_def.is_blocking());
let non_blocking_def = HookDefinition {
event: HookEvent::StageComplete,
..blocking_def.clone()
};
assert!(!non_blocking_def.is_blocking());
}
#[test]
fn blocking_override() {
let def = HookDefinition {
name: None,
event: HookEvent::StageComplete,
command: Some("echo".into()),
hook_type: None,
matcher: None,
blocking: Some(true),
timeout_ms: None,
sandbox: None,
};
assert!(def.is_blocking());
}
#[test]
fn timeout_defaults_to_60s() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: Some("echo".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
}
#[test]
fn sandbox_defaults_to_true() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: Some("echo".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert!(def.runs_in_sandbox());
}
#[test]
fn effective_name_uses_explicit() {
let def = HookDefinition {
name: Some("my-hook".into()),
event: HookEvent::RunStart,
command: Some("echo hi".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.effective_name(), "my-hook");
}
#[test]
fn effective_name_generated_from_event_and_command() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: Some("echo hi".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.effective_name(), "run_start:echo hi");
}
#[test]
fn config_merge_concatenates() {
let a = HookConfig {
hooks: vec![HookDefinition {
name: Some("hook-a".into()),
event: HookEvent::RunStart,
command: Some("echo a".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
}],
};
let b = HookConfig {
hooks: vec![HookDefinition {
name: Some("hook-b".into()),
event: HookEvent::RunComplete,
command: Some("echo b".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
}],
};
let merged = a.merge(b);
assert_eq!(merged.hooks.len(), 2);
assert_eq!(merged.hooks[0].name.as_deref(), Some("hook-a"));
assert_eq!(merged.hooks[1].name.as_deref(), Some("hook-b"));
}
#[test]
fn config_merge_name_collision_later_wins() {
let a = HookConfig {
hooks: vec![HookDefinition {
name: Some("shared".into()),
event: HookEvent::RunStart,
command: Some("echo a".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
}],
};
let b = HookConfig {
hooks: vec![HookDefinition {
name: Some("shared".into()),
event: HookEvent::RunComplete,
command: Some("echo b".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
}],
};
let merged = a.merge(b);
assert_eq!(merged.hooks.len(), 1);
assert_eq!(merged.hooks[0].event, HookEvent::RunComplete);
}
#[test]
fn parse_http_hook_tls_defaults_to_verify() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
match &*hook.resolved_hook_type().unwrap() {
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Verify),
_ => panic!("expected Http hook type"),
}
}
#[test]
fn parse_http_hook_tls_no_verify() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
tls = "no_verify"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
match &*hook.resolved_hook_type().unwrap() {
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::NoVerify),
_ => panic!("expected Http hook type"),
}
}
#[test]
fn parse_http_hook_tls_off() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "http"
url = "http://localhost:8080/done"
tls = "off"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
match &*hook.resolved_hook_type().unwrap() {
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Off),
_ => panic!("expected Http hook type"),
}
}
#[test]
fn parse_prompt_hook() {
let toml = r#"
[[hooks]]
event = "stage_start"
type = "prompt"
prompt = "Should this stage proceed?"
model = "haiku"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert!(matches!(
hook.resolved_hook_type().as_deref(),
Some(HookType::Prompt { prompt, model })
if prompt == "Should this stage proceed?" && *model == Some("haiku".into())
));
}
#[test]
fn parse_agent_hook() {
let toml = r#"
[[hooks]]
event = "run_complete"
type = "agent"
prompt = "Verify tests pass."
model = "sonnet"
max_tool_rounds = 10
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
let hook = &config.hooks[0];
assert!(matches!(
hook.resolved_hook_type().as_deref(),
Some(HookType::Agent { prompt, model, max_tool_rounds })
if prompt == "Verify tests pass."
&& *model == Some("sonnet".into())
&& *max_tool_rounds == Some(10)
));
}
#[test]
fn prompt_hook_default_timeout_30s() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "check".into(),
model: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.timeout(), std::time::Duration::from_secs(30));
}
#[test]
fn agent_hook_default_timeout_60s() {
let def = HookDefinition {
name: None,
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Agent {
prompt: "check".into(),
model: None,
max_tool_rounds: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
}
#[test]
fn effective_name_generated_from_prompt_hook() {
let def = HookDefinition {
name: None,
event: HookEvent::StageStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "Should this stage proceed?".into(),
model: None,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
};
assert!(def.effective_name().starts_with("stage_start:"));
}
#[test]
fn parse_multiple_hooks() {
let toml = r#"
[[hooks]]
event = "run_start"
command = "echo start"
[[hooks]]
event = "stage_complete"
command = "echo done"
matcher = "agent_loop"
"#;
let config: HookConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hooks.len(), 2);
assert_eq!(config.hooks[0].event, HookEvent::RunStart);
assert_eq!(config.hooks[1].event, HookEvent::StageComplete);
assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop"));
}
}
pub use fabro_types::settings::hook::*;

View file

@ -13,9 +13,9 @@ pub mod server;
pub mod settings;
pub use config::FabroConfig;
pub use fabro_config_derive::Combine;
pub use fabro_types::Combine;
pub use fabro_util::path::expand_tilde;
pub use settings::FabroSettings;
pub use settings::{FabroSettings, FabroSettingsExt};
use std::path::Path;

View file

@ -1,186 +1 @@
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::combine::Combine;
pub fn default_startup_timeout_secs() -> u64 {
10
}
pub fn default_tool_timeout_secs() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
pub transport: McpTransport,
#[serde(default = "default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerConfig {
#[must_use]
pub fn startup_timeout(&self) -> Duration {
Duration::from_secs(self.startup_timeout_secs)
}
#[must_use]
pub fn tool_timeout(&self) -> Duration {
Duration::from_secs(self.tool_timeout_secs)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpTransport {
Stdio {
command: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
/// MCP server that runs inside a sandbox and is accessed via HTTP preview URL.
/// During session init, the server is started inside the sandbox and this
/// variant is resolved into an `Http` transport using the sandbox's preview URL.
Sandbox {
command: Vec<String>,
port: u16,
#[serde(default)]
env: HashMap<String, String>,
},
}
impl Combine for McpTransport {
fn combine(self, _other: Self) -> Self {
self
}
}
/// MCP server entry as it appears in TOML config files (without a `name` field).
///
/// Converted to [`McpServerConfig`] via [`McpServerEntry::into_config`].
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct McpServerEntry {
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default = "default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerEntry {
pub fn into_config(self, name: String) -> McpServerConfig {
McpServerConfig {
name,
transport: self.transport,
startup_timeout_secs: self.startup_timeout_secs,
tool_timeout_secs: self.tool_timeout_secs,
}
}
}
impl Combine for McpServerEntry {
fn combine(self, _other: Self) -> Self {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn stdio_config_construction() {
let config = McpServerConfig {
name: "test-server".into(),
transport: McpTransport::Stdio {
command: vec![
"npx".into(),
"-y".into(),
"@modelcontextprotocol/server-filesystem".into(),
],
env: HashMap::new(),
},
startup_timeout_secs: 10,
tool_timeout_secs: 60,
};
assert_eq!(config.name, "test-server");
assert_eq!(config.startup_timeout(), Duration::from_secs(10));
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
}
#[test]
fn http_config_construction() {
let config = McpServerConfig {
name: "remote-server".into(),
transport: McpTransport::Http {
url: "https://example.com/mcp".into(),
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
},
startup_timeout_secs: 30,
tool_timeout_secs: 60,
};
assert_eq!(config.name, "remote-server");
assert_eq!(config.startup_timeout(), Duration::from_secs(30));
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
}
#[test]
fn serde_round_trip_stdio() {
let config = McpServerConfig {
name: "fs".into(),
transport: McpTransport::Stdio {
command: vec!["node".into(), "server.js".into()],
env: HashMap::from([("NODE_ENV".into(), "production".into())]),
},
startup_timeout_secs: 15,
tool_timeout_secs: 90,
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "fs");
assert_eq!(deserialized.startup_timeout_secs, 15);
assert_eq!(deserialized.tool_timeout_secs, 90);
assert!(
matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"])
);
}
#[test]
fn serde_round_trip_http() {
let config = McpServerConfig {
name: "remote".into(),
transport: McpTransport::Http {
url: "https://mcp.example.com".into(),
headers: HashMap::new(),
},
startup_timeout_secs: 10,
tool_timeout_secs: 60,
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "remote");
assert!(
matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com")
);
}
#[test]
fn serde_defaults_applied() {
let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.startup_timeout_secs, 10);
assert_eq!(config.tool_timeout_secs, 60);
}
}
pub use fabro_types::settings::mcp::*;

View file

@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::config::FabroConfig;
use crate::run;
use crate::FabroSettings;
pub use fabro_types::settings::project::ProjectFabroSettings;
const CONFIG_FILENAME: &str = "fabro.toml";
const SUPPORTED_VERSION: u32 = 1;
@ -39,20 +40,6 @@ fn default_root() -> String {
".".to_string()
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ProjectFabroSettings {
#[serde(default = "default_root")]
pub root: String,
}
impl Default for ProjectFabroSettings {
fn default() -> Self {
Self {
root: default_root(),
}
}
}
impl From<ProjectFabroConfig> for ProjectFabroSettings {
fn from(value: ProjectFabroConfig) -> Self {
Self {

View file

@ -7,6 +7,10 @@ use tracing::debug;
use crate::combine::Combine;
use crate::config::FabroConfig;
pub use fabro_types::settings::run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
const SUPPORTED_VERSION: u32 = 1;
@ -25,12 +29,6 @@ impl Combine for CheckpointConfig {
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct CheckpointSettings {
#[serde(default)]
pub exclude_globs: Vec<String>,
}
impl From<CheckpointConfig> for CheckpointSettings {
fn from(value: CheckpointConfig) -> Self {
let mut exclude_globs = value.exclude_globs;
@ -40,10 +38,6 @@ impl From<CheckpointConfig> for CheckpointSettings {
}
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct PullRequestConfig {
pub enabled: Option<bool>,
@ -52,18 +46,6 @@ pub struct PullRequestConfig {
pub merge_strategy: Option<MergeStrategy>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct PullRequestSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub draft: bool,
#[serde(default)]
pub auto_merge: bool,
#[serde(default)]
pub merge_strategy: MergeStrategy,
}
impl From<PullRequestConfig> for PullRequestSettings {
fn from(value: PullRequestConfig) -> Self {
Self {
@ -75,27 +57,12 @@ impl From<PullRequestConfig> for PullRequestSettings {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "lowercase")]
pub enum MergeStrategy {
#[default]
Squash,
Merge,
Rebase,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct AssetsConfig {
#[serde(default)]
pub include: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct AssetsSettings {
#[serde(default)]
pub include: Vec<String>,
}
impl From<AssetsConfig> for AssetsSettings {
fn from(value: AssetsConfig) -> Self {
Self {
@ -110,12 +77,6 @@ pub struct GitHubConfig {
pub permissions: HashMap<String, String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct GitHubSettings {
#[serde(default)]
pub permissions: HashMap<String, String>,
}
impl From<GitHubConfig> for GitHubSettings {
fn from(value: GitHubConfig) -> Self {
Self {
@ -132,14 +93,6 @@ pub struct LlmConfig {
pub fallbacks: Option<HashMap<String, Vec<String>>>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LlmSettings {
pub model: Option<String>,
pub provider: Option<String>,
#[serde(default)]
pub fallbacks: Option<HashMap<String, Vec<String>>>,
}
impl From<LlmConfig> for LlmSettings {
fn from(value: LlmConfig) -> Self {
Self {
@ -157,13 +110,6 @@ pub struct SetupConfig {
pub timeout_ms: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct SetupSettings {
#[serde(default)]
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
impl From<SetupConfig> for SetupSettings {
fn from(value: SetupConfig) -> Self {
Self {

View file

@ -1,9 +1,15 @@
use std::collections::HashMap;
use anyhow::anyhow;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
#[cfg(feature = "exedev")]
pub use fabro_types::settings::sandbox::ExeSettings;
pub use fabro_types::settings::sandbox::{
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
LocalSandboxSettings, SandboxSettings, SshSettings, WorktreeMode,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct DaytonaConfig {
pub auto_stop_interval: Option<i32>,
@ -14,17 +20,6 @@ pub struct DaytonaConfig {
pub skip_clone: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
/// Skip git repo detection and cloning during initialization.
#[serde(default)]
pub skip_clone: bool,
}
impl TryFrom<DaytonaConfig> for DaytonaSettings {
type Error = anyhow::Error;
@ -39,117 +34,6 @@ impl TryFrom<DaytonaConfig> for DaytonaSettings {
}
}
/// Network access mode for a Daytona sandbox.
///
/// TOML syntax:
/// ```toml
/// network = "block" # no egress
/// network = "allow_all" # full access (default)
/// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist
/// ```
#[derive(Clone, Debug, PartialEq, crate::Combine)]
pub enum DaytonaNetwork {
Block,
AllowAll,
AllowList(Vec<String>),
}
impl Serialize for DaytonaNetwork {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
DaytonaNetwork::Block => serializer.serialize_str("block"),
DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"),
DaytonaNetwork::AllowList(cidrs) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("allow_list", cidrs)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for DaytonaNetwork {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DaytonaNetworkVisitor;
impl<'de> Visitor<'de> for DaytonaNetworkVisitor {
type Value = DaytonaNetwork;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
r#""block", "allow_all", or {{ allow_list = [...] }}"#
)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
match value {
"block" => Ok(DaytonaNetwork::Block),
"allow_all" => Ok(DaytonaNetwork::AllowAll),
other => Err(de::Error::custom(format!(
"unknown network mode \"{other}\": expected \"block\" or \"allow_all\""
))),
}
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::custom(
"empty table: expected { allow_list = [...] }",
));
};
if key != "allow_list" {
return Err(de::Error::custom(format!(
"unknown key \"{key}\": expected \"allow_list\""
)));
}
let cidrs: Vec<String> = map.next_value()?;
if cidrs.is_empty() {
return Err(de::Error::custom("allow_list must not be empty"));
}
if let Some(extra) = map.next_key::<String>()? {
return Err(de::Error::custom(format!(
"unexpected key \"{extra}\": allow_list table must have exactly one key"
)));
}
Ok(DaytonaNetwork::AllowList(cidrs))
}
}
deserializer.deserialize_any(DaytonaNetworkVisitor)
}
}
/// Source for a snapshot Dockerfile.
///
/// TOML syntax:
/// ```toml
/// dockerfile = "FROM rust:1.85-slim-bookworm" # inline content
/// dockerfile = { path = "./Dockerfile" } # file reference
/// ```
///
/// `Path` variants are resolved to `Inline` during config loading
/// (see `run_config::resolve_dockerfile`), so downstream consumers
/// should only ever see `Inline`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, crate::Combine)]
#[serde(untagged)]
pub enum DockerfileSource {
Inline(String),
Path { path: String },
}
/// Snapshot configuration: when present, the sandbox is created from a snapshot
/// instead of a bare Docker image.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
@ -161,15 +45,6 @@ pub struct DaytonaSnapshotConfig {
pub dockerfile: Option<DockerfileSource>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSnapshotSettings {
pub name: String,
pub cpu: Option<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
impl TryFrom<DaytonaSnapshotConfig> for DaytonaSnapshotSettings {
type Error = anyhow::Error;
@ -193,12 +68,6 @@ pub struct ExeConfig {
pub image: Option<String>,
}
#[cfg(feature = "exedev")]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExeSettings {
pub image: Option<String>,
}
#[cfg(feature = "exedev")]
impl From<ExeConfig> for ExeSettings {
fn from(value: ExeConfig) -> Self {
@ -220,19 +89,6 @@ pub struct SshConfig {
pub preview_url_base: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SshSettings {
/// SSH destination (e.g. `user@host` or an SSH alias).
pub destination: String,
/// Remote working directory.
pub working_directory: String,
/// Optional path to a custom SSH config file.
pub config_file: Option<String>,
/// Base URL for port previews (e.g. `"http://beast"`).
/// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`.
pub preview_url_base: Option<String>,
}
impl TryFrom<SshConfig> for SshSettings {
type Error = anyhow::Error;
@ -250,27 +106,11 @@ impl TryFrom<SshConfig> for SshSettings {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeMode {
Always,
#[default]
Clean,
Dirty,
Never,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct LocalSandboxConfig {
pub worktree_mode: Option<WorktreeMode>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LocalSandboxSettings {
#[serde(default)]
pub worktree_mode: WorktreeMode,
}
impl From<LocalSandboxConfig> for LocalSandboxSettings {
fn from(value: LocalSandboxConfig) -> Self {
Self {
@ -292,19 +132,6 @@ pub struct SandboxConfig {
pub env: Option<HashMap<String, String>>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct SandboxSettings {
pub provider: Option<String>,
pub preserve: Option<bool>,
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxSettings>,
pub daytona: Option<DaytonaSettings>,
#[cfg(feature = "exedev")]
pub exe: Option<ExeSettings>,
pub ssh: Option<SshSettings>,
pub env: Option<HashMap<String, String>>,
}
impl TryFrom<SandboxConfig> for SandboxSettings {
type Error = anyhow::Error;

View file

@ -4,15 +4,12 @@ use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::FabroConfig;
use crate::settings::FabroSettings;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
#[default]
Github,
InsecureDisabled,
}
use crate::settings::{FabroSettings, FabroSettingsExt};
pub use fabro_types::settings::server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
GitProvider, GitSettings, LogSettings, TlsSettings, WebSettings, WebhookSettings,
WebhookStrategy,
};
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct AuthConfig {
@ -21,14 +18,6 @@ pub struct AuthConfig {
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct AuthSettings {
#[serde(default)]
pub provider: AuthProvider,
#[serde(default)]
pub allowed_usernames: Vec<String>,
}
impl From<AuthConfig> for AuthSettings {
fn from(value: AuthConfig) -> Self {
Self {
@ -38,13 +27,6 @@ impl From<AuthConfig> for AuthSettings {
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum ApiAuthStrategy {
Jwt,
Mtls,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct TlsConfig {
pub cert: Option<PathBuf>,
@ -52,13 +34,6 @@ pub struct TlsConfig {
pub ca: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct TlsSettings {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
impl TryFrom<TlsConfig> for TlsSettings {
type Error = anyhow::Error;
@ -85,29 +60,10 @@ pub struct ApiConfig {
pub tls: Option<TlsConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct ApiSettings {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub authentication_strategies: Vec<ApiAuthStrategy>,
pub tls: Option<TlsSettings>,
}
fn default_base_url() -> String {
"http://localhost:3000".to_string()
}
impl Default for ApiSettings {
fn default() -> Self {
Self {
base_url: default_base_url(),
authentication_strategies: Vec::new(),
tls: None,
}
}
}
impl TryFrom<ApiConfig> for ApiSettings {
type Error = anyhow::Error;
@ -120,25 +76,12 @@ impl TryFrom<ApiConfig> for ApiSettings {
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum GitProvider {
#[default]
Github,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct GitAuthorConfig {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitAuthorSettings {
pub name: Option<String>,
pub email: Option<String>,
}
impl From<GitAuthorConfig> for GitAuthorSettings {
fn from(value: GitAuthorConfig) -> Self {
Self {
@ -148,22 +91,11 @@ impl From<GitAuthorConfig> for GitAuthorSettings {
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct WebhookConfig {
pub strategy: Option<WebhookStrategy>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebhookSettings {
pub strategy: WebhookStrategy,
}
impl TryFrom<WebhookConfig> for WebhookSettings {
type Error = anyhow::Error;
@ -186,18 +118,6 @@ pub struct GitConfig {
pub webhooks: Option<WebhookConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitSettings {
#[serde(default)]
pub provider: GitProvider,
pub app_id: Option<String>,
pub client_id: Option<String>,
pub slug: Option<String>,
#[serde(default)]
pub author: GitAuthorSettings,
pub webhooks: Option<WebhookSettings>,
}
impl TryFrom<GitConfig> for GitSettings {
type Error = anyhow::Error;
@ -219,27 +139,10 @@ pub struct WebConfig {
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebSettings {
#[serde(default = "default_web_url")]
pub url: String,
#[serde(default)]
pub auth: AuthSettings,
}
fn default_web_url() -> String {
"http://localhost:5173".to_string()
}
impl Default for WebSettings {
fn default() -> Self {
Self {
url: default_web_url(),
auth: AuthSettings::default(),
}
}
}
impl From<WebConfig> for WebSettings {
fn from(value: WebConfig) -> Self {
Self {
@ -256,15 +159,6 @@ pub struct Features {
pub retros: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct FeaturesSettings {
#[serde(default)]
pub session_sandboxes: bool,
/// Experimental: enable automatic retro generation after workflow runs.
#[serde(default)]
pub retros: bool,
}
impl From<Features> for FeaturesSettings {
fn from(value: Features) -> Self {
Self {
@ -279,11 +173,6 @@ pub struct LogConfig {
pub level: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LogSettings {
pub level: Option<String>,
}
impl From<LogConfig> for LogSettings {
fn from(value: LogConfig) -> Self {
Self { level: value.level }

View file

@ -1,126 +1,21 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use fabro_types::settings::FabroSettings;
use crate::cli::{ExecSettings, ExecutionMode, ServerSettings};
use crate::config::FabroConfig;
use crate::hook::HookDefinition;
use crate::mcp::McpServerEntry;
use crate::project::ProjectFabroSettings;
use crate::run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, PullRequestSettings,
SetupSettings,
};
use crate::sandbox::SandboxSettings;
use crate::server::{
ApiSettings, FeaturesSettings, GitAuthorSettings, GitSettings, LogSettings, WebSettings,
};
fn is_default_checkpoint(c: &CheckpointSettings) -> bool {
c.exclude_globs.is_empty()
pub trait FabroSettingsExt {
fn storage_dir(&self) -> PathBuf;
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct FabroSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<SetupSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vars: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
pub checkpoint: CheckpointSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetsSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ExecutionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_retro: Option<bool>,
#[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")]
pub storage_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<LogSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabro: Option<ProjectFabroSettings>,
impl FabroSettingsExt for FabroSettings {
fn storage_dir(&self) -> PathBuf {
self.storage_dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.expect("could not determine home directory")
.join(".fabro")
})
}
}
impl TryFrom<FabroConfig> for FabroSettings {
@ -172,90 +67,3 @@ impl TryFrom<&FabroConfig> for FabroSettings {
value.clone().try_into()
}
}
impl FabroSettings {
/// Resolve the storage directory: config value > default `~/.fabro`.
pub fn storage_dir(&self) -> PathBuf {
self.storage_dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.expect("could not determine home directory")
.join(".fabro")
})
}
pub fn app_id(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.app_id.as_deref())
}
pub fn slug(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.slug.as_deref())
}
pub fn client_id(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.client_id.as_deref())
}
pub fn git_author(&self) -> Option<&GitAuthorSettings> {
self.git.as_ref().map(|g| &g.author)
}
pub fn sandbox_settings(&self) -> Option<&SandboxSettings> {
self.sandbox.as_ref()
}
pub fn setup_settings(&self) -> Option<&SetupSettings> {
self.setup.as_ref()
}
pub fn setup_commands(&self) -> &[String] {
self.setup
.as_ref()
.map(|setup| setup.commands.as_slice())
.unwrap_or(&[])
}
pub fn setup_timeout_ms(&self) -> Option<u64> {
self.setup.as_ref().and_then(|setup| setup.timeout_ms)
}
pub fn preserve_sandbox_enabled(&self) -> bool {
self.sandbox
.as_ref()
.and_then(|sandbox| sandbox.preserve)
.unwrap_or(false)
}
pub fn github_permissions(&self) -> Option<&HashMap<String, String>> {
self.github
.as_ref()
.and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions))
}
pub fn mcp_server_entries(&self) -> &HashMap<String, McpServerEntry> {
&self.mcp_servers
}
pub fn verbose_enabled(&self) -> bool {
self.verbose.unwrap_or(false)
}
pub fn prevent_idle_sleep_enabled(&self) -> bool {
self.prevent_idle_sleep.unwrap_or(false)
}
pub fn upgrade_check_enabled(&self) -> bool {
self.upgrade_check.unwrap_or(true)
}
pub fn dry_run_enabled(&self) -> bool {
self.dry_run.unwrap_or(false)
}
pub fn auto_approve_enabled(&self) -> bool {
self.auto_approve.unwrap_or(false)
}
pub fn no_retro_enabled(&self) -> bool {
self.no_retro.unwrap_or(false)
}
}

View file

@ -10,6 +10,7 @@ doctest = false
[dependencies]
async-trait.workspace = true
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
serde.workspace = true
serde_json.workspace = true

View file

@ -12,7 +12,7 @@ use crate::lifecycle::{
AttemptContext, AttemptResultContext, EdgeContext, EdgeDecision, NodeDecision, NoopLifecycle,
RunLifecycle,
};
use crate::outcome::{NodeResult, Outcome, StageStatus};
use crate::outcome::{NodeResult, NodeResultExt, Outcome, StageStatus};
use crate::state::RunState;
#[derive(Default)]

View file

@ -21,7 +21,9 @@ pub use lifecycle::{
AttemptContext, AttemptResultContext, CompositeLifecycle, EdgeContext, EdgeDecision,
NodeDecision, NoopLifecycle, RunLifecycle,
};
pub use outcome::{FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus};
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, NodeResultExt, Outcome, OutcomeMeta, StageStatus,
};
pub use retry::{BackoffPolicy, RetryPolicy};
pub use stall::{ActivityMonitor, StallGuard, StallWatchdog};
pub use state::RunState;

View file

@ -1,264 +1,20 @@
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use fabro_types::outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus,
};
/// Supertrait for the generic usage/metadata type parameter on `Outcome`.
pub trait OutcomeMeta:
Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
{
pub trait NodeResultExt<M: OutcomeMeta = ()> {
fn from_error(
error: &crate::error::CoreError,
duration: Duration,
attempts: u32,
max_attempts: u32,
) -> Self;
}
impl<T> OutcomeMeta for T where
T: Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
{
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StageStatus {
Success,
Fail,
Skipped,
PartialSuccess,
Retry,
}
impl fmt::Display for StageStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Success => write!(f, "success"),
Self::Fail => write!(f, "fail"),
Self::Skipped => write!(f, "skipped"),
Self::PartialSuccess => write!(f, "partial_success"),
Self::Retry => write!(f, "retry"),
}
}
}
impl FromStr for StageStatus {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"success" => Ok(Self::Success),
"fail" => Ok(Self::Fail),
"skipped" => Ok(Self::Skipped),
"partial_success" => Ok(Self::PartialSuccess),
"retry" => Ok(Self::Retry),
other => Err(format!("unknown stage status: {other}")),
}
}
}
/// Classification of failure modes.
///
/// Pipeline authors can write edge conditions like `context.failure_class=budget_exhausted`
/// to route execution based on the nature of the failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureCategory {
/// Temporary infrastructure failure (rate limit, timeout, network, 5xx).
TransientInfra,
/// Permanent failure (auth, bad config, code bug).
Deterministic,
/// Context length, token/turn limit, quota exceeded.
BudgetExhausted,
/// Reserved for future loop detection.
CompilationLoop,
/// User/system cancellation.
Canceled,
/// Reserved for future scope enforcement.
Structural,
}
impl fmt::Display for FailureCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::TransientInfra => "transient_infra",
Self::Deterministic => "deterministic",
Self::BudgetExhausted => "budget_exhausted",
Self::CompilationLoop => "compilation_loop",
Self::Canceled => "canceled",
Self::Structural => "structural",
};
write!(f, "{s}")
}
}
impl FromStr for FailureCategory {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase();
Ok(match normalized.as_str() {
// Canonical names
"transient_infra" => Self::TransientInfra,
"deterministic" => Self::Deterministic,
"budget_exhausted" => Self::BudgetExhausted,
"compilation_loop" => Self::CompilationLoop,
"canceled" => Self::Canceled,
"structural" => Self::Structural,
// Aliases: transient_infra
"transient"
| "transient-infra"
| "infra_transient"
| "transient infra"
| "infrastructure_transient"
| "retryable"
| "toolchain_workspace_io"
| "toolchain-workspace-io"
| "toolchain_or_dependency_registry_unavailable"
| "toolchain-dependency-registry-unavailable" => Self::TransientInfra,
// Aliases: deterministic
"non_transient" | "non-transient" | "permanent" | "logic" | "product" => {
Self::Deterministic
}
// Aliases: canceled
"cancelled" => Self::Canceled,
// Aliases: budget_exhausted
"budget-exhausted" | "budget exhausted" | "budget" => Self::BudgetExhausted,
// Aliases: compilation_loop
"compilation-loop" | "compilation loop" | "compile_loop" | "compile-loop" => {
Self::CompilationLoop
}
// Aliases: structural
"structure" | "scope_violation" | "write_scope_violation" => Self::Structural,
// Unknown → fail-closed to Deterministic
_ => Self::Deterministic,
})
}
}
impl FailureCategory {
/// Whether this failure category should be tracked by the cycle breaker.
pub fn is_signature_tracked(self) -> bool {
matches!(self, Self::Deterministic | Self::Structural)
}
}
/// Structured failure information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureDetail {
pub message: String,
#[serde(rename = "failure_class")]
pub category: FailureCategory,
#[serde(
rename = "failure_signature",
default,
skip_serializing_if = "Option::is_none"
)]
pub signature: Option<String>,
}
impl FailureDetail {
pub fn new(message: impl Into<String>, category: FailureCategory) -> Self {
Self {
message: message.into(),
category,
signature: None,
}
}
}
/// The result of executing a node handler.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound = "M: OutcomeMeta")]
pub struct Outcome<M: OutcomeMeta = ()> {
pub status: StageStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub context_updates: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jump_to_node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
#[serde(default)]
pub usage: M,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl<M: OutcomeMeta> Default for Outcome<M> {
fn default() -> Self {
Self {
status: StageStatus::Success,
preferred_label: None,
suggested_next_ids: Vec::new(),
context_updates: HashMap::new(),
jump_to_node: None,
notes: None,
failure: None,
usage: M::default(),
files_touched: Vec::new(),
duration_ms: None,
}
}
}
impl<M: OutcomeMeta> Outcome<M> {
pub fn success() -> Self {
Self::default()
}
pub fn fail(message: &str) -> Self {
Self {
status: StageStatus::Fail,
failure: Some(FailureDetail {
message: message.to_string(),
category: FailureCategory::Deterministic,
signature: None,
}),
..Self::default()
}
}
pub fn skipped(reason: &str) -> Self {
Self {
status: StageStatus::Skipped,
notes: Some(reason.to_string()),
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct NodeResult<M: OutcomeMeta = ()> {
pub outcome: Outcome<M>,
pub duration: Duration,
pub attempts: u32,
pub max_attempts: u32,
}
impl<M: OutcomeMeta> NodeResult<M> {
pub fn new(outcome: Outcome<M>, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
Self {
outcome,
duration,
attempts,
max_attempts,
}
}
pub fn from_error(
impl<M: OutcomeMeta> NodeResultExt<M> for NodeResult<M> {
fn from_error(
error: &crate::error::CoreError,
duration: Duration,
attempts: u32,
@ -271,203 +27,4 @@ impl<M: OutcomeMeta> NodeResult<M> {
max_attempts,
}
}
pub fn from_skip(outcome: Outcome<M>) -> Self {
Self {
outcome,
duration: Duration::ZERO,
attempts: 0,
max_attempts: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_status_display_roundtrip() {
let statuses = [
StageStatus::Success,
StageStatus::Fail,
StageStatus::Skipped,
StageStatus::PartialSuccess,
StageStatus::Retry,
];
for status in &statuses {
let s = status.to_string();
let parsed: StageStatus = s.parse().unwrap();
assert_eq!(&parsed, status);
}
}
#[test]
fn stage_status_serde_roundtrip() {
let statuses = [
StageStatus::Success,
StageStatus::Fail,
StageStatus::Skipped,
StageStatus::PartialSuccess,
StageStatus::Retry,
];
for status in &statuses {
let json = serde_json::to_string(status).unwrap();
let parsed: StageStatus = serde_json::from_str(&json).unwrap();
assert_eq!(&parsed, status);
}
}
#[test]
fn failure_category_display_roundtrip() {
let categories = [
FailureCategory::TransientInfra,
FailureCategory::Deterministic,
FailureCategory::BudgetExhausted,
FailureCategory::CompilationLoop,
FailureCategory::Canceled,
FailureCategory::Structural,
];
for cat in &categories {
let s = cat.to_string();
let parsed: FailureCategory = s.parse().unwrap();
assert_eq!(&parsed, cat);
}
}
#[test]
fn failure_category_aliases() {
assert_eq!(
"transient".parse::<FailureCategory>().unwrap(),
FailureCategory::TransientInfra
);
assert_eq!(
"cancelled".parse::<FailureCategory>().unwrap(),
FailureCategory::Canceled
);
assert_eq!(
"permanent".parse::<FailureCategory>().unwrap(),
FailureCategory::Deterministic
);
assert_eq!(
"budget".parse::<FailureCategory>().unwrap(),
FailureCategory::BudgetExhausted
);
}
#[test]
fn failure_category_is_signature_tracked() {
assert!(FailureCategory::Deterministic.is_signature_tracked());
assert!(FailureCategory::Structural.is_signature_tracked());
assert!(!FailureCategory::TransientInfra.is_signature_tracked());
assert!(!FailureCategory::BudgetExhausted.is_signature_tracked());
assert!(!FailureCategory::Canceled.is_signature_tracked());
}
#[test]
fn outcome_success_factory() {
let o: Outcome = Outcome::success();
assert_eq!(o.status, StageStatus::Success);
assert!(o.failure.is_none());
assert!(o.notes.is_none());
}
#[test]
fn outcome_fail_factory() {
let o: Outcome = Outcome::fail("broken");
assert_eq!(o.status, StageStatus::Fail);
let f = o.failure.unwrap();
assert_eq!(f.message, "broken");
assert_eq!(f.category, FailureCategory::Deterministic);
assert!(f.signature.is_none());
}
#[test]
fn outcome_skipped_factory() {
let o: Outcome = Outcome::skipped("not needed");
assert_eq!(o.status, StageStatus::Skipped);
assert_eq!(o.notes.as_deref(), Some("not needed"));
}
#[test]
fn outcome_with_context_updates() {
let mut o: Outcome = Outcome::success();
o.context_updates
.insert("key".into(), serde_json::json!("value"));
assert_eq!(o.context_updates["key"], serde_json::json!("value"));
}
#[test]
fn outcome_with_jump() {
let mut o: Outcome = Outcome::success();
o.jump_to_node = Some("target".into());
assert_eq!(o.jump_to_node.as_deref(), Some("target"));
}
#[test]
fn outcome_serde_roundtrip() {
let mut o: Outcome = Outcome::success();
o.notes = Some("done".to_string());
o.context_updates
.insert("key".into(), serde_json::json!("val"));
let json = serde_json::to_string(&o).unwrap();
let parsed: Outcome = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, StageStatus::Success);
assert_eq!(parsed.notes.as_deref(), Some("done"));
assert_eq!(
parsed.context_updates.get("key"),
Some(&serde_json::json!("val"))
);
}
#[test]
fn outcome_deserialize_without_usage_key() {
// Old checkpoints may not have "usage" key — serde(default) handles this
let json = r#"{"status":"success"}"#;
let o: Outcome = serde_json::from_str(json).unwrap();
assert_eq!(o.status, StageStatus::Success);
}
#[test]
fn failure_detail_construction() {
let f = FailureDetail::new("timeout", FailureCategory::TransientInfra);
assert_eq!(f.message, "timeout");
assert_eq!(f.category, FailureCategory::TransientInfra);
assert!(f.signature.is_none());
}
#[test]
fn failure_detail_serde_uses_renamed_keys() {
let f = FailureDetail::new("timeout", FailureCategory::TransientInfra);
let json = serde_json::to_string(&f).unwrap();
// category serializes as "failure_class"
assert!(json.contains("\"failure_class\""));
assert!(!json.contains("\"category\""));
// signature omitted when None
assert!(!json.contains("failure_signature"));
}
#[test]
fn failure_detail_serde_roundtrip() {
let f = FailureDetail {
message: "api down".into(),
category: FailureCategory::TransientInfra,
signature: Some("sig123".into()),
};
let json = serde_json::to_string(&f).unwrap();
let parsed: FailureDetail = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.message, "api down");
assert_eq!(parsed.category, FailureCategory::TransientInfra);
assert_eq!(parsed.signature.as_deref(), Some("sig123"));
}
#[test]
fn node_result_from_outcome() {
let o: Outcome = Outcome::success();
let r = NodeResult::new(o, Duration::from_millis(100), 1, 3);
assert_eq!(r.outcome.status, StageStatus::Success);
assert_eq!(r.duration, Duration::from_millis(100));
assert_eq!(r.attempts, 1);
assert_eq!(r.max_attempts, 3);
}
}

View file

@ -10,6 +10,7 @@ doctest = false
[dependencies]
anyhow.workspace = true
fabro-types = { path = "../fabro-types" }
nom = "7"
regex = { workspace = true }
serde = { workspace = true }

View file

@ -1,827 +1 @@
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// Typed attribute values for nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttrValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Duration(Duration),
}
impl AttrValue {
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
#[must_use]
pub const fn as_i64(&self) -> Option<i64> {
match self {
Self::Integer(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub const fn as_f64(&self) -> Option<f64> {
match self {
Self::Float(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub const fn as_bool(&self) -> Option<bool> {
match self {
Self::Boolean(b) => Some(*b),
_ => None,
}
}
#[must_use]
pub const fn as_duration(&self) -> Option<Duration> {
match self {
Self::Duration(d) => Some(*d),
_ => None,
}
}
/// Convert any variant to its string representation.
#[must_use]
pub fn to_string_value(&self) -> String {
match self {
Self::String(s) => s.clone(),
Self::Integer(n) => n.to_string(),
Self::Float(f) => f.to_string(),
Self::Boolean(b) => b.to_string(),
Self::Duration(d) => format!("{}ms", d.as_millis()),
}
}
}
/// Returns true if the handler type is an LLM-based handler (agent or prompt, including legacy aliases).
#[must_use]
pub fn is_llm_handler_type(handler_type: Option<&str>) -> bool {
matches!(
handler_type,
Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot")
)
}
/// Maps Graphviz shapes to handler type strings (Section 2.8).
#[must_use]
pub fn shape_to_handler_type(shape: &str) -> Option<&'static str> {
match shape {
"Mdiamond" => Some("start"),
"Msquare" => Some("exit"),
"box" => Some("agent"),
"tab" => Some("prompt"),
"hexagon" => Some("human"),
"diamond" => Some("conditional"),
"component" => Some("parallel"),
"tripleoctagon" => Some("parallel.fan_in"),
"parallelogram" => Some("command"),
"house" => Some("stack.manager_loop"),
"insulator" => Some("wait"),
_ => None,
}
}
/// A node in the workflow graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub attrs: HashMap<String, AttrValue>,
/// CSS-like classes for model stylesheet targeting (from `class` attr and subgraph derivation).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub classes: Vec<String>,
}
impl Node {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
attrs: HashMap::new(),
classes: Vec::new(),
}
}
fn str_attr(&self, key: &str) -> Option<&str> {
self.attrs.get(key).and_then(AttrValue::as_str)
}
fn bool_attr(&self, key: &str) -> Option<bool> {
self.attrs.get(key).and_then(AttrValue::as_bool)
}
fn int_attr(&self, key: &str) -> Option<i64> {
self.attrs.get(key).and_then(AttrValue::as_i64)
}
#[must_use]
pub fn label(&self) -> &str {
self.str_attr("label").unwrap_or(&self.id)
}
#[must_use]
pub fn shape(&self) -> &str {
self.str_attr("shape").unwrap_or("box")
}
#[must_use]
pub fn node_type(&self) -> Option<&str> {
self.str_attr("type")
}
#[must_use]
pub fn prompt(&self) -> Option<&str> {
self.str_attr("prompt")
}
#[must_use]
pub fn max_retries(&self) -> Option<i64> {
self.int_attr("max_retries")
}
#[must_use]
pub fn max_visits(&self) -> Option<i64> {
self.int_attr("max_visits")
}
#[must_use]
pub fn goal_gate(&self) -> bool {
self.bool_attr("goal_gate").unwrap_or(false)
}
#[must_use]
pub fn retry_target(&self) -> Option<&str> {
self.str_attr("retry_target")
}
#[must_use]
pub fn fallback_retry_target(&self) -> Option<&str> {
self.str_attr("fallback_retry_target")
}
#[must_use]
pub fn fidelity(&self) -> Option<&str> {
self.str_attr("fidelity")
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.str_attr("thread_id")
}
#[must_use]
pub fn class(&self) -> Option<&str> {
self.str_attr("class")
}
pub fn timeout(&self) -> Option<Duration> {
self.attrs.get("timeout").and_then(AttrValue::as_duration)
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.str_attr("model")
}
#[must_use]
pub fn provider(&self) -> Option<&str> {
self.str_attr("provider")
}
#[must_use]
pub fn max_tokens(&self) -> Option<i64> {
self.int_attr("max_tokens").filter(|&v| v > 0)
}
#[must_use]
pub fn reasoning_effort(&self) -> &str {
self.str_attr("reasoning_effort").unwrap_or("high")
}
#[must_use]
pub fn speed(&self) -> Option<&str> {
self.str_attr("speed")
}
#[must_use]
pub fn auto_status(&self) -> bool {
self.bool_attr("auto_status").unwrap_or(false)
}
#[must_use]
pub fn allow_partial(&self) -> bool {
self.bool_attr("allow_partial").unwrap_or(false)
}
#[must_use]
pub fn project_memory(&self) -> bool {
self.bool_attr("project_memory").unwrap_or(true)
}
#[must_use]
pub fn retry_policy(&self) -> Option<&str> {
self.str_attr("retry_policy")
}
#[must_use]
pub fn backend(&self) -> Option<&str> {
self.str_attr("backend")
}
#[must_use]
pub fn selection(&self) -> &str {
self.str_attr("selection").unwrap_or("deterministic")
}
/// Resolve the handler type for this node using explicit type or shape mapping.
#[must_use]
pub fn handler_type(&self) -> Option<&str> {
if let Some(t) = self.node_type() {
return Some(t);
}
shape_to_handler_type(self.shape())
}
}
/// An edge connecting two nodes in the workflow graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
pub attrs: HashMap<String, AttrValue>,
}
impl Edge {
pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
attrs: HashMap::new(),
}
}
fn str_attr(&self, key: &str) -> Option<&str> {
self.attrs.get(key).and_then(AttrValue::as_str)
}
fn bool_attr(&self, key: &str) -> Option<bool> {
self.attrs.get(key).and_then(AttrValue::as_bool)
}
fn int_attr(&self, key: &str) -> Option<i64> {
self.attrs.get(key).and_then(AttrValue::as_i64)
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.str_attr("label")
}
#[must_use]
pub fn condition(&self) -> Option<&str> {
self.str_attr("condition")
}
#[must_use]
pub fn weight(&self) -> i64 {
self.int_attr("weight").unwrap_or(0)
}
#[must_use]
pub fn fidelity(&self) -> Option<&str> {
self.str_attr("fidelity")
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.str_attr("thread_id")
}
#[must_use]
pub fn loop_restart(&self) -> bool {
self.bool_attr("loop_restart").unwrap_or(false)
}
#[must_use]
pub fn freeform(&self) -> bool {
self.bool_attr("freeform").unwrap_or(false)
}
}
/// The parsed workflow graph containing nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
pub name: String,
pub nodes: HashMap<String, Node>,
pub edges: Vec<Edge>,
pub attrs: HashMap<String, AttrValue>,
}
impl Graph {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
nodes: HashMap::new(),
edges: Vec::new(),
attrs: HashMap::new(),
}
}
/// Returns all outgoing edges from the given node.
#[must_use]
pub fn outgoing_edges(&self, node_id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.from == node_id).collect()
}
/// Returns all incoming edges to the given node.
#[must_use]
pub fn incoming_edges(&self, node_id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.to == node_id).collect()
}
/// Find the start node: shape=Mdiamond, or id "start"/"Start".
#[must_use]
pub fn find_start_node(&self) -> Option<&Node> {
// First: look for shape=Mdiamond
let by_shape = self.nodes.values().find(|n| n.shape() == "Mdiamond");
if by_shape.is_some() {
return by_shape;
}
// Second: look for id "start" or "Start"
self.nodes.get("start").or_else(|| self.nodes.get("Start"))
}
/// Find the exit node: shape=Msquare, or id "exit"/"Exit".
#[must_use]
pub fn find_exit_node(&self) -> Option<&Node> {
let by_shape = self.nodes.values().find(|n| n.shape() == "Msquare");
if by_shape.is_some() {
return by_shape;
}
self.nodes
.get("exit")
.or_else(|| self.nodes.get("Exit"))
.or_else(|| self.nodes.get("end"))
.or_else(|| self.nodes.get("End"))
}
/// Graph-level goal attribute.
pub fn goal(&self) -> &str {
self.attrs
.get("goal")
.and_then(AttrValue::as_str)
.unwrap_or("")
}
/// Graph-level model stylesheet attribute.
pub fn model_stylesheet(&self) -> &str {
self.attrs
.get("model_stylesheet")
.and_then(AttrValue::as_str)
.unwrap_or("")
}
/// Graph-level `default_max_retries` (default 0).
pub fn default_max_retries(&self) -> i64 {
self.attrs
.get("default_max_retries")
.and_then(AttrValue::as_i64)
.unwrap_or(0)
}
/// Graph-level `retry_target`.
pub fn retry_target(&self) -> Option<&str> {
self.attrs.get("retry_target").and_then(AttrValue::as_str)
}
/// Graph-level `fallback_retry_target`.
pub fn fallback_retry_target(&self) -> Option<&str> {
self.attrs
.get("fallback_retry_target")
.and_then(AttrValue::as_str)
}
/// Graph-level `default_fidelity`.
pub fn default_fidelity(&self) -> Option<&str> {
self.attrs
.get("default_fidelity")
.and_then(AttrValue::as_str)
}
/// Graph-level `default_thread`.
pub fn default_thread(&self) -> Option<&str> {
self.attrs.get("default_thread").and_then(AttrValue::as_str)
}
/// Graph-level `loop_restart_signature_limit` (default 3).
/// When the same failure signature repeats this many times, the pipeline aborts.
pub fn loop_restart_signature_limit(&self) -> usize {
self.attrs
.get("loop_restart_signature_limit")
.and_then(AttrValue::as_i64)
.filter(|&v| v >= 1)
.map_or(3, |v| v as usize)
}
/// Graph-level `stall_timeout`. Defaults to 1800s. Returns `None` when set to zero (disabled).
pub fn stall_timeout(&self) -> Option<Duration> {
match self
.attrs
.get("stall_timeout")
.and_then(AttrValue::as_duration)
{
Some(d) if d.is_zero() => None,
Some(d) => Some(d),
None => Some(Duration::from_secs(1800)),
}
}
/// Graph-level `max_node_visits` (default 0 = disabled).
pub fn max_node_visits(&self) -> u64 {
self.attrs
.get("max_node_visits")
.and_then(AttrValue::as_i64)
.and_then(|n| u64::try_from(n).ok())
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attr_value_as_str() {
let val = AttrValue::String("hello".to_string());
assert_eq!(val.as_str(), Some("hello"));
assert_eq!(AttrValue::Integer(1).as_str(), None);
}
#[test]
fn attr_value_as_i64() {
assert_eq!(AttrValue::Integer(42).as_i64(), Some(42));
assert_eq!(AttrValue::String("x".to_string()).as_i64(), None);
}
#[test]
fn attr_value_as_f64() {
assert_eq!(AttrValue::Float(3.15).as_f64(), Some(3.15));
assert_eq!(AttrValue::Integer(1).as_f64(), None);
}
#[test]
fn attr_value_as_bool() {
assert_eq!(AttrValue::Boolean(true).as_bool(), Some(true));
assert_eq!(AttrValue::String("true".to_string()).as_bool(), None);
}
#[test]
fn attr_value_as_duration() {
let d = Duration::from_secs(10);
assert_eq!(AttrValue::Duration(d).as_duration(), Some(d));
assert_eq!(AttrValue::Integer(10).as_duration(), None);
}
#[test]
fn shape_to_handler_type_mappings() {
assert_eq!(shape_to_handler_type("Mdiamond"), Some("start"));
assert_eq!(shape_to_handler_type("Msquare"), Some("exit"));
assert_eq!(shape_to_handler_type("box"), Some("agent"));
assert_eq!(shape_to_handler_type("tab"), Some("prompt"));
assert_eq!(shape_to_handler_type("hexagon"), Some("human"));
assert_eq!(shape_to_handler_type("diamond"), Some("conditional"));
assert_eq!(shape_to_handler_type("component"), Some("parallel"));
assert_eq!(
shape_to_handler_type("tripleoctagon"),
Some("parallel.fan_in")
);
assert_eq!(shape_to_handler_type("parallelogram"), Some("command"));
assert_eq!(shape_to_handler_type("house"), Some("stack.manager_loop"));
assert_eq!(shape_to_handler_type("insulator"), Some("wait"));
assert_eq!(shape_to_handler_type("unknown"), None);
}
#[test]
fn is_llm_handler_type_checks() {
assert!(is_llm_handler_type(Some("agent")));
assert!(is_llm_handler_type(Some("agent_loop")));
assert!(is_llm_handler_type(Some("prompt")));
assert!(is_llm_handler_type(Some("one_shot")));
assert!(!is_llm_handler_type(Some("command")));
assert!(!is_llm_handler_type(Some("human")));
assert!(!is_llm_handler_type(None));
}
#[test]
fn node_defaults() {
let node = Node::new("test");
assert_eq!(node.id, "test");
assert_eq!(node.label(), "test");
assert_eq!(node.shape(), "box");
assert_eq!(node.node_type(), None);
assert_eq!(node.prompt(), None);
assert_eq!(node.max_retries(), None);
assert!(!node.goal_gate());
assert_eq!(node.retry_target(), None);
assert_eq!(node.fallback_retry_target(), None);
assert_eq!(node.fidelity(), None);
assert_eq!(node.thread_id(), None);
assert_eq!(node.class(), None);
assert_eq!(node.timeout(), None);
assert_eq!(node.model(), None);
assert_eq!(node.provider(), None);
assert_eq!(node.reasoning_effort(), "high");
assert_eq!(node.speed(), None);
assert!(!node.auto_status());
assert!(!node.allow_partial());
assert_eq!(node.retry_policy(), None);
assert_eq!(node.max_visits(), None);
assert!(node.project_memory());
}
#[test]
fn node_project_memory_false_overrides_default() {
let mut node = Node::new("x");
node.attrs
.insert("project_memory".to_string(), AttrValue::Boolean(false));
assert!(!node.project_memory());
}
#[test]
fn node_with_attrs() {
let mut node = Node::new("plan");
node.attrs.insert(
"label".to_string(),
AttrValue::String("Plan step".to_string()),
);
node.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
node.attrs
.insert("max_retries".to_string(), AttrValue::Integer(3));
assert_eq!(node.label(), "Plan step");
assert_eq!(node.shape(), "diamond");
assert!(node.goal_gate());
assert_eq!(node.max_retries(), Some(3));
}
#[test]
fn node_max_visits_returns_value() {
let mut node = Node::new("test");
node.attrs
.insert("max_visits".to_string(), AttrValue::Integer(5));
assert_eq!(node.max_visits(), Some(5));
}
#[test]
fn node_handler_type_explicit() {
let mut node = Node::new("gate");
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
assert_eq!(node.handler_type(), Some("human"));
}
#[test]
fn node_handler_type_from_shape() {
let mut node = Node::new("entry");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
assert_eq!(node.handler_type(), Some("start"));
}
#[test]
fn edge_defaults() {
let edge = Edge::new("a", "b");
assert_eq!(edge.from, "a");
assert_eq!(edge.to, "b");
assert_eq!(edge.label(), None);
assert_eq!(edge.condition(), None);
assert_eq!(edge.weight(), 0);
assert_eq!(edge.fidelity(), None);
assert_eq!(edge.thread_id(), None);
assert!(!edge.loop_restart());
assert!(!edge.freeform());
}
#[test]
fn edge_with_attrs() {
let mut edge = Edge::new("a", "b");
edge.attrs
.insert("label".to_string(), AttrValue::String("next".to_string()));
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
edge.attrs
.insert("weight".to_string(), AttrValue::Integer(5));
edge.attrs
.insert("loop_restart".to_string(), AttrValue::Boolean(true));
edge.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
assert_eq!(edge.label(), Some("next"));
assert_eq!(edge.condition(), Some("outcome=success"));
assert_eq!(edge.weight(), 5);
assert!(edge.loop_restart());
assert!(edge.freeform());
}
fn sample_graph() -> Graph {
let mut g = Graph::new("test_pipeline");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
let work = Node::new("work");
g.nodes.insert("work".to_string(), work);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
g.attrs.insert(
"goal".to_string(),
AttrValue::String("Run tests".to_string()),
);
g
}
#[test]
fn graph_find_start_node() {
let g = sample_graph();
let start = g.find_start_node().unwrap();
assert_eq!(start.id, "start");
}
#[test]
fn graph_find_exit_node() {
let g = sample_graph();
let exit = g.find_exit_node().unwrap();
assert_eq!(exit.id, "exit");
}
#[test]
fn graph_find_exit_by_end_id() {
let mut g = Graph::new("test");
let node = Node::new("end");
g.nodes.insert("end".to_string(), node);
let exit = g.find_exit_node().unwrap();
assert_eq!(exit.id, "end");
}
#[test]
fn graph_outgoing_edges() {
let g = sample_graph();
let edges = g.outgoing_edges("start");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].to, "work");
}
#[test]
fn graph_incoming_edges() {
let g = sample_graph();
let edges = g.incoming_edges("exit");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].from, "work");
}
#[test]
fn graph_goal() {
let g = sample_graph();
assert_eq!(g.goal(), "Run tests");
}
#[test]
fn graph_goal_default() {
let g = Graph::new("empty");
assert_eq!(g.goal(), "");
}
#[test]
fn graph_model_stylesheet_default() {
let g = Graph::new("empty");
assert_eq!(g.model_stylesheet(), "");
}
#[test]
fn graph_default_max_retries() {
let g = Graph::new("empty");
assert_eq!(g.default_max_retries(), 0);
}
#[test]
fn graph_find_start_by_id_fallback() {
let mut g = Graph::new("test");
// No Mdiamond shape, but id is "start"
let node = Node::new("start");
g.nodes.insert("start".to_string(), node);
assert!(g.find_start_node().is_some());
}
#[test]
fn graph_no_start_node() {
let g = Graph::new("empty");
assert!(g.find_start_node().is_none());
}
#[test]
fn graph_stall_timeout_default() {
let g = Graph::new("empty");
assert_eq!(g.stall_timeout(), Some(Duration::from_secs(1800)));
}
#[test]
fn graph_stall_timeout_set() {
let mut g = Graph::new("test");
g.attrs.insert(
"stall_timeout".to_string(),
AttrValue::Duration(Duration::from_millis(200)),
);
assert_eq!(g.stall_timeout(), Some(Duration::from_millis(200)));
}
#[test]
fn graph_stall_timeout_zero_disables() {
let mut g = Graph::new("test");
g.attrs.insert(
"stall_timeout".to_string(),
AttrValue::Duration(Duration::ZERO),
);
assert_eq!(g.stall_timeout(), None);
}
#[test]
fn graph_max_node_visits_default() {
let g = Graph::new("empty");
assert_eq!(g.max_node_visits(), 0);
}
#[test]
fn graph_max_node_visits_set() {
let mut g = Graph::new("test");
g.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(10));
assert_eq!(g.max_node_visits(), 10);
}
#[test]
fn graph_loop_restart_signature_limit_default() {
let g = Graph::new("empty");
assert_eq!(g.loop_restart_signature_limit(), 3);
}
#[test]
fn graph_loop_restart_signature_limit_set() {
let mut g = Graph::new("test");
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(5),
);
assert_eq!(g.loop_restart_signature_limit(), 5);
}
#[test]
fn graph_loop_restart_signature_limit_invalid_falls_back() {
let mut g = Graph::new("test");
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(0),
);
assert_eq!(g.loop_restart_signature_limit(), 3);
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(-1),
);
assert_eq!(g.loop_restart_signature_limit(), 3);
}
}
pub use fabro_types::graph::*;

View file

@ -13,6 +13,7 @@ fabro-agent = { path = "../fabro-agent" }
fabro-config = { path = "../fabro-config" }
fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../fabro-model" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
serde.workspace = true
serde_json.workspace = true
@ -26,4 +27,4 @@ tokio-util.workspace = true
[dev-dependencies]
mockito = "1"
tokio = { workspace = true, features = ["test-util", "macros"] }
toml.workspace = true
toml.workspace = true

View file

@ -11,6 +11,7 @@ doctest = false
[dependencies]
anyhow.workspace = true
fabro-config = { path = "../fabro-config" }
fabro-types = { path = "../fabro-types" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true

View file

@ -13,6 +13,7 @@ anyhow = "1"
chrono = { workspace = true, features = ["serde"] }
fabro-agent = { path = "../fabro-agent" }
fabro-llm = { path = "../fabro-llm" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
serde.workspace = true
serde_json.workspace = true

View file

@ -1,2 +1,4 @@
pub mod retro;
pub mod retro_agent;
pub use retro::RetroExt;

View file

@ -1,13 +1,11 @@
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use fabro_types::retro::{
AggregateStats, FrictionKind, FrictionPoint, Learning, LearningCategory, OpenItem,
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
/// Flat summary of a completed stage, built by callers from their own
/// checkpoint/outcome types to decouple retro derivation from the workflow
/// engine internals.
#[derive(Debug, Clone)]
pub struct CompletedStage {
pub node_id: String,
@ -21,181 +19,27 @@ pub struct CompletedStage {
pub files_touched: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SmoothnessRating {
Effortless,
Smooth,
Bumpy,
Struggled,
Failed,
pub trait RetroExt {
fn save(&self, run_dir: &Path) -> anyhow::Result<()>;
fn load(run_dir: &Path) -> anyhow::Result<Self>
where
Self: Sized;
}
impl fmt::Display for SmoothnessRating {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
SmoothnessRating::Effortless => "effortless",
SmoothnessRating::Smooth => "smooth",
SmoothnessRating::Bumpy => "bumpy",
SmoothnessRating::Struggled => "struggled",
SmoothnessRating::Failed => "failed",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LearningCategory {
Repo,
Code,
Workflow,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Learning {
pub category: LearningCategory,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrictionKind {
Retry,
Timeout,
WrongApproach,
ToolFailure,
Ambiguity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrictionPoint {
pub kind: FrictionKind,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenItemKind {
TechDebt,
FollowUp,
Investigation,
TestGap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenItem {
pub kind: OpenItemKind,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageRetro {
pub stage_id: String,
pub stage_label: String,
pub status: String,
pub duration_ms: u64,
pub retries: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregateStats {
pub total_duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub total_retries: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
pub stages_completed: usize,
pub stages_failed: usize,
}
/// Agent-generated qualitative narrative fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetroNarrative {
pub smoothness: SmoothnessRating,
pub intent: String,
pub outcome: String,
#[serde(default)]
pub learnings: Vec<Learning>,
#[serde(default)]
pub friction_points: Vec<FrictionPoint>,
#[serde(default)]
pub open_items: Vec<OpenItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Retro {
pub run_id: String,
pub workflow_name: String,
pub goal: String,
pub timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smoothness: Option<SmoothnessRating>,
pub stages: Vec<StageRetro>,
pub stats: AggregateStats,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learnings: Option<Vec<Learning>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub friction_points: Option<Vec<FrictionPoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_items: Option<Vec<OpenItem>>,
}
impl Retro {
/// Merge agent-generated narrative into this retro.
pub fn apply_narrative(&mut self, narrative: RetroNarrative) {
self.smoothness = Some(narrative.smoothness);
self.intent = Some(narrative.intent);
self.outcome = Some(narrative.outcome);
self.learnings = if narrative.learnings.is_empty() {
None
} else {
Some(narrative.learnings)
};
self.friction_points = if narrative.friction_points.is_empty() {
None
} else {
Some(narrative.friction_points)
};
self.open_items = if narrative.open_items.is_empty() {
None
} else {
Some(narrative.open_items)
};
}
/// Save the retro as JSON to `run_dir/retro.json`.
pub fn save(&self, run_dir: &Path) -> anyhow::Result<()> {
impl RetroExt for Retro {
fn save(&self, run_dir: &Path) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| anyhow::anyhow!("retro serialize failed: {e}"))?;
std::fs::write(run_dir.join("retro.json"), json)?;
Ok(())
}
/// Load a retro from `run_dir/retro.json`.
pub fn load(run_dir: &Path) -> anyhow::Result<Self> {
fn load(run_dir: &Path) -> anyhow::Result<Self> {
let data = std::fs::read_to_string(run_dir.join("retro.json"))?;
serde_json::from_str(&data).map_err(|e| anyhow::anyhow!("retro deserialize failed: {e}"))
}
}
/// Extract stage durations from `progress.jsonl` by reading `StageCompleted` events.
pub fn extract_stage_durations(run_dir: &Path) -> HashMap<String, u64> {
let mut durations = HashMap::new();
let jsonl_path = run_dir.join("progress.jsonl");
@ -220,9 +64,6 @@ pub fn extract_stage_durations(run_dir: &Path) -> HashMap<String, u64> {
durations
}
/// Build a `Retro` from completed stage data and run metadata. All qualitative
/// fields (`smoothness`, `intent`, `outcome`, etc.) are left as `None` for
/// the retro agent to fill in.
pub fn derive_retro(
run_id: &str,
workflow_name: &str,
@ -266,7 +107,14 @@ pub fn derive_retro(
files_touched: cs.files_touched,
});
all_files.extend(stages.last().unwrap().files_touched.iter().cloned());
all_files.extend(
stages
.last()
.expect("stage just pushed")
.files_touched
.iter()
.cloned(),
);
}
all_files.sort();
@ -285,7 +133,7 @@ pub fn derive_retro(
run_id: run_id.to_string(),
workflow_name: workflow_name.to_string(),
goal: goal.to_string(),
timestamp: Utc::now(),
timestamp: chrono::Utc::now(),
smoothness: None,
stages,
stats,
@ -296,245 +144,3 @@ pub fn derive_retro(
open_items: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_completed_stages() -> Vec<CompletedStage> {
vec![
CompletedStage {
node_id: "plan".to_string(),
status: "success".to_string(),
succeeded: true,
failed: false,
retries: 0,
cost: Some(0.05),
notes: Some("Planned the approach".to_string()),
failure_reason: None,
files_touched: vec!["src/main.rs".to_string()],
},
CompletedStage {
node_id: "code".to_string(),
status: "success".to_string(),
succeeded: true,
failed: false,
retries: 1,
cost: Some(0.10),
notes: None,
failure_reason: None,
files_touched: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
},
]
}
#[test]
fn derive_retro_builds_stages() {
let stages = make_completed_stages();
let durations: HashMap<String, u64> =
[("plan".to_string(), 5000), ("code".to_string(), 15000)]
.into_iter()
.collect();
let retro = derive_retro(
"run-1",
"my_pipeline",
"Fix the bug",
stages.clone(),
20000,
&durations,
);
assert_eq!(retro.run_id, "run-1");
assert_eq!(retro.workflow_name, "my_pipeline");
assert_eq!(retro.goal, "Fix the bug");
assert_eq!(retro.stages.len(), 2);
assert_eq!(retro.stages[0].stage_id, "plan");
assert_eq!(retro.stages[0].duration_ms, 5000);
assert_eq!(retro.stages[0].retries, 0);
assert_eq!(retro.stages[1].stage_id, "code");
assert_eq!(retro.stages[1].duration_ms, 15000);
assert_eq!(retro.stages[1].retries, 1);
assert_eq!(retro.stats.total_duration_ms, 20000);
assert_eq!(retro.stats.total_retries, 1);
assert_eq!(retro.stats.stages_completed, 2);
assert_eq!(retro.stats.stages_failed, 0);
assert!((retro.stats.total_cost.unwrap() - 0.15).abs() < f64::EPSILON);
assert_eq!(retro.stats.files_touched, vec!["src/lib.rs", "src/main.rs"]);
assert!(retro.smoothness.is_none());
assert!(retro.intent.is_none());
}
#[test]
fn derive_retro_handles_failed_stage() {
let stages = vec![CompletedStage {
node_id: "start".to_string(),
status: "fail".to_string(),
succeeded: false,
failed: true,
retries: 0,
cost: None,
notes: None,
failure_reason: Some("boom".to_string()),
files_touched: vec![],
}];
let retro = derive_retro(
"run-2",
"pipe",
"goal",
stages.clone(),
5000,
&HashMap::new(),
);
assert_eq!(retro.stats.stages_failed, 1);
assert_eq!(retro.stats.stages_completed, 0);
}
#[test]
fn apply_narrative_merges_fields() {
let stages = make_completed_stages();
let mut retro = derive_retro("r1", "p", "g", stages.clone(), 1000, &HashMap::new());
let narrative = RetroNarrative {
smoothness: SmoothnessRating::Smooth,
intent: "Fix authentication bug".to_string(),
outcome: "Successfully fixed the login flow".to_string(),
learnings: vec![Learning {
category: LearningCategory::Code,
text: "Token refresh logic was in the wrong module".to_string(),
}],
friction_points: vec![],
open_items: vec![OpenItem {
kind: OpenItemKind::TestGap,
description: "No integration test for token refresh".to_string(),
}],
};
retro.apply_narrative(narrative);
assert_eq!(retro.smoothness, Some(SmoothnessRating::Smooth));
assert_eq!(retro.intent.as_deref(), Some("Fix authentication bug"));
assert_eq!(
retro.outcome.as_deref(),
Some("Successfully fixed the login flow")
);
assert_eq!(retro.learnings.as_ref().unwrap().len(), 1);
assert!(retro.friction_points.is_none()); // empty vec -> None
assert_eq!(retro.open_items.as_ref().unwrap().len(), 1);
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let stages = make_completed_stages();
let mut retro = derive_retro("r1", "pipe", "goal", stages.clone(), 1000, &HashMap::new());
retro.smoothness = Some(SmoothnessRating::Bumpy);
retro.intent = Some("Test intent".to_string());
retro.save(dir.path()).unwrap();
let loaded = Retro::load(dir.path()).unwrap();
assert_eq!(loaded.run_id, "r1");
assert_eq!(loaded.smoothness, Some(SmoothnessRating::Bumpy));
assert_eq!(loaded.intent.as_deref(), Some("Test intent"));
assert_eq!(loaded.stages.len(), 2);
}
#[test]
fn load_nonexistent_retro() {
let result = Retro::load(Path::new("/nonexistent"));
assert!(result.is_err());
}
#[test]
fn smoothness_rating_serde_roundtrip() {
let json = serde_json::to_string(&SmoothnessRating::Effortless).unwrap();
assert_eq!(json, "\"effortless\"");
let parsed: SmoothnessRating = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SmoothnessRating::Effortless);
}
#[test]
fn retro_narrative_serde() {
let narrative = RetroNarrative {
smoothness: SmoothnessRating::Failed,
intent: "Deploy feature".to_string(),
outcome: "Build failed".to_string(),
learnings: vec![],
friction_points: vec![FrictionPoint {
kind: FrictionKind::ToolFailure,
description: "Compiler error".to_string(),
stage_id: Some("build".to_string()),
}],
open_items: vec![],
};
let json = serde_json::to_string(&narrative).unwrap();
let parsed: RetroNarrative = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.smoothness, SmoothnessRating::Failed);
assert_eq!(parsed.friction_points.len(), 1);
assert_eq!(parsed.friction_points[0].kind, FrictionKind::ToolFailure);
}
#[test]
fn extract_stage_durations_from_jsonl() {
let dir = tempfile::tempdir().unwrap();
let jsonl = dir.path().join("progress.jsonl");
let event1 = serde_json::json!({
"ts": "2025-01-01T00:00:00.000Z",
"run_id": "r1",
"event": "StageCompleted",
"node_id": "plan",
"node_label": "Plan",
"stage_index": 0,
"duration_ms": 5000,
"status": "success",
"preferred_label": null,
"suggested_next_ids": [],
"usage": null,
"failure_reason": null,
"notes": null,
"files_touched": [],
"attempt": 1,
"max_attempts": 1,
"failure_class": null
});
let event2 = serde_json::json!({
"ts": "2025-01-01T00:00:05.000Z",
"run_id": "r1",
"event": "StageCompleted",
"node_id": "code",
"node_label": "Code",
"stage_index": 1,
"duration_ms": 15000,
"status": "success",
"preferred_label": null,
"suggested_next_ids": [],
"usage": null,
"failure_reason": null,
"notes": null,
"files_touched": [],
"attempt": 1,
"max_attempts": 1,
"failure_class": null
});
let content = format!(
"{}\n{}\n",
serde_json::to_string(&event1).unwrap(),
serde_json::to_string(&event2).unwrap()
);
std::fs::write(&jsonl, content).unwrap();
let durations = extract_stage_durations(dir.path());
assert_eq!(durations.get("plan"), Some(&5000));
assert_eq!(durations.get("code"), Some(&15000));
}
#[test]
fn extract_stage_durations_missing_file() {
let durations = extract_stage_durations(Path::new("/nonexistent"));
assert!(durations.is_empty());
}
}

View file

@ -10,7 +10,7 @@ default = ["local"]
local = ["dep:glob", "dep:libc"]
docker = ["dep:bollard", "dep:tar", "dep:futures"]
ssh = ["dep:openssh", "dep:fabro-github", "dep:fabro-config"]
exe = ["ssh", "fabro-config/exedev"]
exe = ["ssh", "fabro-config/exedev", "fabro-types/exedev"]
sprites = ["dep:chrono", "dep:rand"]
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:chrono", "dep:rand"]
test-support = []
@ -41,6 +41,7 @@ futures = { workspace = true, optional = true }
openssh = { workspace = true, optional = true }
fabro-config = { path = "../fabro-config", optional = true }
fabro-github = { path = "../fabro-github", optional = true }
fabro-types = { path = "../fabro-types" }
# sprites
chrono = { workspace = true, optional = true }

View file

@ -51,4 +51,4 @@ pub use local::LocalSandbox;
#[cfg(feature = "docker")]
pub use docker::{DockerSandbox, DockerSandboxConfig};
pub use sandbox_record::SandboxRecord;
pub use sandbox_record::{SandboxRecord, SandboxRecordExt};

View file

@ -1,140 +1,26 @@
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub use fabro_types::sandbox_record::SandboxRecord;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRecord {
/// Provider type: "local", "docker", "daytona", "exe"
pub provider: String,
/// Working directory inside the sandbox
pub working_directory: String,
/// Provider-specific identifier (container_id / sandbox_name / vm_name)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
/// Docker: host path that is bind-mounted into the container
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
/// Docker: mount point inside the container
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
/// Exe: SSH destination for the data plane
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_host: Option<String>,
pub trait SandboxRecordExt {
fn save(&self, path: &Path) -> anyhow::Result<()>;
fn load(path: &Path) -> anyhow::Result<Self>
where
Self: Sized;
}
impl SandboxRecord {
pub fn save(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self).context("sandbox_record serialize failed")?;
impl SandboxRecordExt for SandboxRecord {
fn save(&self, path: &Path) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| anyhow::anyhow!("sandbox_record serialize failed: {e}"))?;
std::fs::write(path, json)?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self> {
fn load(path: &Path) -> anyhow::Result<Self> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&data).context("sandbox_record deserialize failed")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_and_load_roundtrip_local() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "local".to_string(),
working_directory: "/tmp/work".to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "local");
assert_eq!(loaded.working_directory, "/tmp/work");
assert!(loaded.identifier.is_none());
}
#[test]
fn save_and_load_roundtrip_docker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "docker".to_string(),
working_directory: "/workspace".to_string(),
identifier: Some("abc123container".to_string()),
host_working_directory: Some("/home/user/project".to_string()),
container_mount_point: Some("/workspace".to_string()),
data_host: None,
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "docker");
assert_eq!(loaded.identifier.as_deref(), Some("abc123container"));
assert_eq!(
loaded.host_working_directory.as_deref(),
Some("/home/user/project")
);
assert_eq!(loaded.container_mount_point.as_deref(), Some("/workspace"));
assert!(loaded.data_host.is_none());
}
#[test]
fn save_and_load_roundtrip_exe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "exe".to_string(),
working_directory: "/home/exedev".to_string(),
identifier: Some("my-vm".to_string()),
host_working_directory: None,
container_mount_point: None,
data_host: Some("my-vm.exe.xyz".to_string()),
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "exe");
assert_eq!(loaded.identifier.as_deref(), Some("my-vm"));
assert_eq!(loaded.data_host.as_deref(), Some("my-vm.exe.xyz"));
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "local".to_string(),
working_directory: "/work".to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
};
record.save(&path).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(raw.get("identifier").is_none());
assert!(raw.get("host_working_directory").is_none());
assert!(raw.get("container_mount_point").is_none());
assert!(raw.get("data_host").is_none());
}
#[test]
fn load_nonexistent_file() {
let result = SandboxRecord::load(Path::new("/nonexistent/sandbox.json"));
assert!(result.is_err());
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("sandbox_record deserialize failed: {e}"))
}
}

View file

@ -1,9 +1,9 @@
[package]
name = "fabro-config-derive"
name = "fabro-types-derive"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Derive macros for fabro-config"
description = "Derive macros for fabro-types"
[lib]
proc-macro = true

View file

@ -14,7 +14,7 @@ pub fn derive_combine(input: TokenStream) -> TokenStream {
let combined = fields.named.into_iter().map(|field| {
let ident = field.ident.expect("named field");
quote! {
#ident: ::fabro_config::combine::Combine::combine(self.#ident, other.#ident)
#ident: ::fabro_types::combine::Combine::combine(self.#ident, other.#ident)
}
});
quote! {
@ -27,7 +27,7 @@ pub fn derive_combine(input: TokenStream) -> TokenStream {
let combined = fields.unnamed.iter().enumerate().map(|(index, _)| {
let index = syn::Index::from(index);
quote! {
::fabro_config::combine::Combine::combine(self.#index, other.#index)
::fabro_types::combine::Combine::combine(self.#index, other.#index)
}
});
quote! {
@ -42,7 +42,7 @@ pub fn derive_combine(input: TokenStream) -> TokenStream {
};
quote! {
impl #impl_generics ::fabro_config::combine::Combine for #ident #ty_generics #where_clause {
impl #impl_generics ::fabro_types::combine::Combine for #ident #ty_generics #where_clause {
fn combine(self, other: Self) -> Self {
#body
}

View file

@ -0,0 +1,21 @@
[package]
name = "fabro-types"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Shared record structs and enums for Fabro"
[lib]
doctest = false
[features]
default = []
exedev = []
clap = ["dep:clap"]
[dependencies]
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, optional = true }
fabro-types-derive = { path = "../fabro-types-derive" }
serde.workspace = true
serde_json.workspace = true

View file

@ -0,0 +1,30 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::failure_signature::FailureSignature;
use crate::outcome::Outcome;
use crate::usage::StageUsage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub timestamp: DateTime<Utc>,
pub current_node: String,
pub completed_nodes: Vec<String>,
pub node_retries: HashMap<String, u32>,
pub context_values: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_outcomes: HashMap<String, Outcome<Option<StageUsage>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub loop_failure_signatures: HashMap<FailureSignature, usize>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub restart_failure_signatures: HashMap<FailureSignature, usize>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_visits: HashMap<String, usize>,
}

View file

@ -0,0 +1,60 @@
use std::collections::HashMap;
use std::hash::Hash;
use std::path::PathBuf;
pub trait Combine {
fn combine(self, other: Self) -> Self;
}
impl<T: Combine> Combine for Option<T> {
fn combine(self, other: Self) -> Self {
match (self, other) {
(Some(this), Some(other)) => Some(this.combine(other)),
(Some(this), None) => Some(this),
(None, Some(other)) => Some(other),
(None, None) => None,
}
}
}
impl<T> Combine for Vec<T> {
fn combine(mut self, other: Self) -> Self {
self.extend(other);
self
}
}
impl<K, V> Combine for HashMap<K, V>
where
K: Eq + Hash,
V: Combine,
{
fn combine(mut self, other: Self) -> Self {
for (key, value) in other {
match self.remove(&key) {
Some(existing) => {
self.insert(key, existing.combine(value));
}
None => {
self.insert(key, value);
}
}
}
self
}
}
macro_rules! impl_left_wins {
($($ty:ty),* $(,)?) => {
$(
impl Combine for $ty {
fn combine(self, _other: Self) -> Self {
self
}
}
)*
};
}
impl_left_wins!(bool, i32, u16, u32, u64, usize, String, PathBuf,);

View file

@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::outcome::StageStatus;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub stage_id: String,
pub stage_label: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
pub retries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conclusion {
pub timestamp: DateTime<Utc>,
pub status: StageStatus,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<StageSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
#[serde(default)]
pub total_retries: u32,
#[serde(default)]
pub total_input_tokens: i64,
#[serde(default)]
pub total_output_tokens: i64,
#[serde(default)]
pub total_cache_read_tokens: i64,
#[serde(default)]
pub total_cache_write_tokens: i64,
#[serde(default)]
pub total_reasoning_tokens: i64,
#[serde(default)]
pub has_pricing: bool,
}

View file

@ -0,0 +1,12 @@
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FailureSignature(pub String);
impl fmt::Display for FailureSignature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}

View file

@ -0,0 +1,827 @@
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// Typed attribute values for nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttrValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Duration(Duration),
}
impl AttrValue {
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
#[must_use]
pub const fn as_i64(&self) -> Option<i64> {
match self {
Self::Integer(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub const fn as_f64(&self) -> Option<f64> {
match self {
Self::Float(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub const fn as_bool(&self) -> Option<bool> {
match self {
Self::Boolean(b) => Some(*b),
_ => None,
}
}
#[must_use]
pub const fn as_duration(&self) -> Option<Duration> {
match self {
Self::Duration(d) => Some(*d),
_ => None,
}
}
/// Convert any variant to its string representation.
#[must_use]
pub fn to_string_value(&self) -> String {
match self {
Self::String(s) => s.clone(),
Self::Integer(n) => n.to_string(),
Self::Float(f) => f.to_string(),
Self::Boolean(b) => b.to_string(),
Self::Duration(d) => format!("{}ms", d.as_millis()),
}
}
}
/// Returns true if the handler type is an LLM-based handler (agent or prompt, including legacy aliases).
#[must_use]
pub fn is_llm_handler_type(handler_type: Option<&str>) -> bool {
matches!(
handler_type,
Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot")
)
}
/// Maps Graphviz shapes to handler type strings (Section 2.8).
#[must_use]
pub fn shape_to_handler_type(shape: &str) -> Option<&'static str> {
match shape {
"Mdiamond" => Some("start"),
"Msquare" => Some("exit"),
"box" => Some("agent"),
"tab" => Some("prompt"),
"hexagon" => Some("human"),
"diamond" => Some("conditional"),
"component" => Some("parallel"),
"tripleoctagon" => Some("parallel.fan_in"),
"parallelogram" => Some("command"),
"house" => Some("stack.manager_loop"),
"insulator" => Some("wait"),
_ => None,
}
}
/// A node in the workflow graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub attrs: HashMap<String, AttrValue>,
/// CSS-like classes for model stylesheet targeting (from `class` attr and subgraph derivation).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub classes: Vec<String>,
}
impl Node {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
attrs: HashMap::new(),
classes: Vec::new(),
}
}
fn str_attr(&self, key: &str) -> Option<&str> {
self.attrs.get(key).and_then(AttrValue::as_str)
}
fn bool_attr(&self, key: &str) -> Option<bool> {
self.attrs.get(key).and_then(AttrValue::as_bool)
}
fn int_attr(&self, key: &str) -> Option<i64> {
self.attrs.get(key).and_then(AttrValue::as_i64)
}
#[must_use]
pub fn label(&self) -> &str {
self.str_attr("label").unwrap_or(&self.id)
}
#[must_use]
pub fn shape(&self) -> &str {
self.str_attr("shape").unwrap_or("box")
}
#[must_use]
pub fn node_type(&self) -> Option<&str> {
self.str_attr("type")
}
#[must_use]
pub fn prompt(&self) -> Option<&str> {
self.str_attr("prompt")
}
#[must_use]
pub fn max_retries(&self) -> Option<i64> {
self.int_attr("max_retries")
}
#[must_use]
pub fn max_visits(&self) -> Option<i64> {
self.int_attr("max_visits")
}
#[must_use]
pub fn goal_gate(&self) -> bool {
self.bool_attr("goal_gate").unwrap_or(false)
}
#[must_use]
pub fn retry_target(&self) -> Option<&str> {
self.str_attr("retry_target")
}
#[must_use]
pub fn fallback_retry_target(&self) -> Option<&str> {
self.str_attr("fallback_retry_target")
}
#[must_use]
pub fn fidelity(&self) -> Option<&str> {
self.str_attr("fidelity")
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.str_attr("thread_id")
}
#[must_use]
pub fn class(&self) -> Option<&str> {
self.str_attr("class")
}
pub fn timeout(&self) -> Option<Duration> {
self.attrs.get("timeout").and_then(AttrValue::as_duration)
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.str_attr("model")
}
#[must_use]
pub fn provider(&self) -> Option<&str> {
self.str_attr("provider")
}
#[must_use]
pub fn max_tokens(&self) -> Option<i64> {
self.int_attr("max_tokens").filter(|&v| v > 0)
}
#[must_use]
pub fn reasoning_effort(&self) -> &str {
self.str_attr("reasoning_effort").unwrap_or("high")
}
#[must_use]
pub fn speed(&self) -> Option<&str> {
self.str_attr("speed")
}
#[must_use]
pub fn auto_status(&self) -> bool {
self.bool_attr("auto_status").unwrap_or(false)
}
#[must_use]
pub fn allow_partial(&self) -> bool {
self.bool_attr("allow_partial").unwrap_or(false)
}
#[must_use]
pub fn project_memory(&self) -> bool {
self.bool_attr("project_memory").unwrap_or(true)
}
#[must_use]
pub fn retry_policy(&self) -> Option<&str> {
self.str_attr("retry_policy")
}
#[must_use]
pub fn backend(&self) -> Option<&str> {
self.str_attr("backend")
}
#[must_use]
pub fn selection(&self) -> &str {
self.str_attr("selection").unwrap_or("deterministic")
}
/// Resolve the handler type for this node using explicit type or shape mapping.
#[must_use]
pub fn handler_type(&self) -> Option<&str> {
if let Some(t) = self.node_type() {
return Some(t);
}
shape_to_handler_type(self.shape())
}
}
/// An edge connecting two nodes in the workflow graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
pub attrs: HashMap<String, AttrValue>,
}
impl Edge {
pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
attrs: HashMap::new(),
}
}
fn str_attr(&self, key: &str) -> Option<&str> {
self.attrs.get(key).and_then(AttrValue::as_str)
}
fn bool_attr(&self, key: &str) -> Option<bool> {
self.attrs.get(key).and_then(AttrValue::as_bool)
}
fn int_attr(&self, key: &str) -> Option<i64> {
self.attrs.get(key).and_then(AttrValue::as_i64)
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.str_attr("label")
}
#[must_use]
pub fn condition(&self) -> Option<&str> {
self.str_attr("condition")
}
#[must_use]
pub fn weight(&self) -> i64 {
self.int_attr("weight").unwrap_or(0)
}
#[must_use]
pub fn fidelity(&self) -> Option<&str> {
self.str_attr("fidelity")
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.str_attr("thread_id")
}
#[must_use]
pub fn loop_restart(&self) -> bool {
self.bool_attr("loop_restart").unwrap_or(false)
}
#[must_use]
pub fn freeform(&self) -> bool {
self.bool_attr("freeform").unwrap_or(false)
}
}
/// The parsed workflow graph containing nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
pub name: String,
pub nodes: HashMap<String, Node>,
pub edges: Vec<Edge>,
pub attrs: HashMap<String, AttrValue>,
}
impl Graph {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
nodes: HashMap::new(),
edges: Vec::new(),
attrs: HashMap::new(),
}
}
/// Returns all outgoing edges from the given node.
#[must_use]
pub fn outgoing_edges(&self, node_id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.from == node_id).collect()
}
/// Returns all incoming edges to the given node.
#[must_use]
pub fn incoming_edges(&self, node_id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.to == node_id).collect()
}
/// Find the start node: shape=Mdiamond, or id "start"/"Start".
#[must_use]
pub fn find_start_node(&self) -> Option<&Node> {
// First: look for shape=Mdiamond
let by_shape = self.nodes.values().find(|n| n.shape() == "Mdiamond");
if by_shape.is_some() {
return by_shape;
}
// Second: look for id "start" or "Start"
self.nodes.get("start").or_else(|| self.nodes.get("Start"))
}
/// Find the exit node: shape=Msquare, or id "exit"/"Exit".
#[must_use]
pub fn find_exit_node(&self) -> Option<&Node> {
let by_shape = self.nodes.values().find(|n| n.shape() == "Msquare");
if by_shape.is_some() {
return by_shape;
}
self.nodes
.get("exit")
.or_else(|| self.nodes.get("Exit"))
.or_else(|| self.nodes.get("end"))
.or_else(|| self.nodes.get("End"))
}
/// Graph-level goal attribute.
pub fn goal(&self) -> &str {
self.attrs
.get("goal")
.and_then(AttrValue::as_str)
.unwrap_or("")
}
/// Graph-level model stylesheet attribute.
pub fn model_stylesheet(&self) -> &str {
self.attrs
.get("model_stylesheet")
.and_then(AttrValue::as_str)
.unwrap_or("")
}
/// Graph-level `default_max_retries` (default 0).
pub fn default_max_retries(&self) -> i64 {
self.attrs
.get("default_max_retries")
.and_then(AttrValue::as_i64)
.unwrap_or(0)
}
/// Graph-level `retry_target`.
pub fn retry_target(&self) -> Option<&str> {
self.attrs.get("retry_target").and_then(AttrValue::as_str)
}
/// Graph-level `fallback_retry_target`.
pub fn fallback_retry_target(&self) -> Option<&str> {
self.attrs
.get("fallback_retry_target")
.and_then(AttrValue::as_str)
}
/// Graph-level `default_fidelity`.
pub fn default_fidelity(&self) -> Option<&str> {
self.attrs
.get("default_fidelity")
.and_then(AttrValue::as_str)
}
/// Graph-level `default_thread`.
pub fn default_thread(&self) -> Option<&str> {
self.attrs.get("default_thread").and_then(AttrValue::as_str)
}
/// Graph-level `loop_restart_signature_limit` (default 3).
/// When the same failure signature repeats this many times, the pipeline aborts.
pub fn loop_restart_signature_limit(&self) -> usize {
self.attrs
.get("loop_restart_signature_limit")
.and_then(AttrValue::as_i64)
.filter(|&v| v >= 1)
.map_or(3, |v| v as usize)
}
/// Graph-level `stall_timeout`. Defaults to 1800s. Returns `None` when set to zero (disabled).
pub fn stall_timeout(&self) -> Option<Duration> {
match self
.attrs
.get("stall_timeout")
.and_then(AttrValue::as_duration)
{
Some(d) if d.is_zero() => None,
Some(d) => Some(d),
None => Some(Duration::from_secs(1800)),
}
}
/// Graph-level `max_node_visits` (default 0 = disabled).
pub fn max_node_visits(&self) -> u64 {
self.attrs
.get("max_node_visits")
.and_then(AttrValue::as_i64)
.and_then(|n| u64::try_from(n).ok())
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attr_value_as_str() {
let val = AttrValue::String("hello".to_string());
assert_eq!(val.as_str(), Some("hello"));
assert_eq!(AttrValue::Integer(1).as_str(), None);
}
#[test]
fn attr_value_as_i64() {
assert_eq!(AttrValue::Integer(42).as_i64(), Some(42));
assert_eq!(AttrValue::String("x".to_string()).as_i64(), None);
}
#[test]
fn attr_value_as_f64() {
assert_eq!(AttrValue::Float(3.15).as_f64(), Some(3.15));
assert_eq!(AttrValue::Integer(1).as_f64(), None);
}
#[test]
fn attr_value_as_bool() {
assert_eq!(AttrValue::Boolean(true).as_bool(), Some(true));
assert_eq!(AttrValue::String("true".to_string()).as_bool(), None);
}
#[test]
fn attr_value_as_duration() {
let d = Duration::from_secs(10);
assert_eq!(AttrValue::Duration(d).as_duration(), Some(d));
assert_eq!(AttrValue::Integer(10).as_duration(), None);
}
#[test]
fn shape_to_handler_type_mappings() {
assert_eq!(shape_to_handler_type("Mdiamond"), Some("start"));
assert_eq!(shape_to_handler_type("Msquare"), Some("exit"));
assert_eq!(shape_to_handler_type("box"), Some("agent"));
assert_eq!(shape_to_handler_type("tab"), Some("prompt"));
assert_eq!(shape_to_handler_type("hexagon"), Some("human"));
assert_eq!(shape_to_handler_type("diamond"), Some("conditional"));
assert_eq!(shape_to_handler_type("component"), Some("parallel"));
assert_eq!(
shape_to_handler_type("tripleoctagon"),
Some("parallel.fan_in")
);
assert_eq!(shape_to_handler_type("parallelogram"), Some("command"));
assert_eq!(shape_to_handler_type("house"), Some("stack.manager_loop"));
assert_eq!(shape_to_handler_type("insulator"), Some("wait"));
assert_eq!(shape_to_handler_type("unknown"), None);
}
#[test]
fn is_llm_handler_type_checks() {
assert!(is_llm_handler_type(Some("agent")));
assert!(is_llm_handler_type(Some("agent_loop")));
assert!(is_llm_handler_type(Some("prompt")));
assert!(is_llm_handler_type(Some("one_shot")));
assert!(!is_llm_handler_type(Some("command")));
assert!(!is_llm_handler_type(Some("human")));
assert!(!is_llm_handler_type(None));
}
#[test]
fn node_defaults() {
let node = Node::new("test");
assert_eq!(node.id, "test");
assert_eq!(node.label(), "test");
assert_eq!(node.shape(), "box");
assert_eq!(node.node_type(), None);
assert_eq!(node.prompt(), None);
assert_eq!(node.max_retries(), None);
assert!(!node.goal_gate());
assert_eq!(node.retry_target(), None);
assert_eq!(node.fallback_retry_target(), None);
assert_eq!(node.fidelity(), None);
assert_eq!(node.thread_id(), None);
assert_eq!(node.class(), None);
assert_eq!(node.timeout(), None);
assert_eq!(node.model(), None);
assert_eq!(node.provider(), None);
assert_eq!(node.reasoning_effort(), "high");
assert_eq!(node.speed(), None);
assert!(!node.auto_status());
assert!(!node.allow_partial());
assert_eq!(node.retry_policy(), None);
assert_eq!(node.max_visits(), None);
assert!(node.project_memory());
}
#[test]
fn node_project_memory_false_overrides_default() {
let mut node = Node::new("x");
node.attrs
.insert("project_memory".to_string(), AttrValue::Boolean(false));
assert!(!node.project_memory());
}
#[test]
fn node_with_attrs() {
let mut node = Node::new("plan");
node.attrs.insert(
"label".to_string(),
AttrValue::String("Plan step".to_string()),
);
node.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
node.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
node.attrs
.insert("max_retries".to_string(), AttrValue::Integer(3));
assert_eq!(node.label(), "Plan step");
assert_eq!(node.shape(), "diamond");
assert!(node.goal_gate());
assert_eq!(node.max_retries(), Some(3));
}
#[test]
fn node_max_visits_returns_value() {
let mut node = Node::new("test");
node.attrs
.insert("max_visits".to_string(), AttrValue::Integer(5));
assert_eq!(node.max_visits(), Some(5));
}
#[test]
fn node_handler_type_explicit() {
let mut node = Node::new("gate");
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
assert_eq!(node.handler_type(), Some("human"));
}
#[test]
fn node_handler_type_from_shape() {
let mut node = Node::new("entry");
node.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
assert_eq!(node.handler_type(), Some("start"));
}
#[test]
fn edge_defaults() {
let edge = Edge::new("a", "b");
assert_eq!(edge.from, "a");
assert_eq!(edge.to, "b");
assert_eq!(edge.label(), None);
assert_eq!(edge.condition(), None);
assert_eq!(edge.weight(), 0);
assert_eq!(edge.fidelity(), None);
assert_eq!(edge.thread_id(), None);
assert!(!edge.loop_restart());
assert!(!edge.freeform());
}
#[test]
fn edge_with_attrs() {
let mut edge = Edge::new("a", "b");
edge.attrs
.insert("label".to_string(), AttrValue::String("next".to_string()));
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
edge.attrs
.insert("weight".to_string(), AttrValue::Integer(5));
edge.attrs
.insert("loop_restart".to_string(), AttrValue::Boolean(true));
edge.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
assert_eq!(edge.label(), Some("next"));
assert_eq!(edge.condition(), Some("outcome=success"));
assert_eq!(edge.weight(), 5);
assert!(edge.loop_restart());
assert!(edge.freeform());
}
fn sample_graph() -> Graph {
let mut g = Graph::new("test_pipeline");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
let work = Node::new("work");
g.nodes.insert("work".to_string(), work);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
g.attrs.insert(
"goal".to_string(),
AttrValue::String("Run tests".to_string()),
);
g
}
#[test]
fn graph_find_start_node() {
let g = sample_graph();
let start = g.find_start_node().unwrap();
assert_eq!(start.id, "start");
}
#[test]
fn graph_find_exit_node() {
let g = sample_graph();
let exit = g.find_exit_node().unwrap();
assert_eq!(exit.id, "exit");
}
#[test]
fn graph_find_exit_by_end_id() {
let mut g = Graph::new("test");
let node = Node::new("end");
g.nodes.insert("end".to_string(), node);
let exit = g.find_exit_node().unwrap();
assert_eq!(exit.id, "end");
}
#[test]
fn graph_outgoing_edges() {
let g = sample_graph();
let edges = g.outgoing_edges("start");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].to, "work");
}
#[test]
fn graph_incoming_edges() {
let g = sample_graph();
let edges = g.incoming_edges("exit");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].from, "work");
}
#[test]
fn graph_goal() {
let g = sample_graph();
assert_eq!(g.goal(), "Run tests");
}
#[test]
fn graph_goal_default() {
let g = Graph::new("empty");
assert_eq!(g.goal(), "");
}
#[test]
fn graph_model_stylesheet_default() {
let g = Graph::new("empty");
assert_eq!(g.model_stylesheet(), "");
}
#[test]
fn graph_default_max_retries() {
let g = Graph::new("empty");
assert_eq!(g.default_max_retries(), 0);
}
#[test]
fn graph_find_start_by_id_fallback() {
let mut g = Graph::new("test");
// No Mdiamond shape, but id is "start"
let node = Node::new("start");
g.nodes.insert("start".to_string(), node);
assert!(g.find_start_node().is_some());
}
#[test]
fn graph_no_start_node() {
let g = Graph::new("empty");
assert!(g.find_start_node().is_none());
}
#[test]
fn graph_stall_timeout_default() {
let g = Graph::new("empty");
assert_eq!(g.stall_timeout(), Some(Duration::from_secs(1800)));
}
#[test]
fn graph_stall_timeout_set() {
let mut g = Graph::new("test");
g.attrs.insert(
"stall_timeout".to_string(),
AttrValue::Duration(Duration::from_millis(200)),
);
assert_eq!(g.stall_timeout(), Some(Duration::from_millis(200)));
}
#[test]
fn graph_stall_timeout_zero_disables() {
let mut g = Graph::new("test");
g.attrs.insert(
"stall_timeout".to_string(),
AttrValue::Duration(Duration::ZERO),
);
assert_eq!(g.stall_timeout(), None);
}
#[test]
fn graph_max_node_visits_default() {
let g = Graph::new("empty");
assert_eq!(g.max_node_visits(), 0);
}
#[test]
fn graph_max_node_visits_set() {
let mut g = Graph::new("test");
g.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(10));
assert_eq!(g.max_node_visits(), 10);
}
#[test]
fn graph_loop_restart_signature_limit_default() {
let g = Graph::new("empty");
assert_eq!(g.loop_restart_signature_limit(), 3);
}
#[test]
fn graph_loop_restart_signature_limit_set() {
let mut g = Graph::new("test");
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(5),
);
assert_eq!(g.loop_restart_signature_limit(), 5);
}
#[test]
fn graph_loop_restart_signature_limit_invalid_falls_back() {
let mut g = Graph::new("test");
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(0),
);
assert_eq!(g.loop_restart_signature_limit(), 3);
g.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(-1),
);
assert_eq!(g.loop_restart_signature_limit(), 3);
}
}

View file

@ -0,0 +1,35 @@
extern crate self as fabro_types;
pub mod checkpoint;
pub mod combine;
pub mod conclusion;
pub mod failure_signature;
pub mod graph;
pub mod outcome;
pub mod retro;
pub mod run;
pub mod sandbox_record;
pub mod settings;
pub mod start;
pub mod status;
pub mod usage;
pub use checkpoint::Checkpoint;
pub use conclusion::{Conclusion, StageSummary};
pub use failure_signature::FailureSignature;
pub use graph::{is_llm_handler_type, shape_to_handler_type, AttrValue, Edge, Graph, Node};
pub use outcome::{FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageStatus};
pub use retro::{
AggregateStats, FrictionKind, FrictionPoint, Learning, LearningCategory, OpenItem,
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::RunRecord;
pub use sandbox_record::SandboxRecord;
pub use settings::FabroSettings;
pub use start::StartRecord;
pub use status::{
InvalidTransition, ParseRunStatusError, RunStatus, RunStatusRecord, StatusReason,
};
pub use usage::StageUsage;
pub use fabro_types_derive::Combine;

View file

@ -0,0 +1,240 @@
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub trait OutcomeMeta:
Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
{
}
impl<T> OutcomeMeta for T where
T: Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
{
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StageStatus {
Success,
Fail,
Skipped,
PartialSuccess,
Retry,
}
impl fmt::Display for StageStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Success => write!(f, "success"),
Self::Fail => write!(f, "fail"),
Self::Skipped => write!(f, "skipped"),
Self::PartialSuccess => write!(f, "partial_success"),
Self::Retry => write!(f, "retry"),
}
}
}
impl FromStr for StageStatus {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"success" => Ok(Self::Success),
"fail" => Ok(Self::Fail),
"skipped" => Ok(Self::Skipped),
"partial_success" => Ok(Self::PartialSuccess),
"retry" => Ok(Self::Retry),
other => Err(format!("unknown stage status: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureCategory {
TransientInfra,
Deterministic,
BudgetExhausted,
CompilationLoop,
Canceled,
Structural,
}
impl fmt::Display for FailureCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::TransientInfra => "transient_infra",
Self::Deterministic => "deterministic",
Self::BudgetExhausted => "budget_exhausted",
Self::CompilationLoop => "compilation_loop",
Self::Canceled => "canceled",
Self::Structural => "structural",
};
write!(f, "{s}")
}
}
impl FromStr for FailureCategory {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase();
Ok(match normalized.as_str() {
"transient_infra" => Self::TransientInfra,
"deterministic" => Self::Deterministic,
"budget_exhausted" => Self::BudgetExhausted,
"compilation_loop" => Self::CompilationLoop,
"canceled" => Self::Canceled,
"structural" => Self::Structural,
"transient"
| "transient-infra"
| "infra_transient"
| "transient infra"
| "infrastructure_transient"
| "retryable"
| "toolchain_workspace_io"
| "toolchain-workspace-io"
| "toolchain_or_dependency_registry_unavailable"
| "toolchain-dependency-registry-unavailable" => Self::TransientInfra,
"non_transient" | "non-transient" | "permanent" | "logic" | "product" => {
Self::Deterministic
}
"cancelled" => Self::Canceled,
"budget-exhausted" | "budget exhausted" | "budget" => Self::BudgetExhausted,
"compilation-loop" | "compilation loop" | "compile_loop" | "compile-loop" => {
Self::CompilationLoop
}
"structure" | "scope_violation" | "write_scope_violation" => Self::Structural,
_ => Self::Deterministic,
})
}
}
impl FailureCategory {
pub fn is_signature_tracked(self) -> bool {
matches!(self, Self::Deterministic | Self::Structural)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureDetail {
pub message: String,
#[serde(rename = "failure_class")]
pub category: FailureCategory,
#[serde(
rename = "failure_signature",
default,
skip_serializing_if = "Option::is_none"
)]
pub signature: Option<String>,
}
impl FailureDetail {
pub fn new(message: impl Into<String>, category: FailureCategory) -> Self {
Self {
message: message.into(),
category,
signature: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound = "M: OutcomeMeta")]
pub struct Outcome<M: OutcomeMeta = ()> {
pub status: StageStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub context_updates: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jump_to_node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
#[serde(default)]
pub usage: M,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl<M: OutcomeMeta> Default for Outcome<M> {
fn default() -> Self {
Self {
status: StageStatus::Success,
preferred_label: None,
suggested_next_ids: Vec::new(),
context_updates: HashMap::new(),
jump_to_node: None,
notes: None,
failure: None,
usage: M::default(),
files_touched: Vec::new(),
duration_ms: None,
}
}
}
impl<M: OutcomeMeta> Outcome<M> {
pub fn success() -> Self {
Self::default()
}
pub fn fail(message: &str) -> Self {
Self {
status: StageStatus::Fail,
failure: Some(FailureDetail {
message: message.to_string(),
category: FailureCategory::Deterministic,
signature: None,
}),
..Self::default()
}
}
pub fn skipped(reason: &str) -> Self {
Self {
status: StageStatus::Skipped,
notes: Some(reason.to_string()),
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct NodeResult<M: OutcomeMeta = ()> {
pub outcome: Outcome<M>,
pub duration: Duration,
pub attempts: u32,
pub max_attempts: u32,
}
impl<M: OutcomeMeta> NodeResult<M> {
pub fn new(outcome: Outcome<M>, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
Self {
outcome,
duration,
attempts,
max_attempts,
}
}
pub fn from_skip(outcome: Outcome<M>) -> Self {
Self {
outcome,
duration: Duration::ZERO,
attempts: 0,
max_attempts: 0,
}
}
}

View file

@ -0,0 +1,162 @@
use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SmoothnessRating {
Effortless,
Smooth,
Bumpy,
Struggled,
Failed,
}
impl fmt::Display for SmoothnessRating {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
SmoothnessRating::Effortless => "effortless",
SmoothnessRating::Smooth => "smooth",
SmoothnessRating::Bumpy => "bumpy",
SmoothnessRating::Struggled => "struggled",
SmoothnessRating::Failed => "failed",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LearningCategory {
Repo,
Code,
Workflow,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Learning {
pub category: LearningCategory,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrictionKind {
Retry,
Timeout,
WrongApproach,
ToolFailure,
Ambiguity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrictionPoint {
pub kind: FrictionKind,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenItemKind {
TechDebt,
FollowUp,
Investigation,
TestGap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenItem {
pub kind: OpenItemKind,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageRetro {
pub stage_id: String,
pub stage_label: String,
pub status: String,
pub duration_ms: u64,
pub retries: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregateStats {
pub total_duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub total_retries: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
pub stages_completed: usize,
pub stages_failed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetroNarrative {
pub smoothness: SmoothnessRating,
pub intent: String,
pub outcome: String,
#[serde(default)]
pub learnings: Vec<Learning>,
#[serde(default)]
pub friction_points: Vec<FrictionPoint>,
#[serde(default)]
pub open_items: Vec<OpenItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Retro {
pub run_id: String,
pub workflow_name: String,
pub goal: String,
pub timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smoothness: Option<SmoothnessRating>,
pub stages: Vec<StageRetro>,
pub stats: AggregateStats,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learnings: Option<Vec<Learning>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub friction_points: Option<Vec<FrictionPoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_items: Option<Vec<OpenItem>>,
}
impl Retro {
pub fn apply_narrative(&mut self, narrative: RetroNarrative) {
self.smoothness = Some(narrative.smoothness);
self.intent = Some(narrative.intent);
self.outcome = Some(narrative.outcome);
self.learnings = if narrative.learnings.is_empty() {
None
} else {
Some(narrative.learnings)
};
self.friction_points = if narrative.friction_points.is_empty() {
None
} else {
Some(narrative.friction_points)
};
self.open_items = if narrative.open_items.is_empty() {
None
} else {
Some(narrative.open_items)
};
}
}

View file

@ -0,0 +1,25 @@
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::graph::Graph;
use crate::settings::FabroSettings;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub created_at: DateTime<Utc>,
pub settings: FabroSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
pub working_directory: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
}

View file

@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRecord {
pub provider: String,
pub working_directory: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_host: Option<String>,
}

View file

@ -0,0 +1,50 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "kebab-case")]
pub enum PermissionLevel {
ReadOnly,
ReadWrite,
Full,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
#[default]
Standalone,
Server,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ClientTlsSettings {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ServerSettings {
pub base_url: Option<String>,
pub tls: Option<ClientTlsSettings>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExecSettings {
pub provider: Option<String>,
pub model: Option<String>,
pub permissions: Option<PermissionLevel>,
pub output_format: Option<OutputFormat>,
}

View file

@ -0,0 +1,230 @@
use std::borrow::Cow;
use serde::{Deserialize, Serialize};
/// Lifecycle events that can trigger user-defined hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
RunStart,
RunComplete,
RunFailed,
StageStart,
StageComplete,
StageFailed,
StageRetrying,
EdgeSelected,
ParallelStart,
ParallelComplete,
/// Reserved: hooks for this event are not yet invoked by the engine.
SandboxReady,
/// Reserved: hooks for this event are not yet invoked by the engine.
SandboxCleanup,
CheckpointSaved,
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
/// Whether hooks for this event block execution by default.
#[must_use]
pub fn is_blocking_by_default(self) -> bool {
matches!(
self,
Self::RunStart
| Self::StageStart
| Self::EdgeSelected
| Self::PreToolUse
| Self::SandboxReady
)
}
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::RunStart => "run_start",
Self::RunComplete => "run_complete",
Self::RunFailed => "run_failed",
Self::StageStart => "stage_start",
Self::StageComplete => "stage_complete",
Self::StageFailed => "stage_failed",
Self::StageRetrying => "stage_retrying",
Self::EdgeSelected => "edge_selected",
Self::ParallelStart => "parallel_start",
Self::ParallelComplete => "parallel_complete",
Self::SandboxReady => "sandbox_ready",
Self::SandboxCleanup => "sandbox_cleanup",
Self::CheckpointSaved => "checkpoint_saved",
Self::PreToolUse => "pre_tool_use",
Self::PostToolUse => "post_tool_use",
Self::PostToolUseFailure => "post_tool_use_failure",
})
}
}
/// TLS verification mode for HTTP hooks.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TlsMode {
/// Require `https://` and verify certificates (default).
#[default]
Verify,
/// Require `https://` but skip certificate verification.
NoVerify,
/// Allow `http://`; skip certificate verification for `https://`.
Off,
}
/// How a hook is executed.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookType {
Command {
command: String,
},
Http {
url: String,
headers: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
allowed_env_vars: Vec<String>,
#[serde(default)]
tls: TlsMode,
},
Prompt {
prompt: String,
model: Option<String>,
},
Agent {
prompt: String,
model: Option<String>,
max_tool_rounds: Option<u32>,
},
}
/// A single hook definition.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct HookDefinition {
pub name: Option<String>,
pub event: HookEvent,
/// Inline command shorthand — if set, implies `type = "command"`.
#[serde(default)]
pub command: Option<String>,
/// Explicit hook type (command or http). If omitted and `command` is set,
/// defaults to `Command`.
#[serde(flatten)]
pub hook_type: Option<HookType>,
/// Regex matched against node_id, handler_type, or event-specific fields.
pub matcher: Option<String>,
/// Override the event's default blocking behavior.
pub blocking: Option<bool>,
/// Timeout in milliseconds (default: 60_000).
pub timeout_ms: Option<u64>,
/// Run inside the sandbox (true, default) or on the host (false).
pub sandbox: Option<bool>,
}
impl HookDefinition {
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
/// shorthand, then error.
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
if let Some(ref ht) = self.hook_type {
return Some(Cow::Borrowed(ht));
}
self.command.as_ref().map(|cmd| {
Cow::Owned(HookType::Command {
command: cmd.clone(),
})
})
}
/// Whether this hook is blocking for its event.
#[must_use]
pub fn is_blocking(&self) -> bool {
self.blocking
.unwrap_or_else(|| self.event.is_blocking_by_default())
}
/// Timeout duration for this hook.
///
/// Defaults: 30s for prompt hooks, 60s for all others.
#[must_use]
pub fn timeout(&self) -> std::time::Duration {
if let Some(ms) = self.timeout_ms {
return std::time::Duration::from_millis(ms);
}
let default_ms = match self.resolved_hook_type().as_deref() {
Some(HookType::Prompt { .. }) => 30_000,
_ => 60_000,
};
std::time::Duration::from_millis(default_ms)
}
/// Whether this hook runs in the sandbox.
#[must_use]
pub fn runs_in_sandbox(&self) -> bool {
self.sandbox.unwrap_or(true)
}
/// The effective name: explicit name or a generated one.
#[must_use]
pub fn effective_name(&self) -> String {
if let Some(ref n) = self.name {
return n.clone();
}
let event_str = self.event.to_string();
match self.resolved_hook_type().as_deref() {
Some(HookType::Command { ref command }) => {
let short = &command[..command.floor_char_boundary(20)];
format!("{event_str}:{short}")
}
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
Some(HookType::Prompt { ref prompt, .. })
| Some(HookType::Agent { ref prompt, .. }) => {
let short = &prompt[..prompt.floor_char_boundary(20)];
format!("{event_str}:{short}")
}
None => event_str,
}
}
}
/// Top-level hook configuration: a list of hook definitions.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct HookConfig {
#[serde(default)]
pub hooks: Vec<HookDefinition>,
}
impl HookConfig {
/// Merge with another config. Concatenates lists; on name collisions, `other` wins.
#[must_use]
pub fn merge(self, other: Self) -> Self {
let mut by_name: std::collections::HashMap<String, HookDefinition> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for hook in self.hooks {
let name = hook.effective_name();
if !by_name.contains_key(&name) {
order.push(name.clone());
}
by_name.insert(name, hook);
}
for hook in other.hooks {
let name = hook.effective_name();
if !by_name.contains_key(&name) {
order.push(name.clone());
}
by_name.insert(name, hook);
}
let hooks = order
.into_iter()
.filter_map(|name| by_name.remove(&name))
.collect();
Self { hooks }
}
}

View file

@ -0,0 +1,186 @@
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::combine::Combine;
pub fn default_startup_timeout_secs() -> u64 {
10
}
pub fn default_tool_timeout_secs() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
pub transport: McpTransport,
#[serde(default = "default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerConfig {
#[must_use]
pub fn startup_timeout(&self) -> Duration {
Duration::from_secs(self.startup_timeout_secs)
}
#[must_use]
pub fn tool_timeout(&self) -> Duration {
Duration::from_secs(self.tool_timeout_secs)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpTransport {
Stdio {
command: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
/// MCP server that runs inside a sandbox and is accessed via HTTP preview URL.
/// During session init, the server is started inside the sandbox and this
/// variant is resolved into an `Http` transport using the sandbox's preview URL.
Sandbox {
command: Vec<String>,
port: u16,
#[serde(default)]
env: HashMap<String, String>,
},
}
impl Combine for McpTransport {
fn combine(self, _other: Self) -> Self {
self
}
}
/// MCP server entry as it appears in TOML config files (without a `name` field).
///
/// Converted to [`McpServerConfig`] via [`McpServerEntry::into_config`].
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct McpServerEntry {
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default = "default_startup_timeout_secs")]
pub startup_timeout_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
}
impl McpServerEntry {
pub fn into_config(self, name: String) -> McpServerConfig {
McpServerConfig {
name,
transport: self.transport,
startup_timeout_secs: self.startup_timeout_secs,
tool_timeout_secs: self.tool_timeout_secs,
}
}
}
impl Combine for McpServerEntry {
fn combine(self, _other: Self) -> Self {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn stdio_config_construction() {
let config = McpServerConfig {
name: "test-server".into(),
transport: McpTransport::Stdio {
command: vec![
"npx".into(),
"-y".into(),
"@modelcontextprotocol/server-filesystem".into(),
],
env: HashMap::new(),
},
startup_timeout_secs: 10,
tool_timeout_secs: 60,
};
assert_eq!(config.name, "test-server");
assert_eq!(config.startup_timeout(), Duration::from_secs(10));
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
}
#[test]
fn http_config_construction() {
let config = McpServerConfig {
name: "remote-server".into(),
transport: McpTransport::Http {
url: "https://example.com/mcp".into(),
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
},
startup_timeout_secs: 30,
tool_timeout_secs: 60,
};
assert_eq!(config.name, "remote-server");
assert_eq!(config.startup_timeout(), Duration::from_secs(30));
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
}
#[test]
fn serde_round_trip_stdio() {
let config = McpServerConfig {
name: "fs".into(),
transport: McpTransport::Stdio {
command: vec!["node".into(), "server.js".into()],
env: HashMap::from([("NODE_ENV".into(), "production".into())]),
},
startup_timeout_secs: 15,
tool_timeout_secs: 90,
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "fs");
assert_eq!(deserialized.startup_timeout_secs, 15);
assert_eq!(deserialized.tool_timeout_secs, 90);
assert!(
matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"])
);
}
#[test]
fn serde_round_trip_http() {
let config = McpServerConfig {
name: "remote".into(),
transport: McpTransport::Http {
url: "https://mcp.example.com".into(),
headers: HashMap::new(),
},
startup_timeout_secs: 10,
tool_timeout_secs: 60,
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "remote");
assert!(
matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com")
);
}
#[test]
fn serde_defaults_applied() {
let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.startup_timeout_secs, 10);
assert_eq!(config.tool_timeout_secs, 60);
}
}

View file

@ -0,0 +1,189 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub mod cli;
pub mod hook;
pub mod mcp;
pub mod project;
pub mod run;
pub mod sandbox;
pub mod server;
pub use cli::{
ClientTlsSettings, ExecSettings, ExecutionMode, OutputFormat, PermissionLevel, ServerSettings,
};
pub use hook::{HookConfig, HookDefinition, HookEvent, HookType, TlsMode};
pub use mcp::{
default_startup_timeout_secs, default_tool_timeout_secs, McpServerConfig, McpServerEntry,
McpTransport,
};
pub use project::ProjectFabroSettings;
pub use run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
#[cfg(feature = "exedev")]
pub use sandbox::ExeSettings;
pub use sandbox::{
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
LocalSandboxSettings, SandboxSettings, SshSettings, WorktreeMode,
};
pub use server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
GitProvider, GitSettings, LogSettings, TlsSettings, WebSettings, WebhookSettings,
WebhookStrategy,
};
fn is_default_checkpoint(c: &CheckpointSettings) -> bool {
c.exclude_globs.is_empty()
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct FabroSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<SetupSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vars: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
pub checkpoint: CheckpointSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetsSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ExecutionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_retro: Option<bool>,
#[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")]
pub storage_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<LogSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabro: Option<ProjectFabroSettings>,
}
impl FabroSettings {
pub fn app_id(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.app_id.as_deref())
}
pub fn slug(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.slug.as_deref())
}
pub fn client_id(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.client_id.as_deref())
}
pub fn git_author(&self) -> Option<&GitAuthorSettings> {
self.git.as_ref().map(|g| &g.author)
}
pub fn sandbox_settings(&self) -> Option<&SandboxSettings> {
self.sandbox.as_ref()
}
pub fn setup_settings(&self) -> Option<&SetupSettings> {
self.setup.as_ref()
}
pub fn setup_commands(&self) -> &[String] {
self.setup
.as_ref()
.map(|setup| setup.commands.as_slice())
.unwrap_or(&[])
}
pub fn setup_timeout_ms(&self) -> Option<u64> {
self.setup.as_ref().and_then(|setup| setup.timeout_ms)
}
pub fn preserve_sandbox_enabled(&self) -> bool {
self.sandbox
.as_ref()
.and_then(|sandbox| sandbox.preserve)
.unwrap_or(false)
}
pub fn github_permissions(&self) -> Option<&HashMap<String, String>> {
self.github
.as_ref()
.and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions))
}
pub fn mcp_server_entries(&self) -> &HashMap<String, McpServerEntry> {
&self.mcp_servers
}
pub fn verbose_enabled(&self) -> bool {
self.verbose.unwrap_or(false)
}
pub fn prevent_idle_sleep_enabled(&self) -> bool {
self.prevent_idle_sleep.unwrap_or(false)
}
pub fn upgrade_check_enabled(&self) -> bool {
self.upgrade_check.unwrap_or(true)
}
pub fn dry_run_enabled(&self) -> bool {
self.dry_run.unwrap_or(false)
}
pub fn auto_approve_enabled(&self) -> bool {
self.auto_approve.unwrap_or(false)
}
pub fn no_retro_enabled(&self) -> bool {
self.no_retro.unwrap_or(false)
}
}

View file

@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
fn default_root() -> String {
".".to_string()
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ProjectFabroSettings {
#[serde(default = "default_root")]
pub root: String,
}
impl Default for ProjectFabroSettings {
fn default() -> Self {
Self {
root: default_root(),
}
}
}

View file

@ -0,0 +1,61 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct CheckpointSettings {
#[serde(default)]
pub exclude_globs: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct PullRequestSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub draft: bool,
#[serde(default)]
pub auto_merge: bool,
#[serde(default)]
pub merge_strategy: MergeStrategy,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "lowercase")]
pub enum MergeStrategy {
#[default]
Squash,
Merge,
Rebase,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct AssetsSettings {
#[serde(default)]
pub include: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct GitHubSettings {
#[serde(default)]
pub permissions: HashMap<String, String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LlmSettings {
pub model: Option<String>,
pub provider: Option<String>,
#[serde(default)]
pub fallbacks: Option<HashMap<String, Vec<String>>>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct SetupSettings {
#[serde(default)]
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}

View file

@ -0,0 +1,158 @@
use std::collections::HashMap;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
#[serde(default)]
pub skip_clone: bool,
}
#[derive(Clone, Debug, PartialEq, crate::Combine)]
pub enum DaytonaNetwork {
Block,
AllowAll,
AllowList(Vec<String>),
}
impl Serialize for DaytonaNetwork {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
DaytonaNetwork::Block => serializer.serialize_str("block"),
DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"),
DaytonaNetwork::AllowList(cidrs) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("allow_list", cidrs)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for DaytonaNetwork {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DaytonaNetworkVisitor;
impl<'de> Visitor<'de> for DaytonaNetworkVisitor {
type Value = DaytonaNetwork;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
r#""block", "allow_all", or {{ allow_list = [...] }}"#
)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
match value {
"block" => Ok(DaytonaNetwork::Block),
"allow_all" => Ok(DaytonaNetwork::AllowAll),
other => Err(de::Error::custom(format!(
"unknown network mode \"{other}\": expected \"block\" or \"allow_all\""
))),
}
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::custom(
"empty table: expected { allow_list = [...] }",
));
};
if key != "allow_list" {
return Err(de::Error::custom(format!(
"unknown key \"{key}\": expected \"allow_list\""
)));
}
let cidrs: Vec<String> = map.next_value()?;
if cidrs.is_empty() {
return Err(de::Error::custom("allow_list must not be empty"));
}
if let Some(extra) = map.next_key::<String>()? {
return Err(de::Error::custom(format!(
"unexpected key \"{extra}\": allow_list table must have exactly one key"
)));
}
Ok(DaytonaNetwork::AllowList(cidrs))
}
}
deserializer.deserialize_any(DaytonaNetworkVisitor)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, crate::Combine)]
#[serde(untagged)]
pub enum DockerfileSource {
Inline(String),
Path { path: String },
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSnapshotSettings {
pub name: String,
pub cpu: Option<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
#[cfg(feature = "exedev")]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ExeSettings {
pub image: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SshSettings {
pub destination: String,
pub working_directory: String,
pub config_file: Option<String>,
pub preview_url_base: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeMode {
Always,
#[default]
Clean,
Dirty,
Never,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LocalSandboxSettings {
#[serde(default)]
pub worktree_mode: WorktreeMode,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct SandboxSettings {
pub provider: Option<String>,
pub preserve: Option<bool>,
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxSettings>,
pub daytona: Option<DaytonaSettings>,
#[cfg(feature = "exedev")]
pub exe: Option<ExeSettings>,
pub ssh: Option<SshSettings>,
pub env: Option<HashMap<String, String>>,
}

View file

@ -0,0 +1,126 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
#[default]
Github,
InsecureDisabled,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct AuthSettings {
#[serde(default)]
pub provider: AuthProvider,
#[serde(default)]
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum ApiAuthStrategy {
Jwt,
Mtls,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct TlsSettings {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct ApiSettings {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub authentication_strategies: Vec<ApiAuthStrategy>,
pub tls: Option<TlsSettings>,
}
fn default_base_url() -> String {
"http://localhost:3000".to_string()
}
impl Default for ApiSettings {
fn default() -> Self {
Self {
base_url: default_base_url(),
authentication_strategies: Vec::new(),
tls: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum GitProvider {
#[default]
Github,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitAuthorSettings {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)]
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebhookSettings {
pub strategy: WebhookStrategy,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct GitSettings {
#[serde(default)]
pub provider: GitProvider,
pub app_id: Option<String>,
pub client_id: Option<String>,
pub slug: Option<String>,
#[serde(default)]
pub author: GitAuthorSettings,
pub webhooks: Option<WebhookSettings>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebSettings {
#[serde(default = "default_web_url")]
pub url: String,
#[serde(default)]
pub auth: AuthSettings,
}
fn default_web_url() -> String {
"http://localhost:5173".to_string()
}
impl Default for WebSettings {
fn default() -> Self {
Self {
url: default_web_url(),
auth: AuthSettings::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
pub struct FeaturesSettings {
#[serde(default)]
pub session_sandboxes: bool,
#[serde(default)]
pub retros: bool,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LogSettings {
pub level: Option<String>,
}

View file

@ -0,0 +1,12 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecord {
pub run_id: String,
pub start_time: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
}

View file

@ -0,0 +1,155 @@
use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Submitted,
Starting,
Running,
Paused,
Removing,
Succeeded,
Failed,
Dead,
}
impl RunStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Dead)
}
pub fn is_active(self) -> bool {
matches!(
self,
Self::Submitted | Self::Starting | Self::Running | Self::Paused | Self::Removing
)
}
pub fn can_transition_to(self, to: Self) -> bool {
if to == Self::Dead {
return true;
}
if self.is_terminal() {
return false;
}
matches!(
(self, to),
(Self::Submitted, Self::Starting)
| (Self::Starting, Self::Running)
| (Self::Starting, Self::Failed)
| (Self::Running, Self::Succeeded)
| (Self::Running, Self::Failed)
| (Self::Running, Self::Paused)
| (Self::Running, Self::Removing)
| (Self::Paused, Self::Running)
| (Self::Paused, Self::Failed)
| (Self::Paused, Self::Removing)
| (Self::Removing, Self::Failed)
)
}
pub fn transition_to(self, to: Self) -> Result<Self, InvalidTransition> {
if self.can_transition_to(to) {
Ok(to)
} else {
Err(InvalidTransition { from: self, to })
}
}
}
impl fmt::Display for RunStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Submitted => "submitted",
Self::Starting => "starting",
Self::Running => "running",
Self::Paused => "paused",
Self::Removing => "removing",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Dead => "dead",
};
f.write_str(s)
}
}
impl FromStr for RunStatus {
type Err = ParseRunStatusError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"submitted" => Ok(Self::Submitted),
"starting" => Ok(Self::Starting),
"running" => Ok(Self::Running),
"paused" => Ok(Self::Paused),
"removing" => Ok(Self::Removing),
"succeeded" => Ok(Self::Succeeded),
"failed" => Ok(Self::Failed),
"dead" => Ok(Self::Dead),
_ => Err(ParseRunStatusError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct ParseRunStatusError(String);
impl fmt::Display for ParseRunStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid run status: {:?}", self.0)
}
}
impl std::error::Error for ParseRunStatusError {}
#[derive(Debug, Clone, PartialEq)]
pub struct InvalidTransition {
pub from: RunStatus,
pub to: RunStatus,
}
impl fmt::Display for InvalidTransition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid status transition: {} -> {}", self.from, self.to)
}
}
impl std::error::Error for InvalidTransition {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StatusReason {
Completed,
PartialSuccess,
WorkflowError,
Cancelled,
Terminated,
TransientInfra,
BudgetExhausted,
LaunchFailed,
BootstrapFailed,
SandboxInitFailed,
SandboxInitializing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatusRecord {
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
pub updated_at: DateTime<Utc>,
}
impl RunStatusRecord {
pub fn new(status: RunStatus, reason: Option<StatusReason>) -> Self {
Self {
status,
reason,
updated_at: Utc::now(),
}
}
}

View file

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageUsage {
pub model: String,
pub input_tokens: i64,
pub output_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}

View file

@ -14,7 +14,7 @@ doctest = false
[features]
default = []
exedev = ["fabro-sandbox/exe", "fabro-config/exedev"]
exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-types/exedev"]
[dependencies]
anyhow.workspace = true
@ -35,6 +35,7 @@ fabro-llm = { path = "../fabro-llm" }
fabro-model = { path = "../fabro-model" }
fabro-retro = { path = "../fabro-retro" }
fabro-core = { path = "../fabro-core" }
fabro-types = { path = "../fabro-types" }
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -1,11 +1,10 @@
use std::fmt;
use fabro_llm::error::{ProviderErrorKind, SdkError};
use fabro_validate::Diagnostic;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub use fabro_core::outcome::FailureCategory;
pub use fabro_types::failure_signature::FailureSignature;
pub use fabro_types::outcome::FailureCategory;
/// Classify an `SdkError` into a `FailureCategory` based on its structure.
#[must_use]
@ -160,22 +159,17 @@ pub fn normalize_failure_reason(reason: &str) -> String {
}
}
/// Composite key that uniquely identifies a specific recurring failure.
///
/// Format: `node_id|failure_class|normalized_reason`
///
/// Used by circuit breakers to detect when the same failure keeps repeating,
/// e.g. "verify|deterministic|assertion failed in foo_test".
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FailureSignature(String);
pub trait FailureSignatureExt {
fn new(
node_id: &str,
failure_class: FailureCategory,
signature_hint: Option<&str>,
failure_reason: Option<&str>,
) -> Self;
}
impl FailureSignature {
/// Build a signature from failure context.
///
/// The signature hint from `outcome.context_updates["failure_signature"]` takes
/// priority over the raw `failure_reason`, allowing handlers to provide explicit
/// grouping keys.
pub fn new(
impl FailureSignatureExt for FailureSignature {
fn new(
node_id: &str,
failure_class: FailureCategory,
signature_hint: Option<&str>,
@ -191,12 +185,6 @@ impl FailureSignature {
}
}
impl fmt::Display for FailureSignature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum FabroError {

View file

@ -522,6 +522,8 @@ mod tests {
use super::*;
use std::fs;
use crate::records::{CheckpointExt, RunRecordExt};
/// Create a temporary git repo with an initial commit.
fn init_repo(dir: &Path) {
Command::new("git")

View file

@ -8,7 +8,7 @@ use fabro_core::lifecycle::{EdgeContext, EdgeDecision, RunLifecycle};
use fabro_core::outcome::NodeResult;
use fabro_core::state::RunState;
use crate::error::{FailureCategory, FailureSignature};
use crate::error::{FailureCategory, FailureSignature, FailureSignatureExt};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{OutcomeExt, StageStatus, StageUsage};

View file

@ -13,7 +13,7 @@ use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::StageUsage;
use crate::records::Checkpoint;
use crate::records::{Checkpoint, CheckpointExt};
use crate::run_dir::{write_node_status, write_start_record};
use crate::run_options::RunOptions;

View file

@ -13,7 +13,9 @@ use crate::artifact::ArtifactStore;
use crate::event::{EventEmitter, WorkflowRunEvent};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage};
use crate::outcome::{
stage_usage_to_llm, FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage,
};
use fabro_graphviz::graph::types::Node as GvNode;
type WfRunState = RunState<Option<StageUsage>>;
@ -302,7 +304,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let run_usage = state
.node_outcomes
.values()
.filter_map(|o| o.usage.as_ref().map(fabro_llm::types::Usage::from))
.filter_map(|o| o.usage.as_ref().map(stage_usage_to_llm))
.reduce(|a, b| a + b);
if outcome.status == StageStatus::Success || outcome.status == StageStatus::PartialSuccess {

View file

@ -14,6 +14,7 @@ use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{Outcome, StageStatus, StageUsage};
use crate::records::CheckpointExt;
use crate::run_dir::node_dir;
use crate::run_options::RunOptions;
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};

View file

@ -25,6 +25,7 @@ use fabro_core::state::RunState;
use crate::artifact::ArtifactStore;
use crate::context;
use crate::error::FailureSignatureExt;
use crate::event::EventEmitter;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{Local, Utc};
use fabro_config::FabroSettings;
use fabro_config::{FabroSettings, FabroSettingsExt};
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
@ -344,6 +344,8 @@ mod tests {
use super::*;
use fabro_graphviz::graph::AttrValue;
use crate::run_status::RunStatusRecordExt;
fn validate_dot(dot_source: &str, settings: FabroSettings) -> Validated {
validate(ValidateInput {
workflow: WorkflowInput::DotSource {

View file

@ -22,9 +22,9 @@ use crate::pipeline::{
FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions, RetroOptions,
SandboxEnvSpec, SandboxSpec,
};
use crate::records::{Checkpoint, Conclusion};
use crate::records::{Checkpoint, CheckpointExt, Conclusion, ConclusionExt, RunRecordExt};
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{self, RunStatus, StatusReason};
use crate::run_status::{self, RunStatus, RunStatusRecordExt, StatusReason};
struct StartRetroOptions {
enabled: bool,

View file

@ -1,73 +1,31 @@
use serde::{Deserialize, Serialize};
pub use fabro_core::outcome::{FailureCategory, FailureDetail, OutcomeMeta, StageStatus};
pub use fabro_types::usage::StageUsage;
use crate::error::classify_failure_reason;
/// Token usage from a single pipeline stage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageUsage {
pub model: String,
pub input_tokens: i64,
pub output_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}
impl From<&StageUsage> for fabro_llm::types::Usage {
fn from(u: &StageUsage) -> Self {
Self {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
total_tokens: u.input_tokens + u.output_tokens,
cache_read_tokens: u.cache_read_tokens,
cache_write_tokens: u.cache_write_tokens,
reasoning_tokens: u.reasoning_tokens,
speed: u.speed.clone(),
raw: None,
}
pub fn stage_usage_to_llm(u: &StageUsage) -> fabro_llm::types::Usage {
fabro_llm::types::Usage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
total_tokens: u.input_tokens + u.output_tokens,
cache_read_tokens: u.cache_read_tokens,
cache_write_tokens: u.cache_write_tokens,
reasoning_tokens: u.reasoning_tokens,
speed: u.speed.clone(),
raw: None,
}
}
/// The workflow-specific Outcome type, parameterized with optional stage usage.
pub type Outcome = fabro_core::Outcome<Option<StageUsage>>;
/// Extension trait for workflow-specific Outcome factory methods and accessors.
pub trait OutcomeExt: Sized {
/// Create a failed outcome with a deterministic failure category.
fn fail_deterministic(reason: impl Into<String>) -> Self;
/// Create a failed outcome with the failure category inferred from the message via heuristics.
fn fail_classify(reason: impl Into<String>) -> Self;
/// Create a retry outcome with the failure category inferred from the message via heuristics.
fn retry_classify(reason: impl Into<String>) -> Self;
/// Create a simulated success outcome for dry-run mode.
fn simulated(node_id: &str) -> Self;
/// Set the failure signature on this outcome. Returns self for chaining.
fn with_signature(self, sig: Option<impl Into<String>>) -> Self;
/// Get the failure reason message, if any.
fn failure_reason(&self) -> Option<&str>;
/// Get the failure category, if this is a failed outcome.
fn failure_category(&self) -> Option<FailureCategory>;
/// Resolve the effective failure category for this outcome.
///
/// Returns `None` for success, partial success, and skipped outcomes.
/// Failed and retry outcomes default to `Deterministic` when no
/// structured failure category is present.
fn classified_failure_category(&self) -> Option<FailureCategory>;
}
@ -132,7 +90,6 @@ impl OutcomeExt for Outcome {
}
}
/// Compute the dollar cost for a stage's token usage, if pricing is available.
#[must_use]
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
let info = fabro_model::Catalog::builtin().get(&usage.model)?;
@ -150,381 +107,7 @@ pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
)
}
/// Format a dollar cost for display (e.g. `"$1.23"`).
#[must_use]
pub fn format_cost(cost: f64) -> String {
format!("${cost:.2}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_status_display() {
assert_eq!(StageStatus::Success.to_string(), "success");
assert_eq!(StageStatus::Fail.to_string(), "fail");
assert_eq!(StageStatus::PartialSuccess.to_string(), "partial_success");
assert_eq!(StageStatus::Retry.to_string(), "retry");
assert_eq!(StageStatus::Skipped.to_string(), "skipped");
}
#[test]
fn stage_status_from_str() {
assert_eq!(
"success".parse::<StageStatus>().unwrap(),
StageStatus::Success
);
assert_eq!("fail".parse::<StageStatus>().unwrap(), StageStatus::Fail);
assert_eq!(
"partial_success".parse::<StageStatus>().unwrap(),
StageStatus::PartialSuccess
);
assert_eq!("retry".parse::<StageStatus>().unwrap(), StageStatus::Retry);
assert_eq!(
"skipped".parse::<StageStatus>().unwrap(),
StageStatus::Skipped
);
}
#[test]
fn stage_status_from_str_invalid() {
assert!("unknown".parse::<StageStatus>().is_err());
}
#[test]
fn outcome_success_factory() {
let o = Outcome::success();
assert_eq!(o.status, StageStatus::Success);
assert!(o.preferred_label.is_none());
assert!(o.suggested_next_ids.is_empty());
assert!(o.context_updates.is_empty());
assert!(o.notes.is_none());
assert!(o.failure.is_none());
}
#[test]
fn outcome_fail_deterministic_factory() {
let o = Outcome::fail_deterministic("something broke");
assert_eq!(o.status, StageStatus::Fail);
assert_eq!(o.failure_reason(), Some("something broke"));
assert_eq!(o.failure_category(), Some(FailureCategory::Deterministic));
}
#[test]
fn outcome_fail_classify_factory() {
let o = Outcome::fail_classify("connection refused");
assert_eq!(o.status, StageStatus::Fail);
assert_eq!(o.failure_reason(), Some("connection refused"));
assert_eq!(o.failure_category(), Some(FailureCategory::TransientInfra));
}
#[test]
fn outcome_retry_classify_factory() {
let o = Outcome::retry_classify("try again");
assert_eq!(o.status, StageStatus::Retry);
assert_eq!(o.failure_reason(), Some("try again"));
}
#[test]
fn outcome_skipped_factory() {
let o = Outcome::skipped("");
assert_eq!(o.status, StageStatus::Skipped);
assert!(o.failure.is_none());
}
#[test]
fn failure_detail_construction() {
let fd = FailureDetail::new("timeout", FailureCategory::TransientInfra);
assert_eq!(fd.message, "timeout");
assert_eq!(fd.category, FailureCategory::TransientInfra);
assert!(fd.signature.is_none());
}
#[test]
fn failure_detail_serde_roundtrip() {
let fd = FailureDetail {
message: "timeout".into(),
category: FailureCategory::TransientInfra,
signature: Some("sig".into()),
};
let json = serde_json::to_string(&fd).unwrap();
let deserialized: FailureDetail = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.message, "timeout");
assert_eq!(deserialized.category, FailureCategory::TransientInfra);
assert_eq!(deserialized.signature.as_deref(), Some("sig"));
}
#[test]
fn fail_classify_known_patterns() {
assert_eq!(
Outcome::fail_classify("timeout").failure_category(),
Some(FailureCategory::TransientInfra)
);
assert_eq!(
Outcome::fail_classify("context length exceeded").failure_category(),
Some(FailureCategory::BudgetExhausted)
);
assert_eq!(
Outcome::fail_classify("cancel").failure_category(),
Some(FailureCategory::Canceled)
);
}
#[test]
fn failure_field_is_some_for_failures() {
assert!(Outcome::fail_deterministic("x").failure.is_some());
}
#[test]
fn failure_field_is_none_for_success() {
assert!(Outcome::success().failure.is_none());
}
#[test]
fn with_signature_builder() {
let o = Outcome::fail_deterministic("x").with_signature(Some("sig"));
assert_eq!(
o.failure.as_ref().unwrap().signature.as_deref(),
Some("sig")
);
}
#[test]
fn classified_failure_category_returns_none_for_success() {
assert!(Outcome::success().classified_failure_category().is_none());
}
#[test]
fn classified_failure_category_returns_none_for_skipped() {
assert!(Outcome::skipped("").classified_failure_category().is_none());
}
#[test]
fn classified_failure_category_returns_none_for_partial_success() {
let outcome = Outcome {
status: StageStatus::PartialSuccess,
..Outcome::success()
};
assert!(outcome.classified_failure_category().is_none());
}
#[test]
fn classified_failure_category_reads_failure_detail() {
let mut outcome = Outcome::fail_classify("some error");
outcome.failure.as_mut().unwrap().category = FailureCategory::BudgetExhausted;
assert_eq!(
outcome.classified_failure_category(),
Some(FailureCategory::BudgetExhausted)
);
}
#[test]
fn classified_failure_category_uses_failure_reason_heuristics() {
let outcome = Outcome::fail_classify("rate limited by provider");
assert_eq!(
outcome.classified_failure_category(),
Some(FailureCategory::TransientInfra)
);
}
#[test]
fn classified_failure_category_defaults_to_deterministic() {
let outcome = Outcome::fail_classify("something went wrong");
assert_eq!(
outcome.classified_failure_category(),
Some(FailureCategory::Deterministic)
);
}
#[test]
fn classified_failure_category_fail_no_reason_is_deterministic() {
let outcome = Outcome {
status: StageStatus::Fail,
failure: None,
..Outcome::success()
};
assert_eq!(
outcome.classified_failure_category(),
Some(FailureCategory::Deterministic)
);
}
#[test]
fn classified_failure_category_retry_status_uses_heuristics() {
let outcome = Outcome::retry_classify("connection refused");
assert_eq!(
outcome.classified_failure_category(),
Some(FailureCategory::TransientInfra)
);
}
#[test]
fn stage_usage_serialization_with_cache_and_reasoning() {
let usage = StageUsage {
model: "claude-opus-4-6".to_string(),
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: Some(800),
cache_write_tokens: Some(50),
reasoning_tokens: Some(100),
speed: None,
cost: None,
};
let json = serde_json::to_string(&usage).unwrap();
assert!(json.contains("\"cache_read_tokens\":800"));
assert!(json.contains("\"reasoning_tokens\":100"));
let deserialized: StageUsage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.cache_read_tokens, Some(800));
assert_eq!(deserialized.reasoning_tokens, Some(100));
}
#[test]
fn stage_usage_serialization_omits_none_optional_fields() {
let usage = StageUsage {
model: "test-model".to_string(),
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
};
let json = serde_json::to_string(&usage).unwrap();
assert!(!json.contains("cache_read_tokens"));
assert!(!json.contains("reasoning_tokens"));
}
#[test]
fn outcome_files_touched_serialization() {
let mut o = Outcome::success();
o.files_touched = vec!["src/main.rs".to_string(), "README.md".to_string()];
let json = serde_json::to_string(&o).unwrap();
assert!(json.contains("files_touched"));
assert!(json.contains("src/main.rs"));
let deserialized: Outcome = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.files_touched.len(), 2);
}
#[test]
fn outcome_empty_files_touched_omitted() {
let o = Outcome::success();
let json = serde_json::to_string(&o).unwrap();
assert!(!json.contains("files_touched"));
}
#[test]
fn outcome_serialization_roundtrip() {
let mut o = Outcome::success();
o.notes = Some("done".to_string());
o.context_updates
.insert("key".to_string(), serde_json::json!("val"));
let json = serde_json::to_string(&o).unwrap();
let deserialized: Outcome = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.status, StageStatus::Success);
assert_eq!(deserialized.notes.as_deref(), Some("done"));
assert_eq!(
deserialized.context_updates.get("key"),
Some(&serde_json::json!("val"))
);
}
#[test]
fn stage_status_serde_roundtrip() {
let json = serde_json::to_string(&StageStatus::PartialSuccess).unwrap();
assert_eq!(json, "\"partial_success\"");
let parsed: StageStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, StageStatus::PartialSuccess);
}
#[test]
fn outcome_simulated_factory() {
let o = Outcome::simulated("my_node");
assert_eq!(o.status, StageStatus::Success);
assert_eq!(o.notes.as_deref(), Some("[Simulated] my_node"));
assert!(o.failure.is_none());
assert!(o.context_updates.is_empty());
}
#[test]
fn format_cost_zero() {
assert_eq!(format_cost(0.0), "$0.00");
}
#[test]
fn format_cost_normal() {
assert_eq!(format_cost(1.5), "$1.50");
}
#[test]
fn format_cost_rounds() {
assert_eq!(format_cost(123.456), "$123.46");
}
#[test]
fn compute_stage_cost_known_model() {
let usage = StageUsage {
model: "claude-sonnet-4-5".into(),
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
};
let cost = compute_stage_cost(&usage);
assert!(cost.is_some());
assert!(cost.unwrap() > 0.0);
}
#[test]
fn compute_stage_cost_unknown_model() {
let usage = StageUsage {
model: "nonexistent-model-xyz".into(),
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
};
assert_eq!(compute_stage_cost(&usage), None);
}
#[test]
fn compute_stage_cost_fast_mode_6x_multiplier() {
let standard_usage = StageUsage {
model: "claude-sonnet-4-5".into(),
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
};
let fast_usage = StageUsage {
model: "claude-sonnet-4-5".into(),
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: Some("fast".into()),
cost: None,
};
let standard_cost = compute_stage_cost(&standard_usage).unwrap();
let fast_cost = compute_stage_cost(&fast_usage).unwrap();
assert!(
(fast_cost - standard_cost * 6.0).abs() < 1e-10,
"fast mode should be 6x standard cost: standard={standard_cost}, fast={fast_cost}"
);
}
}

View file

@ -21,7 +21,7 @@ use crate::handler::{Handler as HandlerTrait, HandlerRegistry};
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::pipeline::initialize;
use crate::pipeline::types::{InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec};
use crate::records::{Checkpoint, RunRecord};
use crate::records::{Checkpoint, CheckpointExt, RunRecord, StartRecordExt};
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::test_support::run_graph;

View file

@ -4,8 +4,7 @@ use std::sync::Arc;
use crate::error::FabroError;
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::records::Checkpoint;
use crate::records::Conclusion;
use crate::records::{Checkpoint, CheckpointExt, Conclusion, ConclusionExt};
use crate::run_options::RunOptions;
use crate::run_status::{RunStatus, StatusReason};
use fabro_hooks::{HookContext, HookEvent, HookRunner};

View file

@ -8,8 +8,8 @@ use fabro_config::sandbox::WorktreeMode;
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_llm::client::Client;
use fabro_sandbox::{
DockerSandbox, LocalSandbox, ReadBeforeWriteSandbox, SandboxRecord, WorktreeConfig,
WorktreeSandbox,
DockerSandbox, LocalSandbox, ReadBeforeWriteSandbox, SandboxRecord, SandboxRecordExt,
WorktreeConfig, WorktreeSandbox,
};
use shlex::try_quote;

View file

@ -1,6 +1,7 @@
use std::path::Path;
use crate::error::FabroError;
use crate::records::RunRecordExt;
use super::types::{PersistOptions, Persisted, Validated};

View file

@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use fabro_github::{self as github_app, ssh_url_to_https, GitHubAppCredentials};
use fabro_retro::RetroExt;
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::outcome::StageStatus;
use crate::records::{Conclusion, RunRecord};
use crate::records::{Conclusion, ConclusionExt, RunRecord, RunRecordExt};
use fabro_retro::retro::Retro;
use super::types::{Concluded, Finalized, PullRequestOptions};

View file

@ -2,9 +2,10 @@ use std::sync::Arc;
use fabro_agent::SessionEvent;
use fabro_retro::retro::Retro;
use fabro_retro::RetroExt;
use crate::event::WorkflowRunEvent;
use crate::records::Checkpoint;
use crate::records::{Checkpoint, CheckpointExt};
use super::types::{Executed, RetroOptions, Retroed};

View file

@ -1,46 +1,35 @@
use std::collections::HashMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use fabro_types::checkpoint::Checkpoint;
use crate::context::Context;
use crate::error::{FailureSignature, Result};
use crate::error::FailureSignature;
use crate::outcome::Outcome;
/// Serializable snapshot of execution state for crash recovery and resume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub timestamp: DateTime<Utc>,
pub current_node: String,
pub completed_nodes: Vec<String>,
pub node_retries: HashMap<String, u32>,
pub context_values: HashMap<String, Value>,
/// Persisted node outcomes for goal gate checks after resume.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_outcomes: HashMap<String, Outcome>,
/// The node to resume execution at (the next node after the checkpoint's `current_node`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
/// SHA of the git commit created at this checkpoint (when running in a worktree).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
/// Failure signature counts within the main loop (deterministic/structural failures).
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub loop_failure_signatures: HashMap<FailureSignature, usize>,
/// Failure signature counts across loop_restart edges.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub restart_failure_signatures: HashMap<FailureSignature, usize>,
/// Per-node visit counts persisted for accurate resume.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_visits: HashMap<String, usize>,
pub trait CheckpointExt {
#[allow(clippy::too_many_arguments)]
fn from_context(
context: &Context,
current_node: impl Into<String>,
completed_nodes: Vec<String>,
node_retries: HashMap<String, u32>,
node_outcomes: HashMap<String, Outcome>,
next_node_id: Option<String>,
loop_failure_signatures: HashMap<FailureSignature, usize>,
restart_failure_signatures: HashMap<FailureSignature, usize>,
node_visits: HashMap<String, usize>,
) -> Self
where
Self: Sized;
fn save(&self, path: &Path) -> crate::error::Result<()>;
fn load(path: &Path) -> crate::error::Result<Self>
where
Self: Sized;
}
impl Checkpoint {
/// Create a checkpoint from the current execution state.
#[allow(clippy::too_many_arguments)]
pub fn from_context(
impl CheckpointExt for Checkpoint {
fn from_context(
context: &Context,
current_node: impl Into<String>,
completed_nodes: Vec<String>,
@ -52,7 +41,7 @@ impl Checkpoint {
node_visits: HashMap<String, usize>,
) -> Self {
Self {
timestamp: Utc::now(),
timestamp: chrono::Utc::now(),
current_node: current_node.into(),
completed_nodes,
node_retries,
@ -66,238 +55,13 @@ impl Checkpoint {
}
}
/// Save the checkpoint as JSON to a file.
///
/// # Errors
///
/// Returns an error if serialization or file writing fails.
pub fn save(&self, path: &Path) -> Result<()> {
fn save(&self, path: &Path) -> crate::error::Result<()> {
tracing::debug!(path = %path.display(), node = %self.current_node, "Saving checkpoint");
crate::save_json(self, path, "checkpoint")
}
/// Load a checkpoint from a JSON file.
///
/// # Errors
///
/// Returns an error if the file cannot be read or deserialization fails.
pub fn load(path: &Path) -> Result<Self> {
fn load(path: &Path) -> crate::error::Result<Self> {
tracing::debug!(path = %path.display(), "Loading checkpoint");
crate::load_json(path, "checkpoint")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_context_captures_state() {
let ctx = Context::new();
ctx.set("key", serde_json::json!("value"));
let cp = Checkpoint::from_context(
&ctx,
"node_a",
vec!["start".to_string(), "node_a".to_string()],
HashMap::new(),
HashMap::new(),
None,
HashMap::new(),
HashMap::new(),
HashMap::new(),
);
assert_eq!(cp.current_node, "node_a");
assert_eq!(cp.completed_nodes.len(), 2);
assert_eq!(cp.completed_nodes[0], "start");
assert_eq!(cp.completed_nodes[1], "node_a");
assert_eq!(
cp.context_values.get("key"),
Some(&serde_json::json!("value"))
);
assert!(cp.node_retries.is_empty());
assert!(cp.node_outcomes.is_empty());
assert!(cp.next_node_id.is_none());
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint.json");
let ctx = Context::new();
ctx.set("goal", serde_json::json!("test"));
let mut retries = HashMap::new();
retries.insert("work".to_string(), 2u32);
let mut outcomes = HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
let cp = Checkpoint::from_context(
&ctx,
"work",
vec!["start".to_string()],
retries,
outcomes,
Some("next_step".to_string()),
HashMap::new(),
HashMap::new(),
HashMap::new(),
);
cp.save(&path).unwrap();
let loaded = Checkpoint::load(&path).unwrap();
assert_eq!(loaded.current_node, "work");
assert_eq!(loaded.completed_nodes, vec!["start"]);
assert_eq!(loaded.node_retries.get("work"), Some(&2));
assert_eq!(
loaded.context_values.get("goal"),
Some(&serde_json::json!("test"))
);
assert_eq!(
loaded.node_outcomes.get("start").map(|o| &o.status),
Some(&crate::outcome::StageStatus::Success)
);
assert_eq!(loaded.next_node_id.as_deref(), Some("next_step"));
}
#[test]
fn load_nonexistent_file() {
let result = Checkpoint::load(Path::new("/nonexistent/checkpoint.json"));
assert!(result.is_err());
}
#[test]
fn load_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json").unwrap();
let result = Checkpoint::load(&path);
assert!(result.is_err());
}
#[test]
fn serialization_roundtrip() {
let ctx = Context::new();
let cp = Checkpoint::from_context(
&ctx,
"n1",
vec![],
HashMap::new(),
HashMap::new(),
None,
HashMap::new(),
HashMap::new(),
HashMap::new(),
);
let json = serde_json::to_string(&cp).unwrap();
let deserialized: Checkpoint = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.current_node, "n1");
}
#[test]
fn signature_maps_roundtrip() {
use crate::error::{FailureCategory, FailureSignature};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint.json");
let ctx = Context::new();
let mut loop_sigs = HashMap::new();
loop_sigs.insert(
FailureSignature::new(
"verify",
FailureCategory::Deterministic,
None,
Some("test failed"),
),
2,
);
let mut restart_sigs = HashMap::new();
restart_sigs.insert(
FailureSignature::new(
"build",
FailureCategory::Structural,
None,
Some("scope error"),
),
1,
);
let cp = Checkpoint::from_context(
&ctx,
"verify",
vec![],
HashMap::new(),
HashMap::new(),
None,
loop_sigs,
restart_sigs,
HashMap::new(),
);
cp.save(&path).unwrap();
let loaded = Checkpoint::load(&path).unwrap();
assert_eq!(loaded.loop_failure_signatures.len(), 1);
assert_eq!(loaded.restart_failure_signatures.len(), 1);
let sig = FailureSignature::new(
"verify",
FailureCategory::Deterministic,
None,
Some("test failed"),
);
assert_eq!(loaded.loop_failure_signatures.get(&sig), Some(&2));
}
#[test]
fn backward_compat_missing_signature_fields() {
// A checkpoint saved before signatures were added should deserialize with empty maps
let json = r#"{
"timestamp": "2025-01-01T00:00:00Z",
"current_node": "work",
"completed_nodes": ["start"],
"node_retries": {},
"context_values": {},
"logs": []
}"#;
let cp: Checkpoint = serde_json::from_str(json).unwrap();
assert!(cp.loop_failure_signatures.is_empty());
assert!(cp.restart_failure_signatures.is_empty());
}
#[test]
fn backward_compat_old_checkpoint_with_logs_ignored() {
// Old checkpoints that contain a `logs` field should deserialize fine (field is ignored)
let json = r#"{
"timestamp": "2025-01-01T00:00:00Z",
"current_node": "work",
"completed_nodes": ["start"],
"node_retries": {},
"context_values": {},
"logs": ["old entry 1", "old entry 2"]
}"#;
let cp: Checkpoint = serde_json::from_str(json).unwrap();
assert_eq!(cp.current_node, "work");
}
#[test]
fn new_checkpoint_does_not_serialize_logs() {
let ctx = Context::new();
let cp = Checkpoint::from_context(
&ctx,
"n1",
vec![],
HashMap::new(),
HashMap::new(),
None,
HashMap::new(),
HashMap::new(),
HashMap::new(),
);
let json = serde_json::to_string(&cp).unwrap();
assert!(!json.contains("\"logs\""));
}
}

View file

@ -1,206 +1,20 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use fabro_types::conclusion::{Conclusion, StageSummary};
use crate::error::Result;
use crate::outcome::StageStatus;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub stage_id: String,
pub stage_label: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
pub retries: u32,
pub trait ConclusionExt {
fn save(&self, path: &Path) -> crate::error::Result<()>;
fn load(path: &Path) -> crate::error::Result<Self>
where
Self: Sized;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conclusion {
pub timestamp: DateTime<Utc>,
pub status: StageStatus,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<StageSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
#[serde(default)]
pub total_retries: u32,
#[serde(default)]
pub total_input_tokens: i64,
#[serde(default)]
pub total_output_tokens: i64,
#[serde(default)]
pub total_cache_read_tokens: i64,
#[serde(default)]
pub total_cache_write_tokens: i64,
#[serde(default)]
pub total_reasoning_tokens: i64,
#[serde(default)]
pub has_pricing: bool,
}
impl Conclusion {
pub fn save(&self, path: &Path) -> Result<()> {
impl ConclusionExt for Conclusion {
fn save(&self, path: &Path) -> crate::error::Result<()> {
crate::save_json(self, path, "conclusion")
}
pub fn load(path: &Path) -> Result<Self> {
fn load(path: &Path) -> crate::error::Result<Self> {
crate::load_json(path, "conclusion")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_conclusion() -> Conclusion {
Conclusion {
timestamp: Utc::now(),
status: crate::outcome::StageStatus::Success,
duration_ms: 12345,
failure_reason: None,
final_git_commit_sha: Some("deadbeef".to_string()),
stages: vec![
StageSummary {
stage_id: "plan".to_string(),
stage_label: "plan".to_string(),
duration_ms: 5000,
cost: Some(0.05),
retries: 0,
},
StageSummary {
stage_id: "code".to_string(),
stage_label: "code".to_string(),
duration_ms: 7345,
cost: Some(0.10),
retries: 1,
},
],
total_cost: Some(0.15),
total_retries: 1,
total_input_tokens: 5000,
total_output_tokens: 1500,
total_cache_read_tokens: 2000,
total_cache_write_tokens: 500,
total_reasoning_tokens: 300,
has_pricing: true,
}
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("conclusion.json");
let conclusion = sample_conclusion();
conclusion.save(&path).unwrap();
let loaded = Conclusion::load(&path).unwrap();
assert_eq!(loaded.status, crate::outcome::StageStatus::Success);
assert_eq!(loaded.duration_ms, 12345);
assert!(loaded.failure_reason.is_none());
assert_eq!(loaded.final_git_commit_sha.as_deref(), Some("deadbeef"));
assert_eq!(loaded.stages.len(), 2);
assert_eq!(loaded.stages[0].stage_id, "plan");
assert_eq!(loaded.stages[0].duration_ms, 5000);
assert!((loaded.stages[0].cost.unwrap() - 0.05).abs() < f64::EPSILON);
assert_eq!(loaded.stages[1].retries, 1);
assert!((loaded.total_cost.unwrap() - 0.15).abs() < f64::EPSILON);
assert_eq!(loaded.total_retries, 1);
}
#[test]
fn load_nonexistent_file() {
let result = Conclusion::load(Path::new("/nonexistent/conclusion.json"));
assert!(result.is_err());
}
#[test]
fn load_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json").unwrap();
let result = Conclusion::load(&path);
assert!(result.is_err());
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("conclusion.json");
let conclusion = Conclusion {
timestamp: Utc::now(),
status: crate::outcome::StageStatus::Fail,
duration_ms: 500,
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
conclusion.save(&path).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(raw.get("failure_reason").is_none());
assert!(raw.get("final_git_commit_sha").is_none());
assert!(raw.get("stages").is_none());
assert!(raw.get("total_cost").is_none());
}
#[test]
fn failure_reason_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("conclusion.json");
let conclusion = Conclusion {
timestamp: Utc::now(),
status: crate::outcome::StageStatus::Fail,
duration_ms: 100,
failure_reason: Some("timeout".to_string()),
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
conclusion.save(&path).unwrap();
let loaded = Conclusion::load(&path).unwrap();
assert_eq!(loaded.failure_reason.as_deref(), Some("timeout"));
}
#[test]
fn backward_compat_old_json_without_new_fields() {
let json = r#"{
"timestamp": "2025-01-01T00:00:00Z",
"status": "success",
"duration_ms": 5000,
"final_git_commit_sha": "abc123"
}"#;
let loaded: Conclusion = serde_json::from_str(json).unwrap();
assert_eq!(loaded.duration_ms, 5000);
assert!(loaded.stages.is_empty());
assert!(loaded.total_cost.is_none());
assert_eq!(loaded.total_retries, 0);
}
}

View file

@ -3,7 +3,7 @@ mod conclusion;
mod run;
mod start;
pub use checkpoint::Checkpoint;
pub use conclusion::{Conclusion, StageSummary};
pub use run::RunRecord;
pub use start::StartRecord;
pub use checkpoint::{Checkpoint, CheckpointExt};
pub use conclusion::{Conclusion, ConclusionExt, StageSummary};
pub use run::{RunRecord, RunRecordExt};
pub use start::{StartRecord, StartRecordExt};

View file

@ -1,45 +1,37 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use chrono::{DateTime, Utc};
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use serde::{Deserialize, Serialize};
pub use fabro_types::run::RunRecord;
const FILE_NAME: &str = "run.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub created_at: DateTime<Utc>,
pub settings: FabroSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
pub working_directory: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
pub trait RunRecordExt {
fn file_name() -> &'static str
where
Self: Sized;
fn save(&self, run_dir: &Path) -> crate::error::Result<()>;
fn load(run_dir: &Path) -> crate::error::Result<Self>
where
Self: Sized;
fn workflow_name(&self) -> &str;
fn goal(&self) -> &str;
fn node_count(&self) -> usize;
fn edge_count(&self) -> usize;
}
impl RunRecord {
pub fn file_name() -> &'static str {
impl RunRecordExt for RunRecord {
fn file_name() -> &'static str {
FILE_NAME
}
pub fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
crate::save_json(self, &run_dir.join(FILE_NAME), "run record")
}
pub fn load(run_dir: &Path) -> crate::error::Result<Self> {
fn load(run_dir: &Path) -> crate::error::Result<Self> {
crate::load_json(&run_dir.join(FILE_NAME), "run record")
}
/// Workflow name derived from the graph.
pub fn workflow_name(&self) -> &str {
fn workflow_name(&self) -> &str {
if self.graph.name.is_empty() {
"unnamed"
} else {
@ -47,78 +39,15 @@ impl RunRecord {
}
}
/// Goal derived from the graph.
pub fn goal(&self) -> &str {
fn goal(&self) -> &str {
self.graph.goal()
}
pub fn node_count(&self) -> usize {
fn node_count(&self) -> usize {
self.graph.nodes.len()
}
pub fn edge_count(&self) -> usize {
fn edge_count(&self) -> usize {
self.graph.edges.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record() -> RunRecord {
let graph = Graph {
name: "test_pipeline".to_string(),
..Default::default()
};
RunRecord {
run_id: "run-abc123".to_string(),
created_at: Utc::now(),
settings: FabroSettings::default(),
graph,
workflow_slug: Some("smoke".to_string()),
working_directory: PathBuf::from("/home/user/project"),
host_repo_path: Some("/home/user/project".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("env".into(), "test".into())]),
}
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let record = sample_record();
record.save(dir.path()).unwrap();
let loaded = RunRecord::load(dir.path()).unwrap();
assert_eq!(loaded.run_id, "run-abc123");
assert_eq!(loaded.workflow_name(), "test_pipeline");
assert_eq!(loaded.workflow_slug.as_deref(), Some("smoke"));
assert_eq!(loaded.labels.get("env").map(String::as_str), Some("test"));
}
#[test]
fn load_nonexistent() {
let dir = PathBuf::from("/tmp/nonexistent-run-record-dir-that-does-not-exist");
assert!(RunRecord::load(&dir).is_err());
}
#[test]
fn labels_omitted_when_empty() {
let dir = tempfile::tempdir().unwrap();
let mut record = sample_record();
record.labels = HashMap::new();
record.host_repo_path = None;
record.base_branch = None;
record.workflow_slug = None;
record.save(dir.path()).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.path().join("run.json")).unwrap())
.unwrap();
assert!(raw.get("labels").is_none());
assert!(raw.get("host_repo_path").is_none());
assert!(raw.get("base_branch").is_none());
assert!(raw.get("workflow_slug").is_none());
}
}

View file

@ -1,78 +1,29 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use fabro_types::start::StartRecord;
const FILE_NAME: &str = "start.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecord {
pub run_id: String,
pub start_time: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
pub trait StartRecordExt {
fn file_name() -> &'static str
where
Self: Sized;
fn save(&self, run_dir: &Path) -> crate::error::Result<()>;
fn load(run_dir: &Path) -> crate::error::Result<Self>
where
Self: Sized;
}
impl StartRecord {
pub fn file_name() -> &'static str {
impl StartRecordExt for StartRecord {
fn file_name() -> &'static str {
FILE_NAME
}
pub fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
crate::save_json(self, &run_dir.join(FILE_NAME), "start record")
}
pub fn load(run_dir: &Path) -> crate::error::Result<Self> {
fn load(run_dir: &Path) -> crate::error::Result<Self> {
crate::load_json(&run_dir.join(FILE_NAME), "start record")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_start_record() -> StartRecord {
StartRecord {
run_id: "run-1".to_string(),
start_time: Utc::now(),
run_branch: Some("fabro/run/run-1".to_string()),
base_sha: Some("abc123".to_string()),
}
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let record = sample_start_record();
record.save(dir.path()).unwrap();
let loaded = StartRecord::load(dir.path()).unwrap();
assert_eq!(loaded.run_id, "run-1");
assert_eq!(loaded.run_branch.as_deref(), Some("fabro/run/run-1"));
assert_eq!(loaded.base_sha.as_deref(), Some("abc123"));
}
#[test]
fn load_nonexistent() {
let result = StartRecord::load(Path::new("/nonexistent/dir"));
assert!(result.is_err());
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let mut record = sample_start_record();
record.run_branch = None;
record.base_sha = None;
record.save(dir.path()).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.path().join("start.json")).unwrap())
.unwrap();
assert!(raw.get("run_branch").is_none());
assert!(raw.get("base_sha").is_none());
}
}

View file

@ -4,6 +4,7 @@ use chrono::Utc;
use crate::context::Context;
use crate::outcome::{Outcome, OutcomeExt};
use crate::records::StartRecordExt;
use crate::run_options::RunOptions;
/// Write start.json at the start of a workflow run. Returns the StartRecord.

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