From b032ae32b4e184c85b8d43c6e1216f01efd13343 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Mar 2026 21:15:03 -0400 Subject: [PATCH] Create fabro-types and rewire shared types --- Cargo.lock | 40 +- lib/crates/fabro-api/Cargo.toml | 3 +- lib/crates/fabro-api/src/server.rs | 3 +- lib/crates/fabro-cli/Cargo.toml | 3 +- lib/crates/fabro-cli/src/commands/asset/cp.rs | 1 + .../fabro-cli/src/commands/asset/list.rs | 1 + lib/crates/fabro-cli/src/commands/pr/close.rs | 1 + .../fabro-cli/src/commands/pr/create.rs | 2 + lib/crates/fabro-cli/src/commands/pr/list.rs | 1 + lib/crates/fabro-cli/src/commands/pr/merge.rs | 1 + lib/crates/fabro-cli/src/commands/pr/view.rs | 1 + .../fabro-cli/src/commands/run/attach.rs | 3 +- lib/crates/fabro-cli/src/commands/run/cp.rs | 2 + lib/crates/fabro-cli/src/commands/run/diff.rs | 3 + .../fabro-cli/src/commands/run/launcher.rs | 2 + lib/crates/fabro-cli/src/commands/run/logs.rs | 1 + lib/crates/fabro-cli/src/commands/run/mod.rs | 1 + .../fabro-cli/src/commands/run/output.rs | 2 +- .../fabro-cli/src/commands/run/preview.rs | 2 + .../fabro-cli/src/commands/run/resume.rs | 3 +- lib/crates/fabro-cli/src/commands/run/ssh.rs | 2 + .../fabro-cli/src/commands/run/start.rs | 2 + lib/crates/fabro-cli/src/commands/run/wait.rs | 4 +- .../fabro-cli/src/commands/runs/inspect.rs | 3 + .../fabro-cli/src/commands/runs/list.rs | 1 + lib/crates/fabro-cli/src/commands/runs/rm.rs | 2 + .../fabro-cli/src/commands/system/df.rs | 1 + .../fabro-cli/src/commands/system/prune.rs | 1 + lib/crates/fabro-config/Cargo.toml | 6 +- lib/crates/fabro-config/src/cli.rs | 48 +- lib/crates/fabro-config/src/combine.rs | 61 +- lib/crates/fabro-config/src/hook.rs | 761 +--------------- lib/crates/fabro-config/src/lib.rs | 4 +- lib/crates/fabro-config/src/mcp.rs | 187 +--- lib/crates/fabro-config/src/project.rs | 15 +- lib/crates/fabro-config/src/run.rs | 62 +- lib/crates/fabro-config/src/sandbox.rs | 187 +--- lib/crates/fabro-config/src/server.rs | 123 +-- lib/crates/fabro-config/src/settings.rs | 214 +---- lib/crates/fabro-core/Cargo.toml | 1 + lib/crates/fabro-core/src/executor.rs | 2 +- lib/crates/fabro-core/src/lib.rs | 4 +- lib/crates/fabro-core/src/outcome.rs | 467 +--------- lib/crates/fabro-graphviz/Cargo.toml | 1 + lib/crates/fabro-graphviz/src/graph/types.rs | 828 +----------------- lib/crates/fabro-hooks/Cargo.toml | 3 +- lib/crates/fabro-mcp/Cargo.toml | 1 + lib/crates/fabro-retro/Cargo.toml | 1 + lib/crates/fabro-retro/src/lib.rs | 2 + lib/crates/fabro-retro/src/retro.rs | 436 +-------- lib/crates/fabro-sandbox/Cargo.toml | 3 +- lib/crates/fabro-sandbox/src/lib.rs | 2 +- .../fabro-sandbox/src/sandbox_record.rs | 142 +-- .../Cargo.toml | 4 +- .../src/lib.rs | 6 +- lib/crates/fabro-types/Cargo.toml | 21 + lib/crates/fabro-types/src/checkpoint.rs | 30 + lib/crates/fabro-types/src/combine.rs | 60 ++ lib/crates/fabro-types/src/conclusion.rs | 43 + .../fabro-types/src/failure_signature.rs | 12 + lib/crates/fabro-types/src/graph.rs | 827 +++++++++++++++++ lib/crates/fabro-types/src/lib.rs | 35 + lib/crates/fabro-types/src/outcome.rs | 240 +++++ lib/crates/fabro-types/src/retro.rs | 162 ++++ lib/crates/fabro-types/src/run.rs | 25 + lib/crates/fabro-types/src/sandbox_record.rs | 15 + lib/crates/fabro-types/src/settings/cli.rs | 50 ++ lib/crates/fabro-types/src/settings/hook.rs | 230 +++++ lib/crates/fabro-types/src/settings/mcp.rs | 186 ++++ lib/crates/fabro-types/src/settings/mod.rs | 189 ++++ .../fabro-types/src/settings/project.rs | 19 + lib/crates/fabro-types/src/settings/run.rs | 61 ++ .../fabro-types/src/settings/sandbox.rs | 158 ++++ lib/crates/fabro-types/src/settings/server.rs | 126 +++ lib/crates/fabro-types/src/start.rs | 12 + lib/crates/fabro-types/src/status.rs | 155 ++++ lib/crates/fabro-types/src/usage.rs | 18 + lib/crates/fabro-workflows/Cargo.toml | 3 +- lib/crates/fabro-workflows/src/error.rs | 36 +- lib/crates/fabro-workflows/src/git.rs | 2 + .../src/lifecycle/circuit_breaker.rs | 2 +- .../fabro-workflows/src/lifecycle/disk.rs | 2 +- .../fabro-workflows/src/lifecycle/event.rs | 6 +- .../fabro-workflows/src/lifecycle/git.rs | 1 + .../fabro-workflows/src/lifecycle/mod.rs | 1 + .../fabro-workflows/src/operations/create.rs | 4 +- .../fabro-workflows/src/operations/start.rs | 4 +- lib/crates/fabro-workflows/src/outcome.rs | 439 +--------- .../src/pipeline/execute/tests.rs | 2 +- .../fabro-workflows/src/pipeline/finalize.rs | 3 +- .../src/pipeline/initialize.rs | 4 +- .../fabro-workflows/src/pipeline/persist.rs | 1 + .../src/pipeline/pull_request.rs | 3 +- .../fabro-workflows/src/pipeline/retro.rs | 3 +- .../fabro-workflows/src/records/checkpoint.rs | 288 +----- .../fabro-workflows/src/records/conclusion.rs | 204 +---- lib/crates/fabro-workflows/src/records/mod.rs | 8 +- lib/crates/fabro-workflows/src/records/run.rs | 115 +-- .../fabro-workflows/src/records/start.rs | 75 +- lib/crates/fabro-workflows/src/run_dir.rs | 1 + lib/crates/fabro-workflows/src/run_lookup.rs | 5 +- lib/crates/fabro-workflows/src/run_status.rs | 327 +------ .../fabro-workflows/tests/cp_integration.rs | 2 +- .../tests/daytona_integration.rs | 3 +- .../fabro-workflows/tests/integration.rs | 6 +- 105 files changed, 3000 insertions(+), 4887 deletions(-) rename lib/crates/{fabro-config-derive => fabro-types-derive}/Cargo.toml (72%) rename lib/crates/{fabro-config-derive => fabro-types-derive}/src/lib.rs (83%) create mode 100644 lib/crates/fabro-types/Cargo.toml create mode 100644 lib/crates/fabro-types/src/checkpoint.rs create mode 100644 lib/crates/fabro-types/src/combine.rs create mode 100644 lib/crates/fabro-types/src/conclusion.rs create mode 100644 lib/crates/fabro-types/src/failure_signature.rs create mode 100644 lib/crates/fabro-types/src/graph.rs create mode 100644 lib/crates/fabro-types/src/lib.rs create mode 100644 lib/crates/fabro-types/src/outcome.rs create mode 100644 lib/crates/fabro-types/src/retro.rs create mode 100644 lib/crates/fabro-types/src/run.rs create mode 100644 lib/crates/fabro-types/src/sandbox_record.rs create mode 100644 lib/crates/fabro-types/src/settings/cli.rs create mode 100644 lib/crates/fabro-types/src/settings/hook.rs create mode 100644 lib/crates/fabro-types/src/settings/mcp.rs create mode 100644 lib/crates/fabro-types/src/settings/mod.rs create mode 100644 lib/crates/fabro-types/src/settings/project.rs create mode 100644 lib/crates/fabro-types/src/settings/run.rs create mode 100644 lib/crates/fabro-types/src/settings/sandbox.rs create mode 100644 lib/crates/fabro-types/src/settings/server.rs create mode 100644 lib/crates/fabro-types/src/start.rs create mode 100644 lib/crates/fabro-types/src/status.rs create mode 100644 lib/crates/fabro-types/src/usage.rs diff --git a/Cargo.lock b/Cargo.lock index 1a5cc7d4c..a96babf69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index 55ede29ab..1334b4b1e 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -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"] } \ No newline at end of file +fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] } diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index ef982236b..263ca0c4e 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -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; diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index d547dd147..cea74ffa1 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs index d21da42cf..1647864bc 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs index 4eb3c37d8..265a7f9af 100644 --- a/lib/crates/fabro-cli/src/commands/asset/list.rs +++ b/lib/crates/fabro-cli/src/commands/asset/list.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use fabro_config::FabroSettingsExt; use crate::args::AssetListArgs; use crate::shared::format_size; diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index e45b9a136..7c5f9ec2d 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::{Context, Result}; +use fabro_config::FabroSettingsExt; use tracing::info; use crate::args::PrCloseArgs; diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 705ef6397..94a4fe9ee 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index 840d77e26..a494f2451 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::{Context, Result}; +use fabro_config::FabroSettingsExt; use tracing::info; use crate::args::PrListArgs; diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 75430017d..c6023a8b1 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::{Context, Result}; +use fabro_config::FabroSettingsExt; use tracing::info; use crate::args::PrMergeArgs; diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 720d0fed4..cc7fe89ec 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::{Context, Result}; +use fabro_config::FabroSettingsExt; use tracing::info; use crate::args::PrViewArgs; diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index ca015b81f..fc39ce518 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index c19ff523e..1604b417a 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 50afb41fc..2907f8d29 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/run/launcher.rs b/lib/crates/fabro-cli/src/commands/run/launcher.rs index 990fe9e22..c20f3167d 100644 --- a/lib/crates/fabro-cli/src/commands/run/launcher.rs +++ b/lib/crates/fabro-cli/src/commands/run/launcher.rs @@ -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)] diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index d7ca315ac..7ece228c8 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -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}; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 31607cf5d..2e3397369 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use fabro_config::FabroSettingsExt; use crate::args::{GlobalArgs, RunCommands}; diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 0ca1d5eff..9df127ce7 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -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}; diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index 7fb508dce..ca602a558 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,4 +1,6 @@ use anyhow::{Context, Result}; +use fabro_config::FabroSettingsExt; +use fabro_sandbox::SandboxRecordExt; use tracing::info; use crate::args::PreviewArgs; diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 3851e3c92..294d19b03 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 652d08fc5..82fc9fc55 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,4 +1,6 @@ use anyhow::{bail, Context, Result}; +use fabro_config::FabroSettingsExt; +use fabro_sandbox::SandboxRecordExt; use tracing::info; use crate::args::SshArgs; diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index 2f58145e1..08ecc2b4e 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -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, diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 328ac4e89..2d7188369 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index fc2e3374d..d6b99efd7 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 663cbc23d..085fc445d 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index cb0d5645b..9e8ac747a 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index 8c5e9e180..f030e881d 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 159dabea7..23e744b85 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -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; diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index ddc311b12..c75f21ada 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index f32017b6f..62339c218 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -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, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ClientTlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - impl TryFrom for ClientTlsSettings { type Error = anyhow::Error; @@ -66,12 +38,6 @@ pub struct ServerConfig { pub tls: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ServerSettings { - pub base_url: Option, - pub tls: Option, -} - impl TryFrom for ServerSettings { type Error = anyhow::Error; @@ -91,14 +57,6 @@ pub struct ExecConfig { pub output_format: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExecSettings { - pub provider: Option, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} - impl From for ExecSettings { fn from(value: ExecConfig) -> Self { Self { diff --git a/lib/crates/fabro-config/src/combine.rs b/lib/crates/fabro-config/src/combine.rs index fdf4c5226..775d76aca 100644 --- a/lib/crates/fabro-config/src/combine.rs +++ b/lib/crates/fabro-config/src/combine.rs @@ -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 Combine for Option { - 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 Combine for Vec { - fn combine(mut self, other: Self) -> Self { - self.extend(other); - self - } -} - -impl Combine for HashMap -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::*; diff --git a/lib/crates/fabro-config/src/hook.rs b/lib/crates/fabro-config/src/hook.rs index 66b8c8167..806e3a995 100644 --- a/lib/crates/fabro-config/src/hook.rs +++ b/lib/crates/fabro-config/src/hook.rs @@ -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>, - #[serde(default)] - allowed_env_vars: Vec, - #[serde(default)] - tls: TlsMode, - }, - Prompt { - prompt: String, - model: Option, - }, - Agent { - prompt: String, - model: Option, - max_tool_rounds: Option, - }, -} - -/// A single hook definition. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct HookDefinition { - pub name: Option, - pub event: HookEvent, - /// Inline command shorthand — if set, implies `type = "command"`. - #[serde(default)] - pub command: Option, - /// Explicit hook type (command or http). If omitted and `command` is set, - /// defaults to `Command`. - #[serde(flatten)] - pub hook_type: Option, - /// Regex matched against node_id, handler_type, or event-specific fields. - pub matcher: Option, - /// Override the event's default blocking behavior. - pub blocking: Option, - /// Timeout in milliseconds (default: 60_000). - pub timeout_ms: Option, - /// Run inside the sandbox (true, default) or on the host (false). - pub sandbox: Option, -} - -impl HookDefinition { - /// Resolve the effective hook type: explicit `hook_type` wins, then `command` - /// shorthand, then error. - pub fn resolved_hook_type(&self) -> Option> { - 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, -} - -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 = - std::collections::HashMap::new(); - let mut order: Vec = 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::*; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index dba127594..215a3ec40 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs index 7623443a8..26d5aa144 100644 --- a/lib/crates/fabro-config/src/mcp.rs +++ b/lib/crates/fabro-config/src/mcp.rs @@ -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, - #[serde(default)] - env: HashMap, - }, - Http { - url: String, - #[serde(default)] - headers: HashMap, - }, - /// 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, - port: u16, - #[serde(default)] - env: HashMap, - }, -} - -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::*; diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 131d91085..c7e2a60e6 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -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 for ProjectFabroSettings { fn from(value: ProjectFabroConfig) -> Self { Self { diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index f257b6ffe..027a666b4 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -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, -} - impl From for CheckpointSettings { fn from(value: CheckpointConfig) -> Self { let mut exclude_globs = value.exclude_globs; @@ -40,10 +38,6 @@ impl From for CheckpointSettings { } } -fn default_true() -> bool { - true -} - #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct PullRequestConfig { pub enabled: Option, @@ -52,18 +46,6 @@ pub struct PullRequestConfig { pub merge_strategy: Option, } -#[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 for PullRequestSettings { fn from(value: PullRequestConfig) -> Self { Self { @@ -75,27 +57,12 @@ impl From 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, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct AssetsSettings { - #[serde(default)] - pub include: Vec, -} - impl From for AssetsSettings { fn from(value: AssetsConfig) -> Self { Self { @@ -110,12 +77,6 @@ pub struct GitHubConfig { pub permissions: HashMap, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct GitHubSettings { - #[serde(default)] - pub permissions: HashMap, -} - impl From for GitHubSettings { fn from(value: GitHubConfig) -> Self { Self { @@ -132,14 +93,6 @@ pub struct LlmConfig { pub fallbacks: Option>>, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LlmSettings { - pub model: Option, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - impl From for LlmSettings { fn from(value: LlmConfig) -> Self { Self { @@ -157,13 +110,6 @@ pub struct SetupConfig { pub timeout_ms: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct SetupSettings { - #[serde(default)] - pub commands: Vec, - pub timeout_ms: Option, -} - impl From for SetupSettings { fn from(value: SetupConfig) -> Self { Self { diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs index 827f9540b..827e8ead8 100644 --- a/lib/crates/fabro-config/src/sandbox.rs +++ b/lib/crates/fabro-config/src/sandbox.rs @@ -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, @@ -14,17 +20,6 @@ pub struct DaytonaConfig { pub skip_clone: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct DaytonaSettings { - pub auto_stop_interval: Option, - pub labels: Option>, - pub snapshot: Option, - pub network: Option, - /// Skip git repo detection and cloning during initialization. - #[serde(default)] - pub skip_clone: bool, -} - impl TryFrom for DaytonaSettings { type Error = anyhow::Error; @@ -39,117 +34,6 @@ impl TryFrom 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), -} - -impl Serialize for DaytonaNetwork { - fn serialize(&self, serializer: S) -> Result - 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(deserializer: D) -> Result - 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(self, value: &str) -> Result { - 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>(self, mut map: M) -> Result { - let Some(key) = map.next_key::()? 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 = 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::()? { - 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, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct DaytonaSnapshotSettings { - pub name: String, - pub cpu: Option, - pub memory: Option, - pub disk: Option, - pub dockerfile: Option, -} - impl TryFrom for DaytonaSnapshotSettings { type Error = anyhow::Error; @@ -193,12 +68,6 @@ pub struct ExeConfig { pub image: Option, } -#[cfg(feature = "exedev")] -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExeSettings { - pub image: Option, -} - #[cfg(feature = "exedev")] impl From for ExeSettings { fn from(value: ExeConfig) -> Self { @@ -220,19 +89,6 @@ pub struct SshConfig { pub preview_url_base: Option, } -#[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, - /// 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, -} - impl TryFrom for SshSettings { type Error = anyhow::Error; @@ -250,27 +106,11 @@ impl TryFrom 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, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LocalSandboxSettings { - #[serde(default)] - pub worktree_mode: WorktreeMode, -} - impl From for LocalSandboxSettings { fn from(value: LocalSandboxConfig) -> Self { Self { @@ -292,19 +132,6 @@ pub struct SandboxConfig { pub env: Option>, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct SandboxSettings { - pub provider: Option, - pub preserve: Option, - pub devcontainer: Option, - pub local: Option, - pub daytona: Option, - #[cfg(feature = "exedev")] - pub exe: Option, - pub ssh: Option, - pub env: Option>, -} - impl TryFrom for SandboxSettings { type Error = anyhow::Error; diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index a0472a4f8..4f6b5f633 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -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, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct AuthSettings { - #[serde(default)] - pub provider: AuthProvider, - #[serde(default)] - pub allowed_usernames: Vec, -} - impl From for AuthSettings { fn from(value: AuthConfig) -> Self { Self { @@ -38,13 +27,6 @@ impl From 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, @@ -52,13 +34,6 @@ pub struct TlsConfig { pub ca: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct TlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - impl TryFrom for TlsSettings { type Error = anyhow::Error; @@ -85,29 +60,10 @@ pub struct ApiConfig { pub tls: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct ApiSettings { - #[serde(default = "default_base_url")] - pub base_url: String, - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, -} - 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 for ApiSettings { type Error = anyhow::Error; @@ -120,25 +76,12 @@ impl TryFrom 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, pub email: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitAuthorSettings { - pub name: Option, - pub email: Option, -} - impl From for GitAuthorSettings { fn from(value: GitAuthorConfig) -> Self { Self { @@ -148,22 +91,11 @@ impl From 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, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebhookSettings { - pub strategy: WebhookStrategy, -} - impl TryFrom for WebhookSettings { type Error = anyhow::Error; @@ -186,18 +118,6 @@ pub struct GitConfig { pub webhooks: Option, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitSettings { - #[serde(default)] - pub provider: GitProvider, - pub app_id: Option, - pub client_id: Option, - pub slug: Option, - #[serde(default)] - pub author: GitAuthorSettings, - pub webhooks: Option, -} - impl TryFrom for GitSettings { type Error = anyhow::Error; @@ -219,27 +139,10 @@ pub struct WebConfig { pub auth: Option, } -#[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 for WebSettings { fn from(value: WebConfig) -> Self { Self { @@ -256,15 +159,6 @@ pub struct Features { pub retros: Option, } -#[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 for FeaturesSettings { fn from(value: Features) -> Self { Self { @@ -279,11 +173,6 @@ pub struct LogConfig { pub level: Option, } -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LogSettings { - pub level: Option, -} - impl From for LogSettings { fn from(value: LogConfig) -> Self { Self { level: value.level } diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs index 76c431492..ce7ab08e4 100644 --- a/lib/crates/fabro-config/src/settings.rs +++ b/lib/crates/fabro-config/src/settings.rs @@ -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, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal_file: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, - - #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] - pub work_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub setup: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vars: Option>, - - #[serde(default, skip_serializing_if = "is_default_checkpoint")] - pub checkpoint: CheckpointSettings, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assets: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcp_servers: HashMap, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mode: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbose: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_check: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dry_run: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_approve: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_retro: Option, - - #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] - pub storage_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fabro: Option, +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 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 { - 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> { - self.github - .as_ref() - .and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions)) - } - - pub fn mcp_server_entries(&self) -> &HashMap { - &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) - } -} diff --git a/lib/crates/fabro-core/Cargo.toml b/lib/crates/fabro-core/Cargo.toml index c5bb84899..5e19030f5 100644 --- a/lib/crates/fabro-core/Cargo.toml +++ b/lib/crates/fabro-core/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-core/src/executor.rs b/lib/crates/fabro-core/src/executor.rs index a9b1e1979..c78b5ab2a 100644 --- a/lib/crates/fabro-core/src/executor.rs +++ b/lib/crates/fabro-core/src/executor.rs @@ -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)] diff --git a/lib/crates/fabro-core/src/lib.rs b/lib/crates/fabro-core/src/lib.rs index b20debe10..6f98a92ed 100644 --- a/lib/crates/fabro-core/src/lib.rs +++ b/lib/crates/fabro-core/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-core/src/outcome.rs b/lib/crates/fabro-core/src/outcome.rs index 400cddd60..8aaecec8f 100644 --- a/lib/crates/fabro-core/src/outcome.rs +++ b/lib/crates/fabro-core/src/outcome.rs @@ -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 { + fn from_error( + error: &crate::error::CoreError, + duration: Duration, + attempts: u32, + max_attempts: u32, + ) -> Self; } -impl 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 { - 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 { - 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, -} - -impl FailureDetail { - pub fn new(message: impl Into, 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 { - pub status: StageStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_label: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub suggested_next_ids: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub context_updates: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jump_to_node: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, - #[serde(default)] - pub usage: M, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub files_touched: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration_ms: Option, -} - -impl Default for Outcome { - 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 Outcome { - 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 { - pub outcome: Outcome, - pub duration: Duration, - pub attempts: u32, - pub max_attempts: u32, -} - -impl NodeResult { - pub fn new(outcome: Outcome, duration: Duration, attempts: u32, max_attempts: u32) -> Self { - Self { - outcome, - duration, - attempts, - max_attempts, - } - } - - pub fn from_error( +impl NodeResultExt for NodeResult { + fn from_error( error: &crate::error::CoreError, duration: Duration, attempts: u32, @@ -271,203 +27,4 @@ impl NodeResult { max_attempts, } } - - pub fn from_skip(outcome: Outcome) -> 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::().unwrap(), - FailureCategory::TransientInfra - ); - assert_eq!( - "cancelled".parse::().unwrap(), - FailureCategory::Canceled - ); - assert_eq!( - "permanent".parse::().unwrap(), - FailureCategory::Deterministic - ); - assert_eq!( - "budget".parse::().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); - } } diff --git a/lib/crates/fabro-graphviz/Cargo.toml b/lib/crates/fabro-graphviz/Cargo.toml index d617c9b4c..f3c0027cb 100644 --- a/lib/crates/fabro-graphviz/Cargo.toml +++ b/lib/crates/fabro-graphviz/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] anyhow.workspace = true +fabro-types = { path = "../fabro-types" } nom = "7" regex = { workspace = true } serde = { workspace = true } diff --git a/lib/crates/fabro-graphviz/src/graph/types.rs b/lib/crates/fabro-graphviz/src/graph/types.rs index 691ac391f..46adafde8 100644 --- a/lib/crates/fabro-graphviz/src/graph/types.rs +++ b/lib/crates/fabro-graphviz/src/graph/types.rs @@ -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 { - match self { - Self::Integer(n) => Some(*n), - _ => None, - } - } - - #[must_use] - pub const fn as_f64(&self) -> Option { - match self { - Self::Float(n) => Some(*n), - _ => None, - } - } - - #[must_use] - pub const fn as_bool(&self) -> Option { - match self { - Self::Boolean(b) => Some(*b), - _ => None, - } - } - - #[must_use] - pub const fn as_duration(&self) -> Option { - 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, - /// CSS-like classes for model stylesheet targeting (from `class` attr and subgraph derivation). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub classes: Vec, -} - -impl Node { - pub fn new(id: impl Into) -> 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 { - self.attrs.get(key).and_then(AttrValue::as_bool) - } - - fn int_attr(&self, key: &str) -> Option { - 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 { - self.int_attr("max_retries") - } - - #[must_use] - pub fn max_visits(&self) -> Option { - 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 { - 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 { - 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, -} - -impl Edge { - pub fn new(from: impl Into, to: impl Into) -> 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 { - self.attrs.get(key).and_then(AttrValue::as_bool) - } - - fn int_attr(&self, key: &str) -> Option { - 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, - pub edges: Vec, - pub attrs: HashMap, -} - -impl Graph { - pub fn new(name: impl Into) -> 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 { - 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::*; diff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/crates/fabro-hooks/Cargo.toml index 9f88953fa..8df300bed 100644 --- a/lib/crates/fabro-hooks/Cargo.toml +++ b/lib/crates/fabro-hooks/Cargo.toml @@ -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 \ No newline at end of file +toml.workspace = true diff --git a/lib/crates/fabro-mcp/Cargo.toml b/lib/crates/fabro-mcp/Cargo.toml index 286769aed..912d6b66b 100644 --- a/lib/crates/fabro-mcp/Cargo.toml +++ b/lib/crates/fabro-mcp/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-retro/Cargo.toml b/lib/crates/fabro-retro/Cargo.toml index c9ffe13ae..0d5301a15 100644 --- a/lib/crates/fabro-retro/Cargo.toml +++ b/lib/crates/fabro-retro/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-retro/src/lib.rs b/lib/crates/fabro-retro/src/lib.rs index 5c630bf09..6100640af 100644 --- a/lib/crates/fabro-retro/src/lib.rs +++ b/lib/crates/fabro-retro/src/lib.rs @@ -1,2 +1,4 @@ pub mod retro; pub mod retro_agent; + +pub use retro::RetroExt; diff --git a/lib/crates/fabro-retro/src/retro.rs b/lib/crates/fabro-retro/src/retro.rs index eecefbfc9..f8a6d40a3 100644 --- a/lib/crates/fabro-retro/src/retro.rs +++ b/lib/crates/fabro-retro/src/retro.rs @@ -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, } -#[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 + 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, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure_reason: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub files_touched: Vec, -} - -#[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, - pub total_retries: u32, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub files_touched: Vec, - 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, - #[serde(default)] - pub friction_points: Vec, - #[serde(default)] - pub open_items: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Retro { - pub run_id: String, - pub workflow_name: String, - pub goal: String, - pub timestamp: DateTime, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub smoothness: Option, - pub stages: Vec, - pub stats: AggregateStats, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub intent: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub outcome: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub learnings: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub friction_points: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub open_items: Option>, -} - -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 { + fn load(run_dir: &Path) -> anyhow::Result { 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 { 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 { 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 { - 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 = - [("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()); - } -} diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml index 7199e2d9d..62a29a039 100644 --- a/lib/crates/fabro-sandbox/Cargo.toml +++ b/lib/crates/fabro-sandbox/Cargo.toml @@ -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 } diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 65d4c7636..a69f7ed89 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -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}; diff --git a/lib/crates/fabro-sandbox/src/sandbox_record.rs b/lib/crates/fabro-sandbox/src/sandbox_record.rs index 3b0c1ce13..d8076dbf2 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_record.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_record.rs @@ -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, - /// Docker: host path that is bind-mounted into the container - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_working_directory: Option, - /// Docker: mount point inside the container - #[serde(default, skip_serializing_if = "Option::is_none")] - pub container_mount_point: Option, - /// Exe: SSH destination for the data plane - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_host: Option, +pub trait SandboxRecordExt { + fn save(&self, path: &Path) -> anyhow::Result<()>; + fn load(path: &Path) -> anyhow::Result + 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 { + fn load(path: &Path) -> anyhow::Result { 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}")) } } diff --git a/lib/crates/fabro-config-derive/Cargo.toml b/lib/crates/fabro-types-derive/Cargo.toml similarity index 72% rename from lib/crates/fabro-config-derive/Cargo.toml rename to lib/crates/fabro-types-derive/Cargo.toml index 93bcbe8a5..bff293941 100644 --- a/lib/crates/fabro-config-derive/Cargo.toml +++ b/lib/crates/fabro-types-derive/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-config-derive/src/lib.rs b/lib/crates/fabro-types-derive/src/lib.rs similarity index 83% rename from lib/crates/fabro-config-derive/src/lib.rs rename to lib/crates/fabro-types-derive/src/lib.rs index fe211483d..9df23a86b 100644 --- a/lib/crates/fabro-config-derive/src/lib.rs +++ b/lib/crates/fabro-types-derive/src/lib.rs @@ -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 } diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml new file mode 100644 index 000000000..d1eb407d4 --- /dev/null +++ b/lib/crates/fabro-types/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-types/src/checkpoint.rs b/lib/crates/fabro-types/src/checkpoint.rs new file mode 100644 index 000000000..656c4bc0a --- /dev/null +++ b/lib/crates/fabro-types/src/checkpoint.rs @@ -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, + pub current_node: String, + pub completed_nodes: Vec, + pub node_retries: HashMap, + pub context_values: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub node_outcomes: HashMap>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_commit_sha: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub loop_failure_signatures: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub restart_failure_signatures: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub node_visits: HashMap, +} diff --git a/lib/crates/fabro-types/src/combine.rs b/lib/crates/fabro-types/src/combine.rs new file mode 100644 index 000000000..fdf4c5226 --- /dev/null +++ b/lib/crates/fabro-types/src/combine.rs @@ -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 Combine for Option { + 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 Combine for Vec { + fn combine(mut self, other: Self) -> Self { + self.extend(other); + self + } +} + +impl Combine for HashMap +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,); diff --git a/lib/crates/fabro-types/src/conclusion.rs b/lib/crates/fabro-types/src/conclusion.rs new file mode 100644 index 000000000..b50e2d099 --- /dev/null +++ b/lib/crates/fabro-types/src/conclusion.rs @@ -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, + pub retries: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conclusion { + pub timestamp: DateTime, + pub status: StageStatus, + pub duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_git_commit_sha: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_cost: Option, + #[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, +} diff --git a/lib/crates/fabro-types/src/failure_signature.rs b/lib/crates/fabro-types/src/failure_signature.rs new file mode 100644 index 000000000..227a95c5b --- /dev/null +++ b/lib/crates/fabro-types/src/failure_signature.rs @@ -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) + } +} diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/crates/fabro-types/src/graph.rs new file mode 100644 index 000000000..691ac391f --- /dev/null +++ b/lib/crates/fabro-types/src/graph.rs @@ -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 { + match self { + Self::Integer(n) => Some(*n), + _ => None, + } + } + + #[must_use] + pub const fn as_f64(&self) -> Option { + match self { + Self::Float(n) => Some(*n), + _ => None, + } + } + + #[must_use] + pub const fn as_bool(&self) -> Option { + match self { + Self::Boolean(b) => Some(*b), + _ => None, + } + } + + #[must_use] + pub const fn as_duration(&self) -> Option { + 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, + /// CSS-like classes for model stylesheet targeting (from `class` attr and subgraph derivation). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub classes: Vec, +} + +impl Node { + pub fn new(id: impl Into) -> 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 { + self.attrs.get(key).and_then(AttrValue::as_bool) + } + + fn int_attr(&self, key: &str) -> Option { + 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 { + self.int_attr("max_retries") + } + + #[must_use] + pub fn max_visits(&self) -> Option { + 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 { + 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 { + 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, +} + +impl Edge { + pub fn new(from: impl Into, to: impl Into) -> 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 { + self.attrs.get(key).and_then(AttrValue::as_bool) + } + + fn int_attr(&self, key: &str) -> Option { + 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, + pub edges: Vec, + pub attrs: HashMap, +} + +impl Graph { + pub fn new(name: impl Into) -> 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 { + 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); + } +} diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs new file mode 100644 index 000000000..817516dcb --- /dev/null +++ b/lib/crates/fabro-types/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-types/src/outcome.rs b/lib/crates/fabro-types/src/outcome.rs new file mode 100644 index 000000000..405fc5706 --- /dev/null +++ b/lib/crates/fabro-types/src/outcome.rs @@ -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 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 { + 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 { + 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, +} + +impl FailureDetail { + pub fn new(message: impl Into, category: FailureCategory) -> Self { + Self { + message: message.into(), + category, + signature: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound = "M: OutcomeMeta")] +pub struct Outcome { + pub status: StageStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_label: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggested_next_ids: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub context_updates: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jump_to_node: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, + #[serde(default)] + pub usage: M, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files_touched: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +impl Default for Outcome { + 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 Outcome { + 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 { + pub outcome: Outcome, + pub duration: Duration, + pub attempts: u32, + pub max_attempts: u32, +} + +impl NodeResult { + pub fn new(outcome: Outcome, duration: Duration, attempts: u32, max_attempts: u32) -> Self { + Self { + outcome, + duration, + attempts, + max_attempts, + } + } + + pub fn from_skip(outcome: Outcome) -> Self { + Self { + outcome, + duration: Duration::ZERO, + attempts: 0, + max_attempts: 0, + } + } +} diff --git a/lib/crates/fabro-types/src/retro.rs b/lib/crates/fabro-types/src/retro.rs new file mode 100644 index 000000000..d5d16e0d5 --- /dev/null +++ b/lib/crates/fabro-types/src/retro.rs @@ -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, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files_touched: Vec, +} + +#[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, + pub total_retries: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files_touched: Vec, + 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, + #[serde(default)] + pub friction_points: Vec, + #[serde(default)] + pub open_items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Retro { + pub run_id: String, + pub workflow_name: String, + pub goal: String, + pub timestamp: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smoothness: Option, + pub stages: Vec, + pub stats: AggregateStats, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub learnings: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub friction_points: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_items: Option>, +} + +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) + }; + } +} diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs new file mode 100644 index 000000000..71efb97d4 --- /dev/null +++ b/lib/crates/fabro-types/src/run.rs @@ -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, + pub settings: FabroSettings, + pub graph: Graph, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_slug: Option, + pub working_directory: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_repo_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, +} diff --git a/lib/crates/fabro-types/src/sandbox_record.rs b/lib/crates/fabro-types/src/sandbox_record.rs new file mode 100644 index 000000000..7609e7de9 --- /dev/null +++ b/lib/crates/fabro-types/src/sandbox_record.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_working_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container_mount_point: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_host: Option, +} diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs new file mode 100644 index 000000000..897cea01a --- /dev/null +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -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, + pub tls: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ExecSettings { + pub provider: Option, + pub model: Option, + pub permissions: Option, + pub output_format: Option, +} diff --git a/lib/crates/fabro-types/src/settings/hook.rs b/lib/crates/fabro-types/src/settings/hook.rs new file mode 100644 index 000000000..5ceea43c3 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/hook.rs @@ -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>, + #[serde(default)] + allowed_env_vars: Vec, + #[serde(default)] + tls: TlsMode, + }, + Prompt { + prompt: String, + model: Option, + }, + Agent { + prompt: String, + model: Option, + max_tool_rounds: Option, + }, +} + +/// A single hook definition. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct HookDefinition { + pub name: Option, + pub event: HookEvent, + /// Inline command shorthand — if set, implies `type = "command"`. + #[serde(default)] + pub command: Option, + /// Explicit hook type (command or http). If omitted and `command` is set, + /// defaults to `Command`. + #[serde(flatten)] + pub hook_type: Option, + /// Regex matched against node_id, handler_type, or event-specific fields. + pub matcher: Option, + /// Override the event's default blocking behavior. + pub blocking: Option, + /// Timeout in milliseconds (default: 60_000). + pub timeout_ms: Option, + /// Run inside the sandbox (true, default) or on the host (false). + pub sandbox: Option, +} + +impl HookDefinition { + /// Resolve the effective hook type: explicit `hook_type` wins, then `command` + /// shorthand, then error. + pub fn resolved_hook_type(&self) -> Option> { + 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, +} + +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 = + std::collections::HashMap::new(); + let mut order: Vec = 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 } + } +} diff --git a/lib/crates/fabro-types/src/settings/mcp.rs b/lib/crates/fabro-types/src/settings/mcp.rs new file mode 100644 index 000000000..7623443a8 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/mcp.rs @@ -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, + #[serde(default)] + env: HashMap, + }, + Http { + url: String, + #[serde(default)] + headers: HashMap, + }, + /// 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, + port: u16, + #[serde(default)] + env: HashMap, + }, +} + +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); + } +} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs new file mode 100644 index 000000000..b81fe9b0c --- /dev/null +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] + pub work_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vars: Option>, + #[serde(default, skip_serializing_if = "is_default_checkpoint")] + pub checkpoint: CheckpointSettings, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcp_servers: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prevent_idle_sleep: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upgrade_check: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dry_run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_approve: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_retro: Option, + #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] + pub storage_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fabro: Option, +} + +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 { + 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> { + self.github + .as_ref() + .and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions)) + } + + pub fn mcp_server_entries(&self) -> &HashMap { + &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) + } +} diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs new file mode 100644 index 000000000..d15aafc1a --- /dev/null +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -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(), + } + } +} diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs new file mode 100644 index 000000000..1fbca97c4 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -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, +} + +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, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GitHubSettings { + #[serde(default)] + pub permissions: HashMap, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LlmSettings { + pub model: Option, + pub provider: Option, + #[serde(default)] + pub fallbacks: Option>>, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct SetupSettings { + #[serde(default)] + pub commands: Vec, + pub timeout_ms: Option, +} diff --git a/lib/crates/fabro-types/src/settings/sandbox.rs b/lib/crates/fabro-types/src/settings/sandbox.rs new file mode 100644 index 000000000..ce79df24e --- /dev/null +++ b/lib/crates/fabro-types/src/settings/sandbox.rs @@ -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, + pub labels: Option>, + pub snapshot: Option, + pub network: Option, + #[serde(default)] + pub skip_clone: bool, +} + +#[derive(Clone, Debug, PartialEq, crate::Combine)] +pub enum DaytonaNetwork { + Block, + AllowAll, + AllowList(Vec), +} + +impl Serialize for DaytonaNetwork { + fn serialize(&self, serializer: S) -> Result + 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(deserializer: D) -> Result + 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(self, value: &str) -> Result { + 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>(self, mut map: M) -> Result { + let Some(key) = map.next_key::()? 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 = 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::()? { + 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, + pub memory: Option, + pub disk: Option, + pub dockerfile: Option, +} + +#[cfg(feature = "exedev")] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ExeSettings { + pub image: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct SshSettings { + pub destination: String, + pub working_directory: String, + pub config_file: Option, + pub preview_url_base: Option, +} + +#[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, + pub preserve: Option, + pub devcontainer: Option, + pub local: Option, + pub daytona: Option, + #[cfg(feature = "exedev")] + pub exe: Option, + pub ssh: Option, + pub env: Option>, +} diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs new file mode 100644 index 000000000..f8bd40c8c --- /dev/null +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -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, +} + +#[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, + pub tls: Option, +} + +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, + pub email: Option, +} + +#[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, + pub client_id: Option, + pub slug: Option, + #[serde(default)] + pub author: GitAuthorSettings, + pub webhooks: Option, +} + +#[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, +} diff --git a/lib/crates/fabro-types/src/start.rs b/lib/crates/fabro-types/src/start.rs new file mode 100644 index 000000000..f2b221f4c --- /dev/null +++ b/lib/crates/fabro-types/src/start.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_sha: Option, +} diff --git a/lib/crates/fabro-types/src/status.rs b/lib/crates/fabro-types/src/status.rs new file mode 100644 index 000000000..fed795868 --- /dev/null +++ b/lib/crates/fabro-types/src/status.rs @@ -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 { + 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 { + 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, + pub updated_at: DateTime, +} + +impl RunStatusRecord { + pub fn new(status: RunStatus, reason: Option) -> Self { + Self { + status, + reason, + updated_at: Utc::now(), + } + } +} diff --git a/lib/crates/fabro-types/src/usage.rs b/lib/crates/fabro-types/src/usage.rs new file mode 100644 index 000000000..f3b5d85ba --- /dev/null +++ b/lib/crates/fabro-types/src/usage.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, +} diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index f979b3843..004eee379 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-workflows/src/error.rs b/lib/crates/fabro-workflows/src/error.rs index a5d600284..de3ab97ac 100644 --- a/lib/crates/fabro-workflows/src/error.rs +++ b/lib/crates/fabro-workflows/src/error.rs @@ -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 { diff --git a/lib/crates/fabro-workflows/src/git.rs b/lib/crates/fabro-workflows/src/git.rs index 7dc3603e8..2a5a4722b 100644 --- a/lib/crates/fabro-workflows/src/git.rs +++ b/lib/crates/fabro-workflows/src/git.rs @@ -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") diff --git a/lib/crates/fabro-workflows/src/lifecycle/circuit_breaker.rs b/lib/crates/fabro-workflows/src/lifecycle/circuit_breaker.rs index 3ecbb68fb..5932807f3 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/circuit_breaker.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/circuit_breaker.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/lifecycle/disk.rs b/lib/crates/fabro-workflows/src/lifecycle/disk.rs index 08bba5c4c..e500a7574 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/disk.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/disk.rs @@ -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; diff --git a/lib/crates/fabro-workflows/src/lifecycle/event.rs b/lib/crates/fabro-workflows/src/lifecycle/event.rs index 8c23e85b8..256f54dd1 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/event.rs @@ -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>; @@ -302,7 +304,7 @@ impl RunLifecycle 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 { diff --git a/lib/crates/fabro-workflows/src/lifecycle/git.rs b/lib/crates/fabro-workflows/src/lifecycle/git.rs index f984cd1bf..85e088657 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/git.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/lifecycle/mod.rs b/lib/crates/fabro-workflows/src/lifecycle/mod.rs index 1374e4a6a..cc75f668e 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/mod.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/mod.rs @@ -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; diff --git a/lib/crates/fabro-workflows/src/operations/create.rs b/lib/crates/fabro-workflows/src/operations/create.rs index 09f746458..b4366c9e8 100644 --- a/lib/crates/fabro-workflows/src/operations/create.rs +++ b/lib/crates/fabro-workflows/src/operations/create.rs @@ -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 { diff --git a/lib/crates/fabro-workflows/src/operations/start.rs b/lib/crates/fabro-workflows/src/operations/start.rs index 91cc7e4a8..c08e0af08 100644 --- a/lib/crates/fabro-workflows/src/operations/start.rs +++ b/lib/crates/fabro-workflows/src/operations/start.rs @@ -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, diff --git a/lib/crates/fabro-workflows/src/outcome.rs b/lib/crates/fabro-workflows/src/outcome.rs index 073b48b21..4becd3210 100644 --- a/lib/crates/fabro-workflows/src/outcome.rs +++ b/lib/crates/fabro-workflows/src/outcome.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_write_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost: Option, -} - -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>; -/// 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) -> Self; - - /// Create a failed outcome with the failure category inferred from the message via heuristics. fn fail_classify(reason: impl Into) -> Self; - - /// Create a retry outcome with the failure category inferred from the message via heuristics. fn retry_classify(reason: impl Into) -> 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>) -> 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; - - /// 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; } @@ -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 { let info = fabro_model::Catalog::builtin().get(&usage.model)?; @@ -150,381 +107,7 @@ pub fn compute_stage_cost(usage: &StageUsage) -> Option { ) } -/// 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::().unwrap(), - StageStatus::Success - ); - assert_eq!("fail".parse::().unwrap(), StageStatus::Fail); - assert_eq!( - "partial_success".parse::().unwrap(), - StageStatus::PartialSuccess - ); - assert_eq!("retry".parse::().unwrap(), StageStatus::Retry); - assert_eq!( - "skipped".parse::().unwrap(), - StageStatus::Skipped - ); - } - - #[test] - fn stage_status_from_str_invalid() { - assert!("unknown".parse::().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}" - ); - } -} diff --git a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs index e718da2f8..d0c1fc355 100644 --- a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs @@ -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; diff --git a/lib/crates/fabro-workflows/src/pipeline/finalize.rs b/lib/crates/fabro-workflows/src/pipeline/finalize.rs index 833f4e4bd..0e4705a7c 100644 --- a/lib/crates/fabro-workflows/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/finalize.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/pipeline/initialize.rs b/lib/crates/fabro-workflows/src/pipeline/initialize.rs index 17723d2d6..1a325b49e 100644 --- a/lib/crates/fabro-workflows/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/initialize.rs @@ -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; diff --git a/lib/crates/fabro-workflows/src/pipeline/persist.rs b/lib/crates/fabro-workflows/src/pipeline/persist.rs index d2e50c5db..75bdfc6cf 100644 --- a/lib/crates/fabro-workflows/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflows/src/pipeline/persist.rs @@ -1,6 +1,7 @@ use std::path::Path; use crate::error::FabroError; +use crate::records::RunRecordExt; use super::types::{PersistOptions, Persisted, Validated}; diff --git a/lib/crates/fabro-workflows/src/pipeline/pull_request.rs b/lib/crates/fabro-workflows/src/pipeline/pull_request.rs index ea95f9f01..8ff990ecf 100644 --- a/lib/crates/fabro-workflows/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflows/src/pipeline/pull_request.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/pipeline/retro.rs b/lib/crates/fabro-workflows/src/pipeline/retro.rs index cdc8948f1..2404bb9de 100644 --- a/lib/crates/fabro-workflows/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflows/src/pipeline/retro.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/records/checkpoint.rs b/lib/crates/fabro-workflows/src/records/checkpoint.rs index 4a2f9e8ed..56b64f0f0 100644 --- a/lib/crates/fabro-workflows/src/records/checkpoint.rs +++ b/lib/crates/fabro-workflows/src/records/checkpoint.rs @@ -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, - pub current_node: String, - pub completed_nodes: Vec, - pub node_retries: HashMap, - pub context_values: HashMap, - /// Persisted node outcomes for goal gate checks after resume. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub node_outcomes: HashMap, - /// 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, - /// 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, - /// Failure signature counts within the main loop (deterministic/structural failures). - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub loop_failure_signatures: HashMap, - /// Failure signature counts across loop_restart edges. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub restart_failure_signatures: HashMap, - /// Per-node visit counts persisted for accurate resume. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub node_visits: HashMap, +pub trait CheckpointExt { + #[allow(clippy::too_many_arguments)] + fn from_context( + context: &Context, + current_node: impl Into, + completed_nodes: Vec, + node_retries: HashMap, + node_outcomes: HashMap, + next_node_id: Option, + loop_failure_signatures: HashMap, + restart_failure_signatures: HashMap, + node_visits: HashMap, + ) -> Self + where + Self: Sized; + fn save(&self, path: &Path) -> crate::error::Result<()>; + fn load(path: &Path) -> crate::error::Result + 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, completed_nodes: Vec, @@ -52,7 +41,7 @@ impl Checkpoint { node_visits: HashMap, ) -> 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 { + fn load(path: &Path) -> crate::error::Result { 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\"")); - } -} diff --git a/lib/crates/fabro-workflows/src/records/conclusion.rs b/lib/crates/fabro-workflows/src/records/conclusion.rs index 4abe21317..98fc16d48 100644 --- a/lib/crates/fabro-workflows/src/records/conclusion.rs +++ b/lib/crates/fabro-workflows/src/records/conclusion.rs @@ -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, - pub retries: u32, +pub trait ConclusionExt { + fn save(&self, path: &Path) -> crate::error::Result<()>; + fn load(path: &Path) -> crate::error::Result + where + Self: Sized; } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Conclusion { - pub timestamp: DateTime, - pub status: StageStatus, - pub duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure_reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub stages: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_cost: Option, - #[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 { + fn load(path: &Path) -> crate::error::Result { 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); - } -} diff --git a/lib/crates/fabro-workflows/src/records/mod.rs b/lib/crates/fabro-workflows/src/records/mod.rs index 43f2d1dfd..f1c1938de 100644 --- a/lib/crates/fabro-workflows/src/records/mod.rs +++ b/lib/crates/fabro-workflows/src/records/mod.rs @@ -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}; diff --git a/lib/crates/fabro-workflows/src/records/run.rs b/lib/crates/fabro-workflows/src/records/run.rs index 6f7ec732c..23bb7928a 100644 --- a/lib/crates/fabro-workflows/src/records/run.rs +++ b/lib/crates/fabro-workflows/src/records/run.rs @@ -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, - pub settings: FabroSettings, - pub graph: Graph, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_slug: Option, - pub working_directory: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_repo_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_branch: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, +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 + 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 { + fn load(run_dir: &Path) -> crate::error::Result { 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()); - } -} diff --git a/lib/crates/fabro-workflows/src/records/start.rs b/lib/crates/fabro-workflows/src/records/start.rs index 932520273..b2756fc8c 100644 --- a/lib/crates/fabro-workflows/src/records/start.rs +++ b/lib/crates/fabro-workflows/src/records/start.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub run_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_sha: Option, +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 + 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 { + fn load(run_dir: &Path) -> crate::error::Result { 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()); - } -} diff --git a/lib/crates/fabro-workflows/src/run_dir.rs b/lib/crates/fabro-workflows/src/run_dir.rs index 1263886aa..e070d719d 100644 --- a/lib/crates/fabro-workflows/src/run_dir.rs +++ b/lib/crates/fabro-workflows/src/run_dir.rs @@ -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. diff --git a/lib/crates/fabro-workflows/src/run_lookup.rs b/lib/crates/fabro-workflows/src/run_lookup.rs index aca3a143c..38a5b5c50 100644 --- a/lib/crates/fabro-workflows/src/run_lookup.rs +++ b/lib/crates/fabro-workflows/src/run_lookup.rs @@ -5,9 +5,8 @@ use anyhow::{bail, Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; -use crate::records::RunRecord; -use crate::records::StartRecord; -use crate::run_status::{RunStatus, RunStatusRecord, StatusReason}; +use crate::records::{ConclusionExt, RunRecord, RunRecordExt, StartRecord, StartRecordExt}; +use crate::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt, StatusReason}; #[derive(Debug, Clone, Serialize)] pub struct RunInfo { diff --git a/lib/crates/fabro-workflows/src/run_status.rs b/lib/crates/fabro-workflows/src/run_status.rs index 795ff9769..b82f0c52a 100644 --- a/lib/crates/fabro-workflows/src/run_status.rs +++ b/lib/crates/fabro-workflows/src/run_status.rs @@ -1,335 +1,30 @@ -use std::fmt; use std::path::Path; -use std::str::FromStr; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +pub use fabro_types::status::{ + InvalidTransition, ParseRunStatusError, RunStatus, RunStatusRecord, StatusReason, +}; -/// Status of a workflow run in its lifecycle. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunStatus { - Submitted, - Starting, - Running, - Paused, - Removing, - Succeeded, - Failed, - Dead, +pub trait RunStatusRecordExt { + fn save(&self, path: &Path) -> std::io::Result<()>; + fn load(path: &Path) -> std::io::Result + where + Self: Sized; } -impl RunStatus { - /// Terminal statuses cannot transition to anything (except Dead via `can_transition_to`). - pub fn is_terminal(self) -> bool { - matches!(self, Self::Succeeded | Self::Failed | Self::Dead) - } - - /// Active statuses represent runs that are in-progress or about to run. - pub fn is_active(self) -> bool { - matches!( - self, - Self::Submitted | Self::Starting | Self::Running | Self::Paused | Self::Removing - ) - } - - /// Check whether a transition from `self` to `to` is valid. - pub fn can_transition_to(self, to: Self) -> bool { - // Any state can transition to Dead - if to == Self::Dead { - return true; - } - // Terminal states cannot transition to anything else - 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) - ) - } - - /// Attempt to transition from `self` to `to`. Returns an error if the transition is invalid. - pub fn transition_to(self, to: Self) -> Result { - 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 { - 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 {} - -/// Reason for the current status. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StatusReason { - // Succeeded reasons - Completed, - PartialSuccess, - // Failed reasons - WorkflowError, - Cancelled, - Terminated, - TransientInfra, - BudgetExhausted, - LaunchFailed, - BootstrapFailed, - SandboxInitFailed, - // Non-terminal reasons - SandboxInitializing, -} - -/// Persisted record of a run's status, written to `status.json`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RunStatusRecord { - pub status: RunStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - pub updated_at: DateTime, -} - -impl RunStatusRecord { - pub fn new(status: RunStatus, reason: Option) -> Self { - Self { - status, - reason, - updated_at: Utc::now(), - } - } - - pub fn save(&self, path: &Path) -> std::io::Result<()> { +impl RunStatusRecordExt for RunStatusRecord { + fn save(&self, path: &Path) -> std::io::Result<()> { let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; std::fs::write(path, json) } - pub fn load(path: &Path) -> std::io::Result { + fn load(path: &Path) -> std::io::Result { let data = std::fs::read_to_string(path)?; serde_json::from_str(&data) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } } -/// Write the run status to `status.json` (best-effort). pub fn write_run_status(run_dir: &Path, status: RunStatus, reason: Option) { let record = RunStatusRecord::new(status, reason); let _ = record.save(&run_dir.join("status.json")); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn terminal_states() { - assert!(RunStatus::Succeeded.is_terminal()); - assert!(RunStatus::Failed.is_terminal()); - assert!(RunStatus::Dead.is_terminal()); - assert!(!RunStatus::Running.is_terminal()); - assert!(!RunStatus::Submitted.is_terminal()); - assert!(!RunStatus::Starting.is_terminal()); - assert!(!RunStatus::Paused.is_terminal()); - assert!(!RunStatus::Removing.is_terminal()); - } - - #[test] - fn active_states() { - assert!(RunStatus::Submitted.is_active()); - assert!(RunStatus::Starting.is_active()); - assert!(RunStatus::Running.is_active()); - assert!(RunStatus::Paused.is_active()); - assert!(RunStatus::Removing.is_active()); - assert!(!RunStatus::Succeeded.is_active()); - assert!(!RunStatus::Failed.is_active()); - assert!(!RunStatus::Dead.is_active()); - } - - #[test] - fn valid_transitions() { - assert!(RunStatus::Submitted.can_transition_to(RunStatus::Starting)); - assert!(RunStatus::Starting.can_transition_to(RunStatus::Running)); - assert!(RunStatus::Starting.can_transition_to(RunStatus::Failed)); - assert!(RunStatus::Running.can_transition_to(RunStatus::Succeeded)); - assert!(RunStatus::Running.can_transition_to(RunStatus::Failed)); - assert!(RunStatus::Running.can_transition_to(RunStatus::Paused)); - assert!(RunStatus::Running.can_transition_to(RunStatus::Removing)); - assert!(RunStatus::Paused.can_transition_to(RunStatus::Running)); - assert!(RunStatus::Paused.can_transition_to(RunStatus::Failed)); - assert!(RunStatus::Paused.can_transition_to(RunStatus::Removing)); - assert!(RunStatus::Removing.can_transition_to(RunStatus::Failed)); - } - - #[test] - fn dead_reachable_from_any() { - let all = [ - RunStatus::Submitted, - RunStatus::Starting, - RunStatus::Running, - RunStatus::Paused, - RunStatus::Removing, - RunStatus::Succeeded, - RunStatus::Failed, - RunStatus::Dead, - ]; - for s in all { - assert!( - s.can_transition_to(RunStatus::Dead), - "{s} should be able to transition to Dead" - ); - } - } - - #[test] - fn invalid_transitions() { - assert!(!RunStatus::Submitted.can_transition_to(RunStatus::Running)); - assert!(!RunStatus::Submitted.can_transition_to(RunStatus::Succeeded)); - assert!(!RunStatus::Starting.can_transition_to(RunStatus::Succeeded)); - assert!(!RunStatus::Running.can_transition_to(RunStatus::Starting)); - assert!(!RunStatus::Succeeded.can_transition_to(RunStatus::Running)); - assert!(!RunStatus::Failed.can_transition_to(RunStatus::Running)); - } - - #[test] - fn transition_to_returns_result() { - assert_eq!( - RunStatus::Submitted.transition_to(RunStatus::Starting), - Ok(RunStatus::Starting) - ); - assert!(RunStatus::Failed.transition_to(RunStatus::Running).is_err()); - } - - #[test] - fn display_and_from_str_roundtrip() { - let all = [ - RunStatus::Submitted, - RunStatus::Starting, - RunStatus::Running, - RunStatus::Paused, - RunStatus::Removing, - RunStatus::Succeeded, - RunStatus::Failed, - RunStatus::Dead, - ]; - for s in all { - let text = s.to_string(); - let parsed: RunStatus = text.parse().unwrap(); - assert_eq!(s, parsed); - } - } - - #[test] - fn from_str_invalid() { - assert!("bogus".parse::().is_err()); - } - - #[test] - fn serde_roundtrip() { - let record = - RunStatusRecord::new(RunStatus::Running, Some(StatusReason::SandboxInitializing)); - let json = serde_json::to_string(&record).unwrap(); - let parsed: RunStatusRecord = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.status, RunStatus::Running); - assert_eq!(parsed.reason, Some(StatusReason::SandboxInitializing)); - } - - #[test] - fn save_and_load() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("status.json"); - let record = RunStatusRecord::new(RunStatus::Succeeded, Some(StatusReason::Completed)); - record.save(&path).unwrap(); - let loaded = RunStatusRecord::load(&path).unwrap(); - assert_eq!(loaded.status, RunStatus::Succeeded); - assert_eq!(loaded.reason, Some(StatusReason::Completed)); - } - - #[test] - fn launch_failed_reason_roundtrip() { - let record = RunStatusRecord::new(RunStatus::Failed, Some(StatusReason::LaunchFailed)); - let json = serde_json::to_string(&record).unwrap(); - let parsed: RunStatusRecord = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.reason, Some(StatusReason::LaunchFailed)); - } - - #[test] - fn bootstrap_failed_reason_roundtrip() { - let record = RunStatusRecord::new(RunStatus::Failed, Some(StatusReason::BootstrapFailed)); - let json = serde_json::to_string(&record).unwrap(); - let parsed: RunStatusRecord = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.reason, Some(StatusReason::BootstrapFailed)); - } - - #[test] - fn load_missing_file() { - let result = RunStatusRecord::load(Path::new("/nonexistent/status.json")); - assert!(result.is_err()); - } -} diff --git a/lib/crates/fabro-workflows/tests/cp_integration.rs b/lib/crates/fabro-workflows/tests/cp_integration.rs index c311e24b5..a8929cab9 100644 --- a/lib/crates/fabro-workflows/tests/cp_integration.rs +++ b/lib/crates/fabro-workflows/tests/cp_integration.rs @@ -5,7 +5,7 @@ //! Run Docker tests with: `cargo test --package arc-workflows --test cp_integration -- --ignored` use fabro_sandbox::reconnect::reconnect; -use fabro_sandbox::SandboxRecord; +use fabro_sandbox::{SandboxRecord, SandboxRecordExt}; // --------------------------------------------------------------------------- // Local sandbox diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index ff681169b..d86c2680e 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -12,6 +12,7 @@ use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; +use fabro_sandbox::SandboxRecordExt; use fabro_workflows::artifact::sync_artifacts_to_env; use fabro_workflows::context::Context; use fabro_workflows::error::FabroError; @@ -20,7 +21,7 @@ use fabro_workflows::handler::exit::ExitHandler; use fabro_workflows::handler::start::StartHandler; use fabro_workflows::handler::{Handler, HandlerRegistry}; use fabro_workflows::outcome::{Outcome, OutcomeExt, StageStatus}; -use fabro_workflows::records::Checkpoint; +use fabro_workflows::records::{Checkpoint, CheckpointExt}; use fabro_workflows::run_options::{GitCheckpointOptions, RunOptions}; use fabro_workflows::test_support::WorkflowRunner; diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index aa98f6185..f4ea2a42c 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -13,7 +13,7 @@ use fabro_interview::{ use fabro_llm::provider::Provider; use fabro_validate::{validate, validate_or_raise, Severity}; use fabro_workflows::context::Context; -use fabro_workflows::error::FabroError; +use fabro_workflows::error::{FabroError, FailureSignatureExt}; use fabro_workflows::event::{EventEmitter, WorkflowRunEvent}; use fabro_workflows::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; use fabro_workflows::handler::command::CommandHandler; @@ -28,7 +28,7 @@ use fabro_workflows::handler::start::StartHandler; use fabro_workflows::handler::wait::WaitHandler; use fabro_workflows::handler::{Handler, HandlerRegistry}; use fabro_workflows::outcome::{Outcome, OutcomeExt, StageStatus}; -use fabro_workflows::records::Checkpoint; +use fabro_workflows::records::{Checkpoint, CheckpointExt}; use fabro_workflows::run_options::{GitCheckpointOptions, RunOptions}; use fabro_workflows::stylesheet::{apply_stylesheet, parse_stylesheet}; use fabro_workflows::test_support::{run_graph_with_hooks, WorkflowRunner}; @@ -5973,7 +5973,7 @@ mod real_llm { use fabro_workflows::handler::start::StartHandler; use fabro_workflows::handler::HandlerRegistry; use fabro_workflows::outcome::StageStatus; - use fabro_workflows::records::Checkpoint; + use fabro_workflows::records::{Checkpoint, CheckpointExt}; use fabro_workflows::run_options::RunOptions; use fabro_workflows::test_support::WorkflowRunner;