mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
Quality pass over the intent-producer changes, no behavior changes intended: - Move the TOML->JSON scalar conversion into fabro-types as toml_scalar_to_json_value, next to its inverse, with typed errors and round-trip tests; the CLI now calls the shared helper. - Reuse goal_layer_from_args for --goal/--goal-file resolution instead of a second copy of the exclusivity check and cwd anchoring. - Delete the dead run_manifest_args helper and the test that kept it compiling; preflight_manifest_args is the remaining real builder. - Make run_target_for_environment a pure (provider, cwd) -> target mapping using is_clone_based(), warning at the call site, and default the environment id from DEFAULT_ENVIRONMENT_ID instead of a literal. - Resolve the parent run and retrieve the environment concurrently. - Drop the ResolvedCommandSettings pass-through struct and the duplicated parse-error mapping in the project settings presence read. - Share the environment/workflow-version/git test mocks from the cmd test support module instead of three per-file copies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use fabro_auth::{CredentialSource, SqlVaultCredentialSource};
|
|
use fabro_config::{CliLayer, Storage, load_llm_catalog_settings};
|
|
use fabro_model::Catalog;
|
|
use fabro_types::UserSettings;
|
|
use fabro_types::settings::RunNamespace;
|
|
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
|
|
use fabro_util::error::SharedError;
|
|
use fabro_util::printer::Printer;
|
|
use fabro_vault::SecretStore;
|
|
use tokio::sync::OnceCell;
|
|
|
|
use crate::args::{
|
|
ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override,
|
|
};
|
|
use crate::server_client::Client;
|
|
use crate::user_config::{LoadedSettings, RunSettingsKeyPresence};
|
|
use crate::{server_client, user_config};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum ServerMode {
|
|
None,
|
|
ByTarget {
|
|
target_override: Option<String>,
|
|
},
|
|
ByStorageDir {
|
|
target_override: Option<String>,
|
|
storage_dir_override: Option<PathBuf>,
|
|
},
|
|
}
|
|
|
|
pub(crate) struct CommandContext {
|
|
printer: Printer,
|
|
process_local_json: bool,
|
|
cwd: PathBuf,
|
|
base_config_path: PathBuf,
|
|
cli_layer: CliLayer,
|
|
storage_dir: PathBuf,
|
|
run_settings: std::result::Result<RunNamespace, SharedError>,
|
|
user_settings: UserSettings,
|
|
run_settings_key_presence: RunSettingsKeyPresence,
|
|
server_mode: ServerMode,
|
|
server: OnceCell<Arc<Client>>,
|
|
llm_source: OnceCell<Arc<dyn CredentialSource>>,
|
|
catalog: OnceLock<Arc<Catalog>>,
|
|
}
|
|
|
|
impl CommandContext {
|
|
pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result<Self> {
|
|
let resolved_settings = load_merged_settings(cli_layer, &ServerMode::None)?;
|
|
let printer = printer_from_verbosity(resolved_settings.user_settings.cli.output.verbosity);
|
|
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
|
let base_config_path = user_config::active_settings_path(None);
|
|
|
|
Ok(Self {
|
|
printer,
|
|
process_local_json,
|
|
cwd,
|
|
base_config_path,
|
|
cli_layer: cli_layer.clone(),
|
|
storage_dir: resolved_settings.storage_dir,
|
|
run_settings: resolved_settings.run_settings,
|
|
user_settings: resolved_settings.user_settings,
|
|
run_settings_key_presence: resolved_settings.run_settings_key_presence,
|
|
server_mode: ServerMode::None,
|
|
server: OnceCell::new(),
|
|
llm_source: OnceCell::new(),
|
|
catalog: OnceLock::new(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result<Self> {
|
|
self.with_server_mode(ServerMode::ByTarget {
|
|
target_override: args.server.clone(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result<Self> {
|
|
self.with_server_mode(ServerMode::ByStorageDir {
|
|
target_override: args.target.server.clone(),
|
|
storage_dir_override: args.storage_dir.clone_path(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn printer(&self) -> Printer {
|
|
self.printer
|
|
}
|
|
|
|
pub(crate) fn explicit_json_requested(&self) -> bool {
|
|
self.process_local_json
|
|
}
|
|
|
|
pub(crate) fn require_no_json_override(&self) -> Result<()> {
|
|
require_no_json_override(self.process_local_json)
|
|
}
|
|
|
|
pub(crate) fn cwd(&self) -> &Path {
|
|
&self.cwd
|
|
}
|
|
|
|
pub(crate) fn storage_dir(&self) -> &Path {
|
|
&self.storage_dir
|
|
}
|
|
|
|
pub(crate) fn run_settings(&self) -> Result<&RunNamespace> {
|
|
self.run_settings
|
|
.as_ref()
|
|
.map_err(|err| anyhow::Error::new(err.clone()))
|
|
}
|
|
|
|
pub(crate) fn user_settings(&self) -> &UserSettings {
|
|
&self.user_settings
|
|
}
|
|
|
|
pub(crate) fn run_settings_key_presence(&self) -> &RunSettingsKeyPresence {
|
|
&self.run_settings_key_presence
|
|
}
|
|
|
|
pub(crate) fn base_config_path(&self) -> &Path {
|
|
&self.base_config_path
|
|
}
|
|
|
|
pub(crate) fn json_output(&self) -> bool {
|
|
self.user_settings.cli.output.format == OutputFormat::Json
|
|
}
|
|
|
|
pub(crate) fn verbose(&self) -> bool {
|
|
self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose
|
|
}
|
|
|
|
pub(crate) async fn server(&self) -> Result<Arc<Client>> {
|
|
let server_mode = self.server_mode.clone();
|
|
let base_config_path = self.base_config_path.clone();
|
|
let storage_dir = self.storage_dir.clone();
|
|
let user_settings = self.user_settings.clone();
|
|
|
|
let client = self
|
|
.server
|
|
.get_or_try_init(|| async move {
|
|
let target = match server_mode {
|
|
ServerMode::None => bail!("This command context does not have server access"),
|
|
ServerMode::ByTarget { target_override }
|
|
| ServerMode::ByStorageDir {
|
|
target_override, ..
|
|
} => ServerTargetArgs {
|
|
server: target_override,
|
|
},
|
|
};
|
|
server_client::connect_server_with_settings(
|
|
&target,
|
|
&user_settings,
|
|
&storage_dir,
|
|
&base_config_path,
|
|
)
|
|
.await
|
|
.map(Arc::new)
|
|
})
|
|
.await?;
|
|
|
|
Ok(Arc::clone(client))
|
|
}
|
|
|
|
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialSource>> {
|
|
let storage_dir = self.storage_dir.clone();
|
|
|
|
let source = self
|
|
.llm_source
|
|
.get_or_try_init(|| async move {
|
|
let storage = Storage::new(&storage_dir);
|
|
let store = SecretStore::open(storage.sqlite_path(), storage.secrets_path())
|
|
.await
|
|
.context("opening the Fabro secret store")?;
|
|
let source: Arc<dyn CredentialSource> =
|
|
Arc::new(SqlVaultCredentialSource::new(Arc::new(store)));
|
|
Ok::<Arc<dyn CredentialSource>, anyhow::Error>(source)
|
|
})
|
|
.await?;
|
|
|
|
Ok(Arc::clone(source))
|
|
}
|
|
|
|
pub(crate) fn catalog(&self) -> Result<Arc<Catalog>> {
|
|
if let Some(catalog) = self.catalog.get() {
|
|
return Ok(Arc::clone(catalog));
|
|
}
|
|
|
|
let llm_catalog_settings =
|
|
load_llm_catalog_settings(None).context("loading LLM catalog")?;
|
|
let catalog = Arc::new(
|
|
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
|
|
.context("building LLM catalog")?,
|
|
);
|
|
if self.catalog.set(Arc::clone(&catalog)).is_ok() {
|
|
return Ok(catalog);
|
|
}
|
|
|
|
Ok(Arc::clone(
|
|
self.catalog
|
|
.get()
|
|
.expect("catalog must exist after failed OnceLock set"),
|
|
))
|
|
}
|
|
|
|
fn with_server_mode(&self, server_mode: ServerMode) -> Result<Self> {
|
|
// Always reload settings for the requested derivation mode so the result
|
|
// depends only on the requested mode, not on whichever derived context
|
|
// happened to call into this helper.
|
|
let resolved_settings = load_merged_settings(&self.cli_layer, &server_mode)?;
|
|
|
|
Ok(Self {
|
|
printer: self.printer,
|
|
process_local_json: self.process_local_json,
|
|
cwd: self.cwd.clone(),
|
|
base_config_path: self.base_config_path.clone(),
|
|
cli_layer: self.cli_layer.clone(),
|
|
storage_dir: resolved_settings.storage_dir,
|
|
run_settings: resolved_settings.run_settings,
|
|
user_settings: resolved_settings.user_settings,
|
|
run_settings_key_presence: resolved_settings.run_settings_key_presence,
|
|
server_mode,
|
|
server: OnceCell::new(),
|
|
llm_source: OnceCell::new(),
|
|
catalog: OnceLock::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn load_merged_settings(cli_layer: &CliLayer, server_mode: &ServerMode) -> Result<LoadedSettings> {
|
|
match server_mode {
|
|
ServerMode::None | ServerMode::ByTarget { .. } => {
|
|
user_config::load_resolved_settings(None, None, Some(cli_layer))
|
|
}
|
|
ServerMode::ByStorageDir {
|
|
storage_dir_override,
|
|
..
|
|
} => user_config::load_resolved_settings(
|
|
None,
|
|
storage_dir_override.as_deref(),
|
|
Some(cli_layer),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
use std::sync::OnceLock;
|
|
|
|
use fabro_config::{CliLayer, CliOutputLayer};
|
|
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
|
|
use fabro_util::printer::Printer;
|
|
use tokio::sync::OnceCell;
|
|
|
|
use super::{CommandContext, ServerMode};
|
|
use crate::user_config;
|
|
|
|
fn cli_layer_with_json_and_verbose() -> CliLayer {
|
|
CliLayer {
|
|
output: Some(CliOutputLayer {
|
|
format: Some(OutputFormat::Json),
|
|
verbosity: Some(OutputVerbosity::Verbose),
|
|
}),
|
|
..CliLayer::default()
|
|
}
|
|
}
|
|
|
|
fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext {
|
|
let cli_layer = cli_layer_with_json_and_verbose();
|
|
let resolved_settings =
|
|
user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer))
|
|
.expect("settings should resolve");
|
|
CommandContext {
|
|
printer,
|
|
process_local_json,
|
|
cwd: PathBuf::from("/tmp/workspace"),
|
|
base_config_path: PathBuf::from("/tmp/settings.toml"),
|
|
cli_layer,
|
|
storage_dir: resolved_settings.storage_dir,
|
|
run_settings: resolved_settings.run_settings,
|
|
user_settings: resolved_settings.user_settings,
|
|
run_settings_key_presence: resolved_settings.run_settings_key_presence,
|
|
server_mode: ServerMode::None,
|
|
server: OnceCell::new(),
|
|
llm_source: OnceCell::new(),
|
|
catalog: OnceLock::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn context_exposes_resolved_output_and_explicit_json_state() {
|
|
let ctx = synthetic_context(true, Printer::Default);
|
|
|
|
assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json);
|
|
assert_eq!(
|
|
ctx.user_settings().cli.output.verbosity,
|
|
OutputVerbosity::Verbose
|
|
);
|
|
assert!(ctx.explicit_json_requested());
|
|
assert_eq!(ctx.printer(), Printer::Default);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_dir_override_only_changes_storage_root_in_merged_settings() {
|
|
let cli_layer = cli_layer_with_json_and_verbose();
|
|
let base_settings = user_config::load_resolved_settings_from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro/default"
|
|
"#,
|
|
None,
|
|
Some(&cli_layer),
|
|
)
|
|
.expect("base settings should resolve");
|
|
let connection_settings = user_config::load_resolved_settings_from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro/default"
|
|
"#,
|
|
Some(std::path::Path::new("/srv/fabro/override")),
|
|
Some(&cli_layer),
|
|
)
|
|
.expect("connection settings should resolve");
|
|
|
|
assert_eq!(
|
|
base_settings.user_settings,
|
|
connection_settings.user_settings
|
|
);
|
|
assert_eq!(
|
|
base_settings.user_settings.cli.output.format,
|
|
OutputFormat::Json
|
|
);
|
|
assert_eq!(
|
|
base_settings.storage_dir,
|
|
PathBuf::from("/srv/fabro/default")
|
|
);
|
|
assert_eq!(
|
|
connection_settings.storage_dir,
|
|
PathBuf::from("/srv/fabro/override")
|
|
);
|
|
assert_eq!(base_settings.run_settings.unwrap().agent.mcps.len(), 0);
|
|
assert_eq!(
|
|
connection_settings.run_settings.unwrap().agent.mcps.len(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_dir_stays_available_when_server_settings_do_not_resolve() {
|
|
let loaded = user_config::load_resolved_settings_from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro"
|
|
"#,
|
|
None,
|
|
Some(&CliLayer::default()),
|
|
)
|
|
.expect("settings should resolve");
|
|
|
|
assert!(
|
|
loaded.server_settings.is_err(),
|
|
"fixture intentionally omits [server.auth] so server_settings should fail to resolve"
|
|
);
|
|
|
|
assert_eq!(loaded.storage_dir, PathBuf::from("/srv/fabro"));
|
|
assert!(loaded.run_settings.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn run_settings_include_run_agent_mcps() {
|
|
let resolved = user_config::load_resolved_settings_from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[run.agent.mcps.demo]
|
|
type = "stdio"
|
|
command = ["demo-mcp"]
|
|
"#,
|
|
None,
|
|
Some(&CliLayer::default()),
|
|
)
|
|
.expect("settings should resolve");
|
|
|
|
let run_settings = resolved.run_settings.expect("run settings should resolve");
|
|
assert!(run_settings.agent.mcps.contains_key("demo"));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() {
|
|
let json_ctx = synthetic_context(true, Printer::Default);
|
|
let text_ctx = synthetic_context(false, Printer::Default);
|
|
|
|
assert!(json_ctx.require_no_json_override().is_err());
|
|
assert!(text_ctx.require_no_json_override().is_ok());
|
|
}
|
|
}
|