Files
vibe-kanban/crates/server/src/routes/config.rs
Alex Netsch c100b12ee9 Perfect! The fix is now implemented. Here's what the change does: (#618)
## What the fix accomplishes:

1. **Captures the current state** before updating the config
2. **Detects when analytics is newly enabled** - when it changes from `None` (first-time) or `false` (previously disabled) to `true`
3. **Immediately sends `session_start`** for that current session when analytics is enabled

## Now the flow works correctly:

**First-time user:**
- App starts (no session_start - analytics not enabled yet)
- User sees privacy dialog, clicks "Yes"
- Config updated with `analytics_enabled: true`
- **session_start event fires immediately** ✓
- All subsequent events in that session have proper session context

**Returning user (already opted in):**
- App starts → session_start fires from main.rs ✓
- Normal session tracking continues

**User re-enabling analytics:**
- User toggles analytics back on in Settings
- **session_start event fires immediately** ✓
- Session tracking resumes

This ensures every analytics session has a `session_start` event without sending any events before user consent!
2025-09-03 18:22:42 +01:00

368 lines
12 KiB
Rust

use std::collections::HashMap;
use axum::{
Json, Router,
body::Body,
extract::{Path, Query, State},
http,
response::{Json as ResponseJson, Response},
routing::{get, put},
};
use deployment::{Deployment, DeploymentError};
use executors::{
executors::{BaseCodingAgent, StandardCodingAgentExecutor},
mcp_config::{McpConfig, read_agent_config, write_agent_config},
profile::{ExecutorConfigs, ExecutorProfileId},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use services::services::config::{Config, ConfigError, SoundFile, save_config_to_file};
use tokio::fs;
use ts_rs::TS;
use utils::{assets::config_path, response::ApiResponse};
use crate::{DeploymentImpl, error::ApiError};
pub fn router() -> Router<DeploymentImpl> {
Router::new()
.route("/info", get(get_user_system_info))
.route("/config", put(update_config))
.route("/sounds/{sound}", get(get_sound))
.route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
.route("/profiles", get(get_profiles).put(update_profiles))
}
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct Environment {
pub os_type: String,
pub os_version: String,
pub os_architecture: String,
pub bitness: String,
}
impl Default for Environment {
fn default() -> Self {
Self::new()
}
}
impl Environment {
pub fn new() -> Self {
let info = os_info::get();
Environment {
os_type: info.os_type().to_string(),
os_version: info.version().to_string(),
os_architecture: info.architecture().unwrap_or("unknown").to_string(),
bitness: info.bitness().to_string(),
}
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct UserSystemInfo {
pub config: Config,
#[serde(flatten)]
pub profiles: ExecutorConfigs,
pub environment: Environment,
}
// TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants
#[axum::debug_handler]
async fn get_user_system_info(
State(deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<UserSystemInfo>> {
let config = deployment.config().read().await;
let user_system_info = UserSystemInfo {
config: config.clone(),
profiles: ExecutorConfigs::get_cached(),
environment: Environment::new(),
};
ResponseJson(ApiResponse::success(user_system_info))
}
async fn update_config(
State(deployment): State<DeploymentImpl>,
Json(new_config): Json<Config>,
) -> ResponseJson<ApiResponse<Config>> {
let config_path = config_path();
// Get the current analytics_enabled state before updating
let old_analytics_enabled = {
let config = deployment.config().read().await;
config.analytics_enabled
};
match save_config_to_file(&new_config, &config_path).await {
Ok(_) => {
let mut config = deployment.config().write().await;
*config = new_config.clone();
drop(config);
// If analytics was just enabled (changed from None/false to true), track session_start
if new_config.analytics_enabled == Some(true) && old_analytics_enabled != Some(true) {
deployment
.track_if_analytics_allowed("session_start", serde_json::json!({}))
.await;
}
ResponseJson(ApiResponse::success(new_config))
}
Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))),
}
}
async fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {
let sound = sound.serve().await.map_err(DeploymentError::Other)?;
let response = Response::builder()
.status(http::StatusCode::OK)
.header(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("audio/wav"),
)
.body(Body::from(sound.data.into_owned()))
.unwrap();
Ok(response)
}
#[derive(TS, Debug, Deserialize)]
pub struct McpServerQuery {
executor: BaseCodingAgent,
}
#[derive(TS, Debug, Serialize, Deserialize)]
pub struct GetMcpServerResponse {
// servers: HashMap<String, Value>,
mcp_config: McpConfig,
config_path: String,
}
#[derive(TS, Debug, Serialize, Deserialize)]
pub struct UpdateMcpServersBody {
servers: HashMap<String, Value>,
}
async fn get_mcp_servers(
State(_deployment): State<DeploymentImpl>,
Query(query): Query<McpServerQuery>,
) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {
let coding_agent = ExecutorConfigs::get_cached()
.get_coding_agent(&ExecutorProfileId::new(query.executor))
.ok_or(ConfigError::ValidationError(
"Executor not found".to_string(),
))?;
if !coding_agent.supports_mcp() {
return Ok(ResponseJson(ApiResponse::error(
"MCP not supported by this executor",
)));
}
// Resolve supplied config path or agent default
let config_path = match coding_agent.default_mcp_config_path() {
Some(path) => path,
None => {
return Ok(ResponseJson(ApiResponse::error(
"Could not determine config file path",
)));
}
};
let mut mcpc = coding_agent.get_mcp_config();
let raw_config = read_agent_config(&config_path, &mcpc).await?;
let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);
mcpc.set_servers(servers);
Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {
mcp_config: mcpc,
config_path: config_path.to_string_lossy().to_string(),
})))
}
async fn update_mcp_servers(
State(_deployment): State<DeploymentImpl>,
Query(query): Query<McpServerQuery>,
Json(payload): Json<UpdateMcpServersBody>,
) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {
let profiles = ExecutorConfigs::get_cached();
let agent = profiles
.get_coding_agent(&ExecutorProfileId::new(query.executor))
.ok_or(ConfigError::ValidationError(
"Executor not found".to_string(),
))?;
if !agent.supports_mcp() {
return Ok(ResponseJson(ApiResponse::error(
"This executor does not support MCP servers",
)));
}
// Resolve supplied config path or agent default
let config_path = match agent.default_mcp_config_path() {
Some(path) => path.to_path_buf(),
None => {
return Ok(ResponseJson(ApiResponse::error(
"Could not determine config file path",
)));
}
};
let mcpc = agent.get_mcp_config();
match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {
Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),
Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to update MCP servers: {}",
e
)))),
}
}
async fn update_mcp_servers_in_config(
config_path: &std::path::Path,
mcpc: &McpConfig,
new_servers: HashMap<String, Value>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).await?;
}
// Read existing config (JSON or TOML depending on agent)
let mut config = read_agent_config(config_path, mcpc).await?;
// Get the current server count for comparison
let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();
// Set the MCP servers using the correct attribute path
set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;
// Write the updated config back to file (JSON or TOML depending on agent)
write_agent_config(config_path, mcpc, &config).await?;
let new_count = new_servers.len();
let message = match (old_servers, new_count) {
(0, 0) => "No MCP servers configured".to_string(),
(0, n) => format!("Added {} MCP server(s)", n),
(old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new),
(old, new) => format!(
"Updated MCP server configuration (was {}, now {})",
old, new
),
};
Ok(message)
}
/// Helper function to get MCP servers from config using a path
fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {
let mut current = raw_config;
for part in path {
current = match current.get(part) {
Some(val) => val,
None => return HashMap::new(),
};
}
// Extract the servers object
match current.as_object() {
Some(servers) => servers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
None => HashMap::new(),
}
}
/// Helper function to set MCP servers in config using a path
fn set_mcp_servers_in_config_path(
raw_config: &mut Value,
path: &[String],
servers: &HashMap<String, Value>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Ensure config is an object
if !raw_config.is_object() {
*raw_config = serde_json::json!({});
}
let mut current = raw_config;
// Navigate/create the nested structure (all parts except the last)
for part in &path[..path.len() - 1] {
if current.get(part).is_none() {
current
.as_object_mut()
.unwrap()
.insert(part.to_string(), serde_json::json!({}));
}
current = current.get_mut(part).unwrap();
if !current.is_object() {
*current = serde_json::json!({});
}
}
// Set the final attribute
let final_attr = path.last().unwrap();
current
.as_object_mut()
.unwrap()
.insert(final_attr.to_string(), serde_json::to_value(servers)?);
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProfilesContent {
pub content: String,
pub path: String,
}
async fn get_profiles(
State(_deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<ProfilesContent>> {
let profiles_path = utils::assets::profiles_path();
// Use cached data to ensure consistency with runtime and PUT updates
let profiles = ExecutorConfigs::get_cached();
let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {
tracing::error!("Failed to serialize profiles to JSON: {}", e);
serde_json::to_string_pretty(&ExecutorConfigs::from_defaults())
.unwrap_or_else(|_| "{}".to_string())
});
ResponseJson(ApiResponse::success(ProfilesContent {
content,
path: profiles_path.display().to_string(),
}))
}
async fn update_profiles(
State(_deployment): State<DeploymentImpl>,
body: String,
) -> ResponseJson<ApiResponse<String>> {
// Try to parse as ExecutorProfileConfigs format
match serde_json::from_str::<ExecutorConfigs>(&body) {
Ok(executor_profiles) => {
// Save the profiles to file
match executor_profiles.save_overrides() {
Ok(_) => {
tracing::info!("Executor profiles saved successfully");
// Reload the cached profiles
ExecutorConfigs::reload();
ResponseJson(ApiResponse::success(
"Executor profiles updated successfully".to_string(),
))
}
Err(e) => {
tracing::error!("Failed to save executor profiles: {}", e);
ResponseJson(ApiResponse::error(&format!(
"Failed to save executor profiles: {}",
e
)))
}
}
}
Err(e) => ResponseJson(ApiResponse::error(&format!(
"Invalid executor profiles format: {}",
e
))),
}
}