refactor: centralize runtime feature flag bindings (#2142)

This commit is contained in:
Carlos Florêncio
2026-08-02 15:36:21 +01:00
committed by GitHub
parent 443e9c89cc
commit e077658a61
29 changed files with 1495 additions and 506 deletions
+4 -26
View File
@@ -28,11 +28,7 @@ apps/backend/
│ │ ├── settings/ # Agent settings
│ │ ├── mcpconfig/ # MCP server configuration
│ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI
│ ├── auth/ # Opt-in user authentication + per-user scoping
│ │ ├── authn/ # Request-identity plumbing (context + gin helpers, RequireAdmin)
│ │ ├── httpmw/ # Global enforcement middleware (allowlist, synthetic identity)
│ │ ├── httpapi/ # /api/v1/auth/* + /api/v1/users endpoints
│ │ └── store/ # auth_identities, auth_sessions, auth_api_tokens, auth_invites
│ ├── auth/ # Opt-in auth, per-user scoping, middleware, API, store
│ ├── agentctl/
│ │ └── server/ # agentctl HTTP server
│ │ ├── acp/ # ACP protocol implementation
@@ -60,27 +56,9 @@ apps/backend/
│ │ ├── models/ # Task, Session, Executor, Message models
│ │ ├── repository/ # Database access (SQLite)
│ │ └── service/ # Task business logic
│ ├── office/ # Autonomous agent management (Office feature)
│ │ ├── agents/ # Agent instance CRUD + auth guards
│ │ ├── approvals/ # Approval requests and decisions
│ │ ├── channels/ # External integration channels (webhooks)
│ │ ├── config/ # Config sync (DB ↔ filesystem)
│ │ ├── configloader/ # Filesystem config reader/writer
│ │ ├── costs/ # Cost tracking and budget policies
│ │ ├── dashboard/ # Dashboard API, issues, activity, live runs
│ │ ├── infra/ # GC, reconciliation
│ │ ├── labels/ # Task labels
│ │ ├── onboarding/ # Workspace onboarding wizard API
│ │ ├── projects/ # Project management
│ │ ├── repository/ # Office SQLite persistence
│ │ ├── runtime/ # Agent run context, capabilities, and runtime action surface
│ │ ├── routines/ # Scheduled recurring tasks
│ │ ├── routing/ # Provider routing: resolver, validators, catalogue, backoff, agent-overrides
│ │ ├── scheduler/ # Wakeup scheduler (duplicate of service scheduler features)
│ │ ├── service/ # Core office service (wakeups, event subscribers, execution policy)
│ │ ├── shared/ # Shared interfaces and activity logging
│ │ ├── skills/ # Skill injection and materialization
│ │ └── workspaces/ # Workspace deletion handler
│ ├── office/ # Autonomous agent management (agents, approvals, channels, config, costs,
│ │ # dashboard, infra, labels, onboarding, projects, repository, runtime,
│ │ # routines, routing, scheduler, service, shared, skills, workspaces)
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
@@ -365,9 +365,11 @@ type VoiceConfig struct {
OpenAIAPIKey string `mapstructure:"openAIApiKey"`
}
// FeaturesConfig is the central registry of runtime feature flags. Every flag
// defaults to false so production binaries ship with new work hidden until a
// deployment explicitly opts in (env var, e.g. KANDEV_FEATURES_OFFICE=true).
// FeaturesConfig is the typed wire/config shape for runtime feature flags.
// Every new release toggle defaults to false so production binaries ship with
// new work hidden until a deployment explicitly opts in (env var, e.g.
// KANDEV_FEATURES_OFFICE=true). The runtimeflags package owns metadata and
// typed config bindings.
//
// The struct doubles as the wire shape for GET /api/v1/features — `json` tags
// keep the field names lowercase and the handler in helpers.go just calls
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
@@ -167,14 +168,15 @@ func TestValidate_PostgresSSLMode(t *testing.T) {
})
}
// TestFeatures_ProductionDefaults pins the production policy: in-progress
// features remain off, while the shipped app status bar remains on unless a
// deployment or saved runtime override explicitly disables it.
// TestFeatures_ProductionDefaults pins the production policy: new and
// in-progress features remain off until a deployment explicitly opts in.
func TestFeatures_ProductionDefaults(t *testing.T) {
// Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the
// host shell cannot change the production-profile defaults under test.
t.Setenv("KANDEV_FEATURES_OFFICE", "")
unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR")
unsetEnv(t, "KANDEV_FEATURES_AUTH")
unsetEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF")
t.Setenv("KANDEV_DEBUG_DEV_MODE", "")
t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "")
t.Setenv("KANDEV_E2E_MOCK", "")
@@ -190,6 +192,12 @@ func TestFeatures_ProductionDefaults(t *testing.T) {
if cfg.Features.AppStatusBar {
t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)")
}
if cfg.Features.Auth {
t.Error("Features.Auth = true, want false (authentication must remain opt-in by default)")
}
if cfg.Features.ClaudeBackgroundPromptHandoff {
t.Error("Features.ClaudeBackgroundPromptHandoff = true, want false (experiment must remain opt-in by default)")
}
}
// TestFeatures_OfficeEnabledByEnv proves the documented opt-in path:
@@ -409,24 +417,54 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) {
}
// TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features.
// The handler in helpers.go serializes FeaturesConfig directly so new
// fields flow through without an extra edit; this test guarantees the
// `json` tag is present on every field. A regression (struct field added
// without a tag) would surface as a capitalized JSON key and break the
// frontend's case-sensitive read in apps/web/app/actions/features.ts.
// The handler in helpers.go serializes FeaturesConfig directly so new fields
// flow through without an extra edit. Every feature field must remain a bool
// with explicit JSON and profile/mapstructure names so the runtime flag
// registry and frontend contract can derive from the same declaration.
func TestFeaturesConfig_JSONShape(t *testing.T) {
cfg := FeaturesConfig{
Office: true,
AppStatusBar: true,
Auth: true,
typeOfFeatures := reflect.TypeOf(FeaturesConfig{})
cfgValue := reflect.New(typeOfFeatures).Elem()
seenJSONNames := make(map[string]struct{}, typeOfFeatures.NumField())
seenMapstructureNames := make(map[string]struct{}, typeOfFeatures.NumField())
for i := 0; i < typeOfFeatures.NumField(); i++ {
field := typeOfFeatures.Field(i)
if field.Type.Kind() != reflect.Bool {
t.Fatalf("FeaturesConfig.%s has type %s; want bool", field.Name, field.Type)
}
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
if jsonName == "" || jsonName == "-" || jsonName == field.Name {
t.Fatalf("FeaturesConfig.%s has invalid json tag %q", field.Name, field.Tag.Get("json"))
}
if _, exists := seenJSONNames[jsonName]; exists {
t.Fatalf("FeaturesConfig has duplicate json tag %q", jsonName)
}
seenJSONNames[jsonName] = struct{}{}
mapstructureName := strings.Split(field.Tag.Get("mapstructure"), ",")[0]
if mapstructureName == "" || mapstructureName == "-" || mapstructureName == field.Name {
t.Fatalf("FeaturesConfig.%s has invalid mapstructure tag %q", field.Name, field.Tag.Get("mapstructure"))
}
if _, exists := seenMapstructureNames[mapstructureName]; exists {
t.Fatalf("FeaturesConfig has duplicate mapstructure tag %q", mapstructureName)
}
seenMapstructureNames[mapstructureName] = struct{}{}
cfgValue.Field(i).SetBool(true)
}
cfg := cfgValue.Interface().(FeaturesConfig)
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got := string(raw)
want := `{"office":true,"appStatusBar":true,"auth":true,"claudeBackgroundPromptHandoff":false}`
if got != want {
t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want)
var decoded map[string]bool
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if len(decoded) != typeOfFeatures.NumField() {
t.Fatalf("FeaturesConfig JSON has %d fields; want %d", len(decoded), typeOfFeatures.NumField())
}
for i := 0; i < typeOfFeatures.NumField(); i++ {
jsonName := strings.Split(typeOfFeatures.Field(i).Tag.Get("json"), ",")[0]
if got, ok := decoded[jsonName]; !ok || !got {
t.Errorf("FeaturesConfig JSON missing true field %q: %#v", jsonName, decoded)
}
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
#
# Set its `prod` value to `true`. Ship the next release. Once the
# feature has been on by default for a release or two and is
# permanent, rip the flag out of the codebase entirely.
# permanent, remove its live wiring and move its key/environment pair
# to runtimeflags' append-only retired-identity set.
#
# ── See also ───────────────────────────────────────────────────────
#
@@ -13,6 +13,10 @@ import (
func TestApplyProfile_DefaultsToProd(t *testing.T) {
clearProfileSelectors(t)
clearProfilesYAMLVars(t)
defaults, err := FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
count, env, err := ApplyProfile()
if err != nil {
@@ -21,9 +25,16 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
if env != EnvProd {
t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd)
}
// Prod writes each registered feature flag with its safe shipped default.
if count != 4 {
t.Errorf("ApplyProfile wrote %d vars in prod; want 4", count)
// Prod writes every declared feature flag with its shipped default. Derive
// the count so adding a flag cannot require another hard-coded update here.
if count != len(defaults) {
t.Errorf("ApplyProfile wrote %d vars in prod; want %d profile feature defaults", count, len(defaults))
}
for shortName, want := range defaults {
envVar := featurePrefix + strings.ToUpper(shortName)
if got := os.Getenv(envVar); got != want {
t.Errorf("%s = %q after prod ApplyProfile; want %q", envVar, got, want)
}
}
if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" {
t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false")
@@ -175,6 +186,18 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) {
}
}
func TestProfilesYAMLFeatureKeysUseRequiredPrefix(t *testing.T) {
file, err := parse(profilesYAML)
if err != nil {
t.Fatalf("parse profiles YAML: %v", err)
}
for envVar := range file["features"] {
if _, ok := stripFeaturePrefix(envVar); !ok {
t.Errorf("profiles.yaml feature key %q must start with %q and include a name", envVar, featurePrefix)
}
}
}
func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
clearAppliedEnvVars(t)
+31 -49
View File
@@ -9,35 +9,23 @@ import (
)
const (
featureOfficeKey = "features.office"
featureAppStatusBarKey = "features.appStatusBar"
featureAuthKey = "features.auth"
featureClaudeBackgroundPromptHandoffKey = "features.claudeBackgroundPromptHandoff"
debugDevModeKey = "debug.devMode"
envFeaturesOffice = "KANDEV_FEATURES_OFFICE"
envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR"
envFeaturesAuth = "KANDEV_FEATURES_AUTH"
envFeaturesClaudeBackgroundPromptHandoff = "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"
envDebugDevMode = "KANDEV_DEBUG_DEV_MODE"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED"
envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES"
)
func OptionsFromConfig(cfg *config.Config) Options {
envValues := make(map[string]bool, len(registrations))
for _, registration := range registrations {
envValues[registration.definition.EnvVar] = isTruthy(os.Getenv(registration.definition.EnvVar))
for _, impliedEnvVar := range registration.definition.ImpliedEnvVars {
envValues[impliedEnvVar] = isTruthy(os.Getenv(impliedEnvVar))
}
}
return Options{
DefaultValues: ValuesFromConfig(cfg),
RuntimeValues: ValuesFromConfig(cfg),
EnvValues: map[string]bool{
envFeaturesOffice: isTruthy(os.Getenv(envFeaturesOffice)),
envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)),
envFeaturesAuth: isTruthy(os.Getenv(envFeaturesAuth)),
envFeaturesClaudeBackgroundPromptHandoff: isTruthy(
os.Getenv(envFeaturesClaudeBackgroundPromptHandoff),
),
envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)),
envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)),
envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)),
},
EnvValues: envValues,
IsExplicitEnv: func(name string) bool {
_, ok := os.LookupEnv(name)
return ok && !profiles.WasApplied(name)
@@ -46,41 +34,35 @@ func OptionsFromConfig(cfg *config.Config) Options {
}
func ValuesFromConfig(cfg *config.Config) map[string]bool {
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled
return map[string]bool{
featureOfficeKey: cfg.Features.Office,
featureAppStatusBarKey: cfg.Features.AppStatusBar,
featureAuthKey: cfg.Features.Auth,
featureClaudeBackgroundPromptHandoffKey: cfg.Features.ClaudeBackgroundPromptHandoff,
debugDevModeKey: debugEnabled,
values := make(map[string]bool, len(registrations))
for _, registration := range registrations {
values[registration.definition.Key] = registration.read(cfg)
}
return values
}
func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
for _, state := range states {
switch state.Key {
case featureOfficeKey:
cfg.Features.Office = state.EffectiveValue
case featureAppStatusBarKey:
cfg.Features.AppStatusBar = state.EffectiveValue
case featureAuthKey:
cfg.Features.Auth = state.EffectiveValue
case featureClaudeBackgroundPromptHandoffKey:
cfg.Features.ClaudeBackgroundPromptHandoff = state.EffectiveValue
case debugDevModeKey:
cfg.Debug.DevMode = state.EffectiveValue
cfg.Debug.PprofEnabled = state.EffectiveValue
if state.EffectiveValue {
setIfNotExplicit(envDebugAgentMessages, "true")
setIfNotExplicit(envDebugPprofEnabled, "true")
} else {
unsetIfNotExplicit(envDebugAgentMessages)
unsetIfNotExplicit(envDebugPprofEnabled)
}
registration, ok := registrationByKey(state.Key)
if !ok {
continue
}
registration.apply(cfg, state.EffectiveValue)
}
}
func applyDebugMode(cfg *config.Config, enabled bool) {
cfg.Debug.DevMode = enabled
cfg.Debug.PprofEnabled = enabled
if enabled {
setIfNotExplicit(envDebugAgentMessages, "true")
setIfNotExplicit(envDebugPprofEnabled, "true")
return
}
unsetIfNotExplicit(envDebugAgentMessages)
unsetIfNotExplicit(envDebugPprofEnabled)
}
func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options {
opts := OptionsFromConfig(cfg)
opts.DefaultValues = defaults
+135 -76
View File
@@ -1,91 +1,150 @@
package runtimeflags
var definitions = []RuntimeFlagDefinition{
import "github.com/kandev/kandev/internal/common/config"
// runtimeFlagRegistration keeps the public metadata and the typed config
// binding for a flag together. The function fields stay internal so the HTTP
// registry response remains metadata-only.
type runtimeFlagRegistration struct {
definition RuntimeFlagDefinition
read func(*config.Config) bool
apply func(*config.Config, bool)
}
type runtimeFlagIdentity struct {
key string
envVar string
}
// retiredRuntimeFlagIdentities is append-only. When a flag graduates, remove
// its active registration and move its identity here. Persisted overrides for
// unknown keys are intentionally retained, so neither the key nor environment
// variable may ever be reused for a different flag.
var retiredRuntimeFlagIdentities = []runtimeFlagIdentity{
{key: "features.plugins", envVar: "KANDEV_FEATURES_PLUGINS"},
}
var registrations = []runtimeFlagRegistration{
{
Key: featureOfficeKey,
EnvVar: envFeaturesOffice,
Kind: KindFeature,
Label: "Office mode",
Description: "Enables autonomous agent office workflows and related settings.",
Stability: StabilityExperimental,
RiskLevel: RiskMedium,
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
"may change between releases and should be reviewed before relying on them.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureAppStatusBarKey,
EnvVar: envFeaturesAppStatusBar,
Kind: KindFeature,
Label: "App status bar",
Description: "Adds the global connection, optional host metrics, and plugin status surface.",
Stability: StabilityStable,
RiskLevel: RiskLow,
RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " +
"after restart. It does not stop connections, metrics collection requested by other clients, or plugins.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureAuthKey,
EnvVar: envFeaturesAuth,
Kind: KindFeature,
Label: "Authentication & users",
Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " +
"completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " +
"them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " +
"Enable it before exposing kandev on a shared or public network.",
RestartRequired: true,
Mutable: true,
},
{
Key: featureClaudeBackgroundPromptHandoffKey,
EnvVar: envFeaturesClaudeBackgroundPromptHandoff,
Kind: KindFeature,
Label: "Claude background prompt handoff",
Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " +
"Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " +
"Use it only for controlled testing and disable it if a session behaves unexpectedly.",
RestartRequired: true,
Mutable: true,
},
{
Key: debugDevModeKey,
EnvVar: envDebugDevMode,
Kind: KindDebug,
Label: "Debug mode",
Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.",
Stability: StabilityStable,
RiskLevel: RiskHigh,
RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " +
"and tool-call content to local debug logs. Enable it only on trusted machines.",
RestartRequired: true,
Mutable: true,
ImpliedEnvVars: []string{
envDebugPprofEnabled,
envDebugAgentMessages,
definition: RuntimeFlagDefinition{
Key: "features.office",
EnvVar: "KANDEV_FEATURES_OFFICE",
Kind: KindFeature,
Label: "Office mode",
Description: "Enables autonomous agent office workflows and related settings.",
Stability: StabilityExperimental,
RiskLevel: RiskMedium,
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
"may change between releases and should be reviewed before relying on them.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.Office },
apply: func(cfg *config.Config, value bool) { cfg.Features.Office = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.appStatusBar",
EnvVar: "KANDEV_FEATURES_APP_STATUS_BAR",
Kind: KindFeature,
Label: "App status bar",
Description: "Adds the global connection, optional host metrics, and plugin status surface.",
Stability: StabilityStable,
RiskLevel: RiskLow,
RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " +
"after restart. It does not stop connections, metrics collection requested by other clients, or plugins.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.AppStatusBar },
apply: func(cfg *config.Config, value bool) { cfg.Features.AppStatusBar = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.auth",
EnvVar: "KANDEV_FEATURES_AUTH",
Kind: KindFeature,
Label: "Authentication & users",
Description: "Requires every visitor to sign in and gives each user their own private workspaces. The first person to sign in after enabling becomes the admin.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Turning this ON locks the instance behind a login after restart — the first visitor " +
"completes a setup wizard and becomes the admin, and existing workspaces/secrets are assigned to " +
"them. Turning it OFF after restart makes the instance open to anyone who can reach it again. " +
"Enable it before exposing kandev on a shared or public network.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.Auth },
apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.claudeBackgroundPromptHandoff",
EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF",
Kind: KindFeature,
Label: "Claude background prompt handoff",
Description: "Allows Claude Code to accept a new prompt after its foreground yields while recognized background work remains active.",
Stability: StabilityExperimental,
RiskLevel: RiskHigh,
RiskDescription: "Claude ACP background lifecycle signals can be missing, delayed, duplicated, or ambiguous. " +
"Enabling this experiment can misclassify session activity or dispatch overlapping prompts. " +
"Use it only for controlled testing and disable it if a session behaves unexpectedly.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.ClaudeBackgroundPromptHandoff },
apply: func(cfg *config.Config, value bool) { cfg.Features.ClaudeBackgroundPromptHandoff = value },
},
{
definition: RuntimeFlagDefinition{
Key: "debug.devMode",
EnvVar: "KANDEV_DEBUG_DEV_MODE",
Kind: KindDebug,
Label: "Debug mode",
Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.",
Stability: StabilityStable,
RiskLevel: RiskHigh,
RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " +
"and tool-call content to local debug logs. Enable it only on trusted machines.",
RestartRequired: true,
Mutable: true,
ImpliedEnvVars: []string{
envDebugPprofEnabled,
envDebugAgentMessages,
},
},
read: func(cfg *config.Config) bool { return cfg.Debug.DevMode || cfg.Debug.PprofEnabled },
apply: applyDebugMode,
},
}
func Definitions() []RuntimeFlagDefinition {
out := make([]RuntimeFlagDefinition, len(definitions))
copy(out, definitions)
out := make([]RuntimeFlagDefinition, len(registrations))
for i, registration := range registrations {
out[i] = publicDefinition(registration.definition)
}
return out
}
func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) {
for _, def := range definitions {
if def.Key == key {
return def, true
registration, ok := registrationByKey(key)
if !ok {
return RuntimeFlagDefinition{}, false
}
return publicDefinition(registration.definition), true
}
func registrationByKey(key string) (runtimeFlagRegistration, bool) {
for _, registration := range registrations {
if registration.definition.Key == key {
return registration, true
}
}
return RuntimeFlagDefinition{}, false
return runtimeFlagRegistration{}, false
}
func publicDefinition(definition RuntimeFlagDefinition) RuntimeFlagDefinition {
definition.ImpliedEnvVars = append([]string(nil), definition.ImpliedEnvVars...)
return definition
}
@@ -1,6 +1,21 @@
package runtimeflags
import "testing"
import (
"reflect"
"strings"
"testing"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/profiles"
)
type featureFieldBinding struct {
fieldIndex int
fieldName string
key string
profileKey string
envVar string
}
func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.office")
@@ -35,6 +50,15 @@ func TestDefinitionsExcludePlugins(t *testing.T) {
}
}
func TestRetiredRuntimeFlagIdentitiesIncludePlugins(t *testing.T) {
for _, identity := range retiredRuntimeFlagIdentities {
if identity.key == "features.plugins" && identity.envVar == "KANDEV_FEATURES_PLUGINS" {
return
}
}
t.Fatal("graduated plugins flag identity is missing from the retired identity set")
}
func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.appStatusBar")
if !ok {
@@ -90,3 +114,289 @@ func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
t.Fatal("debug.agentMessages must not be a top-level user-facing toggle")
}
}
func TestRuntimeFlagRegistrationsHaveCompleteMetadata(t *testing.T) {
retiredKeys, retiredEnvVars := retiredIdentitySets(t)
activeKeys := make(map[string]struct{}, len(registrations))
activeEnvVars := make(map[string]string, len(registrations))
for _, registration := range registrations {
definition := registration.definition
validateRuntimeFlagDefinition(t, definition)
if registration.read == nil || registration.apply == nil {
t.Fatalf("runtime flag registration %q is missing a config binding", definition.Key)
}
if _, exists := activeKeys[definition.Key]; exists {
t.Fatalf("duplicate runtime flag registration for %q", definition.Key)
}
activeKeys[definition.Key] = struct{}{}
if _, retired := retiredKeys[definition.Key]; retired {
t.Fatalf("active runtime flag %q reuses a retired key", definition.Key)
}
registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, definition.EnvVar)
for _, envVar := range definition.ImpliedEnvVars {
registerActiveEnvVar(t, activeEnvVars, retiredEnvVars, definition.Key, envVar)
}
}
}
func TestRuntimeFlagConfigBindingsAreIsolated(t *testing.T) {
// Keep debug-mode's intentional environment side effects inert while this
// test exercises only the typed config bindings.
t.Setenv(envDebugAgentMessages, "explicit")
t.Setenv(envDebugPprofEnabled, "explicit")
for _, target := range registrations {
cfg := &config.Config{}
target.apply(cfg, true)
for _, registration := range registrations {
want := registration.definition.Key == target.definition.Key
if got := registration.read(cfg); got != want {
t.Fatalf(
"applying %q changed read value for %q to %t; want %t",
target.definition.Key,
registration.definition.Key,
got,
want,
)
}
}
target.apply(cfg, false)
for _, registration := range registrations {
if registration.read(cfg) {
t.Fatalf("disabling %q left %q enabled", target.definition.Key, registration.definition.Key)
}
}
}
}
// TestFeatureBindingsExactlyMatchConfigAndProfiles keeps the typed config,
// profile defaults, and feature registry in lockstep in both directions.
func TestFeatureBindingsExactlyMatchConfigAndProfiles(t *testing.T) {
defaults, err := profiles.FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
fields := featureFieldBindings(t)
registered := registeredFeatureBindings(t)
for key, field := range fields {
registration, ok := registered[key]
if !ok {
t.Fatalf("FeaturesConfig.%s has no runtime flag registration for %q", field.fieldName, key)
}
if registration.definition.EnvVar != field.envVar {
t.Fatalf("FeaturesConfig.%s EnvVar = %q, want %q", field.fieldName, registration.definition.EnvVar, field.envVar)
}
if _, ok := defaults[field.profileKey]; !ok {
t.Fatalf("FeaturesConfig.%s has no profile default for %q", field.fieldName, field.profileKey)
}
}
for key := range registered {
if _, ok := fields[key]; !ok {
t.Fatalf("feature registration %q has no FeaturesConfig field", key)
}
}
for profileKey := range defaults {
if !hasProfileKey(fields, profileKey) {
t.Fatalf("profile feature %q has no FeaturesConfig field", profileKey)
}
}
if len(fields) != len(registered) || len(fields) != len(defaults) {
t.Fatalf(
"feature contract size mismatch: config=%d registry=%d profiles=%d",
len(fields),
len(registered),
len(defaults),
)
}
assertFeatureConfigRoundTrips(t, fields)
}
func retiredIdentitySets(t *testing.T) (map[string]struct{}, map[string]struct{}) {
t.Helper()
keys := make(map[string]struct{}, len(retiredRuntimeFlagIdentities))
envVars := make(map[string]struct{}, len(retiredRuntimeFlagIdentities))
for _, identity := range retiredRuntimeFlagIdentities {
if strings.TrimSpace(identity.key) == "" || strings.TrimSpace(identity.envVar) == "" {
t.Fatal("retired runtime flag identities must include both key and environment variable")
}
if _, exists := keys[identity.key]; exists {
t.Fatalf("duplicate retired runtime flag key %q", identity.key)
}
keys[identity.key] = struct{}{}
if _, exists := envVars[identity.envVar]; exists {
t.Fatalf("duplicate retired runtime flag environment variable %q", identity.envVar)
}
envVars[identity.envVar] = struct{}{}
}
return keys, envVars
}
func registerActiveEnvVar(
t *testing.T,
active map[string]string,
retired map[string]struct{},
key string,
envVar string,
) {
t.Helper()
if strings.TrimSpace(envVar) == "" {
t.Fatalf("runtime flag %q has an empty environment variable", key)
}
if owner, exists := active[envVar]; exists {
t.Fatalf("runtime flags %q and %q both use environment variable %q", owner, key, envVar)
}
if _, exists := retired[envVar]; exists {
t.Fatalf("active runtime flag %q reuses retired environment variable %q", key, envVar)
}
active[envVar] = key
}
func validateRuntimeFlagDefinition(t *testing.T, definition RuntimeFlagDefinition) {
t.Helper()
wantKeyPrefix, wantEnvPrefix := definitionPrefixes(t, definition.Kind)
if !strings.HasPrefix(definition.Key, wantKeyPrefix) || len(definition.Key) == len(wantKeyPrefix) {
t.Fatalf("runtime flag key %q must start with %q and include a name", definition.Key, wantKeyPrefix)
}
if !strings.HasPrefix(definition.EnvVar, wantEnvPrefix) || len(definition.EnvVar) == len(wantEnvPrefix) {
t.Fatalf("runtime flag %q environment variable %q must start with %q", definition.Key, definition.EnvVar, wantEnvPrefix)
}
for name, value := range map[string]string{
"label": definition.Label, "description": definition.Description, "risk description": definition.RiskDescription,
} {
if strings.TrimSpace(value) == "" {
t.Fatalf("runtime flag %q has an empty %s", definition.Key, name)
}
}
if !validStability(definition.Stability) {
t.Fatalf("runtime flag %q has invalid stability %q", definition.Key, definition.Stability)
}
if !validRiskLevel(definition.RiskLevel) {
t.Fatalf("runtime flag %q has invalid risk level %q", definition.Key, definition.RiskLevel)
}
if !definition.RestartRequired {
t.Fatalf("runtime flag %q must declare restart_required", definition.Key)
}
if !definition.Mutable {
t.Fatalf("runtime flag %q must declare mutable", definition.Key)
}
}
func definitionPrefixes(t *testing.T, kind RuntimeFlagKind) (string, string) {
t.Helper()
switch kind {
case KindFeature:
return "features.", "KANDEV_FEATURES_"
case KindDebug:
return "debug.", "KANDEV_DEBUG_"
default:
t.Fatalf("invalid runtime flag kind %q", kind)
return "", ""
}
}
func validStability(stability RuntimeFlagStability) bool {
switch stability {
case StabilityStable, StabilityBeta, StabilityExperimental:
return true
default:
return false
}
}
func validRiskLevel(risk RuntimeFlagRiskLevel) bool {
switch risk {
case RiskLow, RiskMedium, RiskHigh:
return true
default:
return false
}
}
func featureFieldBindings(t *testing.T) map[string]featureFieldBinding {
t.Helper()
typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{})
fields := make(map[string]featureFieldBinding, typeOfFeatures.NumField())
for i := 0; i < typeOfFeatures.NumField(); i++ {
field := typeOfFeatures.Field(i)
if field.Type.Kind() != reflect.Bool {
t.Fatalf("FeaturesConfig.%s has type %s; feature flags must be bool", field.Name, field.Type)
}
jsonName := requiredTagName(t, field, "json")
profileKey := requiredTagName(t, field, "mapstructure")
key := "features." + jsonName
if _, exists := fields[key]; exists {
t.Fatalf("duplicate FeaturesConfig JSON key %q", jsonName)
}
fields[key] = featureFieldBinding{
fieldIndex: i,
fieldName: field.Name,
key: key,
profileKey: profileKey,
envVar: "KANDEV_FEATURES_" + strings.ToUpper(profileKey),
}
}
return fields
}
func requiredTagName(t *testing.T, field reflect.StructField, tag string) string {
t.Helper()
name := strings.Split(field.Tag.Get(tag), ",")[0]
if name == "" || name == "-" {
t.Fatalf("FeaturesConfig.%s is missing a %s name", field.Name, tag)
}
return name
}
func registeredFeatureBindings(t *testing.T) map[string]runtimeFlagRegistration {
t.Helper()
registered := make(map[string]runtimeFlagRegistration)
for _, registration := range registrations {
definition := registration.definition
if definition.Kind != KindFeature && !strings.HasPrefix(definition.Key, "features.") {
continue
}
if _, exists := registered[definition.Key]; exists {
t.Fatalf("duplicate feature registration %q", definition.Key)
}
registered[definition.Key] = registration
}
return registered
}
func hasProfileKey(fields map[string]featureFieldBinding, profileKey string) bool {
for _, field := range fields {
if field.profileKey == profileKey {
return true
}
}
return false
}
func assertFeatureConfigRoundTrips(t *testing.T, fields map[string]featureFieldBinding) {
t.Helper()
typeOfFeatures := reflect.TypeOf(config.FeaturesConfig{})
for key, field := range fields {
cfg := &config.Config{}
features := reflect.ValueOf(&cfg.Features).Elem()
ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: true}})
if !features.Field(field.fieldIndex).Bool() {
t.Fatalf("ApplyStatesToConfig(%q) did not enable target field %s", key, field.fieldName)
}
for i := 0; i < features.NumField(); i++ {
if i != field.fieldIndex && features.Field(i).Bool() {
t.Fatalf("ApplyStatesToConfig(%q) changed unrelated field %s", key, typeOfFeatures.Field(i).Name)
}
}
if got := ValuesFromConfig(cfg)[key]; !got {
t.Fatalf("ValuesFromConfig(%q) = false after enabling %s", key, field.fieldName)
}
ApplyStatesToConfig(cfg, []RuntimeFlagState{{Key: key, EffectiveValue: false}})
if features.Field(field.fieldIndex).Bool() || ValuesFromConfig(cfg)[key] {
t.Fatalf("ApplyStatesToConfig(%q) did not disable target field %s", key, field.fieldName)
}
}
}
+32
View File
@@ -5,6 +5,9 @@ vi.mock("@/lib/config", () => ({
}));
import { getRuntimeDebugModeAction } from "./features";
import { getFeatureFlagsAction } from "./features";
import { defaultFeatureFlags } from "@/lib/state/slices/features/types";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
@@ -27,6 +30,35 @@ function jsonResponse(body: unknown): Response {
});
}
function flagsWith(value: boolean): FeatureFlags {
return Object.fromEntries(
Object.keys(defaultFeatureFlags).map((name) => [name, value]),
) as FeatureFlags;
}
describe("getFeatureFlagsAction", () => {
it("normalizes declared backend flags and ignores unknown keys", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ ...flagsWith(true), unknown: true }));
await expect(getFeatureFlagsAction()).resolves.toEqual(flagsWith(true));
});
it("fails closed for missing and non-boolean backend values", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ office: "true", appStatusBar: true }));
await expect(getFeatureFlagsAction()).resolves.toEqual({
...flagsWith(false),
appStatusBar: true,
});
});
it("falls back to all-off defaults when the backend is unavailable", async () => {
fetchSpy.mockResolvedValueOnce(new Response("nope", { status: 503 }));
await expect(getFeatureFlagsAction()).resolves.toEqual(flagsWith(false));
});
});
describe("getRuntimeDebugModeAction", () => {
it("returns true when the effective debug runtime flag is enabled", async () => {
fetchSpy.mockResolvedValueOnce(
+6 -7
View File
@@ -1,8 +1,7 @@
"use server";
import { getBackendConfig } from "@/lib/config";
import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types";
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
const { apiBaseUrl } = getBackendConfig();
@@ -12,15 +11,15 @@ const { apiBaseUrl } = getBackendConfig();
// store. Falls back to defaults (all-off) when the backend is unreachable,
// so a dev-server restart doesn't crash page rendering.
//
// Parsing is type-driven via `defaultFeaturesState.features`: every key
// declared on FeatureFlags is read from the response and Boolean-coerced;
// missing or non-bool values fall through to the default. Adding a flag
// is therefore one entry in FeatureFlags + the default — no edit here.
// Parsing is type-driven via `defaultFeatureFlags`: every declared key is read
// from the response and only actual booleans are accepted; missing or
// non-boolean values remain all-off. Adding a flag is therefore one entry in
// the declaration — no edit here.
//
// See docs/decisions/0007-runtime-feature-flags.md.
export async function getFeatureFlagsAction(): Promise<FeatureFlags> {
const url = `${apiBaseUrl}/api/v1/features`;
const defaults = defaultFeaturesState.features;
const defaults: FeatureFlags = { ...defaultFeatureFlags };
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
@@ -1,23 +0,0 @@
import { expect, type Page } from "@playwright/test";
import type { PrAssetCapture } from "../../helpers/pr-asset-capture";
export async function assertUnreadDividerSetting(
page: Page,
prCapture: PrAssetCapture,
projectName: string,
): Promise<void> {
await page.goto("/settings/system/feature-toggles");
const unreadDivider = page.getByTestId("feature-toggle-features.unreadDivider");
await expect(unreadDivider).toBeVisible();
await expect(unreadDivider.getByText("Unread divider", { exact: true })).toBeVisible();
await expect(unreadDivider.getByRole("switch", { name: "Toggle Unread divider" })).toBeChecked();
await expect(unreadDivider).toContainText("Source: Default");
await expect(unreadDivider).toContainText("Requires restart");
await unreadDivider.scrollIntoViewIfNeeded();
await prCapture.screenshot(`unread-divider-setting-${projectName}`, {
caption: `Unread divider feature toggle (${projectName})`,
fullPage: true,
});
}
@@ -0,0 +1,24 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { defaultFeatureFlags } from "./types";
const backendConfigPath = resolve(process.cwd(), "../backend/internal/common/config/config.go");
function backendFeatureKeys(source: string): string[] {
const structBody = source.match(/type FeaturesConfig struct \{([\s\S]*?)\n\}/)?.[1];
if (!structBody) {
throw new Error("FeaturesConfig declaration not found in backend config");
}
return Array.from(structBody.matchAll(/json:"([^",]+)(?:,[^"]*)?"/g), (match) => match[1]).sort();
}
describe("feature flag repository contract", () => {
it("keeps frontend defaults equal to backend FeaturesConfig JSON keys", async () => {
const backendConfig = await readFile(backendConfigPath, "utf8");
expect(Object.keys(defaultFeatureFlags).sort()).toEqual(backendFeatureKeys(backendConfig));
});
});
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createFeaturesSlice, defaultFeaturesState } from "./features-slice";
import type { FeaturesSlice } from "./types";
import { defaultFeatureFlags } from "./types";
import type { FeatureFlags, FeaturesSlice } from "./types";
function makeStore() {
return create<FeaturesSlice>()(
@@ -11,6 +12,12 @@ function makeStore() {
);
}
function flagsWith(value: boolean): FeatureFlags {
return Object.fromEntries(
Object.keys(defaultFeatureFlags).map((name) => [name, value]),
) as FeatureFlags;
}
describe("features slice", () => {
// Production-safety invariant: every flag must be false out of the box.
// If this test starts failing because a new flag was added defaulting to
@@ -20,33 +27,14 @@ describe("features slice", () => {
for (const [name, value] of Object.entries(defaultFeaturesState.features)) {
expect(value, `default of features.${name}`).toBe(false);
}
expect(store.getState().features.office).toBe(false);
expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false);
expect(defaultFeaturesState.features).toHaveProperty("auth", false);
expect(defaultFeaturesState.features).toHaveProperty("claudeBackgroundPromptHandoff", false);
expect(store.getState().features).toEqual(flagsWith(false));
});
it("setFeatures replaces the whole flag map", () => {
const store = makeStore();
store.getState().setFeatures({
office: true,
appStatusBar: true,
auth: true,
claudeBackgroundPromptHandoff: true,
});
expect(store.getState().features.office).toBe(true);
expect(store.getState().features.appStatusBar).toBe(true);
expect(store.getState().features.auth).toBe(true);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(true);
store.getState().setFeatures({
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
});
expect(store.getState().features.office).toBe(false);
expect(store.getState().features.appStatusBar).toBe(false);
expect(store.getState().features.auth).toBe(false);
expect(store.getState().features.claudeBackgroundPromptHandoff).toBe(false);
store.getState().setFeatures(flagsWith(true));
expect(store.getState().features).toEqual(flagsWith(true));
store.getState().setFeatures(flagsWith(false));
expect(store.getState().features).toEqual(flagsWith(false));
});
});
@@ -1,16 +1,8 @@
import type { StateCreator } from "zustand";
import type { FeaturesSlice, FeaturesSliceState } from "./types";
import { defaultFeatureFlags, type FeaturesSlice, type FeaturesSliceState } from "./types";
export const defaultFeaturesState: FeaturesSliceState = {
// All flags default to false. Production releases ship every flag off
// until the deployment opts in via env var (e.g. KANDEV_FEATURES_OFFICE).
// The SSR layer overwrites this with whatever the backend reports.
features: {
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
},
features: { ...defaultFeatureFlags },
};
type ImmerSet = Parameters<
+15 -9
View File
@@ -1,17 +1,23 @@
// Runtime feature-flag state. The shape mirrors the backend's
// /api/v1/features response (FeaturesConfig in
// apps/backend/internal/common/config/config.go). Every flag is a boolean,
// keyed by feature name. New flags are additive — keep this shape stable.
// apps/backend/internal/common/config/config.go). New flags are additive:
// declare the all-off default once and derive the names and state from it.
// See docs/decisions/0007-runtime-feature-flags.md.
export type FeatureFlags = {
office: boolean;
appStatusBar: boolean;
auth: boolean;
claudeBackgroundPromptHandoff: boolean;
};
export const defaultFeatureFlags = {
// New release toggles start disabled in every frontend state. The SSR layer
// overwrites this with the backend's effective values after startup.
office: false,
appStatusBar: false,
auth: false,
claudeBackgroundPromptHandoff: false,
} as const;
export type FeatureName = keyof FeatureFlags;
export type FeatureName = keyof typeof defaultFeatureFlags;
export type FeatureFlags = {
[K in FeatureName]: boolean;
};
export type FeaturesSliceState = {
features: FeatureFlags;