1use serde::{Deserialize, Serialize};
12
13use crate::deployment::DeploymentGrade;
14
15#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21pub struct FeatureFlags {
22 pub global_chat: bool,
24 pub notifications: bool,
26 pub mcp_endpoint: bool,
28 pub evals: bool,
30 pub app_budgets: bool,
33 pub agent_versions: bool,
36 pub voice: bool,
38 #[serde(rename = "apps.detailV2")]
40 pub apps_detail_v2: bool,
41 pub agent_delegation: bool,
45}
46
47#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct FeatureFlagDefinition {
50 pub name: &'static str,
52 pub label: &'static str,
54 pub description: &'static str,
56 pub experimental: bool,
58}
59
60pub const API_FEATURE_FLAG_DEFINITIONS: &[FeatureFlagDefinition] = &[
62 FeatureFlagDefinition {
63 name: "global_chat",
64 label: "Global chat",
65 description: "Per-user singleton chat session in the sidebar.",
66 experimental: true,
67 },
68 FeatureFlagDefinition {
69 name: "notifications",
70 label: "Notifications",
71 description: "In-app notification bell, toasts, and notification SSE.",
72 experimental: true,
73 },
74 FeatureFlagDefinition {
75 name: "mcp_endpoint",
76 label: "MCP server endpoint",
77 description: "Expose Everruns as an MCP server (POST /mcp).",
78 experimental: true,
79 },
80 FeatureFlagDefinition {
81 name: "evals",
82 label: "Evals",
83 description: "Behavioral evals for agents.",
84 experimental: true,
85 },
86 FeatureFlagDefinition {
87 name: "app_budgets",
88 label: "App budgets",
89 description: "App and channel scoped budgets with periodic resets.",
90 experimental: true,
91 },
92 FeatureFlagDefinition {
93 name: "agent_versions",
94 label: "Agent versions",
95 description: "Immutable agent snapshots, forks, rollback, and app version binding.",
96 experimental: true,
97 },
98 FeatureFlagDefinition {
99 name: "voice",
100 label: "Voice",
101 description: "Realtime voice endpoints and microphone controls in chat.",
102 experimental: true,
103 },
104 FeatureFlagDefinition {
105 name: "apps.detailV2",
106 label: "Apps detail v2",
107 description: "Channels-first app detail page and full-page channel forms.",
108 experimental: true,
109 },
110];
111
112impl FeatureFlags {
113 pub fn for_org(system: &Self, org_enabled: &std::collections::HashMap<String, bool>) -> Self {
118 let opt_in = |name: &str, system_on: bool| -> bool {
119 system_on && org_enabled.get(name).copied().unwrap_or(false)
120 };
121 Self {
122 global_chat: opt_in("global_chat", system.global_chat),
123 notifications: opt_in("notifications", system.notifications),
124 mcp_endpoint: opt_in("mcp_endpoint", system.mcp_endpoint),
125 evals: opt_in("evals", system.evals),
126 app_budgets: opt_in("app_budgets", system.app_budgets),
127 agent_versions: opt_in("agent_versions", system.agent_versions),
128 voice: opt_in("voice", system.voice),
129 apps_detail_v2: opt_in("apps.detailV2", system.apps_detail_v2),
130 agent_delegation: opt_in("agent_delegation", system.agent_delegation),
131 }
132 }
133
134 pub fn from_env(grade: &DeploymentGrade) -> Self {
136 Self {
137 global_chat: experimental_flag("FEATURE_GLOBAL_CHAT", grade),
138 notifications: experimental_flag("FEATURE_NOTIFICATIONS", grade),
139 mcp_endpoint: experimental_flag("FEATURE_MCP_ENDPOINT", grade),
140 evals: experimental_flag("FEATURE_EVALS", grade),
141 app_budgets: experimental_flag("FEATURE_APP_BUDGETS", grade),
142 agent_versions: experimental_flag("FEATURE_AGENT_VERSIONS", grade),
143 voice: experimental_flag("FEATURE_VOICE", grade),
144 apps_detail_v2: experimental_flag("FEATURE_APPS_DETAIL_V2", grade),
145 agent_delegation: experimental_flag("FEATURE_AGENT_DELEGATION", grade),
146 }
147 }
148
149 pub fn current() -> Self {
152 Self::from_env(&DeploymentGrade::from_env())
153 }
154
155 pub fn is_enabled(&self, flag: &str) -> bool {
157 match flag {
158 "global_chat" => self.global_chat,
159 "notifications" => self.notifications,
160 "mcp_endpoint" => self.mcp_endpoint,
161 "evals" => self.evals,
162 "app_budgets" => self.app_budgets,
163 "agent_versions" => self.agent_versions,
164 "voice" => self.voice,
165 "apps.detailV2" => self.apps_detail_v2,
166 "agent_delegation" => self.agent_delegation,
167 _ => false,
168 }
169 }
170
171 #[cfg(test)]
173 pub fn all_enabled() -> Self {
174 Self {
175 global_chat: true,
176 notifications: true,
177 mcp_endpoint: true,
178 evals: true,
179 app_budgets: true,
180 agent_versions: true,
181 voice: true,
182 apps_detail_v2: true,
183 agent_delegation: true,
184 }
185 }
186}
187
188#[derive(Debug, Default, Clone, PartialEq, Eq)]
192pub struct InternalFeatureFlags {
193 pub docker_capability: bool,
196 pub container_sandbox: bool,
202 pub session_sandbox: bool,
205 pub machine_payments: bool,
210 pub lua: bool,
214}
215
216impl InternalFeatureFlags {
217 pub fn from_env() -> Self {
219 let docker_capability = standard_flag("FEATURE_DOCKER_CAPABILITY", false);
220
221 Self {
222 docker_capability,
223 container_sandbox: standard_flag("FEATURE_CONTAINER_SANDBOX", docker_capability),
224 session_sandbox: standard_flag("FEATURE_SESSION_SANDBOX", false),
225 machine_payments: standard_flag("FEATURE_MACHINE_PAYMENTS", false),
226 lua: standard_flag("FEATURE_LUA", false),
227 }
228 }
229
230 pub fn is_enabled(&self, flag: &str) -> bool {
232 match flag {
233 "docker_capability" => self.docker_capability,
234 "container_sandbox" => self.container_sandbox,
235 "session_sandbox" => self.session_sandbox,
236 "machine_payments" => self.machine_payments,
237 "lua" => self.lua,
238 _ => false,
239 }
240 }
241}
242
243fn experimental_flag(env_var: &str, grade: &DeploymentGrade) -> bool {
247 if let Ok(val) = std::env::var(env_var) {
248 return val == "true" || val == "1";
249 }
250 grade.experimental_features_enabled()
251}
252
253fn standard_flag(env_var: &str, default: bool) -> bool {
257 std::env::var(env_var)
258 .map(|v| v == "true" || v == "1")
259 .unwrap_or(default)
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
268
269 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
270 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
271 }
272
273 fn restore_env(key: &str, prev: Option<String>) {
275 match prev {
276 Some(value) => unsafe { std::env::set_var(key, value) },
277 None => unsafe { std::env::remove_var(key) },
278 }
279 }
280
281 #[test]
282 fn test_default_flags() {
283 let flags = FeatureFlags::default();
284 assert!(!flags.global_chat);
285 assert!(!flags.notifications);
286 }
287
288 #[test]
292 fn test_experimental_enabled_in_dev() {
293 let _lock = lock_env();
294 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
295 unsafe { std::env::remove_var("FEATURE_EVALS") };
296 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
297 assert!(flags.global_chat);
298 assert!(flags.evals);
299 }
300
301 #[test]
302 fn test_experimental_disabled_in_prod() {
303 let _lock = lock_env();
304 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
305 unsafe { std::env::remove_var("FEATURE_EVALS") };
306 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
307 assert!(!flags.global_chat);
308 assert!(!flags.evals);
309 }
310
311 #[test]
312 fn test_env_override_enables_in_prod() {
313 let _lock = lock_env();
314 unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "true") };
315 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
316 assert!(flags.global_chat);
317 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
318 }
319
320 #[test]
321 fn test_env_override_disables_in_dev() {
322 let _lock = lock_env();
323 unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "false") };
324 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
325 assert!(!flags.global_chat);
326 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
327 }
328
329 #[test]
330 fn test_is_enabled_dynamic() {
331 let flags = FeatureFlags {
332 global_chat: true,
333 notifications: true,
334 mcp_endpoint: true,
335 evals: true,
336 app_budgets: true,
337 agent_versions: true,
338 voice: true,
339 apps_detail_v2: true,
340 agent_delegation: true,
341 };
342 assert!(flags.is_enabled("global_chat"));
343 assert!(flags.is_enabled("notifications"));
344 assert!(flags.is_enabled("mcp_endpoint"));
345 assert!(flags.is_enabled("evals"));
346 assert!(flags.is_enabled("app_budgets"));
347 assert!(flags.is_enabled("agent_versions"));
348 assert!(flags.is_enabled("voice"));
349 assert!(flags.is_enabled("apps.detailV2"));
350 assert!(flags.is_enabled("agent_delegation"));
351 assert!(!flags.is_enabled("nonexistent"));
352 }
353
354 #[test]
355 fn test_serialization() {
356 let flags = FeatureFlags {
357 global_chat: true,
358 notifications: true,
359 mcp_endpoint: true,
360 evals: true,
361 app_budgets: true,
362 agent_versions: true,
363 voice: true,
364 apps_detail_v2: true,
365 agent_delegation: true,
366 };
367 let json = serde_json::to_string(&flags).unwrap();
368 assert!(json.contains("\"global_chat\":true"));
369 assert!(json.contains("\"notifications\":true"));
370 assert!(json.contains("\"app_budgets\":true"));
371 assert!(json.contains("\"agent_versions\":true"));
372 assert!(json.contains("\"voice\":true"));
373 assert!(json.contains("\"apps.detailV2\":true"));
374 assert!(json.contains("\"agent_delegation\":true"));
375
376 let parsed: FeatureFlags = serde_json::from_str(&json).unwrap();
377 assert_eq!(flags, parsed);
378 }
379
380 #[test]
381 fn test_agent_delegation_enabled_in_dev() {
382 let _lock = lock_env();
383 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
384 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
385 assert!(flags.agent_delegation);
386 }
387
388 #[test]
389 fn test_agent_delegation_disabled_in_prod() {
390 let _lock = lock_env();
391 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
392 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
393 assert!(!flags.agent_delegation);
394 }
395
396 #[test]
397 fn test_agent_delegation_env_override_in_prod() {
398 let _lock = lock_env();
399 unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
400 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
401 assert!(flags.agent_delegation);
402 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
403 }
404
405 #[test]
406 fn test_standard_flag() {
407 let _lock = lock_env();
408 unsafe { std::env::remove_var("FEATURE_TEST_STD") };
409 assert!(!standard_flag("FEATURE_TEST_STD", false));
410 assert!(standard_flag("FEATURE_TEST_STD", true));
411
412 unsafe { std::env::set_var("FEATURE_TEST_STD", "1") };
413 assert!(standard_flag("FEATURE_TEST_STD", false));
414 unsafe { std::env::remove_var("FEATURE_TEST_STD") };
415 }
416
417 #[test]
418 fn test_notifications_enabled_in_dev() {
419 let _lock = lock_env();
420 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
421 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
422 assert!(flags.notifications);
423 }
424
425 #[test]
426 fn test_notifications_disabled_in_prod() {
427 let _lock = lock_env();
428 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
429 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
430 assert!(!flags.notifications);
431 }
432
433 #[test]
434 fn test_for_org_requires_system_and_opt_in() {
435 let system = FeatureFlags {
436 global_chat: true,
437 evals: true,
438 ..FeatureFlags::default()
439 };
440 let mut org = std::collections::HashMap::new();
441 org.insert("global_chat".to_string(), true);
442 let effective = FeatureFlags::for_org(&system, &org);
443 assert!(effective.global_chat);
444 assert!(!effective.evals);
445
446 let effective_none = FeatureFlags::for_org(&system, &std::collections::HashMap::new());
447 assert!(!effective_none.global_chat);
448 }
449
450 #[test]
451 fn test_for_org_cannot_enable_when_system_off() {
452 let system = FeatureFlags::default();
453 let mut org = std::collections::HashMap::new();
454 org.insert("global_chat".to_string(), true);
455 let effective = FeatureFlags::for_org(&system, &org);
456 assert!(!effective.global_chat);
457 }
458
459 #[test]
460 fn test_notifications_respects_env_override() {
461 let _lock = lock_env();
462 unsafe { std::env::set_var("FEATURE_NOTIFICATIONS", "true") };
463 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
464 assert!(flags.notifications);
465 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
466 }
467
468 #[test]
473 fn test_internal_default_flags() {
474 let flags = InternalFeatureFlags::default();
475 assert!(!flags.docker_capability);
476 assert!(!flags.container_sandbox);
477 assert!(!flags.session_sandbox);
478 assert!(!flags.machine_payments);
479 }
480
481 #[test]
482 fn test_docker_capability_flag_disabled_by_default_in_dev() {
483 let _lock = lock_env();
484 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
485 let flags = InternalFeatureFlags::from_env();
486 assert!(
487 !flags.docker_capability,
488 "docker_capability should be disabled by default even in dev"
489 );
490 }
491
492 #[test]
493 fn test_docker_capability_flag_enabled_by_env_override() {
494 let _lock = lock_env();
495 unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
496 let flags = InternalFeatureFlags::from_env();
497 assert!(flags.docker_capability);
498 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
499 }
500
501 #[test]
502 fn test_container_sandbox_flag_enabled_by_env_override() {
503 let _lock = lock_env();
504 unsafe { std::env::set_var("FEATURE_CONTAINER_SANDBOX", "true") };
505 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
506 let flags = InternalFeatureFlags::from_env();
507 assert!(flags.container_sandbox);
508 unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
509 }
510
511 #[test]
512 fn test_container_sandbox_flag_falls_back_to_legacy_docker_flag() {
513 let _lock = lock_env();
514 unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
515 unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
516 let flags = InternalFeatureFlags::from_env();
517 assert!(flags.container_sandbox);
518 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
519 }
520
521 #[test]
522 fn test_internal_is_enabled_dynamic() {
523 let flags = InternalFeatureFlags {
524 docker_capability: true,
525 container_sandbox: true,
526 session_sandbox: true,
527 machine_payments: true,
528 lua: true,
529 };
530 assert!(flags.is_enabled("docker_capability"));
531 assert!(flags.is_enabled("container_sandbox"));
532 assert!(flags.is_enabled("session_sandbox"));
533 assert!(flags.is_enabled("machine_payments"));
534 assert!(flags.is_enabled("lua"));
535 assert!(!flags.is_enabled("nonexistent"));
536 }
537
538 #[test]
539 fn test_machine_payments_disabled_by_default() {
540 let _lock = lock_env();
541 let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
542 unsafe { std::env::remove_var("FEATURE_MACHINE_PAYMENTS") };
543 let flags = InternalFeatureFlags::from_env();
544 assert!(
545 !flags.machine_payments,
546 "machine_payments should be disabled by default on all envs"
547 );
548 restore_env("FEATURE_MACHINE_PAYMENTS", prev);
549 }
550
551 #[test]
552 fn test_machine_payments_enabled_by_env_override() {
553 let _lock = lock_env();
554 let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
555 unsafe { std::env::set_var("FEATURE_MACHINE_PAYMENTS", "true") };
556 let flags = InternalFeatureFlags::from_env();
557 assert!(flags.machine_payments);
558 restore_env("FEATURE_MACHINE_PAYMENTS", prev);
559 }
560
561 #[test]
562 fn test_session_sandbox_flag_enabled_by_env_override() {
563 let _lock = lock_env();
564 unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
565 let flags = InternalFeatureFlags::from_env();
566 assert!(flags.session_sandbox);
567 unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
568 }
569}