mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
feat(runtime-api): serve the declared settings schema over HTTP
/v1/settings/schema projects SETTINGS_SCHEMA for GUI clients: every declared row with resolved labels, current value from the owning store, persisted state, row kind (setting/action/diagnostic/session), and the TUI's conditional-visibility and editability rules. POST /v1/config's unknown-key arm now delegates to Settings::set, so every declared settings.toml key persists through the shared validator instead of a curated allowlist. Adds SettingRowKind to the schema, Settings::value/is_set accessors, and live-server tests covering all 82 rows plus the write fallthrough. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2037c2880c
commit
6e58fd1ab7
4 files changed
+720
-27
No files matched your search
@@ -69,6 +69,19 @@ pub enum SettingKind {
|
||||
String,
|
||||
}
|
||||
|
||||
/// What a declared settings row is. `Setting` is a writable preference;
|
||||
/// `Action` opens another surface (the provider/model pickers, module
|
||||
/// links); `Diagnostic` is a read-only receipt or managed-policy fact;
|
||||
/// `Session` is editable for the running session but does not persist
|
||||
/// through the config store.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingRowKind {
|
||||
Setting,
|
||||
Action,
|
||||
Diagnostic,
|
||||
Session,
|
||||
}
|
||||
|
||||
/// Where a setting appears, and what it says. Absent ⇒ no row.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SettingUi {
|
||||
@@ -78,6 +91,11 @@ pub struct SettingUi {
|
||||
pub label: &'static str,
|
||||
/// Message key for the row's description sentence.
|
||||
pub description: &'static str,
|
||||
/// Whether the row is a writable preference, a link to another surface,
|
||||
/// a read-only receipt, or a session-scoped override. Surfaces that
|
||||
/// cannot open the target or scope the write render non-`Setting` rows
|
||||
/// read-only instead of guessing.
|
||||
pub row: SettingRowKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -381,6 +399,57 @@ const fn ui(
|
||||
group,
|
||||
label,
|
||||
description,
|
||||
row: SettingRowKind::Setting,
|
||||
})
|
||||
}
|
||||
|
||||
/// A row that opens another surface rather than editing a value in place.
|
||||
const fn ui_action(
|
||||
tab: &'static str,
|
||||
group: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
) -> Option<SettingUi> {
|
||||
Some(SettingUi {
|
||||
tab,
|
||||
group,
|
||||
label,
|
||||
description,
|
||||
row: SettingRowKind::Action,
|
||||
})
|
||||
}
|
||||
|
||||
/// A read-only receipt row — a managed-policy fact, live route value, or
|
||||
/// descriptive pointer, never a writable control.
|
||||
const fn ui_diagnostic(
|
||||
tab: &'static str,
|
||||
group: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
) -> Option<SettingUi> {
|
||||
Some(SettingUi {
|
||||
tab,
|
||||
group,
|
||||
label,
|
||||
description,
|
||||
row: SettingRowKind::Diagnostic,
|
||||
})
|
||||
}
|
||||
|
||||
/// A row editable for the running session only; it does not persist through
|
||||
/// the config store.
|
||||
const fn ui_session(
|
||||
tab: &'static str,
|
||||
group: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
) -> Option<SettingUi> {
|
||||
Some(SettingUi {
|
||||
tab,
|
||||
group,
|
||||
label,
|
||||
description,
|
||||
row: SettingRowKind::Session,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -600,7 +669,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"provider",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_action(
|
||||
TAB_MODELS,
|
||||
"provider",
|
||||
"ConfigLabelProvider",
|
||||
@@ -611,7 +680,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"provider_templates",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_action(
|
||||
TAB_MODELS,
|
||||
"provider",
|
||||
"ConfigLabelProviderTemplates",
|
||||
@@ -622,7 +691,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"model",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"),
|
||||
ui_action(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"),
|
||||
),
|
||||
def(
|
||||
"reasoning_effort",
|
||||
@@ -642,7 +711,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"fleet.exec.max_spawn_depth",
|
||||
SettingKind::Int,
|
||||
"3",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_MODELS,
|
||||
"model",
|
||||
"ConfigLabelFleetSpawnDepth",
|
||||
@@ -807,7 +876,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"goal_command",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_WORK,
|
||||
"session",
|
||||
"ConfigLabelGoalCommand",
|
||||
@@ -818,7 +887,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"workflow",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_WORK,
|
||||
"workflow",
|
||||
"ConfigLabelWorkflow",
|
||||
@@ -1039,13 +1108,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"mcp_open",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"),
|
||||
ui_action(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"),
|
||||
),
|
||||
def(
|
||||
"mcp_reconnect",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_action(
|
||||
TAB_TOOLS,
|
||||
"mcp",
|
||||
"ConfigLabelMcpReconnect",
|
||||
@@ -1056,7 +1125,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"mcp_diagnose",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_action(
|
||||
TAB_TOOLS,
|
||||
"mcp",
|
||||
"ConfigLabelMcpDiagnose",
|
||||
@@ -1067,7 +1136,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"plugins_open",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_action(
|
||||
TAB_TOOLS,
|
||||
"mcp",
|
||||
"ConfigLabelPluginsOpen",
|
||||
@@ -1090,7 +1159,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"approval_mode",
|
||||
SettingKind::Enum(APPROVAL_MODE),
|
||||
"",
|
||||
ui(
|
||||
ui_session(
|
||||
TAB_TRUST,
|
||||
"permissions",
|
||||
"ConfigLabelApprovalMode",
|
||||
@@ -1123,7 +1192,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"managed_approval_policy",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_TRUST,
|
||||
"permissions",
|
||||
"ConfigLabelManagedApprovalPolicy",
|
||||
@@ -1156,7 +1225,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"managed_allow_shell",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_TRUST,
|
||||
"permissions",
|
||||
"ConfigLabelManagedAllowShell",
|
||||
@@ -1202,7 +1271,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"base_url",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"provider",
|
||||
"ConfigLabelBaseUrlDeepseek",
|
||||
@@ -1213,7 +1282,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"provider_url",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"provider",
|
||||
"ConfigLabelProviderUrl",
|
||||
@@ -1224,13 +1293,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"context_window",
|
||||
SettingKind::Int,
|
||||
"",
|
||||
ui(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"),
|
||||
ui_diagnostic(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"),
|
||||
),
|
||||
def(
|
||||
"effective_context_window",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"provider",
|
||||
"",
|
||||
@@ -1243,7 +1312,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"external_credentials.openai-codex",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"provider",
|
||||
"",
|
||||
@@ -1254,7 +1323,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"external_credentials.xai",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"provider",
|
||||
"",
|
||||
@@ -1277,7 +1346,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"features.subagents",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"experimental",
|
||||
"",
|
||||
@@ -1288,7 +1357,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"features.web_search",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"experimental",
|
||||
"",
|
||||
@@ -1299,7 +1368,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"features.apply_patch",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"experimental",
|
||||
"",
|
||||
@@ -1310,13 +1379,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"features.mcp",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"),
|
||||
ui_diagnostic(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"),
|
||||
),
|
||||
def(
|
||||
"features.exec_policy",
|
||||
SettingKind::String,
|
||||
"",
|
||||
ui(
|
||||
ui_diagnostic(
|
||||
TAB_ADVANCED,
|
||||
"experimental",
|
||||
"",
|
||||
|
||||
@@ -1420,6 +1420,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
)
|
||||
.route("/v1/config", get(get_config).post(set_config))
|
||||
.route("/v1/config/reload", post(reload_config))
|
||||
.route("/v1/settings/schema", get(get_settings_schema))
|
||||
.route(
|
||||
"/v1/threads/{id}/notifications/prepare",
|
||||
post(notification_delivery::prepare),
|
||||
@@ -7877,9 +7878,18 @@ async fn set_config(
|
||||
config_persistence::persist_root_bool_key(config_path, "prompt_suggestion", enabled)
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, thinking_default_expanded, thinking_highlight, show_tool_details, inline_diffs, locale, max_history, calm_mode, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion"
|
||||
)));
|
||||
// Every other declared settings.toml key persists through the
|
||||
// shared validator rather than a curated list — the schema
|
||||
// route advertises them, so a known setting must not die
|
||||
// here. Unknown keys still 400 through `Settings::set`.
|
||||
persist_runtime_tui_setting(&key, &value)?;
|
||||
return Ok(Json(SetConfigResponse {
|
||||
key,
|
||||
value,
|
||||
message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
|
||||
persisted: true,
|
||||
requires_reload,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7903,6 +7913,410 @@ async fn set_config(
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /v1/settings/schema` — the Engine-declared settings surface.
|
||||
///
|
||||
/// `codewhale_config::SETTINGS_SCHEMA` is the single declaration table: one
|
||||
/// entry per setting with kind, closed value set, default, and placement.
|
||||
/// This route projects it for HTTP clients — current values resolved from
|
||||
/// the owning store (settings.toml via [`crate::settings::Settings`],
|
||||
/// config.toml, or the notifications table), labels and descriptions
|
||||
/// resolved through the locale pack. Writes stay on `POST /v1/config`;
|
||||
/// this route never invents a value, an option, or a validator.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SettingsSchemaResponse {
|
||||
/// Payload version. Additive fields may appear without a bump; clients
|
||||
/// must ignore fields and `kind`/`row` values they do not know.
|
||||
version: u32,
|
||||
tabs: Vec<SettingsSchemaTab>,
|
||||
settings: Vec<SettingsSchemaRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SettingsSchemaTab {
|
||||
id: String,
|
||||
/// Humanized tab id — tab labels have no message keys in the schema.
|
||||
label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SettingsSchemaRow {
|
||||
key: &'static str,
|
||||
/// `bool` | `int` | `enum` | `string`. Unknown kinds degrade to a text
|
||||
/// field on the client; writes still validate server-side.
|
||||
kind: &'static str,
|
||||
tab: &'static str,
|
||||
group: &'static str,
|
||||
label: String,
|
||||
description: String,
|
||||
default: &'static str,
|
||||
/// `setting` | `action` | `diagnostic` | `session` — from
|
||||
/// [`codewhale_config::SettingRowKind`].
|
||||
row: &'static str,
|
||||
/// Current value in written-to-disk string form, when a store resolves
|
||||
/// it. Absent for actions, unresolvable diagnostics, and session rows
|
||||
/// the headless runtime cannot read.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<String>,
|
||||
/// Whether the value is a persisted user choice rather than an
|
||||
/// inherited default. Absent where no store can prove either way.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
persisted: Option<bool>,
|
||||
/// Whether a generic client may offer a write control. Action,
|
||||
/// diagnostic and session rows are never editable through this surface;
|
||||
/// conditional rows (managed policy wins) report false.
|
||||
editable: bool,
|
||||
/// False for the hidden member of a conditional pair — e.g. a
|
||||
/// `managed_*` row when no managed policy applies, or `base_url` when
|
||||
/// the active route reads `provider_url`. Clients should not render
|
||||
/// invisible rows.
|
||||
visible: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
options: Vec<SettingsSchemaOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SettingsSchemaOption {
|
||||
value: &'static str,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
label: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// "Turn a schema key or tab id into a title-case label" — the same
|
||||
/// humanization the TUI applies to rows declared without a label message.
|
||||
fn humanize_schema_key(key: &str) -> String {
|
||||
key.split(['.', '_', '-'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let mut word = first.to_uppercase().collect::<String>();
|
||||
word.push_str(chars.as_str());
|
||||
word
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// config.toml-owned keys `POST /v1/config` persists through curated arms.
|
||||
/// Kept beside `set_config`'s match: a schema Setting row outside this list
|
||||
/// and outside `Settings` has no write path and reports `editable: false`.
|
||||
const RUNTIME_CONFIG_KEYS: &[&str] = &[
|
||||
"model",
|
||||
"default_model",
|
||||
"reasoning_effort",
|
||||
"approval_mode",
|
||||
"approval_policy",
|
||||
"base_url",
|
||||
"provider",
|
||||
"provider_url",
|
||||
"provider_base_url",
|
||||
"cost_currency",
|
||||
"max_history",
|
||||
"allow_shell",
|
||||
"mcp_config_path",
|
||||
"subagents_enabled",
|
||||
"subagents_max_depth",
|
||||
"sandbox_mode",
|
||||
"strict_tool_mode",
|
||||
"memory_enabled",
|
||||
"search_provider",
|
||||
"prompt_suggestion",
|
||||
];
|
||||
|
||||
/// A dotted-path lookup over a TOML document — used to decide `persisted`
|
||||
/// for config.toml-owned rows without trusting a decorated display string.
|
||||
fn toml_value_at_path<'a>(document: &'a toml::Value, segments: &[&str]) -> Option<&'a toml::Value> {
|
||||
let mut current = document;
|
||||
for segment in segments {
|
||||
current = current.as_table()?.get(*segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
async fn get_settings_schema(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<SettingsSchemaResponse>, ApiError> {
|
||||
use codewhale_config::notifications::NotificationSetting;
|
||||
use codewhale_config::settings_schema::{
|
||||
SettingKind, SettingRowKind, schema_rows, schema_tabs,
|
||||
};
|
||||
use codewhale_localization::{MessageId, resolve_locale, tr, tr_key};
|
||||
|
||||
let config = state.config.read().clone();
|
||||
let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
|
||||
let locale = resolve_locale(&settings.locale);
|
||||
let notifications = config.notifications_config();
|
||||
|
||||
// Conditional pairs share the TUI's rule: exactly one member is shown,
|
||||
// chosen by which store or policy owns the fact right now.
|
||||
let permission_control = config.approval_policy_control(
|
||||
state.config_path.as_deref(),
|
||||
state.config_profile.as_deref(),
|
||||
&state.workspace,
|
||||
);
|
||||
let shell_control = config.allow_shell_control(
|
||||
state.config_path.as_deref(),
|
||||
state.config_profile.as_deref(),
|
||||
&state.workspace,
|
||||
);
|
||||
let base_url_row_key = match config.api_provider() {
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => "base_url",
|
||||
_ => "provider_url",
|
||||
};
|
||||
let visible = |key: &str| -> bool {
|
||||
match key {
|
||||
"permission_posture" => matches!(
|
||||
permission_control,
|
||||
crate::config::ApprovalPolicyControl::Unset
|
||||
),
|
||||
"approval_policy" => matches!(
|
||||
permission_control,
|
||||
crate::config::ApprovalPolicyControl::RootConfig
|
||||
),
|
||||
"managed_approval_policy" => !matches!(
|
||||
permission_control,
|
||||
crate::config::ApprovalPolicyControl::Unset
|
||||
| crate::config::ApprovalPolicyControl::RootConfig
|
||||
),
|
||||
"allow_shell" => shell_control.editable_root(),
|
||||
"managed_allow_shell" => !shell_control.editable_root(),
|
||||
"base_url" | "provider_url" => key == base_url_row_key,
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
|
||||
// Raw config.toml for `persisted` on config-owned rows. A missing or
|
||||
// unparsable file means nothing was persisted there — the live config
|
||||
// still serves defaults through `value`.
|
||||
let config_document = state
|
||||
.config_path
|
||||
.as_deref()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|body| toml::from_str::<toml::Value>(&body).ok());
|
||||
let notifications_persisted = |key: &str| -> Option<bool> {
|
||||
let setting = NotificationSetting::parse(key)?;
|
||||
let document = config_document.as_ref()?;
|
||||
Some(
|
||||
toml_value_at_path(document, &setting.segments()).is_some()
|
||||
// Legacy location the loader still honors.
|
||||
|| (matches!(setting, NotificationSetting::Condition)
|
||||
&& toml_value_at_path(document, &["tui", "notification_condition"]).is_some()),
|
||||
)
|
||||
};
|
||||
|
||||
let tabs = schema_tabs()
|
||||
.into_iter()
|
||||
.map(|id| SettingsSchemaTab {
|
||||
id: id.to_string(),
|
||||
label: humanize_schema_key(id),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let settings_rows = schema_rows()
|
||||
.map(|def| {
|
||||
let ui = def.ui.as_ref().expect("schema_rows filters on ui");
|
||||
let kind = match def.kind {
|
||||
SettingKind::Bool(_) => "bool",
|
||||
SettingKind::Int => "int",
|
||||
SettingKind::Enum(_) => "enum",
|
||||
SettingKind::String => "string",
|
||||
};
|
||||
let row = match ui.row {
|
||||
SettingRowKind::Setting => "setting",
|
||||
SettingRowKind::Action => "action",
|
||||
SettingRowKind::Diagnostic => "diagnostic",
|
||||
SettingRowKind::Session => "session",
|
||||
};
|
||||
let options = match def.kind {
|
||||
SettingKind::Bool(options) | SettingKind::Enum(options) => options
|
||||
.iter()
|
||||
.map(|option| SettingsSchemaOption {
|
||||
value: option.value,
|
||||
label: if option.label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
tr_key(locale, option.label).into_owned()
|
||||
},
|
||||
description: if option.description.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
tr_key(locale, option.description).into_owned()
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
SettingKind::Int | SettingKind::String => Vec::new(),
|
||||
};
|
||||
// Bool rows with an empty option slice carry the surface's
|
||||
// default on/off labels — emit the bare values so clients can
|
||||
// still build a labeled control.
|
||||
let options = if options.is_empty() && matches!(def.kind, SettingKind::Bool(_)) {
|
||||
vec![
|
||||
SettingsSchemaOption {
|
||||
value: "false",
|
||||
label: tr_key(locale, "ConfigValueOff").into_owned(),
|
||||
description: String::new(),
|
||||
},
|
||||
SettingsSchemaOption {
|
||||
value: "true",
|
||||
label: tr_key(locale, "ConfigValueOn").into_owned(),
|
||||
description: String::new(),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
options
|
||||
};
|
||||
|
||||
let notification_owned = NotificationSetting::parse(def.key).is_some();
|
||||
// `Settings::set` is the authority on which keys settings.toml
|
||||
// owns — including `Option` fields whose unset value serializes
|
||||
// to nothing (e.g. permission_posture). The probe reuses the
|
||||
// write validator on the declared default, so `editable` cannot
|
||||
// claim a key the real write path would reject.
|
||||
let settings_writable = crate::settings::Settings::default()
|
||||
.set(def.key, def.default)
|
||||
.is_ok();
|
||||
let (value, persisted) = if notification_owned {
|
||||
let setting = NotificationSetting::parse(def.key).expect("checked above");
|
||||
(
|
||||
Some(notifications.display(setting)),
|
||||
notifications_persisted(def.key),
|
||||
)
|
||||
} else if settings_writable {
|
||||
// Effective = the persisted value or the declared default;
|
||||
// `is_set` says which.
|
||||
(
|
||||
Some(
|
||||
settings
|
||||
.value(def.key)
|
||||
.unwrap_or_else(|| def.default.to_string()),
|
||||
),
|
||||
Some(settings.is_set(def.key)),
|
||||
)
|
||||
} else if let Some(feature_key) = def.key.strip_prefix("features.") {
|
||||
// Feature rows are diagnostics: the effective flag state plus
|
||||
// whether config.toml names the leaf — no decorated phrasing,
|
||||
// the client owns presentation of default-vs-configured.
|
||||
let value = crate::features::FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.key == feature_key)
|
||||
.map(|spec| config.features().enabled(spec.id).to_string());
|
||||
let persisted = config_document.as_ref().map(|document| {
|
||||
toml_value_at_path(document, &["features", feature_key]).is_some()
|
||||
});
|
||||
(value, persisted)
|
||||
} else {
|
||||
// Managed-policy receipts name the winning source rather than
|
||||
// a writable value; everything else resolves from config.toml
|
||||
// or stays absent for a diagnostic the runtime cannot read.
|
||||
let managed_value = match def.key {
|
||||
"managed_approval_policy" => match permission_control {
|
||||
crate::config::ApprovalPolicyControl::Unset
|
||||
| crate::config::ApprovalPolicyControl::RootConfig => None,
|
||||
source => Some(source.label().to_string()),
|
||||
},
|
||||
"managed_allow_shell" if !shell_control.editable_root() => Some(format!(
|
||||
"{} · {}",
|
||||
config.allow_shell(),
|
||||
shell_control.label()
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
managed_value.or_else(|| config_schema_value(def.key, &config)),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// `editable` means POST /v1/config accepts the key today:
|
||||
// notifications.* through the namespace branch, settings.toml
|
||||
// keys through the Settings::set fallthrough, and the curated
|
||||
// config.toml arm list. A Setting row without a write path
|
||||
// (e.g. telemetry, which persists through its own notice
|
||||
// module) renders read-only rather than promising a 400. The
|
||||
// endpoint rows stay receipts: writing a live route's base URL
|
||||
// cannot mutate an already-running client, so the TUI marks
|
||||
// them read-only and the schema agrees.
|
||||
let endpoint_receipt = matches!(def.key, "base_url" | "provider_url");
|
||||
// A managed or profile-owned approval policy freezes the
|
||||
// session-level mode switch too, not just the saved row.
|
||||
let session_locked = def.key == "approval_mode"
|
||||
&& !matches!(
|
||||
permission_control,
|
||||
crate::config::ApprovalPolicyControl::Unset
|
||||
);
|
||||
let editable = !endpoint_receipt
|
||||
&& !session_locked
|
||||
&& matches!(ui.row, SettingRowKind::Setting | SettingRowKind::Session)
|
||||
&& visible(def.key)
|
||||
&& (notification_owned
|
||||
|| settings_writable
|
||||
|| RUNTIME_CONFIG_KEYS.contains(&def.key));
|
||||
|
||||
SettingsSchemaRow {
|
||||
key: def.key,
|
||||
kind,
|
||||
tab: ui.tab,
|
||||
group: ui.group,
|
||||
label: if !ui.label.is_empty() {
|
||||
tr_key(locale, ui.label).into_owned()
|
||||
} else if def.key.starts_with("features.") {
|
||||
tr(locale, MessageId::ConfigLabelFeaturePrefix).replace(
|
||||
"{name}",
|
||||
&humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)),
|
||||
)
|
||||
} else {
|
||||
humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key))
|
||||
},
|
||||
description: if ui.description.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
tr_key(locale, ui.description).into_owned()
|
||||
},
|
||||
default: def.default,
|
||||
row,
|
||||
value,
|
||||
persisted,
|
||||
editable,
|
||||
visible: visible(def.key),
|
||||
options,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SettingsSchemaResponse {
|
||||
version: 1,
|
||||
tabs,
|
||||
settings: settings_rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Current value of a config.toml-owned schema row, when one resolves
|
||||
/// cheaply. Diagnostics that need per-route or credential computation are
|
||||
/// omitted rather than approximated.
|
||||
fn config_schema_value(key: &str, config: &Config) -> Option<String> {
|
||||
match key {
|
||||
"provider" => Some(config.provider_identity_for(config.api_provider())),
|
||||
"model" => runtime_request_model(config, None).ok(),
|
||||
"approval_policy" => config
|
||||
.approval_policy
|
||||
.clone()
|
||||
.or_else(|| Some("suggest".to_string())),
|
||||
"telemetry" => Some(crate::telemetry_notice::saved_preference_enabled(config).to_string()),
|
||||
"allow_shell" => Some(config.allow_shell().to_string()),
|
||||
"base_url" => Some(config.base_url_for_route(config.api_provider())),
|
||||
"provider_url" => Some(config.base_url_for_route(config.api_provider())),
|
||||
"mcp_config_path" => Some(config.mcp_config_path().display().to_string()),
|
||||
"sandbox_mode" => config.sandbox_mode.clone(),
|
||||
"fleet.exec.max_spawn_depth" => Some(config.subagent_max_spawn_depth().to_string()),
|
||||
"reasoning_effort" => Some(config.reasoning_effort().unwrap_or("auto").to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_runtime_config_model(
|
||||
config: &Config,
|
||||
provider: ApiProvider,
|
||||
|
||||
@@ -9129,6 +9129,179 @@ async fn get_provider_models(
|
||||
.expect("GET /v1/providers/{id}/models should return valid JSON")
|
||||
}
|
||||
|
||||
/// Helper: GET `/v1/settings/schema` and return the parsed response body.
|
||||
async fn get_settings_schema(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::Value {
|
||||
client
|
||||
.get(format!("http://{addr}/v1/settings/schema"))
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /v1/settings/schema should not fail at transport level")
|
||||
.error_for_status()
|
||||
.expect("GET /v1/settings/schema should return 200")
|
||||
.json()
|
||||
.await
|
||||
.expect("GET /v1/settings/schema should return valid JSON")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_schema_serves_every_declared_row_with_runtime_state() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let root = tempfile::tempdir()?;
|
||||
let config_file = root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_file,
|
||||
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\ntelemetry = false\n\n[notifications]\ncondition = \"always\"\n",
|
||||
)?;
|
||||
fs::write(root.path().join("settings.toml"), "calm_mode = true\n")?;
|
||||
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_config_path(config_file.clone()).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
let body = get_settings_schema(&client, &addr).await;
|
||||
handle.abort();
|
||||
|
||||
assert_eq!(body["version"], 1);
|
||||
let tabs = body["tabs"].as_array().expect("tabs array");
|
||||
let tab_ids: Vec<_> = tabs.iter().filter_map(|t| t["id"].as_str()).collect();
|
||||
assert_eq!(
|
||||
tab_ids,
|
||||
codewhale_config::settings_schema::schema_tabs(),
|
||||
"tabs project the schema declaration order"
|
||||
);
|
||||
|
||||
let rows = body["settings"].as_array().expect("settings array");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
codewhale_config::settings_schema::schema_rows().count(),
|
||||
"every declared UI row is served"
|
||||
);
|
||||
let row = |key: &str| {
|
||||
rows.iter()
|
||||
.find(|row| row["key"] == key)
|
||||
.unwrap_or_else(|| panic!("schema row {key} missing"))
|
||||
};
|
||||
|
||||
for entry in rows {
|
||||
let kind = entry["kind"].as_str().expect("kind");
|
||||
assert!(
|
||||
matches!(kind, "bool" | "int" | "enum" | "string"),
|
||||
"unknown kind {kind}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
entry["row"].as_str(),
|
||||
Some("setting" | "action" | "diagnostic" | "session")
|
||||
),
|
||||
"unknown row kind for {}",
|
||||
entry["key"]
|
||||
);
|
||||
let label = entry["label"].as_str().expect("label");
|
||||
assert!(
|
||||
!label.is_empty() && !label.starts_with("Config"),
|
||||
"label must be resolved prose, not a message key: {label}"
|
||||
);
|
||||
assert!(entry["visible"].is_boolean());
|
||||
assert!(entry["editable"].is_boolean());
|
||||
if kind == "enum" {
|
||||
assert!(
|
||||
!entry["options"]
|
||||
.as_array()
|
||||
.expect("enum options")
|
||||
.is_empty(),
|
||||
"enum row {} must serve its closed value set",
|
||||
entry["key"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Values resolve from the owning store, not a decorated guess.
|
||||
assert_eq!(row("calm_mode")["value"], "true");
|
||||
assert_eq!(row("calm_mode")["persisted"], true);
|
||||
assert_eq!(row("notifications.condition")["value"], "always");
|
||||
assert_eq!(row("notifications.condition")["persisted"], true);
|
||||
assert_eq!(row("telemetry")["value"], "false");
|
||||
|
||||
// Row kinds and editability agree with the TUI's own semantics:
|
||||
// model/provider open pickers (actions), approval_mode is the
|
||||
// session-scoped writable, endpoint rows are read-only receipts.
|
||||
assert_eq!(row("model")["row"], "action");
|
||||
assert_eq!(row("model")["editable"], false);
|
||||
assert_eq!(row("approval_mode")["row"], "session");
|
||||
assert_eq!(row("approval_mode")["editable"], true);
|
||||
assert_eq!(row("provider_url")["editable"], false);
|
||||
assert_eq!(row("telemetry")["editable"], false);
|
||||
for action in ["provider_templates", "mcp_open", "plugins_open"] {
|
||||
assert_eq!(row(action)["row"], "action");
|
||||
assert_eq!(row(action)["editable"], false);
|
||||
}
|
||||
|
||||
// The conditional triple resolves to exactly one visible member for a
|
||||
// config with no managed policy: the editable TUI posture row.
|
||||
assert_eq!(row("permission_posture")["visible"], true);
|
||||
assert_eq!(row("approval_policy")["visible"], false);
|
||||
assert_eq!(row("managed_approval_policy")["visible"], false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_schema_write_fallthrough_persists_declared_settings_keys() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let root = tempfile::tempdir()?;
|
||||
let config_file = root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_file,
|
||||
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n",
|
||||
)?;
|
||||
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_config_path(config_file.clone()).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// A schema-declared settings.toml key outside the curated arm list must
|
||||
// persist through the shared validator rather than 400.
|
||||
let response = client
|
||||
.post(format!("http://{addr}/v1/config"))
|
||||
.json(&json!({"key": "composer_density", "value": "compact", "persist": true}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.expect("composer_density is a declared settings key")
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
assert_eq!(response["persisted"], true);
|
||||
assert_eq!(
|
||||
crate::settings::Settings::load_persisted()?.composer_density,
|
||||
"compact"
|
||||
);
|
||||
let body = get_settings_schema(&client, &addr).await;
|
||||
let row = body["settings"]
|
||||
.as_array()
|
||||
.expect("settings array")
|
||||
.iter()
|
||||
.find(|row| row["key"] == "composer_density")
|
||||
.expect("composer_density row");
|
||||
assert_eq!(row["value"], "compact");
|
||||
assert_eq!(row["persisted"], true);
|
||||
|
||||
// A key the schema does not declare still fails closed.
|
||||
let status = client
|
||||
.post(format!("http://{addr}/v1/config"))
|
||||
.json(&json!({"key": "not_a_real_setting", "value": "x", "persist": true}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() {
|
||||
let models = (0..600)
|
||||
|
||||
@@ -1059,6 +1059,43 @@ impl Settings {
|
||||
.unwrap_or(Layer::Default)
|
||||
}
|
||||
|
||||
/// The persisted field name behind a canonical schema key. Two schema
|
||||
/// keys predate their persisted names and cannot be renamed without a
|
||||
/// settings.toml migration.
|
||||
fn persisted_field_name(canonical: &str) -> &str {
|
||||
match canonical {
|
||||
"tool_collapse" => "tool_collapse_mode",
|
||||
"max_history" => "max_input_history",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value `key` currently holds in this store, in its written-to-disk
|
||||
/// string form — `None` when the key is not a field of this store.
|
||||
///
|
||||
/// `Settings` serializes field-for-field to settings.toml, so a document
|
||||
/// lookup on the serialized form shares `set`'s key vocabulary instead
|
||||
/// of growing a second hand-keyed reader beside it.
|
||||
pub fn value(&self, key: &str) -> Option<String> {
|
||||
let canonical = Self::canonical_key(key).unwrap_or(key);
|
||||
let field = Self::persisted_field_name(canonical);
|
||||
let document = toml::Value::try_from(self).ok()?;
|
||||
let value = document.as_table()?.get(field)?;
|
||||
Some(match value {
|
||||
toml::Value::String(text) => text.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the loaded settings document explicitly named `key` — a
|
||||
/// persisted user choice rather than an inherited default.
|
||||
pub fn is_set(&self, key: &str) -> bool {
|
||||
let canonical = Self::canonical_key(key).unwrap_or(key);
|
||||
self.provenance
|
||||
.get(Self::persisted_field_name(canonical))
|
||||
.is_some_and(|layer| *layer == Layer::UserConfig)
|
||||
}
|
||||
|
||||
/// Whether the user explicitly persisted an auto-compaction preference.
|
||||
/// A threshold is intent to enable compaction unless an explicit boolean
|
||||
/// says otherwise. When all three keys are absent, callers may choose a
|
||||
|
||||
Reference in new issue
Block a user