mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-20 00:11:34 +00:00
Blob activation cleanups: - Reuse fabro-db's append_to_path, remove_file_if_exists, and set_private_permissions instead of local duplicates. - Return the store directly from activate_blob_storage; the report wrapper existed only to be logged internally and then discarded. - Collapse compute_disk_preflight to return the required free bytes instead of echoing its inputs back through a struct. - Deduplicate the "exactly one ok row" PRAGMA integrity_check protocol into one executor-generic helper used by the backup and live checks. - Skip re-validating a freshly published backup; the staging copy was validated immediately before the atomic rename, so only a concurrently published file needs its own validation. - Replace the manual anyhow wrapping plus duplicate error log in serve.rs with a plain .context(), matching other startup errors. - Extract the disk-candidate enumeration in resource_sampler.rs that available_space_for_path had copy-pasted from sample_disk_resources. Test fixture cleanups: - Route all hand-assembled Database::new(..., test_blob_store()) test fixtures (32 sites) through fabro_store::test_support::test_database, and make that helper infallible instead of returning an unconditional Ok. - Install the test blob schema from fabro_db::BLOBS_MIGRATION_SQL via a test-support-gated optional dependency instead of a four-level relative include_str! into fabro-db's migrations directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
444 lines
14 KiB
Rust
444 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
#[cfg(test)]
|
|
use std::time::Duration;
|
|
|
|
use fabro_agent::{Sandbox, ToolEnvProvider};
|
|
use fabro_auth::CredentialSource;
|
|
#[cfg(test)]
|
|
use fabro_auth::ResolvedCredentials;
|
|
use fabro_github::token_source::InstallationTokenSource;
|
|
use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};
|
|
use fabro_interview::Interviewer;
|
|
use fabro_model::{Catalog, ProviderId};
|
|
use fabro_types::{ManifestPath, RunId};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::event::Emitter;
|
|
use crate::handler::HandlerRegistry;
|
|
use crate::interview_runtime::RunInterviewBlocker;
|
|
use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};
|
|
use crate::runtime_store::RunStoreHandle;
|
|
use crate::sandbox_git_runtime::SandboxGitRuntime;
|
|
use crate::stage_execution::StageExecutionTracker;
|
|
use crate::workflow_bundle::WorkflowBundle;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RunLocations {
|
|
pub host_source_dir: Option<PathBuf>,
|
|
pub sandbox_work_dir: Option<PathBuf>,
|
|
pub run_scratch_dir: PathBuf,
|
|
}
|
|
|
|
impl RunLocations {
|
|
#[must_use]
|
|
pub fn new(
|
|
host_source_dir: Option<PathBuf>,
|
|
sandbox_work_dir: Option<PathBuf>,
|
|
run_scratch_dir: PathBuf,
|
|
) -> Self {
|
|
Self {
|
|
host_source_dir,
|
|
sandbox_work_dir,
|
|
run_scratch_dir,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn for_sandbox(
|
|
host_source_dir: Option<PathBuf>,
|
|
sandbox: &dyn Sandbox,
|
|
run_scratch_dir: PathBuf,
|
|
) -> Self {
|
|
Self::new(
|
|
host_source_dir,
|
|
Some(PathBuf::from(sandbox.working_directory())),
|
|
run_scratch_dir,
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn hook_execution_context(&self) -> HookExecutionContext {
|
|
HookExecutionContext {
|
|
host_source_dir: self.host_source_dir.clone(),
|
|
sandbox_work_dir: self.sandbox_work_dir.clone(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_sandbox_work_dir(&self, sandbox_work_dir: Option<PathBuf>) -> Self {
|
|
Self {
|
|
sandbox_work_dir,
|
|
..self.clone()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct FabroRunToolServices {
|
|
pub backend: Arc<dyn fabro_tool::FabroToolBackend>,
|
|
pub current_run_id: RunId,
|
|
pub base_cwd: PathBuf,
|
|
pub user_settings_path: PathBuf,
|
|
}
|
|
|
|
/// Services shared across workflow phases.
|
|
///
|
|
/// Production construction is expected to happen from pipeline initialization
|
|
/// with the run's root cancellation token. Use
|
|
/// [`RunServices::with_cancel_token`] only with the same root token or a
|
|
/// `child_token()` derived from it. The token semantically means "cancel this
|
|
/// run or child run," not a generic shutdown signal — dropping a `RunServices`
|
|
/// does NOT count as cancellation.
|
|
#[derive(Clone)]
|
|
pub struct RunServices {
|
|
pub run_store: RunStoreHandle,
|
|
pub emitter: Arc<Emitter>,
|
|
pub sandbox: Arc<dyn Sandbox>,
|
|
pub hook_runner: Option<Arc<HookRunner>>,
|
|
pub locations: RunLocations,
|
|
pub(crate) cancel_token: CancellationToken,
|
|
pub provider_id: ProviderId,
|
|
pub model: String,
|
|
pub llm_source: Arc<dyn CredentialSource>,
|
|
pub catalog: Arc<Catalog>,
|
|
pub(crate) sandbox_git: Arc<SandboxGitRuntime>,
|
|
pub(crate) metadata_runtime: Arc<RunMetadataRuntime>,
|
|
pub(crate) metadata_writer: Option<RunMetadataWriterHandle>,
|
|
pub(crate) interview_blocker: Arc<RunInterviewBlocker>,
|
|
/// Run-scoped stage execution allocator, shared between the core
|
|
/// lifecycle and direct-dispatch handlers such as parallel branches.
|
|
pub(crate) stage_executions: StageExecutionTracker,
|
|
}
|
|
|
|
impl RunServices {
|
|
#[must_use]
|
|
pub(crate) fn new(
|
|
run_store: RunStoreHandle,
|
|
emitter: Arc<Emitter>,
|
|
sandbox: Arc<dyn Sandbox>,
|
|
hook_runner: Option<Arc<HookRunner>>,
|
|
locations: RunLocations,
|
|
cancel_token: CancellationToken,
|
|
provider_id: ProviderId,
|
|
model: String,
|
|
llm_source: Arc<dyn CredentialSource>,
|
|
catalog: Arc<Catalog>,
|
|
sandbox_git: Arc<SandboxGitRuntime>,
|
|
metadata_runtime: Arc<RunMetadataRuntime>,
|
|
metadata_writer: Option<RunMetadataWriterHandle>,
|
|
stage_executions: StageExecutionTracker,
|
|
) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
run_store,
|
|
emitter,
|
|
sandbox,
|
|
hook_runner,
|
|
locations,
|
|
cancel_token,
|
|
provider_id,
|
|
model,
|
|
llm_source,
|
|
catalog,
|
|
sandbox_git,
|
|
metadata_runtime,
|
|
metadata_writer,
|
|
interview_blocker: Arc::new(RunInterviewBlocker::new()),
|
|
stage_executions,
|
|
})
|
|
}
|
|
|
|
/// The run-level cancellation token. Cancel this to terminate the run.
|
|
/// Derive child tokens via `cancel_token().child_token()` for sandbox
|
|
/// command invocations.
|
|
pub fn cancel_token(&self) -> CancellationToken {
|
|
self.cancel_token.clone()
|
|
}
|
|
|
|
/// Run lifecycle hooks and return the merged decision.
|
|
/// Returns `Proceed` if no hook runner is configured.
|
|
pub async fn run_hooks(&self, hook_context: &HookContext) -> HookDecision {
|
|
let Some(ref runner) = self.hook_runner else {
|
|
return HookDecision::Proceed;
|
|
};
|
|
runner
|
|
.run(
|
|
hook_context,
|
|
Arc::clone(&self.sandbox),
|
|
self.locations.hook_execution_context(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_run_store(self: &Arc<Self>, run_store: RunStoreHandle) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
run_store,
|
|
..self.as_ref().clone()
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_emitter(self: &Arc<Self>, emitter: Arc<Emitter>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
emitter,
|
|
..self.as_ref().clone()
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_sandbox(self: &Arc<Self>, sandbox: Arc<dyn Sandbox>) -> Arc<Self> {
|
|
let locations = self
|
|
.locations
|
|
.with_sandbox_work_dir(Some(PathBuf::from(sandbox.working_directory())));
|
|
Arc::new(Self {
|
|
sandbox,
|
|
locations,
|
|
..self.as_ref().clone()
|
|
})
|
|
}
|
|
|
|
/// Replace the cancellation token. Use only with the same root token or
|
|
/// a child derived from it via `child_token()`.
|
|
#[must_use]
|
|
pub(crate) fn with_cancel_token(
|
|
self: &Arc<Self>,
|
|
cancel_token: CancellationToken,
|
|
) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
cancel_token,
|
|
..self.as_ref().clone()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[must_use]
|
|
pub(crate) fn with_catalog_context(
|
|
self: &Arc<Self>,
|
|
catalog: Arc<Catalog>,
|
|
provider_id: ProviderId,
|
|
model: String,
|
|
) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
provider_id,
|
|
model,
|
|
catalog,
|
|
..self.as_ref().clone()
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Services available only while executing workflow nodes.
|
|
#[derive(Clone)]
|
|
pub struct EngineServices {
|
|
pub run: Arc<RunServices>,
|
|
pub registry: Arc<HandlerRegistry>,
|
|
pub interviewer: Arc<dyn Interviewer>,
|
|
/// Environment variables from `[sandbox.env]` config.
|
|
pub base_env: HashMap<String, String>,
|
|
/// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.
|
|
pub github_token: Option<Arc<InstallationTokenSource>>,
|
|
/// Typed values from `[run.inputs]`, available to prompt templates.
|
|
pub inputs: HashMap<String, toml::Value>,
|
|
/// When true, handlers should skip real execution and return simulated
|
|
/// results.
|
|
pub dry_run: bool,
|
|
/// Manifest path of the current workflow when running from a bundle.
|
|
pub workflow_path: Option<ManifestPath>,
|
|
/// Bundled workflows available for child-workflow resolution.
|
|
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
|
|
}
|
|
|
|
impl EngineServices {
|
|
pub async fn env_for_stage(&self) -> anyhow::Result<HashMap<String, String>> {
|
|
resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await
|
|
}
|
|
|
|
/// Test-only default: empty registry and cross-phase services.
|
|
#[cfg(test)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Test scaffolding must build a slate-backed run store from sync code."
|
|
)]
|
|
pub fn test_default() -> Self {
|
|
use object_store::memory::InMemory;
|
|
|
|
use crate::handler::start;
|
|
|
|
#[derive(Debug, Default)]
|
|
struct StubCredentialSource;
|
|
|
|
#[async_trait::async_trait]
|
|
impl CredentialSource for StubCredentialSource {
|
|
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
|
let _ = catalog;
|
|
Ok(ResolvedCredentials {
|
|
credentials: Vec::new(),
|
|
auth_issues: Vec::new(),
|
|
})
|
|
}
|
|
|
|
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
|
let _ = catalog;
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
let store = Arc::new(fabro_store::test_support::test_database(
|
|
Arc::new(InMemory::new()),
|
|
"",
|
|
Duration::from_millis(1),
|
|
None,
|
|
));
|
|
let run_store = std::thread::spawn(move || {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("test runtime should initialize")
|
|
.block_on(async {
|
|
store
|
|
.create_run(&fabro_types::RunId::new())
|
|
.await
|
|
.expect("slate-backed test run store should initialize")
|
|
})
|
|
})
|
|
.join()
|
|
.expect("test run store thread should join");
|
|
let sandbox: Arc<dyn Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
|
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
|
));
|
|
let locations = RunLocations::for_sandbox(None, sandbox.as_ref(), PathBuf::from("."));
|
|
|
|
Self {
|
|
run: RunServices::new(
|
|
run_store.into(),
|
|
Arc::new(Emitter::default()),
|
|
sandbox,
|
|
None,
|
|
locations,
|
|
CancellationToken::new(),
|
|
ProviderId::anthropic(),
|
|
"claude-sonnet-4-6".to_string(),
|
|
Arc::new(StubCredentialSource),
|
|
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
|
Arc::new(SandboxGitRuntime::new()),
|
|
Arc::new(RunMetadataRuntime::new()),
|
|
None,
|
|
StageExecutionTracker::default(),
|
|
),
|
|
registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))),
|
|
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()),
|
|
base_env: HashMap::new(),
|
|
github_token: None,
|
|
inputs: HashMap::new(),
|
|
dry_run: false,
|
|
workflow_path: None,
|
|
workflow_bundle: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct WorkflowToolEnvProvider {
|
|
pub base_env: HashMap<String, String>,
|
|
pub github_token: Option<Arc<InstallationTokenSource>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ToolEnvProvider for WorkflowToolEnvProvider {
|
|
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
|
|
resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await
|
|
}
|
|
}
|
|
|
|
async fn resolve_workflow_env(
|
|
base_env: &HashMap<String, String>,
|
|
github_token: Option<&Arc<InstallationTokenSource>>,
|
|
) -> anyhow::Result<HashMap<String, String>> {
|
|
let mut env = base_env.clone();
|
|
if let Some(source) = github_token {
|
|
let resolved = source.resolve().await?;
|
|
env.insert(
|
|
"GITHUB_TOKEN".to_string(),
|
|
resolved.token.expose().to_owned(),
|
|
);
|
|
}
|
|
Ok(env)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::anyhow;
|
|
use fabro_agent::ToolEnvProvider as _;
|
|
use fabro_github::InstallationToken;
|
|
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
|
use fabro_github::token_source::InstallationTokenSource;
|
|
|
|
use super::{EngineServices, WorkflowToolEnvProvider};
|
|
|
|
#[tokio::test]
|
|
async fn test_default_uses_stub_credential_source() {
|
|
let services = EngineServices::test_default();
|
|
|
|
assert!(
|
|
services
|
|
.run
|
|
.llm_source
|
|
.configured_providers(&services.run.catalog)
|
|
.await
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workflow_tool_env_provider_returns_base_env_without_github_token() {
|
|
let provider = WorkflowToolEnvProvider {
|
|
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
|
github_token: None,
|
|
};
|
|
|
|
let env = provider.resolve().await.unwrap();
|
|
|
|
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
|
|
assert!(!env.contains_key("GITHUB_TOKEN"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workflow_tool_env_provider_merges_current_github_token() {
|
|
let provider = WorkflowToolEnvProvider {
|
|
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
|
github_token: Some(InstallationTokenSource::pat("ghp_pat".to_string())),
|
|
};
|
|
|
|
let env = provider.resolve().await.unwrap();
|
|
|
|
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
|
|
assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_pat"));
|
|
}
|
|
|
|
struct FailingMinter;
|
|
|
|
#[async_trait::async_trait]
|
|
impl InstallationTokenMinter for FailingMinter {
|
|
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
|
Err(anyhow!("GITHUB_TOKEN refresh failed"))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workflow_tool_env_provider_propagates_token_refresh_errors() {
|
|
let provider = WorkflowToolEnvProvider {
|
|
base_env: HashMap::new(),
|
|
github_token: Some(installation_token_source(
|
|
"owner/repo",
|
|
Arc::new(FailingMinter),
|
|
)),
|
|
};
|
|
|
|
let err = format!("{:#}", provider.resolve().await.unwrap_err());
|
|
assert!(err.contains("GITHUB_TOKEN refresh failed"), "got: {err}");
|
|
}
|
|
}
|