## 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!
368 lines
12 KiB
Rust
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
|
|
))),
|
|
}
|
|
}
|