feat(dev): add bootstrap_catalog reference allowlist policy test

Per the settings-driven LLM catalog plan: the install/API-key flow
needs a built-in catalog before any project settings are loaded, so
fabro_model::bootstrap_catalog is the explicit hatch for that. Once
the bootstrap module lands, request-serving paths must use the
resolved Arc<Catalog> threaded through their state — never the
bootstrap default.

This policy test scans every .rs file under lib/ for non-comment
references to 'bootstrap_catalog' and fails unless every reference
sits in an allowlisted path fragment (the bootstrap module itself,
install flows, test_support modules, and tests/policy.rs).

Today the symbol does not yet exist, so the scan is clean and the
test passes; the moment the symbol is introduced and used outside
the allowlist, this test fails and forces an explicit allowlist
update — fulfilling the workspace-policy requirement in the plan.

Also lands rustfmt + clippy hygiene on this slice and drops the
unused Result wrapper from infallible adapter factories so the
factory registry remains plain function pointers.
This commit is contained in:
fabro-bot 2026-05-04 18:23:31 +00:00
parent 8abe8ca515
commit 635d3d588a
21 changed files with 221 additions and 111 deletions

View file

@ -230,4 +230,4 @@ mod tests {
.is_none()
);
}
}
}

View file

@ -542,4 +542,4 @@ command = ["demo-mcp"]
assert_eq!(settings.run.execution.approval, ApprovalMode::Auto);
assert!(!settings.run.execution.retros);
}
}
}

View file

@ -184,11 +184,12 @@ where
D: Deserializer<'de>,
{
use serde::de::Error;
use toml::value::Datetime;
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Toml(toml::value::Datetime),
Toml(Datetime),
Str(String),
}
@ -310,8 +311,9 @@ impl TryFrom<String> for CredentialRef {
impl<'de> Deserialize<'de> for CredentialRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
s.parse().map_err(D::Error::custom)
}
}
@ -428,6 +430,10 @@ mod tests {
// CredentialRef. The error bubbles up as a TOML deserialization
// failure.
#[derive(Deserialize)]
#[expect(
dead_code,
reason = "field exists only to drive the deserializer; we assert on the parse error"
)]
struct Wrap {
v: Vec<CredentialRef>,
}
@ -453,20 +459,14 @@ aliases = ["moonshot"]
let kimi = layer.providers.get("kimi").unwrap();
assert_eq!(kimi.display_name.as_deref(), Some("Kimi"));
assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible"));
assert_eq!(
kimi.base_url.as_deref(),
Some("https://api.moonshot.ai/v1")
);
assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1"));
assert_eq!(kimi.priority, Some(60));
assert_eq!(kimi.enabled, Some(true));
assert_eq!(kimi.aliases.as_deref(), Some(&["moonshot".to_string()][..]));
assert_eq!(
kimi.credentials.as_ref().unwrap(),
&vec![
CredentialRef::Credential("kimi".to_string()),
CredentialRef::Env("KIMI_API_KEY".to_string()),
]
);
assert_eq!(kimi.credentials.as_ref().unwrap(), &vec![
CredentialRef::Credential("kimi".to_string()),
CredentialRef::Env("KIMI_API_KEY".to_string()),
]);
}
#[test]
@ -548,18 +548,9 @@ cache_input_cost_per_mtok = 9.0
let controls = m.controls.as_ref().unwrap();
assert_eq!(
controls.reasoning_effort.as_deref(),
Some(
&[
"low".to_string(),
"medium".to_string(),
"high".to_string()
][..]
)
);
assert_eq!(
controls.speed.as_deref(),
Some(&["fast".to_string()][..])
Some(&["low".to_string(), "medium".to_string(), "high".to_string()][..])
);
assert_eq!(controls.speed.as_deref(), Some(&["fast".to_string()][..]));
let costs = m.costs.as_ref().unwrap();
let fast = costs.speed.as_ref().unwrap().get("fast").unwrap();
@ -629,10 +620,9 @@ mystery = 1
..ProviderSettings::default()
};
let merged = high.combine(low);
assert_eq!(
merged.credentials.unwrap(),
vec![CredentialRef::Env("FOO".to_string())]
);
assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env(
"FOO".to_string()
)]);
}
#[test]
@ -643,35 +633,28 @@ mystery = 1
..ProviderSettings::default()
};
let merged = high.combine(low);
assert_eq!(
merged.credentials.unwrap(),
vec![CredentialRef::Env("FOO".to_string())]
);
assert_eq!(merged.credentials.unwrap(), vec![CredentialRef::Env(
"FOO".to_string()
)]);
}
#[test]
fn merge_map_field_merges_per_provider_id() {
let mut high_map: std::collections::HashMap<String, ProviderSettings> =
std::collections::HashMap::new();
high_map.insert(
"kimi".to_string(),
ProviderSettings {
base_url: Some("https://override".to_string()),
..ProviderSettings::default()
},
);
high_map.insert("kimi".to_string(), ProviderSettings {
base_url: Some("https://override".to_string()),
..ProviderSettings::default()
});
let high: MergeMap<ProviderSettings> = MergeMap::from(high_map);
let mut low_map: std::collections::HashMap<String, ProviderSettings> =
std::collections::HashMap::new();
low_map.insert(
"kimi".to_string(),
ProviderSettings {
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://defaults".to_string()),
..ProviderSettings::default()
},
);
low_map.insert("kimi".to_string(), ProviderSettings {
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://defaults".to_string()),
..ProviderSettings::default()
});
let low: MergeMap<ProviderSettings> = MergeMap::from(low_map);
let merged = high.combine(low);
@ -693,7 +676,10 @@ mystery = 1
speed: Some(vec!["fast".to_string()]),
};
let merged = high.combine(low);
assert_eq!(merged.reasoning_effort.as_deref(), Some(&["high".to_string()][..]));
assert_eq!(
merged.reasoning_effort.as_deref(),
Some(&["high".to_string()][..])
);
assert_eq!(merged.speed.as_deref(), Some(&["fast".to_string()][..]));
}
}
}

View file

@ -43,4 +43,4 @@ pub use server::{
SlackIntegrationLayer, TeamsIntegrationLayer,
};
pub(crate) use settings::SettingsLayer;
pub use workflow::WorkflowLayer;
pub use workflow::WorkflowLayer;

View file

@ -602,4 +602,4 @@ pub struct RunPullRequestLayer {
pub struct RunArtifactsLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
}
}

View file

@ -134,4 +134,4 @@ impl SettingsLayer {
let auth = server.auth.get_or_insert_with(ServerAuthLayer::default);
auth.methods = Some(vec![ServerAuthMethod::DevToken]);
}
}
}

View file

@ -48,11 +48,11 @@ pub use layers::{
ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer,
RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer,
ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, TeamsIntegrationLayer, WorkflowLayer,
ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer,
ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer,
ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, TeamsIntegrationLayer,
WorkflowLayer,
};
pub(crate) use layers::{Combine, SettingsLayer};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
@ -88,4 +88,4 @@ where
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(Error::read_file(&default_path, e)),
}
}
}

View file

@ -216,7 +216,10 @@ mod tests {
.parse::<SettingsLayer>()
.unwrap_err();
let text = err.to_string();
assert!(text.contains("run.model") || text.contains("llm"), "got: {text}");
assert!(
text.contains("run.model") || text.contains("llm"),
"got: {text}"
);
}
#[test]
@ -225,6 +228,9 @@ mod tests {
.parse::<SettingsLayer>()
.unwrap_err();
let text = err.to_string();
assert!(text.contains("run.model") || text.contains("llm"), "got: {text}");
assert!(
text.contains("run.model") || text.contains("llm"),
"got: {text}"
);
}
}
}

View file

@ -4,9 +4,9 @@ use fabro_types::settings::run::{
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, LocalSandboxSettings,
McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings,
NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings,
RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelSettings,
RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings,
TlsMode,
RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelControls,
RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings,
ScmGitHubSettings, TlsMode,
};
use super::ResolveError;
@ -75,7 +75,7 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings {
controls: model
.controls
.as_ref()
.map(|c| fabro_types::settings::RunModelControls {
.map(|c| RunModelControls {
reasoning_effort: c.reasoning_effort.clone(),
speed: c.speed.clone(),
})
@ -505,4 +505,4 @@ fn resolve_artifacts(artifacts: Option<&RunArtifactsLayer>) -> ArtifactsSettings
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}
}

View file

@ -110,4 +110,4 @@ name = "sonnet"
Some(InterpString::parse("anthropic"))
);
assert_eq!(settings.model.name, Some(InterpString::parse("sonnet")));
}
}

View file

@ -4,6 +4,7 @@ mod docker_build;
#[cfg(unix)]
mod docker_entrypoint;
mod docs;
mod policy;
mod release;
mod spa;
@ -11,7 +12,7 @@ fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")
}
fn workspace_root() -> PathBuf {
pub(crate) fn workspace_root() -> PathBuf {
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
root.pop();
root.pop();

View file

@ -0,0 +1,106 @@
//! Workspace policy tests.
//!
//! These tests scan the source tree for references that violate
//! product-level invariants. They run as part of `cargo nextest` and are
//! cheap (text scans only).
use std::path::{Path, PathBuf};
use crate::workspace_root;
/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key
/// validation hatch from the settings-driven LLM catalog plan. It must
/// **not** appear in request-serving paths — server handlers, workflow
/// operations, agent runtime, hooks, or completion handlers — because those
/// must use the resolved `Arc<Catalog>` threaded through their state.
///
/// The allowed-callers list below is the policy boundary. Adding a new
/// caller is intentional and requires updating this list.
const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
// The bootstrap module itself.
"lib/crates/fabro-model/src/bootstrap_catalog",
// Install / first-run / API-key validation flows that legitimately need
// a built-in catalog before any project settings have been loaded.
"lib/crates/fabro-install/",
"lib/crates/fabro-cli/src/commands/install/",
"lib/crates/fabro-cli/src/shared/install_",
"lib/crates/fabro-cli/src/shared/api_key_validation",
// Test support modules.
"tests/",
"test_support",
"/tests/it/",
"/tests/policy.rs",
// Documentation files referencing the policy.
"docs/",
"CLAUDE.md",
"AGENTS.md",
];
#[test]
fn bootstrap_catalog_references_stay_in_allowlist() {
let root = workspace_root();
let mut violations: Vec<(PathBuf, usize, String)> = Vec::new();
walk_rust_sources(&root, &mut |path, contents| {
for (idx, line) in contents.lines().enumerate() {
if !line.contains("bootstrap_catalog") {
continue;
}
// Skip comments referencing the symbol in prose.
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
continue;
}
let rel = path.strip_prefix(&root).unwrap_or(path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
if BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS
.iter()
.any(|frag| rel_str.contains(frag))
{
continue;
}
violations.push((rel.to_path_buf(), idx + 1, line.to_string()));
}
});
assert!(
violations.is_empty(),
"bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/crates/fabro-dev/tests/it/policy.rs.",
violations
.into_iter()
.map(|(p, l, s)| format!(" {}:{}: {}", p.display(), l, s.trim()))
.collect::<Vec<_>>()
.join("\n"),
);
}
#[expect(
clippy::disallowed_methods,
reason = "policy test reads source files synchronously with std::fs"
)]
fn walk_rust_sources(root: &Path, on_file: &mut dyn FnMut(&Path, &str)) {
let mut stack: Vec<PathBuf> = vec![root.join("lib")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip generated/output directories.
if matches!(
name_str.as_ref(),
"target" | ".git" | "node_modules" | "dist" | "build"
) {
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
if let Ok(contents) = std::fs::read_to_string(&path) {
on_file(&path, &contents);
}
}
}
}
}

View file

@ -18,7 +18,6 @@ use std::sync::Arc;
use fabro_auth::ApiKeyHeader;
use fabro_model::adapter::{self as model_adapter, AdapterMetadata};
use crate::error::Error;
use crate::provider::ProviderAdapter;
use crate::providers;
@ -50,13 +49,13 @@ impl AdapterConfig {
/// Construct a minimal config with just provider ID and auth header.
pub fn new(provider_id: impl Into<String>, auth_header: ApiKeyHeader) -> Self {
Self {
provider_id: provider_id.into(),
provider_id: provider_id.into(),
auth_header,
base_url: None,
base_url: None,
extra_headers: HashMap::new(),
codex_mode: false,
org_id: None,
project_id: None,
codex_mode: false,
org_id: None,
project_id: None,
}
}
}
@ -69,11 +68,15 @@ fn auth_value(header: &ApiKeyHeader) -> String {
/// Factory function signature. Takes a fully-resolved [`AdapterConfig`] and
/// returns a registered-ready [`ProviderAdapter`].
pub type AdapterFactory = fn(&AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error>;
///
/// Adapter constructors are infallible today; if a future adapter needs to
/// fail at construction time, add a separate fallible factory variant
/// rather than re-shaping every existing factory.
pub type AdapterFactory = fn(&AdapterConfig) -> Arc<dyn ProviderAdapter>;
const KIMI_BASE_URL: &str = "https://api.moonshot.ai/v1";
fn build_anthropic(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
fn build_anthropic(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
let mut adapter = providers::AnthropicAdapter::new(auth_value(&config.auth_header));
if let Some(base_url) = config.base_url.clone() {
adapter = adapter.with_base_url(base_url);
@ -81,10 +84,10 @@ fn build_anthropic(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, E
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers.clone());
}
Ok(Arc::new(adapter))
Arc::new(adapter)
}
fn build_openai(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
fn build_openai(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
let mut adapter = providers::OpenAiAdapter::new(auth_value(&config.auth_header));
if let Some(base_url) = config.base_url.clone() {
adapter = adapter.with_base_url(base_url);
@ -101,10 +104,10 @@ fn build_openai(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Erro
if let Some(project_id) = config.project_id.clone() {
adapter = adapter.with_project_id(project_id);
}
Ok(Arc::new(adapter))
Arc::new(adapter)
}
fn build_gemini(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
fn build_gemini(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
let mut adapter = providers::GeminiAdapter::new(auth_value(&config.auth_header));
if let Some(base_url) = config.base_url.clone() {
adapter = adapter.with_base_url(base_url);
@ -112,10 +115,10 @@ fn build_gemini(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Erro
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers.clone());
}
Ok(Arc::new(adapter))
Arc::new(adapter)
}
fn build_openai_compatible(config: &AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
fn build_openai_compatible(config: &AdapterConfig) -> Arc<dyn ProviderAdapter> {
let base_url = config
.base_url
.clone()
@ -126,7 +129,7 @@ fn build_openai_compatible(config: &AdapterConfig) -> Result<Arc<dyn ProviderAda
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers.clone());
}
Ok(Arc::new(adapter))
Arc::new(adapter)
}
/// Look up a factory by adapter key. Returns `None` if the key has no factory
@ -182,8 +185,7 @@ mod tests {
#[test]
fn registered_factory_set_matches_metadata_set() {
let metadata: std::collections::BTreeSet<&str> =
model_adapter::keys().collect();
let metadata: std::collections::BTreeSet<&str> = model_adapter::keys().collect();
let factories: std::collections::BTreeSet<&str> = registered_keys().collect();
assert_eq!(metadata, factories);
}
@ -195,14 +197,11 @@ mod tests {
#[test]
fn anthropic_factory_builds_anthropic_adapter() {
let config = AdapterConfig::new(
"anthropic",
ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "test-key".to_string(),
},
);
let adapter = factory_for("anthropic").unwrap()(&config).unwrap();
let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "test-key".to_string(),
});
let adapter = factory_for("anthropic").unwrap()(&config);
assert_eq!(adapter.name(), "anthropic");
}
@ -217,7 +216,7 @@ mod tests {
org_id: None,
project_id: None,
};
let adapter = factory_for("openai_compatible").unwrap()(&config).unwrap();
let adapter = factory_for("openai_compatible").unwrap()(&config);
assert_eq!(adapter.name(), "kimi");
}
}

View file

@ -11,4 +11,4 @@ pub mod tools;
pub mod types;
pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result};
pub use fabro_model::{ModelHandle, Provider};
pub use fabro_model::{ModelHandle, Provider};

View file

@ -8,8 +8,8 @@
//! (in `fabro-model`) and the LLM factory registry (in `fabro-llm`) must agree
//! on the same set of adapter keys; the parity is enforced by tests.
use crate::reasoning::ReasoningEffort;
use crate::Speed;
use crate::reasoning::ReasoningEffort;
/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile.
///
@ -100,7 +100,9 @@ pub const OPENAI: AdapterMetadata = AdapterMetadata {
pub const GEMINI: AdapterMetadata = AdapterMetadata {
key: "gemini",
default_profile: AgentProfileKind::Gemini,
api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-goog-api-key" },
api_key_header: ApiKeyHeaderPolicy::Custom {
name: "x-goog-api-key",
},
controls: AdapterControlCapabilities {
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: &[],
@ -184,10 +186,7 @@ mod tests {
#[test]
fn openai_compatible_uses_openai_profile() {
assert_eq!(
OPENAI_COMPATIBLE.default_profile,
AgentProfileKind::OpenAi
);
assert_eq!(OPENAI_COMPATIBLE.default_profile, AgentProfileKind::OpenAi);
}
#[test]

View file

@ -107,6 +107,7 @@ impl PricePerMTok {
Display,
EnumString,
IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]

View file

@ -133,12 +133,15 @@ mod tests {
#[test]
fn display_writes_inner_string() {
assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic");
assert_eq!(ModelId::new("claude-opus-4-7").to_string(), "claude-opus-4-7");
assert_eq!(
ModelId::new("claude-opus-4-7").to_string(),
"claude-opus-4-7"
);
}
#[test]
fn ord_is_lexicographic() {
let mut v = vec![ProviderId::new("zai"), ProviderId::new("anthropic")];
let mut v = [ProviderId::new("zai"), ProviderId::new("anthropic")];
v.sort();
assert_eq!(v[0].as_str(), "anthropic");
assert_eq!(v[1].as_str(), "zai");

View file

@ -42,17 +42,26 @@ mod tests {
#[test]
fn parses_canonical_lowercase_strings() {
assert_eq!(ReasoningEffort::from_str("low").unwrap(), ReasoningEffort::Low);
assert_eq!(
ReasoningEffort::from_str("low").unwrap(),
ReasoningEffort::Low
);
assert_eq!(
ReasoningEffort::from_str("medium").unwrap(),
ReasoningEffort::Medium
);
assert_eq!(ReasoningEffort::from_str("high").unwrap(), ReasoningEffort::High);
assert_eq!(
ReasoningEffort::from_str("high").unwrap(),
ReasoningEffort::High
);
assert_eq!(
ReasoningEffort::from_str("xhigh").unwrap(),
ReasoningEffort::XHigh
);
assert_eq!(ReasoningEffort::from_str("max").unwrap(), ReasoningEffort::Max);
assert_eq!(
ReasoningEffort::from_str("max").unwrap(),
ReasoningEffort::Max
);
}
#[test]

View file

@ -1981,4 +1981,4 @@ digraph Demo {
);
assert!(response_mock.calls_async().await >= 1);
}
}
}

View file

@ -52,4 +52,4 @@ pub use server::{
ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
};
pub use size::{ParseSizeError, Size};
pub use workflow::WorkflowNamespace;
pub use workflow::WorkflowNamespace;

View file

@ -562,4 +562,4 @@ pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
}