mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Create fabro-types and rewire shared types
This commit is contained in:
parent
8b148d06e4
commit
b032ae32b4
105 changed files with 3000 additions and 4887 deletions
40
Cargo.lock
generated
40
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::Result;
|
||||
use fabro_config::FabroSettingsExt;
|
||||
|
||||
use crate::args::AssetListArgs;
|
||||
use crate::shared::format_size;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrCloseArgs;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrListArgs;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrMergeArgs;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PrViewArgs;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::Result;
|
||||
use fabro_config::FabroSettingsExt;
|
||||
|
||||
use crate::args::{GlobalArgs, RunCommands};
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use fabro_sandbox::SandboxRecordExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::PreviewArgs;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use fabro_config::FabroSettingsExt;
|
||||
use fabro_sandbox::SandboxRecordExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::SshArgs;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
nom = "7"
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
pub mod retro;
|
||||
pub mod retro_agent;
|
||||
|
||||
pub use retro::RetroExt;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
21
lib/crates/fabro-types/Cargo.toml
Normal file
21
lib/crates/fabro-types/Cargo.toml
Normal 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
|
||||
30
lib/crates/fabro-types/src/checkpoint.rs
Normal file
30
lib/crates/fabro-types/src/checkpoint.rs
Normal 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>,
|
||||
}
|
||||
60
lib/crates/fabro-types/src/combine.rs
Normal file
60
lib/crates/fabro-types/src/combine.rs
Normal 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,);
|
||||
43
lib/crates/fabro-types/src/conclusion.rs
Normal file
43
lib/crates/fabro-types/src/conclusion.rs
Normal 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,
|
||||
}
|
||||
12
lib/crates/fabro-types/src/failure_signature.rs
Normal file
12
lib/crates/fabro-types/src/failure_signature.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
827
lib/crates/fabro-types/src/graph.rs
Normal file
827
lib/crates/fabro-types/src/graph.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
lib/crates/fabro-types/src/lib.rs
Normal file
35
lib/crates/fabro-types/src/lib.rs
Normal 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;
|
||||
240
lib/crates/fabro-types/src/outcome.rs
Normal file
240
lib/crates/fabro-types/src/outcome.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
162
lib/crates/fabro-types/src/retro.rs
Normal file
162
lib/crates/fabro-types/src/retro.rs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
25
lib/crates/fabro-types/src/run.rs
Normal file
25
lib/crates/fabro-types/src/run.rs
Normal 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>,
|
||||
}
|
||||
15
lib/crates/fabro-types/src/sandbox_record.rs
Normal file
15
lib/crates/fabro-types/src/sandbox_record.rs
Normal 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>,
|
||||
}
|
||||
50
lib/crates/fabro-types/src/settings/cli.rs
Normal file
50
lib/crates/fabro-types/src/settings/cli.rs
Normal 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>,
|
||||
}
|
||||
230
lib/crates/fabro-types/src/settings/hook.rs
Normal file
230
lib/crates/fabro-types/src/settings/hook.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
186
lib/crates/fabro-types/src/settings/mcp.rs
Normal file
186
lib/crates/fabro-types/src/settings/mcp.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
189
lib/crates/fabro-types/src/settings/mod.rs
Normal file
189
lib/crates/fabro-types/src/settings/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
19
lib/crates/fabro-types/src/settings/project.rs
Normal file
19
lib/crates/fabro-types/src/settings/project.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/crates/fabro-types/src/settings/run.rs
Normal file
61
lib/crates/fabro-types/src/settings/run.rs
Normal 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>,
|
||||
}
|
||||
158
lib/crates/fabro-types/src/settings/sandbox.rs
Normal file
158
lib/crates/fabro-types/src/settings/sandbox.rs
Normal 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>>,
|
||||
}
|
||||
126
lib/crates/fabro-types/src/settings/server.rs
Normal file
126
lib/crates/fabro-types/src/settings/server.rs
Normal 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>,
|
||||
}
|
||||
12
lib/crates/fabro-types/src/start.rs
Normal file
12
lib/crates/fabro-types/src/start.rs
Normal 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>,
|
||||
}
|
||||
155
lib/crates/fabro-types/src/status.rs
Normal file
155
lib/crates/fabro-types/src/status.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/crates/fabro-types/src/usage.rs
Normal file
18
lib/crates/fabro-types/src/usage.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use crate::error::FabroError;
|
||||
use crate::records::RunRecordExt;
|
||||
|
||||
use super::types::{PersistOptions, Persisted, Validated};
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue