Delete the executor-era error and Git helpers in fabro-workflow

The failure classifiers, the handler and publish error builders, the
FailureDetail projections, and the LLM error conversions served the
deleted executor; Petri classifies failures now. Error keeps the
variants the create and read side construct, and the Engine variant
replaces the three-stage Stage shape. git_identity goes: the hooks
record git.identity through fabro_checkpoint. git.rs keeps the
observe, head, non-interactive push, and sync helpers the server and
fabro-manifest call, and loses the push half. fabro-llm loses the
failure signature hint whose only reader was the deleted classifier.
fabro-workflow drops regex, strum, and fabro-checkpoint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-19 09:10:34 -04:00
parent c0fb71a467
commit efb43b45aa
No known key found for this signature in database
8 changed files with 70 additions and 2284 deletions

3
Cargo.lock generated
View file

@ -3049,7 +3049,6 @@ dependencies = [
"chrono",
"dirs",
"fabro-auth",
"fabro-checkpoint",
"fabro-client",
"fabro-config",
"fabro-dump",
@ -3085,13 +3084,11 @@ dependencies = [
"pebble-agent",
"pebble-coding-agent",
"rand 0.9.4",
"regex",
"sandbox-driver",
"scopeguard",
"serde",
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"tokio",

View file

@ -1,61 +0,0 @@
//! The one failure-classification rule that is Fabro's own.
//!
//! Retry, auth, cancellation, and failover questions are answered by the
//! lithos `Error` and `ErrorData` themselves. What stays here is the loop and
//! restart detector's signature format, which names Fabro's own categories.
use lithos_llm::catalog::ProviderId;
use lithos_llm::types::{ErrorData, ErrorKind};
/// A stable `category|provider|detail` string for loop and restart detection.
///
/// The category is `api_canceled` for a cancelled call, `api_transient` for a
/// failure the provider may be asked to repeat, and `api_deterministic` for
/// everything else; the detail is the error kind's stored spelling.
#[must_use]
pub fn failure_signature_hint(error: &ErrorData) -> String {
let provider = error.provider().map_or("unknown", ProviderId::as_str);
let category = if error.is_cancelled() {
"api_canceled"
} else if error.is_retryable() {
"api_transient"
} else {
"api_deterministic"
};
let kind: ErrorKind = error.kind();
format!("{category}|{provider}|{}", kind.as_str())
}
#[cfg(test)]
mod tests {
use lithos_llm::types::{Error, RetryClassification};
use super::*;
fn error(kind: ErrorKind) -> ErrorData {
Error::new(kind, "boom")
.with_provider(ProviderId::new("openai"))
.data()
}
#[test]
fn signatures_name_category_provider_and_kind() {
assert_eq!(
failure_signature_hint(&error(ErrorKind::InvalidRequest)),
"api_deterministic|openai|invalid_request"
);
assert_eq!(
failure_signature_hint(
&Error::new(ErrorKind::RateLimit, "boom")
.with_provider(ProviderId::new("openai"))
.with_retry(RetryClassification::Safe)
.data()
),
"api_transient|openai|rate_limit"
);
assert_eq!(
failure_signature_hint(&error(ErrorKind::Cancelled)),
"api_canceled|openai|cancelled"
);
}
}

View file

@ -12,8 +12,7 @@
//! - model and provider probes ([`probe`]), and the API views of the catalog
//! ([`api`]);
//! - the `fabro exec` gateway adapter that speaks to a Fabro server
//! ([`gateway`]);
//! - the failure signature loop detection reads ([`error`]).
//! ([`gateway`]).
//!
//! Local-file inlining, structured output, readable-reasoning normalization,
//! and the retry, auth, and failover predicates are lithos-llm's own.
@ -21,7 +20,6 @@
pub mod api;
pub mod catalog;
pub mod client;
pub mod error;
pub mod gateway;
pub mod probe;
pub mod selection;
@ -33,7 +31,6 @@ pub use client::{
ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client,
build_offline_client, configured_providers,
};
pub use error::failure_signature_hint;
pub use lithos_llm::client::{Client, ClientBuild};
pub use lithos_llm::middleware::{CallContext, CancellationToken, RetryPolicy, RetryStage};
pub use lithos_llm::resolver::ModelSelectionError as RouteSelectionError;

View file

@ -34,7 +34,6 @@ fabro-template = { path = "../../foundation/fabro-template" }
fabro-tool = { path = "../fabro-tool" }
fabro-util = { path = "../../foundation/fabro-util" }
fabro-redact.workspace = true
fabro-checkpoint = { path = "../fabro-checkpoint" }
fabro-llm = { path = "../fabro-llm" }
fabro-store = { path = "../fabro-store" }
fabro-static.workspace = true
@ -42,7 +41,6 @@ fabro-types = { path = "../../foundation/fabro-types" }
lithos-llm = { workspace = true, features = ["runtime"] }
fabro-http.workspace = true
thiserror.workspace = true
strum.workspace = true
serde.workspace = true
serde_json.workspace = true
jsonschema.workspace = true
@ -56,7 +54,6 @@ async-trait.workspace = true
futures.workspace = true
chrono = { workspace = true, features = ["serde"] }
dirs = "6"
regex.workspace = true
scopeguard = "1"
md5.workspace = true
hex.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,11 @@
use std::path::Path;
use std::process::Command;
pub use fabro_checkpoint::author::GitAuthor;
use fabro_redact::DisplaySafeUrl;
use fabro_types::{DirtyStatus, GitContext, WorkflowSettings};
use tokio::task::{JoinError, spawn_blocking};
use tokio::time::timeout;
use fabro_types::{DirtyStatus, GitContext};
use crate::error::{Error, Result};
/// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`).
pub const RUN_BRANCH_PREFIX: &str = "fabro/run/";
/// A local checkout could not be inspected without changing it.
#[derive(Debug, thiserror::Error)]
pub enum GitObservationError {
@ -112,16 +106,6 @@ fn sanitized_origin_url(value: &str) -> String {
fabro_github::normalize_repo_origin_url(url.as_str())
}
pub fn git_author_from_settings(settings: &WorkflowSettings) -> GitAuthor {
settings
.run
.git
.author
.clone()
.map(|author| GitAuthor::from(&author))
.unwrap_or_default()
}
fn git_error(msg: impl Into<String>) -> Error {
Error::engine(msg.into())
}
@ -138,24 +122,12 @@ fn git_cmd(dir: &Path) -> Command {
cmd
}
/// Assert the working directory is a clean git repo (no uncommitted changes).
pub fn ensure_clean(repo: &Path) -> Result<()> {
tracing::debug!(path = %repo.display(), "Checking git cleanliness");
let output = git_cmd(repo)
/// Whether the working directory is a git repo with no uncommitted changes.
fn working_tree_is_clean(repo: &Path) -> bool {
git_cmd(repo)
.args(["status", "--porcelain"])
.output()
.map_err(|e| Error::engine_with_source("git status failed", e))?;
if !output.status.success() {
return Err(git_error("not a git repository"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
return Err(git_error("working directory has uncommitted changes"));
}
Ok(())
.is_ok_and(|output| output.status.success() && output.stdout.trim_ascii().is_empty())
}
/// Return the SHA of HEAD.
@ -172,50 +144,6 @@ pub fn head_sha(repo: &Path) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Run a `git push` command and check for success.
fn run_git_push(cmd: &mut Command) -> Result<()> {
let output = cmd
.output()
.map_err(|e| Error::engine_with_source("git push failed", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(git_error(format!("git push failed: {stderr}")));
}
Ok(())
}
/// Push a local ref to an explicit remote URL.
///
/// Uses a URL (not a named remote) so the host repo's remote config is
/// untouched. Disables credential helpers so only the inline URL credentials
/// are used.
pub fn push_ref(repo: &Path, url: &str, refname: &str) -> Result<()> {
let redacted_url = if let Some(at_pos) = url.find('@') {
format!("https://***@{}", &url[at_pos + 1..])
} else {
url.to_string()
};
tracing::info!(
repo_dir = %repo.display(),
url = %redacted_url,
refname,
"Pushing ref to remote"
);
run_git_push(git_cmd(repo).args(["-c", "credential.helper=", "push", url, refname]))
}
/// Push a local branch to the named remote using the user's configured
/// credentials.
pub fn push_branch(repo: &Path, remote: &str, branch: &str) -> Result<()> {
tracing::info!(
repo_dir = %repo.display(),
remote,
branch,
"Pushing branch to remote"
);
run_git_push(git_cmd(repo).args(["push", remote, branch]))
}
/// Push a local branch to the named remote without allowing Git to prompt.
pub fn push_branch_noninteractive(repo: &Path, remote: &str, branch: &str) -> Result<()> {
tracing::info!(
@ -224,11 +152,16 @@ pub fn push_branch_noninteractive(repo: &Path, remote: &str, branch: &str) -> Re
branch,
"Pushing branch to remote without terminal prompts"
);
run_git_push(
git_cmd(repo)
.env("GIT_TERMINAL_PROMPT", "0")
.args(["push", remote, branch]),
)
let output = git_cmd(repo)
.env("GIT_TERMINAL_PROMPT", "0")
.args(["push", remote, branch])
.output()
.map_err(|e| Error::engine_with_source("git push failed", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(git_error(format!("git push failed: {stderr}")));
}
Ok(())
}
/// Read the exact commit currently advertised for a remote branch without
@ -265,48 +198,6 @@ pub fn remote_branch_sha_noninteractive(
Ok(None)
}
/// Error from [`blocking_push_with_timeout`].
pub enum BlockingPushError {
/// The git push itself failed.
Push(Error),
/// The spawned blocking task panicked.
Panicked(JoinError),
/// The push did not complete within the timeout.
TimedOut,
}
impl std::fmt::Display for BlockingPushError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Push(e) => write!(f, "{e}"),
Self::Panicked(e) => write!(f, "task panicked: {e}"),
Self::TimedOut => write!(f, "timed out"),
}
}
}
/// Run a blocking git-push function with a timeout, flattening the
/// triple-nested Result.
pub async fn blocking_push_with_timeout<F>(
timeout_secs: u64,
f: F,
) -> std::result::Result<(), BlockingPushError>
where
F: FnOnce() -> Result<()> + Send + 'static,
{
match timeout(
std::time::Duration::from_secs(timeout_secs),
spawn_blocking(f),
)
.await
{
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(e))) => Err(BlockingPushError::Push(e)),
Ok(Err(e)) => Err(BlockingPushError::Panicked(e)),
Err(_) => Err(BlockingPushError::TimedOut),
}
}
/// Returns true if the local branch has commits not yet on the remote.
/// On any git error (no remote ref, detached HEAD, etc.), returns true
/// so the caller falls back to pushing.
@ -354,7 +245,7 @@ impl std::fmt::Display for GitSyncStatus {
/// Determine the sync status of the repository relative to a remote.
pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncStatus {
if ensure_clean(repo).is_err() {
if !working_tree_is_clean(repo) {
return GitSyncStatus::Dirty;
}
match branch {
@ -479,26 +370,27 @@ mod tests {
}
#[test]
fn ensure_clean_on_clean_repo() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
assert!(ensure_clean(dir.path()).is_ok());
}
#[test]
fn ensure_clean_fails_with_dirty_file() {
fn sync_status_is_dirty_with_uncommitted_changes() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
assert_ne!(
sync_status(dir.path(), "origin", None),
GitSyncStatus::Dirty
);
fs::write(dir.path().join("dirty.txt"), "hello").unwrap();
let err = ensure_clean(dir.path()).unwrap_err();
assert!(err.to_string().contains("uncommitted changes"));
assert_eq!(
sync_status(dir.path(), "origin", None),
GitSyncStatus::Dirty
);
}
#[test]
fn ensure_clean_fails_on_non_repo() {
fn sync_status_is_dirty_on_non_repo() {
let dir = tempfile::tempdir().unwrap();
let err = ensure_clean(dir.path()).unwrap_err();
assert!(err.to_string().contains("not a git repository"));
assert_eq!(
sync_status(dir.path(), "origin", None),
GitSyncStatus::Dirty
);
}
#[test]
@ -511,10 +403,10 @@ mod tests {
}
#[test]
fn push_branch_fails_for_nonexistent_remote() {
fn push_branch_noninteractive_fails_for_nonexistent_remote() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let result = push_branch(dir.path(), "nonexistent", "main");
let result = push_branch_noninteractive(dir.path(), "nonexistent", "main");
assert!(result.is_err());
}

View file

@ -1,329 +0,0 @@
//! One Git author and committer identity per run.
//!
//! The run resolves its identity once, after its GitHub credentials are
//! selected and before anything can commit, then uses it everywhere: engine
//! checkpoints and metadata commits read it through
//! [`git_author_from_settings`](crate::git::git_author_from_settings),
//! and every workflow command, prepare step, native agent shell tool, and ACP
//! agent launch receives it as the four `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
//! variables so plain `git commit` inside the sandbox agrees with the engine.
//!
//! Resolution order: an explicit, complete `run.git.author`; the run's GitHub
//! App bot account; the authenticated user of the run's GitHub PAT; the
//! generic Fabro identity. A partial `run.git.author` overlays the fields it
//! supplies on whichever identity the credentials resolve to. Only the run's
//! selected credentials are consulted: a lookup failure for them is a setup
//! error, never a silent change of author.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use fabro_github::token_source::InstallationTokenSource;
use fabro_github::{GitHubCredentials, identity};
use fabro_types::settings::run::GitAuthorSettings;
use fabro_types::{GitIdentity, GitIdentitySource, WorkflowSettings};
use tokio::time::timeout;
use crate::error::Error;
/// Environment variables Git reads for the author and committer.
pub const GIT_IDENTITY_ENV_KEYS: [&str; 4] = [
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
];
/// Upper bound on one identity lookup against the GitHub API.
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
/// The outcome of resolving a run's identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedGitIdentity {
pub identity: GitIdentity,
/// Set when the selected credentials were a standalone installation
/// token whose App bot account cannot be determined; the identity fell
/// back to the generic Fabro identity (plus any explicit fields).
pub warning: Option<String>,
}
/// The explicit `run.git.author` fields, trimmed; empty values count as unset.
fn explicit_fields(settings: &WorkflowSettings) -> (Option<String>, Option<String>) {
let author: Option<&GitAuthorSettings> = settings.run.git.author.as_ref();
let field = |value: Option<&String>| {
value
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
};
(
field(author.and_then(|author| author.name.as_ref())),
field(author.and_then(|author| author.email.as_ref())),
)
}
/// Overlay explicit fields on a resolved identity. The source stays that of
/// the resolved identity unless both fields are explicit.
fn overlay(mut identity: GitIdentity, name: Option<String>, email: Option<String>) -> GitIdentity {
if name.is_some() && email.is_some() {
identity.source = GitIdentitySource::Explicit;
}
if let Some(name) = name {
identity.name = name;
}
if let Some(email) = email {
identity.email = email;
}
identity
}
/// Resolve the run's Git identity from its settings and selected credentials.
///
/// `github_token` is the run's managed token source, used only as the bearer
/// for the App bot-account lookup (that endpoint rejects App JWTs).
pub async fn resolve_git_identity(
settings: &WorkflowSettings,
credentials: Option<&GitHubCredentials>,
github_token: Option<&Arc<InstallationTokenSource>>,
) -> Result<ResolvedGitIdentity, Error> {
let (name, email) = explicit_fields(settings);
if let (Some(name), Some(email)) = (name.clone(), email.clone()) {
return Ok(ResolvedGitIdentity {
identity: GitIdentity {
name,
email,
source: GitIdentitySource::Explicit,
},
warning: None,
});
}
let (credential_identity, warning) = match credentials {
None => (GitIdentity::fabro_default(), None),
Some(GitHubCredentials::Installation(_)) => (
GitIdentity::fabro_default(),
Some(
"The run's GitHub credential is a standalone installation token whose App bot \
account cannot be determined; commits use the generic Fabro identity."
.to_string(),
),
),
Some(credentials) => (
lookup_credential_identity(credentials, github_token)
.await
.map_err(|err| {
Error::engine_with_anyhow("Failed to resolve the run's Git identity", err)
})?,
None,
),
};
Ok(ResolvedGitIdentity {
identity: overlay(credential_identity, name, email),
warning,
})
}
async fn lookup_credential_identity(
credentials: &GitHubCredentials,
github_token: Option<&Arc<InstallationTokenSource>>,
) -> anyhow::Result<GitIdentity> {
let client = fabro_http::http_client()
.map_err(anyhow::Error::new)
.context("building HTTP client for GitHub identity lookup")?;
let base_url = fabro_github::github_api_base_url();
let lookup = async {
match credentials {
GitHubCredentials::App(app) => {
let bearer = match github_token {
Some(source) => Some(
source
.resolve()
.await
.context("resolving the GitHub token for the App bot lookup")?,
),
None => None,
};
let account = identity::lookup_app_bot_identity(
&client,
app,
&base_url,
bearer.as_ref().map(|token| token.token.expose()),
)
.await?;
Ok::<_, anyhow::Error>(GitIdentity {
email: account.noreply_email(),
name: account.login,
source: GitIdentitySource::GithubApp,
})
}
GitHubCredentials::Pat(token) => {
let account = identity::lookup_token_identity(&client, token, &base_url).await?;
Ok(GitIdentity {
email: account.noreply_email(),
name: account.login,
source: GitIdentitySource::GithubPat,
})
}
GitHubCredentials::Installation(_) => {
unreachable!("installation tokens never reach the credential lookup")
}
}
};
timeout(LOOKUP_TIMEOUT, lookup)
.await
.context("GitHub identity lookup timed out")?
}
/// The four Git environment variables for `identity`.
#[must_use]
pub fn git_identity_env(identity: &GitIdentity) -> [(&'static str, String); 4] {
[
("GIT_AUTHOR_NAME", identity.name.clone()),
("GIT_AUTHOR_EMAIL", identity.email.clone()),
("GIT_COMMITTER_NAME", identity.name.clone()),
("GIT_COMMITTER_EMAIL", identity.email.clone()),
]
}
/// Set the identity variables on `env`, replacing any existing values so the
/// run's identity wins over inherited host variables and conflicting run or
/// step environment entries.
pub fn apply_git_identity_env(env: &mut HashMap<String, String>, identity: &GitIdentity) {
for (key, value) in git_identity_env(identity) {
env.insert(key.to_string(), value);
}
}
#[cfg(test)]
mod tests {
use fabro_types::settings::run::GitAuthorSettings;
use super::*;
fn settings(name: Option<&str>, email: Option<&str>) -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
settings.run.git.author = Some(GitAuthorSettings {
name: name.map(str::to_string),
email: email.map(str::to_string),
});
settings
}
fn installation() -> GitHubCredentials {
GitHubCredentials::Installation(fabro_github::InstallationToken {
token: "ghs_token".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
})
}
#[tokio::test]
async fn no_credentials_use_the_generic_identity_without_a_lookup() {
let resolved = resolve_git_identity(&WorkflowSettings::default(), None, None)
.await
.unwrap();
assert_eq!(resolved.identity, GitIdentity::fabro_default());
assert_eq!(resolved.identity.source, GitIdentitySource::Default);
assert!(resolved.warning.is_none());
}
#[tokio::test]
async fn complete_explicit_author_skips_credential_lookup() {
// A PAT lookup would need the network; a complete explicit author
// must never get that far.
let creds = GitHubCredentials::Pat("ghp_never_used".to_string());
let resolved = resolve_git_identity(
&settings(Some("Release Bot"), Some("release@example.com")),
Some(&creds),
None,
)
.await
.unwrap();
assert_eq!(resolved.identity, GitIdentity {
name: "Release Bot".to_string(),
email: "release@example.com".to_string(),
source: GitIdentitySource::Explicit,
});
}
#[tokio::test]
async fn partial_explicit_author_overlays_the_resolved_identity() {
let resolved = resolve_git_identity(&settings(Some("Only Name"), None), None, None)
.await
.unwrap();
assert_eq!(resolved.identity, GitIdentity {
name: "Only Name".to_string(),
email: GitIdentity::DEFAULT_EMAIL.to_string(),
source: GitIdentitySource::Default,
});
let resolved = resolve_git_identity(&settings(None, Some("only@example.com")), None, None)
.await
.unwrap();
assert_eq!(resolved.identity.name, GitIdentity::DEFAULT_NAME);
assert_eq!(resolved.identity.email, "only@example.com");
}
#[tokio::test]
async fn blank_explicit_fields_count_as_unset() {
let resolved = resolve_git_identity(&settings(Some(" "), Some("")), None, None)
.await
.unwrap();
assert_eq!(resolved.identity, GitIdentity::fabro_default());
}
#[tokio::test]
async fn standalone_installation_token_falls_back_with_a_warning() {
let resolved =
resolve_git_identity(&WorkflowSettings::default(), Some(&installation()), None)
.await
.unwrap();
assert_eq!(resolved.identity, GitIdentity::fabro_default());
let warning = resolved.warning.expect("fallback should warn");
assert!(
warning.contains("standalone installation token"),
"{warning}"
);
}
#[tokio::test]
async fn standalone_installation_token_keeps_explicit_fields() {
let resolved = resolve_git_identity(
&settings(None, Some("pinned@example.com")),
Some(&installation()),
None,
)
.await
.unwrap();
assert_eq!(resolved.identity.name, GitIdentity::DEFAULT_NAME);
assert_eq!(resolved.identity.email, "pinned@example.com");
assert_eq!(resolved.identity.source, GitIdentitySource::Default);
assert!(resolved.warning.is_some());
}
#[test]
fn identity_env_replaces_conflicting_entries() {
let identity = GitIdentity {
name: "fabro-bot[bot]".to_string(),
email: "7+fabro-bot[bot]@users.noreply.github.com".to_string(),
source: GitIdentitySource::GithubApp,
};
let mut env = HashMap::from([
("GIT_AUTHOR_NAME".to_string(), "someone else".to_string()),
(
"GIT_COMMITTER_EMAIL".to_string(),
"x@example.com".to_string(),
),
("KEEP".to_string(), "1".to_string()),
]);
apply_git_identity_env(&mut env, &identity);
assert_eq!(env["GIT_AUTHOR_NAME"], "fabro-bot[bot]");
assert_eq!(env["GIT_AUTHOR_EMAIL"], identity.email);
assert_eq!(env["GIT_COMMITTER_NAME"], "fabro-bot[bot]");
assert_eq!(env["GIT_COMMITTER_EMAIL"], identity.email);
assert_eq!(env["KEEP"], "1");
assert_eq!(env.len(), 5);
}
}

View file

@ -5,10 +5,10 @@
//! what Fabro itself owns: the create-time compile of the Fabro graph the
//! read side displays (`pipeline`, `transforms`, `operations`), the run
//! records and status vocabulary (`records`, `run_status`), the Git
//! helpers a run's platform effects use (`git`, `git_identity`,
//! `sandbox_git`), pull request creation (`pull_request`), the run tools an
//! agent session calls (`run_tools`, `services`), the built-in web search
//! backend (`web_search`).
//! helpers a run's platform effects use (`git`, `sandbox_git`), pull
//! request creation (`pull_request`), the run tools an agent session calls
//! (`run_tools`, `services`), the built-in web search backend
//! (`web_search`).
#![cfg_attr(
test,
@ -30,7 +30,6 @@
pub mod error;
pub mod file_resolver;
pub mod git;
pub mod git_identity;
pub mod operations;
pub mod outcome;
pub mod pipeline;
@ -39,7 +38,7 @@ pub mod records;
pub mod run_lookup;
pub mod usage_rollup;
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
pub use error::{Error, Result};
pub use fabro_types::ManifestPath;
pub use usage_rollup::{
ProjectionUsageByModel, ProjectionUsageRollup, ProjectionUsageStage,