refactor: centralize runtime feature flag bindings (#2142)
This commit is contained in:
+4
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user